@atscript/db 0.1.127 → 0.1.129
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-error-DXwEzmYJ.cjs → db-error-C4JuLcvb.cjs} +27 -0
- package/dist/{db-error-BHPXOKzc.mjs → db-error-COrO58t5.mjs} +22 -1
- package/dist/{db-readable-BkAGccv9.d.mts → db-readable-B7eYWS5q.d.cts} +299 -17
- package/dist/{db-readable-C0nDKX8A.d.cts → db-readable-Bn1bV_eC.d.mts} +299 -17
- package/dist/{db-space-B_ASuDaR.d.mts → db-space-C2UCnGHd.d.cts} +108 -30
- package/dist/{db-space-CSntT6yS.d.cts → db-space-DdIPYD0Q.d.mts} +108 -30
- package/dist/{db-view-BP0Qbeux.cjs → db-view-CRBgkEp0.cjs} +786 -100
- package/dist/{db-view-C8rZM5_N.mjs → db-view-Dl0aDTiT.mjs} +733 -101
- package/dist/index.cjs +48 -3
- package/dist/index.d.cts +162 -37
- package/dist/index.d.mts +162 -37
- package/dist/index.mjs +37 -5
- package/dist/{nested-writer-DI-HeTky.mjs → nested-writer-CkDo-ZfH.mjs} +1 -1
- package/dist/{nested-writer-DoDhl3X3.cjs → nested-writer-DxPhmWFz.cjs} +1 -1
- 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 +44 -1
- package/dist/ops.d.cts +2 -2
- package/dist/ops.d.mts +2 -2
- package/dist/ops.mjs +44 -2
- package/dist/plugin.cjs +12 -5
- package/dist/plugin.mjs +12 -5
- package/dist/rel.cjs +2 -2
- package/dist/rel.d.cts +1 -1
- package/dist/rel.d.mts +1 -1
- package/dist/rel.mjs +2 -2
- package/dist/{relation-loader-BnUgJsUG.cjs → relation-loader-C8GOpNYJ.cjs} +1 -1
- package/dist/{relation-loader-BmeOMj0b.mjs → relation-loader-CUGcxJ18.mjs} +1 -1
- package/dist/sync.cjs +1270 -398
- package/dist/sync.d.cts +255 -18
- package/dist/sync.d.mts +255 -18
- package/dist/sync.mjs +1269 -399
- package/dist/{validator-0vRXN51D.mjs → validator-CeD_fqyW.mjs} +21 -4
- package/dist/{validator-CSGug4vg.cjs → validator-lkCJKuoo.cjs} +32 -3
- package/dist/{validator-BcBtg8yW.d.cts → validator-wBARmD68.d.cts} +57 -1
- package/dist/{validator-BcBtg8yW.d.mts → validator-wBARmD68.d.mts} +57 -1
- 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-Dl0aDTiT.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
|
}
|
|
@@ -440,7 +568,7 @@ const noColor = {
|
|
|
440
568
|
dim: (s) => s,
|
|
441
569
|
underline: (s) => s
|
|
442
570
|
};
|
|
443
|
-
var SyncEntry = class {
|
|
571
|
+
var SyncEntry = class SyncEntry {
|
|
444
572
|
name;
|
|
445
573
|
/** 'V' = virtual view, 'M' = materialized view, 'E' = external view, undefined = table */
|
|
446
574
|
viewType;
|
|
@@ -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,48 @@ 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 };
|
|
486
637
|
}
|
|
487
|
-
/**
|
|
638
|
+
/**
|
|
639
|
+
* This entry as an `error` entry with `msg` appended to its errors — the
|
|
640
|
+
* shape a DDL failure inside the entry's step reports (the planned work is
|
|
641
|
+
* kept, the status says it did not land).
|
|
642
|
+
* @since 0.1.129
|
|
643
|
+
*/
|
|
644
|
+
withError(msg) {
|
|
645
|
+
return new SyncEntry({
|
|
646
|
+
...this.toInit(),
|
|
647
|
+
status: "error",
|
|
648
|
+
errors: [...this.errors, msg]
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Whether desired work is still pending after this entry — DDL that was
|
|
653
|
+
* not issued because the entry errored or safe mode skipped it (see
|
|
654
|
+
* `skipped`). A pending entry withholds its snapshot and the schema hash,
|
|
655
|
+
* so the next run retries / applies it. External views are advisory and
|
|
656
|
+
* never pending.
|
|
657
|
+
* @since 0.1.128
|
|
658
|
+
*/
|
|
659
|
+
get pending() {
|
|
660
|
+
return this.status === "error" && this.viewType !== "E" || this.skipped.length > 0;
|
|
661
|
+
}
|
|
662
|
+
/** Whether this entry involves destructive operations (pending work safe mode skipped is not) */
|
|
488
663
|
get destructive() {
|
|
489
664
|
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);
|
|
665
|
+
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
666
|
}
|
|
492
667
|
/** Whether this entry represents any change (not in-sync) */
|
|
493
668
|
get hasChanges() {
|
|
@@ -509,18 +684,73 @@ var SyncEntry = class {
|
|
|
509
684
|
};
|
|
510
685
|
}
|
|
511
686
|
printError(c, label, vp) {
|
|
512
|
-
|
|
687
|
+
const head = this.refused ? `✖ refused: ${vp}${label}` : `✗ ${vp}${label} — error`;
|
|
688
|
+
return [` ${c.red(head)}`, ...this.errors.map((err) => ` ${c.red(err)}`)];
|
|
689
|
+
}
|
|
690
|
+
/** `! PK (id) → (code) — rebuild (table is empty)` / `— skipped (safe mode)` */
|
|
691
|
+
printPkChange(c, mode) {
|
|
692
|
+
const pk = this.pkChange;
|
|
693
|
+
if (!pk) return [];
|
|
694
|
+
const cols = `PK (${pk.from.join(", ")}) → (${pk.to.join(", ")})`;
|
|
695
|
+
if (!pk.rebuild) return [` ${c.yellow(`! ${cols} — skipped (safe mode)`)}`];
|
|
696
|
+
return mode === "plan" ? [` ${c.red(`! ${cols} — rebuild (table is empty)`)}`] : [` ${c.yellow(`~ ${cols} — rebuilt`)}`];
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* `! col: t1 → t2 — drop` (plan), or `! type col (t1 → t2) — skipped (safe
|
|
700
|
+
* mode)` in plan and result when the `'drop'` recreate was skipped.
|
|
701
|
+
*/
|
|
702
|
+
printTypeChanges(c) {
|
|
703
|
+
if (this.skipped.includes("recreate")) return this.typeChanges.map((tc) => ` ${c.yellow(`! type ${tc.column} (${tc.fromType} → ${tc.toType}) — skipped (safe mode)`)}`);
|
|
704
|
+
return this.typeChanges.map((tc) => {
|
|
705
|
+
const action = this.syncMethod ? ` — ${this.syncMethod}` : " — requires migration";
|
|
706
|
+
return ` ${c.red(`! ${tc.column}: ${tc.fromType} → ${tc.toType}${action}`)}`;
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
/** `~ col — nullable` / `~ col — default a → b`, `— skipped (safe mode)` when pending. */
|
|
710
|
+
printNullableDefaults(c) {
|
|
711
|
+
const suffix = this.skipped.includes("nullable-defaults") ? " — skipped (safe mode)" : "";
|
|
712
|
+
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}`)}`)];
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Plan: `~ option k: a → b`, or `! option k: a → b — requires recreation`
|
|
716
|
+
* for a destructive change. Result: `~ option k: a → b` for an applied
|
|
717
|
+
* change. Both: `! option k: a → b — skipped (safe mode)` when pending.
|
|
718
|
+
*/
|
|
719
|
+
printOptionChanges(c, mode) {
|
|
720
|
+
const skipped = this.skipped.includes("table-options");
|
|
721
|
+
return this.optionChanges.map((oc) => {
|
|
722
|
+
if (oc.destructive && skipped) return ` ${c.red("!")} ${c.cyan(`option ${oc.key}`)}: ${oc.oldValue} → ${oc.newValue} — skipped (safe mode)`;
|
|
723
|
+
if (mode === "result") return ` ${c.cyan(`~ option ${oc.key}: ${oc.oldValue} → ${oc.newValue}`)}`;
|
|
724
|
+
const tag = oc.destructive ? c.red("!") : c.yellow("~");
|
|
725
|
+
const action = oc.destructive ? " — requires recreation" : "";
|
|
726
|
+
return ` ${tag} ${c.cyan(`option ${oc.key}`)}: ${oc.oldValue} → ${oc.newValue}${action}`;
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
/** `· after: a, b` (plan only — the executor already runs in this order). */
|
|
730
|
+
printDependsOn(c) {
|
|
731
|
+
if (this.dependsOn.length === 0) return [];
|
|
732
|
+
return [` ${c.dim(`· after: ${this.dependsOn.join(", ")}`)}`];
|
|
733
|
+
}
|
|
734
|
+
printDropGroup(c) {
|
|
735
|
+
const others = this.dropGroup?.filter((n) => n !== this.name) ?? [];
|
|
736
|
+
if (others.length === 0) return [];
|
|
737
|
+
return [` ${c.dim(`· dropped with: ${others.join(", ")}`)}`];
|
|
513
738
|
}
|
|
514
739
|
printPlan(c) {
|
|
515
740
|
const { label, vp } = this.labelAndPrefix(c);
|
|
516
741
|
if (this.status === "error") return this.printError(c, label, vp);
|
|
517
742
|
if (this.status === "drop") {
|
|
518
743
|
const kind = this.viewType ? "drop view" : "drop table";
|
|
519
|
-
return [
|
|
744
|
+
return [
|
|
745
|
+
` ${c.red(`- ${vp}${label} — ${kind}`)}`,
|
|
746
|
+
...this.printDependsOn(c),
|
|
747
|
+
...this.printDropGroup(c)
|
|
748
|
+
];
|
|
520
749
|
}
|
|
521
750
|
if (this.status === "create") return [
|
|
522
751
|
` ${c.green(`+ ${vp}${label} — create`)}`,
|
|
523
752
|
...this.columnsToAdd.map((col) => ` ${c.green(`+ ${col.physicalName} (${col.designType})${col.isPrimaryKey ? " PK" : ""}${col.optional ? " nullable" : ""} — add`)}`),
|
|
753
|
+
...this.printDependsOn(c),
|
|
524
754
|
""
|
|
525
755
|
];
|
|
526
756
|
if (this.status === "alter") {
|
|
@@ -529,21 +759,15 @@ var SyncEntry = class {
|
|
|
529
759
|
` ${c.cyan(`~ ${vp}${label} — alter${renameInfo}`)}`,
|
|
530
760
|
...this.columnsToAdd.map((col) => ` ${c.green(`+ ${col.physicalName} (${col.designType}) — add`)}`),
|
|
531
761
|
...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"}`)}`),
|
|
762
|
+
...this.printTypeChanges(c),
|
|
763
|
+
...this.printNullableDefaults(c),
|
|
764
|
+
...this.printPkChange(c, "plan"),
|
|
538
765
|
...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
|
-
}),
|
|
766
|
+
...this.printOptionChanges(c, "plan"),
|
|
544
767
|
...this.fkAdded.map((fk) => ` ${c.green(`+ FK(${fk.fields.join(",")}) → ${fk.targetTable} — add`)}`),
|
|
545
768
|
...this.fkRemoved.map((fk) => ` ${c.red(`- FK(${fk.fields.join(",")}) → ${fk.targetTable} — remove`)}`),
|
|
546
769
|
...this.fkChanged.map((fk) => ` ${c.yellow(`~ FK(${fk.fields.join(",")}) → ${fk.targetTable} — ${fk.details}`)}`),
|
|
770
|
+
...this.printDependsOn(c),
|
|
547
771
|
""
|
|
548
772
|
];
|
|
549
773
|
}
|
|
@@ -554,22 +778,25 @@ var SyncEntry = class {
|
|
|
554
778
|
if (this.status === "error") return this.printError(c, label, vp);
|
|
555
779
|
if (this.status === "drop") {
|
|
556
780
|
const kind = this.viewType ? "dropped view" : "dropped table";
|
|
557
|
-
return [` ${c.red(`- ${vp}${label} — ${kind}`)}
|
|
781
|
+
return [` ${c.red(`- ${vp}${label} — ${kind}`)}`, ...this.printDropGroup(c)];
|
|
558
782
|
}
|
|
559
783
|
if (this.status === "create") return [
|
|
560
784
|
` ${c.green(`+ ${vp}${label} — created`)}`,
|
|
561
785
|
...this.columnsAdded.map((col) => ` ${c.green(`+ ${col} — added`)}`),
|
|
562
786
|
""
|
|
563
787
|
];
|
|
564
|
-
if (this.columnsAdded.length > 0 || this.columnsRenamed.length > 0 || this.columnsDropped.length > 0 || this.optionChanges.length > 0 || this.recreated || this.renamedFrom) {
|
|
788
|
+
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
789
|
const rlabel = this.recreated ? "recreated" : "altered";
|
|
566
790
|
const renameInfo = this.renamedFrom ? ` ${c.yellow(`(renamed from ${this.renamedFrom})`)}` : "";
|
|
567
791
|
return [
|
|
568
792
|
` ${(this.recreated ? (s) => c.yellow(s) : (s) => c.cyan(s))(`~ ${vp}${label} — ${rlabel}${renameInfo}`)}`,
|
|
569
793
|
...this.columnsAdded.map((col) => ` ${c.green(`+ ${col} — added`)}`),
|
|
570
794
|
...this.columnsRenamed.map((col) => ` ${c.yellow(`~ ${col} — renamed`)}`),
|
|
795
|
+
...this.printPkChange(c, "result"),
|
|
796
|
+
...this.printTypeChanges(c),
|
|
797
|
+
...this.printNullableDefaults(c),
|
|
571
798
|
...this.columnsDropped.map((col) => ` ${c.red(`- ${col} — dropped`)}`),
|
|
572
|
-
...this.
|
|
799
|
+
...this.printOptionChanges(c, "result"),
|
|
573
800
|
""
|
|
574
801
|
];
|
|
575
802
|
}
|
|
@@ -591,97 +818,208 @@ async function viewDefinitionChanged(view, store) {
|
|
|
591
818
|
const currentHash = computeTableHash(computeViewSnapshot(view));
|
|
592
819
|
return computeTableHash(storedSnapshot) !== currentHash;
|
|
593
820
|
}
|
|
594
|
-
|
|
821
|
+
/**
|
|
822
|
+
* The plan entry of one table as `plan()` reports it (and as `run()` reports
|
|
823
|
+
* an entry that issues no DDL): the discovery entry plus `dependsOn`, the
|
|
824
|
+
* key change, and — in safe mode — the destructive parts hidden except the
|
|
825
|
+
* skipped ones, which are pending and shown as skipped.
|
|
826
|
+
*/
|
|
827
|
+
function planTableInit(facts, safe, dependsOn) {
|
|
828
|
+
const init = {
|
|
829
|
+
...facts.planEntry,
|
|
830
|
+
dependsOn
|
|
831
|
+
};
|
|
832
|
+
const skipped = safe ? safeModeSkips(facts) : [];
|
|
833
|
+
const pk = facts.diff?.primaryKeyChanged;
|
|
834
|
+
if (pk) init.pkChange = {
|
|
835
|
+
from: pk.from,
|
|
836
|
+
to: pk.to,
|
|
837
|
+
rebuild: !skipped.includes("pk-rebuild")
|
|
838
|
+
};
|
|
839
|
+
if (!safe) return init;
|
|
840
|
+
return {
|
|
841
|
+
...init,
|
|
842
|
+
columnsToDrop: [],
|
|
843
|
+
typeChanges: skipped.includes("recreate") ? init.typeChanges : [],
|
|
844
|
+
skipped: skipped.length > 0 ? skipped : void 0,
|
|
845
|
+
recreated: false
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* How a type change is applied on this table, in the executor's order:
|
|
850
|
+
* `@db.sync.method "drop"` drop-and-recreate, `"recreate"` with data copy,
|
|
851
|
+
* in-place modification (MySQL `MODIFY COLUMN`), or not at all (`"none"` —
|
|
852
|
+
* an `error` entry, see {@link typeChangeErrors}).
|
|
853
|
+
*/
|
|
854
|
+
function typeChangeStrategy(readable, diff) {
|
|
855
|
+
const adapter = readable.dbAdapter;
|
|
856
|
+
if (willDropRecreate(readable, diff, void 0)) return "drop";
|
|
857
|
+
if (readable.syncMethod === "recreate" && adapter.recreateTable) return "recreate";
|
|
858
|
+
if (adapter.supportsColumnModify && adapter.syncColumns) return "modify";
|
|
859
|
+
return "none";
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* The error messages of a type change the adapter cannot apply (strategy
|
|
863
|
+
* `"none"`), one per column: either no `@db.sync.method` is declared, or the
|
|
864
|
+
* declared one needs a primitive this adapter lacks.
|
|
865
|
+
*/
|
|
866
|
+
function typeChangeErrors(readable, diff) {
|
|
867
|
+
const method = readable.syncMethod;
|
|
868
|
+
const advice = method ? `@db.sync.method "${method}" is declared but the adapter has no ${method === "recreate" ? "recreateTable" : "dropTable"} — migrate manually.` : `Add @db.sync.method "recreate" or "drop", or migrate manually.`;
|
|
869
|
+
return diff.typeChanged.map((tc) => `Type change on ${readable.tableName}.${tc.field.physicalName} (${tc.existingType} → ${tc.field.designType}). ${advice}`);
|
|
870
|
+
}
|
|
871
|
+
/** `(a, b → c)` — the key change as the messages print it. */
|
|
872
|
+
function pkLabel(change) {
|
|
873
|
+
return `(${change.from.join(", ")} → ${change.to.join(", ")})`;
|
|
874
|
+
}
|
|
875
|
+
/** A key rebuild on an adapter with neither `rebuildPrimaryKey` nor `recreateTable`. */
|
|
876
|
+
function pkRebuildUnsupported(name, change) {
|
|
877
|
+
return `Primary key of "${name}" changed ${pkLabel(change)} but the adapter cannot rebuild primary keys. Migrate manually and re-run.`;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Applies one table's discovered facts: rename → column ops (Path A live
|
|
881
|
+
* introspection, Path B snapshot diff, Path C schema-less) → table options →
|
|
882
|
+
* indexes / foreign keys / `afterSyncTable`. Never called for an entry the
|
|
883
|
+
* plan already marks `error` (`SchemaSync.execute()` reports the plan entry
|
|
884
|
+
* for those without DDL).
|
|
885
|
+
*
|
|
886
|
+
* A statement the engine refuses anywhere in those phases (since 0.1.129)
|
|
887
|
+
* becomes an `error` entry — the plan entry plus `<phase> failed on <table>:
|
|
888
|
+
* <cause>` — and the run goes on with the next table. The entry names the
|
|
889
|
+
* foreign keys already dropped ahead of the failure (by `execute()` step 2
|
|
890
|
+
* for a key-changing parent, or by Path A before the column ops): they stay
|
|
891
|
+
* gone until the next run's `syncForeignKeys` re-adds the ones the model
|
|
892
|
+
* still wants. `renamedFrom` on that entry is set only when the rename ran.
|
|
893
|
+
*/
|
|
894
|
+
async function executeSyncTable(facts, safe, deps, exec) {
|
|
895
|
+
const { readable, name, storedSnapshot, fkDiff } = facts;
|
|
595
896
|
const adapter = readable.dbAdapter;
|
|
596
|
-
const name = readable.tableName;
|
|
597
897
|
const init = {
|
|
598
898
|
name,
|
|
599
899
|
status: "in-sync",
|
|
600
|
-
syncMethod: readable.syncMethod
|
|
900
|
+
syncMethod: readable.syncMethod,
|
|
901
|
+
dependsOn: exec.dependsOn
|
|
601
902
|
};
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
if (storedSnapshot) fkDiff = computeForeignKeyDiff(readable.foreignKeys, storedSnapshot.foreignKeys);
|
|
611
|
-
const hasFkChanges = fkDiff ? hasForeignKeyChanges(fkDiff) : false;
|
|
612
|
-
if (adapter.getExistingColumns && adapter.syncColumns) {
|
|
613
|
-
const existing = await adapter.getExistingColumns();
|
|
614
|
-
if (existing.length === 0 && !init.renamedFrom) {
|
|
615
|
-
await adapter.ensureTable();
|
|
616
|
-
init.status = "create";
|
|
617
|
-
} else if (existing.length > 0) if (hasFkChanges && !init.recreated && !adapter.syncForeignKeys && adapter.recreateTable) {
|
|
618
|
-
await adapter.recreateTable();
|
|
619
|
-
init.recreated = true;
|
|
903
|
+
const ensureOpts = exec.deferForeignKeysTo ? { deferForeignKeysTo: exec.deferForeignKeysTo } : void 0;
|
|
904
|
+
/** FK keys dropped before the statement that failed — named by the error entry. */
|
|
905
|
+
const droppedFks = [...exec.preDroppedFks ?? []];
|
|
906
|
+
let phase = "Rename";
|
|
907
|
+
try {
|
|
908
|
+
if (facts.pendingRename && adapter.renameTable) {
|
|
909
|
+
await adapter.renameTable(facts.pendingRename);
|
|
910
|
+
init.renamedFrom = facts.pendingRename;
|
|
620
911
|
init.status = "alter";
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
912
|
+
}
|
|
913
|
+
phase = "Column sync";
|
|
914
|
+
const hasFkChanges = fkDiff ? hasForeignKeyChanges(fkDiff) : false;
|
|
915
|
+
if (adapter.getExistingColumns && adapter.syncColumns) {
|
|
916
|
+
const existing = facts.existing ?? await adapter.getExistingColumns();
|
|
917
|
+
if (existing.length === 0 && !init.renamedFrom) {
|
|
918
|
+
await adapter.ensureTable(ensureOpts);
|
|
919
|
+
init.status = "create";
|
|
920
|
+
} else if (existing.length > 0) {
|
|
921
|
+
const diff = facts.diff ?? computeColumnDiff(readable.fieldDescriptors, existing, adapter.typeMapper?.bind(adapter));
|
|
922
|
+
if (hasFkChanges && !adapter.syncForeignKeys && adapter.recreateTable) {
|
|
923
|
+
await adapter.recreateTable();
|
|
924
|
+
init.recreated = true;
|
|
626
925
|
init.status = "alter";
|
|
926
|
+
if (diff.primaryKeyChanged) init.pkChange = {
|
|
927
|
+
...diff.primaryKeyChanged,
|
|
928
|
+
rebuild: true
|
|
929
|
+
};
|
|
930
|
+
} else {
|
|
931
|
+
if (hasFkChanges && fkDiff && adapter.dropForeignKeys) {
|
|
932
|
+
const keysToDrop = [...fkDiff.removed.map((fk) => fkKey(fk.fields)), ...fkDiff.changed.map((fk) => fkKey(fk.desired.fields))];
|
|
933
|
+
if (keysToDrop.length > 0) {
|
|
934
|
+
await adapter.dropForeignKeys(keysToDrop);
|
|
935
|
+
droppedFks.push(...keysToDrop);
|
|
936
|
+
init.status = "alter";
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
await applyColumnDiff(adapter, readable, diff, init, safe, deps.logger, ensureOpts);
|
|
627
940
|
}
|
|
628
941
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
} else if (adapter.syncColumns) if (!storedSnapshot) {
|
|
633
|
-
const existed = adapter.tableExists ? await adapter.tableExists() : false;
|
|
634
|
-
await adapter.ensureTable();
|
|
635
|
-
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
|
-
}
|
|
640
|
-
else {
|
|
641
|
-
const existed = adapter.tableExists ? await adapter.tableExists() : true;
|
|
642
|
-
if (!init.recreated) {
|
|
643
|
-
await adapter.ensureTable();
|
|
942
|
+
} else if (adapter.syncColumns) if (!storedSnapshot) {
|
|
943
|
+
const existed = adapter.tableExists ? await adapter.tableExists() : false;
|
|
944
|
+
await adapter.ensureTable(ensureOpts);
|
|
644
945
|
if (!existed) init.status = "create";
|
|
946
|
+
} else await applyColumnDiff(adapter, readable, facts.diff ?? computeColumnDiff(readable.fieldDescriptors, snapshotToExistingColumns(storedSnapshot), deps.resolveTypeMapper(adapter)), init, safe, deps.logger, ensureOpts);
|
|
947
|
+
else {
|
|
948
|
+
const existed = adapter.tableExists ? await adapter.tableExists() : true;
|
|
949
|
+
if (!init.recreated) {
|
|
950
|
+
await adapter.ensureTable(ensureOpts);
|
|
951
|
+
if (!existed) init.status = "create";
|
|
952
|
+
}
|
|
645
953
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
if (optionDiff && optionDiff.changed.length > 0) {
|
|
650
|
-
init.optionChanges = optionDiff.changed;
|
|
954
|
+
phase = "Table option sync";
|
|
955
|
+
const optionDiff = facts.optionDiff;
|
|
956
|
+
if (init.status !== "create" && !init.recreated && init.status !== "error" && optionDiff && optionDiff.changed.length > 0) {
|
|
651
957
|
const hasDestructive = optionDiff.changed.some((c) => c.destructive);
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
init.status = "alter";
|
|
656
|
-
}
|
|
657
|
-
if (hasDestructive) {
|
|
658
|
-
if (readable.syncMethod === "recreate" && adapter.recreateTable) {
|
|
659
|
-
deps.logger.warn?.(`[schema-sync] Destructive table option change on "${name}" — recreating with data preservation`);
|
|
660
|
-
await adapter.recreateTable();
|
|
958
|
+
if (safe) {
|
|
959
|
+
if (hasDestructive) {
|
|
960
|
+
init.optionChanges = optionDiff.changed;
|
|
661
961
|
init.status = "alter";
|
|
662
|
-
init
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
962
|
+
markSkipped(init, "table-options");
|
|
963
|
+
deps.logger.warn?.(`[schema-sync] Destructive table option change on "${name}" — recreate skipped (safe mode)`);
|
|
964
|
+
}
|
|
965
|
+
} else {
|
|
966
|
+
init.optionChanges = optionDiff.changed;
|
|
967
|
+
const nonDestructive = optionDiff.changed.filter((c) => !c.destructive);
|
|
968
|
+
if (nonDestructive.length > 0 && adapter.applyTableOptions) {
|
|
969
|
+
await adapter.applyTableOptions(nonDestructive);
|
|
667
970
|
init.status = "alter";
|
|
668
|
-
|
|
971
|
+
}
|
|
972
|
+
if (hasDestructive) {
|
|
973
|
+
if (willDropRecreate(readable, void 0, optionDiff)) {
|
|
974
|
+
deps.logger.warn?.(`[schema-sync] Destructive table option change on "${name}" — dropping and recreating`);
|
|
975
|
+
await dropAndRecreate(adapter, init, name, ensureOpts);
|
|
976
|
+
} else if (readable.syncMethod === "recreate" && adapter.recreateTable) {
|
|
977
|
+
deps.logger.warn?.(`[schema-sync] Destructive table option change on "${name}" — recreating with data preservation`);
|
|
978
|
+
await adapter.recreateTable();
|
|
979
|
+
init.status = "alter";
|
|
980
|
+
init.recreated = true;
|
|
981
|
+
}
|
|
669
982
|
}
|
|
670
983
|
}
|
|
671
984
|
}
|
|
672
|
-
|
|
673
|
-
|
|
985
|
+
if (init.status === "error") return new SyncEntry(init);
|
|
986
|
+
phase = "Index/FK sync";
|
|
674
987
|
await adapter.syncIndexes();
|
|
675
|
-
if (adapter.syncForeignKeys) await adapter.syncForeignKeys();
|
|
988
|
+
if (adapter.syncForeignKeys && !exec.deferForeignKeysTo) await adapter.syncForeignKeys();
|
|
676
989
|
if (adapter.afterSyncTable) await adapter.afterSyncTable();
|
|
677
990
|
} catch (error) {
|
|
678
|
-
const
|
|
991
|
+
const dropped = droppedFks.length > 0 ? ` Dropped foreign keys before the failure: ${droppedFks.join(", ")} — the ones still in the model are re-added by the next run's syncForeignKeys.` : "";
|
|
992
|
+
const msg = `${phase} failed on ${name}: ${error.message}${dropped}`;
|
|
679
993
|
deps.logger.error?.(`[schema-sync] ${msg}`);
|
|
680
|
-
|
|
681
|
-
|
|
994
|
+
const planned = planTableInit(facts, safe, exec.dependsOn);
|
|
995
|
+
return new SyncEntry({
|
|
996
|
+
...planned,
|
|
997
|
+
status: "error",
|
|
998
|
+
errors: [...planned.errors ?? [], msg],
|
|
999
|
+
renamedFrom: init.renamedFrom,
|
|
1000
|
+
recreated: false
|
|
1001
|
+
});
|
|
682
1002
|
}
|
|
683
1003
|
return new SyncEntry(init);
|
|
684
1004
|
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Deferred foreign-key pass for the members of a foreign-key cycle: runs
|
|
1007
|
+
* `syncForeignKeys()` (which adds every FK missing from the live table) now
|
|
1008
|
+
* that all members exist. A failure turns the table's entry into an error
|
|
1009
|
+
* entry — the run continues and the hash is withheld.
|
|
1010
|
+
*/
|
|
1011
|
+
async function executeDeferredForeignKeys(readable, entry, deps) {
|
|
1012
|
+
const adapter = readable.dbAdapter;
|
|
1013
|
+
if (!adapter.syncForeignKeys || entry.status === "error") return entry;
|
|
1014
|
+
try {
|
|
1015
|
+
await adapter.syncForeignKeys();
|
|
1016
|
+
return entry;
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
const msg = `FK sync failed on ${readable.tableName}: ${error.message}`;
|
|
1019
|
+
deps.logger.error?.(`[schema-sync] ${msg}`);
|
|
1020
|
+
return entry.withError(msg);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
685
1023
|
/** Determines whether a view's predecessor (on rename) or stale definition must be dropped. */
|
|
686
1024
|
async function planViewSync(view, trackedNames, store) {
|
|
687
1025
|
const isRenamed = !!(view.renamedFrom && trackedNames.has(view.renamedFrom));
|
|
@@ -695,27 +1033,108 @@ async function dropOutdatedView(view, plan, space) {
|
|
|
695
1033
|
if (plan.isRenamed) await space.dropViewByName(view.renamedFrom);
|
|
696
1034
|
else if (plan.definitionChanged) await space.dropViewByName(view.tableName);
|
|
697
1035
|
}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1036
|
+
/** Status a managed view's entry gets for a given plan. */
|
|
1037
|
+
function viewPlanStatus(view, plan, trackedNames) {
|
|
1038
|
+
if (plan.isRenamed || plan.definitionChanged) return "alter";
|
|
1039
|
+
return trackedNames.has(view.tableName) ? "in-sync" : "create";
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Creates (or recreates) a managed view. The stale/renamed predecessor was
|
|
1043
|
+
* dropped by `SchemaSync.execute()` before the table ops; `planned` is the
|
|
1044
|
+
* entry discovery computed for it and is returned as the run entry.
|
|
1045
|
+
*/
|
|
1046
|
+
async function executeSyncView(view, planned) {
|
|
704
1047
|
await view.dbAdapter.ensureTable();
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1048
|
+
return planned;
|
|
1049
|
+
}
|
|
1050
|
+
/** The plan-shaped summary of a diff's type changes (`entry.typeChanges`). */
|
|
1051
|
+
function describeTypeChanges(diff) {
|
|
1052
|
+
return diff.typeChanged.map((tc) => ({
|
|
1053
|
+
column: tc.field.physicalName,
|
|
1054
|
+
fromType: tc.existingType,
|
|
1055
|
+
toType: tc.field.designType
|
|
1056
|
+
}));
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Whether the run drops and recreates this table's physical table — the two
|
|
1060
|
+
* `dropAndRecreate` sites: a `@db.sync.method "drop"` type change (`diff`),
|
|
1061
|
+
* or a destructive table-option change (`optionDiff`) on a table that does
|
|
1062
|
+
* not resolve it with `@db.sync.method "recreate"` (PostgreSQL / MySQL
|
|
1063
|
+
* `recreateTable` handle inbound FKs themselves, so `'recreate'` is not a
|
|
1064
|
+
* trigger). Both need `dropTable`. One predicate for discovery (which
|
|
1065
|
+
* schedules the removed tables that block the drop right before it — both
|
|
1066
|
+
* halves are covered since 0.1.128) and for the executor, whose two sites
|
|
1067
|
+
* each pass the half they decide and perform the drop — or, in safe mode,
|
|
1068
|
+
* record it as skipped — so they cannot drift.
|
|
1069
|
+
*/
|
|
1070
|
+
function willDropRecreate(readable, diff, optionDiff) {
|
|
1071
|
+
const adapter = readable.dbAdapter;
|
|
1072
|
+
if (!adapter.dropTable) return false;
|
|
1073
|
+
if (readable.syncMethod === "drop" && (diff?.typeChanged.length ?? 0) > 0) return true;
|
|
1074
|
+
return (optionDiff?.changed.some((c) => c.destructive) ?? false) && !(readable.syncMethod === "recreate" && !!adapter.recreateTable);
|
|
1075
|
+
}
|
|
1076
|
+
/** Records work safe mode skipped on the entry (see `SyncEntry.skipped`). */
|
|
1077
|
+
function markSkipped(init, kind) {
|
|
1078
|
+
init.skipped = [...init.skipped ?? [], kind];
|
|
1079
|
+
}
|
|
1080
|
+
/** Whether nullable/default changes need DDL on this adapter (schema-less ones only update the snapshot). */
|
|
1081
|
+
function needsDdlForNullableDefaults(adapter) {
|
|
1082
|
+
return !!adapter.supportsColumnModify && !!adapter.syncColumns || !!adapter.recreateTable;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* The work `run({ safe: true })` skips on this table, in the order the
|
|
1086
|
+
* executor records it — the plan's safe branch reports exactly what the run
|
|
1087
|
+
* reports. A recreate that runs even in safe mode (an FK change on an adapter
|
|
1088
|
+
* without `syncForeignKeys`, a `'recreate'` type change) carries every other
|
|
1089
|
+
* change with it, so nothing is skipped then; an errored plan entry issues no
|
|
1090
|
+
* DDL either way.
|
|
1091
|
+
*/
|
|
1092
|
+
function safeModeSkips(facts) {
|
|
1093
|
+
const { readable, diff, optionDiff, fkDiff } = facts;
|
|
1094
|
+
const adapter = readable.dbAdapter;
|
|
1095
|
+
const out = [];
|
|
1096
|
+
if (facts.planEntry.status === "error") return out;
|
|
1097
|
+
if (fkDiff && hasForeignKeyChanges(fkDiff) && !adapter.syncForeignKeys && adapter.recreateTable) return out;
|
|
1098
|
+
const strategy = diff && diff.typeChanged.length > 0 ? typeChangeStrategy(readable, diff) : "modify";
|
|
1099
|
+
if (strategy === "drop") out.push("recreate");
|
|
1100
|
+
else if (strategy === "recreate") return out;
|
|
1101
|
+
if (diff && (diff.nullableChanged.length > 0 || diff.defaultChanged.length > 0) && needsDdlForNullableDefaults(adapter)) out.push("nullable-defaults");
|
|
1102
|
+
if (diff?.primaryKeyChanged) out.push("pk-rebuild");
|
|
1103
|
+
if (optionDiff?.changed.some((c) => c.destructive)) out.push("table-options");
|
|
1104
|
+
return out;
|
|
1105
|
+
}
|
|
1106
|
+
/** The plan-shaped nullable / default change summaries of a diff. */
|
|
1107
|
+
function describeNullableDefaults(diff) {
|
|
1108
|
+
return {
|
|
1109
|
+
nullableChanges: diff.nullableChanged.map((nc) => ({
|
|
1110
|
+
column: nc.field.physicalName,
|
|
1111
|
+
toNullable: nc.field.optional
|
|
1112
|
+
})),
|
|
1113
|
+
defaultChanges: diff.defaultChanged.map((dc) => ({
|
|
1114
|
+
column: dc.field.physicalName,
|
|
1115
|
+
oldDefault: dc.oldDefault,
|
|
1116
|
+
newDefault: dc.newDefault
|
|
1117
|
+
}))
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* `dropTable()` + `ensureTable()` for `@db.sync.method "drop"` and destructive
|
|
1122
|
+
* option changes. Sync-owned drops never CASCADE: when something outside the
|
|
1123
|
+
* sync inventory (a user view, an unmanaged FK) still depends on the table the
|
|
1124
|
+
* engine refuses — rethrown as `Drop of "<table>" failed: <cause>` for the
|
|
1125
|
+
* executor's phase catch, which makes it the table's error entry.
|
|
1126
|
+
*/
|
|
1127
|
+
async function dropAndRecreate(adapter, init, name, ensureOpts) {
|
|
1128
|
+
try {
|
|
1129
|
+
await adapter.dropTable();
|
|
1130
|
+
await adapter.ensureTable(ensureOpts);
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
throw new Error(`Drop of "${name}" failed: ${error.message}`, { cause: error });
|
|
1133
|
+
}
|
|
1134
|
+
init.recreated = true;
|
|
1135
|
+
init.status = "alter";
|
|
717
1136
|
}
|
|
718
|
-
async function applyColumnDiff(adapter, readable, diff, init, safe, logger) {
|
|
1137
|
+
async function applyColumnDiff(adapter, readable, diff, init, safe, logger, ensureOpts) {
|
|
719
1138
|
const name = readable.tableName;
|
|
720
1139
|
if (diff.conflicts.length > 0) {
|
|
721
1140
|
const errors = diff.conflicts.map((c) => `Column rename conflict on ${name}: cannot rename "${c.oldName}" → "${c.field.physicalName}" because "${c.conflictsWith}" already exists.`);
|
|
@@ -725,45 +1144,90 @@ async function applyColumnDiff(adapter, readable, diff, init, safe, logger) {
|
|
|
725
1144
|
}
|
|
726
1145
|
let needsSyncColumns = false;
|
|
727
1146
|
if (diff.typeChanged.length > 0 && init.status !== "error") {
|
|
728
|
-
const
|
|
729
|
-
if (
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
init.recreated = true;
|
|
1147
|
+
const strategy = typeChangeStrategy(readable, diff);
|
|
1148
|
+
if (strategy === "drop") if (safe) {
|
|
1149
|
+
init.typeChanges = describeTypeChanges(diff);
|
|
1150
|
+
markSkipped(init, "recreate");
|
|
733
1151
|
init.status = "alter";
|
|
734
|
-
|
|
1152
|
+
const cols = init.typeChanges.map((tc) => `${tc.column}: ${tc.fromType} → ${tc.toType}`).join(", ");
|
|
1153
|
+
logger.warn?.(`[schema-sync] Type change on "${name}" (${cols}) — drop-and-recreate skipped (safe mode)`);
|
|
1154
|
+
} else await dropAndRecreate(adapter, init, name, ensureOpts);
|
|
1155
|
+
else if (strategy === "recreate") {
|
|
735
1156
|
await adapter.recreateTable();
|
|
736
1157
|
init.recreated = true;
|
|
737
1158
|
init.status = "alter";
|
|
738
|
-
} else if (
|
|
1159
|
+
} else if (strategy === "modify") {
|
|
739
1160
|
needsSyncColumns = true;
|
|
740
1161
|
init.status = "alter";
|
|
741
1162
|
} else {
|
|
742
|
-
const errors =
|
|
743
|
-
for (const
|
|
744
|
-
const msg = `Type change on ${name}.${change.field.physicalName} (${change.existingType} → ${change.field.designType}). Add @db.sync.method "recreate" or "drop", or migrate manually.`;
|
|
745
|
-
logger.error?.(`[schema-sync] ${msg}`);
|
|
746
|
-
errors.push(msg);
|
|
747
|
-
}
|
|
1163
|
+
const errors = typeChangeErrors(readable, diff);
|
|
1164
|
+
for (const msg of errors) logger.error?.(`[schema-sync] ${msg}`);
|
|
748
1165
|
init.errors = errors;
|
|
749
1166
|
init.status = "error";
|
|
750
1167
|
}
|
|
751
1168
|
}
|
|
752
|
-
if (
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1169
|
+
if (diff.nullableChanged.length > 0 || diff.defaultChanged.length > 0) {
|
|
1170
|
+
if (!safe && !init.recreated && init.status !== "error") if (adapter.supportsColumnModify && adapter.syncColumns) {
|
|
1171
|
+
needsSyncColumns = true;
|
|
1172
|
+
init.status = "alter";
|
|
1173
|
+
} else if (adapter.recreateTable) {
|
|
1174
|
+
await adapter.recreateTable();
|
|
1175
|
+
init.recreated = true;
|
|
1176
|
+
init.status = "alter";
|
|
1177
|
+
} else init.status = "alter";
|
|
1178
|
+
else if (init.status !== "error") {
|
|
1179
|
+
if (safe && !init.recreated && needsDdlForNullableDefaults(adapter)) {
|
|
1180
|
+
Object.assign(init, describeNullableDefaults(diff));
|
|
1181
|
+
markSkipped(init, "nullable-defaults");
|
|
1182
|
+
logger.warn?.(`[schema-sync] Nullable/default change on "${name}" — skipped (safe mode)`);
|
|
1183
|
+
}
|
|
1184
|
+
init.status = "alter";
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
761
1187
|
if (!init.recreated && init.status !== "error" && (diff.added.length > 0 || diff.renamed.length > 0 || needsSyncColumns) && adapter.syncColumns) {
|
|
762
1188
|
const syncResult = await adapter.syncColumns(diff);
|
|
763
1189
|
init.columnsAdded = syncResult.added;
|
|
764
1190
|
init.columnsRenamed = syncResult.renamed;
|
|
765
1191
|
if (syncResult.added.length > 0 || (syncResult.renamed?.length ?? 0) > 0 || needsSyncColumns) init.status = "alter";
|
|
766
1192
|
}
|
|
1193
|
+
if (diff.primaryKeyChanged && init.status !== "error") {
|
|
1194
|
+
const { from, to } = diff.primaryKeyChanged;
|
|
1195
|
+
if (init.recreated) init.pkChange = {
|
|
1196
|
+
from,
|
|
1197
|
+
to,
|
|
1198
|
+
rebuild: true
|
|
1199
|
+
};
|
|
1200
|
+
else if (safe) {
|
|
1201
|
+
logger.warn?.(`[schema-sync] Primary key of "${name}" changed ${pkLabel(diff.primaryKeyChanged)} — rebuild skipped (safe mode)`);
|
|
1202
|
+
init.pkChange = {
|
|
1203
|
+
from,
|
|
1204
|
+
to,
|
|
1205
|
+
rebuild: false
|
|
1206
|
+
};
|
|
1207
|
+
markSkipped(init, "pk-rebuild");
|
|
1208
|
+
init.status = "alter";
|
|
1209
|
+
} else if (adapter.rebuildPrimaryKey || adapter.recreateTable) {
|
|
1210
|
+
if (adapter.rebuildPrimaryKey) await adapter.rebuildPrimaryKey({
|
|
1211
|
+
from,
|
|
1212
|
+
to
|
|
1213
|
+
});
|
|
1214
|
+
else {
|
|
1215
|
+
await adapter.recreateTable();
|
|
1216
|
+
init.recreated = true;
|
|
1217
|
+
}
|
|
1218
|
+
init.pkChange = {
|
|
1219
|
+
from,
|
|
1220
|
+
to,
|
|
1221
|
+
rebuild: true
|
|
1222
|
+
};
|
|
1223
|
+
init.status = "alter";
|
|
1224
|
+
} else {
|
|
1225
|
+
const msg = pkRebuildUnsupported(name, diff.primaryKeyChanged);
|
|
1226
|
+
logger.error?.(`[schema-sync] ${msg}`);
|
|
1227
|
+
init.errors = [...init.errors ?? [], msg];
|
|
1228
|
+
init.status = "error";
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
767
1231
|
if (!safe && !init.recreated && init.status !== "error" && diff.removed.length > 0 && adapter.dropColumns) {
|
|
768
1232
|
const colNames = diff.removed.map((c) => c.name);
|
|
769
1233
|
if (adapter.dropIndexesForColumns) await adapter.dropIndexesForColumns(colNames);
|
|
@@ -774,6 +1238,10 @@ async function applyColumnDiff(adapter, readable, diff, init, safe, logger) {
|
|
|
774
1238
|
}
|
|
775
1239
|
//#endregion
|
|
776
1240
|
//#region src/schema/schema-sync.ts
|
|
1241
|
+
/** Keeps a removed-table group tracked (safe mode, blocked or failed drop). */
|
|
1242
|
+
function retain(group, ctx) {
|
|
1243
|
+
ctx.retained.push(...group.map((name) => ctx.d.removedByName.get(name)));
|
|
1244
|
+
}
|
|
777
1245
|
/** Builds a human-readable description of what changed in an FK constraint. */
|
|
778
1246
|
function buildFkChangeDetails(desired, existing) {
|
|
779
1247
|
const parts = [];
|
|
@@ -783,6 +1251,46 @@ function buildFkChangeDetails(desired, existing) {
|
|
|
783
1251
|
if ((existing.onUpdate ?? void 0) !== (desired.onUpdate ?? void 0)) parts.push(`onUpdate ${existing.onUpdate ?? "noAction"} → ${desired.onUpdate ?? "noAction"}`);
|
|
784
1252
|
return parts.join(", ");
|
|
785
1253
|
}
|
|
1254
|
+
function sortedUnique(names) {
|
|
1255
|
+
return [...new Set(names)].toSorted();
|
|
1256
|
+
}
|
|
1257
|
+
/** Adds `msg` to the refusal list of `name`. */
|
|
1258
|
+
function addRefusal(refusals, name, msg) {
|
|
1259
|
+
const list = refusals.get(name);
|
|
1260
|
+
if (list) list.push(msg);
|
|
1261
|
+
else refusals.set(name, [msg]);
|
|
1262
|
+
}
|
|
1263
|
+
/** The entry with the refusal texts folded in as a refused `error` entry. */
|
|
1264
|
+
function withRefusals(init, msgs) {
|
|
1265
|
+
if (!msgs || msgs.length === 0) return new SyncEntry(init);
|
|
1266
|
+
return new SyncEntry({
|
|
1267
|
+
...init,
|
|
1268
|
+
status: "error",
|
|
1269
|
+
errors: [...init.errors ?? [], ...msgs],
|
|
1270
|
+
refused: true
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
/** Human label of each kind of skipped work, for the safe-mode warning. */
|
|
1274
|
+
const SKIPPED_LABELS = {
|
|
1275
|
+
"pk-rebuild": "primary-key rebuild",
|
|
1276
|
+
recreate: "drop-and-recreate",
|
|
1277
|
+
"table-options": "table-option recreate",
|
|
1278
|
+
"nullable-defaults": "nullable/default change"
|
|
1279
|
+
};
|
|
1280
|
+
/**
|
|
1281
|
+
* The removed tables whose drop must precede `t`'s own drop/rebuild: those
|
|
1282
|
+
* with a live FK to `t` (its probed inbound FKs; when the adapter cannot
|
|
1283
|
+
* probe them, the removed tables' stored snapshots — the same fallback as
|
|
1284
|
+
* the drop graph), closed over the removed-table graph (child → parent) so
|
|
1285
|
+
* a referencer's own removed children come first.
|
|
1286
|
+
*/
|
|
1287
|
+
function removedBlockers(t, removedTables, removedTableSet, dropEdges) {
|
|
1288
|
+
const direct = [];
|
|
1289
|
+
if (t.inboundFks) {
|
|
1290
|
+
for (const fk of t.inboundFks) if (removedTableSet.has(fk.table)) direct.push(fk.table);
|
|
1291
|
+
} 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);
|
|
1292
|
+
return reachable(direct, dropEdges);
|
|
1293
|
+
}
|
|
786
1294
|
var SchemaSync = class {
|
|
787
1295
|
space;
|
|
788
1296
|
store;
|
|
@@ -805,7 +1313,7 @@ var SchemaSync = class {
|
|
|
805
1313
|
if (readable.isView) {
|
|
806
1314
|
const view = readable;
|
|
807
1315
|
if (view.isExternal) externalViews.push(view);
|
|
808
|
-
else views.push(
|
|
1316
|
+
else views.push(view);
|
|
809
1317
|
} else tables.push(readable);
|
|
810
1318
|
}
|
|
811
1319
|
const allReadables = [
|
|
@@ -870,20 +1378,13 @@ var SchemaSync = class {
|
|
|
870
1378
|
});
|
|
871
1379
|
}
|
|
872
1380
|
/**
|
|
873
|
-
* Detects tables/views present in the previous sync but absent from the
|
|
874
|
-
*
|
|
1381
|
+
* Detects tables/views present in the previous sync but absent from the
|
|
1382
|
+
* current schema (external views excluded — sync owns no DDL for them).
|
|
875
1383
|
*/
|
|
876
|
-
|
|
877
|
-
previous ??= await this.store.readTrackedList();
|
|
1384
|
+
detectRemoved(currentReadables, previous) {
|
|
878
1385
|
const currentSet = new Set(currentReadables.map((t) => t.tableName));
|
|
879
1386
|
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;
|
|
1387
|
+
return previous.filter((entry) => !currentSet.has(entry.name) && !renameFromSet.has(entry.name) && entry.viewType !== "E");
|
|
887
1388
|
}
|
|
888
1389
|
/**
|
|
889
1390
|
* Starts a periodic heartbeat that extends the lock's TTL while sync runs.
|
|
@@ -926,7 +1427,19 @@ var SchemaSync = class {
|
|
|
926
1427
|
if (reason) throw new Error(reason);
|
|
927
1428
|
}
|
|
928
1429
|
/**
|
|
929
|
-
* Runs schema synchronization with distributed locking
|
|
1430
|
+
* Runs schema synchronization with distributed locking, in three phases:
|
|
1431
|
+
*
|
|
1432
|
+
* 1. **Discover** (read-only, under the lock): introspect every table once,
|
|
1433
|
+
* diff columns/FKs, read tracking, build the dependency graph.
|
|
1434
|
+
* 2. **Pre-flight** (pure): every change that no order of DDL can apply
|
|
1435
|
+
* safely becomes a refusal. One refusal → `status: "refused"`, no DDL,
|
|
1436
|
+
* nothing persisted, lock released.
|
|
1437
|
+
* 3. **Execute** in dependency order: stale/removed views → inbound FKs of
|
|
1438
|
+
* key-changing tables → tables (parents first; a removed table that
|
|
1439
|
+
* blocks a table's drop-and-recreate or key rebuild is dropped right
|
|
1440
|
+
* before it) → deferred FKs of cycles → managed views → external-view
|
|
1441
|
+
* checks → remaining removed tables (children first) →
|
|
1442
|
+
* snapshots/tracking/hash.
|
|
930
1443
|
*/
|
|
931
1444
|
async run(types, opts) {
|
|
932
1445
|
this.logger = opts?.logger ?? this.logger;
|
|
@@ -936,7 +1449,9 @@ var SchemaSync = class {
|
|
|
936
1449
|
const pollIntervalMs = opts?.pollIntervalMs ?? 500;
|
|
937
1450
|
const force = opts?.force ?? false;
|
|
938
1451
|
const safe = opts?.safe ?? false;
|
|
939
|
-
const
|
|
1452
|
+
const onError = opts?.onError ?? "warn";
|
|
1453
|
+
const resolved = await this.resolveAndHash(types);
|
|
1454
|
+
const { hash } = resolved;
|
|
940
1455
|
await this.store.ensureControlTable();
|
|
941
1456
|
if (!force) {
|
|
942
1457
|
if (await this.store.readHash() === hash) return {
|
|
@@ -963,163 +1478,152 @@ var SchemaSync = class {
|
|
|
963
1478
|
entries: []
|
|
964
1479
|
};
|
|
965
1480
|
}
|
|
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
|
-
}
|
|
1481
|
+
const d = await this.discover(resolved, safe);
|
|
1003
1482
|
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);
|
|
1483
|
+
const refusals = this.preflight(d, safe);
|
|
1484
|
+
if (refusals.size > 0) {
|
|
1485
|
+
const result = {
|
|
1486
|
+
status: "refused",
|
|
1487
|
+
schemaHash: hash,
|
|
1488
|
+
entries: this.buildPlanEntries(d, safe, refusals)
|
|
1489
|
+
};
|
|
1490
|
+
this.reportOutcome(result, onError);
|
|
1491
|
+
return result;
|
|
1016
1492
|
}
|
|
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");
|
|
1493
|
+
const result = await this.execute(d, safe, heartbeat.getAbortReason);
|
|
1494
|
+
this.reportOutcome(result, onError);
|
|
1026
1495
|
return result;
|
|
1027
1496
|
} finally {
|
|
1028
1497
|
heartbeat.stop();
|
|
1029
1498
|
await this.store.releaseLock(podId);
|
|
1030
1499
|
}
|
|
1031
1500
|
}
|
|
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);
|
|
1501
|
+
async discover(resolved, safe) {
|
|
1502
|
+
const { tables, views, externalViews, hash } = resolved;
|
|
1058
1503
|
const allReadables = [
|
|
1059
1504
|
...tables,
|
|
1060
1505
|
...views,
|
|
1061
1506
|
...externalViews
|
|
1062
1507
|
];
|
|
1063
|
-
await this.store.ensureControlTable();
|
|
1064
1508
|
const previouslyTracked = await this.store.readTrackedList();
|
|
1065
1509
|
const trackedNames = new Set(previouslyTracked.map((e) => e.name));
|
|
1066
|
-
|
|
1067
|
-
const
|
|
1510
|
+
const removedTracked = this.detectRemoved(allReadables, previouslyTracked);
|
|
1511
|
+
const probeRecreateInbound = !safe && removedTracked.some((r) => !r.isView);
|
|
1512
|
+
const tableFacts = await Promise.all(tables.map((r) => this.discoverTable(r, trackedNames, probeRecreateInbound, safe)));
|
|
1513
|
+
const tableNames = new Set(tableFacts.map((t) => t.name));
|
|
1514
|
+
const tableByName = new Map(tableFacts.map((t) => [t.name, t]));
|
|
1515
|
+
const tableByDbName = new Map(tableFacts.map((t) => [t.dbName, t]));
|
|
1516
|
+
const viewFacts = await Promise.all(views.map(async (view) => ({
|
|
1517
|
+
plan: await planViewSync(view, trackedNames, this.store),
|
|
1518
|
+
kind: await view.dbAdapter.getObjectKind?.(view.tableName)
|
|
1519
|
+
})));
|
|
1520
|
+
const viewPlans = /* @__PURE__ */ new Map();
|
|
1521
|
+
const viewEntries = [];
|
|
1522
|
+
const viewObjectKinds = /* @__PURE__ */ new Map();
|
|
1523
|
+
for (const [i, view] of views.entries()) {
|
|
1524
|
+
const { plan, kind } = viewFacts[i];
|
|
1525
|
+
viewPlans.set(view.tableName, plan);
|
|
1526
|
+
viewObjectKinds.set(view.tableName, kind);
|
|
1527
|
+
viewEntries.push(new SyncEntry({
|
|
1528
|
+
name: view.tableName,
|
|
1529
|
+
status: viewPlanStatus(view, plan, trackedNames),
|
|
1530
|
+
viewType: view.viewPlan.materialized ? "M" : "V",
|
|
1531
|
+
renamedFrom: plan.isRenamed ? view.renamedFrom : void 0,
|
|
1532
|
+
recreated: plan.definitionChanged || void 0,
|
|
1533
|
+
dependsOn: sortedUnique([view.viewPlan.entryTable, ...view.viewPlan.joins.map((j) => j.targetTable)].filter((n) => tableNames.has(n)))
|
|
1534
|
+
}));
|
|
1535
|
+
}
|
|
1068
1536
|
const externalEntries = await Promise.all(externalViews.map((v) => this.checkExternalView(v)));
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1537
|
+
const probeInbound = !safe && removedTracked.filter((r) => !r.isView).length >= 2;
|
|
1538
|
+
const removed = await Promise.all(removedTracked.map(async (entry) => ({
|
|
1539
|
+
...entry,
|
|
1540
|
+
snapshot: await this.store.readTableSnapshot(entry.name),
|
|
1541
|
+
inbound: probeInbound && !entry.isView ? await this.space.getReferencingForeignKeys(entry.name) : void 0
|
|
1542
|
+
})));
|
|
1543
|
+
const removedByName = new Map(removed.map((r) => [r.name, r]));
|
|
1544
|
+
const externalTargetAdapters = /* @__PURE__ */ new Map();
|
|
1545
|
+
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);
|
|
1546
|
+
const externalTargets = new Map(await Promise.all([...externalTargetAdapters].map(async ([target, adapter]) => [target, await this.objectPresent(adapter, target)])));
|
|
1547
|
+
const createEdges = [];
|
|
1548
|
+
const tableDependsOn = /* @__PURE__ */ new Map();
|
|
1549
|
+
for (const t of tableFacts) {
|
|
1550
|
+
const parents = /* @__PURE__ */ new Set();
|
|
1551
|
+
for (const fk of t.readable.foreignKeys.values()) if (fk.targetTable !== t.name && tableNames.has(fk.targetTable)) {
|
|
1552
|
+
createEdges.push([t.name, fk.targetTable]);
|
|
1553
|
+
parents.add(fk.targetTable);
|
|
1554
|
+
}
|
|
1555
|
+
tableDependsOn.set(t.name, sortedUnique(parents));
|
|
1079
1556
|
}
|
|
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 = [];
|
|
1557
|
+
const createOrder = topoOrder(tableNames, createEdges);
|
|
1558
|
+
const removedTables = removed.filter((r) => !r.isView);
|
|
1559
|
+
const removedTableSet = new Set(removedTables.map((r) => r.name));
|
|
1560
|
+
const dropEdges = [];
|
|
1561
|
+
for (const r of removedTables) if (r.inbound) {
|
|
1562
|
+
for (const fk of r.inbound) if (fk.table !== r.name && removedTableSet.has(fk.table)) dropEdges.push([fk.table, r.name]);
|
|
1563
|
+
} else if (r.snapshot && "foreignKeys" in r.snapshot) {
|
|
1564
|
+
for (const fk of r.snapshot.foreignKeys) if (fk.targetTable !== r.name && removedTableSet.has(fk.targetTable)) dropEdges.push([r.name, fk.targetTable]);
|
|
1565
|
+
}
|
|
1566
|
+
const dropOrder = topoOrder(removedTableSet, dropEdges).toReversed();
|
|
1567
|
+
const earlyDrops = /* @__PURE__ */ new Map();
|
|
1568
|
+
const earlyGroups = /* @__PURE__ */ new Set();
|
|
1569
|
+
if (!safe) for (const name of createOrder.flat()) {
|
|
1570
|
+
const t = tableByName.get(name);
|
|
1571
|
+
if (!t.dropsTable) continue;
|
|
1572
|
+
const blockers = removedBlockers(t, removedTables, removedTableSet, dropEdges);
|
|
1573
|
+
const groups = dropOrder.filter((group) => !earlyGroups.has(group) && group.some((n) => blockers.has(n)));
|
|
1574
|
+
if (groups.length === 0) continue;
|
|
1575
|
+
for (const group of groups) earlyGroups.add(group);
|
|
1576
|
+
earlyDrops.set(name, groups);
|
|
1577
|
+
tableDependsOn.set(name, sortedUnique([...tableDependsOn.get(name), ...groups.flat()]));
|
|
1105
1578
|
}
|
|
1106
|
-
|
|
1579
|
+
const lateDropOrder = dropOrder.filter((group) => !earlyGroups.has(group));
|
|
1580
|
+
const dropEntries = /* @__PURE__ */ new Map();
|
|
1581
|
+
for (const r of removed) if (r.isView) dropEntries.set(r.name, new SyncEntry({
|
|
1582
|
+
name: r.name,
|
|
1583
|
+
viewType: r.viewType,
|
|
1584
|
+
status: "drop"
|
|
1585
|
+
}));
|
|
1586
|
+
for (const group of dropOrder) for (const name of group) dropEntries.set(name, new SyncEntry({
|
|
1587
|
+
name,
|
|
1588
|
+
status: "drop",
|
|
1589
|
+
dependsOn: sortedUnique(dropEdges.filter(([, to]) => to === name).map(([from]) => from)),
|
|
1590
|
+
dropGroup: group.length > 1 ? group : void 0
|
|
1591
|
+
}));
|
|
1107
1592
|
return {
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1593
|
+
tables: tableFacts,
|
|
1594
|
+
tableByName,
|
|
1595
|
+
tableByDbName,
|
|
1596
|
+
views,
|
|
1597
|
+
externalViews,
|
|
1598
|
+
allReadables,
|
|
1599
|
+
hash,
|
|
1600
|
+
trackedNames,
|
|
1601
|
+
viewPlans,
|
|
1602
|
+
viewEntries,
|
|
1603
|
+
viewObjectKinds,
|
|
1604
|
+
externalEntries,
|
|
1605
|
+
removed,
|
|
1606
|
+
removedByName,
|
|
1607
|
+
removedTableSet,
|
|
1608
|
+
dropEntries,
|
|
1609
|
+
createOrder,
|
|
1610
|
+
earlyDrops,
|
|
1611
|
+
lateDropOrder,
|
|
1612
|
+
tableDependsOn,
|
|
1613
|
+
externalTargets
|
|
1116
1614
|
};
|
|
1117
1615
|
}
|
|
1118
|
-
/**
|
|
1119
|
-
|
|
1120
|
-
return adapter.
|
|
1616
|
+
/** Whether an object named `name` exists, or `undefined` when the adapter cannot tell. */
|
|
1617
|
+
async objectPresent(adapter, name) {
|
|
1618
|
+
return adapter.getObjectKind ? await adapter.getObjectKind(name) !== void 0 : void 0;
|
|
1121
1619
|
}
|
|
1122
|
-
|
|
1620
|
+
/**
|
|
1621
|
+
* Introspects one desired table and computes its plan entry.
|
|
1622
|
+
* `probeRecreateInbound` — whether a drop-and-recreate's live inbound FKs
|
|
1623
|
+
* are worth probing (a removed table exists that could block it).
|
|
1624
|
+
* `safe` — safe mode skips the key rebuild, so its plan rule does not apply.
|
|
1625
|
+
*/
|
|
1626
|
+
async discoverTable(readable, trackedNames, probeRecreateInbound, safe) {
|
|
1123
1627
|
const adapter = readable.dbAdapter;
|
|
1124
1628
|
const name = readable.tableName;
|
|
1125
1629
|
const init = {
|
|
@@ -1128,22 +1632,33 @@ var SchemaSync = class {
|
|
|
1128
1632
|
syncMethod: readable.syncMethod
|
|
1129
1633
|
};
|
|
1130
1634
|
const renamedFrom = readable.renamedFrom;
|
|
1131
|
-
const pendingRename = renamedFrom && trackedNames.has(renamedFrom);
|
|
1635
|
+
const pendingRename = renamedFrom && trackedNames.has(renamedFrom) ? renamedFrom : void 0;
|
|
1132
1636
|
if (pendingRename) {
|
|
1133
|
-
init.renamedFrom =
|
|
1637
|
+
init.renamedFrom = pendingRename;
|
|
1134
1638
|
init.status = "alter";
|
|
1135
1639
|
}
|
|
1136
|
-
const
|
|
1137
|
-
const storedSnapshot = await this.store.readTableSnapshot(
|
|
1640
|
+
const dbName = pendingRename ?? name;
|
|
1641
|
+
const storedSnapshot = await this.store.readTableSnapshot(dbName);
|
|
1642
|
+
const facts = {
|
|
1643
|
+
readable,
|
|
1644
|
+
name,
|
|
1645
|
+
pendingRename,
|
|
1646
|
+
dbName,
|
|
1647
|
+
storedSnapshot,
|
|
1648
|
+
fkDiff: storedSnapshot ? computeForeignKeyDiff(readable.foreignKeys, storedSnapshot.foreignKeys) : void 0,
|
|
1649
|
+
dropsTable: false,
|
|
1650
|
+
planEntry: init
|
|
1651
|
+
};
|
|
1138
1652
|
if (adapter.getExistingColumns) {
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1653
|
+
const liveColumns = pendingRename ? adapter.getExistingColumnsForTable ? await adapter.getExistingColumnsForTable(pendingRename) : void 0 : await adapter.getExistingColumns();
|
|
1654
|
+
facts.existing = pendingRename && liveColumns?.length === 0 ? void 0 : liveColumns;
|
|
1655
|
+
if (facts.existing && facts.existing.length === 0) {
|
|
1141
1656
|
init.status = "create";
|
|
1142
1657
|
init.columnsToAdd = readable.fieldDescriptors.filter((f) => !f.ignored);
|
|
1143
|
-
} else if (existing.length > 0) {
|
|
1658
|
+
} else if (facts.existing && facts.existing.length > 0) {
|
|
1144
1659
|
const typeMapper = adapter.typeMapper?.bind(adapter);
|
|
1145
|
-
|
|
1146
|
-
this.populatePlanFromDiff(diff, init,
|
|
1660
|
+
facts.diff = computeColumnDiff(readable.fieldDescriptors, facts.existing, typeMapper);
|
|
1661
|
+
this.populatePlanFromDiff(facts.diff, init, readable, safe);
|
|
1147
1662
|
}
|
|
1148
1663
|
} else if (adapter.syncColumns) if (!storedSnapshot) {
|
|
1149
1664
|
if (!pendingRename) {
|
|
@@ -1154,116 +1669,471 @@ var SchemaSync = class {
|
|
|
1154
1669
|
}
|
|
1155
1670
|
} else {
|
|
1156
1671
|
const existing = snapshotToExistingColumns(storedSnapshot);
|
|
1157
|
-
|
|
1158
|
-
this.populatePlanFromDiff(diff, init,
|
|
1672
|
+
facts.diff = computeColumnDiff(readable.fieldDescriptors, existing, this.resolveTypeMapper(adapter));
|
|
1673
|
+
this.populatePlanFromDiff(facts.diff, init, readable, safe);
|
|
1159
1674
|
}
|
|
1160
1675
|
else if (adapter.tableExists) {
|
|
1161
1676
|
if (!await adapter.tableExists()) init.status = "create";
|
|
1162
1677
|
} else init.status = "create";
|
|
1163
1678
|
if (init.status !== "create") {
|
|
1164
|
-
|
|
1165
|
-
|
|
1679
|
+
facts.optionDiff = await this.diffTableOptions(readable, pendingRename);
|
|
1680
|
+
const optionDiff = facts.optionDiff;
|
|
1681
|
+
if (init.status !== "error" && optionDiff && optionDiff.changed.length > 0) {
|
|
1166
1682
|
init.status = "alter";
|
|
1167
1683
|
init.optionChanges = optionDiff.changed;
|
|
1168
1684
|
if (optionDiff.changed.some((c) => c.destructive)) init.recreated = true;
|
|
1169
1685
|
}
|
|
1170
1686
|
}
|
|
1171
|
-
if (init.status !== "create" &&
|
|
1172
|
-
const fkDiff =
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1687
|
+
if (init.status !== "create" && init.status !== "error" && facts.fkDiff && hasForeignKeyChanges(facts.fkDiff)) {
|
|
1688
|
+
const fkDiff = facts.fkDiff;
|
|
1689
|
+
init.status = "alter";
|
|
1690
|
+
init.fkAdded = fkDiff.added.map((fk) => ({
|
|
1691
|
+
fields: fk.fields,
|
|
1692
|
+
targetTable: fk.targetTable
|
|
1693
|
+
}));
|
|
1694
|
+
init.fkRemoved = fkDiff.removed.map((fk) => ({
|
|
1695
|
+
fields: fk.fields,
|
|
1696
|
+
targetTable: fk.targetTable
|
|
1697
|
+
}));
|
|
1698
|
+
init.fkChanged = fkDiff.changed.map((fk) => ({
|
|
1699
|
+
fields: fk.desired.fields,
|
|
1700
|
+
targetTable: fk.desired.targetTable,
|
|
1701
|
+
details: buildFkChangeDetails(fk.desired, fk.existing)
|
|
1702
|
+
}));
|
|
1703
|
+
}
|
|
1704
|
+
if (facts.diff?.primaryKeyChanged) facts.populated = await adapter.hasRows(pendingRename ? dbName : void 0) ?? "unknown";
|
|
1705
|
+
facts.dropsTable = !!facts.diff?.primaryKeyChanged || willDropRecreate(readable, facts.diff, facts.optionDiff);
|
|
1706
|
+
if (facts.diff?.primaryKeyChanged || probeRecreateInbound && facts.dropsTable) facts.inboundFks = await adapter.getReferencingForeignKeys?.(dbName);
|
|
1707
|
+
if (adapter.getObjectKind) facts.objectKind = await adapter.getObjectKind(dbName);
|
|
1708
|
+
return facts;
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Pure validation over the discovery facts. Every refusal is a change that
|
|
1712
|
+
* no order of DDL can apply safely; the messages are user-facing.
|
|
1713
|
+
*/
|
|
1714
|
+
preflight(d, safe) {
|
|
1715
|
+
const refusals = /* @__PURE__ */ new Map();
|
|
1716
|
+
for (const t of d.tables) {
|
|
1717
|
+
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`);
|
|
1718
|
+
const pk = t.diff?.primaryKeyChanged;
|
|
1719
|
+
if (pk) {
|
|
1720
|
+
const label = pkLabel(pk);
|
|
1721
|
+
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.`);
|
|
1722
|
+
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.`);
|
|
1723
|
+
if (!safe) {
|
|
1724
|
+
const liveInbound = (t.inboundFks ?? []).filter((fk) => {
|
|
1725
|
+
const removed = d.removedByName.get(fk.table);
|
|
1726
|
+
return !(removed && !removed.isView);
|
|
1727
|
+
});
|
|
1728
|
+
if (t.planEntry.status === "error" && liveInbound.length > 0) addRefusal(refusals, t.name, `Primary key of "${t.name}" changed ${label} but the table's own entry has errors — fix them and re-run: ${t.planEntry.errors?.[0] ?? "see its entry"}`);
|
|
1729
|
+
for (const fk of liveInbound) {
|
|
1730
|
+
const child = d.tableByDbName.get(fk.table);
|
|
1731
|
+
if (child?.planEntry.status === "error") {
|
|
1732
|
+
addRefusal(refusals, t.name, `Primary key of "${t.name}" changed ${label} but the referencing table "${child.name}" has errors — fix them and re-run: ${child.planEntry.errors?.[0] ?? "see its entry"}`);
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
if (child?.pendingRename) {
|
|
1736
|
+
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.`);
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
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.`);
|
|
1740
|
+
}
|
|
1741
|
+
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.`);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
for (const fk of t.readable.foreignKeys.values()) {
|
|
1745
|
+
if (fk.targetTable === t.name || d.tableByName.has(fk.targetTable)) continue;
|
|
1746
|
+
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`);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
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`);
|
|
1750
|
+
if (!safe) for (const r of d.removed) {
|
|
1751
|
+
if (r.isView) continue;
|
|
1752
|
+
const referencers = [];
|
|
1753
|
+
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)`);
|
|
1754
|
+
for (const view of d.views) {
|
|
1755
|
+
const plan = view.viewPlan;
|
|
1756
|
+
if (plan.entryTable === r.name || plan.joins.some((j) => j.targetTable === r.name)) referencers.push(`view "${view.tableName}"`);
|
|
1757
|
+
}
|
|
1758
|
+
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.`);
|
|
1759
|
+
}
|
|
1760
|
+
return refusals;
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* The one ordering walk `plan()` and `run()` share: tables parents-first,
|
|
1764
|
+
* each preceded by the removed tables that block its drop/rebuild; the
|
|
1765
|
+
* deferred FK pass; managed views; external-view checks; removed views;
|
|
1766
|
+
* the remaining removed tables children-first.
|
|
1767
|
+
*/
|
|
1768
|
+
*orderedSteps(d) {
|
|
1769
|
+
for (const group of d.createOrder) {
|
|
1770
|
+
const cycle = group.length > 1 ? new Set(group) : void 0;
|
|
1771
|
+
for (const name of group) {
|
|
1772
|
+
for (const early of d.earlyDrops.get(name) ?? []) yield {
|
|
1773
|
+
kind: "drop",
|
|
1774
|
+
group: early
|
|
1775
|
+
};
|
|
1776
|
+
yield {
|
|
1777
|
+
kind: "table",
|
|
1778
|
+
name,
|
|
1779
|
+
cycle
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
yield { kind: "deferred-fks" };
|
|
1784
|
+
for (const [index] of d.views.entries()) yield {
|
|
1785
|
+
kind: "view",
|
|
1786
|
+
index
|
|
1787
|
+
};
|
|
1788
|
+
for (const [index] of d.externalEntries.entries()) yield {
|
|
1789
|
+
kind: "external",
|
|
1790
|
+
index
|
|
1791
|
+
};
|
|
1792
|
+
for (const r of d.removed) if (r.isView) yield {
|
|
1793
|
+
kind: "drop-view",
|
|
1794
|
+
name: r.name
|
|
1795
|
+
};
|
|
1796
|
+
for (const group of d.lateDropOrder) yield {
|
|
1797
|
+
kind: "drop",
|
|
1798
|
+
group
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
/** The plan entries in execution order, refusals folded in as `error` entries. */
|
|
1802
|
+
buildPlanEntries(d, safe, refusals) {
|
|
1803
|
+
const refused = (e) => refusals.has(e.name) ? withRefusals(e.toInit(), refusals.get(e.name)) : e;
|
|
1804
|
+
const entries = [];
|
|
1805
|
+
for (const step of this.orderedSteps(d)) switch (step.kind) {
|
|
1806
|
+
case "drop":
|
|
1807
|
+
if (!safe) entries.push(...step.group.map((name) => refused(d.dropEntries.get(name))));
|
|
1808
|
+
break;
|
|
1809
|
+
case "table": {
|
|
1810
|
+
const t = d.tableByName.get(step.name);
|
|
1811
|
+
entries.push(withRefusals(planTableInit(t, safe, d.tableDependsOn.get(step.name)), refusals.get(step.name)));
|
|
1812
|
+
break;
|
|
1813
|
+
}
|
|
1814
|
+
case "deferred-fks": break;
|
|
1815
|
+
case "view":
|
|
1816
|
+
entries.push(refused(d.viewEntries[step.index]));
|
|
1817
|
+
break;
|
|
1818
|
+
case "external":
|
|
1819
|
+
entries.push(d.externalEntries[step.index]);
|
|
1820
|
+
break;
|
|
1821
|
+
case "drop-view":
|
|
1822
|
+
if (!safe) entries.push(refused(d.dropEntries.get(step.name)));
|
|
1823
|
+
break;
|
|
1824
|
+
}
|
|
1825
|
+
return entries;
|
|
1826
|
+
}
|
|
1827
|
+
async execute(d, safe, getAbortReason) {
|
|
1828
|
+
const { hash, allReadables } = d;
|
|
1829
|
+
const deps = this.buildExecutorDeps();
|
|
1830
|
+
const entries = [];
|
|
1831
|
+
/** Removed entries that were NOT dropped — they stay tracked (I2). */
|
|
1832
|
+
const retained = [];
|
|
1833
|
+
const droppedNames = /* @__PURE__ */ new Set();
|
|
1834
|
+
/** Managed views whose stale definition could not be dropped (→ error entry, not recreated). */
|
|
1835
|
+
const failedViews = /* @__PURE__ */ new Map();
|
|
1836
|
+
/**
|
|
1837
|
+
* Pending renames that did NOT run (the rename failed, or the entry
|
|
1838
|
+
* issued no DDL): the object still exists under its old name, so it stays
|
|
1839
|
+
* tracked there and keeps its old-name snapshot — the next run sees the
|
|
1840
|
+
* rename as pending again. Keyed by the readable's (new) name.
|
|
1841
|
+
*/
|
|
1842
|
+
const stillUnderOldName = /* @__PURE__ */ new Map();
|
|
1843
|
+
for (const view of d.views) {
|
|
1844
|
+
const plan = d.viewPlans.get(view.tableName);
|
|
1845
|
+
try {
|
|
1846
|
+
await dropOutdatedView(view, plan, this.space);
|
|
1847
|
+
} catch (error) {
|
|
1848
|
+
failedViews.set(view.tableName, this.stepFailed(error, "View sync", view.tableName));
|
|
1849
|
+
if (plan.isRenamed) stillUnderOldName.set(view.tableName, {
|
|
1850
|
+
name: view.renamedFrom,
|
|
1851
|
+
isView: true,
|
|
1852
|
+
viewType: view.viewPlan.materialized ? "M" : "V"
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
const droppedViewEntries = /* @__PURE__ */ new Map();
|
|
1857
|
+
for (const r of d.removed) {
|
|
1858
|
+
if (!r.isView) continue;
|
|
1859
|
+
if (safe) {
|
|
1860
|
+
retained.push(r);
|
|
1861
|
+
continue;
|
|
1862
|
+
}
|
|
1863
|
+
try {
|
|
1864
|
+
await this.space.dropViewByName(r.name);
|
|
1865
|
+
droppedViewEntries.set(r.name, d.dropEntries.get(r.name));
|
|
1866
|
+
droppedNames.add(r.name);
|
|
1867
|
+
} catch (error) {
|
|
1868
|
+
const msg = `Drop of view "${r.name}" failed: ${error.message}`;
|
|
1869
|
+
this.logger.error?.(`[schema-sync] ${msg}`);
|
|
1870
|
+
droppedViewEntries.set(r.name, new SyncEntry({
|
|
1871
|
+
name: r.name,
|
|
1872
|
+
viewType: r.viewType,
|
|
1873
|
+
status: "error",
|
|
1874
|
+
errors: [msg]
|
|
1187
1875
|
}));
|
|
1876
|
+
retained.push(r);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
/** FK keys dropped here per child (by its name) — named by a later failure on that child. */
|
|
1880
|
+
const preDroppedFks = /* @__PURE__ */ new Map();
|
|
1881
|
+
if (!safe) for (const t of d.tables) {
|
|
1882
|
+
if (!t.diff?.primaryKeyChanged) continue;
|
|
1883
|
+
const keysByChild = /* @__PURE__ */ new Map();
|
|
1884
|
+
for (const fk of t.inboundFks ?? []) keysByChild.set(fk.table, [...keysByChild.get(fk.table) ?? [], fkKey(fk.fields)]);
|
|
1885
|
+
for (const [childName, fkKeys] of keysByChild) {
|
|
1886
|
+
const child = d.tableByDbName.get(childName);
|
|
1887
|
+
if (child?.readable.dbAdapter.dropForeignKeys) {
|
|
1888
|
+
await child.readable.dbAdapter.dropForeignKeys(fkKeys);
|
|
1889
|
+
preDroppedFks.set(child.name, [...preDroppedFks.get(child.name) ?? [], ...fkKeys]);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
const deferred = [];
|
|
1894
|
+
const drops = {
|
|
1895
|
+
d,
|
|
1896
|
+
retained,
|
|
1897
|
+
droppedNames
|
|
1898
|
+
};
|
|
1899
|
+
for (const step of this.orderedSteps(d)) {
|
|
1900
|
+
this.assertLockHeld(getAbortReason);
|
|
1901
|
+
switch (step.kind) {
|
|
1902
|
+
case "drop":
|
|
1903
|
+
if (safe) retain(step.group, drops);
|
|
1904
|
+
else entries.push(...await this.dropRemovedGroup(step.group, drops));
|
|
1905
|
+
break;
|
|
1906
|
+
case "table": {
|
|
1907
|
+
const t = d.tableByName.get(step.name);
|
|
1908
|
+
const dependsOn = d.tableDependsOn.get(step.name);
|
|
1909
|
+
const entry = t.planEntry.status === "error" ? new SyncEntry(planTableInit(t, safe, dependsOn)) : await executeSyncTable(t, safe, deps, {
|
|
1910
|
+
deferForeignKeysTo: step.cycle,
|
|
1911
|
+
dependsOn,
|
|
1912
|
+
preDroppedFks: preDroppedFks.get(t.name)
|
|
1913
|
+
});
|
|
1914
|
+
const renamed = t.planEntry.status !== "error" && entry.renamedFrom !== void 0;
|
|
1915
|
+
if (t.pendingRename && !renamed) stillUnderOldName.set(t.name, {
|
|
1916
|
+
name: t.pendingRename,
|
|
1917
|
+
isView: false
|
|
1918
|
+
});
|
|
1919
|
+
if (step.cycle) deferred.push({
|
|
1920
|
+
readable: t.readable,
|
|
1921
|
+
index: entries.length
|
|
1922
|
+
});
|
|
1923
|
+
entries.push(entry);
|
|
1924
|
+
break;
|
|
1925
|
+
}
|
|
1926
|
+
case "deferred-fks":
|
|
1927
|
+
for (const { readable, index } of deferred) {
|
|
1928
|
+
this.assertLockHeld(getAbortReason);
|
|
1929
|
+
entries[index] = await executeDeferredForeignKeys(readable, entries[index], deps);
|
|
1930
|
+
}
|
|
1931
|
+
break;
|
|
1932
|
+
case "view": {
|
|
1933
|
+
const view = d.views[step.index];
|
|
1934
|
+
const planned = d.viewEntries[step.index];
|
|
1935
|
+
let msg = failedViews.get(view.tableName);
|
|
1936
|
+
if (msg === void 0) try {
|
|
1937
|
+
entries.push(await executeSyncView(view, planned));
|
|
1938
|
+
break;
|
|
1939
|
+
} catch (error) {
|
|
1940
|
+
msg = this.stepFailed(error, "View sync", view.tableName);
|
|
1941
|
+
}
|
|
1942
|
+
entries.push(planned.withError(msg));
|
|
1943
|
+
break;
|
|
1944
|
+
}
|
|
1945
|
+
case "external":
|
|
1946
|
+
entries.push(d.externalEntries[step.index]);
|
|
1947
|
+
break;
|
|
1948
|
+
case "drop-view": {
|
|
1949
|
+
const entry = droppedViewEntries.get(step.name);
|
|
1950
|
+
if (entry) entries.push(entry);
|
|
1951
|
+
break;
|
|
1952
|
+
}
|
|
1188
1953
|
}
|
|
1189
1954
|
}
|
|
1190
|
-
|
|
1955
|
+
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)`);
|
|
1956
|
+
this.assertLockHeld(getAbortReason);
|
|
1957
|
+
for (const e of entries) if (e.status !== "error" && e.skipped.length > 0) {
|
|
1958
|
+
const work = e.skipped.map((k) => SKIPPED_LABELS[k]).join(", ");
|
|
1959
|
+
this.logger.warn?.(`[schema-sync] Safe mode: "${e.name}" — ${work} skipped, snapshot and hash withheld; the next run without safe applies it`);
|
|
1960
|
+
}
|
|
1961
|
+
const pendingNames = new Set(entries.filter((e) => e.pending).map((e) => e.name));
|
|
1962
|
+
for (const readable of allReadables) {
|
|
1963
|
+
if (pendingNames.has(readable.tableName)) continue;
|
|
1964
|
+
const adapter = readable.dbAdapter;
|
|
1965
|
+
const tm = adapter.typeMapper?.bind(adapter);
|
|
1966
|
+
const opts = adapter.getDesiredTableOptions?.();
|
|
1967
|
+
const snapshot = readable.isView ? computeViewSnapshot(readable) : computeTableSnapshot(readable, tm, opts);
|
|
1968
|
+
await this.store.writeTableSnapshot(readable.tableName, snapshot);
|
|
1969
|
+
}
|
|
1970
|
+
for (const name of droppedNames) await this.store.deleteTableSnapshot(name);
|
|
1971
|
+
for (const readable of allReadables) if (readable.renamedFrom && !stillUnderOldName.has(readable.tableName)) await this.store.deleteTableSnapshot(readable.renamedFrom);
|
|
1972
|
+
await this.store.writeTrackedList(allReadables.filter((r) => !stillUnderOldName.has(r.tableName)), [...retained, ...stillUnderOldName.values()]);
|
|
1973
|
+
if (pendingNames.size === 0) await this.store.writeHash(hash);
|
|
1974
|
+
return {
|
|
1975
|
+
status: "synced",
|
|
1976
|
+
schemaHash: hash,
|
|
1977
|
+
entries
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* Drops one removed-table group (a table, or a foreign-key cycle as one
|
|
1982
|
+
* operation) and returns its entries. A live inbound FK from outside the
|
|
1983
|
+
* removed set blocks the group — error entries, kept tracked — instead of
|
|
1984
|
+
* leaving a dangling constraint or cascading. The probe runs at execution
|
|
1985
|
+
* time, so an inventory child whose FK to the group was dropped earlier in
|
|
1986
|
+
* this run does not block it. Called from the walk for early groups (right
|
|
1987
|
+
* before the table they block) and late groups (after the views) alike.
|
|
1988
|
+
*/
|
|
1989
|
+
async dropRemovedGroup(group, ctx) {
|
|
1990
|
+
const { d, droppedNames } = ctx;
|
|
1991
|
+
const dropGroup = group.length > 1 ? group : void 0;
|
|
1992
|
+
const failed = (message) => {
|
|
1993
|
+
retain(group, ctx);
|
|
1994
|
+
return group.map((name) => {
|
|
1995
|
+
const msg = message(name);
|
|
1996
|
+
this.logger.error?.(`[schema-sync] ${msg}`);
|
|
1997
|
+
return new SyncEntry({
|
|
1998
|
+
name,
|
|
1999
|
+
status: "error",
|
|
2000
|
+
errors: [msg],
|
|
2001
|
+
dropGroup
|
|
2002
|
+
});
|
|
2003
|
+
});
|
|
2004
|
+
};
|
|
2005
|
+
const inbounds = await Promise.all(group.map((name) => this.space.getReferencingForeignKeys(name)));
|
|
2006
|
+
const blockers = [];
|
|
2007
|
+
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}`);
|
|
2008
|
+
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.`);
|
|
2009
|
+
try {
|
|
2010
|
+
if (group.length === 1) await this.space.dropTableByName(group[0]);
|
|
2011
|
+
else await this.space.dropTablesByName(group);
|
|
2012
|
+
} catch (error) {
|
|
2013
|
+
return failed((name) => `Drop of "${name}" failed: ${error.message}`);
|
|
2014
|
+
}
|
|
2015
|
+
for (const name of group) droppedNames.add(name);
|
|
2016
|
+
return group.map((name) => d.dropEntries.get(name));
|
|
2017
|
+
}
|
|
2018
|
+
/** The error-entry message of a DDL failure inside a view step, logged at error level. */
|
|
2019
|
+
stepFailed(error, phase, name) {
|
|
2020
|
+
const msg = `${phase} failed on ${name}: ${error.message}`;
|
|
2021
|
+
this.logger.error?.(`[schema-sync] ${msg}`);
|
|
2022
|
+
return msg;
|
|
2023
|
+
}
|
|
2024
|
+
/**
|
|
2025
|
+
* Surfaces the run outcome per the `onError` policy. Reporting must never be
|
|
2026
|
+
* silently lost to the NoopLogger default — when no real logger is
|
|
2027
|
+
* configured, warnings/errors go to `console` (a production consumer lost
|
|
2028
|
+
* months of failed-index errors to the silent default).
|
|
2029
|
+
*/
|
|
2030
|
+
reportOutcome(result, onError) {
|
|
2031
|
+
if (onError === "silent" || result.entries.length === 0) return;
|
|
2032
|
+
const out = this.logger === NoopLogger ? console : this.logger;
|
|
2033
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2034
|
+
for (const entry of result.entries) counts.set(entry.status, (counts.get(entry.status) ?? 0) + 1);
|
|
2035
|
+
const summary = [...counts.entries()].map(([status, n]) => `${n} ${status}`).join(", ");
|
|
2036
|
+
out.log(`[schema-sync] ${result.status}: ${summary}`);
|
|
2037
|
+
const errored = result.entries.filter((e) => e.hasErrors);
|
|
2038
|
+
if (errored.length === 0) return;
|
|
2039
|
+
const lines = errored.map((e) => `[schema-sync] "${e.name}" ${e.refused ? "refused" : "failed"}: ${e.errors.join("; ") || e.status}`);
|
|
2040
|
+
for (const line of lines) out.error(line);
|
|
2041
|
+
if (onError === "throw") {
|
|
2042
|
+
const verb = result.status === "refused" ? "refused" : "failed";
|
|
2043
|
+
throw new Error(`[schema-sync] ${errored.length} entr${errored.length === 1 ? "y" : "ies"} ${verb}:\n${lines.join("\n")}`);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* Computes a dry-run plan showing what `run()` would do, without executing
|
|
2048
|
+
* any DDL. Entries come back in execution order; pre-flight refusals appear
|
|
2049
|
+
* as `error` entries (`refused: true`) exactly as `run()` would report them.
|
|
2050
|
+
*/
|
|
2051
|
+
async plan(types, opts) {
|
|
2052
|
+
const force = opts?.force ?? false;
|
|
2053
|
+
const safe = opts?.safe ?? false;
|
|
2054
|
+
const resolved = await this.resolveAndHash(types);
|
|
2055
|
+
const { hash } = resolved;
|
|
2056
|
+
await this.store.ensureControlTable();
|
|
2057
|
+
const d = await this.discover(resolved, safe);
|
|
2058
|
+
const refusals = this.preflight(d, safe);
|
|
2059
|
+
const entries = this.buildPlanEntries(d, safe, refusals);
|
|
2060
|
+
if (!force) {
|
|
2061
|
+
if (await this.store.readHash() === hash) return {
|
|
2062
|
+
status: "up-to-date",
|
|
2063
|
+
schemaHash: hash,
|
|
2064
|
+
entries
|
|
2065
|
+
};
|
|
2066
|
+
}
|
|
2067
|
+
return {
|
|
2068
|
+
status: "changes-needed",
|
|
2069
|
+
schemaHash: hash,
|
|
2070
|
+
entries
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
/** Fallback typeMapper for snapshot-based Path B: compares designType directly, skips unions. */
|
|
2074
|
+
resolveTypeMapper(adapter) {
|
|
2075
|
+
return adapter.typeMapper?.bind(adapter) ?? ((f) => f.designType === "union" ? "union" : f.designType);
|
|
1191
2076
|
}
|
|
1192
2077
|
/**
|
|
1193
2078
|
* Populates plan init from a column diff (shared by Path A and Path B).
|
|
2079
|
+
*
|
|
2080
|
+
* The plan's rule set is canonical (since 0.1.129): every change the
|
|
2081
|
+
* executor cannot apply without DDL it does not have is an `error` entry
|
|
2082
|
+
* here, and `executeSyncTable` returns an errored plan entry untouched —
|
|
2083
|
+
* so the executor's own copies of these rules are reached only when
|
|
2084
|
+
* discovery had no diff (a pending rename whose old name it could not
|
|
2085
|
+
* introspect).
|
|
1194
2086
|
*/
|
|
1195
|
-
populatePlanFromDiff(diff, init,
|
|
2087
|
+
populatePlanFromDiff(diff, init, readable, safe) {
|
|
2088
|
+
const name = readable.tableName;
|
|
2089
|
+
const adapter = readable.dbAdapter;
|
|
1196
2090
|
init.columnsToAdd = diff.added;
|
|
1197
2091
|
init.columnsToRename = diff.renamed.map((r) => ({
|
|
1198
2092
|
from: r.oldName,
|
|
1199
2093
|
to: r.field.physicalName
|
|
1200
2094
|
}));
|
|
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
|
-
}));
|
|
2095
|
+
init.typeChanges = describeTypeChanges(diff);
|
|
2096
|
+
Object.assign(init, describeNullableDefaults(diff));
|
|
1215
2097
|
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";
|
|
2098
|
+
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
2099
|
if (diff.conflicts.length > 0) {
|
|
1218
2100
|
init.status = "error";
|
|
1219
2101
|
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.`)];
|
|
1220
2102
|
}
|
|
1221
|
-
if (diff.typeChanged.length > 0 &&
|
|
2103
|
+
if (diff.typeChanged.length > 0 && typeChangeStrategy(readable, diff) === "none") {
|
|
2104
|
+
init.status = "error";
|
|
2105
|
+
init.errors = [...init.errors ?? [], ...typeChangeErrors(readable, diff)];
|
|
2106
|
+
}
|
|
2107
|
+
if (diff.primaryKeyChanged && !safe && !adapter.rebuildPrimaryKey && !adapter.recreateTable && init.status !== "error") {
|
|
1222
2108
|
init.status = "error";
|
|
1223
|
-
init.errors = [...init.errors ?? [],
|
|
2109
|
+
init.errors = [...init.errors ?? [], pkRebuildUnsupported(name, diff.primaryKeyChanged)];
|
|
1224
2110
|
}
|
|
1225
2111
|
}
|
|
1226
2112
|
/**
|
|
1227
2113
|
* Computes table option diff using DB-first introspection with snapshot fallback.
|
|
1228
|
-
* Returns null if the adapter has no table options.
|
|
2114
|
+
* Returns null if the adapter has no table options. `tableName` is the name
|
|
2115
|
+
* the table has in the database right now — the OLD name of a pending
|
|
2116
|
+
* `@db.table.renamed` (the adapter's bound name is the new one, which does
|
|
2117
|
+
* not exist yet); omitted for a table that is not being renamed.
|
|
1229
2118
|
*/
|
|
1230
|
-
async diffTableOptions(readable) {
|
|
2119
|
+
async diffTableOptions(readable, tableName) {
|
|
1231
2120
|
const adapter = readable.dbAdapter;
|
|
1232
2121
|
const desired = adapter.getDesiredTableOptions?.();
|
|
1233
2122
|
if (!desired || desired.length === 0) return null;
|
|
1234
2123
|
let existing;
|
|
1235
|
-
if (adapter.getExistingTableOptions) existing = await adapter.getExistingTableOptions();
|
|
2124
|
+
if (adapter.getExistingTableOptions) existing = await adapter.getExistingTableOptions(tableName);
|
|
1236
2125
|
else {
|
|
1237
|
-
const snapshot = await this.store.readTableSnapshot(readable.tableName);
|
|
2126
|
+
const snapshot = await this.store.readTableSnapshot(tableName ?? readable.tableName);
|
|
1238
2127
|
existing = snapshot ? snapshotToExistingTableOptions(snapshot) : [];
|
|
1239
2128
|
}
|
|
1240
2129
|
if (existing.length === 0) return null;
|
|
1241
2130
|
const destructiveKeys = adapter.destructiveOptionKeys?.();
|
|
1242
2131
|
return computeTableOptionDiff(desired, existing, destructiveKeys);
|
|
1243
2132
|
}
|
|
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
2133
|
buildExecutorDeps() {
|
|
1261
2134
|
return {
|
|
1262
|
-
store: this.store,
|
|
1263
|
-
space: this.space,
|
|
1264
2135
|
logger: this.logger,
|
|
1265
|
-
resolveTypeMapper: this.resolveTypeMapper.bind(this)
|
|
1266
|
-
diffTableOptions: this.diffTableOptions.bind(this)
|
|
2136
|
+
resolveTypeMapper: this.resolveTypeMapper.bind(this)
|
|
1267
2137
|
};
|
|
1268
2138
|
}
|
|
1269
2139
|
};
|
|
@@ -1319,4 +2189,4 @@ async function planSchema(space, types, opts) {
|
|
|
1319
2189
|
return new SchemaSync(space).plan(types, opts);
|
|
1320
2190
|
}
|
|
1321
2191
|
//#endregion
|
|
1322
|
-
export { SchemaSync, SyncEntry, computeColumnDiff, computeForeignKeyDiff, computeSchemaHash, computeTableHash, computeTableOptionDiff, computeTableSnapshot, computeViewSnapshot, fkKey, hasForeignKeyChanges, planSchema, readStoredSnapshot, snapshotToExistingColumns, snapshotToExistingTableOptions, syncSchema };
|
|
2192
|
+
export { SchemaSync, SyncEntry, canonicalizeQueryNode, computeColumnDiff, computeForeignKeyDiff, computeSchemaHash, computeTableHash, computeTableOptionDiff, computeTableSnapshot, computeViewSnapshot, fkKey, hasForeignKeyChanges, planSchema, readStoredSnapshot, snapshotToExistingColumns, snapshotToExistingTableOptions, syncSchema, topoOrder };
|