@vibeorm/migrate 1.0.0

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.
@@ -0,0 +1,618 @@
1
+ /**
2
+ * Introspector — PostgreSQL → Schema IR
3
+ *
4
+ * Queries the live database (information_schema + pg_catalog) and
5
+ * produces a Schema IR. The reverse of ddl-builder.ts.
6
+ */
7
+
8
+ import type {
9
+ Schema,
10
+ Model,
11
+ Field,
12
+ ScalarField,
13
+ EnumField,
14
+ RelationField,
15
+ PrimaryKey,
16
+ UniqueConstraint,
17
+ IndexDefinition,
18
+ Enum,
19
+ EnumValue,
20
+ PrismaScalarType,
21
+ PostgresType,
22
+ DefaultValue,
23
+ } from "@vibeorm/parser";
24
+ import { PRISMA_TO_TS, PRISMA_TO_PG } from "@vibeorm/parser";
25
+ import type { SqlExecutor } from "./types.ts";
26
+
27
+ // ─── Reverse Type Mapping ─────────────────────────────────────────
28
+
29
+ const PG_TO_PRISMA: Record<string, PrismaScalarType> = {
30
+ text: "String",
31
+ varchar: "String",
32
+ "character varying": "String",
33
+ char: "String",
34
+ character: "String",
35
+ uuid: "String",
36
+ boolean: "Boolean",
37
+ bool: "Boolean",
38
+ integer: "Int",
39
+ int4: "Int",
40
+ serial: "Int",
41
+ smallint: "Int",
42
+ int2: "Int",
43
+ bigint: "BigInt",
44
+ int8: "BigInt",
45
+ bigserial: "BigInt",
46
+ "double precision": "Float",
47
+ float8: "Float",
48
+ real: "Float",
49
+ float4: "Float",
50
+ decimal: "Decimal",
51
+ numeric: "Decimal",
52
+ timestamp: "DateTime",
53
+ "timestamp without time zone": "DateTime",
54
+ timestamptz: "DateTime",
55
+ "timestamp with time zone": "DateTime",
56
+ jsonb: "Json",
57
+ json: "Json",
58
+ bytea: "Bytes",
59
+ };
60
+
61
+ // ─── Internal Types ───────────────────────────────────────────────
62
+
63
+ type ColumnInfo = {
64
+ tableName: string;
65
+ columnName: string;
66
+ dataType: string;
67
+ udtName: string;
68
+ isNullable: boolean;
69
+ columnDefault: string | null;
70
+ ordinalPosition: number;
71
+ };
72
+
73
+ type ConstraintInfo = {
74
+ constraintName: string;
75
+ constraintType: string; // PRIMARY KEY, UNIQUE, FOREIGN KEY
76
+ tableName: string;
77
+ columnName: string;
78
+ };
79
+
80
+ type ForeignKeyInfo = {
81
+ constraintName: string;
82
+ tableName: string;
83
+ columnName: string;
84
+ foreignTableName: string;
85
+ foreignColumnName: string;
86
+ };
87
+
88
+ type EnumInfo = {
89
+ typeName: string;
90
+ enumValue: string;
91
+ sortOrder: number;
92
+ };
93
+
94
+ type IndexInfo = {
95
+ tableName: string;
96
+ indexName: string;
97
+ indexDef: string;
98
+ isUnique: boolean;
99
+ };
100
+
101
+ // ─── SQL Queries ──────────────────────────────────────────────────
102
+
103
+ const TABLES_QUERY = `
104
+ SELECT table_name
105
+ FROM information_schema.tables
106
+ WHERE table_schema = 'public'
107
+ AND table_type = 'BASE TABLE'
108
+ AND table_name NOT LIKE '_vibeorm_%'
109
+ ORDER BY table_name;
110
+ `;
111
+
112
+ const COLUMNS_QUERY = `
113
+ SELECT
114
+ table_name,
115
+ column_name,
116
+ data_type,
117
+ udt_name,
118
+ is_nullable,
119
+ column_default,
120
+ ordinal_position
121
+ FROM information_schema.columns
122
+ WHERE table_schema = 'public'
123
+ ORDER BY table_name, ordinal_position;
124
+ `;
125
+
126
+ const CONSTRAINTS_QUERY = `
127
+ SELECT
128
+ tc.constraint_name,
129
+ tc.constraint_type,
130
+ tc.table_name,
131
+ kcu.column_name
132
+ FROM information_schema.table_constraints tc
133
+ JOIN information_schema.key_column_usage kcu
134
+ ON tc.constraint_name = kcu.constraint_name
135
+ AND tc.table_schema = kcu.table_schema
136
+ WHERE tc.table_schema = 'public'
137
+ AND tc.constraint_type IN ('PRIMARY KEY', 'UNIQUE')
138
+ ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position;
139
+ `;
140
+
141
+ const FOREIGN_KEYS_QUERY = `
142
+ SELECT
143
+ tc.constraint_name,
144
+ tc.table_name,
145
+ kcu.column_name,
146
+ ccu.table_name AS foreign_table_name,
147
+ ccu.column_name AS foreign_column_name
148
+ FROM information_schema.table_constraints tc
149
+ JOIN information_schema.key_column_usage kcu
150
+ ON tc.constraint_name = kcu.constraint_name
151
+ AND tc.table_schema = kcu.table_schema
152
+ JOIN information_schema.constraint_column_usage ccu
153
+ ON tc.constraint_name = ccu.constraint_name
154
+ AND tc.table_schema = ccu.table_schema
155
+ WHERE tc.table_schema = 'public'
156
+ AND tc.constraint_type = 'FOREIGN KEY'
157
+ ORDER BY tc.table_name, tc.constraint_name;
158
+ `;
159
+
160
+ const ENUMS_QUERY = `
161
+ SELECT
162
+ t.typname AS type_name,
163
+ e.enumlabel AS enum_value,
164
+ e.enumsortorder AS sort_order
165
+ FROM pg_type t
166
+ JOIN pg_enum e ON t.oid = e.enumtypid
167
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
168
+ WHERE n.nspname = 'public'
169
+ ORDER BY t.typname, e.enumsortorder;
170
+ `;
171
+
172
+ const INDEXES_QUERY = `
173
+ SELECT
174
+ tablename AS table_name,
175
+ indexname AS index_name,
176
+ indexdef AS index_def
177
+ FROM pg_indexes
178
+ WHERE schemaname = 'public'
179
+ ORDER BY tablename, indexname;
180
+ `;
181
+
182
+ // ─── Default Value Parsing ────────────────────────────────────────
183
+
184
+ function parseDefault(params: { columnDefault: string | null; dataType: string }): DefaultValue | undefined {
185
+ const { columnDefault, dataType } = params;
186
+ if (!columnDefault) return undefined;
187
+
188
+ const val = columnDefault;
189
+
190
+ // Autoincrement: nextval('table_column_seq'::regclass)
191
+ if (val.includes("nextval(")) {
192
+ return { kind: "autoincrement" };
193
+ }
194
+
195
+ // now() / CURRENT_TIMESTAMP
196
+ if (val === "CURRENT_TIMESTAMP" || val === "now()" || val.startsWith("CURRENT_TIMESTAMP")) {
197
+ return { kind: "now" };
198
+ }
199
+
200
+ // uuid: gen_random_uuid()
201
+ if (val.includes("gen_random_uuid()")) {
202
+ return { kind: "uuid" };
203
+ }
204
+
205
+ // Boolean literals
206
+ if (val === "true" || val === "false") {
207
+ return { kind: "literal", value: val === "true" };
208
+ }
209
+
210
+ // Numeric literals
211
+ if (/^-?\d+(\.\d+)?$/.test(val)) {
212
+ return { kind: "literal", value: Number(val) };
213
+ }
214
+
215
+ // String literals: 'value'::type or 'value'
216
+ const stringMatch = val.match(/^'(.+?)'::/);
217
+ if (stringMatch) {
218
+ return { kind: "literal", value: stringMatch[1]! };
219
+ }
220
+
221
+ // Plain string
222
+ const plainStringMatch = val.match(/^'(.+?)'$/);
223
+ if (plainStringMatch) {
224
+ return { kind: "literal", value: plainStringMatch[1]! };
225
+ }
226
+
227
+ // dbgenerated for anything else
228
+ return { kind: "dbgenerated", value: val };
229
+ }
230
+
231
+ // ─── Implicit M:N Detection ──────────────────────────────────────
232
+
233
+ function isImplicitJoinTable(params: {
234
+ tableName: string;
235
+ columns: ColumnInfo[];
236
+ foreignKeys: ForeignKeyInfo[];
237
+ }): { modelA: string; modelB: string } | null {
238
+ const { tableName, columns, foreignKeys } = params;
239
+
240
+ // Pattern: _ModelAToModelB
241
+ const match = tableName.match(/^_(\w+)To(\w+)$/);
242
+ if (!match) return null;
243
+
244
+ const tableColumns = columns.filter((c) => c.tableName === tableName);
245
+ const tableFKs = foreignKeys.filter((fk) => fk.tableName === tableName);
246
+
247
+ // Must have exactly columns A and B
248
+ const colNames = tableColumns.map((c) => c.columnName).sort();
249
+ if (colNames.length !== 2 || colNames[0] !== "A" || colNames[1] !== "B") return null;
250
+
251
+ // Must have exactly 2 FKs
252
+ if (tableFKs.length !== 2) return null;
253
+
254
+ return { modelA: match[1]!, modelB: match[2]! };
255
+ }
256
+
257
+ // ─── Main Introspect Function ─────────────────────────────────────
258
+
259
+ export async function introspect(params: { executor: SqlExecutor }): Promise<Schema> {
260
+ const { executor } = params;
261
+
262
+ // Run all queries in parallel
263
+ const [tablesRaw, columnsRaw, constraintsRaw, foreignKeysRaw, enumsRaw, indexesRaw] = await Promise.all([
264
+ executor({ text: TABLES_QUERY }),
265
+ executor({ text: COLUMNS_QUERY }),
266
+ executor({ text: CONSTRAINTS_QUERY }),
267
+ executor({ text: FOREIGN_KEYS_QUERY }),
268
+ executor({ text: ENUMS_QUERY }),
269
+ executor({ text: INDEXES_QUERY }),
270
+ ]);
271
+
272
+ // Parse raw results
273
+ const tables = tablesRaw.map((r) => r.table_name as string);
274
+
275
+ const columns: ColumnInfo[] = columnsRaw.map((r) => ({
276
+ tableName: r.table_name as string,
277
+ columnName: r.column_name as string,
278
+ dataType: r.data_type as string,
279
+ udtName: r.udt_name as string,
280
+ isNullable: (r.is_nullable as string) === "YES",
281
+ columnDefault: r.column_default as string | null,
282
+ ordinalPosition: r.ordinal_position as number,
283
+ }));
284
+
285
+ const constraints: ConstraintInfo[] = constraintsRaw.map((r) => ({
286
+ constraintName: r.constraint_name as string,
287
+ constraintType: r.constraint_type as string,
288
+ tableName: r.table_name as string,
289
+ columnName: r.column_name as string,
290
+ }));
291
+
292
+ const foreignKeys: ForeignKeyInfo[] = foreignKeysRaw.map((r) => ({
293
+ constraintName: r.constraint_name as string,
294
+ tableName: r.table_name as string,
295
+ columnName: r.column_name as string,
296
+ foreignTableName: r.foreign_table_name as string,
297
+ foreignColumnName: r.foreign_column_name as string,
298
+ }));
299
+
300
+ const enumInfos: EnumInfo[] = enumsRaw.map((r) => ({
301
+ typeName: r.type_name as string,
302
+ enumValue: r.enum_value as string,
303
+ sortOrder: r.sort_order as number,
304
+ }));
305
+
306
+ const indexInfos: IndexInfo[] = indexesRaw.map((r) => ({
307
+ tableName: r.table_name as string,
308
+ indexName: r.index_name as string,
309
+ indexDef: r.index_def as string,
310
+ isUnique: (r.index_def as string).includes("UNIQUE"),
311
+ }));
312
+
313
+ // ─── Build Enums ────────────────────────────────────────────────
314
+
315
+ const enumMap = new Map<string, EnumValue[]>();
316
+ for (const ei of enumInfos) {
317
+ if (!enumMap.has(ei.typeName)) {
318
+ enumMap.set(ei.typeName, []);
319
+ }
320
+ enumMap.get(ei.typeName)!.push({ name: ei.enumValue, dbName: undefined });
321
+ }
322
+
323
+ const enums: Enum[] = [];
324
+ for (const [name, values] of enumMap) {
325
+ enums.push({ name, dbName: undefined, values });
326
+ }
327
+
328
+ const enumNames = new Set(enumMap.keys());
329
+
330
+ // ─── Detect join tables ─────────────────────────────────────────
331
+
332
+ const joinTables = new Map<string, { modelA: string; modelB: string }>();
333
+ for (const tableName of tables) {
334
+ const result = isImplicitJoinTable({ tableName, columns, foreignKeys });
335
+ if (result) {
336
+ joinTables.set(tableName, result);
337
+ }
338
+ }
339
+
340
+ // ─── Build constraints maps ─────────────────────────────────────
341
+
342
+ // Primary keys: tableName → column names
343
+ const primaryKeys = new Map<string, string[]>();
344
+ for (const c of constraints) {
345
+ if (c.constraintType !== "PRIMARY KEY") continue;
346
+ if (!primaryKeys.has(c.tableName)) primaryKeys.set(c.tableName, []);
347
+ primaryKeys.get(c.tableName)!.push(c.columnName);
348
+ }
349
+
350
+ // Unique constraints: tableName → array of { constraintName, columns }
351
+ const uniqueConstraints = new Map<string, Map<string, string[]>>();
352
+ for (const c of constraints) {
353
+ if (c.constraintType !== "UNIQUE") continue;
354
+ if (!uniqueConstraints.has(c.tableName)) uniqueConstraints.set(c.tableName, new Map());
355
+ const tableUniques = uniqueConstraints.get(c.tableName)!;
356
+ if (!tableUniques.has(c.constraintName)) tableUniques.set(c.constraintName, []);
357
+ tableUniques.get(c.constraintName)!.push(c.columnName);
358
+ }
359
+
360
+ // FK columns with unique constraints (for 1:1 detection)
361
+ const uniqueColumns = new Set<string>();
362
+ for (const [tableName, tableUniques] of uniqueConstraints) {
363
+ for (const [, cols] of tableUniques) {
364
+ if (cols.length === 1) {
365
+ uniqueColumns.add(`${tableName}.${cols[0]}`);
366
+ }
367
+ }
368
+ }
369
+
370
+ // Group foreign keys by constraint name
371
+ const fkByConstraint = new Map<string, ForeignKeyInfo[]>();
372
+ for (const fk of foreignKeys) {
373
+ if (!fkByConstraint.has(fk.constraintName)) fkByConstraint.set(fk.constraintName, []);
374
+ fkByConstraint.get(fk.constraintName)!.push(fk);
375
+ }
376
+
377
+ // ─── Build Models ───────────────────────────────────────────────
378
+
379
+ const models: Model[] = [];
380
+
381
+ for (const tableName of tables) {
382
+ // Skip join tables
383
+ if (joinTables.has(tableName)) continue;
384
+
385
+ const tableColumns = columns.filter((c) => c.tableName === tableName);
386
+ const tablePK = primaryKeys.get(tableName) ?? [];
387
+ const tableUniques = uniqueConstraints.get(tableName) ?? new Map<string, string[]>();
388
+ const tableFKs = foreignKeys.filter((fk) => fk.tableName === tableName);
389
+
390
+ // Group FKs by constraint for multi-column FK support
391
+ const fkGroups = new Map<string, { localCols: string[]; foreignTable: string; foreignCols: string[] }>();
392
+ for (const fk of tableFKs) {
393
+ if (!fkGroups.has(fk.constraintName)) {
394
+ fkGroups.set(fk.constraintName, {
395
+ localCols: [],
396
+ foreignTable: fk.foreignTableName,
397
+ foreignCols: [],
398
+ });
399
+ }
400
+ const group = fkGroups.get(fk.constraintName)!;
401
+ group.localCols.push(fk.columnName);
402
+ group.foreignCols.push(fk.foreignColumnName);
403
+ }
404
+
405
+ const fields: Field[] = [];
406
+
407
+ // Scalar/Enum fields
408
+ for (const col of tableColumns) {
409
+ const isId = tablePK.includes(col.columnName);
410
+ const isUnique = uniqueColumns.has(`${tableName}.${col.columnName}`);
411
+ const parsedDefault = parseDefault({ columnDefault: col.columnDefault, dataType: col.dataType });
412
+
413
+ // Check if this is an enum column
414
+ if (col.dataType === "USER-DEFINED" && enumNames.has(col.udtName)) {
415
+ const enumField: EnumField = {
416
+ kind: "enum",
417
+ name: col.columnName,
418
+ dbName: col.columnName,
419
+ enumName: col.udtName,
420
+ isRequired: !col.isNullable,
421
+ isList: false,
422
+ isId,
423
+ isUnique,
424
+ default: parsedDefault,
425
+ };
426
+ fields.push(enumField);
427
+ } else {
428
+ // Map PG type to Prisma type
429
+ const pgType = col.dataType === "ARRAY" ? col.udtName.replace(/^_/, "") : col.udtName;
430
+ const prismaType = PG_TO_PRISMA[pgType] ?? PG_TO_PRISMA[col.dataType] ?? "String";
431
+ const isArray = col.dataType === "ARRAY";
432
+
433
+ const scalarField: ScalarField = {
434
+ kind: "scalar",
435
+ name: col.columnName,
436
+ dbName: col.columnName,
437
+ prismaType,
438
+ tsType: PRISMA_TO_TS[prismaType],
439
+ pgType: PRISMA_TO_PG[prismaType],
440
+ isRequired: !col.isNullable,
441
+ isList: isArray,
442
+ isId,
443
+ isUnique,
444
+ isUpdatedAt: false, // Can't detect @updatedAt from DB
445
+ default: parsedDefault,
446
+ nativeType: undefined,
447
+ };
448
+ fields.push(scalarField);
449
+ }
450
+ }
451
+
452
+ // Relation fields (FK side — this model owns the FK)
453
+ for (const [constraintName, group] of fkGroups) {
454
+ // Determine relation type: if FK column is unique → oneToOne, else manyToOne
455
+ const isOneToOne = group.localCols.length === 1 &&
456
+ uniqueColumns.has(`${tableName}.${group.localCols[0]}`);
457
+
458
+ const relationField: RelationField = {
459
+ kind: "relation",
460
+ name: group.foreignTable.charAt(0).toLowerCase() + group.foreignTable.slice(1),
461
+ relatedModel: group.foreignTable,
462
+ isList: false,
463
+ isRequired: group.localCols.every((col) => {
464
+ const colInfo = tableColumns.find((c) => c.columnName === col);
465
+ return colInfo ? !colInfo.isNullable : true;
466
+ }),
467
+ relation: {
468
+ name: undefined,
469
+ fields: group.localCols,
470
+ references: group.foreignCols,
471
+ relatedModel: group.foreignTable,
472
+ type: isOneToOne ? "oneToOne" : "manyToOne",
473
+ isForeignKey: true,
474
+ },
475
+ };
476
+ fields.push(relationField);
477
+ }
478
+
479
+ // Build unique constraints (multi-column only, single-column is on the field)
480
+ const modelUniques: UniqueConstraint[] = [];
481
+ for (const [name, cols] of tableUniques) {
482
+ if (cols.length > 1) {
483
+ modelUniques.push({ fields: cols, name });
484
+ }
485
+ }
486
+
487
+ // Build indexes (exclude PKs and unique constraints that are already tracked)
488
+ const constraintNames = new Set(constraints.filter((c) => c.tableName === tableName).map((c) => c.constraintName));
489
+ const tableIndexes = indexInfos.filter(
490
+ (idx) => idx.tableName === tableName && !constraintNames.has(idx.indexName)
491
+ );
492
+ const modelIndexes: IndexDefinition[] = [];
493
+ for (const idx of tableIndexes) {
494
+ // Parse columns from index definition: CREATE [UNIQUE] INDEX name ON table (col1, col2)
495
+ const colMatch = idx.indexDef.match(/\(([^)]+)\)/);
496
+ if (colMatch) {
497
+ const cols = colMatch[1]!.split(",").map((c) => c.trim().replace(/"/g, ""));
498
+ if (idx.isUnique) {
499
+ // Unique indexes → treat as unique constraints (multi-column only;
500
+ // single-column uniqueness is already captured via information_schema)
501
+ if (cols.length > 1) {
502
+ modelUniques.push({ fields: cols, name: idx.indexName });
503
+ }
504
+ } else {
505
+ modelIndexes.push({ fields: cols, name: idx.indexName });
506
+ }
507
+ }
508
+ }
509
+
510
+ const model: Model = {
511
+ name: tableName,
512
+ dbName: tableName,
513
+ fields,
514
+ primaryKey: {
515
+ fields: tablePK,
516
+ isComposite: tablePK.length > 1,
517
+ },
518
+ uniqueConstraints: modelUniques,
519
+ indexes: modelIndexes,
520
+ };
521
+
522
+ models.push(model);
523
+ }
524
+
525
+ // ─── Add back-reference relation fields ─────────────────────────
526
+
527
+ // For each FK-owning relation, add the inverse relation to the referenced model
528
+ for (const model of models) {
529
+ for (const field of model.fields) {
530
+ if (field.kind !== "relation") continue;
531
+ if (!field.relation.isForeignKey) continue;
532
+
533
+ const relatedModel = models.find((m) => m.name === field.relatedModel);
534
+ if (!relatedModel) continue;
535
+
536
+ // Check if the related model already has a back-reference
537
+ const hasBackRef = relatedModel.fields.some(
538
+ (f) => f.kind === "relation" && f.relatedModel === model.name
539
+ );
540
+ if (hasBackRef) continue;
541
+
542
+ const isOneToOne = field.relation.type === "oneToOne";
543
+
544
+ const backRef: RelationField = {
545
+ kind: "relation",
546
+ name: isOneToOne
547
+ ? model.name.charAt(0).toLowerCase() + model.name.slice(1)
548
+ : model.name.charAt(0).toLowerCase() + model.name.slice(1) + "s",
549
+ relatedModel: model.name,
550
+ isList: !isOneToOne,
551
+ isRequired: false,
552
+ relation: {
553
+ name: undefined,
554
+ fields: [],
555
+ references: [],
556
+ relatedModel: model.name,
557
+ type: isOneToOne ? "oneToOne" : "oneToMany",
558
+ isForeignKey: false,
559
+ },
560
+ };
561
+ relatedModel.fields.push(backRef);
562
+ }
563
+ }
564
+
565
+ // ─── Add implicit M:N relation fields ───────────────────────────
566
+
567
+ for (const [joinTableName, { modelA, modelB }] of joinTables) {
568
+ const modelADef = models.find((m) => m.name === modelA);
569
+ const modelBDef = models.find((m) => m.name === modelB);
570
+ if (!modelADef || !modelBDef) continue;
571
+
572
+ // Add relation field to model A → model B[]
573
+ const hasRelA = modelADef.fields.some(
574
+ (f) => f.kind === "relation" && f.relatedModel === modelB && f.isList
575
+ );
576
+ if (!hasRelA) {
577
+ modelADef.fields.push({
578
+ kind: "relation",
579
+ name: modelB.charAt(0).toLowerCase() + modelB.slice(1) + "s",
580
+ relatedModel: modelB,
581
+ isList: true,
582
+ isRequired: false,
583
+ relation: {
584
+ name: undefined,
585
+ fields: [],
586
+ references: [],
587
+ relatedModel: modelB,
588
+ type: "oneToMany", // M:N represented as two oneToMany from each side
589
+ isForeignKey: false,
590
+ },
591
+ });
592
+ }
593
+
594
+ // Add relation field to model B → model A[]
595
+ const hasRelB = modelBDef.fields.some(
596
+ (f) => f.kind === "relation" && f.relatedModel === modelA && f.isList
597
+ );
598
+ if (!hasRelB) {
599
+ modelBDef.fields.push({
600
+ kind: "relation",
601
+ name: modelA.charAt(0).toLowerCase() + modelA.slice(1) + "s",
602
+ relatedModel: modelA,
603
+ isList: true,
604
+ isRequired: false,
605
+ relation: {
606
+ name: undefined,
607
+ fields: [],
608
+ references: [],
609
+ relatedModel: modelA,
610
+ type: "oneToMany",
611
+ isForeignKey: false,
612
+ },
613
+ });
614
+ }
615
+ }
616
+
617
+ return { models, enums };
618
+ }