/** * Entity archive and trash (#119): the kernel half, written once — both adapters hand * `createEntityStateVerbs` the same things, the way `createEntityEdgeVerbs` is shared. * * ## What the kernel owns, and what it leaves to the vertical * * | Kernel | Vertical | * |---|---| * | The two columns on the entity's table, added by a derived migration | Which entities declare `archive` / `trash`, and the keys | * | The transitions, their refusals, and the event each one emits | The operations that call the verbs, and their routes | * | The declared key, re-checked on the entity by every verb and every trashed read | What an archived row looks like on a screen | * | `ctx.page` / `ctx.search` leaving archived and trashed rows out by default | Its own hand-written `SELECT`s — see the gap below | * * ## The state is two columns * * `_substrat_archived_at` and `_substrat_trashed_at`, each an ISO instant or NULL. Apart, not * one enum, so a trash never forgets an archive: `restore` clears the trash and leaves the * archive, and an archived row that went to the bin comes back archived. The visible state is * derived (contracts `entity-state.ts`): trashed, else archived, else active. * * Module code cannot write either column: `ctx.sql` refuses a write naming a `_substrat_*` * column (`spine-guard.ts`), so the only way into or out of the trash is a verb that checks * the key and emits the event. It can READ them, which is how a vertical's own `SELECT` keeps * archived rows out of a screen `ctx.page` does not compose. * * ## The gap, stated * * The kernel filters the reads it composes — `ctx.page` and `ctx.search` — and nothing else. * A handler's own `SELECT … FROM todo_lists WHERE id = ?` sees an archived or trashed row * exactly as before. That is the honest limit of a kernel that does not parse module SQL: * a get-by-id asks `ctx.entityState(ref)` and decides, and a hand-written list adds the * predicate itself (`entityStateWhere`). * * ## Trash is not erasure, and not tenant deletion * * Trash keeps the row and every event about it. Subject erasure (#37) reaches a trashed row * exactly as it reaches any other, because the row never moved tables; a tenant's deletion * (#36) removes it with everything else. Nothing here extends a retention or shortens one. */ import { type DomainEventInput, type EntityStateDeclaration, type EntityStateName, type Instant, type PermissionKey } from '@substrat-run/contracts'; import type { OperationContext, ScopedSql, SqlMigration } from './scope-host.js'; import type { DerivedObject } from './derived-object.js'; /** A resolved declaration: everything the DDL, the reads and the verbs need. */ export interface EntityStatePlan { readonly moduleId: string; readonly entityType: string; readonly table: string; readonly idColumn: string; /** Present when the entity can be archived. */ readonly archivePermission?: PermissionKey; /** Present when the entity can be trashed. */ readonly trashPermission?: PermissionKey; /** Present when a trashed entity is purged after this many days (`entity-trash.ts`). */ readonly purgeAfterDays?: number; /** `false` when the horizon is a floor for every caller of the purge operation (#2214); absent otherwise. */ readonly purgeEarly?: false; } /** The operation's own check — a pass is recorded as one of its authorizations (K-34). */ export type StateCheck = OperationContext['check']; /** * Resolve one module's declarations. Refuses rather than skips, for `searchIndexPlans`'s * reason: a declaration the author believes is live that silently is not leaves a trashed * row in every list with no error anywhere. */ export declare function entityStatePlans(moduleId: string, declarations: readonly EntityStateDeclaration[] | undefined): EntityStatePlan[]; /** * The migrations that add the columns — one per column, so declaring `trash` on an entity * that already declared `archive` adds one column and re-runs nothing. * * **The version is the declaration**, as for search and list indexes. Unlike theirs, this DDL * is not drop-then-create: a column holds the state, and dropping it would un-trash every row. * So it runs once per column, ever. Withdrawing a declaration leaves the column where it is, * holding what it held; re-declaring it finds the migration already applied. * * Applied after the module's own migrations (the table must exist) and before its list * indexes (their partial `WHERE` names these columns) — `moduleMigrations` writes that order. */ export declare function entityStateMigrations(moduleId: string, declarations: readonly EntityStateDeclaration[] | undefined): SqlMigration[]; /** The column migrations' versions, and the column each adds — what the journal says a table holds. */ export declare const stateColumnVersionsOf: (plan: EntityStatePlan) => { version: string; column: string; }[]; /** The purge index's migration version (#119) — present only on a plan with a purge horizon. */ export declare const purgeIndexVersion: (plan: EntityStatePlan) => string; /** * The partial index the purge sweep walks (#119): trashed rows only, oldest trash first, so a * sweep reads the due rows and nothing else. Its name is kernel-prefixed, so the reserved * namespace. Empty for a plan without a purge horizon. */ export declare function purgeIndexObjects(plan: EntityStatePlan): DerivedObject[]; /** Drop-then-create, for a purge index the catalogue holds under its name but not as emitted. */ export declare function purgeIndexRepairDdl(plan: EntityStatePlan): string; /** * The guard triggers' version. By which columns they guard, so declaring a trash on an * archivable entity rebuilds them to guard both. */ export declare const entityStateGuardVersion: (plan: EntityStatePlan) => string; /** The prefix of every trigger this module derives — kernel-owned, so the reserved one. */ export declare const ENTITY_STATE_TRIGGER_PREFIX = "_substrat_state_"; export declare const ENTITY_STATE_MOVES_DDL = "\n CREATE TABLE IF NOT EXISTS _substrat_state_moves (\n entity_type TEXT NOT NULL,\n entity_id TEXT NOT NULL,\n PRIMARY KEY (entity_type, entity_id)\n );\n"; /** * The invariant below the column guard, as two triggers on the entity's table. * * `ctx.sql` refuses the writes that would set the columns (`assertNoReservedColumnWrite`), but * that is a reading of SQL text, and a reading can miss a form — it did, twice. These hold * whatever the text says: * * - **born** — `BEFORE INSERT`: a row is never inserted archived or trashed. Every row enters * active. * - **moved** — `BEFORE UPDATE OF` the columns: a change to either aborts unless the kernel's * authorization row for this entity exists (`ENTITY_STATE_MOVES_TABLE`), which only a move * writes. An `UPDATE` that does not name the columns does not fire it. * * Drop-then-create, like the search triggers, and re-run after a dump load: a load drops the * table and with it the triggers, then inserts the rows first — a restored binned row is * legitimately born trashed, so the triggers are put back only after them. */ export declare function entityStateTriggerDdl(plan: EntityStatePlan): string; /** The two guard triggers `entityStateTriggerDdl` creates, each with its CREATE statement. */ export declare function entityStateTriggerObjects(plan: EntityStatePlan): DerivedObject[]; /** * Add one module's plans to a scope's registry, refusing a second declaration of a type — the * table is one module's, and two answers to "who may trash it" is no answer — and a key the * module does not declare. The entity is the module's, so its keys are too: a key nobody * declared reaches no role, no grant shape and no `PERMISSIONS.md`, and would make the verb * look gated while nobody could ever pass it. */ export declare function addStatePlans(byType: Map, moduleId: string, declarations: readonly EntityStateDeclaration[] | undefined, declaredKeys: readonly { readonly key: string; }[]): void; /** The tables whose rows carry the columns, lowercased as SQLite resolves a name — `guardSpine`'s input. */ export declare const statefulTablesOf: (plans: ReadonlyMap) => ReadonlySet; /** The columns a plan has, for code that only needs to know which views exist. */ export interface StateColumns { readonly archive: boolean; readonly trash: boolean; } export declare const stateColumnsOf: (plan: EntityStatePlan) => StateColumns; /** The views an entity with these columns has — `active` always, then one per column. */ export declare function viewsOf(columns: StateColumns): EntityStateName[]; /** * The predicate selecting one view's rows, over bare column names or `alias.`-qualified ones. * * **Spelled one way, everywhere.** A partial index is used only when the query's `WHERE` * contains the index's own terms, so the list index DDL and the list query both come from * here — a reworded copy would plan a scan in production and pass every test. * * `columns` absent is an entity that declares no state: every row is active, so there is no * predicate. A view the entity does not have is refused rather than answered empty — asking * an entity with no trash for its trashed rows is a wiring mistake, and an empty page would * read as an empty bin. */ export declare function entityStateWhere(entityType: string, columns: StateColumns, view: EntityStateName, alias?: string): string; export declare function entityStateWhere(entityType: string, columns: StateColumns | undefined, view: EntityStateName | undefined, alias?: string): string | undefined; /** The two columns as one row holds them. */ export interface StateRow { readonly archived_at: string | null; readonly trashed_at: string | null; } /** One row's two columns, or `undefined` when the row does not exist. */ export declare function readStateRow(sql: ScopedSql, plan: EntityStatePlan, entityId: string): StateRow | undefined; /** * The plan for `entityType`, and the key one of its states is gated on — or `validation_failed` * naming what the entity does not declare. Shared by the verbs and the trashed readers, so * "declares no trash" is one sentence. */ export declare function stateKeyOf(plans: ReadonlyMap, verb: string, entityType: string, which: 'archive' | 'trash'): { plan: EntityStatePlan; key: PermissionKey; }; /** What the verbs need from the adapter. */ export interface EntityStateDeps { /** RAW access inside the operation's own transaction — the guarded `ctx.sql` refuses these columns. */ sql: ScopedSql; /** entity type → its plan, for every registered module. */ plans: ReadonlyMap; /** The operation's instant — what each column is stamped with. */ now: Instant; check: StateCheck; /** `ctx.emit`'s kernel writer — stamps the actor, the authorization chain and the operation. */ emit: (event: DomainEventInput) => void; /** K-42's read-only refusal, for the effecting verbs. */ assertWrites: (verb: string) => void; } /** The verbs, as `OperationContext` carries them. */ export type EntityStateVerbs = Pick; export declare function createEntityStateVerbs(deps: EntityStateDeps): EntityStateVerbs; //# sourceMappingURL=entity-state.d.ts.map