@atscript/db 0.1.127 → 0.1.128

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