@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.
- package/dist/index.d.ts +18 -2
- package/dist/index.js +9 -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 +57 -0
- package/dist/schema/normalize-table.js +125 -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 +184 -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 +276 -0
- package/dist/schema/schema-run.d.ts +50 -0
- package/dist/schema/schema-run.js +318 -0
- package/dist/schema/table-reference.d.ts +25 -0
- package/dist/schema/table-reference.js +61 -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 +43 -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 +224 -0
- package/src/schema/reclaim-policy.ts +73 -0
- package/src/schema/schema-driver.ts +207 -0
- package/src/schema/schema-ledger.ts +309 -0
- package/src/schema/schema-reconciler.ts +372 -0
- package/src/schema/schema-run.ts +441 -0
- package/src/schema/table-reference.ts +78 -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,276 @@
|
|
|
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
|
+
/**
|
|
31
|
+
* The columns a key maps to what, which is what makes it THAT key rather than
|
|
32
|
+
* another. Its referential actions are settable properties of it, deliberately
|
|
33
|
+
* excluded: an engine that keeps no name matches on this, and folding the
|
|
34
|
+
* actions in would make a changed delete rule read as a brand new key — an ADD
|
|
35
|
+
* where the author should have been told the rule cannot be changed in place.
|
|
36
|
+
*/
|
|
37
|
+
function sameForeignKeyIdentity(live, declared) {
|
|
38
|
+
return (live.references.table === declared.references.table &&
|
|
39
|
+
live.columns.length === declared.columns.length &&
|
|
40
|
+
live.columns.every((column, i) => column === declared.columns[i]) &&
|
|
41
|
+
live.references.columns.length === declared.references.columns.length &&
|
|
42
|
+
live.references.columns.every((column, i) => column === declared.references.columns[i]));
|
|
43
|
+
}
|
|
44
|
+
function foreignKeyDiffers(live, declared) {
|
|
45
|
+
const action = (value) => value?.toUpperCase();
|
|
46
|
+
return (live.references.table !== declared.references.table ||
|
|
47
|
+
live.columns.length !== declared.columns.length ||
|
|
48
|
+
live.columns.some((column, i) => column !== declared.columns[i]) ||
|
|
49
|
+
live.references.columns.length !== declared.references.columns.length ||
|
|
50
|
+
live.references.columns.some((column, i) => column !== declared.references.columns[i]) ||
|
|
51
|
+
(live.onDelete !== undefined && action(live.onDelete) !== (action(declared.onDelete) ?? "NO ACTION")) ||
|
|
52
|
+
(live.onUpdate !== undefined && action(live.onUpdate) !== (action(declared.onUpdate) ?? "NO ACTION")));
|
|
53
|
+
}
|
|
54
|
+
function liveByName(live) {
|
|
55
|
+
return new Map(live.map((table) => [table.name, table]));
|
|
56
|
+
}
|
|
57
|
+
export function planReconciliation(driver, schema, declared, live, owned, tombstoned) {
|
|
58
|
+
const statements = [];
|
|
59
|
+
const tombstones = [];
|
|
60
|
+
const revived = [];
|
|
61
|
+
const inertRenames = [];
|
|
62
|
+
const refusals = [];
|
|
63
|
+
const liveTables = liveByName(live);
|
|
64
|
+
const declaredKeys = new Set();
|
|
65
|
+
const emit = (phase, describes, sql) => {
|
|
66
|
+
for (const one of sql)
|
|
67
|
+
statements.push({ phase, sql: one, describes });
|
|
68
|
+
};
|
|
69
|
+
// NOT named `declare`: `declare` is a TypeScript modifier keyword, and a
|
|
70
|
+
// statement that begins with it is parsed as an ambient declaration and
|
|
71
|
+
// STRIPPED by a type-stripping transpiler — so `declare({ … });` at statement
|
|
72
|
+
// position vanished while `const k = declare(…)` survived, and the pass
|
|
73
|
+
// tombstoned every object it had just declared. Silent under Node, silent at
|
|
74
|
+
// `tsc`, and destructive only on the runtime that strips types.
|
|
75
|
+
const markDeclared = (id) => {
|
|
76
|
+
const key = objectKey(id);
|
|
77
|
+
declaredKeys.add(key);
|
|
78
|
+
if (tombstoned.has(key))
|
|
79
|
+
revived.push(key);
|
|
80
|
+
return key;
|
|
81
|
+
};
|
|
82
|
+
for (const table of declared) {
|
|
83
|
+
markDeclared({ kind: "table", table: table.name });
|
|
84
|
+
for (const column of table.columns) {
|
|
85
|
+
markDeclared({ kind: "column", table: table.name, name: column.name });
|
|
86
|
+
}
|
|
87
|
+
const liveTable = liveTables.get(table.name);
|
|
88
|
+
if (!liveTable) {
|
|
89
|
+
emit("table", `table ${table.name}`, driver.createTable(schema, table));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const liveColumns = new Map(liveTable.columns.map((c) => [c.name, c]));
|
|
93
|
+
for (const column of table.columns) {
|
|
94
|
+
const existing = liveColumns.get(column.name);
|
|
95
|
+
if (!existing) {
|
|
96
|
+
const renamedFrom = column.renamedFrom;
|
|
97
|
+
const source = renamedFrom ? liveColumns.get(renamedFrom) : undefined;
|
|
98
|
+
// Classified BEFORE anything is emitted, so a refused rename
|
|
99
|
+
// contributes no statements at all. The runner refuses to execute a
|
|
100
|
+
// plan carrying refusals, but a plan that is half a rename is still
|
|
101
|
+
// the wrong thing to hand anyone.
|
|
102
|
+
if (source && renamedFrom) {
|
|
103
|
+
// A rename that changes the type is two changes wearing one name.
|
|
104
|
+
// Unchecked, the copy is a raw driver error on an engine that
|
|
105
|
+
// refuses the assignment, and silently stores the old
|
|
106
|
+
// representation on one that does not.
|
|
107
|
+
const safety = driver.classifyCopy(source, column);
|
|
108
|
+
if (!safety.safe) {
|
|
109
|
+
refusals.push({
|
|
110
|
+
object: `${table.name}.${column.name}`,
|
|
111
|
+
reason: `renamedFrom '${renamedFrom}': ${safety.reason}`,
|
|
112
|
+
});
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
emit("table", `column ${table.name}.${column.name}`, driver.addColumn(schema, table.name, column));
|
|
117
|
+
// Expand-contract: the source column is copied, then tombstoned. A
|
|
118
|
+
// native RENAME would take effect immediately and break the older
|
|
119
|
+
// version still running — the one operation that would be exempt from
|
|
120
|
+
// the deferral this design exists for.
|
|
121
|
+
if (source && renamedFrom) {
|
|
122
|
+
emit("table", `copy ${table.name}.${renamedFrom} → ${column.name}`, driver.copyColumn(schema, table.name, renamedFrom, column.name));
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!columnDiffers(driver, existing, column))
|
|
127
|
+
continue;
|
|
128
|
+
// Classification happens here, against live state, because the
|
|
129
|
+
// declaration is the only artifact: there is no historical declared
|
|
130
|
+
// state to diff against, so whether a change is safe depends on what is
|
|
131
|
+
// in the column right now.
|
|
132
|
+
const safety = driver.classifyAlter(existing, column);
|
|
133
|
+
if (!safety.safe) {
|
|
134
|
+
refusals.push({ object: `${table.name}.${column.name}`, reason: safety.reason });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
emit("table", `column ${table.name}.${column.name}`, driver.alterColumn(schema, table.name, existing, column));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// A rename is inert when its source is gone for good: not present, and not
|
|
141
|
+
// held by a tombstone. Only asked of a table that ALREADY existed — on one
|
|
142
|
+
// this pass creates there was never anything to copy, so every rename would
|
|
143
|
+
// look finished when in fact it has not run anywhere yet, and the same
|
|
144
|
+
// manifest still deploys to databases that do need it.
|
|
145
|
+
if (liveTable) {
|
|
146
|
+
const liveColumnNames = new Set(liveTable.columns.map((c) => c.name));
|
|
147
|
+
for (const column of table.columns) {
|
|
148
|
+
if (!column.renamedFrom)
|
|
149
|
+
continue;
|
|
150
|
+
const sourceKey = objectKey({
|
|
151
|
+
kind: "column",
|
|
152
|
+
table: table.name,
|
|
153
|
+
name: column.renamedFrom,
|
|
154
|
+
});
|
|
155
|
+
if (liveColumnNames.has(column.renamedFrom) || tombstoned.has(sourceKey))
|
|
156
|
+
continue;
|
|
157
|
+
inertRenames.push(`column ${table.name}.${column.name} (renamedFrom ${column.renamedFrom})`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const liveIndexes = new Map((liveTable?.indexes ?? []).map((index) => [index.name, index]));
|
|
161
|
+
for (const index of table.indexes) {
|
|
162
|
+
markDeclared({ kind: "index", table: table.name, name: index.name });
|
|
163
|
+
const existing = liveIndexes.get(index.name);
|
|
164
|
+
if (!existing) {
|
|
165
|
+
emit("index", `index ${index.name}`, driver.createIndex(schema, table.name, index));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// An index that exists under the right name may still cover the wrong
|
|
169
|
+
// columns, or have stopped being unique. Silence there is the declaration
|
|
170
|
+
// asserting something the database is not doing.
|
|
171
|
+
if (!indexDiffers(existing, index))
|
|
172
|
+
continue;
|
|
173
|
+
const safety = driver.classifyIndexChange(existing, index);
|
|
174
|
+
if (!safety.safe) {
|
|
175
|
+
refusals.push({ object: `${table.name}.${index.name}`, reason: safety.reason });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
emit("index", `index ${index.name}`, [
|
|
179
|
+
...driver.dropIndex(schema, table.name, index.name),
|
|
180
|
+
...driver.createIndex(schema, table.name, index),
|
|
181
|
+
]);
|
|
182
|
+
}
|
|
183
|
+
// A table this pass just created already carries its keys where the engine
|
|
184
|
+
// can only emit them there. They are still MARKED declared, or the next boot
|
|
185
|
+
// would read every one of them as removed and tombstone it.
|
|
186
|
+
const carriedByCreate = !liveTable && driver.foreignKeysInCreateTable;
|
|
187
|
+
// Where the engine keeps no name, a declaration is matched to a live key by
|
|
188
|
+
// its structure. Matching by name regardless is what made such a table
|
|
189
|
+
// unrestartable: every later boot read its own key as missing and refused to
|
|
190
|
+
// add what the engine cannot add. Matches are CONSUMED, so two keys that are
|
|
191
|
+
// structurally identical pair up one for one instead of both claiming the
|
|
192
|
+
// first.
|
|
193
|
+
const unmatched = [...(liveTable?.foreignKeys ?? [])];
|
|
194
|
+
const liveForeignKeys = new Map(unmatched.map((fk) => [fk.name, fk]));
|
|
195
|
+
const takeStructural = (fk) => {
|
|
196
|
+
const at = unmatched.findIndex((live) => sameForeignKeyIdentity(live, fk));
|
|
197
|
+
return at < 0 ? undefined : unmatched.splice(at, 1)[0];
|
|
198
|
+
};
|
|
199
|
+
for (const fk of table.foreignKeys) {
|
|
200
|
+
markDeclared({ kind: "foreignKey", table: table.name, name: fk.name });
|
|
201
|
+
if (carriedByCreate)
|
|
202
|
+
continue;
|
|
203
|
+
const existing = driver.namesForeignKeys
|
|
204
|
+
? liveForeignKeys.get(fk.name)
|
|
205
|
+
: takeStructural(fk);
|
|
206
|
+
if (!existing) {
|
|
207
|
+
emit("constraint", `foreign key ${fk.name}`, driver.addForeignKey(schema, table.name, fk));
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (!foreignKeyDiffers(existing, fk))
|
|
211
|
+
continue;
|
|
212
|
+
const safety = driver.classifyForeignKeyChange(existing, fk);
|
|
213
|
+
if (!safety.safe) {
|
|
214
|
+
refusals.push({ object: `${table.name}.${fk.name}`, reason: safety.reason });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
emit("constraint", `foreign key ${fk.name}`, [
|
|
218
|
+
...driver.dropForeignKey(schema, table.name, fk.name),
|
|
219
|
+
...driver.addForeignKey(schema, table.name, fk),
|
|
220
|
+
]);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Removal never emits DDL. An object this resource once declared and no longer
|
|
224
|
+
// does is tombstoned; the drop is deferred to reclamation, which is the whole
|
|
225
|
+
// point. An object it has NEVER declared is not ours and is not considered.
|
|
226
|
+
const tombstoneKeys = new Set();
|
|
227
|
+
const tombstone = (id, key, definition) => {
|
|
228
|
+
if (tombstoneKeys.has(key))
|
|
229
|
+
return;
|
|
230
|
+
tombstoneKeys.add(key);
|
|
231
|
+
tombstones.push({ id, key, definition });
|
|
232
|
+
};
|
|
233
|
+
// A table that is going away takes its columns, indexes and constraints with
|
|
234
|
+
// it, so only the TABLE is tombstoned. Recording the children too would plan a
|
|
235
|
+
// drop for each — and they are dropped first, since reclamation walks
|
|
236
|
+
// dependents before their table — so an engine that refuses to drop a primary
|
|
237
|
+
// key or an indexed column (SQLite refuses both) would fail the pass, and go
|
|
238
|
+
// on failing it, over objects the DROP TABLE was about to remove anyway.
|
|
239
|
+
const retiredTables = new Set(Object.keys(owned)
|
|
240
|
+
.filter((key) => key.startsWith("table:"))
|
|
241
|
+
.map((key) => parseObjectKey(key).table)
|
|
242
|
+
.filter((table) => !declaredKeys.has(objectKey({ kind: "table", table }))));
|
|
243
|
+
for (const [key, definition] of Object.entries(owned)) {
|
|
244
|
+
if (declaredKeys.has(key) || tombstoned.has(key))
|
|
245
|
+
continue;
|
|
246
|
+
const id = parseObjectKey(key);
|
|
247
|
+
if (id.kind !== "table" && retiredTables.has(id.table))
|
|
248
|
+
continue;
|
|
249
|
+
tombstone(id, key, definition);
|
|
250
|
+
}
|
|
251
|
+
// A renamed-away source column is tombstoned even while the declaration still
|
|
252
|
+
// names it through `renamedFrom`, so its budget starts at the rename rather
|
|
253
|
+
// than at whichever later release deletes the mention.
|
|
254
|
+
//
|
|
255
|
+
// Only a source that is actually THERE. Once a rename's source has been
|
|
256
|
+
// reclaimed the mention is inert, and tombstoning it again would put a column
|
|
257
|
+
// that no longer exists back on the books and eventually emit a DROP for it.
|
|
258
|
+
for (const table of declared) {
|
|
259
|
+
const liveColumnNames = new Set((liveTables.get(table.name)?.columns ?? []).map((c) => c.name));
|
|
260
|
+
for (const column of table.columns) {
|
|
261
|
+
if (!column.renamedFrom)
|
|
262
|
+
continue;
|
|
263
|
+
if (!liveColumnNames.has(column.renamedFrom))
|
|
264
|
+
continue;
|
|
265
|
+
const id = { kind: "column", table: table.name, name: column.renamedFrom };
|
|
266
|
+
const key = objectKey(id);
|
|
267
|
+
if (tombstoned.has(key) || declaredKeys.has(key))
|
|
268
|
+
continue;
|
|
269
|
+
tombstone(id, key, owned[key] ?? JSON.stringify({ name: column.renamedFrom }));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { statements, tombstones, revived, inertRenames, refusals };
|
|
273
|
+
}
|
|
274
|
+
export function describeRefusals(refusals) {
|
|
275
|
+
return refusals.map((r) => ` ${r.object}: ${r.reason}`).join("\n");
|
|
276
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import type { TableReferenceResolver } from "./normalize-table.js";
|
|
3
|
+
/**
|
|
4
|
+
* Resolves a `references.table` slot to the referenced table's physical name.
|
|
5
|
+
*
|
|
6
|
+
* **BOTH shapes arrive, and which one is a race.** A table reads this slot while
|
|
7
|
+
* it is being CREATED, and Phase-5 injection replaces a reference only when the
|
|
8
|
+
* target is already registered — a local ref naming nothing pending is left
|
|
9
|
+
* exactly as written. So the same manifest hands over a live instance on one
|
|
10
|
+
* pass of the init loop and the raw `{ kind, name }` on another, and reading
|
|
11
|
+
* only the instance is what made every cross-table foreign key fail outright.
|
|
12
|
+
*
|
|
13
|
+
* The reference is resolved to the target's DECLARATION, which carries the one
|
|
14
|
+
* thing a foreign key needs from it — the physical name — and carries it whether
|
|
15
|
+
* or not the target has been constructed. That is also why the slot stays
|
|
16
|
+
* `use: schema` and registers no ordering edge: nothing here requires the
|
|
17
|
+
* referenced table to exist first, and an edge would make a tree table (which
|
|
18
|
+
* references ITSELF) and a mutual pair into init cycles, though both are
|
|
19
|
+
* perfectly creatable on an engine that emits keys after every table.
|
|
20
|
+
*
|
|
21
|
+
* A plain string is accepted for an internal caller that already holds a name;
|
|
22
|
+
* an author cannot write one, since a ref slot rejects a bare string
|
|
23
|
+
* (`INVALID_REFERENCE_FORM`).
|
|
24
|
+
*/
|
|
25
|
+
export declare function tableReferenceResolver(ctx: ResourceContext, kind: string, table: string): TableReferenceResolver;
|