@telorun/sql 0.21.3 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/index.d.ts +17 -2
  2. package/dist/index.js +8 -2
  3. package/dist/schema/declaration-snapshot.d.ts +21 -0
  4. package/dist/schema/declaration-snapshot.js +46 -0
  5. package/dist/schema/declared-schema.d.ts +64 -0
  6. package/dist/schema/declared-schema.js +16 -0
  7. package/dist/schema/migration-runner.d.ts +30 -0
  8. package/dist/schema/migration-runner.js +38 -0
  9. package/dist/schema/normalize-table.d.ts +46 -0
  10. package/dist/schema/normalize-table.js +135 -0
  11. package/dist/schema/reclaim-policy.d.ts +34 -0
  12. package/dist/schema/reclaim-policy.js +40 -0
  13. package/dist/schema/schema-driver.d.ts +161 -0
  14. package/dist/schema/schema-driver.js +1 -0
  15. package/dist/schema/schema-ledger.d.ts +119 -0
  16. package/dist/schema/schema-ledger.js +231 -0
  17. package/dist/schema/schema-reconciler.d.ts +45 -0
  18. package/dist/schema/schema-reconciler.js +243 -0
  19. package/dist/schema/schema-run.d.ts +50 -0
  20. package/dist/schema/schema-run.js +318 -0
  21. package/dist/sql-connection-base.d.ts +21 -0
  22. package/dist/sql-connection-base.js +30 -4
  23. package/dist/sql-connection.d.ts +19 -0
  24. package/package.json +5 -3
  25. package/src/index.ts +36 -2
  26. package/src/schema/declaration-snapshot.ts +71 -0
  27. package/src/schema/declared-schema.ts +73 -0
  28. package/src/schema/migration-runner.ts +69 -0
  29. package/src/schema/normalize-table.ts +218 -0
  30. package/src/schema/reclaim-policy.ts +73 -0
  31. package/src/schema/schema-driver.ts +182 -0
  32. package/src/schema/schema-ledger.ts +309 -0
  33. package/src/schema/schema-reconciler.ts +339 -0
  34. package/src/schema/schema-run.ts +441 -0
  35. package/src/sql-connection-base.ts +35 -4
  36. package/src/sql-connection.ts +21 -0
  37. package/dist/sql-migration-controller.d.ts +0 -16
  38. package/dist/sql-migration-controller.js +0 -13
  39. package/dist/sql-migrations-controller.d.ts +0 -23
  40. package/dist/sql-migrations-controller.js +0 -98
  41. package/src/sql-migration-controller.ts +0 -20
  42. package/src/sql-migrations-controller.ts +0 -143
@@ -0,0 +1,243 @@
1
+ import { objectKey } from "./declared-schema.js";
2
+ import { parseObjectKey } from "./declaration-snapshot.js";
3
+ /**
4
+ * Whether a live column still matches its declaration. Comparison is over the
5
+ * driver's own canonical type signature, nullability and the presence of a
6
+ * default — every type rule stays inside the driver, and nothing here parses a
7
+ * type.
8
+ */
9
+ function columnDiffers(driver, live, declared) {
10
+ const declaresDefault = declared.default !== undefined || declared.defaultExpression !== undefined;
11
+ return (live.typeSignature !== driver.typeSignature(declared) ||
12
+ live.nullable !== declared.nullable ||
13
+ live.hasDefault !== declaresDefault ||
14
+ // Uniqueness and key membership are what the declaration PROMISES about the
15
+ // data. Compared by nothing at all, adding `unique: true` to a live column
16
+ // emitted no DDL and no report while the ledger recorded it as owned — so
17
+ // the manifest asserted a constraint the database was not enforcing.
18
+ live.primaryKey !== declared.primaryKey ||
19
+ live.unique !== declared.unique);
20
+ }
21
+ /** Column order is part of an index: `(a, b)` and `(b, a)` are different indexes. */
22
+ function indexDiffers(live, declared) {
23
+ return (live.unique !== declared.unique ||
24
+ live.columns.length !== declared.columns.length ||
25
+ live.columns.some((column, i) => column !== declared.columns[i]));
26
+ }
27
+ /** A referential action is what the constraint DOES, so a change to it is a
28
+ * change to the constraint. An action the engine did not report is not compared
29
+ * — an absent reading is not evidence of a difference. */
30
+ function foreignKeyDiffers(live, declared) {
31
+ const action = (value) => value?.toUpperCase();
32
+ return (live.references.table !== declared.references.table ||
33
+ live.columns.length !== declared.columns.length ||
34
+ live.columns.some((column, i) => column !== declared.columns[i]) ||
35
+ live.references.columns.length !== declared.references.columns.length ||
36
+ live.references.columns.some((column, i) => column !== declared.references.columns[i]) ||
37
+ (live.onDelete !== undefined && action(live.onDelete) !== (action(declared.onDelete) ?? "NO ACTION")) ||
38
+ (live.onUpdate !== undefined && action(live.onUpdate) !== (action(declared.onUpdate) ?? "NO ACTION")));
39
+ }
40
+ function liveByName(live) {
41
+ return new Map(live.map((table) => [table.name, table]));
42
+ }
43
+ export function planReconciliation(driver, schema, declared, live, owned, tombstoned) {
44
+ const statements = [];
45
+ const tombstones = [];
46
+ const revived = [];
47
+ const inertRenames = [];
48
+ const refusals = [];
49
+ const liveTables = liveByName(live);
50
+ const declaredKeys = new Set();
51
+ const emit = (phase, describes, sql) => {
52
+ for (const one of sql)
53
+ statements.push({ phase, sql: one, describes });
54
+ };
55
+ // NOT named `declare`: `declare` is a TypeScript modifier keyword, and a
56
+ // statement that begins with it is parsed as an ambient declaration and
57
+ // STRIPPED by a type-stripping transpiler — so `declare({ … });` at statement
58
+ // position vanished while `const k = declare(…)` survived, and the pass
59
+ // tombstoned every object it had just declared. Silent under Node, silent at
60
+ // `tsc`, and destructive only on the runtime that strips types.
61
+ const markDeclared = (id) => {
62
+ const key = objectKey(id);
63
+ declaredKeys.add(key);
64
+ if (tombstoned.has(key))
65
+ revived.push(key);
66
+ return key;
67
+ };
68
+ for (const table of declared) {
69
+ markDeclared({ kind: "table", table: table.name });
70
+ for (const column of table.columns) {
71
+ markDeclared({ kind: "column", table: table.name, name: column.name });
72
+ }
73
+ const liveTable = liveTables.get(table.name);
74
+ if (!liveTable) {
75
+ emit("table", `table ${table.name}`, driver.createTable(schema, table));
76
+ }
77
+ else {
78
+ const liveColumns = new Map(liveTable.columns.map((c) => [c.name, c]));
79
+ for (const column of table.columns) {
80
+ const existing = liveColumns.get(column.name);
81
+ if (!existing) {
82
+ const renamedFrom = column.renamedFrom;
83
+ const source = renamedFrom ? liveColumns.get(renamedFrom) : undefined;
84
+ // Classified BEFORE anything is emitted, so a refused rename
85
+ // contributes no statements at all. The runner refuses to execute a
86
+ // plan carrying refusals, but a plan that is half a rename is still
87
+ // the wrong thing to hand anyone.
88
+ if (source && renamedFrom) {
89
+ // A rename that changes the type is two changes wearing one name.
90
+ // Unchecked, the copy is a raw driver error on an engine that
91
+ // refuses the assignment, and silently stores the old
92
+ // representation on one that does not.
93
+ const safety = driver.classifyCopy(source, column);
94
+ if (!safety.safe) {
95
+ refusals.push({
96
+ object: `${table.name}.${column.name}`,
97
+ reason: `renamedFrom '${renamedFrom}': ${safety.reason}`,
98
+ });
99
+ continue;
100
+ }
101
+ }
102
+ emit("table", `column ${table.name}.${column.name}`, driver.addColumn(schema, table.name, column));
103
+ // Expand-contract: the source column is copied, then tombstoned. A
104
+ // native RENAME would take effect immediately and break the older
105
+ // version still running — the one operation that would be exempt from
106
+ // the deferral this design exists for.
107
+ if (source && renamedFrom) {
108
+ emit("table", `copy ${table.name}.${renamedFrom} → ${column.name}`, driver.copyColumn(schema, table.name, renamedFrom, column.name));
109
+ }
110
+ continue;
111
+ }
112
+ if (!columnDiffers(driver, existing, column))
113
+ continue;
114
+ // Classification happens here, against live state, because the
115
+ // declaration is the only artifact: there is no historical declared
116
+ // state to diff against, so whether a change is safe depends on what is
117
+ // in the column right now.
118
+ const safety = driver.classifyAlter(existing, column);
119
+ if (!safety.safe) {
120
+ refusals.push({ object: `${table.name}.${column.name}`, reason: safety.reason });
121
+ continue;
122
+ }
123
+ emit("table", `column ${table.name}.${column.name}`, driver.alterColumn(schema, table.name, existing, column));
124
+ }
125
+ }
126
+ // A rename is inert when its source is gone for good: not present, and not
127
+ // held by a tombstone. Only asked of a table that ALREADY existed — on one
128
+ // this pass creates there was never anything to copy, so every rename would
129
+ // look finished when in fact it has not run anywhere yet, and the same
130
+ // manifest still deploys to databases that do need it.
131
+ if (liveTable) {
132
+ const liveColumnNames = new Set(liveTable.columns.map((c) => c.name));
133
+ for (const column of table.columns) {
134
+ if (!column.renamedFrom)
135
+ continue;
136
+ const sourceKey = objectKey({
137
+ kind: "column",
138
+ table: table.name,
139
+ name: column.renamedFrom,
140
+ });
141
+ if (liveColumnNames.has(column.renamedFrom) || tombstoned.has(sourceKey))
142
+ continue;
143
+ inertRenames.push(`column ${table.name}.${column.name} (renamedFrom ${column.renamedFrom})`);
144
+ }
145
+ }
146
+ const liveIndexes = new Map((liveTable?.indexes ?? []).map((index) => [index.name, index]));
147
+ for (const index of table.indexes) {
148
+ markDeclared({ kind: "index", table: table.name, name: index.name });
149
+ const existing = liveIndexes.get(index.name);
150
+ if (!existing) {
151
+ emit("index", `index ${index.name}`, driver.createIndex(schema, table.name, index));
152
+ continue;
153
+ }
154
+ // An index that exists under the right name may still cover the wrong
155
+ // columns, or have stopped being unique. Silence there is the declaration
156
+ // asserting something the database is not doing.
157
+ if (!indexDiffers(existing, index))
158
+ continue;
159
+ const safety = driver.classifyIndexChange(existing, index);
160
+ if (!safety.safe) {
161
+ refusals.push({ object: `${table.name}.${index.name}`, reason: safety.reason });
162
+ continue;
163
+ }
164
+ emit("index", `index ${index.name}`, [
165
+ ...driver.dropIndex(schema, table.name, index.name),
166
+ ...driver.createIndex(schema, table.name, index),
167
+ ]);
168
+ }
169
+ const liveForeignKeys = new Map((liveTable?.foreignKeys ?? []).map((fk) => [fk.name, fk]));
170
+ for (const fk of table.foreignKeys) {
171
+ markDeclared({ kind: "foreignKey", table: table.name, name: fk.name });
172
+ const existing = liveForeignKeys.get(fk.name);
173
+ if (!existing) {
174
+ emit("constraint", `foreign key ${fk.name}`, driver.addForeignKey(schema, table.name, fk));
175
+ continue;
176
+ }
177
+ if (!foreignKeyDiffers(existing, fk))
178
+ continue;
179
+ const safety = driver.classifyForeignKeyChange(existing, fk);
180
+ if (!safety.safe) {
181
+ refusals.push({ object: `${table.name}.${fk.name}`, reason: safety.reason });
182
+ continue;
183
+ }
184
+ emit("constraint", `foreign key ${fk.name}`, [
185
+ ...driver.dropForeignKey(schema, table.name, fk.name),
186
+ ...driver.addForeignKey(schema, table.name, fk),
187
+ ]);
188
+ }
189
+ }
190
+ // Removal never emits DDL. An object this resource once declared and no longer
191
+ // does is tombstoned; the drop is deferred to reclamation, which is the whole
192
+ // point. An object it has NEVER declared is not ours and is not considered.
193
+ const tombstoneKeys = new Set();
194
+ const tombstone = (id, key, definition) => {
195
+ if (tombstoneKeys.has(key))
196
+ return;
197
+ tombstoneKeys.add(key);
198
+ tombstones.push({ id, key, definition });
199
+ };
200
+ // A table that is going away takes its columns, indexes and constraints with
201
+ // it, so only the TABLE is tombstoned. Recording the children too would plan a
202
+ // drop for each — and they are dropped first, since reclamation walks
203
+ // dependents before their table — so an engine that refuses to drop a primary
204
+ // key or an indexed column (SQLite refuses both) would fail the pass, and go
205
+ // on failing it, over objects the DROP TABLE was about to remove anyway.
206
+ const retiredTables = new Set(Object.keys(owned)
207
+ .filter((key) => key.startsWith("table:"))
208
+ .map((key) => parseObjectKey(key).table)
209
+ .filter((table) => !declaredKeys.has(objectKey({ kind: "table", table }))));
210
+ for (const [key, definition] of Object.entries(owned)) {
211
+ if (declaredKeys.has(key) || tombstoned.has(key))
212
+ continue;
213
+ const id = parseObjectKey(key);
214
+ if (id.kind !== "table" && retiredTables.has(id.table))
215
+ continue;
216
+ tombstone(id, key, definition);
217
+ }
218
+ // A renamed-away source column is tombstoned even while the declaration still
219
+ // names it through `renamedFrom`, so its budget starts at the rename rather
220
+ // than at whichever later release deletes the mention.
221
+ //
222
+ // Only a source that is actually THERE. Once a rename's source has been
223
+ // reclaimed the mention is inert, and tombstoning it again would put a column
224
+ // that no longer exists back on the books and eventually emit a DROP for it.
225
+ for (const table of declared) {
226
+ const liveColumnNames = new Set((liveTables.get(table.name)?.columns ?? []).map((c) => c.name));
227
+ for (const column of table.columns) {
228
+ if (!column.renamedFrom)
229
+ continue;
230
+ if (!liveColumnNames.has(column.renamedFrom))
231
+ continue;
232
+ const id = { kind: "column", table: table.name, name: column.renamedFrom };
233
+ const key = objectKey(id);
234
+ if (tombstoned.has(key) || declaredKeys.has(key))
235
+ continue;
236
+ tombstone(id, key, owned[key] ?? JSON.stringify({ name: column.renamedFrom }));
237
+ }
238
+ }
239
+ return { statements, tombstones, revived, inertRenames, refusals };
240
+ }
241
+ export function describeRefusals(refusals) {
242
+ return refusals.map((r) => ` ${r.object}: ${r.reason}`).join("\n");
243
+ }
@@ -0,0 +1,50 @@
1
+ import { type ResourceContext } from "@telorun/sdk";
2
+ import type { DeclaredTable } from "./declared-schema.js";
3
+ import type { SchemaDriver } from "./schema-driver.js";
4
+ import { type ReclaimPolicy } from "./reclaim-policy.js";
5
+ import { type MigrationMap } from "./migration-runner.js";
6
+ export interface SchemaRunInput {
7
+ readonly schema: string;
8
+ /**
9
+ * Which ledger this schema keeps its history in — the per-set name, or
10
+ * undefined for the default. Two schema resources over one namespace MUST
11
+ * name different ledgers: the ledger records the declaration, so a shared one
12
+ * would make each read the other's tables as removed.
13
+ */
14
+ readonly ledger?: string;
15
+ /** The released version this deployment is running. Absent when no `reclaim:`
16
+ * policy is declared — nothing else reads it. */
17
+ readonly version?: string;
18
+ readonly tables: readonly DeclaredTable[];
19
+ readonly beforeMigrations: MigrationMap;
20
+ readonly migrations: MigrationMap;
21
+ readonly reclaim?: ReclaimPolicy;
22
+ }
23
+ /** What the pass did, reported as observed state so there is nothing to invoke to see it. */
24
+ export interface PendingReclamation {
25
+ readonly object: string;
26
+ readonly missingSinceVersion: string;
27
+ /** `null` when no `reclaim:` policy is declared — nothing is ever dropped, so
28
+ * there is no budget to count down, only the fact that the object is held. */
29
+ readonly versionsRemaining: number | null;
30
+ readonly msRemaining: number | null;
31
+ readonly eligible: boolean;
32
+ /** Present when the engine cannot drop an object of this kind at all: the
33
+ * tombstone stands, and this says what has to happen instead. */
34
+ readonly unreclaimable?: string;
35
+ }
36
+ export interface SchemaRunStatus {
37
+ /** The released version this deployment is running. Absent when no `reclaim:`
38
+ * policy is declared — nothing else reads it. */
39
+ readonly version?: string;
40
+ readonly digest: string;
41
+ readonly sequence: number;
42
+ readonly migrationsApplied: string[];
43
+ readonly orphanedMigrations: string[];
44
+ readonly tombstoned: string[];
45
+ readonly revived: string[];
46
+ readonly inertRenames: string[];
47
+ readonly reclaimed: string[];
48
+ readonly pendingReclamation: PendingReclamation[];
49
+ }
50
+ export declare function runSchemaPass(driver: SchemaDriver, ctx: ResourceContext, input: SchemaRunInput): Promise<SchemaRunStatus>;
@@ -0,0 +1,318 @@
1
+ import { parseDurationMs } from "@telorun/sdk";
2
+ import { describeObject } from "./declared-schema.js";
3
+ import { snapshotDeclaration, snapshotDigest, parseObjectKey } from "./declaration-snapshot.js";
4
+ import { ledgerTables, SchemaLedger } from "./schema-ledger.js";
5
+ import { assessTombstone } from "./reclaim-policy.js";
6
+ import { migrationStatements, orphanedKeys, runMigrations, } from "./migration-runner.js";
7
+ import { describeRefusals, planReconciliation } from "./schema-reconciler.js";
8
+ /**
9
+ * The boot pass, in one defined order: lock, before-migrations, reconcile,
10
+ * migrations, record the version, tombstone, reclaim.
11
+ *
12
+ * The order is the reason schema change is ONE kind. Imperative and declarative
13
+ * schema change need the same lock, the same bookkeeping and a defined order
14
+ * between them; as separate kinds that order would live in the author's
15
+ * `targets:` list, invisible and uncheckable.
16
+ *
17
+ * The version row is written only once reconciliation and both migration phases
18
+ * have succeeded. A pass that fails before that records nothing, and the next
19
+ * boot re-derives everything from live state — which is what keeps the clock
20
+ * that gates an irreversible drop from advancing on a half-applied pass.
21
+ */
22
+ /**
23
+ * Ledgers already claimed on a connection, so two schema resources sharing one
24
+ * cannot silently share a history.
25
+ *
26
+ * Hung off the CONNECTION INSTANCE rather than held in module scope: a
27
+ * controller bundle inlines its own copy of a shared source file, so a module
28
+ * global is one map per bundle and every lookup a miss (the payload rule,
29
+ * kernel/specs/execution-zones.md §8).
30
+ *
31
+ * This sees only what one process declares. Two APPLICATIONS sharing a namespace
32
+ * with the same ledger name are invisible here — as they are to every tool that
33
+ * separates history by table name — which is why the rule is also documented.
34
+ */
35
+ const claimedLedgers = new WeakMap();
36
+ function claimLedger(connection, schema, versionsTable) {
37
+ const key = `${schema}\u0000${versionsTable}`;
38
+ let claimed = claimedLedgers.get(connection);
39
+ if (!claimed)
40
+ claimedLedgers.set(connection, (claimed = new Set()));
41
+ if (claimed.has(key)) {
42
+ throw new Error(`Two schema resources share the ledger '${versionsTable}' in namespace '${schema}' on one ` +
43
+ `connection. The ledger records what its schema declares, so a shared one would make ` +
44
+ `each read the other's tables as removed and eventually drop them. Give one of them its ` +
45
+ `own: 'ledger: <name>'.`);
46
+ }
47
+ claimed.add(key);
48
+ }
49
+ /**
50
+ * One physical table has ONE schema resource that manages it.
51
+ *
52
+ * Giving two of them separate ledgers keeps their HISTORIES apart, which is what
53
+ * the ledger name is for — but it says nothing about the tables themselves.
54
+ * Two resources declaring one table is worse than a shared history: remove it
55
+ * from one and that ledger tombstones it and eventually drops it, while the
56
+ * other recreates it empty on its next boot through `CREATE TABLE IF NOT
57
+ * EXISTS`. The data is gone and both manifests still look correct.
58
+ */
59
+ function claimTable(connection, schema, table) {
60
+ const key = `${schema}\u0000table:${table}`;
61
+ let claimed = claimedLedgers.get(connection);
62
+ if (!claimed)
63
+ claimedLedgers.set(connection, (claimed = new Set()));
64
+ if (claimed.has(key)) {
65
+ throw new Error(`Two schema resources declare the table '${table}' in namespace '${schema}' on one ` +
66
+ `connection. One table has one schema resource that manages it: were it removed from ` +
67
+ `one declaration, that schema would drop it while the other recreated it empty.`);
68
+ }
69
+ claimed.add(key);
70
+ }
71
+ export async function runSchemaPass(driver, ctx, input) {
72
+ return driver.withLock(input.schema, () => pass(driver, ctx, input));
73
+ }
74
+ async function pass(driver, ctx, input) {
75
+ const now = () => driver.now();
76
+ const tables = ledgerTables(input.ledger);
77
+ claimLedger(driver.connection, input.schema, tables.versions);
78
+ for (const table of input.tables)
79
+ claimTable(driver.connection, input.schema, table.name);
80
+ const ledger = new SchemaLedger(driver, input.schema, tables);
81
+ await driver.runAtomically(driver.ensureNamespaceStatements(input.schema));
82
+ await ledger.ensureTables();
83
+ const applied = await ledger.appliedMigrationKeys();
84
+ const history = await ledger.versionHistory();
85
+ const owned = history[history.length - 1]?.declaration ?? {};
86
+ const tombstones = await ledger.tombstones();
87
+ const tombstonedKeys = new Set(tombstones.map((t) => t.objectKey));
88
+ // The version is the reclamation clock. Declaring a policy without one would
89
+ // make every boot look like the same release, so no tombstone would ever age
90
+ // and the policy would silently never fire. The schema requires the pair; this
91
+ // is the same rule for a caller reaching the library directly, and it also
92
+ // catches an expression that evaluated to nothing.
93
+ // Allowed — the tests need it and an author may genuinely want it — but never
94
+ // silent: the time window is the backstop that exists because several releases
95
+ // can land in an afternoon, and zero switches it off, leaving `afterVersions`
96
+ // alone to gate an irreversible drop. A static diagnostic belongs to the
97
+ // declaration-consistency mechanism (analyzer/nodejs/plans/); until that
98
+ // lands, saying it at boot is better than not saying it.
99
+ if (input.reclaim && parseDurationMs(input.reclaim.afterDuration) === 0) {
100
+ ctx.log.warn("Reclamation has no time backstop", {
101
+ "sql.schema.reclaim.afterVersions": input.reclaim.afterVersions,
102
+ });
103
+ }
104
+ const declaredVersion = input.version?.trim() ?? "";
105
+ if (input.reclaim && declaredVersion === "") {
106
+ throw new Error(`Schema '${input.schema}': 'reclaim' is declared without a 'version'. The version is the ` +
107
+ `clock reclamation is gated on — without one every boot looks like the same release, so ` +
108
+ `nothing would ever age out. Declare it, conventionally as !cel "module.version".`);
109
+ }
110
+ // Two declarations of one physical table in one schema resource would be
111
+ // reconciled twice against one live table, each pass seeing the other's
112
+ // columns as undeclared.
113
+ const byPhysicalName = new Map();
114
+ for (const table of input.tables) {
115
+ byPhysicalName.set(table.name, (byPhysicalName.get(table.name) ?? 0) + 1);
116
+ }
117
+ const duplicated = [...byPhysicalName].filter(([, count]) => count > 1).map(([name]) => name);
118
+ if (duplicated.length > 0) {
119
+ throw new Error(`Schema '${input.schema}': ${duplicated.map((n) => `'${n}'`).join(", ")} ` +
120
+ `${duplicated.length === 1 ? "is declared" : "are declared"} by more than one table in ` +
121
+ `this schema. One physical table has one declaration; a table in two namespaces means ` +
122
+ `two schema resources.`);
123
+ }
124
+ // A foreign key can only be created once its target exists, and this pass
125
+ // creates exactly the tables it was given.
126
+ const declaredNames = new Set(input.tables.map((table) => table.name));
127
+ for (const table of input.tables) {
128
+ for (const fk of table.foreignKeys) {
129
+ if (declaredNames.has(fk.references.table))
130
+ continue;
131
+ throw new Error(`Schema '${input.schema}': foreign key '${table.name}.${fk.name}' references table ` +
132
+ `'${fk.references.table}', which this schema does not declare. Add it to 'tables:', ` +
133
+ `or create the constraint in a 'migrations:' entry if the target is owned elsewhere.`);
134
+ }
135
+ }
136
+ // Phase is not part of identity — the ledger stores the key alone, which is
137
+ // what lets a migration move between the two maps without re-running. The
138
+ // price is that a key in BOTH is meaningless: the merge below would drop one
139
+ // of them and the ledger would skip the other as already applied, so a
140
+ // migration the author wrote would never run and nothing would say so.
141
+ const collisions = Object.keys(input.beforeMigrations).filter((key) => key in input.migrations);
142
+ if (collisions.length > 0) {
143
+ throw new Error(`Schema '${input.schema}': ${collisions.map((k) => `'${k}'`).join(", ")} ` +
144
+ `${collisions.length === 1 ? "is declared" : "are declared"} in both ` +
145
+ `'beforeMigrations' and 'migrations'. A migration key is its identity across both ` +
146
+ `phases, so it may appear in only one — move it to the phase it belongs in.`);
147
+ }
148
+ // Both phases are checked for statements up front, so a malformed entry fails
149
+ // before any DDL has run rather than between two that have.
150
+ for (const [key, entry] of Object.entries({ ...input.beforeMigrations, ...input.migrations })) {
151
+ migrationStatements(key, entry);
152
+ }
153
+ const beforeApplied = await runMigrations(driver, ledger, input.beforeMigrations, applied, now);
154
+ for (const key of beforeApplied)
155
+ applied.add(key);
156
+ const live = await driver.introspect(input.schema, input.tables.map((table) => table.name));
157
+ const plan = planReconciliation(driver, input.schema, input.tables, live, owned, tombstonedKeys);
158
+ if (plan.refusals.length > 0) {
159
+ // Never applied, never skipped: the release stops here.
160
+ throw new Error(`Schema '${input.schema}': ${plan.refusals.length} declared change(s) cannot be applied ` +
161
+ `safely to the data already present:\n${describeRefusals(plan.refusals)}`);
162
+ }
163
+ for (const phase of ["table", "index", "constraint"]) {
164
+ const statements = plan.statements.filter((s) => s.phase === phase);
165
+ if (statements.length === 0)
166
+ continue;
167
+ await driver.runAtomically(statements.map((s) => s.sql));
168
+ for (const statement of statements) {
169
+ ctx.log.info("Schema reconciled", { "sql.schema.object": statement.describes });
170
+ }
171
+ }
172
+ const afterApplied = await runMigrations(driver, ledger, input.migrations, applied, now);
173
+ for (const key of afterApplied)
174
+ applied.add(key);
175
+ const at = await driver.now();
176
+ const declaration = snapshotDeclaration(input.tables);
177
+ const digest = snapshotDigest(declaration);
178
+ // One group. The version row records the NEW declaration as owned, and the
179
+ // tombstones record what the old one had that this one does not — so a crash
180
+ // between them loses those objects for ever: the next boot's `owned` no longer
181
+ // mentions them, nothing tombstones them again, and they sit in the database
182
+ // untracked and undroppable. Committing them together is the same rule
183
+ // `runMigrations` follows for a migration and its ledger row.
184
+ const versionWrite = ledger.versionRecordStatements(declaredVersion, declaration, digest, at, await ledger.versionHistory());
185
+ const version = versionWrite.record;
186
+ await driver.runAtomically([
187
+ ...versionWrite.statements,
188
+ ...plan.tombstones.map((entry) => ledger.tombstoneRecordStatement(entry.id, entry.key, entry.definition, version, at)),
189
+ ]);
190
+ for (const entry of plan.tombstones) {
191
+ ctx.log.info("Schema object tombstoned", {
192
+ "sql.schema.object": describeObject(entry.id),
193
+ "sql.schema.version": version.version,
194
+ });
195
+ }
196
+ // A revival is idempotent on its own — the tombstone is simply gone — so it
197
+ // needs no place in the group above.
198
+ for (const key of plan.revived)
199
+ await ledger.clearTombstone(key);
200
+ // Dependents before the thing they hang off. Inherited from `ORDER BY
201
+ // object_key` this happened to be right — `c` < `f` < `i` < `t` — which is a
202
+ // property of the words, not of the design, and nothing said so or tested it.
203
+ const RECLAIM_ORDER = { foreignKey: 0, index: 1, column: 2, table: 3 };
204
+ const outstanding = (await ledger.tombstones())
205
+ .filter((t) => !plan.revived.includes(t.objectKey))
206
+ .sort((a, b) => (RECLAIM_ORDER[a.kind] ?? 9) - (RECLAIM_ORDER[b.kind] ?? 9));
207
+ const reclaimed = await reclaim(driver, ledger, ctx, input, outstanding, at);
208
+ return {
209
+ version: version.version,
210
+ digest,
211
+ sequence: version.sequence,
212
+ migrationsApplied: [...beforeApplied, ...afterApplied],
213
+ orphanedMigrations: orphanedKeys(applied, input.beforeMigrations, input.migrations),
214
+ tombstoned: plan.tombstones.map((t) => describeObject(t.id)),
215
+ revived: plan.revived.map((key) => describeObject(parseObjectKey(key))),
216
+ inertRenames: [...plan.inertRenames],
217
+ reclaimed: reclaimed.dropped,
218
+ pendingReclamation: reclaimed.pending,
219
+ };
220
+ }
221
+ /**
222
+ * Reclamation runs automatically, gated by the declared policy. The control is
223
+ * declaring the policy at all: with none, nothing is ever dropped and the
224
+ * ledger still reports what WOULD be eligible, so a schema can run indefinitely
225
+ * with reclamation declared nowhere and still show what it is holding.
226
+ */
227
+ async function reclaim(driver, ledger, ctx, input, tombstones, at) {
228
+ const history = await ledger.versionHistory();
229
+ const nowMs = Date.parse(at);
230
+ const dropped = [];
231
+ const pending = [];
232
+ for (const tombstone of tombstones) {
233
+ const id = parseObjectKey(tombstone.objectKey);
234
+ const described = describeObject(id);
235
+ if (!input.reclaim) {
236
+ pending.push({
237
+ object: described,
238
+ missingSinceVersion: tombstone.missingSinceVersion,
239
+ versionsRemaining: null,
240
+ msRemaining: null,
241
+ eligible: false,
242
+ });
243
+ continue;
244
+ }
245
+ // A tombstone this engine cannot act on is left standing and REPORTED with
246
+ // the reason. Attempting it would fail the boot, and since the tombstone
247
+ // stays eligible it would fail every boot after it too — the application
248
+ // would never start again over a schema object nobody is waiting on.
249
+ const support = driver.canReclaim(id);
250
+ if (!support.safe) {
251
+ pending.push({
252
+ object: described,
253
+ missingSinceVersion: tombstone.missingSinceVersion,
254
+ versionsRemaining: null,
255
+ msRemaining: null,
256
+ eligible: false,
257
+ unreclaimable: support.reason,
258
+ });
259
+ ctx.log.warn("Schema object cannot be reclaimed by this engine", {
260
+ "sql.schema.object": described,
261
+ });
262
+ continue;
263
+ }
264
+ const verdict = assessTombstone(tombstone, history, input.reclaim, nowMs);
265
+ if (!verdict.eligible) {
266
+ pending.push({
267
+ object: described,
268
+ missingSinceVersion: tombstone.missingSinceVersion,
269
+ versionsRemaining: verdict.versionsRemaining,
270
+ msRemaining: verdict.msRemaining,
271
+ eligible: false,
272
+ });
273
+ continue;
274
+ }
275
+ const statements = id.kind === "table"
276
+ ? driver.dropTable(input.schema, id.table)
277
+ : id.kind === "column"
278
+ ? driver.dropColumn(input.schema, id.table, id.name)
279
+ : id.kind === "index"
280
+ ? driver.dropIndex(input.schema, id.table, id.name)
281
+ : driver.dropForeignKey(input.schema, id.table, id.name);
282
+ // A drop can still fail for a reason `canReclaim` cannot see — a dependent
283
+ // view, a lock timeout, a constraint discovered at the moment it runs. That
284
+ // must not be why the application stops starting: the tombstone stays
285
+ // eligible, so an unguarded failure here would fail this boot and every boot
286
+ // after it. Reported through the channel that already exists for held
287
+ // objects, and left standing.
288
+ try {
289
+ await driver.runAtomically(statements);
290
+ }
291
+ catch (error) {
292
+ // The reason travels with the log, not only in observed state: this is on
293
+ // the boot path, and a warning that says an object could not be dropped
294
+ // without saying why sends the reader to a status field they may not be
295
+ // looking at.
296
+ ctx.log.warn("Schema object could not be reclaimed", {
297
+ "sql.schema.object": described,
298
+ "error.message": error instanceof Error ? error.message : String(error),
299
+ });
300
+ pending.push({
301
+ object: described,
302
+ missingSinceVersion: tombstone.missingSinceVersion,
303
+ versionsRemaining: 0,
304
+ msRemaining: 0,
305
+ eligible: true,
306
+ unreclaimable: error instanceof Error ? error.message : String(error),
307
+ });
308
+ continue;
309
+ }
310
+ await ledger.clearTombstone(tombstone.objectKey);
311
+ dropped.push(described);
312
+ ctx.log.info("Schema object reclaimed", {
313
+ "sql.schema.object": described,
314
+ "sql.schema.version": tombstone.missingSinceVersion,
315
+ });
316
+ }
317
+ return { dropped, pending };
318
+ }
@@ -20,6 +20,7 @@ export declare abstract class SqlConnectionBase implements SqlConnection {
20
20
  teardown(): Promise<void>;
21
21
  runInTransaction<T>(body: (bind: (entry: ZoneEntry) => void) => Promise<T>): Promise<T>;
22
22
  hasOpenTransaction(ctx?: InvokeContext): boolean;
23
+ bindsZone(zone: ZoneEntry): boolean;
23
24
  /**
24
25
  * Every statement this connection runs funnels through here — `executeTemplate`
25
26
  * and `executeScript` both delegate — so it is the single instrumentation point.
@@ -33,6 +34,26 @@ export declare abstract class SqlConnectionBase implements SqlConnection {
33
34
  * the hottest thing this module does.
34
35
  */
35
36
  execute<T>(sql: string, params?: unknown[], zone?: ZoneEntry, ctx?: InvokeContext): Promise<QueryResult<T>>;
37
+ /** The single instrumentation point, shared by every path that runs a
38
+ * statement. The disabled branch allocates nothing and takes no clock
39
+ * reading — a query is the hottest thing this module does. */
40
+ private instrument;
41
+ /**
42
+ * Run a statement on the CONNECTION, never on an ambient transaction.
43
+ *
44
+ * The complement of {@link resolveExecutor}, and it exists because "joins
45
+ * whatever transaction is open" is the right default and the wrong one for a
46
+ * particular class of write: a record ABOUT the work rather than part of it.
47
+ * A durable journal settling a run is the case that forced it — a settlement
48
+ * discarded by the caller's rollback leaves a run recorded as still executing
49
+ * while its effects are gone, and a claim that rolls back releases a run
50
+ * another poller may already hold.
51
+ *
52
+ * On the contract rather than left to each caller to reach for `kysely`,
53
+ * because the escape hatch is the same for everyone and a caller that reaches
54
+ * past `execute` also loses its instrumentation — this keeps both.
55
+ */
56
+ executeUncommitted<T>(sql: string, params?: unknown[]): Promise<QueryResult<T>>;
36
57
  executeTemplate<T>(fragments: string[], values: unknown[], zone?: ZoneEntry, ctx?: InvokeContext): Promise<QueryResult<T>>;
37
58
  /** Hand the whole script to the driver as one statement. Backends whose driver
38
59
  * needs a dedicated multi-statement entry point override this. */