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 +1 -1
- package/scripts/promote-changelog.mjs +71 -0
- package/server/server.js +12 -0
- package/server/services/adapters/FileAdapter.js +13 -0
- package/server/services/collections.js +4 -1
- package/server/services/entryValues.js +54 -0
- package/server/services/number-fields-migration.js +97 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|