domma-cms 0.116.5 → 0.116.6

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.116.5",
3
+ "version": "0.116.6",
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",
@@ -22,6 +22,7 @@ import fs from 'fs/promises';
22
22
  import path from 'path';
23
23
  import { config } from '../../config.js';
24
24
  import { applyFilter } from '../filterEngine.js';
25
+ import { withFileLock, writeFileAtomic } from './fileLock.js';
25
26
 
26
27
  const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
27
28
 
@@ -43,9 +44,16 @@ async function read(slug) {
43
44
  }
44
45
 
45
46
  async function write(slug, entries) {
46
- await fs.writeFile(dataPath(slug), JSON.stringify(entries, null, 2) + '\n', 'utf8');
47
+ await writeFileAtomic(dataPath(slug), JSON.stringify(entries, null, 2) + '\n');
47
48
  }
48
49
 
50
+ /**
51
+ * Every change is read-modify-write of the whole file, so changes to one
52
+ * collection run one at a time (./fileLock.js) - overlapping ones used to
53
+ * lose all but the last. Reads need no lock: writes are atomic renames.
54
+ */
55
+ const locked = (slug, fn) => withFileLock(dataPath(slug), fn);
56
+
49
57
  // ---------------------------------------------------------------------------
50
58
  // Adapter implementation
51
59
  // ---------------------------------------------------------------------------
@@ -125,10 +133,12 @@ export class FileAdapter {
125
133
  * @returns {Promise<object>}
126
134
  */
127
135
  async insert(slug, entry) {
128
- const entries = await read(slug);
129
- entries.push(entry);
130
- await write(slug, entries);
131
- return entry;
136
+ return locked(slug, async () => {
137
+ const entries = await read(slug);
138
+ entries.push(entry);
139
+ await write(slug, entries);
140
+ return entry;
141
+ });
132
142
  }
133
143
 
134
144
  /**
@@ -141,12 +151,14 @@ export class FileAdapter {
141
151
  * @throws {Error} If entry not found
142
152
  */
143
153
  async update(slug, entryId, updated) {
144
- const entries = await read(slug);
145
- const idx = entries.findIndex(e => e.id === entryId);
146
- if (idx === -1) throw new Error('Entry not found');
147
- entries[idx] = updated;
148
- await write(slug, entries);
149
- return updated;
154
+ return locked(slug, async () => {
155
+ const entries = await read(slug);
156
+ const idx = entries.findIndex(e => e.id === entryId);
157
+ if (idx === -1) throw new Error('Entry not found');
158
+ entries[idx] = updated;
159
+ await write(slug, entries);
160
+ return updated;
161
+ });
150
162
  }
151
163
 
152
164
  /**
@@ -158,11 +170,13 @@ export class FileAdapter {
158
170
  * @throws {Error} If entry not found
159
171
  */
160
172
  async remove(slug, entryId) {
161
- const entries = await read(slug);
162
- const idx = entries.findIndex(e => e.id === entryId);
163
- if (idx === -1) throw new Error('Entry not found');
164
- entries.splice(idx, 1);
165
- await write(slug, entries);
173
+ return locked(slug, async () => {
174
+ const entries = await read(slug);
175
+ const idx = entries.findIndex(e => e.id === entryId);
176
+ if (idx === -1) throw new Error('Entry not found');
177
+ entries.splice(idx, 1);
178
+ await write(slug, entries);
179
+ });
166
180
  }
167
181
 
168
182
  /**
@@ -172,7 +186,7 @@ export class FileAdapter {
172
186
  * @returns {Promise<void>}
173
187
  */
174
188
  async clear(slug) {
175
- await write(slug, []);
189
+ return locked(slug, () => write(slug, []));
176
190
  }
177
191
 
178
192
  /**
@@ -193,10 +207,12 @@ export class FileAdapter {
193
207
  * @returns {Promise<{ imported: number, skipped: number, errors: string[] }>}
194
208
  */
195
209
  async insertMany(slug, entries) {
196
- const existing = await read(slug);
197
- existing.push(...entries);
198
- await write(slug, existing);
199
- return { imported: entries.length, skipped: 0, errors: [] };
210
+ return locked(slug, async () => {
211
+ const existing = await read(slug);
212
+ existing.push(...entries);
213
+ await write(slug, existing);
214
+ return { imported: entries.length, skipped: 0, errors: [] };
215
+ });
200
216
  }
201
217
 
202
218
  /**
@@ -219,7 +235,7 @@ export class FileAdapter {
219
235
  * @returns {Promise<void>}
220
236
  */
221
237
  async replaceAll(slug, entries) {
222
- await write(slug, entries);
238
+ return locked(slug, () => write(slug, entries));
223
239
  }
224
240
 
225
241
  /**
@@ -231,6 +247,10 @@ export class FileAdapter {
231
247
  * @returns {Promise<string|null>} The backup's file name, or null if there was no data.json
232
248
  */
233
249
  async archive(slug) {
250
+ return locked(slug, () => this.#archive(slug));
251
+ }
252
+
253
+ async #archive(slug) {
234
254
  const from = dataPath(slug);
235
255
  let to = `${from}.bak`;
236
256
  try {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * One writer at a time per JSON file, and no half-written file ever on disk.
3
+ *
4
+ * A collection's `data.json` is changed by reading the whole file, changing it
5
+ * in memory and writing it back. Two of those overlapping - two form
6
+ * submissions, two imports, a save during a migration - each read the same
7
+ * starting file, and the second write silently drops the first one's change.
8
+ * `withFileLock()` queues them per file, so each read sees the last write.
9
+ *
10
+ * `writeFileAtomic()` writes a temporary file beside the target and renames
11
+ * it over, so a crash mid-write leaves the old file whole rather than a
12
+ * truncated one that reads as "no entries".
13
+ *
14
+ * The queue is in-process: one site is one Node process, which is how every
15
+ * site runs. Anything else writing the same file must go through here too
16
+ * (userProfiles.js does).
17
+ *
18
+ * @module server/services/adapters/fileLock
19
+ */
20
+ import fs from 'fs/promises';
21
+ import path from 'path';
22
+
23
+ /** file path -> the tail of its queue */
24
+ const queues = new Map();
25
+
26
+ /**
27
+ * Run `fn` once every earlier call for the same file has finished (resolved
28
+ * or thrown). A failure is passed to this caller only; the queue goes on.
29
+ *
30
+ * @template T
31
+ * @param {string} file
32
+ * @param {() => Promise<T>} fn
33
+ * @returns {Promise<T>}
34
+ */
35
+ export function withFileLock(file, fn) {
36
+ const key = path.resolve(file);
37
+ const prev = queues.get(key) ?? Promise.resolve();
38
+ const run = prev.then(fn, fn);
39
+ const tail = run.then(() => {}, () => {});
40
+ queues.set(key, tail);
41
+ tail.then(() => { if (queues.get(key) === tail) queues.delete(key); });
42
+ return run;
43
+ }
44
+
45
+ let seq = 0;
46
+
47
+ /**
48
+ * Write `text` to `file` atomically: a temporary file in the same directory,
49
+ * then a rename over the target.
50
+ *
51
+ * @param {string} file
52
+ * @param {string} text
53
+ */
54
+ export async function writeFileAtomic(file, text) {
55
+ const tmp = `${file}.${process.pid}.${Date.now().toString(36)}${(seq++).toString(36)}.tmp`;
56
+ try {
57
+ await fs.writeFile(tmp, text, 'utf8');
58
+ await fs.rename(tmp, file);
59
+ } catch (err) {
60
+ await fs.rm(tmp, {force: true}).catch(() => {});
61
+ throw err;
62
+ }
63
+ }
@@ -6,6 +6,7 @@
6
6
  import fs from 'fs/promises';
7
7
  import path from 'path';
8
8
  import {config} from '../config.js';
9
+ import {withFileLock, writeFileAtomic} from './adapters/fileLock.js';
9
10
 
10
11
  const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
11
12
  const SLUG = 'user-profiles';
@@ -54,9 +55,12 @@ async function readData() {
54
55
  * @returns {Promise<void>}
55
56
  */
56
57
  async function writeData(entries) {
57
- await fs.writeFile(DATA_PATH, JSON.stringify(entries, null, 2) + '\n', 'utf8');
58
+ await writeFileAtomic(DATA_PATH, JSON.stringify(entries, null, 2) + '\n');
58
59
  }
59
60
 
61
+ /** Changes go one at a time, sharing the FileAdapter's queue for this file (adapters/fileLock.js). */
62
+ const locked = (fn) => withFileLock(DATA_PATH, fn);
63
+
60
64
  // ---------------------------------------------------------------------------
61
65
  // Public API
62
66
  // ---------------------------------------------------------------------------
@@ -93,6 +97,10 @@ export async function seed() {
93
97
  * @returns {Promise<void>}
94
98
  */
95
99
  export async function ensureProfile(userId) {
100
+ return locked(() => ensureProfileUnlocked(userId));
101
+ }
102
+
103
+ async function ensureProfileUnlocked(userId) {
96
104
  const entries = await readData();
97
105
  const exists = entries.some(e => e.id === userId);
98
106
  if (!exists) {
@@ -125,6 +133,10 @@ export async function getProfile(userId) {
125
133
  * @returns {Promise<object>} Updated profile entry
126
134
  */
127
135
  export async function updateProfile(userId, data) {
136
+ return locked(() => updateProfileUnlocked(userId, data));
137
+ }
138
+
139
+ async function updateProfileUnlocked(userId, data) {
128
140
  const entries = await readData();
129
141
  const index = entries.findIndex(e => e.id === userId);
130
142
 
@@ -155,11 +167,13 @@ export async function updateProfile(userId, data) {
155
167
  * @returns {Promise<void>}
156
168
  */
157
169
  export async function deleteProfile(userId) {
158
- const entries = await readData();
159
- const filtered = entries.filter(e => e.id !== userId);
160
- if (filtered.length !== entries.length) {
161
- await writeData(filtered);
162
- }
170
+ return locked(async () => {
171
+ const entries = await readData();
172
+ const filtered = entries.filter(e => e.id !== userId);
173
+ if (filtered.length !== entries.length) {
174
+ await writeData(filtered);
175
+ }
176
+ });
163
177
  }
164
178
 
165
179
  /**