@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,356 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// libs/migrations/src/cli/status.ts
|
|
4
|
+
var import_node_path2 = require("node:path");
|
|
5
|
+
|
|
6
|
+
// libs/core/src/lib/collections/define-collection.ts
|
|
7
|
+
function defineCollection(config) {
|
|
8
|
+
const collection = {
|
|
9
|
+
timestamps: true,
|
|
10
|
+
// Enable timestamps by default
|
|
11
|
+
...config
|
|
12
|
+
};
|
|
13
|
+
if (!collection.slug) {
|
|
14
|
+
throw new Error("Collection must have a slug");
|
|
15
|
+
}
|
|
16
|
+
if (!collection.fields || collection.fields.length === 0) {
|
|
17
|
+
throw new Error(`Collection "${collection.slug}" must have at least one field`);
|
|
18
|
+
}
|
|
19
|
+
if (!/^[a-z][a-z0-9-]*$/.test(collection.slug)) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Collection slug "${collection.slug}" must be kebab-case (lowercase letters, numbers, and hyphens, starting with a letter)`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return collection;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// libs/core/src/lib/fields/field-builders.ts
|
|
28
|
+
function text(name, options = {}) {
|
|
29
|
+
return {
|
|
30
|
+
name,
|
|
31
|
+
type: "text",
|
|
32
|
+
...options
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function number(name, options = {}) {
|
|
36
|
+
return {
|
|
37
|
+
name,
|
|
38
|
+
type: "number",
|
|
39
|
+
...options
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function json(name, options = {}) {
|
|
43
|
+
return {
|
|
44
|
+
name,
|
|
45
|
+
type: "json",
|
|
46
|
+
...options
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// libs/core/src/lib/collections/media.collection.ts
|
|
51
|
+
var MediaCollection = defineCollection({
|
|
52
|
+
slug: "media",
|
|
53
|
+
labels: {
|
|
54
|
+
singular: "Media",
|
|
55
|
+
plural: "Media"
|
|
56
|
+
},
|
|
57
|
+
upload: {
|
|
58
|
+
mimeTypes: ["image/*", "application/pdf", "video/*", "audio/*"]
|
|
59
|
+
},
|
|
60
|
+
admin: {
|
|
61
|
+
useAsTitle: "filename",
|
|
62
|
+
defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
|
|
63
|
+
},
|
|
64
|
+
fields: [
|
|
65
|
+
text("filename", {
|
|
66
|
+
required: true,
|
|
67
|
+
label: "Filename",
|
|
68
|
+
description: "Original filename of the uploaded file"
|
|
69
|
+
}),
|
|
70
|
+
text("mimeType", {
|
|
71
|
+
required: true,
|
|
72
|
+
label: "MIME Type",
|
|
73
|
+
description: "File MIME type (e.g., image/jpeg, application/pdf)"
|
|
74
|
+
}),
|
|
75
|
+
number("filesize", {
|
|
76
|
+
label: "File Size",
|
|
77
|
+
description: "File size in bytes"
|
|
78
|
+
}),
|
|
79
|
+
text("path", {
|
|
80
|
+
label: "Storage Path",
|
|
81
|
+
description: "Path/key where the file is stored",
|
|
82
|
+
admin: {
|
|
83
|
+
hidden: true
|
|
84
|
+
}
|
|
85
|
+
}),
|
|
86
|
+
text("url", {
|
|
87
|
+
label: "URL",
|
|
88
|
+
description: "Public URL to access the file"
|
|
89
|
+
}),
|
|
90
|
+
text("alt", {
|
|
91
|
+
label: "Alt Text",
|
|
92
|
+
description: "Alternative text for accessibility"
|
|
93
|
+
}),
|
|
94
|
+
number("width", {
|
|
95
|
+
label: "Width",
|
|
96
|
+
description: "Image width in pixels (for images only)"
|
|
97
|
+
}),
|
|
98
|
+
number("height", {
|
|
99
|
+
label: "Height",
|
|
100
|
+
description: "Image height in pixels (for images only)"
|
|
101
|
+
}),
|
|
102
|
+
json("focalPoint", {
|
|
103
|
+
label: "Focal Point",
|
|
104
|
+
description: "Focal point coordinates for image cropping",
|
|
105
|
+
admin: {
|
|
106
|
+
hidden: true
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
],
|
|
110
|
+
access: {
|
|
111
|
+
// Media is readable by anyone by default
|
|
112
|
+
read: () => true,
|
|
113
|
+
// Only authenticated users can create/update/delete
|
|
114
|
+
create: ({ req }) => !!req?.user,
|
|
115
|
+
update: ({ req }) => !!req?.user,
|
|
116
|
+
delete: ({ req }) => !!req?.user
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// libs/core/src/lib/migrations.ts
|
|
121
|
+
function resolveMigrationMode(mode) {
|
|
122
|
+
if (mode === "push" || mode === "migrate")
|
|
123
|
+
return mode;
|
|
124
|
+
const env = process.env["NODE_ENV"];
|
|
125
|
+
if (env === "production")
|
|
126
|
+
return "migrate";
|
|
127
|
+
return "push";
|
|
128
|
+
}
|
|
129
|
+
function resolveMigrationConfig(config) {
|
|
130
|
+
if (!config)
|
|
131
|
+
return void 0;
|
|
132
|
+
const mode = resolveMigrationMode(config.mode);
|
|
133
|
+
return {
|
|
134
|
+
...config,
|
|
135
|
+
directory: config.directory ?? "./migrations",
|
|
136
|
+
mode,
|
|
137
|
+
cloneTest: config.cloneTest ?? mode === "migrate",
|
|
138
|
+
dangerDetection: config.dangerDetection ?? true,
|
|
139
|
+
autoApply: config.autoApply ?? mode === "push"
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// libs/migrations/src/lib/loader/migration-loader.ts
|
|
144
|
+
var import_node_fs = require("node:fs");
|
|
145
|
+
var import_node_path = require("node:path");
|
|
146
|
+
var import_node_url = require("node:url");
|
|
147
|
+
var MIGRATION_FILE_PATTERN = /^\d{14}_.+\.ts$/;
|
|
148
|
+
function isMigrationFile(value) {
|
|
149
|
+
if (typeof value !== "object" || value === null)
|
|
150
|
+
return false;
|
|
151
|
+
const obj = value;
|
|
152
|
+
return "meta" in obj && typeof obj["meta"] === "object" && obj["meta"] !== null && "up" in obj && typeof obj["up"] === "function" && "down" in obj && typeof obj["down"] === "function";
|
|
153
|
+
}
|
|
154
|
+
function validateMigrationModule(mod, filePath) {
|
|
155
|
+
const file = mod["default"] ?? mod;
|
|
156
|
+
if (!isMigrationFile(file)) {
|
|
157
|
+
if (typeof file !== "object" || file === null) {
|
|
158
|
+
throw new Error(`Migration file ${filePath} does not export a valid module`);
|
|
159
|
+
}
|
|
160
|
+
if (!("meta" in file) || typeof file["meta"] !== "object") {
|
|
161
|
+
throw new Error(`Migration file ${filePath} is missing a valid 'meta' export`);
|
|
162
|
+
}
|
|
163
|
+
if (!("up" in file) || typeof file["up"] !== "function") {
|
|
164
|
+
throw new Error(`Migration file ${filePath} is missing an 'up' function export`);
|
|
165
|
+
}
|
|
166
|
+
if (!("down" in file) || typeof file["down"] !== "function") {
|
|
167
|
+
throw new Error(`Migration file ${filePath} is missing a 'down' function export`);
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`Migration file ${filePath} does not conform to MigrationFile interface`);
|
|
170
|
+
}
|
|
171
|
+
return file;
|
|
172
|
+
}
|
|
173
|
+
async function loadMigrationsFromDisk(directory) {
|
|
174
|
+
if (!(0, import_node_fs.existsSync)(directory))
|
|
175
|
+
return [];
|
|
176
|
+
const files = (0, import_node_fs.readdirSync)(directory).filter((f) => MIGRATION_FILE_PATTERN.test(f)).sort();
|
|
177
|
+
if (files.length === 0)
|
|
178
|
+
return [];
|
|
179
|
+
const migrations = [];
|
|
180
|
+
for (const filename of files) {
|
|
181
|
+
const filePath = (0, import_node_path.join)(directory, filename);
|
|
182
|
+
const fileUrl = (0, import_node_url.pathToFileURL)(filePath).href;
|
|
183
|
+
const mod = await import(fileUrl);
|
|
184
|
+
const file = validateMigrationModule(mod, filePath);
|
|
185
|
+
const name = filename.replace(/\.ts$/, "");
|
|
186
|
+
migrations.push({ name, file });
|
|
187
|
+
}
|
|
188
|
+
return migrations;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// libs/migrations/src/lib/migration.types.ts
|
|
192
|
+
var MIGRATION_TRACKING_TABLE = "_momentum_migrations";
|
|
193
|
+
|
|
194
|
+
// libs/migrations/src/lib/tracking/migration-tracker.ts
|
|
195
|
+
async function ensureTrackingTable(db, dialect) {
|
|
196
|
+
if (dialect === "postgresql") {
|
|
197
|
+
await db.execute(`
|
|
198
|
+
CREATE TABLE IF NOT EXISTS "${MIGRATION_TRACKING_TABLE}" (
|
|
199
|
+
"id" VARCHAR(36) PRIMARY KEY,
|
|
200
|
+
"name" VARCHAR(255) NOT NULL UNIQUE,
|
|
201
|
+
"batch" INTEGER NOT NULL,
|
|
202
|
+
"checksum" VARCHAR(64) NOT NULL,
|
|
203
|
+
"appliedAt" TIMESTAMPTZ NOT NULL,
|
|
204
|
+
"executionMs" INTEGER NOT NULL
|
|
205
|
+
)
|
|
206
|
+
`);
|
|
207
|
+
} else {
|
|
208
|
+
await db.execute(`
|
|
209
|
+
CREATE TABLE IF NOT EXISTS "${MIGRATION_TRACKING_TABLE}" (
|
|
210
|
+
"id" TEXT PRIMARY KEY,
|
|
211
|
+
"name" TEXT NOT NULL UNIQUE,
|
|
212
|
+
"batch" INTEGER NOT NULL,
|
|
213
|
+
"checksum" TEXT NOT NULL,
|
|
214
|
+
"appliedAt" TEXT NOT NULL,
|
|
215
|
+
"executionMs" INTEGER NOT NULL
|
|
216
|
+
)
|
|
217
|
+
`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function getAppliedMigrations(db) {
|
|
221
|
+
const rows = await db.query(
|
|
222
|
+
`SELECT * FROM "${MIGRATION_TRACKING_TABLE}" ORDER BY "batch" ASC, "name" ASC`
|
|
223
|
+
);
|
|
224
|
+
return rows.map(toTrackingRecord);
|
|
225
|
+
}
|
|
226
|
+
function toTrackingRecord(row2) {
|
|
227
|
+
return {
|
|
228
|
+
id: String(row2["id"]),
|
|
229
|
+
name: String(row2["name"]),
|
|
230
|
+
batch: Number(row2["batch"]),
|
|
231
|
+
checksum: String(row2["checksum"]),
|
|
232
|
+
appliedAt: String(row2["appliedAt"]),
|
|
233
|
+
executionMs: Number(row2["executionMs"])
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// libs/migrations/src/lib/runner/migrate-runner.ts
|
|
238
|
+
async function getMigrationStatus(migrations, tracker, dialect) {
|
|
239
|
+
await ensureTrackingTable(tracker, dialect);
|
|
240
|
+
const applied = await getAppliedMigrations(tracker);
|
|
241
|
+
const appliedMap = new Map(applied.map((m) => [m.name, m]));
|
|
242
|
+
return migrations.map((m) => {
|
|
243
|
+
const record = appliedMap.get(m.name);
|
|
244
|
+
if (record) {
|
|
245
|
+
return {
|
|
246
|
+
name: m.name,
|
|
247
|
+
status: "applied",
|
|
248
|
+
batch: record.batch,
|
|
249
|
+
appliedAt: record.appliedAt
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { name: m.name, status: "pending" };
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// libs/migrations/src/cli/shared.ts
|
|
257
|
+
var import_node_url2 = require("node:url");
|
|
258
|
+
function isResolvedConfig(value) {
|
|
259
|
+
return typeof value === "object" && value !== null && "collections" in value && "db" in value;
|
|
260
|
+
}
|
|
261
|
+
async function loadMomentumConfig(configPath) {
|
|
262
|
+
const configUrl = (0, import_node_url2.pathToFileURL)(configPath).href;
|
|
263
|
+
const mod = await import(configUrl);
|
|
264
|
+
const raw = mod["default"] ?? mod;
|
|
265
|
+
if (!isResolvedConfig(raw)) {
|
|
266
|
+
throw new Error(`Config at ${configPath} is not a valid ResolvedMomentumConfig`);
|
|
267
|
+
}
|
|
268
|
+
if (!raw.db?.adapter) {
|
|
269
|
+
throw new Error(`Config at ${configPath} is missing db.adapter`);
|
|
270
|
+
}
|
|
271
|
+
if (!raw.collections || raw.collections.length === 0) {
|
|
272
|
+
throw new Error(`Config at ${configPath} has no collections`);
|
|
273
|
+
}
|
|
274
|
+
return raw;
|
|
275
|
+
}
|
|
276
|
+
function resolveDialect(adapter) {
|
|
277
|
+
if (!adapter.dialect) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
"DatabaseAdapter.dialect is not set. Ensure your adapter factory (postgresAdapter/sqliteAdapter) sets the dialect property."
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return adapter.dialect;
|
|
283
|
+
}
|
|
284
|
+
function buildTrackerFromAdapter(adapter) {
|
|
285
|
+
if (!adapter.queryRaw || !adapter.executeRaw) {
|
|
286
|
+
throw new Error("DatabaseAdapter must implement queryRaw and executeRaw for migration tracking");
|
|
287
|
+
}
|
|
288
|
+
const queryRaw = adapter.queryRaw.bind(adapter);
|
|
289
|
+
const executeRaw = adapter.executeRaw.bind(adapter);
|
|
290
|
+
return {
|
|
291
|
+
async query(sql, params) {
|
|
292
|
+
return queryRaw(sql, params);
|
|
293
|
+
},
|
|
294
|
+
async execute(sql, params) {
|
|
295
|
+
return executeRaw(sql, params);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function parseMigrationArgs(args) {
|
|
300
|
+
const configPath = args.find((a) => !a.startsWith("--"));
|
|
301
|
+
if (!configPath) {
|
|
302
|
+
throw new Error("Usage: npx tsx <command>.ts <configPath> [options]");
|
|
303
|
+
}
|
|
304
|
+
let name;
|
|
305
|
+
const nameIdx = args.indexOf("--name");
|
|
306
|
+
if (nameIdx !== -1 && args[nameIdx + 1]) {
|
|
307
|
+
name = args[nameIdx + 1];
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
configPath,
|
|
311
|
+
name,
|
|
312
|
+
dryRun: args.includes("--dry-run"),
|
|
313
|
+
testOnly: args.includes("--test-only"),
|
|
314
|
+
skipCloneTest: args.includes("--skip-clone-test")
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// libs/migrations/src/cli/status.ts
|
|
319
|
+
async function main() {
|
|
320
|
+
const args = parseMigrationArgs(process.argv.slice(2));
|
|
321
|
+
const config = await loadMomentumConfig((0, import_node_path2.resolve)(args.configPath));
|
|
322
|
+
const adapter = config.db.adapter;
|
|
323
|
+
const dialect = resolveDialect(adapter);
|
|
324
|
+
const migrationConfig = resolveMigrationConfig(config.migrations ?? {});
|
|
325
|
+
if (!migrationConfig) {
|
|
326
|
+
console.warn("No migration config found.");
|
|
327
|
+
process.exit(1);
|
|
328
|
+
}
|
|
329
|
+
const directory = (0, import_node_path2.resolve)(migrationConfig.directory);
|
|
330
|
+
const migrations = await loadMigrationsFromDisk(directory);
|
|
331
|
+
if (migrations.length === 0) {
|
|
332
|
+
console.warn("No migration files found in", directory);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const tracker = buildTrackerFromAdapter(adapter);
|
|
336
|
+
const entries = await getMigrationStatus(migrations, tracker, dialect);
|
|
337
|
+
const appliedCount = entries.filter((e) => e.status === "applied").length;
|
|
338
|
+
const pendingCount = entries.filter((e) => e.status === "pending").length;
|
|
339
|
+
console.warn(`
|
|
340
|
+
Migration Status (${appliedCount} applied, ${pendingCount} pending)
|
|
341
|
+
`);
|
|
342
|
+
console.warn(" Name".padEnd(50) + "Status".padEnd(12) + "Batch".padEnd(8) + "Applied At");
|
|
343
|
+
console.warn(" " + "-".repeat(80));
|
|
344
|
+
for (const entry of entries) {
|
|
345
|
+
const name = ` ${entry.name}`.padEnd(50);
|
|
346
|
+
const status = entry.status.padEnd(12);
|
|
347
|
+
const batch = (entry.batch?.toString() ?? "-").padEnd(8);
|
|
348
|
+
const appliedAt = entry.appliedAt ?? "-";
|
|
349
|
+
console.warn(`${name}${status}${batch}${appliedAt}`);
|
|
350
|
+
}
|
|
351
|
+
console.warn("");
|
|
352
|
+
}
|
|
353
|
+
main().catch((err) => {
|
|
354
|
+
console.error("Migration status failed:", err);
|
|
355
|
+
process.exit(1);
|
|
356
|
+
});
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// libs/migrations/src/cli/status.ts
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
// libs/core/src/lib/collections/define-collection.ts
|
|
5
|
+
function defineCollection(config) {
|
|
6
|
+
const collection = {
|
|
7
|
+
timestamps: true,
|
|
8
|
+
// Enable timestamps by default
|
|
9
|
+
...config
|
|
10
|
+
};
|
|
11
|
+
if (!collection.slug) {
|
|
12
|
+
throw new Error("Collection must have a slug");
|
|
13
|
+
}
|
|
14
|
+
if (!collection.fields || collection.fields.length === 0) {
|
|
15
|
+
throw new Error(`Collection "${collection.slug}" must have at least one field`);
|
|
16
|
+
}
|
|
17
|
+
if (!/^[a-z][a-z0-9-]*$/.test(collection.slug)) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Collection slug "${collection.slug}" must be kebab-case (lowercase letters, numbers, and hyphens, starting with a letter)`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return collection;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// libs/core/src/lib/fields/field-builders.ts
|
|
26
|
+
function text(name, options = {}) {
|
|
27
|
+
return {
|
|
28
|
+
name,
|
|
29
|
+
type: "text",
|
|
30
|
+
...options
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function number(name, options = {}) {
|
|
34
|
+
return {
|
|
35
|
+
name,
|
|
36
|
+
type: "number",
|
|
37
|
+
...options
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function json(name, options = {}) {
|
|
41
|
+
return {
|
|
42
|
+
name,
|
|
43
|
+
type: "json",
|
|
44
|
+
...options
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// libs/core/src/lib/collections/media.collection.ts
|
|
49
|
+
var MediaCollection = defineCollection({
|
|
50
|
+
slug: "media",
|
|
51
|
+
labels: {
|
|
52
|
+
singular: "Media",
|
|
53
|
+
plural: "Media"
|
|
54
|
+
},
|
|
55
|
+
upload: {
|
|
56
|
+
mimeTypes: ["image/*", "application/pdf", "video/*", "audio/*"]
|
|
57
|
+
},
|
|
58
|
+
admin: {
|
|
59
|
+
useAsTitle: "filename",
|
|
60
|
+
defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
|
|
61
|
+
},
|
|
62
|
+
fields: [
|
|
63
|
+
text("filename", {
|
|
64
|
+
required: true,
|
|
65
|
+
label: "Filename",
|
|
66
|
+
description: "Original filename of the uploaded file"
|
|
67
|
+
}),
|
|
68
|
+
text("mimeType", {
|
|
69
|
+
required: true,
|
|
70
|
+
label: "MIME Type",
|
|
71
|
+
description: "File MIME type (e.g., image/jpeg, application/pdf)"
|
|
72
|
+
}),
|
|
73
|
+
number("filesize", {
|
|
74
|
+
label: "File Size",
|
|
75
|
+
description: "File size in bytes"
|
|
76
|
+
}),
|
|
77
|
+
text("path", {
|
|
78
|
+
label: "Storage Path",
|
|
79
|
+
description: "Path/key where the file is stored",
|
|
80
|
+
admin: {
|
|
81
|
+
hidden: true
|
|
82
|
+
}
|
|
83
|
+
}),
|
|
84
|
+
text("url", {
|
|
85
|
+
label: "URL",
|
|
86
|
+
description: "Public URL to access the file"
|
|
87
|
+
}),
|
|
88
|
+
text("alt", {
|
|
89
|
+
label: "Alt Text",
|
|
90
|
+
description: "Alternative text for accessibility"
|
|
91
|
+
}),
|
|
92
|
+
number("width", {
|
|
93
|
+
label: "Width",
|
|
94
|
+
description: "Image width in pixels (for images only)"
|
|
95
|
+
}),
|
|
96
|
+
number("height", {
|
|
97
|
+
label: "Height",
|
|
98
|
+
description: "Image height in pixels (for images only)"
|
|
99
|
+
}),
|
|
100
|
+
json("focalPoint", {
|
|
101
|
+
label: "Focal Point",
|
|
102
|
+
description: "Focal point coordinates for image cropping",
|
|
103
|
+
admin: {
|
|
104
|
+
hidden: true
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
],
|
|
108
|
+
access: {
|
|
109
|
+
// Media is readable by anyone by default
|
|
110
|
+
read: () => true,
|
|
111
|
+
// Only authenticated users can create/update/delete
|
|
112
|
+
create: ({ req }) => !!req?.user,
|
|
113
|
+
update: ({ req }) => !!req?.user,
|
|
114
|
+
delete: ({ req }) => !!req?.user
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// libs/core/src/lib/migrations.ts
|
|
119
|
+
function resolveMigrationMode(mode) {
|
|
120
|
+
if (mode === "push" || mode === "migrate")
|
|
121
|
+
return mode;
|
|
122
|
+
const env = process.env["NODE_ENV"];
|
|
123
|
+
if (env === "production")
|
|
124
|
+
return "migrate";
|
|
125
|
+
return "push";
|
|
126
|
+
}
|
|
127
|
+
function resolveMigrationConfig(config) {
|
|
128
|
+
if (!config)
|
|
129
|
+
return void 0;
|
|
130
|
+
const mode = resolveMigrationMode(config.mode);
|
|
131
|
+
return {
|
|
132
|
+
...config,
|
|
133
|
+
directory: config.directory ?? "./migrations",
|
|
134
|
+
mode,
|
|
135
|
+
cloneTest: config.cloneTest ?? mode === "migrate",
|
|
136
|
+
dangerDetection: config.dangerDetection ?? true,
|
|
137
|
+
autoApply: config.autoApply ?? mode === "push"
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// libs/migrations/src/lib/loader/migration-loader.ts
|
|
142
|
+
import { readdirSync, existsSync } from "node:fs";
|
|
143
|
+
import { join } from "node:path";
|
|
144
|
+
import { pathToFileURL } from "node:url";
|
|
145
|
+
var MIGRATION_FILE_PATTERN = /^\d{14}_.+\.ts$/;
|
|
146
|
+
function isMigrationFile(value) {
|
|
147
|
+
if (typeof value !== "object" || value === null)
|
|
148
|
+
return false;
|
|
149
|
+
const obj = value;
|
|
150
|
+
return "meta" in obj && typeof obj["meta"] === "object" && obj["meta"] !== null && "up" in obj && typeof obj["up"] === "function" && "down" in obj && typeof obj["down"] === "function";
|
|
151
|
+
}
|
|
152
|
+
function validateMigrationModule(mod, filePath) {
|
|
153
|
+
const file = mod["default"] ?? mod;
|
|
154
|
+
if (!isMigrationFile(file)) {
|
|
155
|
+
if (typeof file !== "object" || file === null) {
|
|
156
|
+
throw new Error(`Migration file ${filePath} does not export a valid module`);
|
|
157
|
+
}
|
|
158
|
+
if (!("meta" in file) || typeof file["meta"] !== "object") {
|
|
159
|
+
throw new Error(`Migration file ${filePath} is missing a valid 'meta' export`);
|
|
160
|
+
}
|
|
161
|
+
if (!("up" in file) || typeof file["up"] !== "function") {
|
|
162
|
+
throw new Error(`Migration file ${filePath} is missing an 'up' function export`);
|
|
163
|
+
}
|
|
164
|
+
if (!("down" in file) || typeof file["down"] !== "function") {
|
|
165
|
+
throw new Error(`Migration file ${filePath} is missing a 'down' function export`);
|
|
166
|
+
}
|
|
167
|
+
throw new Error(`Migration file ${filePath} does not conform to MigrationFile interface`);
|
|
168
|
+
}
|
|
169
|
+
return file;
|
|
170
|
+
}
|
|
171
|
+
async function loadMigrationsFromDisk(directory) {
|
|
172
|
+
if (!existsSync(directory))
|
|
173
|
+
return [];
|
|
174
|
+
const files = readdirSync(directory).filter((f) => MIGRATION_FILE_PATTERN.test(f)).sort();
|
|
175
|
+
if (files.length === 0)
|
|
176
|
+
return [];
|
|
177
|
+
const migrations = [];
|
|
178
|
+
for (const filename of files) {
|
|
179
|
+
const filePath = join(directory, filename);
|
|
180
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
181
|
+
const mod = await import(fileUrl);
|
|
182
|
+
const file = validateMigrationModule(mod, filePath);
|
|
183
|
+
const name = filename.replace(/\.ts$/, "");
|
|
184
|
+
migrations.push({ name, file });
|
|
185
|
+
}
|
|
186
|
+
return migrations;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// libs/migrations/src/lib/migration.types.ts
|
|
190
|
+
var MIGRATION_TRACKING_TABLE = "_momentum_migrations";
|
|
191
|
+
|
|
192
|
+
// libs/migrations/src/lib/tracking/migration-tracker.ts
|
|
193
|
+
async function ensureTrackingTable(db, dialect) {
|
|
194
|
+
if (dialect === "postgresql") {
|
|
195
|
+
await db.execute(`
|
|
196
|
+
CREATE TABLE IF NOT EXISTS "${MIGRATION_TRACKING_TABLE}" (
|
|
197
|
+
"id" VARCHAR(36) PRIMARY KEY,
|
|
198
|
+
"name" VARCHAR(255) NOT NULL UNIQUE,
|
|
199
|
+
"batch" INTEGER NOT NULL,
|
|
200
|
+
"checksum" VARCHAR(64) NOT NULL,
|
|
201
|
+
"appliedAt" TIMESTAMPTZ NOT NULL,
|
|
202
|
+
"executionMs" INTEGER NOT NULL
|
|
203
|
+
)
|
|
204
|
+
`);
|
|
205
|
+
} else {
|
|
206
|
+
await db.execute(`
|
|
207
|
+
CREATE TABLE IF NOT EXISTS "${MIGRATION_TRACKING_TABLE}" (
|
|
208
|
+
"id" TEXT PRIMARY KEY,
|
|
209
|
+
"name" TEXT NOT NULL UNIQUE,
|
|
210
|
+
"batch" INTEGER NOT NULL,
|
|
211
|
+
"checksum" TEXT NOT NULL,
|
|
212
|
+
"appliedAt" TEXT NOT NULL,
|
|
213
|
+
"executionMs" INTEGER NOT NULL
|
|
214
|
+
)
|
|
215
|
+
`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async function getAppliedMigrations(db) {
|
|
219
|
+
const rows = await db.query(
|
|
220
|
+
`SELECT * FROM "${MIGRATION_TRACKING_TABLE}" ORDER BY "batch" ASC, "name" ASC`
|
|
221
|
+
);
|
|
222
|
+
return rows.map(toTrackingRecord);
|
|
223
|
+
}
|
|
224
|
+
function toTrackingRecord(row2) {
|
|
225
|
+
return {
|
|
226
|
+
id: String(row2["id"]),
|
|
227
|
+
name: String(row2["name"]),
|
|
228
|
+
batch: Number(row2["batch"]),
|
|
229
|
+
checksum: String(row2["checksum"]),
|
|
230
|
+
appliedAt: String(row2["appliedAt"]),
|
|
231
|
+
executionMs: Number(row2["executionMs"])
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// libs/migrations/src/lib/runner/migrate-runner.ts
|
|
236
|
+
async function getMigrationStatus(migrations, tracker, dialect) {
|
|
237
|
+
await ensureTrackingTable(tracker, dialect);
|
|
238
|
+
const applied = await getAppliedMigrations(tracker);
|
|
239
|
+
const appliedMap = new Map(applied.map((m) => [m.name, m]));
|
|
240
|
+
return migrations.map((m) => {
|
|
241
|
+
const record = appliedMap.get(m.name);
|
|
242
|
+
if (record) {
|
|
243
|
+
return {
|
|
244
|
+
name: m.name,
|
|
245
|
+
status: "applied",
|
|
246
|
+
batch: record.batch,
|
|
247
|
+
appliedAt: record.appliedAt
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
return { name: m.name, status: "pending" };
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// libs/migrations/src/cli/shared.ts
|
|
255
|
+
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
256
|
+
function isResolvedConfig(value) {
|
|
257
|
+
return typeof value === "object" && value !== null && "collections" in value && "db" in value;
|
|
258
|
+
}
|
|
259
|
+
async function loadMomentumConfig(configPath) {
|
|
260
|
+
const configUrl = pathToFileURL2(configPath).href;
|
|
261
|
+
const mod = await import(configUrl);
|
|
262
|
+
const raw = mod["default"] ?? mod;
|
|
263
|
+
if (!isResolvedConfig(raw)) {
|
|
264
|
+
throw new Error(`Config at ${configPath} is not a valid ResolvedMomentumConfig`);
|
|
265
|
+
}
|
|
266
|
+
if (!raw.db?.adapter) {
|
|
267
|
+
throw new Error(`Config at ${configPath} is missing db.adapter`);
|
|
268
|
+
}
|
|
269
|
+
if (!raw.collections || raw.collections.length === 0) {
|
|
270
|
+
throw new Error(`Config at ${configPath} has no collections`);
|
|
271
|
+
}
|
|
272
|
+
return raw;
|
|
273
|
+
}
|
|
274
|
+
function resolveDialect(adapter) {
|
|
275
|
+
if (!adapter.dialect) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
"DatabaseAdapter.dialect is not set. Ensure your adapter factory (postgresAdapter/sqliteAdapter) sets the dialect property."
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return adapter.dialect;
|
|
281
|
+
}
|
|
282
|
+
function buildTrackerFromAdapter(adapter) {
|
|
283
|
+
if (!adapter.queryRaw || !adapter.executeRaw) {
|
|
284
|
+
throw new Error("DatabaseAdapter must implement queryRaw and executeRaw for migration tracking");
|
|
285
|
+
}
|
|
286
|
+
const queryRaw = adapter.queryRaw.bind(adapter);
|
|
287
|
+
const executeRaw = adapter.executeRaw.bind(adapter);
|
|
288
|
+
return {
|
|
289
|
+
async query(sql, params) {
|
|
290
|
+
return queryRaw(sql, params);
|
|
291
|
+
},
|
|
292
|
+
async execute(sql, params) {
|
|
293
|
+
return executeRaw(sql, params);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function parseMigrationArgs(args) {
|
|
298
|
+
const configPath = args.find((a) => !a.startsWith("--"));
|
|
299
|
+
if (!configPath) {
|
|
300
|
+
throw new Error("Usage: npx tsx <command>.ts <configPath> [options]");
|
|
301
|
+
}
|
|
302
|
+
let name;
|
|
303
|
+
const nameIdx = args.indexOf("--name");
|
|
304
|
+
if (nameIdx !== -1 && args[nameIdx + 1]) {
|
|
305
|
+
name = args[nameIdx + 1];
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
configPath,
|
|
309
|
+
name,
|
|
310
|
+
dryRun: args.includes("--dry-run"),
|
|
311
|
+
testOnly: args.includes("--test-only"),
|
|
312
|
+
skipCloneTest: args.includes("--skip-clone-test")
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// libs/migrations/src/cli/status.ts
|
|
317
|
+
async function main() {
|
|
318
|
+
const args = parseMigrationArgs(process.argv.slice(2));
|
|
319
|
+
const config = await loadMomentumConfig(resolve(args.configPath));
|
|
320
|
+
const adapter = config.db.adapter;
|
|
321
|
+
const dialect = resolveDialect(adapter);
|
|
322
|
+
const migrationConfig = resolveMigrationConfig(config.migrations ?? {});
|
|
323
|
+
if (!migrationConfig) {
|
|
324
|
+
console.warn("No migration config found.");
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
const directory = resolve(migrationConfig.directory);
|
|
328
|
+
const migrations = await loadMigrationsFromDisk(directory);
|
|
329
|
+
if (migrations.length === 0) {
|
|
330
|
+
console.warn("No migration files found in", directory);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const tracker = buildTrackerFromAdapter(adapter);
|
|
334
|
+
const entries = await getMigrationStatus(migrations, tracker, dialect);
|
|
335
|
+
const appliedCount = entries.filter((e) => e.status === "applied").length;
|
|
336
|
+
const pendingCount = entries.filter((e) => e.status === "pending").length;
|
|
337
|
+
console.warn(`
|
|
338
|
+
Migration Status (${appliedCount} applied, ${pendingCount} pending)
|
|
339
|
+
`);
|
|
340
|
+
console.warn(" Name".padEnd(50) + "Status".padEnd(12) + "Batch".padEnd(8) + "Applied At");
|
|
341
|
+
console.warn(" " + "-".repeat(80));
|
|
342
|
+
for (const entry of entries) {
|
|
343
|
+
const name = ` ${entry.name}`.padEnd(50);
|
|
344
|
+
const status = entry.status.padEnd(12);
|
|
345
|
+
const batch = (entry.batch?.toString() ?? "-").padEnd(8);
|
|
346
|
+
const appliedAt = entry.appliedAt ?? "-";
|
|
347
|
+
console.warn(`${name}${status}${batch}${appliedAt}`);
|
|
348
|
+
}
|
|
349
|
+
console.warn("");
|
|
350
|
+
}
|
|
351
|
+
main().catch((err) => {
|
|
352
|
+
console.error("Migration status failed:", err);
|
|
353
|
+
process.exit(1);
|
|
354
|
+
});
|