@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,265 @@
1
+ import type {
2
+ Change, AmbiguousCallback, AmbiguousChange, AmbiguousResolution, ColumnDescriptor,
3
+ TableDescriptor,
4
+ } from "../types.js";
5
+ import type { SqlType } from "../sql-type.js";
6
+ import { sqlTypeEquals } from "../sql-type.js";
7
+ import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata";
8
+
9
+ const TABLE_RENAME_OVERLAP_THRESHOLD = 0.8;
10
+
11
+ /**
12
+ * Mutates `changes` in place: detects (drop-table, create-table) pairs whose
13
+ * column sets have Jaccard similarity ≥ 0.8. Per spec §6.3.
14
+ *
15
+ * Must run BEFORE detectColumnRenames so a renamed table's columns aren't
16
+ * scanned as orphaned drop/add pairs.
17
+ *
18
+ * Side-channel: diff() attaches `_columns` to drop-table changes so this
19
+ * function has access to the dropped table's column list.
20
+ */
21
+ export async function detectTableRenames(
22
+ changes: Change[],
23
+ onAmbiguous: AmbiguousCallback | undefined,
24
+ ): Promise<void> {
25
+ const drops: {
26
+ idx: number; tableName: string; schema: string | undefined; columns: ColumnDescriptor[];
27
+ }[] = [];
28
+ const creates: { idx: number; table: TableDescriptor }[] = [];
29
+
30
+ changes.forEach((c, idx) => {
31
+ if (c.kind === "drop-table") {
32
+ const aug = c as Change & { _columns?: ColumnDescriptor[] };
33
+ if (aug._columns) drops.push({ idx, tableName: c.table, schema: c.schema, columns: aug._columns });
34
+ } else if (c.kind === "create-table") {
35
+ creates.push({ idx, table: c.table });
36
+ }
37
+ });
38
+
39
+ if (drops.length === 0 || creates.length === 0) return;
40
+
41
+ const indicesToRemove = new Set<number>();
42
+ const renamesToInsert: { afterIdx: number; change: Change }[] = [];
43
+
44
+ for (const drop of drops) {
45
+ let bestOverlap = 0;
46
+ let bestCreate: typeof creates[number] | undefined;
47
+ for (const create of creates) {
48
+ if (indicesToRemove.has(create.idx)) continue;
49
+ // Only pair with creates in the SAME schema — cross-schema name collision
50
+ // is not a rename, it's a distinct entity.
51
+ if (!sameSchema(drop.schema, create.table.schema)) continue;
52
+ const overlap = columnSetOverlap(drop.columns, create.table.columns);
53
+ if (overlap > bestOverlap) {
54
+ bestOverlap = overlap;
55
+ bestCreate = create;
56
+ }
57
+ }
58
+ if (!bestCreate || bestOverlap < TABLE_RENAME_OVERLAP_THRESHOLD) continue;
59
+
60
+ const q: AmbiguousChange = {
61
+ kind: "possible-table-rename",
62
+ from: { name: drop.tableName, columnCount: drop.columns.length },
63
+ to: { name: bestCreate.table.name, columnCount: bestCreate.table.columns.length },
64
+ columnOverlap: bestOverlap,
65
+ };
66
+ const resolution: AmbiguousResolution = onAmbiguous ? await onAmbiguous(q) : "drop+add";
67
+ if (resolution === "abort") {
68
+ throw new Error(
69
+ `diff aborted by onAmbiguous: possible rename ${drop.tableName} → ${bestCreate.table.name}`,
70
+ );
71
+ }
72
+ if (resolution === "rename") {
73
+ indicesToRemove.add(drop.idx);
74
+ indicesToRemove.add(bestCreate.idx);
75
+ const renameChange: Change = {
76
+ kind: "rename-table",
77
+ from: drop.tableName,
78
+ to: bestCreate.table.name,
79
+ ...(drop.schema !== undefined ? { schema: drop.schema } : {}),
80
+ status: { state: "allowed" },
81
+ };
82
+ renamesToInsert.push({ afterIdx: drop.idx, change: renameChange });
83
+ }
84
+ }
85
+
86
+ if (indicesToRemove.size === 0) return;
87
+
88
+ const insertByIdx = new Map<number, Change>();
89
+ for (const r of renamesToInsert) insertByIdx.set(r.afterIdx, r.change);
90
+ const result: Change[] = [];
91
+ for (let i = 0; i < changes.length; i++) {
92
+ if (insertByIdx.has(i)) result.push(insertByIdx.get(i)!);
93
+ if (!indicesToRemove.has(i)) result.push(changes[i]!);
94
+ }
95
+ changes.length = 0;
96
+ changes.push(...result);
97
+ }
98
+
99
+ /**
100
+ * Compare two schema strings, treating undefined as equivalent to "public" (Postgres default).
101
+ * Used by the table-rename heuristic to avoid pairing drops and creates across schemas.
102
+ */
103
+ function sameSchema(a: string | undefined, b: string | undefined): boolean {
104
+ return (a ?? DEFAULT_DB_SCHEMA_POSTGRES) === (b ?? DEFAULT_DB_SCHEMA_POSTGRES);
105
+ }
106
+
107
+ function columnSetOverlap(a: ColumnDescriptor[], b: ColumnDescriptor[]): number {
108
+ const aSet = new Set(a.map(colSig));
109
+ const bSet = new Set(b.map(colSig));
110
+ let intersection = 0;
111
+ for (const x of aSet) if (bSet.has(x)) intersection++;
112
+ const union = aSet.size + bSet.size - intersection;
113
+ if (union === 0) return 0;
114
+ return intersection / union;
115
+ }
116
+
117
+ function colSig(c: ColumnDescriptor): string {
118
+ return `${c.name}|${c.sqlType.kind}|${c.nullable}`;
119
+ }
120
+
121
+ /**
122
+ * Mutates `changes` in place: detects (drop-column 'old', add-column 'new') pairs
123
+ * on the same table that match the rename heuristic. Per spec §6.2.
124
+ *
125
+ * For each candidate: invokes onAmbiguous (default 'drop+add' if absent).
126
+ * - 'rename' → replace the pair with a single rename-column Change.
127
+ * - 'drop+add'→ leave both as-is.
128
+ * - 'abort' → throw.
129
+ */
130
+ export async function detectColumnRenames(
131
+ changes: Change[],
132
+ onAmbiguous: AmbiguousCallback | undefined,
133
+ ): Promise<void> {
134
+ // Group drop-columns and add-columns by (schema, table) — same table name in
135
+ // different schemas must not cross-pair. Key: schema-or-public . table.
136
+ // Drop-columns carry side-channel _sqlType/_nullable fields added by diff().
137
+ type TableKey = string; // "schema.table"; "public.<n>" if schema undefined
138
+ type DropEntry = {
139
+ idx: number; table: string; schema: string | undefined;
140
+ column: string; sqlType: SqlType; nullable: boolean;
141
+ };
142
+ type AddEntry = {
143
+ idx: number; table: string; schema: string | undefined;
144
+ column: ColumnDescriptor;
145
+ };
146
+ const keyOf = (table: string, schema: string | undefined): TableKey =>
147
+ (schema ?? DEFAULT_DB_SCHEMA_POSTGRES) + "." + table;
148
+
149
+ const dropsByTable = new Map<TableKey, DropEntry[]>();
150
+ const addsByTable = new Map<TableKey, AddEntry[]>();
151
+
152
+ changes.forEach((c, idx) => {
153
+ if (c.kind === "drop-column") {
154
+ const aug = c as Change & { _sqlType?: SqlType; _nullable?: boolean };
155
+ if (aug._sqlType !== undefined && aug._nullable !== undefined) {
156
+ const k = keyOf(c.table, c.schema);
157
+ let arr = dropsByTable.get(k);
158
+ if (!arr) { arr = []; dropsByTable.set(k, arr); }
159
+ arr.push({
160
+ idx, table: c.table, schema: c.schema,
161
+ column: c.column, sqlType: aug._sqlType, nullable: aug._nullable,
162
+ });
163
+ }
164
+ } else if (c.kind === "add-column") {
165
+ const k = keyOf(c.table, c.schema);
166
+ let arr = addsByTable.get(k);
167
+ if (!arr) { arr = []; addsByTable.set(k, arr); }
168
+ arr.push({ idx, table: c.table, schema: c.schema, column: c.column });
169
+ }
170
+ });
171
+
172
+ const indicesToRemove = new Set<number>();
173
+ const renamesToInsert: { afterIdx: number; change: Change }[] = [];
174
+
175
+ for (const [k, drops] of dropsByTable) {
176
+ const adds = addsByTable.get(k) ?? [];
177
+ for (const drop of drops) {
178
+ // Find candidate adds matching same sqlType + same nullable + Levenshtein threshold.
179
+ const candidates = adds.filter((a) =>
180
+ sqlTypeEquals(a.column.sqlType, drop.sqlType)
181
+ && a.column.nullable === drop.nullable
182
+ && withinLevenshteinThreshold(drop.column, a.column.name),
183
+ );
184
+ if (candidates.length === 0) continue;
185
+
186
+ // Pick the closest candidate (smallest distance).
187
+ candidates.sort((a, b) =>
188
+ levenshtein(drop.column, a.column.name) - levenshtein(drop.column, b.column.name),
189
+ );
190
+ const winner = candidates[0]!;
191
+
192
+ const q: AmbiguousChange = {
193
+ kind: "possible-column-rename",
194
+ table: drop.table,
195
+ from: { name: drop.column, sqlType: drop.sqlType },
196
+ to: { name: winner.column.name, sqlType: winner.column.sqlType },
197
+ };
198
+
199
+ const resolution: AmbiguousResolution = onAmbiguous
200
+ ? await onAmbiguous(q)
201
+ : "drop+add";
202
+
203
+ if (resolution === "abort") {
204
+ throw new Error(
205
+ `diff aborted by onAmbiguous: possible rename ${drop.table}.${drop.column} → ${drop.table}.${winner.column.name}`,
206
+ );
207
+ }
208
+ if (resolution === "rename") {
209
+ indicesToRemove.add(drop.idx);
210
+ indicesToRemove.add(winner.idx);
211
+ const renameChange: Change = {
212
+ kind: "rename-column",
213
+ table: drop.table,
214
+ ...(drop.schema !== undefined ? { schema: drop.schema } : {}),
215
+ from: drop.column,
216
+ to: winner.column.name,
217
+ status: { state: "allowed" },
218
+ };
219
+ renamesToInsert.push({ afterIdx: drop.idx, change: renameChange });
220
+ // Remove winner from adds so it isn't paired again.
221
+ const wIdx = adds.indexOf(winner);
222
+ if (wIdx >= 0) adds.splice(wIdx, 1);
223
+ }
224
+ // 'drop+add' → no-op
225
+ }
226
+ }
227
+
228
+ if (indicesToRemove.size === 0) return;
229
+
230
+ // Build new array: filter out removed, insert renames at the right positions.
231
+ const insertByIdx = new Map<number, Change>();
232
+ for (const r of renamesToInsert) insertByIdx.set(r.afterIdx, r.change);
233
+
234
+ const result: Change[] = [];
235
+ for (let i = 0; i < changes.length; i++) {
236
+ if (insertByIdx.has(i)) result.push(insertByIdx.get(i)!); // insert rename in place of drop
237
+ if (!indicesToRemove.has(i)) result.push(changes[i]!);
238
+ }
239
+ changes.length = 0;
240
+ changes.push(...result);
241
+ }
242
+
243
+ function withinLevenshteinThreshold(a: string, b: string): boolean {
244
+ const minLen = Math.min(a.length, b.length);
245
+ const threshold = Math.max(2, Math.floor(minLen / 3));
246
+ return levenshtein(a, b) <= threshold;
247
+ }
248
+
249
+ function levenshtein(a: string, b: string): number {
250
+ if (a === b) return 0;
251
+ if (a.length === 0) return b.length;
252
+ if (b.length === 0) return a.length;
253
+ let prev = new Array<number>(b.length + 1).fill(0);
254
+ let curr = new Array<number>(b.length + 1).fill(0);
255
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
256
+ for (let i = 1; i <= a.length; i++) {
257
+ curr[0] = i;
258
+ for (let j = 1; j <= b.length; j++) {
259
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
260
+ curr[j] = Math.min(curr[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost);
261
+ }
262
+ [prev, curr] = [curr, prev];
263
+ }
264
+ return prev[b.length]!;
265
+ }
@@ -0,0 +1,55 @@
1
+ import type { Change, AllowOptions } from "../types.js";
2
+ import { isWidening } from "../sql-type.js";
3
+
4
+ /**
5
+ * Mutates each Change's `status` field per the rules in spec §6.5.
6
+ * Destructive/lossy changes default to blocked unless the corresponding
7
+ * `allow.*` flag is set.
8
+ */
9
+ export function applyStatus(changes: Change[], allow: AllowOptions = {}): void {
10
+ for (const c of changes) {
11
+ const blockedReason = blockedReasonFor(c, allow);
12
+ if (blockedReason !== null) {
13
+ c.status = { state: "blocked", blockedReason };
14
+ } else {
15
+ c.status = { state: "allowed" };
16
+ }
17
+ }
18
+ }
19
+
20
+ function blockedReasonFor(c: Change, allow: AllowOptions): string | null {
21
+ switch (c.kind) {
22
+ case "drop-column":
23
+ return allow.dropColumn ? null : "destructive: drop-column not allowed (pass allow.dropColumn)";
24
+ case "drop-table":
25
+ return allow.dropTable ? null : "destructive: drop-table not allowed (pass allow.dropTable)";
26
+ case "drop-index":
27
+ return allow.dropIndex ? null : "destructive: drop-index not allowed (pass allow.dropIndex)";
28
+ case "drop-fk":
29
+ return allow.dropFk ? null : "destructive: drop-fk not allowed (pass allow.dropFk)";
30
+
31
+ case "change-column-type":
32
+ if (isWidening(c.from, c.to)) return null; // widening always allowed
33
+ return allow.typeChange ? null : "lossy type change (pass allow.typeChange)";
34
+
35
+ case "change-column-nullable":
36
+ // from = actual.nullable, to = expected.nullable
37
+ // notnull (false) → nullable (true): allowed
38
+ // nullable (true) → notnull (false): requires flag (existing data must satisfy)
39
+ if (c.from === false && c.to === true) return null;
40
+ return allow.nullableToNotNull ? null : "nullable→notnull requires existing data to satisfy (pass allow.nullableToNotNull)";
41
+
42
+ // Always-allowed kinds
43
+ case "create-table":
44
+ case "rename-table":
45
+ case "add-column":
46
+ case "rename-column":
47
+ case "change-column-default":
48
+ case "add-index":
49
+ case "add-fk":
50
+ case "create-view":
51
+ case "drop-view":
52
+ case "replace-view":
53
+ return null;
54
+ }
55
+ }
@@ -0,0 +1,38 @@
1
+ import type { Change, EmitResult, Dialect, SchemaSnapshot, SnapshotMeta } from "../types.js";
2
+ import { BlockedChangesError } from "../errors.js";
3
+ import { renderPostgres } from "./postgres.js";
4
+ import { renderSqlite } from "./sqlite.js";
5
+
6
+ export interface EmitOptions {
7
+ dialect: Dialect;
8
+ /**
9
+ * Required when dialect="sqlite" AND any change triggers recreate-and-copy
10
+ * (change-column-type, change-column-nullable, change-column-default, add-fk, drop-fk).
11
+ * Used to look up the post-migration table descriptor for the recreate recipe.
12
+ */
13
+ expectedSchema?: SchemaSnapshot;
14
+ /**
15
+ * Per spec §7.4: if SQLite version < 3.35 (DROP COLUMN) or < 3.25 (RENAME COLUMN),
16
+ * fall back to recreate-and-copy. Unknown/absent version → assume modern.
17
+ */
18
+ actualMeta?: SnapshotMeta;
19
+ }
20
+
21
+ const VIEW_KINDS = new Set<Change["kind"]>(["create-view", "drop-view", "replace-view"]);
22
+
23
+ export function emit(changes: Change[], opts: EmitOptions): EmitResult {
24
+ const blocked = changes.filter((c) => c.status.state === "blocked");
25
+ if (blocked.length > 0) throw new BlockedChangesError(blocked);
26
+
27
+ const viewChanges = changes.filter((c) => VIEW_KINDS.has(c.kind));
28
+ if (viewChanges.length > 0) {
29
+ throw new Error(
30
+ `view migration not implemented in v0.1 (${viewChanges.length} view-targeting change(s); deferred to v0.3)`,
31
+ );
32
+ }
33
+
34
+ switch (opts.dialect) {
35
+ case "postgres": return renderPostgres(changes);
36
+ case "sqlite": return renderSqlite(changes, opts.expectedSchema, opts.actualMeta);
37
+ }
38
+ }
@@ -0,0 +1,189 @@
1
+ import type {
2
+ Change, EmitResult, ColumnDescriptor, IndexDescriptor, FkDescriptor,
3
+ TableDescriptor, ColumnDefault, FkAction,
4
+ } from "../types.js";
5
+ import type { SqlType } from "../sql-type.js";
6
+ import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata";
7
+
8
+ const STAGE_ORDER: Record<Change["kind"], number> = {
9
+ "create-table": 1,
10
+ "add-column": 2, "drop-column": 2,
11
+ "change-column-type": 2, "change-column-nullable": 2, "change-column-default": 2,
12
+ "rename-column": 3, "rename-table": 3,
13
+ "add-index": 4, "drop-index": 4,
14
+ "add-fk": 5, "drop-fk": 5,
15
+ "drop-table": 6,
16
+ // view kinds — never reach here (filtered in emit())
17
+ "create-view": 99, "drop-view": 99, "replace-view": 99,
18
+ };
19
+
20
+ export function renderPostgres(changes: Change[]): EmitResult {
21
+ const sorted = [...changes].sort((a, b) => STAGE_ORDER[a.kind] - STAGE_ORDER[b.kind]);
22
+ const upStmts: string[] = [];
23
+ const downStmts: string[] = [];
24
+ for (const c of sorted) {
25
+ upStmts.push(renderUp(c));
26
+ downStmts.push(renderDown(c));
27
+ }
28
+ // Down runs in reverse order (so creates undo correctly w.r.t. FKs).
29
+ return {
30
+ up: upStmts.join("\n\n"),
31
+ down: [...downStmts].reverse().join("\n\n"),
32
+ recreatedTables: new Set(), // postgres alters in place; no recreate-and-copy
33
+ };
34
+ }
35
+
36
+ function renderUp(c: Change): string {
37
+ switch (c.kind) {
38
+ case "create-table": return renderCreateTable(c.table);
39
+ case "drop-table": return `DROP TABLE ${quoteQualified(c.table, c.schema)};`;
40
+ case "rename-table": return `ALTER TABLE ${quoteQualified(c.from, c.schema)} RENAME TO ${quote(c.to)};`;
41
+ case "add-column": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} ADD COLUMN ${renderColumn(c.column)};`;
42
+ case "drop-column": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP COLUMN ${quote(c.column)};`;
43
+ case "rename-column": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} RENAME COLUMN ${quote(c.from)} TO ${quote(c.to)};`;
44
+ case "change-column-type": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} TYPE ${pgType(c.to)};`;
45
+ case "change-column-nullable":
46
+ return c.to
47
+ ? `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} DROP NOT NULL;`
48
+ : `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} SET NOT NULL;`;
49
+ case "change-column-default":
50
+ return c.to !== undefined
51
+ ? `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} SET DEFAULT ${renderDefault(c.to)};`
52
+ : `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} DROP DEFAULT;`;
53
+ case "add-index": return renderCreateIndex(c.table, c.schema, c.index);
54
+ case "drop-index": return `DROP INDEX ${quoteIndexQualified(c.index, c.schema)};`;
55
+ case "add-fk": return renderAddFk(c.table, c.schema, c.fk);
56
+ case "drop-fk": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.fk)};`;
57
+ case "create-view":
58
+ case "drop-view":
59
+ case "replace-view":
60
+ // emit() filters these; defensive throw if reached.
61
+ throw new Error(`unexpected view-kind in renderPostgres: ${c.kind}`);
62
+ }
63
+ }
64
+
65
+ function renderDown(c: Change): string {
66
+ switch (c.kind) {
67
+ case "create-table": return `DROP TABLE ${quoteQualified(c.table.name, c.table.schema)};`;
68
+ case "drop-table": return `-- WARNING: down migration cannot restore data\n-- TODO: restore table "${c.table}" structure manually`;
69
+ case "rename-table": return `ALTER TABLE ${quoteQualified(c.to, c.schema)} RENAME TO ${quote(c.from)};`;
70
+ case "add-column": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP COLUMN ${quote(c.column.name)};`;
71
+ case "drop-column": return `-- WARNING: down migration cannot restore data\n-- TODO: re-add dropped column "${c.column}" manually with original type/nullable/default`;
72
+ case "rename-column": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} RENAME COLUMN ${quote(c.to)} TO ${quote(c.from)};`;
73
+ case "change-column-type": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} TYPE ${pgType(c.from)};`;
74
+ case "change-column-nullable":
75
+ return c.from
76
+ ? `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} DROP NOT NULL;`
77
+ : `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} SET NOT NULL;`;
78
+ case "change-column-default":
79
+ return c.from !== undefined
80
+ ? `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} SET DEFAULT ${renderDefault(c.from)};`
81
+ : `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} DROP DEFAULT;`;
82
+ case "add-index": return `DROP INDEX ${quoteIndexQualified(c.index.name, c.schema)};`;
83
+ case "drop-index": return `-- WARNING: down migration cannot restore the original index definition`;
84
+ case "add-fk": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.fk.name)};`;
85
+ case "drop-fk": return `-- WARNING: down migration cannot restore the original FK definition`;
86
+ case "create-view":
87
+ case "drop-view":
88
+ case "replace-view":
89
+ throw new Error(`unexpected view-kind in renderPostgres: ${c.kind}`);
90
+ }
91
+ }
92
+
93
+ function renderCreateTable(t: TableDescriptor): string {
94
+ const colDefs = t.columns.map((c) => ` ${renderColumn(c)}`);
95
+ if (t.primaryKey.length > 0) {
96
+ colDefs.push(` CONSTRAINT ${quote(t.name + "_pkey")} PRIMARY KEY (${t.primaryKey.map(quote).join(", ")})`);
97
+ }
98
+ return `CREATE TABLE ${quoteQualified(t.name, t.schema)} (\n${colDefs.join(",\n")}\n);`;
99
+ }
100
+
101
+ function renderColumn(c: ColumnDescriptor): string {
102
+ let s = `${quote(c.name)} ${pgType(c.sqlType)}`;
103
+ if (c.identity === "increment") s += " GENERATED BY DEFAULT AS IDENTITY";
104
+ if (c.identity === "uuid") s += " DEFAULT gen_random_uuid()";
105
+ s += c.nullable ? "" : " NOT NULL";
106
+ if (c.default !== undefined && c.identity !== "uuid") {
107
+ // For uuid identity we already set DEFAULT gen_random_uuid(); don't duplicate.
108
+ s += ` DEFAULT ${renderDefault(c.default)}`;
109
+ }
110
+ return s;
111
+ }
112
+
113
+ function pgType(t: SqlType): string {
114
+ switch (t.kind) {
115
+ case "text": return t.maxLength !== undefined ? `VARCHAR(${t.maxLength})` : "TEXT";
116
+ case "integer": return t.bits === 64 ? "BIGINT" : "INTEGER";
117
+ case "real": return "DOUBLE PRECISION";
118
+ case "numeric": {
119
+ if (t.precision !== undefined && t.scale !== undefined) return `NUMERIC(${t.precision},${t.scale})`;
120
+ if (t.precision !== undefined) return `NUMERIC(${t.precision})`;
121
+ return "NUMERIC";
122
+ }
123
+ case "boolean": return "BOOLEAN";
124
+ case "timestamp": return t.withTimezone ? "TIMESTAMPTZ" : "TIMESTAMP";
125
+ case "date": return "DATE";
126
+ case "json": return "JSONB";
127
+ case "blob": return "BYTEA";
128
+ case "uuid": return "UUID";
129
+ }
130
+ }
131
+
132
+ function renderDefault(d: ColumnDefault): string {
133
+ if (d.kind === "expr") return d.value;
134
+ // Literal: quote string-form values.
135
+ return `'${d.value.replace(/'/g, "''")}'`;
136
+ }
137
+
138
+ function renderCreateIndex(table: string, schema: string | undefined, ix: IndexDescriptor): string {
139
+ const u = ix.unique ? "UNIQUE " : "";
140
+ // Index name itself is unqualified in CREATE INDEX (Postgres places the index
141
+ // in the same schema as the table being indexed). Only the ON clause needs qualification.
142
+ return `CREATE ${u}INDEX ${quote(ix.name)} ON ${quoteQualified(table, schema)} (${ix.columns.map(quote).join(", ")});`;
143
+ }
144
+
145
+ function renderAddFk(table: string, schema: string | undefined, fk: FkDescriptor): string {
146
+ let s = `ALTER TABLE ${quoteQualified(table, schema)} ADD CONSTRAINT ${quote(fk.name)} `;
147
+ s += `FOREIGN KEY (${fk.columns.map(quote).join(", ")}) `;
148
+ // v1 limitation: FkDescriptor does not carry the ref-table's schema today.
149
+ // Assume the referenced table lives in the same schema as the FK-owner.
150
+ // For cross-schema FKs, add `refSchema?` to FkDescriptor in a follow-up.
151
+ s += `REFERENCES ${quoteQualified(fk.refTable, schema)} (${fk.refColumns.map(quote).join(", ")})`;
152
+ if (fk.onDelete) s += ` ON DELETE ${fkActionSql(fk.onDelete)}`;
153
+ if (fk.onUpdate) s += ` ON UPDATE ${fkActionSql(fk.onUpdate)}`;
154
+ return s + ";";
155
+ }
156
+
157
+ function fkActionSql(a: FkAction): string {
158
+ switch (a) {
159
+ case "cascade": return "CASCADE";
160
+ case "set-null": return "SET NULL";
161
+ case "restrict": return "RESTRICT";
162
+ case "no-action": return "NO ACTION";
163
+ }
164
+ }
165
+
166
+ function quote(ident: string): string {
167
+ // Conservative double-quoting; reject embedded quotes (defense).
168
+ if (ident.includes('"')) throw new Error(`unsafe identifier: ${ident}`);
169
+ return `"${ident}"`;
170
+ }
171
+
172
+ /**
173
+ * Quote a table identifier, prefixing the schema when non-default. The Postgres
174
+ * default schema is `public`; undefined and "public" both mean "no prefix needed."
175
+ */
176
+ function quoteQualified(table: string, schema: string | undefined): string {
177
+ if (!schema || schema === DEFAULT_DB_SCHEMA_POSTGRES) return quote(table);
178
+ return quote(schema) + "." + quote(table);
179
+ }
180
+
181
+ /**
182
+ * Quote an index identifier for DROP INDEX, prefixing the schema when non-default.
183
+ * In Postgres, indexes live in the same schema as their owning table; DROP INDEX
184
+ * accepts the qualified form `"schema"."index"`.
185
+ */
186
+ function quoteIndexQualified(index: string, schema: string | undefined): string {
187
+ if (!schema || schema === DEFAULT_DB_SCHEMA_POSTGRES) return quote(index);
188
+ return quote(schema) + "." + quote(index);
189
+ }