@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.
- package/dist/index.d.ts +17 -2
- package/dist/index.js +8 -2
- package/dist/schema/declaration-snapshot.d.ts +21 -0
- package/dist/schema/declaration-snapshot.js +46 -0
- package/dist/schema/declared-schema.d.ts +64 -0
- package/dist/schema/declared-schema.js +16 -0
- package/dist/schema/migration-runner.d.ts +30 -0
- package/dist/schema/migration-runner.js +38 -0
- package/dist/schema/normalize-table.d.ts +46 -0
- package/dist/schema/normalize-table.js +135 -0
- package/dist/schema/reclaim-policy.d.ts +34 -0
- package/dist/schema/reclaim-policy.js +40 -0
- package/dist/schema/schema-driver.d.ts +161 -0
- package/dist/schema/schema-driver.js +1 -0
- package/dist/schema/schema-ledger.d.ts +119 -0
- package/dist/schema/schema-ledger.js +231 -0
- package/dist/schema/schema-reconciler.d.ts +45 -0
- package/dist/schema/schema-reconciler.js +243 -0
- package/dist/schema/schema-run.d.ts +50 -0
- package/dist/schema/schema-run.js +318 -0
- package/dist/sql-connection-base.d.ts +21 -0
- package/dist/sql-connection-base.js +30 -4
- package/dist/sql-connection.d.ts +19 -0
- package/package.json +5 -3
- package/src/index.ts +36 -2
- package/src/schema/declaration-snapshot.ts +71 -0
- package/src/schema/declared-schema.ts +73 -0
- package/src/schema/migration-runner.ts +69 -0
- package/src/schema/normalize-table.ts +218 -0
- package/src/schema/reclaim-policy.ts +73 -0
- package/src/schema/schema-driver.ts +182 -0
- package/src/schema/schema-ledger.ts +309 -0
- package/src/schema/schema-reconciler.ts +339 -0
- package/src/schema/schema-run.ts +441 -0
- package/src/sql-connection-base.ts +35 -4
- package/src/sql-connection.ts +21 -0
- package/dist/sql-migration-controller.d.ts +0 -16
- package/dist/sql-migration-controller.js +0 -13
- package/dist/sql-migrations-controller.d.ts +0 -23
- package/dist/sql-migrations-controller.js +0 -98
- package/src/sql-migration-controller.ts +0 -20
- 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
|
+
}
|