canopycms 0.0.56 → 0.0.58

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 (272) hide show
  1. package/dist/ai/json-to-markdown.js +74 -3
  2. package/dist/ai/json-to-markdown.js.map +1 -1
  3. package/dist/api/__test__/mock-client.d.ts +9 -5
  4. package/dist/api/__test__/mock-client.d.ts.map +1 -1
  5. package/dist/api/__test__/mock-client.js +15 -7
  6. package/dist/api/__test__/mock-client.js.map +1 -1
  7. package/dist/api/assets.d.ts +100 -36
  8. package/dist/api/assets.d.ts.map +1 -1
  9. package/dist/api/assets.js +338 -51
  10. package/dist/api/assets.js.map +1 -1
  11. package/dist/api/branch.d.ts +6 -0
  12. package/dist/api/branch.d.ts.map +1 -1
  13. package/dist/api/branch.js +63 -19
  14. package/dist/api/branch.js.map +1 -1
  15. package/dist/api/client.d.ts +16 -5
  16. package/dist/api/client.d.ts.map +1 -1
  17. package/dist/api/client.js +24 -6
  18. package/dist/api/client.js.map +1 -1
  19. package/dist/api/content.d.ts +2 -2
  20. package/dist/api/entries.d.ts +4 -4
  21. package/dist/api/entries.d.ts.map +1 -1
  22. package/dist/api/entries.js +10 -2
  23. package/dist/api/entries.js.map +1 -1
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/index.d.ts.map +1 -1
  26. package/dist/api/index.js.map +1 -1
  27. package/dist/api/route-builder.d.ts +9 -0
  28. package/dist/api/route-builder.d.ts.map +1 -1
  29. package/dist/api/route-builder.js +1 -0
  30. package/dist/api/route-builder.js.map +1 -1
  31. package/dist/api/schema.d.ts +4 -4
  32. package/dist/api/types.d.ts +11 -1
  33. package/dist/api/types.d.ts.map +1 -1
  34. package/dist/assets/asset-prefixes.d.ts +31 -0
  35. package/dist/assets/asset-prefixes.d.ts.map +1 -0
  36. package/dist/assets/asset-prefixes.js +20 -0
  37. package/dist/assets/asset-prefixes.js.map +1 -0
  38. package/dist/assets/asset-src.d.ts +23 -0
  39. package/dist/assets/asset-src.d.ts.map +1 -0
  40. package/dist/assets/asset-src.js +28 -0
  41. package/dist/assets/asset-src.js.map +1 -0
  42. package/dist/assets/asset-url.d.ts +35 -0
  43. package/dist/assets/asset-url.d.ts.map +1 -0
  44. package/dist/assets/asset-url.js +77 -0
  45. package/dist/assets/asset-url.js.map +1 -0
  46. package/dist/assets/factory.d.ts +18 -0
  47. package/dist/assets/factory.d.ts.map +1 -0
  48. package/dist/assets/factory.js +42 -0
  49. package/dist/assets/factory.js.map +1 -0
  50. package/dist/assets/finalize.d.ts +36 -0
  51. package/dist/assets/finalize.d.ts.map +1 -0
  52. package/dist/assets/finalize.js +72 -0
  53. package/dist/assets/finalize.js.map +1 -0
  54. package/dist/assets/index.d.ts +20 -0
  55. package/dist/assets/index.d.ts.map +1 -0
  56. package/dist/assets/index.js +19 -0
  57. package/dist/assets/index.js.map +1 -0
  58. package/dist/assets/keys.d.ts +56 -0
  59. package/dist/assets/keys.d.ts.map +1 -0
  60. package/dist/assets/keys.js +88 -0
  61. package/dist/assets/keys.js.map +1 -0
  62. package/dist/assets/pipeline.d.ts +50 -0
  63. package/dist/assets/pipeline.d.ts.map +1 -0
  64. package/dist/assets/pipeline.js +202 -0
  65. package/dist/assets/pipeline.js.map +1 -0
  66. package/dist/assets/store-local.d.ts +84 -0
  67. package/dist/assets/store-local.d.ts.map +1 -0
  68. package/dist/assets/store-local.js +254 -0
  69. package/dist/assets/store-local.js.map +1 -0
  70. package/dist/assets/store-s3.d.ts +71 -0
  71. package/dist/assets/store-s3.d.ts.map +1 -0
  72. package/dist/assets/store-s3.js +183 -0
  73. package/dist/assets/store-s3.js.map +1 -0
  74. package/dist/assets/svg-sanitizer.d.ts +37 -0
  75. package/dist/assets/svg-sanitizer.d.ts.map +1 -0
  76. package/dist/assets/svg-sanitizer.js +222 -0
  77. package/dist/assets/svg-sanitizer.js.map +1 -0
  78. package/dist/assets/transform-directives.d.ts +97 -0
  79. package/dist/assets/transform-directives.d.ts.map +1 -0
  80. package/dist/assets/transform-directives.js +267 -0
  81. package/dist/assets/transform-directives.js.map +1 -0
  82. package/dist/assets/transform.d.ts +51 -0
  83. package/dist/assets/transform.d.ts.map +1 -0
  84. package/dist/assets/transform.js +190 -0
  85. package/dist/assets/transform.js.map +1 -0
  86. package/dist/assets/types.d.ts +82 -0
  87. package/dist/assets/types.d.ts.map +1 -0
  88. package/dist/assets/types.js +10 -0
  89. package/dist/assets/types.js.map +1 -0
  90. package/dist/authorization/groups/schema.d.ts +2 -2
  91. package/dist/authorization/permissions/schema.d.ts +2 -2
  92. package/dist/branch-metadata.d.ts +71 -6
  93. package/dist/branch-metadata.d.ts.map +1 -1
  94. package/dist/branch-metadata.js +132 -116
  95. package/dist/branch-metadata.js.map +1 -1
  96. package/dist/branch-registry.d.ts +78 -9
  97. package/dist/branch-registry.d.ts.map +1 -1
  98. package/dist/branch-registry.js +155 -29
  99. package/dist/branch-registry.js.map +1 -1
  100. package/dist/branch-schema-cache.d.ts +126 -11
  101. package/dist/branch-schema-cache.d.ts.map +1 -1
  102. package/dist/branch-schema-cache.js +208 -58
  103. package/dist/branch-schema-cache.js.map +1 -1
  104. package/dist/cli/generate-ai-content.js +1993 -1506
  105. package/dist/cli/init.js +16 -4
  106. package/dist/cli/migrate.d.ts.map +1 -1
  107. package/dist/cli/migrate.js +25 -0
  108. package/dist/cli/migrate.js.map +1 -1
  109. package/dist/cli/sync.js +5 -5
  110. package/dist/cli/sync.js.map +1 -1
  111. package/dist/comment-store.d.ts +45 -15
  112. package/dist/comment-store.d.ts.map +1 -1
  113. package/dist/comment-store.js +80 -101
  114. package/dist/comment-store.js.map +1 -1
  115. package/dist/config/helpers.d.ts.map +1 -1
  116. package/dist/config/helpers.js +2 -1
  117. package/dist/config/helpers.js.map +1 -1
  118. package/dist/config/index.d.ts +2 -2
  119. package/dist/config/index.d.ts.map +1 -1
  120. package/dist/config/index.js +1 -1
  121. package/dist/config/index.js.map +1 -1
  122. package/dist/config/schemas/config.d.ts +10 -0
  123. package/dist/config/schemas/config.d.ts.map +1 -1
  124. package/dist/config/schemas/field.d.ts +38 -3
  125. package/dist/config/schemas/field.d.ts.map +1 -1
  126. package/dist/config/schemas/field.js +18 -0
  127. package/dist/config/schemas/field.js.map +1 -1
  128. package/dist/config/schemas/media.d.ts +8 -0
  129. package/dist/config/schemas/media.d.ts.map +1 -1
  130. package/dist/config/schemas/media.js +9 -0
  131. package/dist/config/schemas/media.js.map +1 -1
  132. package/dist/config/types.d.ts +43 -3
  133. package/dist/config/types.d.ts.map +1 -1
  134. package/dist/config/types.js +4 -1
  135. package/dist/config/types.js.map +1 -1
  136. package/dist/content-index-generation.d.ts +40 -4
  137. package/dist/content-index-generation.d.ts.map +1 -1
  138. package/dist/content-index-generation.js +70 -73
  139. package/dist/content-index-generation.js.map +1 -1
  140. package/dist/content-store.d.ts +63 -0
  141. package/dist/content-store.d.ts.map +1 -1
  142. package/dist/content-store.js +421 -194
  143. package/dist/content-store.js.map +1 -1
  144. package/dist/editor/CanopyEditor.d.ts.map +1 -1
  145. package/dist/editor/CanopyEditor.js +1 -1
  146. package/dist/editor/CanopyEditor.js.map +1 -1
  147. package/dist/editor/Editor.d.ts +2 -0
  148. package/dist/editor/Editor.d.ts.map +1 -1
  149. package/dist/editor/Editor.js +82 -80
  150. package/dist/editor/Editor.js.map +1 -1
  151. package/dist/editor/FormRenderer.d.ts.map +1 -1
  152. package/dist/editor/FormRenderer.js +9 -0
  153. package/dist/editor/FormRenderer.js.map +1 -1
  154. package/dist/editor/components/EditorSidebar.d.ts +6 -1
  155. package/dist/editor/components/EditorSidebar.d.ts.map +1 -1
  156. package/dist/editor/components/EditorSidebar.js +4 -3
  157. package/dist/editor/components/EditorSidebar.js.map +1 -1
  158. package/dist/editor/context/AssetContext.d.ts +25 -0
  159. package/dist/editor/context/AssetContext.d.ts.map +1 -0
  160. package/dist/editor/context/AssetContext.js +26 -0
  161. package/dist/editor/context/AssetContext.js.map +1 -0
  162. package/dist/editor/context/index.d.ts +1 -0
  163. package/dist/editor/context/index.d.ts.map +1 -1
  164. package/dist/editor/context/index.js +1 -0
  165. package/dist/editor/context/index.js.map +1 -1
  166. package/dist/editor/fields/ImageField.d.ts +22 -0
  167. package/dist/editor/fields/ImageField.d.ts.map +1 -0
  168. package/dist/editor/fields/ImageField.js +100 -0
  169. package/dist/editor/fields/ImageField.js.map +1 -0
  170. package/dist/editor/fields/MarkdownField.d.ts.map +1 -1
  171. package/dist/editor/fields/MarkdownField.js +30 -4
  172. package/dist/editor/fields/MarkdownField.js.map +1 -1
  173. package/dist/editor/fields/MdxImageDialog.d.ts +16 -0
  174. package/dist/editor/fields/MdxImageDialog.d.ts.map +1 -0
  175. package/dist/editor/fields/MdxImageDialog.js +73 -0
  176. package/dist/editor/fields/MdxImageDialog.js.map +1 -0
  177. package/dist/editor/media/AssetCard.d.ts +21 -0
  178. package/dist/editor/media/AssetCard.d.ts.map +1 -0
  179. package/dist/editor/media/AssetCard.js +41 -0
  180. package/dist/editor/media/AssetCard.js.map +1 -0
  181. package/dist/editor/media/CropStep.d.ts +26 -0
  182. package/dist/editor/media/CropStep.d.ts.map +1 -0
  183. package/dist/editor/media/CropStep.js +38 -0
  184. package/dist/editor/media/CropStep.js.map +1 -0
  185. package/dist/editor/media/MediaLibrary.d.ts +22 -0
  186. package/dist/editor/media/MediaLibrary.d.ts.map +1 -0
  187. package/dist/editor/media/MediaLibrary.js +17 -0
  188. package/dist/editor/media/MediaLibrary.js.map +1 -0
  189. package/dist/editor/media/MediaLibraryBody.d.ts +17 -0
  190. package/dist/editor/media/MediaLibraryBody.d.ts.map +1 -0
  191. package/dist/editor/media/MediaLibraryBody.js +133 -0
  192. package/dist/editor/media/MediaLibraryBody.js.map +1 -0
  193. package/dist/editor/media/crop-math.d.ts +37 -0
  194. package/dist/editor/media/crop-math.d.ts.map +1 -0
  195. package/dist/editor/media/crop-math.js +62 -0
  196. package/dist/editor/media/crop-math.js.map +1 -0
  197. package/dist/editor/media/upload-asset.d.ts +31 -0
  198. package/dist/editor/media/upload-asset.d.ts.map +1 -0
  199. package/dist/editor/media/upload-asset.js +56 -0
  200. package/dist/editor/media/upload-asset.js.map +1 -0
  201. package/dist/editor/media/upload-constants.d.ts +27 -0
  202. package/dist/editor/media/upload-constants.d.ts.map +1 -0
  203. package/dist/editor/media/upload-constants.js +40 -0
  204. package/dist/editor/media/upload-constants.js.map +1 -0
  205. package/dist/editor/media/useAssetUpload.d.ts +19 -0
  206. package/dist/editor/media/useAssetUpload.d.ts.map +1 -0
  207. package/dist/editor/media/useAssetUpload.js +53 -0
  208. package/dist/editor/media/useAssetUpload.js.map +1 -0
  209. package/dist/editor/media/xhr-upload.d.ts +20 -0
  210. package/dist/editor/media/xhr-upload.d.ts.map +1 -0
  211. package/dist/editor/media/xhr-upload.js +42 -0
  212. package/dist/editor/media/xhr-upload.js.map +1 -0
  213. package/dist/editor/theme.d.ts +2 -1
  214. package/dist/editor/theme.d.ts.map +1 -1
  215. package/dist/editor/theme.js +1 -0
  216. package/dist/editor/theme.js.map +1 -1
  217. package/dist/entry-schema.d.ts +19 -0
  218. package/dist/entry-schema.d.ts.map +1 -1
  219. package/dist/entry-schema.js.map +1 -1
  220. package/dist/git-manager.d.ts +15 -10
  221. package/dist/git-manager.d.ts.map +1 -1
  222. package/dist/git-manager.js +16 -11
  223. package/dist/git-manager.js.map +1 -1
  224. package/dist/http/handler.d.ts +5 -2
  225. package/dist/http/handler.d.ts.map +1 -1
  226. package/dist/http/handler.js +13 -4
  227. package/dist/http/handler.js.map +1 -1
  228. package/dist/http/index.d.ts +2 -2
  229. package/dist/http/index.d.ts.map +1 -1
  230. package/dist/http/index.js +1 -1
  231. package/dist/http/index.js.map +1 -1
  232. package/dist/http/router.d.ts +14 -1
  233. package/dist/http/router.d.ts.map +1 -1
  234. package/dist/http/router.js +4 -1
  235. package/dist/http/router.js.map +1 -1
  236. package/dist/http/types.d.ts +40 -0
  237. package/dist/http/types.d.ts.map +1 -1
  238. package/dist/http/types.js +9 -0
  239. package/dist/http/types.js.map +1 -1
  240. package/dist/index.d.ts +3 -0
  241. package/dist/index.d.ts.map +1 -1
  242. package/dist/index.js +4 -0
  243. package/dist/index.js.map +1 -1
  244. package/dist/resource-generation.d.ts +54 -0
  245. package/dist/resource-generation.d.ts.map +1 -0
  246. package/dist/resource-generation.js +156 -0
  247. package/dist/resource-generation.js.map +1 -0
  248. package/dist/schema/schema-store.d.ts +31 -4
  249. package/dist/schema/schema-store.d.ts.map +1 -1
  250. package/dist/schema/schema-store.js +47 -8
  251. package/dist/schema/schema-store.js.map +1 -1
  252. package/dist/server.d.ts +45 -0
  253. package/dist/server.d.ts.map +1 -1
  254. package/dist/server.js +41 -0
  255. package/dist/server.js.map +1 -1
  256. package/dist/sync-core.js +2 -2
  257. package/dist/sync-core.js.map +1 -1
  258. package/dist/utils/occ-json-write.d.ts +100 -0
  259. package/dist/utils/occ-json-write.d.ts.map +1 -0
  260. package/dist/utils/occ-json-write.js +234 -0
  261. package/dist/utils/occ-json-write.js.map +1 -0
  262. package/dist/validation/entry-validator.d.ts.map +1 -1
  263. package/dist/validation/entry-validator.js +56 -1
  264. package/dist/validation/entry-validator.js.map +1 -1
  265. package/dist/worker/cms-worker.d.ts.map +1 -1
  266. package/dist/worker/cms-worker.js +21 -3
  267. package/dist/worker/cms-worker.js.map +1 -1
  268. package/package.json +13 -1
  269. package/dist/asset-store.d.ts +0 -32
  270. package/dist/asset-store.d.ts.map +0 -1
  271. package/dist/asset-store.js +0 -85
  272. package/dist/asset-store.js.map +0 -1
@@ -13,6 +13,23 @@ import { isNodeError } from './utils/error.js';
13
13
  import { filePathExists } from './utils/fs.js';
14
14
  import { asRecord, getFormatExtension } from './utils/format.js';
15
15
  import { normalizeFilesystemPath, } from './paths/index.js';
16
+ /**
17
+ * Acquire multiple lock keys (via withLock) in a canonical (sorted) order
18
+ * before running `fn`, to rule out AB-BA deadlocks between callers that need
19
+ * overlapping key sets.
20
+ *
21
+ * Only renameEntry() needs two keys today (the source entry's ID lock, plus
22
+ * the destination slug's create-lock -- see ContentStore.createLockKey()).
23
+ * The `id:` and `create:` keyspaces never share a literal string (disjoint
24
+ * prefixes), so two renameEntry() calls can never contend for the exact same
25
+ * pair of keys in reversed roles -- but sorting costs nothing and is cheap
26
+ * insurance against that changing later.
27
+ */
28
+ async function withLocks(keys, fn) {
29
+ const sorted = Array.from(new Set(keys)).sort();
30
+ const acquireFrom = (index) => index === sorted.length ? fn() : withLock(sorted[index], () => acquireFrom(index + 1));
31
+ return acquireFrom(0);
32
+ }
16
33
  export class ContentStoreError extends Error {
17
34
  constructor(message, code) {
18
35
  super(message);
@@ -248,6 +265,62 @@ export class ContentStore {
248
265
  }
249
266
  return item;
250
267
  }
268
+ /**
269
+ * Lock key for an existing entry, addressed by its permanent content ID
270
+ * (stable across renames -- the whole point of this locking scheme; see
271
+ * .claude/future-tasks/resolved/content-store-lock-key.md). Namespaced by store
272
+ * root: the same content ID exists in every clone of a branch, and one
273
+ * process can hold ContentStore instances on several clones (dev
274
+ * workspace roots, prod branch clones) at once, so the root prefix keeps
275
+ * the keyspace scoped per-store. It also keeps this ID keyspace disjoint
276
+ * from other modules' raw-path-keyed locks sharing the same withLock()
277
+ * map (comment-store, branch-metadata, etc).
278
+ */
279
+ idLockKey(id) {
280
+ return `${this.root}:id:${id}`;
281
+ }
282
+ /**
283
+ * Stable collection+slug identifier for lock-key namespacing. Mirrors the
284
+ * schemaItem resolution buildPaths() performs for entry-type delegation:
285
+ * an entry-type item delegates to its parent collection with the slug
286
+ * defaulting to the entry type's own name (see buildPaths()'s
287
+ * `schemaItem.type === 'entry-type'` branch).
288
+ */
289
+ collectionSlugKey(schemaItem, slug) {
290
+ if (schemaItem.type === 'entry-type') {
291
+ return `${schemaItem.parentPath}/${slug || schemaItem.name}`;
292
+ }
293
+ return `${schemaItem.logicalPath}/${slug}`;
294
+ }
295
+ /**
296
+ * Lock key for a not-yet-existing entry (a create). Serializes concurrent
297
+ * same-slug creates WITHIN this process: the second call's in-lock
298
+ * buildPaths() re-resolution will find the first call's just-written file
299
+ * and fold in as an edit (see write()). A concurrent create racing from a
300
+ * DIFFERENT process is NOT covered by this in-process mutex -- accepted
301
+ * per the epic's design review: each writer mints its own fresh ID and
302
+ * writes its own distinct filename, so both writes succeed and the result
303
+ * is two same-slug files with different IDs (a slug-uniqueness violation
304
+ * surfaced on the next listing/lookup), never a duplicate-ID collision
305
+ * that would poison index rebuilds.
306
+ */
307
+ createLockKey(schemaItem, slug) {
308
+ return `${this.root}:create:${this.collectionSlugKey(schemaItem, slug)}`;
309
+ }
310
+ /**
311
+ * Lock key for a write()/delete() pre-pass classification. Existing
312
+ * entries lock on their content ID (idLockKey()). Legacy entries with no
313
+ * embedded ID (pre-ID-era filenames -- renameEntry() already refuses to
314
+ * touch these, via its four-part filename check, so there is no rename
315
+ * race to guard against for them) fall back to the physical path, matching
316
+ * this store's original locking behavior for that narrow case. Not-yet-
317
+ * existing entries lock on a per-slug create-key (createLockKey()).
318
+ */
319
+ entryLockKey(schemaItem, slug, prePass) {
320
+ if (!prePass.existed)
321
+ return this.createLockKey(schemaItem, slug);
322
+ return prePass.id ? this.idLockKey(prePass.id) : prePass.absolutePath;
323
+ }
251
324
  /**
252
325
  * Build absolute and relative paths with security validation.
253
326
  * All entries use the unified filename pattern: {type}.{slug}.{id}.{ext}
@@ -323,6 +396,7 @@ export class ContentStore {
323
396
  let id = options.existingId;
324
397
  let existingFilename;
325
398
  let existingEntryType;
399
+ let foundExisting = false;
326
400
  if (!id) {
327
401
  // Try to find existing file with this slug
328
402
  const entries = await fs.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
@@ -340,8 +414,14 @@ export class ContentStore {
340
414
  existingFilename = existingFile.name;
341
415
  // Extract and preserve entry type from existing file (immutable after creation)
342
416
  existingEntryType = extractEntryTypeFromFilename(existingFile.name) || undefined;
417
+ foundExisting = true;
343
418
  }
344
419
  }
420
+ // An entry "existed" if the directory scan above found it, OR the
421
+ // caller asserted an existingId (a presumed edit -- see buildPaths()'s
422
+ // doc comment on options.existingId). Not the same as `id` being
423
+ // truthy: a brand-new entry gets a freshly generated id below too.
424
+ const existed = foundExisting || Boolean(options.existingId);
345
425
  // For existing entries, preserve the entry type (immutable after creation)
346
426
  // For new entries, use the specified entry type
347
427
  const finalEntryTypeName = existingEntryType || entryTypeName;
@@ -373,6 +453,7 @@ export class ContentStore {
373
453
  relativePath: path.relative(this.root, resolved),
374
454
  id,
375
455
  entryTypeName: finalEntryTypeName,
456
+ existed,
376
457
  };
377
458
  }
378
459
  throw new ContentStoreError('Invalid schema item type', 'VALIDATION');
@@ -505,125 +586,163 @@ export class ContentStore {
505
586
  if (expectedFormat !== input.format) {
506
587
  throw new ContentStoreError(`Format mismatch: expects ${expectedFormat}, got ${input.format}`, 'VALIDATION');
507
588
  }
508
- // Resolve paths outside the lock so validation errors surface immediately
509
- const { absolutePath, relativePath, id } = await this.buildPaths(schemaItem, slug, {
510
- entryTypeName,
511
- existingId,
512
- });
513
- return withLock(absolutePath, async () => {
514
- await fs.mkdir(path.dirname(absolutePath), { recursive: true });
515
- // OCC: if caller supplied a version token, reject stale writes
516
- if (input.expectedVersion !== undefined) {
517
- try {
518
- const existing = await fs.stat(absolutePath);
519
- if (existing.mtimeMs !== input.expectedVersion) {
589
+ // Pre-pass: resolve paths OUTSIDE the lock, but ONLY to classify
590
+ // existing-vs-new and pick a stable lock key -- never used for the
591
+ // actual write. This trades away the old "validation errors surface
592
+ // before the lock" property for buildPaths() specifically (the format
593
+ // check above still runs unlocked): buildPaths() resolves the physical
594
+ // path by directory-scanning for the slug, and that resolution can go
595
+ // stale the instant a concurrent renameEntry() completes, so it must be
596
+ // re-resolved (ground truth) after the lock is held -- see
597
+ // .claude/future-tasks/resolved/content-store-lock-key.md and entryLockKey().
598
+ const prePass = await this.buildPaths(schemaItem, slug, { entryTypeName, existingId });
599
+ let lockKey = this.entryLockKey(schemaItem, slug, prePass);
600
+ // Reclassification loop: the pre-pass picked the lock key, but the world
601
+ // can change between the pre-pass and lock acquisition (a concurrent
602
+ // renameEntry() freeing this slug flips existing->new; a concurrent
603
+ // create landing flips new->existing). Writing under the WRONG kind of
604
+ // key would bypass serialization against the writers holding the right
605
+ // one (e.g. a reclassified-to-create write under an id-key racing a
606
+ // create-key holder on the same slug -> two same-slug files). So after
607
+ // the in-lock re-resolution, re-derive the key; on mismatch, release and
608
+ // re-acquire under the current key. Bounded: each flip requires another
609
+ // mutator to have completed in the gap; the cap only guards pathological
610
+ // scheduling.
611
+ const RETRY_KEY = Symbol('retry-with-new-lock-key');
612
+ for (let attempt = 0; attempt < 10; attempt++) {
613
+ const outcome = await withLock(lockKey, async () => {
614
+ // Re-resolve inside the lock: ground truth after acquisition. A
615
+ // concurrent renameEntry() may have moved this entry between the
616
+ // pre-pass above and acquiring this lock.
617
+ const inLock = await this.buildPaths(schemaItem, slug, {
618
+ entryTypeName,
619
+ existingId,
620
+ });
621
+ const currentKey = this.entryLockKey(schemaItem, slug, inLock);
622
+ if (currentKey !== lockKey) {
623
+ lockKey = currentKey;
624
+ return RETRY_KEY;
625
+ }
626
+ const { absolutePath, relativePath, id } = inLock;
627
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
628
+ // OCC: if caller supplied a version token, reject stale writes
629
+ if (input.expectedVersion !== undefined) {
630
+ try {
631
+ const existing = await fs.stat(absolutePath);
632
+ if (existing.mtimeMs !== input.expectedVersion) {
633
+ throw new ContentConflictError();
634
+ }
635
+ }
636
+ catch (err) {
637
+ if (err instanceof ContentConflictError)
638
+ throw err;
639
+ if (isNodeError(err) && err.code === 'ENOENT') {
640
+ // File doesn't exist yet — first write, skip version check
641
+ }
642
+ else {
643
+ throw err;
644
+ }
645
+ }
646
+ }
647
+ // Existence guard (cross-process): the caller asserts this entry already
648
+ // exists (existingId), so if no file is at the path we are about to
649
+ // write, this store's index may be stale — another process may have
650
+ // renamed the entry. Recreating a renamed entry's old path would leave
651
+ // two files with the same embedded ID and poison every subsequent index
652
+ // rebuild (ID collision). The directory listing is authoritative on this
653
+ // host: if the ID's actual on-disk location differs from what our index
654
+ // believes, fail with a conflict so the caller reloads fresh state.
655
+ // (An intentional slug-change save passes: the index and the directory
656
+ // agree on the entry's current — old-slug — path. External deletes also
657
+ // pass: the ID is nowhere on disk, so recreating is last-writer-wins.)
658
+ //
659
+ // indexedRelPath reads the LIVE index synchronously — NOT idIndex() —
660
+ // because idIndex() would run a full rescan while holding the entry
661
+ // lock if invalidateIndex() fired between the pre-lock warm-up above
662
+ // and here. The live index may then be stale, but staleness only errs
663
+ // toward throwing ContentConflictError: actualRelPath comes from the
664
+ // fresh in-lock directory scan just below (ground truth), so a stale
665
+ // indexedRelPath can only turn agree->disagree (spurious conflict,
666
+ // which the caller already handles by reloading), never
667
+ // disagree->agree. Fail-closed, never fail-open.
668
+ if (existingId && !(await filePathExists(absolutePath))) {
669
+ const actualRelPath = await this.findEntryPathById(path.dirname(absolutePath), existingId);
670
+ const indexedRelPath = this._idIndex.findById(existingId)?.relativePath ?? null;
671
+ if (actualRelPath !== null && actualRelPath !== indexedRelPath) {
520
672
  throw new ContentConflictError();
521
673
  }
522
674
  }
523
- catch (err) {
524
- if (err instanceof ContentConflictError)
525
- throw err;
526
- if (isNodeError(err) && err.code === 'ENOENT') {
527
- // File doesn't exist yet — first write, skip version check
675
+ // Serialize content string
676
+ let content;
677
+ if (input.format === 'json') {
678
+ content = `${JSON.stringify(input.data ?? {}, null, 2)}\n`;
679
+ }
680
+ else if (input.format === 'yaml') {
681
+ content = yamlStringify(input.data ?? {});
682
+ }
683
+ else {
684
+ content = matter.stringify(input.body, input.data ?? {});
685
+ }
686
+ await atomicWriteFile(absolutePath, content);
687
+ // Update the ID index after a successful write. Look up and mutate the
688
+ // LIVE index in one synchronous window (no awaits in between): a
689
+ // concurrent rebuild may have swapped in a fresh instance since the
690
+ // pre-write snapshot, and updates must land where future lookups go.
691
+ const liveIndex = this._idIndex;
692
+ let staleOldAbsPath = null;
693
+ if (id) {
694
+ const existing = liveIndex.findById(id);
695
+ if (existing) {
696
+ if (existing.relativePath !== relativePath) {
697
+ // Slug changed — remember the orphaned old path to delete below
698
+ staleOldAbsPath = path.join(this.root, existing.relativePath);
699
+ liveIndex.updatePath(existing.id, relativePath);
700
+ }
528
701
  }
529
702
  else {
530
- throw err;
703
+ liveIndex.add({
704
+ type: 'entry',
705
+ relativePath,
706
+ collection: collectionPath,
707
+ slug: slug || undefined,
708
+ });
531
709
  }
532
710
  }
533
- }
534
- // Existence guard (cross-process): the caller asserts this entry already
535
- // exists (existingId), so if no file is at the path we are about to
536
- // write, this store's index may be stale — another process may have
537
- // renamed the entry. Recreating a renamed entry's old path would leave
538
- // two files with the same embedded ID and poison every subsequent index
539
- // rebuild (ID collision). The directory listing is authoritative on this
540
- // host: if the ID's actual on-disk location differs from what our index
541
- // believes, fail with a conflict so the caller reloads fresh state.
542
- // (An intentional slug-change save passes: the index and the directory
543
- // agree on the entry's current — old-slug — path. External deletes also
544
- // pass: the ID is nowhere on disk, so recreating is last-writer-wins.)
545
- //
546
- // indexedRelPath reads the LIVE index synchronously — NOT idIndex() —
547
- // because idIndex() would run a full rescan while holding the entry
548
- // lock if invalidateIndex() fired between the pre-lock warm-up above
549
- // and here. The live index may then be stale, but staleness only errs
550
- // toward throwing ContentConflictError: actualRelPath comes from the
551
- // fresh in-lock directory scan just below (ground truth), so a stale
552
- // indexedRelPath can only turn agree->disagree (spurious conflict,
553
- // which the caller already handles by reloading), never
554
- // disagree->agree. Fail-closed, never fail-open.
555
- if (existingId && !(await filePathExists(absolutePath))) {
556
- const actualRelPath = await this.findEntryPathById(path.dirname(absolutePath), existingId);
557
- const indexedRelPath = this._idIndex.findById(existingId)?.relativePath ?? null;
558
- if (actualRelPath !== null && actualRelPath !== indexedRelPath) {
559
- throw new ContentConflictError();
711
+ if (staleOldAbsPath) {
712
+ await fs.unlink(staleOldAbsPath).catch((err) => {
713
+ if (!isNodeError(err) || err.code !== 'ENOENT')
714
+ throw err;
715
+ });
560
716
  }
561
- }
562
- // Serialize content string
563
- let content;
564
- if (input.format === 'json') {
565
- content = `${JSON.stringify(input.data ?? {}, null, 2)}\n`;
566
- }
567
- else if (input.format === 'yaml') {
568
- content = yamlStringify(input.data ?? {});
569
- }
570
- else {
571
- content = matter.stringify(input.body, input.data ?? {});
572
- }
573
- await atomicWriteFile(absolutePath, content);
574
- // Update the ID index after a successful write. Look up and mutate the
575
- // LIVE index in one synchronous window (no awaits in between): a
576
- // concurrent rebuild may have swapped in a fresh instance since the
577
- // pre-write snapshot, and updates must land where future lookups go.
578
- const liveIndex = this._idIndex;
579
- let staleOldAbsPath = null;
580
- if (id) {
581
- const existing = liveIndex.findById(id);
582
- if (existing) {
583
- if (existing.relativePath !== relativePath) {
584
- // Slug changed — remember the orphaned old path to delete below
585
- staleOldAbsPath = path.join(this.root, existing.relativePath);
586
- liveIndex.updatePath(existing.id, relativePath);
587
- }
717
+ await this.recordOwnMutation(liveIndex);
718
+ const afterStat = await fs.stat(absolutePath);
719
+ const base = {
720
+ collection: schemaItem.logicalPath,
721
+ collectionName: schemaItem.name,
722
+ relativePath,
723
+ absolutePath,
724
+ version: afterStat.mtimeMs,
725
+ };
726
+ if (input.format === 'json') {
727
+ return { ...base, format: 'json', data: input.data ?? {} };
588
728
  }
589
- else {
590
- liveIndex.add({
591
- type: 'entry',
592
- relativePath,
593
- collection: collectionPath,
594
- slug: slug || undefined,
595
- });
729
+ if (input.format === 'yaml') {
730
+ return { ...base, format: 'yaml', data: input.data ?? {} };
596
731
  }
597
- }
598
- if (staleOldAbsPath) {
599
- await fs.unlink(staleOldAbsPath).catch((err) => {
600
- if (!isNodeError(err) || err.code !== 'ENOENT')
601
- throw err;
602
- });
603
- }
604
- await this.recordOwnMutation(liveIndex);
605
- const afterStat = await fs.stat(absolutePath);
606
- const base = {
607
- collection: schemaItem.logicalPath,
608
- collectionName: schemaItem.name,
609
- relativePath,
610
- absolutePath,
611
- version: afterStat.mtimeMs,
612
- };
613
- if (input.format === 'json') {
614
- return { ...base, format: 'json', data: input.data ?? {} };
615
- }
616
- if (input.format === 'yaml') {
617
- return { ...base, format: 'yaml', data: input.data ?? {} };
618
- }
619
- return {
620
- ...base,
621
- format: input.format,
622
- data: input.data ?? {},
623
- body: input.body,
624
- bodyFieldName: findBodyFieldName(fields),
625
- };
626
- });
732
+ return {
733
+ ...base,
734
+ format: input.format,
735
+ data: input.data ?? {},
736
+ body: input.body,
737
+ bodyFieldName: findBodyFieldName(fields),
738
+ };
739
+ });
740
+ if (typeof outcome !== 'symbol')
741
+ return outcome;
742
+ }
743
+ // Ten completed foreign mutations landed in our acquisition gaps in a
744
+ // row — treat as contention and let the caller reload + retry.
745
+ throw new ContentConflictError();
627
746
  }
628
747
  /**
629
748
  * Read an entry by its ID (UUID).
@@ -723,6 +842,18 @@ export class ContentStore {
723
842
  .catch(() => []);
724
843
  return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
725
844
  }
845
+ /**
846
+ * Test-only seam: awaited right after the pre-pass buildPaths() resolves
847
+ * in delete() and renameEntry(), before the lock key is used to acquire
848
+ * anything. No-op in production. A test subclass can override this to
849
+ * inject a controlled pause in that exact window, letting a test
850
+ * deterministically run OTHER mutations (which don't hold this call's
851
+ * not-yet-acquired lock) to completion before this call proceeds to
852
+ * acquire its (possibly now-stale) lock key -- see the "Deterministic
853
+ * interleavings" testing pattern in docs/concurrency.md and
854
+ * branch-registry.test.ts's `BlockingRegistry` for the same idiom.
855
+ */
856
+ async afterPrePassForTesting() { }
726
857
  /**
727
858
  * Delete an entry and remove it from the index.
728
859
  */
@@ -730,19 +861,61 @@ export class ContentStore {
730
861
  // Warm the index outside the lock (a full scan must not hold the entry lock)
731
862
  await this.idIndex();
732
863
  const collection = this.assertCollection(collectionPath);
733
- const { absolutePath, relativePath } = await this.buildPaths(collection, slug);
734
- return withLock(absolutePath, async () => {
735
- // Delete file
736
- await fs.unlink(absolutePath);
737
- // Remove from the LIVE index — lookup and mutation in one synchronous
738
- // window, since a concurrent rebuild may swap instances across awaits.
739
- const liveIndex = this._idIndex;
740
- const id = liveIndex.findByPath(relativePath);
741
- if (id) {
742
- liveIndex.remove(id);
743
- }
744
- await this.recordOwnMutation(liveIndex);
745
- });
864
+ // Pre-pass: classify existing-vs-already-gone via a directory scan
865
+ // (local ground truth) -- not this._idIndex, which can be stale in
866
+ // exactly the way this locking scheme guards against (a rename this
867
+ // store hasn't observed yet).
868
+ const prePass = await this.buildPaths(collection, slug);
869
+ if (!prePass.existed) {
870
+ // Nothing on disk for this slug -- no shared resource to lock on, and
871
+ // this matches the store's original behavior (fs.unlink throwing
872
+ // ENOENT on the pre-pass's freshly generated, necessarily-nonexistent
873
+ // path).
874
+ await fs.unlink(prePass.absolutePath);
875
+ return;
876
+ }
877
+ let lockKey = this.entryLockKey(collection, slug, prePass);
878
+ await this.afterPrePassForTesting();
879
+ // Reclassification loop, mirroring write()'s (see its doc comment): the
880
+ // pre-pass's lock key can go stale between resolution and acquisition --
881
+ // e.g. a concurrent renameEntry() moving this slug's entry away flips
882
+ // the key this slug would resolve to. Re-derive the key from the in-lock
883
+ // ground truth and retry under the corrected key on mismatch, bounded to
884
+ // rule out only pathological scheduling.
885
+ const RETRY_KEY = Symbol('retry-with-new-lock-key');
886
+ for (let attempt = 0; attempt < 10; attempt++) {
887
+ const outcome = await withLock(lockKey, async () => {
888
+ // Re-resolve inside the lock: ground truth after acquisition. If a
889
+ // concurrent renameEntry() moved this slug away in the meantime,
890
+ // buildPaths() no longer finds it here (inLock.existed is false) --
891
+ // the entry genuinely isn't at this slug anymore, so we fall through
892
+ // to the unlink below on a freshly generated (nonexistent) path,
893
+ // preserving the original ENOENT-throwing behavior.
894
+ const inLock = await this.buildPaths(collection, slug);
895
+ const currentKey = this.entryLockKey(collection, slug, inLock);
896
+ if (currentKey !== lockKey) {
897
+ lockKey = currentKey;
898
+ return RETRY_KEY;
899
+ }
900
+ const { absolutePath, relativePath } = inLock;
901
+ // Delete file
902
+ await fs.unlink(absolutePath);
903
+ // Remove from the LIVE index — lookup and mutation in one synchronous
904
+ // window, since a concurrent rebuild may swap instances across awaits.
905
+ const liveIndex = this._idIndex;
906
+ const id = liveIndex.findByPath(relativePath);
907
+ if (id) {
908
+ liveIndex.remove(id);
909
+ }
910
+ await this.recordOwnMutation(liveIndex);
911
+ return undefined;
912
+ });
913
+ if (outcome !== RETRY_KEY)
914
+ return;
915
+ }
916
+ // Ten completed foreign mutations landed in our acquisition gaps in a
917
+ // row — treat as contention and let the caller reload + retry.
918
+ throw new ContentConflictError();
746
919
  }
747
920
  /**
748
921
  * Rename an entry by changing its slug (middle segment of filename).
@@ -768,82 +941,136 @@ export class ContentStore {
768
941
  if (currentSlug === safeNewSlug) {
769
942
  return { newPath: `${collectionPath}/${currentSlug}` };
770
943
  }
771
- // Resolve source path outside the lock so validation errors surface immediately
772
- const { absolutePath: currentPath, relativePath: currentRelPath } = await this.buildPaths(collection, currentSlug);
773
- return withLock(currentPath, async () => {
774
- // Verify source exists (inside lock so we see the current state)
775
- try {
776
- await fs.access(currentPath);
777
- }
778
- catch {
779
- throw new ContentStoreError(`Entry not found: ${currentSlug}`, 'NOT_FOUND');
780
- }
781
- // Extract entry type name and extension from current filename
782
- const currentFilename = path.basename(currentPath);
783
- const parts = currentFilename.split('.');
784
- if (parts.length < 4) {
785
- throw new ContentStoreError(`Invalid entry filename format: ${currentFilename}`, 'VALIDATION');
786
- }
787
- const entryTypeName = parts[0];
788
- const contentId = parts[parts.length - 2];
789
- const ext = `.${parts[parts.length - 1]}`;
790
- // Build new filename with new slug
791
- const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
792
- const parentDir = path.dirname(currentPath);
793
- const newPath = path.join(parentDir, newFilename);
794
- // Check if any file with the new slug already exists (regardless of ID)
795
- // This catches same-slug-different-ID conflicts that link() alone cannot prevent
796
- try {
797
- const entries = await fs.readdir(parentDir, { withFileTypes: true });
798
- for (const entry of entries) {
799
- if (entry.isDirectory())
800
- continue;
801
- const existingSlug = extractSlugFromFilename(entry.name, entryTypeName);
802
- if (existingSlug === safeNewSlug) {
803
- throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, 'VALIDATION');
944
+ // Pre-pass: classify via a directory scan (local ground truth, not the
945
+ // index) and pick the source entry's stable lock key.
946
+ const prePass = await this.buildPaths(collection, currentSlug);
947
+ if (!prePass.existed) {
948
+ throw new ContentStoreError(`Entry not found: ${currentSlug}`, 'NOT_FOUND');
949
+ }
950
+ let sourceLockKey = this.entryLockKey(collection, currentSlug, prePass);
951
+ let sourceId = prePass.id;
952
+ // Also lock the destination create-key. Content ID is rename-invariant,
953
+ // so the SOURCE side only ever needs one lock -- but the DESTINATION
954
+ // slug can simultaneously be the target of a concurrent write() creating
955
+ // a brand-new entry there. Without this second lock, that write()'s
956
+ // in-lock buildPaths() re-resolution could observe the just-linked
957
+ // destination file and silently fold in as an "edit" of the renamed
958
+ // entry -- using the create's caller-requested entry type/schema against
959
+ // a file that's actually a different (renamed-in) entry. Acquiring both
960
+ // keys in canonical (sorted) order rules out AB-BA deadlocks; in
961
+ // practice the id: and create: keyspaces never share a literal key
962
+ // string (disjoint prefixes), so two renameEntry() calls can never
963
+ // contend for the exact same pair of keys in reversed roles, but sorting
964
+ // costs nothing.
965
+ let destLockKey = this.createLockKey(collection, safeNewSlug);
966
+ await this.afterPrePassForTesting();
967
+ // Reclassification loop, mirroring write()'s (see its doc comment): the
968
+ // pre-pass's source key can go stale between resolution and acquisition
969
+ // -- e.g. this entry was renamed away by another writer and a brand-new,
970
+ // different-ID entry landed at this same slug in the gap. Re-derive the
971
+ // source key from the in-lock ground truth AND verify the resolved
972
+ // file's embedded ID still matches the pre-pass ID (a path-based
973
+ // fallback key for legacy no-ID filenames can't otherwise distinguish
974
+ // "same file" from "a different legacy file now occupies this slug").
975
+ // On either mismatch, release and retry under the corrected keys rather
976
+ // than renaming the wrong entry under the stale lock -- bounded to rule
977
+ // out only pathological scheduling.
978
+ const RETRY_KEY = Symbol('retry-with-new-lock-key');
979
+ for (let attempt = 0; attempt < 10; attempt++) {
980
+ const outcome = await withLocks([sourceLockKey, destLockKey], async () => {
981
+ // Re-resolve inside the lock: ground truth after acquisition.
982
+ const inLock = await this.buildPaths(collection, currentSlug);
983
+ if (!inLock.existed) {
984
+ // Entry genuinely isn't at this slug anymore (e.g. deleted) --
985
+ // matches the original access()-based NOT_FOUND behavior.
986
+ throw new ContentStoreError(`Entry not found: ${currentSlug}`, 'NOT_FOUND');
987
+ }
988
+ const currentSourceKey = this.entryLockKey(collection, currentSlug, inLock);
989
+ // The dest create-key is purely slug-derived and can't actually
990
+ // change across attempts, but recompute for uniformity with the
991
+ // source side.
992
+ const currentDestKey = this.createLockKey(collection, safeNewSlug);
993
+ if (currentSourceKey !== sourceLockKey || inLock.id !== sourceId) {
994
+ sourceLockKey = currentSourceKey;
995
+ destLockKey = currentDestKey;
996
+ sourceId = inLock.id;
997
+ return RETRY_KEY;
998
+ }
999
+ destLockKey = currentDestKey;
1000
+ const { absolutePath: currentPath, relativePath: currentRelPath } = inLock;
1001
+ // Extract entry type name and extension from current filename
1002
+ const currentFilename = path.basename(currentPath);
1003
+ const parts = currentFilename.split('.');
1004
+ if (parts.length < 4) {
1005
+ throw new ContentStoreError(`Invalid entry filename format: ${currentFilename}`, 'VALIDATION');
1006
+ }
1007
+ const entryTypeName = parts[0];
1008
+ const contentId = parts[parts.length - 2];
1009
+ const ext = `.${parts[parts.length - 1]}`;
1010
+ // Build new filename with new slug
1011
+ const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
1012
+ const parentDir = path.dirname(currentPath);
1013
+ const newPath = path.join(parentDir, newFilename);
1014
+ // Check if any file with the new slug already exists (regardless of ID)
1015
+ // This catches same-slug-different-ID conflicts that link() alone cannot prevent
1016
+ try {
1017
+ const entries = await fs.readdir(parentDir, { withFileTypes: true });
1018
+ for (const entry of entries) {
1019
+ if (entry.isDirectory())
1020
+ continue;
1021
+ const existingSlug = extractSlugFromFilename(entry.name, entryTypeName);
1022
+ if (existingSlug === safeNewSlug) {
1023
+ throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, 'VALIDATION');
1024
+ }
804
1025
  }
805
1026
  }
806
- }
807
- catch (err) {
808
- if (err instanceof ContentStoreError)
1027
+ catch (err) {
1028
+ if (err instanceof ContentStoreError)
1029
+ throw err;
1030
+ // Ignore filesystem errors (e.g. ENOENT if parent dir doesn't exist)
1031
+ }
1032
+ // Use link()+unlink() instead of rename() so a concurrent cross-process rename to the
1033
+ // exact same destination path fails with EEXIST rather than silently overwriting.
1034
+ //
1035
+ // Tradeoff: this is a two-step operation, not a single atomic syscall. If unlink()
1036
+ // fails after a successful link() (e.g. a transient EFS error), both the old and new
1037
+ // slug files will exist pointing at the same inode. The ID index will reflect the new
1038
+ // path, so subsequent reads work, but the orphaned source file will persist until the
1039
+ // next rename or deletion of that entry. This is an acceptable tradeoff: the EEXIST
1040
+ // protection on link() prevents silent data loss on concurrent renames, and the
1041
+ // partial-failure case is detectable and recoverable. Note: write()/delete()/
1042
+ // renameEntry() now lock on content ID (see idLockKey()), so a concurrent write() or
1043
+ // delete() targeting this same entry is fully serialized against this rename and
1044
+ // cannot observe this partial-failure window; only a genuinely separate process
1045
+ // acting directly on the filesystem without going through this store could.
1046
+ try {
1047
+ await fs.link(currentPath, newPath);
1048
+ }
1049
+ catch (err) {
1050
+ if (isNodeError(err) && err.code === 'EEXIST') {
1051
+ throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, 'VALIDATION');
1052
+ }
809
1053
  throw err;
810
- // Ignore filesystem errors (e.g. ENOENT if parent dir doesn't exist)
811
- }
812
- // Use link()+unlink() instead of rename() so a concurrent cross-process rename to the
813
- // exact same destination path fails with EEXIST rather than silently overwriting.
814
- //
815
- // Tradeoff: this is a two-step operation, not a single atomic syscall. If unlink()
816
- // fails after a successful link() (e.g. a transient EFS error), both the old and new
817
- // slug files will exist pointing at the same inode. The ID index will reflect the new
818
- // path, so subsequent reads work, but the orphaned source file will persist until the
819
- // next rename or deletion of that entry. This is an acceptable tradeoff: the EEXIST
820
- // protection on link() prevents silent data loss on concurrent renames, and the
821
- // partial-failure case is detectable and recoverable. See also:
822
- // .claude/future-tasks/content-store-lock-key.md — when lock keys migrate to content
823
- // IDs, rename and write on the same entry will be fully serialized, further reducing
824
- // this window.
825
- try {
826
- await fs.link(currentPath, newPath);
827
- }
828
- catch (err) {
829
- if (isNodeError(err) && err.code === 'EEXIST') {
830
- throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, 'VALIDATION');
831
1054
  }
832
- throw err;
833
- }
834
- await fs.unlink(currentPath);
835
- // Update the LIVE index — lookup and mutation in one synchronous window,
836
- // since a concurrent rebuild may swap instances across awaits.
837
- const newRelativePath = path.relative(this.root, newPath);
838
- const liveIndex = this._idIndex;
839
- const entryId = liveIndex.findByPath(currentRelPath);
840
- if (entryId) {
841
- liveIndex.updatePath(entryId, newRelativePath);
842
- }
843
- await this.recordOwnMutation(liveIndex);
844
- // Return new logical path
845
- return { newPath: `${collectionPath}/${safeNewSlug}` };
846
- });
1055
+ await fs.unlink(currentPath);
1056
+ // Update the LIVE index — lookup and mutation in one synchronous window,
1057
+ // since a concurrent rebuild may swap instances across awaits.
1058
+ const newRelativePath = path.relative(this.root, newPath);
1059
+ const liveIndex = this._idIndex;
1060
+ const entryId = liveIndex.findByPath(currentRelPath);
1061
+ if (entryId) {
1062
+ liveIndex.updatePath(entryId, newRelativePath);
1063
+ }
1064
+ await this.recordOwnMutation(liveIndex);
1065
+ // Return new logical path
1066
+ return { newPath: `${collectionPath}/${safeNewSlug}` };
1067
+ });
1068
+ if (typeof outcome !== 'symbol')
1069
+ return outcome;
1070
+ }
1071
+ // Ten completed foreign mutations landed in our acquisition gaps in a
1072
+ // row — treat as contention and let the caller reload + retry.
1073
+ throw new ContentConflictError();
847
1074
  }
848
1075
  /**
849
1076
  * List all entries in a collection tree (including subcollections).