domma-cms 0.77.1 → 0.77.2
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
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();
|
|
@@ -0,0 +1,109 @@
|
|
|
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. It is deliberately not a standing coercion -
|
|
17
|
+
* what an API client writes after this is the client's business.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'fs/promises';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import {config} from '../config.js';
|
|
22
|
+
|
|
23
|
+
export const MIGRATION_ID = 'number-field-types';
|
|
24
|
+
|
|
25
|
+
const DECIMAL = /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
|
|
26
|
+
|
|
27
|
+
/** "42" -> 42; anything that is not plain decimal text is returned unchanged. */
|
|
28
|
+
export function numberFromText(value) {
|
|
29
|
+
if (typeof value !== 'string') return value;
|
|
30
|
+
const t = value.trim();
|
|
31
|
+
if (!DECIMAL.test(t)) return value;
|
|
32
|
+
const n = Number(t);
|
|
33
|
+
return Number.isFinite(n) ? n : value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* An entry with its number fields converted, or null when nothing changed.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} entry
|
|
40
|
+
* @param {string[]} names - the collection's number field names
|
|
41
|
+
*/
|
|
42
|
+
export function convertEntry(entry, names) {
|
|
43
|
+
const data = entry?.data;
|
|
44
|
+
if (!data || typeof data !== 'object') return null;
|
|
45
|
+
let changed = null;
|
|
46
|
+
for (const name of names) {
|
|
47
|
+
if (!(name in data)) continue;
|
|
48
|
+
const next = numberFromText(data[name]);
|
|
49
|
+
if (next !== data[name]) (changed ||= {...data})[name] = next;
|
|
50
|
+
}
|
|
51
|
+
return changed ? {...entry, data: changed} : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function defaultMarkerPath() {
|
|
55
|
+
return path.join(path.dirname(path.resolve(config.content.collectionsDir)), '.migrations.json');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readMarker(file) {
|
|
59
|
+
try { return JSON.parse(await fs.readFile(file, 'utf8')) || {}; } catch { return {}; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run the migration.
|
|
64
|
+
*
|
|
65
|
+
* @param {{markerPath?: string, force?: boolean}} [opts]
|
|
66
|
+
* @returns {Promise<{ran: boolean, converted: object, pending: string[]}>}
|
|
67
|
+
* converted: slug -> entries changed; pending: collections that could not be done
|
|
68
|
+
* (they are retried on the next start, because the marker is not written)
|
|
69
|
+
*/
|
|
70
|
+
export async function migrateNumberFields({markerPath = defaultMarkerPath(), force = false} = {}) {
|
|
71
|
+
const marker = await readMarker(markerPath);
|
|
72
|
+
if (marker[MIGRATION_ID] && !force) return {ran: false, converted: {}, pending: []};
|
|
73
|
+
|
|
74
|
+
const {listCollections} = await import('./collections.js');
|
|
75
|
+
const {getAdapter} = await import('./adapterRegistry.js');
|
|
76
|
+
const {default: fileAdapter} = await import('./adapters/FileAdapter.js');
|
|
77
|
+
const cache = await import('./cache/index.js');
|
|
78
|
+
|
|
79
|
+
const converted = {};
|
|
80
|
+
const pending = [];
|
|
81
|
+
for (const schema of await listCollections()) {
|
|
82
|
+
const names = (schema.fields || []).filter(f => f?.type === 'number' && f.name).map(f => f.name);
|
|
83
|
+
if (!names.length) continue;
|
|
84
|
+
try {
|
|
85
|
+
const adapter = await getAdapter(schema.slug);
|
|
86
|
+
// A MongoDB collection whose connection is down resolves to the file adapter,
|
|
87
|
+
// which would find nothing to do - leave it for a start where Mongo is up.
|
|
88
|
+
if (schema.storage?.adapter === 'mongodb' && adapter === fileAdapter) { pending.push(schema.slug); continue; }
|
|
89
|
+
const entries = await adapter.all(schema.slug);
|
|
90
|
+
const next = entries.map(e => convertEntry(e, names));
|
|
91
|
+
const count = next.filter(Boolean).length;
|
|
92
|
+
if (!count) continue;
|
|
93
|
+
if (typeof adapter.replaceAll === 'function') {
|
|
94
|
+
await adapter.replaceAll(schema.slug, entries.map((e, i) => next[i] || e));
|
|
95
|
+
} else {
|
|
96
|
+
for (const e of next) if (e) await adapter.update(schema.slug, e.id, e);
|
|
97
|
+
}
|
|
98
|
+
converted[schema.slug] = count;
|
|
99
|
+
await cache.invalidateTags([`collection:${schema.slug}`]).catch(() => {});
|
|
100
|
+
} catch {
|
|
101
|
+
pending.push(schema.slug);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!pending.length) {
|
|
106
|
+
await fs.writeFile(markerPath, JSON.stringify({...marker, [MIGRATION_ID]: new Date().toISOString()}, null, 2) + '\n');
|
|
107
|
+
}
|
|
108
|
+
return {ran: true, converted, pending};
|
|
109
|
+
}
|