@vibeorm/migrate 1.3.1 → 2.0.0-alpha.10
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/README.md +33 -66
- package/dist/ddl/drop-order.d.ts +25 -0
- package/dist/ddl/drop-order.d.ts.map +1 -0
- package/dist/ddl/mysql.d.ts +46 -0
- package/dist/ddl/mysql.d.ts.map +1 -0
- package/dist/ddl/postgres.d.ts +24 -0
- package/dist/ddl/postgres.d.ts.map +1 -0
- package/dist/ddl/render.d.ts +25 -0
- package/dist/ddl/render.d.ts.map +1 -0
- package/dist/ddl/rls.d.ts +40 -0
- package/dist/ddl/rls.d.ts.map +1 -0
- package/dist/ddl/sqlite.d.ts +79 -0
- package/dist/ddl/sqlite.d.ts.map +1 -0
- package/dist/differ.d.ts +41 -0
- package/dist/differ.d.ts.map +1 -0
- package/dist/down.d.ts +57 -0
- package/dist/down.d.ts.map +1 -0
- package/dist/enum-values.d.ts +32 -0
- package/dist/enum-values.d.ts.map +1 -0
- package/dist/execute.d.ts +124 -0
- package/dist/execute.d.ts.map +1 -0
- package/dist/execution.d.ts +168 -0
- package/dist/execution.d.ts.map +1 -0
- package/dist/extensions.d.ts +74 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5688 -0
- package/dist/index.js.map +37 -0
- package/dist/introspect/mysql.d.ts +31 -0
- package/dist/introspect/mysql.d.ts.map +1 -0
- package/dist/introspect/postgres-rls.d.ts +41 -0
- package/dist/introspect/postgres-rls.d.ts.map +1 -0
- package/dist/introspect/postgres.d.ts +12 -0
- package/dist/introspect/postgres.d.ts.map +1 -0
- package/dist/introspect/shared.d.ts +78 -0
- package/dist/introspect/shared.d.ts.map +1 -0
- package/dist/introspect/sqlite.d.ts +53 -0
- package/dist/introspect/sqlite.d.ts.map +1 -0
- package/dist/journal-table.d.ts +15 -0
- package/dist/journal-table.d.ts.map +1 -0
- package/dist/mysql-types.d.ts +66 -0
- package/dist/mysql-types.d.ts.map +1 -0
- package/dist/normalize.d.ts +143 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/postgres-types.d.ts +95 -0
- package/dist/postgres-types.d.ts.map +1 -0
- package/dist/push.d.ts +51 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/recovery.d.ts +41 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/relations.d.ts +40 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/renames.d.ts +58 -0
- package/dist/renames.d.ts.map +1 -0
- package/dist/runner.d.ts +181 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/sqlite-types.d.ts +101 -0
- package/dist/sqlite-types.d.ts.map +1 -0
- package/dist/types.d.ts +329 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +34 -23
- package/src/cascade-actions.ts +0 -88
- package/src/ddl-builder.ts +0 -415
- package/src/index.ts +0 -41
- package/src/introspector.ts +0 -684
- package/src/migration-runner.ts +0 -127
- package/src/relation-utils.ts +0 -79
- package/src/schema-differ.ts +0 -865
- package/src/schema-printer.ts +0 -259
- package/src/snapshot.ts +0 -141
- package/src/sql-utils.ts +0 -45
- package/src/types.ts +0 -13
package/src/schema-differ.ts
DELETED
|
@@ -1,865 +0,0 @@
|
|
|
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
|
-
import { buildFkActionClause, fkActionFingerprint } from "./cascade-actions.ts";
|
|
21
|
-
import { detectImplicitM2MPairs } from "./relation-utils.ts";
|
|
22
|
-
|
|
23
|
-
// ─── Types ────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
export type DiffOperationType =
|
|
26
|
-
| "createEnum"
|
|
27
|
-
| "addEnumValue"
|
|
28
|
-
| "removeEnumValue"
|
|
29
|
-
| "dropEnum"
|
|
30
|
-
| "createTable"
|
|
31
|
-
| "dropTable"
|
|
32
|
-
| "addColumn"
|
|
33
|
-
| "dropColumn"
|
|
34
|
-
| "alterColumnType"
|
|
35
|
-
| "alterColumnNullability"
|
|
36
|
-
| "alterColumnDefault"
|
|
37
|
-
| "addUnique"
|
|
38
|
-
| "dropUnique"
|
|
39
|
-
| "addIndex"
|
|
40
|
-
| "dropIndex"
|
|
41
|
-
| "addForeignKey"
|
|
42
|
-
| "dropForeignKey"
|
|
43
|
-
| "createJoinTable"
|
|
44
|
-
| "dropJoinTable";
|
|
45
|
-
|
|
46
|
-
export type DiffOperation = {
|
|
47
|
-
type: DiffOperationType;
|
|
48
|
-
sql: string;
|
|
49
|
-
/** Reverse SQL to undo this operation (for down migrations) */
|
|
50
|
-
downSql: string;
|
|
51
|
-
isDestructive: boolean;
|
|
52
|
-
/** Whether this operation can be cleanly reversed without data loss */
|
|
53
|
-
isReversible: boolean;
|
|
54
|
-
description: string;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
// ─── Helpers ──────────────────────────────────────────────────────
|
|
58
|
-
|
|
59
|
-
function q(params: { name: string }): string {
|
|
60
|
-
return `"${params.name}"`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function getScalarFields(params: { model: Model }): (ScalarField | EnumField)[] {
|
|
64
|
-
return params.model.fields.filter(
|
|
65
|
-
(f): f is ScalarField | EnumField => f.kind === "scalar" || f.kind === "enum"
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function getRelationFields(params: { model: Model }): RelationField[] {
|
|
70
|
-
return params.model.fields.filter((f): f is RelationField => f.kind === "relation");
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function resolveColumnType(params: { field: ScalarField | EnumField; enums: Enum[] }): string {
|
|
74
|
-
const { field, enums } = params;
|
|
75
|
-
if (field.kind === "enum") {
|
|
76
|
-
// Enum lists must emit `EnumName[]`, not the scalar enum type. Mirrors
|
|
77
|
-
// `ddl-builder.ts:135-141`. Bug 2 — see .ai/bug-report-2026-05-24.md.
|
|
78
|
-
const enumDef = enums.find((e) => e.name === field.enumName);
|
|
79
|
-
const enumType = q({ name: enumDef?.dbName ?? field.enumName });
|
|
80
|
-
return field.isList ? `${enumType}[]` : enumType;
|
|
81
|
-
}
|
|
82
|
-
if (field.default?.kind === "autoincrement") {
|
|
83
|
-
return field.prismaType === "BigInt" ? "BIGSERIAL" : "SERIAL";
|
|
84
|
-
}
|
|
85
|
-
if (field.nativeType) return field.nativeType.toLowerCase();
|
|
86
|
-
if (field.isList) return `${PRISMA_TO_PG[field.prismaType]}[]`;
|
|
87
|
-
return PRISMA_TO_PG[field.prismaType];
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Resolve to the base column type (no SERIAL — used for FK reference columns in join tables) */
|
|
91
|
-
function resolveBaseColumnType(params: { field: ScalarField | EnumField; enums: Enum[] }): string {
|
|
92
|
-
const { field, enums } = params;
|
|
93
|
-
if (field.kind === "enum") {
|
|
94
|
-
// Same enum-list handling as resolveColumnType (Bug 2).
|
|
95
|
-
const enumDef = enums.find((e) => e.name === field.enumName);
|
|
96
|
-
const enumType = q({ name: enumDef?.dbName ?? field.enumName });
|
|
97
|
-
return field.isList ? `${enumType}[]` : enumType;
|
|
98
|
-
}
|
|
99
|
-
if (field.nativeType) return field.nativeType.toLowerCase();
|
|
100
|
-
if (field.isList) return `${PRISMA_TO_PG[field.prismaType]}[]`;
|
|
101
|
-
return PRISMA_TO_PG[field.prismaType];
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function resolveDefault(params: { field: ScalarField | EnumField }): string | undefined {
|
|
105
|
-
const { field } = params;
|
|
106
|
-
const def = field.default;
|
|
107
|
-
if (!def) return undefined;
|
|
108
|
-
switch (def.kind) {
|
|
109
|
-
case "autoincrement": return undefined;
|
|
110
|
-
case "now": return "CURRENT_TIMESTAMP";
|
|
111
|
-
case "uuid": return "gen_random_uuid()::TEXT";
|
|
112
|
-
case "cuid": return undefined;
|
|
113
|
-
case "nanoid": return undefined;
|
|
114
|
-
case "ulid": return undefined;
|
|
115
|
-
case "dbgenerated": return def.value;
|
|
116
|
-
case "literal": {
|
|
117
|
-
const val = def.value;
|
|
118
|
-
if (typeof val === "string") return `'${val.replace(/'/g, "''")}'`;
|
|
119
|
-
if (typeof val === "boolean") return val ? "true" : "false";
|
|
120
|
-
return String(val);
|
|
121
|
-
}
|
|
122
|
-
default: return undefined;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function uniqueKey(params: { uc: UniqueConstraint; model: Model }): string {
|
|
127
|
-
return params.uc.fields
|
|
128
|
-
.map((f: string) => resolveFieldDbName({ model: params.model, fieldName: f }))
|
|
129
|
-
.sort()
|
|
130
|
-
.join(",");
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Build the effective set of unique constraints for a model by merging
|
|
135
|
-
* explicit `uniqueConstraints` entries with synthetic single-field entries
|
|
136
|
-
* from fields that have `isUnique: true`.
|
|
137
|
-
*
|
|
138
|
-
* This normalizes the two representations of single-field uniqueness:
|
|
139
|
-
* - `@unique` on a field → `isUnique: true`, no `uniqueConstraints` entry
|
|
140
|
-
* - `@@unique([field])` on a model → `uniqueConstraints` entry, `isUnique: false`
|
|
141
|
-
*
|
|
142
|
-
* The database represents both identically, so the differ must treat them
|
|
143
|
-
* as equivalent to avoid phantom diffs when comparing parsed schemas against
|
|
144
|
-
* introspected schemas (which always use `isUnique: true` for single-field uniques).
|
|
145
|
-
*/
|
|
146
|
-
function getEffectiveUniques(params: { model: Model }): Map<string, UniqueConstraint> {
|
|
147
|
-
const { model } = params;
|
|
148
|
-
const result = new Map<string, UniqueConstraint>();
|
|
149
|
-
|
|
150
|
-
// Add all explicit uniqueConstraints entries
|
|
151
|
-
for (const uc of model.uniqueConstraints) {
|
|
152
|
-
result.set(uniqueKey({ uc, model }), uc);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Add synthetic entries for isUnique fields not already covered
|
|
156
|
-
for (const field of model.fields) {
|
|
157
|
-
if (field.kind === "relation") continue;
|
|
158
|
-
if (!field.isUnique) continue;
|
|
159
|
-
const syntheticUc: UniqueConstraint = { fields: [field.name], name: undefined };
|
|
160
|
-
const key = uniqueKey({ uc: syntheticUc, model });
|
|
161
|
-
if (!result.has(key)) {
|
|
162
|
-
result.set(key, syntheticUc);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return result;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function indexKey(params: { idx: IndexDefinition; model: Model }): string {
|
|
170
|
-
return params.idx.fields
|
|
171
|
-
.map((f: string) => resolveFieldDbName({ model: params.model, fieldName: f }))
|
|
172
|
-
.sort()
|
|
173
|
-
.join(",");
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function fkKey(params: { rel: RelationField; model: Model; allModels: Model[] }): string {
|
|
177
|
-
const { rel, model, allModels } = params;
|
|
178
|
-
const resolvedFields = rel.relation.fields
|
|
179
|
-
.map((f: string) => resolveFieldDbName({ model, fieldName: f }))
|
|
180
|
-
.join(",");
|
|
181
|
-
const relatedModel = allModels.find((m: Model) => m.name === rel.relatedModel);
|
|
182
|
-
const relatedTableName = relatedModel?.dbName ?? rel.relatedModel;
|
|
183
|
-
const resolvedRefs = rel.relation.references
|
|
184
|
-
.map((f: string) => {
|
|
185
|
-
if (relatedModel) return resolveFieldDbName({ model: relatedModel, fieldName: f });
|
|
186
|
-
return f;
|
|
187
|
-
})
|
|
188
|
-
.join(",");
|
|
189
|
-
return `${resolvedFields}->${relatedTableName}.${resolvedRefs}`;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Resolve a logical field name to its dbName by looking up the model's fields. */
|
|
193
|
-
function resolveFieldDbName(params: { model: Model; fieldName: string }): string {
|
|
194
|
-
const { model, fieldName } = params;
|
|
195
|
-
const field = model.fields.find((f: Field) => f.name === fieldName);
|
|
196
|
-
if (field && field.kind !== "relation") return field.dbName;
|
|
197
|
-
return fieldName;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// ─── Implicit M:N Detection ──────────────────────────────────────
|
|
201
|
-
|
|
202
|
-
type JoinTableInfo = {
|
|
203
|
-
modelA: string;
|
|
204
|
-
modelB: string;
|
|
205
|
-
tableName: string;
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
function detectImplicitManyToMany(params: { schema: Schema }): JoinTableInfo[] {
|
|
209
|
-
const { schema } = params;
|
|
210
|
-
|
|
211
|
-
// Detection lives in the shared helper (single source of truth with the DDL
|
|
212
|
-
// builder); here we only attach the `_AToB` join-table name to each pair.
|
|
213
|
-
return detectImplicitM2MPairs({ schema }).map((pair) => ({
|
|
214
|
-
modelA: pair.modelA,
|
|
215
|
-
modelB: pair.modelB,
|
|
216
|
-
tableName: `_${pair.modelA}To${pair.modelB}`,
|
|
217
|
-
}));
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// ─── Main Differ ──────────────────────────────────────────────────
|
|
221
|
-
|
|
222
|
-
export function diffSchemas(params: { previous: Schema; current: Schema }): DiffOperation[] {
|
|
223
|
-
const { previous, current } = params;
|
|
224
|
-
const ops: DiffOperation[] = [];
|
|
225
|
-
|
|
226
|
-
// Match models by dbName so that @@map("table_name") models match introspected models
|
|
227
|
-
const prevModelMap = new Map(previous.models.map((m: Model) => [m.dbName, m]));
|
|
228
|
-
const currModelMap = new Map(current.models.map((m: Model) => [m.dbName, m]));
|
|
229
|
-
const prevEnumMap = new Map(previous.enums.map((e: Enum) => [e.dbName ?? e.name, e]));
|
|
230
|
-
const currEnumMap = new Map(current.enums.map((e: Enum) => [e.dbName ?? e.name, e]));
|
|
231
|
-
|
|
232
|
-
// ─── Enum Diffs ───────────────────────────────────────────────
|
|
233
|
-
|
|
234
|
-
// New enums
|
|
235
|
-
for (const [name, enumDef] of currEnumMap) {
|
|
236
|
-
if (!prevEnumMap.has(name)) {
|
|
237
|
-
const typeName = enumDef.dbName ?? enumDef.name;
|
|
238
|
-
const values = enumDef.values
|
|
239
|
-
.map((v) => `'${(v.dbName ?? v.name).replace(/'/g, "''")}'`)
|
|
240
|
-
.join(", ");
|
|
241
|
-
ops.push({
|
|
242
|
-
type: "createEnum",
|
|
243
|
-
sql: `DO $$ BEGIN\n CREATE TYPE ${q({ name: typeName })} AS ENUM (${values});\nEXCEPTION WHEN duplicate_object THEN NULL;\nEND $$;`,
|
|
244
|
-
downSql: `DROP TYPE IF EXISTS ${q({ name: typeName })};`,
|
|
245
|
-
isDestructive: false,
|
|
246
|
-
isReversible: true,
|
|
247
|
-
description: `Create enum type "${name}"`,
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Dropped enums — reverse is recreating the enum with its previous values
|
|
253
|
-
for (const [name, enumDef] of prevEnumMap) {
|
|
254
|
-
if (!currEnumMap.has(name)) {
|
|
255
|
-
const typeName = enumDef.dbName ?? enumDef.name;
|
|
256
|
-
const values = enumDef.values
|
|
257
|
-
.map((v) => `'${(v.dbName ?? v.name).replace(/'/g, "''")}'`)
|
|
258
|
-
.join(", ");
|
|
259
|
-
ops.push({
|
|
260
|
-
type: "dropEnum",
|
|
261
|
-
sql: `DROP TYPE IF EXISTS ${q({ name: typeName })};`,
|
|
262
|
-
downSql: `DO $$ BEGIN\n CREATE TYPE ${q({ name: typeName })} AS ENUM (${values});\nEXCEPTION WHEN duplicate_object THEN NULL;\nEND $$;`,
|
|
263
|
-
isDestructive: true,
|
|
264
|
-
isReversible: true,
|
|
265
|
-
description: `Drop enum type "${name}"`,
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Modified enums (added/removed values)
|
|
271
|
-
for (const [name, currEnum] of currEnumMap) {
|
|
272
|
-
const prevEnum = prevEnumMap.get(name);
|
|
273
|
-
if (!prevEnum) continue;
|
|
274
|
-
|
|
275
|
-
const typeName = currEnum.dbName ?? currEnum.name;
|
|
276
|
-
const prevValues = new Set(prevEnum.values.map((v) => v.name));
|
|
277
|
-
const currValues = new Set(currEnum.values.map((v) => v.name));
|
|
278
|
-
|
|
279
|
-
// Added values — reverse requires type recreation (PG cannot DROP VALUE)
|
|
280
|
-
for (const val of currEnum.values) {
|
|
281
|
-
if (!prevValues.has(val.name)) {
|
|
282
|
-
const dbVal = val.dbName ?? val.name;
|
|
283
|
-
ops.push({
|
|
284
|
-
type: "addEnumValue",
|
|
285
|
-
sql: `ALTER TYPE ${q({ name: typeName })} ADD VALUE IF NOT EXISTS '${dbVal.replace(/'/g, "''")}';`,
|
|
286
|
-
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.`,
|
|
287
|
-
isDestructive: false,
|
|
288
|
-
isReversible: false,
|
|
289
|
-
description: `Add value "${val.name}" to enum "${name}"`,
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Removed values (destructive — PG doesn't support DROP VALUE easily)
|
|
295
|
-
for (const val of prevEnum.values) {
|
|
296
|
-
if (!currValues.has(val.name)) {
|
|
297
|
-
const dbVal = val.dbName ?? val.name;
|
|
298
|
-
ops.push({
|
|
299
|
-
type: "removeEnumValue",
|
|
300
|
-
sql: `-- WARNING: PostgreSQL does not support removing enum values directly.\n-- You need to recreate the type. Value "${val.name}" removed from enum "${name}".`,
|
|
301
|
-
downSql: `ALTER TYPE ${q({ name: typeName })} ADD VALUE IF NOT EXISTS '${dbVal.replace(/'/g, "''")}';`,
|
|
302
|
-
isDestructive: true,
|
|
303
|
-
isReversible: true,
|
|
304
|
-
description: `Remove value "${val.name}" from enum "${name}" (requires type recreation)`,
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// ─── Table Diffs ──────────────────────────────────────────────
|
|
311
|
-
|
|
312
|
-
// New tables
|
|
313
|
-
for (const [name, model] of currModelMap) {
|
|
314
|
-
if (prevModelMap.has(name)) continue;
|
|
315
|
-
|
|
316
|
-
const tableName = model.dbName;
|
|
317
|
-
const columns: string[] = [];
|
|
318
|
-
|
|
319
|
-
for (const field of getScalarFields({ model })) {
|
|
320
|
-
const colName = field.dbName;
|
|
321
|
-
const colType = resolveColumnType({ field, enums: current.enums });
|
|
322
|
-
let colDef = ` ${q({ name: colName })} ${colType}`;
|
|
323
|
-
|
|
324
|
-
if (field.kind === "scalar" && field.isRequired && field.default?.kind !== "autoincrement") {
|
|
325
|
-
colDef += " NOT NULL";
|
|
326
|
-
} else if (field.kind === "enum" && field.isRequired) {
|
|
327
|
-
colDef += " NOT NULL";
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const defaultExpr = resolveDefault({ field });
|
|
331
|
-
if (defaultExpr) colDef += ` DEFAULT ${defaultExpr}`;
|
|
332
|
-
if (field.isUnique) colDef += " UNIQUE";
|
|
333
|
-
columns.push(colDef);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const pkFields = model.primaryKey.fields
|
|
337
|
-
.map((f: string) => {
|
|
338
|
-
const fld = model.fields.find((mf: Field) => mf.name === f);
|
|
339
|
-
if (fld && fld.kind !== "relation") return q({ name: fld.dbName });
|
|
340
|
-
return q({ name: f });
|
|
341
|
-
})
|
|
342
|
-
.join(", ");
|
|
343
|
-
|
|
344
|
-
columns.push(` CONSTRAINT ${q({ name: `${tableName}_pkey` })} PRIMARY KEY (${pkFields})`);
|
|
345
|
-
|
|
346
|
-
ops.push({
|
|
347
|
-
type: "createTable",
|
|
348
|
-
sql: `CREATE TABLE ${q({ name: tableName })} (\n${columns.join(",\n")}\n);`,
|
|
349
|
-
downSql: `DROP TABLE IF EXISTS ${q({ name: tableName })} CASCADE;`,
|
|
350
|
-
isDestructive: false,
|
|
351
|
-
isReversible: true,
|
|
352
|
-
description: `Create table "${name}"`,
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// Unique constraints, indexes, and foreign keys for new tables
|
|
357
|
-
for (const [name, model] of currModelMap) {
|
|
358
|
-
if (prevModelMap.has(name)) continue;
|
|
359
|
-
|
|
360
|
-
const tableName = model.dbName;
|
|
361
|
-
|
|
362
|
-
// Unique constraints from @@unique (single-field @unique is inline on the column)
|
|
363
|
-
for (const uc of model.uniqueConstraints) {
|
|
364
|
-
const cols = uc.fields.map((f: string) => q({ name: resolveFieldDbName({ model, fieldName: f }) })).join(", ");
|
|
365
|
-
const resolvedFieldNames = uc.fields.map((f: string) => resolveFieldDbName({ model, fieldName: f }));
|
|
366
|
-
const idxName = uc.name ?? `${tableName}_${resolvedFieldNames.join("_")}_key`;
|
|
367
|
-
ops.push({
|
|
368
|
-
type: "addUnique",
|
|
369
|
-
sql: `CREATE UNIQUE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
|
|
370
|
-
downSql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
|
|
371
|
-
isDestructive: false,
|
|
372
|
-
isReversible: true,
|
|
373
|
-
description: `Add unique constraint on (${uc.fields.join(", ")}) in "${name}"`,
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Regular indexes
|
|
378
|
-
for (const idx of model.indexes) {
|
|
379
|
-
const cols = idx.fields.map((f: string) => q({ name: resolveFieldDbName({ model, fieldName: f }) })).join(", ");
|
|
380
|
-
const resolvedFieldNames = idx.fields.map((f: string) => resolveFieldDbName({ model, fieldName: f }));
|
|
381
|
-
const idxName = idx.name ?? `${tableName}_${resolvedFieldNames.join("_")}_idx`;
|
|
382
|
-
ops.push({
|
|
383
|
-
type: "addIndex",
|
|
384
|
-
sql: `CREATE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
|
|
385
|
-
downSql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
|
|
386
|
-
isDestructive: false,
|
|
387
|
-
isReversible: true,
|
|
388
|
-
description: `Add index on (${idx.fields.join(", ")}) in "${name}"`,
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// Foreign keys
|
|
393
|
-
const fkRels = getRelationFields({ model })
|
|
394
|
-
.filter((r: RelationField) => r.relation.isForeignKey && r.relation.fields.length > 0);
|
|
395
|
-
for (const rel of fkRels) {
|
|
396
|
-
const fkCols = rel.relation.fields.map((f: string) => q({ name: resolveFieldDbName({ model, fieldName: f }) })).join(", ");
|
|
397
|
-
const relatedModel = current.models.find((m: Model) => m.name === rel.relatedModel);
|
|
398
|
-
const relatedTableName = relatedModel?.dbName ?? rel.relatedModel;
|
|
399
|
-
const refCols = rel.relation.references.map((f: string) => {
|
|
400
|
-
if (relatedModel) return q({ name: resolveFieldDbName({ model: relatedModel, fieldName: f }) });
|
|
401
|
-
return q({ name: f });
|
|
402
|
-
}).join(", ");
|
|
403
|
-
const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model, fieldName: f }));
|
|
404
|
-
const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
|
|
405
|
-
const actionClause = buildFkActionClause({ rel });
|
|
406
|
-
ops.push({
|
|
407
|
-
type: "addForeignKey",
|
|
408
|
-
sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
|
|
409
|
-
downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
|
|
410
|
-
isDestructive: false,
|
|
411
|
-
isReversible: true,
|
|
412
|
-
description: `Add foreign key on "${name}" (${rel.relation.fields.join(", ")}) \u2192 "${rel.relatedModel}"`,
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Dropped tables — reverse is recreating the table from the previous schema
|
|
418
|
-
for (const [name, model] of prevModelMap) {
|
|
419
|
-
if (currModelMap.has(name)) continue;
|
|
420
|
-
|
|
421
|
-
// Build the CREATE TABLE statement for the down migration
|
|
422
|
-
const dropTableName = model.dbName;
|
|
423
|
-
const downColumns: string[] = [];
|
|
424
|
-
for (const field of getScalarFields({ model })) {
|
|
425
|
-
const colName = field.dbName;
|
|
426
|
-
const colType = resolveColumnType({ field, enums: previous.enums });
|
|
427
|
-
let colDef = ` ${q({ name: colName })} ${colType}`;
|
|
428
|
-
if (field.kind === "scalar" && field.isRequired && field.default?.kind !== "autoincrement") {
|
|
429
|
-
colDef += " NOT NULL";
|
|
430
|
-
} else if (field.kind === "enum" && field.isRequired) {
|
|
431
|
-
colDef += " NOT NULL";
|
|
432
|
-
}
|
|
433
|
-
const defaultExpr = resolveDefault({ field });
|
|
434
|
-
if (defaultExpr) colDef += ` DEFAULT ${defaultExpr}`;
|
|
435
|
-
if (field.isUnique) colDef += " UNIQUE";
|
|
436
|
-
downColumns.push(colDef);
|
|
437
|
-
}
|
|
438
|
-
const downPkFields = model.primaryKey.fields
|
|
439
|
-
.map((f: string) => {
|
|
440
|
-
const fld = model.fields.find((mf: Field) => mf.name === f);
|
|
441
|
-
if (fld && fld.kind !== "relation") return q({ name: fld.dbName });
|
|
442
|
-
return q({ name: f });
|
|
443
|
-
})
|
|
444
|
-
.join(", ");
|
|
445
|
-
downColumns.push(` CONSTRAINT ${q({ name: `${dropTableName}_pkey` })} PRIMARY KEY (${downPkFields})`);
|
|
446
|
-
|
|
447
|
-
ops.push({
|
|
448
|
-
type: "dropTable",
|
|
449
|
-
sql: `DROP TABLE IF EXISTS ${q({ name: model.dbName })} CASCADE;`,
|
|
450
|
-
downSql: `CREATE TABLE ${q({ name: dropTableName })} (\n${downColumns.join(",\n")}\n);`,
|
|
451
|
-
isDestructive: true,
|
|
452
|
-
isReversible: true,
|
|
453
|
-
description: `Drop table "${name}"`,
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// ─── Column Diffs (for surviving models) ──────────────────────
|
|
458
|
-
|
|
459
|
-
for (const [name, currModel] of currModelMap) {
|
|
460
|
-
const prevModel = prevModelMap.get(name);
|
|
461
|
-
if (!prevModel) continue; // New table, already handled
|
|
462
|
-
|
|
463
|
-
const tableName = currModel.dbName;
|
|
464
|
-
const prevFields = new Map(
|
|
465
|
-
getScalarFields({ model: prevModel }).map((f) => [f.dbName, f])
|
|
466
|
-
);
|
|
467
|
-
const currFields = new Map(
|
|
468
|
-
getScalarFields({ model: currModel }).map((f) => [f.dbName, f])
|
|
469
|
-
);
|
|
470
|
-
|
|
471
|
-
// Added columns — reverse is dropping the column
|
|
472
|
-
for (const [fieldName, field] of currFields) {
|
|
473
|
-
if (prevFields.has(fieldName)) continue;
|
|
474
|
-
|
|
475
|
-
const colName = field.dbName;
|
|
476
|
-
const colType = resolveColumnType({ field, enums: current.enums });
|
|
477
|
-
const defaultExpr = resolveDefault({ field });
|
|
478
|
-
|
|
479
|
-
// Safe if nullable or has default
|
|
480
|
-
const isDestructive = field.isRequired && !defaultExpr;
|
|
481
|
-
|
|
482
|
-
let colDef = `${colType}`;
|
|
483
|
-
if (field.kind === "scalar" && field.isRequired && field.default?.kind !== "autoincrement") {
|
|
484
|
-
colDef += " NOT NULL";
|
|
485
|
-
} else if (field.kind === "enum" && field.isRequired) {
|
|
486
|
-
colDef += " NOT NULL";
|
|
487
|
-
}
|
|
488
|
-
if (defaultExpr) colDef += ` DEFAULT ${defaultExpr}`;
|
|
489
|
-
|
|
490
|
-
ops.push({
|
|
491
|
-
type: "addColumn",
|
|
492
|
-
sql: `ALTER TABLE ${q({ name: tableName })} ADD COLUMN ${q({ name: colName })} ${colDef};`,
|
|
493
|
-
downSql: `ALTER TABLE ${q({ name: tableName })} DROP COLUMN IF EXISTS ${q({ name: colName })};`,
|
|
494
|
-
isDestructive,
|
|
495
|
-
isReversible: true,
|
|
496
|
-
description: `Add column "${fieldName}" to "${name}"`,
|
|
497
|
-
});
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// Dropped columns — reverse is re-adding the column with its previous definition
|
|
501
|
-
for (const [fieldName, field] of prevFields) {
|
|
502
|
-
if (currFields.has(fieldName)) continue;
|
|
503
|
-
|
|
504
|
-
const colName = field.dbName;
|
|
505
|
-
const colType = resolveColumnType({ field, enums: previous.enums });
|
|
506
|
-
const defaultExpr = resolveDefault({ field });
|
|
507
|
-
// Down migration always adds the column back as nullable because
|
|
508
|
-
// the original data is lost after DROP COLUMN — adding NOT NULL
|
|
509
|
-
// would fail if the table has existing rows with null values.
|
|
510
|
-
let downColDef = `${colType}`;
|
|
511
|
-
if (defaultExpr) downColDef += ` DEFAULT ${defaultExpr}`;
|
|
512
|
-
|
|
513
|
-
ops.push({
|
|
514
|
-
type: "dropColumn",
|
|
515
|
-
sql: `ALTER TABLE ${q({ name: tableName })} DROP COLUMN IF EXISTS ${q({ name: field.dbName })};`,
|
|
516
|
-
downSql: `ALTER TABLE ${q({ name: tableName })} ADD COLUMN ${q({ name: colName })} ${downColDef};`,
|
|
517
|
-
isDestructive: true,
|
|
518
|
-
isReversible: true,
|
|
519
|
-
description: `Drop column "${fieldName}" from "${name}"`,
|
|
520
|
-
});
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// Modified columns
|
|
524
|
-
for (const [fieldName, currField] of currFields) {
|
|
525
|
-
const prevField = prevFields.get(fieldName);
|
|
526
|
-
if (!prevField) continue;
|
|
527
|
-
|
|
528
|
-
const colName = currField.dbName;
|
|
529
|
-
const prevType = resolveColumnType({ field: prevField, enums: previous.enums });
|
|
530
|
-
const currType = resolveColumnType({ field: currField, enums: current.enums });
|
|
531
|
-
|
|
532
|
-
// Type change — reverse is setting back to the previous type
|
|
533
|
-
if (prevType !== currType) {
|
|
534
|
-
// Detect scalar -> array transition of same base type. PG can't implicit-cast,
|
|
535
|
-
// so we add a USING clause that wraps the existing value in a 1-element array
|
|
536
|
-
// (or NULL if the column is NULL). This heals databases broken by an earlier
|
|
537
|
-
// enum-array bug (Bug 2) where the column was created as the scalar type.
|
|
538
|
-
// We intentionally do NOT handle the reverse direction (array -> scalar) — that
|
|
539
|
-
// is lossy and the user must decide which element to keep.
|
|
540
|
-
const isScalarToArray =
|
|
541
|
-
currType.endsWith("[]") && !prevType.endsWith("[]") &&
|
|
542
|
-
currType.slice(0, -2) === prevType;
|
|
543
|
-
|
|
544
|
-
const usingClause = isScalarToArray
|
|
545
|
-
? ` USING CASE WHEN ${q({ name: colName })} IS NULL THEN NULL ELSE ARRAY[${q({ name: colName })}] END`
|
|
546
|
-
: "";
|
|
547
|
-
|
|
548
|
-
ops.push({
|
|
549
|
-
type: "alterColumnType",
|
|
550
|
-
sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${currType}${usingClause};`,
|
|
551
|
-
downSql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DATA TYPE ${prevType};`,
|
|
552
|
-
isDestructive: !isScalarToArray, // scalar -> array is non-destructive (data preserved as 1-element arrays)
|
|
553
|
-
isReversible: true,
|
|
554
|
-
description: `Change type of "${fieldName}" in "${name}" from ${prevType} to ${currType}`,
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Nullability change — reverse is toggling back
|
|
559
|
-
if (prevField.isRequired !== currField.isRequired) {
|
|
560
|
-
if (currField.isRequired) {
|
|
561
|
-
ops.push({
|
|
562
|
-
type: "alterColumnNullability",
|
|
563
|
-
sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET NOT NULL;`,
|
|
564
|
-
downSql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} DROP NOT NULL;`,
|
|
565
|
-
isDestructive: true,
|
|
566
|
-
isReversible: true,
|
|
567
|
-
description: `Set NOT NULL on "${fieldName}" in "${name}"`,
|
|
568
|
-
});
|
|
569
|
-
} else {
|
|
570
|
-
ops.push({
|
|
571
|
-
type: "alterColumnNullability",
|
|
572
|
-
sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} DROP NOT NULL;`,
|
|
573
|
-
downSql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET NOT NULL;`,
|
|
574
|
-
isDestructive: false,
|
|
575
|
-
isReversible: true,
|
|
576
|
-
description: `Drop NOT NULL on "${fieldName}" in "${name}"`,
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// Default change — reverse is restoring the previous default
|
|
582
|
-
const prevDefault = resolveDefault({ field: prevField });
|
|
583
|
-
const currDefault = resolveDefault({ field: currField });
|
|
584
|
-
if (prevDefault !== currDefault) {
|
|
585
|
-
if (currDefault) {
|
|
586
|
-
const downDefaultSql = prevDefault
|
|
587
|
-
? `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DEFAULT ${prevDefault};`
|
|
588
|
-
: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} DROP DEFAULT;`;
|
|
589
|
-
ops.push({
|
|
590
|
-
type: "alterColumnDefault",
|
|
591
|
-
sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DEFAULT ${currDefault};`,
|
|
592
|
-
downSql: downDefaultSql,
|
|
593
|
-
isDestructive: false,
|
|
594
|
-
isReversible: true,
|
|
595
|
-
description: `Set default on "${fieldName}" in "${name}" to ${currDefault}`,
|
|
596
|
-
});
|
|
597
|
-
} else {
|
|
598
|
-
const downDefaultSql = prevDefault
|
|
599
|
-
? `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} SET DEFAULT ${prevDefault};`
|
|
600
|
-
: `-- No previous default to restore`;
|
|
601
|
-
ops.push({
|
|
602
|
-
type: "alterColumnDefault",
|
|
603
|
-
sql: `ALTER TABLE ${q({ name: tableName })} ALTER COLUMN ${q({ name: colName })} DROP DEFAULT;`,
|
|
604
|
-
downSql: downDefaultSql,
|
|
605
|
-
isDestructive: false,
|
|
606
|
-
isReversible: true,
|
|
607
|
-
description: `Drop default on "${fieldName}" in "${name}"`,
|
|
608
|
-
});
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// ─── Unique Constraint Diffs ──────────────────────────────
|
|
614
|
-
// Use effective uniques to normalize @unique (field-level) and
|
|
615
|
-
// @@unique([field]) (model-level) representations before diffing.
|
|
616
|
-
|
|
617
|
-
const prevUniques = getEffectiveUniques({ model: prevModel });
|
|
618
|
-
const currUniques = getEffectiveUniques({ model: currModel });
|
|
619
|
-
|
|
620
|
-
for (const [key, uc] of currUniques) {
|
|
621
|
-
if (prevUniques.has(key)) continue;
|
|
622
|
-
const cols = uc.fields.map((f: string) => q({ name: resolveFieldDbName({ model: currModel, fieldName: f }) })).join(", ");
|
|
623
|
-
const resolvedFieldNames = uc.fields.map((f: string) => resolveFieldDbName({ model: currModel, fieldName: f }));
|
|
624
|
-
const idxName = uc.name ?? `${tableName}_${resolvedFieldNames.join("_")}_key`;
|
|
625
|
-
ops.push({
|
|
626
|
-
type: "addUnique",
|
|
627
|
-
sql: `CREATE UNIQUE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
|
|
628
|
-
downSql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
|
|
629
|
-
isDestructive: false,
|
|
630
|
-
isReversible: true,
|
|
631
|
-
description: `Add unique constraint on (${uc.fields.join(", ")}) in "${name}"`,
|
|
632
|
-
});
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
for (const [key, uc] of prevUniques) {
|
|
636
|
-
if (currUniques.has(key)) continue;
|
|
637
|
-
const cols = uc.fields.map((f: string) => q({ name: resolveFieldDbName({ model: prevModel, fieldName: f }) })).join(", ");
|
|
638
|
-
const resolvedFieldNames = uc.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
|
|
639
|
-
const idxName = uc.name ?? `${tableName}_${resolvedFieldNames.join("_")}_key`;
|
|
640
|
-
ops.push({
|
|
641
|
-
type: "dropUnique",
|
|
642
|
-
sql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
|
|
643
|
-
downSql: `CREATE UNIQUE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
|
|
644
|
-
isDestructive: true,
|
|
645
|
-
isReversible: true,
|
|
646
|
-
description: `Drop unique constraint on (${uc.fields.join(", ")}) in "${name}"`,
|
|
647
|
-
});
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// ─── Index Diffs ──────────────────────────────────────────
|
|
651
|
-
|
|
652
|
-
const prevIndexes = new Map(
|
|
653
|
-
prevModel.indexes.map((idx: IndexDefinition) => [indexKey({ idx, model: prevModel }), idx])
|
|
654
|
-
);
|
|
655
|
-
const currIndexes = new Map(
|
|
656
|
-
currModel.indexes.map((idx: IndexDefinition) => [indexKey({ idx, model: currModel }), idx])
|
|
657
|
-
);
|
|
658
|
-
|
|
659
|
-
for (const [key, idx] of currIndexes) {
|
|
660
|
-
if (prevIndexes.has(key)) continue;
|
|
661
|
-
const cols = idx.fields.map((f: string) => q({ name: resolveFieldDbName({ model: currModel, fieldName: f }) })).join(", ");
|
|
662
|
-
const resolvedFieldNames = idx.fields.map((f: string) => resolveFieldDbName({ model: currModel, fieldName: f }));
|
|
663
|
-
const idxName = idx.name ?? `${tableName}_${resolvedFieldNames.join("_")}_idx`;
|
|
664
|
-
ops.push({
|
|
665
|
-
type: "addIndex",
|
|
666
|
-
sql: `CREATE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
|
|
667
|
-
downSql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
|
|
668
|
-
isDestructive: false,
|
|
669
|
-
isReversible: true,
|
|
670
|
-
description: `Add index on (${idx.fields.join(", ")}) in "${name}"`,
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
for (const [key, idx] of prevIndexes) {
|
|
675
|
-
if (currIndexes.has(key)) continue;
|
|
676
|
-
const cols = idx.fields.map((f: string) => q({ name: resolveFieldDbName({ model: prevModel, fieldName: f }) })).join(", ");
|
|
677
|
-
const resolvedFieldNames = idx.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
|
|
678
|
-
const idxName = idx.name ?? `${tableName}_${resolvedFieldNames.join("_")}_idx`;
|
|
679
|
-
ops.push({
|
|
680
|
-
type: "dropIndex",
|
|
681
|
-
sql: `DROP INDEX IF EXISTS ${q({ name: idxName })};`,
|
|
682
|
-
downSql: `CREATE INDEX ${q({ name: idxName })} ON ${q({ name: tableName })} (${cols});`,
|
|
683
|
-
isDestructive: true,
|
|
684
|
-
isReversible: true,
|
|
685
|
-
description: `Drop index on (${idx.fields.join(", ")}) in "${name}"`,
|
|
686
|
-
});
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// ─── Foreign Key Diffs ────────────────────────────────────
|
|
690
|
-
|
|
691
|
-
const prevFKs = new Map(
|
|
692
|
-
getRelationFields({ model: prevModel })
|
|
693
|
-
.filter((r: RelationField) => r.relation.isForeignKey && r.relation.fields.length > 0)
|
|
694
|
-
.map((r: RelationField) => [fkKey({ rel: r, model: prevModel, allModels: previous.models }), r])
|
|
695
|
-
);
|
|
696
|
-
const currFKs = new Map(
|
|
697
|
-
getRelationFields({ model: currModel })
|
|
698
|
-
.filter((r: RelationField) => r.relation.isForeignKey && r.relation.fields.length > 0)
|
|
699
|
-
.map((r: RelationField) => [fkKey({ rel: r, model: currModel, allModels: current.models }), r])
|
|
700
|
-
);
|
|
701
|
-
|
|
702
|
-
for (const [key, rel] of currFKs) {
|
|
703
|
-
const prev = prevFKs.get(key);
|
|
704
|
-
const fkCols = rel.relation.fields.map((f: string) => q({ name: resolveFieldDbName({ model: currModel, fieldName: f }) })).join(", ");
|
|
705
|
-
const relatedModel = current.models.find((m: Model) => m.name === rel.relatedModel);
|
|
706
|
-
const relatedTableName = relatedModel?.dbName ?? rel.relatedModel;
|
|
707
|
-
const refCols = rel.relation.references.map((f: string) => {
|
|
708
|
-
if (relatedModel) return q({ name: resolveFieldDbName({ model: relatedModel, fieldName: f }) });
|
|
709
|
-
return q({ name: f });
|
|
710
|
-
}).join(", ");
|
|
711
|
-
const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model: currModel, fieldName: f }));
|
|
712
|
-
const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
|
|
713
|
-
const actionClause = buildFkActionClause({ rel });
|
|
714
|
-
|
|
715
|
-
if (!prev) {
|
|
716
|
-
ops.push({
|
|
717
|
-
type: "addForeignKey",
|
|
718
|
-
sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
|
|
719
|
-
downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
|
|
720
|
-
isDestructive: false,
|
|
721
|
-
isReversible: true,
|
|
722
|
-
description: `Add foreign key on "${name}" (${rel.relation.fields.join(", ")}) → "${rel.relatedModel}"`,
|
|
723
|
-
});
|
|
724
|
-
continue;
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
// FK exists on both sides — detect cascade-action drift. Postgres has no
|
|
728
|
-
// `ALTER CONSTRAINT ... ON DELETE` for FKs, so we emit a DROP + ADD pair.
|
|
729
|
-
const prevFingerprint = fkActionFingerprint({ rel: prev });
|
|
730
|
-
const currFingerprint = fkActionFingerprint({ rel });
|
|
731
|
-
if (prevFingerprint !== currFingerprint) {
|
|
732
|
-
const prevActionClause = buildFkActionClause({ rel: prev });
|
|
733
|
-
ops.push({
|
|
734
|
-
type: "dropForeignKey",
|
|
735
|
-
sql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
|
|
736
|
-
downSql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${prevActionClause};`,
|
|
737
|
-
isDestructive: false,
|
|
738
|
-
isReversible: true,
|
|
739
|
-
description: `Drop foreign key on "${name}" (${rel.relation.fields.join(", ")}) to change cascade actions`,
|
|
740
|
-
});
|
|
741
|
-
ops.push({
|
|
742
|
-
type: "addForeignKey",
|
|
743
|
-
sql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
|
|
744
|
-
downSql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
|
|
745
|
-
isDestructive: false,
|
|
746
|
-
isReversible: true,
|
|
747
|
-
description: `Re-add foreign key on "${name}" (${rel.relation.fields.join(", ")}) with new cascade actions (${currFingerprint.replace("|", " / ")})`,
|
|
748
|
-
});
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
for (const [key, rel] of prevFKs) {
|
|
753
|
-
if (currFKs.has(key)) continue;
|
|
754
|
-
const fkCols = rel.relation.fields.map((f: string) => q({ name: resolveFieldDbName({ model: prevModel, fieldName: f }) })).join(", ");
|
|
755
|
-
const relatedModel = previous.models.find((m: Model) => m.name === rel.relatedModel);
|
|
756
|
-
const relatedTableName = relatedModel?.dbName ?? rel.relatedModel;
|
|
757
|
-
const refCols = rel.relation.references.map((f: string) => {
|
|
758
|
-
if (relatedModel) return q({ name: resolveFieldDbName({ model: relatedModel, fieldName: f }) });
|
|
759
|
-
return q({ name: f });
|
|
760
|
-
}).join(", ");
|
|
761
|
-
const resolvedFkNames = rel.relation.fields.map((f: string) => resolveFieldDbName({ model: prevModel, fieldName: f }));
|
|
762
|
-
const constraintName = `${tableName}_${resolvedFkNames.join("_")}_fkey`;
|
|
763
|
-
const actionClause = buildFkActionClause({ rel });
|
|
764
|
-
ops.push({
|
|
765
|
-
type: "dropForeignKey",
|
|
766
|
-
sql: `ALTER TABLE ${q({ name: tableName })} DROP CONSTRAINT IF EXISTS ${q({ name: constraintName })};`,
|
|
767
|
-
downSql: `ALTER TABLE ${q({ name: tableName })} ADD CONSTRAINT ${q({ name: constraintName })} FOREIGN KEY (${fkCols}) REFERENCES ${q({ name: relatedTableName })} (${refCols}) ${actionClause};`,
|
|
768
|
-
isDestructive: true,
|
|
769
|
-
isReversible: true,
|
|
770
|
-
description: `Drop foreign key on "${name}" (${rel.relation.fields.join(", ")}) → "${rel.relatedModel}"`,
|
|
771
|
-
});
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// ─── Implicit M:N Join Table Diffs ────────────────────────────
|
|
776
|
-
|
|
777
|
-
const prevJoinTables = detectImplicitManyToMany({ schema: previous });
|
|
778
|
-
const currJoinTables = detectImplicitManyToMany({ schema: current });
|
|
779
|
-
|
|
780
|
-
const prevJoinSet = new Set(prevJoinTables.map((j: JoinTableInfo) => j.tableName));
|
|
781
|
-
const currJoinSet = new Set(currJoinTables.map((j: JoinTableInfo) => j.tableName));
|
|
782
|
-
|
|
783
|
-
for (const jt of currJoinTables) {
|
|
784
|
-
if (prevJoinSet.has(jt.tableName)) continue;
|
|
785
|
-
|
|
786
|
-
const modelADef = currModelMap.get(jt.modelA);
|
|
787
|
-
const modelBDef = currModelMap.get(jt.modelB);
|
|
788
|
-
if (!modelADef || !modelBDef) continue;
|
|
789
|
-
|
|
790
|
-
const pkFieldA = modelADef.primaryKey.fields[0]!;
|
|
791
|
-
const pkFieldB = modelBDef.primaryKey.fields[0]!;
|
|
792
|
-
|
|
793
|
-
// Resolve PK types
|
|
794
|
-
const pkFieldDefA = modelADef.fields.find((f: Field) => f.name === pkFieldA);
|
|
795
|
-
const pkFieldDefB = modelBDef.fields.find((f: Field) => f.name === pkFieldB);
|
|
796
|
-
|
|
797
|
-
const pkTypeA = pkFieldDefA && (pkFieldDefA.kind === "scalar" || pkFieldDefA.kind === "enum")
|
|
798
|
-
? resolveBaseColumnType({ field: pkFieldDefA, enums: current.enums })
|
|
799
|
-
: "INTEGER";
|
|
800
|
-
const pkTypeB = pkFieldDefB && (pkFieldDefB.kind === "scalar" || pkFieldDefB.kind === "enum")
|
|
801
|
-
? resolveBaseColumnType({ field: pkFieldDefB, enums: current.enums })
|
|
802
|
-
: "INTEGER";
|
|
803
|
-
|
|
804
|
-
const tableA = modelADef.dbName;
|
|
805
|
-
const tableB = modelBDef.dbName;
|
|
806
|
-
|
|
807
|
-
ops.push({
|
|
808
|
-
type: "createJoinTable",
|
|
809
|
-
sql: [
|
|
810
|
-
`CREATE TABLE ${q({ name: jt.tableName })} (\n "A" ${pkTypeA} NOT NULL,\n "B" ${pkTypeB} NOT NULL\n);`,
|
|
811
|
-
`CREATE UNIQUE INDEX ${q({ name: `${jt.tableName}_AB_unique` })} ON ${q({ name: jt.tableName })} ("A", "B");`,
|
|
812
|
-
`CREATE INDEX ${q({ name: `${jt.tableName}_B_index` })} ON ${q({ name: jt.tableName })} ("B");`,
|
|
813
|
-
`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;`,
|
|
814
|
-
`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;`,
|
|
815
|
-
].join("\n"),
|
|
816
|
-
downSql: `DROP TABLE IF EXISTS ${q({ name: jt.tableName })} CASCADE;`,
|
|
817
|
-
isDestructive: false,
|
|
818
|
-
isReversible: true,
|
|
819
|
-
description: `Create join table "${jt.tableName}" for ${jt.modelA} ↔ ${jt.modelB}`,
|
|
820
|
-
});
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
for (const jt of prevJoinTables) {
|
|
824
|
-
if (currJoinSet.has(jt.tableName)) continue;
|
|
825
|
-
|
|
826
|
-
// For the down migration, reconstruct the join table from the previous schema
|
|
827
|
-
const prevModelADef = prevModelMap.get(jt.modelA);
|
|
828
|
-
const prevModelBDef = prevModelMap.get(jt.modelB);
|
|
829
|
-
let downJoinSql = `-- WARNING: Cannot fully reconstruct join table "${jt.tableName}". Manual intervention required.`;
|
|
830
|
-
|
|
831
|
-
if (prevModelADef && prevModelBDef) {
|
|
832
|
-
const prevPkFieldA = prevModelADef.primaryKey.fields[0]!;
|
|
833
|
-
const prevPkFieldB = prevModelBDef.primaryKey.fields[0]!;
|
|
834
|
-
const prevPkFieldDefA = prevModelADef.fields.find((f: Field) => f.name === prevPkFieldA);
|
|
835
|
-
const prevPkFieldDefB = prevModelBDef.fields.find((f: Field) => f.name === prevPkFieldB);
|
|
836
|
-
const prevPkTypeA = prevPkFieldDefA && (prevPkFieldDefA.kind === "scalar" || prevPkFieldDefA.kind === "enum")
|
|
837
|
-
? resolveBaseColumnType({ field: prevPkFieldDefA, enums: previous.enums })
|
|
838
|
-
: "INTEGER";
|
|
839
|
-
const prevPkTypeB = prevPkFieldDefB && (prevPkFieldDefB.kind === "scalar" || prevPkFieldDefB.kind === "enum")
|
|
840
|
-
? resolveBaseColumnType({ field: prevPkFieldDefB, enums: previous.enums })
|
|
841
|
-
: "INTEGER";
|
|
842
|
-
const prevTableA = prevModelADef.dbName;
|
|
843
|
-
const prevTableB = prevModelBDef.dbName;
|
|
844
|
-
|
|
845
|
-
downJoinSql = [
|
|
846
|
-
`CREATE TABLE ${q({ name: jt.tableName })} (\n "A" ${prevPkTypeA} NOT NULL,\n "B" ${prevPkTypeB} NOT NULL\n);`,
|
|
847
|
-
`CREATE UNIQUE INDEX ${q({ name: `${jt.tableName}_AB_unique` })} ON ${q({ name: jt.tableName })} ("A", "B");`,
|
|
848
|
-
`CREATE INDEX ${q({ name: `${jt.tableName}_B_index` })} ON ${q({ name: jt.tableName })} ("B");`,
|
|
849
|
-
`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;`,
|
|
850
|
-
`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;`,
|
|
851
|
-
].join("\n");
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
ops.push({
|
|
855
|
-
type: "dropJoinTable",
|
|
856
|
-
sql: `DROP TABLE IF EXISTS ${q({ name: jt.tableName })} CASCADE;`,
|
|
857
|
-
downSql: downJoinSql,
|
|
858
|
-
isDestructive: true,
|
|
859
|
-
isReversible: prevModelADef !== undefined && prevModelBDef !== undefined,
|
|
860
|
-
description: `Drop join table "${jt.tableName}" for ${jt.modelA} ↔ ${jt.modelB}`,
|
|
861
|
-
});
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
return ops;
|
|
865
|
-
}
|