domma-cms 0.77.1 → 0.78.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.77.1",
3
+ "version": "0.78.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Turn the CHANGELOG's `## [Unreleased]` heading into `## [X.Y.Z] - YYYY-MM-DD`.
3
+ *
4
+ * The release notes are the one part of a release a workflow cannot write, so
5
+ * they are written as the work lands, under `[Unreleased]`, and this is what
6
+ * the "Cut a release" workflow does with them. Refusing to run without them is
7
+ * the point: a release with no entry is a release nobody can read about.
8
+ *
9
+ * With `--print X.Y.Z` it instead prints an already-released section.
10
+ *
11
+ * The same script as domma-reactive/scripts/promote-changelog.mjs; both
12
+ * CHANGELOGs follow Keep a Changelog. Keep the two in step.
13
+ *
14
+ * node scripts/promote-changelog.mjs 1.2.0 2026-10-01
15
+ * node scripts/promote-changelog.mjs --print 1.2.0
16
+ */
17
+
18
+ import {readFileSync, writeFileSync} from 'node:fs';
19
+ import {fileURLToPath} from 'node:url';
20
+ import path from 'node:path';
21
+
22
+ const FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'CHANGELOG.md');
23
+
24
+ const fail = (message) => {
25
+ console.error(`\n promote-changelog: ${message}\n`);
26
+ process.exit(1);
27
+ };
28
+
29
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
30
+
31
+ /** The body under a `## [label]` heading, up to the next `## [` heading. */
32
+ function section(source, label) {
33
+ const heading = new RegExp(`^## \\[${escape(label)}\\][^\\n]*\\n`, 'm');
34
+ const match = heading.exec(source);
35
+ if (!match) return null;
36
+
37
+ const start = match.index + match[0].length;
38
+ const next = source.slice(start).search(/^## \[/m);
39
+ const end = next === -1 ? source.length : start + next;
40
+ return {heading: match, start, end, body: source.slice(start, end).trim()};
41
+ }
42
+
43
+ const source = readFileSync(FILE, 'utf8');
44
+
45
+ if (process.argv[2] === '--print') {
46
+ const version = process.argv[3];
47
+ const found = version && section(source, version);
48
+ if (!found) fail(`no '## [${version}]' section in CHANGELOG.md`);
49
+ console.log(found.body);
50
+ process.exit(0);
51
+ }
52
+
53
+ const [version, date] = process.argv.slice(2);
54
+ if (!/^\d+\.\d+\.\d+$/.test(version || '')) fail('usage: promote-changelog.mjs X.Y.Z YYYY-MM-DD');
55
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date || '')) fail(`"${date}" is not a YYYY-MM-DD date`);
56
+
57
+ if (section(source, version)) fail(`CHANGELOG.md already has a '## [${version}]' section`);
58
+
59
+ const pending = section(source, 'Unreleased');
60
+ if (!pending) {
61
+ fail("no '## [Unreleased]' section - write the release notes there first, then cut the release");
62
+ }
63
+ if (pending.body === '') {
64
+ fail("'## [Unreleased]' is empty - write the release notes there first, then cut the release");
65
+ }
66
+
67
+ writeFileSync(
68
+ FILE,
69
+ source.slice(0, pending.heading.index) + `## [${version}] - ${date}\n` + source.slice(pending.start)
70
+ );
71
+ console.log(`\n CHANGELOG.md: [Unreleased] → [${version}] - ${date}\n`);
package/server/server.js CHANGED
@@ -314,6 +314,18 @@ try {
314
314
  // Initialise response cache (Memory/None/Redis driver per config.cache).
315
315
  await cache.initCache(config.cache);
316
316
 
317
+ // Number fields saved as text by the pre-0.77.1 entry form - one-shot repair.
318
+ // After the cache: it invalidates the collections it changes.
319
+ try {
320
+ const {migrateNumberFields} = await import('./services/number-fields-migration.js');
321
+ const {ran, converted, pending} = await migrateNumberFields();
322
+ const done = Object.entries(converted).map(([slug, n]) => `${slug} (${n})`);
323
+ if (ran && done.length) app.log.info(`[collections] Number fields stored as text converted: ${done.join(', ')}`);
324
+ if (pending.length) app.log.warn(`[collections] Number-field repair will retry next start for: ${pending.join(', ')}`);
325
+ } catch (err) {
326
+ app.log.warn(`[collections] Number-field repair skipped: ${err.message}`);
327
+ }
328
+
317
329
  /*
318
330
  * An admin change clears the response cache.
319
331
  *
@@ -15,6 +15,7 @@
15
15
  * all(slug) → entry[]
16
16
  * insertMany(slug, entries) → { imported, skipped, errors }
17
17
  * count(slug) → number
18
+ * replaceAll(slug, entries) → void (file only - one write for a whole-collection rewrite)
18
19
  */
19
20
  import fs from 'fs/promises';
20
21
  import path from 'path';
@@ -207,6 +208,18 @@ export class FileAdapter {
207
208
  const entries = await read(slug);
208
209
  return entries.length;
209
210
  }
211
+
212
+ /**
213
+ * Replace every entry in one write - for a data migration that touches many
214
+ * entries, where update() would rewrite the whole file once per entry.
215
+ *
216
+ * @param {string} slug
217
+ * @param {object[]} entries
218
+ * @returns {Promise<void>}
219
+ */
220
+ async replaceAll(slug, entries) {
221
+ await write(slug, entries);
222
+ }
210
223
  }
211
224
 
212
225
  export default new FileAdapter();
@@ -12,6 +12,7 @@ import {v4 as uuidv4} from 'uuid';
12
12
  import {config} from '../config.js';
13
13
  import {getAdapter, invalidate} from './adapterRegistry.js';
14
14
  import * as cache from './cache/index.js';
15
+ import {typedEntryData} from './entryValues.js';
15
16
  import {toCsv} from '../../public/js/collection-export.mjs';
16
17
 
17
18
  const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
@@ -388,6 +389,7 @@ export async function getEntry(slug, entryId) {
388
389
  export async function createEntry(slug, data, { createdBy = null, source = 'admin' } = {}) {
389
390
  const schema = await getCollection(slug);
390
391
  if (!schema) throw new Error(`Collection "${slug}" not found`);
392
+ data = typedEntryData(schema, data);
391
393
 
392
394
  const { valid, errors } = validateEntryData(schema, data);
393
395
  if (!valid) throw new Error(`Validation failed: ${errors.join('; ')}`);
@@ -420,6 +422,7 @@ export async function createEntry(slug, data, { createdBy = null, source = 'admi
420
422
  export async function updateEntry(slug, entryId, data) {
421
423
  const schema = await getCollection(slug);
422
424
  if (!schema) throw new Error(`Collection "${slug}" not found`);
425
+ data = typedEntryData(schema, data);
423
426
 
424
427
  const { valid, errors } = validateEntryData(schema, data);
425
428
  if (!valid) throw new Error(`Validation failed: ${errors.join('; ')}`);
@@ -526,7 +529,7 @@ export async function importEntries(slug, incoming, { createdBy = null } = {}) {
526
529
  const valid = [];
527
530
 
528
531
  for (const item of incoming) {
529
- const data = item.data || item;
532
+ const data = typedEntryData(schema, item.data || item);
530
533
  const { valid: ok, errors: valErrors } = validateEntryData(schema, data);
531
534
  if (!ok) {
532
535
  skipped++;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Entry values as their field types store them.
3
+ *
4
+ * Everything that writes entries through the collections service - the admin,
5
+ * the external /api/v1 API, form submissions, import, Actions - arrives here
6
+ * first, so a `number` field sent as text ("42", as HTML forms and CSV-minded
7
+ * clients send it) is stored as a number and sorts, filters and totals as one.
8
+ *
9
+ * Deliberately a conversion, not a check: text that is not a plain decimal
10
+ * number ("", "abc", "0x10", "Infinity") is stored as sent, exactly as before,
11
+ * so no existing client starts getting errors. Only top-level `number` fields.
12
+ */
13
+
14
+ const DECIMAL = /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
15
+
16
+ /** "42" -> 42; anything that is not plain decimal text is returned unchanged. */
17
+ export function numberFromText(value) {
18
+ if (typeof value !== 'string') return value;
19
+ const t = value.trim();
20
+ if (!DECIMAL.test(t)) return value;
21
+ const n = Number(t);
22
+ return Number.isFinite(n) ? n : value;
23
+ }
24
+
25
+ /** The schema's number field names. */
26
+ export function numberFieldNames(schema) {
27
+ return (schema?.fields || []).filter(f => f?.type === 'number' && f.name).map(f => f.name);
28
+ }
29
+
30
+ /**
31
+ * `data` with its number fields converted - a new object when anything changed,
32
+ * the same object when nothing did (the caller's object is never modified).
33
+ *
34
+ * @param {object} data
35
+ * @param {string[]} names - from numberFieldNames()
36
+ */
37
+ export function withNumbers(data, names) {
38
+ if (!data || typeof data !== 'object' || Array.isArray(data) || !names?.length) return data;
39
+ let out = data;
40
+ for (const name of names) {
41
+ if (!Object.prototype.hasOwnProperty.call(data, name)) continue;
42
+ const next = numberFromText(data[name]);
43
+ if (next !== data[name]) {
44
+ if (out === data) out = {...data};
45
+ out[name] = next;
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /** An entry's data typed for its collection's schema. */
52
+ export function typedEntryData(schema, data) {
53
+ return withNumbers(data, numberFieldNames(schema));
54
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Number-fields migration - one-shot repair of number fields stored as text.
3
+ *
4
+ * Before 0.77.1 the admin's entry form read every input as text, so a `number`
5
+ * field saved from it was stored as "42" rather than 42 - and a stored string
6
+ * sorts, filters and sums as text in the API, [collection] displays and views.
7
+ * The form is fixed; this converts what it left behind, in every collection and
8
+ * through the storage adapter, so MongoDB-backed collections are included.
9
+ *
10
+ * Only top-level `number` fields, and only text that is a plain decimal number
11
+ * (whitespace trimmed): "", "abc", "0x10" and "Infinity" are left exactly as
12
+ * they are. An entry's `meta.updatedAt` is not touched - this is a repair, not
13
+ * an edit.
14
+ *
15
+ * Runs ONCE per install: the marker (content/.migrations.json) is written only
16
+ * when every collection was done. From 0.78.0 the collections service converts
17
+ * on every write (entryValues.js), so nothing new of this kind arrives - this
18
+ * only mends what was stored before.
19
+ */
20
+ import fs from 'fs/promises';
21
+ import path from 'path';
22
+ import {config} from '../config.js';
23
+ import {numberFieldNames, numberFromText, withNumbers} from './entryValues.js';
24
+
25
+ export const MIGRATION_ID = 'number-field-types';
26
+
27
+ export {numberFromText};
28
+
29
+ /**
30
+ * An entry with its number fields converted, or null when nothing changed.
31
+ *
32
+ * @param {object} entry
33
+ * @param {string[]} names - the collection's number field names
34
+ */
35
+ export function convertEntry(entry, names) {
36
+ const data = entry?.data;
37
+ if (!data || typeof data !== 'object') return null;
38
+ const next = withNumbers(data, names);
39
+ return next === data ? null : {...entry, data: next};
40
+ }
41
+
42
+ function defaultMarkerPath() {
43
+ return path.join(path.dirname(path.resolve(config.content.collectionsDir)), '.migrations.json');
44
+ }
45
+
46
+ async function readMarker(file) {
47
+ try { return JSON.parse(await fs.readFile(file, 'utf8')) || {}; } catch { return {}; }
48
+ }
49
+
50
+ /**
51
+ * Run the migration.
52
+ *
53
+ * @param {{markerPath?: string, force?: boolean}} [opts]
54
+ * @returns {Promise<{ran: boolean, converted: object, pending: string[]}>}
55
+ * converted: slug -> entries changed; pending: collections that could not be done
56
+ * (they are retried on the next start, because the marker is not written)
57
+ */
58
+ export async function migrateNumberFields({markerPath = defaultMarkerPath(), force = false} = {}) {
59
+ const marker = await readMarker(markerPath);
60
+ if (marker[MIGRATION_ID] && !force) return {ran: false, converted: {}, pending: []};
61
+
62
+ const {listCollections} = await import('./collections.js');
63
+ const {getAdapter} = await import('./adapterRegistry.js');
64
+ const {default: fileAdapter} = await import('./adapters/FileAdapter.js');
65
+ const cache = await import('./cache/index.js');
66
+
67
+ const converted = {};
68
+ const pending = [];
69
+ for (const schema of await listCollections()) {
70
+ const names = numberFieldNames(schema);
71
+ if (!names.length) continue;
72
+ try {
73
+ const adapter = await getAdapter(schema.slug);
74
+ // A MongoDB collection whose connection is down resolves to the file adapter,
75
+ // which would find nothing to do - leave it for a start where Mongo is up.
76
+ if (schema.storage?.adapter === 'mongodb' && adapter === fileAdapter) { pending.push(schema.slug); continue; }
77
+ const entries = await adapter.all(schema.slug);
78
+ const next = entries.map(e => convertEntry(e, names));
79
+ const count = next.filter(Boolean).length;
80
+ if (!count) continue;
81
+ if (typeof adapter.replaceAll === 'function') {
82
+ await adapter.replaceAll(schema.slug, entries.map((e, i) => next[i] || e));
83
+ } else {
84
+ for (const e of next) if (e) await adapter.update(schema.slug, e.id, e);
85
+ }
86
+ converted[schema.slug] = count;
87
+ await cache.invalidateTags([`collection:${schema.slug}`]).catch(() => {});
88
+ } catch {
89
+ pending.push(schema.slug);
90
+ }
91
+ }
92
+
93
+ if (!pending.length) {
94
+ await fs.writeFile(markerPath, JSON.stringify({...marker, [MIGRATION_ID]: new Date().toISOString()}, null, 2) + '\n');
95
+ }
96
+ return {ran: true, converted, pending};
97
+ }