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