@telorun/sql 0.21.3 → 0.22.1

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 (45) hide show
  1. package/dist/index.d.ts +18 -2
  2. package/dist/index.js +9 -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 +57 -0
  10. package/dist/schema/normalize-table.js +125 -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 +184 -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 +276 -0
  19. package/dist/schema/schema-run.d.ts +50 -0
  20. package/dist/schema/schema-run.js +318 -0
  21. package/dist/schema/table-reference.d.ts +25 -0
  22. package/dist/schema/table-reference.js +61 -0
  23. package/dist/sql-connection-base.d.ts +21 -0
  24. package/dist/sql-connection-base.js +30 -4
  25. package/dist/sql-connection.d.ts +19 -0
  26. package/package.json +5 -3
  27. package/src/index.ts +43 -2
  28. package/src/schema/declaration-snapshot.ts +71 -0
  29. package/src/schema/declared-schema.ts +73 -0
  30. package/src/schema/migration-runner.ts +69 -0
  31. package/src/schema/normalize-table.ts +224 -0
  32. package/src/schema/reclaim-policy.ts +73 -0
  33. package/src/schema/schema-driver.ts +207 -0
  34. package/src/schema/schema-ledger.ts +309 -0
  35. package/src/schema/schema-reconciler.ts +372 -0
  36. package/src/schema/schema-run.ts +441 -0
  37. package/src/schema/table-reference.ts +78 -0
  38. package/src/sql-connection-base.ts +35 -4
  39. package/src/sql-connection.ts +21 -0
  40. package/dist/sql-migration-controller.d.ts +0 -16
  41. package/dist/sql-migration-controller.js +0 -13
  42. package/dist/sql-migrations-controller.d.ts +0 -23
  43. package/dist/sql-migrations-controller.js +0 -98
  44. package/src/sql-migration-controller.ts +0 -20
  45. package/src/sql-migrations-controller.ts +0 -143
@@ -0,0 +1,309 @@
1
+ import type { SqlConnection } from "../sql-connection.js";
2
+ import type { SchemaDriver } from "./schema-driver.js";
3
+ import type { SchemaObjectId } from "./declared-schema.js";
4
+ import type { DeclarationSnapshot } from "./declaration-snapshot.js";
5
+
6
+ /**
7
+ * The ledger lives in the target schema, not in the repository: one manifest
8
+ * deploys to many databases and each has its own history, so the database is
9
+ * the authority. Three tables — what has been applied, what versions have been
10
+ * observed, and what is tombstoned awaiting reclamation.
11
+ *
12
+ * Which ledger a schema resource writes to is chosen by NAME (see
13
+ * {@link ledgerTables}), not recorded in a column, so two resources over one
14
+ * namespace keep separate histories by keeping separate tables.
15
+ *
16
+ * Timestamps are ISO-8601 UTC text so ordering is lexicographic on every
17
+ * engine and no driver has to agree about a timestamp type.
18
+ */
19
+
20
+ /**
21
+ * The reserved root for everything Telo keeps in someone else's database.
22
+ *
23
+ * `telo_` reserves the root and `schema` names the domain, so a later subsystem
24
+ * that needs SQL bookkeeping of its own (a durable journal, a lease table) sits
25
+ * beside this one under one convention rather than inventing a second. Fixed:
26
+ * moving it would mean a rename on every deployed database, which is why the
27
+ * per-set name is a SUFFIX the author chooses and this part is not.
28
+ */
29
+ const LEDGER_ROOT = "telo_schema";
30
+
31
+ /** The three tables one ledger is kept in. */
32
+ export interface LedgerTables {
33
+ readonly migrations: string;
34
+ readonly versions: string;
35
+ readonly tombstones: string;
36
+ }
37
+
38
+ /**
39
+ * Where a schema resource keeps its history.
40
+ *
41
+ * A namespace can be reached by more than one schema resource — two libraries in
42
+ * one application, or two applications over one database — and each needs its
43
+ * own history, because the ledger records the DECLARATION and a shared one would
44
+ * make each read the other's tables as removed. Separating them by TABLE NAME is
45
+ * how every tool in this space does it (`flyway.table`, `databaseChangeLogTableName`,
46
+ * Alembic's `version_table`, kysely's `migrationTableName`), and it has the
47
+ * property an owner column lacks: the identity is written down, so renaming the
48
+ * resource that declares it changes nothing.
49
+ */
50
+ export function ledgerTables(ledger?: string): LedgerTables {
51
+ const stem = ledger ? `${LEDGER_ROOT}_${ledger}` : LEDGER_ROOT;
52
+ return {
53
+ migrations: `${stem}_migrations`,
54
+ versions: `${stem}_versions`,
55
+ tombstones: `${stem}_tombstones`,
56
+ };
57
+ }
58
+
59
+ /** One observation of a deployed `(version, digest)` pair, with the declaration
60
+ * that produced it — the record of which objects this schema resource owns. */
61
+ export interface VersionRecord {
62
+ readonly sequence: number;
63
+ readonly version: string;
64
+ readonly digest: string;
65
+ readonly firstSeenAt: string;
66
+ readonly declaration: DeclarationSnapshot;
67
+ }
68
+
69
+ export interface TombstoneRecord {
70
+ readonly objectKey: string;
71
+ readonly kind: string;
72
+ readonly tableName: string;
73
+ readonly name: string | null;
74
+ /** The object's last-known definition, kept so reclamation can report what it is dropping. */
75
+ readonly definition: string;
76
+ readonly missingSinceVersion: string;
77
+ readonly missingSinceSequence: number;
78
+ readonly missingSinceAt: string;
79
+ }
80
+
81
+ function num(value: unknown): number {
82
+ return typeof value === "bigint" ? Number(value) : Number(value ?? 0);
83
+ }
84
+
85
+ function text(value: unknown): string {
86
+ return value == null ? "" : String(value);
87
+ }
88
+
89
+ /** A SQL string literal. Standard doubling of the quote, which every engine here reads. */
90
+ function sqlText(value: string): string {
91
+ return `'${value.replace(/'/g, "''")}'`;
92
+ }
93
+
94
+ function parseDeclaration(value: unknown): DeclarationSnapshot {
95
+ if (value == null || value === "") return {};
96
+ if (typeof value === "object") return value as DeclarationSnapshot;
97
+ return JSON.parse(String(value)) as DeclarationSnapshot;
98
+ }
99
+
100
+ export class SchemaLedger {
101
+ readonly #conn: SqlConnection;
102
+ readonly #driver: SchemaDriver;
103
+ readonly #schema: string;
104
+ readonly #tables: LedgerTables;
105
+
106
+ constructor(driver: SchemaDriver, schema: string, tables: LedgerTables) {
107
+ this.#driver = driver;
108
+ this.#conn = driver.connection;
109
+ this.#schema = schema;
110
+ this.#tables = tables;
111
+ }
112
+
113
+ #table(name: string): string {
114
+ return this.#driver.qualify(this.#schema, name);
115
+ }
116
+
117
+ async ensureTables(): Promise<void> {
118
+ for (const statement of this.#driver.ledgerStatements(this.#schema, this.#tables)) {
119
+ await this.#conn.execute(statement);
120
+ }
121
+ }
122
+
123
+ // --- applied migrations -------------------------------------------------
124
+
125
+ async appliedMigrationKeys(): Promise<Set<string>> {
126
+ const result = await this.#conn.execute<{ key: string }>(
127
+ `SELECT key FROM ${this.#table(this.#tables.migrations)}`,
128
+ );
129
+ return new Set(result.rows.map((row) => text(row.key)));
130
+ }
131
+
132
+ /**
133
+ * The ledger row for an applied migration, as a STATEMENT rather than an
134
+ * executed write — so it can join the migration's own atomic group.
135
+ *
136
+ * Recording it separately would leave a window in which the migration has run
137
+ * and the ledger does not know: a crash there re-runs a migration that may not
138
+ * be idempotent. Literals rather than bound parameters because this statement
139
+ * is grouped with DDL the driver executes as one unit; both values are
140
+ * escaped, and the key is already constrained to `[A-Za-z0-9_.-]`.
141
+ */
142
+ migrationRecordStatement(key: string, at: string): string {
143
+ return (
144
+ `INSERT INTO ${this.#table(this.#tables.migrations)} (key, applied_at) ` +
145
+ `VALUES (${sqlText(key)}, ${sqlText(at)})`
146
+ );
147
+ }
148
+
149
+ // --- observed version sequence -----------------------------------------
150
+
151
+ async versionHistory(): Promise<VersionRecord[]> {
152
+ const result = await this.#conn.execute<Record<string, unknown>>(
153
+ `SELECT sequence, version, digest, first_seen_at, declaration FROM ` +
154
+ `${this.#table(this.#tables.versions)} ORDER BY sequence ASC`,
155
+ );
156
+ return result.rows.map((row) => ({
157
+ sequence: num(row.sequence),
158
+ version: text(row.version),
159
+ digest: text(row.digest),
160
+ firstSeenAt: text(row.first_seen_at),
161
+ declaration: parseDeclaration(row.declaration),
162
+ }));
163
+ }
164
+
165
+ /**
166
+ * Record this boot's `(version, digest)`.
167
+ *
168
+ * The *sequence* is recorded rather than a counter, so going backwards is
169
+ * visible: an older version booting proves older code is live, which is
170
+ * exactly the condition a grace window exists to survive. A boot at the
171
+ * version already at the head updates its digest in place and advances
172
+ * nothing — so forgetting to bump costs grace progress rather than causing
173
+ * harm, and local iteration accrues no budget.
174
+ */
175
+ /**
176
+ * A schema with no `reclaim:` policy declares no version, so its rows carry an
177
+ * empty label: the head is then always "the same version", updated in place,
178
+ * and the sequence never advances. That is the honest degenerate case — there
179
+ * is no clock because nothing is counting — and the declaration and digest,
180
+ * which are what ownership rests on, are recorded either way.
181
+ */
182
+ /**
183
+ * The version row as a STATEMENT, for the group that also carries its
184
+ * tombstones, plus the row it will write so the caller can stamp with it.
185
+ *
186
+ * The *sequence* is recorded rather than a counter, so going backwards is
187
+ * visible: an older version booting proves older code is live, which is
188
+ * exactly the condition a grace window exists to survive. A boot at the
189
+ * version already at the head updates its digest in place and advances
190
+ * nothing — so forgetting to bump costs grace progress rather than causing
191
+ * harm, and local iteration accrues no budget.
192
+ *
193
+ * A schema with no `reclaim:` policy declares no version, so its rows carry an
194
+ * empty label: the head is then always "the same version", updated in place,
195
+ * and the sequence never advances. That is the honest degenerate case — there
196
+ * is no clock because nothing is counting — and the declaration and digest,
197
+ * which are what ownership rests on, are recorded either way.
198
+ */
199
+ versionRecordStatements(
200
+ version: string,
201
+ declaration: DeclarationSnapshot,
202
+ digest: string,
203
+ at: string,
204
+ history: readonly VersionRecord[],
205
+ ): { record: VersionRecord; statements: string[] } {
206
+ const head = history[history.length - 1];
207
+ const encoded = JSON.stringify(declaration);
208
+ if (head && head.version === version) {
209
+ if (head.digest === digest) return { record: head, statements: [] };
210
+ return {
211
+ record: { ...head, digest, declaration },
212
+ statements: [
213
+ `UPDATE ${this.#table(this.#tables.versions)} SET digest = ${sqlText(digest)}, ` +
214
+ `declaration = ${sqlText(encoded)} WHERE sequence = ${head.sequence}`,
215
+ ],
216
+ };
217
+ }
218
+ const record: VersionRecord = {
219
+ sequence: (head?.sequence ?? 0) + 1,
220
+ version,
221
+ digest,
222
+ firstSeenAt: at,
223
+ declaration,
224
+ };
225
+ return {
226
+ record,
227
+ statements: [
228
+ `INSERT INTO ${this.#table(this.#tables.versions)} ` +
229
+ `(sequence, version, digest, first_seen_at, declaration) VALUES (` +
230
+ `${record.sequence}, ${sqlText(version)}, ${sqlText(digest)}, ` +
231
+ `${sqlText(at)}, ${sqlText(encoded)})`,
232
+ ],
233
+ };
234
+ }
235
+
236
+ // --- tombstones ---------------------------------------------------------
237
+
238
+ async tombstones(): Promise<TombstoneRecord[]> {
239
+ const result = await this.#conn.execute<Record<string, unknown>>(
240
+ `SELECT object_key, kind, table_name, name, definition, missing_since_version, ` +
241
+ `missing_since_sequence, missing_since_at FROM ${this.#table(this.#tables.tombstones)} ` +
242
+ `ORDER BY object_key ASC`,
243
+ );
244
+ return result.rows.map((row) => ({
245
+ objectKey: text(row.object_key),
246
+ kind: text(row.kind),
247
+ tableName: text(row.table_name),
248
+ name: row.name == null ? null : text(row.name),
249
+ definition: text(row.definition),
250
+ missingSinceVersion: text(row.missing_since_version),
251
+ missingSinceSequence: num(row.missing_since_sequence),
252
+ missingSinceAt: text(row.missing_since_at),
253
+ }));
254
+ }
255
+
256
+ /**
257
+ * The row for a tombstone, as a STATEMENT — so it can be committed with the
258
+ * version row that stamps it.
259
+ *
260
+ * Recording them separately leaves a window in which the new declaration is
261
+ * `owned` and the removals are not yet tombstoned. A crash there loses them
262
+ * for good: the next boot's `owned` no longer mentions them, so nothing is
263
+ * ever tombstoned for those objects again and they sit in the database
264
+ * untracked, invisible to `pendingReclamation` and undroppable without
265
+ * hand-written SQL. The whole ownership model rests on that snapshot being the
266
+ * PREVIOUS declaration.
267
+ *
268
+ * `ON CONFLICT DO NOTHING` because two passes can race where the engine has no
269
+ * cross-process lock (SQLite): recording the same tombstone twice is a
270
+ * convergent no-op, and a primary-key error there would be a failure over
271
+ * agreement.
272
+ */
273
+ tombstoneRecordStatement(
274
+ id: SchemaObjectId,
275
+ objectKey: string,
276
+ definition: string,
277
+ version: VersionRecord,
278
+ at: string,
279
+ ): string {
280
+ const values = [
281
+ objectKey,
282
+ id.kind,
283
+ id.table,
284
+ id.name,
285
+ definition,
286
+ version.version,
287
+ String(version.sequence),
288
+ at,
289
+ ];
290
+ const encoded = values
291
+ .map((value, index) =>
292
+ value == null ? "NULL" : index === 6 ? String(value) : sqlText(String(value)),
293
+ )
294
+ .join(", ");
295
+ return (
296
+ `INSERT INTO ${this.#table(this.#tables.tombstones)} (object_key, kind, table_name, ` +
297
+ `name, definition, missing_since_version, missing_since_sequence, missing_since_at) ` +
298
+ `VALUES (${encoded}) ON CONFLICT DO NOTHING`
299
+ );
300
+ }
301
+
302
+ /** A tombstoned object that came back — the declaration is authoritative again. */
303
+ async clearTombstone(objectKey: string): Promise<void> {
304
+ await this.#conn.executeTemplate(
305
+ [`DELETE FROM ${this.#table(this.#tables.tombstones)} WHERE object_key = `, ``],
306
+ [objectKey],
307
+ );
308
+ }
309
+ }
@@ -0,0 +1,372 @@
1
+ import type {
2
+ DeclaredColumn,
3
+ DeclaredForeignKey,
4
+ DeclaredIndex,
5
+ DeclaredTable,
6
+ SchemaObjectId,
7
+ } from "./declared-schema.js";
8
+ import { objectKey } from "./declared-schema.js";
9
+ import type { DeclarationSnapshot } from "./declaration-snapshot.js";
10
+ import { parseObjectKey } from "./declaration-snapshot.js";
11
+ import type {
12
+ LiveColumn,
13
+ LiveForeignKey,
14
+ LiveIndex,
15
+ LiveTable,
16
+ SchemaDriver,
17
+ } from "./schema-driver.js";
18
+
19
+ /**
20
+ * The diff. One global pass over the declaration: the manifest holds only
21
+ * current declared state, so there is exactly one reconciliation target and no
22
+ * historical state to replay towards.
23
+ *
24
+ * Nothing here executes. A plan is built, refusals are collected, and the
25
+ * runner decides — which is what lets an unsafe change stop the release before
26
+ * any DDL has run rather than half way through it.
27
+ */
28
+
29
+ /** DDL ordering. Cross-table constraints come last so declaration order is
30
+ * never load-bearing and foreign-key ordering is never the author's problem. */
31
+ export type PlanPhase = "table" | "index" | "constraint";
32
+
33
+ export interface PlannedStatement {
34
+ readonly phase: PlanPhase;
35
+ readonly sql: string;
36
+ readonly describes: string;
37
+ }
38
+
39
+ export interface PlannedTombstone {
40
+ readonly id: SchemaObjectId;
41
+ readonly key: string;
42
+ readonly definition: string;
43
+ }
44
+
45
+ export interface Refusal {
46
+ readonly object: string;
47
+ readonly reason: string;
48
+ }
49
+
50
+ export interface SchemaPlan {
51
+ readonly statements: readonly PlannedStatement[];
52
+ readonly tombstones: readonly PlannedTombstone[];
53
+ /** Tombstoned objects the declaration has brought back. */
54
+ readonly revived: readonly string[];
55
+ /**
56
+ * `renamedFrom:` mentions that can no longer do anything — the source column
57
+ * is neither present nor tombstoned, so there is nothing left to copy and
58
+ * nothing left to hold. The rename is finished; the mention is now dead
59
+ * manifest text, and saying so is the only way the author learns it can go.
60
+ */
61
+ readonly inertRenames: readonly string[];
62
+ readonly refusals: readonly Refusal[];
63
+ }
64
+
65
+ /**
66
+ * Whether a live column still matches its declaration. Comparison is over the
67
+ * driver's own canonical type signature, nullability and the presence of a
68
+ * default — every type rule stays inside the driver, and nothing here parses a
69
+ * type.
70
+ */
71
+ function columnDiffers(driver: SchemaDriver, live: LiveColumn, declared: DeclaredColumn): boolean {
72
+ const declaresDefault =
73
+ declared.default !== undefined || declared.defaultExpression !== undefined;
74
+ return (
75
+ live.typeSignature !== driver.typeSignature(declared) ||
76
+ live.nullable !== declared.nullable ||
77
+ live.hasDefault !== declaresDefault ||
78
+ // Uniqueness and key membership are what the declaration PROMISES about the
79
+ // data. Compared by nothing at all, adding `unique: true` to a live column
80
+ // emitted no DDL and no report while the ledger recorded it as owned — so
81
+ // the manifest asserted a constraint the database was not enforcing.
82
+ live.primaryKey !== declared.primaryKey ||
83
+ live.unique !== declared.unique
84
+ );
85
+ }
86
+
87
+ /** Column order is part of an index: `(a, b)` and `(b, a)` are different indexes. */
88
+ function indexDiffers(live: LiveIndex, declared: DeclaredIndex): boolean {
89
+ return (
90
+ live.unique !== declared.unique ||
91
+ live.columns.length !== declared.columns.length ||
92
+ live.columns.some((column, i) => column !== declared.columns[i])
93
+ );
94
+ }
95
+
96
+ /** A referential action is what the constraint DOES, so a change to it is a
97
+ * change to the constraint. An action the engine did not report is not compared
98
+ * — an absent reading is not evidence of a difference. */
99
+ /**
100
+ * The columns a key maps to what, which is what makes it THAT key rather than
101
+ * another. Its referential actions are settable properties of it, deliberately
102
+ * excluded: an engine that keeps no name matches on this, and folding the
103
+ * actions in would make a changed delete rule read as a brand new key — an ADD
104
+ * where the author should have been told the rule cannot be changed in place.
105
+ */
106
+ function sameForeignKeyIdentity(live: LiveForeignKey, declared: DeclaredForeignKey): boolean {
107
+ return (
108
+ live.references.table === declared.references.table &&
109
+ live.columns.length === declared.columns.length &&
110
+ live.columns.every((column, i) => column === declared.columns[i]) &&
111
+ live.references.columns.length === declared.references.columns.length &&
112
+ live.references.columns.every((column, i) => column === declared.references.columns[i])
113
+ );
114
+ }
115
+
116
+ function foreignKeyDiffers(live: LiveForeignKey, declared: DeclaredForeignKey): boolean {
117
+ const action = (value: string | undefined): string | undefined => value?.toUpperCase();
118
+ return (
119
+ live.references.table !== declared.references.table ||
120
+ live.columns.length !== declared.columns.length ||
121
+ live.columns.some((column, i) => column !== declared.columns[i]) ||
122
+ live.references.columns.length !== declared.references.columns.length ||
123
+ live.references.columns.some((column, i) => column !== declared.references.columns[i]) ||
124
+ (live.onDelete !== undefined && action(live.onDelete) !== (action(declared.onDelete) ?? "NO ACTION")) ||
125
+ (live.onUpdate !== undefined && action(live.onUpdate) !== (action(declared.onUpdate) ?? "NO ACTION"))
126
+ );
127
+ }
128
+
129
+ function liveByName(live: readonly LiveTable[]): Map<string, LiveTable> {
130
+ return new Map(live.map((table) => [table.name, table]));
131
+ }
132
+
133
+ export function planReconciliation(
134
+ driver: SchemaDriver,
135
+ schema: string,
136
+ declared: readonly DeclaredTable[],
137
+ live: readonly LiveTable[],
138
+ owned: DeclarationSnapshot,
139
+ tombstoned: ReadonlySet<string>,
140
+ ): SchemaPlan {
141
+ const statements: PlannedStatement[] = [];
142
+ const tombstones: PlannedTombstone[] = [];
143
+ const revived: string[] = [];
144
+ const inertRenames: string[] = [];
145
+ const refusals: Refusal[] = [];
146
+ const liveTables = liveByName(live);
147
+ const declaredKeys = new Set<string>();
148
+
149
+ const emit = (phase: PlanPhase, describes: string, sql: readonly string[]): void => {
150
+ for (const one of sql) statements.push({ phase, sql: one, describes });
151
+ };
152
+ // NOT named `declare`: `declare` is a TypeScript modifier keyword, and a
153
+ // statement that begins with it is parsed as an ambient declaration and
154
+ // STRIPPED by a type-stripping transpiler — so `declare({ … });` at statement
155
+ // position vanished while `const k = declare(…)` survived, and the pass
156
+ // tombstoned every object it had just declared. Silent under Node, silent at
157
+ // `tsc`, and destructive only on the runtime that strips types.
158
+ const markDeclared = (id: SchemaObjectId): string => {
159
+ const key = objectKey(id);
160
+ declaredKeys.add(key);
161
+ if (tombstoned.has(key)) revived.push(key);
162
+ return key;
163
+ };
164
+
165
+ for (const table of declared) {
166
+ markDeclared({ kind: "table", table: table.name });
167
+ for (const column of table.columns) {
168
+ markDeclared({ kind: "column", table: table.name, name: column.name });
169
+ }
170
+ const liveTable = liveTables.get(table.name);
171
+
172
+ if (!liveTable) {
173
+ emit("table", `table ${table.name}`, driver.createTable(schema, table));
174
+ } else {
175
+ const liveColumns = new Map(liveTable.columns.map((c) => [c.name, c]));
176
+ for (const column of table.columns) {
177
+ const existing = liveColumns.get(column.name);
178
+ if (!existing) {
179
+ const renamedFrom = column.renamedFrom;
180
+ const source = renamedFrom ? liveColumns.get(renamedFrom) : undefined;
181
+ // Classified BEFORE anything is emitted, so a refused rename
182
+ // contributes no statements at all. The runner refuses to execute a
183
+ // plan carrying refusals, but a plan that is half a rename is still
184
+ // the wrong thing to hand anyone.
185
+ if (source && renamedFrom) {
186
+ // A rename that changes the type is two changes wearing one name.
187
+ // Unchecked, the copy is a raw driver error on an engine that
188
+ // refuses the assignment, and silently stores the old
189
+ // representation on one that does not.
190
+ const safety = driver.classifyCopy(source, column);
191
+ if (!safety.safe) {
192
+ refusals.push({
193
+ object: `${table.name}.${column.name}`,
194
+ reason: `renamedFrom '${renamedFrom}': ${safety.reason}`,
195
+ });
196
+ continue;
197
+ }
198
+ }
199
+ emit(
200
+ "table",
201
+ `column ${table.name}.${column.name}`,
202
+ driver.addColumn(schema, table.name, column),
203
+ );
204
+ // Expand-contract: the source column is copied, then tombstoned. A
205
+ // native RENAME would take effect immediately and break the older
206
+ // version still running — the one operation that would be exempt from
207
+ // the deferral this design exists for.
208
+ if (source && renamedFrom) {
209
+ emit(
210
+ "table",
211
+ `copy ${table.name}.${renamedFrom} → ${column.name}`,
212
+ driver.copyColumn(schema, table.name, renamedFrom, column.name),
213
+ );
214
+ }
215
+ continue;
216
+ }
217
+ if (!columnDiffers(driver, existing, column)) continue;
218
+ // Classification happens here, against live state, because the
219
+ // declaration is the only artifact: there is no historical declared
220
+ // state to diff against, so whether a change is safe depends on what is
221
+ // in the column right now.
222
+ const safety = driver.classifyAlter(existing, column);
223
+ if (!safety.safe) {
224
+ refusals.push({ object: `${table.name}.${column.name}`, reason: safety.reason });
225
+ continue;
226
+ }
227
+ emit(
228
+ "table",
229
+ `column ${table.name}.${column.name}`,
230
+ driver.alterColumn(schema, table.name, existing, column),
231
+ );
232
+ }
233
+ }
234
+
235
+ // A rename is inert when its source is gone for good: not present, and not
236
+ // held by a tombstone. Only asked of a table that ALREADY existed — on one
237
+ // this pass creates there was never anything to copy, so every rename would
238
+ // look finished when in fact it has not run anywhere yet, and the same
239
+ // manifest still deploys to databases that do need it.
240
+ if (liveTable) {
241
+ const liveColumnNames = new Set(liveTable.columns.map((c) => c.name));
242
+ for (const column of table.columns) {
243
+ if (!column.renamedFrom) continue;
244
+ const sourceKey = objectKey({
245
+ kind: "column",
246
+ table: table.name,
247
+ name: column.renamedFrom,
248
+ });
249
+ if (liveColumnNames.has(column.renamedFrom) || tombstoned.has(sourceKey)) continue;
250
+ inertRenames.push(
251
+ `column ${table.name}.${column.name} (renamedFrom ${column.renamedFrom})`,
252
+ );
253
+ }
254
+ }
255
+
256
+ const liveIndexes = new Map((liveTable?.indexes ?? []).map((index) => [index.name, index]));
257
+ for (const index of table.indexes) {
258
+ markDeclared({ kind: "index", table: table.name, name: index.name });
259
+ const existing = liveIndexes.get(index.name);
260
+ if (!existing) {
261
+ emit("index", `index ${index.name}`, driver.createIndex(schema, table.name, index));
262
+ continue;
263
+ }
264
+ // An index that exists under the right name may still cover the wrong
265
+ // columns, or have stopped being unique. Silence there is the declaration
266
+ // asserting something the database is not doing.
267
+ if (!indexDiffers(existing, index)) continue;
268
+ const safety = driver.classifyIndexChange(existing, index);
269
+ if (!safety.safe) {
270
+ refusals.push({ object: `${table.name}.${index.name}`, reason: safety.reason });
271
+ continue;
272
+ }
273
+ emit("index", `index ${index.name}`, [
274
+ ...driver.dropIndex(schema, table.name, index.name),
275
+ ...driver.createIndex(schema, table.name, index),
276
+ ]);
277
+ }
278
+
279
+ // A table this pass just created already carries its keys where the engine
280
+ // can only emit them there. They are still MARKED declared, or the next boot
281
+ // would read every one of them as removed and tombstone it.
282
+ const carriedByCreate = !liveTable && driver.foreignKeysInCreateTable;
283
+ // Where the engine keeps no name, a declaration is matched to a live key by
284
+ // its structure. Matching by name regardless is what made such a table
285
+ // unrestartable: every later boot read its own key as missing and refused to
286
+ // add what the engine cannot add. Matches are CONSUMED, so two keys that are
287
+ // structurally identical pair up one for one instead of both claiming the
288
+ // first.
289
+ const unmatched = [...(liveTable?.foreignKeys ?? [])];
290
+ const liveForeignKeys = new Map(unmatched.map((fk) => [fk.name, fk]));
291
+ const takeStructural = (fk: DeclaredForeignKey): LiveForeignKey | undefined => {
292
+ const at = unmatched.findIndex((live) => sameForeignKeyIdentity(live, fk));
293
+ return at < 0 ? undefined : unmatched.splice(at, 1)[0];
294
+ };
295
+ for (const fk of table.foreignKeys) {
296
+ markDeclared({ kind: "foreignKey", table: table.name, name: fk.name });
297
+ if (carriedByCreate) continue;
298
+ const existing = driver.namesForeignKeys
299
+ ? liveForeignKeys.get(fk.name)
300
+ : takeStructural(fk);
301
+ if (!existing) {
302
+ emit("constraint", `foreign key ${fk.name}`, driver.addForeignKey(schema, table.name, fk));
303
+ continue;
304
+ }
305
+ if (!foreignKeyDiffers(existing, fk)) continue;
306
+ const safety = driver.classifyForeignKeyChange(existing, fk);
307
+ if (!safety.safe) {
308
+ refusals.push({ object: `${table.name}.${fk.name}`, reason: safety.reason });
309
+ continue;
310
+ }
311
+ emit("constraint", `foreign key ${fk.name}`, [
312
+ ...driver.dropForeignKey(schema, table.name, fk.name),
313
+ ...driver.addForeignKey(schema, table.name, fk),
314
+ ]);
315
+ }
316
+ }
317
+
318
+ // Removal never emits DDL. An object this resource once declared and no longer
319
+ // does is tombstoned; the drop is deferred to reclamation, which is the whole
320
+ // point. An object it has NEVER declared is not ours and is not considered.
321
+ const tombstoneKeys = new Set<string>();
322
+ const tombstone = (id: SchemaObjectId, key: string, definition: string): void => {
323
+ if (tombstoneKeys.has(key)) return;
324
+ tombstoneKeys.add(key);
325
+ tombstones.push({ id, key, definition });
326
+ };
327
+ // A table that is going away takes its columns, indexes and constraints with
328
+ // it, so only the TABLE is tombstoned. Recording the children too would plan a
329
+ // drop for each — and they are dropped first, since reclamation walks
330
+ // dependents before their table — so an engine that refuses to drop a primary
331
+ // key or an indexed column (SQLite refuses both) would fail the pass, and go
332
+ // on failing it, over objects the DROP TABLE was about to remove anyway.
333
+ const retiredTables = new Set(
334
+ Object.keys(owned)
335
+ .filter((key) => key.startsWith("table:"))
336
+ .map((key) => parseObjectKey(key).table)
337
+ .filter((table) => !declaredKeys.has(objectKey({ kind: "table", table }))),
338
+ );
339
+ for (const [key, definition] of Object.entries(owned)) {
340
+ if (declaredKeys.has(key) || tombstoned.has(key)) continue;
341
+ const id = parseObjectKey(key);
342
+ if (id.kind !== "table" && retiredTables.has(id.table)) continue;
343
+ tombstone(id, key, definition);
344
+ }
345
+
346
+ // A renamed-away source column is tombstoned even while the declaration still
347
+ // names it through `renamedFrom`, so its budget starts at the rename rather
348
+ // than at whichever later release deletes the mention.
349
+ //
350
+ // Only a source that is actually THERE. Once a rename's source has been
351
+ // reclaimed the mention is inert, and tombstoning it again would put a column
352
+ // that no longer exists back on the books and eventually emit a DROP for it.
353
+ for (const table of declared) {
354
+ const liveColumnNames = new Set(
355
+ (liveTables.get(table.name)?.columns ?? []).map((c) => c.name),
356
+ );
357
+ for (const column of table.columns) {
358
+ if (!column.renamedFrom) continue;
359
+ if (!liveColumnNames.has(column.renamedFrom)) continue;
360
+ const id: SchemaObjectId = { kind: "column", table: table.name, name: column.renamedFrom };
361
+ const key = objectKey(id);
362
+ if (tombstoned.has(key) || declaredKeys.has(key)) continue;
363
+ tombstone(id, key, owned[key] ?? JSON.stringify({ name: column.renamedFrom }));
364
+ }
365
+ }
366
+
367
+ return { statements, tombstones, revived, inertRenames, refusals };
368
+ }
369
+
370
+ export function describeRefusals(refusals: readonly Refusal[]): string {
371
+ return refusals.map((r) => ` ${r.object}: ${r.reason}`).join("\n");
372
+ }