@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.
- package/LICENSE +189 -0
- package/README.md +73 -0
- package/dist/diff/index.d.ts +30 -0
- package/dist/diff/index.d.ts.map +1 -0
- package/dist/diff/index.js +226 -0
- package/dist/diff/index.js.map +1 -0
- package/dist/diff/rename-heuristic.d.ts +23 -0
- package/dist/diff/rename-heuristic.d.ts.map +1 -0
- package/dist/diff/rename-heuristic.js +236 -0
- package/dist/diff/rename-heuristic.js.map +1 -0
- package/dist/diff/status.d.ts +8 -0
- package/dist/diff/status.d.ts.map +1 -0
- package/dist/diff/status.js +53 -0
- package/dist/diff/status.js.map +1 -0
- package/dist/emit/index.d.ts +17 -0
- package/dist/emit/index.d.ts.map +1 -0
- package/dist/emit/index.js +18 -0
- package/dist/emit/index.js.map +1 -0
- package/dist/emit/postgres.d.ts +3 -0
- package/dist/emit/postgres.d.ts.map +1 -0
- package/dist/emit/postgres.js +181 -0
- package/dist/emit/postgres.js.map +1 -0
- package/dist/emit/sqlite.d.ts +3 -0
- package/dist/emit/sqlite.d.ts.map +1 -0
- package/dist/emit/sqlite.js +302 -0
- package/dist/emit/sqlite.js.map +1 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +54 -0
- package/dist/errors.js.map +1 -0
- package/dist/expected-schema.d.ts +15 -0
- package/dist/expected-schema.d.ts.map +1 -0
- package/dist/expected-schema.js +243 -0
- package/dist/expected-schema.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/introspect/index.d.ts +6 -0
- package/dist/introspect/index.d.ts.map +1 -0
- package/dist/introspect/index.js +11 -0
- package/dist/introspect/index.js.map +1 -0
- package/dist/introspect/postgres.d.ts +57 -0
- package/dist/introspect/postgres.d.ts.map +1 -0
- package/dist/introspect/postgres.js +339 -0
- package/dist/introspect/postgres.js.map +1 -0
- package/dist/introspect/sqlite.d.ts +4 -0
- package/dist/introspect/sqlite.d.ts.map +1 -0
- package/dist/introspect/sqlite.js +192 -0
- package/dist/introspect/sqlite.js.map +1 -0
- package/dist/source-aware-diff.d.ts +20 -0
- package/dist/source-aware-diff.d.ts.map +1 -0
- package/dist/source-aware-diff.js +24 -0
- package/dist/source-aware-diff.js.map +1 -0
- package/dist/sql-type.d.ts +45 -0
- package/dist/sql-type.d.ts.map +1 -0
- package/dist/sql-type.js +76 -0
- package/dist/sql-type.js.map +1 -0
- package/dist/types.d.ts +223 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/view-ddl-postgres.d.ts +4 -0
- package/dist/view-ddl-postgres.d.ts.map +1 -0
- package/dist/view-ddl-postgres.js +13 -0
- package/dist/view-ddl-postgres.js.map +1 -0
- package/dist/view-ddl-sqlite.d.ts +3 -0
- package/dist/view-ddl-sqlite.d.ts.map +1 -0
- package/dist/view-ddl-sqlite.js +7 -0
- package/dist/view-ddl-sqlite.js.map +1 -0
- package/dist/view-diff.d.ts +13 -0
- package/dist/view-diff.d.ts.map +1 -0
- package/dist/view-diff.js +42 -0
- package/dist/view-diff.js.map +1 -0
- package/dist/write-migration.d.ts +19 -0
- package/dist/write-migration.d.ts.map +1 -0
- package/dist/write-migration.js +34 -0
- package/dist/write-migration.js.map +1 -0
- package/package.json +50 -0
- package/src/diff/index.ts +294 -0
- package/src/diff/rename-heuristic.ts +265 -0
- package/src/diff/status.ts +55 -0
- package/src/emit/index.ts +38 -0
- package/src/emit/postgres.ts +189 -0
- package/src/emit/sqlite.ts +322 -0
- package/src/errors.ts +58 -0
- package/src/expected-schema.ts +326 -0
- package/src/index.ts +49 -0
- package/src/introspect/index.ts +14 -0
- package/src/introspect/postgres.ts +428 -0
- package/src/introspect/sqlite.ts +216 -0
- package/src/source-aware-diff.ts +49 -0
- package/src/sql-type.ts +91 -0
- package/src/types.ts +174 -0
- package/src/view-ddl-postgres.ts +15 -0
- package/src/view-ddl-sqlite.ts +7 -0
- package/src/view-diff.ts +55 -0
- 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
|
+
}
|