@vibeorm/migrate 1.1.9 → 1.2.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.2.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.2.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)!;
@@ -473,6 +510,8 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
473
510
  relatedModel: group.foreignTable,
474
511
  type: isOneToOne ? "oneToOne" : "manyToOne",
475
512
  isForeignKey: true,
513
+ onDelete: group.onDelete,
514
+ onUpdate: group.onUpdate,
476
515
  },
477
516
  };
478
517
  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
 
@@ -426,9 +427,10 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
426
427
  }).join(", ");
427
428
  const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model, fieldName: f }));
428
429
  const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
430
+ const actionClause = buildFkActionClause({ rel });
429
431
  ops.push({
430
432
  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;`,
433
+ sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
432
434
  downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
433
435
  isDestructive: false,
434
436
  isReversible: true,
@@ -709,7 +711,7 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
709
711
  );
710
712
 
711
713
  for (const [key, rel] of currFKs) {
712
- if (prevFKs.has(key)) continue;
714
+ const prev = prevFKs.get(key);
713
715
  const fkCols = rel.relation.fields.map((f: string) => q({ name: resolveFieldDbName({ model: currModel, fieldName: f }) })).join(", ");
714
716
  const relatedModel = current.models.find((m: Model) => m.name === rel.relatedModel);
715
717
  const relatedTableName = relatedModel?.dbName ?? rel.relatedModel;
@@ -719,14 +721,43 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
719
721
  }).join(", ");
720
722
  const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model: currModel, fieldName: f }));
721
723
  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
- });
724
+ const actionClause = buildFkActionClause({ rel });
725
+
726
+ if (!prev) {
727
+ ops.push({
728
+ type: "addForeignKey",
729
+ sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
730
+ downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
731
+ isDestructive: false,
732
+ isReversible: true,
733
+ description: `Add foreign key on "${name}" (${rel.relation.fields.join(", ")}) → "${rel.relatedModel}"`,
734
+ });
735
+ continue;
736
+ }
737
+
738
+ // FK exists on both sides — detect cascade-action drift. Postgres has no
739
+ // `ALTER CONSTRAINT ... ON DELETE` for FKs, so we emit a DROP + ADD pair.
740
+ const prevFingerprint = fkActionFingerprint({ rel: prev });
741
+ const currFingerprint = fkActionFingerprint({ rel });
742
+ if (prevFingerprint !== currFingerprint) {
743
+ const prevActionClause = buildFkActionClause({ rel: prev });
744
+ ops.push({
745
+ type: "dropForeignKey",
746
+ sql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
747
+ downSql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${prevActionClause};`,
748
+ isDestructive: false,
749
+ isReversible: true,
750
+ description: `Drop foreign key on "${name}" (${rel.relation.fields.join(", ")}) to change cascade actions`,
751
+ });
752
+ ops.push({
753
+ type: "addForeignKey",
754
+ sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
755
+ downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
756
+ isDestructive: false,
757
+ isReversible: true,
758
+ description: `Re-add foreign key on "${name}" (${rel.relation.fields.join(", ")}) with new cascade actions (${currFingerprint.replace("|", " / ")})`,
759
+ });
760
+ }
730
761
  }
731
762
 
732
763
  for (const [key, rel] of prevFKs) {
@@ -740,10 +771,11 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
740
771
  }).join(", ");
741
772
  const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
742
773
  const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
774
+ const actionClause = buildFkActionClause({ rel });
743
775
  ops.push({
744
776
  type: "dropForeignKey",
745
777
  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;`,
778
+ downSql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
747
779
  isDestructive: true,
748
780
  isReversible: true,
749
781
  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":