@atscript/db 0.1.154 → 0.1.156

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 (35) hide show
  1. package/dist/agg.d.cts +1 -1
  2. package/dist/agg.d.mts +1 -1
  3. package/dist/{buckets-CBKL_ZWE.d.mts → buckets-8mG67Mtw.d.mts} +168 -16
  4. package/dist/{buckets-jy8BfswO.d.cts → buckets-B4T_alUe.d.cts} +168 -16
  5. package/dist/{column-diff-DAIAq6XR.mjs → column-diff-DR1glykD.mjs} +419 -75
  6. package/dist/{column-diff-qnzx1o3c.cjs → column-diff-DpeUbhEI.cjs} +430 -74
  7. package/dist/{fk-diff-BIJ8l_hF.d.mts → fk-diff-5gX42hBt.d.mts} +2 -1
  8. package/dist/{fk-diff-D1mrLMjU.d.cts → fk-diff-BWdQ7kRZ.d.cts} +2 -1
  9. package/dist/index.cjs +3 -3
  10. package/dist/index.d.cts +15 -9
  11. package/dist/index.d.mts +15 -9
  12. package/dist/index.mjs +3 -3
  13. package/dist/plugin.cjs +21 -4
  14. package/dist/plugin.mjs +21 -4
  15. package/dist/rel.d.cts +2 -2
  16. package/dist/rel.d.mts +2 -2
  17. package/dist/{relation-helpers-BUsxNNGU.d.mts → relation-helpers-17_2f5hX.d.mts} +1 -1
  18. package/dist/{relation-helpers-D_8bydc2.d.cts → relation-helpers-BLBrLQYi.d.cts} +1 -1
  19. package/dist/{search-fields-DBIZKwyb.mjs → search-fields-CE8cmj9X.mjs} +1 -0
  20. package/dist/{search-fields-DwrsJUC1.cjs → search-fields-CT_UZ9fg.cjs} +1 -0
  21. package/dist/shared.cjs +2 -1
  22. package/dist/shared.d.cts +10 -3
  23. package/dist/shared.d.mts +10 -3
  24. package/dist/shared.mjs +2 -2
  25. package/dist/sync.cjs +317 -35
  26. package/dist/sync.d.cts +34 -5
  27. package/dist/sync.d.mts +34 -5
  28. package/dist/sync.mjs +317 -35
  29. package/dist/{validation-utils-DqZAH6aT.mjs → validation-utils-BGjqc2jU.mjs} +24 -6
  30. package/dist/{validation-utils-debIYpq0.cjs → validation-utils-CtLNlBtB.cjs} +29 -5
  31. package/dist/{validator-BACewoRj.mjs → validator-B3fPJjS1.mjs} +178 -10
  32. package/dist/{validator-BxWwh3wf.cjs → validator-B4YDLUkj.cjs} +201 -9
  33. package/dist/validator.cjs +1 -1
  34. package/dist/validator.mjs +1 -1
  35. package/package.json +6 -6
package/dist/sync.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { $t as DbSpace, At as TReferencingForeignKey, Ft as TTableOptionDiff, Gn as TGenericLogger, U as TDbFieldMeta, et as TDbObjectKind, gt as TEnsureTableOptions, kt as TPrimaryKeyChange, mt as TDerivedChangeReason, vt as TExistingForeignKey, yt as TExistingTableOption } from "./buckets-CBKL_ZWE.mjs";
2
- import { _ as computeTableSnapshot, a as hasForeignKeyChanges, b as snapshotToExistingTableOptions, c as TFieldSnapshot, d as TViewColumnSnapshot, f as TViewJoinSnapshot, g as computeTableHash, h as computeSchemaHash, i as fkKey, l as TForeignKeySnapshot, m as canonicalizeQueryNode, n as computeForeignKeyDiff, o as computeColumnDiff, p as TViewSnapshot, t as TForeignKeyDiff, u as TTableSnapshot, v as computeViewSnapshot, y as snapshotToExistingColumns } from "./fk-diff-BIJ8l_hF.mjs";
1
+ import { At as TPrimaryKeyChange, Jn as TGenericLogger, Lt as TTableOptionDiff, U as TDbFieldMeta, et as TDbObjectKind, gt as TEnsureTableOptions, jt as TReferencingForeignKey, mt as TDerivedChangeReason, nn as DbSpace, vt as TExistingForeignKey, yt as TExistingTableOption } from "./buckets-8mG67Mtw.mjs";
2
+ import { _ as computeTableSnapshot, a as hasForeignKeyChanges, b as snapshotToExistingTableOptions, c as TFieldSnapshot, d as TViewColumnSnapshot, f as TViewJoinSnapshot, g as computeTableHash, h as computeSchemaHash, i as fkKey, l as TForeignKeySnapshot, m as canonicalizeQueryNode, n as computeForeignKeyDiff, o as computeColumnDiff, p as TViewSnapshot, t as TForeignKeyDiff, u as TTableSnapshot, v as computeViewSnapshot, y as snapshotToExistingColumns } from "./fk-diff-5gX42hBt.mjs";
3
3
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
4
4
 
5
5
  //#region src/schema/sync-entry.d.ts
@@ -77,6 +77,22 @@ interface TSyncEntryInit {
77
77
  * @since 0.1.141
78
78
  */
79
79
  derivedChanges?: TSyncDerivedChange[];
80
+ /**
81
+ * Text columns of the atscript-db ≤ 0.1.154 union layout whose JSON values
82
+ * the run copies into the columns that replace them, before the old
83
+ * column is dropped (see `planJsonColumnMigration`).
84
+ * @since 0.1.155
85
+ */
86
+ jsonCopies?: Array<{
87
+ from: string;
88
+ to: string[];
89
+ }>;
90
+ /**
91
+ * Text columns of that layout becoming JSON columns: their plain-text
92
+ * values are rewritten as JSON strings first.
93
+ * @since 0.1.155
94
+ */
95
+ jsonified?: string[];
80
96
  columnsAdded?: string[];
81
97
  columnsRenamed?: string[];
82
98
  columnsDropped?: string[];
@@ -85,14 +101,17 @@ interface TSyncEntryInit {
85
101
  renamedFrom?: string;
86
102
  /**
87
103
  * The table's primary-key field set changes. `rebuild: true` when sync
88
- * rebuilds the key (empty table); `false` when the rebuild is skipped
89
- * (safe mode). A populated table is refused instead (see `refused`).
104
+ * rebuilds the key; `false` when the rebuild is skipped (safe mode).
105
+ * `populated: true` (since 0.1.155) when the table has rows — they satisfy
106
+ * the new key; a populated table whose rows do not is refused instead (see
107
+ * `refused`).
90
108
  * @since 0.1.128
91
109
  */
92
110
  pkChange?: {
93
111
  from: string[];
94
112
  to: string[];
95
113
  rebuild: boolean;
114
+ populated?: boolean;
96
115
  };
97
116
  /**
98
117
  * The work safe mode skipped on this table: `"pk-rebuild"` (`pkChange`
@@ -174,6 +193,13 @@ declare class SyncEntry {
174
193
  }>;
175
194
  /** @since 0.1.141 — see {@link TSyncEntryInit.derivedChanges}. */
176
195
  readonly derivedChanges: TSyncDerivedChange[];
196
+ /** @since 0.1.155 — see {@link TSyncEntryInit.jsonCopies}. */
197
+ readonly jsonCopies: Array<{
198
+ from: string;
199
+ to: string[];
200
+ }>;
201
+ /** @since 0.1.155 — see {@link TSyncEntryInit.jsonified}. */
202
+ readonly jsonified: string[];
177
203
  readonly columnsAdded: string[];
178
204
  readonly columnsRenamed: string[];
179
205
  readonly columnsDropped: string[];
@@ -185,6 +211,7 @@ declare class SyncEntry {
185
211
  from: string[];
186
212
  to: string[];
187
213
  rebuild: boolean;
214
+ populated?: boolean;
188
215
  };
189
216
  /** @since 0.1.128 — see {@link TSyncEntryInit.skipped}. */
190
217
  readonly skipped: ReadonlyArray<TSyncSkippedWork>;
@@ -229,7 +256,7 @@ declare class SyncEntry {
229
256
  print(mode: "plan" | "result", colors?: TSyncColors): string[];
230
257
  private labelAndPrefix;
231
258
  private printError;
232
- /** `! PK (id) → (code) — rebuild (table is empty)` / `— skipped (safe mode)` */
259
+ /** `! PK (id) → (code) — rebuild (table is empty)` / `— rebuild (rows satisfy the new key)` / `— skipped (safe mode)` */
233
260
  private printPkChange;
234
261
  /**
235
262
  * `! col: t1 → t2 — drop` (plan), or `! type col (t1 → t2) — skipped (safe
@@ -241,6 +268,8 @@ declare class SyncEntry {
241
268
  * `— rebuilt` (result), or `! … — skipped (safe mode)` when pending.
242
269
  */
243
270
  private printDerivedChanges;
271
+ /** `~ addr → addr__street, addr__zip — copy JSON values` / `~ extra — text values as JSON`. */
272
+ private printJsonMigration;
244
273
  /** `~ col — nullable` / `~ col — default a → b`, `— skipped (safe mode)` when pending. */
245
274
  private printNullableDefaults;
246
275
  /**
package/dist/sync.mjs CHANGED
@@ -1,5 +1,173 @@
1
1
  import { r as DbError } from "./db-error-eSsQX7rt.mjs";
2
- import { _t as NoopLogger, a as computeTableHash, c as snapshotToExistingColumns, d as computeForeignKeyDiff, dt as aliasTargetOf, f as fkColumns, i as computeSchemaHash, l as snapshotToExistingTableOptions, m as hasForeignKeyChanges, o as computeTableSnapshot, p as fkKey, r as canonicalizeQueryNode, s as computeViewSnapshot, t as computeColumnDiff, u as viewSnapshotSources } from "./column-diff-DAIAq6XR.mjs";
2
+ import { a as computeTableHash, c as serializeDefaultValue, d as viewSnapshotSources, f as computeForeignKeyDiff, ft as relationalColumnName, h as hasForeignKeyChanges, i as computeSchemaHash, j as resolveDesignType, l as snapshotToExistingColumns, m as fkKey, o as computeTableSnapshot, p as fkColumns, pt as aliasTargetOf, r as canonicalizeQueryNode, s as computeViewSnapshot, t as computeColumnDiff, u as snapshotToExistingTableOptions, yt as NoopLogger } from "./column-diff-DR1glykD.mjs";
3
+ import { f as unionValueMembers } from "./validator-B3fPJjS1.mjs";
4
+ //#region src/schema/json-column-copy.ts
5
+ const NONE = {
6
+ copies: [],
7
+ jsonify: [],
8
+ errors: []
9
+ };
10
+ /** A text column type of the old layout (`TEXT`, `LONGTEXT`, `VARCHAR(n)`, `character varying`). */
11
+ const TEXT_TYPE = /text|char|clob/i;
12
+ /**
13
+ * Design types a stored snapshot gives a field whose column held a whole
14
+ * value as JSON text: the old union layout (`union`), or `@db.json`.
15
+ */
16
+ const JSON_TEXT_DESIGN_TYPES = new Set([
17
+ "union",
18
+ "json",
19
+ "object"
20
+ ]);
21
+ /** How a target column reads its value out of the JSON, or why it cannot. */
22
+ function targetKind(fd) {
23
+ if (fd.encrypted) return { problem: "it is @db.encrypted" };
24
+ if (fd.isGeoPoint) return { problem: "it is a db.geoPoint" };
25
+ if (fd.storage === "json") return "json";
26
+ return fd.designType === "boolean" ? "boolean" : "text";
27
+ }
28
+ /** Whether `type` is a union one of whose value members is a string. */
29
+ function hasStringMember(type) {
30
+ if (type?.type.kind !== "union") return false;
31
+ return unionValueMembers(type).members.some((m) => resolveDesignType(m) === "string");
32
+ }
33
+ /**
34
+ * Plans {@link TJsonColumnMigration} for one table from its live column diff
35
+ * (and its stored snapshot — on SQLite the old text column and a JSON
36
+ * column have the same type).
37
+ */
38
+ function planJsonColumnMigration(readable, diff, snapshot) {
39
+ const meta = readable.getMetadata();
40
+ if (meta.nestedObjects) return NONE;
41
+ const adapter = readable.dbAdapter;
42
+ const name = readable.tableName;
43
+ const removed = new Set(diff.removed.map((c) => c.name));
44
+ const snapshotFields = new Map(snapshot?.fields.map((f) => [f.physicalName, f]));
45
+ const out = {
46
+ copies: [],
47
+ jsonify: [],
48
+ errors: []
49
+ };
50
+ if (removed.size > 0) for (const parent of meta.flattenedParents) {
51
+ const source = relationalColumnName(parent, void 0, parent.includes("."));
52
+ if (!removed.has(source)) continue;
53
+ const was = snapshotFields.get(source);
54
+ if (was && !JSON_TEXT_DESIGN_TYPES.has(was.designType)) continue;
55
+ const prefix = `${parent}.`;
56
+ const targets = [];
57
+ const problems = [];
58
+ for (const fd of meta.storedDescriptors) {
59
+ if (!fd.path.startsWith(prefix)) continue;
60
+ const kind = targetKind(fd);
61
+ if (typeof kind === "object") {
62
+ problems.push(`"${fd.physicalName}" (${kind.problem})`);
63
+ continue;
64
+ }
65
+ targets.push({
66
+ column: fd.physicalName,
67
+ path: fd.path.slice(prefix.length).split("."),
68
+ kind,
69
+ field: fd
70
+ });
71
+ }
72
+ if (targets.length === 0 && problems.length === 0) continue;
73
+ if (problems.length > 0 || !adapter.copyFromJsonColumn) {
74
+ const why = problems.length > 0 ? `cannot fill ${problems.join(", ")} from it` : "the adapter cannot copy JSON values into columns";
75
+ out.errors.push(`Column "${source}" of ${name} holds "${parent}" as JSON text (the layout of atscript-db ≤ 0.1.154) and ${why}. Annotate "${parent}" with @db.json to keep it in one column, or migrate the data and drop "${source}" manually.`);
76
+ continue;
77
+ }
78
+ out.copies.push({
79
+ source,
80
+ targets
81
+ });
82
+ }
83
+ const typeChanged = new Map(diff.typeChanged.map((tc) => [tc.field.physicalName, tc]));
84
+ for (const fd of meta.storedDescriptors) {
85
+ if (fd.storage !== "json" || !hasStringMember(meta.flatMap.get(fd.path))) continue;
86
+ const tc = typeChanged.get(fd.physicalName);
87
+ const old = snapshotFields.get(fd.physicalName);
88
+ const dotted = `${fd.physicalName}.`;
89
+ if (!(tc !== void 0 && TEXT_TYPE.test(tc.existingType) || old !== void 0 && old.storage !== "json" && old.designType !== "json" || diff.removed.some((c) => c.name.startsWith(dotted)))) continue;
90
+ if (!adapter.jsonifyTextColumn) {
91
+ out.errors.push(`Column "${fd.physicalName}" of ${name} becomes a JSON column but the adapter cannot convert its text values. Migrate them manually.`);
92
+ continue;
93
+ }
94
+ out.jsonify.push(fd.physicalName);
95
+ }
96
+ return out.copies.length > 0 || out.jsonify.length > 0 || out.errors.length > 0 ? out : NONE;
97
+ }
98
+ /** The copy targets {@link applyJsonColumnMigration} adds: those not in the table yet. */
99
+ function copyTargetsToAdd(migration, diff) {
100
+ const targets = new Set(migration.copies.flatMap((c) => c.targets.map((t) => t.column)));
101
+ return diff.added.filter((fd) => targets.has(fd.physicalName));
102
+ }
103
+ /**
104
+ * The column diff the rest of the table's sync applies after
105
+ * {@link applyJsonColumnMigration}: without the copy targets it added, with
106
+ * their NOT NULL and model default as nullability / default changes. Also
107
+ * what `plan({ safe: true })` reports as skipped — safe mode adds and fills
108
+ * the targets but leaves them nullable and without their default.
109
+ */
110
+ function diffAfterJsonCopy(migration, diff) {
111
+ const toAdd = copyTargetsToAdd(migration, diff);
112
+ if (toAdd.length === 0) return diff;
113
+ return {
114
+ ...diff,
115
+ added: diff.added.filter((fd) => !toAdd.includes(fd)),
116
+ nullableChanged: [...diff.nullableChanged, ...toAdd.filter((fd) => !fd.optional).map((fd) => ({
117
+ field: fd,
118
+ wasNullable: true
119
+ }))],
120
+ defaultChanged: [...diff.defaultChanged, ...toAdd.filter((fd) => fd.defaultValue !== void 0).map((fd) => ({
121
+ field: fd,
122
+ newDefault: serializeDefaultValue(fd.defaultValue)
123
+ }))]
124
+ };
125
+ }
126
+ /**
127
+ * Runs a planned {@link TJsonColumnMigration} — first, before any other
128
+ * column work of the table: text columns become JSON text, the copy targets
129
+ * that do not exist yet are added, and the values are copied. Returns the
130
+ * diff without the columns it added ({@link diffAfterJsonCopy}). Idempotent:
131
+ * a re-run converts nothing twice and copies only into rows whose targets
132
+ * are all NULL.
133
+ *
134
+ * The targets are added nullable and WITHOUT a default — `ADD COLUMN …
135
+ * DEFAULT`, or the type default an adapter gives a required column (a
136
+ * leaf every member of a non-null union of objects declares), fills every
137
+ * existing row, so the copy would skip them all. A model default and NOT
138
+ * NULL are returned as default / nullability changes of the diff, which the
139
+ * rest of the table's sync applies after the copy (`SET DEFAULT` / `SET NOT
140
+ * NULL` / `MODIFY COLUMN`, a table recreation on SQLite). Rows the copy
141
+ * leaves NULL keep NULL in a nullable column; a required one backfills them.
142
+ */
143
+ async function applyJsonColumnMigration(adapter, migration, diff) {
144
+ for (const column of migration.jsonify) await adapter.jsonifyTextColumn(column);
145
+ if (migration.copies.length === 0) return {
146
+ diff,
147
+ added: []
148
+ };
149
+ const toAdd = copyTargetsToAdd(migration, diff);
150
+ let added = [];
151
+ if (toAdd.length > 0) added = (await adapter.syncColumns({
152
+ added: toAdd.map((fd) => ({
153
+ ...fd,
154
+ optional: true,
155
+ defaultValue: void 0
156
+ })),
157
+ removed: [],
158
+ renamed: [],
159
+ typeChanged: [],
160
+ nullableChanged: [],
161
+ defaultChanged: [],
162
+ conflicts: []
163
+ })).added;
164
+ for (const copy of migration.copies) await adapter.copyFromJsonColumn(copy.source, copy.targets);
165
+ return {
166
+ diff: diffAfterJsonCopy(migration, diff),
167
+ added
168
+ };
169
+ }
170
+ //#endregion
3
171
  //#region src/schema/table-option-diff.ts
4
172
  /**
5
173
  * Computes the difference between desired and existing table options.
@@ -369,6 +537,10 @@ var SyncEntry = class SyncEntry {
369
537
  fkChanged;
370
538
  /** @since 0.1.141 — see {@link TSyncEntryInit.derivedChanges}. */
371
539
  derivedChanges;
540
+ /** @since 0.1.155 — see {@link TSyncEntryInit.jsonCopies}. */
541
+ jsonCopies;
542
+ /** @since 0.1.155 — see {@link TSyncEntryInit.jsonified}. */
543
+ jsonified;
372
544
  columnsAdded;
373
545
  columnsRenamed;
374
546
  columnsDropped;
@@ -403,6 +575,8 @@ var SyncEntry = class SyncEntry {
403
575
  this.fkRemoved = init.fkRemoved ?? [];
404
576
  this.fkChanged = init.fkChanged ?? [];
405
577
  this.derivedChanges = init.derivedChanges ?? [];
578
+ this.jsonCopies = init.jsonCopies ?? [];
579
+ this.jsonified = init.jsonified ?? [];
406
580
  this.columnsAdded = init.columnsAdded ?? [];
407
581
  this.columnsRenamed = init.columnsRenamed ?? [];
408
582
  this.columnsDropped = init.columnsDropped ?? [];
@@ -476,13 +650,13 @@ var SyncEntry = class SyncEntry {
476
650
  const head = this.refused ? `✖ refused: ${vp}${label}` : `✗ ${vp}${label} — error`;
477
651
  return [` ${c.red(head)}`, ...this.errors.map((err) => ` ${c.red(err)}`)];
478
652
  }
479
- /** `! PK (id) → (code) — rebuild (table is empty)` / `— skipped (safe mode)` */
653
+ /** `! PK (id) → (code) — rebuild (table is empty)` / `— rebuild (rows satisfy the new key)` / `— skipped (safe mode)` */
480
654
  printPkChange(c, mode) {
481
655
  const pk = this.pkChange;
482
656
  if (!pk) return [];
483
657
  const cols = `PK (${pk.from.join(", ")}) → (${pk.to.join(", ")})`;
484
658
  if (!pk.rebuild) return [` ${c.yellow(`! ${cols} — skipped (safe mode)`)}`];
485
- return mode === "plan" ? [` ${c.red(`! ${cols} — rebuild (table is empty)`)}`] : [` ${c.yellow(`~ ${cols} — rebuilt`)}`];
659
+ return mode === "plan" ? [` ${c.red(`! ${cols} — rebuild (${pk.populated ? "rows satisfy the new key" : "table is empty"})`)}`] : [` ${c.yellow(`~ ${cols} — rebuilt`)}`];
486
660
  }
487
661
  /**
488
662
  * `! col: t1 → t2 — drop` (plan), or `! type col (t1 → t2) — skipped (safe
@@ -508,6 +682,11 @@ var SyncEntry = class SyncEntry {
508
682
  return ` ${dc.reason === "kind" ? c.red(line) : c.yellow(line)}`;
509
683
  });
510
684
  }
685
+ /** `~ addr → addr__street, addr__zip — copy JSON values` / `~ extra — text values as JSON`. */
686
+ printJsonMigration(c, mode) {
687
+ const copy = mode === "plan" ? "copy JSON values" : "JSON values copied";
688
+ return [...this.jsonified.map((col) => ` ${c.yellow(`~ ${col} — text values as JSON`)}`), ...this.jsonCopies.map((jc) => ` ${c.yellow(`~ ${jc.from} → ${jc.to.join(", ")} — ${copy}`)}`)];
689
+ }
511
690
  /** `~ col — nullable` / `~ col — default a → b`, `— skipped (safe mode)` when pending. */
512
691
  printNullableDefaults(c) {
513
692
  const suffix = this.skipped.includes("nullable-defaults") ? " — skipped (safe mode)" : "";
@@ -565,6 +744,7 @@ var SyncEntry = class SyncEntry {
565
744
  ` ${c.cyan(`~ ${vp}${label} — alter${renameInfo}`)}`,
566
745
  ...this.columnsToAdd.map((col) => ` ${c.green(`+ ${col.physicalName} (${col.designType})${col.derived ? " derived" : ""} — add`)}`),
567
746
  ...this.columnsToRename.map((r) => ` ${c.yellow(`~ ${r.from} → ${r.to} — rename`)}`),
747
+ ...this.printJsonMigration(c, "plan"),
568
748
  ...this.printTypeChanges(c),
569
749
  ...this.printNullableDefaults(c),
570
750
  ...this.printDerivedChanges(c, "plan"),
@@ -593,13 +773,14 @@ var SyncEntry = class SyncEntry {
593
773
  ...this.columnsAdded.map((col) => ` ${c.green(`+ ${col} — added`)}`),
594
774
  ""
595
775
  ];
596
- if (this.columnsAdded.length > 0 || this.columnsRenamed.length > 0 || this.columnsDropped.length > 0 || this.optionChanges.length > 0 || this.pkChange !== void 0 || this.derivedChanges.length > 0 || this.skipped.length > 0 || this.recreated || this.renamedFrom) {
776
+ 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.jsonCopies.length > 0 || this.jsonified.length > 0 || this.skipped.length > 0 || this.recreated || this.renamedFrom) {
597
777
  const rlabel = this.recreated ? "recreated" : "altered";
598
778
  const renameInfo = this.renamedFrom ? ` ${c.yellow(`(renamed from ${this.renamedFrom})`)}` : "";
599
779
  return [
600
780
  ` ${(this.recreated ? (s) => c.yellow(s) : (s) => c.cyan(s))(`~ ${vp}${label} — ${rlabel}${renameInfo}`)}`,
601
781
  ...this.columnsAdded.map((col) => ` ${c.green(`+ ${col} — added`)}`),
602
782
  ...this.columnsRenamed.map((col) => ` ${c.yellow(`~ ${col} — renamed`)}`),
783
+ ...this.printJsonMigration(c, "result"),
603
784
  ...this.printPkChange(c, "result"),
604
785
  ...this.printTypeChanges(c),
605
786
  ...this.printNullableDefaults(c),
@@ -641,14 +822,19 @@ function planTableInit(facts, safe, dependsOn) {
641
822
  };
642
823
  const skipped = safe ? safeModeSkips(facts) : [];
643
824
  const pk = facts.diff?.primaryKeyChanged;
644
- if (pk) init.pkChange = {
645
- from: pk.from,
646
- to: pk.to,
647
- rebuild: !skipped.includes("pk-rebuild")
648
- };
825
+ if (pk) {
826
+ init.pkChange = {
827
+ from: pk.from,
828
+ to: pk.to,
829
+ rebuild: !skipped.includes("pk-rebuild")
830
+ };
831
+ if (facts.populated === true) init.pkChange.populated = true;
832
+ }
649
833
  if (!safe) return init;
834
+ const diff = afterJsonCopy(facts);
650
835
  return {
651
836
  ...init,
837
+ ...diff && describeNullableDefaults(diff),
652
838
  columnsToDrop: [],
653
839
  typeChanges: skipped.includes("recreate") ? init.typeChanges : [],
654
840
  derivedChanges: skipped.includes("derived") ? init.derivedChanges : [],
@@ -666,6 +852,7 @@ function typeChangeStrategy(readable, diff) {
666
852
  const adapter = readable.dbAdapter;
667
853
  if (willDropRecreate(readable, diff, void 0)) return "drop";
668
854
  if (readable.syncMethod === "recreate" && adapter.recreateTable) return "recreate";
855
+ if (diff.primaryKeyChanged && !adapter.rebuildPrimaryKey && adapter.recreateTable) return "recreate";
669
856
  if (adapter.supportsColumnModify && adapter.syncColumns) return "modify";
670
857
  return "none";
671
858
  }
@@ -733,11 +920,23 @@ async function executeSyncTable(facts, safe, deps, exec) {
733
920
  await adapter.ensureTable(ensureOpts);
734
921
  init.status = "create";
735
922
  } else if (existing.length > 0) {
736
- const diff = facts.diff ?? computeColumnDiff(readable.columnDescriptors, existing, adapter.typeMapper?.bind(adapter), { snapshot: storedSnapshot });
737
- if (hasFkChanges && !adapter.syncForeignKeys && adapter.recreateTable) {
738
- await adapter.recreateTable();
739
- init.recreated = true;
923
+ let diff = facts.diff ?? computeColumnDiff(readable.columnDescriptors, existing, adapter.typeMapper?.bind(adapter), {
924
+ snapshot: storedSnapshot,
925
+ nativeDefaultFns: adapter.nativeDefaultFns()
926
+ });
927
+ const migration = facts.jsonMigration ?? planJsonColumnMigration(readable, diff, storedSnapshot);
928
+ if (migration.errors.length > 0) throw new Error(migration.errors.join(" "));
929
+ if (migration.copies.length > 0 || migration.jsonify.length > 0) {
930
+ phase = "JSON column copy";
931
+ const applied = await applyJsonColumnMigration(adapter, migration, diff);
932
+ diff = applied.diff;
933
+ Object.assign(init, describeJsonMigration(migration));
934
+ if (applied.added.length > 0) init.columnsAdded = applied.added;
740
935
  init.status = "alter";
936
+ phase = "Column sync";
937
+ }
938
+ if (hasFkChanges && !adapter.syncForeignKeys && adapter.recreateTable) {
939
+ await recreateKeepingRenames(adapter, diff, init);
741
940
  if (diff.primaryKeyChanged) init.pkChange = {
742
941
  ...diff.primaryKeyChanged,
743
942
  rebuild: true
@@ -746,8 +945,11 @@ async function executeSyncTable(facts, safe, deps, exec) {
746
945
  if (hasFkChanges && fkDiff && adapter.dropForeignKeys) {
747
946
  const keysToDrop = [...fkDiff.removed.map((fk) => fkKey(fk.fields)), ...fkDiff.changed.map((fk) => fkKey(fkColumns(fk.desired).fields))];
748
947
  if (keysToDrop.length > 0) {
749
- await adapter.dropForeignKeys(keysToDrop);
750
- droppedFks.push(...keysToDrop);
948
+ const fresh = keysToDrop.filter((key) => !droppedFks.includes(key));
949
+ if (fresh.length > 0) {
950
+ await adapter.dropForeignKeys(fresh);
951
+ droppedFks.push(...fresh);
952
+ }
751
953
  init.status = "alter";
752
954
  }
753
955
  }
@@ -758,7 +960,10 @@ async function executeSyncTable(facts, safe, deps, exec) {
758
960
  const existed = adapter.tableExists ? await adapter.tableExists() : false;
759
961
  await adapter.ensureTable(ensureOpts);
760
962
  if (!existed) init.status = "create";
761
- } else await applyColumnDiff(adapter, readable, facts.diff ?? computeColumnDiff(readable.columnDescriptors, snapshotToExistingColumns(storedSnapshot, readable), deps.resolveTypeMapper(adapter), { snapshot: storedSnapshot }), init, safe, deps.logger, ensureOpts);
963
+ } else await applyColumnDiff(adapter, readable, facts.diff ?? computeColumnDiff(readable.columnDescriptors, snapshotToExistingColumns(storedSnapshot, readable), deps.resolveTypeMapper(adapter), {
964
+ snapshot: storedSnapshot,
965
+ nativeDefaultFns: adapter.nativeDefaultFns()
966
+ }), init, safe, deps.logger, ensureOpts);
762
967
  else {
763
968
  const existed = adapter.tableExists ? await adapter.tableExists() : true;
764
969
  if (!init.recreated) {
@@ -797,9 +1002,10 @@ async function executeSyncTable(facts, safe, deps, exec) {
797
1002
  }
798
1003
  }
799
1004
  }
1005
+ if (init.pkChange && facts.populated === true) init.pkChange.populated = true;
800
1006
  if (init.status === "error") return new SyncEntry(init);
801
1007
  phase = "Index/FK sync";
802
- await adapter.syncIndexes();
1008
+ await adapter.syncIndexes(keptIndexes(init));
803
1009
  if (adapter.syncForeignKeys && !exec.deferForeignKeysTo) await adapter.syncForeignKeys();
804
1010
  if (adapter.afterSyncTable) await adapter.afterSyncTable();
805
1011
  } catch (error) {
@@ -900,13 +1106,40 @@ function willDropRecreate(readable, diff, optionDiff) {
900
1106
  if (readable.syncMethod === "drop" && (diff?.typeChanged.length ?? 0) > 0) return true;
901
1107
  return (optionDiff?.changed.some((c) => c.destructive) ?? false) && !(readable.syncMethod === "recreate" && !!adapter.recreateTable);
902
1108
  }
1109
+ /**
1110
+ * The column diff the executor applies after the table's JSON column copy —
1111
+ * its targets' NOT NULL and defaults deferred (see {@link diffAfterJsonCopy}).
1112
+ */
1113
+ function afterJsonCopy(facts) {
1114
+ return facts.diff && facts.jsonMigration ? diffAfterJsonCopy(facts.jsonMigration, facts.diff) : facts.diff;
1115
+ }
903
1116
  /** Records work safe mode skipped on the entry (see `SyncEntry.skipped`). */
904
1117
  function markSkipped(init, kind) {
905
1118
  init.skipped = [...init.skipped ?? [], kind];
906
1119
  }
907
- /** Whether nullable/default changes need DDL on this adapter (schema-less ones only update the snapshot). */
1120
+ /**
1121
+ * Whether nullable/default changes need DDL on this adapter: in-place modify,
1122
+ * or a recreate on a live-introspected one (SQLite). Snapshot-based adapters
1123
+ * (MongoDB) have no column constraint to change — they only update the
1124
+ * snapshot, even with a `recreateTable`.
1125
+ */
908
1126
  function needsDdlForNullableDefaults(adapter) {
909
- return !!adapter.supportsColumnModify && !!adapter.syncColumns || !!adapter.recreateTable;
1127
+ return !!adapter.supportsColumnModify && !!adapter.syncColumns || !!adapter.recreateTable && !!adapter.getExistingColumns;
1128
+ }
1129
+ /**
1130
+ * The indexes `syncIndexes` keeps as they are because safe mode skipped
1131
+ * the change they depend on — a skipped key rebuild, the columns of a
1132
+ * skipped type-change recreate (see `TSyncIndexesOptions`).
1133
+ */
1134
+ function keptIndexes(init) {
1135
+ const skipped = init.skipped ?? [];
1136
+ const keepPrimaryKey = skipped.includes("pk-rebuild");
1137
+ const keepColumns = skipped.includes("recreate") ? (init.typeChanges ?? []).map((tc) => tc.column) : [];
1138
+ if (!keepPrimaryKey && keepColumns.length === 0) return;
1139
+ return {
1140
+ keepPrimaryKey,
1141
+ keepColumns
1142
+ };
910
1143
  }
911
1144
  /**
912
1145
  * The work `run({ safe: true })` skips on this table, in the order the
@@ -917,7 +1150,8 @@ function needsDdlForNullableDefaults(adapter) {
917
1150
  * DDL either way.
918
1151
  */
919
1152
  function safeModeSkips(facts) {
920
- const { readable, diff, optionDiff, fkDiff } = facts;
1153
+ const { readable, optionDiff, fkDiff } = facts;
1154
+ const diff = afterJsonCopy(facts);
921
1155
  const adapter = readable.dbAdapter;
922
1156
  const out = [];
923
1157
  if (facts.planEntry.status === "error") return out;
@@ -931,6 +1165,16 @@ function safeModeSkips(facts) {
931
1165
  if (optionDiff?.changed.some((c) => c.destructive)) out.push("table-options");
932
1166
  return out;
933
1167
  }
1168
+ /** The plan-shaped summary of a {@link TJsonColumnMigration}. */
1169
+ function describeJsonMigration(migration) {
1170
+ return {
1171
+ jsonCopies: migration.copies.length > 0 ? migration.copies.map((c) => ({
1172
+ from: c.source,
1173
+ to: c.targets.map((t) => t.column)
1174
+ })) : void 0,
1175
+ jsonified: migration.jsonify.length > 0 ? migration.jsonify : void 0
1176
+ };
1177
+ }
934
1178
  /** The plan-shaped nullable / default change summaries of a diff. */
935
1179
  function describeNullableDefaults(diff) {
936
1180
  return {
@@ -962,6 +1206,28 @@ async function dropAndRecreate(adapter, init, name, ensureOpts) {
962
1206
  init.recreated = true;
963
1207
  init.status = "alter";
964
1208
  }
1209
+ /**
1210
+ * `recreateTable()` before the column diff is applied: it copies the rows by
1211
+ * column name, so the columns renamed in this run are renamed first — under
1212
+ * the old name their values would not be copied.
1213
+ */
1214
+ async function recreateKeepingRenames(adapter, diff, init) {
1215
+ if (diff.renamed.length > 0 && adapter.syncColumns) {
1216
+ const { renamed } = await adapter.syncColumns({
1217
+ added: [],
1218
+ removed: [],
1219
+ renamed: diff.renamed,
1220
+ typeChanged: [],
1221
+ nullableChanged: [],
1222
+ defaultChanged: [],
1223
+ conflicts: []
1224
+ });
1225
+ init.columnsRenamed = renamed;
1226
+ }
1227
+ await adapter.recreateTable();
1228
+ init.recreated = true;
1229
+ init.status = "alter";
1230
+ }
965
1231
  async function applyColumnDiff(adapter, readable, diff, init, safe, logger, ensureOpts) {
966
1232
  const name = readable.tableName;
967
1233
  if (diff.conflicts.length > 0) {
@@ -980,11 +1246,8 @@ async function applyColumnDiff(adapter, readable, diff, init, safe, logger, ensu
980
1246
  const cols = init.typeChanges.map((tc) => `${tc.column}: ${tc.fromType} → ${tc.toType}`).join(", ");
981
1247
  logger.warn?.(`[schema-sync] Type change on "${name}" (${cols}) — drop-and-recreate skipped (safe mode)`);
982
1248
  } else await dropAndRecreate(adapter, init, name, ensureOpts);
983
- else if (strategy === "recreate") {
984
- await adapter.recreateTable();
985
- init.recreated = true;
986
- init.status = "alter";
987
- } else if (strategy === "modify") {
1249
+ else if (strategy === "recreate") await recreateKeepingRenames(adapter, diff, init);
1250
+ else if (strategy === "modify") {
988
1251
  needsSyncColumns = true;
989
1252
  init.status = "alter";
990
1253
  } else {
@@ -998,11 +1261,8 @@ async function applyColumnDiff(adapter, readable, diff, init, safe, logger, ensu
998
1261
  if (!safe && !init.recreated && init.status !== "error") if (adapter.supportsColumnModify && adapter.syncColumns) {
999
1262
  needsSyncColumns = true;
1000
1263
  init.status = "alter";
1001
- } else if (adapter.recreateTable) {
1002
- await adapter.recreateTable();
1003
- init.recreated = true;
1004
- init.status = "alter";
1005
- } else init.status = "alter";
1264
+ } else if (needsDdlForNullableDefaults(adapter)) await recreateKeepingRenames(adapter, diff, init);
1265
+ else init.status = "alter";
1006
1266
  else if (init.status !== "error") {
1007
1267
  if (safe && !init.recreated && needsDdlForNullableDefaults(adapter)) {
1008
1268
  Object.assign(init, describeNullableDefaults(diff));
@@ -1014,7 +1274,7 @@ async function applyColumnDiff(adapter, readable, diff, init, safe, logger, ensu
1014
1274
  }
1015
1275
  if (!init.recreated && init.status !== "error" && (diff.added.length > 0 || diff.renamed.length > 0 || needsSyncColumns) && adapter.syncColumns) {
1016
1276
  const syncResult = await adapter.syncColumns(diff);
1017
- init.columnsAdded = syncResult.added;
1277
+ init.columnsAdded = [...init.columnsAdded ?? [], ...syncResult.added];
1018
1278
  init.columnsRenamed = syncResult.renamed;
1019
1279
  if (syncResult.added.length > 0 || (syncResult.renamed?.length ?? 0) > 0 || needsSyncColumns) init.status = "alter";
1020
1280
  }
@@ -1579,8 +1839,18 @@ var SchemaSync = class {
1579
1839
  init.columnsToAdd = [...readable.columnDescriptors];
1580
1840
  } else if (facts.existing && facts.existing.length > 0) {
1581
1841
  const typeMapper = adapter.typeMapper?.bind(adapter);
1582
- facts.diff = computeColumnDiff(readable.columnDescriptors, facts.existing, typeMapper, { snapshot: storedSnapshot });
1842
+ facts.diff = computeColumnDiff(readable.columnDescriptors, facts.existing, typeMapper, {
1843
+ snapshot: storedSnapshot,
1844
+ nativeDefaultFns: adapter.nativeDefaultFns()
1845
+ });
1583
1846
  this.populatePlanFromDiff(facts.diff, init, readable, safe);
1847
+ const migration = planJsonColumnMigration(readable, facts.diff, storedSnapshot);
1848
+ facts.jsonMigration = migration;
1849
+ Object.assign(init, describeJsonMigration(migration));
1850
+ if (migration.errors.length > 0) {
1851
+ init.status = "error";
1852
+ init.errors = [...init.errors ?? [], ...migration.errors];
1853
+ } else if (init.status === "in-sync" && (migration.copies.length > 0 || migration.jsonify.length > 0)) init.status = "alter";
1584
1854
  }
1585
1855
  } else if (adapter.syncColumns) if (!storedSnapshot) {
1586
1856
  if (!pendingRename) {
@@ -1591,7 +1861,10 @@ var SchemaSync = class {
1591
1861
  }
1592
1862
  } else {
1593
1863
  const existing = snapshotToExistingColumns(storedSnapshot, readable);
1594
- facts.diff = computeColumnDiff(readable.columnDescriptors, existing, this.resolveTypeMapper(adapter), { snapshot: storedSnapshot });
1864
+ facts.diff = computeColumnDiff(readable.columnDescriptors, existing, this.resolveTypeMapper(adapter), {
1865
+ snapshot: storedSnapshot,
1866
+ nativeDefaultFns: adapter.nativeDefaultFns()
1867
+ });
1595
1868
  this.populatePlanFromDiff(facts.diff, init, readable, safe);
1596
1869
  }
1597
1870
  else if (adapter.tableExists) {
@@ -1626,7 +1899,16 @@ var SchemaSync = class {
1626
1899
  }, fk.existing)
1627
1900
  }));
1628
1901
  }
1629
- if (facts.diff?.primaryKeyChanged) facts.populated = await adapter.hasRows(pendingRename ? dbName : void 0) ?? "unknown";
1902
+ if (facts.diff?.primaryKeyChanged) {
1903
+ const tableName = pendingRename ? dbName : void 0;
1904
+ facts.populated = await adapter.hasRows(tableName) ?? "unknown";
1905
+ const { added, renamed } = facts.diff;
1906
+ const { to } = facts.diff.primaryKeyChanged;
1907
+ if (facts.populated === true && adapter.countKeyViolations && !added.some((f) => to.includes(f.physicalName))) {
1908
+ const liveName = new Map(renamed.map((r) => [r.field.physicalName, r.oldName]));
1909
+ facts.keyViolations = await adapter.countKeyViolations(to.map((c) => liveName.get(c) ?? c), tableName);
1910
+ }
1911
+ }
1630
1912
  facts.dropsTable = !!facts.diff?.primaryKeyChanged || willDropRecreate(readable, facts.diff, facts.optionDiff);
1631
1913
  if (facts.diff?.primaryKeyChanged || probeRecreateInbound && facts.dropsTable) facts.inboundFks = await adapter.getReferencingForeignKeys?.(dbName);
1632
1914
  if (adapter.getObjectKind) facts.objectKind = await adapter.getObjectKind(dbName);
@@ -1643,7 +1925,7 @@ var SchemaSync = class {
1643
1925
  const pk = t.diff?.primaryKeyChanged;
1644
1926
  if (pk) {
1645
1927
  const label = pkLabel(pk);
1646
- if (t.populated === true) addRefusal(refusals, t.name, `Primary key of "${t.name}" changed ${label} but the table has rows; schema sync cannot rebuild a populated primary key. Migrate manually (or empty the table) and re-run.`);
1928
+ if (t.populated === true && t.keyViolations !== 0) addRefusal(refusals, t.name, t.keyViolations === void 0 ? `Primary key of "${t.name}" changed ${label} but the table has rows; schema sync cannot rebuild a populated primary key. Migrate manually (or empty the table) and re-run.` : `Primary key of "${t.name}" changed ${label} but ${t.keyViolations} row${t.keyViolations === 1 ? " has" : "s have"} a NULL or duplicate (${pk.to.join(", ")}) — fix or remove ${t.keyViolations === 1 ? "it" : "them"} (or migrate manually) and re-run.`);
1647
1929
  else if (t.populated === "unknown") addRefusal(refusals, t.name, `Primary key of "${t.name}" changed ${label} but the adapter cannot tell whether the table has rows under its old name "${t.dbName}" — implement hasRows(tableName) on the adapter, or rename the table in a separate run first.`);
1648
1930
  if (!safe) {
1649
1931
  const liveInbound = (t.inboundFks ?? []).filter((fk) => {