/** * 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 carries the * id alone (its envelope has no separate `id`, K-44). */ import { SubstratError } from '@substrat-run/contracts'; import type { EntityStateDeclaration, EntityStateName } from '@substrat-run/contracts'; import { type StateColumns } from './entity-state.js'; import type { DerivedObject } from './derived-object.js'; 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 entity's archive/trash columns, when it declares either (#119). Present, every index * is PARTIAL — one per view — and every walk is narrowed to one view. */ readonly states?: StateColumns; } /** 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, /** The SAME module's archive/trash declarations (#119) — its table, its columns. */ entityStates?: readonly EntityStateDeclaration[]): ListIndexPlan[]; /** * 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 plan's indexes, each with its CREATE statement — what `listIndexDdl` runs. */ export declare function listIndexObjects(plan: ListIndexPlan): DerivedObject[]; /** * 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, entityStates?: readonly EntityStateDeclaration[]): SqlMigration[]; /** * One plan's migration version. The views are part of the declaration the DDL depends on * (#119): declaring a trash makes every index partial, so the version moves and the indexes * are rebuilt. */ export declare const listIndexVersion: (plan: ListIndexPlan) => string; /** 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; `null` is * the rows with no value there (`IS NULL`). */ readonly filters?: Readonly>; /** * Which rows (#119). `active` when unset — an archived or trashed row is never in a page * nobody asked to see it in. Refused for an entity that declares no such view. */ readonly view?: EntityStateName; } /** 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'; /** The view the walk ran over (#119) — what its cursors are minted for. */ readonly view: EntityStateName; } /** * Raised for a cursor this walk cannot continue (#2001): one minted under a * different order or sort, or one `ctx.page` never minted at all. * * A keyset position only means something in the walk that produced it. Replayed * under the other order, `created_at < ?` becomes `created_at > ?` over the same * value and the "next" page is the rows the caller has already seen — a silent * answer that reads as a working list. So the cursor names its walk, and a * disagreement is the caller's 400, narrowed by `reason: 'cursor_restart'` so a * client can tell "read the first page again" from a malformed request. */ export declare class CursorMismatch extends SubstratError { constructor(message: string); } /** Build the cursor a row hands to the next page — the walk that minted it, and where. */ export declare function cursorOf(row: Record, sortColumn: string, idColumn: string, order: 'asc' | 'desc', /** The walk's view (#119). `active`, or unset, is left out of the envelope. */ view?: EntityStateName): string; /** Migration gate for filtered walks: their old input was a plain ctx.page cursor. */ export declare function isPlainPageCursor(cursor: string): boolean; /** * 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