@atscript/db 0.1.140 → 0.1.142

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 (49) hide show
  1. package/dist/agg.d.cts +1 -1
  2. package/dist/agg.d.mts +1 -1
  3. package/dist/{buckets-C-27xmtq.d.cts → buckets-Bv4pah66.d.cts} +225 -22
  4. package/dist/{buckets-BFG2RYRW.d.mts → buckets-CjL7F-hp.d.mts} +225 -22
  5. package/dist/{column-diff-CgxgFKzx.cjs → column-diff-CfPNcP6e.cjs} +663 -239
  6. package/dist/{column-diff-BwOA5101.mjs → column-diff-CmFNXV8C.mjs} +638 -220
  7. package/dist/column-diff-DiBbXyLA.d.cts +211 -0
  8. package/dist/column-diff-n-k5KY0u.d.mts +211 -0
  9. package/dist/derived-rules-0sKn4f5C.mjs +44 -0
  10. package/dist/derived-rules-YstgIxG-.cjs +67 -0
  11. package/dist/index.cjs +38 -4
  12. package/dist/index.d.cts +23 -6
  13. package/dist/index.d.mts +23 -6
  14. package/dist/index.mjs +34 -4
  15. package/dist/{nested-writer-FWD5oOYh.mjs → nested-writer-BO3vhbkP.mjs} +8 -4
  16. package/dist/{nested-writer-BZNCuqI6.cjs → nested-writer-DYsRxZ5f.cjs} +8 -4
  17. package/dist/object-DSN0h9lB.d.cts +30 -0
  18. package/dist/object-DSN0h9lB.d.mts +30 -0
  19. package/dist/plugin.cjs +392 -139
  20. package/dist/plugin.mjs +392 -139
  21. package/dist/rel.cjs +2 -2
  22. package/dist/rel.d.cts +2 -2
  23. package/dist/rel.d.mts +2 -2
  24. package/dist/rel.mjs +2 -2
  25. package/dist/{relation-helpers-D3Zu0Mta.d.mts → relation-helpers-B59to_dG.d.mts} +5 -4
  26. package/dist/{relation-helpers-DxrvS6ar.d.cts → relation-helpers-DQ_nRsV9.d.cts} +5 -4
  27. package/dist/{relation-loader-6ZB_5KFq.cjs → relation-loader-CgJ8bK6X.cjs} +1 -1
  28. package/dist/{relation-loader-CTFaZpVa.mjs → relation-loader-CuhEBzFU.mjs} +1 -1
  29. package/dist/shared.cjs +6 -1
  30. package/dist/shared.d.cts +48 -9
  31. package/dist/shared.d.mts +48 -9
  32. package/dist/shared.mjs +2 -2
  33. package/dist/sync.cjs +331 -105
  34. package/dist/sync.d.cts +62 -163
  35. package/dist/sync.d.mts +62 -163
  36. package/dist/sync.mjs +331 -105
  37. package/dist/{validation-utils-B4h-GW4d.mjs → validation-utils-CMR4fe2M.mjs} +99 -34
  38. package/dist/{validation-utils-Dg0hW6dn.cjs → validation-utils-DOsB4e6G.cjs} +128 -33
  39. package/dist/{validator-Drb2N-YL.d.cts → validator-Bw6ks9Hy.d.cts} +1 -11
  40. package/dist/{validator-Drb2N-YL.d.mts → validator-Bw6ks9Hy.d.mts} +1 -11
  41. package/dist/{validator-Ch7UIQl9.mjs → validator-D8bPsXPN.mjs} +54 -2
  42. package/dist/{validator-BtZbcLN2.cjs → validator-DASnXf1j.cjs} +77 -1
  43. package/dist/validator.cjs +1 -1
  44. package/dist/validator.d.cts +2 -1
  45. package/dist/validator.d.mts +2 -1
  46. package/dist/validator.mjs +1 -1
  47. package/package.json +6 -6
  48. package/dist/column-diff-BmqvgBWw.d.cts +0 -24
  49. package/dist/column-diff-DPkbZIVE.d.mts +0 -24
package/dist/sync.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { a as hasForeignKeyChanges, c as computeTableHash, d as snapshotToExistingColumns, f as snapshotToExistingTableOptions, i as fkKey, l as computeTableSnapshot, nt as NoopLogger, o as canonicalizeQueryNode, r as computeForeignKeyDiff, s as computeSchemaHash, t as computeColumnDiff, u as computeViewSnapshot } from "./column-diff-BwOA5101.mjs";
1
+ import { r as DbError } from "./db-error-D5uilS_A.mjs";
2
+ import { Z as aliasTargetOf, a as hasForeignKeyChanges, c as computeTableHash, d as snapshotToExistingColumns, f as snapshotToExistingTableOptions, i as fkKey, l as computeTableSnapshot, o as canonicalizeQueryNode, p as viewSnapshotSources, r as computeForeignKeyDiff, rt as NoopLogger, s as computeSchemaHash, t as computeColumnDiff, u as computeViewSnapshot } from "./column-diff-CmFNXV8C.mjs";
2
3
  //#region src/schema/table-option-diff.ts
3
4
  /**
4
5
  * Computes the difference between desired and existing table options.
@@ -99,6 +100,50 @@ function reachable(seeds, edges) {
99
100
  }
100
101
  return out;
101
102
  }
103
+ /**
104
+ * Orders `nodes` so that every node comes AFTER the nodes it depends on,
105
+ * keeping the given order wherever the dependencies allow it: a depth-first
106
+ * walk in `nodes` order that emits a node's dependencies (recursively, in
107
+ * their listed order) right before the node itself. Unlike {@link topoOrder}
108
+ * the result follows the caller's order, not the names — schema sync uses it
109
+ * for managed views, which are created upstream-first in inventory order.
110
+ *
111
+ * - Dependencies outside `nodes` are ignored.
112
+ * - A dependency cycle is reported as `cycle` (the closed path, e.g.
113
+ * `["a", "b", "a"]`) and the order is undefined.
114
+ * @since 0.1.141
115
+ */
116
+ function stableTopo(nodes, dependsOn) {
117
+ const listed = [...new Set(nodes)];
118
+ const nodeSet = new Set(listed);
119
+ const done = /* @__PURE__ */ new Set();
120
+ const path = [];
121
+ const onPath = /* @__PURE__ */ new Set();
122
+ const order = [];
123
+ const visit = (node) => {
124
+ if (done.has(node)) return;
125
+ if (onPath.has(node)) return [...path.slice(path.indexOf(node)), node];
126
+ onPath.add(node);
127
+ path.push(node);
128
+ for (const dep of dependsOn(node)) {
129
+ if (dep === node || !nodeSet.has(dep)) continue;
130
+ const cycle = visit(dep);
131
+ if (cycle) return cycle;
132
+ }
133
+ path.pop();
134
+ onPath.delete(node);
135
+ done.add(node);
136
+ order.push(node);
137
+ };
138
+ for (const node of listed) {
139
+ const cycle = visit(node);
140
+ if (cycle) return {
141
+ order,
142
+ cycle
143
+ };
144
+ }
145
+ return { order };
146
+ }
102
147
  //#endregion
103
148
  //#region src/schema/sync-store.ts
104
149
  var SyncStore = class {
@@ -193,73 +238,93 @@ var SyncStore = class {
193
238
  entries.sort((a, b) => a.name.localeCompare(b.name));
194
239
  await this.writeControlValue("synced_tables", JSON.stringify(entries));
195
240
  }
196
- async tryAcquireLock(podId, ttlMs) {
197
- const now = Date.now();
198
- const existing = await this.controlTable.findOne({
199
- filter: { _id: { $eq: "sync_lock" } },
241
+ async readLock() {
242
+ return await this.controlTable.findOne({
243
+ filter: lockFilter(),
200
244
  controls: {}
201
245
  });
246
+ }
247
+ /**
248
+ * Deletes the lock row only while it is still expired — a peer that took
249
+ * the expired lock over in the meantime keeps its fresh one.
250
+ */
251
+ async deleteExpiredLock(now) {
252
+ await this.controlTable.deleteMany(lockFilter({ expiresAt: { $lt: now } }));
253
+ }
254
+ /**
255
+ * One acquisition attempt: `true` when this pod now holds the lock, `false`
256
+ * when a live lock (or a peer's concurrent insert) holds it. Any other error
257
+ * propagates — a failing insert is not contention.
258
+ */
259
+ async tryAcquireLock(podId, ttlMs) {
260
+ const now = Date.now();
261
+ const existing = await this.readLock();
202
262
  if (existing) {
203
- const expiresAt = existing.expiresAt;
204
- if (expiresAt && expiresAt < now) await this.controlTable.deleteOne("sync_lock");
205
- else return false;
263
+ if (!isExpired(existing, now)) return false;
264
+ await this.deleteExpiredLock(now);
206
265
  }
207
266
  try {
208
267
  await this.controlTable.insertOne({
209
- _id: "sync_lock",
268
+ _id: LOCK_ID,
210
269
  lockedBy: podId,
211
270
  lockedAt: now,
212
271
  expiresAt: now + ttlMs
213
272
  });
214
273
  return true;
215
- } catch {
216
- return false;
274
+ } catch (error) {
275
+ if (error instanceof DbError && error.code === "CONFLICT") return false;
276
+ throw error;
217
277
  }
218
278
  }
279
+ /** Extends this pod's lock; never touches a lock another pod holds. */
219
280
  async refreshLock(podId, ttlMs) {
220
- const existing = await this.controlTable.findOne({
221
- filter: { _id: { $eq: "sync_lock" } },
222
- controls: {}
223
- });
224
- if (!existing) return "missing";
225
- if (existing.lockedBy !== podId) return "stolen";
226
- await this.controlTable.replaceOne({
227
- _id: "sync_lock",
228
- lockedBy: podId,
229
- lockedAt: existing.lockedAt,
230
- expiresAt: Date.now() + ttlMs
231
- });
232
- return "refreshed";
281
+ const { matchedCount } = await this.controlTable.updateMany(lockFilter({ lockedBy: { $eq: podId } }), { expiresAt: Date.now() + ttlMs });
282
+ if (matchedCount > 0) return "refreshed";
283
+ return await this.readLock() ? "stolen" : "missing";
233
284
  }
285
+ /**
286
+ * Deletes this pod's lock. Resolves `false` when there was none to delete
287
+ * (it expired and a peer took it over, or it was removed); errors propagate.
288
+ */
234
289
  async releaseLock(podId) {
235
- try {
236
- const existing = await this.controlTable.findOne({
237
- filter: { _id: { $eq: "sync_lock" } },
238
- controls: {}
239
- });
240
- if (existing && existing.lockedBy === podId) await this.controlTable.deleteOne("sync_lock");
241
- } catch {}
290
+ const { deletedCount } = await this.controlTable.deleteMany(lockFilter({ lockedBy: { $eq: podId } }));
291
+ return deletedCount > 0;
242
292
  }
293
+ /**
294
+ * Polls until the lock is free (or expired — then it is cleared). Resolves
295
+ * `true` then, `false` once `timeoutMs` has passed with the lock still held.
296
+ * Acquiring it is the caller's next step.
297
+ */
243
298
  async waitForLock(timeoutMs, pollIntervalMs) {
244
299
  const deadline = Date.now() + timeoutMs;
245
300
  while (Date.now() < deadline) {
246
- const lock = await this.controlTable.findOne({
247
- filter: { _id: { $eq: "sync_lock" } },
248
- controls: {}
249
- });
250
- if (!lock) return;
251
- const expiresAt = lock.expiresAt;
252
- if (expiresAt && expiresAt < Date.now()) {
253
- await this.controlTable.deleteOne("sync_lock");
254
- return;
301
+ const lock = await this.readLock();
302
+ if (!lock) return true;
303
+ const now = Date.now();
304
+ if (isExpired(lock, now)) {
305
+ await this.deleteExpiredLock(now);
306
+ return true;
255
307
  }
256
308
  await new Promise((resolve) => {
257
- setTimeout(resolve, pollIntervalMs);
309
+ setTimeout(resolve, Math.min(pollIntervalMs, Math.max(deadline - Date.now(), 0)));
258
310
  });
259
311
  }
260
- throw new Error(`Schema sync lock wait timed out after ${timeoutMs}ms`);
312
+ return false;
261
313
  }
262
314
  };
315
+ const LOCK_ID = "sync_lock";
316
+ /** The lock row's filter, narrowed by `conditions` (the compare-and-set part). */
317
+ function lockFilter(conditions) {
318
+ return {
319
+ _id: { $eq: LOCK_ID },
320
+ ...conditions
321
+ };
322
+ }
323
+ /** A lock row without an expiry never expires (it blocks until the wait times out). */
324
+ function isExpired(lock, now) {
325
+ const expiresAt = Number(lock.expiresAt);
326
+ return expiresAt > 0 && expiresAt < now;
327
+ }
263
328
  async function readStoredSnapshot(space, tableName, _asView) {
264
329
  const { AtscriptControl } = await import("./control-D5cQmzFj.mjs");
265
330
  const table = space.getTable(AtscriptControl);
@@ -281,6 +346,11 @@ const noColor = {
281
346
  dim: (s) => s,
282
347
  underline: (s) => s
283
348
  };
349
+ /** `regular → derived` / `expression changed` / `type changed`. */
350
+ function derivedChangeLabel(change) {
351
+ if (change.reason === "kind") return change.derived ? "regular → derived" : "derived → regular";
352
+ return change.reason === "expression" ? "expression changed" : "type changed";
353
+ }
284
354
  var SyncEntry = class SyncEntry {
285
355
  name;
286
356
  /** 'V' = virtual view, 'M' = materialized view, 'E' = external view, undefined = table */
@@ -297,6 +367,8 @@ var SyncEntry = class SyncEntry {
297
367
  fkAdded;
298
368
  fkRemoved;
299
369
  fkChanged;
370
+ /** @since 0.1.141 — see {@link TSyncEntryInit.derivedChanges}. */
371
+ derivedChanges;
300
372
  columnsAdded;
301
373
  columnsRenamed;
302
374
  columnsDropped;
@@ -313,6 +385,8 @@ var SyncEntry = class SyncEntry {
313
385
  dropGroup;
314
386
  /** @since 0.1.128 — see {@link TSyncEntryInit.refused}. */
315
387
  refused;
388
+ /** @since 0.1.141 — see {@link TSyncEntryInit.cascadeFrom}. */
389
+ cascadeFrom;
316
390
  constructor(init) {
317
391
  this.name = init.name;
318
392
  this.viewType = init.viewType;
@@ -328,6 +402,7 @@ var SyncEntry = class SyncEntry {
328
402
  this.fkAdded = init.fkAdded ?? [];
329
403
  this.fkRemoved = init.fkRemoved ?? [];
330
404
  this.fkChanged = init.fkChanged ?? [];
405
+ this.derivedChanges = init.derivedChanges ?? [];
331
406
  this.columnsAdded = init.columnsAdded ?? [];
332
407
  this.columnsRenamed = init.columnsRenamed ?? [];
333
408
  this.columnsDropped = init.columnsDropped ?? [];
@@ -339,6 +414,7 @@ var SyncEntry = class SyncEntry {
339
414
  this.dependsOn = init.dependsOn ?? [];
340
415
  this.dropGroup = init.dropGroup;
341
416
  this.refused = init.refused ?? false;
417
+ this.cascadeFrom = init.cascadeFrom ?? [];
342
418
  }
343
419
  /**
344
420
  * The init object this entry was built from — lets callers derive a
@@ -375,7 +451,7 @@ var SyncEntry = class SyncEntry {
375
451
  /** Whether this entry involves destructive operations (pending work safe mode skipped is not) */
376
452
  get destructive() {
377
453
  if (this.status === "drop") return this.viewType !== "V" && this.viewType !== "E";
378
- 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");
454
+ return this.columnsToDrop.length > 0 || this.typeChanges.length > 0 && !this.skipped.includes("recreate") || this.recreated || this.pkChange?.rebuild === true || this.derivedChanges.some((dc) => dc.reason === "kind") && !this.skipped.includes("derived") || this.optionChanges.some((c) => c.destructive) && !this.skipped.includes("table-options");
379
455
  }
380
456
  /** Whether this entry represents any change (not in-sync) */
381
457
  get hasChanges() {
@@ -419,6 +495,19 @@ var SyncEntry = class SyncEntry {
419
495
  return ` ${c.red(`! ${tc.column}: ${tc.fromType} → ${tc.toType}${action}`)}`;
420
496
  });
421
497
  }
498
+ /**
499
+ * `~ col — derived column (expression changed) — drop + add` (plan) /
500
+ * `— rebuilt` (result), or `! … — skipped (safe mode)` when pending.
501
+ */
502
+ printDerivedChanges(c, mode) {
503
+ const skipped = this.skipped.includes("derived");
504
+ return this.derivedChanges.map((dc) => {
505
+ const head = `${dc.column} — derived column (${derivedChangeLabel(dc)})`;
506
+ if (skipped) return ` ${c.yellow(`! ${head} — skipped (safe mode)`)}`;
507
+ const line = `${dc.reason === "kind" ? "!" : "~"} ${head} — ${mode === "plan" ? "drop + add" : "rebuilt"}`;
508
+ return ` ${dc.reason === "kind" ? c.red(line) : c.yellow(line)}`;
509
+ });
510
+ }
422
511
  /** `~ col — nullable` / `~ col — default a → b`, `— skipped (safe mode)` when pending. */
423
512
  printNullableDefaults(c) {
424
513
  const suffix = this.skipped.includes("nullable-defaults") ? " — skipped (safe mode)" : "";
@@ -444,6 +533,10 @@ var SyncEntry = class SyncEntry {
444
533
  if (this.dependsOn.length === 0) return [];
445
534
  return [` ${c.dim(`· after: ${this.dependsOn.join(", ")}`)}`];
446
535
  }
536
+ /** `· upstream view "x" recreated` — why an unchanged view is recreated. */
537
+ printCascade(c) {
538
+ return this.cascadeFrom.map((name) => ` ${c.dim(`· upstream view "${name}" recreated`)}`);
539
+ }
447
540
  printDropGroup(c) {
448
541
  const others = this.dropGroup?.filter((n) => n !== this.name) ?? [];
449
542
  if (others.length === 0) return [];
@@ -462,7 +555,7 @@ var SyncEntry = class SyncEntry {
462
555
  }
463
556
  if (this.status === "create") return [
464
557
  ` ${c.green(`+ ${vp}${label} — create`)}`,
465
- ...this.columnsToAdd.map((col) => ` ${c.green(`+ ${col.physicalName} (${col.designType})${col.isPrimaryKey ? " PK" : ""}${col.optional ? " nullable" : ""} — add`)}`),
558
+ ...this.columnsToAdd.map((col) => ` ${c.green(`+ ${col.physicalName} (${col.designType})${col.isPrimaryKey ? " PK" : ""}${col.optional ? " nullable" : ""}${col.derived ? " derived" : ""} — add`)}`),
466
559
  ...this.printDependsOn(c),
467
560
  ""
468
561
  ];
@@ -470,16 +563,18 @@ var SyncEntry = class SyncEntry {
470
563
  const renameInfo = this.renamedFrom ? ` ${c.yellow(`(renamed from ${this.renamedFrom})`)}` : "";
471
564
  return [
472
565
  ` ${c.cyan(`~ ${vp}${label} — alter${renameInfo}`)}`,
473
- ...this.columnsToAdd.map((col) => ` ${c.green(`+ ${col.physicalName} (${col.designType}) — add`)}`),
566
+ ...this.columnsToAdd.map((col) => ` ${c.green(`+ ${col.physicalName} (${col.designType})${col.derived ? " derived" : ""} — add`)}`),
474
567
  ...this.columnsToRename.map((r) => ` ${c.yellow(`~ ${r.from} → ${r.to} — rename`)}`),
475
568
  ...this.printTypeChanges(c),
476
569
  ...this.printNullableDefaults(c),
570
+ ...this.printDerivedChanges(c, "plan"),
477
571
  ...this.printPkChange(c, "plan"),
478
572
  ...this.columnsToDrop.map((col) => ` ${c.red(`- ${col} — drop`)}`),
479
573
  ...this.printOptionChanges(c, "plan"),
480
574
  ...this.fkAdded.map((fk) => ` ${c.green(`+ FK(${fk.fields.join(",")}) → ${fk.targetTable} — add`)}`),
481
575
  ...this.fkRemoved.map((fk) => ` ${c.red(`- FK(${fk.fields.join(",")}) → ${fk.targetTable} — remove`)}`),
482
576
  ...this.fkChanged.map((fk) => ` ${c.yellow(`~ FK(${fk.fields.join(",")}) → ${fk.targetTable} — ${fk.details}`)}`),
577
+ ...this.printCascade(c),
483
578
  ...this.printDependsOn(c),
484
579
  ""
485
580
  ];
@@ -498,7 +593,7 @@ var SyncEntry = class SyncEntry {
498
593
  ...this.columnsAdded.map((col) => ` ${c.green(`+ ${col} — added`)}`),
499
594
  ""
500
595
  ];
501
- 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) {
596
+ if (this.columnsAdded.length > 0 || this.columnsRenamed.length > 0 || this.columnsDropped.length > 0 || this.optionChanges.length > 0 || this.pkChange !== void 0 || this.derivedChanges.length > 0 || this.skipped.length > 0 || this.recreated || this.renamedFrom) {
502
597
  const rlabel = this.recreated ? "recreated" : "altered";
503
598
  const renameInfo = this.renamedFrom ? ` ${c.yellow(`(renamed from ${this.renamedFrom})`)}` : "";
504
599
  return [
@@ -508,8 +603,10 @@ var SyncEntry = class SyncEntry {
508
603
  ...this.printPkChange(c, "result"),
509
604
  ...this.printTypeChanges(c),
510
605
  ...this.printNullableDefaults(c),
606
+ ...this.printDerivedChanges(c, "result"),
511
607
  ...this.columnsDropped.map((col) => ` ${c.red(`- ${col} — dropped`)}`),
512
608
  ...this.printOptionChanges(c, "result"),
609
+ ...this.printCascade(c),
513
610
  ""
514
611
  ];
515
612
  }
@@ -554,6 +651,7 @@ function planTableInit(facts, safe, dependsOn) {
554
651
  ...init,
555
652
  columnsToDrop: [],
556
653
  typeChanges: skipped.includes("recreate") ? init.typeChanges : [],
654
+ derivedChanges: skipped.includes("derived") ? init.derivedChanges : [],
557
655
  skipped: skipped.length > 0 ? skipped : void 0,
558
656
  recreated: false
559
657
  };
@@ -581,6 +679,10 @@ function typeChangeErrors(readable, diff) {
581
679
  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.`;
582
680
  return diff.typeChanged.map((tc) => `Type change on ${readable.tableName}.${tc.field.physicalName} (${tc.existingType} → ${tc.field.designType}). ${advice}`);
583
681
  }
682
+ /** A derived-column rebuild on an adapter without `dropColumns` / `syncColumns`. */
683
+ function derivedRebuildUnsupported(name, columns) {
684
+ return `Derived column${columns.length === 1 ? "" : "s"} ${columns.map((c) => `"${c}"`).join(", ")} of "${name}" changed but the adapter cannot drop and re-add columns. Migrate manually and re-run.`;
685
+ }
584
686
  /** `(a, b → c)` — the key change as the messages print it. */
585
687
  function pkLabel(change) {
586
688
  return `(${change.from.join(", ")} → ${change.to.join(", ")})`;
@@ -631,7 +733,7 @@ async function executeSyncTable(facts, safe, deps, exec) {
631
733
  await adapter.ensureTable(ensureOpts);
632
734
  init.status = "create";
633
735
  } else if (existing.length > 0) {
634
- const diff = facts.diff ?? computeColumnDiff(readable.fieldDescriptors, existing, adapter.typeMapper?.bind(adapter));
736
+ const diff = facts.diff ?? computeColumnDiff(readable.columnDescriptors, existing, adapter.typeMapper?.bind(adapter), { snapshot: storedSnapshot });
635
737
  if (hasFkChanges && !adapter.syncForeignKeys && adapter.recreateTable) {
636
738
  await adapter.recreateTable();
637
739
  init.recreated = true;
@@ -656,7 +758,7 @@ async function executeSyncTable(facts, safe, deps, exec) {
656
758
  const existed = adapter.tableExists ? await adapter.tableExists() : false;
657
759
  await adapter.ensureTable(ensureOpts);
658
760
  if (!existed) init.status = "create";
659
- } else await applyColumnDiff(adapter, readable, facts.diff ?? computeColumnDiff(readable.fieldDescriptors, snapshotToExistingColumns(storedSnapshot), deps.resolveTypeMapper(adapter)), init, safe, deps.logger, ensureOpts);
761
+ } else await applyColumnDiff(adapter, readable, facts.diff ?? computeColumnDiff(readable.columnDescriptors, snapshotToExistingColumns(storedSnapshot, readable), deps.resolveTypeMapper(adapter), { snapshot: storedSnapshot }), init, safe, deps.logger, ensureOpts);
660
762
  else {
661
763
  const existed = adapter.tableExists ? await adapter.tableExists() : true;
662
764
  if (!init.recreated) {
@@ -733,6 +835,10 @@ async function executeDeferredForeignKeys(readable, entry, deps) {
733
835
  return entry.withError(msg);
734
836
  }
735
837
  }
838
+ /** Whether a plan drops and recreates the view (its own change, or an upstream's). */
839
+ function viewRecreated(plan) {
840
+ return plan.definitionChanged || (plan.upstreamRecreated?.length ?? 0) > 0;
841
+ }
736
842
  /** Determines whether a view's predecessor (on rename) or stale definition must be dropped. */
737
843
  async function planViewSync(view, trackedNames, store) {
738
844
  const isRenamed = !!(view.renamedFrom && trackedNames.has(view.renamedFrom));
@@ -744,11 +850,11 @@ async function planViewSync(view, trackedNames, store) {
744
850
  /** Drops the stale view a plan identified — views don't support ALTER VIEW. */
745
851
  async function dropOutdatedView(view, plan, space) {
746
852
  if (plan.isRenamed) await space.dropViewByName(view.renamedFrom);
747
- else if (plan.definitionChanged) await space.dropViewByName(view.tableName);
853
+ else if (viewRecreated(plan)) await space.dropViewByName(view.tableName);
748
854
  }
749
855
  /** Status a managed view's entry gets for a given plan. */
750
856
  function viewPlanStatus(view, plan, trackedNames) {
751
- if (plan.isRenamed || plan.definitionChanged) return "alter";
857
+ if (plan.isRenamed || viewRecreated(plan)) return "alter";
752
858
  return trackedNames.has(view.tableName) ? "in-sync" : "create";
753
859
  }
754
860
  /**
@@ -760,6 +866,14 @@ async function executeSyncView(view, planned) {
760
866
  await view.dbAdapter.ensureTable();
761
867
  return planned;
762
868
  }
869
+ /** The plan-shaped summary of a diff's derived-column rebuilds (`entry.derivedChanges`). */
870
+ function describeDerivedChanges(diff) {
871
+ return (diff.derivedChanged ?? []).map((dc) => ({
872
+ column: dc.field.physicalName,
873
+ reason: dc.reason,
874
+ derived: dc.field.derived !== void 0
875
+ }));
876
+ }
763
877
  /** The plan-shaped summary of a diff's type changes (`entry.typeChanges`). */
764
878
  function describeTypeChanges(diff) {
765
879
  return diff.typeChanged.map((tc) => ({
@@ -812,6 +926,7 @@ function safeModeSkips(facts) {
812
926
  if (strategy === "drop") out.push("recreate");
813
927
  else if (strategy === "recreate") return out;
814
928
  if (diff && (diff.nullableChanged.length > 0 || diff.defaultChanged.length > 0) && needsDdlForNullableDefaults(adapter)) out.push("nullable-defaults");
929
+ if ((diff?.derivedChanged?.length ?? 0) > 0) out.push("derived");
815
930
  if (diff?.primaryKeyChanged) out.push("pk-rebuild");
816
931
  if (optionDiff?.changed.some((c) => c.destructive)) out.push("table-options");
817
932
  return out;
@@ -903,6 +1018,34 @@ async function applyColumnDiff(adapter, readable, diff, init, safe, logger, ensu
903
1018
  init.columnsRenamed = syncResult.renamed;
904
1019
  if (syncResult.added.length > 0 || (syncResult.renamed?.length ?? 0) > 0 || needsSyncColumns) init.status = "alter";
905
1020
  }
1021
+ const derivedChanged = diff.derivedChanged ?? [];
1022
+ if (derivedChanged.length > 0 && !init.recreated && init.status !== "error") {
1023
+ init.derivedChanges = describeDerivedChanges(diff);
1024
+ const columns = derivedChanged.map((dc) => dc.field.physicalName);
1025
+ if (safe) {
1026
+ markSkipped(init, "derived");
1027
+ init.status = "alter";
1028
+ logger.warn?.(`[schema-sync] Derived column change on "${name}" (${columns.join(", ")}) — rebuild skipped (safe mode)`);
1029
+ } else if (adapter.dropColumns && adapter.syncColumns) {
1030
+ if (adapter.dropIndexesForColumns) await adapter.dropIndexesForColumns(columns);
1031
+ await adapter.dropColumns(columns);
1032
+ await adapter.syncColumns({
1033
+ added: derivedChanged.map((dc) => dc.field),
1034
+ removed: [],
1035
+ renamed: [],
1036
+ typeChanged: [],
1037
+ nullableChanged: [],
1038
+ defaultChanged: [],
1039
+ conflicts: []
1040
+ });
1041
+ init.status = "alter";
1042
+ } else {
1043
+ const msg = derivedRebuildUnsupported(name, columns);
1044
+ logger.error?.(`[schema-sync] ${msg}`);
1045
+ init.errors = [...init.errors ?? [], msg];
1046
+ init.status = "error";
1047
+ }
1048
+ }
906
1049
  if (diff.primaryKeyChanged && init.status !== "error") {
907
1050
  const { from, to } = diff.primaryKeyChanged;
908
1051
  if (init.recreated) init.pkChange = {
@@ -988,7 +1131,8 @@ const SKIPPED_LABELS = {
988
1131
  "pk-rebuild": "primary-key rebuild",
989
1132
  recreate: "drop-and-recreate",
990
1133
  "table-options": "table-option recreate",
991
- "nullable-defaults": "nullable/default change"
1134
+ "nullable-defaults": "nullable/default change",
1135
+ derived: "derived-column rebuild"
992
1136
  };
993
1137
  /**
994
1138
  * The removed tables whose drop must precede `t`'s own drop/rebuild: those
@@ -1022,6 +1166,7 @@ var SchemaSync = class {
1022
1166
  const views = [];
1023
1167
  const externalViews = [];
1024
1168
  for (const type of types) {
1169
+ if (aliasTargetOf(type)) continue;
1025
1170
  const readable = this.space.get(type);
1026
1171
  if (readable.isView) {
1027
1172
  const view = readable;
@@ -1103,13 +1248,17 @@ var SchemaSync = class {
1103
1248
  * Starts a periodic heartbeat that extends the lock's TTL while sync runs.
1104
1249
  * Returns a handle with `stop()` to cancel and `getAbortReason()` to check
1105
1250
  * whether the lock was stolen or unexpectedly removed.
1251
+ *
1252
+ * At most one refresh is in flight; `stop()` resolves once it has settled,
1253
+ * so the lock is released only after the last refresh — a late refresh must
1254
+ * never race the release (or the next holder's lock).
1106
1255
  */
1107
1256
  startHeartbeat(podId, ttlMs) {
1108
1257
  let abortReason;
1109
1258
  let stopped = false;
1259
+ let inFlight;
1110
1260
  const intervalMs = Math.max(Math.floor(ttlMs / 3), 1e3);
1111
- const timer = setInterval(async () => {
1112
- if (stopped) return;
1261
+ const refresh = async () => {
1113
1262
  try {
1114
1263
  const status = await this.store.refreshLock(podId, ttlMs);
1115
1264
  if (stopped) return;
@@ -1124,22 +1273,55 @@ var SchemaSync = class {
1124
1273
  if (stopped) return;
1125
1274
  this.logger.warn("[schema-sync] Failed to refresh lock heartbeat (will retry):", error instanceof Error ? error.message : error);
1126
1275
  }
1276
+ };
1277
+ const timer = setInterval(() => {
1278
+ if (stopped || inFlight) return;
1279
+ inFlight = refresh().finally(() => {
1280
+ inFlight = void 0;
1281
+ });
1127
1282
  }, intervalMs);
1128
1283
  if (typeof timer === "object" && "unref" in timer) timer.unref();
1129
1284
  return {
1130
- stop() {
1285
+ async stop() {
1131
1286
  stopped = true;
1132
1287
  clearInterval(timer);
1288
+ await inFlight;
1133
1289
  },
1134
1290
  getAbortReason: () => abortReason
1135
1291
  };
1136
1292
  }
1293
+ /**
1294
+ * Acquires the sync lock, waiting for a peer that holds it. Resolves
1295
+ * `"acquired"`, or `"synced-by-peer"` when a non-forced run finds, after a
1296
+ * wait, that the peer stored this schema's hash. A forced run never
1297
+ * short-circuits — it waits its turn and runs. Losing the race for a freed
1298
+ * lock to another waiter just waits again, within the same `waitTimeoutMs`.
1299
+ */
1300
+ async acquireLock(podId, hash, opts) {
1301
+ const deadline = Date.now() + opts.waitTimeoutMs;
1302
+ while (!await this.store.tryAcquireLock(podId, opts.lockTtlMs)) {
1303
+ if (!await this.store.waitForLock(deadline - Date.now(), opts.pollIntervalMs)) throw new Error(`Schema sync lock wait timed out after ${opts.waitTimeoutMs}ms`);
1304
+ if (!opts.force && await this.store.readHash() === hash) return "synced-by-peer";
1305
+ }
1306
+ return "acquired";
1307
+ }
1137
1308
  /** Throws if the heartbeat detected a stolen/missing lock. */
1138
1309
  assertLockHeld(getAbortReason) {
1139
1310
  const reason = getAbortReason();
1140
1311
  if (reason) throw new Error(reason);
1141
1312
  }
1142
1313
  /**
1314
+ * Releases the lock without masking the run's outcome: a failed release is
1315
+ * logged (the row expires after `lockTtlMs`, blocking peers until then).
1316
+ */
1317
+ async releaseLock(podId) {
1318
+ try {
1319
+ await this.store.releaseLock(podId);
1320
+ } catch (error) {
1321
+ this.logger.warn("[schema-sync] Failed to release the sync lock — peers wait until it expires:", error instanceof Error ? error.message : error);
1322
+ }
1323
+ }
1324
+ /**
1143
1325
  * Runs schema synchronization with distributed locking, in three phases:
1144
1326
  *
1145
1327
  * 1. **Discover** (read-only, under the lock): introspect every table once,
@@ -1147,12 +1329,12 @@ var SchemaSync = class {
1147
1329
  * 2. **Pre-flight** (pure): every change that no order of DDL can apply
1148
1330
  * safely becomes a refusal. One refusal → `status: "refused"`, no DDL,
1149
1331
  * nothing persisted, lock released.
1150
- * 3. **Execute** in dependency order: stale/removed views → inbound FKs of
1151
- * key-changing tables → tables (parents first; a removed table that
1152
- * blocks a table's drop-and-recreate or key rebuild is dropped right
1153
- * before it) → deferred FKs of cycles → managed views → external-view
1154
- * checks → remaining removed tables (children first) →
1155
- * snapshots/tracking/hash.
1332
+ * 3. **Execute** in dependency order: removed views, then stale views
1333
+ * (dependents first) → inbound FKs of key-changing tables → tables
1334
+ * (parents first; a removed table that blocks a table's drop-and-recreate
1335
+ * or key rebuild is dropped right before it) → deferred FKs of cycles →
1336
+ * managed views (upstream views first) → external-view checks →
1337
+ * remaining removed tables (children first) → snapshots/tracking/hash.
1156
1338
  */
1157
1339
  async run(types, opts) {
1158
1340
  this.logger = opts?.logger ?? this.logger;
@@ -1173,15 +1355,16 @@ var SchemaSync = class {
1173
1355
  entries: []
1174
1356
  };
1175
1357
  }
1176
- if (!await this.store.tryAcquireLock(podId, lockTtlMs)) {
1177
- await this.store.waitForLock(waitTimeoutMs, pollIntervalMs);
1178
- if (await this.store.readHash() === hash) return {
1179
- status: "synced-by-peer",
1180
- schemaHash: hash,
1181
- entries: []
1182
- };
1183
- if (!await this.store.tryAcquireLock(podId, lockTtlMs)) throw new Error("Failed to acquire schema sync lock after waiting");
1184
- }
1358
+ if (await this.acquireLock(podId, hash, {
1359
+ lockTtlMs,
1360
+ waitTimeoutMs,
1361
+ pollIntervalMs,
1362
+ force
1363
+ }) === "synced-by-peer") return {
1364
+ status: "synced-by-peer",
1365
+ schemaHash: hash,
1366
+ entries: []
1367
+ };
1185
1368
  const heartbeat = this.startHeartbeat(podId, lockTtlMs);
1186
1369
  try {
1187
1370
  if (!force) {
@@ -1207,15 +1390,15 @@ var SchemaSync = class {
1207
1390
  this.reportOutcome(result, onError);
1208
1391
  return result;
1209
1392
  } finally {
1210
- heartbeat.stop();
1211
- await this.store.releaseLock(podId);
1393
+ await heartbeat.stop();
1394
+ await this.releaseLock(podId);
1212
1395
  }
1213
1396
  }
1214
1397
  async discover(resolved, safe) {
1215
- const { tables, views, externalViews, hash } = resolved;
1398
+ const { tables, views: inventoryViews, externalViews, hash } = resolved;
1216
1399
  const allReadables = [
1217
1400
  ...tables,
1218
- ...views,
1401
+ ...inventoryViews,
1219
1402
  ...externalViews
1220
1403
  ];
1221
1404
  const previouslyTracked = await this.store.readTrackedList();
@@ -1226,6 +1409,16 @@ var SchemaSync = class {
1226
1409
  const tableNames = new Set(tableFacts.map((t) => t.name));
1227
1410
  const tableByName = new Map(tableFacts.map((t) => [t.name, t]));
1228
1411
  const tableByDbName = new Map(tableFacts.map((t) => [t.dbName, t]));
1412
+ const viewNames = new Set(inventoryViews.map((v) => v.tableName));
1413
+ const externalViewNames = new Set(externalViews.map((v) => v.tableName));
1414
+ const viewSources = /* @__PURE__ */ new Map();
1415
+ for (const view of inventoryViews) {
1416
+ const plan = view.viewPlan;
1417
+ viewSources.set(view.tableName, sortedUnique([plan.entryTable, ...plan.joins.map((j) => j.targetTable)]));
1418
+ }
1419
+ const viewTopo = stableTopo(inventoryViews.map((v) => v.tableName), (name) => viewSources.get(name) ?? []);
1420
+ const viewByName = new Map(inventoryViews.map((v) => [v.tableName, v]));
1421
+ const views = viewTopo.cycle ? inventoryViews : viewTopo.order.map((name) => viewByName.get(name));
1229
1422
  const viewFacts = await Promise.all(views.map(async (view) => ({
1230
1423
  plan: await planViewSync(view, trackedNames, this.store),
1231
1424
  kind: await view.dbAdapter.getObjectKind?.(view.tableName)
@@ -1233,8 +1426,13 @@ var SchemaSync = class {
1233
1426
  const viewPlans = /* @__PURE__ */ new Map();
1234
1427
  const viewEntries = [];
1235
1428
  const viewObjectKinds = /* @__PURE__ */ new Map();
1429
+ /** Managed views the run drops and recreates (own change, rename, or cascade). */
1430
+ const recreatedViews = /* @__PURE__ */ new Set();
1236
1431
  for (const [i, view] of views.entries()) {
1237
1432
  const { plan, kind } = viewFacts[i];
1433
+ const upstreamRecreated = viewSources.get(view.tableName).filter((n) => recreatedViews.has(n));
1434
+ if (upstreamRecreated.length > 0 && trackedNames.has(view.tableName) && !plan.isRenamed && !plan.definitionChanged) plan.upstreamRecreated = upstreamRecreated;
1435
+ if (plan.isRenamed || viewRecreated(plan)) recreatedViews.add(view.tableName);
1238
1436
  viewPlans.set(view.tableName, plan);
1239
1437
  viewObjectKinds.set(view.tableName, kind);
1240
1438
  viewEntries.push(new SyncEntry({
@@ -1242,8 +1440,9 @@ var SchemaSync = class {
1242
1440
  status: viewPlanStatus(view, plan, trackedNames),
1243
1441
  viewType: view.viewPlan.materialized ? "M" : "V",
1244
1442
  renamedFrom: plan.isRenamed ? view.renamedFrom : void 0,
1245
- recreated: plan.definitionChanged || void 0,
1246
- dependsOn: sortedUnique([view.viewPlan.entryTable, ...view.viewPlan.joins.map((j) => j.targetTable)].filter((n) => tableNames.has(n)))
1443
+ recreated: viewRecreated(plan) || void 0,
1444
+ cascadeFrom: plan.upstreamRecreated,
1445
+ dependsOn: viewSources.get(view.tableName).filter((n) => tableNames.has(n) || viewNames.has(n) || externalViewNames.has(n))
1247
1446
  }));
1248
1447
  }
1249
1448
  const externalEntries = await Promise.all(externalViews.map((v) => this.checkExternalView(v)));
@@ -1256,6 +1455,7 @@ var SchemaSync = class {
1256
1455
  const removedByName = new Map(removed.map((r) => [r.name, r]));
1257
1456
  const externalTargetAdapters = /* @__PURE__ */ new Map();
1258
1457
  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);
1458
+ for (const view of views) for (const source of viewSources.get(view.tableName)) if (!tableNames.has(source) && !viewNames.has(source)) externalTargetAdapters.set(source, view.dbAdapter);
1259
1459
  const externalTargets = new Map(await Promise.all([...externalTargetAdapters].map(async ([target, adapter]) => [target, await this.objectPresent(adapter, target)])));
1260
1460
  const createEdges = [];
1261
1461
  const tableDependsOn = /* @__PURE__ */ new Map();
@@ -1290,6 +1490,12 @@ var SchemaSync = class {
1290
1490
  tableDependsOn.set(name, sortedUnique([...tableDependsOn.get(name), ...groups.flat()]));
1291
1491
  }
1292
1492
  const lateDropOrder = dropOrder.filter((group) => !earlyGroups.has(group));
1493
+ const removedViewNames = removed.filter((r) => r.isView).map((r) => r.name);
1494
+ const removedViewTopo = stableTopo(removedViewNames, (name) => {
1495
+ const snapshot = removedByName.get(name)?.snapshot;
1496
+ return snapshot && "viewType" in snapshot ? viewSnapshotSources(snapshot) : [];
1497
+ });
1498
+ const removedViewOrder = removedViewTopo.cycle ? removedViewNames : removedViewTopo.order.toReversed();
1293
1499
  const dropEntries = /* @__PURE__ */ new Map();
1294
1500
  for (const r of removed) if (r.isView) dropEntries.set(r.name, new SyncEntry({
1295
1501
  name: r.name,
@@ -1313,6 +1519,9 @@ var SchemaSync = class {
1313
1519
  trackedNames,
1314
1520
  viewPlans,
1315
1521
  viewEntries,
1522
+ viewSources,
1523
+ viewCycle: viewTopo.cycle,
1524
+ removedViewOrder,
1316
1525
  viewObjectKinds,
1317
1526
  externalEntries,
1318
1527
  removed,
@@ -1367,22 +1576,22 @@ var SchemaSync = class {
1367
1576
  facts.existing = pendingRename && liveColumns?.length === 0 ? void 0 : liveColumns;
1368
1577
  if (facts.existing && facts.existing.length === 0) {
1369
1578
  init.status = "create";
1370
- init.columnsToAdd = readable.fieldDescriptors.filter((f) => !f.ignored);
1579
+ init.columnsToAdd = [...readable.columnDescriptors];
1371
1580
  } else if (facts.existing && facts.existing.length > 0) {
1372
1581
  const typeMapper = adapter.typeMapper?.bind(adapter);
1373
- facts.diff = computeColumnDiff(readable.fieldDescriptors, facts.existing, typeMapper);
1582
+ facts.diff = computeColumnDiff(readable.columnDescriptors, facts.existing, typeMapper, { snapshot: storedSnapshot });
1374
1583
  this.populatePlanFromDiff(facts.diff, init, readable, safe);
1375
1584
  }
1376
1585
  } else if (adapter.syncColumns) if (!storedSnapshot) {
1377
1586
  if (!pendingRename) {
1378
1587
  if (!(adapter.tableExists ? await adapter.tableExists() : false)) {
1379
1588
  init.status = "create";
1380
- init.columnsToAdd = readable.fieldDescriptors.filter((f) => !f.ignored);
1589
+ init.columnsToAdd = [...readable.columnDescriptors];
1381
1590
  }
1382
1591
  }
1383
1592
  } else {
1384
- const existing = snapshotToExistingColumns(storedSnapshot);
1385
- facts.diff = computeColumnDiff(readable.fieldDescriptors, existing, this.resolveTypeMapper(adapter));
1593
+ const existing = snapshotToExistingColumns(storedSnapshot, readable);
1594
+ facts.diff = computeColumnDiff(readable.columnDescriptors, existing, this.resolveTypeMapper(adapter), { snapshot: storedSnapshot });
1386
1595
  this.populatePlanFromDiff(facts.diff, init, readable, safe);
1387
1596
  }
1388
1597
  else if (adapter.tableExists) {
@@ -1459,20 +1668,20 @@ var SchemaSync = class {
1459
1668
  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`);
1460
1669
  }
1461
1670
  }
1671
+ if (d.viewCycle) for (const name of new Set(d.viewCycle)) addRefusal(refusals, name, `Managed views form a dependency cycle: ${d.viewCycle.join(" → ")}`);
1462
1672
  for (const view of d.views) {
1463
1673
  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`);
1464
1674
  const fns = view.dbAdapter.aggregateFns();
1465
1675
  for (const col of view.getViewColumnMappings()) if (col.aggFn && !fns.has(col.aggFn)) addRefusal(refusals, view.tableName, `View "${view.tableName}" field "${col.viewPath}": aggregate "${col.aggFn}" is not supported by this adapter (aggregateFns())`);
1676
+ for (const source of d.viewSources.get(view.tableName)) if (d.externalTargets.get(source) === false) addRefusal(refusals, view.tableName, `View "${view.tableName}" reads "${source}" which is neither in the sync inventory nor present in the database`);
1466
1677
  }
1467
1678
  if (!safe) for (const r of d.removed) {
1468
- if (r.isView) continue;
1469
1679
  const referencers = [];
1470
- 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)`);
1471
- for (const view of d.views) {
1472
- const plan = view.viewPlan;
1473
- if (plan.entryTable === r.name || plan.joins.some((j) => j.targetTable === r.name)) referencers.push(`view "${view.tableName}"`);
1680
+ if (!r.isView) {
1681
+ 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)`);
1474
1682
  }
1475
- 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.`);
1683
+ for (const view of d.views) if (d.viewSources.get(view.tableName).includes(r.name)) referencers.push(`view "${view.tableName}"`);
1684
+ if (referencers.length > 0) addRefusal(refusals, r.name, `Cannot drop ${r.isView ? "view " : ""}"${r.name}": it is still referenced by ${referencers.join(", ")}. Add "${r.name}" to the sync inventory or remove the reference.`);
1476
1685
  }
1477
1686
  return refusals;
1478
1687
  }
@@ -1506,9 +1715,9 @@ var SchemaSync = class {
1506
1715
  kind: "external",
1507
1716
  index
1508
1717
  };
1509
- for (const r of d.removed) if (r.isView) yield {
1718
+ for (const name of d.removedViewOrder) yield {
1510
1719
  kind: "drop-view",
1511
- name: r.name
1720
+ name
1512
1721
  };
1513
1722
  for (const group of d.lateDropOrder) yield {
1514
1723
  kind: "drop",
@@ -1548,7 +1757,11 @@ var SchemaSync = class {
1548
1757
  /** Removed entries that were NOT dropped — they stay tracked (I2). */
1549
1758
  const retained = [];
1550
1759
  const droppedNames = /* @__PURE__ */ new Set();
1551
- /** Managed views whose stale definition could not be dropped (→ error entry, not recreated). */
1760
+ /**
1761
+ * Managed views whose step failed, with the message: a stale definition
1762
+ * that could not be dropped (→ error entry, not recreated), or a create
1763
+ * that errored — their dependents are not created over a missing source.
1764
+ */
1552
1765
  const failedViews = /* @__PURE__ */ new Map();
1553
1766
  /**
1554
1767
  * Pending renames that did NOT run (the rename failed, or the entry
@@ -1557,22 +1770,9 @@ var SchemaSync = class {
1557
1770
  * rename as pending again. Keyed by the readable's (new) name.
1558
1771
  */
1559
1772
  const stillUnderOldName = /* @__PURE__ */ new Map();
1560
- for (const view of d.views) {
1561
- const plan = d.viewPlans.get(view.tableName);
1562
- try {
1563
- await dropOutdatedView(view, plan, this.space);
1564
- } catch (error) {
1565
- failedViews.set(view.tableName, this.stepFailed(error, "View sync", view.tableName));
1566
- if (plan.isRenamed) stillUnderOldName.set(view.tableName, {
1567
- name: view.renamedFrom,
1568
- isView: true,
1569
- viewType: view.viewPlan.materialized ? "M" : "V"
1570
- });
1571
- }
1572
- }
1573
1773
  const droppedViewEntries = /* @__PURE__ */ new Map();
1574
- for (const r of d.removed) {
1575
- if (!r.isView) continue;
1774
+ for (const name of d.removedViewOrder) {
1775
+ const r = d.removedByName.get(name);
1576
1776
  if (safe) {
1577
1777
  retained.push(r);
1578
1778
  continue;
@@ -1593,6 +1793,19 @@ var SchemaSync = class {
1593
1793
  retained.push(r);
1594
1794
  }
1595
1795
  }
1796
+ for (const view of d.views.toReversed()) {
1797
+ const plan = d.viewPlans.get(view.tableName);
1798
+ try {
1799
+ await dropOutdatedView(view, plan, this.space);
1800
+ } catch (error) {
1801
+ failedViews.set(view.tableName, this.stepFailed(error, "View sync", view.tableName));
1802
+ if (plan.isRenamed) stillUnderOldName.set(view.tableName, {
1803
+ name: view.renamedFrom,
1804
+ isView: true,
1805
+ viewType: view.viewPlan.materialized ? "M" : "V"
1806
+ });
1807
+ }
1808
+ }
1596
1809
  /** FK keys dropped here per child (by its name) — named by a later failure on that child. */
1597
1810
  const preDroppedFks = /* @__PURE__ */ new Map();
1598
1811
  if (!safe) for (const t of d.tables) {
@@ -1650,12 +1863,20 @@ var SchemaSync = class {
1650
1863
  const view = d.views[step.index];
1651
1864
  const planned = d.viewEntries[step.index];
1652
1865
  let msg = failedViews.get(view.tableName);
1866
+ if (msg === void 0) {
1867
+ const failedUpstream = d.viewSources.get(view.tableName).filter((n) => failedViews.has(n));
1868
+ if (failedUpstream.length > 0) {
1869
+ msg = `Upstream view ${failedUpstream.map((n) => `"${n}"`).join(", ")} failed — "${view.tableName}" was not created over it`;
1870
+ this.logger.error?.(`[schema-sync] ${msg}`);
1871
+ }
1872
+ }
1653
1873
  if (msg === void 0) try {
1654
1874
  entries.push(await executeSyncView(view, planned));
1655
1875
  break;
1656
1876
  } catch (error) {
1657
1877
  msg = this.stepFailed(error, "View sync", view.tableName);
1658
1878
  }
1879
+ failedViews.set(view.tableName, msg);
1659
1880
  entries.push(planned.withError(msg));
1660
1881
  break;
1661
1882
  }
@@ -1811,8 +2032,9 @@ var SchemaSync = class {
1811
2032
  }));
1812
2033
  init.typeChanges = describeTypeChanges(diff);
1813
2034
  Object.assign(init, describeNullableDefaults(diff));
2035
+ init.derivedChanges = describeDerivedChanges(diff);
1814
2036
  init.columnsToDrop = diff.removed.map((c) => c.name);
1815
- 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";
2037
+ 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.derivedChanges.length > 0 || diff.primaryKeyChanged !== void 0) init.status = "alter";
1816
2038
  if (diff.conflicts.length > 0) {
1817
2039
  init.status = "error";
1818
2040
  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.`)];
@@ -1821,6 +2043,10 @@ var SchemaSync = class {
1821
2043
  init.status = "error";
1822
2044
  init.errors = [...init.errors ?? [], ...typeChangeErrors(readable, diff)];
1823
2045
  }
2046
+ if (init.derivedChanges.length > 0 && !safe && (!adapter.dropColumns || !adapter.syncColumns) && init.status !== "error") {
2047
+ init.status = "error";
2048
+ init.errors = [...init.errors ?? [], derivedRebuildUnsupported(name, init.derivedChanges.map((dc) => dc.column))];
2049
+ }
1824
2050
  if (diff.primaryKeyChanged && !safe && !adapter.rebuildPrimaryKey && !adapter.recreateTable && init.status !== "error") {
1825
2051
  init.status = "error";
1826
2052
  init.errors = [...init.errors ?? [], pkRebuildUnsupported(name, diff.primaryKeyChanged)];