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.
Files changed (184) hide show
  1. package/dist/ai/json-to-markdown.js +1 -2
  2. package/dist/ai/json-to-markdown.js.map +1 -1
  3. package/dist/api/__test__/mock-client.d.ts +5 -1
  4. package/dist/api/__test__/mock-client.d.ts.map +1 -1
  5. package/dist/api/__test__/mock-client.js +9 -2
  6. package/dist/api/__test__/mock-client.js.map +1 -1
  7. package/dist/api/admin-branch-health.d.ts +36 -1
  8. package/dist/api/admin-branch-health.d.ts.map +1 -1
  9. package/dist/api/admin-branch-health.js +176 -2
  10. package/dist/api/admin-branch-health.js.map +1 -1
  11. package/dist/api/admin.d.ts +8 -1
  12. package/dist/api/admin.d.ts.map +1 -1
  13. package/dist/api/admin.js.map +1 -1
  14. package/dist/api/branch.d.ts.map +1 -1
  15. package/dist/api/branch.js +16 -2
  16. package/dist/api/branch.js.map +1 -1
  17. package/dist/api/client.d.ts +5 -1
  18. package/dist/api/client.d.ts.map +1 -1
  19. package/dist/api/client.js +6 -0
  20. package/dist/api/client.js.map +1 -1
  21. package/dist/api/comments.d.ts +2 -2
  22. package/dist/api/content.d.ts +11 -5
  23. package/dist/api/content.d.ts.map +1 -1
  24. package/dist/api/content.js +79 -6
  25. package/dist/api/content.js.map +1 -1
  26. package/dist/api/entries.d.ts +10 -0
  27. package/dist/api/entries.d.ts.map +1 -1
  28. package/dist/api/entries.js +40 -15
  29. package/dist/api/entries.js.map +1 -1
  30. package/dist/api/schema.d.ts +4 -4
  31. package/dist/api/schema.d.ts.map +1 -1
  32. package/dist/api/schema.js +13 -13
  33. package/dist/api/schema.js.map +1 -1
  34. package/dist/assets/transform.d.ts.map +1 -1
  35. package/dist/assets/transform.js +19 -6
  36. package/dist/assets/transform.js.map +1 -1
  37. package/dist/branch-health.d.ts +13 -0
  38. package/dist/branch-health.d.ts.map +1 -1
  39. package/dist/branch-health.js +23 -0
  40. package/dist/branch-health.js.map +1 -1
  41. package/dist/cli/cli.js +858 -537
  42. package/dist/cli/generate-ai-content.js +760 -455
  43. package/dist/cli/init.js +25 -7
  44. package/dist/cli/template-files/Dockerfile.cms.template +19 -1
  45. package/dist/cli/template-files/canopycms.config.ts.template +7 -0
  46. package/dist/cli/template-files/cms-stack.ts.template +7 -1
  47. package/dist/client.d.ts +1 -0
  48. package/dist/client.d.ts.map +1 -1
  49. package/dist/client.js.map +1 -1
  50. package/dist/config/schemas/field.d.ts +15 -15
  51. package/dist/config/types.d.ts +2 -2
  52. package/dist/config/types.d.ts.map +1 -1
  53. package/dist/config/types.js +0 -1
  54. package/dist/config/types.js.map +1 -1
  55. package/dist/config/validation.d.ts +9 -0
  56. package/dist/config/validation.d.ts.map +1 -1
  57. package/dist/config/validation.js +14 -0
  58. package/dist/config/validation.js.map +1 -1
  59. package/dist/content-id-index.d.ts +97 -5
  60. package/dist/content-id-index.d.ts.map +1 -1
  61. package/dist/content-id-index.js +170 -18
  62. package/dist/content-id-index.js.map +1 -1
  63. package/dist/content-store.d.ts +81 -7
  64. package/dist/content-store.d.ts.map +1 -1
  65. package/dist/content-store.js +443 -258
  66. package/dist/content-store.js.map +1 -1
  67. package/dist/editor/BranchManager.d.ts.map +1 -1
  68. package/dist/editor/BranchManager.js +18 -6
  69. package/dist/editor/BranchManager.js.map +1 -1
  70. package/dist/editor/CanopyEditor.d.ts.map +1 -1
  71. package/dist/editor/CanopyEditor.js +2 -2
  72. package/dist/editor/CanopyEditor.js.map +1 -1
  73. package/dist/editor/CanopyEditorPage.d.ts +9 -1
  74. package/dist/editor/CanopyEditorPage.d.ts.map +1 -1
  75. package/dist/editor/CanopyEditorPage.js +9 -2
  76. package/dist/editor/CanopyEditorPage.js.map +1 -1
  77. package/dist/editor/Editor.d.ts +9 -1
  78. package/dist/editor/Editor.d.ts.map +1 -1
  79. package/dist/editor/Editor.js +58 -24
  80. package/dist/editor/Editor.js.map +1 -1
  81. package/dist/editor/FormRenderer.d.ts.map +1 -1
  82. package/dist/editor/FormRenderer.js +12 -0
  83. package/dist/editor/FormRenderer.js.map +1 -1
  84. package/dist/editor/admin/SystemHealthPanel.js +6 -2
  85. package/dist/editor/admin/SystemHealthPanel.js.map +1 -1
  86. package/dist/editor/components/EntryCreateModal.d.ts +9 -1
  87. package/dist/editor/components/EntryCreateModal.d.ts.map +1 -1
  88. package/dist/editor/components/EntryCreateModal.js +4 -1
  89. package/dist/editor/components/EntryCreateModal.js.map +1 -1
  90. package/dist/editor/fields/DateTimeField.d.ts +46 -0
  91. package/dist/editor/fields/DateTimeField.d.ts.map +1 -0
  92. package/dist/editor/fields/DateTimeField.js +66 -0
  93. package/dist/editor/fields/DateTimeField.js.map +1 -0
  94. package/dist/editor/fields/NumberField.d.ts +34 -0
  95. package/dist/editor/fields/NumberField.d.ts.map +1 -0
  96. package/dist/editor/fields/NumberField.js +53 -0
  97. package/dist/editor/fields/NumberField.js.map +1 -0
  98. package/dist/editor/fields/NumberListField.d.ts +25 -0
  99. package/dist/editor/fields/NumberListField.d.ts.map +1 -0
  100. package/dist/editor/fields/NumberListField.js +53 -0
  101. package/dist/editor/fields/NumberListField.js.map +1 -0
  102. package/dist/editor/hooks/useBranchManager.d.ts.map +1 -1
  103. package/dist/editor/hooks/useBranchManager.js +115 -26
  104. package/dist/editor/hooks/useBranchManager.js.map +1 -1
  105. package/dist/editor/hooks/useDraftManager.d.ts +8 -0
  106. package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
  107. package/dist/editor/hooks/useDraftManager.js +261 -21
  108. package/dist/editor/hooks/useDraftManager.js.map +1 -1
  109. package/dist/editor/hooks/useEntryManager.d.ts +21 -0
  110. package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
  111. package/dist/editor/hooks/useEntryManager.js +25 -2
  112. package/dist/editor/hooks/useEntryManager.js.map +1 -1
  113. package/dist/editor/theme.d.ts +8 -8
  114. package/dist/git-manager.d.ts +51 -7
  115. package/dist/git-manager.d.ts.map +1 -1
  116. package/dist/git-manager.js +105 -14
  117. package/dist/git-manager.js.map +1 -1
  118. package/dist/http/handler.d.ts.map +1 -1
  119. package/dist/http/handler.js +70 -27
  120. package/dist/http/handler.js.map +1 -1
  121. package/dist/http/router.d.ts.map +1 -1
  122. package/dist/http/router.js +40 -3
  123. package/dist/http/router.js.map +1 -1
  124. package/dist/operating-mode/deployment-name-fixtures.d.ts +29 -0
  125. package/dist/operating-mode/deployment-name-fixtures.d.ts.map +1 -0
  126. package/dist/operating-mode/deployment-name-fixtures.js +55 -0
  127. package/dist/operating-mode/deployment-name-fixtures.js.map +1 -0
  128. package/dist/operating-mode/deployment-name.d.ts +9 -0
  129. package/dist/operating-mode/deployment-name.d.ts.map +1 -1
  130. package/dist/operating-mode/deployment-name.js +9 -1
  131. package/dist/operating-mode/deployment-name.js.map +1 -1
  132. package/dist/operating-mode/index.d.ts +2 -1
  133. package/dist/operating-mode/index.d.ts.map +1 -1
  134. package/dist/operating-mode/index.js +4 -1
  135. package/dist/operating-mode/index.js.map +1 -1
  136. package/dist/operating-mode/mode-env.d.ts +58 -0
  137. package/dist/operating-mode/mode-env.d.ts.map +1 -0
  138. package/dist/operating-mode/mode-env.js +97 -0
  139. package/dist/operating-mode/mode-env.js.map +1 -0
  140. package/dist/resolve-canopy-user.d.ts +34 -0
  141. package/dist/resolve-canopy-user.d.ts.map +1 -0
  142. package/dist/resolve-canopy-user.js +55 -0
  143. package/dist/resolve-canopy-user.js.map +1 -0
  144. package/dist/schema/schema-store.d.ts +53 -2
  145. package/dist/schema/schema-store.d.ts.map +1 -1
  146. package/dist/schema/schema-store.js +92 -21
  147. package/dist/schema/schema-store.js.map +1 -1
  148. package/dist/server.d.ts +9 -0
  149. package/dist/server.d.ts.map +1 -1
  150. package/dist/server.js +9 -0
  151. package/dist/server.js.map +1 -1
  152. package/dist/services.d.ts.map +1 -1
  153. package/dist/services.js +40 -6
  154. package/dist/services.js.map +1 -1
  155. package/dist/settings-workspace.d.ts +40 -0
  156. package/dist/settings-workspace.d.ts.map +1 -1
  157. package/dist/settings-workspace.js +139 -151
  158. package/dist/settings-workspace.js.map +1 -1
  159. package/dist/types.d.ts +10 -0
  160. package/dist/types.d.ts.map +1 -1
  161. package/dist/utils/content-write-lock.d.ts +127 -0
  162. package/dist/utils/content-write-lock.d.ts.map +1 -0
  163. package/dist/utils/content-write-lock.js +170 -0
  164. package/dist/utils/content-write-lock.js.map +1 -0
  165. package/dist/utils/error.d.ts +1 -1
  166. package/dist/utils/error.js +1 -1
  167. package/dist/utils/git.d.ts +16 -0
  168. package/dist/utils/git.d.ts.map +1 -1
  169. package/dist/utils/git.js +22 -0
  170. package/dist/utils/git.js.map +1 -1
  171. package/dist/utils/sanitize-href.d.ts +28 -3
  172. package/dist/utils/sanitize-href.d.ts.map +1 -1
  173. package/dist/utils/sanitize-href.js +64 -6
  174. package/dist/utils/sanitize-href.js.map +1 -1
  175. package/dist/validation/entry-link-validator.d.ts +1 -1
  176. package/dist/validation/entry-link-validator.js +3 -3
  177. package/dist/validation/entry-link-validator.js.map +1 -1
  178. package/dist/validation/entry-validator.js +1 -1
  179. package/dist/validation/entry-validator.js.map +1 -1
  180. package/dist/worker/cms-worker.d.ts +13 -0
  181. package/dist/worker/cms-worker.d.ts.map +1 -1
  182. package/dist/worker/cms-worker.js +326 -264
  183. package/dist/worker/cms-worker.js.map +1 -1
  184. package/package.json +2 -2
@@ -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('Content was modified by another editor');
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
- * Find the file in `dir` whose filename embeds `id`.
226
- * Returns its root-relative path, or null if no such file (or no such dir).
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 findEntryPathById(dir, id) {
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 null;
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
- return path.relative(this.root, path.join(dir, entry.name));
340
+ matches.push(path.relative(this.root, path.join(dir, entry.name)));
243
341
  }
244
342
  }
245
- return null;
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
- const RETRY_KEY = Symbol('retry-with-new-lock-key');
613
- for (let attempt = 0; attempt < 10; attempt++) {
614
- const outcome = await withLock(lockKey, async () => {
615
- // Re-resolve inside the lock: ground truth after acquisition. A
616
- // concurrent renameEntry() may have moved this entry between the
617
- // pre-pass above and acquiring this lock.
618
- const inLock = await this.buildPaths(schemaItem, slug, {
619
- entryTypeName,
620
- existingId,
621
- });
622
- const currentKey = this.entryLockKey(schemaItem, slug, inLock);
623
- if (currentKey !== lockKey) {
624
- lockKey = currentKey;
625
- return RETRY_KEY;
626
- }
627
- const { absolutePath, relativePath, id } = inLock;
628
- await fs.mkdir(path.dirname(absolutePath), { recursive: true });
629
- // OCC: if caller supplied a version token, reject stale writes
630
- if (input.expectedVersion !== undefined) {
631
- try {
632
- const existing = await fs.stat(absolutePath);
633
- if (existing.mtimeMs !== input.expectedVersion) {
634
- throw new ContentConflictError();
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
- catch (err) {
638
- if (err instanceof ContentConflictError)
639
- throw err;
640
- if (isNodeError(err) && err.code === 'ENOENT') {
641
- // File doesn't exist yet — first write, skip version check
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
- else {
644
- throw err;
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
- // Existence guard (cross-process): the caller asserts this entry already
649
- // exists (existingId), so if no file is at the path we are about to
650
- // write, this store's index may be stale — another process may have
651
- // renamed the entry. Recreating a renamed entry's old path would leave
652
- // two files with the same embedded ID and poison every subsequent index
653
- // rebuild (ID collision). The directory listing is authoritative on this
654
- // host: if the ID's actual on-disk location differs from what our index
655
- // believes, fail with a conflict so the caller reloads fresh state.
656
- // (An intentional slug-change save passes: the index and the directory
657
- // agree on the entry's current — old-slug — path. External deletes also
658
- // pass: the ID is nowhere on disk, so recreating is last-writer-wins.)
659
- //
660
- // indexedRelPath reads the LIVE index synchronously — NOT idIndex() —
661
- // because idIndex() would run a full rescan while holding the entry
662
- // lock if invalidateIndex() fired between the pre-lock warm-up above
663
- // and here. The live index may then be stale, but staleness only errs
664
- // toward throwing ContentConflictError: actualRelPath comes from the
665
- // fresh in-lock directory scan just below (ground truth), so a stale
666
- // indexedRelPath can only turn agree->disagree (spurious conflict,
667
- // which the caller already handles by reloading), never
668
- // disagree->agree. Fail-closed, never fail-open.
669
- if (existingId && !(await filePathExists(absolutePath))) {
670
- const actualRelPath = await this.findEntryPathById(path.dirname(absolutePath), existingId);
671
- const indexedRelPath = this._idIndex.findById(existingId)?.relativePath ?? null;
672
- if (actualRelPath !== null && actualRelPath !== indexedRelPath) {
673
- throw new ContentConflictError();
674
- }
675
- }
676
- // Serialize content string
677
- let content;
678
- if (input.format === 'json') {
679
- content = `${JSON.stringify(input.data ?? {}, null, 2)}\n`;
680
- }
681
- else if (input.format === 'yaml') {
682
- content = yamlStringify(input.data ?? {});
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
- liveIndex.add({
705
- type: 'entry',
706
- relativePath,
707
- collection: collectionPath,
708
- slug: slug || undefined,
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
- if (staleOldAbsPath) {
713
- await fs.unlink(staleOldAbsPath).catch((err) => {
714
- if (!isNodeError(err) || err.code !== 'ENOENT')
715
- throw err;
716
- });
717
- }
718
- await this.recordOwnMutation(liveIndex);
719
- const afterStat = await fs.stat(absolutePath);
720
- const base = {
721
- collection: schemaItem.logicalPath,
722
- collectionName: schemaItem.name,
723
- relativePath,
724
- absolutePath,
725
- version: afterStat.mtimeMs,
726
- };
727
- if (input.format === 'json') {
728
- return { ...base, format: 'json', data: input.data ?? {} };
729
- }
730
- if (input.format === 'yaml') {
731
- return { ...base, format: 'yaml', data: input.data ?? {} };
732
- }
733
- return {
734
- ...base,
735
- format: input.format,
736
- data: input.data ?? {},
737
- body: input.body,
738
- bodyFieldName: findBodyFieldName(fields),
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
- const RETRY_KEY = Symbol('retry-with-new-lock-key');
887
- for (let attempt = 0; attempt < 10; attempt++) {
888
- const outcome = await withLock(lockKey, async () => {
889
- // Re-resolve inside the lock: ground truth after acquisition. If a
890
- // concurrent renameEntry() moved this slug away in the meantime,
891
- // buildPaths() no longer finds it here (inLock.existed is false) --
892
- // the entry genuinely isn't at this slug anymore, so we fall through
893
- // to the unlink below on a freshly generated (nonexistent) path,
894
- // preserving the original ENOENT-throwing behavior.
895
- const inLock = await this.buildPaths(collection, slug);
896
- const currentKey = this.entryLockKey(collection, slug, inLock);
897
- if (currentKey !== lockKey) {
898
- lockKey = currentKey;
899
- return RETRY_KEY;
900
- }
901
- const { absolutePath, relativePath } = inLock;
902
- // Delete file
903
- await fs.unlink(absolutePath);
904
- // Remove from the LIVE index — lookup and mutation in one synchronous
905
- // window, since a concurrent rebuild may swap instances across awaits.
906
- const liveIndex = this._idIndex;
907
- const id = liveIndex.findByPath(relativePath);
908
- if (id) {
909
- liveIndex.remove(id);
910
- }
911
- await this.recordOwnMutation(liveIndex);
912
- return undefined;
913
- });
914
- if (outcome !== RETRY_KEY)
915
- return;
916
- }
917
- // Ten completed foreign mutations landed in our acquisition gaps in a
918
- // row — treat as contention and let the caller reload + retry.
919
- throw new ContentConflictError();
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
- const RETRY_KEY = Symbol('retry-with-new-lock-key');
980
- for (let attempt = 0; attempt < 10; attempt++) {
981
- const outcome = await withLocks([sourceLockKey, destLockKey], async () => {
982
- // Re-resolve inside the lock: ground truth after acquisition.
983
- const inLock = await this.buildPaths(collection, currentSlug);
984
- if (!inLock.existed) {
985
- // Entry genuinely isn't at this slug anymore (e.g. deleted) --
986
- // matches the original access()-based NOT_FOUND behavior.
987
- throw new ContentStoreError(`Entry not found: ${currentSlug}`, 'NOT_FOUND');
988
- }
989
- const currentSourceKey = this.entryLockKey(collection, currentSlug, inLock);
990
- // The dest create-key is purely slug-derived and can't actually
991
- // change across attempts, but recompute for uniformity with the
992
- // source side.
993
- const currentDestKey = this.createLockKey(collection, safeNewSlug);
994
- if (currentSourceKey !== sourceLockKey || inLock.id !== sourceId) {
995
- sourceLockKey = currentSourceKey;
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
- sourceId = inLock.id;
998
- return RETRY_KEY;
999
- }
1000
- destLockKey = currentDestKey;
1001
- const { absolutePath: currentPath, relativePath: currentRelPath } = inLock;
1002
- // Extract entry type name and extension from current filename
1003
- const currentFilename = path.basename(currentPath);
1004
- const parts = currentFilename.split('.');
1005
- if (parts.length < 4) {
1006
- throw new ContentStoreError(`Invalid entry filename format: ${currentFilename}`, 'VALIDATION');
1007
- }
1008
- const entryTypeName = parts[0];
1009
- const contentId = parts[parts.length - 2];
1010
- const ext = `.${parts[parts.length - 1]}`;
1011
- // Build new filename with new slug
1012
- const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
1013
- const parentDir = path.dirname(currentPath);
1014
- const newPath = path.join(parentDir, newFilename);
1015
- // Check if any file with the new slug already exists (regardless of ID)
1016
- // This catches same-slug-different-ID conflicts that link() alone cannot prevent
1017
- try {
1018
- const entries = await fs.readdir(parentDir, { withFileTypes: true });
1019
- for (const entry of entries) {
1020
- if (entry.isDirectory())
1021
- continue;
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
- catch (err) {
1029
- if (err instanceof ContentStoreError)
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
- throw err;
1055
- }
1056
- await fs.unlink(currentPath);
1057
- // Update the LIVE index — lookup and mutation in one synchronous window,
1058
- // since a concurrent rebuild may swap instances across awaits.
1059
- const newRelativePath = path.relative(this.root, newPath);
1060
- const liveIndex = this._idIndex;
1061
- const entryId = liveIndex.findByPath(currentRelPath);
1062
- if (entryId) {
1063
- liveIndex.updatePath(entryId, newRelativePath);
1064
- }
1065
- await this.recordOwnMutation(liveIndex);
1066
- // Return new logical path
1067
- return { newPath: `${collectionPath}/${safeNewSlug}` };
1068
- });
1069
- if (typeof outcome !== 'symbol')
1070
- return outcome;
1071
- }
1072
- // Ten completed foreign mutations landed in our acquisition gaps in a
1073
- // row — treat as contention and let the caller reload + retry.
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).