/** * List indexes and the queries that use them, derived from a paged read's * `paged.over` declaration (#811, K-18). * * K-18 promised *"engine list APIs accept registry-declared filter/sort * predicates with correct pagination and counts, the kernel composing the join * inside the scope DB"* and nothing implemented it. This is that, and it takes * the same shape `searchables` took in #827 for the same reasons. * * ## Why the kernel owns this rather than a helper in contracts * * A fragment builder in `@substrat-run/contracts` could emit a correct * `WHERE status = ?` and a correct keyset comparison. It could not create the * INDEX behind either, because contracts sits below the migration machinery and * has no way to reach a scope's DDL. A declared filter with no index is a table * scan that passes every test, survives review, and degrades when one tenant's * table grows — the same delayed bug an unbounded list read is. * * That is what K-18 means by filter, sort key and index being *one declared * thing*: the third one is the reason it has to be here. * * ## What is derived, and what stays the handler's * * | Kernel | Handler | * |---|---| * | `WHERE` from declared filters, `ORDER BY` from the chosen sort, the keyset comparison, `LIMIT`, and the `COUNT` over the same `WHERE` | The projection, and any hydration — a `toWorkOrder`, a per-row aggregate, a second query for children | * | The indexes behind those, emitted as migrations and refused if unindexable | The permission check, which nothing on `ctx` ever does | * * The handler still writes its own `SELECT`, so this is not the generated-CRUD * layer `generated-verticals.md` §4 says does not exist: it invents no routes and * no handlers. It stops eleven call sites hand-writing the same cursor branch and * the same duplicated count `WHERE`. * * ## The tie-break, which is not optional * * A keyset walk over a NON-UNIQUE column drops and duplicates rows: order * `status ASC` with a cursor of `'open'` and every remaining `open` row is * skipped, because `status > 'open'` excludes its own ties. So every walk here * is over `(sortColumn, idColumn)` and the cursor is composite — which is the * `|`-joined form `pagination.ts` already pins ("first part always `|`-free"). * Where the sort column IS the id, the pair collapses and the cursor is the bare * value, unchanged from what shipped. */ import type { SqlMigration } from './scope-host.js'; /** * One paged read's kernel-composed half, as the kernel needs it. * * `table` and `idColumn` are not authored — the same `manifestEntities()`-shaped * enrichment `searchables` gets fills them in from the entity registry, so there * is no second statement of where a work order lives to drift from the first. */ export interface ListDeclaration { /** The entity whose table the walk runs over. */ readonly entityType: string; /** Columns a caller may sort by. The first is the default. */ readonly sortable: readonly string[]; /** Columns a caller may filter by equality on. */ readonly filterable?: readonly string[]; /** Filled in from the registry. */ readonly table?: string; readonly idColumn?: string; } /** A resolved list declaration: everything the DDL and the query need. */ export interface ListIndexPlan { readonly moduleId: string; readonly entityType: string; readonly table: string; readonly idColumn: string; readonly sortable: readonly string[]; readonly filterable: readonly string[]; /** The index-name stem. Kernel-owned, so it carries the reserved prefix. */ readonly indexStem: string; } /** The prefix every derived list index carries. */ export declare const LIST_INDEX_PREFIX = "_substrat_list_"; /** Is this index one the kernel derived for a paged read? */ export declare function isListIndexName(name: string): boolean; /** Raised for an entity type no registered module declares as a paged list. */ export declare class NotListable extends Error { readonly entityType: string; constructor(entityType: string); } /** Raised for a `?sort=` naming a column the declaration does not offer. */ export declare class SortNotDeclared extends Error { readonly entityType: string; readonly requested: string; readonly declared: readonly string[]; constructor(entityType: string, requested: string, declared: readonly string[]); } /** Raised for a filter naming a column the declaration does not offer. */ export declare class FilterNotDeclared extends Error { readonly entityType: string; readonly requested: string; readonly declared: readonly string[]; constructor(entityType: string, requested: string, declared: readonly string[]); } /** * Resolve one module's declarations into plans. * * Refuses rather than skips, for the reason `searchIndexPlans` does: a * declaration the author believes is live that silently is not produces a list * that pages wrongly with no error anywhere. */ export declare function listIndexPlans(moduleId: string, lists: readonly ListDeclaration[] | undefined): ListIndexPlan[]; /** * The index columns for one walk: the sort, then the tie-break, prefixed by a * filter when the walk narrows by one. * * **One index per (filter, sort) pair, plus one per bare sort** — deliberately * not every subset of the filters. `S × 2^F` indexes is a combinatorial answer * to a question nobody asked: two filters applied together use the leftmost * index and narrow the rest by scan, which for a filtered page is the right * trade against paying write amplification on every insert forever. * * Stated rather than left implicit because it is a real limit: a list whose * two-filter combination is hot wants a hand-written index, and knowing that is * how somebody adds one. */ export declare function listIndexColumns(plan: ListIndexPlan): { name: string; columns: string[]; }[]; /** * The DDL for one plan's indexes. * * Drop-then-create, like the search index and for the same reason: the version * below is the declaration itself, so a changed declaration re-runs this and has * to produce indexes matching the NEW declaration rather than accumulating the * old ones. An index is derived data; nothing is lost by dropping it. */ export declare function listIndexDdl(plan: ListIndexPlan): string; /** * The migrations that provision a module's declared list indexes, journaled like * any other so a scope applies them once and a changed declaration re-applies. * * **The version IS the declaration**, as it is for search: everything that * decides the DDL appears in the version string, so adding a sort produces a new * version and re-runs while changing nothing does not. Legible in * `_substrat_migrations`, which is the one place an operator reads when a scope * is stuck. * * Appended AFTER the module's own migrations by the adapter, which is what makes * the table exist by the time `CREATE INDEX` names it. */ export declare function listIndexMigrations(moduleId: string, lists: readonly ListDeclaration[] | undefined): SqlMigration[]; /** * Index the plans by entity type for a whole scope, refusing an ambiguity — the * same refusal `searchPlansByEntityType` makes, because `ctx.page('customer', …)` * meaning different rows depending on registration order is the kind of fact * that stays true in tests and changes in production. */ export declare function listPlansByEntityType(modules: readonly { readonly id: string; readonly lists?: readonly ListDeclaration[]; }[]): Map; /** What a caller asks for. Everything optional but the limit, which the host defaults. */ export interface ListQueryParams { readonly limit: number; readonly sort?: string; readonly order?: 'asc' | 'desc'; readonly cursor?: string; /** * Narrowing, per declared column. A scalar is an equality; an ARRAY is the set * of permitted values (`IN`), and an empty array permits none of them. */ readonly filters?: Readonly>; } /** A composed read: the page query, and the count over the same `WHERE`. */ export interface ComposedListQuery { readonly sql: string; readonly params: unknown[]; readonly countSql: string; readonly countParams: unknown[]; /** The column the walk ordered by — what the cursor's first part came from. */ readonly sortColumn: string; readonly order: 'asc' | 'desc'; } /** * Split a composite cursor into its sort value and its tie-break id. * * The first part is `|`-free by construction (`pagination.ts`), so the split is * on the FIRST separator and a sort value containing `|` still round-trips as * long as the id does not — which it cannot, being a ULID. */ export declare function splitCursor(cursor: string): { value: string; id: string | undefined; }; /** Build the cursor a row hands to the next page. */ export declare function cursorOf(row: Record, sortColumn: string, idColumn: string): string; /** * Compose the page query and its count. * * The two share one `WHERE` **by construction** rather than by being written * twice in the same style — which is the defect `CountedPage` warns about ("a * count of the whole table beside a filtered page is a number that is wrong in a * way nobody notices until a customer does"). The count deliberately drops the * CURSOR clause: a total counts the filtered set, not the part of it after the * current page. */ export declare function listQuery(plan: ListIndexPlan, params: ListQueryParams): ComposedListQuery; //# sourceMappingURL=list-index.d.ts.map