import { moduleMigrations } from './module-migrations.js'; import { type SqlMigration } from './scope-host.js'; /** * What a scope's migration journal records beside each version, and the check that reads it * (#2066). Shared by both adapters, so the rule and the sentence an operator reads are one. * * A scope used to record an applied migration by `(module_id, version)` alone, so a later * `0025` whose SQL differed from the `0025` it ran was skipped as already applied: a dev * database that ran one branch's `0025` and then `main`'s, or a preview that ran an unmerged * branch, kept whichever schema it got first, and the first sign was a query failing against * a column that was not there. `_substrat_migrations.sql_digest` closes that. * * - **The digest is SHA-256 over the exact SQL text**, lowercase hex, with no normalisation: * not whitespace, not comments, not line endings. Any change is a different migration. A * shipped migration's text never changes on `main` (`lint:migrations --check` refuses the * edit upstream), so a difference at a scope means the scope ran something else. * - **Only a module's AUTHORED migrations are held to it.** The kernel-derived DDL * (`search/…`, `state/…`, `list/…`, see `moduleMigrations`) is versioned by its * declaration on purpose: the version names what decides the DDL, so the kernel may change * how it spells the same declaration between releases. Holding those rows to a digest would * fail every scope with a searchable entity closed on the kernel upgrade that respelled a * trigger. Their digest is still recorded, as the fact of what ran. * - **A row applied before the column carries `'legacy'`, and is accepted.** Nobody measured * what it ran, so it gets no digest: backfilling the registered one would write a value * nobody measured, and bless the one row this exists to catch if that scope did run * something else. The mark is explicit rather than a NULL, because a NULL cannot say where it * came from: the rows present when the column arrives are marked (`MIGRATION_DIGEST_MARK_LEGACY`), * a dump taken before the column restores as `'legacy'` (the restore derives it), and from * then on the fence below refuses a NULL however it is written. So `'legacy'` is provenance, * and a NULL — say a dump edited to clear a digest — is refused rather than trusted. Such a * row is unprotected for good, which is the price of not lying in the journal. */ export declare function migrationDigest(sql: string): Promise; /** What a journal row applied before digests were recorded carries in `sql_digest` (#2066). */ export declare const MIGRATION_DIGEST_LEGACY = "legacy"; /** * Marks the rows present when the column arrived. Run right after the column's ALTER on every * wake: once the fence is in place it can only ever find those rows, since nothing else can * leave a NULL. Literal text, like the fence, so `lint:spine-ddl` and a reviewer read one spelling. */ export declare const MIGRATION_DIGEST_MARK_LEGACY = "UPDATE _substrat_migrations SET sql_digest = 'legacy' WHERE sql_digest IS NULL"; /** * The fence: no journal row is written, or rewritten, without a digest or the legacy mark — in * both adapters' KERNEL_DDL, after the journal table, so `lint:spine-ddl` holds it like any * spine trigger. * * Without it an older writer (an instance still on the previous release during a rollout or a * rollback, or a second process over the same SQLite file) inserts a row that omits the * column, and SQLite records NULL. With it that INSERT aborts, so the older writer's migration * fails and its scope fails closed until code that records the digest serves it, which then * applies the migration properly. KERNEL_DDL runs before the column is ALTERed onto a legacy * journal, and on a table without the column the INSERT trigger makes any INSERT fail with * `no such column`: there is no window in which a NULL can be written. A restore loads its * rows under the fence too, so a dump that carries a NULL is refused (`spineRowsInsert` gives a * dump from before the column the legacy mark instead). * * Migration SQL cannot drop it: `assertNoJournalSql` refuses any migration naming the journal. * Literal text, no interpolation: `lint:spine-ddl` inlines a kernel fragment one level deep. */ export declare const MIGRATION_DIGEST_FENCE_DDL = "\n CREATE TRIGGER IF NOT EXISTS _substrat_migrations_digest_required\n BEFORE INSERT ON _substrat_migrations WHEN NEW.sql_digest IS NULL\n BEGIN\n SELECT RAISE(ABORT, 'a migration journal row must carry its sql_digest (#2066)');\n END;\n CREATE TRIGGER IF NOT EXISTS _substrat_migrations_digest_kept\n BEFORE UPDATE OF sql_digest ON _substrat_migrations WHEN NEW.sql_digest IS NULL\n BEGIN\n SELECT RAISE(ABORT, 'a migration journal row must carry its sql_digest (#2066)');\n END;\n"; /** * Refuse a migration's SQL before any of it runs, if it reaches where a migration must not. * * A migration runs on the kernel's own handle, not `ctx.sql`, so `ctx.sql`'s guard never sees it. * Every migration is held to: no foreign key to the spine (#1898), and nothing that names the * journal (`assertNoJournalSql`). An AUTHORED one is also held to the full spine write guard — * no write, DDL or same-named TEMP object on any `_substrat_*` table — unless it is one of the * exact reviewed texts above. Kernel-derived DDL is exempt from that last rule: creating * `_substrat_*` indexes and triggers is what it is for. */ export declare function assertMigrationSql(sql: string, step: { key: string; digest: string; authored: boolean; }): void; /** * Refuse a dump whose journal is not consistent with its own shape (#2066 r3): a table whose * recorded DDL declares `sql_digest` while its columns omit it, or the other way round. * * A dump exported before digests were recorded restores as `'legacy'` (`spineRowsInsert`), so * the shape of the journal is what says which kind of dump this is — and a current dump with * the column stripped would otherwise pass as legacy and unprotect every row. The dump's DDL is * `sqlite_master`'s text, which an ALTER rewrites, so a real pre-#2066 journal carries neither. * Nothing else a dump carries changed with #2066 to cross-check against (the fence is a trigger, * and a dump holds tables). A dump is freely editable data: a consistent edit of both is not * detectable, and restore is privileged, audited staff work that could forge any digest anyway. * This catches corruption and partial edits, not an author set on lying. */ export declare function assertJournalDumpCoherent(tables: readonly { name: string; ddl: string; columns: readonly string[]; }[]): void; /** One migration a host applies, with its digest and whether it is held to it. */ export interface MigrationStep { migration: SqlMigration; digest: string; /** Authored by the module, so held to its digest; false for kernel-derived DDL. */ authored: boolean; } /** * A module's migrations in the order the host applies them (`moduleMigrations`), each with its * digest and whether it is authored — read from the registration as it is now. The digest of a * text is computed once per isolate, so a Durable Object that wakes with the same code hashes * nothing again. */ export declare function migrationSteps(registration: Parameters[0]): Promise; /** A step a migration pass still has to run, with the module it belongs to. */ export interface PendingMigration extends MigrationStep { moduleId: string; } /** What a migration pass has to do, or the divergence that fails the scope closed instead. */ export type MigrationPlan = { pending: PendingMigration[]; diverged?: undefined; } | { pending: []; diverged: { version: string; error: string; }; }; /** * Plan a scope's migration pass against its journal (`module@version` → recorded digest): the * registered steps it has not applied, or the first applied one whose SQL differs, which fails * the scope closed before anything else runs. Both adapters plan through this. */ export declare function planMigrations(modules: Iterable<{ readonly id: string; readonly steps: Promise; }>, applied: ReadonlyMap): Promise; /** The error a scope fails closed with when `module@version` cannot be applied or trusted. */ export declare function migrationFailedError(key: string, cause: string): Error; /** * Why a registered migration the scope has already applied cannot be trusted, or null when it * can: on an authored migration, a recorded digest that differs from the registered one, or * no digest and no legacy mark at all. * * The caller fails the scope closed with this as the cause, the way a migration that threw * does — the same `migration failed for @ — scope fails closed:` record, so * the directory, the sweep and the dashboard read it with nothing new. The scope recovers * when its journal and its schema agree with the deployment again: a scope that ran another * branch's migration is rebuilt (a dev database, a preview) or restored to a dump taken before * it ran; a deployment carrying the wrong SQL is redeployed with the SQL the scope ran. A * migration is never edited or renumbered once a scope has it, so re-numbering is not a * recovery: the scope would still hold the other SQL under this version. */ export declare function migrationDivergence(recorded: string | null | undefined, registered: string, authored: boolean): string | null; //# sourceMappingURL=migration-digest.d.ts.map