@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.cjs CHANGED
@@ -1,6 +1,174 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_db_error = require("./db-error-JcfJqqpk.cjs");
3
- const require_column_diff = require("./column-diff-qnzx1o3c.cjs");
3
+ const require_column_diff = require("./column-diff-DpeUbhEI.cjs");
4
+ const require_validator = require("./validator-B4YDLUkj.cjs");
5
+ //#region src/schema/json-column-copy.ts
6
+ const NONE = {
7
+ copies: [],
8
+ jsonify: [],
9
+ errors: []
10
+ };
11
+ /** A text column type of the old layout (`TEXT`, `LONGTEXT`, `VARCHAR(n)`, `character varying`). */
12
+ const TEXT_TYPE = /text|char|clob/i;
13
+ /**
14
+ * Design types a stored snapshot gives a field whose column held a whole
15
+ * value as JSON text: the old union layout (`union`), or `@db.json`.
16
+ */
17
+ const JSON_TEXT_DESIGN_TYPES = new Set([
18
+ "union",
19
+ "json",
20
+ "object"
21
+ ]);
22
+ /** How a target column reads its value out of the JSON, or why it cannot. */
23
+ function targetKind(fd) {
24
+ if (fd.encrypted) return { problem: "it is @db.encrypted" };
25
+ if (fd.isGeoPoint) return { problem: "it is a db.geoPoint" };
26
+ if (fd.storage === "json") return "json";
27
+ return fd.designType === "boolean" ? "boolean" : "text";
28
+ }
29
+ /** Whether `type` is a union one of whose value members is a string. */
30
+ function hasStringMember(type) {
31
+ if (type?.type.kind !== "union") return false;
32
+ return require_validator.unionValueMembers(type).members.some((m) => require_column_diff.resolveDesignType(m) === "string");
33
+ }
34
+ /**
35
+ * Plans {@link TJsonColumnMigration} for one table from its live column diff
36
+ * (and its stored snapshot — on SQLite the old text column and a JSON
37
+ * column have the same type).
38
+ */
39
+ function planJsonColumnMigration(readable, diff, snapshot) {
40
+ const meta = readable.getMetadata();
41
+ if (meta.nestedObjects) return NONE;
42
+ const adapter = readable.dbAdapter;
43
+ const name = readable.tableName;
44
+ const removed = new Set(diff.removed.map((c) => c.name));
45
+ const snapshotFields = new Map(snapshot?.fields.map((f) => [f.physicalName, f]));
46
+ const out = {
47
+ copies: [],
48
+ jsonify: [],
49
+ errors: []
50
+ };
51
+ if (removed.size > 0) for (const parent of meta.flattenedParents) {
52
+ const source = require_column_diff.relationalColumnName(parent, void 0, parent.includes("."));
53
+ if (!removed.has(source)) continue;
54
+ const was = snapshotFields.get(source);
55
+ if (was && !JSON_TEXT_DESIGN_TYPES.has(was.designType)) continue;
56
+ const prefix = `${parent}.`;
57
+ const targets = [];
58
+ const problems = [];
59
+ for (const fd of meta.storedDescriptors) {
60
+ if (!fd.path.startsWith(prefix)) continue;
61
+ const kind = targetKind(fd);
62
+ if (typeof kind === "object") {
63
+ problems.push(`"${fd.physicalName}" (${kind.problem})`);
64
+ continue;
65
+ }
66
+ targets.push({
67
+ column: fd.physicalName,
68
+ path: fd.path.slice(prefix.length).split("."),
69
+ kind,
70
+ field: fd
71
+ });
72
+ }
73
+ if (targets.length === 0 && problems.length === 0) continue;
74
+ if (problems.length > 0 || !adapter.copyFromJsonColumn) {
75
+ const why = problems.length > 0 ? `cannot fill ${problems.join(", ")} from it` : "the adapter cannot copy JSON values into columns";
76
+ 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.`);
77
+ continue;
78
+ }
79
+ out.copies.push({
80
+ source,
81
+ targets
82
+ });
83
+ }
84
+ const typeChanged = new Map(diff.typeChanged.map((tc) => [tc.field.physicalName, tc]));
85
+ for (const fd of meta.storedDescriptors) {
86
+ if (fd.storage !== "json" || !hasStringMember(meta.flatMap.get(fd.path))) continue;
87
+ const tc = typeChanged.get(fd.physicalName);
88
+ const old = snapshotFields.get(fd.physicalName);
89
+ const dotted = `${fd.physicalName}.`;
90
+ 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;
91
+ if (!adapter.jsonifyTextColumn) {
92
+ out.errors.push(`Column "${fd.physicalName}" of ${name} becomes a JSON column but the adapter cannot convert its text values. Migrate them manually.`);
93
+ continue;
94
+ }
95
+ out.jsonify.push(fd.physicalName);
96
+ }
97
+ return out.copies.length > 0 || out.jsonify.length > 0 || out.errors.length > 0 ? out : NONE;
98
+ }
99
+ /** The copy targets {@link applyJsonColumnMigration} adds: those not in the table yet. */
100
+ function copyTargetsToAdd(migration, diff) {
101
+ const targets = new Set(migration.copies.flatMap((c) => c.targets.map((t) => t.column)));
102
+ return diff.added.filter((fd) => targets.has(fd.physicalName));
103
+ }
104
+ /**
105
+ * The column diff the rest of the table's sync applies after
106
+ * {@link applyJsonColumnMigration}: without the copy targets it added, with
107
+ * their NOT NULL and model default as nullability / default changes. Also
108
+ * what `plan({ safe: true })` reports as skipped — safe mode adds and fills
109
+ * the targets but leaves them nullable and without their default.
110
+ */
111
+ function diffAfterJsonCopy(migration, diff) {
112
+ const toAdd = copyTargetsToAdd(migration, diff);
113
+ if (toAdd.length === 0) return diff;
114
+ return {
115
+ ...diff,
116
+ added: diff.added.filter((fd) => !toAdd.includes(fd)),
117
+ nullableChanged: [...diff.nullableChanged, ...toAdd.filter((fd) => !fd.optional).map((fd) => ({
118
+ field: fd,
119
+ wasNullable: true
120
+ }))],
121
+ defaultChanged: [...diff.defaultChanged, ...toAdd.filter((fd) => fd.defaultValue !== void 0).map((fd) => ({
122
+ field: fd,
123
+ newDefault: require_column_diff.serializeDefaultValue(fd.defaultValue)
124
+ }))]
125
+ };
126
+ }
127
+ /**
128
+ * Runs a planned {@link TJsonColumnMigration} — first, before any other
129
+ * column work of the table: text columns become JSON text, the copy targets
130
+ * that do not exist yet are added, and the values are copied. Returns the
131
+ * diff without the columns it added ({@link diffAfterJsonCopy}). Idempotent:
132
+ * a re-run converts nothing twice and copies only into rows whose targets
133
+ * are all NULL.
134
+ *
135
+ * The targets are added nullable and WITHOUT a default — `ADD COLUMN …
136
+ * DEFAULT`, or the type default an adapter gives a required column (a
137
+ * leaf every member of a non-null union of objects declares), fills every
138
+ * existing row, so the copy would skip them all. A model default and NOT
139
+ * NULL are returned as default / nullability changes of the diff, which the
140
+ * rest of the table's sync applies after the copy (`SET DEFAULT` / `SET NOT
141
+ * NULL` / `MODIFY COLUMN`, a table recreation on SQLite). Rows the copy
142
+ * leaves NULL keep NULL in a nullable column; a required one backfills them.
143
+ */
144
+ async function applyJsonColumnMigration(adapter, migration, diff) {
145
+ for (const column of migration.jsonify) await adapter.jsonifyTextColumn(column);
146
+ if (migration.copies.length === 0) return {
147
+ diff,
148
+ added: []
149
+ };
150
+ const toAdd = copyTargetsToAdd(migration, diff);
151
+ let added = [];
152
+ if (toAdd.length > 0) added = (await adapter.syncColumns({
153
+ added: toAdd.map((fd) => ({
154
+ ...fd,
155
+ optional: true,
156
+ defaultValue: void 0
157
+ })),
158
+ removed: [],
159
+ renamed: [],
160
+ typeChanged: [],
161
+ nullableChanged: [],
162
+ defaultChanged: [],
163
+ conflicts: []
164
+ })).added;
165
+ for (const copy of migration.copies) await adapter.copyFromJsonColumn(copy.source, copy.targets);
166
+ return {
167
+ diff: diffAfterJsonCopy(migration, diff),
168
+ added
169
+ };
170
+ }
171
+ //#endregion
4
172
  //#region src/schema/table-option-diff.ts
5
173
  /**
6
174
  * Computes the difference between desired and existing table options.
@@ -370,6 +538,10 @@ var SyncEntry = class SyncEntry {
370
538
  fkChanged;
371
539
  /** @since 0.1.141 — see {@link TSyncEntryInit.derivedChanges}. */
372
540
  derivedChanges;
541
+ /** @since 0.1.155 — see {@link TSyncEntryInit.jsonCopies}. */
542
+ jsonCopies;
543
+ /** @since 0.1.155 — see {@link TSyncEntryInit.jsonified}. */
544
+ jsonified;
373
545
  columnsAdded;
374
546
  columnsRenamed;
375
547
  columnsDropped;
@@ -404,6 +576,8 @@ var SyncEntry = class SyncEntry {
404
576
  this.fkRemoved = init.fkRemoved ?? [];
405
577
  this.fkChanged = init.fkChanged ?? [];
406
578
  this.derivedChanges = init.derivedChanges ?? [];
579
+ this.jsonCopies = init.jsonCopies ?? [];
580
+ this.jsonified = init.jsonified ?? [];
407
581
  this.columnsAdded = init.columnsAdded ?? [];
408
582
  this.columnsRenamed = init.columnsRenamed ?? [];
409
583
  this.columnsDropped = init.columnsDropped ?? [];
@@ -477,13 +651,13 @@ var SyncEntry = class SyncEntry {
477
651
  const head = this.refused ? `✖ refused: ${vp}${label}` : `✗ ${vp}${label} — error`;
478
652
  return [` ${c.red(head)}`, ...this.errors.map((err) => ` ${c.red(err)}`)];
479
653
  }
480
- /** `! PK (id) → (code) — rebuild (table is empty)` / `— skipped (safe mode)` */
654
+ /** `! PK (id) → (code) — rebuild (table is empty)` / `— rebuild (rows satisfy the new key)` / `— skipped (safe mode)` */
481
655
  printPkChange(c, mode) {
482
656
  const pk = this.pkChange;
483
657
  if (!pk) return [];
484
658
  const cols = `PK (${pk.from.join(", ")}) → (${pk.to.join(", ")})`;
485
659
  if (!pk.rebuild) return [` ${c.yellow(`! ${cols} — skipped (safe mode)`)}`];
486
- return mode === "plan" ? [` ${c.red(`! ${cols} — rebuild (table is empty)`)}`] : [` ${c.yellow(`~ ${cols} — rebuilt`)}`];
660
+ return mode === "plan" ? [` ${c.red(`! ${cols} — rebuild (${pk.populated ? "rows satisfy the new key" : "table is empty"})`)}`] : [` ${c.yellow(`~ ${cols} — rebuilt`)}`];
487
661
  }
488
662
  /**
489
663
  * `! col: t1 → t2 — drop` (plan), or `! type col (t1 → t2) — skipped (safe
@@ -509,6 +683,11 @@ var SyncEntry = class SyncEntry {
509
683
  return ` ${dc.reason === "kind" ? c.red(line) : c.yellow(line)}`;
510
684
  });
511
685
  }
686
+ /** `~ addr → addr__street, addr__zip — copy JSON values` / `~ extra — text values as JSON`. */
687
+ printJsonMigration(c, mode) {
688
+ const copy = mode === "plan" ? "copy JSON values" : "JSON values copied";
689
+ return [...this.jsonified.map((col) => ` ${c.yellow(`~ ${col} — text values as JSON`)}`), ...this.jsonCopies.map((jc) => ` ${c.yellow(`~ ${jc.from} → ${jc.to.join(", ")} — ${copy}`)}`)];
690
+ }
512
691
  /** `~ col — nullable` / `~ col — default a → b`, `— skipped (safe mode)` when pending. */
513
692
  printNullableDefaults(c) {
514
693
  const suffix = this.skipped.includes("nullable-defaults") ? " — skipped (safe mode)" : "";
@@ -566,6 +745,7 @@ var SyncEntry = class SyncEntry {
566
745
  ` ${c.cyan(`~ ${vp}${label} — alter${renameInfo}`)}`,
567
746
  ...this.columnsToAdd.map((col) => ` ${c.green(`+ ${col.physicalName} (${col.designType})${col.derived ? " derived" : ""} — add`)}`),
568
747
  ...this.columnsToRename.map((r) => ` ${c.yellow(`~ ${r.from} → ${r.to} — rename`)}`),
748
+ ...this.printJsonMigration(c, "plan"),
569
749
  ...this.printTypeChanges(c),
570
750
  ...this.printNullableDefaults(c),
571
751
  ...this.printDerivedChanges(c, "plan"),
@@ -594,13 +774,14 @@ var SyncEntry = class SyncEntry {
594
774
  ...this.columnsAdded.map((col) => ` ${c.green(`+ ${col} — added`)}`),
595
775
  ""
596
776
  ];
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) {
777
+ 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) {
598
778
  const rlabel = this.recreated ? "recreated" : "altered";
599
779
  const renameInfo = this.renamedFrom ? ` ${c.yellow(`(renamed from ${this.renamedFrom})`)}` : "";
600
780
  return [
601
781
  ` ${(this.recreated ? (s) => c.yellow(s) : (s) => c.cyan(s))(`~ ${vp}${label} — ${rlabel}${renameInfo}`)}`,
602
782
  ...this.columnsAdded.map((col) => ` ${c.green(`+ ${col} — added`)}`),
603
783
  ...this.columnsRenamed.map((col) => ` ${c.yellow(`~ ${col} — renamed`)}`),
784
+ ...this.printJsonMigration(c, "result"),
604
785
  ...this.printPkChange(c, "result"),
605
786
  ...this.printTypeChanges(c),
606
787
  ...this.printNullableDefaults(c),
@@ -642,14 +823,19 @@ function planTableInit(facts, safe, dependsOn) {
642
823
  };
643
824
  const skipped = safe ? safeModeSkips(facts) : [];
644
825
  const pk = facts.diff?.primaryKeyChanged;
645
- if (pk) init.pkChange = {
646
- from: pk.from,
647
- to: pk.to,
648
- rebuild: !skipped.includes("pk-rebuild")
649
- };
826
+ if (pk) {
827
+ init.pkChange = {
828
+ from: pk.from,
829
+ to: pk.to,
830
+ rebuild: !skipped.includes("pk-rebuild")
831
+ };
832
+ if (facts.populated === true) init.pkChange.populated = true;
833
+ }
650
834
  if (!safe) return init;
835
+ const diff = afterJsonCopy(facts);
651
836
  return {
652
837
  ...init,
838
+ ...diff && describeNullableDefaults(diff),
653
839
  columnsToDrop: [],
654
840
  typeChanges: skipped.includes("recreate") ? init.typeChanges : [],
655
841
  derivedChanges: skipped.includes("derived") ? init.derivedChanges : [],
@@ -667,6 +853,7 @@ function typeChangeStrategy(readable, diff) {
667
853
  const adapter = readable.dbAdapter;
668
854
  if (willDropRecreate(readable, diff, void 0)) return "drop";
669
855
  if (readable.syncMethod === "recreate" && adapter.recreateTable) return "recreate";
856
+ if (diff.primaryKeyChanged && !adapter.rebuildPrimaryKey && adapter.recreateTable) return "recreate";
670
857
  if (adapter.supportsColumnModify && adapter.syncColumns) return "modify";
671
858
  return "none";
672
859
  }
@@ -734,11 +921,23 @@ async function executeSyncTable(facts, safe, deps, exec) {
734
921
  await adapter.ensureTable(ensureOpts);
735
922
  init.status = "create";
736
923
  } else if (existing.length > 0) {
737
- const diff = facts.diff ?? require_column_diff.computeColumnDiff(readable.columnDescriptors, existing, adapter.typeMapper?.bind(adapter), { snapshot: storedSnapshot });
738
- if (hasFkChanges && !adapter.syncForeignKeys && adapter.recreateTable) {
739
- await adapter.recreateTable();
740
- init.recreated = true;
924
+ let diff = facts.diff ?? require_column_diff.computeColumnDiff(readable.columnDescriptors, existing, adapter.typeMapper?.bind(adapter), {
925
+ snapshot: storedSnapshot,
926
+ nativeDefaultFns: adapter.nativeDefaultFns()
927
+ });
928
+ const migration = facts.jsonMigration ?? planJsonColumnMigration(readable, diff, storedSnapshot);
929
+ if (migration.errors.length > 0) throw new Error(migration.errors.join(" "));
930
+ if (migration.copies.length > 0 || migration.jsonify.length > 0) {
931
+ phase = "JSON column copy";
932
+ const applied = await applyJsonColumnMigration(adapter, migration, diff);
933
+ diff = applied.diff;
934
+ Object.assign(init, describeJsonMigration(migration));
935
+ if (applied.added.length > 0) init.columnsAdded = applied.added;
741
936
  init.status = "alter";
937
+ phase = "Column sync";
938
+ }
939
+ if (hasFkChanges && !adapter.syncForeignKeys && adapter.recreateTable) {
940
+ await recreateKeepingRenames(adapter, diff, init);
742
941
  if (diff.primaryKeyChanged) init.pkChange = {
743
942
  ...diff.primaryKeyChanged,
744
943
  rebuild: true
@@ -747,8 +946,11 @@ async function executeSyncTable(facts, safe, deps, exec) {
747
946
  if (hasFkChanges && fkDiff && adapter.dropForeignKeys) {
748
947
  const keysToDrop = [...fkDiff.removed.map((fk) => require_column_diff.fkKey(fk.fields)), ...fkDiff.changed.map((fk) => require_column_diff.fkKey(require_column_diff.fkColumns(fk.desired).fields))];
749
948
  if (keysToDrop.length > 0) {
750
- await adapter.dropForeignKeys(keysToDrop);
751
- droppedFks.push(...keysToDrop);
949
+ const fresh = keysToDrop.filter((key) => !droppedFks.includes(key));
950
+ if (fresh.length > 0) {
951
+ await adapter.dropForeignKeys(fresh);
952
+ droppedFks.push(...fresh);
953
+ }
752
954
  init.status = "alter";
753
955
  }
754
956
  }
@@ -759,7 +961,10 @@ async function executeSyncTable(facts, safe, deps, exec) {
759
961
  const existed = adapter.tableExists ? await adapter.tableExists() : false;
760
962
  await adapter.ensureTable(ensureOpts);
761
963
  if (!existed) init.status = "create";
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);
964
+ } else await applyColumnDiff(adapter, readable, facts.diff ?? require_column_diff.computeColumnDiff(readable.columnDescriptors, require_column_diff.snapshotToExistingColumns(storedSnapshot, readable), deps.resolveTypeMapper(adapter), {
965
+ snapshot: storedSnapshot,
966
+ nativeDefaultFns: adapter.nativeDefaultFns()
967
+ }), init, safe, deps.logger, ensureOpts);
763
968
  else {
764
969
  const existed = adapter.tableExists ? await adapter.tableExists() : true;
765
970
  if (!init.recreated) {
@@ -798,9 +1003,10 @@ async function executeSyncTable(facts, safe, deps, exec) {
798
1003
  }
799
1004
  }
800
1005
  }
1006
+ if (init.pkChange && facts.populated === true) init.pkChange.populated = true;
801
1007
  if (init.status === "error") return new SyncEntry(init);
802
1008
  phase = "Index/FK sync";
803
- await adapter.syncIndexes();
1009
+ await adapter.syncIndexes(keptIndexes(init));
804
1010
  if (adapter.syncForeignKeys && !exec.deferForeignKeysTo) await adapter.syncForeignKeys();
805
1011
  if (adapter.afterSyncTable) await adapter.afterSyncTable();
806
1012
  } catch (error) {
@@ -901,13 +1107,40 @@ function willDropRecreate(readable, diff, optionDiff) {
901
1107
  if (readable.syncMethod === "drop" && (diff?.typeChanged.length ?? 0) > 0) return true;
902
1108
  return (optionDiff?.changed.some((c) => c.destructive) ?? false) && !(readable.syncMethod === "recreate" && !!adapter.recreateTable);
903
1109
  }
1110
+ /**
1111
+ * The column diff the executor applies after the table's JSON column copy —
1112
+ * its targets' NOT NULL and defaults deferred (see {@link diffAfterJsonCopy}).
1113
+ */
1114
+ function afterJsonCopy(facts) {
1115
+ return facts.diff && facts.jsonMigration ? diffAfterJsonCopy(facts.jsonMigration, facts.diff) : facts.diff;
1116
+ }
904
1117
  /** Records work safe mode skipped on the entry (see `SyncEntry.skipped`). */
905
1118
  function markSkipped(init, kind) {
906
1119
  init.skipped = [...init.skipped ?? [], kind];
907
1120
  }
908
- /** Whether nullable/default changes need DDL on this adapter (schema-less ones only update the snapshot). */
1121
+ /**
1122
+ * Whether nullable/default changes need DDL on this adapter: in-place modify,
1123
+ * or a recreate on a live-introspected one (SQLite). Snapshot-based adapters
1124
+ * (MongoDB) have no column constraint to change — they only update the
1125
+ * snapshot, even with a `recreateTable`.
1126
+ */
909
1127
  function needsDdlForNullableDefaults(adapter) {
910
- return !!adapter.supportsColumnModify && !!adapter.syncColumns || !!adapter.recreateTable;
1128
+ return !!adapter.supportsColumnModify && !!adapter.syncColumns || !!adapter.recreateTable && !!adapter.getExistingColumns;
1129
+ }
1130
+ /**
1131
+ * The indexes `syncIndexes` keeps as they are because safe mode skipped
1132
+ * the change they depend on — a skipped key rebuild, the columns of a
1133
+ * skipped type-change recreate (see `TSyncIndexesOptions`).
1134
+ */
1135
+ function keptIndexes(init) {
1136
+ const skipped = init.skipped ?? [];
1137
+ const keepPrimaryKey = skipped.includes("pk-rebuild");
1138
+ const keepColumns = skipped.includes("recreate") ? (init.typeChanges ?? []).map((tc) => tc.column) : [];
1139
+ if (!keepPrimaryKey && keepColumns.length === 0) return;
1140
+ return {
1141
+ keepPrimaryKey,
1142
+ keepColumns
1143
+ };
911
1144
  }
912
1145
  /**
913
1146
  * The work `run({ safe: true })` skips on this table, in the order the
@@ -918,7 +1151,8 @@ function needsDdlForNullableDefaults(adapter) {
918
1151
  * DDL either way.
919
1152
  */
920
1153
  function safeModeSkips(facts) {
921
- const { readable, diff, optionDiff, fkDiff } = facts;
1154
+ const { readable, optionDiff, fkDiff } = facts;
1155
+ const diff = afterJsonCopy(facts);
922
1156
  const adapter = readable.dbAdapter;
923
1157
  const out = [];
924
1158
  if (facts.planEntry.status === "error") return out;
@@ -932,6 +1166,16 @@ function safeModeSkips(facts) {
932
1166
  if (optionDiff?.changed.some((c) => c.destructive)) out.push("table-options");
933
1167
  return out;
934
1168
  }
1169
+ /** The plan-shaped summary of a {@link TJsonColumnMigration}. */
1170
+ function describeJsonMigration(migration) {
1171
+ return {
1172
+ jsonCopies: migration.copies.length > 0 ? migration.copies.map((c) => ({
1173
+ from: c.source,
1174
+ to: c.targets.map((t) => t.column)
1175
+ })) : void 0,
1176
+ jsonified: migration.jsonify.length > 0 ? migration.jsonify : void 0
1177
+ };
1178
+ }
935
1179
  /** The plan-shaped nullable / default change summaries of a diff. */
936
1180
  function describeNullableDefaults(diff) {
937
1181
  return {
@@ -963,6 +1207,28 @@ async function dropAndRecreate(adapter, init, name, ensureOpts) {
963
1207
  init.recreated = true;
964
1208
  init.status = "alter";
965
1209
  }
1210
+ /**
1211
+ * `recreateTable()` before the column diff is applied: it copies the rows by
1212
+ * column name, so the columns renamed in this run are renamed first — under
1213
+ * the old name their values would not be copied.
1214
+ */
1215
+ async function recreateKeepingRenames(adapter, diff, init) {
1216
+ if (diff.renamed.length > 0 && adapter.syncColumns) {
1217
+ const { renamed } = await adapter.syncColumns({
1218
+ added: [],
1219
+ removed: [],
1220
+ renamed: diff.renamed,
1221
+ typeChanged: [],
1222
+ nullableChanged: [],
1223
+ defaultChanged: [],
1224
+ conflicts: []
1225
+ });
1226
+ init.columnsRenamed = renamed;
1227
+ }
1228
+ await adapter.recreateTable();
1229
+ init.recreated = true;
1230
+ init.status = "alter";
1231
+ }
966
1232
  async function applyColumnDiff(adapter, readable, diff, init, safe, logger, ensureOpts) {
967
1233
  const name = readable.tableName;
968
1234
  if (diff.conflicts.length > 0) {
@@ -981,11 +1247,8 @@ async function applyColumnDiff(adapter, readable, diff, init, safe, logger, ensu
981
1247
  const cols = init.typeChanges.map((tc) => `${tc.column}: ${tc.fromType} → ${tc.toType}`).join(", ");
982
1248
  logger.warn?.(`[schema-sync] Type change on "${name}" (${cols}) — drop-and-recreate skipped (safe mode)`);
983
1249
  } else await dropAndRecreate(adapter, init, name, ensureOpts);
984
- else if (strategy === "recreate") {
985
- await adapter.recreateTable();
986
- init.recreated = true;
987
- init.status = "alter";
988
- } else if (strategy === "modify") {
1250
+ else if (strategy === "recreate") await recreateKeepingRenames(adapter, diff, init);
1251
+ else if (strategy === "modify") {
989
1252
  needsSyncColumns = true;
990
1253
  init.status = "alter";
991
1254
  } else {
@@ -999,11 +1262,8 @@ async function applyColumnDiff(adapter, readable, diff, init, safe, logger, ensu
999
1262
  if (!safe && !init.recreated && init.status !== "error") if (adapter.supportsColumnModify && adapter.syncColumns) {
1000
1263
  needsSyncColumns = true;
1001
1264
  init.status = "alter";
1002
- } else if (adapter.recreateTable) {
1003
- await adapter.recreateTable();
1004
- init.recreated = true;
1005
- init.status = "alter";
1006
- } else init.status = "alter";
1265
+ } else if (needsDdlForNullableDefaults(adapter)) await recreateKeepingRenames(adapter, diff, init);
1266
+ else init.status = "alter";
1007
1267
  else if (init.status !== "error") {
1008
1268
  if (safe && !init.recreated && needsDdlForNullableDefaults(adapter)) {
1009
1269
  Object.assign(init, describeNullableDefaults(diff));
@@ -1015,7 +1275,7 @@ async function applyColumnDiff(adapter, readable, diff, init, safe, logger, ensu
1015
1275
  }
1016
1276
  if (!init.recreated && init.status !== "error" && (diff.added.length > 0 || diff.renamed.length > 0 || needsSyncColumns) && adapter.syncColumns) {
1017
1277
  const syncResult = await adapter.syncColumns(diff);
1018
- init.columnsAdded = syncResult.added;
1278
+ init.columnsAdded = [...init.columnsAdded ?? [], ...syncResult.added];
1019
1279
  init.columnsRenamed = syncResult.renamed;
1020
1280
  if (syncResult.added.length > 0 || (syncResult.renamed?.length ?? 0) > 0 || needsSyncColumns) init.status = "alter";
1021
1281
  }
@@ -1580,8 +1840,18 @@ var SchemaSync = class {
1580
1840
  init.columnsToAdd = [...readable.columnDescriptors];
1581
1841
  } else if (facts.existing && facts.existing.length > 0) {
1582
1842
  const typeMapper = adapter.typeMapper?.bind(adapter);
1583
- facts.diff = require_column_diff.computeColumnDiff(readable.columnDescriptors, facts.existing, typeMapper, { snapshot: storedSnapshot });
1843
+ facts.diff = require_column_diff.computeColumnDiff(readable.columnDescriptors, facts.existing, typeMapper, {
1844
+ snapshot: storedSnapshot,
1845
+ nativeDefaultFns: adapter.nativeDefaultFns()
1846
+ });
1584
1847
  this.populatePlanFromDiff(facts.diff, init, readable, safe);
1848
+ const migration = planJsonColumnMigration(readable, facts.diff, storedSnapshot);
1849
+ facts.jsonMigration = migration;
1850
+ Object.assign(init, describeJsonMigration(migration));
1851
+ if (migration.errors.length > 0) {
1852
+ init.status = "error";
1853
+ init.errors = [...init.errors ?? [], ...migration.errors];
1854
+ } else if (init.status === "in-sync" && (migration.copies.length > 0 || migration.jsonify.length > 0)) init.status = "alter";
1585
1855
  }
1586
1856
  } else if (adapter.syncColumns) if (!storedSnapshot) {
1587
1857
  if (!pendingRename) {
@@ -1592,7 +1862,10 @@ var SchemaSync = class {
1592
1862
  }
1593
1863
  } else {
1594
1864
  const existing = require_column_diff.snapshotToExistingColumns(storedSnapshot, readable);
1595
- facts.diff = require_column_diff.computeColumnDiff(readable.columnDescriptors, existing, this.resolveTypeMapper(adapter), { snapshot: storedSnapshot });
1865
+ facts.diff = require_column_diff.computeColumnDiff(readable.columnDescriptors, existing, this.resolveTypeMapper(adapter), {
1866
+ snapshot: storedSnapshot,
1867
+ nativeDefaultFns: adapter.nativeDefaultFns()
1868
+ });
1596
1869
  this.populatePlanFromDiff(facts.diff, init, readable, safe);
1597
1870
  }
1598
1871
  else if (adapter.tableExists) {
@@ -1627,7 +1900,16 @@ var SchemaSync = class {
1627
1900
  }, fk.existing)
1628
1901
  }));
1629
1902
  }
1630
- if (facts.diff?.primaryKeyChanged) facts.populated = await adapter.hasRows(pendingRename ? dbName : void 0) ?? "unknown";
1903
+ if (facts.diff?.primaryKeyChanged) {
1904
+ const tableName = pendingRename ? dbName : void 0;
1905
+ facts.populated = await adapter.hasRows(tableName) ?? "unknown";
1906
+ const { added, renamed } = facts.diff;
1907
+ const { to } = facts.diff.primaryKeyChanged;
1908
+ if (facts.populated === true && adapter.countKeyViolations && !added.some((f) => to.includes(f.physicalName))) {
1909
+ const liveName = new Map(renamed.map((r) => [r.field.physicalName, r.oldName]));
1910
+ facts.keyViolations = await adapter.countKeyViolations(to.map((c) => liveName.get(c) ?? c), tableName);
1911
+ }
1912
+ }
1631
1913
  facts.dropsTable = !!facts.diff?.primaryKeyChanged || willDropRecreate(readable, facts.diff, facts.optionDiff);
1632
1914
  if (facts.diff?.primaryKeyChanged || probeRecreateInbound && facts.dropsTable) facts.inboundFks = await adapter.getReferencingForeignKeys?.(dbName);
1633
1915
  if (adapter.getObjectKind) facts.objectKind = await adapter.getObjectKind(dbName);
@@ -1644,7 +1926,7 @@ var SchemaSync = class {
1644
1926
  const pk = t.diff?.primaryKeyChanged;
1645
1927
  if (pk) {
1646
1928
  const label = pkLabel(pk);
1647
- 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.`);
1929
+ 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.`);
1648
1930
  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.`);
1649
1931
  if (!safe) {
1650
1932
  const liveInbound = (t.inboundFks ?? []).filter((fk) => {
package/dist/sync.d.cts 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-jy8BfswO.cjs";
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-D1mrLMjU.cjs";
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-B4T_alUe.cjs";
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-BWdQ7kRZ.cjs";
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
  /**