@atscript/db 0.1.127 → 0.1.128
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/{db-readable-BkAGccv9.d.mts → db-readable-BTl4NzNN.d.mts} +289 -16
- package/dist/{db-readable-C0nDKX8A.d.cts → db-readable-CkbZn_z-.d.cts} +289 -16
- package/dist/{db-space-B_ASuDaR.d.mts → db-space-BEB5Jl8M.d.mts} +81 -29
- package/dist/{db-space-CSntT6yS.d.cts → db-space-BevOyVrC.d.cts} +81 -29
- package/dist/{db-view-BP0Qbeux.cjs → db-view-C8-MsjND.cjs} +696 -97
- package/dist/{db-view-C8rZM5_N.mjs → db-view-Ck0I-PMI.mjs} +643 -98
- package/dist/index.cjs +46 -2
- package/dist/index.d.cts +162 -37
- package/dist/index.d.mts +162 -37
- package/dist/index.mjs +36 -4
- package/dist/{ops-DJRnNTVo.d.cts → ops-AqhV7s9o.d.cts} +24 -1
- package/dist/{ops-DJRnNTVo.d.mts → ops-AqhV7s9o.d.mts} +24 -1
- package/dist/ops.cjs +43 -0
- package/dist/ops.d.cts +2 -2
- package/dist/ops.d.mts +2 -2
- package/dist/ops.mjs +43 -1
- package/dist/plugin.cjs +12 -5
- package/dist/plugin.mjs +12 -5
- package/dist/rel.d.cts +1 -1
- package/dist/rel.d.mts +1 -1
- package/dist/sync.cjs +1096 -361
- package/dist/sync.d.cts +234 -15
- package/dist/sync.d.mts +234 -15
- package/dist/sync.mjs +1095 -362
- package/dist/{validator-CSGug4vg.cjs → validator-BNUHCXIE.cjs} +31 -2
- package/dist/{validator-BcBtg8yW.d.cts → validator-DuAmWc8L.d.cts} +38 -1
- package/dist/{validator-BcBtg8yW.d.mts → validator-DuAmWc8L.d.mts} +38 -1
- package/dist/{validator-0vRXN51D.mjs → validator-eKYWf3xf.mjs} +20 -3
- package/dist/validator.cjs +7 -1
- package/dist/validator.d.cts +3 -3
- package/dist/validator.d.mts +3 -3
- package/dist/validator.mjs +4 -3
- package/package.json +8 -8
package/dist/sync.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_db_view = require("./db-view-
|
|
2
|
+
const require_db_view = require("./db-view-C8-MsjND.cjs");
|
|
3
3
|
//#region src/schema/schema-hash.ts
|
|
4
4
|
/** Extracts sorted field snapshots from a readable's field descriptors. */
|
|
5
5
|
function extractFieldSnapshots(fields, typeMapper) {
|
|
@@ -65,18 +65,46 @@ function computeViewSnapshot(view) {
|
|
|
65
65
|
fields
|
|
66
66
|
};
|
|
67
67
|
const plan = view.viewPlan;
|
|
68
|
+
const qualify = (ref) => view.resolveFieldRef(ref, (n) => n);
|
|
68
69
|
const result = {
|
|
69
70
|
tableName: view.tableName,
|
|
70
71
|
viewType: plan.materialized ? "M" : "V",
|
|
71
72
|
entryTable: plan.entryTable,
|
|
72
|
-
joinTables: plan.joins.map((j) =>
|
|
73
|
+
joinTables: plan.joins.map((j) => ({
|
|
74
|
+
targetTable: j.targetTable,
|
|
75
|
+
condition: JSON.stringify(canonicalizeQueryNode(j.condition, qualify))
|
|
76
|
+
})),
|
|
73
77
|
materialized: plan.materialized || void 0,
|
|
74
78
|
fields
|
|
75
79
|
};
|
|
76
|
-
if (plan.filter) result.filterHash = fnv1a(JSON.stringify(plan.filter,
|
|
80
|
+
if (plan.filter) result.filterHash = fnv1a(JSON.stringify(canonicalizeQueryNode(plan.filter, qualify)));
|
|
81
|
+
if (plan.having) result.havingHash = fnv1a(JSON.stringify(canonicalizeQueryNode(plan.having, qualify)));
|
|
77
82
|
return result;
|
|
78
83
|
}
|
|
79
84
|
/**
|
|
85
|
+
* Converts a view predicate (join condition, `@db.view.filter`,
|
|
86
|
+
* `@db.view.having`) into a serializable structure whose JSON is a stable
|
|
87
|
+
* function of its MEANING: field refs become `qualify(ref)` — the view's
|
|
88
|
+
* `resolveFieldRef(ref, (n) => n)`, i.e. `"<table>.<field>"`, so a predicate
|
|
89
|
+
* retargeted to another table with the same field name changes — operators
|
|
90
|
+
* and literal values are kept as-is, `$and`/`$or` keep declaration order, and
|
|
91
|
+
* no function references survive. Two identical models produce byte-identical
|
|
92
|
+
* JSON.
|
|
93
|
+
* @since 0.1.128
|
|
94
|
+
*/
|
|
95
|
+
function canonicalizeQueryNode(node, qualify) {
|
|
96
|
+
if ("$and" in node) return { and: node.$and.map((n) => canonicalizeQueryNode(n, qualify)) };
|
|
97
|
+
if ("$or" in node) return { or: node.$or.map((n) => canonicalizeQueryNode(n, qualify)) };
|
|
98
|
+
if ("$not" in node) return { not: canonicalizeQueryNode(node.$not, qualify) };
|
|
99
|
+
const comp = node;
|
|
100
|
+
const out = {
|
|
101
|
+
l: qualify(comp.left),
|
|
102
|
+
op: comp.op
|
|
103
|
+
};
|
|
104
|
+
if (comp.right !== void 0) out.r = comp.right !== null && typeof comp.right === "object" && "field" in comp.right ? { f: qualify(comp.right) } : comp.right;
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
80
108
|
* Computes a deterministic hash string from multiple table snapshots.
|
|
81
109
|
* Uses FNV-1a for speed — not cryptographic, just needs stability + collision resistance.
|
|
82
110
|
*/
|
|
@@ -131,6 +159,52 @@ function fnv1a(str) {
|
|
|
131
159
|
return Math.trunc(hash).toString(16).padStart(8, "0");
|
|
132
160
|
}
|
|
133
161
|
//#endregion
|
|
162
|
+
//#region src/schema/fk-diff.ts
|
|
163
|
+
/** Canonical key for an FK: sorted local field names, comma-joined. */
|
|
164
|
+
function fkKey(fields) {
|
|
165
|
+
return [...fields].toSorted().join(",");
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Compares desired FK constraints against stored snapshot to detect
|
|
169
|
+
* additions, removals, and property changes (target table, target fields,
|
|
170
|
+
* onDelete, onUpdate).
|
|
171
|
+
*/
|
|
172
|
+
function computeForeignKeyDiff(desired, existingSnapshot) {
|
|
173
|
+
const added = [];
|
|
174
|
+
const removed = [];
|
|
175
|
+
const changed = [];
|
|
176
|
+
const existingByKey = /* @__PURE__ */ new Map();
|
|
177
|
+
for (const fk of existingSnapshot) existingByKey.set(fkKey(fk.fields), fk);
|
|
178
|
+
const desiredKeys = /* @__PURE__ */ new Set();
|
|
179
|
+
for (const fk of desired.values()) {
|
|
180
|
+
const key = fkKey(fk.fields);
|
|
181
|
+
desiredKeys.add(key);
|
|
182
|
+
const existing = existingByKey.get(key);
|
|
183
|
+
if (!existing) added.push(fk);
|
|
184
|
+
else if (fkPropertiesDiffer(fk, existing)) changed.push({
|
|
185
|
+
desired: fk,
|
|
186
|
+
existing
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
for (const [key, fk] of existingByKey) if (!desiredKeys.has(key)) removed.push(fk);
|
|
190
|
+
return {
|
|
191
|
+
added,
|
|
192
|
+
removed,
|
|
193
|
+
changed
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** Whether the FK diff contains any changes. */
|
|
197
|
+
function hasForeignKeyChanges(diff) {
|
|
198
|
+
return diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
|
|
199
|
+
}
|
|
200
|
+
function fkPropertiesDiffer(desired, existing) {
|
|
201
|
+
if (desired.targetTable !== existing.targetTable) return true;
|
|
202
|
+
if (fkKey(desired.targetFields) !== fkKey(existing.targetFields)) return true;
|
|
203
|
+
if ((desired.onDelete ?? void 0) !== (existing.onDelete ?? void 0)) return true;
|
|
204
|
+
if ((desired.onUpdate ?? void 0) !== (existing.onUpdate ?? void 0)) return true;
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
134
208
|
//#region src/schema/column-diff.ts
|
|
135
209
|
/**
|
|
136
210
|
* Computes the difference between desired schema fields and existing database columns.
|
|
@@ -192,7 +266,7 @@ function computeColumnDiff(desired, existing, typeMapper) {
|
|
|
192
266
|
renamedOldNames.add(field.renamedFrom);
|
|
193
267
|
} else added.push(field);
|
|
194
268
|
}
|
|
195
|
-
|
|
269
|
+
const diff = {
|
|
196
270
|
added,
|
|
197
271
|
removed: existing.filter((c) => !desiredByName.has(c.name) && !renamedOldNames.has(c.name)),
|
|
198
272
|
renamed,
|
|
@@ -201,52 +275,16 @@ function computeColumnDiff(desired, existing, typeMapper) {
|
|
|
201
275
|
defaultChanged,
|
|
202
276
|
conflicts
|
|
203
277
|
};
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
* Compares desired FK constraints against stored snapshot to detect
|
|
213
|
-
* additions, removals, and property changes (target table, target fields,
|
|
214
|
-
* onDelete, onUpdate).
|
|
215
|
-
*/
|
|
216
|
-
function computeForeignKeyDiff(desired, existingSnapshot) {
|
|
217
|
-
const added = [];
|
|
218
|
-
const removed = [];
|
|
219
|
-
const changed = [];
|
|
220
|
-
const existingByKey = /* @__PURE__ */ new Map();
|
|
221
|
-
for (const fk of existingSnapshot) existingByKey.set(fkKey(fk.fields), fk);
|
|
222
|
-
const desiredKeys = /* @__PURE__ */ new Set();
|
|
223
|
-
for (const fk of desired.values()) {
|
|
224
|
-
const key = fkKey(fk.fields);
|
|
225
|
-
desiredKeys.add(key);
|
|
226
|
-
const existing = existingByKey.get(key);
|
|
227
|
-
if (!existing) added.push(fk);
|
|
228
|
-
else if (fkPropertiesDiffer(fk, existing)) changed.push({
|
|
229
|
-
desired: fk,
|
|
230
|
-
existing
|
|
231
|
-
});
|
|
278
|
+
if (existing.length > 0) {
|
|
279
|
+
const newNameByOld = new Map(renamed.map((r) => [r.oldName, r.field.physicalName]));
|
|
280
|
+
const from = existing.filter((c) => c.pk).map((c) => newNameByOld.get(c.name) ?? c.name);
|
|
281
|
+
const to = desired.filter((f) => !f.ignored && f.isPrimaryKey).map((f) => f.physicalName);
|
|
282
|
+
if (fkKey(from) !== fkKey(to)) diff.primaryKeyChanged = {
|
|
283
|
+
from,
|
|
284
|
+
to
|
|
285
|
+
};
|
|
232
286
|
}
|
|
233
|
-
|
|
234
|
-
return {
|
|
235
|
-
added,
|
|
236
|
-
removed,
|
|
237
|
-
changed
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
/** Whether the FK diff contains any changes. */
|
|
241
|
-
function hasForeignKeyChanges(diff) {
|
|
242
|
-
return diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
|
|
243
|
-
}
|
|
244
|
-
function fkPropertiesDiffer(desired, existing) {
|
|
245
|
-
if (desired.targetTable !== existing.targetTable) return true;
|
|
246
|
-
if (fkKey(desired.targetFields) !== fkKey(existing.targetFields)) return true;
|
|
247
|
-
if ((desired.onDelete ?? void 0) !== (existing.onDelete ?? void 0)) return true;
|
|
248
|
-
if ((desired.onUpdate ?? void 0) !== (existing.onUpdate ?? void 0)) return true;
|
|
249
|
-
return false;
|
|
287
|
+
return diff;
|
|
250
288
|
}
|
|
251
289
|
//#endregion
|
|
252
290
|
//#region src/schema/table-option-diff.ts
|
|
@@ -276,6 +314,80 @@ function computeTableOptionDiff(desired, existing, destructiveKeys) {
|
|
|
276
314
|
return { changed };
|
|
277
315
|
}
|
|
278
316
|
//#endregion
|
|
317
|
+
//#region src/schema/dependency-order.ts
|
|
318
|
+
/**
|
|
319
|
+
* Orders `nodes` so that dependencies come first. Returns the strongly-
|
|
320
|
+
* connected components in dependency order: every group appears AFTER each
|
|
321
|
+
* group it depends on (parents first). Singleton groups are ordinary tables;
|
|
322
|
+
* groups of size > 1 are cycles. Members are name-sorted.
|
|
323
|
+
*
|
|
324
|
+
* - Edges referencing a name outside `nodes` are ignored (external tables
|
|
325
|
+
* impose no ordering).
|
|
326
|
+
* - Self-loops are ignored (a self-referential FK is legal inline everywhere).
|
|
327
|
+
* - Deterministic: nodes and adjacency lists are sorted by name before the
|
|
328
|
+
* traversal, so two processes with different inventory orders compute the
|
|
329
|
+
* same plan.
|
|
330
|
+
*/
|
|
331
|
+
function topoOrder(nodes, edges) {
|
|
332
|
+
const names = [...new Set(nodes)].toSorted();
|
|
333
|
+
const nodeSet = new Set(names);
|
|
334
|
+
const adjacency = new Map(names.map((n) => [n, /* @__PURE__ */ new Set()]));
|
|
335
|
+
for (const [from, to] of edges) {
|
|
336
|
+
if (from === to || !nodeSet.has(from) || !nodeSet.has(to)) continue;
|
|
337
|
+
adjacency.get(from).add(to);
|
|
338
|
+
}
|
|
339
|
+
const index = /* @__PURE__ */ new Map();
|
|
340
|
+
const lowlink = /* @__PURE__ */ new Map();
|
|
341
|
+
const onStack = /* @__PURE__ */ new Set();
|
|
342
|
+
const stack = [];
|
|
343
|
+
const order = [];
|
|
344
|
+
let counter = 0;
|
|
345
|
+
const visit = (node) => {
|
|
346
|
+
index.set(node, counter);
|
|
347
|
+
lowlink.set(node, counter);
|
|
348
|
+
counter++;
|
|
349
|
+
stack.push(node);
|
|
350
|
+
onStack.add(node);
|
|
351
|
+
for (const next of [...adjacency.get(node)].toSorted()) if (!index.has(next)) {
|
|
352
|
+
visit(next);
|
|
353
|
+
lowlink.set(node, Math.min(lowlink.get(node), lowlink.get(next)));
|
|
354
|
+
} else if (onStack.has(next)) lowlink.set(node, Math.min(lowlink.get(node), index.get(next)));
|
|
355
|
+
if (lowlink.get(node) === index.get(node)) {
|
|
356
|
+
const group = [];
|
|
357
|
+
let member;
|
|
358
|
+
do {
|
|
359
|
+
member = stack.pop();
|
|
360
|
+
onStack.delete(member);
|
|
361
|
+
group.push(member);
|
|
362
|
+
} while (member !== node);
|
|
363
|
+
group.sort();
|
|
364
|
+
order.push(group);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
for (const name of names) if (!index.has(name)) visit(name);
|
|
368
|
+
return order;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* The dependents closure of `seeds`: the seeds themselves plus every node
|
|
372
|
+
* that depends on one of them — directly or through other dependents — along
|
|
373
|
+
* `edges` (`from` depends on `to`). A fixed point over the edge list, so the
|
|
374
|
+
* edges may come in any order. Schema sync uses it over the removed-table
|
|
375
|
+
* graph: the removed tables that block a drop, plus their own removed
|
|
376
|
+
* children, which must go first.
|
|
377
|
+
*/
|
|
378
|
+
function reachable(seeds, edges) {
|
|
379
|
+
const out = new Set(seeds);
|
|
380
|
+
const edgeList = [...edges];
|
|
381
|
+
for (let grew = out.size > 0; grew;) {
|
|
382
|
+
grew = false;
|
|
383
|
+
for (const [from, to] of edgeList) if (out.has(to) && !out.has(from)) {
|
|
384
|
+
out.add(from);
|
|
385
|
+
grew = true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
//#endregion
|
|
279
391
|
//#region src/schema/sync-store.ts
|
|
280
392
|
var SyncStore = class {
|
|
281
393
|
space;
|
|
@@ -336,7 +448,14 @@ var SyncStore = class {
|
|
|
336
448
|
for (const e of entries) e.viewType ??= e.isView ? "V" : void 0;
|
|
337
449
|
return entries;
|
|
338
450
|
}
|
|
339
|
-
|
|
451
|
+
/**
|
|
452
|
+
* Writes the tracked list: the current readables plus `retained` — entries
|
|
453
|
+
* from the previous list that were scheduled for removal but NOT dropped
|
|
454
|
+
* (safe mode, blocked or failed drop). Tracking must keep describing what
|
|
455
|
+
* sync believes exists, so a later run that actually executes still drops
|
|
456
|
+
* them instead of orphaning them behind a matching hash.
|
|
457
|
+
*/
|
|
458
|
+
async writeTrackedList(readables, retained = []) {
|
|
340
459
|
const entries = readables.map((r) => {
|
|
341
460
|
const isView = r.isView;
|
|
342
461
|
let viewType;
|
|
@@ -350,6 +469,15 @@ var SyncStore = class {
|
|
|
350
469
|
viewType
|
|
351
470
|
};
|
|
352
471
|
});
|
|
472
|
+
const current = new Set(entries.map((e) => e.name));
|
|
473
|
+
for (const e of retained) if (!current.has(e.name)) {
|
|
474
|
+
entries.push({
|
|
475
|
+
name: e.name,
|
|
476
|
+
isView: e.isView,
|
|
477
|
+
viewType: e.viewType
|
|
478
|
+
});
|
|
479
|
+
current.add(e.name);
|
|
480
|
+
}
|
|
353
481
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
354
482
|
await this.writeControlValue("synced_tables", JSON.stringify(entries));
|
|
355
483
|
}
|
|
@@ -463,6 +591,16 @@ var SyncEntry = class {
|
|
|
463
591
|
recreated;
|
|
464
592
|
errors;
|
|
465
593
|
renamedFrom;
|
|
594
|
+
/** @since 0.1.128 — see {@link TSyncEntryInit.pkChange}. */
|
|
595
|
+
pkChange;
|
|
596
|
+
/** @since 0.1.128 — see {@link TSyncEntryInit.skipped}. */
|
|
597
|
+
skipped;
|
|
598
|
+
/** @since 0.1.128 — see {@link TSyncEntryInit.dependsOn}. */
|
|
599
|
+
dependsOn;
|
|
600
|
+
/** @since 0.1.128 — see {@link TSyncEntryInit.dropGroup}. */
|
|
601
|
+
dropGroup;
|
|
602
|
+
/** @since 0.1.128 — see {@link TSyncEntryInit.refused}. */
|
|
603
|
+
refused;
|
|
466
604
|
constructor(init) {
|
|
467
605
|
this.name = init.name;
|
|
468
606
|
this.viewType = init.viewType;
|
|
@@ -484,11 +622,35 @@ var SyncEntry = class {
|
|
|
484
622
|
this.recreated = init.recreated ?? false;
|
|
485
623
|
this.errors = init.errors ?? [];
|
|
486
624
|
this.renamedFrom = init.renamedFrom;
|
|
625
|
+
this.pkChange = init.pkChange;
|
|
626
|
+
this.skipped = init.skipped ?? [];
|
|
627
|
+
this.dependsOn = init.dependsOn ?? [];
|
|
628
|
+
this.dropGroup = init.dropGroup;
|
|
629
|
+
this.refused = init.refused ?? false;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* The init object this entry was built from — lets callers derive a
|
|
633
|
+
* modified copy (`new SyncEntry({ ...entry.toInit(), status: "error" })`).
|
|
634
|
+
* @since 0.1.128
|
|
635
|
+
*/
|
|
636
|
+
toInit() {
|
|
637
|
+
return { ...this };
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Whether desired work is still pending after this entry — DDL that was
|
|
641
|
+
* not issued because the entry errored or safe mode skipped it (see
|
|
642
|
+
* `skipped`). A pending entry withholds its snapshot and the schema hash,
|
|
643
|
+
* so the next run retries / applies it. External views are advisory and
|
|
644
|
+
* never pending.
|
|
645
|
+
* @since 0.1.128
|
|
646
|
+
*/
|
|
647
|
+
get pending() {
|
|
648
|
+
return this.status === "error" && this.viewType !== "E" || this.skipped.length > 0;
|
|
487
649
|
}
|
|
488
|
-
/** Whether this entry involves destructive operations */
|
|
650
|
+
/** Whether this entry involves destructive operations (pending work safe mode skipped is not) */
|
|
489
651
|
get destructive() {
|
|
490
652
|
if (this.status === "drop") return this.viewType !== "V" && this.viewType !== "E";
|
|
491
|
-
return this.columnsToDrop.length > 0 || this.typeChanges.length > 0 || this.recreated || this.optionChanges.some((c) => c.destructive);
|
|
653
|
+
return this.columnsToDrop.length > 0 || this.typeChanges.length > 0 && !this.skipped.includes("recreate") || this.recreated || this.pkChange?.rebuild === true || this.optionChanges.some((c) => c.destructive) && !this.skipped.includes("table-options");
|
|
492
654
|
}
|
|
493
655
|
/** Whether this entry represents any change (not in-sync) */
|
|
494
656
|
get hasChanges() {
|
|
@@ -510,18 +672,73 @@ var SyncEntry = class {
|
|
|
510
672
|
};
|
|
511
673
|
}
|
|
512
674
|
printError(c, label, vp) {
|
|
513
|
-
|
|
675
|
+
const head = this.refused ? `✖ refused: ${vp}${label}` : `✗ ${vp}${label} — error`;
|
|
676
|
+
return [` ${c.red(head)}`, ...this.errors.map((err) => ` ${c.red(err)}`)];
|
|
677
|
+
}
|
|
678
|
+
/** `! PK (id) → (code) — rebuild (table is empty)` / `— skipped (safe mode)` */
|
|
679
|
+
printPkChange(c, mode) {
|
|
680
|
+
const pk = this.pkChange;
|
|
681
|
+
if (!pk) return [];
|
|
682
|
+
const cols = `PK (${pk.from.join(", ")}) → (${pk.to.join(", ")})`;
|
|
683
|
+
if (!pk.rebuild) return [` ${c.yellow(`! ${cols} — skipped (safe mode)`)}`];
|
|
684
|
+
return mode === "plan" ? [` ${c.red(`! ${cols} — rebuild (table is empty)`)}`] : [` ${c.yellow(`~ ${cols} — rebuilt`)}`];
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* `! col: t1 → t2 — drop` (plan), or `! type col (t1 → t2) — skipped (safe
|
|
688
|
+
* mode)` in plan and result when the `'drop'` recreate was skipped.
|
|
689
|
+
*/
|
|
690
|
+
printTypeChanges(c) {
|
|
691
|
+
if (this.skipped.includes("recreate")) return this.typeChanges.map((tc) => ` ${c.yellow(`! type ${tc.column} (${tc.fromType} → ${tc.toType}) — skipped (safe mode)`)}`);
|
|
692
|
+
return this.typeChanges.map((tc) => {
|
|
693
|
+
const action = this.syncMethod ? ` — ${this.syncMethod}` : " — requires migration";
|
|
694
|
+
return ` ${c.red(`! ${tc.column}: ${tc.fromType} → ${tc.toType}${action}`)}`;
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
/** `~ col — nullable` / `~ col — default a → b`, `— skipped (safe mode)` when pending. */
|
|
698
|
+
printNullableDefaults(c) {
|
|
699
|
+
const suffix = this.skipped.includes("nullable-defaults") ? " — skipped (safe mode)" : "";
|
|
700
|
+
return [...this.nullableChanges.map((nc) => ` ${c.yellow(`~ ${nc.column} — ${nc.toNullable ? "nullable" : "non-nullable"}${suffix}`)}`), ...this.defaultChanges.map((dc) => ` ${c.yellow(`~ ${dc.column} — default ${dc.oldDefault ?? "none"} → ${dc.newDefault ?? "none"}${suffix}`)}`)];
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Plan: `~ option k: a → b`, or `! option k: a → b — requires recreation`
|
|
704
|
+
* for a destructive change. Result: `~ option k: a → b` for an applied
|
|
705
|
+
* change. Both: `! option k: a → b — skipped (safe mode)` when pending.
|
|
706
|
+
*/
|
|
707
|
+
printOptionChanges(c, mode) {
|
|
708
|
+
const skipped = this.skipped.includes("table-options");
|
|
709
|
+
return this.optionChanges.map((oc) => {
|
|
710
|
+
if (oc.destructive && skipped) return ` ${c.red("!")} ${c.cyan(`option ${oc.key}`)}: ${oc.oldValue} → ${oc.newValue} — skipped (safe mode)`;
|
|
711
|
+
if (mode === "result") return ` ${c.cyan(`~ option ${oc.key}: ${oc.oldValue} → ${oc.newValue}`)}`;
|
|
712
|
+
const tag = oc.destructive ? c.red("!") : c.yellow("~");
|
|
713
|
+
const action = oc.destructive ? " — requires recreation" : "";
|
|
714
|
+
return ` ${tag} ${c.cyan(`option ${oc.key}`)}: ${oc.oldValue} → ${oc.newValue}${action}`;
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
/** `· after: a, b` (plan only — the executor already runs in this order). */
|
|
718
|
+
printDependsOn(c) {
|
|
719
|
+
if (this.dependsOn.length === 0) return [];
|
|
720
|
+
return [` ${c.dim(`· after: ${this.dependsOn.join(", ")}`)}`];
|
|
721
|
+
}
|
|
722
|
+
printDropGroup(c) {
|
|
723
|
+
const others = this.dropGroup?.filter((n) => n !== this.name) ?? [];
|
|
724
|
+
if (others.length === 0) return [];
|
|
725
|
+
return [` ${c.dim(`· dropped with: ${others.join(", ")}`)}`];
|
|
514
726
|
}
|
|
515
727
|
printPlan(c) {
|
|
516
728
|
const { label, vp } = this.labelAndPrefix(c);
|
|
517
729
|
if (this.status === "error") return this.printError(c, label, vp);
|
|
518
730
|
if (this.status === "drop") {
|
|
519
731
|
const kind = this.viewType ? "drop view" : "drop table";
|
|
520
|
-
return [
|
|
732
|
+
return [
|
|
733
|
+
` ${c.red(`- ${vp}${label} — ${kind}`)}`,
|
|
734
|
+
...this.printDependsOn(c),
|
|
735
|
+
...this.printDropGroup(c)
|
|
736
|
+
];
|
|
521
737
|
}
|
|
522
738
|
if (this.status === "create") return [
|
|
523
739
|
` ${c.green(`+ ${vp}${label} — create`)}`,
|
|
524
740
|
...this.columnsToAdd.map((col) => ` ${c.green(`+ ${col.physicalName} (${col.designType})${col.isPrimaryKey ? " PK" : ""}${col.optional ? " nullable" : ""} — add`)}`),
|
|
741
|
+
...this.printDependsOn(c),
|
|
525
742
|
""
|
|
526
743
|
];
|
|
527
744
|
if (this.status === "alter") {
|
|
@@ -530,21 +747,15 @@ var SyncEntry = class {
|
|
|
530
747
|
` ${c.cyan(`~ ${vp}${label} — alter${renameInfo}`)}`,
|
|
531
748
|
...this.columnsToAdd.map((col) => ` ${c.green(`+ ${col.physicalName} (${col.designType}) — add`)}`),
|
|
532
749
|
...this.columnsToRename.map((r) => ` ${c.yellow(`~ ${r.from} → ${r.to} — rename`)}`),
|
|
533
|
-
...this.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}),
|
|
537
|
-
...this.nullableChanges.map((nc) => ` ${c.yellow(`~ ${nc.column} — ${nc.toNullable ? "nullable" : "non-nullable"}`)}`),
|
|
538
|
-
...this.defaultChanges.map((dc) => ` ${c.yellow(`~ ${dc.column} — default ${dc.oldDefault ?? "none"} → ${dc.newDefault ?? "none"}`)}`),
|
|
750
|
+
...this.printTypeChanges(c),
|
|
751
|
+
...this.printNullableDefaults(c),
|
|
752
|
+
...this.printPkChange(c, "plan"),
|
|
539
753
|
...this.columnsToDrop.map((col) => ` ${c.red(`- ${col} — drop`)}`),
|
|
540
|
-
...this.
|
|
541
|
-
const tag = oc.destructive ? c.red("!") : c.yellow("~");
|
|
542
|
-
const action = oc.destructive ? " — requires recreation" : "";
|
|
543
|
-
return ` ${tag} ${c.cyan(`option ${oc.key}`)}: ${oc.oldValue} → ${oc.newValue}${action}`;
|
|
544
|
-
}),
|
|
754
|
+
...this.printOptionChanges(c, "plan"),
|
|
545
755
|
...this.fkAdded.map((fk) => ` ${c.green(`+ FK(${fk.fields.join(",")}) → ${fk.targetTable} — add`)}`),
|
|
546
756
|
...this.fkRemoved.map((fk) => ` ${c.red(`- FK(${fk.fields.join(",")}) → ${fk.targetTable} — remove`)}`),
|
|
547
757
|
...this.fkChanged.map((fk) => ` ${c.yellow(`~ FK(${fk.fields.join(",")}) → ${fk.targetTable} — ${fk.details}`)}`),
|
|
758
|
+
...this.printDependsOn(c),
|
|
548
759
|
""
|
|
549
760
|
];
|
|
550
761
|
}
|
|
@@ -555,22 +766,25 @@ var SyncEntry = class {
|
|
|
555
766
|
if (this.status === "error") return this.printError(c, label, vp);
|
|
556
767
|
if (this.status === "drop") {
|
|
557
768
|
const kind = this.viewType ? "dropped view" : "dropped table";
|
|
558
|
-
return [` ${c.red(`- ${vp}${label} — ${kind}`)}
|
|
769
|
+
return [` ${c.red(`- ${vp}${label} — ${kind}`)}`, ...this.printDropGroup(c)];
|
|
559
770
|
}
|
|
560
771
|
if (this.status === "create") return [
|
|
561
772
|
` ${c.green(`+ ${vp}${label} — created`)}`,
|
|
562
773
|
...this.columnsAdded.map((col) => ` ${c.green(`+ ${col} — added`)}`),
|
|
563
774
|
""
|
|
564
775
|
];
|
|
565
|
-
if (this.columnsAdded.length > 0 || this.columnsRenamed.length > 0 || this.columnsDropped.length > 0 || this.optionChanges.length > 0 || this.recreated || this.renamedFrom) {
|
|
776
|
+
if (this.columnsAdded.length > 0 || this.columnsRenamed.length > 0 || this.columnsDropped.length > 0 || this.optionChanges.length > 0 || this.pkChange !== void 0 || this.skipped.length > 0 || this.recreated || this.renamedFrom) {
|
|
566
777
|
const rlabel = this.recreated ? "recreated" : "altered";
|
|
567
778
|
const renameInfo = this.renamedFrom ? ` ${c.yellow(`(renamed from ${this.renamedFrom})`)}` : "";
|
|
568
779
|
return [
|
|
569
780
|
` ${(this.recreated ? (s) => c.yellow(s) : (s) => c.cyan(s))(`~ ${vp}${label} — ${rlabel}${renameInfo}`)}`,
|
|
570
781
|
...this.columnsAdded.map((col) => ` ${c.green(`+ ${col} — added`)}`),
|
|
571
782
|
...this.columnsRenamed.map((col) => ` ${c.yellow(`~ ${col} — renamed`)}`),
|
|
783
|
+
...this.printPkChange(c, "result"),
|
|
784
|
+
...this.printTypeChanges(c),
|
|
785
|
+
...this.printNullableDefaults(c),
|
|
572
786
|
...this.columnsDropped.map((col) => ` ${c.red(`- ${col} — dropped`)}`),
|
|
573
|
-
...this.
|
|
787
|
+
...this.printOptionChanges(c, "result"),
|
|
574
788
|
""
|
|
575
789
|
];
|
|
576
790
|
}
|
|
@@ -592,88 +806,94 @@ async function viewDefinitionChanged(view, store) {
|
|
|
592
806
|
const currentHash = computeTableHash(computeViewSnapshot(view));
|
|
593
807
|
return computeTableHash(storedSnapshot) !== currentHash;
|
|
594
808
|
}
|
|
595
|
-
async function executeSyncTable(
|
|
809
|
+
async function executeSyncTable(facts, safe, deps, exec) {
|
|
810
|
+
const { readable, name, storedSnapshot, fkDiff } = facts;
|
|
596
811
|
const adapter = readable.dbAdapter;
|
|
597
|
-
const name = readable.tableName;
|
|
598
812
|
const init = {
|
|
599
813
|
name,
|
|
600
814
|
status: "in-sync",
|
|
601
|
-
syncMethod: readable.syncMethod
|
|
815
|
+
syncMethod: readable.syncMethod,
|
|
816
|
+
dependsOn: exec.dependsOn
|
|
602
817
|
};
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
818
|
+
const ensureOpts = exec.deferForeignKeysTo ? { deferForeignKeysTo: exec.deferForeignKeysTo } : void 0;
|
|
819
|
+
if (facts.pendingRename && adapter.renameTable) {
|
|
820
|
+
await adapter.renameTable(facts.pendingRename);
|
|
821
|
+
init.renamedFrom = facts.pendingRename;
|
|
606
822
|
init.status = "alter";
|
|
607
823
|
}
|
|
608
|
-
let fkDiff;
|
|
609
|
-
const fkSnapshotName = init.renamedFrom ?? name;
|
|
610
|
-
const storedSnapshot = await deps.store.readTableSnapshot(fkSnapshotName);
|
|
611
|
-
if (storedSnapshot) fkDiff = computeForeignKeyDiff(readable.foreignKeys, storedSnapshot.foreignKeys);
|
|
612
824
|
const hasFkChanges = fkDiff ? hasForeignKeyChanges(fkDiff) : false;
|
|
613
825
|
if (adapter.getExistingColumns && adapter.syncColumns) {
|
|
614
|
-
const existing = await adapter.getExistingColumns();
|
|
826
|
+
const existing = facts.existing ?? await adapter.getExistingColumns();
|
|
615
827
|
if (existing.length === 0 && !init.renamedFrom) {
|
|
616
|
-
await adapter.ensureTable();
|
|
828
|
+
await adapter.ensureTable(ensureOpts);
|
|
617
829
|
init.status = "create";
|
|
618
|
-
} else if (existing.length > 0)
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
830
|
+
} else if (existing.length > 0) {
|
|
831
|
+
const diff = facts.diff ?? computeColumnDiff(readable.fieldDescriptors, existing, adapter.typeMapper?.bind(adapter));
|
|
832
|
+
if (hasFkChanges && !adapter.syncForeignKeys && adapter.recreateTable) {
|
|
833
|
+
await adapter.recreateTable();
|
|
834
|
+
init.recreated = true;
|
|
835
|
+
init.status = "alter";
|
|
836
|
+
if (diff.primaryKeyChanged) init.pkChange = {
|
|
837
|
+
...diff.primaryKeyChanged,
|
|
838
|
+
rebuild: true
|
|
839
|
+
};
|
|
840
|
+
} else {
|
|
841
|
+
if (hasFkChanges && fkDiff && adapter.dropForeignKeys) {
|
|
842
|
+
const keysToDrop = [...fkDiff.removed.map((fk) => fkKey(fk.fields)), ...fkDiff.changed.map((fk) => fkKey(fk.desired.fields))];
|
|
843
|
+
if (keysToDrop.length > 0) {
|
|
844
|
+
await adapter.dropForeignKeys(keysToDrop);
|
|
845
|
+
init.status = "alter";
|
|
846
|
+
}
|
|
628
847
|
}
|
|
848
|
+
await applyColumnDiff(adapter, readable, diff, init, safe, deps.logger, ensureOpts);
|
|
629
849
|
}
|
|
630
|
-
const typeMapper = adapter.typeMapper?.bind(adapter);
|
|
631
|
-
await applyColumnDiff(adapter, readable, computeColumnDiff(readable.fieldDescriptors, existing, typeMapper), init, safe, deps.logger);
|
|
632
850
|
}
|
|
633
851
|
} else if (adapter.syncColumns) if (!storedSnapshot) {
|
|
634
852
|
const existed = adapter.tableExists ? await adapter.tableExists() : false;
|
|
635
|
-
await adapter.ensureTable();
|
|
853
|
+
await adapter.ensureTable(ensureOpts);
|
|
636
854
|
if (!existed) init.status = "create";
|
|
637
|
-
} else
|
|
638
|
-
const existing = snapshotToExistingColumns(storedSnapshot);
|
|
639
|
-
await applyColumnDiff(adapter, readable, computeColumnDiff(readable.fieldDescriptors, existing, deps.resolveTypeMapper(adapter)), init, safe, deps.logger);
|
|
640
|
-
}
|
|
855
|
+
} else await applyColumnDiff(adapter, readable, facts.diff ?? computeColumnDiff(readable.fieldDescriptors, snapshotToExistingColumns(storedSnapshot), deps.resolveTypeMapper(adapter)), init, safe, deps.logger, ensureOpts);
|
|
641
856
|
else {
|
|
642
857
|
const existed = adapter.tableExists ? await adapter.tableExists() : true;
|
|
643
858
|
if (!init.recreated) {
|
|
644
|
-
await adapter.ensureTable();
|
|
859
|
+
await adapter.ensureTable(ensureOpts);
|
|
645
860
|
if (!existed) init.status = "create";
|
|
646
861
|
}
|
|
647
862
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
863
|
+
const optionDiff = facts.optionDiff;
|
|
864
|
+
if (init.status !== "create" && !init.recreated && init.status !== "error" && optionDiff && optionDiff.changed.length > 0) {
|
|
865
|
+
const hasDestructive = optionDiff.changed.some((c) => c.destructive);
|
|
866
|
+
if (safe) {
|
|
867
|
+
if (hasDestructive) {
|
|
868
|
+
init.optionChanges = optionDiff.changed;
|
|
869
|
+
init.status = "alter";
|
|
870
|
+
markSkipped(init, "table-options");
|
|
871
|
+
deps.logger.warn?.(`[schema-sync] Destructive table option change on "${name}" — recreate skipped (safe mode)`);
|
|
872
|
+
}
|
|
873
|
+
} else {
|
|
651
874
|
init.optionChanges = optionDiff.changed;
|
|
652
|
-
const hasDestructive = optionDiff.changed.some((c) => c.destructive);
|
|
653
875
|
const nonDestructive = optionDiff.changed.filter((c) => !c.destructive);
|
|
654
876
|
if (nonDestructive.length > 0 && adapter.applyTableOptions) {
|
|
655
877
|
await adapter.applyTableOptions(nonDestructive);
|
|
656
878
|
init.status = "alter";
|
|
657
879
|
}
|
|
658
880
|
if (hasDestructive) {
|
|
659
|
-
if (readable
|
|
881
|
+
if (willDropRecreate(readable, void 0, optionDiff)) {
|
|
882
|
+
deps.logger.warn?.(`[schema-sync] Destructive table option change on "${name}" — dropping and recreating`);
|
|
883
|
+
await dropAndRecreate(adapter, init, name, deps.logger, ensureOpts);
|
|
884
|
+
} else if (readable.syncMethod === "recreate" && adapter.recreateTable) {
|
|
660
885
|
deps.logger.warn?.(`[schema-sync] Destructive table option change on "${name}" — recreating with data preservation`);
|
|
661
886
|
await adapter.recreateTable();
|
|
662
887
|
init.status = "alter";
|
|
663
888
|
init.recreated = true;
|
|
664
|
-
} else if (adapter.dropTable) {
|
|
665
|
-
deps.logger.warn?.(`[schema-sync] Destructive table option change on "${name}" — dropping and recreating`);
|
|
666
|
-
await adapter.dropTable();
|
|
667
|
-
await adapter.ensureTable();
|
|
668
|
-
init.status = "alter";
|
|
669
|
-
init.recreated = true;
|
|
670
889
|
}
|
|
671
890
|
}
|
|
672
891
|
}
|
|
673
892
|
}
|
|
893
|
+
if (init.status === "error") return new SyncEntry(init);
|
|
674
894
|
try {
|
|
675
895
|
await adapter.syncIndexes();
|
|
676
|
-
if (adapter.syncForeignKeys) await adapter.syncForeignKeys();
|
|
896
|
+
if (adapter.syncForeignKeys && !exec.deferForeignKeysTo) await adapter.syncForeignKeys();
|
|
677
897
|
if (adapter.afterSyncTable) await adapter.afterSyncTable();
|
|
678
898
|
} catch (error) {
|
|
679
899
|
const msg = `Index/FK sync failed on ${name}: ${error.message}`;
|
|
@@ -683,6 +903,28 @@ async function executeSyncTable(readable, safe, trackedNames, deps) {
|
|
|
683
903
|
}
|
|
684
904
|
return new SyncEntry(init);
|
|
685
905
|
}
|
|
906
|
+
/**
|
|
907
|
+
* Deferred foreign-key pass for the members of a foreign-key cycle: runs
|
|
908
|
+
* `syncForeignKeys()` (which adds every FK missing from the live table) now
|
|
909
|
+
* that all members exist. A failure turns the table's entry into an error
|
|
910
|
+
* entry — the run continues and the hash is withheld.
|
|
911
|
+
*/
|
|
912
|
+
async function executeDeferredForeignKeys(readable, entry, deps) {
|
|
913
|
+
const adapter = readable.dbAdapter;
|
|
914
|
+
if (!adapter.syncForeignKeys || entry.status === "error") return entry;
|
|
915
|
+
try {
|
|
916
|
+
await adapter.syncForeignKeys();
|
|
917
|
+
return entry;
|
|
918
|
+
} catch (error) {
|
|
919
|
+
const msg = `FK sync failed on ${readable.tableName}: ${error.message}`;
|
|
920
|
+
deps.logger.error?.(`[schema-sync] ${msg}`);
|
|
921
|
+
return new SyncEntry({
|
|
922
|
+
...entry.toInit(),
|
|
923
|
+
status: "error",
|
|
924
|
+
errors: [...entry.errors, msg]
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
}
|
|
686
928
|
/** Determines whether a view's predecessor (on rename) or stale definition must be dropped. */
|
|
687
929
|
async function planViewSync(view, trackedNames, store) {
|
|
688
930
|
const isRenamed = !!(view.renamedFrom && trackedNames.has(view.renamedFrom));
|
|
@@ -696,27 +938,114 @@ async function dropOutdatedView(view, plan, space) {
|
|
|
696
938
|
if (plan.isRenamed) await space.dropViewByName(view.renamedFrom);
|
|
697
939
|
else if (plan.definitionChanged) await space.dropViewByName(view.tableName);
|
|
698
940
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
941
|
+
/** Status a managed view's entry gets for a given plan. */
|
|
942
|
+
function viewPlanStatus(view, plan, trackedNames) {
|
|
943
|
+
if (plan.isRenamed || plan.definitionChanged) return "alter";
|
|
944
|
+
return trackedNames.has(view.tableName) ? "in-sync" : "create";
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Creates (or recreates) a managed view. The stale/renamed predecessor was
|
|
948
|
+
* dropped by `SchemaSync.execute()` before the table ops; `planned` is the
|
|
949
|
+
* entry discovery computed for it and is returned as the run entry.
|
|
950
|
+
*/
|
|
951
|
+
async function executeSyncView(view, planned) {
|
|
705
952
|
await view.dbAdapter.ensureTable();
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
953
|
+
return planned;
|
|
954
|
+
}
|
|
955
|
+
/** The plan-shaped summary of a diff's type changes (`entry.typeChanges`). */
|
|
956
|
+
function describeTypeChanges(diff) {
|
|
957
|
+
return diff.typeChanged.map((tc) => ({
|
|
958
|
+
column: tc.field.physicalName,
|
|
959
|
+
fromType: tc.existingType,
|
|
960
|
+
toType: tc.field.designType
|
|
961
|
+
}));
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Whether the run drops and recreates this table's physical table — the two
|
|
965
|
+
* `dropAndRecreate` sites: a `@db.sync.method "drop"` type change (`diff`),
|
|
966
|
+
* or a destructive table-option change (`optionDiff`) on a table that does
|
|
967
|
+
* not resolve it with `@db.sync.method "recreate"` (PostgreSQL / MySQL
|
|
968
|
+
* `recreateTable` handle inbound FKs themselves, so `'recreate'` is not a
|
|
969
|
+
* trigger). Both need `dropTable`. One predicate for discovery (which
|
|
970
|
+
* schedules the removed tables that block the drop right before it — both
|
|
971
|
+
* halves are covered since 0.1.128) and for the executor, whose two sites
|
|
972
|
+
* each pass the half they decide and perform the drop — or, in safe mode,
|
|
973
|
+
* record it as skipped — so they cannot drift.
|
|
974
|
+
*/
|
|
975
|
+
function willDropRecreate(readable, diff, optionDiff) {
|
|
976
|
+
const adapter = readable.dbAdapter;
|
|
977
|
+
if (!adapter.dropTable) return false;
|
|
978
|
+
if (readable.syncMethod === "drop" && (diff?.typeChanged.length ?? 0) > 0) return true;
|
|
979
|
+
return (optionDiff?.changed.some((c) => c.destructive) ?? false) && !(readable.syncMethod === "recreate" && !!adapter.recreateTable);
|
|
718
980
|
}
|
|
719
|
-
|
|
981
|
+
/** Records work safe mode skipped on the entry (see `SyncEntry.skipped`). */
|
|
982
|
+
function markSkipped(init, kind) {
|
|
983
|
+
init.skipped = [...init.skipped ?? [], kind];
|
|
984
|
+
}
|
|
985
|
+
/** Whether nullable/default changes need DDL on this adapter (schema-less ones only update the snapshot). */
|
|
986
|
+
function needsDdlForNullableDefaults(adapter) {
|
|
987
|
+
return !!adapter.supportsColumnModify && !!adapter.syncColumns || !!adapter.recreateTable;
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* The work `run({ safe: true })` skips on this table, in the order the
|
|
991
|
+
* executor records it — the plan's safe branch reports exactly what the run
|
|
992
|
+
* reports. A recreate that runs even in safe mode (an FK change on an adapter
|
|
993
|
+
* without `syncForeignKeys`, a `'recreate'` type change) carries every other
|
|
994
|
+
* change with it, so nothing is skipped then; an errored plan entry issues no
|
|
995
|
+
* DDL either way.
|
|
996
|
+
*/
|
|
997
|
+
function safeModeSkips(facts) {
|
|
998
|
+
const { readable, diff, optionDiff, fkDiff } = facts;
|
|
999
|
+
const adapter = readable.dbAdapter;
|
|
1000
|
+
const out = [];
|
|
1001
|
+
if (facts.planEntry.status === "error") return out;
|
|
1002
|
+
if (fkDiff && hasForeignKeyChanges(fkDiff) && !adapter.syncForeignKeys && adapter.recreateTable) return out;
|
|
1003
|
+
let recreated = false;
|
|
1004
|
+
if (diff && diff.typeChanged.length > 0) {
|
|
1005
|
+
if (willDropRecreate(readable, diff, void 0)) out.push("recreate");
|
|
1006
|
+
else if (readable.syncMethod === "recreate" && adapter.recreateTable) recreated = true;
|
|
1007
|
+
}
|
|
1008
|
+
if (recreated) return out;
|
|
1009
|
+
if (diff && (diff.nullableChanged.length > 0 || diff.defaultChanged.length > 0) && needsDdlForNullableDefaults(adapter)) out.push("nullable-defaults");
|
|
1010
|
+
if (diff?.primaryKeyChanged) out.push("pk-rebuild");
|
|
1011
|
+
if (optionDiff?.changed.some((c) => c.destructive)) out.push("table-options");
|
|
1012
|
+
return out;
|
|
1013
|
+
}
|
|
1014
|
+
/** The plan-shaped nullable / default change summaries of a diff. */
|
|
1015
|
+
function describeNullableDefaults(diff) {
|
|
1016
|
+
return {
|
|
1017
|
+
nullableChanges: diff.nullableChanged.map((nc) => ({
|
|
1018
|
+
column: nc.field.physicalName,
|
|
1019
|
+
toNullable: nc.field.optional
|
|
1020
|
+
})),
|
|
1021
|
+
defaultChanges: diff.defaultChanged.map((dc) => ({
|
|
1022
|
+
column: dc.field.physicalName,
|
|
1023
|
+
oldDefault: dc.oldDefault,
|
|
1024
|
+
newDefault: dc.newDefault
|
|
1025
|
+
}))
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* `dropTable()` + `ensureTable()` for `@db.sync.method "drop"` and destructive
|
|
1030
|
+
* option changes. Sync-owned drops never CASCADE: when something outside the
|
|
1031
|
+
* sync inventory (a user view, an unmanaged FK) still depends on the table the
|
|
1032
|
+
* engine refuses, and that becomes an error entry for this table — not a
|
|
1033
|
+
* thrown run.
|
|
1034
|
+
*/
|
|
1035
|
+
async function dropAndRecreate(adapter, init, name, logger, ensureOpts) {
|
|
1036
|
+
try {
|
|
1037
|
+
await adapter.dropTable();
|
|
1038
|
+
await adapter.ensureTable(ensureOpts);
|
|
1039
|
+
init.recreated = true;
|
|
1040
|
+
init.status = "alter";
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
const msg = `Drop of "${name}" failed: ${error.message}`;
|
|
1043
|
+
logger.error?.(`[schema-sync] ${msg}`);
|
|
1044
|
+
init.errors = [...init.errors ?? [], msg];
|
|
1045
|
+
init.status = "error";
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
async function applyColumnDiff(adapter, readable, diff, init, safe, logger, ensureOpts) {
|
|
720
1049
|
const name = readable.tableName;
|
|
721
1050
|
if (diff.conflicts.length > 0) {
|
|
722
1051
|
const errors = diff.conflicts.map((c) => `Column rename conflict on ${name}: cannot rename "${c.oldName}" → "${c.field.physicalName}" because "${c.conflictsWith}" already exists.`);
|
|
@@ -727,12 +1056,14 @@ async function applyColumnDiff(adapter, readable, diff, init, safe, logger) {
|
|
|
727
1056
|
let needsSyncColumns = false;
|
|
728
1057
|
if (diff.typeChanged.length > 0 && init.status !== "error") {
|
|
729
1058
|
const syncMethod = readable.syncMethod;
|
|
730
|
-
if (
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
init.recreated = true;
|
|
1059
|
+
if (willDropRecreate(readable, diff, void 0)) if (safe) {
|
|
1060
|
+
init.typeChanges = describeTypeChanges(diff);
|
|
1061
|
+
markSkipped(init, "recreate");
|
|
734
1062
|
init.status = "alter";
|
|
735
|
-
|
|
1063
|
+
const cols = init.typeChanges.map((tc) => `${tc.column}: ${tc.fromType} → ${tc.toType}`).join(", ");
|
|
1064
|
+
logger.warn?.(`[schema-sync] Type change on "${name}" (${cols}) — drop-and-recreate skipped (safe mode)`);
|
|
1065
|
+
} else await dropAndRecreate(adapter, init, name, logger, ensureOpts);
|
|
1066
|
+
else if (syncMethod === "recreate" && adapter.recreateTable) {
|
|
736
1067
|
await adapter.recreateTable();
|
|
737
1068
|
init.recreated = true;
|
|
738
1069
|
init.status = "alter";
|
|
@@ -750,21 +1081,69 @@ async function applyColumnDiff(adapter, readable, diff, init, safe, logger) {
|
|
|
750
1081
|
init.status = "error";
|
|
751
1082
|
}
|
|
752
1083
|
}
|
|
753
|
-
if (
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1084
|
+
if (diff.nullableChanged.length > 0 || diff.defaultChanged.length > 0) {
|
|
1085
|
+
if (!safe && !init.recreated && init.status !== "error") if (adapter.supportsColumnModify && adapter.syncColumns) {
|
|
1086
|
+
needsSyncColumns = true;
|
|
1087
|
+
init.status = "alter";
|
|
1088
|
+
} else if (adapter.recreateTable) {
|
|
1089
|
+
await adapter.recreateTable();
|
|
1090
|
+
init.recreated = true;
|
|
1091
|
+
init.status = "alter";
|
|
1092
|
+
} else init.status = "alter";
|
|
1093
|
+
else if (init.status !== "error") {
|
|
1094
|
+
if (safe && !init.recreated && needsDdlForNullableDefaults(adapter)) {
|
|
1095
|
+
Object.assign(init, describeNullableDefaults(diff));
|
|
1096
|
+
markSkipped(init, "nullable-defaults");
|
|
1097
|
+
logger.warn?.(`[schema-sync] Nullable/default change on "${name}" — skipped (safe mode)`);
|
|
1098
|
+
}
|
|
1099
|
+
init.status = "alter";
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
762
1102
|
if (!init.recreated && init.status !== "error" && (diff.added.length > 0 || diff.renamed.length > 0 || needsSyncColumns) && adapter.syncColumns) {
|
|
763
1103
|
const syncResult = await adapter.syncColumns(diff);
|
|
764
1104
|
init.columnsAdded = syncResult.added;
|
|
765
1105
|
init.columnsRenamed = syncResult.renamed;
|
|
766
1106
|
if (syncResult.added.length > 0 || (syncResult.renamed?.length ?? 0) > 0 || needsSyncColumns) init.status = "alter";
|
|
767
1107
|
}
|
|
1108
|
+
if (diff.primaryKeyChanged && init.status !== "error") {
|
|
1109
|
+
const { from, to } = diff.primaryKeyChanged;
|
|
1110
|
+
const label = `(${from.join(", ")} → ${to.join(", ")})`;
|
|
1111
|
+
if (init.recreated) init.pkChange = {
|
|
1112
|
+
from,
|
|
1113
|
+
to,
|
|
1114
|
+
rebuild: true
|
|
1115
|
+
};
|
|
1116
|
+
else if (safe) {
|
|
1117
|
+
logger.warn?.(`[schema-sync] Primary key of "${name}" changed ${label} — rebuild skipped (safe mode)`);
|
|
1118
|
+
init.pkChange = {
|
|
1119
|
+
from,
|
|
1120
|
+
to,
|
|
1121
|
+
rebuild: false
|
|
1122
|
+
};
|
|
1123
|
+
markSkipped(init, "pk-rebuild");
|
|
1124
|
+
init.status = "alter";
|
|
1125
|
+
} else if (adapter.rebuildPrimaryKey || adapter.recreateTable) {
|
|
1126
|
+
if (adapter.rebuildPrimaryKey) await adapter.rebuildPrimaryKey({
|
|
1127
|
+
from,
|
|
1128
|
+
to
|
|
1129
|
+
});
|
|
1130
|
+
else {
|
|
1131
|
+
await adapter.recreateTable();
|
|
1132
|
+
init.recreated = true;
|
|
1133
|
+
}
|
|
1134
|
+
init.pkChange = {
|
|
1135
|
+
from,
|
|
1136
|
+
to,
|
|
1137
|
+
rebuild: true
|
|
1138
|
+
};
|
|
1139
|
+
init.status = "alter";
|
|
1140
|
+
} else {
|
|
1141
|
+
const msg = `Primary key of "${name}" changed ${label} but the adapter cannot rebuild primary keys. Migrate manually and re-run.`;
|
|
1142
|
+
logger.error?.(`[schema-sync] ${msg}`);
|
|
1143
|
+
init.errors = [...init.errors ?? [], msg];
|
|
1144
|
+
init.status = "error";
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
768
1147
|
if (!safe && !init.recreated && init.status !== "error" && diff.removed.length > 0 && adapter.dropColumns) {
|
|
769
1148
|
const colNames = diff.removed.map((c) => c.name);
|
|
770
1149
|
if (adapter.dropIndexesForColumns) await adapter.dropIndexesForColumns(colNames);
|
|
@@ -775,6 +1154,10 @@ async function applyColumnDiff(adapter, readable, diff, init, safe, logger) {
|
|
|
775
1154
|
}
|
|
776
1155
|
//#endregion
|
|
777
1156
|
//#region src/schema/schema-sync.ts
|
|
1157
|
+
/** Keeps a removed-table group tracked (safe mode, blocked or failed drop). */
|
|
1158
|
+
function retain(group, ctx) {
|
|
1159
|
+
ctx.retained.push(...group.map((name) => ctx.d.removedByName.get(name)));
|
|
1160
|
+
}
|
|
778
1161
|
/** Builds a human-readable description of what changed in an FK constraint. */
|
|
779
1162
|
function buildFkChangeDetails(desired, existing) {
|
|
780
1163
|
const parts = [];
|
|
@@ -784,6 +1167,49 @@ function buildFkChangeDetails(desired, existing) {
|
|
|
784
1167
|
if ((existing.onUpdate ?? void 0) !== (desired.onUpdate ?? void 0)) parts.push(`onUpdate ${existing.onUpdate ?? "noAction"} → ${desired.onUpdate ?? "noAction"}`);
|
|
785
1168
|
return parts.join(", ");
|
|
786
1169
|
}
|
|
1170
|
+
function pkLabel(change) {
|
|
1171
|
+
return `(${change.from.join(", ")} → ${change.to.join(", ")})`;
|
|
1172
|
+
}
|
|
1173
|
+
function sortedUnique(names) {
|
|
1174
|
+
return [...new Set(names)].toSorted();
|
|
1175
|
+
}
|
|
1176
|
+
/** Adds `msg` to the refusal list of `name`. */
|
|
1177
|
+
function addRefusal(refusals, name, msg) {
|
|
1178
|
+
const list = refusals.get(name);
|
|
1179
|
+
if (list) list.push(msg);
|
|
1180
|
+
else refusals.set(name, [msg]);
|
|
1181
|
+
}
|
|
1182
|
+
/** The entry with the refusal texts folded in as a refused `error` entry. */
|
|
1183
|
+
function withRefusals(init, msgs) {
|
|
1184
|
+
if (!msgs || msgs.length === 0) return new SyncEntry(init);
|
|
1185
|
+
return new SyncEntry({
|
|
1186
|
+
...init,
|
|
1187
|
+
status: "error",
|
|
1188
|
+
errors: [...init.errors ?? [], ...msgs],
|
|
1189
|
+
refused: true
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
/** Human label of each kind of skipped work, for the safe-mode warning. */
|
|
1193
|
+
const SKIPPED_LABELS = {
|
|
1194
|
+
"pk-rebuild": "primary-key rebuild",
|
|
1195
|
+
recreate: "drop-and-recreate",
|
|
1196
|
+
"table-options": "table-option recreate",
|
|
1197
|
+
"nullable-defaults": "nullable/default change"
|
|
1198
|
+
};
|
|
1199
|
+
/**
|
|
1200
|
+
* The removed tables whose drop must precede `t`'s own drop/rebuild: those
|
|
1201
|
+
* with a live FK to `t` (its probed inbound FKs; when the adapter cannot
|
|
1202
|
+
* probe them, the removed tables' stored snapshots — the same fallback as
|
|
1203
|
+
* the drop graph), closed over the removed-table graph (child → parent) so
|
|
1204
|
+
* a referencer's own removed children come first.
|
|
1205
|
+
*/
|
|
1206
|
+
function removedBlockers(t, removedTables, removedTableSet, dropEdges) {
|
|
1207
|
+
const direct = [];
|
|
1208
|
+
if (t.inboundFks) {
|
|
1209
|
+
for (const fk of t.inboundFks) if (removedTableSet.has(fk.table)) direct.push(fk.table);
|
|
1210
|
+
} else for (const r of removedTables) if (r.snapshot && "foreignKeys" in r.snapshot && r.snapshot.foreignKeys.some((fk) => fk.targetTable === t.dbName)) direct.push(r.name);
|
|
1211
|
+
return reachable(direct, dropEdges);
|
|
1212
|
+
}
|
|
787
1213
|
var SchemaSync = class {
|
|
788
1214
|
space;
|
|
789
1215
|
store;
|
|
@@ -806,7 +1232,7 @@ var SchemaSync = class {
|
|
|
806
1232
|
if (readable.isView) {
|
|
807
1233
|
const view = readable;
|
|
808
1234
|
if (view.isExternal) externalViews.push(view);
|
|
809
|
-
else views.push(
|
|
1235
|
+
else views.push(view);
|
|
810
1236
|
} else tables.push(readable);
|
|
811
1237
|
}
|
|
812
1238
|
const allReadables = [
|
|
@@ -871,20 +1297,13 @@ var SchemaSync = class {
|
|
|
871
1297
|
});
|
|
872
1298
|
}
|
|
873
1299
|
/**
|
|
874
|
-
* Detects tables/views present in the previous sync but absent from the
|
|
875
|
-
*
|
|
1300
|
+
* Detects tables/views present in the previous sync but absent from the
|
|
1301
|
+
* current schema (external views excluded — sync owns no DDL for them).
|
|
876
1302
|
*/
|
|
877
|
-
|
|
878
|
-
previous ??= await this.store.readTrackedList();
|
|
1303
|
+
detectRemoved(currentReadables, previous) {
|
|
879
1304
|
const currentSet = new Set(currentReadables.map((t) => t.tableName));
|
|
880
1305
|
const renameFromSet = new Set(currentReadables.map((r) => r.renamedFrom).filter(Boolean));
|
|
881
|
-
|
|
882
|
-
for (const entry of previous) if (!currentSet.has(entry.name) && !renameFromSet.has(entry.name)) removed.push(new SyncEntry({
|
|
883
|
-
name: entry.name,
|
|
884
|
-
viewType: entry.viewType,
|
|
885
|
-
status: "drop"
|
|
886
|
-
}));
|
|
887
|
-
return removed;
|
|
1306
|
+
return previous.filter((entry) => !currentSet.has(entry.name) && !renameFromSet.has(entry.name) && entry.viewType !== "E");
|
|
888
1307
|
}
|
|
889
1308
|
/**
|
|
890
1309
|
* Starts a periodic heartbeat that extends the lock's TTL while sync runs.
|
|
@@ -927,7 +1346,19 @@ var SchemaSync = class {
|
|
|
927
1346
|
if (reason) throw new Error(reason);
|
|
928
1347
|
}
|
|
929
1348
|
/**
|
|
930
|
-
* Runs schema synchronization with distributed locking
|
|
1349
|
+
* Runs schema synchronization with distributed locking, in three phases:
|
|
1350
|
+
*
|
|
1351
|
+
* 1. **Discover** (read-only, under the lock): introspect every table once,
|
|
1352
|
+
* diff columns/FKs, read tracking, build the dependency graph.
|
|
1353
|
+
* 2. **Pre-flight** (pure): every change that no order of DDL can apply
|
|
1354
|
+
* safely becomes a refusal. One refusal → `status: "refused"`, no DDL,
|
|
1355
|
+
* nothing persisted, lock released.
|
|
1356
|
+
* 3. **Execute** in dependency order: stale/removed views → inbound FKs of
|
|
1357
|
+
* key-changing tables → tables (parents first; a removed table that
|
|
1358
|
+
* blocks a table's drop-and-recreate or key rebuild is dropped right
|
|
1359
|
+
* before it) → deferred FKs of cycles → managed views → external-view
|
|
1360
|
+
* checks → remaining removed tables (children first) →
|
|
1361
|
+
* snapshots/tracking/hash.
|
|
931
1362
|
*/
|
|
932
1363
|
async run(types, opts) {
|
|
933
1364
|
this.logger = opts?.logger ?? this.logger;
|
|
@@ -937,7 +1368,9 @@ var SchemaSync = class {
|
|
|
937
1368
|
const pollIntervalMs = opts?.pollIntervalMs ?? 500;
|
|
938
1369
|
const force = opts?.force ?? false;
|
|
939
1370
|
const safe = opts?.safe ?? false;
|
|
940
|
-
const
|
|
1371
|
+
const onError = opts?.onError ?? "warn";
|
|
1372
|
+
const resolved = await this.resolveAndHash(types);
|
|
1373
|
+
const { hash } = resolved;
|
|
941
1374
|
await this.store.ensureControlTable();
|
|
942
1375
|
if (!force) {
|
|
943
1376
|
if (await this.store.readHash() === hash) return {
|
|
@@ -964,163 +1397,151 @@ var SchemaSync = class {
|
|
|
964
1397
|
entries: []
|
|
965
1398
|
};
|
|
966
1399
|
}
|
|
967
|
-
const
|
|
968
|
-
...tables,
|
|
969
|
-
...views,
|
|
970
|
-
...externalViews
|
|
971
|
-
];
|
|
972
|
-
const previouslyTracked = await this.store.readTrackedList();
|
|
973
|
-
const trackedNames = new Set(previouslyTracked.map((e) => e.name));
|
|
974
|
-
const deps = this.buildExecutorDeps();
|
|
975
|
-
const viewPlans = /* @__PURE__ */ new Map();
|
|
976
|
-
for (const readable of views) {
|
|
977
|
-
const view = readable;
|
|
978
|
-
const plan = await planViewSync(view, trackedNames, this.store);
|
|
979
|
-
viewPlans.set(view.tableName, plan);
|
|
980
|
-
await dropOutdatedView(view, plan, this.space);
|
|
981
|
-
}
|
|
982
|
-
const entries = [];
|
|
983
|
-
for (const readable of tables) {
|
|
984
|
-
this.assertLockHeld(heartbeat.getAbortReason);
|
|
985
|
-
entries.push(await executeSyncTable(readable, safe, trackedNames, deps));
|
|
986
|
-
}
|
|
987
|
-
this.assertLockHeld(heartbeat.getAbortReason);
|
|
988
|
-
const removed = await this.detectRemoved(allReadables, previouslyTracked);
|
|
989
|
-
for (const readable of views) {
|
|
990
|
-
this.assertLockHeld(heartbeat.getAbortReason);
|
|
991
|
-
entries.push(await executeSyncView(readable, trackedNames, deps, viewPlans.get(readable.tableName)));
|
|
992
|
-
}
|
|
993
|
-
const externalEntries = await Promise.all(externalViews.map((v) => this.checkExternalView(v)));
|
|
994
|
-
entries.push(...externalEntries);
|
|
995
|
-
this.assertLockHeld(heartbeat.getAbortReason);
|
|
996
|
-
if (!safe) {
|
|
997
|
-
for (const entry of removed) {
|
|
998
|
-
if (entry.viewType === "E") continue;
|
|
999
|
-
if (entry.viewType) await this.space.dropViewByName(entry.name);
|
|
1000
|
-
else await this.space.dropTableByName(entry.name);
|
|
1001
|
-
}
|
|
1002
|
-
entries.push(...removed.filter((e) => e.viewType !== "E"));
|
|
1003
|
-
}
|
|
1400
|
+
const d = await this.discover(resolved, safe);
|
|
1004
1401
|
this.assertLockHeld(heartbeat.getAbortReason);
|
|
1005
|
-
const
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
if (!safe) for (const entry of removed) {
|
|
1015
|
-
if (entry.viewType === "E") continue;
|
|
1016
|
-
await this.store.deleteTableSnapshot(entry.name);
|
|
1402
|
+
const refusals = this.preflight(d, safe);
|
|
1403
|
+
if (refusals.size > 0) {
|
|
1404
|
+
const result = {
|
|
1405
|
+
status: "refused",
|
|
1406
|
+
schemaHash: hash,
|
|
1407
|
+
entries: this.buildPlanEntries(d, safe, refusals)
|
|
1408
|
+
};
|
|
1409
|
+
this.reportOutcome(result, onError);
|
|
1410
|
+
return result;
|
|
1017
1411
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
if (erroredNames.size === 0) await this.store.writeHash(hash);
|
|
1021
|
-
const result = {
|
|
1022
|
-
status: "synced",
|
|
1023
|
-
schemaHash: hash,
|
|
1024
|
-
entries
|
|
1025
|
-
};
|
|
1026
|
-
this.reportOutcome(result, opts?.onError ?? "warn");
|
|
1412
|
+
const result = await this.execute(d, safe, heartbeat.getAbortReason);
|
|
1413
|
+
this.reportOutcome(result, onError);
|
|
1027
1414
|
return result;
|
|
1028
1415
|
} finally {
|
|
1029
1416
|
heartbeat.stop();
|
|
1030
1417
|
await this.store.releaseLock(podId);
|
|
1031
1418
|
}
|
|
1032
1419
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
* silently lost to the NoopLogger default — when no real logger is
|
|
1036
|
-
* configured, warnings/errors go to `console` (a production consumer lost
|
|
1037
|
-
* months of failed-index errors to the silent default).
|
|
1038
|
-
*/
|
|
1039
|
-
reportOutcome(result, onError) {
|
|
1040
|
-
if (onError === "silent" || result.entries.length === 0) return;
|
|
1041
|
-
const out = this.logger === require_db_view.NoopLogger ? console : this.logger;
|
|
1042
|
-
const counts = /* @__PURE__ */ new Map();
|
|
1043
|
-
for (const entry of result.entries) counts.set(entry.status, (counts.get(entry.status) ?? 0) + 1);
|
|
1044
|
-
const summary = [...counts.entries()].map(([status, n]) => `${n} ${status}`).join(", ");
|
|
1045
|
-
out.log(`[schema-sync] ${result.status}: ${summary}`);
|
|
1046
|
-
const errored = result.entries.filter((e) => e.hasErrors);
|
|
1047
|
-
if (errored.length === 0) return;
|
|
1048
|
-
const lines = errored.map((e) => `[schema-sync] "${e.name}" failed: ${e.errors.join("; ") || e.status}`);
|
|
1049
|
-
for (const line of lines) out.error(line);
|
|
1050
|
-
if (onError === "throw") throw new Error(`[schema-sync] ${errored.length} entr${errored.length === 1 ? "y" : "ies"} failed:\n${lines.join("\n")}`);
|
|
1051
|
-
}
|
|
1052
|
-
/**
|
|
1053
|
-
* Computes a dry-run plan showing what `run()` would do, without executing any DDL.
|
|
1054
|
-
*/
|
|
1055
|
-
async plan(types, opts) {
|
|
1056
|
-
const force = opts?.force ?? false;
|
|
1057
|
-
const safe = opts?.safe ?? false;
|
|
1058
|
-
const { tables, views, externalViews, hash } = await this.resolveAndHash(types);
|
|
1420
|
+
async discover(resolved, safe) {
|
|
1421
|
+
const { tables, views, externalViews, hash } = resolved;
|
|
1059
1422
|
const allReadables = [
|
|
1060
1423
|
...tables,
|
|
1061
1424
|
...views,
|
|
1062
1425
|
...externalViews
|
|
1063
1426
|
];
|
|
1064
|
-
await this.store.ensureControlTable();
|
|
1065
1427
|
const previouslyTracked = await this.store.readTrackedList();
|
|
1066
1428
|
const trackedNames = new Set(previouslyTracked.map((e) => e.name));
|
|
1067
|
-
|
|
1068
|
-
const
|
|
1429
|
+
const removedTracked = this.detectRemoved(allReadables, previouslyTracked);
|
|
1430
|
+
const probeRecreateInbound = !safe && removedTracked.some((r) => !r.isView);
|
|
1431
|
+
const tableFacts = await Promise.all(tables.map((r) => this.discoverTable(r, trackedNames, probeRecreateInbound)));
|
|
1432
|
+
const tableNames = new Set(tableFacts.map((t) => t.name));
|
|
1433
|
+
const tableByName = new Map(tableFacts.map((t) => [t.name, t]));
|
|
1434
|
+
const tableByDbName = new Map(tableFacts.map((t) => [t.dbName, t]));
|
|
1435
|
+
const viewFacts = await Promise.all(views.map(async (view) => ({
|
|
1436
|
+
plan: await planViewSync(view, trackedNames, this.store),
|
|
1437
|
+
kind: await view.dbAdapter.getObjectKind?.(view.tableName)
|
|
1438
|
+
})));
|
|
1439
|
+
const viewPlans = /* @__PURE__ */ new Map();
|
|
1440
|
+
const viewEntries = [];
|
|
1441
|
+
const viewObjectKinds = /* @__PURE__ */ new Map();
|
|
1442
|
+
for (const [i, view] of views.entries()) {
|
|
1443
|
+
const { plan, kind } = viewFacts[i];
|
|
1444
|
+
viewPlans.set(view.tableName, plan);
|
|
1445
|
+
viewObjectKinds.set(view.tableName, kind);
|
|
1446
|
+
viewEntries.push(new SyncEntry({
|
|
1447
|
+
name: view.tableName,
|
|
1448
|
+
status: viewPlanStatus(view, plan, trackedNames),
|
|
1449
|
+
viewType: view.viewPlan.materialized ? "M" : "V",
|
|
1450
|
+
renamedFrom: plan.isRenamed ? view.renamedFrom : void 0,
|
|
1451
|
+
recreated: plan.definitionChanged || void 0,
|
|
1452
|
+
dependsOn: sortedUnique([view.viewPlan.entryTable, ...view.viewPlan.joins.map((j) => j.targetTable)].filter((n) => tableNames.has(n)))
|
|
1453
|
+
}));
|
|
1454
|
+
}
|
|
1069
1455
|
const externalEntries = await Promise.all(externalViews.map((v) => this.checkExternalView(v)));
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1456
|
+
const probeInbound = !safe && removedTracked.filter((r) => !r.isView).length >= 2;
|
|
1457
|
+
const removed = await Promise.all(removedTracked.map(async (entry) => ({
|
|
1458
|
+
...entry,
|
|
1459
|
+
snapshot: await this.store.readTableSnapshot(entry.name),
|
|
1460
|
+
inbound: probeInbound && !entry.isView ? await this.space.getReferencingForeignKeys(entry.name) : void 0
|
|
1461
|
+
})));
|
|
1462
|
+
const removedByName = new Map(removed.map((r) => [r.name, r]));
|
|
1463
|
+
const externalTargetAdapters = /* @__PURE__ */ new Map();
|
|
1464
|
+
for (const t of tableFacts) for (const fk of t.readable.foreignKeys.values()) if (fk.targetTable !== t.name && !tableNames.has(fk.targetTable)) externalTargetAdapters.set(fk.targetTable, t.readable.dbAdapter);
|
|
1465
|
+
const externalTargets = new Map(await Promise.all([...externalTargetAdapters].map(async ([target, adapter]) => [target, await this.objectPresent(adapter, target)])));
|
|
1466
|
+
const createEdges = [];
|
|
1467
|
+
const tableDependsOn = /* @__PURE__ */ new Map();
|
|
1468
|
+
for (const t of tableFacts) {
|
|
1469
|
+
const parents = /* @__PURE__ */ new Set();
|
|
1470
|
+
for (const fk of t.readable.foreignKeys.values()) if (fk.targetTable !== t.name && tableNames.has(fk.targetTable)) {
|
|
1471
|
+
createEdges.push([t.name, fk.targetTable]);
|
|
1472
|
+
parents.add(fk.targetTable);
|
|
1473
|
+
}
|
|
1474
|
+
tableDependsOn.set(t.name, sortedUnique(parents));
|
|
1080
1475
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
typeChanges: [],
|
|
1103
|
-
recreated: false
|
|
1104
|
-
}));
|
|
1105
|
-
removed = [];
|
|
1476
|
+
const createOrder = topoOrder(tableNames, createEdges);
|
|
1477
|
+
const removedTables = removed.filter((r) => !r.isView);
|
|
1478
|
+
const removedTableSet = new Set(removedTables.map((r) => r.name));
|
|
1479
|
+
const dropEdges = [];
|
|
1480
|
+
for (const r of removedTables) if (r.inbound) {
|
|
1481
|
+
for (const fk of r.inbound) if (fk.table !== r.name && removedTableSet.has(fk.table)) dropEdges.push([fk.table, r.name]);
|
|
1482
|
+
} else if (r.snapshot && "foreignKeys" in r.snapshot) {
|
|
1483
|
+
for (const fk of r.snapshot.foreignKeys) if (fk.targetTable !== r.name && removedTableSet.has(fk.targetTable)) dropEdges.push([r.name, fk.targetTable]);
|
|
1484
|
+
}
|
|
1485
|
+
const dropOrder = topoOrder(removedTableSet, dropEdges).toReversed();
|
|
1486
|
+
const earlyDrops = /* @__PURE__ */ new Map();
|
|
1487
|
+
const earlyGroups = /* @__PURE__ */ new Set();
|
|
1488
|
+
if (!safe) for (const name of createOrder.flat()) {
|
|
1489
|
+
const t = tableByName.get(name);
|
|
1490
|
+
if (!t.dropsTable) continue;
|
|
1491
|
+
const blockers = removedBlockers(t, removedTables, removedTableSet, dropEdges);
|
|
1492
|
+
const groups = dropOrder.filter((group) => !earlyGroups.has(group) && group.some((n) => blockers.has(n)));
|
|
1493
|
+
if (groups.length === 0) continue;
|
|
1494
|
+
for (const group of groups) earlyGroups.add(group);
|
|
1495
|
+
earlyDrops.set(name, groups);
|
|
1496
|
+
tableDependsOn.set(name, sortedUnique([...tableDependsOn.get(name), ...groups.flat()]));
|
|
1106
1497
|
}
|
|
1107
|
-
|
|
1498
|
+
const lateDropOrder = dropOrder.filter((group) => !earlyGroups.has(group));
|
|
1499
|
+
const dropEntries = /* @__PURE__ */ new Map();
|
|
1500
|
+
for (const r of removed) if (r.isView) dropEntries.set(r.name, new SyncEntry({
|
|
1501
|
+
name: r.name,
|
|
1502
|
+
viewType: r.viewType,
|
|
1503
|
+
status: "drop"
|
|
1504
|
+
}));
|
|
1505
|
+
for (const group of dropOrder) for (const name of group) dropEntries.set(name, new SyncEntry({
|
|
1506
|
+
name,
|
|
1507
|
+
status: "drop",
|
|
1508
|
+
dependsOn: sortedUnique(dropEdges.filter(([, to]) => to === name).map(([from]) => from)),
|
|
1509
|
+
dropGroup: group.length > 1 ? group : void 0
|
|
1510
|
+
}));
|
|
1108
1511
|
return {
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1512
|
+
tables: tableFacts,
|
|
1513
|
+
tableByName,
|
|
1514
|
+
tableByDbName,
|
|
1515
|
+
views,
|
|
1516
|
+
externalViews,
|
|
1517
|
+
allReadables,
|
|
1518
|
+
hash,
|
|
1519
|
+
trackedNames,
|
|
1520
|
+
viewPlans,
|
|
1521
|
+
viewEntries,
|
|
1522
|
+
viewObjectKinds,
|
|
1523
|
+
externalEntries,
|
|
1524
|
+
removed,
|
|
1525
|
+
removedByName,
|
|
1526
|
+
removedTableSet,
|
|
1527
|
+
dropEntries,
|
|
1528
|
+
createOrder,
|
|
1529
|
+
earlyDrops,
|
|
1530
|
+
lateDropOrder,
|
|
1531
|
+
tableDependsOn,
|
|
1532
|
+
externalTargets
|
|
1117
1533
|
};
|
|
1118
1534
|
}
|
|
1119
|
-
/**
|
|
1120
|
-
|
|
1121
|
-
return adapter.
|
|
1535
|
+
/** Whether an object named `name` exists, or `undefined` when the adapter cannot tell. */
|
|
1536
|
+
async objectPresent(adapter, name) {
|
|
1537
|
+
return adapter.getObjectKind ? await adapter.getObjectKind(name) !== void 0 : void 0;
|
|
1122
1538
|
}
|
|
1123
|
-
|
|
1539
|
+
/**
|
|
1540
|
+
* Introspects one desired table and computes its plan entry.
|
|
1541
|
+
* `probeRecreateInbound` — whether a drop-and-recreate's live inbound FKs
|
|
1542
|
+
* are worth probing (a removed table exists that could block it).
|
|
1543
|
+
*/
|
|
1544
|
+
async discoverTable(readable, trackedNames, probeRecreateInbound) {
|
|
1124
1545
|
const adapter = readable.dbAdapter;
|
|
1125
1546
|
const name = readable.tableName;
|
|
1126
1547
|
const init = {
|
|
@@ -1129,22 +1550,33 @@ var SchemaSync = class {
|
|
|
1129
1550
|
syncMethod: readable.syncMethod
|
|
1130
1551
|
};
|
|
1131
1552
|
const renamedFrom = readable.renamedFrom;
|
|
1132
|
-
const pendingRename = renamedFrom && trackedNames.has(renamedFrom);
|
|
1553
|
+
const pendingRename = renamedFrom && trackedNames.has(renamedFrom) ? renamedFrom : void 0;
|
|
1133
1554
|
if (pendingRename) {
|
|
1134
|
-
init.renamedFrom =
|
|
1555
|
+
init.renamedFrom = pendingRename;
|
|
1135
1556
|
init.status = "alter";
|
|
1136
1557
|
}
|
|
1137
|
-
const
|
|
1138
|
-
const storedSnapshot = await this.store.readTableSnapshot(
|
|
1558
|
+
const dbName = pendingRename ?? name;
|
|
1559
|
+
const storedSnapshot = await this.store.readTableSnapshot(dbName);
|
|
1560
|
+
const facts = {
|
|
1561
|
+
readable,
|
|
1562
|
+
name,
|
|
1563
|
+
pendingRename,
|
|
1564
|
+
dbName,
|
|
1565
|
+
storedSnapshot,
|
|
1566
|
+
fkDiff: storedSnapshot ? computeForeignKeyDiff(readable.foreignKeys, storedSnapshot.foreignKeys) : void 0,
|
|
1567
|
+
dropsTable: false,
|
|
1568
|
+
planEntry: init
|
|
1569
|
+
};
|
|
1139
1570
|
if (adapter.getExistingColumns) {
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1571
|
+
const liveColumns = pendingRename ? adapter.getExistingColumnsForTable ? await adapter.getExistingColumnsForTable(pendingRename) : void 0 : await adapter.getExistingColumns();
|
|
1572
|
+
facts.existing = pendingRename && liveColumns?.length === 0 ? void 0 : liveColumns;
|
|
1573
|
+
if (facts.existing && facts.existing.length === 0) {
|
|
1142
1574
|
init.status = "create";
|
|
1143
1575
|
init.columnsToAdd = readable.fieldDescriptors.filter((f) => !f.ignored);
|
|
1144
|
-
} else if (existing.length > 0) {
|
|
1576
|
+
} else if (facts.existing && facts.existing.length > 0) {
|
|
1145
1577
|
const typeMapper = adapter.typeMapper?.bind(adapter);
|
|
1146
|
-
|
|
1147
|
-
this.populatePlanFromDiff(diff, init, name, readable.syncMethod, adapter.supportsColumnModify);
|
|
1578
|
+
facts.diff = computeColumnDiff(readable.fieldDescriptors, facts.existing, typeMapper);
|
|
1579
|
+
this.populatePlanFromDiff(facts.diff, init, name, readable.syncMethod, adapter.supportsColumnModify);
|
|
1148
1580
|
}
|
|
1149
1581
|
} else if (adapter.syncColumns) if (!storedSnapshot) {
|
|
1150
1582
|
if (!pendingRename) {
|
|
@@ -1155,40 +1587,369 @@ var SchemaSync = class {
|
|
|
1155
1587
|
}
|
|
1156
1588
|
} else {
|
|
1157
1589
|
const existing = snapshotToExistingColumns(storedSnapshot);
|
|
1158
|
-
|
|
1159
|
-
this.populatePlanFromDiff(diff, init, name, readable.syncMethod, adapter.supportsColumnModify);
|
|
1590
|
+
facts.diff = computeColumnDiff(readable.fieldDescriptors, existing, this.resolveTypeMapper(adapter));
|
|
1591
|
+
this.populatePlanFromDiff(facts.diff, init, name, readable.syncMethod, adapter.supportsColumnModify);
|
|
1160
1592
|
}
|
|
1161
1593
|
else if (adapter.tableExists) {
|
|
1162
1594
|
if (!await adapter.tableExists()) init.status = "create";
|
|
1163
1595
|
} else init.status = "create";
|
|
1164
1596
|
if (init.status !== "create") {
|
|
1165
|
-
|
|
1166
|
-
|
|
1597
|
+
facts.optionDiff = await this.diffTableOptions(readable, pendingRename);
|
|
1598
|
+
const optionDiff = facts.optionDiff;
|
|
1599
|
+
if (init.status !== "error" && optionDiff && optionDiff.changed.length > 0) {
|
|
1167
1600
|
init.status = "alter";
|
|
1168
1601
|
init.optionChanges = optionDiff.changed;
|
|
1169
1602
|
if (optionDiff.changed.some((c) => c.destructive)) init.recreated = true;
|
|
1170
1603
|
}
|
|
1171
1604
|
}
|
|
1172
|
-
if (init.status !== "create" &&
|
|
1173
|
-
const fkDiff =
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1605
|
+
if (init.status !== "create" && init.status !== "error" && facts.fkDiff && hasForeignKeyChanges(facts.fkDiff)) {
|
|
1606
|
+
const fkDiff = facts.fkDiff;
|
|
1607
|
+
init.status = "alter";
|
|
1608
|
+
init.fkAdded = fkDiff.added.map((fk) => ({
|
|
1609
|
+
fields: fk.fields,
|
|
1610
|
+
targetTable: fk.targetTable
|
|
1611
|
+
}));
|
|
1612
|
+
init.fkRemoved = fkDiff.removed.map((fk) => ({
|
|
1613
|
+
fields: fk.fields,
|
|
1614
|
+
targetTable: fk.targetTable
|
|
1615
|
+
}));
|
|
1616
|
+
init.fkChanged = fkDiff.changed.map((fk) => ({
|
|
1617
|
+
fields: fk.desired.fields,
|
|
1618
|
+
targetTable: fk.desired.targetTable,
|
|
1619
|
+
details: buildFkChangeDetails(fk.desired, fk.existing)
|
|
1620
|
+
}));
|
|
1621
|
+
}
|
|
1622
|
+
if (facts.diff?.primaryKeyChanged) facts.populated = await adapter.hasRows(pendingRename ? dbName : void 0) ?? "unknown";
|
|
1623
|
+
facts.dropsTable = !!facts.diff?.primaryKeyChanged || willDropRecreate(readable, facts.diff, facts.optionDiff);
|
|
1624
|
+
if (facts.diff?.primaryKeyChanged || probeRecreateInbound && facts.dropsTable) facts.inboundFks = await adapter.getReferencingForeignKeys?.(dbName);
|
|
1625
|
+
if (adapter.getObjectKind) facts.objectKind = await adapter.getObjectKind(dbName);
|
|
1626
|
+
return facts;
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* Pure validation over the discovery facts. Every refusal is a change that
|
|
1630
|
+
* no order of DDL can apply safely; the messages are user-facing.
|
|
1631
|
+
*/
|
|
1632
|
+
preflight(d, safe) {
|
|
1633
|
+
const refusals = /* @__PURE__ */ new Map();
|
|
1634
|
+
for (const t of d.tables) {
|
|
1635
|
+
if (t.objectKind === "view" || t.objectKind === "materialized") addRefusal(refusals, t.name, `A view "${t.dbName}" exists where table "${t.name}" is declared — drop or rename it`);
|
|
1636
|
+
const pk = t.diff?.primaryKeyChanged;
|
|
1637
|
+
if (pk) {
|
|
1638
|
+
const label = pkLabel(pk);
|
|
1639
|
+
if (t.populated === true) addRefusal(refusals, t.name, `Primary key of "${t.name}" changed ${label} but the table has rows; schema sync cannot rebuild a populated primary key. Migrate manually (or empty the table) and re-run.`);
|
|
1640
|
+
else if (t.populated === "unknown") addRefusal(refusals, t.name, `Primary key of "${t.name}" changed ${label} but the adapter cannot tell whether the table has rows under its old name "${t.dbName}" — implement hasRows(tableName) on the adapter, or rename the table in a separate run first.`);
|
|
1641
|
+
if (!safe) {
|
|
1642
|
+
for (const fk of t.inboundFks ?? []) {
|
|
1643
|
+
const removed = d.removedByName.get(fk.table);
|
|
1644
|
+
if (removed && !removed.isView) continue;
|
|
1645
|
+
const child = d.tableByDbName.get(fk.table);
|
|
1646
|
+
if (child?.pendingRename) {
|
|
1647
|
+
addRefusal(refusals, t.name, `Primary key of "${t.name}" changed ${label} but the referencing table "${child.name}" is renamed in the same run (from "${child.pendingRename}") — rename it in a separate run first, then retarget the foreign key and re-run.`);
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
if (!(child !== void 0 && [...child.readable.foreignKeys.values()].some((desired) => desired.targetTable === t.name && fkKey(desired.fields) === fkKey(fk.fields) && fkKey(desired.targetFields) === fkKey(pk.to)))) addRefusal(refusals, t.name, `Primary key of "${t.name}" changed ${label} but "${fk.table}.${fk.fields.join(",")}" still references the old key — retarget the foreign key (or migrate manually) and re-run.`);
|
|
1651
|
+
}
|
|
1652
|
+
for (const f of t.readable.fieldDescriptors) if (!f.ignored && !f.isPrimaryKey && f.defaultValue?.kind === "fn" && f.defaultValue.fn === "increment") addRefusal(refusals, t.name, `"${t.name}.${f.physicalName}" is auto-increment but no longer part of the primary key; auto-increment columns must be primary-key columns.`);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
for (const fk of t.readable.foreignKeys.values()) {
|
|
1656
|
+
if (fk.targetTable === t.name || d.tableByName.has(fk.targetTable)) continue;
|
|
1657
|
+
if (d.externalTargets.get(fk.targetTable) === false) addRefusal(refusals, t.name, `FK ${t.name}.${fk.fields.join(",")} references "${fk.targetTable}" which is neither in the sync inventory nor present in the database`);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
for (const view of d.views) if (d.viewObjectKinds.get(view.tableName) === "table") addRefusal(refusals, view.tableName, `A physical table "${view.tableName}" exists where managed view "${view.tableName}" is declared — drop or rename it`);
|
|
1661
|
+
if (!safe) for (const r of d.removed) {
|
|
1662
|
+
if (r.isView) continue;
|
|
1663
|
+
const referencers = [];
|
|
1664
|
+
for (const t of d.tables) for (const fk of t.readable.foreignKeys.values()) if (fk.targetTable === r.name) referencers.push(`${t.name}.${fk.fields.join(",")} (@db.rel.FK)`);
|
|
1665
|
+
for (const view of d.views) {
|
|
1666
|
+
const plan = view.viewPlan;
|
|
1667
|
+
if (plan.entryTable === r.name || plan.joins.some((j) => j.targetTable === r.name)) referencers.push(`view "${view.tableName}"`);
|
|
1668
|
+
}
|
|
1669
|
+
if (referencers.length > 0) addRefusal(refusals, r.name, `Cannot drop "${r.name}": it is still referenced by ${referencers.join(", ")}. Add "${r.name}" to the sync inventory or remove the reference.`);
|
|
1670
|
+
}
|
|
1671
|
+
return refusals;
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* The one ordering walk `plan()` and `run()` share: tables parents-first,
|
|
1675
|
+
* each preceded by the removed tables that block its drop/rebuild; the
|
|
1676
|
+
* deferred FK pass; managed views; external-view checks; removed views;
|
|
1677
|
+
* the remaining removed tables children-first.
|
|
1678
|
+
*/
|
|
1679
|
+
*orderedSteps(d) {
|
|
1680
|
+
for (const group of d.createOrder) {
|
|
1681
|
+
const cycle = group.length > 1 ? new Set(group) : void 0;
|
|
1682
|
+
for (const name of group) {
|
|
1683
|
+
for (const early of d.earlyDrops.get(name) ?? []) yield {
|
|
1684
|
+
kind: "drop",
|
|
1685
|
+
group: early
|
|
1686
|
+
};
|
|
1687
|
+
yield {
|
|
1688
|
+
kind: "table",
|
|
1689
|
+
name,
|
|
1690
|
+
cycle
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
yield { kind: "deferred-fks" };
|
|
1695
|
+
for (const [index] of d.views.entries()) yield {
|
|
1696
|
+
kind: "view",
|
|
1697
|
+
index
|
|
1698
|
+
};
|
|
1699
|
+
for (const [index] of d.externalEntries.entries()) yield {
|
|
1700
|
+
kind: "external",
|
|
1701
|
+
index
|
|
1702
|
+
};
|
|
1703
|
+
for (const r of d.removed) if (r.isView) yield {
|
|
1704
|
+
kind: "drop-view",
|
|
1705
|
+
name: r.name
|
|
1706
|
+
};
|
|
1707
|
+
for (const group of d.lateDropOrder) yield {
|
|
1708
|
+
kind: "drop",
|
|
1709
|
+
group
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
/** The plan entries in execution order, refusals folded in as `error` entries. */
|
|
1713
|
+
buildPlanEntries(d, safe, refusals) {
|
|
1714
|
+
const refused = (e) => refusals.has(e.name) ? withRefusals(e.toInit(), refusals.get(e.name)) : e;
|
|
1715
|
+
const entries = [];
|
|
1716
|
+
for (const step of this.orderedSteps(d)) switch (step.kind) {
|
|
1717
|
+
case "drop":
|
|
1718
|
+
if (!safe) entries.push(...step.group.map((name) => refused(d.dropEntries.get(name))));
|
|
1719
|
+
break;
|
|
1720
|
+
case "table": {
|
|
1721
|
+
const t = d.tableByName.get(step.name);
|
|
1722
|
+
let init = {
|
|
1723
|
+
...t.planEntry,
|
|
1724
|
+
dependsOn: d.tableDependsOn.get(step.name)
|
|
1725
|
+
};
|
|
1726
|
+
const skipped = safe ? safeModeSkips(t) : [];
|
|
1727
|
+
const pk = t.diff?.primaryKeyChanged;
|
|
1728
|
+
if (pk) init.pkChange = {
|
|
1729
|
+
from: pk.from,
|
|
1730
|
+
to: pk.to,
|
|
1731
|
+
rebuild: !skipped.includes("pk-rebuild")
|
|
1732
|
+
};
|
|
1733
|
+
if (safe) init = {
|
|
1734
|
+
...init,
|
|
1735
|
+
columnsToDrop: [],
|
|
1736
|
+
typeChanges: skipped.includes("recreate") ? init.typeChanges : [],
|
|
1737
|
+
skipped: skipped.length > 0 ? skipped : void 0,
|
|
1738
|
+
recreated: false
|
|
1739
|
+
};
|
|
1740
|
+
entries.push(withRefusals(init, refusals.get(step.name)));
|
|
1741
|
+
break;
|
|
1742
|
+
}
|
|
1743
|
+
case "deferred-fks": break;
|
|
1744
|
+
case "view":
|
|
1745
|
+
entries.push(refused(d.viewEntries[step.index]));
|
|
1746
|
+
break;
|
|
1747
|
+
case "external":
|
|
1748
|
+
entries.push(d.externalEntries[step.index]);
|
|
1749
|
+
break;
|
|
1750
|
+
case "drop-view":
|
|
1751
|
+
if (!safe) entries.push(refused(d.dropEntries.get(step.name)));
|
|
1752
|
+
break;
|
|
1753
|
+
}
|
|
1754
|
+
return entries;
|
|
1755
|
+
}
|
|
1756
|
+
async execute(d, safe, getAbortReason) {
|
|
1757
|
+
const { hash, allReadables } = d;
|
|
1758
|
+
const deps = this.buildExecutorDeps();
|
|
1759
|
+
const entries = [];
|
|
1760
|
+
/** Removed entries that were NOT dropped — they stay tracked (I2). */
|
|
1761
|
+
const retained = [];
|
|
1762
|
+
const droppedNames = /* @__PURE__ */ new Set();
|
|
1763
|
+
for (const view of d.views) await dropOutdatedView(view, d.viewPlans.get(view.tableName), this.space);
|
|
1764
|
+
const droppedViewEntries = /* @__PURE__ */ new Map();
|
|
1765
|
+
for (const r of d.removed) {
|
|
1766
|
+
if (!r.isView) continue;
|
|
1767
|
+
if (safe) {
|
|
1768
|
+
retained.push(r);
|
|
1769
|
+
continue;
|
|
1770
|
+
}
|
|
1771
|
+
try {
|
|
1772
|
+
await this.space.dropViewByName(r.name);
|
|
1773
|
+
droppedViewEntries.set(r.name, d.dropEntries.get(r.name));
|
|
1774
|
+
droppedNames.add(r.name);
|
|
1775
|
+
} catch (error) {
|
|
1776
|
+
const msg = `Drop of view "${r.name}" failed: ${error.message}`;
|
|
1777
|
+
this.logger.error?.(`[schema-sync] ${msg}`);
|
|
1778
|
+
droppedViewEntries.set(r.name, new SyncEntry({
|
|
1779
|
+
name: r.name,
|
|
1780
|
+
viewType: r.viewType,
|
|
1781
|
+
status: "error",
|
|
1782
|
+
errors: [msg]
|
|
1188
1783
|
}));
|
|
1784
|
+
retained.push(r);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
if (!safe) for (const t of d.tables) {
|
|
1788
|
+
if (!t.diff?.primaryKeyChanged) continue;
|
|
1789
|
+
const keysByChild = /* @__PURE__ */ new Map();
|
|
1790
|
+
for (const fk of t.inboundFks ?? []) keysByChild.set(fk.table, [...keysByChild.get(fk.table) ?? [], fkKey(fk.fields)]);
|
|
1791
|
+
for (const [childName, fkKeys] of keysByChild) await d.tableByDbName.get(childName)?.readable.dbAdapter.dropForeignKeys?.(fkKeys);
|
|
1792
|
+
}
|
|
1793
|
+
const deferred = [];
|
|
1794
|
+
const drops = {
|
|
1795
|
+
d,
|
|
1796
|
+
retained,
|
|
1797
|
+
droppedNames
|
|
1798
|
+
};
|
|
1799
|
+
for (const step of this.orderedSteps(d)) {
|
|
1800
|
+
this.assertLockHeld(getAbortReason);
|
|
1801
|
+
switch (step.kind) {
|
|
1802
|
+
case "drop":
|
|
1803
|
+
if (safe) retain(step.group, drops);
|
|
1804
|
+
else entries.push(...await this.dropRemovedGroup(step.group, drops));
|
|
1805
|
+
break;
|
|
1806
|
+
case "table": {
|
|
1807
|
+
const t = d.tableByName.get(step.name);
|
|
1808
|
+
const entry = await executeSyncTable(t, safe, deps, {
|
|
1809
|
+
deferForeignKeysTo: step.cycle,
|
|
1810
|
+
dependsOn: d.tableDependsOn.get(step.name)
|
|
1811
|
+
});
|
|
1812
|
+
if (step.cycle) deferred.push({
|
|
1813
|
+
readable: t.readable,
|
|
1814
|
+
index: entries.length
|
|
1815
|
+
});
|
|
1816
|
+
entries.push(entry);
|
|
1817
|
+
break;
|
|
1818
|
+
}
|
|
1819
|
+
case "deferred-fks":
|
|
1820
|
+
for (const { readable, index } of deferred) {
|
|
1821
|
+
this.assertLockHeld(getAbortReason);
|
|
1822
|
+
entries[index] = await executeDeferredForeignKeys(readable, entries[index], deps);
|
|
1823
|
+
}
|
|
1824
|
+
break;
|
|
1825
|
+
case "view":
|
|
1826
|
+
entries.push(await executeSyncView(d.views[step.index], d.viewEntries[step.index]));
|
|
1827
|
+
break;
|
|
1828
|
+
case "external":
|
|
1829
|
+
entries.push(d.externalEntries[step.index]);
|
|
1830
|
+
break;
|
|
1831
|
+
case "drop-view": {
|
|
1832
|
+
const entry = droppedViewEntries.get(step.name);
|
|
1833
|
+
if (entry) entries.push(entry);
|
|
1834
|
+
break;
|
|
1835
|
+
}
|
|
1189
1836
|
}
|
|
1190
1837
|
}
|
|
1191
|
-
|
|
1838
|
+
if (safe && retained.length > 0) this.logger.warn?.(`[schema-sync] Safe mode: ${retained.map((r) => `"${r.name}"`).join(", ")} no longer in the schema — kept (still tracked; dropped by the next run that executes)`);
|
|
1839
|
+
this.assertLockHeld(getAbortReason);
|
|
1840
|
+
for (const e of entries) if (e.status !== "error" && e.skipped.length > 0) {
|
|
1841
|
+
const work = e.skipped.map((k) => SKIPPED_LABELS[k]).join(", ");
|
|
1842
|
+
this.logger.warn?.(`[schema-sync] Safe mode: "${e.name}" — ${work} skipped, snapshot and hash withheld; the next run without safe applies it`);
|
|
1843
|
+
}
|
|
1844
|
+
const pendingNames = new Set(entries.filter((e) => e.pending).map((e) => e.name));
|
|
1845
|
+
for (const readable of allReadables) {
|
|
1846
|
+
if (pendingNames.has(readable.tableName)) continue;
|
|
1847
|
+
const adapter = readable.dbAdapter;
|
|
1848
|
+
const tm = adapter.typeMapper?.bind(adapter);
|
|
1849
|
+
const opts = adapter.getDesiredTableOptions?.();
|
|
1850
|
+
const snapshot = readable.isView ? computeViewSnapshot(readable) : computeTableSnapshot(readable, tm, opts);
|
|
1851
|
+
await this.store.writeTableSnapshot(readable.tableName, snapshot);
|
|
1852
|
+
}
|
|
1853
|
+
for (const name of droppedNames) await this.store.deleteTableSnapshot(name);
|
|
1854
|
+
for (const readable of allReadables) if (readable.renamedFrom) await this.store.deleteTableSnapshot(readable.renamedFrom);
|
|
1855
|
+
await this.store.writeTrackedList(allReadables, retained);
|
|
1856
|
+
if (pendingNames.size === 0) await this.store.writeHash(hash);
|
|
1857
|
+
return {
|
|
1858
|
+
status: "synced",
|
|
1859
|
+
schemaHash: hash,
|
|
1860
|
+
entries
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Drops one removed-table group (a table, or a foreign-key cycle as one
|
|
1865
|
+
* operation) and returns its entries. A live inbound FK from outside the
|
|
1866
|
+
* removed set blocks the group — error entries, kept tracked — instead of
|
|
1867
|
+
* leaving a dangling constraint or cascading. The probe runs at execution
|
|
1868
|
+
* time, so an inventory child whose FK to the group was dropped earlier in
|
|
1869
|
+
* this run does not block it. Called from the walk for early groups (right
|
|
1870
|
+
* before the table they block) and late groups (after the views) alike.
|
|
1871
|
+
*/
|
|
1872
|
+
async dropRemovedGroup(group, ctx) {
|
|
1873
|
+
const { d, droppedNames } = ctx;
|
|
1874
|
+
const dropGroup = group.length > 1 ? group : void 0;
|
|
1875
|
+
const failed = (message) => {
|
|
1876
|
+
retain(group, ctx);
|
|
1877
|
+
return group.map((name) => {
|
|
1878
|
+
const msg = message(name);
|
|
1879
|
+
this.logger.error?.(`[schema-sync] ${msg}`);
|
|
1880
|
+
return new SyncEntry({
|
|
1881
|
+
name,
|
|
1882
|
+
status: "error",
|
|
1883
|
+
errors: [msg],
|
|
1884
|
+
dropGroup
|
|
1885
|
+
});
|
|
1886
|
+
});
|
|
1887
|
+
};
|
|
1888
|
+
const inbounds = await Promise.all(group.map((name) => this.space.getReferencingForeignKeys(name)));
|
|
1889
|
+
const blockers = [];
|
|
1890
|
+
for (const [i, name] of group.entries()) for (const fk of inbounds[i] ?? []) if (!d.removedTableSet.has(fk.table)) blockers.push(`${fk.table}.${fk.fields.join(",")} → ${name}`);
|
|
1891
|
+
if (blockers.length > 0) return failed((name) => `Cannot drop "${name}": it is still referenced by ${blockers.join(", ")}. Drop the referencing constraint (or add the table back) and re-run.`);
|
|
1892
|
+
try {
|
|
1893
|
+
if (group.length === 1) await this.space.dropTableByName(group[0]);
|
|
1894
|
+
else await this.space.dropTablesByName(group);
|
|
1895
|
+
} catch (error) {
|
|
1896
|
+
return failed((name) => `Drop of "${name}" failed: ${error.message}`);
|
|
1897
|
+
}
|
|
1898
|
+
for (const name of group) droppedNames.add(name);
|
|
1899
|
+
return group.map((name) => d.dropEntries.get(name));
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* Surfaces the run outcome per the `onError` policy. Reporting must never be
|
|
1903
|
+
* silently lost to the NoopLogger default — when no real logger is
|
|
1904
|
+
* configured, warnings/errors go to `console` (a production consumer lost
|
|
1905
|
+
* months of failed-index errors to the silent default).
|
|
1906
|
+
*/
|
|
1907
|
+
reportOutcome(result, onError) {
|
|
1908
|
+
if (onError === "silent" || result.entries.length === 0) return;
|
|
1909
|
+
const out = this.logger === require_db_view.NoopLogger ? console : this.logger;
|
|
1910
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1911
|
+
for (const entry of result.entries) counts.set(entry.status, (counts.get(entry.status) ?? 0) + 1);
|
|
1912
|
+
const summary = [...counts.entries()].map(([status, n]) => `${n} ${status}`).join(", ");
|
|
1913
|
+
out.log(`[schema-sync] ${result.status}: ${summary}`);
|
|
1914
|
+
const errored = result.entries.filter((e) => e.hasErrors);
|
|
1915
|
+
if (errored.length === 0) return;
|
|
1916
|
+
const lines = errored.map((e) => `[schema-sync] "${e.name}" ${e.refused ? "refused" : "failed"}: ${e.errors.join("; ") || e.status}`);
|
|
1917
|
+
for (const line of lines) out.error(line);
|
|
1918
|
+
if (onError === "throw") {
|
|
1919
|
+
const verb = result.status === "refused" ? "refused" : "failed";
|
|
1920
|
+
throw new Error(`[schema-sync] ${errored.length} entr${errored.length === 1 ? "y" : "ies"} ${verb}:\n${lines.join("\n")}`);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Computes a dry-run plan showing what `run()` would do, without executing
|
|
1925
|
+
* any DDL. Entries come back in execution order; pre-flight refusals appear
|
|
1926
|
+
* as `error` entries (`refused: true`) exactly as `run()` would report them.
|
|
1927
|
+
*/
|
|
1928
|
+
async plan(types, opts) {
|
|
1929
|
+
const force = opts?.force ?? false;
|
|
1930
|
+
const safe = opts?.safe ?? false;
|
|
1931
|
+
const resolved = await this.resolveAndHash(types);
|
|
1932
|
+
const { hash } = resolved;
|
|
1933
|
+
await this.store.ensureControlTable();
|
|
1934
|
+
const d = await this.discover(resolved, safe);
|
|
1935
|
+
const refusals = this.preflight(d, safe);
|
|
1936
|
+
const entries = this.buildPlanEntries(d, safe, refusals);
|
|
1937
|
+
if (!force) {
|
|
1938
|
+
if (await this.store.readHash() === hash) return {
|
|
1939
|
+
status: "up-to-date",
|
|
1940
|
+
schemaHash: hash,
|
|
1941
|
+
entries
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
return {
|
|
1945
|
+
status: "changes-needed",
|
|
1946
|
+
schemaHash: hash,
|
|
1947
|
+
entries
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
/** Fallback typeMapper for snapshot-based Path B: compares designType directly, skips unions. */
|
|
1951
|
+
resolveTypeMapper(adapter) {
|
|
1952
|
+
return adapter.typeMapper?.bind(adapter) ?? ((f) => f.designType === "union" ? "union" : f.designType);
|
|
1192
1953
|
}
|
|
1193
1954
|
/**
|
|
1194
1955
|
* Populates plan init from a column diff (shared by Path A and Path B).
|
|
@@ -1199,22 +1960,10 @@ var SchemaSync = class {
|
|
|
1199
1960
|
from: r.oldName,
|
|
1200
1961
|
to: r.field.physicalName
|
|
1201
1962
|
}));
|
|
1202
|
-
init.typeChanges = diff
|
|
1203
|
-
|
|
1204
|
-
fromType: tc.existingType,
|
|
1205
|
-
toType: tc.field.designType
|
|
1206
|
-
}));
|
|
1207
|
-
init.nullableChanges = diff.nullableChanged.map((nc) => ({
|
|
1208
|
-
column: nc.field.physicalName,
|
|
1209
|
-
toNullable: nc.field.optional
|
|
1210
|
-
}));
|
|
1211
|
-
init.defaultChanges = diff.defaultChanged.map((dc) => ({
|
|
1212
|
-
column: dc.field.physicalName,
|
|
1213
|
-
oldDefault: dc.oldDefault,
|
|
1214
|
-
newDefault: dc.newDefault
|
|
1215
|
-
}));
|
|
1963
|
+
init.typeChanges = describeTypeChanges(diff);
|
|
1964
|
+
Object.assign(init, describeNullableDefaults(diff));
|
|
1216
1965
|
init.columnsToDrop = diff.removed.map((c) => c.name);
|
|
1217
|
-
if (diff.added.length > 0 || diff.renamed.length > 0 || diff.typeChanged.length > 0 || diff.nullableChanged.length > 0 || diff.defaultChanged.length > 0 || diff.removed.length > 0) init.status = "alter";
|
|
1966
|
+
if (diff.added.length > 0 || diff.renamed.length > 0 || diff.typeChanged.length > 0 || diff.nullableChanged.length > 0 || diff.defaultChanged.length > 0 || diff.removed.length > 0 || diff.primaryKeyChanged !== void 0) init.status = "alter";
|
|
1218
1967
|
if (diff.conflicts.length > 0) {
|
|
1219
1968
|
init.status = "error";
|
|
1220
1969
|
init.errors = [...init.errors ?? [], ...diff.conflicts.map((c) => `Column rename conflict on ${name}: cannot rename "${c.oldName}" → "${c.field.physicalName}" because "${c.conflictsWith}" already exists.`)];
|
|
@@ -1226,45 +1975,29 @@ var SchemaSync = class {
|
|
|
1226
1975
|
}
|
|
1227
1976
|
/**
|
|
1228
1977
|
* Computes table option diff using DB-first introspection with snapshot fallback.
|
|
1229
|
-
* Returns null if the adapter has no table options.
|
|
1978
|
+
* Returns null if the adapter has no table options. `tableName` is the name
|
|
1979
|
+
* the table has in the database right now — the OLD name of a pending
|
|
1980
|
+
* `@db.table.renamed` (the adapter's bound name is the new one, which does
|
|
1981
|
+
* not exist yet); omitted for a table that is not being renamed.
|
|
1230
1982
|
*/
|
|
1231
|
-
async diffTableOptions(readable) {
|
|
1983
|
+
async diffTableOptions(readable, tableName) {
|
|
1232
1984
|
const adapter = readable.dbAdapter;
|
|
1233
1985
|
const desired = adapter.getDesiredTableOptions?.();
|
|
1234
1986
|
if (!desired || desired.length === 0) return null;
|
|
1235
1987
|
let existing;
|
|
1236
|
-
if (adapter.getExistingTableOptions) existing = await adapter.getExistingTableOptions();
|
|
1988
|
+
if (adapter.getExistingTableOptions) existing = await adapter.getExistingTableOptions(tableName);
|
|
1237
1989
|
else {
|
|
1238
|
-
const snapshot = await this.store.readTableSnapshot(readable.tableName);
|
|
1990
|
+
const snapshot = await this.store.readTableSnapshot(tableName ?? readable.tableName);
|
|
1239
1991
|
existing = snapshot ? snapshotToExistingTableOptions(snapshot) : [];
|
|
1240
1992
|
}
|
|
1241
1993
|
if (existing.length === 0) return null;
|
|
1242
1994
|
const destructiveKeys = adapter.destructiveOptionKeys?.();
|
|
1243
1995
|
return computeTableOptionDiff(desired, existing, destructiveKeys);
|
|
1244
1996
|
}
|
|
1245
|
-
async planView(view, trackedNames) {
|
|
1246
|
-
const viewType = view.viewPlan.materialized ? "M" : "V";
|
|
1247
|
-
const renamedFrom = view.renamedFrom;
|
|
1248
|
-
const isRenamed = renamedFrom && trackedNames.has(renamedFrom);
|
|
1249
|
-
let status;
|
|
1250
|
-
if (isRenamed) status = "alter";
|
|
1251
|
-
else if (trackedNames.has(view.tableName)) status = await viewDefinitionChanged(view, this.store) ? "alter" : "in-sync";
|
|
1252
|
-
else status = "create";
|
|
1253
|
-
return new SyncEntry({
|
|
1254
|
-
name: view.tableName,
|
|
1255
|
-
status,
|
|
1256
|
-
viewType,
|
|
1257
|
-
renamedFrom: isRenamed ? renamedFrom : void 0,
|
|
1258
|
-
recreated: status === "alter" && !isRenamed ? true : void 0
|
|
1259
|
-
});
|
|
1260
|
-
}
|
|
1261
1997
|
buildExecutorDeps() {
|
|
1262
1998
|
return {
|
|
1263
|
-
store: this.store,
|
|
1264
|
-
space: this.space,
|
|
1265
1999
|
logger: this.logger,
|
|
1266
|
-
resolveTypeMapper: this.resolveTypeMapper.bind(this)
|
|
1267
|
-
diffTableOptions: this.diffTableOptions.bind(this)
|
|
2000
|
+
resolveTypeMapper: this.resolveTypeMapper.bind(this)
|
|
1268
2001
|
};
|
|
1269
2002
|
}
|
|
1270
2003
|
};
|
|
@@ -1322,6 +2055,7 @@ async function planSchema(space, types, opts) {
|
|
|
1322
2055
|
//#endregion
|
|
1323
2056
|
exports.SchemaSync = SchemaSync;
|
|
1324
2057
|
exports.SyncEntry = SyncEntry;
|
|
2058
|
+
exports.canonicalizeQueryNode = canonicalizeQueryNode;
|
|
1325
2059
|
exports.computeColumnDiff = computeColumnDiff;
|
|
1326
2060
|
exports.computeForeignKeyDiff = computeForeignKeyDiff;
|
|
1327
2061
|
exports.computeSchemaHash = computeSchemaHash;
|
|
@@ -1336,3 +2070,4 @@ exports.readStoredSnapshot = readStoredSnapshot;
|
|
|
1336
2070
|
exports.snapshotToExistingColumns = snapshotToExistingColumns;
|
|
1337
2071
|
exports.snapshotToExistingTableOptions = snapshotToExistingTableOptions;
|
|
1338
2072
|
exports.syncSchema = syncSchema;
|
|
2073
|
+
exports.topoOrder = topoOrder;
|