@vibeorm/migrate 1.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibeorm/migrate",
3
- "version": "1.3.0",
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.3.0"
40
+ "@vibeorm/parser": "1.3.1"
41
41
  }
42
42
  }
@@ -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
- for (const model of schema.models) {
305
- for (const field of model.fields) {
306
- if (field.kind !== "relation") continue;
307
- if (!field.isList) continue;
308
- if (field.relation.isForeignKey) continue;
309
- if (field.relation.fields.length > 0) continue;
310
-
311
- // Check if the other side is also a list with no FK fields
312
- const relatedModel = schema.models.find((m) => m.name === field.relatedModel);
313
- if (!relatedModel) continue;
314
-
315
- const backRef = relatedModel.fields.find(
316
- (f) =>
317
- f.kind === "relation" &&
318
- f.relatedModel === model.name &&
319
- f.isList &&
320
- !f.relation.isForeignKey &&
321
- f.relation.fields.length === 0
322
- );
323
- if (!backRef) continue;
324
-
325
- // Sort alphabetically for consistent naming
326
- const sorted = [model.name, relatedModel.name].sort();
327
- const nameA = sorted[0]!;
328
- const nameB = sorted[1]!;
329
- const key = `${nameA}_${nameB}`;
330
- if (seen.has(key)) continue;
331
- seen.add(key);
332
-
333
- const modelADef = schema.models.find((m) => m.name === nameA)!;
334
- const modelBDef = schema.models.find((m) => m.name === nameB)!;
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[] {
@@ -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
+ }
@@ -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
 
@@ -206,45 +207,14 @@ type JoinTableInfo = {
206
207
 
207
208
  function detectImplicitManyToMany(params: { schema: Schema }): JoinTableInfo[] {
208
209
  const { schema } = params;
209
- const result: JoinTableInfo[] = [];
210
- const seen = new Set<string>();
211
-
212
- for (const model of schema.models) {
213
- for (const field of model.fields) {
214
- if (field.kind !== "relation") continue;
215
- if (!field.isList) continue;
216
- if (field.relation.isForeignKey) continue;
217
- if (field.relation.fields.length > 0) continue;
218
-
219
- const relatedModel = schema.models.find((m: Model) => m.name === field.relatedModel);
220
- if (!relatedModel) continue;
221
-
222
- const backRef = relatedModel.fields.find(
223
- (f: Field) =>
224
- f.kind === "relation" &&
225
- f.relatedModel === model.name &&
226
- f.isList &&
227
- !f.relation.isForeignKey &&
228
- f.relation.fields.length === 0
229
- );
230
- if (!backRef) continue;
231
-
232
- const sorted = [model.name, relatedModel.name].sort();
233
- const nameA = sorted[0]!;
234
- const nameB = sorted[1]!;
235
- const key = `${nameA}_${nameB}`;
236
- if (seen.has(key)) continue;
237
- seen.add(key);
238
-
239
- result.push({
240
- modelA: nameA,
241
- modelB: nameB,
242
- tableName: `_${nameA}To${nameB}`,
243
- });
244
- }
245
- }
246
210
 
247
- return result;
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
+ }));
248
218
  }
249
219
 
250
220
  // ─── Main Differ ──────────────────────────────────────────────────