@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,428 @@
1
+ /**
2
+ * Postgres introspection — stage 2 of the migration pipeline.
3
+ *
4
+ * Produces a SchemaSnapshot from a live Kysely<Record<string, unknown>> pointing at a Postgres
5
+ * (or pg-mem) database.
6
+ *
7
+ * Design notes:
8
+ * - We deliberately avoid Kysely's built-in db.introspection.getTables() because
9
+ * it uses the `!~` regex operator in its internal query, which pg-mem (v3) does
10
+ * not support. We read information_schema directly via raw SQL instead.
11
+ * - Primary keys come from information_schema.table_constraints +
12
+ * key_column_usage. Real Postgres returns rows; pg-mem (v3) returns empty rows
13
+ * for both views — so PK tests are gated on MIGRATE_TS_PG_URL in the test file.
14
+ * - Default values come from information_schema.columns.column_default. pg-mem
15
+ * always returns null for this column, so default tests are gated too.
16
+ * - Type normalization (pgTypeToSqlType) is exported so it can be unit-tested
17
+ * without a live DB.
18
+ *
19
+ * pg-mem gaps (documented here, gated in the test file):
20
+ * - information_schema.columns.character_maximum_length → always null
21
+ * - information_schema.columns.column_default → always null
22
+ * - information_schema.table_constraints / key_column_usage → empty rows
23
+ * - bigserial appears as "integer" (no sequence differentiation)
24
+ * - array_position() not implemented → pg_index catalog query throws;
25
+ * readPgIndexes() catches and returns [] on pg-mem
26
+ * - information_schema.referential_constraints not supported →
27
+ * readPgForeignKeys() catches and returns [] on pg-mem
28
+ */
29
+ import type { Kysely } from "kysely";
30
+ import { sql } from "kysely";
31
+ import type { SchemaSnapshot, TableDescriptor, ColumnDescriptor, ColumnDefault, IndexDescriptor, FkDescriptor, FkAction, ViewDescriptor } from "../types.js";
32
+ import type { SqlType } from "../sql-type.js";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Public API
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export async function introspectPostgres(db: Kysely<Record<string, unknown>>): Promise<SchemaSnapshot> {
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ const k = db as Kysely<any>;
41
+
42
+ const tableRefs = await readTableNames(k);
43
+ const tables: TableDescriptor[] = [];
44
+
45
+ for (const { schema, name } of tableRefs) {
46
+ const columns = await readColumns(k, schema, name);
47
+ const primaryKey = await readPrimaryKey(k, schema, name);
48
+ tables.push({
49
+ name,
50
+ schema,
51
+ columns,
52
+ indexes: await readPgIndexes(k, schema, name),
53
+ foreignKeys: await readPgForeignKeys(k, schema, name),
54
+ primaryKey,
55
+ });
56
+ }
57
+
58
+ const views = await readPgViews(k);
59
+ return {
60
+ tables,
61
+ views,
62
+ };
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Helpers — all exported so they can be tested in isolation
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Normalise a PG column data type string into a canonical SqlType.
71
+ * The `dataType` string comes from information_schema.columns.data_type (or
72
+ * occasionally from information_schema.columns.udt_name). Both are lower-cased
73
+ * before matching.
74
+ *
75
+ * If character_maximum_length is available, callers should pass `maxLength`.
76
+ */
77
+ export function pgTypeToSqlType(dataType: string, maxLength?: number | null): SqlType {
78
+ const dt = dataType.toLowerCase().trim();
79
+
80
+ // Length-bearing text types — may arrive as "character varying(255)" (full
81
+ // inline) or as bare "character varying" with maxLength from a separate column.
82
+ const varcharMatch = /^(?:character varying|varchar)\((\d+)\)$/.exec(dt);
83
+ if (varcharMatch) {
84
+ return { kind: "text", maxLength: parseInt(varcharMatch[1] ?? "0", 10) };
85
+ }
86
+ if (dt === "character varying" || dt === "varchar") {
87
+ return maxLength != null ? { kind: "text", maxLength } : { kind: "text" };
88
+ }
89
+ if (dt === "text") {
90
+ return { kind: "text" };
91
+ }
92
+
93
+ // Integer types — serial variants are also covered here; callers supply
94
+ // identity=increment separately via a sequence check.
95
+ if (dt === "int8" || dt === "bigint" || dt === "bigserial") {
96
+ return { kind: "integer", bits: 64 };
97
+ }
98
+ if (
99
+ dt === "int4" || dt === "integer" || dt === "serial" ||
100
+ dt === "int2" || dt === "smallint" || dt === "smallserial" ||
101
+ dt === "int"
102
+ ) {
103
+ return { kind: "integer", bits: 32 };
104
+ }
105
+
106
+ // Floating-point
107
+ if (dt === "float4" || dt === "real") return { kind: "real" };
108
+ if (dt === "float8" || dt === "double precision") return { kind: "real" };
109
+
110
+ // Arbitrary-precision numeric — "numeric(p,s)" or bare "numeric"/"decimal"
111
+ const numMatch = /^(?:numeric|decimal)(?:\((\d+)(?:,\s*(\d+))?\))?$/.exec(dt);
112
+ if (numMatch) {
113
+ const out: SqlType = { kind: "numeric" };
114
+ if (numMatch[1]) out.precision = parseInt(numMatch[1], 10);
115
+ if (numMatch[2]) out.scale = parseInt(numMatch[2], 10);
116
+ return out;
117
+ }
118
+
119
+ // Boolean
120
+ if (dt === "bool" || dt === "boolean") return { kind: "boolean" };
121
+
122
+ // Date + time
123
+ if (dt === "date") return { kind: "date" };
124
+ if (dt === "timestamp" || dt === "timestamp without time zone") {
125
+ return { kind: "timestamp", withTimezone: false };
126
+ }
127
+ if (dt === "timestamptz" || dt === "timestamp with time zone") {
128
+ return { kind: "timestamp", withTimezone: true };
129
+ }
130
+
131
+ // JSON
132
+ if (dt === "json" || dt === "jsonb") return { kind: "json" };
133
+
134
+ // Binary
135
+ if (dt === "bytea") return { kind: "blob" };
136
+
137
+ // UUID
138
+ if (dt === "uuid") return { kind: "uuid" };
139
+
140
+ // Unknown types (user-defined enums, citext, ltree, etc.) fall back to text
141
+ // so we don't blow up on unrecognised types.
142
+ return { kind: "text" };
143
+ }
144
+
145
+ /**
146
+ * Parse a raw PG column_default string into a ColumnDefault.
147
+ * Returns undefined if the default is absent or empty.
148
+ *
149
+ * Classification rules:
150
+ * - Expressions: now(), CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME,
151
+ * nextval(...), and any value that starts with a non-quote character and
152
+ * contains a `::` cast (i.e. bare identifier with cast, like `NULL::text`).
153
+ * - Literals: `'value'` (optionally followed by `::type` cast, which PG
154
+ * commonly appends for clarity). The cast is stripped; the value is unquoted.
155
+ *
156
+ * PG stores literal booleans as `'true'::boolean`, integers as `'42'::integer`,
157
+ * strings as `'hello'::text` — all are literals after stripping the cast.
158
+ */
159
+ export function parsePgDefault(raw: string | null | undefined): ColumnDefault | undefined {
160
+ if (raw === undefined || raw === null || raw === "") return undefined;
161
+
162
+ // Function-call or keyword expressions
163
+ if (
164
+ /^now\(\)$/i.test(raw) ||
165
+ /^current_timestamp\b/i.test(raw) ||
166
+ /^current_date\b/i.test(raw) ||
167
+ /^current_time\b/i.test(raw) ||
168
+ /^nextval\(/i.test(raw)
169
+ ) {
170
+ return { kind: "expr", value: raw };
171
+ }
172
+
173
+ // If it starts with a single-quote, it's a quoted literal (possibly with
174
+ // a trailing ::type cast that PG appends for type clarity).
175
+ if (raw.startsWith("'")) {
176
+ // Strip the cast suffix (e.g. `::boolean`, `::text`, `::integer`)
177
+ const withoutCast = raw.replace(/::[^']+$/, "");
178
+ // Strip the surrounding single-quotes
179
+ const cleaned = withoutCast.replace(/^'(.*)'$/, "$1");
180
+ return { kind: "literal", value: cleaned };
181
+ }
182
+
183
+ // Anything else that contains a :: cast is a complex expression
184
+ // (e.g. NULL::text, ARRAY[]::text[])
185
+ if (/::/g.test(raw)) {
186
+ return { kind: "expr", value: raw };
187
+ }
188
+
189
+ // Bare literal (no quotes, no cast)
190
+ return { kind: "literal", value: raw };
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Internal — raw SQL queries
195
+ // ---------------------------------------------------------------------------
196
+
197
+ interface SchemaTableRef {
198
+ schema: string;
199
+ name: string;
200
+ }
201
+
202
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
203
+ async function readTableNames(k: Kysely<any>): Promise<SchemaTableRef[]> {
204
+ const rows = await sql<{ table_name: string; table_schema: string }>`
205
+ SELECT table_name, table_schema
206
+ FROM information_schema.tables
207
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
208
+ AND table_schema NOT LIKE 'pg_%'
209
+ AND table_type = 'BASE TABLE'
210
+ ORDER BY table_schema, table_name
211
+ `.execute(k);
212
+ return rows.rows.map((r) => ({ schema: r.table_schema, name: r.table_name }));
213
+ }
214
+
215
+ async function readPgViews(k: RawKysely): Promise<ViewDescriptor[]> {
216
+ // pg-mem gap: information_schema.views is not supported — the query throws
217
+ // "relation views does not exist". We catch and return [] so other tests
218
+ // still pass on pg-mem. Real PG (Postgres 16) handles this correctly.
219
+ try {
220
+ const rows = await sql<{ table_name: string; table_schema: string }>`
221
+ SELECT table_name, table_schema FROM information_schema.views
222
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
223
+ AND table_schema NOT LIKE 'pg_%'
224
+ ORDER BY table_schema, table_name
225
+ `.execute(k);
226
+ return rows.rows.map((r) => ({ name: r.table_name, schema: r.table_schema }));
227
+ } catch {
228
+ // pg-mem: information_schema.views not supported — return empty view list.
229
+ return [];
230
+ }
231
+ }
232
+
233
+ interface RawColumn {
234
+ column_name: string;
235
+ data_type: string;
236
+ udt_name: string;
237
+ character_maximum_length: number | null;
238
+ is_nullable: string; // 'YES' | 'NO'
239
+ column_default: string | null;
240
+ }
241
+
242
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
243
+ async function readColumns(k: Kysely<any>, schema: string, tableName: string): Promise<ColumnDescriptor[]> {
244
+ const rows = await sql<RawColumn>`
245
+ SELECT
246
+ column_name,
247
+ data_type,
248
+ udt_name,
249
+ character_maximum_length,
250
+ is_nullable,
251
+ column_default
252
+ FROM information_schema.columns
253
+ WHERE table_schema = ${schema}
254
+ AND table_name = ${tableName}
255
+ ORDER BY ordinal_position
256
+ `.execute(k);
257
+
258
+ return rows.rows.map((r) => {
259
+ const sqlType = pgTypeToSqlType(r.data_type, r.character_maximum_length);
260
+ const col: ColumnDescriptor = {
261
+ name: r.column_name,
262
+ sqlType,
263
+ nullable: r.is_nullable === "YES",
264
+ };
265
+
266
+ const def = parsePgDefault(r.column_default);
267
+ if (def !== undefined) col.default = def;
268
+
269
+ // Detect auto-increment (sequence) columns — real PG surfaces bigserial /
270
+ // serial as nextval(...) in column_default. We also check udt_name for
271
+ // explicit serial type names as a belt-and-suspenders guard.
272
+ const isSerial =
273
+ (r.column_default !== null && /^nextval\(/i.test(r.column_default)) ||
274
+ /^(?:bigserial|serial8|serial4|serial|smallserial|serial2)$/i.test(r.udt_name);
275
+ if (isSerial) col.identity = "increment";
276
+
277
+ return col;
278
+ });
279
+ }
280
+
281
+ // Typed alias for raw Kysely — avoids per-call `as any` casts in the helpers below.
282
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
283
+ type RawKysely = Kysely<any>;
284
+
285
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
286
+ async function readPrimaryKey(k: Kysely<any>, schema: string, tableName: string): Promise<string[]> {
287
+ // Uses information_schema only — avoids pg_attribute / pg_constraint joins
288
+ // that are either missing or return empty in pg-mem.
289
+ const rows = await sql<{ column_name: string; ordinal_position: number }>`
290
+ SELECT kcu.column_name, kcu.ordinal_position
291
+ FROM information_schema.table_constraints tc
292
+ JOIN information_schema.key_column_usage kcu
293
+ ON kcu.constraint_name = tc.constraint_name
294
+ AND kcu.table_schema = tc.table_schema
295
+ AND kcu.table_name = tc.table_name
296
+ WHERE tc.constraint_type = 'PRIMARY KEY'
297
+ AND tc.table_schema = ${schema}
298
+ AND tc.table_name = ${tableName}
299
+ ORDER BY kcu.ordinal_position
300
+ `.execute(k);
301
+ return rows.rows.map((r) => r.column_name);
302
+ }
303
+
304
+ async function readPgIndexes(k: RawKysely, schema: string, table: string): Promise<IndexDescriptor[]> {
305
+ // pg-mem gap: array_position() is not implemented, so this query throws on
306
+ // pg-mem. We catch and return [] so non-index tests still pass against pg-mem.
307
+ // Real PG (Postgres 16) handles this correctly.
308
+ let rows: { rows: Array<{ index_name: string; is_unique: boolean; is_primary: boolean; column_name: string; ordinal: number }> };
309
+ try {
310
+ rows = await sql<{
311
+ index_name: string;
312
+ is_unique: boolean;
313
+ is_primary: boolean;
314
+ column_name: string;
315
+ ordinal: number;
316
+ }>`
317
+ SELECT i.relname AS index_name,
318
+ ix.indisunique AS is_unique,
319
+ ix.indisprimary AS is_primary,
320
+ a.attname AS column_name,
321
+ array_position(ix.indkey, a.attnum) AS ordinal
322
+ FROM pg_index ix
323
+ JOIN pg_class i ON i.oid = ix.indexrelid
324
+ JOIN pg_class t ON t.oid = ix.indrelid
325
+ JOIN pg_namespace n ON n.oid = t.relnamespace
326
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
327
+ WHERE n.nspname = ${schema}
328
+ AND t.relname = ${table}
329
+ ORDER BY i.relname, ordinal
330
+ `.execute(k);
331
+ } catch {
332
+ // pg-mem: array_position() not implemented — return empty index list.
333
+ return [];
334
+ }
335
+
336
+ const byName = new Map<string, { isUnique: boolean; isPrimary: boolean; cols: string[] }>();
337
+ for (const r of rows.rows) {
338
+ let entry = byName.get(r.index_name);
339
+ if (!entry) {
340
+ entry = { isUnique: r.is_unique, isPrimary: r.is_primary, cols: [] };
341
+ byName.set(r.index_name, entry);
342
+ }
343
+ entry.cols.push(r.column_name);
344
+ }
345
+ return Array.from(byName.entries())
346
+ .filter(([, v]) => !v.isPrimary) // PK index excluded — PK lives in TableDescriptor.primaryKey
347
+ .map(([name, v]) => ({ name, columns: v.cols, unique: v.isUnique }));
348
+ }
349
+
350
+ async function readPgForeignKeys(k: RawKysely, schema: string, table: string): Promise<FkDescriptor[]> {
351
+ // pg-mem gap: information_schema.referential_constraints is not supported —
352
+ // the query returns empty rows or throws. We catch and return [] so other
353
+ // tests still pass on pg-mem. Real PG (Postgres 16) handles this correctly.
354
+ let rows: { rows: Array<{ fk_name: string; column_name: string; ref_table: string; ref_column: string; update_rule: string; delete_rule: string; ordinal: number }> };
355
+ try {
356
+ rows = await sql<{
357
+ fk_name: string;
358
+ column_name: string;
359
+ ref_table: string;
360
+ ref_column: string;
361
+ update_rule: string;
362
+ delete_rule: string;
363
+ ordinal: number;
364
+ }>`
365
+ SELECT tc.constraint_name AS fk_name,
366
+ kcu.column_name,
367
+ ccu.table_name AS ref_table,
368
+ ccu.column_name AS ref_column,
369
+ rc.update_rule,
370
+ rc.delete_rule,
371
+ kcu.ordinal_position AS ordinal
372
+ FROM information_schema.table_constraints tc
373
+ JOIN information_schema.key_column_usage kcu
374
+ ON kcu.constraint_name = tc.constraint_name
375
+ AND kcu.table_schema = tc.table_schema
376
+ JOIN information_schema.referential_constraints rc
377
+ ON rc.constraint_name = tc.constraint_name
378
+ AND rc.constraint_schema = tc.table_schema
379
+ JOIN information_schema.constraint_column_usage ccu
380
+ ON ccu.constraint_name = tc.constraint_name
381
+ AND ccu.table_schema = tc.table_schema
382
+ WHERE tc.constraint_type = 'FOREIGN KEY'
383
+ AND tc.table_schema = ${schema}
384
+ AND tc.table_name = ${table}
385
+ ORDER BY tc.constraint_name, kcu.ordinal_position
386
+ `.execute(k);
387
+ } catch {
388
+ // pg-mem: referential_constraints not supported — return empty FK list.
389
+ return [];
390
+ }
391
+
392
+ const byName = new Map<string, {
393
+ cols: string[]; refTable: string; refCols: string[];
394
+ onDelete: FkAction; onUpdate: FkAction;
395
+ }>();
396
+ for (const r of rows.rows) {
397
+ let entry = byName.get(r.fk_name);
398
+ if (!entry) {
399
+ entry = {
400
+ cols: [], refTable: r.ref_table, refCols: [],
401
+ onDelete: pgRuleToAction(r.delete_rule),
402
+ onUpdate: pgRuleToAction(r.update_rule),
403
+ };
404
+ byName.set(r.fk_name, entry);
405
+ }
406
+ entry.cols.push(r.column_name);
407
+ entry.refCols.push(r.ref_column);
408
+ }
409
+ return Array.from(byName.entries()).map(([name, v]) => {
410
+ const fk: FkDescriptor = {
411
+ name,
412
+ columns: v.cols,
413
+ refTable: v.refTable,
414
+ refColumns: v.refCols,
415
+ };
416
+ if (v.onDelete !== "no-action") fk.onDelete = v.onDelete;
417
+ if (v.onUpdate !== "no-action") fk.onUpdate = v.onUpdate;
418
+ return fk;
419
+ });
420
+ }
421
+
422
+ function pgRuleToAction(rule: string): FkAction {
423
+ const r = rule.toUpperCase();
424
+ if (r === "CASCADE") return "cascade";
425
+ if (r === "SET NULL") return "set-null";
426
+ if (r === "RESTRICT") return "restrict";
427
+ return "no-action";
428
+ }
@@ -0,0 +1,216 @@
1
+ import type { Kysely } from "kysely";
2
+ import { sql } from "kysely";
3
+ import type {
4
+ SchemaSnapshot, TableDescriptor, ColumnDescriptor, ColumnDefault, SnapshotMeta,
5
+ IndexDescriptor, FkDescriptor, FkAction, ViewDescriptor,
6
+ } from "../types.js";
7
+ import type { SqlType } from "../sql-type.js";
8
+
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ type RawKysely = Kysely<any>;
11
+
12
+ export async function introspectSqlite(db: Kysely<Record<string, unknown>>): Promise<SchemaSnapshot> {
13
+ const k = db as RawKysely;
14
+
15
+ const versionRow = await sql<{ v: string }>`SELECT sqlite_version() AS v`.execute(k);
16
+ const meta: SnapshotMeta = { sqliteVersion: versionRow.rows[0]?.v ?? "0.0.0" };
17
+
18
+ const tableNamesRows = await sql<{ name: string; sql: string | null }>`
19
+ SELECT name, sql FROM sqlite_master
20
+ WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__new_%'
21
+ ORDER BY name
22
+ `.execute(k);
23
+
24
+ const tables: TableDescriptor[] = [];
25
+ for (const t of tableNamesRows.rows) {
26
+ const cols = await readSqliteColumns(k, t.name);
27
+ const pk = await readSqlitePrimaryKey(k, t.name);
28
+ // Detect AUTOINCREMENT via the CREATE TABLE statement (sqlite has no PRAGMA for it).
29
+ const hasAutoincrement = (t.sql ?? "").toUpperCase().includes("AUTOINCREMENT");
30
+ if (hasAutoincrement && pk.length === 1) {
31
+ const pkCol = cols.find((c) => c.name === pk[0]);
32
+ if (pkCol) pkCol.identity = "increment";
33
+ }
34
+ tables.push({
35
+ name: t.name,
36
+ columns: cols,
37
+ indexes: await readSqliteIndexes(k, t.name),
38
+ foreignKeys: await readSqliteForeignKeys(k, t.name),
39
+ primaryKey: pk,
40
+ });
41
+ }
42
+
43
+ const views = await readSqliteViews(k);
44
+ return { tables, views, meta };
45
+ }
46
+
47
+ async function readSqliteViews(k: RawKysely): Promise<ViewDescriptor[]> {
48
+ const rows = await sql<{ name: string }>`
49
+ SELECT name FROM sqlite_master WHERE type='view' AND name NOT LIKE 'sqlite_%'
50
+ ORDER BY name
51
+ `.execute(k);
52
+ return rows.rows.map((r) => ({ name: r.name }));
53
+ }
54
+
55
+ async function readSqliteColumns(k: RawKysely, table: string): Promise<ColumnDescriptor[]> {
56
+ // SELECT * avoids "notnull" being treated as a reserved keyword by libsql.
57
+ const rows = await sql<{
58
+ cid: number;
59
+ name: string;
60
+ type: string;
61
+ notnull: number;
62
+ dflt_value: string | null;
63
+ pk: number;
64
+ }>`SELECT * FROM pragma_table_info(${table}) ORDER BY cid`.execute(k);
65
+
66
+ return rows.rows.map((r) => {
67
+ const col: ColumnDescriptor = {
68
+ name: r.name,
69
+ sqlType: sqliteTypeToSqlType(r.type),
70
+ nullable: r.notnull === 0 && r.pk === 0,
71
+ };
72
+ const def = parseSqliteDefault(r.dflt_value);
73
+ if (def) col.default = def;
74
+ return col;
75
+ });
76
+ }
77
+
78
+ async function readSqlitePrimaryKey(k: RawKysely, table: string): Promise<string[]> {
79
+ // SELECT * avoids the "notnull" reserved-keyword issue in libsql.
80
+ const rows = await sql<{ name: string; pk: number }>`
81
+ SELECT * FROM pragma_table_info(${table}) WHERE pk > 0 ORDER BY pk
82
+ `.execute(k);
83
+ return rows.rows.map((r) => r.name);
84
+ }
85
+
86
+ function sqliteTypeToSqlType(declaredType: string): SqlType {
87
+ const t = declaredType.trim().toUpperCase();
88
+
89
+ // SQLite's type affinity is loose; we honor the declared type literally for round-trip stability.
90
+ // Affinity rules per sqlite.org/datatype3.html — adapted to canonical SqlType.
91
+
92
+ // text affinity
93
+ const varcharMatch = /^(?:VARCHAR|CHAR|CHARACTER|TEXT)\((\d+)\)$/.exec(t);
94
+ if (varcharMatch) return { kind: "text", maxLength: parseInt(varcharMatch[1] ?? "0", 10) };
95
+ if (/TEXT|CLOB|VARCHAR|CHAR/.test(t)) return { kind: "text" };
96
+
97
+ // numeric affinity
98
+ const numMatch = /^(?:NUMERIC|DECIMAL)\((\d+)(?:,\s*(\d+))?\)$/.exec(t);
99
+ if (numMatch) {
100
+ const out: SqlType = { kind: "numeric" };
101
+ if (numMatch[1]) out.precision = parseInt(numMatch[1], 10);
102
+ if (numMatch[2]) out.scale = parseInt(numMatch[2], 10);
103
+ return out;
104
+ }
105
+ if (t === "BOOLEAN" || t === "BOOL") return { kind: "boolean" };
106
+ if (t === "DATE") return { kind: "date" };
107
+ if (t === "DATETIME" || t === "TIMESTAMP") return { kind: "timestamp", withTimezone: false };
108
+
109
+ // integer affinity (SQLite stores all INTEGER as 64-bit internally).
110
+ // Distinguish INT (32-bit) from INTEGER/BIGINT (64-bit) for round-trip fidelity:
111
+ // the emitter uses "INT" for integer{32} and "INTEGER" for integer{64}.
112
+ if (t === "INT" || t === "SMALLINT" || t === "TINYINT") return { kind: "integer", bits: 32 };
113
+ if (/INT/.test(t)) return { kind: "integer", bits: 64 };
114
+
115
+ // real affinity
116
+ if (/REAL|FLOA|DOUB/.test(t)) return { kind: "real" };
117
+
118
+ // blob affinity
119
+ if (t === "BLOB" || t === "") return { kind: "blob" };
120
+
121
+ // numeric affinity fallback
122
+ if (/NUMERIC|DECIMAL/.test(t)) return { kind: "numeric" };
123
+
124
+ // json (libsql/sqlite have JSON1)
125
+ if (t === "JSON") return { kind: "json" };
126
+
127
+ return { kind: "text" };
128
+ }
129
+
130
+ const SQLITE_EXPR_DEFAULT_PATTERNS = [
131
+ /^current_timestamp$/i,
132
+ /^current_date$/i,
133
+ /^current_time$/i,
134
+ /\(.*\)/, // anything function-like
135
+ ];
136
+
137
+ function parseSqliteDefault(raw: string | null): ColumnDefault | undefined {
138
+ if (raw === null || raw === undefined || raw === "") return undefined;
139
+ const isExpr = SQLITE_EXPR_DEFAULT_PATTERNS.some((re) => re.test(raw));
140
+ if (isExpr) return { kind: "expr", value: raw };
141
+ // SQLite stores literal string defaults with surrounding quotes.
142
+ const cleaned = raw.replace(/^'(.*)'$/, "$1");
143
+ return { kind: "literal", value: cleaned };
144
+ }
145
+
146
+ async function readSqliteIndexes(k: RawKysely, table: string): Promise<IndexDescriptor[]> {
147
+ // SELECT * avoids "unique" being treated as a reserved keyword by libsql.
148
+ const listRows = await sql<{ seq: number; name: string; unique: number; origin: string; partial: number }>`
149
+ SELECT * FROM pragma_index_list(${table})
150
+ `.execute(k);
151
+
152
+ const indexes: IndexDescriptor[] = [];
153
+ for (const ix of listRows.rows) {
154
+ if (ix.origin === "pk") continue; // PK index — excluded (lives in TableDescriptor.primaryKey)
155
+ if (ix.partial === 1) continue; // partial indexes deferred to v0.3
156
+ const cols = await sql<{ seqno: number; cid: number; name: string }>`
157
+ SELECT seqno, cid, name FROM pragma_index_info(${ix.name}) ORDER BY seqno
158
+ `.execute(k);
159
+ indexes.push({
160
+ name: ix.name,
161
+ columns: cols.rows.map((c) => c.name),
162
+ unique: ix.unique === 1,
163
+ });
164
+ }
165
+ return indexes;
166
+ }
167
+
168
+ async function readSqliteForeignKeys(k: RawKysely, table: string): Promise<FkDescriptor[]> {
169
+ // SELECT * avoids reserved-word column names ("table", "from", "to", "match") in libsql.
170
+ const rows = await sql<{
171
+ id: number; seq: number; table: string; from: string; to: string;
172
+ on_update: string; on_delete: string; match: string;
173
+ }>`
174
+ SELECT * FROM pragma_foreign_key_list(${table}) ORDER BY id, seq
175
+ `.execute(k);
176
+
177
+ const byId = new Map<number, {
178
+ refTable: string; cols: string[]; refCols: string[];
179
+ onDelete: FkAction; onUpdate: FkAction;
180
+ }>();
181
+ for (const r of rows.rows) {
182
+ let entry = byId.get(r.id);
183
+ if (!entry) {
184
+ entry = {
185
+ refTable: r.table,
186
+ cols: [],
187
+ refCols: [],
188
+ onDelete: sqliteRuleToAction(r.on_delete),
189
+ onUpdate: sqliteRuleToAction(r.on_update),
190
+ };
191
+ byId.set(r.id, entry);
192
+ }
193
+ entry.cols.push(r.from);
194
+ entry.refCols.push(r.to);
195
+ }
196
+
197
+ return Array.from(byId.entries()).map(([_id, v]) => {
198
+ const fk: FkDescriptor = {
199
+ name: `${table}_${v.cols.join("_")}_fk`, // SQLite has no FK name; synthesize to match expected-schema convention
200
+ columns: v.cols,
201
+ refTable: v.refTable,
202
+ refColumns: v.refCols,
203
+ };
204
+ if (v.onDelete !== "no-action") fk.onDelete = v.onDelete;
205
+ if (v.onUpdate !== "no-action") fk.onUpdate = v.onUpdate;
206
+ return fk;
207
+ });
208
+ }
209
+
210
+ function sqliteRuleToAction(rule: string): FkAction {
211
+ const r = rule.toUpperCase();
212
+ if (r === "CASCADE") return "cascade";
213
+ if (r === "SET NULL") return "set-null";
214
+ if (r === "RESTRICT") return "restrict";
215
+ return "no-action";
216
+ }
@@ -0,0 +1,49 @@
1
+ import { classifyViewDiff, type ViewShape } from "./view-diff.js";
2
+ import { emitPostgresViewMigration } from "./view-ddl-postgres.js";
3
+ import { emitSqliteViewMigration } from "./view-ddl-sqlite.js";
4
+
5
+ export interface ViewMigrationInput {
6
+ readonly viewName: string;
7
+ /** Previous view shape (from migration log / prior generation). undefined = new view. */
8
+ readonly prevShape?: ViewShape;
9
+ readonly nextShape: ViewShape;
10
+ /** Full `CREATE VIEW ... AS ...;` SQL for nextShape. */
11
+ readonly createSql: string;
12
+ }
13
+
14
+ export interface ViewMigrationsOpts {
15
+ readonly dialect: "postgres" | "sqlite";
16
+ readonly allowBreaking: boolean;
17
+ readonly views: readonly ViewMigrationInput[];
18
+ }
19
+
20
+ export interface ViewMigrationsResult {
21
+ readonly migrations: readonly string[];
22
+ readonly errors: readonly string[];
23
+ }
24
+
25
+ export function computeViewMigrations(opts: ViewMigrationsOpts): ViewMigrationsResult {
26
+ const migrations: string[] = [];
27
+ const errors: string[] = [];
28
+
29
+ for (const view of opts.views) {
30
+ const diffClass = view.prevShape
31
+ ? classifyViewDiff(view.prevShape, view.nextShape)
32
+ : "safe-append"; // new view = treated like safe-append
33
+
34
+ if (diffClass === "breaking" && !opts.allowBreaking) {
35
+ errors.push(
36
+ `View ${view.viewName} has a breaking change. Pass --allow-breaking to allow drop+recreate.`
37
+ );
38
+ continue;
39
+ }
40
+
41
+ const emit = opts.dialect === "postgres"
42
+ ? emitPostgresViewMigration
43
+ : emitSqliteViewMigration;
44
+ const sql = emit({ diffClass, viewName: view.viewName, createSql: view.createSql });
45
+ if (sql) migrations.push(sql);
46
+ }
47
+
48
+ return { migrations, errors };
49
+ }