@atscript/db 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/sync.mjs ADDED
@@ -0,0 +1,1186 @@
1
+ import { NoopLogger } from "./logger-B7oxCfLQ.mjs";
2
+
3
+ //#region packages/db/src/schema/schema-hash.ts
4
+ /** Extracts sorted field snapshots from a readable's field descriptors. */ function extractFieldSnapshots(fields, typeMapper) {
5
+ return fields.filter((f) => !f.ignored).map((f) => {
6
+ const snap = {
7
+ physicalName: f.physicalName,
8
+ designType: f.designType,
9
+ optional: f.optional,
10
+ isPrimaryKey: f.isPrimaryKey,
11
+ storage: f.storage
12
+ };
13
+ if (f.defaultValue) snap.defaultValue = f.defaultValue;
14
+ if (typeMapper) snap.mappedType = typeMapper(f);
15
+ return snap;
16
+ }).sort((a, b) => a.physicalName.localeCompare(b.physicalName));
17
+ }
18
+ function computeTableSnapshot(readable, typeMapper, tableOptions) {
19
+ const fields = extractFieldSnapshots(readable.fieldDescriptors, typeMapper);
20
+ const indexes = [...readable.indexes.values()].map((idx) => ({
21
+ key: idx.key,
22
+ type: idx.type,
23
+ fields: idx.fields.map((f) => ({
24
+ name: f.name,
25
+ sort: f.sort
26
+ }))
27
+ })).sort((a, b) => a.key.localeCompare(b.key));
28
+ const foreignKeys = [...readable.foreignKeys.values()].map((fk) => ({
29
+ fields: [...fk.fields].sort(),
30
+ targetTable: fk.targetTable,
31
+ targetFields: [...fk.targetFields].sort(),
32
+ onDelete: fk.onDelete,
33
+ onUpdate: fk.onUpdate
34
+ })).sort((a, b) => a.fields.join(",").localeCompare(b.fields.join(",")));
35
+ const snapshot = {
36
+ tableName: readable.tableName,
37
+ fields,
38
+ indexes,
39
+ foreignKeys
40
+ };
41
+ if (tableOptions?.length) snapshot.tableOptions = [...tableOptions].sort((a, b) => a.key.localeCompare(b.key));
42
+ return snapshot;
43
+ }
44
+ function computeViewSnapshot(view) {
45
+ const fields = extractFieldSnapshots(view.fieldDescriptors);
46
+ if (view.isExternal) return {
47
+ tableName: view.tableName,
48
+ viewType: "E",
49
+ fields
50
+ };
51
+ const plan = view.viewPlan;
52
+ const result = {
53
+ tableName: view.tableName,
54
+ viewType: plan.materialized ? "M" : "V",
55
+ entryTable: plan.entryTable,
56
+ joinTables: plan.joins.map((j) => j.targetTable),
57
+ materialized: plan.materialized || undefined,
58
+ fields
59
+ };
60
+ if (plan.filter) result.filterHash = fnv1a(JSON.stringify(plan.filter, (_, v) => typeof v === "function" ? "[fn]" : v));
61
+ return result;
62
+ }
63
+ function computeSchemaHash(snapshots) {
64
+ const sorted = [...snapshots].sort((a, b) => a.tableName.localeCompare(b.tableName));
65
+ const json = JSON.stringify(sorted);
66
+ return fnv1a(json);
67
+ }
68
+ function computeTableHash(snapshot) {
69
+ return fnv1a(JSON.stringify(snapshot));
70
+ }
71
+ function snapshotToExistingColumns(snapshot) {
72
+ return snapshot.fields.map((f) => ({
73
+ name: f.physicalName,
74
+ type: f.mappedType ?? f.designType,
75
+ notnull: !f.optional,
76
+ pk: f.isPrimaryKey,
77
+ dflt_value: serializeDefaultValue(f.defaultValue)
78
+ }));
79
+ }
80
+ function snapshotToExistingTableOptions(snapshot) {
81
+ return snapshot.tableOptions ?? [];
82
+ }
83
+ function serializeDefaultValue(dv) {
84
+ if (!dv) return undefined;
85
+ if (dv.kind === "value") return dv.value;
86
+ return `fn:${dv.fn}`;
87
+ }
88
+ /** FNV-1a 32-bit hash → hex string */ function fnv1a(str) {
89
+ let hash = 2166136261;
90
+ for (let i = 0; i < str.length; i++) {
91
+ hash ^= str.codePointAt(i);
92
+ hash = Math.imul(hash, 16777619);
93
+ }
94
+ return Math.trunc(hash).toString(16).padStart(8, "0");
95
+ }
96
+
97
+ //#endregion
98
+ //#region packages/db/src/schema/column-diff.ts
99
+ function computeColumnDiff(desired, existing, typeMapper) {
100
+ const existingByName = new Map(existing.map((c) => [c.name, c]));
101
+ const desiredByName = new Map();
102
+ const renamedOldNames = new Set();
103
+ const added = [];
104
+ const renamed = [];
105
+ const typeChanged = [];
106
+ const nullableChanged = [];
107
+ const defaultChanged = [];
108
+ const conflicts = [];
109
+ for (const field of desired) {
110
+ if (field.ignored) continue;
111
+ desiredByName.set(field.physicalName, field);
112
+ const existingCol = existingByName.get(field.physicalName);
113
+ if (existingCol) if (field.renamedFrom && existingByName.has(field.renamedFrom)) {
114
+ conflicts.push({
115
+ field,
116
+ oldName: field.renamedFrom,
117
+ conflictsWith: field.physicalName
118
+ });
119
+ renamedOldNames.add(field.renamedFrom);
120
+ } else {
121
+ if (typeMapper) {
122
+ const expectedType = typeMapper(field);
123
+ if (expectedType.toUpperCase() !== existingCol.type.toUpperCase()) typeChanged.push({
124
+ field,
125
+ existingType: existingCol.type
126
+ });
127
+ }
128
+ if (!field.isPrimaryKey && !existingCol.pk) {
129
+ const desiredNotNull = !field.optional;
130
+ if (existingCol.notnull !== desiredNotNull) nullableChanged.push({
131
+ field,
132
+ wasNullable: !existingCol.notnull
133
+ });
134
+ }
135
+ const desiredDefault = serializeDefaultValue(field.defaultValue);
136
+ if (existingCol.dflt_value !== undefined && existingCol.dflt_value !== desiredDefault) defaultChanged.push({
137
+ field,
138
+ oldDefault: existingCol.dflt_value,
139
+ newDefault: desiredDefault
140
+ });
141
+ }
142
+ else if (field.renamedFrom && existingByName.has(field.renamedFrom)) {
143
+ renamed.push({
144
+ field,
145
+ oldName: field.renamedFrom
146
+ });
147
+ renamedOldNames.add(field.renamedFrom);
148
+ } else added.push(field);
149
+ }
150
+ const removed = existing.filter((c) => !desiredByName.has(c.name) && !renamedOldNames.has(c.name));
151
+ return {
152
+ added,
153
+ removed,
154
+ renamed,
155
+ typeChanged,
156
+ nullableChanged,
157
+ defaultChanged,
158
+ conflicts
159
+ };
160
+ }
161
+
162
+ //#endregion
163
+ //#region packages/db/src/schema/fk-diff.ts
164
+ function fkKey(fields) {
165
+ return [...fields].sort().join(",");
166
+ }
167
+ function computeForeignKeyDiff(desired, existingSnapshot) {
168
+ const added = [];
169
+ const removed = [];
170
+ const changed = [];
171
+ const existingByKey = new Map();
172
+ for (const fk of existingSnapshot) existingByKey.set(fkKey(fk.fields), fk);
173
+ const desiredKeys = new Set();
174
+ for (const fk of desired.values()) {
175
+ const key = fkKey(fk.fields);
176
+ desiredKeys.add(key);
177
+ const existing = existingByKey.get(key);
178
+ if (!existing) added.push(fk);
179
+ else if (fkPropertiesDiffer(fk, existing)) changed.push({
180
+ desired: fk,
181
+ existing
182
+ });
183
+ }
184
+ for (const [key, fk] of existingByKey) if (!desiredKeys.has(key)) removed.push(fk);
185
+ return {
186
+ added,
187
+ removed,
188
+ changed
189
+ };
190
+ }
191
+ function hasForeignKeyChanges(diff) {
192
+ return diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
193
+ }
194
+ function fkPropertiesDiffer(desired, existing) {
195
+ if (desired.targetTable !== existing.targetTable) return true;
196
+ if (fkKey(desired.targetFields) !== fkKey(existing.targetFields)) return true;
197
+ if ((desired.onDelete ?? undefined) !== (existing.onDelete ?? undefined)) return true;
198
+ if ((desired.onUpdate ?? undefined) !== (existing.onUpdate ?? undefined)) return true;
199
+ return false;
200
+ }
201
+
202
+ //#endregion
203
+ //#region packages/db/src/schema/table-option-diff.ts
204
+ function computeTableOptionDiff(desired, existing, destructiveKeys) {
205
+ const existingByKey = new Map(existing.map((o) => [o.key, o.value]));
206
+ const changed = [];
207
+ for (const opt of desired) {
208
+ const existingValue = existingByKey.get(opt.key);
209
+ if (existingValue !== undefined && existingValue !== opt.value) changed.push({
210
+ key: opt.key,
211
+ oldValue: existingValue,
212
+ newValue: opt.value,
213
+ destructive: destructiveKeys?.has(opt.key) ?? false
214
+ });
215
+ }
216
+ return { changed };
217
+ }
218
+
219
+ //#endregion
220
+ //#region packages/db/src/schema/sync-store.ts
221
+ function _define_property$2(obj, key, value) {
222
+ if (key in obj) Object.defineProperty(obj, key, {
223
+ value,
224
+ enumerable: true,
225
+ configurable: true,
226
+ writable: true
227
+ });
228
+ else obj[key] = value;
229
+ return obj;
230
+ }
231
+ var SyncStore = class {
232
+ async ensureControlTable() {
233
+ if (!this.controlTable) {
234
+ const { AtscriptControl } = await import("./control_as-bjmwe24C.mjs");
235
+ this.controlTable = this.space.getTable(AtscriptControl);
236
+ }
237
+ await this.controlTable.ensureTable();
238
+ }
239
+ async readControlValue(_id) {
240
+ const row = await this.controlTable.findOne({
241
+ filter: { _id: { $eq: _id } },
242
+ controls: {}
243
+ });
244
+ return row?.value ?? null;
245
+ }
246
+ async writeControlValue(_id, value) {
247
+ const existing = await this.readControlValue(_id);
248
+ if (existing !== null) await this.controlTable.replaceOne({
249
+ _id,
250
+ value
251
+ });
252
+ else await this.controlTable.insertOne({
253
+ _id,
254
+ value
255
+ });
256
+ }
257
+ async readHash() {
258
+ return this.readControlValue("schema_version");
259
+ }
260
+ async writeHash(hash) {
261
+ await this.writeControlValue("schema_version", hash);
262
+ }
263
+ async readTableSnapshot(tableName, _asView) {
264
+ const value = await this.readControlValue(`table_snapshot:${tableName}`);
265
+ return value ? JSON.parse(value) : null;
266
+ }
267
+ async writeTableSnapshot(tableName, snapshot) {
268
+ await this.writeControlValue(`table_snapshot:${tableName}`, JSON.stringify(snapshot));
269
+ }
270
+ async deleteTableSnapshot(tableName) {
271
+ try {
272
+ await this.controlTable.deleteOne(`table_snapshot:${tableName}`);
273
+ } catch {}
274
+ }
275
+ async readTrackedList() {
276
+ const value = await this.readControlValue("synced_tables");
277
+ if (!value) return [];
278
+ const parsed = JSON.parse(value);
279
+ if (Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0] === "string") return parsed.map((name) => ({
280
+ name,
281
+ isView: false
282
+ }));
283
+ return parsed.map((e) => ({
284
+ ...e,
285
+ viewType: e.viewType ?? (e.isView ? "V" : undefined)
286
+ }));
287
+ }
288
+ async writeTrackedList(readables) {
289
+ const entries = readables.map((r) => {
290
+ const isView = r.isView;
291
+ let viewType;
292
+ if (isView) {
293
+ const view = r;
294
+ viewType = view.isExternal ? "E" : view.viewPlan.materialized ? "M" : "V";
295
+ }
296
+ return {
297
+ name: r.tableName,
298
+ isView,
299
+ viewType
300
+ };
301
+ });
302
+ entries.sort((a, b) => a.name.localeCompare(b.name));
303
+ await this.writeControlValue("synced_tables", JSON.stringify(entries));
304
+ }
305
+ async tryAcquireLock(podId, ttlMs) {
306
+ const now = Date.now();
307
+ const existing = await this.controlTable.findOne({
308
+ filter: { _id: { $eq: "sync_lock" } },
309
+ controls: {}
310
+ });
311
+ if (existing) {
312
+ const expiresAt = existing.expiresAt;
313
+ if (expiresAt && expiresAt < now) await this.controlTable.deleteOne("sync_lock");
314
+ else return false;
315
+ }
316
+ try {
317
+ await this.controlTable.insertOne({
318
+ _id: "sync_lock",
319
+ lockedBy: podId,
320
+ lockedAt: now,
321
+ expiresAt: now + ttlMs
322
+ });
323
+ return true;
324
+ } catch {
325
+ return false;
326
+ }
327
+ }
328
+ async refreshLock(podId, ttlMs) {
329
+ const existing = await this.controlTable.findOne({
330
+ filter: { _id: { $eq: "sync_lock" } },
331
+ controls: {}
332
+ });
333
+ if (!existing) return "missing";
334
+ if (existing.lockedBy !== podId) return "stolen";
335
+ await this.controlTable.replaceOne({
336
+ _id: "sync_lock",
337
+ lockedBy: podId,
338
+ lockedAt: existing.lockedAt,
339
+ expiresAt: Date.now() + ttlMs
340
+ });
341
+ return "refreshed";
342
+ }
343
+ async releaseLock(podId) {
344
+ try {
345
+ const existing = await this.controlTable.findOne({
346
+ filter: { _id: { $eq: "sync_lock" } },
347
+ controls: {}
348
+ });
349
+ if (existing && existing.lockedBy === podId) await this.controlTable.deleteOne("sync_lock");
350
+ } catch {}
351
+ }
352
+ async waitForLock(timeoutMs, pollIntervalMs) {
353
+ const deadline = Date.now() + timeoutMs;
354
+ while (Date.now() < deadline) {
355
+ const lock = await this.controlTable.findOne({
356
+ filter: { _id: { $eq: "sync_lock" } },
357
+ controls: {}
358
+ });
359
+ if (!lock) return;
360
+ const expiresAt = lock.expiresAt;
361
+ if (expiresAt && expiresAt < Date.now()) {
362
+ await this.controlTable.deleteOne("sync_lock");
363
+ return;
364
+ }
365
+ await new Promise((resolve) => {
366
+ setTimeout(resolve, pollIntervalMs);
367
+ });
368
+ }
369
+ throw new Error(`Schema sync lock wait timed out after ${timeoutMs}ms`);
370
+ }
371
+ constructor(space) {
372
+ _define_property$2(this, "space", void 0);
373
+ _define_property$2(this, "controlTable", void 0);
374
+ this.space = space;
375
+ }
376
+ };
377
+ async function readStoredSnapshot(space, tableName, _asView) {
378
+ const { AtscriptControl } = await import("./control_as-bjmwe24C.mjs");
379
+ const table = space.getTable(AtscriptControl);
380
+ await table.ensureTable();
381
+ const row = await table.findOne({
382
+ filter: { _id: { $eq: `table_snapshot:${tableName}` } },
383
+ controls: {}
384
+ });
385
+ const value = row?.value ?? null;
386
+ return value ? JSON.parse(value) : null;
387
+ }
388
+
389
+ //#endregion
390
+ //#region packages/db/src/schema/sync-entry.ts
391
+ function _define_property$1(obj, key, value) {
392
+ if (key in obj) Object.defineProperty(obj, key, {
393
+ value,
394
+ enumerable: true,
395
+ configurable: true,
396
+ writable: true
397
+ });
398
+ else obj[key] = value;
399
+ return obj;
400
+ }
401
+ const noColor = {
402
+ green: (s) => s,
403
+ red: (s) => s,
404
+ cyan: (s) => s,
405
+ yellow: (s) => s,
406
+ bold: (s) => s,
407
+ dim: (s) => s,
408
+ underline: (s) => s
409
+ };
410
+ var SyncEntry = class {
411
+ /** Whether this entry involves destructive operations */ get destructive() {
412
+ if (this.status === "drop") return this.viewType !== "V" && this.viewType !== "E";
413
+ return this.columnsToDrop.length > 0 || this.typeChanges.length > 0 || this.recreated || this.optionChanges.some((c) => c.destructive);
414
+ }
415
+ /** Whether this entry represents any change (not in-sync) */ get hasChanges() {
416
+ return this.status !== "in-sync" && this.status !== "error";
417
+ }
418
+ /** Whether this entry has errors */ get hasErrors() {
419
+ return this.status === "error" || this.errors.length > 0;
420
+ }
421
+ /** Render this entry for display */ print(mode, colors) {
422
+ const c = colors ?? noColor;
423
+ return mode === "plan" ? this.printPlan(c) : this.printResult(c);
424
+ }
425
+ labelAndPrefix(c) {
426
+ return {
427
+ label: c.bold(c.underline(this.name)),
428
+ vp: this.viewType ? `${c.dim(`[${this.viewType}]`)} ` : ""
429
+ };
430
+ }
431
+ printError(c, label, vp) {
432
+ return [` ${c.red(`✗ ${vp}${label} — error`)}`, ...this.errors.map((err) => ` ${c.red(err)}`)];
433
+ }
434
+ printPlan(c) {
435
+ const { label, vp } = this.labelAndPrefix(c);
436
+ if (this.status === "error") return this.printError(c, label, vp);
437
+ if (this.status === "drop") {
438
+ const kind = this.viewType ? "drop view" : "drop table";
439
+ return [` ${c.red(`- ${vp}${label} — ${kind}`)}`];
440
+ }
441
+ if (this.status === "create") return [
442
+ ` ${c.green(`+ ${vp}${label} — create`)}`,
443
+ ...this.columnsToAdd.map((col) => ` ${c.green(`+ ${col.physicalName} (${col.designType})${col.isPrimaryKey ? " PK" : ""}${col.optional ? " nullable" : ""} — add`)}`),
444
+ ""
445
+ ];
446
+ if (this.status === "alter") {
447
+ const renameInfo = this.renamedFrom ? ` ${c.yellow(`(renamed from ${this.renamedFrom})`)}` : "";
448
+ return [
449
+ ` ${c.cyan(`~ ${vp}${label} — alter${renameInfo}`)}`,
450
+ ...this.columnsToAdd.map((col) => ` ${c.green(`+ ${col.physicalName} (${col.designType}) — add`)}`),
451
+ ...this.columnsToRename.map((r) => ` ${c.yellow(`~ ${r.from} → ${r.to} — rename`)}`),
452
+ ...this.typeChanges.map((tc) => {
453
+ const action = this.syncMethod ? ` — ${this.syncMethod}` : " — requires migration";
454
+ return ` ${c.red(`! ${tc.column}: ${tc.fromType} → ${tc.toType}${action}`)}`;
455
+ }),
456
+ ...this.nullableChanges.map((nc) => ` ${c.yellow(`~ ${nc.column} — ${nc.toNullable ? "nullable" : "non-nullable"}`)}`),
457
+ ...this.defaultChanges.map((dc) => ` ${c.yellow(`~ ${dc.column} — default ${dc.oldDefault ?? "none"} → ${dc.newDefault ?? "none"}`)}`),
458
+ ...this.columnsToDrop.map((col) => ` ${c.red(`- ${col} — drop`)}`),
459
+ ...this.optionChanges.map((oc) => {
460
+ const tag = oc.destructive ? c.red("!") : c.yellow("~");
461
+ const action = oc.destructive ? " — requires recreation" : "";
462
+ return ` ${tag} ${c.cyan(`option ${oc.key}`)}: ${oc.oldValue} → ${oc.newValue}${action}`;
463
+ }),
464
+ ...this.fkAdded.map((fk) => ` ${c.green(`+ FK(${fk.fields.join(",")}) → ${fk.targetTable} — add`)}`),
465
+ ...this.fkRemoved.map((fk) => ` ${c.red(`- FK(${fk.fields.join(",")}) → ${fk.targetTable} — remove`)}`),
466
+ ...this.fkChanged.map((fk) => ` ${c.yellow(`~ FK(${fk.fields.join(",")}) → ${fk.targetTable} — ${fk.details}`)}`),
467
+ ""
468
+ ];
469
+ }
470
+ return [this.printInSync(c)];
471
+ }
472
+ printResult(c) {
473
+ const { label, vp } = this.labelAndPrefix(c);
474
+ if (this.status === "error") return this.printError(c, label, vp);
475
+ if (this.status === "drop") {
476
+ const kind = this.viewType ? "dropped view" : "dropped table";
477
+ return [` ${c.red(`- ${vp}${label} — ${kind}`)}`];
478
+ }
479
+ if (this.status === "create") return [
480
+ ` ${c.green(`+ ${vp}${label} — created`)}`,
481
+ ...this.columnsAdded.map((col) => ` ${c.green(`+ ${col} — added`)}`),
482
+ ""
483
+ ];
484
+ const hasChanges = this.columnsAdded.length > 0 || this.columnsRenamed.length > 0 || this.columnsDropped.length > 0 || this.optionChanges.length > 0;
485
+ if (hasChanges || this.recreated || this.renamedFrom) {
486
+ const rlabel = this.recreated ? "recreated" : "altered";
487
+ const renameInfo = this.renamedFrom ? ` ${c.yellow(`(renamed from ${this.renamedFrom})`)}` : "";
488
+ const color = this.recreated ? c.yellow : c.cyan;
489
+ return [
490
+ ` ${color(`~ ${vp}${label} — ${rlabel}${renameInfo}`)}`,
491
+ ...this.columnsAdded.map((col) => ` ${c.green(`+ ${col} — added`)}`),
492
+ ...this.columnsRenamed.map((col) => ` ${c.yellow(`~ ${col} — renamed`)}`),
493
+ ...this.columnsDropped.map((col) => ` ${c.red(`- ${col} — dropped`)}`),
494
+ ...this.optionChanges.map((oc) => ` ${c.cyan(`~ option ${oc.key}: ${oc.oldValue} → ${oc.newValue}`)}`),
495
+ ""
496
+ ];
497
+ }
498
+ const lines = [this.printInSync(c)];
499
+ if (this.errors.length > 0) lines.push(...this.errors.map((err) => ` ${c.red(`Error: ${err}`)}`));
500
+ return lines;
501
+ }
502
+ printInSync(c) {
503
+ const prefix = this.viewType ? `${c.dim(`[${this.viewType}]`)} ` : "";
504
+ return ` ${c.green("✓")} ${prefix}${c.bold(this.name)} ${c.dim("— in sync")}`;
505
+ }
506
+ constructor(init) {
507
+ _define_property$1(this, "name", void 0);
508
+ /** 'V' = virtual view, 'M' = materialized view, 'E' = external view, undefined = table */ _define_property$1(this, "viewType", void 0);
509
+ _define_property$1(this, "status", void 0);
510
+ _define_property$1(this, "syncMethod", void 0);
511
+ _define_property$1(this, "columnsToAdd", void 0);
512
+ _define_property$1(this, "columnsToRename", void 0);
513
+ _define_property$1(this, "typeChanges", void 0);
514
+ _define_property$1(this, "nullableChanges", void 0);
515
+ _define_property$1(this, "defaultChanges", void 0);
516
+ _define_property$1(this, "columnsToDrop", void 0);
517
+ _define_property$1(this, "optionChanges", void 0);
518
+ _define_property$1(this, "fkAdded", void 0);
519
+ _define_property$1(this, "fkRemoved", void 0);
520
+ _define_property$1(this, "fkChanged", void 0);
521
+ _define_property$1(this, "columnsAdded", void 0);
522
+ _define_property$1(this, "columnsRenamed", void 0);
523
+ _define_property$1(this, "columnsDropped", void 0);
524
+ _define_property$1(this, "recreated", void 0);
525
+ _define_property$1(this, "errors", void 0);
526
+ _define_property$1(this, "renamedFrom", void 0);
527
+ this.name = init.name;
528
+ this.viewType = init.viewType;
529
+ this.status = init.status;
530
+ this.syncMethod = init.syncMethod;
531
+ this.columnsToAdd = init.columnsToAdd ?? [];
532
+ this.columnsToRename = init.columnsToRename ?? [];
533
+ this.typeChanges = init.typeChanges ?? [];
534
+ this.nullableChanges = init.nullableChanges ?? [];
535
+ this.defaultChanges = init.defaultChanges ?? [];
536
+ this.columnsToDrop = init.columnsToDrop ?? [];
537
+ this.optionChanges = init.optionChanges ?? [];
538
+ this.fkAdded = init.fkAdded ?? [];
539
+ this.fkRemoved = init.fkRemoved ?? [];
540
+ this.fkChanged = init.fkChanged ?? [];
541
+ this.columnsAdded = init.columnsAdded ?? [];
542
+ this.columnsRenamed = init.columnsRenamed ?? [];
543
+ this.columnsDropped = init.columnsDropped ?? [];
544
+ this.recreated = init.recreated ?? false;
545
+ this.errors = init.errors ?? [];
546
+ this.renamedFrom = init.renamedFrom;
547
+ }
548
+ };
549
+
550
+ //#endregion
551
+ //#region packages/db/src/schema/sync-executor.ts
552
+ async function viewDefinitionChanged(view, store) {
553
+ const storedSnapshot = await store.readTableSnapshot(view.tableName, true);
554
+ if (!storedSnapshot) return false;
555
+ const currentHash = computeTableHash(computeViewSnapshot(view));
556
+ return computeTableHash(storedSnapshot) !== currentHash;
557
+ }
558
+ async function executeSyncTable(readable, safe, trackedNames, deps) {
559
+ const adapter = readable.dbAdapter;
560
+ const name = readable.tableName;
561
+ const init = {
562
+ name,
563
+ status: "in-sync",
564
+ syncMethod: readable.syncMethod
565
+ };
566
+ if (readable.renamedFrom && trackedNames.has(readable.renamedFrom) && adapter.renameTable) {
567
+ await adapter.renameTable(readable.renamedFrom);
568
+ init.renamedFrom = readable.renamedFrom;
569
+ init.status = "alter";
570
+ }
571
+ let fkDiff;
572
+ const fkSnapshotName = init.renamedFrom ?? name;
573
+ const storedSnapshot = await deps.store.readTableSnapshot(fkSnapshotName);
574
+ if (storedSnapshot) fkDiff = computeForeignKeyDiff(readable.foreignKeys, storedSnapshot.foreignKeys);
575
+ const hasFkChanges = fkDiff ? hasForeignKeyChanges(fkDiff) : false;
576
+ if (adapter.getExistingColumns && adapter.syncColumns) {
577
+ const existing = await adapter.getExistingColumns();
578
+ if (existing.length === 0 && !init.renamedFrom) {
579
+ await adapter.ensureTable();
580
+ init.status = "create";
581
+ } else if (existing.length > 0) if (hasFkChanges && !init.recreated && !adapter.syncForeignKeys && adapter.recreateTable) {
582
+ await adapter.recreateTable();
583
+ init.recreated = true;
584
+ init.status = "alter";
585
+ } else {
586
+ if (hasFkChanges && fkDiff && adapter.dropForeignKeys) {
587
+ const keysToDrop = [...fkDiff.removed.map((fk) => fkKey(fk.fields)), ...fkDiff.changed.map((fk) => fkKey(fk.desired.fields))];
588
+ if (keysToDrop.length > 0) {
589
+ await adapter.dropForeignKeys(keysToDrop);
590
+ init.status = "alter";
591
+ }
592
+ }
593
+ const typeMapper = adapter.typeMapper?.bind(adapter);
594
+ const diff = computeColumnDiff(readable.fieldDescriptors, existing, typeMapper);
595
+ await applyColumnDiff(adapter, readable, diff, init, safe, deps.logger);
596
+ }
597
+ } else if (adapter.syncColumns) if (!storedSnapshot) {
598
+ const existed = adapter.tableExists ? await adapter.tableExists() : false;
599
+ await adapter.ensureTable();
600
+ if (!existed) init.status = "create";
601
+ } else {
602
+ const existing = snapshotToExistingColumns(storedSnapshot);
603
+ const diff = computeColumnDiff(readable.fieldDescriptors, existing, deps.resolveTypeMapper(adapter));
604
+ await applyColumnDiff(adapter, readable, diff, init, safe, deps.logger);
605
+ }
606
+ else {
607
+ const existed = adapter.tableExists ? await adapter.tableExists() : true;
608
+ if (!init.recreated) {
609
+ await adapter.ensureTable();
610
+ if (!existed) init.status = "create";
611
+ }
612
+ }
613
+ if (init.status !== "create" && !init.recreated && !safe) {
614
+ const optionDiff = await deps.diffTableOptions(readable);
615
+ if (optionDiff && optionDiff.changed.length > 0) {
616
+ init.optionChanges = optionDiff.changed;
617
+ const hasDestructive = optionDiff.changed.some((c) => c.destructive);
618
+ const nonDestructive = optionDiff.changed.filter((c) => !c.destructive);
619
+ if (nonDestructive.length > 0 && adapter.applyTableOptions) {
620
+ await adapter.applyTableOptions(nonDestructive);
621
+ init.status = "alter";
622
+ }
623
+ if (hasDestructive) {
624
+ const syncMethod = readable.syncMethod;
625
+ if (syncMethod === "recreate" && adapter.recreateTable) {
626
+ deps.logger.warn?.(`[schema-sync] Destructive table option change on "${name}" — recreating with data preservation`);
627
+ await adapter.recreateTable();
628
+ init.status = "alter";
629
+ init.recreated = true;
630
+ } else if (adapter.dropTable) {
631
+ deps.logger.warn?.(`[schema-sync] Destructive table option change on "${name}" — dropping and recreating`);
632
+ await adapter.dropTable();
633
+ await adapter.ensureTable();
634
+ init.status = "alter";
635
+ init.recreated = true;
636
+ }
637
+ }
638
+ }
639
+ }
640
+ await adapter.syncIndexes();
641
+ if (adapter.syncForeignKeys) await adapter.syncForeignKeys();
642
+ if (adapter.afterSyncTable) await adapter.afterSyncTable();
643
+ return new SyncEntry(init);
644
+ }
645
+ async function executeSyncView(view, trackedNames, deps) {
646
+ const renamedFrom = view.renamedFrom;
647
+ const isRenamed = renamedFrom && trackedNames.has(renamedFrom);
648
+ if (isRenamed) await deps.space.dropViewByName(renamedFrom);
649
+ let definitionChanged = false;
650
+ if (!isRenamed && trackedNames.has(view.tableName)) {
651
+ definitionChanged = await viewDefinitionChanged(view, deps.store);
652
+ if (definitionChanged) await deps.space.dropViewByName(view.tableName);
653
+ }
654
+ await view.dbAdapter.ensureTable();
655
+ const viewType = view.viewPlan.materialized ? "M" : "V";
656
+ let status;
657
+ if (isRenamed || definitionChanged) status = "alter";
658
+ else if (trackedNames.has(view.tableName)) status = "in-sync";
659
+ else status = "create";
660
+ return new SyncEntry({
661
+ name: view.tableName,
662
+ status,
663
+ viewType,
664
+ renamedFrom: isRenamed ? renamedFrom : undefined,
665
+ recreated: definitionChanged || undefined
666
+ });
667
+ }
668
+ async function applyColumnDiff(adapter, readable, diff, init, safe, logger) {
669
+ const name = readable.tableName;
670
+ if (diff.conflicts.length > 0) {
671
+ const errors = diff.conflicts.map((c) => `Column rename conflict on ${name}: cannot rename "${c.oldName}" → "${c.field.physicalName}" because "${c.conflictsWith}" already exists.`);
672
+ for (const msg of errors) logger.error?.(`[schema-sync] ${msg}`);
673
+ init.errors = [...init.errors ?? [], ...errors];
674
+ init.status = "error";
675
+ }
676
+ let needsSyncColumns = false;
677
+ if (diff.typeChanged.length > 0 && init.status !== "error") {
678
+ const syncMethod = readable.syncMethod;
679
+ if (syncMethod === "drop" && adapter.dropTable) {
680
+ await adapter.dropTable();
681
+ await adapter.ensureTable();
682
+ init.recreated = true;
683
+ init.status = "alter";
684
+ } else if (syncMethod === "recreate" && adapter.recreateTable) {
685
+ await adapter.recreateTable();
686
+ init.recreated = true;
687
+ init.status = "alter";
688
+ } else if (adapter.supportsColumnModify && adapter.syncColumns) {
689
+ needsSyncColumns = true;
690
+ init.status = "alter";
691
+ } else {
692
+ const errors = [];
693
+ for (const change of diff.typeChanged) {
694
+ const msg = `Type change on ${name}.${change.field.physicalName} ` + `(${change.existingType} → ${change.field.designType}). ` + `Add @db.sync.method "recreate" or "drop", or migrate manually.`;
695
+ logger.error?.(`[schema-sync] ${msg}`);
696
+ errors.push(msg);
697
+ }
698
+ init.errors = errors;
699
+ init.status = "error";
700
+ }
701
+ }
702
+ if (!safe && !init.recreated && init.status !== "error" && (diff.nullableChanged.length > 0 || diff.defaultChanged.length > 0)) if (adapter.supportsColumnModify && adapter.syncColumns) {
703
+ needsSyncColumns = true;
704
+ init.status = "alter";
705
+ } else if (adapter.recreateTable) {
706
+ await adapter.recreateTable();
707
+ init.recreated = true;
708
+ init.status = "alter";
709
+ } else init.status = "alter";
710
+ else if (diff.nullableChanged.length > 0 || diff.defaultChanged.length > 0) init.status = "alter";
711
+ if (!init.recreated && init.status !== "error" && (diff.added.length > 0 || diff.renamed.length > 0 || needsSyncColumns) && adapter.syncColumns) {
712
+ const syncResult = await adapter.syncColumns(diff);
713
+ init.columnsAdded = syncResult.added;
714
+ init.columnsRenamed = syncResult.renamed;
715
+ if (syncResult.added.length > 0 || (syncResult.renamed?.length ?? 0) > 0 || needsSyncColumns) init.status = "alter";
716
+ }
717
+ if (!safe && !init.recreated && init.status !== "error" && diff.removed.length > 0 && adapter.dropColumns) {
718
+ const colNames = diff.removed.map((c) => c.name);
719
+ await adapter.dropColumns(colNames);
720
+ init.columnsDropped = colNames;
721
+ init.status = "alter";
722
+ }
723
+ }
724
+
725
+ //#endregion
726
+ //#region packages/db/src/schema/schema-sync.ts
727
+ function _define_property(obj, key, value) {
728
+ if (key in obj) Object.defineProperty(obj, key, {
729
+ value,
730
+ enumerable: true,
731
+ configurable: true,
732
+ writable: true
733
+ });
734
+ else obj[key] = value;
735
+ return obj;
736
+ }
737
+ /** Builds a human-readable description of what changed in an FK constraint. */ function buildFkChangeDetails(desired, existing) {
738
+ const parts = [];
739
+ if (existing.targetTable !== desired.targetTable) parts.push(`retarget ${existing.targetTable} → ${desired.targetTable}`);
740
+ if ([...existing.targetFields].sort().join(",") !== [...desired.targetFields].sort().join(",")) parts.push(`fields ${existing.targetFields.join(",")} → ${desired.targetFields.join(",")}`);
741
+ if ((existing.onDelete ?? undefined) !== (desired.onDelete ?? undefined)) parts.push(`onDelete ${existing.onDelete ?? "noAction"} → ${desired.onDelete ?? "noAction"}`);
742
+ if ((existing.onUpdate ?? undefined) !== (desired.onUpdate ?? undefined)) parts.push(`onUpdate ${existing.onUpdate ?? "noAction"} → ${desired.onUpdate ?? "noAction"}`);
743
+ return parts.join(", ");
744
+ }
745
+ var SchemaSync = class {
746
+ /**
747
+ * Resolves types into categorized readables and computes the schema hash.
748
+ * Passes each adapter's typeMapper for precise type tracking in snapshots.
749
+ */ async resolveAndHash(types) {
750
+ const tables = [];
751
+ const views = [];
752
+ const externalViews = [];
753
+ for (const type of types) {
754
+ const readable = this.space.get(type);
755
+ if (readable.isView) {
756
+ const view = readable;
757
+ if (view.isExternal) externalViews.push(view);
758
+ else views.push(readable);
759
+ } else tables.push(readable);
760
+ }
761
+ const allReadables = [
762
+ ...tables,
763
+ ...views,
764
+ ...externalViews
765
+ ];
766
+ const snapshots = allReadables.map((r) => {
767
+ if (r.isView) return computeViewSnapshot(r);
768
+ const tm = r.dbAdapter.typeMapper?.bind(r.dbAdapter);
769
+ const opts = (r.fieldDescriptors, r.dbAdapter.getDesiredTableOptions?.());
770
+ return computeTableSnapshot(r, tm, opts);
771
+ });
772
+ const hash = computeSchemaHash(snapshots);
773
+ return {
774
+ tables,
775
+ views,
776
+ externalViews,
777
+ hash
778
+ };
779
+ }
780
+ /**
781
+ * Checks an external view: verifies it exists in the DB and columns match.
782
+ * Returns a SyncEntry with status 'in-sync' or 'error'.
783
+ */ async checkExternalView(view) {
784
+ const adapter = view.dbAdapter;
785
+ const name = view.tableName;
786
+ if (adapter.getExistingColumns) {
787
+ const existing = await adapter.getExistingColumns();
788
+ if (existing.length === 0) return new SyncEntry({
789
+ name,
790
+ viewType: "E",
791
+ status: "error",
792
+ errors: [`External view "${name}" not found in the database`]
793
+ });
794
+ const existingNames = new Set(existing.map((c) => c.name));
795
+ const missing = view.fieldDescriptors.filter((f) => !f.ignored && !existingNames.has(f.physicalName)).map((f) => f.physicalName);
796
+ if (missing.length > 0) return new SyncEntry({
797
+ name,
798
+ viewType: "E",
799
+ status: "error",
800
+ errors: [`External view "${name}" is missing columns: ${missing.join(", ")}`]
801
+ });
802
+ } else if (adapter.tableExists) {
803
+ const exists = await adapter.tableExists();
804
+ if (!exists) return new SyncEntry({
805
+ name,
806
+ viewType: "E",
807
+ status: "error",
808
+ errors: [`External view "${name}" not found in the database`]
809
+ });
810
+ }
811
+ return new SyncEntry({
812
+ name,
813
+ viewType: "E",
814
+ status: "in-sync"
815
+ });
816
+ }
817
+ /**
818
+ * Detects tables/views present in the previous sync but absent from the current schema.
819
+ * Returns SyncEntry instances with status 'drop'.
820
+ */ async detectRemoved(currentReadables, previous) {
821
+ previous ?? (previous = await this.store.readTrackedList());
822
+ const currentSet = new Set(currentReadables.map((t) => t.tableName));
823
+ const renameFromSet = new Set(currentReadables.map((r) => r.renamedFrom).filter(Boolean));
824
+ const removed = [];
825
+ for (const entry of previous) if (!currentSet.has(entry.name) && !renameFromSet.has(entry.name)) removed.push(new SyncEntry({
826
+ name: entry.name,
827
+ viewType: entry.viewType,
828
+ status: "drop"
829
+ }));
830
+ return removed;
831
+ }
832
+ /**
833
+ * Starts a periodic heartbeat that extends the lock's TTL while sync runs.
834
+ * Returns a handle with `stop()` to cancel and `getAbortReason()` to check
835
+ * whether the lock was stolen or unexpectedly removed.
836
+ */ startHeartbeat(podId, ttlMs) {
837
+ let abortReason;
838
+ let stopped = false;
839
+ const intervalMs = Math.max(Math.floor(ttlMs / 3), 1e3);
840
+ const timer = setInterval(async () => {
841
+ if (stopped) return;
842
+ try {
843
+ const status = await this.store.refreshLock(podId, ttlMs);
844
+ if (stopped) return;
845
+ if (status === "stolen") {
846
+ abortReason = "Schema sync lock was stolen by another pod";
847
+ this.logger.warn("[schema-sync] Lock stolen by another pod — aborting after current operation");
848
+ } else if (status === "missing") {
849
+ abortReason = "Schema sync lock was unexpectedly removed";
850
+ this.logger.warn("[schema-sync] Lock row missing — aborting after current operation");
851
+ }
852
+ } catch (error) {
853
+ if (stopped) return;
854
+ this.logger.warn("[schema-sync] Failed to refresh lock heartbeat (will retry):", error instanceof Error ? error.message : error);
855
+ }
856
+ }, intervalMs);
857
+ if (typeof timer === "object" && "unref" in timer) timer.unref();
858
+ return {
859
+ stop() {
860
+ stopped = true;
861
+ clearInterval(timer);
862
+ },
863
+ getAbortReason: () => abortReason
864
+ };
865
+ }
866
+ /** Throws if the heartbeat detected a stolen/missing lock. */ assertLockHeld(getAbortReason) {
867
+ const reason = getAbortReason();
868
+ if (reason) throw new Error(reason);
869
+ }
870
+ /**
871
+ * Runs schema synchronization with distributed locking.
872
+ */ async run(types, opts) {
873
+ const podId = opts?.podId ?? crypto.randomUUID();
874
+ const lockTtlMs = opts?.lockTtlMs ?? 3e4;
875
+ const waitTimeoutMs = opts?.waitTimeoutMs ?? 6e4;
876
+ const pollIntervalMs = opts?.pollIntervalMs ?? 500;
877
+ const force = opts?.force ?? false;
878
+ const safe = opts?.safe ?? false;
879
+ const { tables, views, externalViews, hash } = await this.resolveAndHash(types);
880
+ await this.store.ensureControlTable();
881
+ if (!force) {
882
+ const storedHash = await this.store.readHash();
883
+ if (storedHash === hash) return {
884
+ status: "up-to-date",
885
+ schemaHash: hash,
886
+ entries: []
887
+ };
888
+ }
889
+ const acquired = await this.store.tryAcquireLock(podId, lockTtlMs);
890
+ if (!acquired) {
891
+ await this.store.waitForLock(waitTimeoutMs, pollIntervalMs);
892
+ const storedHash = await this.store.readHash();
893
+ if (storedHash === hash) return {
894
+ status: "synced-by-peer",
895
+ schemaHash: hash,
896
+ entries: []
897
+ };
898
+ const retryAcquired = await this.store.tryAcquireLock(podId, lockTtlMs);
899
+ if (!retryAcquired) throw new Error("Failed to acquire schema sync lock after waiting");
900
+ }
901
+ const heartbeat = this.startHeartbeat(podId, lockTtlMs);
902
+ try {
903
+ if (!force) {
904
+ const storedHash = await this.store.readHash();
905
+ if (storedHash === hash) return {
906
+ status: "synced-by-peer",
907
+ schemaHash: hash,
908
+ entries: []
909
+ };
910
+ }
911
+ const allReadables = [
912
+ ...tables,
913
+ ...views,
914
+ ...externalViews
915
+ ];
916
+ const previouslyTracked = await this.store.readTrackedList();
917
+ const trackedNames = new Set(previouslyTracked.map((e) => e.name));
918
+ const deps = this.buildExecutorDeps();
919
+ const entries = [];
920
+ for (const readable of tables) {
921
+ this.assertLockHeld(heartbeat.getAbortReason);
922
+ entries.push(await executeSyncTable(readable, safe, trackedNames, deps));
923
+ }
924
+ this.assertLockHeld(heartbeat.getAbortReason);
925
+ const removed = await this.detectRemoved(allReadables, previouslyTracked);
926
+ for (const readable of views) {
927
+ this.assertLockHeld(heartbeat.getAbortReason);
928
+ entries.push(await executeSyncView(readable, trackedNames, deps));
929
+ }
930
+ const externalEntries = await Promise.all(externalViews.map((v) => this.checkExternalView(v)));
931
+ entries.push(...externalEntries);
932
+ this.assertLockHeld(heartbeat.getAbortReason);
933
+ if (!safe) {
934
+ for (const entry of removed) {
935
+ if (entry.viewType === "E") continue;
936
+ if (entry.viewType) await this.space.dropViewByName(entry.name);
937
+ else await this.space.dropTableByName(entry.name);
938
+ }
939
+ entries.push(...removed.filter((e) => e.viewType !== "E"));
940
+ }
941
+ this.assertLockHeld(heartbeat.getAbortReason);
942
+ for (const readable of allReadables) {
943
+ const adapter = readable.dbAdapter;
944
+ const tm = adapter.typeMapper?.bind(adapter);
945
+ const opts$1 = adapter.getDesiredTableOptions?.();
946
+ const snapshot = readable.isView ? computeViewSnapshot(readable) : computeTableSnapshot(readable, tm, opts$1);
947
+ await this.store.writeTableSnapshot(readable.tableName, snapshot);
948
+ }
949
+ if (!safe) for (const entry of removed) {
950
+ if (entry.viewType === "E") continue;
951
+ await this.store.deleteTableSnapshot(entry.name);
952
+ }
953
+ for (const readable of allReadables) if (readable.renamedFrom) await this.store.deleteTableSnapshot(readable.renamedFrom);
954
+ await this.store.writeTrackedList(allReadables);
955
+ await this.store.writeHash(hash);
956
+ return {
957
+ status: "synced",
958
+ schemaHash: hash,
959
+ entries
960
+ };
961
+ } finally {
962
+ heartbeat.stop();
963
+ await this.store.releaseLock(podId);
964
+ }
965
+ }
966
+ /**
967
+ * Computes a dry-run plan showing what `run()` would do, without executing any DDL.
968
+ */ async plan(types, opts) {
969
+ const force = opts?.force ?? false;
970
+ const safe = opts?.safe ?? false;
971
+ const { tables, views, externalViews, hash } = await this.resolveAndHash(types);
972
+ const allReadables = [
973
+ ...tables,
974
+ ...views,
975
+ ...externalViews
976
+ ];
977
+ await this.store.ensureControlTable();
978
+ const previouslyTracked = await this.store.readTrackedList();
979
+ const trackedNames = new Set(previouslyTracked.map((e) => e.name));
980
+ let planEntries = await Promise.all(tables.map((r) => this.planTable(r, trackedNames)));
981
+ const viewEntries = await Promise.all(views.map((v) => this.planView(v, trackedNames)));
982
+ const externalEntries = await Promise.all(externalViews.map((v) => this.checkExternalView(v)));
983
+ if (!force) {
984
+ const storedHash = await this.store.readHash();
985
+ if (storedHash === hash) return {
986
+ status: "up-to-date",
987
+ schemaHash: hash,
988
+ entries: [
989
+ ...planEntries,
990
+ ...viewEntries,
991
+ ...externalEntries
992
+ ]
993
+ };
994
+ }
995
+ let removed = await this.detectRemoved(allReadables, previouslyTracked);
996
+ if (safe) {
997
+ planEntries = planEntries.map((e) => new SyncEntry({
998
+ ...e,
999
+ columnsToDrop: [],
1000
+ typeChanges: [],
1001
+ recreated: false
1002
+ }));
1003
+ removed = [];
1004
+ }
1005
+ removed = removed.filter((e) => e.viewType !== "E");
1006
+ return {
1007
+ status: "changes-needed",
1008
+ schemaHash: hash,
1009
+ entries: [
1010
+ ...planEntries,
1011
+ ...viewEntries,
1012
+ ...externalEntries,
1013
+ ...removed
1014
+ ]
1015
+ };
1016
+ }
1017
+ /** Fallback typeMapper for snapshot-based Path B: compares designType directly, skips unions. */ resolveTypeMapper(adapter) {
1018
+ return adapter.typeMapper?.bind(adapter) ?? ((f) => f.designType === "union" ? "union" : f.designType);
1019
+ }
1020
+ async planTable(readable, trackedNames) {
1021
+ const adapter = readable.dbAdapter;
1022
+ const name = readable.tableName;
1023
+ const init = {
1024
+ name,
1025
+ status: "in-sync",
1026
+ syncMethod: readable.syncMethod
1027
+ };
1028
+ const renamedFrom = readable.renamedFrom;
1029
+ const pendingRename = renamedFrom && trackedNames.has(renamedFrom);
1030
+ if (pendingRename) {
1031
+ init.renamedFrom = renamedFrom;
1032
+ init.status = "alter";
1033
+ }
1034
+ const fkSnapshotName = pendingRename ? renamedFrom : name;
1035
+ const storedSnapshot = await this.store.readTableSnapshot(fkSnapshotName);
1036
+ if (adapter.getExistingColumns) {
1037
+ const existing = pendingRename && adapter.getExistingColumnsForTable ? await adapter.getExistingColumnsForTable(renamedFrom) : await adapter.getExistingColumns();
1038
+ if (existing.length === 0 && !pendingRename) {
1039
+ init.status = "create";
1040
+ init.columnsToAdd = readable.fieldDescriptors.filter((f) => !f.ignored);
1041
+ } else if (existing.length > 0) {
1042
+ const typeMapper = adapter.typeMapper?.bind(adapter);
1043
+ const diff = computeColumnDiff(readable.fieldDescriptors, existing, typeMapper);
1044
+ this.populatePlanFromDiff(diff, init, name, readable.syncMethod, adapter.supportsColumnModify);
1045
+ }
1046
+ } else if (adapter.syncColumns) if (!storedSnapshot) {
1047
+ if (!pendingRename) {
1048
+ const exists = adapter.tableExists ? await adapter.tableExists() : false;
1049
+ if (!exists) {
1050
+ init.status = "create";
1051
+ init.columnsToAdd = readable.fieldDescriptors.filter((f) => !f.ignored);
1052
+ }
1053
+ }
1054
+ } else {
1055
+ const existing = snapshotToExistingColumns(storedSnapshot);
1056
+ const diff = computeColumnDiff(readable.fieldDescriptors, existing, this.resolveTypeMapper(adapter));
1057
+ this.populatePlanFromDiff(diff, init, name, readable.syncMethod, adapter.supportsColumnModify);
1058
+ }
1059
+ else if (adapter.tableExists) {
1060
+ const exists = await adapter.tableExists();
1061
+ if (!exists) init.status = "create";
1062
+ } else init.status = "create";
1063
+ if (init.status !== "create") {
1064
+ const optionDiff = await this.diffTableOptions(readable);
1065
+ if (optionDiff && optionDiff.changed.length > 0) {
1066
+ init.status = "alter";
1067
+ init.optionChanges = optionDiff.changed;
1068
+ if (optionDiff.changed.some((c) => c.destructive)) init.recreated = true;
1069
+ }
1070
+ }
1071
+ if (init.status !== "create" && storedSnapshot) {
1072
+ const fkDiff = computeForeignKeyDiff(readable.foreignKeys, storedSnapshot.foreignKeys);
1073
+ if (hasForeignKeyChanges(fkDiff)) {
1074
+ init.status = "alter";
1075
+ init.fkAdded = fkDiff.added.map((fk) => ({
1076
+ fields: fk.fields,
1077
+ targetTable: fk.targetTable
1078
+ }));
1079
+ init.fkRemoved = fkDiff.removed.map((fk) => ({
1080
+ fields: fk.fields,
1081
+ targetTable: fk.targetTable
1082
+ }));
1083
+ init.fkChanged = fkDiff.changed.map((fk) => ({
1084
+ fields: fk.desired.fields,
1085
+ targetTable: fk.desired.targetTable,
1086
+ details: buildFkChangeDetails(fk.desired, fk.existing)
1087
+ }));
1088
+ }
1089
+ }
1090
+ return new SyncEntry(init);
1091
+ }
1092
+ /**
1093
+ * Populates plan init from a column diff (shared by Path A and Path B).
1094
+ */ populatePlanFromDiff(diff, init, name, syncMethod, adapterSupportsModify) {
1095
+ init.columnsToAdd = diff.added;
1096
+ init.columnsToRename = diff.renamed.map((r) => ({
1097
+ from: r.oldName,
1098
+ to: r.field.physicalName
1099
+ }));
1100
+ init.typeChanges = diff.typeChanged.map((tc) => ({
1101
+ column: tc.field.physicalName,
1102
+ fromType: tc.existingType,
1103
+ toType: tc.field.designType
1104
+ }));
1105
+ init.nullableChanges = diff.nullableChanged.map((nc) => ({
1106
+ column: nc.field.physicalName,
1107
+ toNullable: nc.field.optional
1108
+ }));
1109
+ init.defaultChanges = diff.defaultChanged.map((dc) => ({
1110
+ column: dc.field.physicalName,
1111
+ oldDefault: dc.oldDefault,
1112
+ newDefault: dc.newDefault
1113
+ }));
1114
+ init.columnsToDrop = diff.removed.map((c) => c.name);
1115
+ const hasChanges = diff.added.length > 0 || diff.renamed.length > 0 || diff.typeChanged.length > 0 || diff.nullableChanged.length > 0 || diff.defaultChanged.length > 0 || diff.removed.length > 0;
1116
+ if (hasChanges) init.status = "alter";
1117
+ if (diff.conflicts.length > 0) {
1118
+ init.status = "error";
1119
+ 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.`)];
1120
+ }
1121
+ if (diff.typeChanged.length > 0 && !syncMethod && !adapterSupportsModify) {
1122
+ init.status = "error";
1123
+ init.errors = [...init.errors ?? [], ...diff.typeChanged.map((tc) => `Type change on ${name}.${tc.field.physicalName} ` + `(${tc.existingType} → ${tc.field.designType}). ` + `Add @db.sync.method "recreate" or "drop", or migrate manually.`)];
1124
+ }
1125
+ }
1126
+ /**
1127
+ * Computes table option diff using DB-first introspection with snapshot fallback.
1128
+ * Returns null if the adapter has no table options.
1129
+ */ async diffTableOptions(readable) {
1130
+ const adapter = readable.dbAdapter;
1131
+ const desired = adapter.getDesiredTableOptions?.();
1132
+ if (!desired || desired.length === 0) return null;
1133
+ let existing;
1134
+ if (adapter.getExistingTableOptions) existing = await adapter.getExistingTableOptions();
1135
+ else {
1136
+ const snapshot = await this.store.readTableSnapshot(readable.tableName);
1137
+ existing = snapshot ? snapshotToExistingTableOptions(snapshot) : [];
1138
+ }
1139
+ if (existing.length === 0) return null;
1140
+ const destructiveKeys = adapter.destructiveOptionKeys?.();
1141
+ return computeTableOptionDiff(desired, existing, destructiveKeys);
1142
+ }
1143
+ async planView(view, trackedNames) {
1144
+ const viewType = view.viewPlan.materialized ? "M" : "V";
1145
+ const renamedFrom = view.renamedFrom;
1146
+ const isRenamed = renamedFrom && trackedNames.has(renamedFrom);
1147
+ let status;
1148
+ if (isRenamed) status = "alter";
1149
+ else if (trackedNames.has(view.tableName)) status = await viewDefinitionChanged(view, this.store) ? "alter" : "in-sync";
1150
+ else status = "create";
1151
+ return new SyncEntry({
1152
+ name: view.tableName,
1153
+ status,
1154
+ viewType,
1155
+ renamedFrom: isRenamed ? renamedFrom : undefined,
1156
+ recreated: status === "alter" && !isRenamed ? true : undefined
1157
+ });
1158
+ }
1159
+ buildExecutorDeps() {
1160
+ return {
1161
+ store: this.store,
1162
+ space: this.space,
1163
+ logger: this.logger,
1164
+ resolveTypeMapper: this.resolveTypeMapper.bind(this),
1165
+ diffTableOptions: this.diffTableOptions.bind(this)
1166
+ };
1167
+ }
1168
+ constructor(space, logger) {
1169
+ _define_property(this, "space", void 0);
1170
+ _define_property(this, "store", void 0);
1171
+ _define_property(this, "logger", void 0);
1172
+ this.space = space;
1173
+ this.logger = logger || NoopLogger;
1174
+ this.store = new SyncStore(space);
1175
+ }
1176
+ };
1177
+
1178
+ //#endregion
1179
+ //#region packages/db/src/sync.ts
1180
+ async function syncSchema(space, types, opts) {
1181
+ const sync = new SchemaSync(space);
1182
+ return sync.run(types, opts);
1183
+ }
1184
+
1185
+ //#endregion
1186
+ export { SchemaSync, SyncEntry, computeColumnDiff, computeForeignKeyDiff, computeSchemaHash, computeTableHash, computeTableOptionDiff, computeTableSnapshot, computeViewSnapshot, fkKey, hasForeignKeyChanges, readStoredSnapshot, snapshotToExistingColumns, snapshotToExistingTableOptions, syncSchema };