@metaobjectsdev/migrate-ts 0.5.0-rc.1

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 (98) hide show
  1. package/LICENSE +189 -0
  2. package/README.md +73 -0
  3. package/dist/diff/index.d.ts +30 -0
  4. package/dist/diff/index.d.ts.map +1 -0
  5. package/dist/diff/index.js +226 -0
  6. package/dist/diff/index.js.map +1 -0
  7. package/dist/diff/rename-heuristic.d.ts +23 -0
  8. package/dist/diff/rename-heuristic.d.ts.map +1 -0
  9. package/dist/diff/rename-heuristic.js +236 -0
  10. package/dist/diff/rename-heuristic.js.map +1 -0
  11. package/dist/diff/status.d.ts +8 -0
  12. package/dist/diff/status.d.ts.map +1 -0
  13. package/dist/diff/status.js +53 -0
  14. package/dist/diff/status.js.map +1 -0
  15. package/dist/emit/index.d.ts +17 -0
  16. package/dist/emit/index.d.ts.map +1 -0
  17. package/dist/emit/index.js +18 -0
  18. package/dist/emit/index.js.map +1 -0
  19. package/dist/emit/postgres.d.ts +3 -0
  20. package/dist/emit/postgres.d.ts.map +1 -0
  21. package/dist/emit/postgres.js +181 -0
  22. package/dist/emit/postgres.js.map +1 -0
  23. package/dist/emit/sqlite.d.ts +3 -0
  24. package/dist/emit/sqlite.d.ts.map +1 -0
  25. package/dist/emit/sqlite.js +302 -0
  26. package/dist/emit/sqlite.js.map +1 -0
  27. package/dist/errors.d.ts +8 -0
  28. package/dist/errors.d.ts.map +1 -0
  29. package/dist/errors.js +54 -0
  30. package/dist/errors.js.map +1 -0
  31. package/dist/expected-schema.d.ts +15 -0
  32. package/dist/expected-schema.d.ts.map +1 -0
  33. package/dist/expected-schema.js +243 -0
  34. package/dist/expected-schema.js.map +1 -0
  35. package/dist/index.d.ts +18 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +25 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/introspect/index.d.ts +6 -0
  40. package/dist/introspect/index.d.ts.map +1 -0
  41. package/dist/introspect/index.js +11 -0
  42. package/dist/introspect/index.js.map +1 -0
  43. package/dist/introspect/postgres.d.ts +57 -0
  44. package/dist/introspect/postgres.d.ts.map +1 -0
  45. package/dist/introspect/postgres.js +339 -0
  46. package/dist/introspect/postgres.js.map +1 -0
  47. package/dist/introspect/sqlite.d.ts +4 -0
  48. package/dist/introspect/sqlite.d.ts.map +1 -0
  49. package/dist/introspect/sqlite.js +192 -0
  50. package/dist/introspect/sqlite.js.map +1 -0
  51. package/dist/source-aware-diff.d.ts +20 -0
  52. package/dist/source-aware-diff.d.ts.map +1 -0
  53. package/dist/source-aware-diff.js +24 -0
  54. package/dist/source-aware-diff.js.map +1 -0
  55. package/dist/sql-type.d.ts +45 -0
  56. package/dist/sql-type.d.ts.map +1 -0
  57. package/dist/sql-type.js +76 -0
  58. package/dist/sql-type.js.map +1 -0
  59. package/dist/types.d.ts +223 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +2 -0
  62. package/dist/types.js.map +1 -0
  63. package/dist/view-ddl-postgres.d.ts +4 -0
  64. package/dist/view-ddl-postgres.d.ts.map +1 -0
  65. package/dist/view-ddl-postgres.js +13 -0
  66. package/dist/view-ddl-postgres.js.map +1 -0
  67. package/dist/view-ddl-sqlite.d.ts +3 -0
  68. package/dist/view-ddl-sqlite.d.ts.map +1 -0
  69. package/dist/view-ddl-sqlite.js +7 -0
  70. package/dist/view-ddl-sqlite.js.map +1 -0
  71. package/dist/view-diff.d.ts +13 -0
  72. package/dist/view-diff.d.ts.map +1 -0
  73. package/dist/view-diff.js +42 -0
  74. package/dist/view-diff.js.map +1 -0
  75. package/dist/write-migration.d.ts +19 -0
  76. package/dist/write-migration.d.ts.map +1 -0
  77. package/dist/write-migration.js +34 -0
  78. package/dist/write-migration.js.map +1 -0
  79. package/package.json +50 -0
  80. package/src/diff/index.ts +294 -0
  81. package/src/diff/rename-heuristic.ts +265 -0
  82. package/src/diff/status.ts +55 -0
  83. package/src/emit/index.ts +38 -0
  84. package/src/emit/postgres.ts +189 -0
  85. package/src/emit/sqlite.ts +322 -0
  86. package/src/errors.ts +58 -0
  87. package/src/expected-schema.ts +326 -0
  88. package/src/index.ts +49 -0
  89. package/src/introspect/index.ts +14 -0
  90. package/src/introspect/postgres.ts +428 -0
  91. package/src/introspect/sqlite.ts +216 -0
  92. package/src/source-aware-diff.ts +49 -0
  93. package/src/sql-type.ts +91 -0
  94. package/src/types.ts +174 -0
  95. package/src/view-ddl-postgres.ts +15 -0
  96. package/src/view-ddl-sqlite.ts +7 -0
  97. package/src/view-diff.ts +55 -0
  98. package/src/write-migration.ts +64 -0
@@ -0,0 +1,322 @@
1
+ import type {
2
+ Change, EmitResult, ColumnDescriptor, IndexDescriptor,
3
+ TableDescriptor, SchemaSnapshot, SnapshotMeta, ColumnDefault,
4
+ } from "../types.js";
5
+ import type { SqlType } from "../sql-type.js";
6
+
7
+ // Stage ordering similar to PG; recreate-and-copy bundles get inserted
8
+ // at their first triggering change's position in Task 23.
9
+ const STAGE_ORDER: Record<Change["kind"], number> = {
10
+ "create-table": 1,
11
+ "add-column": 2, "drop-column": 2,
12
+ "change-column-type": 2, "change-column-nullable": 2, "change-column-default": 2,
13
+ "rename-column": 3, "rename-table": 3,
14
+ "add-index": 4, "drop-index": 4,
15
+ "add-fk": 5, "drop-fk": 5,
16
+ "drop-table": 6,
17
+ "create-view": 99, "drop-view": 99, "replace-view": 99,
18
+ };
19
+
20
+ const RECREATE_TRIGGERING_KINDS = new Set<Change["kind"]>([
21
+ "change-column-type", "change-column-nullable", "change-column-default",
22
+ "add-fk", "drop-fk",
23
+ ]);
24
+
25
+ export function renderSqlite(
26
+ changes: Change[],
27
+ expectedSchema?: SchemaSnapshot,
28
+ actualMeta?: SnapshotMeta,
29
+ ): EmitResult {
30
+ const version = parseVersion(actualMeta?.sqliteVersion);
31
+ const SUPPORTS_DROP = compareVersions(version, [3, 35, 0]) >= 0;
32
+ const SUPPORTS_RENAME = compareVersions(version, [3, 25, 0]) >= 0;
33
+
34
+ // Decide per-change whether it triggers recreate-and-copy.
35
+ const triggersRecreate = (c: Change): boolean => {
36
+ if (RECREATE_TRIGGERING_KINDS.has(c.kind)) return true;
37
+ if (c.kind === "drop-column" && !SUPPORTS_DROP) return true;
38
+ if (c.kind === "rename-column" && !SUPPORTS_RENAME) return true;
39
+ return false;
40
+ };
41
+
42
+ const sorted = [...changes].sort((a, b) => STAGE_ORDER[a.kind] - STAGE_ORDER[b.kind]);
43
+
44
+ // Tables being newly created in this batch — FKs are already included in CREATE TABLE DDL,
45
+ // so add-fk / drop-fk against brand-new tables must NOT trigger recreate-and-copy.
46
+ const newlyCreatedTables = new Set<string>();
47
+ for (const c of sorted) {
48
+ if (c.kind === "create-table") newlyCreatedTables.add(c.table.name);
49
+ }
50
+
51
+ // Partition: which tables need a recreate?
52
+ const recreateTables = new Set<string>();
53
+ for (const c of sorted) {
54
+ if (triggersRecreate(c)) {
55
+ const t = changeTable(c);
56
+ if (t && !newlyCreatedTables.has(t)) recreateTables.add(t);
57
+ }
58
+ }
59
+
60
+ if (recreateTables.size > 0 && !expectedSchema) {
61
+ throw new Error("expectedSchema required for SQLite recreate-and-copy (pass via emit() options)");
62
+ }
63
+
64
+ // For each recreate table, gather every change targeting it; render the recipe once.
65
+ // Other changes pass through native rendering.
66
+ const upStmts: string[] = [];
67
+ const downStmts: string[] = [];
68
+ const handledRecreate = new Set<string>();
69
+
70
+ for (const c of sorted) {
71
+ const t = changeTable(c);
72
+ if (t && recreateTables.has(t)) {
73
+ if (handledRecreate.has(t)) continue; // already bundled at first triggering change
74
+ const tableChanges = sorted.filter((x) => changeTable(x) === t);
75
+ const newTable = expectedSchema!.tables.find((tt) => tt.name === t);
76
+ if (!newTable) throw new Error(`expectedSchema missing table "${t}" needed for recreate`);
77
+ const { up, down } = renderRecreate(t, tableChanges, newTable);
78
+ upStmts.push(up);
79
+ downStmts.push(down);
80
+ handledRecreate.add(t);
81
+ continue;
82
+ }
83
+ // add-fk / drop-fk targeting a newly-created table: FK is already in CREATE TABLE DDL — skip.
84
+ if ((c.kind === "add-fk" || c.kind === "drop-fk") && t && newlyCreatedTables.has(t)) {
85
+ continue;
86
+ }
87
+ upStmts.push(renderUpNative(c));
88
+ downStmts.push(renderDownNative(c));
89
+ }
90
+
91
+ return {
92
+ up: upStmts.join("\n\n"),
93
+ down: [...downStmts].reverse().join("\n\n"),
94
+ recreatedTables: recreateTables,
95
+ };
96
+ }
97
+
98
+ function changeTable(c: Change): string | undefined {
99
+ switch (c.kind) {
100
+ case "create-table": return c.table.name;
101
+ case "drop-table": return c.table;
102
+ case "rename-table": return c.from; // applies to source table
103
+ case "add-column":
104
+ case "drop-column":
105
+ case "rename-column":
106
+ case "change-column-type":
107
+ case "change-column-nullable":
108
+ case "change-column-default":
109
+ case "add-index":
110
+ case "drop-index":
111
+ case "add-fk":
112
+ case "drop-fk":
113
+ return c.table;
114
+ default:
115
+ return undefined;
116
+ }
117
+ }
118
+
119
+ function renderRecreate(
120
+ table: string,
121
+ tableChanges: Change[],
122
+ newTable: TableDescriptor,
123
+ ): { up: string; down: string } {
124
+ // Build the column-name remapping: old_name → new_name (for renames).
125
+ const renames = new Map<string, string>();
126
+ for (const c of tableChanges) {
127
+ if (c.kind === "rename-column") renames.set(c.from, c.to);
128
+ }
129
+
130
+ // Columns added in this migration don't exist in old table — exclude from SELECT.
131
+ const addedNames = new Set<string>();
132
+ for (const c of tableChanges) {
133
+ if (c.kind === "add-column") addedNames.add(c.column.name);
134
+ }
135
+
136
+ // Reverse map: new_name → old_name (for SELECT source column lookup).
137
+ const renamesReverse = new Map<string, string>();
138
+ for (const [from, to] of renames) renamesReverse.set(to, from);
139
+
140
+ // carryColumns = newTable columns NOT being newly added (they exist in the old table).
141
+ const carryColumns = newTable.columns.filter((c) => !addedNames.has(c.name));
142
+ const insertCols = carryColumns.map((c) => c.name);
143
+ const selectCols = carryColumns.map((c) => renamesReverse.get(c.name) ?? c.name);
144
+
145
+ // Build the new-table CREATE using temp name.
146
+ const tmp = `__new_${table}`;
147
+ const tmpDescriptor: TableDescriptor = { ...newTable, name: tmp };
148
+ const createNew = renderCreateTable(tmpDescriptor);
149
+ const indexes = newTable.indexes; // recreated post-rename
150
+
151
+ const lines: string[] = [];
152
+ lines.push("PRAGMA foreign_keys = OFF;");
153
+ lines.push("BEGIN TRANSACTION;");
154
+ lines.push("");
155
+ lines.push(createNew);
156
+ if (insertCols.length > 0) {
157
+ lines.push(
158
+ `INSERT INTO ${quote(tmp)} (${insertCols.map(quote).join(", ")}) ` +
159
+ `SELECT ${selectCols.map(quote).join(", ")} FROM ${quote(table)};`,
160
+ );
161
+ }
162
+ lines.push(`DROP TABLE ${quote(table)};`);
163
+ lines.push(`ALTER TABLE ${quote(tmp)} RENAME TO ${quote(table)};`);
164
+ for (const ix of indexes) lines.push(renderCreateIndex(table, ix));
165
+ lines.push("");
166
+ lines.push("COMMIT;");
167
+ lines.push("PRAGMA foreign_keys = ON;");
168
+ lines.push("PRAGMA foreign_key_check;");
169
+
170
+ // Down: best-effort. Without the actual snapshot we can't perfectly restore,
171
+ // so emit a WARNING comment block.
172
+ const down = [
173
+ `-- WARNING: SQLite recreate-and-copy down migration is best-effort.`,
174
+ `-- Reverse the column type/nullable/default changes by hand if needed.`,
175
+ `-- Dropped data cannot be restored.`,
176
+ ].join("\n");
177
+
178
+ return { up: lines.join("\n"), down };
179
+ }
180
+
181
+ function renderUpNative(c: Change): string {
182
+ switch (c.kind) {
183
+ case "create-table": return renderCreateTable(c.table);
184
+ case "drop-table": return `DROP TABLE ${quote(c.table)};`;
185
+ case "rename-table": return `ALTER TABLE ${quote(c.from)} RENAME TO ${quote(c.to)};`;
186
+ case "add-column": return `ALTER TABLE ${quote(c.table)} ADD COLUMN ${renderColumnInline(c.column)};`;
187
+ case "drop-column": return `ALTER TABLE ${quote(c.table)} DROP COLUMN ${quote(c.column)};`;
188
+ case "rename-column": return `ALTER TABLE ${quote(c.table)} RENAME COLUMN ${quote(c.from)} TO ${quote(c.to)};`;
189
+ case "add-index": return renderCreateIndex(c.table, c.index);
190
+ case "drop-index": return `DROP INDEX ${quote(c.index)};`;
191
+ case "change-column-type":
192
+ case "change-column-nullable":
193
+ case "change-column-default":
194
+ case "add-fk":
195
+ case "drop-fk":
196
+ // These are handled by renderRecreate before reaching renderUpNative.
197
+ throw new Error(`renderUpNative: ${c.kind} should have been handled by recreate bundler`);
198
+ case "create-view":
199
+ case "drop-view":
200
+ case "replace-view":
201
+ throw new Error(`unexpected view-kind in renderSqlite: ${c.kind}`);
202
+ }
203
+ }
204
+
205
+ function renderDownNative(c: Change): string {
206
+ switch (c.kind) {
207
+ case "create-table": return `DROP TABLE ${quote(c.table.name)};`;
208
+ case "drop-table": return `-- WARNING: down migration cannot restore data\n-- TODO: restore table "${c.table}" structure manually`;
209
+ case "rename-table": return `ALTER TABLE ${quote(c.to)} RENAME TO ${quote(c.from)};`;
210
+ case "add-column": return `ALTER TABLE ${quote(c.table)} DROP COLUMN ${quote(c.column.name)};`;
211
+ case "drop-column": return `-- WARNING: down migration cannot restore data\n-- TODO: re-add dropped column "${c.column}" manually`;
212
+ case "rename-column": return `ALTER TABLE ${quote(c.table)} RENAME COLUMN ${quote(c.to)} TO ${quote(c.from)};`;
213
+ case "add-index": return `DROP INDEX ${quote(c.index.name)};`;
214
+ case "drop-index": return `-- WARNING: down migration cannot restore the original index definition`;
215
+ case "change-column-type":
216
+ case "change-column-nullable":
217
+ case "change-column-default":
218
+ case "add-fk":
219
+ case "drop-fk":
220
+ // These are handled by renderRecreate before reaching renderDownNative.
221
+ throw new Error(`renderDownNative: ${c.kind} should have been handled by recreate bundler`);
222
+ case "create-view":
223
+ case "drop-view":
224
+ case "replace-view":
225
+ throw new Error(`unexpected view-kind in renderSqlite: ${c.kind}`);
226
+ }
227
+ }
228
+
229
+ function renderCreateTable(t: TableDescriptor): string {
230
+ const compositePk = t.primaryKey.length > 1;
231
+ const colDefs = t.columns.map((c) => {
232
+ const isSinglePk = !compositePk && t.primaryKey[0] === c.name;
233
+ return ` ${renderColumnInline(c, isSinglePk)}`;
234
+ });
235
+ if (compositePk) {
236
+ colDefs.push(` PRIMARY KEY (${t.primaryKey.map(quote).join(", ")})`);
237
+ }
238
+ for (const fk of t.foreignKeys) {
239
+ const cols = fk.columns.map(quote).join(", ");
240
+ const refCols = fk.refColumns.map(quote).join(", ");
241
+ let clause = ` FOREIGN KEY (${cols}) REFERENCES ${quote(fk.refTable)} (${refCols})`;
242
+ if (fk.onDelete) clause += ` ON DELETE ${renderFkAction(fk.onDelete)}`;
243
+ if (fk.onUpdate) clause += ` ON UPDATE ${renderFkAction(fk.onUpdate)}`;
244
+ colDefs.push(clause);
245
+ }
246
+ return `CREATE TABLE ${quote(t.name)} (\n${colDefs.join(",\n")}\n);`;
247
+ }
248
+
249
+ function renderFkAction(action: "cascade" | "set-null" | "restrict" | "no-action"): string {
250
+ switch (action) {
251
+ case "cascade": return "CASCADE";
252
+ case "set-null": return "SET NULL";
253
+ case "restrict": return "RESTRICT";
254
+ case "no-action": return "NO ACTION";
255
+ }
256
+ }
257
+
258
+ function renderColumnInline(c: ColumnDescriptor, isSinglePk = false): string {
259
+ let s = `${quote(c.name)} ${sqliteType(c.sqlType, c.identity)}`;
260
+ if (isSinglePk) s += " PRIMARY KEY";
261
+ if (c.identity === "increment" && isSinglePk) s += " AUTOINCREMENT";
262
+ s += c.nullable ? "" : " NOT NULL";
263
+ if (c.default !== undefined) {
264
+ s += ` DEFAULT ${renderDefault(c.default)}`;
265
+ } else if (c.identity === "uuid") {
266
+ // SQLite has no native uuid(); approximate via lower(hex(randomblob(16))).
267
+ s += " DEFAULT (lower(hex(randomblob(16))))";
268
+ }
269
+ return s;
270
+ }
271
+
272
+ function sqliteType(t: SqlType, identity: ColumnDescriptor["identity"]): string {
273
+ if (identity === "increment") return "INTEGER";
274
+ if (identity === "uuid") return "TEXT";
275
+ switch (t.kind) {
276
+ case "text": return t.maxLength !== undefined ? `VARCHAR(${t.maxLength})` : "TEXT";
277
+ // Use INTEGER for 64-bit and INT for 32-bit — SQLite preserves the declared type
278
+ // in pragma_table_info, enabling round-trip fidelity (see introspect/sqlite.ts).
279
+ case "integer": return t.bits === 64 ? "INTEGER" : "INT";
280
+ case "real": return "REAL";
281
+ case "numeric": {
282
+ if (t.precision !== undefined && t.scale !== undefined) return `NUMERIC(${t.precision},${t.scale})`;
283
+ return "NUMERIC";
284
+ }
285
+ case "boolean": return "BOOLEAN"; // SQLite stores as 0/1 but preserves declared type for round-trip
286
+ case "timestamp": return "TIMESTAMP";
287
+ case "date": return "DATE";
288
+ case "json": return "TEXT"; // SQLite has JSON1 but stores as text
289
+ case "blob": return "BLOB";
290
+ case "uuid": return "TEXT";
291
+ }
292
+ }
293
+
294
+ function renderDefault(d: ColumnDefault): string {
295
+ if (d.kind === "expr") return d.value;
296
+ return `'${d.value.replace(/'/g, "''")}'`;
297
+ }
298
+
299
+ function renderCreateIndex(table: string, ix: IndexDescriptor): string {
300
+ const u = ix.unique ? "UNIQUE " : "";
301
+ return `CREATE ${u}INDEX ${quote(ix.name)} ON ${quote(table)} (${ix.columns.map(quote).join(", ")});`;
302
+ }
303
+
304
+ function quote(ident: string): string {
305
+ if (ident.includes('"')) throw new Error(`unsafe identifier: ${ident}`);
306
+ return `"${ident}"`;
307
+ }
308
+
309
+ function parseVersion(v: string | undefined): [number, number, number] {
310
+ if (!v) return [99, 0, 0]; // assume modern when unknown
311
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
312
+ if (!m) return [99, 0, 0];
313
+ return [parseInt(m[1]!, 10), parseInt(m[2]!, 10), parseInt(m[3]!, 10)];
314
+ }
315
+
316
+ function compareVersions(a: [number, number, number], b: [number, number, number]): number {
317
+ for (let i = 0; i < 3; i++) {
318
+ if (a[i]! < b[i]!) return -1;
319
+ if (a[i]! > b[i]!) return 1;
320
+ }
321
+ return 0;
322
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { Change, ChangeKind } from "./types.js";
2
+
3
+ const ENABLE_FLAG_BY_KIND: Partial<Record<ChangeKind, string>> = {
4
+ "drop-column": "allow.dropColumn",
5
+ "drop-table": "allow.dropTable",
6
+ "change-column-type": "allow.typeChange",
7
+ "change-column-nullable": "allow.nullableToNotNull",
8
+ "drop-index": "allow.dropIndex",
9
+ "drop-fk": "allow.dropFk",
10
+ };
11
+
12
+ function changeLocator(c: Change): string {
13
+ switch (c.kind) {
14
+ case "drop-column":
15
+ case "rename-column":
16
+ case "change-column-type":
17
+ case "change-column-nullable":
18
+ case "change-column-default":
19
+ return `${c.table}.${"column" in c ? c.column : c.from}`;
20
+ case "add-column":
21
+ return `${c.table}.${c.column.name}`;
22
+ case "drop-table":
23
+ case "rename-table":
24
+ return c.kind === "drop-table" ? c.table : `${c.from}→${c.to}`;
25
+ case "create-table":
26
+ return c.table.name;
27
+ case "add-index":
28
+ return `${c.table}.${c.index.name}`;
29
+ case "drop-index":
30
+ return `${c.table}.${c.index}`;
31
+ case "add-fk":
32
+ return `${c.table}.${c.fk.name}`;
33
+ case "drop-fk":
34
+ return `${c.table}.${c.fk}`;
35
+ case "create-view":
36
+ case "replace-view":
37
+ return c.view.name;
38
+ case "drop-view":
39
+ return c.view;
40
+ }
41
+ }
42
+
43
+ export class BlockedChangesError extends Error {
44
+ override readonly name = "BlockedChangesError";
45
+ readonly blocked: Change[];
46
+ readonly enableHints: string[];
47
+
48
+ constructor(blocked: Change[]) {
49
+ const hints = blocked.map((c) => {
50
+ const flag = ENABLE_FLAG_BY_KIND[c.kind] ?? "(no flag enables this)";
51
+ return `${c.kind} on ${changeLocator(c)}: pass ${flag}`;
52
+ });
53
+ const msg = `${blocked.length} blocked change(s):\n - ${hints.join("\n - ")}`;
54
+ super(msg);
55
+ this.blocked = blocked;
56
+ this.enableHints = hints;
57
+ }
58
+ }