@atscript/db 0.1.140 → 0.1.141

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