@vibeorm/migrate 1.1.0 → 1.1.2

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.0",
3
+ "version": "1.1.2",
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.0"
40
+ "@vibeorm/parser": "1.1.2"
41
41
  }
42
42
  }
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export {
30
30
  applyMigration,
31
31
  removeMigrationRecord,
32
32
  markMigrationApplied,
33
+ rollbackMigration,
33
34
  } from "./migration-runner.ts";
34
35
  export type { AppliedMigration } from "./migration-runner.ts";
35
36
 
@@ -422,6 +422,7 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
422
422
  isId,
423
423
  isUnique,
424
424
  default: parsedDefault,
425
+ documentation: undefined,
425
426
  };
426
427
  fields.push(enumField);
427
428
  } else {
@@ -444,6 +445,7 @@ export async function introspect(params: { executor: SqlExecutor }): Promise<Sch
444
445
  isUpdatedAt: false, // Can't detect @updatedAt from DB
445
446
  default: parsedDefault,
446
447
  nativeType: undefined,
448
+ documentation: undefined,
447
449
  };
448
450
  fields.push(scalarField);
449
451
  }
@@ -101,3 +101,27 @@ export async function removeMigrationRecord(params: {
101
101
  values: [migrationName],
102
102
  });
103
103
  }
104
+
105
+ // ─── Rollback Migration ───────────────────────────────────────────
106
+
107
+ export async function rollbackMigration(params: {
108
+ executor: SqlExecutor;
109
+ migrationName: string;
110
+ sql: string;
111
+ }): Promise<{ executionTime: number }> {
112
+ const { executor, migrationName, sql } = params;
113
+ const start = Date.now();
114
+
115
+ // Split down migration SQL into individual statements
116
+ const statements = splitSqlStatements({ sql });
117
+ for (const stmt of statements) {
118
+ await executor({ text: stmt });
119
+ }
120
+
121
+ const executionTime = Date.now() - start;
122
+
123
+ // Remove the migration tracking record
124
+ await removeMigrationRecord({ executor, migrationName });
125
+
126
+ return { executionTime };
127
+ }
@@ -44,7 +44,11 @@ export type DiffOperationType =
44
44
  export type DiffOperation = {
45
45
  type: DiffOperationType;
46
46
  sql: string;
47
+ /** Reverse SQL to undo this operation (for down migrations) */
48
+ downSql: string;
47
49
  isDestructive: boolean;
50
+ /** Whether this operation can be cleanly reversed without data loss */
51
+ isReversible: boolean;
48
52
  description: string;
49
53
  };
50
54
 
@@ -225,20 +229,27 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
225
229
  ops.push({
226
230
  type: "createEnum",
227
231
  sql: `DO $$ BEGIN\n CREATE TYPE ${q({ name: typeName })} AS ENUM (${values});\nEXCEPTION WHEN duplicate_object THEN NULL;\nEND $$;`,
232
+ downSql: `DROP TYPE IF EXISTS ${q({ name: typeName })};`,
228
233
  isDestructive: false,
234
+ isReversible: true,
229
235
  description: `Create enum type "${name}"`,
230
236
  });
231
237
  }
232
238
  }
233
239
 
234
- // Dropped enums
240
+ // Dropped enums — reverse is recreating the enum with its previous values
235
241
  for (const [name, enumDef] of prevEnumMap) {
236
242
  if (!currEnumMap.has(name)) {
237
243
  const typeName = enumDef.dbName ?? enumDef.name;
244
+ const values = enumDef.values
245
+ .map((v) => `'${(v.dbName ?? v.name).replace(/'/g, "''")}'`)
246
+ .join(", ");
238
247
  ops.push({
239
248
  type: "dropEnum",
240
249
  sql: `DROP TYPE IF EXISTS ${q({ name: typeName })};`,
250
+ downSql: `DO $$ BEGIN\n CREATE TYPE ${q({ name: typeName })} AS ENUM (${values});\nEXCEPTION WHEN duplicate_object THEN NULL;\nEND $$;`,
241
251
  isDestructive: true,
252
+ isReversible: true,
242
253
  description: `Drop enum type "${name}"`,
243
254
  });
244
255
  }
@@ -253,14 +264,16 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
253
264
  const prevValues = new Set(prevEnum.values.map((v) => v.name));
254
265
  const currValues = new Set(currEnum.values.map((v) => v.name));
255
266
 
256
- // Added values
267
+ // Added values — reverse requires type recreation (PG cannot DROP VALUE)
257
268
  for (const val of currEnum.values) {
258
269
  if (!prevValues.has(val.name)) {
259
270
  const dbVal = val.dbName ?? val.name;
260
271
  ops.push({
261
272
  type: "addEnumValue",
262
273
  sql: `ALTER TYPE ${q({ name: typeName })} ADD VALUE IF NOT EXISTS '${dbVal.replace(/'/g, "''")}';`,
274
+ downSql: `-- WARNING: PostgreSQL does not support removing enum values directly.\n-- Value "${val.name}" was added to enum "${name}" and cannot be cleanly removed.\n-- Manual type recreation may be required.`,
263
275
  isDestructive: false,
276
+ isReversible: false,
264
277
  description: `Add value "${val.name}" to enum "${name}"`,
265
278
  });
266
279
  }
@@ -269,10 +282,13 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
269
282
  // Removed values (destructive — PG doesn't support DROP VALUE easily)
270
283
  for (const val of prevEnum.values) {
271
284
  if (!currValues.has(val.name)) {
285
+ const dbVal = val.dbName ?? val.name;
272
286
  ops.push({
273
287
  type: "removeEnumValue",
274
288
  sql: `-- WARNING: PostgreSQL does not support removing enum values directly.\n-- You need to recreate the type. Value "${val.name}" removed from enum "${name}".`,
289
+ downSql: `ALTER TYPE ${q({ name: typeName })} ADD VALUE IF NOT EXISTS '${dbVal.replace(/'/g, "''")}';`,
275
290
  isDestructive: true,
291
+ isReversible: true,
276
292
  description: `Remove value "${val.name}" from enum "${name}" (requires type recreation)`,
277
293
  });
278
294
  }
@@ -318,7 +334,9 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
318
334
  ops.push({
319
335
  type: "createTable",
320
336
  sql: `CREATE TABLE ${q({ name: tableName })} (\n${columns.join(",\n")}\n);`,
337
+ downSql: `DROP TABLE IF EXISTS ${q({ name: tableName })} CASCADE;`,
321
338
  isDestructive: false,
339
+ isReversible: true,
322
340
  description: `Create table "${name}"`,
323
341
  });
324
342
  }
@@ -337,7 +355,9 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
337
355
  ops.push({
338
356
  type: "addUnique",
339
357
  sql: `CREATE UNIQUE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
358
+ downSql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
340
359
  isDestructive: false,
360
+ isReversible: true,
341
361
  description: `Add unique constraint on (${uc.fields.join(", ")}) in "${name}"`,
342
362
  });
343
363
  }
@@ -350,7 +370,9 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
350
370
  ops.push({
351
371
  type: "addIndex",
352
372
  sql: `CREATE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
373
+ downSql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
353
374
  isDestructive: false,
375
+ isReversible: true,
354
376
  description: `Add index on (${idx.fields.join(", ")}) in "${name}"`,
355
377
  });
356
378
  }
@@ -371,19 +393,50 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
371
393
  ops.push({
372
394
  type: "addForeignKey",
373
395
  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;`,
396
+ downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
374
397
  isDestructive: false,
398
+ isReversible: true,
375
399
  description: `Add foreign key on "${name}" (${rel.relation.fields.join(", ")}) \u2192 "${rel.relatedModel}"`,
376
400
  });
377
401
  }
378
402
  }
379
403
 
380
- // Dropped tables
404
+ // Dropped tables — reverse is recreating the table from the previous schema
381
405
  for (const [name, model] of prevModelMap) {
382
406
  if (currModelMap.has(name)) continue;
407
+
408
+ // Build the CREATE TABLE statement for the down migration
409
+ const dropTableName = model.dbName;
410
+ const downColumns: string[] = [];
411
+ for (const field of getScalarFields({ model })) {
412
+ const colName = field.dbName;
413
+ const colType = resolveColumnType({ field, enums: previous.enums });
414
+ let colDef = ` ${q({ name: colName })} ${colType}`;
415
+ if (field.kind === "scalar" && field.isRequired && field.default?.kind !== "autoincrement") {
416
+ colDef += " NOT NULL";
417
+ } else if (field.kind === "enum" && field.isRequired) {
418
+ colDef += " NOT NULL";
419
+ }
420
+ const defaultExpr = resolveDefault({ field });
421
+ if (defaultExpr) colDef += ` DEFAULT ${defaultExpr}`;
422
+ if (field.isUnique) colDef += " UNIQUE";
423
+ downColumns.push(colDef);
424
+ }
425
+ const downPkFields = model.primaryKey.fields
426
+ .map((f: string) => {
427
+ const fld = model.fields.find((mf: Field) => mf.name === f);
428
+ if (fld && fld.kind !== "relation") return q({ name: fld.dbName });
429
+ return q({ name: f });
430
+ })
431
+ .join(", ");
432
+ downColumns.push(` CONSTRAINT ${q({ name: `${dropTableName}_pkey` })} PRIMARY KEY (${downPkFields})`);
433
+
383
434
  ops.push({
384
435
  type: "dropTable",
385
436
  sql: `DROP TABLE IF EXISTS ${q({ name: model.dbName })} CASCADE;`,
437
+ downSql: `CREATE TABLE ${q({ name: dropTableName })} (\n${downColumns.join(",\n")}\n);`,
386
438
  isDestructive: true,
439
+ isReversible: true,
387
440
  description: `Drop table "${name}"`,
388
441
  });
389
442
  }
@@ -402,7 +455,7 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
402
455
  getScalarFields({ model: currModel }).map((f) => [f.dbName, f])
403
456
  );
404
457
 
405
- // Added columns
458
+ // Added columns — reverse is dropping the column
406
459
  for (const [fieldName, field] of currFields) {
407
460
  if (prevFields.has(fieldName)) continue;
408
461
 
@@ -424,18 +477,32 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
424
477
  ops.push({
425
478
  type: "addColumn",
426
479
  sql: `ALTER TABLE ${q({ name: tableName })} ADD COLUMN ${q({ name: colName })} ${colDef};`,
480
+ downSql: `ALTER TABLE ${q({ name: tableName })} DROP COLUMN IF EXISTS ${q({ name: colName })};`,
427
481
  isDestructive,
482
+ isReversible: true,
428
483
  description: `Add column "${fieldName}" to "${name}"`,
429
484
  });
430
485
  }
431
486
 
432
- // Dropped columns
487
+ // Dropped columns — reverse is re-adding the column with its previous definition
433
488
  for (const [fieldName, field] of prevFields) {
434
489
  if (currFields.has(fieldName)) continue;
490
+
491
+ const colName = field.dbName;
492
+ const colType = resolveColumnType({ field, enums: previous.enums });
493
+ const defaultExpr = resolveDefault({ field });
494
+ // Down migration always adds the column back as nullable because
495
+ // the original data is lost after DROP COLUMN — adding NOT NULL
496
+ // would fail if the table has existing rows with null values.
497
+ let downColDef = `${colType}`;
498
+ if (defaultExpr) downColDef += ` DEFAULT ${defaultExpr}`;
499
+
435
500
  ops.push({
436
501
  type: "dropColumn",
437
502
  sql: `ALTER TABLE ${q({ name: tableName })} DROP COLUMN IF EXISTS ${q({ name: field.dbName })};`,
503
+ downSql: `ALTER TABLE ${q({ name: tableName })} ADD COLUMN ${q({ name: colName })} ${downColDef};`,
438
504
  isDestructive: true,
505
+ isReversible: true,
439
506
  description: `Drop column "${fieldName}" from "${name}"`,
440
507
  });
441
508
  }
@@ -449,51 +516,67 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
449
516
  const prevType = resolveColumnType({ field: prevField, enums: previous.enums });
450
517
  const currType = resolveColumnType({ field: currField, enums: current.enums });
451
518
 
452
- // Type change
519
+ // Type change — reverse is setting back to the previous type
453
520
  if (prevType !== currType) {
454
521
  ops.push({
455
522
  type: "alterColumnType",
456
523
  sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${currType};`,
524
+ downSql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${prevType};`,
457
525
  isDestructive: true,
526
+ isReversible: true,
458
527
  description: `Change type of "${fieldName}" in "${name}" from ${prevType} to ${currType}`,
459
528
  });
460
529
  }
461
530
 
462
- // Nullability change
531
+ // Nullability change — reverse is toggling back
463
532
  if (prevField.isRequired !== currField.isRequired) {
464
533
  if (currField.isRequired) {
465
534
  ops.push({
466
535
  type: "alterColumnNullability",
467
536
  sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET NOT NULL;`,
537
+ downSql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} DROP NOT NULL;`,
468
538
  isDestructive: true,
539
+ isReversible: true,
469
540
  description: `Set NOT NULL on "${fieldName}" in "${name}"`,
470
541
  });
471
542
  } else {
472
543
  ops.push({
473
544
  type: "alterColumnNullability",
474
545
  sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} DROP NOT NULL;`,
546
+ downSql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET NOT NULL;`,
475
547
  isDestructive: false,
548
+ isReversible: true,
476
549
  description: `Drop NOT NULL on "${fieldName}" in "${name}"`,
477
550
  });
478
551
  }
479
552
  }
480
553
 
481
- // Default change
554
+ // Default change — reverse is restoring the previous default
482
555
  const prevDefault = resolveDefault({ field: prevField });
483
556
  const currDefault = resolveDefault({ field: currField });
484
557
  if (prevDefault !== currDefault) {
485
558
  if (currDefault) {
559
+ const downDefaultSql = prevDefault
560
+ ? `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DEFAULT ${prevDefault};`
561
+ : `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} DROP DEFAULT;`;
486
562
  ops.push({
487
563
  type: "alterColumnDefault",
488
564
  sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DEFAULT ${currDefault};`,
565
+ downSql: downDefaultSql,
489
566
  isDestructive: false,
567
+ isReversible: true,
490
568
  description: `Set default on "${fieldName}" in "${name}" to ${currDefault}`,
491
569
  });
492
570
  } else {
571
+ const downDefaultSql = prevDefault
572
+ ? `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DEFAULT ${prevDefault};`
573
+ : `-- No previous default to restore`;
493
574
  ops.push({
494
575
  type: "alterColumnDefault",
495
576
  sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} DROP DEFAULT;`,
577
+ downSql: downDefaultSql,
496
578
  isDestructive: false,
579
+ isReversible: true,
497
580
  description: `Drop default on "${fieldName}" in "${name}"`,
498
581
  });
499
582
  }
@@ -517,19 +600,24 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
517
600
  ops.push({
518
601
  type: "addUnique",
519
602
  sql: `CREATE UNIQUE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
603
+ downSql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
520
604
  isDestructive: false,
605
+ isReversible: true,
521
606
  description: `Add unique constraint on (${uc.fields.join(", ")}) in "${name}"`,
522
607
  });
523
608
  }
524
609
 
525
610
  for (const [key, uc] of prevUniques) {
526
611
  if (currUniques.has(key)) continue;
612
+ const cols = uc.fields.map((f: string) => q({ name: resolveFieldDbName({ model: prevModel, fieldName: f }) })).join(", ");
527
613
  const resolvedFieldNames = uc.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
528
614
  const idxName = uc.name ?? `${tableName}_${resolvedFieldNames.join("_")}_key`;
529
615
  ops.push({
530
616
  type: "dropUnique",
531
617
  sql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
618
+ downSql: `CREATE UNIQUE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
532
619
  isDestructive: true,
620
+ isReversible: true,
533
621
  description: `Drop unique constraint on (${uc.fields.join(", ")}) in "${name}"`,
534
622
  });
535
623
  }
@@ -551,19 +639,24 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
551
639
  ops.push({
552
640
  type: "addIndex",
553
641
  sql: `CREATE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
642
+ downSql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
554
643
  isDestructive: false,
644
+ isReversible: true,
555
645
  description: `Add index on (${idx.fields.join(", ")}) in "${name}"`,
556
646
  });
557
647
  }
558
648
 
559
649
  for (const [key, idx] of prevIndexes) {
560
650
  if (currIndexes.has(key)) continue;
651
+ const cols = idx.fields.map((f: string) => q({ name: resolveFieldDbName({ model: prevModel, fieldName: f }) })).join(", ");
561
652
  const resolvedFieldNames = idx.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
562
653
  const idxName = idx.name ?? `${tableName}_${resolvedFieldNames.join("_")}_idx`;
563
654
  ops.push({
564
655
  type: "dropIndex",
565
656
  sql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
657
+ downSql: `CREATE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
566
658
  isDestructive: true,
659
+ isReversible: true,
567
660
  description: `Drop index on (${idx.fields.join(", ")}) in "${name}"`,
568
661
  });
569
662
  }
@@ -595,19 +688,30 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
595
688
  ops.push({
596
689
  type: "addForeignKey",
597
690
  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;`,
691
+ downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
598
692
  isDestructive: false,
693
+ isReversible: true,
599
694
  description: `Add foreign key on "${name}" (${rel.relation.fields.join(", ")}) → "${rel.relatedModel}"`,
600
695
  });
601
696
  }
602
697
 
603
698
  for (const [key, rel] of prevFKs) {
604
699
  if (currFKs.has(key)) continue;
700
+ const fkCols = rel.relation.fields.map((f: string) => q({ name: resolveFieldDbName({ model: prevModel, fieldName: f }) })).join(", ");
701
+ const relatedModel = previous.models.find((m: Model) => m.name === rel.relatedModel);
702
+ const relatedTableName = relatedModel?.dbName ?? rel.relatedModel;
703
+ const refCols = rel.relation.references.map((f: string) => {
704
+ if (relatedModel) return q({ name: resolveFieldDbName({ model: relatedModel, fieldName: f }) });
705
+ return q({ name: f });
706
+ }).join(", ");
605
707
  const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
606
708
  const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
607
709
  ops.push({
608
710
  type: "dropForeignKey",
609
711
  sql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
712
+ 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;`,
610
713
  isDestructive: true,
714
+ isReversible: true,
611
715
  description: `Drop foreign key on "${name}" (${rel.relation.fields.join(", ")}) → "${rel.relatedModel}"`,
612
716
  });
613
717
  }
@@ -654,17 +758,50 @@ export function diffSchemas(params: { previous: Schema; current: Schema }): Diff
654
758
  `ALTER TABLE ${q({ name: jt.tableName })} ADD CONSTRAINT ${q({ name: `${jt.tableName}_A_fkey` })} FOREIGN KEY ("A") REFERENCES ${q({ name: tableA })} ("${pkFieldA}") ON DELETE CASCADE ON UPDATE CASCADE;`,
655
759
  `ALTER TABLE ${q({ name: jt.tableName })} ADD CONSTRAINT ${q({ name: `${jt.tableName}_B_fkey` })} FOREIGN KEY ("B") REFERENCES ${q({ name: tableB })} ("${pkFieldB}") ON DELETE CASCADE ON UPDATE CASCADE;`,
656
760
  ].join("\n"),
761
+ downSql: `DROP TABLE IF EXISTS ${q({ name: jt.tableName })} CASCADE;`,
657
762
  isDestructive: false,
763
+ isReversible: true,
658
764
  description: `Create join table "${jt.tableName}" for ${jt.modelA} ↔ ${jt.modelB}`,
659
765
  });
660
766
  }
661
767
 
662
768
  for (const jt of prevJoinTables) {
663
769
  if (currJoinSet.has(jt.tableName)) continue;
770
+
771
+ // For the down migration, reconstruct the join table from the previous schema
772
+ const prevModelADef = prevModelMap.get(jt.modelA);
773
+ const prevModelBDef = prevModelMap.get(jt.modelB);
774
+ let downJoinSql = `-- WARNING: Cannot fully reconstruct join table "${jt.tableName}". Manual intervention required.`;
775
+
776
+ if (prevModelADef && prevModelBDef) {
777
+ const prevPkFieldA = prevModelADef.primaryKey.fields[0]!;
778
+ const prevPkFieldB = prevModelBDef.primaryKey.fields[0]!;
779
+ const prevPkFieldDefA = prevModelADef.fields.find((f: Field) => f.name === prevPkFieldA);
780
+ const prevPkFieldDefB = prevModelBDef.fields.find((f: Field) => f.name === prevPkFieldB);
781
+ const prevPkTypeA = prevPkFieldDefA && (prevPkFieldDefA.kind === "scalar" || prevPkFieldDefA.kind === "enum")
782
+ ? resolveBaseColumnType({ field: prevPkFieldDefA, enums: previous.enums })
783
+ : "INTEGER";
784
+ const prevPkTypeB = prevPkFieldDefB && (prevPkFieldDefB.kind === "scalar" || prevPkFieldDefB.kind === "enum")
785
+ ? resolveBaseColumnType({ field: prevPkFieldDefB, enums: previous.enums })
786
+ : "INTEGER";
787
+ const prevTableA = prevModelADef.dbName;
788
+ const prevTableB = prevModelBDef.dbName;
789
+
790
+ downJoinSql = [
791
+ `CREATE TABLE ${q({ name: jt.tableName })} (\n "A" ${prevPkTypeA} NOT NULL,\n "B" ${prevPkTypeB} NOT NULL\n);`,
792
+ `CREATE UNIQUE INDEX ${q({ name: `${jt.tableName}_AB_unique` })} ON ${q({ name: jt.tableName })} ("A", "B");`,
793
+ `CREATE INDEX ${q({ name: `${jt.tableName}_B_index` })} ON ${q({ name: jt.tableName })} ("B");`,
794
+ `ALTER TABLE ${q({ name: jt.tableName })} ADD CONSTRAINT ${q({ name: `${jt.tableName}_A_fkey` })} FOREIGN KEY ("A") REFERENCES ${q({ name: prevTableA })} ("${prevPkFieldA}") ON DELETE CASCADE ON UPDATE CASCADE;`,
795
+ `ALTER TABLE ${q({ name: jt.tableName })} ADD CONSTRAINT ${q({ name: `${jt.tableName}_B_fkey` })} FOREIGN KEY ("B") REFERENCES ${q({ name: prevTableB })} ("${prevPkFieldB}") ON DELETE CASCADE ON UPDATE CASCADE;`,
796
+ ].join("\n");
797
+ }
798
+
664
799
  ops.push({
665
800
  type: "dropJoinTable",
666
801
  sql: `DROP TABLE IF EXISTS ${q({ name: jt.tableName })} CASCADE;`,
802
+ downSql: downJoinSql,
667
803
  isDestructive: true,
804
+ isReversible: prevModelADef !== undefined && prevModelBDef !== undefined,
668
805
  description: `Drop join table "${jt.tableName}" for ${jt.modelA} ↔ ${jt.modelB}`,
669
806
  });
670
807
  }