@momentumcms/migrations 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +19 -12
- package/schematics/collection.json +25 -0
- package/schematics/generate/index.cjs +50 -0
- package/schematics/generate/index.js +25 -0
- package/schematics/generate/schema.d.ts +5 -0
- package/schematics/generate/schema.json +24 -0
- package/schematics/rollback/index.cjs +44 -0
- package/schematics/rollback/index.js +19 -0
- package/schematics/rollback/schema.d.ts +3 -0
- package/schematics/rollback/schema.json +15 -0
- package/schematics/run/index.cjs +50 -0
- package/schematics/run/index.js +25 -0
- package/schematics/run/schema.d.ts +5 -0
- package/schematics/run/schema.json +25 -0
- package/schematics/status/index.cjs +44 -0
- package/schematics/status/index.js +19 -0
- package/schematics/status/schema.d.ts +3 -0
- package/schematics/status/schema.json +15 -0
- package/src/cli/generate.cjs +1688 -0
- package/src/cli/generate.js +1686 -0
- package/src/cli/rollback.cjs +640 -0
- package/src/cli/rollback.js +638 -0
- package/src/cli/run.cjs +1091 -0
- package/src/cli/run.js +1097 -0
- package/src/cli/status.cjs +356 -0
- package/src/cli/status.js +354 -0
- package/CHANGELOG.md +0 -14
- package/LICENSE +0 -21
- /package/{index.cjs → src/index.cjs} +0 -0
- /package/{index.js → src/index.js} +0 -0
|
@@ -0,0 +1,1688 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// libs/migrations/src/cli/generate.ts
|
|
4
|
+
var import_node_fs2 = require("node:fs");
|
|
5
|
+
var import_node_path2 = require("node:path");
|
|
6
|
+
|
|
7
|
+
// libs/core/src/lib/collections/define-collection.ts
|
|
8
|
+
function defineCollection(config) {
|
|
9
|
+
const collection = {
|
|
10
|
+
timestamps: true,
|
|
11
|
+
// Enable timestamps by default
|
|
12
|
+
...config
|
|
13
|
+
};
|
|
14
|
+
if (!collection.slug) {
|
|
15
|
+
throw new Error("Collection must have a slug");
|
|
16
|
+
}
|
|
17
|
+
if (!collection.fields || collection.fields.length === 0) {
|
|
18
|
+
throw new Error(`Collection "${collection.slug}" must have at least one field`);
|
|
19
|
+
}
|
|
20
|
+
if (!/^[a-z][a-z0-9-]*$/.test(collection.slug)) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Collection slug "${collection.slug}" must be kebab-case (lowercase letters, numbers, and hyphens, starting with a letter)`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return collection;
|
|
26
|
+
}
|
|
27
|
+
function getSoftDeleteField(config) {
|
|
28
|
+
if (!config.softDelete)
|
|
29
|
+
return null;
|
|
30
|
+
if (config.softDelete === true)
|
|
31
|
+
return "deletedAt";
|
|
32
|
+
const sdConfig = config.softDelete;
|
|
33
|
+
return sdConfig.field ?? "deletedAt";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// libs/core/src/lib/fields/field.types.ts
|
|
37
|
+
function isNamedTab(tab) {
|
|
38
|
+
return typeof tab.name === "string" && tab.name.length > 0;
|
|
39
|
+
}
|
|
40
|
+
function flattenDataFields(fields) {
|
|
41
|
+
const result = [];
|
|
42
|
+
for (const field of fields) {
|
|
43
|
+
if (field.type === "tabs") {
|
|
44
|
+
for (const tab of field.tabs) {
|
|
45
|
+
if (isNamedTab(tab)) {
|
|
46
|
+
const syntheticGroup = {
|
|
47
|
+
name: tab.name,
|
|
48
|
+
type: "group",
|
|
49
|
+
label: tab.label,
|
|
50
|
+
description: tab.description,
|
|
51
|
+
fields: tab.fields
|
|
52
|
+
};
|
|
53
|
+
result.push(syntheticGroup);
|
|
54
|
+
} else {
|
|
55
|
+
result.push(...flattenDataFields(tab.fields));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} else if (field.type === "collapsible" || field.type === "row") {
|
|
59
|
+
result.push(...flattenDataFields(field.fields));
|
|
60
|
+
} else {
|
|
61
|
+
result.push(field);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// libs/core/src/lib/fields/field-builders.ts
|
|
68
|
+
function text(name, options = {}) {
|
|
69
|
+
return {
|
|
70
|
+
name,
|
|
71
|
+
type: "text",
|
|
72
|
+
...options
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function number(name, options = {}) {
|
|
76
|
+
return {
|
|
77
|
+
name,
|
|
78
|
+
type: "number",
|
|
79
|
+
...options
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function json(name, options = {}) {
|
|
83
|
+
return {
|
|
84
|
+
name,
|
|
85
|
+
type: "json",
|
|
86
|
+
...options
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// libs/core/src/lib/collections/media.collection.ts
|
|
91
|
+
var MediaCollection = defineCollection({
|
|
92
|
+
slug: "media",
|
|
93
|
+
labels: {
|
|
94
|
+
singular: "Media",
|
|
95
|
+
plural: "Media"
|
|
96
|
+
},
|
|
97
|
+
upload: {
|
|
98
|
+
mimeTypes: ["image/*", "application/pdf", "video/*", "audio/*"]
|
|
99
|
+
},
|
|
100
|
+
admin: {
|
|
101
|
+
useAsTitle: "filename",
|
|
102
|
+
defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
|
|
103
|
+
},
|
|
104
|
+
fields: [
|
|
105
|
+
text("filename", {
|
|
106
|
+
required: true,
|
|
107
|
+
label: "Filename",
|
|
108
|
+
description: "Original filename of the uploaded file"
|
|
109
|
+
}),
|
|
110
|
+
text("mimeType", {
|
|
111
|
+
required: true,
|
|
112
|
+
label: "MIME Type",
|
|
113
|
+
description: "File MIME type (e.g., image/jpeg, application/pdf)"
|
|
114
|
+
}),
|
|
115
|
+
number("filesize", {
|
|
116
|
+
label: "File Size",
|
|
117
|
+
description: "File size in bytes"
|
|
118
|
+
}),
|
|
119
|
+
text("path", {
|
|
120
|
+
label: "Storage Path",
|
|
121
|
+
description: "Path/key where the file is stored",
|
|
122
|
+
admin: {
|
|
123
|
+
hidden: true
|
|
124
|
+
}
|
|
125
|
+
}),
|
|
126
|
+
text("url", {
|
|
127
|
+
label: "URL",
|
|
128
|
+
description: "Public URL to access the file"
|
|
129
|
+
}),
|
|
130
|
+
text("alt", {
|
|
131
|
+
label: "Alt Text",
|
|
132
|
+
description: "Alternative text for accessibility"
|
|
133
|
+
}),
|
|
134
|
+
number("width", {
|
|
135
|
+
label: "Width",
|
|
136
|
+
description: "Image width in pixels (for images only)"
|
|
137
|
+
}),
|
|
138
|
+
number("height", {
|
|
139
|
+
label: "Height",
|
|
140
|
+
description: "Image height in pixels (for images only)"
|
|
141
|
+
}),
|
|
142
|
+
json("focalPoint", {
|
|
143
|
+
label: "Focal Point",
|
|
144
|
+
description: "Focal point coordinates for image cropping",
|
|
145
|
+
admin: {
|
|
146
|
+
hidden: true
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
],
|
|
150
|
+
access: {
|
|
151
|
+
// Media is readable by anyone by default
|
|
152
|
+
read: () => true,
|
|
153
|
+
// Only authenticated users can create/update/delete
|
|
154
|
+
create: ({ req }) => !!req?.user,
|
|
155
|
+
update: ({ req }) => !!req?.user,
|
|
156
|
+
delete: ({ req }) => !!req?.user
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// libs/core/src/lib/migrations.ts
|
|
161
|
+
function resolveMigrationMode(mode) {
|
|
162
|
+
if (mode === "push" || mode === "migrate")
|
|
163
|
+
return mode;
|
|
164
|
+
const env = process.env["NODE_ENV"];
|
|
165
|
+
if (env === "production")
|
|
166
|
+
return "migrate";
|
|
167
|
+
return "push";
|
|
168
|
+
}
|
|
169
|
+
function resolveMigrationConfig(config) {
|
|
170
|
+
if (!config)
|
|
171
|
+
return void 0;
|
|
172
|
+
const mode = resolveMigrationMode(config.mode);
|
|
173
|
+
return {
|
|
174
|
+
...config,
|
|
175
|
+
directory: config.directory ?? "./migrations",
|
|
176
|
+
mode,
|
|
177
|
+
cloneTest: config.cloneTest ?? mode === "migrate",
|
|
178
|
+
dangerDetection: config.dangerDetection ?? true,
|
|
179
|
+
autoApply: config.autoApply ?? mode === "push"
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// libs/migrations/src/lib/schema/schema-snapshot.ts
|
|
184
|
+
var import_node_crypto = require("node:crypto");
|
|
185
|
+
var INTERNAL_TABLES = /* @__PURE__ */ new Set(["_momentum_migrations", "_momentum_seeds", "_globals"]);
|
|
186
|
+
function computeSchemaChecksum(tables) {
|
|
187
|
+
const normalized = tables.map((t) => ({
|
|
188
|
+
name: t.name,
|
|
189
|
+
columns: [...t.columns].sort((a, b) => a.name.localeCompare(b.name)),
|
|
190
|
+
foreignKeys: [...t.foreignKeys].sort(
|
|
191
|
+
(a, b) => a.constraintName.localeCompare(b.constraintName)
|
|
192
|
+
),
|
|
193
|
+
indexes: [...t.indexes].sort((a, b) => a.name.localeCompare(b.name))
|
|
194
|
+
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
195
|
+
const json2 = JSON.stringify(normalized);
|
|
196
|
+
return (0, import_node_crypto.createHash)("sha256").update(json2).digest("hex");
|
|
197
|
+
}
|
|
198
|
+
function createSchemaSnapshot(dialect, tables) {
|
|
199
|
+
return {
|
|
200
|
+
dialect,
|
|
201
|
+
tables,
|
|
202
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
203
|
+
checksum: computeSchemaChecksum(tables)
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function serializeSnapshot(snapshot) {
|
|
207
|
+
return JSON.stringify(snapshot, null, " ");
|
|
208
|
+
}
|
|
209
|
+
function deserializeSnapshot(json2) {
|
|
210
|
+
const parsed = JSON.parse(json2);
|
|
211
|
+
if (!isSchemaSnapshot(parsed)) {
|
|
212
|
+
throw new Error("Invalid schema snapshot JSON");
|
|
213
|
+
}
|
|
214
|
+
return parsed;
|
|
215
|
+
}
|
|
216
|
+
function isSchemaSnapshot(value) {
|
|
217
|
+
if (typeof value !== "object" || value === null)
|
|
218
|
+
return false;
|
|
219
|
+
const obj = value;
|
|
220
|
+
return (obj["dialect"] === "postgresql" || obj["dialect"] === "sqlite") && Array.isArray(obj["tables"]) && typeof obj["capturedAt"] === "string" && typeof obj["checksum"] === "string";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// libs/migrations/src/lib/schema/column-type-map.ts
|
|
224
|
+
function fieldToPostgresType(field) {
|
|
225
|
+
switch (field.type) {
|
|
226
|
+
case "text":
|
|
227
|
+
case "textarea":
|
|
228
|
+
case "richText":
|
|
229
|
+
case "password":
|
|
230
|
+
case "radio":
|
|
231
|
+
case "point":
|
|
232
|
+
return "TEXT";
|
|
233
|
+
case "email":
|
|
234
|
+
case "slug":
|
|
235
|
+
case "select":
|
|
236
|
+
return "VARCHAR(255)";
|
|
237
|
+
case "number":
|
|
238
|
+
return "NUMERIC";
|
|
239
|
+
case "checkbox":
|
|
240
|
+
return "BOOLEAN";
|
|
241
|
+
case "date":
|
|
242
|
+
return "TIMESTAMPTZ";
|
|
243
|
+
case "relationship":
|
|
244
|
+
case "upload":
|
|
245
|
+
return "VARCHAR(36)";
|
|
246
|
+
case "array":
|
|
247
|
+
case "group":
|
|
248
|
+
case "blocks":
|
|
249
|
+
case "json":
|
|
250
|
+
return "JSONB";
|
|
251
|
+
case "tabs":
|
|
252
|
+
case "collapsible":
|
|
253
|
+
case "row":
|
|
254
|
+
return "TEXT";
|
|
255
|
+
default:
|
|
256
|
+
return "TEXT";
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function fieldToSqliteType(field) {
|
|
260
|
+
switch (field.type) {
|
|
261
|
+
case "text":
|
|
262
|
+
case "textarea":
|
|
263
|
+
case "richText":
|
|
264
|
+
case "email":
|
|
265
|
+
case "slug":
|
|
266
|
+
case "select":
|
|
267
|
+
case "password":
|
|
268
|
+
case "radio":
|
|
269
|
+
case "point":
|
|
270
|
+
return "TEXT";
|
|
271
|
+
case "number":
|
|
272
|
+
return "REAL";
|
|
273
|
+
case "checkbox":
|
|
274
|
+
return "INTEGER";
|
|
275
|
+
case "date":
|
|
276
|
+
case "relationship":
|
|
277
|
+
case "upload":
|
|
278
|
+
return "TEXT";
|
|
279
|
+
case "array":
|
|
280
|
+
case "group":
|
|
281
|
+
case "blocks":
|
|
282
|
+
case "json":
|
|
283
|
+
return "TEXT";
|
|
284
|
+
default:
|
|
285
|
+
return "TEXT";
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function fieldToColumnType(field, dialect) {
|
|
289
|
+
if (dialect === "postgresql")
|
|
290
|
+
return fieldToPostgresType(field);
|
|
291
|
+
return fieldToSqliteType(field);
|
|
292
|
+
}
|
|
293
|
+
function normalizeColumnType(rawType, dialect) {
|
|
294
|
+
const upper = rawType.toUpperCase().trim();
|
|
295
|
+
if (dialect === "postgresql") {
|
|
296
|
+
return normalizePgType(upper);
|
|
297
|
+
}
|
|
298
|
+
return normalizeSqliteType(upper);
|
|
299
|
+
}
|
|
300
|
+
function normalizePgType(type) {
|
|
301
|
+
const charVaryingMatch = type.match(/^CHARACTER VARYING\((\d+)\)$/);
|
|
302
|
+
if (charVaryingMatch)
|
|
303
|
+
return `VARCHAR(${charVaryingMatch[1]})`;
|
|
304
|
+
if (type === "CHARACTER VARYING")
|
|
305
|
+
return "VARCHAR(255)";
|
|
306
|
+
if (type === "TIMESTAMP WITH TIME ZONE")
|
|
307
|
+
return "TIMESTAMPTZ";
|
|
308
|
+
if (type === "TIMESTAMP WITHOUT TIME ZONE")
|
|
309
|
+
return "TIMESTAMP";
|
|
310
|
+
if (type === "BOOLEAN")
|
|
311
|
+
return "BOOLEAN";
|
|
312
|
+
if (type === "NUMERIC")
|
|
313
|
+
return "NUMERIC";
|
|
314
|
+
if (type === "TEXT")
|
|
315
|
+
return "TEXT";
|
|
316
|
+
if (type === "JSONB")
|
|
317
|
+
return "JSONB";
|
|
318
|
+
if (type === "JSON")
|
|
319
|
+
return "JSON";
|
|
320
|
+
if (type === "INTEGER")
|
|
321
|
+
return "INTEGER";
|
|
322
|
+
if (type === "BIGINT")
|
|
323
|
+
return "BIGINT";
|
|
324
|
+
if (type === "REAL")
|
|
325
|
+
return "REAL";
|
|
326
|
+
if (type === "DOUBLE PRECISION")
|
|
327
|
+
return "DOUBLE PRECISION";
|
|
328
|
+
return type;
|
|
329
|
+
}
|
|
330
|
+
function normalizeSqliteType(type) {
|
|
331
|
+
if (type === "INT" || type === "INTEGER")
|
|
332
|
+
return "INTEGER";
|
|
333
|
+
if (type === "REAL" || type === "FLOAT" || type === "DOUBLE")
|
|
334
|
+
return "REAL";
|
|
335
|
+
return type;
|
|
336
|
+
}
|
|
337
|
+
function areTypesCompatible(typeA, typeB, dialect) {
|
|
338
|
+
const normA = normalizeColumnType(typeA, dialect);
|
|
339
|
+
const normB = normalizeColumnType(typeB, dialect);
|
|
340
|
+
return normA === normB;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// libs/migrations/src/lib/schema/collections-to-schema.ts
|
|
344
|
+
function mapOnDelete(onDelete, required) {
|
|
345
|
+
const effective = required && (!onDelete || onDelete === "set-null") ? "restrict" : onDelete;
|
|
346
|
+
switch (effective) {
|
|
347
|
+
case "restrict":
|
|
348
|
+
return "RESTRICT";
|
|
349
|
+
case "cascade":
|
|
350
|
+
return "CASCADE";
|
|
351
|
+
default:
|
|
352
|
+
return "SET NULL";
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function getTableName(collection) {
|
|
356
|
+
return collection.dbName ?? collection.slug;
|
|
357
|
+
}
|
|
358
|
+
function hasVersionDrafts(collection) {
|
|
359
|
+
const versions = collection.versions;
|
|
360
|
+
if (!versions)
|
|
361
|
+
return false;
|
|
362
|
+
if (typeof versions === "boolean")
|
|
363
|
+
return false;
|
|
364
|
+
return !!versions.drafts;
|
|
365
|
+
}
|
|
366
|
+
function isCollectionConfig(value) {
|
|
367
|
+
return typeof value === "object" && value !== null && "slug" in value && "fields" in value;
|
|
368
|
+
}
|
|
369
|
+
function resolveCollectionRef(ref) {
|
|
370
|
+
try {
|
|
371
|
+
const resolved = ref();
|
|
372
|
+
if (isCollectionConfig(resolved)) {
|
|
373
|
+
return resolved;
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
} catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function buildAutoColumns(collection, dialect) {
|
|
381
|
+
const columns = [];
|
|
382
|
+
columns.push({
|
|
383
|
+
name: "id",
|
|
384
|
+
type: dialect === "postgresql" ? "VARCHAR(36)" : "TEXT",
|
|
385
|
+
nullable: false,
|
|
386
|
+
defaultValue: null,
|
|
387
|
+
isPrimaryKey: true
|
|
388
|
+
});
|
|
389
|
+
const timestamps = collection.timestamps;
|
|
390
|
+
const addCreatedAt = timestamps !== false && (timestamps === true || timestamps === void 0 || timestamps.createdAt !== false);
|
|
391
|
+
const addUpdatedAt = timestamps !== false && (timestamps === true || timestamps === void 0 || timestamps.updatedAt !== false);
|
|
392
|
+
if (addCreatedAt) {
|
|
393
|
+
columns.push({
|
|
394
|
+
name: "createdAt",
|
|
395
|
+
type: dialect === "postgresql" ? "TIMESTAMPTZ" : "TEXT",
|
|
396
|
+
nullable: false,
|
|
397
|
+
defaultValue: null,
|
|
398
|
+
isPrimaryKey: false
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (addUpdatedAt) {
|
|
402
|
+
columns.push({
|
|
403
|
+
name: "updatedAt",
|
|
404
|
+
type: dialect === "postgresql" ? "TIMESTAMPTZ" : "TEXT",
|
|
405
|
+
nullable: false,
|
|
406
|
+
defaultValue: null,
|
|
407
|
+
isPrimaryKey: false
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (hasVersionDrafts(collection)) {
|
|
411
|
+
columns.push({
|
|
412
|
+
name: "_status",
|
|
413
|
+
type: dialect === "postgresql" ? "VARCHAR(20)" : "TEXT",
|
|
414
|
+
nullable: false,
|
|
415
|
+
defaultValue: "'draft'",
|
|
416
|
+
isPrimaryKey: false
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
const softDeleteCol = getSoftDeleteField(collection);
|
|
420
|
+
if (softDeleteCol) {
|
|
421
|
+
columns.push({
|
|
422
|
+
name: softDeleteCol,
|
|
423
|
+
type: dialect === "postgresql" ? "TIMESTAMPTZ" : "TEXT",
|
|
424
|
+
nullable: true,
|
|
425
|
+
defaultValue: null,
|
|
426
|
+
isPrimaryKey: false
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return columns;
|
|
430
|
+
}
|
|
431
|
+
function fieldToColumn(field, dialect) {
|
|
432
|
+
return {
|
|
433
|
+
name: field.name,
|
|
434
|
+
type: fieldToColumnType(field, dialect),
|
|
435
|
+
nullable: !field.required,
|
|
436
|
+
defaultValue: null,
|
|
437
|
+
isPrimaryKey: false
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function buildForeignKeys(tableName, fields) {
|
|
441
|
+
const foreignKeys = [];
|
|
442
|
+
for (const field of fields) {
|
|
443
|
+
if (field.type !== "relationship")
|
|
444
|
+
continue;
|
|
445
|
+
if (field.hasMany)
|
|
446
|
+
continue;
|
|
447
|
+
if (field.relationTo && field.relationTo.length > 0)
|
|
448
|
+
continue;
|
|
449
|
+
const target = resolveCollectionRef(field.collection);
|
|
450
|
+
if (!target)
|
|
451
|
+
continue;
|
|
452
|
+
const targetTable = getTableName(target);
|
|
453
|
+
const onDelete = mapOnDelete(field.onDelete, !!field.required);
|
|
454
|
+
foreignKeys.push({
|
|
455
|
+
constraintName: `fk_${tableName}_${field.name}`,
|
|
456
|
+
column: field.name,
|
|
457
|
+
referencedTable: targetTable,
|
|
458
|
+
referencedColumn: "id",
|
|
459
|
+
onDelete
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return foreignKeys;
|
|
463
|
+
}
|
|
464
|
+
function buildIndexes(tableName, collection) {
|
|
465
|
+
const indexes = [];
|
|
466
|
+
const sdField = getSoftDeleteField(collection);
|
|
467
|
+
if (sdField) {
|
|
468
|
+
indexes.push({
|
|
469
|
+
name: `idx_${tableName}_${sdField}`,
|
|
470
|
+
columns: [sdField],
|
|
471
|
+
unique: false
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (collection.indexes) {
|
|
475
|
+
for (const idx of collection.indexes) {
|
|
476
|
+
indexes.push({
|
|
477
|
+
name: idx.name ?? `idx_${tableName}_${idx.columns.join("_")}`,
|
|
478
|
+
columns: [...idx.columns],
|
|
479
|
+
unique: !!idx.unique
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return indexes;
|
|
484
|
+
}
|
|
485
|
+
function buildVersionTable(collection, dialect) {
|
|
486
|
+
if (!collection.versions)
|
|
487
|
+
return null;
|
|
488
|
+
const baseTable = getTableName(collection);
|
|
489
|
+
const tableName = `${baseTable}_versions`;
|
|
490
|
+
const columns = [
|
|
491
|
+
{
|
|
492
|
+
name: "id",
|
|
493
|
+
type: dialect === "postgresql" ? "VARCHAR(36)" : "TEXT",
|
|
494
|
+
nullable: false,
|
|
495
|
+
defaultValue: null,
|
|
496
|
+
isPrimaryKey: true
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
name: "parent",
|
|
500
|
+
type: dialect === "postgresql" ? "VARCHAR(36)" : "TEXT",
|
|
501
|
+
nullable: false,
|
|
502
|
+
defaultValue: null,
|
|
503
|
+
isPrimaryKey: false
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
name: "version",
|
|
507
|
+
type: "TEXT",
|
|
508
|
+
nullable: false,
|
|
509
|
+
defaultValue: null,
|
|
510
|
+
isPrimaryKey: false
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
name: "_status",
|
|
514
|
+
type: dialect === "postgresql" ? "VARCHAR(20)" : "TEXT",
|
|
515
|
+
nullable: false,
|
|
516
|
+
defaultValue: "'draft'",
|
|
517
|
+
isPrimaryKey: false
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
name: "autosave",
|
|
521
|
+
type: dialect === "postgresql" ? "BOOLEAN" : "INTEGER",
|
|
522
|
+
nullable: false,
|
|
523
|
+
defaultValue: dialect === "postgresql" ? "false" : "0",
|
|
524
|
+
isPrimaryKey: false
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
name: "publishedAt",
|
|
528
|
+
type: dialect === "postgresql" ? "TIMESTAMPTZ" : "TEXT",
|
|
529
|
+
nullable: true,
|
|
530
|
+
defaultValue: null,
|
|
531
|
+
isPrimaryKey: false
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
name: "createdAt",
|
|
535
|
+
type: dialect === "postgresql" ? "TIMESTAMPTZ" : "TEXT",
|
|
536
|
+
nullable: false,
|
|
537
|
+
defaultValue: null,
|
|
538
|
+
isPrimaryKey: false
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
name: "updatedAt",
|
|
542
|
+
type: dialect === "postgresql" ? "TIMESTAMPTZ" : "TEXT",
|
|
543
|
+
nullable: false,
|
|
544
|
+
defaultValue: null,
|
|
545
|
+
isPrimaryKey: false
|
|
546
|
+
}
|
|
547
|
+
];
|
|
548
|
+
const foreignKeys = [
|
|
549
|
+
{
|
|
550
|
+
constraintName: `fk_${tableName}_parent`,
|
|
551
|
+
column: "parent",
|
|
552
|
+
referencedTable: baseTable,
|
|
553
|
+
referencedColumn: "id",
|
|
554
|
+
onDelete: "CASCADE"
|
|
555
|
+
}
|
|
556
|
+
];
|
|
557
|
+
const indexes = [
|
|
558
|
+
{ name: `idx_${tableName}_parent`, columns: ["parent"], unique: false },
|
|
559
|
+
{ name: `idx_${tableName}_status`, columns: ["_status"], unique: false },
|
|
560
|
+
{ name: `idx_${tableName}_createdAt`, columns: ["createdAt"], unique: false }
|
|
561
|
+
];
|
|
562
|
+
return { name: tableName, columns, foreignKeys, indexes };
|
|
563
|
+
}
|
|
564
|
+
function collectionToTableSnapshot(collection, dialect) {
|
|
565
|
+
const tableName = getTableName(collection);
|
|
566
|
+
const dataFields = flattenDataFields(collection.fields);
|
|
567
|
+
const columns = [
|
|
568
|
+
...buildAutoColumns(collection, dialect),
|
|
569
|
+
...dataFields.map((f) => fieldToColumn(f, dialect))
|
|
570
|
+
];
|
|
571
|
+
const foreignKeys = buildForeignKeys(tableName, dataFields);
|
|
572
|
+
const indexes = buildIndexes(tableName, collection);
|
|
573
|
+
return { name: tableName, columns, foreignKeys, indexes };
|
|
574
|
+
}
|
|
575
|
+
function collectionsToSchema(collections, dialect) {
|
|
576
|
+
const tables = [];
|
|
577
|
+
for (const collection of collections) {
|
|
578
|
+
tables.push(collectionToTableSnapshot(collection, dialect));
|
|
579
|
+
const versionTable = buildVersionTable(collection, dialect);
|
|
580
|
+
if (versionTable) {
|
|
581
|
+
tables.push(versionTable);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return createSchemaSnapshot(dialect, tables);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// libs/migrations/src/lib/schema/schema-diff.ts
|
|
588
|
+
var DEFAULT_DIFF_OPTIONS = {
|
|
589
|
+
detectRenames: true,
|
|
590
|
+
renameSimilarityThreshold: 0.6
|
|
591
|
+
};
|
|
592
|
+
function diffSchemas(desired, actual, dialect, options) {
|
|
593
|
+
const opts = { ...DEFAULT_DIFF_OPTIONS, ...options };
|
|
594
|
+
const operations = [];
|
|
595
|
+
const summary = [];
|
|
596
|
+
const desiredMap = /* @__PURE__ */ new Map();
|
|
597
|
+
const actualMap = /* @__PURE__ */ new Map();
|
|
598
|
+
for (const t of desired.tables)
|
|
599
|
+
desiredMap.set(t.name, t);
|
|
600
|
+
for (const t of actual.tables)
|
|
601
|
+
actualMap.set(t.name, t);
|
|
602
|
+
for (const [name, desiredTable] of desiredMap) {
|
|
603
|
+
if (!actualMap.has(name)) {
|
|
604
|
+
operations.push({
|
|
605
|
+
type: "createTable",
|
|
606
|
+
table: name,
|
|
607
|
+
columns: desiredTable.columns.map((c) => ({
|
|
608
|
+
name: c.name,
|
|
609
|
+
type: c.type,
|
|
610
|
+
nullable: c.nullable,
|
|
611
|
+
defaultValue: c.defaultValue ?? void 0,
|
|
612
|
+
primaryKey: c.isPrimaryKey || void 0
|
|
613
|
+
}))
|
|
614
|
+
});
|
|
615
|
+
summary.push(`Create table "${name}"`);
|
|
616
|
+
for (const fk of desiredTable.foreignKeys) {
|
|
617
|
+
operations.push({
|
|
618
|
+
type: "addForeignKey",
|
|
619
|
+
table: name,
|
|
620
|
+
constraintName: fk.constraintName,
|
|
621
|
+
column: fk.column,
|
|
622
|
+
referencedTable: fk.referencedTable,
|
|
623
|
+
referencedColumn: fk.referencedColumn,
|
|
624
|
+
onDelete: fk.onDelete
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
for (const idx of desiredTable.indexes) {
|
|
628
|
+
operations.push({
|
|
629
|
+
type: "createIndex",
|
|
630
|
+
table: name,
|
|
631
|
+
indexName: idx.name,
|
|
632
|
+
columns: idx.columns,
|
|
633
|
+
unique: idx.unique
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
for (const [name] of actualMap) {
|
|
639
|
+
if (!desiredMap.has(name)) {
|
|
640
|
+
operations.push({ type: "dropTable", table: name });
|
|
641
|
+
summary.push(`Drop table "${name}"`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
for (const [name, desiredTable] of desiredMap) {
|
|
645
|
+
const actualTable = actualMap.get(name);
|
|
646
|
+
if (!actualTable)
|
|
647
|
+
continue;
|
|
648
|
+
const tableOps = diffTable(desiredTable, actualTable, dialect, opts);
|
|
649
|
+
operations.push(...tableOps.operations);
|
|
650
|
+
summary.push(...tableOps.summary);
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
hasChanges: operations.length > 0,
|
|
654
|
+
operations,
|
|
655
|
+
summary
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
function diffTable(desired, actual, dialect, opts) {
|
|
659
|
+
const operations = [];
|
|
660
|
+
const summary = [];
|
|
661
|
+
const tableName = desired.name;
|
|
662
|
+
const colOps = diffColumns(tableName, desired.columns, actual.columns, dialect, opts);
|
|
663
|
+
operations.push(...colOps.operations);
|
|
664
|
+
summary.push(...colOps.summary);
|
|
665
|
+
const fkOps = diffForeignKeys(tableName, desired.foreignKeys, actual.foreignKeys);
|
|
666
|
+
operations.push(...fkOps.operations);
|
|
667
|
+
summary.push(...fkOps.summary);
|
|
668
|
+
const idxOps = diffIndexes(tableName, desired.indexes, actual.indexes);
|
|
669
|
+
operations.push(...idxOps.operations);
|
|
670
|
+
summary.push(...idxOps.summary);
|
|
671
|
+
return { operations, summary };
|
|
672
|
+
}
|
|
673
|
+
function diffColumns(tableName, desiredColumns, actualColumns, dialect, opts) {
|
|
674
|
+
const operations = [];
|
|
675
|
+
const summary = [];
|
|
676
|
+
const desiredMap = /* @__PURE__ */ new Map();
|
|
677
|
+
const actualMap = /* @__PURE__ */ new Map();
|
|
678
|
+
for (const c of desiredColumns)
|
|
679
|
+
desiredMap.set(c.name, c);
|
|
680
|
+
for (const c of actualColumns)
|
|
681
|
+
actualMap.set(c.name, c);
|
|
682
|
+
const renamedFrom = /* @__PURE__ */ new Set();
|
|
683
|
+
const renamedTo = /* @__PURE__ */ new Set();
|
|
684
|
+
if (opts.detectRenames) {
|
|
685
|
+
const missingInActual = [...desiredMap.keys()].filter((k) => !actualMap.has(k));
|
|
686
|
+
const extraInActual = [...actualMap.keys()].filter((k) => !desiredMap.has(k));
|
|
687
|
+
for (const newName of missingInActual) {
|
|
688
|
+
const desiredCol = desiredMap.get(newName);
|
|
689
|
+
for (const oldName of extraInActual) {
|
|
690
|
+
if (renamedFrom.has(oldName))
|
|
691
|
+
continue;
|
|
692
|
+
const actualCol = actualMap.get(oldName);
|
|
693
|
+
if (areTypesCompatible(desiredCol.type, actualCol.type, dialect)) {
|
|
694
|
+
operations.push({
|
|
695
|
+
type: "renameColumn",
|
|
696
|
+
table: tableName,
|
|
697
|
+
from: oldName,
|
|
698
|
+
to: newName
|
|
699
|
+
});
|
|
700
|
+
summary.push(
|
|
701
|
+
`Rename column "${tableName}"."${oldName}" \u2192 "${newName}"`
|
|
702
|
+
);
|
|
703
|
+
renamedFrom.add(oldName);
|
|
704
|
+
renamedTo.add(newName);
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
for (const [name, desiredCol] of desiredMap) {
|
|
711
|
+
if (actualMap.has(name) || renamedTo.has(name))
|
|
712
|
+
continue;
|
|
713
|
+
operations.push({
|
|
714
|
+
type: "addColumn",
|
|
715
|
+
table: tableName,
|
|
716
|
+
column: name,
|
|
717
|
+
columnType: desiredCol.type,
|
|
718
|
+
nullable: desiredCol.nullable,
|
|
719
|
+
defaultValue: desiredCol.defaultValue ?? void 0
|
|
720
|
+
});
|
|
721
|
+
summary.push(`Add column "${tableName}"."${name}" (${desiredCol.type})`);
|
|
722
|
+
}
|
|
723
|
+
for (const [name, actualCol] of actualMap) {
|
|
724
|
+
if (desiredMap.has(name) || renamedFrom.has(name))
|
|
725
|
+
continue;
|
|
726
|
+
operations.push({
|
|
727
|
+
type: "dropColumn",
|
|
728
|
+
table: tableName,
|
|
729
|
+
column: name,
|
|
730
|
+
previousType: actualCol.type,
|
|
731
|
+
previousNullable: actualCol.nullable
|
|
732
|
+
});
|
|
733
|
+
summary.push(`Drop column "${tableName}"."${name}"`);
|
|
734
|
+
}
|
|
735
|
+
for (const [name, desiredCol] of desiredMap) {
|
|
736
|
+
const actualCol = actualMap.get(name);
|
|
737
|
+
if (!actualCol)
|
|
738
|
+
continue;
|
|
739
|
+
if (!areTypesCompatible(desiredCol.type, actualCol.type, dialect)) {
|
|
740
|
+
operations.push({
|
|
741
|
+
type: "alterColumnType",
|
|
742
|
+
table: tableName,
|
|
743
|
+
column: name,
|
|
744
|
+
fromType: normalizeColumnType(actualCol.type, dialect),
|
|
745
|
+
toType: normalizeColumnType(desiredCol.type, dialect)
|
|
746
|
+
});
|
|
747
|
+
summary.push(
|
|
748
|
+
`Change type "${tableName}"."${name}": ${actualCol.type} \u2192 ${desiredCol.type}`
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
if (desiredCol.nullable !== actualCol.nullable) {
|
|
752
|
+
operations.push({
|
|
753
|
+
type: "alterColumnNullable",
|
|
754
|
+
table: tableName,
|
|
755
|
+
column: name,
|
|
756
|
+
nullable: desiredCol.nullable
|
|
757
|
+
});
|
|
758
|
+
summary.push(
|
|
759
|
+
`Change nullable "${tableName}"."${name}": ${actualCol.nullable} \u2192 ${desiredCol.nullable}`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
if (normalizeDefault(desiredCol.defaultValue) !== normalizeDefault(actualCol.defaultValue)) {
|
|
763
|
+
operations.push({
|
|
764
|
+
type: "alterColumnDefault",
|
|
765
|
+
table: tableName,
|
|
766
|
+
column: name,
|
|
767
|
+
defaultValue: desiredCol.defaultValue,
|
|
768
|
+
previousDefault: actualCol.defaultValue
|
|
769
|
+
});
|
|
770
|
+
summary.push(
|
|
771
|
+
`Change default "${tableName}"."${name}": ${actualCol.defaultValue ?? "NULL"} \u2192 ${desiredCol.defaultValue ?? "NULL"}`
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return { operations, summary };
|
|
776
|
+
}
|
|
777
|
+
function normalizeDefault(value) {
|
|
778
|
+
if (value === null || value === void 0 || value === "")
|
|
779
|
+
return null;
|
|
780
|
+
return value;
|
|
781
|
+
}
|
|
782
|
+
function diffForeignKeys(tableName, desiredFks, actualFks) {
|
|
783
|
+
const operations = [];
|
|
784
|
+
const summary = [];
|
|
785
|
+
const desiredMap = /* @__PURE__ */ new Map();
|
|
786
|
+
const actualMap = /* @__PURE__ */ new Map();
|
|
787
|
+
for (const fk of desiredFks)
|
|
788
|
+
desiredMap.set(fk.constraintName, fk);
|
|
789
|
+
for (const fk of actualFks)
|
|
790
|
+
actualMap.set(fk.constraintName, fk);
|
|
791
|
+
for (const [name, fk] of desiredMap) {
|
|
792
|
+
if (!actualMap.has(name)) {
|
|
793
|
+
operations.push({
|
|
794
|
+
type: "addForeignKey",
|
|
795
|
+
table: tableName,
|
|
796
|
+
constraintName: fk.constraintName,
|
|
797
|
+
column: fk.column,
|
|
798
|
+
referencedTable: fk.referencedTable,
|
|
799
|
+
referencedColumn: fk.referencedColumn,
|
|
800
|
+
onDelete: fk.onDelete
|
|
801
|
+
});
|
|
802
|
+
summary.push(`Add foreign key "${name}" on "${tableName}"`);
|
|
803
|
+
} else {
|
|
804
|
+
const actualFk = actualMap.get(name);
|
|
805
|
+
if (fk.column !== actualFk.column || fk.referencedTable !== actualFk.referencedTable || fk.referencedColumn !== actualFk.referencedColumn || fk.onDelete !== actualFk.onDelete) {
|
|
806
|
+
operations.push({
|
|
807
|
+
type: "dropForeignKey",
|
|
808
|
+
table: tableName,
|
|
809
|
+
constraintName: name
|
|
810
|
+
});
|
|
811
|
+
operations.push({
|
|
812
|
+
type: "addForeignKey",
|
|
813
|
+
table: tableName,
|
|
814
|
+
constraintName: fk.constraintName,
|
|
815
|
+
column: fk.column,
|
|
816
|
+
referencedTable: fk.referencedTable,
|
|
817
|
+
referencedColumn: fk.referencedColumn,
|
|
818
|
+
onDelete: fk.onDelete
|
|
819
|
+
});
|
|
820
|
+
summary.push(`Modify foreign key "${name}" on "${tableName}"`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
for (const [name] of actualMap) {
|
|
825
|
+
if (!desiredMap.has(name)) {
|
|
826
|
+
operations.push({
|
|
827
|
+
type: "dropForeignKey",
|
|
828
|
+
table: tableName,
|
|
829
|
+
constraintName: name
|
|
830
|
+
});
|
|
831
|
+
summary.push(`Drop foreign key "${name}" on "${tableName}"`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return { operations, summary };
|
|
835
|
+
}
|
|
836
|
+
function diffIndexes(tableName, desiredIdxs, actualIdxs) {
|
|
837
|
+
const operations = [];
|
|
838
|
+
const summary = [];
|
|
839
|
+
const desiredMap = /* @__PURE__ */ new Map();
|
|
840
|
+
const actualMap = /* @__PURE__ */ new Map();
|
|
841
|
+
for (const idx of desiredIdxs)
|
|
842
|
+
desiredMap.set(idx.name, idx);
|
|
843
|
+
for (const idx of actualIdxs)
|
|
844
|
+
actualMap.set(idx.name, idx);
|
|
845
|
+
for (const [name, idx] of desiredMap) {
|
|
846
|
+
if (!actualMap.has(name)) {
|
|
847
|
+
operations.push({
|
|
848
|
+
type: "createIndex",
|
|
849
|
+
table: tableName,
|
|
850
|
+
indexName: idx.name,
|
|
851
|
+
columns: idx.columns,
|
|
852
|
+
unique: idx.unique
|
|
853
|
+
});
|
|
854
|
+
summary.push(`Create index "${name}" on "${tableName}"`);
|
|
855
|
+
} else {
|
|
856
|
+
const actualIdx = actualMap.get(name);
|
|
857
|
+
if (idx.unique !== actualIdx.unique || JSON.stringify(idx.columns) !== JSON.stringify(actualIdx.columns)) {
|
|
858
|
+
operations.push({
|
|
859
|
+
type: "dropIndex",
|
|
860
|
+
table: tableName,
|
|
861
|
+
indexName: name
|
|
862
|
+
});
|
|
863
|
+
operations.push({
|
|
864
|
+
type: "createIndex",
|
|
865
|
+
table: tableName,
|
|
866
|
+
indexName: idx.name,
|
|
867
|
+
columns: idx.columns,
|
|
868
|
+
unique: idx.unique
|
|
869
|
+
});
|
|
870
|
+
summary.push(`Modify index "${name}" on "${tableName}"`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
for (const [name] of actualMap) {
|
|
875
|
+
if (!desiredMap.has(name)) {
|
|
876
|
+
operations.push({
|
|
877
|
+
type: "dropIndex",
|
|
878
|
+
table: tableName,
|
|
879
|
+
indexName: name
|
|
880
|
+
});
|
|
881
|
+
summary.push(`Drop index "${name}" on "${tableName}"`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return { operations, summary };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// libs/migrations/src/lib/danger/danger-detector.ts
|
|
888
|
+
function detectDangers(operations, dialect) {
|
|
889
|
+
const warnings = [];
|
|
890
|
+
for (let i = 0; i < operations.length; i++) {
|
|
891
|
+
const op = operations[i];
|
|
892
|
+
warnings.push(...checkOperation(op, i, operations, dialect));
|
|
893
|
+
}
|
|
894
|
+
const severityOrder = { error: 0, warning: 1, info: 2 };
|
|
895
|
+
warnings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
896
|
+
return {
|
|
897
|
+
warnings,
|
|
898
|
+
hasErrors: warnings.some((w) => w.severity === "error"),
|
|
899
|
+
hasWarnings: warnings.some((w) => w.severity === "warning")
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
function checkOperation(op, index, _allOps, dialect) {
|
|
903
|
+
const warnings = [];
|
|
904
|
+
switch (op.type) {
|
|
905
|
+
case "dropTable":
|
|
906
|
+
warnings.push({
|
|
907
|
+
severity: "error",
|
|
908
|
+
operation: op,
|
|
909
|
+
operationIndex: index,
|
|
910
|
+
message: `Dropping table "${op.table}" will permanently delete all data.`,
|
|
911
|
+
suggestion: 'Consider renaming the table with a deprecation prefix (e.g., "_deprecated_") and scheduling deletion after verifying no data is needed.'
|
|
912
|
+
});
|
|
913
|
+
break;
|
|
914
|
+
case "dropColumn":
|
|
915
|
+
warnings.push({
|
|
916
|
+
severity: "warning",
|
|
917
|
+
operation: op,
|
|
918
|
+
operationIndex: index,
|
|
919
|
+
message: `Dropping column "${op.table}"."${op.column}" will permanently delete all values in this column.`,
|
|
920
|
+
suggestion: "Before dropping, verify the column data is either migrated elsewhere or truly unneeded. Consider a backup or data export first."
|
|
921
|
+
});
|
|
922
|
+
break;
|
|
923
|
+
case "alterColumnType":
|
|
924
|
+
warnings.push(...checkTypeChange(op, index, dialect));
|
|
925
|
+
break;
|
|
926
|
+
case "alterColumnNullable":
|
|
927
|
+
if (!op.nullable) {
|
|
928
|
+
warnings.push({
|
|
929
|
+
severity: "warning",
|
|
930
|
+
operation: op,
|
|
931
|
+
operationIndex: index,
|
|
932
|
+
message: `Setting "${op.table}"."${op.column}" to NOT NULL may fail if existing rows contain NULL values.`,
|
|
933
|
+
suggestion: "First backfill NULL values with a default (e.g., UPDATE table SET column = 'default' WHERE column IS NULL), then add the NOT NULL constraint."
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
break;
|
|
937
|
+
case "addColumn":
|
|
938
|
+
if (!op.nullable && !op.defaultValue) {
|
|
939
|
+
warnings.push({
|
|
940
|
+
severity: "error",
|
|
941
|
+
operation: op,
|
|
942
|
+
operationIndex: index,
|
|
943
|
+
message: `Adding NOT NULL column "${op.table}"."${op.column}" without a default value will fail if the table has existing rows.`,
|
|
944
|
+
suggestion: "Either add a DEFAULT value, make the column nullable first and backfill, or add the column as nullable, backfill, then alter to NOT NULL."
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
break;
|
|
948
|
+
case "renameColumn":
|
|
949
|
+
warnings.push({
|
|
950
|
+
severity: "warning",
|
|
951
|
+
operation: op,
|
|
952
|
+
operationIndex: index,
|
|
953
|
+
message: `Renaming "${op.table}"."${op.from}" to "${op.to}" may break application code that references the old name.`,
|
|
954
|
+
suggestion: "Deploy application code changes to use the new column name before or alongside the migration. Consider a phased approach: add new column, migrate data, update code, then drop old column."
|
|
955
|
+
});
|
|
956
|
+
break;
|
|
957
|
+
case "renameTable":
|
|
958
|
+
warnings.push({
|
|
959
|
+
severity: "warning",
|
|
960
|
+
operation: op,
|
|
961
|
+
operationIndex: index,
|
|
962
|
+
message: `Renaming table "${op.from}" to "${op.to}" may break application code and queries.`,
|
|
963
|
+
suggestion: "Update application code to use the new table name before or alongside the migration."
|
|
964
|
+
});
|
|
965
|
+
break;
|
|
966
|
+
case "addForeignKey":
|
|
967
|
+
if (dialect === "postgresql") {
|
|
968
|
+
warnings.push({
|
|
969
|
+
severity: "info",
|
|
970
|
+
operation: op,
|
|
971
|
+
operationIndex: index,
|
|
972
|
+
message: `Adding foreign key "${op.constraintName}" acquires an ACCESS EXCLUSIVE lock on the referenced table.`,
|
|
973
|
+
suggestion: "On large tables, consider adding the FK constraint with NOT VALID first, then validating separately: ALTER TABLE ... ADD CONSTRAINT ... NOT VALID; ALTER TABLE ... VALIDATE CONSTRAINT ..."
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
break;
|
|
977
|
+
case "createIndex":
|
|
978
|
+
if (dialect === "postgresql" && !isCreateIndexConcurrent(op)) {
|
|
979
|
+
warnings.push({
|
|
980
|
+
severity: "info",
|
|
981
|
+
operation: op,
|
|
982
|
+
operationIndex: index,
|
|
983
|
+
message: `Creating index "${op.indexName}" will lock "${op.table}" for writes during index creation.`,
|
|
984
|
+
suggestion: "For large tables, consider CREATE INDEX CONCURRENTLY to avoid blocking writes (requires running outside a transaction)."
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
break;
|
|
988
|
+
}
|
|
989
|
+
return warnings;
|
|
990
|
+
}
|
|
991
|
+
function checkTypeChange(op, index, dialect) {
|
|
992
|
+
const warnings = [];
|
|
993
|
+
if (dialect === "sqlite") {
|
|
994
|
+
warnings.push({
|
|
995
|
+
severity: "error",
|
|
996
|
+
operation: op,
|
|
997
|
+
operationIndex: index,
|
|
998
|
+
message: `SQLite does not support ALTER COLUMN TYPE. Changing "${op.table}"."${op.column}" from ${op.fromType} to ${op.toType} requires a table rebuild.`,
|
|
999
|
+
suggestion: "Create a new table with the desired schema, copy data, drop old table, and rename new table. Use a raw SQL migration for this."
|
|
1000
|
+
});
|
|
1001
|
+
return warnings;
|
|
1002
|
+
}
|
|
1003
|
+
if (isLossyTypeChange(op.fromType, op.toType)) {
|
|
1004
|
+
warnings.push({
|
|
1005
|
+
severity: "warning",
|
|
1006
|
+
operation: op,
|
|
1007
|
+
operationIndex: index,
|
|
1008
|
+
message: `Changing "${op.table}"."${op.column}" from ${op.fromType} to ${op.toType} may cause data loss or cast errors.`,
|
|
1009
|
+
suggestion: "Test the type conversion on a clone database first. Consider adding a USING clause with explicit cast logic."
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
if (isTableRewriteType(op.fromType, op.toType)) {
|
|
1013
|
+
warnings.push({
|
|
1014
|
+
severity: "info",
|
|
1015
|
+
operation: op,
|
|
1016
|
+
operationIndex: index,
|
|
1017
|
+
message: `Changing "${op.table}"."${op.column}" from ${op.fromType} to ${op.toType} may require a table rewrite on large tables.`,
|
|
1018
|
+
suggestion: "On large tables, this can take significant time and lock the table. Consider running during low-traffic periods or using a phased approach."
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
return warnings;
|
|
1022
|
+
}
|
|
1023
|
+
function isLossyTypeChange(from, to) {
|
|
1024
|
+
const fromUpper = from.toUpperCase();
|
|
1025
|
+
const toUpper = to.toUpperCase();
|
|
1026
|
+
if (isTextType(fromUpper) && isNumericType(toUpper))
|
|
1027
|
+
return true;
|
|
1028
|
+
if (fromUpper === "NUMERIC" && (toUpper === "INTEGER" || toUpper === "SMALLINT"))
|
|
1029
|
+
return true;
|
|
1030
|
+
if (fromUpper === "BIGINT" && (toUpper === "INTEGER" || toUpper === "SMALLINT"))
|
|
1031
|
+
return true;
|
|
1032
|
+
if (fromUpper === "DOUBLE PRECISION" && toUpper === "REAL")
|
|
1033
|
+
return true;
|
|
1034
|
+
if ((fromUpper === "JSONB" || fromUpper === "JSON") && !fromUpper.includes("JSON"))
|
|
1035
|
+
return true;
|
|
1036
|
+
if (fromUpper.includes("TIMESTAMP") && toUpper === "DATE")
|
|
1037
|
+
return true;
|
|
1038
|
+
const fromLength = extractLength(fromUpper);
|
|
1039
|
+
const toLength = extractLength(toUpper);
|
|
1040
|
+
if (fromLength && toLength && toLength < fromLength)
|
|
1041
|
+
return true;
|
|
1042
|
+
return false;
|
|
1043
|
+
}
|
|
1044
|
+
function isTableRewriteType(from, to) {
|
|
1045
|
+
const fromUpper = from.toUpperCase();
|
|
1046
|
+
const toUpper = to.toUpperCase();
|
|
1047
|
+
if (fromUpper.startsWith("VARCHAR") && toUpper === "TEXT")
|
|
1048
|
+
return false;
|
|
1049
|
+
if (fromUpper === "TEXT" && toUpper.startsWith("VARCHAR"))
|
|
1050
|
+
return true;
|
|
1051
|
+
if (isNumericType(fromUpper) !== isNumericType(toUpper))
|
|
1052
|
+
return true;
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
function isTextType(type) {
|
|
1056
|
+
return type === "TEXT" || type.startsWith("VARCHAR") || type.startsWith("CHAR");
|
|
1057
|
+
}
|
|
1058
|
+
function isNumericType(type) {
|
|
1059
|
+
return ["INTEGER", "BIGINT", "SMALLINT", "NUMERIC", "REAL", "DOUBLE PRECISION", "FLOAT"].includes(
|
|
1060
|
+
type
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
function extractLength(type) {
|
|
1064
|
+
const match = type.match(/\((\d+)\)/);
|
|
1065
|
+
return match ? parseInt(match[1], 10) : null;
|
|
1066
|
+
}
|
|
1067
|
+
function isCreateIndexConcurrent(_op) {
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// libs/migrations/src/lib/generator/sql-generator.ts
|
|
1072
|
+
function operationToSql(op, dialect) {
|
|
1073
|
+
switch (op.type) {
|
|
1074
|
+
case "createTable":
|
|
1075
|
+
return generateCreateTable(op, dialect);
|
|
1076
|
+
case "dropTable":
|
|
1077
|
+
return `DROP TABLE IF EXISTS "${op.table}"`;
|
|
1078
|
+
case "renameTable":
|
|
1079
|
+
return `ALTER TABLE "${op.from}" RENAME TO "${op.to}"`;
|
|
1080
|
+
case "addColumn":
|
|
1081
|
+
return generateAddColumn(op, dialect);
|
|
1082
|
+
case "dropColumn":
|
|
1083
|
+
return generateDropColumn(op, dialect);
|
|
1084
|
+
case "alterColumnType":
|
|
1085
|
+
return generateAlterColumnType(op, dialect);
|
|
1086
|
+
case "alterColumnNullable":
|
|
1087
|
+
return generateAlterColumnNullable(op, dialect);
|
|
1088
|
+
case "alterColumnDefault":
|
|
1089
|
+
return generateAlterColumnDefault(op, dialect);
|
|
1090
|
+
case "renameColumn":
|
|
1091
|
+
return `ALTER TABLE "${op.table}" RENAME COLUMN "${op.from}" TO "${op.to}"`;
|
|
1092
|
+
case "addForeignKey":
|
|
1093
|
+
return generateAddForeignKey(op, dialect);
|
|
1094
|
+
case "dropForeignKey":
|
|
1095
|
+
return generateDropForeignKey(op, dialect);
|
|
1096
|
+
case "createIndex":
|
|
1097
|
+
return generateCreateIndex(op);
|
|
1098
|
+
case "dropIndex":
|
|
1099
|
+
return `DROP INDEX IF EXISTS "${op.indexName}"`;
|
|
1100
|
+
case "rawSql":
|
|
1101
|
+
return op.upSql;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
function operationToReverseSql(op, dialect) {
|
|
1105
|
+
switch (op.type) {
|
|
1106
|
+
case "createTable":
|
|
1107
|
+
return `DROP TABLE IF EXISTS "${op.table}"`;
|
|
1108
|
+
case "dropTable":
|
|
1109
|
+
return null;
|
|
1110
|
+
case "renameTable":
|
|
1111
|
+
return `ALTER TABLE "${op.to}" RENAME TO "${op.from}"`;
|
|
1112
|
+
case "addColumn":
|
|
1113
|
+
return `ALTER TABLE "${op.table}" DROP COLUMN "${op.column}"`;
|
|
1114
|
+
case "dropColumn":
|
|
1115
|
+
if (op.previousType) {
|
|
1116
|
+
const nullable = op.previousNullable !== false ? "" : " NOT NULL";
|
|
1117
|
+
return `ALTER TABLE "${op.table}" ADD COLUMN "${op.column}" ${op.previousType}${nullable}`;
|
|
1118
|
+
}
|
|
1119
|
+
return null;
|
|
1120
|
+
case "alterColumnType":
|
|
1121
|
+
return generateAlterColumnType(
|
|
1122
|
+
{ ...op, fromType: op.toType, toType: op.fromType },
|
|
1123
|
+
dialect
|
|
1124
|
+
);
|
|
1125
|
+
case "alterColumnNullable":
|
|
1126
|
+
return generateAlterColumnNullable(
|
|
1127
|
+
{ ...op, nullable: !op.nullable },
|
|
1128
|
+
dialect
|
|
1129
|
+
);
|
|
1130
|
+
case "alterColumnDefault":
|
|
1131
|
+
return generateAlterColumnDefault(
|
|
1132
|
+
{
|
|
1133
|
+
...op,
|
|
1134
|
+
defaultValue: op.previousDefault,
|
|
1135
|
+
previousDefault: op.defaultValue
|
|
1136
|
+
},
|
|
1137
|
+
dialect
|
|
1138
|
+
);
|
|
1139
|
+
case "renameColumn":
|
|
1140
|
+
return `ALTER TABLE "${op.table}" RENAME COLUMN "${op.to}" TO "${op.from}"`;
|
|
1141
|
+
case "addForeignKey":
|
|
1142
|
+
return generateDropForeignKey(
|
|
1143
|
+
{ type: "dropForeignKey", table: op.table, constraintName: op.constraintName },
|
|
1144
|
+
dialect
|
|
1145
|
+
);
|
|
1146
|
+
case "dropForeignKey":
|
|
1147
|
+
return null;
|
|
1148
|
+
case "createIndex":
|
|
1149
|
+
return `DROP INDEX IF EXISTS "${op.indexName}"`;
|
|
1150
|
+
case "dropIndex":
|
|
1151
|
+
return null;
|
|
1152
|
+
case "rawSql":
|
|
1153
|
+
return op.downSql;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
function operationsToUpSql(operations, dialect) {
|
|
1157
|
+
return operations.map((op) => operationToSql(op, dialect));
|
|
1158
|
+
}
|
|
1159
|
+
function operationsToDownSql(operations, dialect) {
|
|
1160
|
+
return [...operations].reverse().map((op) => operationToReverseSql(op, dialect)).filter((sql) => sql !== null);
|
|
1161
|
+
}
|
|
1162
|
+
function generateCreateTable(op, _dialect) {
|
|
1163
|
+
const columnDefs = op.columns.map((c) => {
|
|
1164
|
+
let def = `"${c.name}" ${c.type}`;
|
|
1165
|
+
if (c.primaryKey)
|
|
1166
|
+
def += " PRIMARY KEY";
|
|
1167
|
+
if (!c.nullable)
|
|
1168
|
+
def += " NOT NULL";
|
|
1169
|
+
if (c.defaultValue)
|
|
1170
|
+
def += ` DEFAULT ${c.defaultValue}`;
|
|
1171
|
+
return def;
|
|
1172
|
+
});
|
|
1173
|
+
return `CREATE TABLE "${op.table}" (
|
|
1174
|
+
${columnDefs.join(",\n ")}
|
|
1175
|
+
)`;
|
|
1176
|
+
}
|
|
1177
|
+
function generateAddColumn(op, _dialect) {
|
|
1178
|
+
let sql = `ALTER TABLE "${op.table}" ADD COLUMN "${op.column}" ${op.columnType}`;
|
|
1179
|
+
if (!op.nullable)
|
|
1180
|
+
sql += " NOT NULL";
|
|
1181
|
+
if (op.defaultValue)
|
|
1182
|
+
sql += ` DEFAULT ${op.defaultValue}`;
|
|
1183
|
+
return sql;
|
|
1184
|
+
}
|
|
1185
|
+
function generateDropColumn(op, _dialect) {
|
|
1186
|
+
return `ALTER TABLE "${op.table}" DROP COLUMN "${op.column}"`;
|
|
1187
|
+
}
|
|
1188
|
+
function generateAlterColumnType(op, dialect) {
|
|
1189
|
+
if (dialect === "sqlite") {
|
|
1190
|
+
return `-- SQLite: Cannot alter column type for "${op.table}"."${op.column}" (${op.fromType} \u2192 ${op.toType}). Requires table rebuild.`;
|
|
1191
|
+
}
|
|
1192
|
+
const using = op.castExpression ? ` USING ${op.castExpression}` : ` USING "${op.column}"::${op.toType}`;
|
|
1193
|
+
return `ALTER TABLE "${op.table}" ALTER COLUMN "${op.column}" TYPE ${op.toType}${using}`;
|
|
1194
|
+
}
|
|
1195
|
+
function generateAlterColumnNullable(op, dialect) {
|
|
1196
|
+
if (dialect === "sqlite") {
|
|
1197
|
+
return `-- SQLite: Cannot alter nullable for "${op.table}"."${op.column}". Requires table rebuild.`;
|
|
1198
|
+
}
|
|
1199
|
+
if (op.nullable) {
|
|
1200
|
+
return `ALTER TABLE "${op.table}" ALTER COLUMN "${op.column}" DROP NOT NULL`;
|
|
1201
|
+
}
|
|
1202
|
+
return `ALTER TABLE "${op.table}" ALTER COLUMN "${op.column}" SET NOT NULL`;
|
|
1203
|
+
}
|
|
1204
|
+
function generateAlterColumnDefault(op, dialect) {
|
|
1205
|
+
if (dialect === "sqlite") {
|
|
1206
|
+
return `-- SQLite: Cannot alter default for "${op.table}"."${op.column}". Requires table rebuild.`;
|
|
1207
|
+
}
|
|
1208
|
+
if (op.defaultValue === null) {
|
|
1209
|
+
return `ALTER TABLE "${op.table}" ALTER COLUMN "${op.column}" DROP DEFAULT`;
|
|
1210
|
+
}
|
|
1211
|
+
return `ALTER TABLE "${op.table}" ALTER COLUMN "${op.column}" SET DEFAULT ${op.defaultValue}`;
|
|
1212
|
+
}
|
|
1213
|
+
function generateAddForeignKey(op, dialect) {
|
|
1214
|
+
if (dialect === "sqlite") {
|
|
1215
|
+
return `-- SQLite: Cannot add FK "${op.constraintName}" after table creation. Requires table rebuild.`;
|
|
1216
|
+
}
|
|
1217
|
+
return `ALTER TABLE "${op.table}" ADD CONSTRAINT "${op.constraintName}" FOREIGN KEY ("${op.column}") REFERENCES "${op.referencedTable}"("${op.referencedColumn}") ON DELETE ${op.onDelete}`;
|
|
1218
|
+
}
|
|
1219
|
+
function generateDropForeignKey(op, dialect) {
|
|
1220
|
+
if (dialect === "sqlite") {
|
|
1221
|
+
return `-- SQLite: Cannot drop FK "${op.constraintName}" after table creation. Requires table rebuild.`;
|
|
1222
|
+
}
|
|
1223
|
+
return `ALTER TABLE "${op.table}" DROP CONSTRAINT "${op.constraintName}"`;
|
|
1224
|
+
}
|
|
1225
|
+
function generateCreateIndex(op) {
|
|
1226
|
+
const unique = op.unique ? "UNIQUE " : "";
|
|
1227
|
+
const cols = op.columns.map((c) => `"${c}"`).join(", ");
|
|
1228
|
+
return `CREATE ${unique}INDEX IF NOT EXISTS "${op.indexName}" ON "${op.table}" (${cols})`;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// libs/migrations/src/lib/generator/migration-file-generator.ts
|
|
1232
|
+
function generateMigrationName(name, timestamp) {
|
|
1233
|
+
const d = timestamp ?? /* @__PURE__ */ new Date();
|
|
1234
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1235
|
+
const prefix = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
1236
|
+
return `${prefix}_${name}`;
|
|
1237
|
+
}
|
|
1238
|
+
function generateMigrationFileContent(diff, options) {
|
|
1239
|
+
const { name, description, dialect } = options;
|
|
1240
|
+
const desc = description ?? (diff.summary.join("; ") || "Auto-generated migration");
|
|
1241
|
+
const upStatements = operationsToUpSql(diff.operations, dialect);
|
|
1242
|
+
const downStatements = operationsToDownSql(diff.operations, dialect);
|
|
1243
|
+
const operationsMeta = serializeOperationsMeta(diff.operations);
|
|
1244
|
+
const lines = [];
|
|
1245
|
+
lines.push("import type { MigrationFile, MigrationContext } from '@momentumcms/migrations';");
|
|
1246
|
+
lines.push("");
|
|
1247
|
+
lines.push("export const meta: MigrationFile['meta'] = {");
|
|
1248
|
+
lines.push(` name: ${JSON.stringify(name)},`);
|
|
1249
|
+
lines.push(` description: ${JSON.stringify(desc)},`);
|
|
1250
|
+
lines.push(` operations: ${operationsMeta},`);
|
|
1251
|
+
lines.push("};");
|
|
1252
|
+
lines.push("");
|
|
1253
|
+
lines.push("export async function up(ctx: MigrationContext): Promise<void> {");
|
|
1254
|
+
for (const sql of upStatements) {
|
|
1255
|
+
if (sql.startsWith("--")) {
|
|
1256
|
+
lines.push(` // ${sql.slice(3)}`);
|
|
1257
|
+
} else {
|
|
1258
|
+
lines.push(` await ctx.sql(${JSON.stringify(sql)});`);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
if (upStatements.length === 0) {
|
|
1262
|
+
lines.push(" // No operations");
|
|
1263
|
+
}
|
|
1264
|
+
lines.push("}");
|
|
1265
|
+
lines.push("");
|
|
1266
|
+
lines.push("export async function down(ctx: MigrationContext): Promise<void> {");
|
|
1267
|
+
for (const sql of downStatements) {
|
|
1268
|
+
if (sql.startsWith("--")) {
|
|
1269
|
+
lines.push(` // ${sql.slice(3)}`);
|
|
1270
|
+
} else {
|
|
1271
|
+
lines.push(` await ctx.sql(${JSON.stringify(sql)});`);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
if (downStatements.length === 0) {
|
|
1275
|
+
lines.push(" // Cannot reverse all operations");
|
|
1276
|
+
}
|
|
1277
|
+
lines.push("}");
|
|
1278
|
+
lines.push("");
|
|
1279
|
+
return lines.join("\n");
|
|
1280
|
+
}
|
|
1281
|
+
function serializeOperationsMeta(operations) {
|
|
1282
|
+
const simplified = operations.map((op) => {
|
|
1283
|
+
switch (op.type) {
|
|
1284
|
+
case "createTable":
|
|
1285
|
+
return { type: op.type, table: op.table };
|
|
1286
|
+
case "dropTable":
|
|
1287
|
+
return { type: op.type, table: op.table };
|
|
1288
|
+
case "renameTable":
|
|
1289
|
+
return { type: op.type, from: op.from, to: op.to };
|
|
1290
|
+
case "addColumn":
|
|
1291
|
+
return {
|
|
1292
|
+
type: op.type,
|
|
1293
|
+
table: op.table,
|
|
1294
|
+
column: op.column,
|
|
1295
|
+
nullable: op.nullable,
|
|
1296
|
+
defaultValue: op.defaultValue ?? null
|
|
1297
|
+
};
|
|
1298
|
+
case "dropColumn":
|
|
1299
|
+
return { type: op.type, table: op.table, column: op.column };
|
|
1300
|
+
case "alterColumnType":
|
|
1301
|
+
return {
|
|
1302
|
+
type: op.type,
|
|
1303
|
+
table: op.table,
|
|
1304
|
+
column: op.column,
|
|
1305
|
+
fromType: op.fromType,
|
|
1306
|
+
toType: op.toType
|
|
1307
|
+
};
|
|
1308
|
+
case "alterColumnNullable":
|
|
1309
|
+
return { type: op.type, table: op.table, column: op.column, nullable: op.nullable };
|
|
1310
|
+
case "alterColumnDefault":
|
|
1311
|
+
return { type: op.type, table: op.table, column: op.column };
|
|
1312
|
+
case "renameColumn":
|
|
1313
|
+
return { type: op.type, table: op.table, from: op.from, to: op.to };
|
|
1314
|
+
case "addForeignKey":
|
|
1315
|
+
return { type: op.type, table: op.table, constraintName: op.constraintName };
|
|
1316
|
+
case "dropForeignKey":
|
|
1317
|
+
return { type: op.type, table: op.table, constraintName: op.constraintName };
|
|
1318
|
+
case "createIndex":
|
|
1319
|
+
return { type: op.type, table: op.table, indexName: op.indexName };
|
|
1320
|
+
case "dropIndex":
|
|
1321
|
+
return { type: op.type, table: op.table, indexName: op.indexName };
|
|
1322
|
+
case "rawSql":
|
|
1323
|
+
return { type: op.type, description: op.description };
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1326
|
+
return JSON.stringify(simplified, null, " ");
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// libs/migrations/src/lib/loader/snapshot-manager.ts
|
|
1330
|
+
var import_node_fs = require("node:fs");
|
|
1331
|
+
var import_node_path = require("node:path");
|
|
1332
|
+
var SNAPSHOT_FILENAME = ".snapshot.json";
|
|
1333
|
+
function readSnapshot(directory) {
|
|
1334
|
+
const filePath = (0, import_node_path.join)(directory, SNAPSHOT_FILENAME);
|
|
1335
|
+
if (!(0, import_node_fs.existsSync)(filePath))
|
|
1336
|
+
return null;
|
|
1337
|
+
const json2 = (0, import_node_fs.readFileSync)(filePath, "utf-8");
|
|
1338
|
+
return deserializeSnapshot(json2);
|
|
1339
|
+
}
|
|
1340
|
+
function writeSnapshot(directory, snapshot) {
|
|
1341
|
+
(0, import_node_fs.mkdirSync)(directory, { recursive: true });
|
|
1342
|
+
const filePath = (0, import_node_path.join)(directory, SNAPSHOT_FILENAME);
|
|
1343
|
+
(0, import_node_fs.writeFileSync)(filePath, serializeSnapshot(snapshot), "utf-8");
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// libs/migrations/src/cli/shared.ts
|
|
1347
|
+
var import_node_url = require("node:url");
|
|
1348
|
+
|
|
1349
|
+
// libs/migrations/src/lib/schema/introspect-postgres.ts
|
|
1350
|
+
async function introspectPostgres(queryFn, schema = "public") {
|
|
1351
|
+
const [columnRows, fkRows, indexRows, pkRows] = await Promise.all([
|
|
1352
|
+
queryFn(
|
|
1353
|
+
`SELECT table_name, column_name, data_type, character_maximum_length, is_nullable, column_default
|
|
1354
|
+
FROM information_schema.columns
|
|
1355
|
+
WHERE table_schema = $1
|
|
1356
|
+
ORDER BY table_name, ordinal_position`,
|
|
1357
|
+
[schema]
|
|
1358
|
+
),
|
|
1359
|
+
queryFn(
|
|
1360
|
+
`SELECT
|
|
1361
|
+
tc.table_name,
|
|
1362
|
+
tc.constraint_name,
|
|
1363
|
+
kcu.column_name,
|
|
1364
|
+
ccu.table_name AS foreign_table_name,
|
|
1365
|
+
ccu.column_name AS foreign_column_name,
|
|
1366
|
+
rc.delete_rule
|
|
1367
|
+
FROM information_schema.table_constraints tc
|
|
1368
|
+
JOIN information_schema.key_column_usage kcu
|
|
1369
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
1370
|
+
AND tc.table_schema = kcu.table_schema
|
|
1371
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
1372
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
1373
|
+
AND ccu.table_schema = tc.table_schema
|
|
1374
|
+
JOIN information_schema.referential_constraints rc
|
|
1375
|
+
ON rc.constraint_name = tc.constraint_name
|
|
1376
|
+
AND rc.constraint_schema = tc.constraint_schema
|
|
1377
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
1378
|
+
AND tc.table_schema = $1
|
|
1379
|
+
ORDER BY tc.table_name, tc.constraint_name`,
|
|
1380
|
+
[schema]
|
|
1381
|
+
),
|
|
1382
|
+
queryFn(
|
|
1383
|
+
`SELECT tablename, indexname, indexdef
|
|
1384
|
+
FROM pg_indexes
|
|
1385
|
+
WHERE schemaname = $1
|
|
1386
|
+
ORDER BY tablename, indexname`,
|
|
1387
|
+
[schema]
|
|
1388
|
+
),
|
|
1389
|
+
queryFn(
|
|
1390
|
+
`SELECT tc.table_name, kcu.column_name
|
|
1391
|
+
FROM information_schema.table_constraints tc
|
|
1392
|
+
JOIN information_schema.key_column_usage kcu
|
|
1393
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
1394
|
+
AND tc.table_schema = kcu.table_schema
|
|
1395
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
1396
|
+
AND tc.table_schema = $1`,
|
|
1397
|
+
[schema]
|
|
1398
|
+
)
|
|
1399
|
+
]);
|
|
1400
|
+
const pkLookup = /* @__PURE__ */ new Map();
|
|
1401
|
+
for (const row2 of pkRows) {
|
|
1402
|
+
const tableName = row2.table_name;
|
|
1403
|
+
if (!pkLookup.has(tableName)) {
|
|
1404
|
+
pkLookup.set(tableName, /* @__PURE__ */ new Set());
|
|
1405
|
+
}
|
|
1406
|
+
pkLookup.get(tableName).add(row2.column_name);
|
|
1407
|
+
}
|
|
1408
|
+
const tableColumnsMap = /* @__PURE__ */ new Map();
|
|
1409
|
+
for (const row2 of columnRows) {
|
|
1410
|
+
const tableName = row2.table_name;
|
|
1411
|
+
if (INTERNAL_TABLES.has(tableName))
|
|
1412
|
+
continue;
|
|
1413
|
+
if (!tableColumnsMap.has(tableName)) {
|
|
1414
|
+
tableColumnsMap.set(tableName, []);
|
|
1415
|
+
}
|
|
1416
|
+
const rawType = buildPgColumnType(row2);
|
|
1417
|
+
const pkSet = pkLookup.get(tableName);
|
|
1418
|
+
tableColumnsMap.get(tableName).push({
|
|
1419
|
+
name: row2.column_name,
|
|
1420
|
+
type: normalizeColumnType(rawType, "postgresql"),
|
|
1421
|
+
nullable: row2.is_nullable === "YES",
|
|
1422
|
+
defaultValue: row2.column_default,
|
|
1423
|
+
isPrimaryKey: pkSet?.has(row2.column_name) ?? false
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
const tableFkMap = /* @__PURE__ */ new Map();
|
|
1427
|
+
for (const row2 of fkRows) {
|
|
1428
|
+
const tableName = row2.table_name;
|
|
1429
|
+
if (INTERNAL_TABLES.has(tableName))
|
|
1430
|
+
continue;
|
|
1431
|
+
if (!tableFkMap.has(tableName)) {
|
|
1432
|
+
tableFkMap.set(tableName, []);
|
|
1433
|
+
}
|
|
1434
|
+
tableFkMap.get(tableName).push({
|
|
1435
|
+
constraintName: row2.constraint_name,
|
|
1436
|
+
column: row2.column_name,
|
|
1437
|
+
referencedTable: row2.foreign_table_name,
|
|
1438
|
+
referencedColumn: row2.foreign_column_name,
|
|
1439
|
+
onDelete: row2.delete_rule
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
const fkConstraintNames = new Set(fkRows.map((r) => r.constraint_name));
|
|
1443
|
+
const tableIndexMap = /* @__PURE__ */ new Map();
|
|
1444
|
+
for (const row2 of indexRows) {
|
|
1445
|
+
const tableName = row2.tablename;
|
|
1446
|
+
if (INTERNAL_TABLES.has(tableName))
|
|
1447
|
+
continue;
|
|
1448
|
+
if (row2.indexdef.includes("PRIMARY KEY"))
|
|
1449
|
+
continue;
|
|
1450
|
+
if (fkConstraintNames.has(row2.indexname))
|
|
1451
|
+
continue;
|
|
1452
|
+
if (row2.indexname.endsWith("_pkey"))
|
|
1453
|
+
continue;
|
|
1454
|
+
if (!tableIndexMap.has(tableName)) {
|
|
1455
|
+
tableIndexMap.set(tableName, []);
|
|
1456
|
+
}
|
|
1457
|
+
const columns = extractIndexColumns(row2.indexdef);
|
|
1458
|
+
const unique = row2.indexdef.toUpperCase().includes("UNIQUE");
|
|
1459
|
+
tableIndexMap.get(tableName).push({
|
|
1460
|
+
name: row2.indexname,
|
|
1461
|
+
columns,
|
|
1462
|
+
unique
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
const tables = [];
|
|
1466
|
+
for (const [tableName, columns] of tableColumnsMap) {
|
|
1467
|
+
tables.push({
|
|
1468
|
+
name: tableName,
|
|
1469
|
+
columns,
|
|
1470
|
+
foreignKeys: tableFkMap.get(tableName) ?? [],
|
|
1471
|
+
indexes: tableIndexMap.get(tableName) ?? []
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
return createSchemaSnapshot("postgresql", tables);
|
|
1475
|
+
}
|
|
1476
|
+
function buildPgColumnType(row2) {
|
|
1477
|
+
const dataType = row2.data_type.toUpperCase();
|
|
1478
|
+
if (dataType === "CHARACTER VARYING") {
|
|
1479
|
+
const len = row2.character_maximum_length ?? 255;
|
|
1480
|
+
return `VARCHAR(${len})`;
|
|
1481
|
+
}
|
|
1482
|
+
if (dataType === "CHARACTER") {
|
|
1483
|
+
const len = row2.character_maximum_length ?? 1;
|
|
1484
|
+
return `CHAR(${len})`;
|
|
1485
|
+
}
|
|
1486
|
+
return dataType;
|
|
1487
|
+
}
|
|
1488
|
+
function extractIndexColumns(indexDef) {
|
|
1489
|
+
const match = indexDef.match(/\(([^)]+)\)\s*$/);
|
|
1490
|
+
if (!match)
|
|
1491
|
+
return [];
|
|
1492
|
+
return match[1].split(",").map((col) => col.trim().replace(/^"/, "").replace(/"$/, "")).filter((col) => col.length > 0);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// libs/migrations/src/lib/schema/introspect-sqlite.ts
|
|
1496
|
+
async function introspectSqlite(queryFn) {
|
|
1497
|
+
const masterRows = await queryFn(
|
|
1498
|
+
`SELECT name, type, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`
|
|
1499
|
+
);
|
|
1500
|
+
const tables = [];
|
|
1501
|
+
for (const masterRow of masterRows) {
|
|
1502
|
+
const tableName = masterRow.name;
|
|
1503
|
+
if (INTERNAL_TABLES.has(tableName))
|
|
1504
|
+
continue;
|
|
1505
|
+
const columnRows = await queryFn(
|
|
1506
|
+
`PRAGMA table_info("${tableName}")`
|
|
1507
|
+
);
|
|
1508
|
+
const columns = columnRows.map((row2) => ({
|
|
1509
|
+
name: row2.name,
|
|
1510
|
+
type: normalizeColumnType(row2.type || "TEXT", "sqlite"),
|
|
1511
|
+
nullable: row2.notnull === 0,
|
|
1512
|
+
defaultValue: row2.dflt_value,
|
|
1513
|
+
isPrimaryKey: row2.pk > 0
|
|
1514
|
+
}));
|
|
1515
|
+
const fkRows = await queryFn(
|
|
1516
|
+
`PRAGMA foreign_key_list("${tableName}")`
|
|
1517
|
+
);
|
|
1518
|
+
const foreignKeys = fkRows.map((row2) => ({
|
|
1519
|
+
constraintName: `fk_${tableName}_${row2.from}`,
|
|
1520
|
+
column: row2.from,
|
|
1521
|
+
referencedTable: row2.table,
|
|
1522
|
+
referencedColumn: row2.to,
|
|
1523
|
+
onDelete: row2.on_delete
|
|
1524
|
+
}));
|
|
1525
|
+
const indexListRows = await queryFn(
|
|
1526
|
+
`PRAGMA index_list("${tableName}")`
|
|
1527
|
+
);
|
|
1528
|
+
const indexes = [];
|
|
1529
|
+
for (const idxRow of indexListRows) {
|
|
1530
|
+
if (idxRow.origin === "pk")
|
|
1531
|
+
continue;
|
|
1532
|
+
const indexInfoRows = await queryFn(
|
|
1533
|
+
`PRAGMA index_info("${idxRow.name}")`
|
|
1534
|
+
);
|
|
1535
|
+
const indexColumns = indexInfoRows.sort((a, b) => a.seqno - b.seqno).map((r) => r.name).filter((n) => n.length > 0);
|
|
1536
|
+
if (indexColumns.length > 0) {
|
|
1537
|
+
indexes.push({
|
|
1538
|
+
name: idxRow.name,
|
|
1539
|
+
columns: indexColumns,
|
|
1540
|
+
unique: idxRow.unique === 1
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
tables.push({
|
|
1545
|
+
name: tableName,
|
|
1546
|
+
columns,
|
|
1547
|
+
foreignKeys,
|
|
1548
|
+
indexes
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
return createSchemaSnapshot("sqlite", tables);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// libs/migrations/src/cli/shared.ts
|
|
1555
|
+
function isResolvedConfig(value) {
|
|
1556
|
+
return typeof value === "object" && value !== null && "collections" in value && "db" in value;
|
|
1557
|
+
}
|
|
1558
|
+
async function loadMomentumConfig(configPath) {
|
|
1559
|
+
const configUrl = (0, import_node_url.pathToFileURL)(configPath).href;
|
|
1560
|
+
const mod = await import(configUrl);
|
|
1561
|
+
const raw = mod["default"] ?? mod;
|
|
1562
|
+
if (!isResolvedConfig(raw)) {
|
|
1563
|
+
throw new Error(`Config at ${configPath} is not a valid ResolvedMomentumConfig`);
|
|
1564
|
+
}
|
|
1565
|
+
if (!raw.db?.adapter) {
|
|
1566
|
+
throw new Error(`Config at ${configPath} is missing db.adapter`);
|
|
1567
|
+
}
|
|
1568
|
+
if (!raw.collections || raw.collections.length === 0) {
|
|
1569
|
+
throw new Error(`Config at ${configPath} has no collections`);
|
|
1570
|
+
}
|
|
1571
|
+
return raw;
|
|
1572
|
+
}
|
|
1573
|
+
function resolveDialect(adapter) {
|
|
1574
|
+
if (!adapter.dialect) {
|
|
1575
|
+
throw new Error(
|
|
1576
|
+
"DatabaseAdapter.dialect is not set. Ensure your adapter factory (postgresAdapter/sqliteAdapter) sets the dialect property."
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
return adapter.dialect;
|
|
1580
|
+
}
|
|
1581
|
+
function buildIntrospector(adapter, dialect) {
|
|
1582
|
+
if (!adapter.queryRaw) {
|
|
1583
|
+
throw new Error("DatabaseAdapter must implement queryRaw for introspection");
|
|
1584
|
+
}
|
|
1585
|
+
const queryRaw = adapter.queryRaw.bind(adapter);
|
|
1586
|
+
if (dialect === "postgresql") {
|
|
1587
|
+
const queryFn2 = async (sql, params) => queryRaw(sql, params);
|
|
1588
|
+
return () => introspectPostgres(queryFn2);
|
|
1589
|
+
}
|
|
1590
|
+
const queryFn = async (sql, params) => queryRaw(sql, params);
|
|
1591
|
+
return () => introspectSqlite(queryFn);
|
|
1592
|
+
}
|
|
1593
|
+
function parseMigrationArgs(args) {
|
|
1594
|
+
const configPath = args.find((a) => !a.startsWith("--"));
|
|
1595
|
+
if (!configPath) {
|
|
1596
|
+
throw new Error("Usage: npx tsx <command>.ts <configPath> [options]");
|
|
1597
|
+
}
|
|
1598
|
+
let name;
|
|
1599
|
+
const nameIdx = args.indexOf("--name");
|
|
1600
|
+
if (nameIdx !== -1 && args[nameIdx + 1]) {
|
|
1601
|
+
name = args[nameIdx + 1];
|
|
1602
|
+
}
|
|
1603
|
+
return {
|
|
1604
|
+
configPath,
|
|
1605
|
+
name,
|
|
1606
|
+
dryRun: args.includes("--dry-run"),
|
|
1607
|
+
testOnly: args.includes("--test-only"),
|
|
1608
|
+
skipCloneTest: args.includes("--skip-clone-test")
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// libs/migrations/src/cli/generate.ts
|
|
1613
|
+
async function main() {
|
|
1614
|
+
const args = parseMigrationArgs(process.argv.slice(2));
|
|
1615
|
+
const config = await loadMomentumConfig((0, import_node_path2.resolve)(args.configPath));
|
|
1616
|
+
const adapter = config.db.adapter;
|
|
1617
|
+
const dialect = resolveDialect(adapter);
|
|
1618
|
+
const migrationConfig = resolveMigrationConfig(config.migrations ?? {});
|
|
1619
|
+
if (!migrationConfig) {
|
|
1620
|
+
console.warn("No migration config found. Add migrations to your momentum.config.ts.");
|
|
1621
|
+
process.exit(1);
|
|
1622
|
+
}
|
|
1623
|
+
const directory = (0, import_node_path2.resolve)(migrationConfig.directory);
|
|
1624
|
+
const desired = collectionsToSchema(config.collections, dialect);
|
|
1625
|
+
let previous = readSnapshot(directory);
|
|
1626
|
+
if (!previous) {
|
|
1627
|
+
try {
|
|
1628
|
+
const introspect = buildIntrospector(adapter, dialect);
|
|
1629
|
+
previous = await introspect();
|
|
1630
|
+
} catch {
|
|
1631
|
+
previous = createSchemaSnapshot(dialect, []);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
const diff = diffSchemas(desired, previous, dialect);
|
|
1635
|
+
if (diff.operations.length === 0) {
|
|
1636
|
+
console.warn("Schema up to date. No migration needed.");
|
|
1637
|
+
if (!args.dryRun) {
|
|
1638
|
+
writeSnapshot(directory, desired);
|
|
1639
|
+
}
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
if (migrationConfig.dangerDetection) {
|
|
1643
|
+
const dangers = detectDangers(diff.operations, dialect);
|
|
1644
|
+
if (dangers.warnings.length > 0) {
|
|
1645
|
+
console.warn("\n--- Danger Detection ---");
|
|
1646
|
+
for (const w of dangers.warnings) {
|
|
1647
|
+
console.warn(` [${w.severity}] ${w.message}`);
|
|
1648
|
+
if (w.suggestion)
|
|
1649
|
+
console.warn(` Suggestion: ${w.suggestion}`);
|
|
1650
|
+
}
|
|
1651
|
+
if (dangers.hasErrors) {
|
|
1652
|
+
console.error("\nBlocked: migration contains error-severity dangers.");
|
|
1653
|
+
console.error(
|
|
1654
|
+
"Review the operations and adjust your collections, or disable danger detection."
|
|
1655
|
+
);
|
|
1656
|
+
process.exit(1);
|
|
1657
|
+
}
|
|
1658
|
+
console.warn("");
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
const migrationName = args.name ?? "migration";
|
|
1662
|
+
const timestampedName = generateMigrationName(migrationName);
|
|
1663
|
+
const fileContent = generateMigrationFileContent(diff, {
|
|
1664
|
+
name: timestampedName,
|
|
1665
|
+
dialect
|
|
1666
|
+
});
|
|
1667
|
+
if (args.dryRun) {
|
|
1668
|
+
console.warn("\n--- Dry Run (migration file content) ---\n");
|
|
1669
|
+
console.warn(fileContent);
|
|
1670
|
+
console.warn(`
|
|
1671
|
+
Would write: ${(0, import_node_path2.join)(directory, `${timestampedName}.ts`)}`);
|
|
1672
|
+
console.warn(`Operations: ${diff.operations.length}`);
|
|
1673
|
+
console.warn(`Summary: ${diff.summary.join("; ")}`);
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
(0, import_node_fs2.mkdirSync)(directory, { recursive: true });
|
|
1677
|
+
const filePath = (0, import_node_path2.join)(directory, `${timestampedName}.ts`);
|
|
1678
|
+
(0, import_node_fs2.writeFileSync)(filePath, fileContent, "utf-8");
|
|
1679
|
+
writeSnapshot(directory, desired);
|
|
1680
|
+
console.warn(`
|
|
1681
|
+
Generated migration: ${filePath}`);
|
|
1682
|
+
console.warn(`Operations: ${diff.operations.length}`);
|
|
1683
|
+
console.warn(`Summary: ${diff.summary.join("; ")}`);
|
|
1684
|
+
}
|
|
1685
|
+
main().catch((err) => {
|
|
1686
|
+
console.error("Migration generate failed:", err);
|
|
1687
|
+
process.exit(1);
|
|
1688
|
+
});
|