@vibeorm/migrate 1.0.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.
@@ -0,0 +1,673 @@
1
+ /**
2
+ * Schema Differ — Diff Two Schema IRs → Migration Operations
3
+ *
4
+ * Compares a "previous" and "current" Schema IR and produces
5
+ * an ordered list of DDL operations to migrate between them.
6
+ */
7
+
8
+ import type {
9
+ Schema,
10
+ Model,
11
+ Field,
12
+ ScalarField,
13
+ EnumField,
14
+ RelationField,
15
+ Enum,
16
+ UniqueConstraint,
17
+ IndexDefinition,
18
+ } from "@vibeorm/parser";
19
+ import { PRISMA_TO_PG } from "@vibeorm/parser";
20
+
21
+ // ─── Types ────────────────────────────────────────────────────────
22
+
23
+ export type DiffOperationType =
24
+ | "createEnum"
25
+ | "addEnumValue"
26
+ | "removeEnumValue"
27
+ | "dropEnum"
28
+ | "createTable"
29
+ | "dropTable"
30
+ | "addColumn"
31
+ | "dropColumn"
32
+ | "alterColumnType"
33
+ | "alterColumnNullability"
34
+ | "alterColumnDefault"
35
+ | "addUnique"
36
+ | "dropUnique"
37
+ | "addIndex"
38
+ | "dropIndex"
39
+ | "addForeignKey"
40
+ | "dropForeignKey"
41
+ | "createJoinTable"
42
+ | "dropJoinTable";
43
+
44
+ export type DiffOperation = {
45
+ type: DiffOperationType;
46
+ sql: string;
47
+ isDestructive: boolean;
48
+ description: string;
49
+ };
50
+
51
+ // ─── Helpers ──────────────────────────────────────────────────────
52
+
53
+ function q(params: { name: string }): string {
54
+ return `"${params.name}"`;
55
+ }
56
+
57
+ function getScalarFields(params: { model: Model }): (ScalarField | EnumField)[] {
58
+ return params.model.fields.filter(
59
+ (f): f is ScalarField | EnumField => f.kind === "scalar" || f.kind === "enum"
60
+ );
61
+ }
62
+
63
+ function getRelationFields(params: { model: Model }): RelationField[] {
64
+ return params.model.fields.filter((f): f is RelationField => f.kind === "relation");
65
+ }
66
+
67
+ function resolveColumnType(params: { field: ScalarField | EnumField; enums: Enum[] }): string {
68
+ const { field, enums } = params;
69
+ if (field.kind === "enum") {
70
+ const enumDef = enums.find((e) => e.name === field.enumName);
71
+ return q({ name: enumDef?.dbName ?? field.enumName });
72
+ }
73
+ if (field.default?.kind === "autoincrement") {
74
+ return field.prismaType === "BigInt" ? "BIGSERIAL" : "SERIAL";
75
+ }
76
+ if (field.nativeType) return field.nativeType;
77
+ if (field.isList) return `${PRISMA_TO_PG[field.prismaType]}[]`;
78
+ return PRISMA_TO_PG[field.prismaType];
79
+ }
80
+
81
+ /** Resolve to the base column type (no SERIAL — used for FK reference columns in join tables) */
82
+ function resolveBaseColumnType(params: { field: ScalarField | EnumField; enums: Enum[] }): string {
83
+ const { field, enums } = params;
84
+ if (field.kind === "enum") {
85
+ const enumDef = enums.find((e) => e.name === field.enumName);
86
+ return q({ name: enumDef?.dbName ?? field.enumName });
87
+ }
88
+ if (field.nativeType) return field.nativeType;
89
+ if (field.isList) return `${PRISMA_TO_PG[field.prismaType]}[]`;
90
+ return PRISMA_TO_PG[field.prismaType];
91
+ }
92
+
93
+ function resolveDefault(params: { field: ScalarField | EnumField }): string | undefined {
94
+ const { field } = params;
95
+ const def = field.default;
96
+ if (!def) return undefined;
97
+ switch (def.kind) {
98
+ case "autoincrement": return undefined;
99
+ case "now": return "CURRENT_TIMESTAMP";
100
+ case "uuid": return "gen_random_uuid()::TEXT";
101
+ case "cuid": return undefined;
102
+ case "nanoid": return undefined;
103
+ case "ulid": return undefined;
104
+ case "dbgenerated": return def.value;
105
+ case "literal": {
106
+ const val = def.value;
107
+ if (typeof val === "string") return `'${val.replace(/'/g, "''")}'`;
108
+ if (typeof val === "boolean") return val ? "true" : "false";
109
+ return String(val);
110
+ }
111
+ default: return undefined;
112
+ }
113
+ }
114
+
115
+ function uniqueKey(params: { uc: UniqueConstraint; model: Model }): string {
116
+ return params.uc.fields
117
+ .map((f: string) => resolveFieldDbName({ model: params.model, fieldName: f }))
118
+ .sort()
119
+ .join(",");
120
+ }
121
+
122
+ function indexKey(params: { idx: IndexDefinition; model: Model }): string {
123
+ return params.idx.fields
124
+ .map((f: string) => resolveFieldDbName({ model: params.model, fieldName: f }))
125
+ .sort()
126
+ .join(",");
127
+ }
128
+
129
+ function fkKey(params: { rel: RelationField; model: Model; allModels: Model[] }): string {
130
+ const { rel, model, allModels } = params;
131
+ const resolvedFields = rel.relation.fields
132
+ .map((f: string) => resolveFieldDbName({ model, fieldName: f }))
133
+ .join(",");
134
+ const relatedModel = allModels.find((m: Model) => m.name === rel.relatedModel);
135
+ const relatedTableName = relatedModel?.dbName ?? rel.relatedModel;
136
+ const resolvedRefs = rel.relation.references
137
+ .map((f: string) => {
138
+ if (relatedModel) return resolveFieldDbName({ model: relatedModel, fieldName: f });
139
+ return f;
140
+ })
141
+ .join(",");
142
+ return `${resolvedFields}->${relatedTableName}.${resolvedRefs}`;
143
+ }
144
+
145
+ /** Resolve a logical field name to its dbName by looking up the model's fields. */
146
+ function resolveFieldDbName(params: { model: Model; fieldName: string }): string {
147
+ const { model, fieldName } = params;
148
+ const field = model.fields.find((f: Field) => f.name === fieldName);
149
+ if (field && field.kind !== "relation") return field.dbName;
150
+ return fieldName;
151
+ }
152
+
153
+ // ─── Implicit M:N Detection ──────────────────────────────────────
154
+
155
+ type JoinTableInfo = {
156
+ modelA: string;
157
+ modelB: string;
158
+ tableName: string;
159
+ };
160
+
161
+ function detectImplicitManyToMany(params: { schema: Schema }): JoinTableInfo[] {
162
+ const { schema } = params;
163
+ const result: JoinTableInfo[] = [];
164
+ const seen = new Set<string>();
165
+
166
+ for (const model of schema.models) {
167
+ for (const field of model.fields) {
168
+ if (field.kind !== "relation") continue;
169
+ if (!field.isList) continue;
170
+ if (field.relation.isForeignKey) continue;
171
+ if (field.relation.fields.length > 0) continue;
172
+
173
+ const relatedModel = schema.models.find((m: Model) => m.name === field.relatedModel);
174
+ if (!relatedModel) continue;
175
+
176
+ const backRef = relatedModel.fields.find(
177
+ (f: Field) =>
178
+ f.kind === "relation" &&
179
+ f.relatedModel === model.name &&
180
+ f.isList &&
181
+ !f.relation.isForeignKey &&
182
+ f.relation.fields.length === 0
183
+ );
184
+ if (!backRef) continue;
185
+
186
+ const sorted = [model.name, relatedModel.name].sort();
187
+ const nameA = sorted[0]!;
188
+ const nameB = sorted[1]!;
189
+ const key = `${nameA}_${nameB}`;
190
+ if (seen.has(key)) continue;
191
+ seen.add(key);
192
+
193
+ result.push({
194
+ modelA: nameA,
195
+ modelB: nameB,
196
+ tableName: `_${nameA}To${nameB}`,
197
+ });
198
+ }
199
+ }
200
+
201
+ return result;
202
+ }
203
+
204
+ // ─── Main Differ ──────────────────────────────────────────────────
205
+
206
+ export function diffSchemas(params: { previous: Schema; current: Schema }): DiffOperation[] {
207
+ const { previous, current } = params;
208
+ const ops: DiffOperation[] = [];
209
+
210
+ // Match models by dbName so that @@map("table_name") models match introspected models
211
+ const prevModelMap = new Map(previous.models.map((m: Model) => [m.dbName, m]));
212
+ const currModelMap = new Map(current.models.map((m: Model) => [m.dbName, m]));
213
+ const prevEnumMap = new Map(previous.enums.map((e: Enum) => [e.dbName ?? e.name, e]));
214
+ const currEnumMap = new Map(current.enums.map((e: Enum) => [e.dbName ?? e.name, e]));
215
+
216
+ // ─── Enum Diffs ───────────────────────────────────────────────
217
+
218
+ // New enums
219
+ for (const [name, enumDef] of currEnumMap) {
220
+ if (!prevEnumMap.has(name)) {
221
+ const typeName = enumDef.dbName ?? enumDef.name;
222
+ const values = enumDef.values
223
+ .map((v) => `'${(v.dbName ?? v.name).replace(/'/g, "''")}'`)
224
+ .join(", ");
225
+ ops.push({
226
+ type: "createEnum",
227
+ sql: `DO $$ BEGIN\n CREATE TYPE ${q({ name: typeName })} AS ENUM (${values});\nEXCEPTION WHEN duplicate_object THEN NULL;\nEND $$;`,
228
+ isDestructive: false,
229
+ description: `Create enum type "${name}"`,
230
+ });
231
+ }
232
+ }
233
+
234
+ // Dropped enums
235
+ for (const [name, enumDef] of prevEnumMap) {
236
+ if (!currEnumMap.has(name)) {
237
+ const typeName = enumDef.dbName ?? enumDef.name;
238
+ ops.push({
239
+ type: "dropEnum",
240
+ sql: `DROP TYPE IF EXISTS ${q({ name: typeName })};`,
241
+ isDestructive: true,
242
+ description: `Drop enum type "${name}"`,
243
+ });
244
+ }
245
+ }
246
+
247
+ // Modified enums (added/removed values)
248
+ for (const [name, currEnum] of currEnumMap) {
249
+ const prevEnum = prevEnumMap.get(name);
250
+ if (!prevEnum) continue;
251
+
252
+ const typeName = currEnum.dbName ?? currEnum.name;
253
+ const prevValues = new Set(prevEnum.values.map((v) => v.name));
254
+ const currValues = new Set(currEnum.values.map((v) => v.name));
255
+
256
+ // Added values
257
+ for (const val of currEnum.values) {
258
+ if (!prevValues.has(val.name)) {
259
+ const dbVal = val.dbName ?? val.name;
260
+ ops.push({
261
+ type: "addEnumValue",
262
+ sql: `ALTER TYPE ${q({ name: typeName })} ADD VALUE IF NOT EXISTS '${dbVal.replace(/'/g, "''")}';`,
263
+ isDestructive: false,
264
+ description: `Add value "${val.name}" to enum "${name}"`,
265
+ });
266
+ }
267
+ }
268
+
269
+ // Removed values (destructive — PG doesn't support DROP VALUE easily)
270
+ for (const val of prevEnum.values) {
271
+ if (!currValues.has(val.name)) {
272
+ ops.push({
273
+ type: "removeEnumValue",
274
+ sql: `-- WARNING: PostgreSQL does not support removing enum values directly.\n-- You need to recreate the type. Value "${val.name}" removed from enum "${name}".`,
275
+ isDestructive: true,
276
+ description: `Remove value "${val.name}" from enum "${name}" (requires type recreation)`,
277
+ });
278
+ }
279
+ }
280
+ }
281
+
282
+ // ─── Table Diffs ──────────────────────────────────────────────
283
+
284
+ // New tables
285
+ for (const [name, model] of currModelMap) {
286
+ if (prevModelMap.has(name)) continue;
287
+
288
+ const tableName = model.dbName;
289
+ const columns: string[] = [];
290
+
291
+ for (const field of getScalarFields({ model })) {
292
+ const colName = field.dbName;
293
+ const colType = resolveColumnType({ field, enums: current.enums });
294
+ let colDef = ` ${q({ name: colName })} ${colType}`;
295
+
296
+ if (field.kind === "scalar" && field.isRequired && field.default?.kind !== "autoincrement") {
297
+ colDef += " NOT NULL";
298
+ } else if (field.kind === "enum" && field.isRequired) {
299
+ colDef += " NOT NULL";
300
+ }
301
+
302
+ const defaultExpr = resolveDefault({ field });
303
+ if (defaultExpr) colDef += ` DEFAULT ${defaultExpr}`;
304
+ if (field.isUnique) colDef += " UNIQUE";
305
+ columns.push(colDef);
306
+ }
307
+
308
+ const pkFields = model.primaryKey.fields
309
+ .map((f: string) => {
310
+ const fld = model.fields.find((mf: Field) => mf.name === f);
311
+ if (fld && fld.kind !== "relation") return q({ name: fld.dbName });
312
+ return q({ name: f });
313
+ })
314
+ .join(", ");
315
+
316
+ columns.push(` CONSTRAINT ${q({ name: `${tableName}_pkey` })} PRIMARY KEY (${pkFields})`);
317
+
318
+ ops.push({
319
+ type: "createTable",
320
+ sql: `CREATE TABLE ${q({ name: tableName })} (\n${columns.join(",\n")}\n);`,
321
+ isDestructive: false,
322
+ description: `Create table "${name}"`,
323
+ });
324
+ }
325
+
326
+ // Unique constraints, indexes, and foreign keys for new tables
327
+ for (const [name, model] of currModelMap) {
328
+ if (prevModelMap.has(name)) continue;
329
+
330
+ const tableName = model.dbName;
331
+
332
+ // Composite unique constraints
333
+ for (const uc of model.uniqueConstraints) {
334
+ const cols = uc.fields.map((f: string) => q({ name: resolveFieldDbName({ model, fieldName: f }) })).join(", ");
335
+ const resolvedFieldNames = uc.fields.map((f: string) => resolveFieldDbName({ model, fieldName: f }));
336
+ const idxName = uc.name ?? `${tableName}_${resolvedFieldNames.join("_")}_key`;
337
+ ops.push({
338
+ type: "addUnique",
339
+ sql: `CREATE UNIQUE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
340
+ isDestructive: false,
341
+ description: `Add unique constraint on (${uc.fields.join(", ")}) in "${name}"`,
342
+ });
343
+ }
344
+
345
+ // Regular indexes
346
+ for (const idx of model.indexes) {
347
+ const cols = idx.fields.map((f: string) => q({ name: resolveFieldDbName({ model, fieldName: f }) })).join(", ");
348
+ const resolvedFieldNames = idx.fields.map((f: string) => resolveFieldDbName({ model, fieldName: f }));
349
+ const idxName = idx.name ?? `${tableName}_${resolvedFieldNames.join("_")}_idx`;
350
+ ops.push({
351
+ type: "addIndex",
352
+ sql: `CREATE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
353
+ isDestructive: false,
354
+ description: `Add index on (${idx.fields.join(", ")}) in "${name}"`,
355
+ });
356
+ }
357
+
358
+ // Foreign keys
359
+ const fkRels = getRelationFields({ model })
360
+ .filter((r: RelationField) => r.relation.isForeignKey && r.relation.fields.length > 0);
361
+ for (const rel of fkRels) {
362
+ const fkCols = rel.relation.fields.map((f: string) => q({ name: resolveFieldDbName({ model, fieldName: f }) })).join(", ");
363
+ const relatedModel = current.models.find((m: Model) => m.name === rel.relatedModel);
364
+ const relatedTableName = relatedModel?.dbName ?? rel.relatedModel;
365
+ const refCols = rel.relation.references.map((f: string) => {
366
+ if (relatedModel) return q({ name: resolveFieldDbName({ model: relatedModel, fieldName: f }) });
367
+ return q({ name: f });
368
+ }).join(", ");
369
+ const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model, fieldName: f }));
370
+ const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
371
+ ops.push({
372
+ type: "addForeignKey",
373
+ 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;`,
374
+ isDestructive: false,
375
+ description: `Add foreign key on "${name}" (${rel.relation.fields.join(", ")}) \u2192 "${rel.relatedModel}"`,
376
+ });
377
+ }
378
+ }
379
+
380
+ // Dropped tables
381
+ for (const [name, model] of prevModelMap) {
382
+ if (currModelMap.has(name)) continue;
383
+ ops.push({
384
+ type: "dropTable",
385
+ sql: `DROP TABLE IF EXISTS ${q({ name: model.dbName })} CASCADE;`,
386
+ isDestructive: true,
387
+ description: `Drop table "${name}"`,
388
+ });
389
+ }
390
+
391
+ // ─── Column Diffs (for surviving models) ──────────────────────
392
+
393
+ for (const [name, currModel] of currModelMap) {
394
+ const prevModel = prevModelMap.get(name);
395
+ if (!prevModel) continue; // New table, already handled
396
+
397
+ const tableName = currModel.dbName;
398
+ const prevFields = new Map(
399
+ getScalarFields({ model: prevModel }).map((f) => [f.dbName, f])
400
+ );
401
+ const currFields = new Map(
402
+ getScalarFields({ model: currModel }).map((f) => [f.dbName, f])
403
+ );
404
+
405
+ // Added columns
406
+ for (const [fieldName, field] of currFields) {
407
+ if (prevFields.has(fieldName)) continue;
408
+
409
+ const colName = field.dbName;
410
+ const colType = resolveColumnType({ field, enums: current.enums });
411
+ const defaultExpr = resolveDefault({ field });
412
+
413
+ // Safe if nullable or has default
414
+ const isDestructive = field.isRequired && !defaultExpr;
415
+
416
+ let colDef = `${colType}`;
417
+ if (field.kind === "scalar" && field.isRequired && field.default?.kind !== "autoincrement") {
418
+ colDef += " NOT NULL";
419
+ } else if (field.kind === "enum" && field.isRequired) {
420
+ colDef += " NOT NULL";
421
+ }
422
+ if (defaultExpr) colDef += ` DEFAULT ${defaultExpr}`;
423
+
424
+ ops.push({
425
+ type: "addColumn",
426
+ sql: `ALTER TABLE ${q({ name: tableName })} ADD COLUMN ${q({ name: colName })} ${colDef};`,
427
+ isDestructive,
428
+ description: `Add column "${fieldName}" to "${name}"`,
429
+ });
430
+ }
431
+
432
+ // Dropped columns
433
+ for (const [fieldName, field] of prevFields) {
434
+ if (currFields.has(fieldName)) continue;
435
+ ops.push({
436
+ type: "dropColumn",
437
+ sql: `ALTER TABLE ${q({ name: tableName })} DROP COLUMN IF EXISTS ${q({ name: field.dbName })};`,
438
+ isDestructive: true,
439
+ description: `Drop column "${fieldName}" from "${name}"`,
440
+ });
441
+ }
442
+
443
+ // Modified columns
444
+ for (const [fieldName, currField] of currFields) {
445
+ const prevField = prevFields.get(fieldName);
446
+ if (!prevField) continue;
447
+
448
+ const colName = currField.dbName;
449
+ const prevType = resolveColumnType({ field: prevField, enums: previous.enums });
450
+ const currType = resolveColumnType({ field: currField, enums: current.enums });
451
+
452
+ // Type change
453
+ if (prevType !== currType) {
454
+ ops.push({
455
+ type: "alterColumnType",
456
+ sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${currType};`,
457
+ isDestructive: true,
458
+ description: `Change type of "${fieldName}" in "${name}" from ${prevType} to ${currType}`,
459
+ });
460
+ }
461
+
462
+ // Nullability change
463
+ if (prevField.isRequired !== currField.isRequired) {
464
+ if (currField.isRequired) {
465
+ ops.push({
466
+ type: "alterColumnNullability",
467
+ sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET NOT NULL;`,
468
+ isDestructive: true,
469
+ description: `Set NOT NULL on "${fieldName}" in "${name}"`,
470
+ });
471
+ } else {
472
+ ops.push({
473
+ type: "alterColumnNullability",
474
+ sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} DROP NOT NULL;`,
475
+ isDestructive: false,
476
+ description: `Drop NOT NULL on "${fieldName}" in "${name}"`,
477
+ });
478
+ }
479
+ }
480
+
481
+ // Default change
482
+ const prevDefault = resolveDefault({ field: prevField });
483
+ const currDefault = resolveDefault({ field: currField });
484
+ if (prevDefault !== currDefault) {
485
+ if (currDefault) {
486
+ ops.push({
487
+ type: "alterColumnDefault",
488
+ sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DEFAULT ${currDefault};`,
489
+ isDestructive: false,
490
+ description: `Set default on "${fieldName}" in "${name}" to ${currDefault}`,
491
+ });
492
+ } else {
493
+ ops.push({
494
+ type: "alterColumnDefault",
495
+ sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} DROP DEFAULT;`,
496
+ isDestructive: false,
497
+ description: `Drop default on "${fieldName}" in "${name}"`,
498
+ });
499
+ }
500
+ }
501
+ }
502
+
503
+ // ─── Unique Constraint Diffs ──────────────────────────────
504
+
505
+ const prevUniques = new Map(
506
+ prevModel.uniqueConstraints.map((uc: UniqueConstraint) => [uniqueKey({ uc, model: prevModel }), uc])
507
+ );
508
+ const currUniques = new Map(
509
+ currModel.uniqueConstraints.map((uc: UniqueConstraint) => [uniqueKey({ uc, model: currModel }), uc])
510
+ );
511
+
512
+ for (const [key, uc] of currUniques) {
513
+ if (prevUniques.has(key)) continue;
514
+ const cols = uc.fields.map((f: string) => q({ name: resolveFieldDbName({ model: currModel, fieldName: f }) })).join(", ");
515
+ const resolvedFieldNames = uc.fields.map((f: string) => resolveFieldDbName({ model: currModel, fieldName: f }));
516
+ const idxName = uc.name ?? `${tableName}_${resolvedFieldNames.join("_")}_key`;
517
+ ops.push({
518
+ type: "addUnique",
519
+ sql: `CREATE UNIQUE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
520
+ isDestructive: false,
521
+ description: `Add unique constraint on (${uc.fields.join(", ")}) in "${name}"`,
522
+ });
523
+ }
524
+
525
+ for (const [key, uc] of prevUniques) {
526
+ if (currUniques.has(key)) continue;
527
+ const resolvedFieldNames = uc.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
528
+ const idxName = uc.name ?? `${tableName}_${resolvedFieldNames.join("_")}_key`;
529
+ ops.push({
530
+ type: "dropUnique",
531
+ sql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
532
+ isDestructive: true,
533
+ description: `Drop unique constraint on (${uc.fields.join(", ")}) in "${name}"`,
534
+ });
535
+ }
536
+
537
+ // ─── Index Diffs ──────────────────────────────────────────
538
+
539
+ const prevIndexes = new Map(
540
+ prevModel.indexes.map((idx: IndexDefinition) => [indexKey({ idx, model: prevModel }), idx])
541
+ );
542
+ const currIndexes = new Map(
543
+ currModel.indexes.map((idx: IndexDefinition) => [indexKey({ idx, model: currModel }), idx])
544
+ );
545
+
546
+ for (const [key, idx] of currIndexes) {
547
+ if (prevIndexes.has(key)) continue;
548
+ const cols = idx.fields.map((f: string) => q({ name: resolveFieldDbName({ model: currModel, fieldName: f }) })).join(", ");
549
+ const resolvedFieldNames = idx.fields.map((f: string) => resolveFieldDbName({ model: currModel, fieldName: f }));
550
+ const idxName = idx.name ?? `${tableName}_${resolvedFieldNames.join("_")}_idx`;
551
+ ops.push({
552
+ type: "addIndex",
553
+ sql: `CREATE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
554
+ isDestructive: false,
555
+ description: `Add index on (${idx.fields.join(", ")}) in "${name}"`,
556
+ });
557
+ }
558
+
559
+ for (const [key, idx] of prevIndexes) {
560
+ if (currIndexes.has(key)) continue;
561
+ const resolvedFieldNames = idx.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
562
+ const idxName = idx.name ?? `${tableName}_${resolvedFieldNames.join("_")}_idx`;
563
+ ops.push({
564
+ type: "dropIndex",
565
+ sql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
566
+ isDestructive: true,
567
+ description: `Drop index on (${idx.fields.join(", ")}) in "${name}"`,
568
+ });
569
+ }
570
+
571
+ // ─── Foreign Key Diffs ────────────────────────────────────
572
+
573
+ const prevFKs = new Map(
574
+ getRelationFields({ model: prevModel })
575
+ .filter((r: RelationField) => r.relation.isForeignKey && r.relation.fields.length > 0)
576
+ .map((r: RelationField) => [fkKey({ rel: r, model: prevModel, allModels: previous.models }), r])
577
+ );
578
+ const currFKs = new Map(
579
+ getRelationFields({ model: currModel })
580
+ .filter((r: RelationField) => r.relation.isForeignKey && r.relation.fields.length > 0)
581
+ .map((r: RelationField) => [fkKey({ rel: r, model: currModel, allModels: current.models }), r])
582
+ );
583
+
584
+ for (const [key, rel] of currFKs) {
585
+ if (prevFKs.has(key)) continue;
586
+ const fkCols = rel.relation.fields.map((f: string) => q({ name: resolveFieldDbName({ model: currModel, fieldName: f }) })).join(", ");
587
+ const relatedModel = current.models.find((m: Model) => m.name === rel.relatedModel);
588
+ const relatedTableName = relatedModel?.dbName ?? rel.relatedModel;
589
+ const refCols = rel.relation.references.map((f: string) => {
590
+ if (relatedModel) return q({ name: resolveFieldDbName({ model: relatedModel, fieldName: f }) });
591
+ return q({ name: f });
592
+ }).join(", ");
593
+ const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model: currModel, fieldName: f }));
594
+ const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
595
+ ops.push({
596
+ type: "addForeignKey",
597
+ 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;`,
598
+ isDestructive: false,
599
+ description: `Add foreign key on "${name}" (${rel.relation.fields.join(", ")}) → "${rel.relatedModel}"`,
600
+ });
601
+ }
602
+
603
+ for (const [key, rel] of prevFKs) {
604
+ if (currFKs.has(key)) continue;
605
+ const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
606
+ const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
607
+ ops.push({
608
+ type: "dropForeignKey",
609
+ sql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
610
+ isDestructive: true,
611
+ description: `Drop foreign key on "${name}" (${rel.relation.fields.join(", ")}) → "${rel.relatedModel}"`,
612
+ });
613
+ }
614
+ }
615
+
616
+ // ─── Implicit M:N Join Table Diffs ────────────────────────────
617
+
618
+ const prevJoinTables = detectImplicitManyToMany({ schema: previous });
619
+ const currJoinTables = detectImplicitManyToMany({ schema: current });
620
+
621
+ const prevJoinSet = new Set(prevJoinTables.map((j: JoinTableInfo) => j.tableName));
622
+ const currJoinSet = new Set(currJoinTables.map((j: JoinTableInfo) => j.tableName));
623
+
624
+ for (const jt of currJoinTables) {
625
+ if (prevJoinSet.has(jt.tableName)) continue;
626
+
627
+ const modelADef = currModelMap.get(jt.modelA);
628
+ const modelBDef = currModelMap.get(jt.modelB);
629
+ if (!modelADef || !modelBDef) continue;
630
+
631
+ const pkFieldA = modelADef.primaryKey.fields[0]!;
632
+ const pkFieldB = modelBDef.primaryKey.fields[0]!;
633
+
634
+ // Resolve PK types
635
+ const pkFieldDefA = modelADef.fields.find((f: Field) => f.name === pkFieldA);
636
+ const pkFieldDefB = modelBDef.fields.find((f: Field) => f.name === pkFieldB);
637
+
638
+ const pkTypeA = pkFieldDefA && (pkFieldDefA.kind === "scalar" || pkFieldDefA.kind === "enum")
639
+ ? resolveBaseColumnType({ field: pkFieldDefA, enums: current.enums })
640
+ : "INTEGER";
641
+ const pkTypeB = pkFieldDefB && (pkFieldDefB.kind === "scalar" || pkFieldDefB.kind === "enum")
642
+ ? resolveBaseColumnType({ field: pkFieldDefB, enums: current.enums })
643
+ : "INTEGER";
644
+
645
+ const tableA = modelADef.dbName;
646
+ const tableB = modelBDef.dbName;
647
+
648
+ ops.push({
649
+ type: "createJoinTable",
650
+ sql: [
651
+ `CREATE TABLE ${q({ name: jt.tableName })} (\n "A" ${pkTypeA} NOT NULL,\n "B" ${pkTypeB} NOT NULL\n);`,
652
+ `CREATE UNIQUE INDEX ${q({ name: `${jt.tableName}_AB_unique` })} ON ${q({ name: jt.tableName })} ("A", "B");`,
653
+ `CREATE INDEX ${q({ name: `${jt.tableName}_B_index` })} ON ${q({ name: jt.tableName })} ("B");`,
654
+ `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
+ `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
+ ].join("\n"),
657
+ isDestructive: false,
658
+ description: `Create join table "${jt.tableName}" for ${jt.modelA} ↔ ${jt.modelB}`,
659
+ });
660
+ }
661
+
662
+ for (const jt of prevJoinTables) {
663
+ if (currJoinSet.has(jt.tableName)) continue;
664
+ ops.push({
665
+ type: "dropJoinTable",
666
+ sql: `DROP TABLE IF EXISTS ${q({ name: jt.tableName })} CASCADE;`,
667
+ isDestructive: true,
668
+ description: `Drop join table "${jt.tableName}" for ${jt.modelA} ↔ ${jt.modelB}`,
669
+ });
670
+ }
671
+
672
+ return ops;
673
+ }