@telorun/sql 0.21.2 → 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,339 @@
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
+ function foreignKeyDiffers(live: LiveForeignKey, declared: DeclaredForeignKey): boolean {
100
+ const action = (value: string | undefined): string | undefined => value?.toUpperCase();
101
+ return (
102
+ live.references.table !== declared.references.table ||
103
+ live.columns.length !== declared.columns.length ||
104
+ live.columns.some((column, i) => column !== declared.columns[i]) ||
105
+ live.references.columns.length !== declared.references.columns.length ||
106
+ live.references.columns.some((column, i) => column !== declared.references.columns[i]) ||
107
+ (live.onDelete !== undefined && action(live.onDelete) !== (action(declared.onDelete) ?? "NO ACTION")) ||
108
+ (live.onUpdate !== undefined && action(live.onUpdate) !== (action(declared.onUpdate) ?? "NO ACTION"))
109
+ );
110
+ }
111
+
112
+ function liveByName(live: readonly LiveTable[]): Map<string, LiveTable> {
113
+ return new Map(live.map((table) => [table.name, table]));
114
+ }
115
+
116
+ export function planReconciliation(
117
+ driver: SchemaDriver,
118
+ schema: string,
119
+ declared: readonly DeclaredTable[],
120
+ live: readonly LiveTable[],
121
+ owned: DeclarationSnapshot,
122
+ tombstoned: ReadonlySet<string>,
123
+ ): SchemaPlan {
124
+ const statements: PlannedStatement[] = [];
125
+ const tombstones: PlannedTombstone[] = [];
126
+ const revived: string[] = [];
127
+ const inertRenames: string[] = [];
128
+ const refusals: Refusal[] = [];
129
+ const liveTables = liveByName(live);
130
+ const declaredKeys = new Set<string>();
131
+
132
+ const emit = (phase: PlanPhase, describes: string, sql: readonly string[]): void => {
133
+ for (const one of sql) statements.push({ phase, sql: one, describes });
134
+ };
135
+ // NOT named `declare`: `declare` is a TypeScript modifier keyword, and a
136
+ // statement that begins with it is parsed as an ambient declaration and
137
+ // STRIPPED by a type-stripping transpiler — so `declare({ … });` at statement
138
+ // position vanished while `const k = declare(…)` survived, and the pass
139
+ // tombstoned every object it had just declared. Silent under Node, silent at
140
+ // `tsc`, and destructive only on the runtime that strips types.
141
+ const markDeclared = (id: SchemaObjectId): string => {
142
+ const key = objectKey(id);
143
+ declaredKeys.add(key);
144
+ if (tombstoned.has(key)) revived.push(key);
145
+ return key;
146
+ };
147
+
148
+ for (const table of declared) {
149
+ markDeclared({ kind: "table", table: table.name });
150
+ for (const column of table.columns) {
151
+ markDeclared({ kind: "column", table: table.name, name: column.name });
152
+ }
153
+ const liveTable = liveTables.get(table.name);
154
+
155
+ if (!liveTable) {
156
+ emit("table", `table ${table.name}`, driver.createTable(schema, table));
157
+ } else {
158
+ const liveColumns = new Map(liveTable.columns.map((c) => [c.name, c]));
159
+ for (const column of table.columns) {
160
+ const existing = liveColumns.get(column.name);
161
+ if (!existing) {
162
+ const renamedFrom = column.renamedFrom;
163
+ const source = renamedFrom ? liveColumns.get(renamedFrom) : undefined;
164
+ // Classified BEFORE anything is emitted, so a refused rename
165
+ // contributes no statements at all. The runner refuses to execute a
166
+ // plan carrying refusals, but a plan that is half a rename is still
167
+ // the wrong thing to hand anyone.
168
+ if (source && renamedFrom) {
169
+ // A rename that changes the type is two changes wearing one name.
170
+ // Unchecked, the copy is a raw driver error on an engine that
171
+ // refuses the assignment, and silently stores the old
172
+ // representation on one that does not.
173
+ const safety = driver.classifyCopy(source, column);
174
+ if (!safety.safe) {
175
+ refusals.push({
176
+ object: `${table.name}.${column.name}`,
177
+ reason: `renamedFrom '${renamedFrom}': ${safety.reason}`,
178
+ });
179
+ continue;
180
+ }
181
+ }
182
+ emit(
183
+ "table",
184
+ `column ${table.name}.${column.name}`,
185
+ driver.addColumn(schema, table.name, column),
186
+ );
187
+ // Expand-contract: the source column is copied, then tombstoned. A
188
+ // native RENAME would take effect immediately and break the older
189
+ // version still running — the one operation that would be exempt from
190
+ // the deferral this design exists for.
191
+ if (source && renamedFrom) {
192
+ emit(
193
+ "table",
194
+ `copy ${table.name}.${renamedFrom} → ${column.name}`,
195
+ driver.copyColumn(schema, table.name, renamedFrom, column.name),
196
+ );
197
+ }
198
+ continue;
199
+ }
200
+ if (!columnDiffers(driver, existing, column)) continue;
201
+ // Classification happens here, against live state, because the
202
+ // declaration is the only artifact: there is no historical declared
203
+ // state to diff against, so whether a change is safe depends on what is
204
+ // in the column right now.
205
+ const safety = driver.classifyAlter(existing, column);
206
+ if (!safety.safe) {
207
+ refusals.push({ object: `${table.name}.${column.name}`, reason: safety.reason });
208
+ continue;
209
+ }
210
+ emit(
211
+ "table",
212
+ `column ${table.name}.${column.name}`,
213
+ driver.alterColumn(schema, table.name, existing, column),
214
+ );
215
+ }
216
+ }
217
+
218
+ // A rename is inert when its source is gone for good: not present, and not
219
+ // held by a tombstone. Only asked of a table that ALREADY existed — on one
220
+ // this pass creates there was never anything to copy, so every rename would
221
+ // look finished when in fact it has not run anywhere yet, and the same
222
+ // manifest still deploys to databases that do need it.
223
+ if (liveTable) {
224
+ const liveColumnNames = new Set(liveTable.columns.map((c) => c.name));
225
+ for (const column of table.columns) {
226
+ if (!column.renamedFrom) continue;
227
+ const sourceKey = objectKey({
228
+ kind: "column",
229
+ table: table.name,
230
+ name: column.renamedFrom,
231
+ });
232
+ if (liveColumnNames.has(column.renamedFrom) || tombstoned.has(sourceKey)) continue;
233
+ inertRenames.push(
234
+ `column ${table.name}.${column.name} (renamedFrom ${column.renamedFrom})`,
235
+ );
236
+ }
237
+ }
238
+
239
+ const liveIndexes = new Map((liveTable?.indexes ?? []).map((index) => [index.name, index]));
240
+ for (const index of table.indexes) {
241
+ markDeclared({ kind: "index", table: table.name, name: index.name });
242
+ const existing = liveIndexes.get(index.name);
243
+ if (!existing) {
244
+ emit("index", `index ${index.name}`, driver.createIndex(schema, table.name, index));
245
+ continue;
246
+ }
247
+ // An index that exists under the right name may still cover the wrong
248
+ // columns, or have stopped being unique. Silence there is the declaration
249
+ // asserting something the database is not doing.
250
+ if (!indexDiffers(existing, index)) continue;
251
+ const safety = driver.classifyIndexChange(existing, index);
252
+ if (!safety.safe) {
253
+ refusals.push({ object: `${table.name}.${index.name}`, reason: safety.reason });
254
+ continue;
255
+ }
256
+ emit("index", `index ${index.name}`, [
257
+ ...driver.dropIndex(schema, table.name, index.name),
258
+ ...driver.createIndex(schema, table.name, index),
259
+ ]);
260
+ }
261
+
262
+ const liveForeignKeys = new Map(
263
+ (liveTable?.foreignKeys ?? []).map((fk) => [fk.name, fk]),
264
+ );
265
+ for (const fk of table.foreignKeys) {
266
+ markDeclared({ kind: "foreignKey", table: table.name, name: fk.name });
267
+ const existing = liveForeignKeys.get(fk.name);
268
+ if (!existing) {
269
+ emit("constraint", `foreign key ${fk.name}`, driver.addForeignKey(schema, table.name, fk));
270
+ continue;
271
+ }
272
+ if (!foreignKeyDiffers(existing, fk)) continue;
273
+ const safety = driver.classifyForeignKeyChange(existing, fk);
274
+ if (!safety.safe) {
275
+ refusals.push({ object: `${table.name}.${fk.name}`, reason: safety.reason });
276
+ continue;
277
+ }
278
+ emit("constraint", `foreign key ${fk.name}`, [
279
+ ...driver.dropForeignKey(schema, table.name, fk.name),
280
+ ...driver.addForeignKey(schema, table.name, fk),
281
+ ]);
282
+ }
283
+ }
284
+
285
+ // Removal never emits DDL. An object this resource once declared and no longer
286
+ // does is tombstoned; the drop is deferred to reclamation, which is the whole
287
+ // point. An object it has NEVER declared is not ours and is not considered.
288
+ const tombstoneKeys = new Set<string>();
289
+ const tombstone = (id: SchemaObjectId, key: string, definition: string): void => {
290
+ if (tombstoneKeys.has(key)) return;
291
+ tombstoneKeys.add(key);
292
+ tombstones.push({ id, key, definition });
293
+ };
294
+ // A table that is going away takes its columns, indexes and constraints with
295
+ // it, so only the TABLE is tombstoned. Recording the children too would plan a
296
+ // drop for each — and they are dropped first, since reclamation walks
297
+ // dependents before their table — so an engine that refuses to drop a primary
298
+ // key or an indexed column (SQLite refuses both) would fail the pass, and go
299
+ // on failing it, over objects the DROP TABLE was about to remove anyway.
300
+ const retiredTables = new Set(
301
+ Object.keys(owned)
302
+ .filter((key) => key.startsWith("table:"))
303
+ .map((key) => parseObjectKey(key).table)
304
+ .filter((table) => !declaredKeys.has(objectKey({ kind: "table", table }))),
305
+ );
306
+ for (const [key, definition] of Object.entries(owned)) {
307
+ if (declaredKeys.has(key) || tombstoned.has(key)) continue;
308
+ const id = parseObjectKey(key);
309
+ if (id.kind !== "table" && retiredTables.has(id.table)) continue;
310
+ tombstone(id, key, definition);
311
+ }
312
+
313
+ // A renamed-away source column is tombstoned even while the declaration still
314
+ // names it through `renamedFrom`, so its budget starts at the rename rather
315
+ // than at whichever later release deletes the mention.
316
+ //
317
+ // Only a source that is actually THERE. Once a rename's source has been
318
+ // reclaimed the mention is inert, and tombstoning it again would put a column
319
+ // that no longer exists back on the books and eventually emit a DROP for it.
320
+ for (const table of declared) {
321
+ const liveColumnNames = new Set(
322
+ (liveTables.get(table.name)?.columns ?? []).map((c) => c.name),
323
+ );
324
+ for (const column of table.columns) {
325
+ if (!column.renamedFrom) continue;
326
+ if (!liveColumnNames.has(column.renamedFrom)) continue;
327
+ const id: SchemaObjectId = { kind: "column", table: table.name, name: column.renamedFrom };
328
+ const key = objectKey(id);
329
+ if (tombstoned.has(key) || declaredKeys.has(key)) continue;
330
+ tombstone(id, key, owned[key] ?? JSON.stringify({ name: column.renamedFrom }));
331
+ }
332
+ }
333
+
334
+ return { statements, tombstones, revived, inertRenames, refusals };
335
+ }
336
+
337
+ export function describeRefusals(refusals: readonly Refusal[]): string {
338
+ return refusals.map((r) => ` ${r.object}: ${r.reason}`).join("\n");
339
+ }