canopycms 0.0.61 → 0.0.62
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/dist/ai/json-to-markdown.js +1 -2
- package/dist/ai/json-to-markdown.js.map +1 -1
- package/dist/api/__test__/mock-client.d.ts +5 -1
- package/dist/api/__test__/mock-client.d.ts.map +1 -1
- package/dist/api/__test__/mock-client.js +9 -2
- package/dist/api/__test__/mock-client.js.map +1 -1
- package/dist/api/admin-branch-health.d.ts +36 -1
- package/dist/api/admin-branch-health.d.ts.map +1 -1
- package/dist/api/admin-branch-health.js +176 -2
- package/dist/api/admin-branch-health.js.map +1 -1
- package/dist/api/admin.d.ts +8 -1
- package/dist/api/admin.d.ts.map +1 -1
- package/dist/api/admin.js.map +1 -1
- package/dist/api/branch.d.ts.map +1 -1
- package/dist/api/branch.js +16 -2
- package/dist/api/branch.js.map +1 -1
- package/dist/api/client.d.ts +5 -1
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +6 -0
- package/dist/api/client.js.map +1 -1
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +11 -5
- package/dist/api/content.d.ts.map +1 -1
- package/dist/api/content.js +79 -6
- package/dist/api/content.js.map +1 -1
- package/dist/api/entries.d.ts +10 -0
- package/dist/api/entries.d.ts.map +1 -1
- package/dist/api/entries.js +40 -15
- package/dist/api/entries.js.map +1 -1
- package/dist/api/schema.d.ts +4 -4
- package/dist/api/schema.d.ts.map +1 -1
- package/dist/api/schema.js +13 -13
- package/dist/api/schema.js.map +1 -1
- package/dist/assets/transform.d.ts.map +1 -1
- package/dist/assets/transform.js +19 -6
- package/dist/assets/transform.js.map +1 -1
- package/dist/branch-health.d.ts +13 -0
- package/dist/branch-health.d.ts.map +1 -1
- package/dist/branch-health.js +23 -0
- package/dist/branch-health.js.map +1 -1
- package/dist/cli/cli.js +858 -537
- package/dist/cli/generate-ai-content.js +760 -455
- package/dist/cli/init.js +25 -7
- package/dist/cli/template-files/Dockerfile.cms.template +19 -1
- package/dist/cli/template-files/canopycms.config.ts.template +7 -0
- package/dist/cli/template-files/cms-stack.ts.template +7 -1
- package/dist/client.d.ts +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/config/schemas/field.d.ts +15 -15
- package/dist/config/types.d.ts +2 -2
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +0 -1
- package/dist/config/types.js.map +1 -1
- package/dist/config/validation.d.ts +9 -0
- package/dist/config/validation.d.ts.map +1 -1
- package/dist/config/validation.js +14 -0
- package/dist/config/validation.js.map +1 -1
- package/dist/content-id-index.d.ts +97 -5
- package/dist/content-id-index.d.ts.map +1 -1
- package/dist/content-id-index.js +170 -18
- package/dist/content-id-index.js.map +1 -1
- package/dist/content-store.d.ts +81 -7
- package/dist/content-store.d.ts.map +1 -1
- package/dist/content-store.js +443 -258
- package/dist/content-store.js.map +1 -1
- package/dist/editor/BranchManager.d.ts.map +1 -1
- package/dist/editor/BranchManager.js +18 -6
- package/dist/editor/BranchManager.js.map +1 -1
- package/dist/editor/CanopyEditor.d.ts.map +1 -1
- package/dist/editor/CanopyEditor.js +2 -2
- package/dist/editor/CanopyEditor.js.map +1 -1
- package/dist/editor/CanopyEditorPage.d.ts +9 -1
- package/dist/editor/CanopyEditorPage.d.ts.map +1 -1
- package/dist/editor/CanopyEditorPage.js +9 -2
- package/dist/editor/CanopyEditorPage.js.map +1 -1
- package/dist/editor/Editor.d.ts +9 -1
- package/dist/editor/Editor.d.ts.map +1 -1
- package/dist/editor/Editor.js +58 -24
- package/dist/editor/Editor.js.map +1 -1
- package/dist/editor/FormRenderer.d.ts.map +1 -1
- package/dist/editor/FormRenderer.js +12 -0
- package/dist/editor/FormRenderer.js.map +1 -1
- package/dist/editor/admin/SystemHealthPanel.js +6 -2
- package/dist/editor/admin/SystemHealthPanel.js.map +1 -1
- package/dist/editor/components/EntryCreateModal.d.ts +9 -1
- package/dist/editor/components/EntryCreateModal.d.ts.map +1 -1
- package/dist/editor/components/EntryCreateModal.js +4 -1
- package/dist/editor/components/EntryCreateModal.js.map +1 -1
- package/dist/editor/fields/DateTimeField.d.ts +46 -0
- package/dist/editor/fields/DateTimeField.d.ts.map +1 -0
- package/dist/editor/fields/DateTimeField.js +66 -0
- package/dist/editor/fields/DateTimeField.js.map +1 -0
- package/dist/editor/fields/NumberField.d.ts +34 -0
- package/dist/editor/fields/NumberField.d.ts.map +1 -0
- package/dist/editor/fields/NumberField.js +53 -0
- package/dist/editor/fields/NumberField.js.map +1 -0
- package/dist/editor/fields/NumberListField.d.ts +25 -0
- package/dist/editor/fields/NumberListField.d.ts.map +1 -0
- package/dist/editor/fields/NumberListField.js +53 -0
- package/dist/editor/fields/NumberListField.js.map +1 -0
- package/dist/editor/hooks/useBranchManager.d.ts.map +1 -1
- package/dist/editor/hooks/useBranchManager.js +115 -26
- package/dist/editor/hooks/useBranchManager.js.map +1 -1
- package/dist/editor/hooks/useDraftManager.d.ts +8 -0
- package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
- package/dist/editor/hooks/useDraftManager.js +261 -21
- package/dist/editor/hooks/useDraftManager.js.map +1 -1
- package/dist/editor/hooks/useEntryManager.d.ts +21 -0
- package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
- package/dist/editor/hooks/useEntryManager.js +25 -2
- package/dist/editor/hooks/useEntryManager.js.map +1 -1
- package/dist/editor/theme.d.ts +8 -8
- package/dist/git-manager.d.ts +51 -7
- package/dist/git-manager.d.ts.map +1 -1
- package/dist/git-manager.js +105 -14
- package/dist/git-manager.js.map +1 -1
- package/dist/http/handler.d.ts.map +1 -1
- package/dist/http/handler.js +70 -27
- package/dist/http/handler.js.map +1 -1
- package/dist/http/router.d.ts.map +1 -1
- package/dist/http/router.js +40 -3
- package/dist/http/router.js.map +1 -1
- package/dist/operating-mode/deployment-name-fixtures.d.ts +29 -0
- package/dist/operating-mode/deployment-name-fixtures.d.ts.map +1 -0
- package/dist/operating-mode/deployment-name-fixtures.js +55 -0
- package/dist/operating-mode/deployment-name-fixtures.js.map +1 -0
- package/dist/operating-mode/deployment-name.d.ts +9 -0
- package/dist/operating-mode/deployment-name.d.ts.map +1 -1
- package/dist/operating-mode/deployment-name.js +9 -1
- package/dist/operating-mode/deployment-name.js.map +1 -1
- package/dist/operating-mode/index.d.ts +2 -1
- package/dist/operating-mode/index.d.ts.map +1 -1
- package/dist/operating-mode/index.js +4 -1
- package/dist/operating-mode/index.js.map +1 -1
- package/dist/operating-mode/mode-env.d.ts +58 -0
- package/dist/operating-mode/mode-env.d.ts.map +1 -0
- package/dist/operating-mode/mode-env.js +97 -0
- package/dist/operating-mode/mode-env.js.map +1 -0
- package/dist/resolve-canopy-user.d.ts +34 -0
- package/dist/resolve-canopy-user.d.ts.map +1 -0
- package/dist/resolve-canopy-user.js +55 -0
- package/dist/resolve-canopy-user.js.map +1 -0
- package/dist/schema/schema-store.d.ts +53 -2
- package/dist/schema/schema-store.d.ts.map +1 -1
- package/dist/schema/schema-store.js +92 -21
- package/dist/schema/schema-store.js.map +1 -1
- package/dist/server.d.ts +9 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +9 -0
- package/dist/server.js.map +1 -1
- package/dist/services.d.ts.map +1 -1
- package/dist/services.js +40 -6
- package/dist/services.js.map +1 -1
- package/dist/settings-workspace.d.ts +40 -0
- package/dist/settings-workspace.d.ts.map +1 -1
- package/dist/settings-workspace.js +139 -151
- package/dist/settings-workspace.js.map +1 -1
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/content-write-lock.d.ts +127 -0
- package/dist/utils/content-write-lock.d.ts.map +1 -0
- package/dist/utils/content-write-lock.js +170 -0
- package/dist/utils/content-write-lock.js.map +1 -0
- package/dist/utils/error.d.ts +1 -1
- package/dist/utils/error.js +1 -1
- package/dist/utils/git.d.ts +16 -0
- package/dist/utils/git.d.ts.map +1 -1
- package/dist/utils/git.js +22 -0
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/sanitize-href.d.ts +28 -3
- package/dist/utils/sanitize-href.d.ts.map +1 -1
- package/dist/utils/sanitize-href.js +64 -6
- package/dist/utils/sanitize-href.js.map +1 -1
- package/dist/validation/entry-link-validator.d.ts +1 -1
- package/dist/validation/entry-link-validator.js +3 -3
- package/dist/validation/entry-link-validator.js.map +1 -1
- package/dist/validation/entry-validator.js +1 -1
- package/dist/validation/entry-validator.js.map +1 -1
- package/dist/worker/cms-worker.d.ts +13 -0
- package/dist/worker/cms-worker.d.ts.map +1 -1
- package/dist/worker/cms-worker.js +326 -264
- package/dist/worker/cms-worker.js.map +1 -1
- package/package.json +2 -2
package/dist/content-store.js
CHANGED
|
@@ -4,6 +4,7 @@ import matter from 'gray-matter';
|
|
|
4
4
|
import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
|
|
5
5
|
import { atomicWriteFile } from './utils/atomic-write.js';
|
|
6
6
|
import { withLock } from './utils/async-mutex.js';
|
|
7
|
+
import { ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, withContentWriteLock, } from './utils/content-write-lock.js';
|
|
7
8
|
import { findBodyFieldName } from './utils/body-field.js';
|
|
8
9
|
import { ContentIdIndex, extractIdFromFilename, extractSlugFromFilename, extractEntryTypeFromFilename, resolveCollectionPath, } from './content-id-index.js';
|
|
9
10
|
import { registerContentIndexForInvalidation } from './content-index-registry.js';
|
|
@@ -41,11 +42,71 @@ export class ContentStoreError extends Error {
|
|
|
41
42
|
* Indicates a cross-process concurrent write — the caller should reload and retry.
|
|
42
43
|
*/
|
|
43
44
|
export class ContentConflictError extends Error {
|
|
44
|
-
constructor() {
|
|
45
|
-
super(
|
|
45
|
+
constructor(message = 'Content was modified by another editor') {
|
|
46
|
+
super(message);
|
|
46
47
|
this.name = 'ContentConflictError';
|
|
47
48
|
}
|
|
48
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* [SYNC-C1] Thrown when a mutation could not take the branch's cross-host
|
|
52
|
+
* content-write lock within its bounded wait -- in practice, the worker is
|
|
53
|
+
* mid-rebase on this branch's working tree (utils/content-write-lock.ts).
|
|
54
|
+
*
|
|
55
|
+
* A `ContentConflictError` subclass so every existing 409 mapping keeps
|
|
56
|
+
* working unchanged; the distinct type exists so the API can surface THIS
|
|
57
|
+
* message ("the branch is busy, retry") instead of the generic "modified by
|
|
58
|
+
* another editor", which would be actively misleading. The default wording
|
|
59
|
+
* covers writer-vs-writer contention too, which this lock also produces --
|
|
60
|
+
* see ContentWriteLockBusyError.
|
|
61
|
+
*/
|
|
62
|
+
export class BranchSyncingError extends ContentConflictError {
|
|
63
|
+
constructor(message) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = 'BranchSyncingError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* [F1] Thrown when a save's content ID is carried by MORE THAN ONE file in
|
|
70
|
+
* the branch's content tree -- the duplicate-ID state `ContentIdIndex`
|
|
71
|
+
* quarantines (see its "Duplicate-ID quarantine" section).
|
|
72
|
+
*
|
|
73
|
+
* Why refuse rather than write: with two files sharing one ID, "this entry"
|
|
74
|
+
* is ambiguous, and every way of proceeding is worse than stopping.
|
|
75
|
+
* Following the index would mutate (and, via the slug-change cleanup, DELETE)
|
|
76
|
+
* a file the caller never addressed -- the data-loss bug this class exists to
|
|
77
|
+
* prevent. Writing only the addressed file would succeed silently into a file
|
|
78
|
+
* that is invisible to every ID-based lookup (reads-by-id, references,
|
|
79
|
+
* listings all resolve to the OTHER copy) and that the repair action later
|
|
80
|
+
* archives away, so the editor's work would appear to evaporate with no error
|
|
81
|
+
* anywhere. Refusing mutates nothing under any interleaving, and says what is
|
|
82
|
+
* wrong and who can fix it.
|
|
83
|
+
*
|
|
84
|
+
* A `ContentConflictError` subclass so every existing 409 mapping keeps
|
|
85
|
+
* working; the distinct type exists so the API can surface THIS message
|
|
86
|
+
* rather than the generic "modified by another editor", which would send the
|
|
87
|
+
* editor into a reload-and-retry loop that cannot succeed.
|
|
88
|
+
*/
|
|
89
|
+
export class DuplicateContentIdError extends ContentConflictError {
|
|
90
|
+
constructor(contentId, paths) {
|
|
91
|
+
const sorted = Array.from(new Set(paths)).sort();
|
|
92
|
+
super(
|
|
93
|
+
// Names the STATE, not an action. The repair-content-duplicates
|
|
94
|
+
// endpoint exists but nothing in the editor renders it, so telling an
|
|
95
|
+
// editor "an admin can run X" sent them to an admin who could neither
|
|
96
|
+
// run X nor see that the branch was affected. Say what is true; the
|
|
97
|
+
// admin panel's read-only duplicate list (SystemHealthPanel) is the
|
|
98
|
+
// diagnosis half, and the repair UI is tracked in
|
|
99
|
+
// .claude/future-tasks/duplicate-content-id-repair-ui.md.
|
|
100
|
+
`Content ID ${contentId} is on more than one file (${sorted
|
|
101
|
+
.map((p) => `"${p}"`)
|
|
102
|
+
.join(' and ')}), so this save was refused rather than risk overwriting or ` +
|
|
103
|
+
`deleting the wrong one. An administrator needs to resolve the duplicate on the ` +
|
|
104
|
+
`server before this entry can be saved.`);
|
|
105
|
+
this.name = 'DuplicateContentIdError';
|
|
106
|
+
this.contentId = contentId;
|
|
107
|
+
this.paths = sorted;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
49
110
|
/**
|
|
50
111
|
* Get the default entry type from a collection's entries array.
|
|
51
112
|
* Returns the entry marked as default, or the first one, or undefined if no entries.
|
|
@@ -95,10 +156,41 @@ export class ContentStore {
|
|
|
95
156
|
this.contentRootName = options.contentRootName || 'content';
|
|
96
157
|
this.indexFreshnessIntervalMs =
|
|
97
158
|
options.indexFreshnessIntervalMs ?? DEFAULT_INDEX_FRESHNESS_INTERVAL_MS;
|
|
159
|
+
this.contentWriteLockWaitMs =
|
|
160
|
+
options.contentWriteLockWaitMs ?? DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS;
|
|
98
161
|
this.schemaIndex = new Map(flatSchema.map((item) => [item.logicalPath, item]));
|
|
99
162
|
this._idIndex = new ContentIdIndex(this.root);
|
|
100
163
|
registerContentIndexForInvalidation(this.root, this);
|
|
101
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* [SYNC-C1] Run a working-tree mutation under the branch's cross-host
|
|
167
|
+
* content-write lock (utils/content-write-lock.ts), on top of the
|
|
168
|
+
* in-process locks the callee takes for itself.
|
|
169
|
+
*
|
|
170
|
+
* The in-process mutex serializes writers inside ONE process; it says
|
|
171
|
+
* nothing about the EC2 worker rebasing this same tree on shared EFS, which
|
|
172
|
+
* destroys an in-flight save (`checkout --theirs` overwrites it and the
|
|
173
|
+
* rebase then reports success; `rebase --abort` hard-resets it). This is the
|
|
174
|
+
* layer that actually excludes the two.
|
|
175
|
+
*
|
|
176
|
+
* Reads deliberately do NOT take it -- an extra EFS round-trip per read is
|
|
177
|
+
* not an acceptable cost, and reads cannot be destroyed by a rebase.
|
|
178
|
+
*
|
|
179
|
+
* Acquisition order is always content lock -> `withLock`, never the reverse,
|
|
180
|
+
* so the two cannot deadlock.
|
|
181
|
+
*/
|
|
182
|
+
async withContentWriteExclusion(fn) {
|
|
183
|
+
try {
|
|
184
|
+
return await withContentWriteLock(this.root, fn, this.contentWriteLockWaitMs);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
// Translate at the boundary, after the bounded wait -- never inside the
|
|
188
|
+
// acquire loop, which would disable its ELOCKED retry predicate.
|
|
189
|
+
if (err instanceof ContentWriteLockBusyError)
|
|
190
|
+
throw new BranchSyncingError(err.message);
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
102
194
|
/**
|
|
103
195
|
* Mark the ID index stale so the next idIndex() access rebuilds it from disk.
|
|
104
196
|
*
|
|
@@ -222,27 +314,33 @@ export class ContentStore {
|
|
|
222
314
|
return true;
|
|
223
315
|
}
|
|
224
316
|
/**
|
|
225
|
-
*
|
|
226
|
-
*
|
|
317
|
+
* Every file in `dir` whose filename embeds `id`, as root-relative paths
|
|
318
|
+
* (empty when there is no such file, or no such dir).
|
|
319
|
+
*
|
|
320
|
+
* [F1] Returns ALL matches, not the first: more than one match is a
|
|
321
|
+
* duplicate-ID pair, and callers must be able to tell that apart from a
|
|
322
|
+
* clean single hit rather than silently picking whichever one `readdir()`
|
|
323
|
+
* happened to yield first.
|
|
227
324
|
*/
|
|
228
|
-
async
|
|
325
|
+
async findEntryPathsById(dir, id) {
|
|
229
326
|
let entries;
|
|
230
327
|
try {
|
|
231
328
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
232
329
|
}
|
|
233
330
|
catch (err) {
|
|
234
331
|
if (isNodeError(err) && err.code === 'ENOENT')
|
|
235
|
-
return
|
|
332
|
+
return [];
|
|
236
333
|
throw err;
|
|
237
334
|
}
|
|
335
|
+
const matches = [];
|
|
238
336
|
for (const entry of entries) {
|
|
239
337
|
if (entry.isDirectory())
|
|
240
338
|
continue;
|
|
241
339
|
if (extractIdFromFilename(entry.name) === id) {
|
|
242
|
-
|
|
340
|
+
matches.push(path.relative(this.root, path.join(dir, entry.name)));
|
|
243
341
|
}
|
|
244
342
|
}
|
|
245
|
-
return
|
|
343
|
+
return matches.sort();
|
|
246
344
|
}
|
|
247
345
|
/**
|
|
248
346
|
* Get all schema items for iteration.
|
|
@@ -609,141 +707,222 @@ export class ContentStore {
|
|
|
609
707
|
// re-acquire under the current key. Bounded: each flip requires another
|
|
610
708
|
// mutator to have completed in the gap; the cap only guards pathological
|
|
611
709
|
// scheduling.
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
710
|
+
// [SYNC-C1] Cross-host exclusion against the worker's rebase loop wraps
|
|
711
|
+
// the WHOLE reclassification loop: one acquisition per call, and no
|
|
712
|
+
// window between attempts where a rebase could start.
|
|
713
|
+
return this.withContentWriteExclusion(async () => {
|
|
714
|
+
const RETRY_KEY = Symbol('retry-with-new-lock-key');
|
|
715
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
716
|
+
const outcome = await withLock(lockKey, async () => {
|
|
717
|
+
// Re-resolve inside the lock: ground truth after acquisition. A
|
|
718
|
+
// concurrent renameEntry() may have moved this entry between the
|
|
719
|
+
// pre-pass above and acquiring this lock.
|
|
720
|
+
const inLock = await this.buildPaths(schemaItem, slug, {
|
|
721
|
+
entryTypeName,
|
|
722
|
+
existingId,
|
|
723
|
+
});
|
|
724
|
+
const currentKey = this.entryLockKey(schemaItem, slug, inLock);
|
|
725
|
+
if (currentKey !== lockKey) {
|
|
726
|
+
lockKey = currentKey;
|
|
727
|
+
return RETRY_KEY;
|
|
728
|
+
}
|
|
729
|
+
const { absolutePath, relativePath, id } = inLock;
|
|
730
|
+
// [F1] Duplicate-ID guard. INVARIANT: a write must never remove or
|
|
731
|
+
// modify a file it did not address. The post-write index-repair
|
|
732
|
+
// step below deletes the ID's previously-indexed path when it
|
|
733
|
+
// differs from the one being written ("the slug changed"); that is
|
|
734
|
+
// only sound while an ID identifies exactly one file. When a
|
|
735
|
+
// duplicate-ID pair is on disk (ContentIdIndex's quarantine, from
|
|
736
|
+
// rename-crash debris or a merge) the quarantined file is still
|
|
737
|
+
// addressable by collection+slug -- buildPaths() resolves slugs by
|
|
738
|
+
// directory scan and knows nothing about the quarantine -- so a
|
|
739
|
+
// save to it used to resolve the index to the OTHER file and unlink
|
|
740
|
+
// that one: a different document, silently deleted, with the write
|
|
741
|
+
// reporting success. Refuse instead (see DuplicateContentIdError).
|
|
742
|
+
//
|
|
743
|
+
// Runs before any mutation, so a refusal leaves the tree exactly as
|
|
744
|
+
// it was. Two independent detections, because neither alone covers
|
|
745
|
+
// everything:
|
|
746
|
+
// 1. the index's own quarantine record -- catches a duplicate
|
|
747
|
+
// whose other copy lives in a different directory, and the
|
|
748
|
+
// ID-addressed (existingId) shape where the target is a fresh
|
|
749
|
+
// third path; needs the index to have scanned the duplicate.
|
|
750
|
+
// 2. disk-verified ambiguity -- both the file we are about to
|
|
751
|
+
// write AND the indexed location exist right now. This one
|
|
752
|
+
// does not depend on index freshness at all, which is what
|
|
753
|
+
// makes the common (slug-addressed) save safe even against an
|
|
754
|
+
// index built before the duplicate landed.
|
|
755
|
+
// Neither fires for the ordinary slug-change save of a
|
|
756
|
+
// non-duplicated entry: there the indexed path exists but the
|
|
757
|
+
// target does not (it is about to be created), so the cleanup
|
|
758
|
+
// below still removes exactly the file the caller relocated.
|
|
759
|
+
if (id) {
|
|
760
|
+
const guardIndex = this._idIndex;
|
|
761
|
+
const indexedPath = guardIndex.findById(id)?.relativePath ?? null;
|
|
762
|
+
if (indexedPath && indexedPath !== relativePath) {
|
|
763
|
+
const quarantined = guardIndex.getDuplicateFor(id);
|
|
764
|
+
const bothOnDisk = (await filePathExists(absolutePath)) &&
|
|
765
|
+
(await filePathExists(path.join(this.root, indexedPath)));
|
|
766
|
+
if (quarantined || bothOnDisk) {
|
|
767
|
+
throw new DuplicateContentIdError(id, [
|
|
768
|
+
indexedPath,
|
|
769
|
+
relativePath,
|
|
770
|
+
...(quarantined?.droppedPaths ?? []),
|
|
771
|
+
...(quarantined ? [quarantined.keptPath] : []),
|
|
772
|
+
]);
|
|
773
|
+
}
|
|
635
774
|
}
|
|
636
775
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
776
|
+
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
777
|
+
// OCC: undefined means no opinion (skip entirely, back-compat blind
|
|
778
|
+
// write). A number means "must match this mtime" (stale-write
|
|
779
|
+
// rejection). `null` means "must NOT exist yet" — the create-intent
|
|
780
|
+
// guard: without this, a create request against a slug that already
|
|
781
|
+
// has content falls through to an ordinary blind overwrite (August
|
|
782
|
+
// 2026 baseline review, Critical finding). This is the authoritative
|
|
783
|
+
// check — it runs inside the per-entry lock against a fresh stat, so
|
|
784
|
+
// it holds even if a caller's own pre-write existence check (e.g. the
|
|
785
|
+
// API layer's `documentExists`) went stale under concurrency.
|
|
786
|
+
if (input.expectedVersion !== undefined) {
|
|
787
|
+
try {
|
|
788
|
+
const existing = await fs.stat(absolutePath);
|
|
789
|
+
if (input.expectedVersion === null) {
|
|
790
|
+
throw new ContentConflictError('An entry with this slug already exists');
|
|
791
|
+
}
|
|
792
|
+
if (existing.mtimeMs !== input.expectedVersion) {
|
|
793
|
+
throw new ContentConflictError();
|
|
794
|
+
}
|
|
642
795
|
}
|
|
643
|
-
|
|
644
|
-
|
|
796
|
+
catch (err) {
|
|
797
|
+
if (err instanceof ContentConflictError)
|
|
798
|
+
throw err;
|
|
799
|
+
if (isNodeError(err) && err.code === 'ENOENT') {
|
|
800
|
+
// File doesn't exist yet: for a numeric expectedVersion this is
|
|
801
|
+
// the existing "first write, skip version check" back-compat
|
|
802
|
+
// behavior; for expectedVersion === null this is the success
|
|
803
|
+
// case (create-only correctly finds no collision) — either way,
|
|
804
|
+
// proceed with the write.
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
throw err;
|
|
808
|
+
}
|
|
645
809
|
}
|
|
646
810
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
}
|
|
684
|
-
else {
|
|
685
|
-
content = matter.stringify(input.body, input.data ?? {});
|
|
686
|
-
}
|
|
687
|
-
await atomicWriteFile(absolutePath, content);
|
|
688
|
-
// Update the ID index after a successful write. Look up and mutate the
|
|
689
|
-
// LIVE index in one synchronous window (no awaits in between): a
|
|
690
|
-
// concurrent rebuild may have swapped in a fresh instance since the
|
|
691
|
-
// pre-write snapshot, and updates must land where future lookups go.
|
|
692
|
-
const liveIndex = this._idIndex;
|
|
693
|
-
let staleOldAbsPath = null;
|
|
694
|
-
if (id) {
|
|
695
|
-
const existing = liveIndex.findById(id);
|
|
696
|
-
if (existing) {
|
|
697
|
-
if (existing.relativePath !== relativePath) {
|
|
698
|
-
// Slug changed — remember the orphaned old path to delete below
|
|
699
|
-
staleOldAbsPath = path.join(this.root, existing.relativePath);
|
|
700
|
-
liveIndex.updatePath(existing.id, relativePath);
|
|
811
|
+
// Existence guard (cross-process): the caller asserts this entry already
|
|
812
|
+
// exists (existingId), so if no file is at the path we are about to
|
|
813
|
+
// write, this store's index may be stale — another process may have
|
|
814
|
+
// renamed the entry. Recreating a renamed entry's old path would leave
|
|
815
|
+
// two files with the same embedded ID and poison every subsequent index
|
|
816
|
+
// rebuild (ID collision). The directory listing is authoritative on this
|
|
817
|
+
// host: if the ID's actual on-disk location differs from what our index
|
|
818
|
+
// believes, fail with a conflict so the caller reloads fresh state.
|
|
819
|
+
// (An intentional slug-change save passes: the index and the directory
|
|
820
|
+
// agree on the entry's current — old-slug — path. External deletes also
|
|
821
|
+
// pass: the ID is nowhere on disk, so recreating is last-writer-wins.)
|
|
822
|
+
//
|
|
823
|
+
// indexedRelPath reads the LIVE index synchronously — NOT idIndex() —
|
|
824
|
+
// because idIndex() would run a full rescan while holding the entry
|
|
825
|
+
// lock if invalidateIndex() fired between the pre-lock warm-up above
|
|
826
|
+
// and here. The live index may then be stale, but staleness only errs
|
|
827
|
+
// toward throwing ContentConflictError: actualRelPath comes from the
|
|
828
|
+
// fresh in-lock directory scan just below (ground truth), so a stale
|
|
829
|
+
// indexedRelPath can only turn agree->disagree (spurious conflict,
|
|
830
|
+
// which the caller already handles by reloading), never
|
|
831
|
+
// disagree->agree. Fail-closed, never fail-open.
|
|
832
|
+
if (existingId && !(await filePathExists(absolutePath))) {
|
|
833
|
+
const actualRelPaths = await this.findEntryPathsById(path.dirname(absolutePath), existingId);
|
|
834
|
+
// [F1] Two files in the target directory carry this ID -- a
|
|
835
|
+
// duplicate the index has not scanned yet (the guard above asks
|
|
836
|
+
// the index; this asks the directory). Which one a single-match
|
|
837
|
+
// scan returns is readdir-order-dependent, so it could 409 or
|
|
838
|
+
// pass at random, and passing meant the cleanup below unlinked
|
|
839
|
+
// one of two indistinguishable documents. Refuse deterministically.
|
|
840
|
+
if (actualRelPaths.length > 1) {
|
|
841
|
+
throw new DuplicateContentIdError(existingId, actualRelPaths);
|
|
842
|
+
}
|
|
843
|
+
const actualRelPath = actualRelPaths[0] ?? null;
|
|
844
|
+
const indexedRelPath = this._idIndex.findById(existingId)?.relativePath ?? null;
|
|
845
|
+
if (actualRelPath !== null && actualRelPath !== indexedRelPath) {
|
|
846
|
+
throw new ContentConflictError();
|
|
701
847
|
}
|
|
702
848
|
}
|
|
849
|
+
// Serialize content string
|
|
850
|
+
let content;
|
|
851
|
+
if (input.format === 'json') {
|
|
852
|
+
content = `${JSON.stringify(input.data ?? {}, null, 2)}\n`;
|
|
853
|
+
}
|
|
854
|
+
else if (input.format === 'yaml') {
|
|
855
|
+
content = yamlStringify(input.data ?? {});
|
|
856
|
+
}
|
|
703
857
|
else {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
858
|
+
content = matter.stringify(input.body, input.data ?? {});
|
|
859
|
+
}
|
|
860
|
+
await atomicWriteFile(absolutePath, content);
|
|
861
|
+
// Update the ID index after a successful write. Look up and mutate the
|
|
862
|
+
// LIVE index in one synchronous window (no awaits in between): a
|
|
863
|
+
// concurrent rebuild may have swapped in a fresh instance since the
|
|
864
|
+
// pre-write snapshot, and updates must land where future lookups go.
|
|
865
|
+
const liveIndex = this._idIndex;
|
|
866
|
+
let staleOldAbsPath = null;
|
|
867
|
+
if (id) {
|
|
868
|
+
const existing = liveIndex.findById(id);
|
|
869
|
+
if (existing) {
|
|
870
|
+
if (existing.relativePath !== relativePath) {
|
|
871
|
+
// Slug changed — remember the orphaned old path to delete
|
|
872
|
+
// below. Safe ONLY because the [F1] guard above has already
|
|
873
|
+
// established that this ID is not on two files: without it
|
|
874
|
+
// this line deletes a document the caller never addressed
|
|
875
|
+
// (see DuplicateContentIdError). Do not move, weaken or skip
|
|
876
|
+
// that guard while this unlink exists.
|
|
877
|
+
staleOldAbsPath = path.join(this.root, existing.relativePath);
|
|
878
|
+
liveIndex.updatePath(existing.id, relativePath);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
liveIndex.add({
|
|
883
|
+
type: 'entry',
|
|
884
|
+
relativePath,
|
|
885
|
+
collection: collectionPath,
|
|
886
|
+
slug: slug || undefined,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
if (staleOldAbsPath) {
|
|
891
|
+
await fs.unlink(staleOldAbsPath).catch((err) => {
|
|
892
|
+
if (!isNodeError(err) || err.code !== 'ENOENT')
|
|
893
|
+
throw err;
|
|
709
894
|
});
|
|
710
895
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
if (typeof outcome !== 'symbol')
|
|
742
|
-
return outcome;
|
|
743
|
-
}
|
|
744
|
-
// Ten completed foreign mutations landed in our acquisition gaps in a
|
|
745
|
-
// row — treat as contention and let the caller reload + retry.
|
|
746
|
-
throw new ContentConflictError();
|
|
896
|
+
await this.recordOwnMutation(liveIndex);
|
|
897
|
+
const afterStat = await fs.stat(absolutePath);
|
|
898
|
+
const base = {
|
|
899
|
+
collection: schemaItem.logicalPath,
|
|
900
|
+
collectionName: schemaItem.name,
|
|
901
|
+
relativePath,
|
|
902
|
+
absolutePath,
|
|
903
|
+
version: afterStat.mtimeMs,
|
|
904
|
+
};
|
|
905
|
+
if (input.format === 'json') {
|
|
906
|
+
return { ...base, format: 'json', data: input.data ?? {} };
|
|
907
|
+
}
|
|
908
|
+
if (input.format === 'yaml') {
|
|
909
|
+
return { ...base, format: 'yaml', data: input.data ?? {} };
|
|
910
|
+
}
|
|
911
|
+
return {
|
|
912
|
+
...base,
|
|
913
|
+
format: input.format,
|
|
914
|
+
data: input.data ?? {},
|
|
915
|
+
body: input.body,
|
|
916
|
+
bodyFieldName: findBodyFieldName(fields),
|
|
917
|
+
};
|
|
918
|
+
});
|
|
919
|
+
if (typeof outcome !== 'symbol')
|
|
920
|
+
return outcome;
|
|
921
|
+
}
|
|
922
|
+
// Ten completed foreign mutations landed in our acquisition gaps in a
|
|
923
|
+
// row — treat as contention and let the caller reload + retry.
|
|
924
|
+
throw new ContentConflictError();
|
|
925
|
+
});
|
|
747
926
|
}
|
|
748
927
|
/**
|
|
749
928
|
* Read an entry by its ID (UUID).
|
|
@@ -883,40 +1062,43 @@ export class ContentStore {
|
|
|
883
1062
|
// the key this slug would resolve to. Re-derive the key from the in-lock
|
|
884
1063
|
// ground truth and retry under the corrected key on mismatch, bounded to
|
|
885
1064
|
// rule out only pathological scheduling.
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
liveIndex.
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1065
|
+
// [SYNC-C1] See write()'s call to withContentWriteExclusion.
|
|
1066
|
+
return this.withContentWriteExclusion(async () => {
|
|
1067
|
+
const RETRY_KEY = Symbol('retry-with-new-lock-key');
|
|
1068
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
1069
|
+
const outcome = await withLock(lockKey, async () => {
|
|
1070
|
+
// Re-resolve inside the lock: ground truth after acquisition. If a
|
|
1071
|
+
// concurrent renameEntry() moved this slug away in the meantime,
|
|
1072
|
+
// buildPaths() no longer finds it here (inLock.existed is false) --
|
|
1073
|
+
// the entry genuinely isn't at this slug anymore, so we fall through
|
|
1074
|
+
// to the unlink below on a freshly generated (nonexistent) path,
|
|
1075
|
+
// preserving the original ENOENT-throwing behavior.
|
|
1076
|
+
const inLock = await this.buildPaths(collection, slug);
|
|
1077
|
+
const currentKey = this.entryLockKey(collection, slug, inLock);
|
|
1078
|
+
if (currentKey !== lockKey) {
|
|
1079
|
+
lockKey = currentKey;
|
|
1080
|
+
return RETRY_KEY;
|
|
1081
|
+
}
|
|
1082
|
+
const { absolutePath, relativePath } = inLock;
|
|
1083
|
+
// Delete file
|
|
1084
|
+
await fs.unlink(absolutePath);
|
|
1085
|
+
// Remove from the LIVE index — lookup and mutation in one synchronous
|
|
1086
|
+
// window, since a concurrent rebuild may swap instances across awaits.
|
|
1087
|
+
const liveIndex = this._idIndex;
|
|
1088
|
+
const id = liveIndex.findByPath(relativePath);
|
|
1089
|
+
if (id) {
|
|
1090
|
+
liveIndex.remove(id);
|
|
1091
|
+
}
|
|
1092
|
+
await this.recordOwnMutation(liveIndex);
|
|
1093
|
+
return undefined;
|
|
1094
|
+
});
|
|
1095
|
+
if (outcome !== RETRY_KEY)
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
// Ten completed foreign mutations landed in our acquisition gaps in a
|
|
1099
|
+
// row — treat as contention and let the caller reload + retry.
|
|
1100
|
+
throw new ContentConflictError();
|
|
1101
|
+
});
|
|
920
1102
|
}
|
|
921
1103
|
/**
|
|
922
1104
|
* Rename an entry by changing its slug (middle segment of filename).
|
|
@@ -976,102 +1158,105 @@ export class ContentStore {
|
|
|
976
1158
|
// On either mismatch, release and retry under the corrected keys rather
|
|
977
1159
|
// than renaming the wrong entry under the stale lock -- bounded to rule
|
|
978
1160
|
// out only pathological scheduling.
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
const
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1161
|
+
// [SYNC-C1] See write()'s call to withContentWriteExclusion.
|
|
1162
|
+
return this.withContentWriteExclusion(async () => {
|
|
1163
|
+
const RETRY_KEY = Symbol('retry-with-new-lock-key');
|
|
1164
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
1165
|
+
const outcome = await withLocks([sourceLockKey, destLockKey], async () => {
|
|
1166
|
+
// Re-resolve inside the lock: ground truth after acquisition.
|
|
1167
|
+
const inLock = await this.buildPaths(collection, currentSlug);
|
|
1168
|
+
if (!inLock.existed) {
|
|
1169
|
+
// Entry genuinely isn't at this slug anymore (e.g. deleted) --
|
|
1170
|
+
// matches the original access()-based NOT_FOUND behavior.
|
|
1171
|
+
throw new ContentStoreError(`Entry not found: ${currentSlug}`, 'NOT_FOUND');
|
|
1172
|
+
}
|
|
1173
|
+
const currentSourceKey = this.entryLockKey(collection, currentSlug, inLock);
|
|
1174
|
+
// The dest create-key is purely slug-derived and can't actually
|
|
1175
|
+
// change across attempts, but recompute for uniformity with the
|
|
1176
|
+
// source side.
|
|
1177
|
+
const currentDestKey = this.createLockKey(collection, safeNewSlug);
|
|
1178
|
+
if (currentSourceKey !== sourceLockKey || inLock.id !== sourceId) {
|
|
1179
|
+
sourceLockKey = currentSourceKey;
|
|
1180
|
+
destLockKey = currentDestKey;
|
|
1181
|
+
sourceId = inLock.id;
|
|
1182
|
+
return RETRY_KEY;
|
|
1183
|
+
}
|
|
996
1184
|
destLockKey = currentDestKey;
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
const existingSlug = extractSlugFromFilename(entry.name, entryTypeName);
|
|
1023
|
-
if (existingSlug === safeNewSlug) {
|
|
1024
|
-
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, 'VALIDATION');
|
|
1185
|
+
const { absolutePath: currentPath, relativePath: currentRelPath } = inLock;
|
|
1186
|
+
// Extract entry type name and extension from current filename
|
|
1187
|
+
const currentFilename = path.basename(currentPath);
|
|
1188
|
+
const parts = currentFilename.split('.');
|
|
1189
|
+
if (parts.length < 4) {
|
|
1190
|
+
throw new ContentStoreError(`Invalid entry filename format: ${currentFilename}`, 'VALIDATION');
|
|
1191
|
+
}
|
|
1192
|
+
const entryTypeName = parts[0];
|
|
1193
|
+
const contentId = parts[parts.length - 2];
|
|
1194
|
+
const ext = `.${parts[parts.length - 1]}`;
|
|
1195
|
+
// Build new filename with new slug
|
|
1196
|
+
const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
|
|
1197
|
+
const parentDir = path.dirname(currentPath);
|
|
1198
|
+
const newPath = path.join(parentDir, newFilename);
|
|
1199
|
+
// Check if any file with the new slug already exists (regardless of ID)
|
|
1200
|
+
// This catches same-slug-different-ID conflicts that link() alone cannot prevent
|
|
1201
|
+
try {
|
|
1202
|
+
const entries = await fs.readdir(parentDir, { withFileTypes: true });
|
|
1203
|
+
for (const entry of entries) {
|
|
1204
|
+
if (entry.isDirectory())
|
|
1205
|
+
continue;
|
|
1206
|
+
const existingSlug = extractSlugFromFilename(entry.name, entryTypeName);
|
|
1207
|
+
if (existingSlug === safeNewSlug) {
|
|
1208
|
+
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, 'VALIDATION');
|
|
1209
|
+
}
|
|
1025
1210
|
}
|
|
1026
1211
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1212
|
+
catch (err) {
|
|
1213
|
+
if (err instanceof ContentStoreError)
|
|
1214
|
+
throw err;
|
|
1215
|
+
// Ignore filesystem errors (e.g. ENOENT if parent dir doesn't exist)
|
|
1216
|
+
}
|
|
1217
|
+
// Use link()+unlink() instead of rename() so a concurrent cross-process rename to the
|
|
1218
|
+
// exact same destination path fails with EEXIST rather than silently overwriting.
|
|
1219
|
+
//
|
|
1220
|
+
// Tradeoff: this is a two-step operation, not a single atomic syscall. If unlink()
|
|
1221
|
+
// fails after a successful link() (e.g. a transient EFS error), both the old and new
|
|
1222
|
+
// slug files will exist pointing at the same inode. The ID index will reflect the new
|
|
1223
|
+
// path, so subsequent reads work, but the orphaned source file will persist until the
|
|
1224
|
+
// next rename or deletion of that entry. This is an acceptable tradeoff: the EEXIST
|
|
1225
|
+
// protection on link() prevents silent data loss on concurrent renames, and the
|
|
1226
|
+
// partial-failure case is detectable and recoverable. Note: write()/delete()/
|
|
1227
|
+
// renameEntry() now lock on content ID (see idLockKey()), so a concurrent write() or
|
|
1228
|
+
// delete() targeting this same entry is fully serialized against this rename and
|
|
1229
|
+
// cannot observe this partial-failure window; only a genuinely separate process
|
|
1230
|
+
// acting directly on the filesystem without going through this store could.
|
|
1231
|
+
try {
|
|
1232
|
+
await fs.link(currentPath, newPath);
|
|
1233
|
+
}
|
|
1234
|
+
catch (err) {
|
|
1235
|
+
if (isNodeError(err) && err.code === 'EEXIST') {
|
|
1236
|
+
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, 'VALIDATION');
|
|
1237
|
+
}
|
|
1030
1238
|
throw err;
|
|
1031
|
-
// Ignore filesystem errors (e.g. ENOENT if parent dir doesn't exist)
|
|
1032
|
-
}
|
|
1033
|
-
// Use link()+unlink() instead of rename() so a concurrent cross-process rename to the
|
|
1034
|
-
// exact same destination path fails with EEXIST rather than silently overwriting.
|
|
1035
|
-
//
|
|
1036
|
-
// Tradeoff: this is a two-step operation, not a single atomic syscall. If unlink()
|
|
1037
|
-
// fails after a successful link() (e.g. a transient EFS error), both the old and new
|
|
1038
|
-
// slug files will exist pointing at the same inode. The ID index will reflect the new
|
|
1039
|
-
// path, so subsequent reads work, but the orphaned source file will persist until the
|
|
1040
|
-
// next rename or deletion of that entry. This is an acceptable tradeoff: the EEXIST
|
|
1041
|
-
// protection on link() prevents silent data loss on concurrent renames, and the
|
|
1042
|
-
// partial-failure case is detectable and recoverable. Note: write()/delete()/
|
|
1043
|
-
// renameEntry() now lock on content ID (see idLockKey()), so a concurrent write() or
|
|
1044
|
-
// delete() targeting this same entry is fully serialized against this rename and
|
|
1045
|
-
// cannot observe this partial-failure window; only a genuinely separate process
|
|
1046
|
-
// acting directly on the filesystem without going through this store could.
|
|
1047
|
-
try {
|
|
1048
|
-
await fs.link(currentPath, newPath);
|
|
1049
|
-
}
|
|
1050
|
-
catch (err) {
|
|
1051
|
-
if (isNodeError(err) && err.code === 'EEXIST') {
|
|
1052
|
-
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, 'VALIDATION');
|
|
1053
1239
|
}
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
throw new ContentConflictError();
|
|
1240
|
+
await fs.unlink(currentPath);
|
|
1241
|
+
// Update the LIVE index — lookup and mutation in one synchronous window,
|
|
1242
|
+
// since a concurrent rebuild may swap instances across awaits.
|
|
1243
|
+
const newRelativePath = path.relative(this.root, newPath);
|
|
1244
|
+
const liveIndex = this._idIndex;
|
|
1245
|
+
const entryId = liveIndex.findByPath(currentRelPath);
|
|
1246
|
+
if (entryId) {
|
|
1247
|
+
liveIndex.updatePath(entryId, newRelativePath);
|
|
1248
|
+
}
|
|
1249
|
+
await this.recordOwnMutation(liveIndex);
|
|
1250
|
+
// Return new logical path
|
|
1251
|
+
return { newPath: `${collectionPath}/${safeNewSlug}` };
|
|
1252
|
+
});
|
|
1253
|
+
if (typeof outcome !== 'symbol')
|
|
1254
|
+
return outcome;
|
|
1255
|
+
}
|
|
1256
|
+
// Ten completed foreign mutations landed in our acquisition gaps in a
|
|
1257
|
+
// row — treat as contention and let the caller reload + retry.
|
|
1258
|
+
throw new ContentConflictError();
|
|
1259
|
+
});
|
|
1075
1260
|
}
|
|
1076
1261
|
/**
|
|
1077
1262
|
* List all entries in a collection tree (including subcollections).
|