@momentumcms/core 0.1.10 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
1
+ ## 0.3.0 (2026-02-20)
2
+
3
+ ### 🚀 Features
4
+
5
+ - implement Payload-style migration CLI workflow with clone-test-apply safety ([#35](https://github.com/DonaldMurillo/momentum-cms/pull/35))
6
+ - add named tabs support with nested data grouping and UI improvements ([#30](https://github.com/DonaldMurillo/momentum-cms/pull/30))
7
+
8
+ ### 🩹 Fixes
9
+
10
+ - fix nav highlighting and resolve pre-existing E2E test failures ([#34](https://github.com/DonaldMurillo/momentum-cms/pull/34))
11
+ - add auth guard and MIME validation to PATCH upload route; fix pagination with client-side filtering ([#32](https://github.com/DonaldMurillo/momentum-cms/pull/32))
12
+
13
+ ### ❤️ Thank You
14
+
15
+ - Claude Haiku 4.5
16
+ - Claude Opus 4.6
17
+ - Donald Murillo @DonaldMurillo
18
+
19
+ ## 0.2.0 (2026-02-17)
20
+
21
+ ### 🚀 Features
22
+
23
+ - add named tabs support with nested data grouping and tab UI improvements ([63ab63e](https://github.com/DonaldMurillo/momentum-cms/commit/63ab63e))
24
+
25
+ ### ❤️ Thank You
26
+
27
+ - Claude Opus 4.6
28
+ - Donald Murillo @DonaldMurillo
29
+
1
30
  ## 0.1.10 (2026-02-17)
2
31
 
3
32
  ### 🩹 Fixes
@@ -36,12 +36,26 @@ var import_node_path = require("node:path");
36
36
  var import_node_url = require("node:url");
37
37
 
38
38
  // libs/core/src/lib/fields/field.types.ts
39
+ function isNamedTab(tab) {
40
+ return typeof tab.name === "string" && tab.name.length > 0;
41
+ }
39
42
  function flattenDataFields(fields) {
40
43
  const result = [];
41
44
  for (const field of fields) {
42
45
  if (field.type === "tabs") {
43
46
  for (const tab of field.tabs) {
44
- result.push(...flattenDataFields(tab.fields));
47
+ if (isNamedTab(tab)) {
48
+ const syntheticGroup = {
49
+ name: tab.name,
50
+ type: "group",
51
+ label: tab.label,
52
+ description: tab.description,
53
+ fields: tab.fields
54
+ };
55
+ result.push(syntheticGroup);
56
+ } else {
57
+ result.push(...flattenDataFields(tab.fields));
58
+ }
45
59
  }
46
60
  } else if (field.type === "collapsible" || field.type === "row") {
47
61
  result.push(...flattenDataFields(field.fields));
@@ -262,11 +276,7 @@ function generateTypes(config) {
262
276
  const hasTimestamps = collection.timestamps !== false;
263
277
  for (const field of dataFields) {
264
278
  if (field.type === "blocks") {
265
- const blockResult = generateBlockTypes(
266
- collection.slug,
267
- field.name,
268
- field
269
- );
279
+ const blockResult = generateBlockTypes(collection.slug, field.name, field);
270
280
  blockDeclarations.push(blockResult.declarations);
271
281
  }
272
282
  }
@@ -280,11 +290,7 @@ function generateTypes(config) {
280
290
  const optional = field.required ? "" : "?";
281
291
  const propName = needsQuoting2(field.name) ? safeQuote(field.name) : field.name;
282
292
  if (field.type === "blocks") {
283
- const blockResult = generateBlockTypes(
284
- collection.slug,
285
- field.name,
286
- field
287
- );
293
+ const blockResult = generateBlockTypes(collection.slug, field.name, field);
288
294
  lines.push(` ${propName}${optional}: ${blockResult.unionTypeName}[];`);
289
295
  } else {
290
296
  const tsType = fieldTypeToTS(field);
@@ -386,6 +392,25 @@ var FIELD_ADMIN_STRIP_KEYS = /* @__PURE__ */ new Set(["condition"]);
386
392
  function isRecord(value) {
387
393
  return typeof value === "object" && value !== null && !Array.isArray(value);
388
394
  }
395
+ function previewFunctionToTemplate(fn, fields) {
396
+ try {
397
+ const sentinel = "__MCMS_FIELD_";
398
+ const mockDoc = {};
399
+ for (const field of fields) {
400
+ mockDoc[field.name] = `${sentinel}${field.name}__`;
401
+ }
402
+ const result = fn(mockDoc);
403
+ if (typeof result !== "string")
404
+ return true;
405
+ const template = result.replace(
406
+ new RegExp(`${sentinel}(\\w+)__`, "g"),
407
+ (_match, fieldName) => `{${fieldName}}`
408
+ );
409
+ return template;
410
+ } catch {
411
+ return true;
412
+ }
413
+ }
389
414
  function serializeValue(value, indent = " ") {
390
415
  if (value === null)
391
416
  return "null";
@@ -554,6 +579,9 @@ function serializeTabsArray(tabs, indent) {
554
579
  return "[]";
555
580
  const items = tabs.map((tab) => {
556
581
  const parts = [];
582
+ if (tab.name) {
583
+ parts.push(`${indent} name: ${JSON.stringify(tab.name)}`);
584
+ }
557
585
  parts.push(`${indent} label: ${JSON.stringify(tab.label)}`);
558
586
  if (tab.description) {
559
587
  parts.push(`${indent} description: ${JSON.stringify(tab.description)}`);
@@ -575,9 +603,13 @@ function serializeCollection(collection, indent = " ") {
575
603
  }
576
604
  parts.push(`${indent} fields: ${serializeFieldsArray(collection.fields, indent + " ")}`);
577
605
  if (collection.admin) {
578
- const adminEntries = Object.entries(collection.admin).filter(
579
- ([, v]) => v !== void 0 && typeof v !== "function"
580
- );
606
+ const adminEntries = Object.entries(collection.admin).filter(([, v]) => v !== void 0).map(([k, v]) => {
607
+ if (k === "preview" && typeof v === "function") {
608
+ const fn = v;
609
+ return [k, previewFunctionToTemplate(fn, collection.fields)];
610
+ }
611
+ return [k, v];
612
+ }).filter(([, v]) => typeof v !== "function");
581
613
  if (adminEntries.length > 0) {
582
614
  const adminObj = Object.fromEntries(adminEntries);
583
615
  parts.push(`${indent} admin: ${serializeValue(adminObj, indent + " ")}`);
@@ -601,6 +633,9 @@ function serializeCollection(collection, indent = " ") {
601
633
  if (collection.defaultSort) {
602
634
  parts.push(`${indent} defaultSort: ${JSON.stringify(collection.defaultSort)}`);
603
635
  }
636
+ if (collection.upload !== void 0) {
637
+ parts.push(`${indent} upload: ${serializeValue(collection.upload, indent + " ")}`);
638
+ }
604
639
  return `{
605
640
  ${parts.join(",\n")},
606
641
  ${indent}}`;
@@ -730,6 +765,15 @@ function parseArgs(args) {
730
765
  }
731
766
  return { configPath, typesOutputPath, configOutputPath, watch: watchMode };
732
767
  }
768
+ function formatWithPrettier(...filePaths) {
769
+ try {
770
+ (0, import_node_child_process.execFileSync)("npx", ["prettier", "--write", ...filePaths], {
771
+ stdio: "pipe"
772
+ });
773
+ } catch {
774
+ console.warn("prettier not available \u2014 skipping formatting of generated files");
775
+ }
776
+ }
733
777
  async function runGenerator(options) {
734
778
  const configPath = (0, import_node_path.resolve)(options.configPath);
735
779
  const typesOutputPath = (0, import_node_path.resolve)(options.typesOutputPath);
@@ -749,6 +793,7 @@ async function runGenerator(options) {
749
793
  (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(configOutputPath), { recursive: true });
750
794
  (0, import_node_fs.writeFileSync)(configOutputPath, adminConfigContent, "utf-8");
751
795
  console.info(`Admin config generated: ${configOutputPath}`);
796
+ formatWithPrettier(typesOutputPath, configOutputPath);
752
797
  } catch (error) {
753
798
  console.error(`Error generating:`, error);
754
799
  throw error;
@@ -5,12 +5,26 @@ import { dirname, resolve, relative } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
 
7
7
  // libs/core/src/lib/fields/field.types.ts
8
+ function isNamedTab(tab) {
9
+ return typeof tab.name === "string" && tab.name.length > 0;
10
+ }
8
11
  function flattenDataFields(fields) {
9
12
  const result = [];
10
13
  for (const field of fields) {
11
14
  if (field.type === "tabs") {
12
15
  for (const tab of field.tabs) {
13
- result.push(...flattenDataFields(tab.fields));
16
+ if (isNamedTab(tab)) {
17
+ const syntheticGroup = {
18
+ name: tab.name,
19
+ type: "group",
20
+ label: tab.label,
21
+ description: tab.description,
22
+ fields: tab.fields
23
+ };
24
+ result.push(syntheticGroup);
25
+ } else {
26
+ result.push(...flattenDataFields(tab.fields));
27
+ }
14
28
  }
15
29
  } else if (field.type === "collapsible" || field.type === "row") {
16
30
  result.push(...flattenDataFields(field.fields));
@@ -231,11 +245,7 @@ function generateTypes(config) {
231
245
  const hasTimestamps = collection.timestamps !== false;
232
246
  for (const field of dataFields) {
233
247
  if (field.type === "blocks") {
234
- const blockResult = generateBlockTypes(
235
- collection.slug,
236
- field.name,
237
- field
238
- );
248
+ const blockResult = generateBlockTypes(collection.slug, field.name, field);
239
249
  blockDeclarations.push(blockResult.declarations);
240
250
  }
241
251
  }
@@ -249,11 +259,7 @@ function generateTypes(config) {
249
259
  const optional = field.required ? "" : "?";
250
260
  const propName = needsQuoting2(field.name) ? safeQuote(field.name) : field.name;
251
261
  if (field.type === "blocks") {
252
- const blockResult = generateBlockTypes(
253
- collection.slug,
254
- field.name,
255
- field
256
- );
262
+ const blockResult = generateBlockTypes(collection.slug, field.name, field);
257
263
  lines.push(` ${propName}${optional}: ${blockResult.unionTypeName}[];`);
258
264
  } else {
259
265
  const tsType = fieldTypeToTS(field);
@@ -355,6 +361,25 @@ var FIELD_ADMIN_STRIP_KEYS = /* @__PURE__ */ new Set(["condition"]);
355
361
  function isRecord(value) {
356
362
  return typeof value === "object" && value !== null && !Array.isArray(value);
357
363
  }
364
+ function previewFunctionToTemplate(fn, fields) {
365
+ try {
366
+ const sentinel = "__MCMS_FIELD_";
367
+ const mockDoc = {};
368
+ for (const field of fields) {
369
+ mockDoc[field.name] = `${sentinel}${field.name}__`;
370
+ }
371
+ const result = fn(mockDoc);
372
+ if (typeof result !== "string")
373
+ return true;
374
+ const template = result.replace(
375
+ new RegExp(`${sentinel}(\\w+)__`, "g"),
376
+ (_match, fieldName) => `{${fieldName}}`
377
+ );
378
+ return template;
379
+ } catch {
380
+ return true;
381
+ }
382
+ }
358
383
  function serializeValue(value, indent = " ") {
359
384
  if (value === null)
360
385
  return "null";
@@ -523,6 +548,9 @@ function serializeTabsArray(tabs, indent) {
523
548
  return "[]";
524
549
  const items = tabs.map((tab) => {
525
550
  const parts = [];
551
+ if (tab.name) {
552
+ parts.push(`${indent} name: ${JSON.stringify(tab.name)}`);
553
+ }
526
554
  parts.push(`${indent} label: ${JSON.stringify(tab.label)}`);
527
555
  if (tab.description) {
528
556
  parts.push(`${indent} description: ${JSON.stringify(tab.description)}`);
@@ -544,9 +572,13 @@ function serializeCollection(collection, indent = " ") {
544
572
  }
545
573
  parts.push(`${indent} fields: ${serializeFieldsArray(collection.fields, indent + " ")}`);
546
574
  if (collection.admin) {
547
- const adminEntries = Object.entries(collection.admin).filter(
548
- ([, v]) => v !== void 0 && typeof v !== "function"
549
- );
575
+ const adminEntries = Object.entries(collection.admin).filter(([, v]) => v !== void 0).map(([k, v]) => {
576
+ if (k === "preview" && typeof v === "function") {
577
+ const fn = v;
578
+ return [k, previewFunctionToTemplate(fn, collection.fields)];
579
+ }
580
+ return [k, v];
581
+ }).filter(([, v]) => typeof v !== "function");
550
582
  if (adminEntries.length > 0) {
551
583
  const adminObj = Object.fromEntries(adminEntries);
552
584
  parts.push(`${indent} admin: ${serializeValue(adminObj, indent + " ")}`);
@@ -570,6 +602,9 @@ function serializeCollection(collection, indent = " ") {
570
602
  if (collection.defaultSort) {
571
603
  parts.push(`${indent} defaultSort: ${JSON.stringify(collection.defaultSort)}`);
572
604
  }
605
+ if (collection.upload !== void 0) {
606
+ parts.push(`${indent} upload: ${serializeValue(collection.upload, indent + " ")}`);
607
+ }
573
608
  return `{
574
609
  ${parts.join(",\n")},
575
610
  ${indent}}`;
@@ -699,6 +734,15 @@ function parseArgs(args) {
699
734
  }
700
735
  return { configPath, typesOutputPath, configOutputPath, watch: watchMode };
701
736
  }
737
+ function formatWithPrettier(...filePaths) {
738
+ try {
739
+ execFileSync("npx", ["prettier", "--write", ...filePaths], {
740
+ stdio: "pipe"
741
+ });
742
+ } catch {
743
+ console.warn("prettier not available \u2014 skipping formatting of generated files");
744
+ }
745
+ }
702
746
  async function runGenerator(options) {
703
747
  const configPath = resolve(options.configPath);
704
748
  const typesOutputPath = resolve(options.typesOutputPath);
@@ -718,6 +762,7 @@ async function runGenerator(options) {
718
762
  mkdirSync(dirname(configOutputPath), { recursive: true });
719
763
  writeFileSync(configOutputPath, adminConfigContent, "utf-8");
720
764
  console.info(`Admin config generated: ${configOutputPath}`);
765
+ formatWithPrettier(typesOutputPath, configOutputPath);
721
766
  } catch (error) {
722
767
  console.error(`Error generating:`, error);
723
768
  throw error;
package/index.cjs CHANGED
@@ -46,6 +46,7 @@ __export(src_exports, {
46
46
  getDbAdapter: () => getDbAdapter,
47
47
  getGlobals: () => getGlobals,
48
48
  getSoftDeleteField: () => getSoftDeleteField,
49
+ getUploadFieldMapping: () => getUploadFieldMapping,
49
50
  group: () => group,
50
51
  hasAllRoles: () => hasAllRoles,
51
52
  hasAnyRole: () => hasAnyRole,
@@ -53,7 +54,9 @@ __export(src_exports, {
53
54
  humanizeFieldName: () => humanizeFieldName,
54
55
  isAuthenticated: () => isAuthenticated,
55
56
  isLayoutField: () => isLayoutField,
57
+ isNamedTab: () => isNamedTab,
56
58
  isOwner: () => isOwner,
59
+ isUploadCollection: () => isUploadCollection,
57
60
  json: () => json,
58
61
  not: () => not,
59
62
  number: () => number,
@@ -62,6 +65,8 @@ __export(src_exports, {
62
65
  point: () => point,
63
66
  radio: () => radio,
64
67
  relationship: () => relationship,
68
+ resolveMigrationConfig: () => resolveMigrationConfig,
69
+ resolveMigrationMode: () => resolveMigrationMode,
65
70
  richText: () => richText,
66
71
  row: () => row,
67
72
  select: () => select,
@@ -116,6 +121,21 @@ function getSoftDeleteField(config) {
116
121
  const sdConfig = config.softDelete;
117
122
  return sdConfig.field ?? "deletedAt";
118
123
  }
124
+ function isUploadCollection(config) {
125
+ return config.upload != null;
126
+ }
127
+ function getUploadFieldMapping(config) {
128
+ if (!isUploadCollection(config))
129
+ return null;
130
+ const u = config.upload;
131
+ return {
132
+ filename: u.filenameField ?? "filename",
133
+ mimeType: u.mimeTypeField ?? "mimeType",
134
+ filesize: u.filesizeField ?? "filesize",
135
+ path: u.pathField ?? "path",
136
+ url: u.urlField ?? "url"
137
+ };
138
+ }
119
139
 
120
140
  // libs/core/src/lib/fields/field.types.ts
121
141
  var LAYOUT_FIELD_TYPES = /* @__PURE__ */ new Set(["tabs", "collapsible", "row"]);
@@ -127,6 +147,9 @@ var ReferentialIntegrityError = class extends Error {
127
147
  this.constraint = constraint;
128
148
  }
129
149
  };
150
+ function isNamedTab(tab) {
151
+ return typeof tab.name === "string" && tab.name.length > 0;
152
+ }
130
153
  function isLayoutField(field) {
131
154
  return LAYOUT_FIELD_TYPES.has(field.type);
132
155
  }
@@ -135,7 +158,18 @@ function flattenDataFields(fields) {
135
158
  for (const field of fields) {
136
159
  if (field.type === "tabs") {
137
160
  for (const tab of field.tabs) {
138
- result.push(...flattenDataFields(tab.fields));
161
+ if (isNamedTab(tab)) {
162
+ const syntheticGroup = {
163
+ name: tab.name,
164
+ type: "group",
165
+ label: tab.label,
166
+ description: tab.description,
167
+ fields: tab.fields
168
+ };
169
+ result.push(syntheticGroup);
170
+ } else {
171
+ result.push(...flattenDataFields(tab.fields));
172
+ }
139
173
  }
140
174
  } else if (field.type === "collapsible" || field.type === "row") {
141
175
  result.push(...flattenDataFields(field.fields));
@@ -415,6 +449,9 @@ var MediaCollection = defineCollection({
415
449
  singular: "Media",
416
450
  plural: "Media"
417
451
  },
452
+ upload: {
453
+ mimeTypes: ["image/*", "application/pdf", "video/*", "audio/*"]
454
+ },
418
455
  admin: {
419
456
  useAsTitle: "filename",
420
457
  defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
@@ -435,7 +472,6 @@ var MediaCollection = defineCollection({
435
472
  description: "File size in bytes"
436
473
  }),
437
474
  text("path", {
438
- required: true,
439
475
  label: "Storage Path",
440
476
  description: "Path/key where the file is stored",
441
477
  admin: {
@@ -546,6 +582,29 @@ function isOwner(ownerField = "createdBy") {
546
582
  };
547
583
  }
548
584
 
585
+ // libs/core/src/lib/migrations.ts
586
+ function resolveMigrationMode(mode) {
587
+ if (mode === "push" || mode === "migrate")
588
+ return mode;
589
+ const env = process.env["NODE_ENV"];
590
+ if (env === "production")
591
+ return "migrate";
592
+ return "push";
593
+ }
594
+ function resolveMigrationConfig(config) {
595
+ if (!config)
596
+ return void 0;
597
+ const mode = resolveMigrationMode(config.mode);
598
+ return {
599
+ ...config,
600
+ directory: config.directory ?? "./migrations",
601
+ mode,
602
+ cloneTest: config.cloneTest ?? mode === "migrate",
603
+ dangerDetection: config.dangerDetection ?? true,
604
+ autoApply: config.autoApply ?? mode === "push"
605
+ };
606
+ }
607
+
549
608
  // libs/core/src/lib/config.ts
550
609
  var MIN_PASSWORD_LENGTH = 8;
551
610
  function defineMomentumConfig(config) {
@@ -576,7 +635,8 @@ function defineMomentumConfig(config) {
576
635
  level: config.logging?.level ?? "info",
577
636
  format: config.logging?.format ?? "pretty",
578
637
  timestamps: config.logging?.timestamps ?? true
579
- }
638
+ },
639
+ migrations: resolveMigrationConfig(config.migrations)
580
640
  };
581
641
  }
582
642
  function getDbAdapter(config) {
@@ -702,6 +762,7 @@ function createSeedHelpers() {
702
762
  getDbAdapter,
703
763
  getGlobals,
704
764
  getSoftDeleteField,
765
+ getUploadFieldMapping,
705
766
  group,
706
767
  hasAllRoles,
707
768
  hasAnyRole,
@@ -709,7 +770,9 @@ function createSeedHelpers() {
709
770
  humanizeFieldName,
710
771
  isAuthenticated,
711
772
  isLayoutField,
773
+ isNamedTab,
712
774
  isOwner,
775
+ isUploadCollection,
713
776
  json,
714
777
  not,
715
778
  number,
@@ -718,6 +781,8 @@ function createSeedHelpers() {
718
781
  point,
719
782
  radio,
720
783
  relationship,
784
+ resolveMigrationConfig,
785
+ resolveMigrationMode,
721
786
  richText,
722
787
  row,
723
788
  select,
package/index.js CHANGED
@@ -40,6 +40,21 @@ function getSoftDeleteField(config) {
40
40
  const sdConfig = config.softDelete;
41
41
  return sdConfig.field ?? "deletedAt";
42
42
  }
43
+ function isUploadCollection(config) {
44
+ return config.upload != null;
45
+ }
46
+ function getUploadFieldMapping(config) {
47
+ if (!isUploadCollection(config))
48
+ return null;
49
+ const u = config.upload;
50
+ return {
51
+ filename: u.filenameField ?? "filename",
52
+ mimeType: u.mimeTypeField ?? "mimeType",
53
+ filesize: u.filesizeField ?? "filesize",
54
+ path: u.pathField ?? "path",
55
+ url: u.urlField ?? "url"
56
+ };
57
+ }
43
58
 
44
59
  // libs/core/src/lib/fields/field.types.ts
45
60
  var LAYOUT_FIELD_TYPES = /* @__PURE__ */ new Set(["tabs", "collapsible", "row"]);
@@ -51,6 +66,9 @@ var ReferentialIntegrityError = class extends Error {
51
66
  this.constraint = constraint;
52
67
  }
53
68
  };
69
+ function isNamedTab(tab) {
70
+ return typeof tab.name === "string" && tab.name.length > 0;
71
+ }
54
72
  function isLayoutField(field) {
55
73
  return LAYOUT_FIELD_TYPES.has(field.type);
56
74
  }
@@ -59,7 +77,18 @@ function flattenDataFields(fields) {
59
77
  for (const field of fields) {
60
78
  if (field.type === "tabs") {
61
79
  for (const tab of field.tabs) {
62
- result.push(...flattenDataFields(tab.fields));
80
+ if (isNamedTab(tab)) {
81
+ const syntheticGroup = {
82
+ name: tab.name,
83
+ type: "group",
84
+ label: tab.label,
85
+ description: tab.description,
86
+ fields: tab.fields
87
+ };
88
+ result.push(syntheticGroup);
89
+ } else {
90
+ result.push(...flattenDataFields(tab.fields));
91
+ }
63
92
  }
64
93
  } else if (field.type === "collapsible" || field.type === "row") {
65
94
  result.push(...flattenDataFields(field.fields));
@@ -339,6 +368,9 @@ var MediaCollection = defineCollection({
339
368
  singular: "Media",
340
369
  plural: "Media"
341
370
  },
371
+ upload: {
372
+ mimeTypes: ["image/*", "application/pdf", "video/*", "audio/*"]
373
+ },
342
374
  admin: {
343
375
  useAsTitle: "filename",
344
376
  defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
@@ -359,7 +391,6 @@ var MediaCollection = defineCollection({
359
391
  description: "File size in bytes"
360
392
  }),
361
393
  text("path", {
362
- required: true,
363
394
  label: "Storage Path",
364
395
  description: "Path/key where the file is stored",
365
396
  admin: {
@@ -470,6 +501,29 @@ function isOwner(ownerField = "createdBy") {
470
501
  };
471
502
  }
472
503
 
504
+ // libs/core/src/lib/migrations.ts
505
+ function resolveMigrationMode(mode) {
506
+ if (mode === "push" || mode === "migrate")
507
+ return mode;
508
+ const env = process.env["NODE_ENV"];
509
+ if (env === "production")
510
+ return "migrate";
511
+ return "push";
512
+ }
513
+ function resolveMigrationConfig(config) {
514
+ if (!config)
515
+ return void 0;
516
+ const mode = resolveMigrationMode(config.mode);
517
+ return {
518
+ ...config,
519
+ directory: config.directory ?? "./migrations",
520
+ mode,
521
+ cloneTest: config.cloneTest ?? mode === "migrate",
522
+ dangerDetection: config.dangerDetection ?? true,
523
+ autoApply: config.autoApply ?? mode === "push"
524
+ };
525
+ }
526
+
473
527
  // libs/core/src/lib/config.ts
474
528
  var MIN_PASSWORD_LENGTH = 8;
475
529
  function defineMomentumConfig(config) {
@@ -500,7 +554,8 @@ function defineMomentumConfig(config) {
500
554
  level: config.logging?.level ?? "info",
501
555
  format: config.logging?.format ?? "pretty",
502
556
  timestamps: config.logging?.timestamps ?? true
503
- }
557
+ },
558
+ migrations: resolveMigrationConfig(config.migrations)
504
559
  };
505
560
  }
506
561
  function getDbAdapter(config) {
@@ -625,6 +680,7 @@ export {
625
680
  getDbAdapter,
626
681
  getGlobals,
627
682
  getSoftDeleteField,
683
+ getUploadFieldMapping,
628
684
  group,
629
685
  hasAllRoles,
630
686
  hasAnyRole,
@@ -632,7 +688,9 @@ export {
632
688
  humanizeFieldName,
633
689
  isAuthenticated,
634
690
  isLayoutField,
691
+ isNamedTab,
635
692
  isOwner,
693
+ isUploadCollection,
636
694
  json,
637
695
  not,
638
696
  number,
@@ -641,6 +699,8 @@ export {
641
699
  point,
642
700
  radio,
643
701
  relationship,
702
+ resolveMigrationConfig,
703
+ resolveMigrationMode,
644
704
  richText,
645
705
  row,
646
706
  select,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momentumcms/core",
3
- "version": "0.1.10",
3
+ "version": "0.3.0",
4
4
  "description": "Core collection config, fields, hooks, and access control for Momentum CMS",
5
5
  "license": "MIT",
6
6
  "author": "Momentum CMS Contributors",
@@ -31,6 +31,7 @@ interface FieldDefinition {
31
31
  editor?: Record<string, unknown>;
32
32
  }>;
33
33
  tabs?: Array<{
34
+ name?: string;
34
35
  label: string;
35
36
  description?: string;
36
37
  fields: FieldDefinition[];
@@ -90,6 +91,16 @@ interface CollectionDefinition {
90
91
  defaultWhere?: unknown;
91
92
  endpoints?: unknown[];
92
93
  webhooks?: unknown[];
94
+ upload?: {
95
+ mimeTypes?: string[];
96
+ maxFileSize?: number;
97
+ directory?: string;
98
+ filenameField?: string;
99
+ mimeTypeField?: string;
100
+ filesizeField?: string;
101
+ pathField?: string;
102
+ urlField?: string;
103
+ };
93
104
  }
94
105
  interface GlobalDefinition {
95
106
  slug: string;
package/src/index.d.ts CHANGED
@@ -12,3 +12,4 @@ export * from './lib/plugins';
12
12
  export * from './lib/storage';
13
13
  export * from './lib/seeding';
14
14
  export * from './lib/versions';
15
+ export * from './lib/migrations';
@@ -74,8 +74,8 @@ export interface AdminConfig {
74
74
  description?: string;
75
75
  /** Hide from admin navigation */
76
76
  hidden?: boolean;
77
- /** Enable preview mode */
78
- preview?: boolean | ((doc: Record<string, unknown>) => string);
77
+ /** Enable preview mode. String values are URL templates with {fieldName} placeholders. */
78
+ preview?: boolean | string | ((doc: Record<string, unknown>) => string);
79
79
  /** Custom action buttons displayed in the collection list header (alongside Create button) */
80
80
  headerActions?: Array<{
81
81
  id: string;
@@ -120,6 +120,24 @@ export interface SoftDeleteConfig {
120
120
  /** Auto-purge soft-deleted records after this many days. Undefined means never purge. */
121
121
  retentionDays?: number;
122
122
  }
123
+ export interface UploadCollectionConfig {
124
+ /** Allowed MIME types for uploads to this collection (e.g., ['image/*', 'application/pdf']) */
125
+ mimeTypes?: string[];
126
+ /** Maximum file size in bytes (overrides global storage.maxFileSize) */
127
+ maxFileSize?: number;
128
+ /** Subdirectory within the upload dir for this collection's files */
129
+ directory?: string;
130
+ /** Field name for the original filename. @default 'filename' */
131
+ filenameField?: string;
132
+ /** Field name for the MIME type. @default 'mimeType' */
133
+ mimeTypeField?: string;
134
+ /** Field name for the file size in bytes. @default 'filesize' */
135
+ filesizeField?: string;
136
+ /** Field name for the storage path. @default 'path' */
137
+ pathField?: string;
138
+ /** Field name for the public URL. @default 'url' */
139
+ urlField?: string;
140
+ }
123
141
  export interface TimestampsConfig {
124
142
  /** Add createdAt field */
125
143
  createdAt?: boolean;
@@ -188,6 +206,12 @@ export interface CollectionConfig {
188
206
  endpoints?: EndpointConfig[];
189
207
  /** Webhook subscriptions for this collection */
190
208
  webhooks?: WebhookConfig[];
209
+ /**
210
+ * Upload configuration. When present, this collection becomes an "upload collection"
211
+ * where POST /api/{slug} accepts multipart/form-data and auto-populates file metadata fields.
212
+ * Similar to Payload CMS's upload collection pattern.
213
+ */
214
+ upload?: UploadCollectionConfig;
191
215
  }
192
216
  /** Events that trigger webhooks. */
193
217
  export type WebhookEvent = 'afterChange' | 'afterDelete' | 'afterCreate' | 'afterUpdate';
@@ -40,6 +40,25 @@ export declare function defineGlobal(config: GlobalConfig): GlobalConfig;
40
40
  * @returns The deletedAt field name, or null if soft delete is not enabled
41
41
  */
42
42
  export declare function getSoftDeleteField(config: CollectionConfig): string | null;
43
+ /**
44
+ * Check whether a collection is configured as an upload collection.
45
+ */
46
+ export declare function isUploadCollection(config: CollectionConfig): boolean;
47
+ /**
48
+ * Resolved field name mapping for upload collection metadata fields.
49
+ */
50
+ export interface UploadFieldMapping {
51
+ filename: string;
52
+ mimeType: string;
53
+ filesize: string;
54
+ path: string;
55
+ url: string;
56
+ }
57
+ /**
58
+ * Get the resolved upload field mapping for an upload collection.
59
+ * Returns field names used for auto-populating file metadata, or null for non-upload collections.
60
+ */
61
+ export declare function getUploadFieldMapping(config: CollectionConfig): UploadFieldMapping | null;
43
62
  /**
44
63
  * Helper type to extract the document type from a collection
45
64
  * Useful for typing API responses and database queries
@@ -1,5 +1,5 @@
1
1
  export * from './collection.types';
2
- export { defineCollection, defineGlobal, getSoftDeleteField } from './define-collection';
3
- export type { InferDocumentType } from './define-collection';
2
+ export { defineCollection, defineGlobal, getSoftDeleteField, isUploadCollection, getUploadFieldMapping, } from './define-collection';
3
+ export type { InferDocumentType, UploadFieldMapping } from './define-collection';
4
4
  export { MediaCollection } from './media.collection';
5
5
  export type { MediaDocument } from './media.collection';
@@ -3,6 +3,7 @@ import type { SeedingConfig, SeedingOptions } from './seeding';
3
3
  import type { DocumentVersion, DocumentStatus, VersionQueryOptions, VersionCountOptions, CreateVersionOptions } from './versions';
4
4
  import type { MomentumPlugin, PluginAdminRouteDescriptor } from './plugins';
5
5
  import type { StorageAdapter } from './storage';
6
+ import type { MigrationConfig, ResolvedMigrationConfig } from './migrations';
6
7
  /**
7
8
  * Minimum password length for user accounts.
8
9
  * Shared across seeding, user sync hooks, and setup middleware.
@@ -13,6 +14,12 @@ export declare const MIN_PASSWORD_LENGTH = 8;
13
14
  * This is a placeholder type - actual adapters implement this interface.
14
15
  */
15
16
  export interface DatabaseAdapter {
17
+ /**
18
+ * Database dialect identifier.
19
+ * Set by the adapter factory (e.g., postgresAdapter sets 'postgresql', sqliteAdapter sets 'sqlite').
20
+ * Used by the migration CLI to select the correct introspection and SQL generation strategy.
21
+ */
22
+ dialect?: 'postgresql' | 'sqlite';
16
23
  find(collection: string, query: Record<string, unknown>): Promise<Record<string, unknown>[]>;
17
24
  findById(collection: string, id: string): Promise<Record<string, unknown> | null>;
18
25
  create(collection: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
@@ -148,6 +155,34 @@ export interface DatabaseAdapter {
148
155
  * @returns The full global record after update
149
156
  */
150
157
  updateGlobal?(slug: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
158
+ /**
159
+ * Introspect the current database schema.
160
+ * Returns table/column metadata for diffing against collection config.
161
+ */
162
+ introspect?(): Promise<Record<string, unknown>>;
163
+ /**
164
+ * Execute a raw SQL statement (DDL or DML).
165
+ * Returns the number of affected rows.
166
+ */
167
+ executeRaw?(sql: string, params?: unknown[]): Promise<number>;
168
+ /**
169
+ * Execute a raw SQL query and return rows.
170
+ */
171
+ queryRaw?<T extends Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
172
+ /**
173
+ * Clone the database for migration testing.
174
+ * Returns a connection string or path for the clone.
175
+ */
176
+ cloneDatabase?(targetName: string): Promise<string>;
177
+ /**
178
+ * Drop a cloned database.
179
+ */
180
+ dropClone?(targetName: string): Promise<void>;
181
+ /**
182
+ * Acquire an advisory lock for migration safety.
183
+ * Returns a release function.
184
+ */
185
+ acquireMigrationLock?(): Promise<() => Promise<void>>;
151
186
  }
152
187
  /**
153
188
  * Database configuration options.
@@ -327,6 +362,11 @@ export interface MomentumConfig {
327
362
  * Plugins run in array order during init/ready, reverse during shutdown.
328
363
  */
329
364
  plugins?: MomentumPlugin[];
365
+ /**
366
+ * Migration system configuration.
367
+ * When set, enables the schema migration system.
368
+ */
369
+ migrations?: MigrationConfig;
330
370
  }
331
371
  /**
332
372
  * Resolved seeding options with defaults applied.
@@ -346,6 +386,7 @@ export interface ResolvedMomentumConfig extends MomentumConfig {
346
386
  server: Required<ServerConfig>;
347
387
  seeding?: ResolvedSeedingConfig;
348
388
  logging: ResolvedLoggingConfig;
389
+ migrations?: ResolvedMigrationConfig;
349
390
  }
350
391
  /**
351
392
  * Defines Momentum CMS configuration.
@@ -239,10 +239,16 @@ export interface SlugField extends BaseField {
239
239
  }
240
240
  /** Tab definition within a tabs layout field */
241
241
  export interface TabConfig {
242
+ /** When present, creates a nested data structure (like a group). Omit for layout-only tabs. */
243
+ name?: string;
242
244
  label: string;
243
245
  description?: string;
244
246
  fields: Field[];
245
247
  }
248
+ /** Type guard: returns true if the tab has a non-empty name (stores nested data). */
249
+ export declare function isNamedTab(tab: TabConfig): tab is TabConfig & {
250
+ name: string;
251
+ };
246
252
  /** Tabs layout field - organizes fields into tabbed sections */
247
253
  export interface TabsField extends BaseField {
248
254
  type: 'tabs';
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Migration Types for Momentum CMS
3
+ *
4
+ * Universal (browser + server) types for the migration system.
5
+ * Server-only implementation lives in @momentumcms/migrations.
6
+ */
7
+ /**
8
+ * Configuration for the migration system.
9
+ * Added to MomentumConfig.migrations when migrations are enabled.
10
+ */
11
+ export interface MigrationConfig {
12
+ /**
13
+ * Directory where migration files are stored.
14
+ * Relative to the app root.
15
+ * @default './migrations'
16
+ */
17
+ directory?: string;
18
+ /**
19
+ * Database operation mode.
20
+ * - 'push': Dev mode — direct schema sync, no migration files
21
+ * - 'migrate': Production mode — migration files required
22
+ * - 'auto': 'push' in development, 'migrate' in production
23
+ * @default 'auto'
24
+ */
25
+ mode?: 'push' | 'migrate' | 'auto';
26
+ /**
27
+ * Enable clone-test-apply safety pipeline before applying migrations.
28
+ * When enabled, migrations are first tested on a database clone.
29
+ * @default true in migrate mode
30
+ */
31
+ cloneTest?: boolean;
32
+ /**
33
+ * Enable dangerous operation detection and warnings.
34
+ * @default true
35
+ */
36
+ dangerDetection?: boolean;
37
+ /**
38
+ * Automatically apply pending migrations on server start.
39
+ * Only applies in 'push' mode. Migrate mode always requires explicit CLI commands.
40
+ * @default true in push mode
41
+ */
42
+ autoApply?: boolean;
43
+ }
44
+ /**
45
+ * Status of an individual applied migration.
46
+ */
47
+ export interface MigrationStatus {
48
+ /** Migration filename (without extension) */
49
+ name: string;
50
+ /** Batch number (migrations applied together share a batch) */
51
+ batch: number;
52
+ /** When the migration was applied (ISO string) */
53
+ appliedAt: string;
54
+ /** SHA-256 checksum of the migration file */
55
+ checksum: string;
56
+ /** How long the migration took to execute (ms) */
57
+ executionMs: number;
58
+ }
59
+ /**
60
+ * Overall migration system status (browser-safe for admin UI).
61
+ */
62
+ export interface MigrationSystemStatus {
63
+ /** Whether the database schema matches the collection config */
64
+ inSync: boolean;
65
+ /** Number of pending (unapplied) migrations */
66
+ pending: number;
67
+ /** List of applied migrations */
68
+ applied: MigrationStatus[];
69
+ /** Current migration mode */
70
+ mode: 'push' | 'migrate';
71
+ }
72
+ /**
73
+ * Severity levels for dangerous operation warnings.
74
+ */
75
+ export type DangerSeverity = 'warning' | 'destructive' | 'irreversible';
76
+ /**
77
+ * Resolved migration config with defaults applied.
78
+ */
79
+ export interface ResolvedMigrationConfig extends MigrationConfig {
80
+ directory: string;
81
+ mode: 'push' | 'migrate';
82
+ cloneTest: boolean;
83
+ dangerDetection: boolean;
84
+ autoApply: boolean;
85
+ }
86
+ /**
87
+ * Resolve the effective migration mode from config and environment.
88
+ */
89
+ export declare function resolveMigrationMode(mode: MigrationConfig['mode']): 'push' | 'migrate';
90
+ /**
91
+ * Resolve migration config with defaults applied.
92
+ */
93
+ export declare function resolveMigrationConfig(config: MigrationConfig | undefined): ResolvedMigrationConfig | undefined;