@vibeorm/migrate 1.2.0 → 1.3.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/package.json +2 -2
- package/src/ddl-builder.ts +32 -61
- package/src/introspector.ts +28 -3
- package/src/relation-utils.ts +79 -0
- package/src/schema-differ.ts +31 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibeorm/migrate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Migration, introspection, and schema diff toolkit for VibeORM",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -37,6 +37,6 @@
|
|
|
37
37
|
"bun": ">=1.1.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@vibeorm/parser": "1.
|
|
40
|
+
"@vibeorm/parser": "1.3.1"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/ddl-builder.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { Schema, Model, Field, ScalarField, EnumField, RelationField, DefaultValue, Enum, EnumValue } from "@vibeorm/parser";
|
|
9
9
|
import { PRISMA_TO_PG } from "@vibeorm/parser";
|
|
10
10
|
import { buildFkActionClause } from "./cascade-actions.ts";
|
|
11
|
+
import { detectImplicitM2MPairs } from "./relation-utils.ts";
|
|
11
12
|
|
|
12
13
|
export type DDLResult = {
|
|
13
14
|
/** The full DDL SQL wrapped in BEGIN/COMMIT */
|
|
@@ -298,68 +299,38 @@ type ImplicitManyToMany = {
|
|
|
298
299
|
|
|
299
300
|
function detectImplicitManyToMany(params: { schema: Schema }): ImplicitManyToMany[] {
|
|
300
301
|
const { schema } = params;
|
|
301
|
-
const result: ImplicitManyToMany[] = [];
|
|
302
|
-
const seen = new Set<string>();
|
|
303
302
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const pkFieldA = modelADef.primaryKey.fields[0]!;
|
|
337
|
-
const pkFieldB = modelBDef.primaryKey.fields[0]!;
|
|
338
|
-
|
|
339
|
-
const pkFieldDefA = modelADef.fields.find((f) => f.name === pkFieldA);
|
|
340
|
-
const pkFieldDefB = modelBDef.fields.find((f) => f.name === pkFieldB);
|
|
341
|
-
|
|
342
|
-
const pkTypeA = pkFieldDefA && pkFieldDefA.kind === "scalar"
|
|
343
|
-
? resolveBaseColumnType({ field: pkFieldDefA })
|
|
344
|
-
: "INTEGER";
|
|
345
|
-
const pkTypeB = pkFieldDefB && pkFieldDefB.kind === "scalar"
|
|
346
|
-
? resolveBaseColumnType({ field: pkFieldDefB })
|
|
347
|
-
: "INTEGER";
|
|
348
|
-
|
|
349
|
-
result.push({
|
|
350
|
-
modelA: nameA,
|
|
351
|
-
modelB: nameB,
|
|
352
|
-
tableA: getDbName({ model: modelADef }),
|
|
353
|
-
tableB: getDbName({ model: modelBDef }),
|
|
354
|
-
pkFieldA,
|
|
355
|
-
pkFieldB,
|
|
356
|
-
pkTypeA,
|
|
357
|
-
pkTypeB,
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
return result;
|
|
303
|
+
// Detection lives in the shared helper (single source of truth with the
|
|
304
|
+
// schema differ); here we only resolve the per-table DDL details (PK column
|
|
305
|
+
// names + types) for each detected pair.
|
|
306
|
+
return detectImplicitM2MPairs({ schema }).map((pair) => {
|
|
307
|
+
const modelADef = schema.models.find((m) => m.name === pair.modelA)!;
|
|
308
|
+
const modelBDef = schema.models.find((m) => m.name === pair.modelB)!;
|
|
309
|
+
|
|
310
|
+
const pkFieldA = modelADef.primaryKey.fields[0]!;
|
|
311
|
+
const pkFieldB = modelBDef.primaryKey.fields[0]!;
|
|
312
|
+
|
|
313
|
+
const pkFieldDefA = modelADef.fields.find((f) => f.name === pkFieldA);
|
|
314
|
+
const pkFieldDefB = modelBDef.fields.find((f) => f.name === pkFieldB);
|
|
315
|
+
|
|
316
|
+
const pkTypeA = pkFieldDefA && pkFieldDefA.kind === "scalar"
|
|
317
|
+
? resolveBaseColumnType({ field: pkFieldDefA })
|
|
318
|
+
: "INTEGER";
|
|
319
|
+
const pkTypeB = pkFieldDefB && pkFieldDefB.kind === "scalar"
|
|
320
|
+
? resolveBaseColumnType({ field: pkFieldDefB })
|
|
321
|
+
: "INTEGER";
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
modelA: pair.modelA,
|
|
325
|
+
modelB: pair.modelB,
|
|
326
|
+
tableA: getDbName({ model: modelADef }),
|
|
327
|
+
tableB: getDbName({ model: modelBDef }),
|
|
328
|
+
pkFieldA,
|
|
329
|
+
pkFieldB,
|
|
330
|
+
pkTypeA,
|
|
331
|
+
pkTypeB,
|
|
332
|
+
};
|
|
333
|
+
});
|
|
363
334
|
}
|
|
364
335
|
|
|
365
336
|
function buildJoinTableStatements(params: { joinTable: ImplicitManyToMany }): string[] {
|
package/src/introspector.ts
CHANGED
|
@@ -447,7 +447,7 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
|
|
|
447
447
|
const isUnique = uniqueColumns.has(`${tableName}.${col.columnName}`);
|
|
448
448
|
const parsedDefault = parseDefault({ columnDefault: col.columnDefault, dataType: col.dataType });
|
|
449
449
|
|
|
450
|
-
//
|
|
450
|
+
// Branch 1: scalar enum column (data_type=USER-DEFINED, udt_name=EnumName).
|
|
451
451
|
if (col.dataType === "USER-DEFINED" && enumNames.has(col.udtName)) {
|
|
452
452
|
const enumField: EnumField = {
|
|
453
453
|
kind: "enum",
|
|
@@ -462,8 +462,33 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
|
|
|
462
462
|
documentation: undefined,
|
|
463
463
|
};
|
|
464
464
|
fields.push(enumField);
|
|
465
|
-
}
|
|
466
|
-
|
|
465
|
+
}
|
|
466
|
+
// Branch 2: enum-array column. Postgres reports the underlying array type
|
|
467
|
+
// as `_EnumName` (the leading underscore is the array-type naming convention),
|
|
468
|
+
// and `data_type` is `ARRAY`. Without this branch the field would be parsed
|
|
469
|
+
// as `String[]` and the round-trip drift differ would always want to ALTER it.
|
|
470
|
+
// Bug 2 — see .ai/bug-report-2026-05-24.md.
|
|
471
|
+
else if (
|
|
472
|
+
col.dataType === "ARRAY" &&
|
|
473
|
+
col.udtName.startsWith("_") &&
|
|
474
|
+
enumNames.has(col.udtName.slice(1))
|
|
475
|
+
) {
|
|
476
|
+
const enumField: EnumField = {
|
|
477
|
+
kind: "enum",
|
|
478
|
+
name: col.columnName,
|
|
479
|
+
dbName: col.columnName,
|
|
480
|
+
enumName: col.udtName.slice(1),
|
|
481
|
+
isRequired: !col.isNullable,
|
|
482
|
+
isList: true,
|
|
483
|
+
isId,
|
|
484
|
+
isUnique,
|
|
485
|
+
default: parsedDefault,
|
|
486
|
+
documentation: undefined,
|
|
487
|
+
};
|
|
488
|
+
fields.push(enumField);
|
|
489
|
+
}
|
|
490
|
+
// Branch 3: scalar / scalar-array fallback.
|
|
491
|
+
else {
|
|
467
492
|
const pgType = col.dataType === "ARRAY" ? col.udtName.replace(/^_/, "") : col.udtName;
|
|
468
493
|
const prismaType = PG_TO_PRISMA[pgType] ?? PG_TO_PRISMA[col.dataType] ?? "String";
|
|
469
494
|
const isArray = col.dataType === "ARRAY";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation utilities — shared implicit many-to-many detection.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for "which model pairs are joined by an implicit
|
|
5
|
+
* many-to-many table". Both the DDL builder (initial create / `migrate
|
|
6
|
+
* generate`) and the schema differ (`db push` / migration diffs) consume this
|
|
7
|
+
* so they can never drift apart — the previous divergence between those two
|
|
8
|
+
* copies was the root cause of phantom join tables for 1:N self-relations and
|
|
9
|
+
* for two separate 1:N relations between the same pair of models.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Schema, RelationField } from "@vibeorm/parser";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A detected implicit many-to-many relation, identified by its two endpoint
|
|
16
|
+
* model names sorted alphabetically (matching Prisma's `_AToB` join-table
|
|
17
|
+
* naming convention). For a self-relation both names are equal.
|
|
18
|
+
*/
|
|
19
|
+
export type ImplicitM2MPair = {
|
|
20
|
+
readonly modelA: string;
|
|
21
|
+
readonly modelB: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Detect every implicit many-to-many relation in a schema.
|
|
26
|
+
*
|
|
27
|
+
* Pairing rule (kept in lock-step with the parser's `resolveRelationType`):
|
|
28
|
+
* two relation fields form an implicit M:N **iff** both are lists with no
|
|
29
|
+
* foreign key, they are **distinct fields** (self-relation safe), and their
|
|
30
|
+
* relation names match **symmetrically** — both unnamed, or both the same name.
|
|
31
|
+
*
|
|
32
|
+
* Detection is purely structural (it does not read `relation.type`), so it
|
|
33
|
+
* works for both parsed schemas (where each side carries `type: "manyToMany"`)
|
|
34
|
+
* and introspected schemas (where a real join table round-trips as two unnamed
|
|
35
|
+
* `oneToMany` list fields). That keeps the differ stable across round-trips.
|
|
36
|
+
*/
|
|
37
|
+
export function detectImplicitM2MPairs(params: { schema: Schema }): ImplicitM2MPair[] {
|
|
38
|
+
const { schema } = params;
|
|
39
|
+
const result: ImplicitM2MPair[] = [];
|
|
40
|
+
const seen = new Set<string>();
|
|
41
|
+
|
|
42
|
+
for (const model of schema.models) {
|
|
43
|
+
for (const field of model.fields) {
|
|
44
|
+
if (field.kind !== "relation") continue;
|
|
45
|
+
if (!field.isList) continue;
|
|
46
|
+
if (field.relation.isForeignKey) continue;
|
|
47
|
+
if (field.relation.fields.length > 0) continue;
|
|
48
|
+
|
|
49
|
+
const relatedModel = schema.models.find((m) => m.name === field.relatedModel);
|
|
50
|
+
if (!relatedModel) continue;
|
|
51
|
+
|
|
52
|
+
const backRef = relatedModel.fields.find(
|
|
53
|
+
(f): f is RelationField =>
|
|
54
|
+
f.kind === "relation" &&
|
|
55
|
+
// Self-exclusion: a list field must never pair with itself, otherwise
|
|
56
|
+
// every 1:N self-relation would be mistaken for an M:N self-relation.
|
|
57
|
+
f !== field &&
|
|
58
|
+
f.relatedModel === model.name &&
|
|
59
|
+
f.isList &&
|
|
60
|
+
!f.relation.isForeignKey &&
|
|
61
|
+
f.relation.fields.length === 0 &&
|
|
62
|
+
// Symmetric relation-name match — see doc comment above.
|
|
63
|
+
(f.relation.name ?? undefined) === (field.relation.name ?? undefined),
|
|
64
|
+
);
|
|
65
|
+
if (!backRef) continue;
|
|
66
|
+
|
|
67
|
+
const sorted = [model.name, relatedModel.name].sort();
|
|
68
|
+
const nameA = sorted[0]!;
|
|
69
|
+
const nameB = sorted[1]!;
|
|
70
|
+
const key = `${nameA}_${nameB}`;
|
|
71
|
+
if (seen.has(key)) continue;
|
|
72
|
+
seen.add(key);
|
|
73
|
+
|
|
74
|
+
result.push({ modelA: nameA, modelB: nameB });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return result;
|
|
79
|
+
}
|
package/src/schema-differ.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
} from "@vibeorm/parser";
|
|
19
19
|
import { PRISMA_TO_PG } from "@vibeorm/parser";
|
|
20
20
|
import { buildFkActionClause, fkActionFingerprint } from "./cascade-actions.ts";
|
|
21
|
+
import { detectImplicitM2MPairs } from "./relation-utils.ts";
|
|
21
22
|
|
|
22
23
|
// ─── Types ────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -72,8 +73,11 @@ function getRelationFields(params: { model: Model }): RelationField[] {
|
|
|
72
73
|
function resolveColumnType(params: { field: ScalarField | EnumField; enums: Enum[] }): string {
|
|
73
74
|
const { field, enums } = params;
|
|
74
75
|
if (field.kind === "enum") {
|
|
76
|
+
// Enum lists must emit `EnumName[]`, not the scalar enum type. Mirrors
|
|
77
|
+
// `ddl-builder.ts:135-141`. Bug 2 — see .ai/bug-report-2026-05-24.md.
|
|
75
78
|
const enumDef = enums.find((e) => e.name === field.enumName);
|
|
76
|
-
|
|
79
|
+
const enumType = q({ name: enumDef?.dbName ?? field.enumName });
|
|
80
|
+
return field.isList ? `${enumType}[]` : enumType;
|
|
77
81
|
}
|
|
78
82
|
if (field.default?.kind === "autoincrement") {
|
|
79
83
|
return field.prismaType === "BigInt" ? "BIGSERIAL" : "SERIAL";
|
|
@@ -87,8 +91,10 @@ function resolveColumnType(params: { field: ScalarField | EnumField; enums: Enum
|
|
|
87
91
|
function resolveBaseColumnType(params: { field: ScalarField | EnumField; enums: Enum[] }): string {
|
|
88
92
|
const { field, enums } = params;
|
|
89
93
|
if (field.kind === "enum") {
|
|
94
|
+
// Same enum-list handling as resolveColumnType (Bug 2).
|
|
90
95
|
const enumDef = enums.find((e) => e.name === field.enumName);
|
|
91
|
-
|
|
96
|
+
const enumType = q({ name: enumDef?.dbName ?? field.enumName });
|
|
97
|
+
return field.isList ? `${enumType}[]` : enumType;
|
|
92
98
|
}
|
|
93
99
|
if (field.nativeType) return field.nativeType.toLowerCase();
|
|
94
100
|
if (field.isList) return `${PRISMA_TO_PG[field.prismaType]}[]`;
|
|
@@ -201,45 +207,14 @@ type JoinTableInfo = {
|
|
|
201
207
|
|
|
202
208
|
function detectImplicitManyToMany(params: { schema: Schema }): JoinTableInfo[] {
|
|
203
209
|
const { schema } = params;
|
|
204
|
-
const result: JoinTableInfo[] = [];
|
|
205
|
-
const seen = new Set<string>();
|
|
206
|
-
|
|
207
|
-
for (const model of schema.models) {
|
|
208
|
-
for (const field of model.fields) {
|
|
209
|
-
if (field.kind !== "relation") continue;
|
|
210
|
-
if (!field.isList) continue;
|
|
211
|
-
if (field.relation.isForeignKey) continue;
|
|
212
|
-
if (field.relation.fields.length > 0) continue;
|
|
213
|
-
|
|
214
|
-
const relatedModel = schema.models.find((m: Model) => m.name === field.relatedModel);
|
|
215
|
-
if (!relatedModel) continue;
|
|
216
|
-
|
|
217
|
-
const backRef = relatedModel.fields.find(
|
|
218
|
-
(f: Field) =>
|
|
219
|
-
f.kind === "relation" &&
|
|
220
|
-
f.relatedModel === model.name &&
|
|
221
|
-
f.isList &&
|
|
222
|
-
!f.relation.isForeignKey &&
|
|
223
|
-
f.relation.fields.length === 0
|
|
224
|
-
);
|
|
225
|
-
if (!backRef) continue;
|
|
226
|
-
|
|
227
|
-
const sorted = [model.name, relatedModel.name].sort();
|
|
228
|
-
const nameA = sorted[0]!;
|
|
229
|
-
const nameB = sorted[1]!;
|
|
230
|
-
const key = `${nameA}_${nameB}`;
|
|
231
|
-
if (seen.has(key)) continue;
|
|
232
|
-
seen.add(key);
|
|
233
|
-
|
|
234
|
-
result.push({
|
|
235
|
-
modelA: nameA,
|
|
236
|
-
modelB: nameB,
|
|
237
|
-
tableName: `_${nameA}To${nameB}`,
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
210
|
|
|
242
|
-
|
|
211
|
+
// Detection lives in the shared helper (single source of truth with the DDL
|
|
212
|
+
// builder); here we only attach the `_AToB` join-table name to each pair.
|
|
213
|
+
return detectImplicitM2MPairs({ schema }).map((pair) => ({
|
|
214
|
+
modelA: pair.modelA,
|
|
215
|
+
modelB: pair.modelB,
|
|
216
|
+
tableName: `_${pair.modelA}To${pair.modelB}`,
|
|
217
|
+
}));
|
|
243
218
|
}
|
|
244
219
|
|
|
245
220
|
// ─── Main Differ ──────────────────────────────────────────────────
|
|
@@ -556,11 +531,25 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
|
|
|
556
531
|
|
|
557
532
|
// Type change — reverse is setting back to the previous type
|
|
558
533
|
if (prevType !== currType) {
|
|
534
|
+
// Detect scalar -> array transition of same base type. PG can't implicit-cast,
|
|
535
|
+
// so we add a USING clause that wraps the existing value in a 1-element array
|
|
536
|
+
// (or NULL if the column is NULL). This heals databases broken by an earlier
|
|
537
|
+
// enum-array bug (Bug 2) where the column was created as the scalar type.
|
|
538
|
+
// We intentionally do NOT handle the reverse direction (array -> scalar) — that
|
|
539
|
+
// is lossy and the user must decide which element to keep.
|
|
540
|
+
const isScalarToArray =
|
|
541
|
+
currType.endsWith("[]") && !prevType.endsWith("[]") &&
|
|
542
|
+
currType.slice(0, -2) === prevType;
|
|
543
|
+
|
|
544
|
+
const usingClause = isScalarToArray
|
|
545
|
+
? ` USING CASE WHEN ${q({ name: colName })} IS NULL THEN NULL ELSE ARRAY[${q({ name: colName })}] END`
|
|
546
|
+
: "";
|
|
547
|
+
|
|
559
548
|
ops.push({
|
|
560
549
|
type: "alterColumnType",
|
|
561
|
-
sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${currType};`,
|
|
550
|
+
sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${currType}${usingClause};`,
|
|
562
551
|
downSql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${prevType};`,
|
|
563
|
-
isDestructive:
|
|
552
|
+
isDestructive: !isScalarToArray, // scalar -> array is non-destructive (data preserved as 1-element arrays)
|
|
564
553
|
isReversible: true,
|
|
565
554
|
description: `Change type of "${fieldName}" in "${name}" from ${prevType} to ${currType}`,
|
|
566
555
|
});
|