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