@vibeorm/migrate 1.1.9 → 1.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibeorm/migrate",
3
- "version": "1.1.9",
3
+ "version": "1.3.0",
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.1.8"
40
+ "@vibeorm/parser": "1.3.0"
41
41
  }
42
42
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Cascade-action resolution and SQL serialisation.
3
+ *
4
+ * Translates a `CascadeAction` from the parser IR into a Postgres
5
+ * `ON DELETE`/`ON UPDATE` clause, applying Prisma-compatible defaults when
6
+ * the schema doesn't specify an action explicitly.
7
+ *
8
+ * Defaults (matching Prisma's Postgres behaviour):
9
+ * - `onDelete`: required FK → `Restrict`, optional FK → `SetNull`
10
+ * - `onUpdate`: always → `Cascade`
11
+ */
12
+
13
+ import type { CascadeAction, RelationField } from "@vibeorm/parser";
14
+
15
+ const ACTION_TO_SQL: Record<CascadeAction, string> = {
16
+ Cascade: "CASCADE",
17
+ Restrict: "RESTRICT",
18
+ NoAction: "NO ACTION",
19
+ SetNull: "SET NULL",
20
+ SetDefault: "SET DEFAULT",
21
+ };
22
+
23
+ /**
24
+ * Inverse of {@link ACTION_TO_SQL} — used by the introspector when reading
25
+ * `pg_constraint.confdeltype`/`confupdtype`. The single-char codes are
26
+ * Postgres's catalog representation.
27
+ */
28
+ export const PG_CHAR_TO_CASCADE_ACTION: Record<string, CascadeAction> = {
29
+ a: "NoAction",
30
+ r: "Restrict",
31
+ c: "Cascade",
32
+ n: "SetNull",
33
+ d: "SetDefault",
34
+ };
35
+
36
+ /**
37
+ * Resolve the effective onDelete action for a relation, applying the Prisma
38
+ * default when the schema didn't say anything explicit.
39
+ *
40
+ * `isRequired` is the FK column's nullability (Prisma's "required relation").
41
+ */
42
+ export function resolveOnDelete(params: {
43
+ explicit: CascadeAction | undefined;
44
+ isRequired: boolean;
45
+ }): CascadeAction {
46
+ if (params.explicit) return params.explicit;
47
+ return params.isRequired ? "Restrict" : "SetNull";
48
+ }
49
+
50
+ /** Resolve the effective onUpdate action. Prisma's Postgres default is Cascade. */
51
+ export function resolveOnUpdate(params: {
52
+ explicit: CascadeAction | undefined;
53
+ }): CascadeAction {
54
+ return params.explicit ?? "Cascade";
55
+ }
56
+
57
+ /** Convenience: serialise a CascadeAction to its Postgres clause body. */
58
+ export function cascadeActionSql(params: { action: CascadeAction }): string {
59
+ return ACTION_TO_SQL[params.action];
60
+ }
61
+
62
+ /**
63
+ * Build the full `ON DELETE X ON UPDATE Y` clause for a relation field,
64
+ * applying defaults as needed.
65
+ */
66
+ export function buildFkActionClause(params: { rel: RelationField }): string {
67
+ const { rel } = params;
68
+ const onDelete = resolveOnDelete({
69
+ explicit: rel.relation.onDelete,
70
+ isRequired: rel.isRequired,
71
+ });
72
+ const onUpdate = resolveOnUpdate({ explicit: rel.relation.onUpdate });
73
+ return `ON DELETE ${ACTION_TO_SQL[onDelete]} ON UPDATE ${ACTION_TO_SQL[onUpdate]}`;
74
+ }
75
+
76
+ /**
77
+ * Stable comparison key for whether two relations have the same effective
78
+ * cascade behaviour. Used by the differ to detect drift even when the schema
79
+ * was written without explicit actions (defaults must still be considered).
80
+ */
81
+ export function fkActionFingerprint(params: { rel: RelationField }): string {
82
+ const onDelete = resolveOnDelete({
83
+ explicit: params.rel.relation.onDelete,
84
+ isRequired: params.rel.isRequired,
85
+ });
86
+ const onUpdate = resolveOnUpdate({ explicit: params.rel.relation.onUpdate });
87
+ return `${onDelete}|${onUpdate}`;
88
+ }
@@ -7,6 +7,7 @@
7
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
+ import { buildFkActionClause } from "./cascade-actions.ts";
10
11
 
11
12
  export type DDLResult = {
12
13
  /** The full DDL SQL wrapped in BEGIN/COMMIT */
@@ -271,10 +272,11 @@ function buildForeignKeyStatements(params: { model: Model; allModels: Model[] })
271
272
  .join(", ");
272
273
 
273
274
  const constraintName = `${tableName}_${field.relation.fields.join("_")}_fkey`;
275
+ const actionClause = buildFkActionClause({ rel: field });
274
276
  statements.push(
275
277
  `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} ` +
276
278
  `FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ` +
277
- `ON DELETE RESTRICT ON UPDATE CASCADE;`
279
+ `${actionClause};`
278
280
  );
279
281
  }
280
282
 
@@ -20,9 +20,11 @@ import type {
20
20
  PrismaScalarType,
21
21
  PostgresType,
22
22
  DefaultValue,
23
+ CascadeAction,
23
24
  } from "@vibeorm/parser";
24
25
  import { PRISMA_TO_TS, PRISMA_TO_PG } from "@vibeorm/parser";
25
26
  import type { SqlExecutor } from "./types.ts";
27
+ import { PG_CHAR_TO_CASCADE_ACTION } from "./cascade-actions.ts";
26
28
 
27
29
  // ─── Reverse Type Mapping ─────────────────────────────────────────
28
30
 
@@ -83,6 +85,10 @@ type ForeignKeyInfo = {
83
85
  columnName: string;
84
86
  foreignTableName: string;
85
87
  foreignColumnName: string;
88
+ /** ON DELETE action from `pg_constraint.confdeltype` (mapped to CascadeAction). */
89
+ onDelete: CascadeAction;
90
+ /** ON UPDATE action from `pg_constraint.confupdtype` (mapped to CascadeAction). */
91
+ onUpdate: CascadeAction;
86
92
  };
87
93
 
88
94
  type EnumInfo = {
@@ -138,23 +144,36 @@ const CONSTRAINTS_QUERY = `
138
144
  ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position;
139
145
  `;
140
146
 
147
+ /**
148
+ * Foreign keys via pg_catalog rather than information_schema, because
149
+ * information_schema's `referential_constraints.delete_rule` exposes the rule
150
+ * but doesn't preserve the column ordering of composite FKs reliably across
151
+ * server versions. The pg_catalog path with `unnest(...) WITH ORDINALITY`
152
+ * pairs up `conkey` / `confkey` in declaration order and also surfaces
153
+ * `confdeltype` / `confupdtype` directly.
154
+ */
141
155
  const FOREIGN_KEYS_QUERY = `
142
156
  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;
157
+ c.conname AS constraint_name,
158
+ cl.relname AS table_name,
159
+ att.attname AS column_name,
160
+ fcl.relname AS foreign_table_name,
161
+ fatt.attname AS foreign_column_name,
162
+ c.confdeltype AS on_delete_char,
163
+ c.confupdtype AS on_update_char
164
+ FROM pg_catalog.pg_constraint c
165
+ JOIN pg_catalog.pg_class cl ON cl.oid = c.conrelid
166
+ JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace
167
+ JOIN pg_catalog.pg_class fcl ON fcl.oid = c.confrelid
168
+ JOIN pg_catalog.pg_namespace fn ON fn.oid = fcl.relnamespace
169
+ JOIN unnest(c.conkey) WITH ORDINALITY AS lk(attnum, ord) ON true
170
+ JOIN unnest(c.confkey) WITH ORDINALITY AS rk(attnum, ord) ON lk.ord = rk.ord
171
+ JOIN pg_catalog.pg_attribute att ON att.attrelid = c.conrelid AND att.attnum = lk.attnum
172
+ JOIN pg_catalog.pg_attribute fatt ON fatt.attrelid = c.confrelid AND fatt.attnum = rk.attnum
173
+ WHERE c.contype = 'f'
174
+ AND n.nspname = 'public'
175
+ AND fn.nspname = 'public'
176
+ ORDER BY cl.relname, c.conname, lk.ord;
158
177
  `;
159
178
 
160
179
  const ENUMS_QUERY = `
@@ -289,13 +308,22 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
289
308
  columnName: r.column_name as string,
290
309
  }));
291
310
 
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
- }));
311
+ const foreignKeys: ForeignKeyInfo[] = foreignKeysRaw.map((r) => {
312
+ // Postgres uses single-char codes: a=NoAction, r=Restrict, c=Cascade,
313
+ // n=SetNull, d=SetDefault. Fall back to NoAction (the Postgres default)
314
+ // if we ever see an unrecognised char from a future PG version.
315
+ const onDeleteChar = (r.on_delete_char as string) ?? "a";
316
+ const onUpdateChar = (r.on_update_char as string) ?? "a";
317
+ return {
318
+ constraintName: r.constraint_name as string,
319
+ tableName: r.table_name as string,
320
+ columnName: r.column_name as string,
321
+ foreignTableName: r.foreign_table_name as string,
322
+ foreignColumnName: r.foreign_column_name as string,
323
+ onDelete: PG_CHAR_TO_CASCADE_ACTION[onDeleteChar] ?? "NoAction",
324
+ onUpdate: PG_CHAR_TO_CASCADE_ACTION[onUpdateChar] ?? "NoAction",
325
+ };
326
+ });
299
327
 
300
328
  const enumInfos: EnumInfo[] = enumsRaw.map((r) => ({
301
329
  typeName: r.type_name as string,
@@ -387,14 +415,23 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
387
415
  const tableUniques = uniqueConstraints.get(tableName) ?? new Map<string, string[]>();
388
416
  const tableFKs = foreignKeys.filter((fk) => fk.tableName === tableName);
389
417
 
390
- // Group FKs by constraint for multi-column FK support
391
- const fkGroups = new Map<string, { localCols: string[]; foreignTable: string; foreignCols: string[] }>();
418
+ // Group FKs by constraint for multi-column FK support. Cascade actions are
419
+ // a property of the constraint as a whole, so we carry them once per group.
420
+ const fkGroups = new Map<string, {
421
+ localCols: string[];
422
+ foreignTable: string;
423
+ foreignCols: string[];
424
+ onDelete: CascadeAction;
425
+ onUpdate: CascadeAction;
426
+ }>();
392
427
  for (const fk of tableFKs) {
393
428
  if (!fkGroups.has(fk.constraintName)) {
394
429
  fkGroups.set(fk.constraintName, {
395
430
  localCols: [],
396
431
  foreignTable: fk.foreignTableName,
397
432
  foreignCols: [],
433
+ onDelete: fk.onDelete,
434
+ onUpdate: fk.onUpdate,
398
435
  });
399
436
  }
400
437
  const group = fkGroups.get(fk.constraintName)!;
@@ -410,7 +447,7 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
410
447
  const isUnique = uniqueColumns.has(`${tableName}.${col.columnName}`);
411
448
  const parsedDefault = parseDefault({ columnDefault: col.columnDefault, dataType: col.dataType });
412
449
 
413
- // Check if this is an enum column
450
+ // Branch 1: scalar enum column (data_type=USER-DEFINED, udt_name=EnumName).
414
451
  if (col.dataType === "USER-DEFINED" && enumNames.has(col.udtName)) {
415
452
  const enumField: EnumField = {
416
453
  kind: "enum",
@@ -425,8 +462,33 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
425
462
  documentation: undefined,
426
463
  };
427
464
  fields.push(enumField);
428
- } else {
429
- // Map PG type to Prisma type
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 {
430
492
  const pgType = col.dataType === "ARRAY" ? col.udtName.replace(/^_/, "") : col.udtName;
431
493
  const prismaType = PG_TO_PRISMA[pgType] ?? PG_TO_PRISMA[col.dataType] ?? "String";
432
494
  const isArray = col.dataType === "ARRAY";
@@ -473,6 +535,8 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
473
535
  relatedModel: group.foreignTable,
474
536
  type: isOneToOne ? "oneToOne" : "manyToOne",
475
537
  isForeignKey: true,
538
+ onDelete: group.onDelete,
539
+ onUpdate: group.onUpdate,
476
540
  },
477
541
  };
478
542
  fields.push(relationField);
@@ -17,6 +17,7 @@ import type {
17
17
  IndexDefinition,
18
18
  } from "@vibeorm/parser";
19
19
  import { PRISMA_TO_PG } from "@vibeorm/parser";
20
+ import { buildFkActionClause, fkActionFingerprint } from "./cascade-actions.ts";
20
21
 
21
22
  // ─── Types ────────────────────────────────────────────────────────
22
23
 
@@ -71,8 +72,11 @@ function getRelationFields(params: { model: Model }): RelationField[] {
71
72
  function resolveColumnType(params: { field: ScalarField | EnumField; enums: Enum[] }): string {
72
73
  const { field, enums } = params;
73
74
  if (field.kind === "enum") {
75
+ // Enum lists must emit `EnumName[]`, not the scalar enum type. Mirrors
76
+ // `ddl-builder.ts:135-141`. Bug 2 — see .ai/bug-report-2026-05-24.md.
74
77
  const enumDef = enums.find((e) => e.name === field.enumName);
75
- return q({ name: enumDef?.dbName ?? field.enumName });
78
+ const enumType = q({ name: enumDef?.dbName ?? field.enumName });
79
+ return field.isList ? `${enumType}[]` : enumType;
76
80
  }
77
81
  if (field.default?.kind === "autoincrement") {
78
82
  return field.prismaType === "BigInt" ? "BIGSERIAL" : "SERIAL";
@@ -86,8 +90,10 @@ function resolveColumnType(params: { field: ScalarField | EnumField; enums: Enum
86
90
  function resolveBaseColumnType(params: { field: ScalarField | EnumField; enums: Enum[] }): string {
87
91
  const { field, enums } = params;
88
92
  if (field.kind === "enum") {
93
+ // Same enum-list handling as resolveColumnType (Bug 2).
89
94
  const enumDef = enums.find((e) => e.name === field.enumName);
90
- return q({ name: enumDef?.dbName ?? field.enumName });
95
+ const enumType = q({ name: enumDef?.dbName ?? field.enumName });
96
+ return field.isList ? `${enumType}[]` : enumType;
91
97
  }
92
98
  if (field.nativeType) return field.nativeType.toLowerCase();
93
99
  if (field.isList) return `${PRISMA_TO_PG[field.prismaType]}[]`;
@@ -426,9 +432,10 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
426
432
  }).join(", ");
427
433
  const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model, fieldName: f }));
428
434
  const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
435
+ const actionClause = buildFkActionClause({ rel });
429
436
  ops.push({
430
437
  type: "addForeignKey",
431
- sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ON DELETE RESTRICT ON UPDATE CASCADE;`,
438
+ sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
432
439
  downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
433
440
  isDestructive: false,
434
441
  isReversible: true,
@@ -554,11 +561,25 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
554
561
 
555
562
  // Type change — reverse is setting back to the previous type
556
563
  if (prevType !== currType) {
564
+ // Detect scalar -> array transition of same base type. PG can't implicit-cast,
565
+ // so we add a USING clause that wraps the existing value in a 1-element array
566
+ // (or NULL if the column is NULL). This heals databases broken by an earlier
567
+ // enum-array bug (Bug 2) where the column was created as the scalar type.
568
+ // We intentionally do NOT handle the reverse direction (array -> scalar) — that
569
+ // is lossy and the user must decide which element to keep.
570
+ const isScalarToArray =
571
+ currType.endsWith("[]") && !prevType.endsWith("[]") &&
572
+ currType.slice(0, -2) === prevType;
573
+
574
+ const usingClause = isScalarToArray
575
+ ? ` USING CASE WHEN ${q({ name: colName })} IS NULL THEN NULL ELSE ARRAY[${q({ name: colName })}] END`
576
+ : "";
577
+
557
578
  ops.push({
558
579
  type: "alterColumnType",
559
- sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${currType};`,
580
+ sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${currType}${usingClause};`,
560
581
  downSql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${prevType};`,
561
- isDestructive: true,
582
+ isDestructive: !isScalarToArray, // scalar -> array is non-destructive (data preserved as 1-element arrays)
562
583
  isReversible: true,
563
584
  description: `Change type of "${fieldName}" in "${name}" from ${prevType} to ${currType}`,
564
585
  });
@@ -709,7 +730,7 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
709
730
  );
710
731
 
711
732
  for (const [key, rel] of currFKs) {
712
- if (prevFKs.has(key)) continue;
733
+ const prev = prevFKs.get(key);
713
734
  const fkCols = rel.relation.fields.map((f: string) => q({ name: resolveFieldDbName({ model: currModel, fieldName: f }) })).join(", ");
714
735
  const relatedModel = current.models.find((m: Model) => m.name === rel.relatedModel);
715
736
  const relatedTableName = relatedModel?.dbName ?? rel.relatedModel;
@@ -719,14 +740,43 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
719
740
  }).join(", ");
720
741
  const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model: currModel, fieldName: f }));
721
742
  const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
722
- ops.push({
723
- type: "addForeignKey",
724
- sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ON DELETE RESTRICT ON UPDATE CASCADE;`,
725
- downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
726
- isDestructive: false,
727
- isReversible: true,
728
- description: `Add foreign key on "${name}" (${rel.relation.fields.join(", ")}) → "${rel.relatedModel}"`,
729
- });
743
+ const actionClause = buildFkActionClause({ rel });
744
+
745
+ if (!prev) {
746
+ ops.push({
747
+ type: "addForeignKey",
748
+ sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
749
+ downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
750
+ isDestructive: false,
751
+ isReversible: true,
752
+ description: `Add foreign key on "${name}" (${rel.relation.fields.join(", ")}) → "${rel.relatedModel}"`,
753
+ });
754
+ continue;
755
+ }
756
+
757
+ // FK exists on both sides — detect cascade-action drift. Postgres has no
758
+ // `ALTER CONSTRAINT ... ON DELETE` for FKs, so we emit a DROP + ADD pair.
759
+ const prevFingerprint = fkActionFingerprint({ rel: prev });
760
+ const currFingerprint = fkActionFingerprint({ rel });
761
+ if (prevFingerprint !== currFingerprint) {
762
+ const prevActionClause = buildFkActionClause({ rel: prev });
763
+ ops.push({
764
+ type: "dropForeignKey",
765
+ sql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
766
+ downSql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${prevActionClause};`,
767
+ isDestructive: false,
768
+ isReversible: true,
769
+ description: `Drop foreign key on "${name}" (${rel.relation.fields.join(", ")}) to change cascade actions`,
770
+ });
771
+ ops.push({
772
+ type: "addForeignKey",
773
+ sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
774
+ downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
775
+ isDestructive: false,
776
+ isReversible: true,
777
+ description: `Re-add foreign key on "${name}" (${rel.relation.fields.join(", ")}) with new cascade actions (${currFingerprint.replace("|", " / ")})`,
778
+ });
779
+ }
730
780
  }
731
781
 
732
782
  for (const [key, rel] of prevFKs) {
@@ -740,10 +790,11 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
740
790
  }).join(", ");
741
791
  const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
742
792
  const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
793
+ const actionClause = buildFkActionClause({ rel });
743
794
  ops.push({
744
795
  type: "dropForeignKey",
745
796
  sql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
746
- downSql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ON DELETE RESTRICT ON UPDATE CASCADE;`,
797
+ downSql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
747
798
  isDestructive: true,
748
799
  isReversible: true,
749
800
  description: `Drop foreign key on "${name}" (${rel.relation.fields.join(", ")}) → "${rel.relatedModel}"`,
@@ -14,7 +14,9 @@ import type {
14
14
  RelationField,
15
15
  Enum,
16
16
  DefaultValue,
17
+ CascadeAction,
17
18
  } from "@vibeorm/parser";
19
+ import { resolveOnDelete, resolveOnUpdate } from "./cascade-actions.ts";
18
20
 
19
21
  // ─── Helpers ──────────────────────────────────────────────────────
20
22
 
@@ -105,12 +107,21 @@ function printRelationField(params: { field: RelationField }): string {
105
107
  if (field.relation.isForeignKey && field.relation.fields.length > 0) {
106
108
  const fieldsStr = field.relation.fields.map((f: string) => f).join(", ");
107
109
  const refsStr = field.relation.references.map((f: string) => f).join(", ");
108
- let relAttr = `@relation(fields: [${fieldsStr}], references: [${refsStr}]`;
109
- if (field.relation.name) {
110
- relAttr = `@relation("${field.relation.name}", fields: [${fieldsStr}], references: [${refsStr}]`;
111
- }
112
- relAttr += ")";
113
- parts.push(relAttr);
110
+
111
+ // Only emit cascade actions when they differ from the Prisma default for
112
+ // this relation's optionality. Keeps `db pull` output minimal and matches
113
+ // what users would have hand-written.
114
+ const explicitOnDelete = nonDefaultOnDelete({ field });
115
+ const explicitOnUpdate = nonDefaultOnUpdate({ field });
116
+
117
+ const segments: string[] = [];
118
+ if (field.relation.name) segments.push(`"${field.relation.name}"`);
119
+ segments.push(`fields: [${fieldsStr}]`);
120
+ segments.push(`references: [${refsStr}]`);
121
+ if (explicitOnDelete) segments.push(`onDelete: ${explicitOnDelete}`);
122
+ if (explicitOnUpdate) segments.push(`onUpdate: ${explicitOnUpdate}`);
123
+
124
+ parts.push(`@relation(${segments.join(", ")})`);
114
125
  } else if (field.relation.name) {
115
126
  parts.push(`@relation("${field.relation.name}")`);
116
127
  }
@@ -118,6 +129,28 @@ function printRelationField(params: { field: RelationField }): string {
118
129
  return indent({ text: parts.join(" ") });
119
130
  }
120
131
 
132
+ /**
133
+ * Returns the onDelete action only if it differs from the Prisma default for
134
+ * this relation's optionality. Returns `undefined` when the action is the
135
+ * default (in which case we omit it from the printed `@relation(...)`).
136
+ */
137
+ function nonDefaultOnDelete(params: { field: RelationField }): CascadeAction | undefined {
138
+ const { field } = params;
139
+ if (field.relation.onDelete === undefined) return undefined;
140
+ const effectiveDefault = resolveOnDelete({
141
+ explicit: undefined,
142
+ isRequired: field.isRequired,
143
+ });
144
+ return field.relation.onDelete === effectiveDefault ? undefined : field.relation.onDelete;
145
+ }
146
+
147
+ function nonDefaultOnUpdate(params: { field: RelationField }): CascadeAction | undefined {
148
+ const { field } = params;
149
+ if (field.relation.onUpdate === undefined) return undefined;
150
+ const effectiveDefault = resolveOnUpdate({ explicit: undefined });
151
+ return field.relation.onUpdate === effectiveDefault ? undefined : field.relation.onUpdate;
152
+ }
153
+
121
154
  function printField(params: { field: Field }): string {
122
155
  switch (params.field.kind) {
123
156
  case "scalar":