canopycms 0.0.67-int.90 → 0.0.67

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 (539) hide show
  1. package/README.md +30 -3
  2. package/dist/ai/generate.js +1 -27
  3. package/dist/ai/handler.d.ts +0 -4
  4. package/dist/ai/handler.js +0 -11
  5. package/dist/ai/index.d.ts +1 -8
  6. package/dist/ai/index.js +1 -8
  7. package/dist/ai/json-to-markdown.js +15 -58
  8. package/dist/ai/resolve-branch.d.ts +2 -7
  9. package/dist/ai/resolve-branch.js +2 -8
  10. package/dist/ai/strip-mdx.d.ts +5 -13
  11. package/dist/ai/strip-mdx.js +5 -17
  12. package/dist/ai/to-plain-text.d.ts +10 -28
  13. package/dist/ai/to-plain-text.js +10 -28
  14. package/dist/ai/transform-components.d.ts +2 -4
  15. package/dist/ai/transform-components.js +8 -19
  16. package/dist/ai/types.d.ts +5 -10
  17. package/dist/api/admin-branch-health.d.ts +4 -12
  18. package/dist/api/admin-branch-health.js +26 -66
  19. package/dist/api/admin.d.ts +4 -12
  20. package/dist/api/admin.js +10 -26
  21. package/dist/api/assets.d.ts +0 -25
  22. package/dist/api/assets.js +50 -91
  23. package/dist/api/branch-merge.d.ts +1 -1
  24. package/dist/api/branch-merge.js +0 -2
  25. package/dist/api/branch-review.d.ts +0 -8
  26. package/dist/api/branch-review.js +0 -13
  27. package/dist/api/branch-status.d.ts +0 -3
  28. package/dist/api/branch-status.js +8 -33
  29. package/dist/api/branch-withdraw.d.ts +0 -1
  30. package/dist/api/branch-withdraw.js +6 -9
  31. package/dist/api/branch.d.ts +7 -12
  32. package/dist/api/branch.js +7 -44
  33. package/dist/api/client.d.ts +71 -224
  34. package/dist/api/client.js +82 -233
  35. package/dist/api/comments.d.ts +2 -4
  36. package/dist/api/comments.js +0 -17
  37. package/dist/api/content.d.ts +0 -6
  38. package/dist/api/content.js +66 -142
  39. package/dist/api/entries-constants.d.ts +5 -9
  40. package/dist/api/entries-constants.js +5 -9
  41. package/dist/api/entries.d.ts +8 -13
  42. package/dist/api/entries.js +20 -51
  43. package/dist/api/github-sync.d.ts +2 -4
  44. package/dist/api/github-sync.js +4 -8
  45. package/dist/api/groups.d.ts +3 -0
  46. package/dist/api/groups.js +8 -21
  47. package/dist/api/guards.d.ts +4 -16
  48. package/dist/api/guards.js +3 -22
  49. package/dist/api/index.d.ts +4 -4
  50. package/dist/api/permissions.d.ts +2 -0
  51. package/dist/api/permissions.js +18 -40
  52. package/dist/api/reference-options.d.ts +0 -1
  53. package/dist/api/reference-options.js +3 -11
  54. package/dist/api/request-body-hash.d.ts +12 -23
  55. package/dist/api/request-body-hash.js +12 -23
  56. package/dist/api/resolve-references.d.ts +0 -1
  57. package/dist/api/resolve-references.js +13 -28
  58. package/dist/api/route-builder.d.ts +3 -7
  59. package/dist/api/route-builder.js +1 -1
  60. package/dist/api/routes.d.ts +12 -0
  61. package/dist/api/routes.js +47 -0
  62. package/dist/api/schema.d.ts +23 -33
  63. package/dist/api/schema.js +20 -74
  64. package/dist/api/settings-helpers.js +4 -5
  65. package/dist/api/types.d.ts +0 -5
  66. package/dist/api/user.js +0 -5
  67. package/dist/api/validators.d.ts +14 -52
  68. package/dist/api/validators.js +14 -52
  69. package/dist/assets/asset-prefixes.d.ts +4 -8
  70. package/dist/assets/asset-prefixes.js +4 -8
  71. package/dist/assets/asset-src.d.ts +5 -22
  72. package/dist/assets/asset-src.js +5 -21
  73. package/dist/assets/factory.d.ts +1 -2
  74. package/dist/assets/factory.js +1 -2
  75. package/dist/assets/finalize.d.ts +3 -1
  76. package/dist/assets/finalize.js +3 -1
  77. package/dist/assets/index.d.ts +3 -3
  78. package/dist/assets/index.js +1 -1
  79. package/dist/assets/keys.d.ts +5 -3
  80. package/dist/assets/keys.js +6 -3
  81. package/dist/assets/pipeline.d.ts +3 -2
  82. package/dist/assets/pipeline.js +22 -56
  83. package/dist/assets/sharp-loader.d.ts +2 -10
  84. package/dist/assets/sharp-loader.js +2 -10
  85. package/dist/assets/store-local.d.ts +1 -2
  86. package/dist/assets/store-local.js +1 -2
  87. package/dist/assets/store-s3.d.ts +3 -4
  88. package/dist/assets/svg-sanitizer.d.ts +6 -14
  89. package/dist/assets/svg-sanitizer.js +6 -14
  90. package/dist/assets/transform-directives.d.ts +3 -4
  91. package/dist/assets/transform-directives.js +1 -1
  92. package/dist/assets/transform.d.ts +19 -30
  93. package/dist/assets/transform.js +18 -31
  94. package/dist/auth/cache.d.ts +2 -3
  95. package/dist/auth/cache.js +2 -3
  96. package/dist/auth/caching-auth-plugin.d.ts +11 -22
  97. package/dist/auth/caching-auth-plugin.js +12 -14
  98. package/dist/auth/context-helpers.d.ts +6 -20
  99. package/dist/auth/context-helpers.js +5 -17
  100. package/dist/auth/file-based-auth-cache.d.ts +10 -21
  101. package/dist/auth/file-based-auth-cache.js +23 -51
  102. package/dist/auth/plugin.d.ts +29 -61
  103. package/dist/auth/plugin.js +9 -13
  104. package/dist/auth/types.d.ts +11 -20
  105. package/dist/authorization/branch.d.ts +30 -49
  106. package/dist/authorization/branch.js +16 -42
  107. package/dist/authorization/content.d.ts +11 -40
  108. package/dist/authorization/content.js +13 -45
  109. package/dist/authorization/groups/index.d.ts +0 -3
  110. package/dist/authorization/groups/index.js +0 -3
  111. package/dist/authorization/groups/loader.d.ts +14 -28
  112. package/dist/authorization/groups/loader.js +14 -38
  113. package/dist/authorization/groups/schema.d.ts +0 -6
  114. package/dist/authorization/groups/schema.js +4 -7
  115. package/dist/authorization/helpers.d.ts +9 -28
  116. package/dist/authorization/helpers.js +9 -28
  117. package/dist/authorization/index.d.ts +3 -25
  118. package/dist/authorization/index.js +3 -35
  119. package/dist/authorization/path.d.ts +6 -13
  120. package/dist/authorization/path.js +8 -25
  121. package/dist/authorization/permissions/index.d.ts +0 -3
  122. package/dist/authorization/permissions/index.js +0 -3
  123. package/dist/authorization/permissions/loader.d.ts +14 -27
  124. package/dist/authorization/permissions/loader.js +14 -32
  125. package/dist/authorization/permissions/schema.d.ts +1 -6
  126. package/dist/authorization/permissions/schema.js +6 -13
  127. package/dist/authorization/protected-branch.d.ts +36 -65
  128. package/dist/authorization/protected-branch.js +29 -41
  129. package/dist/authorization/settings-file-store.d.ts +38 -81
  130. package/dist/authorization/settings-file-store.js +38 -79
  131. package/dist/authorization/types.d.ts +4 -26
  132. package/dist/authorization/types.js +0 -6
  133. package/dist/authorization/validation.d.ts +1 -23
  134. package/dist/authorization/validation.js +1 -24
  135. package/dist/branch-health.d.ts +39 -67
  136. package/dist/branch-health.js +27 -43
  137. package/dist/branch-metadata-file.d.ts +16 -32
  138. package/dist/branch-metadata-file.js +13 -26
  139. package/dist/branch-metadata.d.ts +49 -80
  140. package/dist/branch-metadata.js +57 -87
  141. package/dist/branch-registry.d.ts +26 -64
  142. package/dist/branch-registry.js +44 -93
  143. package/dist/branch-schema-cache.d.ts +34 -124
  144. package/dist/branch-schema-cache.js +49 -155
  145. package/dist/branch-workspace.d.ts +4 -4
  146. package/dist/branch-workspace.js +15 -19
  147. package/dist/build/generate-ai-content.d.ts +1 -0
  148. package/dist/build/generate-ai-content.js +12 -25
  149. package/dist/build-canopy.d.ts +17 -27
  150. package/dist/build-canopy.js +16 -23
  151. package/dist/build-mode.d.ts +25 -27
  152. package/dist/build-mode.js +25 -29
  153. package/dist/cli/cli.d.ts +15 -26
  154. package/dist/cli/cli.js +601 -978
  155. package/dist/cli/generate-ai-content.js +351 -628
  156. package/dist/cli/github-app-manifest.d.ts +84 -0
  157. package/dist/cli/github-app-manifest.js +161 -0
  158. package/dist/cli/init-github-app.d.ts +110 -444
  159. package/dist/cli/init-github-app.js +236 -773
  160. package/dist/cli/init.js +39 -80
  161. package/dist/cli/migrate.d.ts +13 -10
  162. package/dist/cli/migrate.js +22 -30
  163. package/dist/cli/project-detect.d.ts +17 -31
  164. package/dist/cli/project-detect.js +31 -42
  165. package/dist/cli/project-root.d.ts +4 -11
  166. package/dist/cli/project-root.js +4 -11
  167. package/dist/cli/prompt.d.ts +10 -0
  168. package/dist/cli/prompt.js +67 -0
  169. package/dist/cli/sync.d.ts +0 -8
  170. package/dist/cli/sync.js +8 -28
  171. package/dist/cli/template-files/middleware.ts.template +15 -29
  172. package/dist/cli/templates.d.ts +2 -12
  173. package/dist/cli/templates.js +4 -4
  174. package/dist/client.d.ts +1 -1
  175. package/dist/comment-store.d.ts +32 -53
  176. package/dist/comment-store.js +32 -52
  177. package/dist/config/flatten.d.ts +0 -3
  178. package/dist/config/flatten.js +2 -12
  179. package/dist/config/helpers.js +2 -2
  180. package/dist/config/index.d.ts +2 -2
  181. package/dist/config/index.js +1 -4
  182. package/dist/config/schemas/collection.d.ts +0 -8
  183. package/dist/config/schemas/collection.js +0 -10
  184. package/dist/config/schemas/config.d.ts +6 -78
  185. package/dist/config/schemas/config.js +38 -54
  186. package/dist/config/schemas/field.d.ts +2 -242
  187. package/dist/config/schemas/field.js +9 -20
  188. package/dist/config/schemas/media.js +1 -1
  189. package/dist/config/schemas/permissions.js +0 -2
  190. package/dist/config/schemas/url.d.ts +5 -6
  191. package/dist/config/schemas/url.js +5 -6
  192. package/dist/config/types.d.ts +45 -33
  193. package/dist/config/types.js +0 -1
  194. package/dist/config.d.ts +1 -8
  195. package/dist/config.js +1 -9
  196. package/dist/content-id-index.d.ts +76 -188
  197. package/dist/content-id-index.js +79 -230
  198. package/dist/content-index-generation.d.ts +27 -48
  199. package/dist/content-index-generation.js +35 -84
  200. package/dist/content-index-registry.d.ts +15 -24
  201. package/dist/content-index-registry.js +5 -9
  202. package/dist/content-listing.d.ts +64 -107
  203. package/dist/content-listing.js +68 -118
  204. package/dist/content-reader.d.ts +20 -29
  205. package/dist/content-reader.js +10 -18
  206. package/dist/content-store.d.ts +210 -336
  207. package/dist/content-store.js +413 -657
  208. package/dist/content-tree.d.ts +44 -86
  209. package/dist/content-tree.js +26 -54
  210. package/dist/context.d.ts +67 -94
  211. package/dist/context.js +53 -72
  212. package/dist/dev-content-watcher.d.ts +18 -18
  213. package/dist/dev-content-watcher.js +41 -44
  214. package/dist/editor/BranchManager.d.ts +3 -6
  215. package/dist/editor/BranchManager.js +24 -44
  216. package/dist/editor/CanopyEditor.js +5 -2
  217. package/dist/editor/CommentsPanel.js +0 -2
  218. package/dist/editor/Editor.js +19 -76
  219. package/dist/editor/EditorAuthGate.d.ts +27 -0
  220. package/dist/editor/EditorAuthGate.js +140 -0
  221. package/dist/editor/EntryNavigator.js +0 -20
  222. package/dist/editor/FormRenderer.d.ts +1 -2
  223. package/dist/editor/FormRenderer.js +8 -23
  224. package/dist/editor/admin/SystemHealthPanel.js +20 -37
  225. package/dist/editor/admin/useSystemHealth.d.ts +2 -3
  226. package/dist/editor/admin/useSystemHealth.js +10 -12
  227. package/dist/editor/client-reference-resolver.d.ts +2 -5
  228. package/dist/editor/client-reference-resolver.js +2 -19
  229. package/dist/editor/comments/BranchComments.js +0 -4
  230. package/dist/editor/comments/EntryComments.js +0 -4
  231. package/dist/editor/comments/FieldWrapper.js +0 -4
  232. package/dist/editor/comments/InlineCommentThread.js +0 -2
  233. package/dist/editor/comments/ThreadCarousel.d.ts +0 -58
  234. package/dist/editor/comments/ThreadCarousel.js +0 -12
  235. package/dist/editor/components/EditorFooter.d.ts +0 -8
  236. package/dist/editor/components/EditorFooter.js +0 -5
  237. package/dist/editor/components/EditorHeader.d.ts +0 -31
  238. package/dist/editor/components/EditorHeader.js +21 -71
  239. package/dist/editor/components/EditorSidebar.d.ts +0 -19
  240. package/dist/editor/components/EditorSidebar.js +0 -16
  241. package/dist/editor/components/EntryCreateModal.d.ts +4 -4
  242. package/dist/editor/components/EntryCreateModal.js +7 -22
  243. package/dist/editor/components/RenameEntryModal.js +0 -4
  244. package/dist/editor/components/UserBadge.js +0 -10
  245. package/dist/editor/components/index.js +0 -2
  246. package/dist/editor/context/ApiClientContext.d.ts +8 -7
  247. package/dist/editor/context/ApiClientContext.js +39 -11
  248. package/dist/editor/context/AssetContext.d.ts +7 -17
  249. package/dist/editor/context/AssetContext.js +7 -17
  250. package/dist/editor/context/EditorIdentityContext.d.ts +13 -0
  251. package/dist/editor/context/EditorIdentityContext.js +7 -0
  252. package/dist/editor/context/EditorStateContext.d.ts +10 -41
  253. package/dist/editor/context/EditorStateContext.js +7 -23
  254. package/dist/editor/context/SWRProvider.d.ts +3 -6
  255. package/dist/editor/context/index.d.ts +1 -20
  256. package/dist/editor/context/index.js +1 -20
  257. package/dist/editor/editor-utils.d.ts +8 -47
  258. package/dist/editor/editor-utils.js +19 -72
  259. package/dist/editor/fields/BlockField.d.ts +2 -2
  260. package/dist/editor/fields/BlockField.js +1 -2
  261. package/dist/editor/fields/CodeField.d.ts +0 -1
  262. package/dist/editor/fields/CodeField.js +0 -1
  263. package/dist/editor/fields/DateTimeField.d.ts +15 -22
  264. package/dist/editor/fields/DateTimeField.js +17 -26
  265. package/dist/editor/fields/ImageField.d.ts +2 -2
  266. package/dist/editor/fields/ImageField.js +8 -14
  267. package/dist/editor/fields/InlineGroupField.d.ts +0 -1
  268. package/dist/editor/fields/InlineGroupField.js +0 -1
  269. package/dist/editor/fields/MarkdownField.d.ts +0 -1
  270. package/dist/editor/fields/MarkdownField.js +6 -11
  271. package/dist/editor/fields/MdxImageDialog.d.ts +1 -1
  272. package/dist/editor/fields/MdxImageDialog.js +0 -1
  273. package/dist/editor/fields/NumberField.d.ts +9 -19
  274. package/dist/editor/fields/NumberField.js +11 -22
  275. package/dist/editor/fields/NumberListField.d.ts +7 -10
  276. package/dist/editor/fields/NumberListField.js +7 -10
  277. package/dist/editor/fields/ObjectField.d.ts +5 -8
  278. package/dist/editor/fields/ObjectField.js +0 -1
  279. package/dist/editor/fields/ReferenceField.d.ts +1 -1
  280. package/dist/editor/fields/ReferenceField.js +11 -16
  281. package/dist/editor/fields/SelectField.d.ts +2 -2
  282. package/dist/editor/fields/SelectField.js +0 -1
  283. package/dist/editor/fields/StringListField.d.ts +0 -4
  284. package/dist/editor/fields/StringListField.js +4 -9
  285. package/dist/editor/fields/TextField.d.ts +0 -1
  286. package/dist/editor/fields/TextField.js +0 -1
  287. package/dist/editor/fields/ToggleField.d.ts +0 -1
  288. package/dist/editor/fields/ToggleField.js +0 -1
  289. package/dist/editor/fields/entry-link/EntryLinkContext.js +2 -4
  290. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +0 -3
  291. package/dist/editor/fields/entry-link/InsertEntryLink.js +0 -4
  292. package/dist/editor/group-manager/ExternalGroupsTab.d.ts +0 -3
  293. package/dist/editor/group-manager/ExternalGroupsTab.js +0 -1
  294. package/dist/editor/group-manager/GroupCard.d.ts +0 -3
  295. package/dist/editor/group-manager/GroupForm.d.ts +0 -3
  296. package/dist/editor/group-manager/InternalGroupsTab.d.ts +0 -3
  297. package/dist/editor/group-manager/InternalGroupsTab.js +0 -2
  298. package/dist/editor/group-manager/MemberList.d.ts +0 -3
  299. package/dist/editor/group-manager/hooks/useExternalGroupSearch.d.ts +0 -3
  300. package/dist/editor/group-manager/hooks/useExternalGroupSearch.js +0 -3
  301. package/dist/editor/group-manager/hooks/useGroupState.d.ts +0 -3
  302. package/dist/editor/group-manager/hooks/useGroupState.js +0 -5
  303. package/dist/editor/group-manager/hooks/useUserSearch.d.ts +0 -3
  304. package/dist/editor/group-manager/hooks/useUserSearch.js +0 -3
  305. package/dist/editor/group-manager/index.d.ts +0 -6
  306. package/dist/editor/group-manager/index.js +0 -10
  307. package/dist/editor/group-manager/types.d.ts +2 -5
  308. package/dist/editor/group-manager/types.js +0 -3
  309. package/dist/editor/hooks/index.d.ts +6 -11
  310. package/dist/editor/hooks/index.js +6 -12
  311. package/dist/editor/hooks/useBranchActions.d.ts +0 -14
  312. package/dist/editor/hooks/useBranchActions.js +0 -22
  313. package/dist/editor/hooks/useBranchManager.d.ts +2 -48
  314. package/dist/editor/hooks/useBranchManager.js +4 -64
  315. package/dist/editor/hooks/useBranchesData.d.ts +7 -6
  316. package/dist/editor/hooks/useBranchesData.js +7 -6
  317. package/dist/editor/hooks/useCommentSystem.d.ts +0 -49
  318. package/dist/editor/hooks/useCommentSystem.js +1 -36
  319. package/dist/editor/hooks/useDraftManager.d.ts +2 -35
  320. package/dist/editor/hooks/useDraftManager.js +23 -64
  321. package/dist/editor/hooks/useEditorLayout.d.ts +0 -20
  322. package/dist/editor/hooks/useEditorLayout.js +0 -23
  323. package/dist/editor/hooks/useEntriesData.d.ts +1 -0
  324. package/dist/editor/hooks/useEntriesData.js +2 -3
  325. package/dist/editor/hooks/useEntryLinkResolution.d.ts +2 -2
  326. package/dist/editor/hooks/useEntryLinkResolution.js +2 -2
  327. package/dist/editor/hooks/useEntryManager.d.ts +2 -27
  328. package/dist/editor/hooks/useEntryManager.js +29 -78
  329. package/dist/editor/hooks/useGroupManager.d.ts +0 -21
  330. package/dist/editor/hooks/useGroupManager.js +0 -21
  331. package/dist/editor/hooks/usePermissionManager.d.ts +0 -20
  332. package/dist/editor/hooks/usePermissionManager.js +0 -20
  333. package/dist/editor/hooks/useReferenceResolution.d.ts +2 -33
  334. package/dist/editor/hooks/useReferenceResolution.js +1 -48
  335. package/dist/editor/hooks/useSchemaManager.d.ts +8 -27
  336. package/dist/editor/hooks/useSchemaManager.js +0 -19
  337. package/dist/editor/hooks/useUserContext.d.ts +2 -9
  338. package/dist/editor/hooks/useUserContext.js +10 -10
  339. package/dist/editor/hooks/useUserMetadata.d.ts +0 -2
  340. package/dist/editor/hooks/useUserMetadata.js +0 -3
  341. package/dist/editor/media/AssetCard.d.ts +0 -1
  342. package/dist/editor/media/AssetCard.js +1 -2
  343. package/dist/editor/media/CropStep.d.ts +0 -1
  344. package/dist/editor/media/CropStep.js +0 -1
  345. package/dist/editor/media/MediaLibrary.d.ts +1 -2
  346. package/dist/editor/media/MediaLibrary.js +1 -2
  347. package/dist/editor/media/MediaLibraryBody.d.ts +0 -1
  348. package/dist/editor/media/MediaLibraryBody.js +0 -1
  349. package/dist/editor/media/crop-math.d.ts +1 -1
  350. package/dist/editor/media/crop-math.js +1 -1
  351. package/dist/editor/permission-manager/GroupSelector.d.ts +0 -3
  352. package/dist/editor/permission-manager/PermissionEditor.d.ts +0 -4
  353. package/dist/editor/permission-manager/PermissionEditor.js +0 -1
  354. package/dist/editor/permission-manager/PermissionLevelBadge.d.ts +0 -7
  355. package/dist/editor/permission-manager/PermissionLevelBadge.js +0 -4
  356. package/dist/editor/permission-manager/PermissionTree.d.ts +0 -4
  357. package/dist/editor/permission-manager/UserSelector.d.ts +0 -3
  358. package/dist/editor/permission-manager/constants.d.ts +0 -3
  359. package/dist/editor/permission-manager/hooks/useGroupsAndUsers.d.ts +0 -3
  360. package/dist/editor/permission-manager/hooks/useGroupsAndUsers.js +0 -10
  361. package/dist/editor/permission-manager/hooks/usePermissionTree.d.ts +0 -14
  362. package/dist/editor/permission-manager/hooks/usePermissionTree.js +0 -11
  363. package/dist/editor/permission-manager/index.d.ts +0 -7
  364. package/dist/editor/permission-manager/index.js +0 -16
  365. package/dist/editor/permission-manager/types.d.ts +2 -12
  366. package/dist/editor/permission-manager/types.js +0 -3
  367. package/dist/editor/permission-manager/utils.d.ts +0 -28
  368. package/dist/editor/permission-manager/utils.js +9 -23
  369. package/dist/editor/preview-bridge.d.ts +5 -1
  370. package/dist/editor/preview-bridge.js +4 -4
  371. package/dist/editor/schema-editor/CollectionEditor.d.ts +0 -5
  372. package/dist/editor/schema-editor/CollectionEditor.js +9 -31
  373. package/dist/editor/schema-editor/EntryTypeEditor.d.ts +0 -6
  374. package/dist/editor/schema-editor/EntryTypeEditor.js +3 -24
  375. package/dist/editor/schema-editor/index.d.ts +0 -7
  376. package/dist/editor/schema-editor/index.js +0 -7
  377. package/dist/editor/utils/env.d.ts +3 -18
  378. package/dist/editor/utils/env.js +6 -25
  379. package/dist/entry-link-resolver.d.ts +13 -35
  380. package/dist/entry-link-resolver.js +20 -53
  381. package/dist/entry-schema-registry.d.ts +20 -60
  382. package/dist/entry-schema-registry.js +20 -69
  383. package/dist/entry-schema.d.ts +118 -223
  384. package/dist/entry-schema.js +46 -91
  385. package/dist/git-manager.d.ts +101 -200
  386. package/dist/git-manager.js +211 -384
  387. package/dist/github-service.d.ts +34 -75
  388. package/dist/github-service.js +37 -91
  389. package/dist/http/handler.d.ts +6 -37
  390. package/dist/http/handler.js +65 -120
  391. package/dist/http/router.d.ts +15 -32
  392. package/dist/http/router.js +34 -99
  393. package/dist/http/types.d.ts +15 -40
  394. package/dist/http/types.js +3 -7
  395. package/dist/id.d.ts +4 -10
  396. package/dist/id.js +4 -10
  397. package/dist/index.js +6 -9
  398. package/dist/operating-mode/client-safe-strategy.d.ts +7 -14
  399. package/dist/operating-mode/client-safe-strategy.js +8 -28
  400. package/dist/operating-mode/client-unsafe-strategy.d.ts +6 -16
  401. package/dist/operating-mode/client-unsafe-strategy.js +9 -34
  402. package/dist/operating-mode/client.d.ts +3 -10
  403. package/dist/operating-mode/client.js +3 -8
  404. package/dist/operating-mode/deployment-name-fixtures.d.ts +14 -18
  405. package/dist/operating-mode/deployment-name-fixtures.js +14 -18
  406. package/dist/operating-mode/deployment-name.d.ts +19 -29
  407. package/dist/operating-mode/deployment-name.js +29 -47
  408. package/dist/operating-mode/index.d.ts +4 -25
  409. package/dist/operating-mode/index.js +9 -31
  410. package/dist/operating-mode/mode-env.d.ts +32 -54
  411. package/dist/operating-mode/mode-env.js +36 -58
  412. package/dist/operating-mode/types.d.ts +24 -72
  413. package/dist/operating-mode/types.js +1 -7
  414. package/dist/paths/branch-name.d.ts +30 -40
  415. package/dist/paths/branch-name.js +30 -40
  416. package/dist/paths/branch.d.ts +4 -18
  417. package/dist/paths/branch.js +6 -22
  418. package/dist/paths/index.d.ts +2 -13
  419. package/dist/paths/index.js +3 -23
  420. package/dist/paths/normalize-server.d.ts +3 -15
  421. package/dist/paths/normalize-server.js +3 -15
  422. package/dist/paths/normalize.d.ts +13 -33
  423. package/dist/paths/normalize.js +13 -33
  424. package/dist/paths/resolve.d.ts +4 -17
  425. package/dist/paths/resolve.js +4 -22
  426. package/dist/paths/types.d.ts +12 -34
  427. package/dist/paths/types.js +5 -7
  428. package/dist/paths/validation.d.ts +22 -119
  429. package/dist/paths/validation.js +30 -148
  430. package/dist/reference-resolver.d.ts +0 -13
  431. package/dist/reference-resolver.js +0 -18
  432. package/dist/resolve-canopy-user.d.ts +16 -18
  433. package/dist/resolve-canopy-user.js +17 -26
  434. package/dist/resource-generation.d.ts +16 -25
  435. package/dist/resource-generation.js +34 -97
  436. package/dist/schema/meta-loader.d.ts +2 -14
  437. package/dist/schema/meta-loader.js +15 -64
  438. package/dist/schema/resolver.d.ts +1 -4
  439. package/dist/schema/resolver.js +1 -6
  440. package/dist/schema/schema-store-types.d.ts +0 -12
  441. package/dist/schema/schema-store.d.ts +61 -138
  442. package/dist/schema/schema-store.js +85 -282
  443. package/dist/schema/types.d.ts +0 -4
  444. package/dist/server.d.ts +62 -93
  445. package/dist/server.js +63 -94
  446. package/dist/services.d.ts +23 -47
  447. package/dist/services.js +55 -98
  448. package/dist/settings-workspace.d.ts +17 -30
  449. package/dist/settings-workspace.js +51 -81
  450. package/dist/static/index.d.ts +26 -52
  451. package/dist/static/index.js +33 -67
  452. package/dist/{worker/task-queue.d.ts → task-queue/cms-task-queue.d.ts} +7 -9
  453. package/dist/task-queue/cms-task-queue.js +13 -0
  454. package/dist/task-queue/task-queue-config.d.ts +3 -0
  455. package/dist/{worker → task-queue}/task-queue-config.js +1 -6
  456. package/dist/task-queue/task-queue.d.ts +16 -22
  457. package/dist/task-queue/task-queue.js +20 -44
  458. package/dist/task-queue/types.d.ts +0 -1
  459. package/dist/task-queue/worker-status.d.ts +35 -0
  460. package/dist/task-queue/worker-status.js +40 -0
  461. package/dist/types.d.ts +40 -51
  462. package/dist/url-collision.d.ts +26 -40
  463. package/dist/url-collision.js +47 -63
  464. package/dist/url-exclusivity-fixtures.d.ts +15 -20
  465. package/dist/url-exclusivity-fixtures.js +14 -19
  466. package/dist/url-path-resolver.d.ts +8 -16
  467. package/dist/url-path-resolver.js +19 -29
  468. package/dist/user.d.ts +8 -21
  469. package/dist/user.js +10 -19
  470. package/dist/utils/async-mutex.js +4 -6
  471. package/dist/utils/atomic-write.d.ts +2 -11
  472. package/dist/utils/atomic-write.js +2 -11
  473. package/dist/utils/body-field.d.ts +8 -15
  474. package/dist/utils/body-field.js +8 -15
  475. package/dist/utils/content-serialize.d.ts +18 -24
  476. package/dist/utils/content-serialize.js +109 -160
  477. package/dist/utils/content-write-lock.d.ts +51 -106
  478. package/dist/utils/content-write-lock.js +66 -111
  479. package/dist/utils/debug.d.ts +7 -23
  480. package/dist/utils/debug.js +7 -23
  481. package/dist/utils/entry-url.d.ts +11 -23
  482. package/dist/utils/entry-url.js +11 -26
  483. package/dist/utils/error.d.ts +25 -90
  484. package/dist/utils/error.js +60 -147
  485. package/dist/utils/flatten-group-fields.d.ts +5 -7
  486. package/dist/utils/flatten-group-fields.js +5 -7
  487. package/dist/utils/format.d.ts +1 -5
  488. package/dist/utils/format.js +1 -5
  489. package/dist/utils/fs.d.ts +3 -4
  490. package/dist/utils/fs.js +3 -4
  491. package/dist/utils/git.d.ts +52 -83
  492. package/dist/utils/git.js +79 -125
  493. package/dist/utils/logger.d.ts +35 -50
  494. package/dist/utils/logger.js +35 -52
  495. package/dist/utils/provisioning-lock.d.ts +23 -35
  496. package/dist/utils/provisioning-lock.js +49 -69
  497. package/dist/utils/sanitize-href.d.ts +5 -10
  498. package/dist/utils/sanitize-href.js +8 -18
  499. package/dist/utils/title-field.d.ts +17 -20
  500. package/dist/utils/title-field.js +18 -27
  501. package/dist/utils/typed-filename.d.ts +20 -48
  502. package/dist/utils/typed-filename.js +25 -61
  503. package/dist/utils/url-prefix.d.ts +3 -3
  504. package/dist/utils/url-prefix.js +3 -3
  505. package/dist/validation/block-structural-keys.d.ts +16 -31
  506. package/dist/validation/block-structural-keys.js +19 -29
  507. package/dist/validation/deletion-checker.d.ts +0 -34
  508. package/dist/validation/deletion-checker.js +1 -43
  509. package/dist/validation/entry-link-validator.d.ts +6 -13
  510. package/dist/validation/entry-link-validator.js +4 -13
  511. package/dist/validation/entry-type-reference-validator.d.ts +13 -18
  512. package/dist/validation/entry-type-reference-validator.js +13 -18
  513. package/dist/validation/entry-validator.d.ts +13 -25
  514. package/dist/validation/entry-validator.js +15 -32
  515. package/dist/validation/field-traversal.d.ts +3 -30
  516. package/dist/validation/field-traversal.js +1 -32
  517. package/dist/validation/reference-validator.d.ts +5 -24
  518. package/dist/validation/reference-validator.js +5 -30
  519. package/dist/worker/cms-worker.d.ts +172 -232
  520. package/dist/worker/cms-worker.js +241 -333
  521. package/dist/worker/git-sync.d.ts +32 -133
  522. package/dist/worker/git-sync.js +129 -191
  523. package/dist/worker/github-auth.d.ts +13 -19
  524. package/dist/worker/github-auth.js +13 -19
  525. package/dist/worker/history-rewrite.d.ts +53 -66
  526. package/dist/worker/history-rewrite.js +54 -60
  527. package/dist/worker/log.d.ts +24 -41
  528. package/dist/worker/log.js +25 -42
  529. package/dist/worker/rebase.d.ts +20 -110
  530. package/dist/worker/rebase.js +279 -354
  531. package/dist/worker/task-runner.d.ts +32 -66
  532. package/dist/worker/task-runner.js +126 -162
  533. package/dist/worker/worker-context.d.ts +41 -81
  534. package/package.json +4 -4
  535. package/dist/cli/template-files/middleware-clerk.ts.template +0 -37
  536. package/dist/worker/task-queue-config.d.ts +0 -8
  537. package/dist/worker/task-queue.js +0 -19
  538. package/dist/worker/worker-status.d.ts +0 -47
  539. package/dist/worker/worker-status.js +0 -52
@@ -1,31 +1,15 @@
1
1
  /**
2
- * ContentStore — the authoritative read/write boundary for entry content.
2
+ * ContentStore — the authoritative read/write boundary for entry content. Everything that
3
+ * creates, reads, updates, renames or deletes an entry file goes through here;
4
+ * `api/content.ts` is its HTTP front door.
3
5
  *
4
- * Everything that creates, reads, updates, renames or deletes an entry file goes
5
- * through here. `api/content.ts` is its HTTP front door.
6
+ * Neighbours, since picking the wrong one is easy: content-reader.ts (branch-aware read facade
7
+ * over this store), content-listing.ts (batch `listEntries`), content-tree.ts (navigable
8
+ * tree), content-id-index.ts (the id -> path index this store consults),
9
+ * content-index-registry.ts (IN-PROCESS cache invalidation) and content-index-generation.ts
10
+ * (ON-DISK generation marker) — the last two are near-homonyms with unrelated jobs.
6
11
  *
7
- * ORIENTATION — this is one of seven `content-*` modules, and picking the wrong
8
- * one costs more time than reading this comment:
9
- *
10
- * content-store.ts (this file) the write boundary, plus path-and-id resolution
11
- * content-reader.ts branch-aware read facade over this store
12
- * content-listing.ts batch listing (`listEntries`) for adopters
13
- * content-tree.ts the adopter-facing navigable tree
14
- * content-id-index.ts the id -> path index this store consults
15
- * content-index-registry.ts IN-PROCESS cache invalidation registry
16
- * content-index-generation.ts ON-DISK generation marker <- near-homonym of the line above,
17
- * unrelated job; check which one you want
18
- *
19
- * SHAPE — the CRUD/path/lock core is genuinely interwoven (`buildPaths` alone has
20
- * ~25 call sites in this file), but two clusters are only loosely attached and are the ones
21
- * to read in isolation: reference resolution (`resolveReferences` and friends, at
22
- * the end of the file) and ID-index coherency (`idIndex`, `recordOwnMutation`,
23
- * `refreshIndexForSuspiciousLookup`).
24
- *
25
- * The load-bearing rules are documented at the point of the rule, not here — see
26
- * in particular `ContentStoreOptions.contentRootName`, the `[SLUG]` guard in
27
- * `write()`, and the read-path note on `parseSlug`. Those comments are
28
- * authoritative. Module map: ./AGENTS.md.
12
+ * Load-bearing rules live at the point of the rule. Module map: ./AGENTS.md.
29
13
  */
30
14
  import fs from 'node:fs/promises';
31
15
  import path from 'node:path';
@@ -48,16 +32,11 @@ import { filePathExists, readFileIfExists } from './utils/fs.js';
48
32
  import { asRecord, getFormatExtension } from './utils/format.js';
49
33
  import { normalizeFilesystemPath, parseSlug, } from './paths/index.js';
50
34
  /**
51
- * Acquire multiple lock keys (via withLock) in a canonical (sorted) order
52
- * before running `fn`, to rule out AB-BA deadlocks between callers that need
53
- * overlapping key sets.
54
- *
55
- * Only renameEntry() needs two keys today (the source entry's ID lock, plus
56
- * the destination slug's create-lock -- see ContentStore.createLockKey()).
57
- * The `id:` and `create:` keyspaces never share a literal string (disjoint
58
- * prefixes), so two renameEntry() calls can never contend for the exact same
59
- * pair of keys in reversed roles -- but sorting costs nothing and is cheap
60
- * insurance against that changing later.
35
+ * Acquire `keys` in canonical (sorted) order before running `fn`, ruling out AB-BA
36
+ * deadlocks between callers with overlapping key sets. Only renameEntry() needs two
37
+ * (the source entry's ID lock plus the destination slug's create-lock, see
38
+ * ContentStore.createLockKey()), and the `id:`/`create:` keyspaces are disjoint, so
39
+ * sorting is insurance against that changing rather than a live requirement.
61
40
  */
62
41
  async function withLocks(keys, fn) {
63
42
  const sorted = Array.from(new Set(keys)).sort();
@@ -82,15 +61,13 @@ export class ContentConflictError extends Error {
82
61
  }
83
62
  /**
84
63
  * [SYNC-C1] Thrown when a mutation could not take the branch's cross-host
85
- * content-write lock within its bounded wait -- in practice, the worker is
86
- * mid-rebase on this branch's working tree (utils/content-write-lock.ts).
64
+ * content-write lock within its bounded wait (utils/content-write-lock.ts) --
65
+ * usually the worker mid-rebase on this branch's working tree, though
66
+ * writer-vs-writer contention raises it too (see ContentWriteLockBusyError).
87
67
  *
88
- * A `ContentConflictError` subclass so every existing 409 mapping keeps
89
- * working unchanged; the distinct type exists so the API can surface THIS
90
- * message ("the branch is busy, retry") instead of the generic "modified by
91
- * another editor", which would be actively misleading. The default wording
92
- * covers writer-vs-writer contention too, which this lock also produces --
93
- * see ContentWriteLockBusyError.
68
+ * A `ContentConflictError` subclass so every 409 mapping keeps working; the
69
+ * distinct type lets the API surface THIS message ("the branch is busy, retry")
70
+ * instead of the misleading generic "modified by another editor".
94
71
  */
95
72
  export class BranchSyncingError extends ContentConflictError {
96
73
  constructor(message) {
@@ -99,37 +76,27 @@ export class BranchSyncingError extends ContentConflictError {
99
76
  }
100
77
  }
101
78
  /**
102
- * [F1] Thrown when a save's content ID is carried by MORE THAN ONE file in
103
- * the branch's content tree -- the duplicate-ID state `ContentIdIndex`
104
- * quarantines (see its "Duplicate-ID quarantine" section).
79
+ * [F1] Thrown when a save's content ID is carried by MORE THAN ONE file in the branch's
80
+ * content tree -- the duplicate-ID state `ContentIdIndex` quarantines.
105
81
  *
106
- * Why refuse rather than write: with two files sharing one ID, "this entry"
107
- * is ambiguous, and every way of proceeding is worse than stopping.
108
- * Following the index would mutate (and, via the slug-change cleanup, DELETE)
109
- * a file the caller never addressed -- the data-loss bug this class exists to
110
- * prevent. Writing only the addressed file would succeed silently into a file
111
- * that is invisible to every ID-based lookup (reads-by-id, references,
112
- * listings all resolve to the OTHER copy) and that the repair action later
113
- * archives away, so the editor's work would appear to evaporate with no error
114
- * anywhere. Refusing mutates nothing under any interleaving, and says what is
115
- * wrong and who can fix it.
82
+ * Refuse rather than write: with two files sharing one ID "this entry" is ambiguous and every
83
+ * way of proceeding is worse. Following the index would DELETE (via the slug-change cleanup) a
84
+ * file the caller never addressed; writing only the addressed file would succeed into a file
85
+ * invisible to every ID-based lookup and later archived by the repair action, so the editor's
86
+ * work evaporates with no error anywhere. Refusing mutates nothing under any interleaving.
116
87
  *
117
- * A `ContentConflictError` subclass so every existing 409 mapping keeps
118
- * working; the distinct type exists so the API can surface THIS message
119
- * rather than the generic "modified by another editor", which would send the
88
+ * A `ContentConflictError` subclass so every 409 mapping keeps working; the distinct type lets
89
+ * the API surface THIS message rather than "modified by another editor", which would send the
120
90
  * editor into a reload-and-retry loop that cannot succeed.
121
91
  */
122
92
  export class DuplicateContentIdError extends ContentConflictError {
123
93
  constructor(contentId, paths) {
124
94
  const sorted = Array.from(new Set(paths)).sort();
125
95
  super(
126
- // Names the STATE, not an action. The repair-content-duplicates
127
- // endpoint exists but nothing in the editor renders it, so telling an
128
- // editor "an admin can run X" sent them to an admin who could neither
129
- // run X nor see that the branch was affected. Say what is true; the
130
- // admin panel's read-only duplicate list (SystemHealthPanel) is the
131
- // diagnosis half, and the repair UI is tracked in
132
- // .claude/future-tasks/duplicate-content-id-repair-ui.md.
96
+ // Names the STATE, not an action: the repair-content-duplicates endpoint
97
+ // exists but nothing in the editor renders it, and the admin panel's
98
+ // duplicate list (SystemHealthPanel) is the diagnosis half only. The repair
99
+ // UI is tracked in .claude/future-tasks/duplicate-content-id-repair-ui.md.
133
100
  `Content ID ${contentId} is on more than one file (${sorted
134
101
  .map((p) => `"${p}"`)
135
102
  .join(' and ')}), so this save was refused rather than risk overwriting or ` +
@@ -142,17 +109,15 @@ export class DuplicateContentIdError extends ContentConflictError {
142
109
  }
143
110
  /**
144
111
  * Thrown when a create or rename would give a SECOND entry a `urlPath` another entry already
145
- * holds -- the write-boundary half of the invariant `assertNoDuplicateUrlPaths` enforces at build
146
- * time (see url-collision.ts for which shapes count and, just as importantly, which do not).
112
+ * holds -- the write-boundary half of the invariant `assertNoDuplicateUrlPaths` enforces at
113
+ * build time (url-collision.ts says which shapes count and which deliberately do not).
147
114
  *
148
- * Why refuse rather than write: only one of the two entries can be served at that URL, so the
149
- * other silently has no route anywhere. Allowing the write trades a clear error now for a page
150
- * that quietly does not exist later -- and, because the loser is picked by resolver precedence
151
- * rather than by the author, not necessarily the page they were editing.
115
+ * Refuse rather than write: only one of the two can be served at that URL, so the other
116
+ * silently has no route anywhere -- and the loser is picked by resolver precedence rather than
117
+ * by the author, so not necessarily the page they were editing.
152
118
  *
153
- * A `ContentConflictError` subclass so every existing 409 mapping keeps working; the distinct
154
- * type exists so the API can surface THIS message rather than the generic "modified by another
155
- * editor", which would send the editor into a reload-and-retry loop that cannot succeed.
119
+ * A `ContentConflictError` subclass so every 409 mapping keeps working; the distinct type lets
120
+ * the API surface THIS message rather than the generic "modified by another editor".
156
121
  */
157
122
  export class UrlPathConflictError extends ContentConflictError {
158
123
  constructor(message, conflictingPath) {
@@ -161,25 +126,21 @@ export class UrlPathConflictError extends ContentConflictError {
161
126
  this.conflictingPath = conflictingPath;
162
127
  }
163
128
  }
164
- /**
165
- * Get the default entry type from a collection's entries array.
166
- * Returns the entry marked as default, or the first one, or undefined if no entries.
167
- */
129
+ /** The entry type marked `default`, else the first, else undefined. */
168
130
  export function getDefaultEntryType(entries) {
169
131
  if (!entries || entries.length === 0)
170
132
  return undefined;
171
133
  return entries.find((e) => e.default) || entries[0];
172
134
  }
173
135
  /**
174
- * Validates that a slug doesn't contain slashes or backslashes.
175
- * Slugs must be simple filenames (last path segment only).
136
+ * Rejects slashes and backslashes -- the path-traversal guard: a slug is one filename segment.
176
137
  *
177
- * Deliberately WEAKER than `parseSlug`, and deliberately not merged with it: this runs on every
178
- * resolution, reads included (`buildPaths`), so it can only enforce what must be true of content
179
- * that already exists on disk -- i.e. path safety. Content whose slug predates CanopyCMS, or was
180
- * hand-authored, or came in over git, has to stay readable. `parseSlug`'s stricter URL-addressable
181
- * rule is enforced only where a NEW filename is minted (see the [SLUG] guards in `write()` and
182
- * `renameEntry()`); applying it here would convert a build-time failure into unreachable data.
138
+ * Deliberately WEAKER than `parseSlug`, because this runs on every resolution, reads included
139
+ * (`buildPaths`), so it can only require what must be true of content already on disk: content
140
+ * predating CanopyCMS, hand-authored, or arrived over git has to stay readable. `parseSlug`'s
141
+ * stricter URL-addressable rule is enforced only where a NEW filename is minted (the [SLUG]
142
+ * guards in `write()` and `renameEntry()`); applying it here would turn a build-time failure
143
+ * into unreachable data.
183
144
  */
184
145
  function validateSlug(slug) {
185
146
  if (slug.includes('/')) {
@@ -227,20 +188,16 @@ export class ContentStore {
227
188
  }
228
189
  /**
229
190
  * [SYNC-C1] Run a working-tree mutation under the branch's cross-host
230
- * content-write lock (utils/content-write-lock.ts), on top of the
231
- * in-process locks the callee takes for itself.
191
+ * content-write lock (utils/content-write-lock.ts), on top of the in-process
192
+ * locks the callee takes for itself. The in-process mutex serializes writers
193
+ * inside ONE process and says nothing about the EC2 worker rebasing this same
194
+ * tree on shared EFS, which destroys an in-flight save.
232
195
  *
233
- * The in-process mutex serializes writers inside ONE process; it says
234
- * nothing about the EC2 worker rebasing this same tree on shared EFS, which
235
- * destroys an in-flight save (`checkout --theirs` overwrites it and the
236
- * rebase then reports success; `rebase --abort` hard-resets it). This is the
237
- * layer that actually excludes the two.
196
+ * Reads deliberately do NOT take it: an extra EFS round-trip per read is not an
197
+ * acceptable cost, and a rebase cannot destroy a read.
238
198
  *
239
- * Reads deliberately do NOT take it -- an extra EFS round-trip per read is
240
- * not an acceptable cost, and reads cannot be destroyed by a rebase.
241
- *
242
- * Acquisition order is always content lock -> `withLock`, never the reverse,
243
- * so the two cannot deadlock.
199
+ * Acquisition order is always content lock -> `withLock`, never the reverse, so
200
+ * the two cannot deadlock.
244
201
  */
245
202
  async withContentWriteExclusion(fn) {
246
203
  try {
@@ -259,26 +216,23 @@ export class ContentStore {
259
216
  *
260
217
  * Called (via content-index-registry) after in-process operations that change the
261
218
  * working tree underneath this store — git checkout/merge/rebase, the worker's
262
- * rebase loop, and sync-core's content replacement. Without this, ID→path lookups
263
- * keep resolving to pre-mutation paths and saves can target moved/deleted files.
264
- *
265
- * Cheap: only bumps a generation counter; the rebuild happens lazily.
219
+ * rebase loop, sync-core's content replacement. Without it, ID→path lookups keep
220
+ * resolving to pre-mutation paths and saves can target moved/deleted files.
221
+ * Cheap: bumps a generation counter, the rebuild is lazy.
266
222
  */
267
223
  invalidateIndex() {
268
224
  this.indexGeneration++;
269
225
  }
270
226
  /**
271
- * Get the ID index, ensuring it's loaded and current first.
272
- * Loads lazily on first access and rebuilds after invalidateIndex(); repeated
273
- * accesses with no intervening invalidation reuse the already-built index,
274
- * except for a throttled probe of the on-disk generation marker that detects
275
- * mutations made by OTHER processes sharing this root (e.g. on EFS).
227
+ * The ID index, rebuilt first if stale. Loads lazily, rebuilds after
228
+ * invalidateIndex(), and otherwise reuses the built index apart from a throttled
229
+ * probe of the on-disk generation marker, which detects mutations by OTHER
230
+ * processes sharing this root (e.g. on EFS).
276
231
  */
277
232
  async idIndex() {
278
- // Cross-process freshness probe: when the in-memory generations already
279
- // match, cheaply check whether another process bumped the on-disk marker
280
- // since this index was built. Skipped while a rebuild is due anyway — the
281
- // rebuild below captures the marker itself.
233
+ // Cross-process freshness probe: with the in-memory generations already matching,
234
+ // cheaply check whether another process bumped the on-disk marker. Skipped while a
235
+ // rebuild is due anyway — the rebuild below captures the marker itself.
282
236
  if (this.loadedIndexGeneration === this.indexGeneration && this.shouldProbeDiskGeneration()) {
283
237
  const diskToken = await readContentIndexGeneration(this.root);
284
238
  if (diskToken !== this.loadedDiskGeneration) {
@@ -337,14 +291,12 @@ export class ContentStore {
337
291
  return true;
338
292
  }
339
293
  /**
340
- * After one of this store's own successful mutations: publish the change to
341
- * other processes (bump the on-disk marker) and adopt the written token,
342
- * since the in-memory index was already updated incrementally — avoiding a
343
- * pointless self-rescan on the next probe. Adoption is skipped unless the
344
- * index is quiescent and `updatedIndex` is still the live instance: if a
345
- * rebuild raced with the mutation, its scan may predate our file change, so
346
- * we leave the recorded token older and let the next probe observe our bump
347
- * and trigger the healing rebuild.
294
+ * After one of this store's own successful mutations: publish it to other
295
+ * processes (bump the on-disk marker) and adopt the written token, since the
296
+ * in-memory index was already updated incrementally. Adoption is skipped unless
297
+ * the index is quiescent and `updatedIndex` is still the live instance: a rebuild
298
+ * that raced the mutation may have scanned before our file change, so leaving the
299
+ * recorded token older lets the next probe observe our bump and heal.
348
300
  */
349
301
  async recordOwnMutation(updatedIndex) {
350
302
  const token = await bumpContentIndexGeneration(this.root);
@@ -356,16 +308,14 @@ export class ContentStore {
356
308
  }
357
309
  }
358
310
  /**
359
- * Backstop for the residual staleness windows of the cross-process marker
360
- * (NFS attribute caching, probe throttle, self-adoption — see
361
- * content-index-generation.ts): force one rebuild in response to a
362
- * suspicious lookup (an ID miss, or an index hit whose file is gone), but
363
- * throttle how often a caller can force one. Time-boxed so genuinely
364
- * dangling IDs cost at most one rescan per window. Returns true if a
365
- * refresh was performed by THIS call; callers that lose the throttle race
366
- * still benefit — idIndex() dedupes concurrent builds, so a caller that won
367
- * the race rebuilds the index for everyone, and callers should still retry
368
- * their lookup against the live index regardless of this return value.
311
+ * Backstop for the residual staleness windows of the cross-process marker (NFS
312
+ * attribute caching, probe throttle, self-adoption — see
313
+ * content-index-generation.ts): force one rebuild in response to a suspicious
314
+ * lookup (an ID miss, or an index hit whose file is gone), throttled so a
315
+ * genuinely dangling ID costs at most one rescan per window. Returns true only if
316
+ * THIS call refreshed; a caller that loses the throttle race must still retry its
317
+ * lookup, since idIndex() dedupes concurrent builds and the winner rebuilds for
318
+ * everyone.
369
319
  */
370
320
  async refreshIndexForSuspiciousLookup() {
371
321
  const now = Date.now();
@@ -377,13 +327,12 @@ export class ContentStore {
377
327
  return true;
378
328
  }
379
329
  /**
380
- * Every file in `dir` whose filename embeds `id`, as root-relative paths
381
- * (empty when there is no such file, or no such dir).
330
+ * Every file in `dir` whose filename embeds `id`, as root-relative paths (empty
331
+ * when there is no such file, or no such dir).
382
332
  *
383
- * [F1] Returns ALL matches, not the first: more than one match is a
384
- * duplicate-ID pair, and callers must be able to tell that apart from a
385
- * clean single hit rather than silently picking whichever one `readdir()`
386
- * happened to yield first.
333
+ * [F1] Returns ALL matches, not the first: more than one match is a duplicate-ID
334
+ * pair, and callers must be able to tell that apart from a clean single hit rather
335
+ * than silently picking whichever one `readdir()` happened to yield first.
387
336
  */
388
337
  async findEntryPathsById(dir, id) {
389
338
  let entries;
@@ -405,10 +354,6 @@ export class ContentStore {
405
354
  }
406
355
  return matches.sort();
407
356
  }
408
- /**
409
- * Get all schema items for iteration.
410
- * Used internally by ReferenceResolver for path matching.
411
- */
412
357
  getSchemaItems() {
413
358
  return this.schemaIndex.values();
414
359
  }
@@ -424,17 +369,16 @@ export class ContentStore {
424
369
  * Is this path a COLLECTION schema item?
425
370
  *
426
371
  * The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
427
- * point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
428
- * Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
429
- * both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
430
- * first-wins and would not.
372
+ * point: a caller that gates on this cannot disagree with what `buildPaths` will then do,
373
+ * because the Map is last-wins where a subcollection's path collides with a parent's
374
+ * entry-type name. A `find` over the flat schema LIST is first-wins and would not.
431
375
  *
432
- * Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
433
- * with subcollections and no entries of its own is legal, and mirroring that stricter test here
434
- * would reject something `buildPaths` accepts.
376
+ * Type-only, deliberately: `resolvePath` additionally requires `entries`, but a collection
377
+ * with subcollections and no entries of its own is legal, and testing for that here would
378
+ * reject something `buildPaths` accepts.
435
379
  *
436
380
  * Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
437
- * `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
381
+ * `urlAddressableOnly`.
438
382
  */
439
383
  isCollectionPath(collectionPath) {
440
384
  return this.schemaIndex.get(normalizeFilesystemPath(collectionPath))?.type === 'collection';
@@ -442,23 +386,20 @@ export class ContentStore {
442
386
  /**
443
387
  * Does this collection declare `entryTypeName` in its `entries` config?
444
388
  *
445
- * Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
446
- * whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
447
- * scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
448
- * was renamed out of the schema stays findable and therefore still editable, renameable and
449
- * deletable. Only URL resolution consults this, so what enumeration hides is not served.
389
+ * Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries`
390
+ * decides whether a file on disk is one of the collection's entries at all. `buildPaths`'
391
+ * own directory scan deliberately does NOT check this -- it matches on slug alone, so an
392
+ * entry whose type was renamed out of the schema stays findable and therefore editable,
393
+ * renameable and deletable. Only URL resolution consults this, so what enumeration hides is
394
+ * not served.
450
395
  *
451
- * Returns FALSE when the collection declares no `entries` at all, which is stricter than
452
- * `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
453
- * surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
454
- * outright for a collection with no `entries`. A collections-only container therefore publishes
455
- * nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
456
- * advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
457
- * been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
458
- * or by retrofit, and it is invisible to the sitemap and to static params either way.
396
+ * Returns FALSE when the collection declares no `entries` at all -- stricter than
397
+ * `parseTypedFilename`'s own guard, and deliberately so: the enumerating surface is
398
+ * `listCollectionEntries`, which returns `[]` outright for such a collection, so a URL read
399
+ * resolving a file sitting in one would answer where nothing is advertised.
459
400
  *
460
- * Returns true for a path that is not a collection at all, because that is rule 1's question,
461
- * not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
401
+ * Returns true for a path that is not a collection at all, because that is rule 1's
402
+ * question, and under `urlAddressableOnly` rule 1 has already rejected it.
462
403
  */
463
404
  declaresEntryType(collectionPath, entryTypeName) {
464
405
  const item = this.schemaIndex.get(normalizeFilesystemPath(collectionPath));
@@ -476,25 +417,20 @@ export class ContentStore {
476
417
  return item;
477
418
  }
478
419
  /**
479
- * Lock key for an existing entry, addressed by its permanent content ID
480
- * (stable across renames -- the whole point of this locking scheme; see
481
- * .claude/future-tasks/resolved/content-store-lock-key.md). Namespaced by store
482
- * root: the same content ID exists in every clone of a branch, and one
483
- * process can hold ContentStore instances on several clones (dev
484
- * workspace roots, prod branch clones) at once, so the root prefix keeps
485
- * the keyspace scoped per-store. It also keeps this ID keyspace disjoint
486
- * from other modules' raw-path-keyed locks sharing the same withLock()
487
- * map (comment-store, branch-metadata, etc).
420
+ * Lock key for an existing entry, addressed by its permanent content ID --
421
+ * stable across renames, which is the whole point of this locking scheme.
422
+ * Namespaced by store root: the same content ID exists in every clone of a
423
+ * branch and one process can hold stores on several clones at once, so the
424
+ * prefix scopes the keyspace per store and keeps it disjoint from other
425
+ * modules' raw-path-keyed locks sharing the same withLock() map.
488
426
  */
489
427
  idLockKey(id) {
490
428
  return `${this.root}:id:${id}`;
491
429
  }
492
430
  /**
493
- * Stable collection+slug identifier for lock-key namespacing. Mirrors the
494
- * schemaItem resolution buildPaths() performs for entry-type delegation:
495
- * an entry-type item delegates to its parent collection with the slug
496
- * defaulting to the entry type's own name (see buildPaths()'s
497
- * `schemaItem.type === 'entry-type'` branch).
431
+ * Stable collection+slug identifier for lock-key namespacing. Mirrors
432
+ * buildPaths()' entry-type delegation: an entry-type item delegates to its
433
+ * parent collection with the slug defaulting to the entry type's own name.
498
434
  */
499
435
  collectionSlugKey(schemaItem, slug) {
500
436
  if (schemaItem.type === 'entry-type') {
@@ -504,27 +440,24 @@ export class ContentStore {
504
440
  }
505
441
  /**
506
442
  * Lock key for a not-yet-existing entry (a create). Serializes concurrent
507
- * same-slug creates WITHIN this process: the second call's in-lock
508
- * buildPaths() re-resolution will find the first call's just-written file
509
- * and fold in as an edit (see write()). A concurrent create racing from a
510
- * DIFFERENT process is NOT covered by this in-process mutex -- accepted
511
- * per the epic's design review: each writer mints its own fresh ID and
512
- * writes its own distinct filename, so both writes succeed and the result
513
- * is two same-slug files with different IDs (a slug-uniqueness violation
514
- * surfaced on the next listing/lookup), never a duplicate-ID collision
515
- * that would poison index rebuilds.
443
+ * same-slug creates WITHIN this process: the second call's in-lock buildPaths()
444
+ * re-resolution finds the first call's just-written file and folds in as an edit.
445
+ *
446
+ * A create racing from a DIFFERENT process is NOT covered by this in-process
447
+ * mutex, and that is accepted: each writer mints its own fresh ID and filename, so
448
+ * both writes succeed and the result is two same-slug files with different IDs (a
449
+ * slug-uniqueness violation surfaced on the next listing/lookup), never a
450
+ * duplicate-ID collision that would poison index rebuilds.
516
451
  */
517
452
  createLockKey(schemaItem, slug) {
518
453
  return `${this.root}:create:${this.collectionSlugKey(schemaItem, slug)}`;
519
454
  }
520
455
  /**
521
- * Lock key for a write()/delete() pre-pass classification. Existing
522
- * entries lock on their content ID (idLockKey()). Legacy entries with no
523
- * embedded ID (pre-ID-era filenames -- renameEntry() already refuses to
524
- * touch these, via its four-part filename check, so there is no rename
525
- * race to guard against for them) fall back to the physical path, matching
526
- * this store's original locking behavior for that narrow case. Not-yet-
527
- * existing entries lock on a per-slug create-key (createLockKey()).
456
+ * Lock key for a write()/delete() pre-pass classification: an existing entry
457
+ * locks on its content ID (idLockKey()), a not-yet-existing one on a per-slug
458
+ * create-key (createLockKey()). An entry with no embedded ID falls back to its
459
+ * physical path -- renameEntry() refuses to touch those (its four-part filename
460
+ * check), so they have no rename race to guard against.
528
461
  */
529
462
  entryLockKey(schemaItem, slug, prePass) {
530
463
  if (!prePass.existed)
@@ -535,9 +468,6 @@ export class ContentStore {
535
468
  * Refuse a create/rename that would give a second entry a `urlPath` another entry already
536
469
  * holds. See url-collision.ts for the two shapes that count, and the legitimate
537
470
  * landing-page-beside-a-collection shape that deliberately does not.
538
- *
539
- * @param collectionDir Absolute physical directory the entry will live in.
540
- * @param slug The entry's slug, as it will be written.
541
471
  */
542
472
  async assertUrlPathAvailable(collectionDir, slug) {
543
473
  const claimant = await findUrlPathClaimant({
@@ -558,45 +488,36 @@ export class ContentStore {
558
488
  throw new UrlPathConflictError(message, claimant.physicalPath);
559
489
  }
560
490
  /**
561
- * Build absolute and relative paths with security validation.
562
- * All entries use the unified filename pattern: {type}.{slug}.{id}.{ext}
491
+ * Build absolute and relative paths for an entry, filename pattern
492
+ * `{type}.{slug}.{id}.{ext}`. `options.existingId` addresses a known entry; without it a
493
+ * new ID is generated. `options.entryTypeName` picks among a collection's entry types,
494
+ * defaulting to its default one.
563
495
  *
564
- * SECURITY BOUNDARY: This method prevents path traversal attacks by:
565
- * 1. Validating that resolved paths stay within the content root
566
- * 2. Checking slugs for malicious patterns (via validateSlug)
567
- * 3. Using path.resolve to normalize paths before validation
568
- *
569
- * This validation is performed BEFORE file I/O in resolveDocumentPath(),
570
- * ensuring permission checks happen before any file system access.
571
- *
572
- * @param options.existingId - Optional ID to use (for edits). If not provided, generates new ID.
573
- * @param options.entryTypeName - For collections with multiple entry types, specify which one to use. Defaults to the default entry type.
496
+ * SECURITY BOUNDARY against path traversal: resolved paths must stay inside the content
497
+ * root, slugs go through `validateSlug`, and everything is `path.resolve`d before those
498
+ * checks. All of it runs BEFORE any file I/O in resolveDocumentPath(), so permission checks
499
+ * happen before filesystem access.
574
500
  */
575
501
  async buildPaths(schemaItem, slug, options = {}) {
576
502
  const rootWithSep = this.root.endsWith(path.sep) ? this.root : `${this.root}${path.sep}`;
577
- // Entry-type items: delegate to their parent collection.
578
- // Uses the same {type}.{slug}.{id}.{ext} pattern as all entries.
579
- //
580
- // This branch is for DIRECT ContentStore usage -- store.read('content/home', ''), and the
581
- // read({ entryPath: 'content/home' }) API built on it, where the slug defaults to the entry
582
- // type's own name. The API layer resolves paths via resolvePath(), which returns the parent
583
- // collection directly and so never lands here.
503
+ // Entry-type items delegate to their parent collection, using the same
504
+ // {type}.{slug}.{id}.{ext} pattern, with the slug defaulting to the entry type's own
505
+ // name. That is DIRECT ContentStore usage -- store.read('content/home', '') and the
506
+ // read({ entryPath: 'content/home' }) API built on it. The API layer resolves paths via
507
+ // resolvePath(), which returns the parent collection directly and so never lands here.
584
508
  //
585
- // That used to be an observation, and it was WRONG: readByUrlPath reached this branch too,
586
- // because `resolveUrlPathCandidates` happily produces `content/<typeName>` for the URL
587
- // `/<typeName>` and the delegation below then answered it with the parent collection's index
588
- // entry -- a URL no forward surface publishes. It is now enforced rather than assumed:
589
- // readByUrlPath requires every candidate's entryPath to be a collection (see
590
- // `isCollectionPath` and `ReadContentInput.urlAddressableOnly`). Narrowing the delegation
591
- // ITSELF was the wrong fix -- write()/renameEntry()/delete() resolve through here as well,
592
- // and the by-URL rule has no business constraining them.
509
+ // readByUrlPath must not reach this branch: `resolveUrlPathCandidates` produces
510
+ // `content/<typeName>` for the URL `/<typeName>`, which the delegation would answer with
511
+ // the parent collection's index entry -- a URL no forward surface publishes. That is
512
+ // enforced on the read path (every candidate's entryPath must be a collection, see
513
+ // `isCollectionPath` and `ReadContentInput.urlAddressableOnly`) rather than by narrowing
514
+ // the delegation itself, which write()/renameEntry()/delete() also resolve through.
593
515
  if (schemaItem.type === 'entry-type') {
594
516
  const parentPath = schemaItem.parentPath || '';
595
517
  const parentCollection = this.schemaIndex.get(parentPath);
596
518
  if (!parentCollection || parentCollection.type !== 'collection') {
597
519
  throw new ContentStoreError(`Parent collection not found for entry type: ${schemaItem.name}`, 'NO_SCHEMA_ITEM');
598
520
  }
599
- // Use provided slug, falling back to entry type name
600
521
  const effectiveSlug = slug || schemaItem.name;
601
522
  return this.buildPaths(parentCollection, effectiveSlug, {
602
523
  ...options,
@@ -611,17 +532,14 @@ export class ContentStore {
611
532
  }
612
533
  // Security: Validate slug format (prevents ../../../etc/passwd)
613
534
  validateSlug(safeSlug);
614
- // Determine which entry type to use
615
535
  let entryTypeConfig;
616
536
  if (options.entryTypeName) {
617
- // Use specified entry type
618
537
  entryTypeConfig = schemaItem.entries?.find((e) => e.name === options.entryTypeName);
619
538
  if (!entryTypeConfig) {
620
539
  throw new ContentStoreError(`Entry type '${options.entryTypeName}' not found in collection`, 'NO_SCHEMA_ITEM');
621
540
  }
622
541
  }
623
542
  else {
624
- // Use default entry type
625
543
  entryTypeConfig = getDefaultEntryType(schemaItem.entries);
626
544
  }
627
545
  const format = entryTypeConfig?.format || 'json';
@@ -631,26 +549,22 @@ export class ContentStore {
631
549
  // e.g., "content/docs/api" → "content/docs.bChqT78gcaLd/api.meiuwxTSo7UN"
632
550
  let collectionRoot = await resolveCollectionPath(this.root, schemaItem.logicalPath);
633
551
  if (!collectionRoot) {
634
- // Collection directory doesn't exist yet - use logical path
635
- // (Directory will be created on write if needed)
552
+ // No collection directory yet: use the logical path, write() creates it.
636
553
  collectionRoot = path.resolve(this.root, schemaItem.logicalPath);
637
554
  }
638
555
  // Security: Prevent path traversal at collection level
639
556
  if (!collectionRoot.startsWith(rootWithSep)) {
640
557
  throw new ContentStoreError('Path traversal detected', 'VALIDATION');
641
558
  }
642
- // Check if file already exists (editing case)
643
559
  let id = options.existingId;
644
560
  let existingFilename;
645
561
  let existingEntryType;
646
562
  let foundExisting = false;
647
563
  if (!id) {
648
- // Try to find existing file with this slug
649
564
  const entries = await fs.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
650
565
  const existingFile = entries.find((entry) => {
651
566
  if (entry.isDirectory())
652
567
  return false;
653
- // Extract entry type from filename to check slug properly
654
568
  const fileEntryType = extractEntryTypeFromFilename(entry.name);
655
569
  const existingSlug = extractSlugFromFilename(entry.name, fileEntryType || undefined);
656
570
  return existingSlug === safeSlug;
@@ -664,27 +578,17 @@ export class ContentStore {
664
578
  foundExisting = true;
665
579
  }
666
580
  }
667
- // An entry "existed" if the directory scan above found it, OR the
668
- // caller asserted an existingId (a presumed edit -- see buildPaths()'s
669
- // doc comment on options.existingId). Not the same as `id` being
670
- // truthy: a brand-new entry gets a freshly generated id below too.
671
581
  const existed = foundExisting || Boolean(options.existingId);
672
- // For existing entries, preserve the entry type (immutable after creation)
673
- // For new entries, use the specified entry type
674
582
  const finalEntryTypeName = existingEntryType || entryTypeName;
675
- // Build filename: use existing filename if found, or generate new one with ID
676
583
  let filename;
677
584
  if (existingFilename) {
678
585
  // Existing file found - use its original filename to preserve on-disk casing
679
586
  filename = existingFilename;
680
587
  }
681
588
  else {
682
- // Generate new ID if needed
683
589
  if (!id) {
684
590
  id = generateId();
685
591
  }
686
- // Build filename with embedded ID: type.slug.id.ext
687
- // Use finalEntryTypeName to preserve entry type for existing entries
688
592
  filename = `${finalEntryTypeName}.${safeSlug}.${id}${ext}`;
689
593
  }
690
594
  const resolved = path.resolve(collectionRoot, filename);
@@ -705,16 +609,12 @@ export class ContentStore {
705
609
  }
706
610
  throw new ContentStoreError('Invalid schema item type', 'VALIDATION');
707
611
  }
708
- /**
709
- * Path resolution: resolves a URL path to a schema item
710
- * - Try as collection + slug (last segment = slug)
711
- */
612
+ /** Resolve path segments to a collection schema item plus the trailing slug. */
712
613
  resolvePath(pathSegments) {
713
614
  if (pathSegments.length === 0) {
714
615
  throw new ContentStoreError('Empty path', 'VALIDATION');
715
616
  }
716
617
  const logicalPath = pathSegments.join('/');
717
- // Try as collection + slug
718
618
  // Last segment of an API-validated LogicalPath; normalize to lowercase
719
619
  const slug = pathSegments[pathSegments.length - 1].toLowerCase();
720
620
  const collectionPath = pathSegments.slice(0, -1).join('/');
@@ -735,18 +635,16 @@ export class ContentStore {
735
635
  async read(collectionPath, slug = '', options = {}) {
736
636
  const schemaItem = this.assertSchemaItem(collectionPath);
737
637
  const { absolutePath, relativePath, entryTypeName: resolvedEntryTypeName, } = await this.buildPaths(schemaItem, slug);
738
- // stat BEFORE readFile: conservative version token that can only produce false-positive
739
- // conflicts, never false-negatives. If a write lands between stat and readFile the client
740
- // receives newer content but an older token → their next save triggers a 409 (safe).
741
- // stat-after or parallel stat+read risks the opposite: old content + new token → silent
742
- // overwrite of a concurrent write (data loss).
638
+ // stat BEFORE readFile: a conservative version token can only produce false-positive
639
+ // conflicts. A write landing between stat and readFile gives the client newer content with
640
+ // an older token, so their next save 409s (safe); stat-after or a parallel stat+read risks
641
+ // the opposite, old content with a new token -- a silent overwrite of a concurrent write.
743
642
  const stat = await fs.stat(absolutePath);
744
643
  const raw = await fs.readFile(absolutePath, 'utf8');
745
644
  let doc;
746
645
  let format;
747
646
  let fields;
748
647
  if (schemaItem.type === 'entry-type') {
749
- // Entry type from unified model
750
648
  format = schemaItem.format;
751
649
  fields = schemaItem.schema;
752
650
  }
@@ -791,15 +689,11 @@ export class ContentStore {
791
689
  collection: schemaItem.logicalPath,
792
690
  collectionName: schemaItem.name,
793
691
  format: format,
794
- // Copy, never the object gray-matter hands back: its cache is
795
- // process-global and keyed by file content, so every caller parsing the
796
- // same bytes gets the SAME `data` instance (the hazard content-listing.ts
797
- // documents at length). Assigning it straight into `doc.data` was safe
798
- // only by accident -- `resolveReferencesInData` spreads its input, so the
799
- // shared object got replaced a few lines down. But that only happens when
800
- // resolution runs: with `resolveReferences: false` the caller was handed
801
- // the shared instance itself, and one mutation would poison every later
802
- // parse of those bytes.
692
+ // Copy, never the object gray-matter hands back: its cache is process-global and
693
+ // keyed by file content, so every caller parsing the same bytes gets the SAME `data`
694
+ // instance (the hazard content-listing.ts documents at length). With
695
+ // `resolveReferences: false` the caller would otherwise be handed that shared
696
+ // instance, and one mutation would poison every later parse of those bytes.
803
697
  data: { ...(parsed.data ?? {}) },
804
698
  body: parsed.content,
805
699
  bodyFieldName: findBodyFieldName(fields),
@@ -807,7 +701,6 @@ export class ContentStore {
807
701
  absolutePath,
808
702
  };
809
703
  }
810
- // Automatic reference resolution (defaults to true)
811
704
  if (options.resolveReferences !== false) {
812
705
  doc.data = await this.resolveReferencesInData(doc.data, fields);
813
706
  }
@@ -826,7 +719,6 @@ export class ContentStore {
826
719
  fields = schemaItem.schema;
827
720
  }
828
721
  else {
829
- // For collections, determine format from specified or default entry type
830
722
  let entryTypeConfig;
831
723
  if (entryTypeName) {
832
724
  entryTypeConfig = schemaItem.entries?.find((e) => e.name === entryTypeName);
@@ -843,38 +735,29 @@ export class ContentStore {
843
735
  if (expectedFormat !== input.format) {
844
736
  throw new ContentStoreError(`Format mismatch: expects ${expectedFormat}, got ${input.format}`, 'VALIDATION');
845
737
  }
846
- // Pre-pass: resolve paths OUTSIDE the lock, but ONLY to classify
847
- // existing-vs-new and pick a stable lock key -- never used for the
848
- // actual write. This trades away the old "validation errors surface
849
- // before the lock" property for buildPaths() specifically (the format
850
- // check above still runs unlocked): buildPaths() resolves the physical
851
- // path by directory-scanning for the slug, and that resolution can go
852
- // stale the instant a concurrent renameEntry() completes, so it must be
853
- // re-resolved (ground truth) after the lock is held -- see
854
- // .claude/future-tasks/resolved/content-store-lock-key.md and entryLockKey().
738
+ // Pre-pass: resolve paths OUTSIDE the lock, but ONLY to classify existing-vs-new and pick
739
+ // a stable lock key -- never for the actual write. buildPaths() resolves the physical path
740
+ // by directory-scanning for the slug, and that resolution goes stale the instant a
741
+ // concurrent renameEntry() completes, so it must be re-resolved as ground truth once the
742
+ // lock is held (see entryLockKey()). The format check above still runs unlocked.
855
743
  const prePass = await this.buildPaths(schemaItem, slug, { entryTypeName, existingId });
856
744
  let lockKey = this.entryLockKey(schemaItem, slug, prePass);
857
- // Reclassification loop: the pre-pass picked the lock key, but the world
858
- // can change between the pre-pass and lock acquisition (a concurrent
859
- // renameEntry() freeing this slug flips existing->new; a concurrent
860
- // create landing flips new->existing). Writing under the WRONG kind of
861
- // key would bypass serialization against the writers holding the right
862
- // one (e.g. a reclassified-to-create write under an id-key racing a
863
- // create-key holder on the same slug -> two same-slug files). So after
864
- // the in-lock re-resolution, re-derive the key; on mismatch, release and
865
- // re-acquire under the current key. Bounded: each flip requires another
866
- // mutator to have completed in the gap; the cap only guards pathological
867
- // scheduling.
868
- // [SYNC-C1] Cross-host exclusion against the worker's rebase loop wraps
869
- // the WHOLE reclassification loop: one acquisition per call, and no
870
- // window between attempts where a rebase could start.
745
+ // Reclassification loop: the world can change between the pre-pass and lock acquisition (a
746
+ // concurrent renameEntry() freeing this slug flips existing->new; a concurrent create
747
+ // landing flips new->existing). Writing under the WRONG kind of key bypasses serialization
748
+ // against writers holding the right one -- e.g. a reclassified-to-create write under an
749
+ // id-key racing a create-key holder on the same slug, producing two same-slug files. So
750
+ // after the in-lock re-resolution, re-derive the key and, on mismatch, release and
751
+ // re-acquire under the current one. Bounded: each flip requires another mutator to have
752
+ // completed in the gap, so the cap only guards pathological scheduling.
753
+ // [SYNC-C1] Cross-host exclusion against the worker's rebase loop wraps the WHOLE loop:
754
+ // one acquisition per call, and no window between attempts where a rebase could start.
871
755
  return this.withContentWriteExclusion(async () => {
872
756
  const RETRY_KEY = Symbol('retry-with-new-lock-key');
873
757
  for (let attempt = 0; attempt < 10; attempt++) {
874
758
  const outcome = await withLock(lockKey, async () => {
875
- // Re-resolve inside the lock: ground truth after acquisition. A
876
- // concurrent renameEntry() may have moved this entry between the
877
- // pre-pass above and acquiring this lock.
759
+ // Re-resolve inside the lock: ground truth after acquisition, since a concurrent
760
+ // renameEntry() may have moved this entry since the pre-pass.
878
761
  const inLock = await this.buildPaths(schemaItem, slug, {
879
762
  entryTypeName,
880
763
  existingId,
@@ -885,35 +768,29 @@ export class ContentStore {
885
768
  return RETRY_KEY;
886
769
  }
887
770
  const { absolutePath, relativePath, id } = inLock;
888
- // [F1] Duplicate-ID guard. INVARIANT: a write must never remove or
889
- // modify a file it did not address. The post-write index-repair
890
- // step below deletes the ID's previously-indexed path when it
891
- // differs from the one being written ("the slug changed"); that is
892
- // only sound while an ID identifies exactly one file. When a
893
- // duplicate-ID pair is on disk (ContentIdIndex's quarantine, from
894
- // rename-crash debris or a merge) the quarantined file is still
895
- // addressable by collection+slug -- buildPaths() resolves slugs by
896
- // directory scan and knows nothing about the quarantine -- so a
897
- // save to it used to resolve the index to the OTHER file and unlink
898
- // that one: a different document, silently deleted, with the write
899
- // reporting success. Refuse instead (see DuplicateContentIdError).
771
+ // [F1] Duplicate-ID guard. INVARIANT: a write must never remove or modify a file it
772
+ // did not address. The post-write index-repair step below deletes the path the index
773
+ // holds for this ID when it differs from the one being written ("the slug changed"),
774
+ // which is sound only while an ID identifies exactly one file. A
775
+ // quarantined duplicate (ContentIdIndex's quarantine, from rename-crash debris or a
776
+ // merge) is still addressable by collection+slug -- buildPaths() resolves slugs by
777
+ // directory scan and knows nothing about the quarantine -- so a save to it would
778
+ // unlink a different document while reporting success. Refuse instead (see
779
+ // DuplicateContentIdError).
900
780
  //
901
- // Runs before any mutation, so a refusal leaves the tree exactly as
902
- // it was. Two independent detections, because neither alone covers
903
- // everything:
904
- // 1. the index's own quarantine record -- catches a duplicate
905
- // whose other copy lives in a different directory, and the
906
- // ID-addressed (existingId) shape where the target is a fresh
907
- // third path; needs the index to have scanned the duplicate.
908
- // 2. disk-verified ambiguity -- both the file we are about to
909
- // write AND the indexed location exist right now. This one
910
- // does not depend on index freshness at all, which is what
911
- // makes the common (slug-addressed) save safe even against an
912
- // index built before the duplicate landed.
913
- // Neither fires for the ordinary slug-change save of a
914
- // non-duplicated entry: there the indexed path exists but the
915
- // target does not (it is about to be created), so the cleanup
916
- // below still removes exactly the file the caller relocated.
781
+ // Runs before any mutation, so a refusal leaves the tree exactly as it was. Two
782
+ // independent detections, because neither alone covers everything:
783
+ // 1. the index's own quarantine record -- catches a duplicate whose other copy
784
+ // lives in a different directory, and the ID-addressed (existingId) shape where
785
+ // the target is a fresh third path; needs the index to have scanned the
786
+ // duplicate.
787
+ // 2. disk-verified ambiguity -- the file we are about to write AND the indexed
788
+ // location both exist right now. Independent of index freshness, which is what
789
+ // makes the common slug-addressed save safe even against an index built before
790
+ // the duplicate landed.
791
+ // Neither fires for the ordinary slug-change save of a non-duplicated entry: there
792
+ // the indexed path exists but the target does not (it is about to be created), so
793
+ // the cleanup below still removes exactly the file the caller relocated.
917
794
  if (id) {
918
795
  const guardIndex = this._idIndex;
919
796
  const indexedPath = guardIndex.findById(id)?.relativePath ?? null;
@@ -932,46 +809,40 @@ export class ContentStore {
932
809
  }
933
810
  }
934
811
  // [URL] Contested-URL guard, create only: an ordinary save cannot contest a URL it
935
- // already holds, and blocking it would trap the author in an entry they can no longer
936
- // edit.
812
+ // already holds, and blocking it would trap the author in an entry they can no
813
+ // longer edit.
937
814
  //
938
- // Keyed on whether the target file actually EXISTS, not on `inLock.existed`. That flag
939
- // is `foundExisting || Boolean(options.existingId)`, and the `existingId` half is a
940
- // caller ASSERTION rather than disk truth -- so an id-addressed write recreating an
941
- // entry that was deleted out from under it (git, another process) would skip the guard
942
- // while in fact creating one.
815
+ // Keyed on whether the target file actually EXISTS, not on `inLock.existed`, whose
816
+ // `existingId` half is a caller ASSERTION rather than disk truth -- so an
817
+ // id-addressed write recreating an entry deleted out from under it (git, another
818
+ // process) would skip the guard while in fact creating one.
943
819
  //
944
820
  // The slug is read back off the filename `buildPaths` actually chose, never the
945
- // caller's raw argument: `buildPaths` strips leading slashes and lowercases, and for
946
- // the entry-type delegation shape substitutes the type name for an empty slug. Checking
947
- // the raw value let `write(collection, '/guides', ...)` sail past a `guides` claimant.
821
+ // caller's raw argument, which it strips leading slashes from and lowercases:
822
+ // checking the raw value let `write(collection, '/guides', ...)` sail past a
823
+ // `guides` claimant.
948
824
  //
949
825
  // Serialization comes from `withContentWriteExclusion` (the branch-wide, cross-host
950
- // content-write lock wrapping this whole reclassification loop), NOT from the per-entry
951
- // lock: the two halves of a contested pair live in DIFFERENT collections and so take
952
- // different entry-lock keys. The branch lock is what makes check-then-write atomic
953
- // against the other half landing concurrently.
826
+ // lock wrapping this whole loop), NOT the per-entry lock: the two halves of a
827
+ // contested pair live in DIFFERENT collections and take different entry-lock keys.
954
828
  if (!(await filePathExists(absolutePath))) {
955
829
  const chosenSlug = extractSlugFromFilename(path.basename(absolutePath));
956
- // [SLUG] Routability guard, create only. `validateSlug` above (in buildPaths) is the
957
- // path-traversal check -- it only rejects `/` and `\`, and it has to stay that weak
958
- // because it also runs on every READ. So the filename grammar accepts slugs that
959
- // `parseSlug` does not (a dot, an underscore, a leading hyphen), and `readByUrlPath`
960
- // runs every URL candidate through `parseSlug` before trying a read -- meaning such
961
- // an entry writes fine, builds fine, gets a `generateStaticParams` entry and a
962
- // sitemap `<loc>`, and then 404s on every actual visit. `assertRoutableSlugs`
963
- // (static/index.ts) now fails the whole production build over it.
830
+ // [SLUG] Routability guard, create only. `validateSlug` (in buildPaths) is the
831
+ // path-traversal check and rejects only `/` and `\`, and has to stay that weak
832
+ // because it also runs on every READ. So the filename grammar accepts slugs
833
+ // `parseSlug` rejects (a dot, an underscore, a leading hyphen), while
834
+ // `readByUrlPath` runs every URL candidate through `parseSlug` -- such an entry
835
+ // writes fine, builds fine, gets a `generateStaticParams` entry and a sitemap
836
+ // `<loc>`, then 404s on every actual visit. `assertRoutableSlugs`
837
+ // (static/index.ts) fails the whole production build over it.
964
838
  //
965
- // Refuse at the moment the unroutable entry is CREATED, so the failure lands on the
966
- // caller that caused it rather than on whoever builds next. Create-only, keyed on the
967
- // target file not existing, for exactly the reason the [URL] guard below is: content
968
- // that already has a non-conforming slug (hand-authored, imported, or predating this
969
- // guard) must stay editable -- and must stay renameable, which is the only way out.
970
- // Tightening the read path instead would turn a red build into unreachable data.
971
- //
972
- // Checked against the slug `buildPaths` actually chose, not the caller's raw
973
- // argument: buildPaths lowercases, strips leading slashes, and substitutes the entry
974
- // type's name for an empty slug (the singleton shape, e.g. write('content/home', '')).
839
+ // Refuse at the moment the entry is CREATED, so the failure lands on the caller
840
+ // that caused it rather than on whoever builds next. Create-only, keyed on the
841
+ // target file not existing, for the same reason as the [URL] guard: content that
842
+ // already has a non-conforming slug must stay editable, and renaming is the only
843
+ // way out. Checked against the slug `buildPaths` chose (lowercased, leading
844
+ // slashes stripped, entry-type name substituted for an empty slug), not the
845
+ // caller's raw argument.
975
846
  const routable = parseSlug(chosenSlug);
976
847
  if (!routable.ok) {
977
848
  throw new ContentStoreError(`Cannot create entry "${chosenSlug}": ${routable.error}. An entry whose slug is ` +
@@ -980,15 +851,13 @@ export class ContentStore {
980
851
  await this.assertUrlPathAvailable(path.dirname(absolutePath), chosenSlug);
981
852
  }
982
853
  await fs.mkdir(path.dirname(absolutePath), { recursive: true });
983
- // OCC: undefined means no opinion (skip entirely, back-compat blind
984
- // write). A number means "must match this mtime" (stale-write
985
- // rejection). `null` means "must NOT exist yet" — the create-intent
986
- // guard: without this, a create request against a slug that already
987
- // has content falls through to an ordinary blind overwrite (August
988
- // 2026 baseline review, Critical finding). This is the authoritative
989
- // check — it runs inside the per-entry lock against a fresh stat, so
990
- // it holds even if a caller's own pre-write existence check (e.g. the
991
- // API layer's `documentExists`) went stale under concurrency.
854
+ // OCC: undefined means no opinion (skip entirely, a blind write). A number means
855
+ // "must match this mtime" (stale-write rejection). `null` means "must NOT exist
856
+ // yet" — the create-intent guard, without which a create request against a slug
857
+ // that already has content falls through to an ordinary blind overwrite. This is
858
+ // the authoritative check: it runs inside the per-entry lock against a fresh stat,
859
+ // so it holds even if a caller's own pre-write existence check (e.g. the API
860
+ // layer's `documentExists`) went stale under concurrency.
992
861
  if (input.expectedVersion !== undefined) {
993
862
  try {
994
863
  const existing = await fs.stat(absolutePath);
@@ -1003,46 +872,39 @@ export class ContentStore {
1003
872
  if (err instanceof ContentConflictError)
1004
873
  throw err;
1005
874
  if (isNodeError(err) && err.code === 'ENOENT') {
1006
- // File doesn't exist yet: for a numeric expectedVersion this is
1007
- // the existing "first write, skip version check" back-compat
1008
- // behavior; for expectedVersion === null this is the success
1009
- // case (create-only correctly finds no collision) — either way,
1010
- // proceed with the write.
875
+ // No file yet: for a numeric expectedVersion this is the "first write,
876
+ // skip the version check" back-compat behavior; for null it is create-only
877
+ // correctly seeing no collision. Either way, proceed with the write.
1011
878
  }
1012
879
  else {
1013
880
  throw err;
1014
881
  }
1015
882
  }
1016
883
  }
1017
- // Existence guard (cross-process): the caller asserts this entry already
1018
- // exists (existingId), so if no file is at the path we are about to
1019
- // write, this store's index may be stale — another process may have
1020
- // renamed the entry. Recreating a renamed entry's old path would leave
1021
- // two files with the same embedded ID and poison every subsequent index
1022
- // rebuild (ID collision). The directory listing is authoritative on this
1023
- // host: if the ID's actual on-disk location differs from what our index
1024
- // believes, fail with a conflict so the caller reloads fresh state.
1025
- // (An intentional slug-change save passes: the index and the directory
1026
- // agree on the entry's current — old-slug — path. External deletes also
1027
- // pass: the ID is nowhere on disk, so recreating is last-writer-wins.)
884
+ // Existence guard (cross-process): the caller asserts this entry exists
885
+ // (existingId), so a missing file at the path about to be written means this store's
886
+ // index may be stale -- another process may have renamed the entry. Recreating a
887
+ // renamed entry's old path would leave two files with the same embedded ID and
888
+ // poison every subsequent index rebuild. The directory listing is authoritative on
889
+ // this host: if the ID's actual on-disk location differs from what the index
890
+ // believes, fail with a conflict so the caller reloads fresh state. (An intentional
891
+ // slug-change save passes: index and directory agree on the entry's current,
892
+ // old-slug path. External deletes also pass: the ID is nowhere on disk, so
893
+ // recreating is last-writer-wins.)
1028
894
  //
1029
- // indexedRelPath reads the LIVE index synchronously — NOT idIndex() —
1030
- // because idIndex() would run a full rescan while holding the entry
1031
- // lock if invalidateIndex() fired between the pre-lock warm-up above
1032
- // and here. The live index may then be stale, but staleness only errs
1033
- // toward throwing ContentConflictError: actualRelPath comes from the
1034
- // fresh in-lock directory scan just below (ground truth), so a stale
1035
- // indexedRelPath can only turn agree->disagree (spurious conflict,
1036
- // which the caller already handles by reloading), never
1037
- // disagree->agree. Fail-closed, never fail-open.
895
+ // indexedRelPath reads the LIVE index synchronously — NOT idIndex(), which would
896
+ // run a full rescan while holding the entry lock if invalidateIndex() fired since
897
+ // the pre-lock warm-up. A stale live index only errs toward throwing: actualRelPath
898
+ // comes from the fresh in-lock directory scan (ground truth), so staleness can turn
899
+ // agree->disagree (a spurious conflict the caller already handles by reloading),
900
+ // never disagree->agree. Fail-closed, never fail-open.
1038
901
  if (existingId && !(await filePathExists(absolutePath))) {
1039
902
  const actualRelPaths = await this.findEntryPathsById(path.dirname(absolutePath), existingId);
1040
- // [F1] Two files in the target directory carry this ID -- a
1041
- // duplicate the index has not scanned yet (the guard above asks
1042
- // the index; this asks the directory). Which one a single-match
1043
- // scan returns is readdir-order-dependent, so it could 409 or
1044
- // pass at random, and passing meant the cleanup below unlinked
1045
- // one of two indistinguishable documents. Refuse deterministically.
903
+ // [F1] Two files in the target directory carry this ID -- a duplicate the index
904
+ // has not scanned yet (the guard above asks the index; this asks the directory).
905
+ // Which one a single-match scan returns is readdir-order-dependent, so it would
906
+ // 409 or pass at random, and passing meant unlinking one of two indistinguishable
907
+ // documents. Refuse deterministically.
1046
908
  if (actualRelPaths.length > 1) {
1047
909
  throw new DuplicateContentIdError(existingId, actualRelPaths);
1048
910
  }
@@ -1052,27 +914,25 @@ export class ContentStore {
1052
914
  throw new ContentConflictError();
1053
915
  }
1054
916
  }
1055
- // Comment preservation: re-serialise onto the file's OWN parsed document rather than
1056
- // a fresh one, so nodes the payload did not change -- and the comments attached to
1057
- // them -- survive the write. Without this every editor save silently deleted every
1058
- // comment in the file (JSON has no comment syntax, so it is unaffected and skips the
1059
- // read). See utils/content-serialize.ts.
917
+ // Comment preservation: re-serialise onto the file's OWN parsed document rather
918
+ // than a fresh one, so nodes the payload did not change -- and their attached
919
+ // comments -- survive the write. JSON has no comment syntax and skips the read. See
920
+ // utils/content-serialize.ts.
1060
921
  //
1061
- // This makes the write a genuine read-modify-write of the content file, so WHERE the
1062
- // read happens matters: it is inside withLock(lockKey), inside
1063
- // withContentWriteExclusion ([SYNC-C1]), and after the expectedVersion stat above --
1064
- // so the bytes read here are the bytes that OCC check validated, and no rebase can be
922
+ // This makes the write a genuine read-modify-write of the content file, so WHERE
923
+ // the read happens matters: inside withLock(lockKey), inside
924
+ // withContentWriteExclusion ([SYNC-C1]), and after the expectedVersion stat -- so
925
+ // the bytes read here are the bytes that OCC check validated, and no rebase can be
1065
926
  // running against this branch. Do not hoist it out of the critical section.
1066
927
  //
1067
928
  // INVARIANT, and the one case that does NOT preserve comments: this reads the path
1068
- // being WRITTEN. A relocating write -- one where the ID resolves to a different
1069
- // relativePath, so the block below unlinks `staleOldAbsPath` -- finds nothing at the
1070
- // new path and falls back to a plain stringify, losing the old file's comments. Latent
929
+ // being WRITTEN. A relocating write -- the ID resolves to a different relativePath,
930
+ // so the block below unlinks `staleOldAbsPath` -- finds nothing at the new path and
931
+ // falls back to a plain stringify, losing the old file's comments. Unreachable
1071
932
  // today: the editor renames through renameEntry(), which link()s the bytes across
1072
- // intact, and no caller passes `existingId`. If a relocating write ever becomes
933
+ // intact, and no caller passes `existingId`. If a relocating write becomes
1073
934
  // reachable, read the ID's current path here instead of `absolutePath`.
1074
935
  const existingRaw = input.format === 'json' ? undefined : await readFileIfExists(absolutePath);
1075
- // Serialize content string
1076
936
  let content;
1077
937
  if (input.format === 'json') {
1078
938
  content = `${JSON.stringify(input.data ?? {}, null, 2)}\n`;
@@ -1084,10 +944,9 @@ export class ContentStore {
1084
944
  content = serializeFrontmatter(input.body, input.data ?? {}, existingRaw);
1085
945
  }
1086
946
  await atomicWriteFile(absolutePath, content);
1087
- // Update the ID index after a successful write. Look up and mutate the
1088
- // LIVE index in one synchronous window (no awaits in between): a
1089
- // concurrent rebuild may have swapped in a fresh instance since the
1090
- // pre-write snapshot, and updates must land where future lookups go.
947
+ // Update the ID index after a successful write. Look up and mutate the LIVE index
948
+ // in one synchronous window (no awaits in between): a concurrent rebuild may have
949
+ // swapped in a fresh instance, and updates must land where future lookups go.
1091
950
  const liveIndex = this._idIndex;
1092
951
  let staleOldAbsPath = null;
1093
952
  if (id) {
@@ -1145,8 +1004,8 @@ export class ContentStore {
1145
1004
  if (typeof outcome !== 'symbol')
1146
1005
  return outcome;
1147
1006
  }
1148
- // Ten completed foreign mutations landed in our acquisition gaps in a
1149
- // row — treat as contention and let the caller reload + retry.
1007
+ // Ten foreign mutations in our acquisition gaps in a row: contention, so let the caller
1008
+ // reload and retry.
1150
1009
  throw new ContentConflictError();
1151
1010
  });
1152
1011
  }
@@ -1186,7 +1045,6 @@ export class ContentStore {
1186
1045
  }
1187
1046
  }
1188
1047
  /**
1189
- * Get the ID for an entry given its collection and slug.
1190
1048
  * Returns null if no ID exists yet.
1191
1049
  */
1192
1050
  async getIdForEntry(collectionPath, slug) {
@@ -1213,20 +1071,16 @@ export class ContentStore {
1213
1071
  }
1214
1072
  }
1215
1073
  /**
1216
- * Resolve the actual on-disk entry type of an existing collection entry, by
1217
- * inspecting its filename directly. Entry filenames embed their type
1218
- * (`{type}.{slug}.{id}.{ext}`) and it is immutable after creation — see the
1219
- * existing-file lookup in buildPaths(), which this mirrors. Returns
1220
- * undefined if no entry exists yet at this slug.
1074
+ * The on-disk entry type of an existing collection entry, read from its filename
1075
+ * (`{type}.{slug}.{id}.{ext}`), which is immutable after creation -- mirroring the
1076
+ * existing-file lookup in buildPaths(). Undefined if no entry exists yet at this slug.
1221
1077
  *
1222
- * Used at the API write boundary (api/content.ts) to validate an existing
1223
- * entry's payload against ITS real schema rather than a caller-supplied (or
1224
- * omitted/spoofed) `entryType` param — ContentStore.write() preserves the
1225
- * on-disk type regardless of what's requested, so validation must agree
1226
- * with what will actually be written (post-review M2).
1078
+ * The API write boundary (api/content.ts) validates an existing entry's payload against ITS
1079
+ * real schema rather than a caller-supplied (or omitted, or spoofed) `entryType` param:
1080
+ * ContentStore.write() preserves the on-disk type regardless of what is requested, so
1081
+ * validation must agree with what will actually be written.
1227
1082
  *
1228
- * Cheap: one buildPaths() resolution plus a stat, no file content is read —
1229
- * same cost class as documentExists().
1083
+ * Cheap: one buildPaths() resolution plus a stat, no file content is read.
1230
1084
  */
1231
1085
  async getExistingEntryType(collectionPath, slug = '') {
1232
1086
  const schemaItem = this.assertCollection(collectionPath);
@@ -1234,9 +1088,9 @@ export class ContentStore {
1234
1088
  return (await filePathExists(absolutePath)) ? entryTypeName : undefined;
1235
1089
  }
1236
1090
  /**
1237
- * Count existing entries of a given entry type in a collection, by filename
1238
- * (entry filenames embed their type: `{type}.{slug}.{id}.{ext}`). Used to
1239
- * enforce `EntryTypeConfig.maxItems` server-side at the create boundary.
1091
+ * Count existing entries of a given entry type in a collection, by filename (entry
1092
+ * filenames embed their type: `{type}.{slug}.{id}.{ext}`). Enforces
1093
+ * `EntryTypeConfig.maxItems` server-side at the create boundary.
1240
1094
  */
1241
1095
  async countEntriesOfType(collectionPath, entryTypeName) {
1242
1096
  this.assertCollection(collectionPath);
@@ -1249,56 +1103,40 @@ export class ContentStore {
1249
1103
  return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
1250
1104
  }
1251
1105
  /**
1252
- * Test-only seam: awaited right after the pre-pass buildPaths() resolves
1253
- * in delete() and renameEntry(), before the lock key is used to acquire
1254
- * anything. No-op in production. A test subclass can override this to
1255
- * inject a controlled pause in that exact window, letting a test
1256
- * deterministically run OTHER mutations (which don't hold this call's
1257
- * not-yet-acquired lock) to completion before this call proceeds to
1258
- * acquire its (possibly now-stale) lock key -- see the "Deterministic
1259
- * interleavings" testing pattern in docs/concurrency.md and
1260
- * branch-registry.test.ts's `BlockingRegistry` for the same idiom.
1106
+ * Test-only seam: awaited right after the pre-pass buildPaths() resolves in delete() and
1107
+ * renameEntry(), while neither has yet acquired a lock. No-op in production. A test subclass
1108
+ * overrides it to pause in that exact window and run OTHER mutations to completion, making
1109
+ * the stale-lock-key path deterministic -- see the "Deterministic interleavings" testing
1110
+ * pattern in docs/concurrency.md and branch-registry.test.ts's `BlockingRegistry`.
1261
1111
  */
1262
1112
  async afterPrePassForTesting() { }
1263
- /**
1264
- * Delete an entry and remove it from the index.
1265
- */
1113
+ /** Delete an entry and remove it from the index. */
1266
1114
  async delete(collectionPath, slug) {
1267
1115
  // Warm the index outside the lock (a full scan must not hold the entry lock)
1268
1116
  await this.idIndex();
1269
1117
  const collection = this.assertCollection(collectionPath);
1270
- // Pre-pass: classify existing-vs-already-gone via a directory scan
1271
- // (local ground truth) -- not this._idIndex, which can be stale in
1272
- // exactly the way this locking scheme guards against (a rename this
1273
- // store hasn't observed yet).
1118
+ // Pre-pass: classify existing-vs-already-gone by directory scan (local ground truth), not
1119
+ // this._idIndex, which can be stale in exactly the way this locking scheme guards against.
1274
1120
  const prePass = await this.buildPaths(collection, slug);
1275
1121
  if (!prePass.existed) {
1276
- // Nothing on disk for this slug -- no shared resource to lock on, and
1277
- // this matches the store's original behavior (fs.unlink throwing
1278
- // ENOENT on the pre-pass's freshly generated, necessarily-nonexistent
1279
- // path).
1122
+ // Nothing on disk for this slug -- no shared resource to lock on, and fs.unlink on the
1123
+ // freshly generated (necessarily nonexistent) path throws ENOENT, as callers expect.
1280
1124
  await fs.unlink(prePass.absolutePath);
1281
1125
  return;
1282
1126
  }
1283
1127
  let lockKey = this.entryLockKey(collection, slug, prePass);
1284
1128
  await this.afterPrePassForTesting();
1285
- // Reclassification loop, mirroring write()'s (see its doc comment): the
1286
- // pre-pass's lock key can go stale between resolution and acquisition --
1287
- // e.g. a concurrent renameEntry() moving this slug's entry away flips
1288
- // the key this slug would resolve to. Re-derive the key from the in-lock
1289
- // ground truth and retry under the corrected key on mismatch, bounded to
1290
- // rule out only pathological scheduling.
1129
+ // Reclassification loop, mirroring write()'s (see its doc comment): a concurrent
1130
+ // renameEntry() moving this slug's entry away flips the key this slug resolves to, so
1131
+ // re-derive the key from in-lock ground truth and retry under the corrected one.
1291
1132
  // [SYNC-C1] See write()'s call to withContentWriteExclusion.
1292
1133
  return this.withContentWriteExclusion(async () => {
1293
1134
  const RETRY_KEY = Symbol('retry-with-new-lock-key');
1294
1135
  for (let attempt = 0; attempt < 10; attempt++) {
1295
1136
  const outcome = await withLock(lockKey, async () => {
1296
- // Re-resolve inside the lock: ground truth after acquisition. If a
1297
- // concurrent renameEntry() moved this slug away in the meantime,
1298
- // buildPaths() no longer finds it here (inLock.existed is false) --
1299
- // the entry genuinely isn't at this slug anymore, so we fall through
1300
- // to the unlink below on a freshly generated (nonexistent) path,
1301
- // preserving the original ENOENT-throwing behavior.
1137
+ // Re-resolve inside the lock: ground truth after acquisition. If a concurrent
1138
+ // renameEntry() moved this slug away, buildPaths() no longer finds it here, so we
1139
+ // fall through to unlink a freshly generated (nonexistent) path and throw ENOENT.
1302
1140
  const inLock = await this.buildPaths(collection, slug);
1303
1141
  const currentKey = this.entryLockKey(collection, slug, inLock);
1304
1142
  if (currentKey !== lockKey) {
@@ -1306,7 +1144,6 @@ export class ContentStore {
1306
1144
  return RETRY_KEY;
1307
1145
  }
1308
1146
  const { absolutePath, relativePath } = inLock;
1309
- // Delete file
1310
1147
  await fs.unlink(absolutePath);
1311
1148
  // Remove from the LIVE index — lookup and mutation in one synchronous
1312
1149
  // window, since a concurrent rebuild may swap instances across awaits.
@@ -1321,20 +1158,14 @@ export class ContentStore {
1321
1158
  if (outcome !== RETRY_KEY)
1322
1159
  return;
1323
1160
  }
1324
- // Ten completed foreign mutations landed in our acquisition gaps in a
1325
- // row — treat as contention and let the caller reload + retry.
1161
+ // Ten foreign mutations in our acquisition gaps in a row: contention, so let the caller
1162
+ // reload and retry.
1326
1163
  throw new ContentConflictError();
1327
1164
  });
1328
1165
  }
1329
1166
  /**
1330
1167
  * Rename an entry by changing its slug (middle segment of filename).
1331
1168
  * Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
1332
- *
1333
- * @param collectionPath - Logical path to the collection
1334
- * @param currentSlug - Current slug of the entry
1335
- * @param newSlug - New slug (must be unique within collection)
1336
- * @returns Object with new logical path
1337
- * @throws ContentStoreError if entry doesn't exist, new slug conflicts, or validation fails
1338
1169
  */
1339
1170
  async renameEntry(collectionPath, currentSlug, newSlug) {
1340
1171
  // Warm the index outside the lock (a full scan must not hold the entry lock)
@@ -1346,20 +1177,18 @@ export class ContentStore {
1346
1177
  if (!safeNewSlug) {
1347
1178
  throw new ContentStoreError('New slug cannot be empty', 'VALIDATION');
1348
1179
  }
1349
- // If slugs are the same, no-op
1350
1180
  if (currentSlug === safeNewSlug) {
1351
1181
  return { newPath: `${collectionPath}/${currentSlug}` };
1352
1182
  }
1353
- // [SLUG] Routability check, run here rather than trusted from the caller. The `Slug` branded
1354
- // type does NOT guarantee it: `ContentStore.resolvePath` casts a raw path segment to `Slug`
1355
- // with only `.toLowerCase()`, and tests/callers reach for `unsafeAsSlug`. The API's
1356
- // `slugSchema` does run `parseSlug` on `newSlug`, but that is one caller of an exported
1357
- // method -- and a rename mints a new filename, so it is a write that can create an
1358
- // unroutable entry just as a create can. See write()'s [SLUG] guard for what goes wrong.
1183
+ // [SLUG] Routability check, run here rather than trusted from the caller: the `Slug`
1184
+ // branded type does not guarantee it (`ContentStore.resolvePath` casts a raw path segment
1185
+ // with only `.toLowerCase()`, and tests/callers reach for `unsafeAsSlug`), and a rename
1186
+ // mints a new filename, so it can create an unroutable entry just as a create can. See
1187
+ // write()'s [SLUG] guard for what goes wrong.
1359
1188
  //
1360
1189
  // Deliberately AFTER the no-op short-circuit above: a rename to the slug the entry already
1361
- // has mints nothing, so refusing it would only take an existing non-conforming entry and
1362
- // make one more operation on it fail, for no gain.
1190
+ // has mints nothing, so refusing it would only make one more operation on an existing
1191
+ // non-conforming entry fail, for no gain.
1363
1192
  const routable = parseSlug(safeNewSlug);
1364
1193
  if (!routable.ok) {
1365
1194
  throw new ContentStoreError(`Cannot rename to "${safeNewSlug}": ${routable.error}. An entry whose slug is not ` +
@@ -1373,32 +1202,22 @@ export class ContentStore {
1373
1202
  }
1374
1203
  let sourceLockKey = this.entryLockKey(collection, currentSlug, prePass);
1375
1204
  let sourceId = prePass.id;
1376
- // Also lock the destination create-key. Content ID is rename-invariant,
1377
- // so the SOURCE side only ever needs one lock -- but the DESTINATION
1378
- // slug can simultaneously be the target of a concurrent write() creating
1379
- // a brand-new entry there. Without this second lock, that write()'s
1380
- // in-lock buildPaths() re-resolution could observe the just-linked
1381
- // destination file and silently fold in as an "edit" of the renamed
1382
- // entry -- using the create's caller-requested entry type/schema against
1383
- // a file that's actually a different (renamed-in) entry. Acquiring both
1384
- // keys in canonical (sorted) order rules out AB-BA deadlocks; in
1385
- // practice the id: and create: keyspaces never share a literal key
1386
- // string (disjoint prefixes), so two renameEntry() calls can never
1387
- // contend for the exact same pair of keys in reversed roles, but sorting
1388
- // costs nothing.
1205
+ // Also lock the destination create-key. Content ID is rename-invariant, so the SOURCE side
1206
+ // only ever needs one lock -- but the DESTINATION slug can simultaneously be the target of
1207
+ // a concurrent write() creating a brand-new entry there. Without this second lock, that
1208
+ // write()'s in-lock re-resolution could observe the just-linked destination file and
1209
+ // silently fold in as an "edit" of the renamed entry, applying the create's requested
1210
+ // entry type/schema to what is actually a different entry. Both keys are acquired in
1211
+ // canonical order (see withLocks).
1389
1212
  let destLockKey = this.createLockKey(collection, safeNewSlug);
1390
1213
  await this.afterPrePassForTesting();
1391
- // Reclassification loop, mirroring write()'s (see its doc comment): the
1392
- // pre-pass's source key can go stale between resolution and acquisition
1393
- // -- e.g. this entry was renamed away by another writer and a brand-new,
1394
- // different-ID entry landed at this same slug in the gap. Re-derive the
1395
- // source key from the in-lock ground truth AND verify the resolved
1396
- // file's embedded ID still matches the pre-pass ID (a path-based
1397
- // fallback key for legacy no-ID filenames can't otherwise distinguish
1398
- // "same file" from "a different legacy file now occupies this slug").
1399
- // On either mismatch, release and retry under the corrected keys rather
1400
- // than renaming the wrong entry under the stale lock -- bounded to rule
1401
- // out only pathological scheduling.
1214
+ // Reclassification loop, mirroring write()'s (see its doc comment): the pre-pass's source
1215
+ // key can go stale -- this entry may have been renamed away and a brand-new, different-ID
1216
+ // entry landed at this same slug in the gap. Re-derive the source key from in-lock ground
1217
+ // truth AND verify the resolved file's embedded ID still matches the pre-pass ID, since a
1218
+ // path-based fallback key (legacy no-ID filenames) cannot otherwise tell "same file" from
1219
+ // "a different legacy file now occupying this slug". On either mismatch, release and retry
1220
+ // under the corrected keys rather than renaming the wrong entry under the stale lock.
1402
1221
  // [SYNC-C1] See write()'s call to withContentWriteExclusion.
1403
1222
  return this.withContentWriteExclusion(async () => {
1404
1223
  const RETRY_KEY = Symbol('retry-with-new-lock-key');
@@ -1407,14 +1226,12 @@ export class ContentStore {
1407
1226
  // Re-resolve inside the lock: ground truth after acquisition.
1408
1227
  const inLock = await this.buildPaths(collection, currentSlug);
1409
1228
  if (!inLock.existed) {
1410
- // Entry genuinely isn't at this slug anymore (e.g. deleted) --
1411
- // matches the original access()-based NOT_FOUND behavior.
1229
+ // The entry genuinely isn't at this slug anymore (e.g. deleted).
1412
1230
  throw new ContentStoreError(`Entry not found: ${currentSlug}`, 'NOT_FOUND');
1413
1231
  }
1414
1232
  const currentSourceKey = this.entryLockKey(collection, currentSlug, inLock);
1415
- // The dest create-key is purely slug-derived and can't actually
1416
- // change across attempts, but recompute for uniformity with the
1417
- // source side.
1233
+ // The dest create-key is purely slug-derived and cannot change across attempts;
1234
+ // recomputed for uniformity with the source side.
1418
1235
  const currentDestKey = this.createLockKey(collection, safeNewSlug);
1419
1236
  if (currentSourceKey !== sourceLockKey || inLock.id !== sourceId) {
1420
1237
  sourceLockKey = currentSourceKey;
@@ -1424,7 +1241,6 @@ export class ContentStore {
1424
1241
  }
1425
1242
  destLockKey = currentDestKey;
1426
1243
  const { absolutePath: currentPath, relativePath: currentRelPath } = inLock;
1427
- // Extract entry type name and extension from current filename
1428
1244
  const currentFilename = path.basename(currentPath);
1429
1245
  const parts = currentFilename.split('.');
1430
1246
  if (parts.length < 4) {
@@ -1433,7 +1249,6 @@ export class ContentStore {
1433
1249
  const entryTypeName = parts[0];
1434
1250
  const contentId = parts[parts.length - 2];
1435
1251
  const ext = `.${parts[parts.length - 1]}`;
1436
- // Build new filename with new slug
1437
1252
  const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
1438
1253
  const parentDir = path.dirname(currentPath);
1439
1254
  const newPath = path.join(parentDir, newFilename);
@@ -1456,29 +1271,25 @@ export class ContentStore {
1456
1271
  // Ignore filesystem errors (e.g. ENOENT if parent dir doesn't exist)
1457
1272
  }
1458
1273
  // [URL] Contested-URL guard. A rename moves the entry to a NEW url, so the same
1459
- // check a create gets applies here -- including the index direction, since renaming
1460
- // an entry TO `index` is how an existing collection acquires a landing page.
1274
+ // check a create gets applies here -- including the index direction, since
1275
+ // renaming an entry TO `index` is how a collection acquires a landing page.
1461
1276
  //
1462
- // Runs AFTER the same-slug scan above, deliberately. In an already-contested tree
1463
- // both refusals apply, and the same-slug one is the more immediate and more
1464
- // actionable of the two -- reporting the URL conflict first sent the author to fix
1465
- // the other claimant, only to hit the same-slug refusal on the retry. Still before
1466
- // link(), so either refusal leaves the tree untouched.
1277
+ // Runs AFTER the same-slug scan above, deliberately: in an already-contested tree
1278
+ // both refusals apply and the same-slug one is the more immediate and actionable.
1279
+ // Still before link(), so either refusal leaves the tree untouched.
1467
1280
  await this.assertUrlPathAvailable(parentDir, safeNewSlug);
1468
- // Use link()+unlink() instead of rename() so a concurrent cross-process rename to the
1469
- // exact same destination path fails with EEXIST rather than silently overwriting.
1281
+ // link()+unlink() rather than rename(), so a concurrent cross-process rename to
1282
+ // the exact same destination path fails with EEXIST instead of silently
1283
+ // overwriting.
1470
1284
  //
1471
- // Tradeoff: this is a two-step operation, not a single atomic syscall. If unlink()
1472
- // fails after a successful link() (e.g. a transient EFS error), both the old and new
1473
- // slug files will exist pointing at the same inode. The ID index will reflect the new
1474
- // path, so subsequent reads work, but the orphaned source file will persist until the
1475
- // next rename or deletion of that entry. This is an acceptable tradeoff: the EEXIST
1476
- // protection on link() prevents silent data loss on concurrent renames, and the
1477
- // partial-failure case is detectable and recoverable. Note: write()/delete()/
1478
- // renameEntry() now lock on content ID (see idLockKey()), so a concurrent write() or
1479
- // delete() targeting this same entry is fully serialized against this rename and
1480
- // cannot observe this partial-failure window; only a genuinely separate process
1481
- // acting directly on the filesystem without going through this store could.
1285
+ // Tradeoff: two steps, not one atomic syscall. If unlink() fails after a
1286
+ // successful link() (a transient EFS error), both slug files exist pointing at one
1287
+ // inode; the index holds the new path so reads work, and the orphaned source
1288
+ // persists until the next rename or deletion of that entry. Acceptable: the EEXIST
1289
+ // protection prevents silent data loss on concurrent renames, and the partial
1290
+ // failure is detectable and recoverable. write()/delete()/renameEntry() all lock
1291
+ // on content ID, so only a process acting on the filesystem outside this store can
1292
+ // observe that window.
1482
1293
  try {
1483
1294
  await fs.link(currentPath, newPath);
1484
1295
  }
@@ -1498,45 +1309,32 @@ export class ContentStore {
1498
1309
  liveIndex.updatePath(entryId, newRelativePath);
1499
1310
  }
1500
1311
  await this.recordOwnMutation(liveIndex);
1501
- // Return new logical path
1502
1312
  return { newPath: `${collectionPath}/${safeNewSlug}` };
1503
1313
  });
1504
1314
  if (typeof outcome !== 'symbol')
1505
1315
  return outcome;
1506
1316
  }
1507
- // Ten completed foreign mutations landed in our acquisition gaps in a
1508
- // row — treat as contention and let the caller reload + retry.
1317
+ // Ten foreign mutations in our acquisition gaps in a row: contention, so let the caller
1318
+ // reload and retry.
1509
1319
  throw new ContentConflictError();
1510
1320
  });
1511
1321
  }
1512
- /**
1513
- * List all entries in a collection tree (including subcollections).
1514
- * For example, passing 'content/data-catalog' returns entries from
1515
- * 'content/data-catalog', 'content/data-catalog/partner-a', etc.
1516
- * Returns array of entry metadata (relativePath, collection, slug).
1517
- * Returns empty array if the collection doesn't exist.
1518
- */
1519
1322
  /**
1520
1323
  * Resolve a schema collection referenced by name or logical path.
1521
1324
  *
1522
- * Accepts either a full logical path ("content/authors") or a bare
1523
- * collection name ("authors") — the contract reference fields use for
1524
- * `collections: [...]` (see README). This is the single normalization
1525
- * point shared by reference-option loading and reference validation so
1526
- * the dropdown and the write boundary can never disagree.
1325
+ * Accepts a full logical path ("content/authors") or a bare collection name ("authors") --
1326
+ * the contract reference fields use for `collections: [...]` (see README). The single
1327
+ * normalization point shared by reference-option loading and reference validation, so the
1328
+ * dropdown and the write boundary can never disagree.
1527
1329
  *
1528
- * CAVEAT: the bare-name fallback matches on the LAST path segment and
1529
- * returns the first hit in schema order — with two collections sharing a
1530
- * leaf name (e.g. content/blog/posts and content/news/posts), a bare
1531
- * 'posts' is ambiguous. Use the full logical path in schemas that nest
1532
- * same-named collections.
1330
+ * CAVEAT: the bare-name fallback matches on the LAST path segment and returns the first hit
1331
+ * in schema order, so with two collections sharing a leaf name (content/blog/posts,
1332
+ * content/news/posts) a bare 'posts' is ambiguous. Use the full logical path in schemas that
1333
+ * nest same-named collections.
1533
1334
  */
1534
1335
  resolveCollectionItem(collectionPath) {
1535
- // The schema index uses normalized logical paths like "content/authors"
1536
1336
  const normalized = normalizeFilesystemPath(collectionPath);
1537
1337
  let item = this.schemaIndex.get(normalized);
1538
- // If not found by full path, try matching the last segment
1539
- // (handles cases where caller passes "posts" instead of "content/posts")
1540
1338
  if (!item) {
1541
1339
  for (const schemaItem of this.schemaIndex.values()) {
1542
1340
  if (schemaItem.type === 'collection') {
@@ -1550,16 +1348,18 @@ export class ContentStore {
1550
1348
  }
1551
1349
  return item && item.type === 'collection' ? item : undefined;
1552
1350
  }
1351
+ /**
1352
+ * Every entry in a collection tree, including subcollections: 'content/data-catalog'
1353
+ * returns entries from it and from 'content/data-catalog/partner-a' and so on. Empty when
1354
+ * the collection does not exist.
1355
+ */
1553
1356
  async getCollectionEntryPaths(collectionPath) {
1554
1357
  const idIndex = await this.idIndex();
1555
- // Return empty array if collection doesn't exist or isn't a collection
1556
1358
  const collection = this.resolveCollectionItem(collectionPath);
1557
1359
  if (!collection) {
1558
1360
  return [];
1559
1361
  }
1560
- // Get entries from this collection and all subcollections via tree traversal
1561
1362
  const treeEntries = idIndex.getEntriesInCollectionTree(collection.logicalPath);
1562
- // Filter and map to required format
1563
1363
  const entries = [];
1564
1364
  for (const location of treeEntries) {
1565
1365
  if (location.type === 'entry' && location.slug && location.collection) {
@@ -1575,29 +1375,21 @@ export class ContentStore {
1575
1375
  /**
1576
1376
  * Resolve every `reference` field in `data` against `fields`, returning a copy.
1577
1377
  *
1578
- * This is what `read()` applies automatically (unless `resolveReferences: false`), exposed
1579
- * so BATCH surfaces can opt into the same resolution without duplicating the walk: the
1580
- * listing primitives in content-listing.ts and content-tree.ts read entry files straight
1581
- * off disk and never touch this store, so before this existed a `reference` field reached
1582
- * `listEntries()`/`buildContentTree()` callers as a bare id string or `null`.
1378
+ * What `read()` applies automatically (unless `resolveReferences: false`), exposed so BATCH
1379
+ * surfaces can opt into the same resolution without duplicating the walk: the listing
1380
+ * primitives in content-listing.ts and content-tree.ts read entry files straight off disk
1381
+ * and never touch this store. Pass one {@link ReferenceResolveCache} across a whole batch so
1382
+ * a target referenced by many entries is read once; omit `cache` for exactly `read()`'s
1383
+ * unmemoized behavior.
1583
1384
  *
1584
- * Pass one {@link ReferenceResolveCache} across a whole batch so a target referenced by
1585
- * many entries is read once rather than once per referencing entry. Omit `cache` and the
1586
- * behavior is exactly what `read()` has always done, unmemoized.
1587
- *
1588
- * **Path ACLs are not consulted for the targets.** Resolution goes through this store's
1589
- * own `read()`, below the per-entry permission check in content-reader.ts — so a resolved
1590
- * target may be an entry the caller could not `read()` directly. That is pre-existing
1591
- * `read()` behavior, matched here on purpose so one rule covers both; see the
1592
- * `resolveReferences` option in content-listing.ts for the note adopters see.
1385
+ * **Path ACLs are not consulted for the targets.** Resolution goes through this store's own
1386
+ * `read()`, below the per-entry permission check in content-reader.ts, so a resolved target
1387
+ * may be an entry the caller could not `read()` directly -- pre-existing `read()` behavior,
1388
+ * matched here on purpose so one rule covers both.
1593
1389
  */
1594
1390
  async resolveReferences(data, fields, cache) {
1595
1391
  return this.resolveReferencesInData(data, fields, cache);
1596
1392
  }
1597
- /**
1598
- * Recursively resolve reference fields in data.
1599
- * This traverses objects, arrays, and blocks to find and resolve all reference fields.
1600
- */
1601
1393
  async resolveReferencesInData(data, fields, cache) {
1602
1394
  const resolved = { ...data };
1603
1395
  const idIndex = await this.idIndex();
@@ -1614,18 +1406,15 @@ export class ContentStore {
1614
1406
  // to it is a property of the field, declared once in the schema -- not of the call,
1615
1407
  // which routinely contains both kinds at once. See ReferenceFieldConfig.includeBody.
1616
1408
  const includeBody = field.includeBody === true;
1617
- // Single reference
1618
1409
  if (typeof value === 'string' && value) {
1619
1410
  resolved[field.name] = await this.resolveSingleReference(value, idIndex, includeBody, cache);
1620
1411
  }
1621
- // Array of references (list: true)
1622
1412
  else if (field.list && Array.isArray(value)) {
1623
1413
  resolved[field.name] = await Promise.all(value.map((id) => typeof id === 'string'
1624
1414
  ? this.resolveSingleReference(id, idIndex, includeBody, cache)
1625
1415
  : null));
1626
1416
  }
1627
1417
  }
1628
- // Recursively handle nested objects
1629
1418
  else if (field.type === 'object' && value) {
1630
1419
  const objectField = field;
1631
1420
  if (!objectField.fields)
@@ -1639,7 +1428,6 @@ export class ContentStore {
1639
1428
  resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields, cache);
1640
1429
  }
1641
1430
  }
1642
- // Recursively handle blocks
1643
1431
  else if (field.type === 'block' && Array.isArray(value)) {
1644
1432
  const blockField = field;
1645
1433
  resolved[field.name] = await Promise.all(value.map(async (block) => {
@@ -1659,95 +1447,69 @@ export class ContentStore {
1659
1447
  return resolved;
1660
1448
  }
1661
1449
  /**
1662
- * Resolve a single reference ID to full entry data, memoized when a batch cache is
1663
- * supplied. Without a cache this is a straight pass-through to the uncached path, so
1664
- * `read()` behaves exactly as it always has.
1450
+ * Resolve a single reference ID to full entry data, memoized when a batch cache is supplied.
1451
+ * Without a cache this is a straight pass-through, so `read()` behaves as it always has.
1665
1452
  *
1666
- * Every occurrence gets its OWN deep copy, even on a cache hit. Without that, the memo
1667
- * would hand one shared object to all 40 entries referencing the same block — a mutation
1668
- * in one caller's `extract` (truncating a body for a search index, deleting a field) would
1669
- * silently rewrite it for every sibling, and both `list: true` elements of `[id, id]` would
1670
- * be the same instance. The copy is what keeps the cache a pure performance optimization
1671
- * instead of a semantic change.
1453
+ * Every occurrence gets its OWN deep copy, even on a cache hit. Without that, the memo would
1454
+ * hand one shared object to all 40 entries referencing the same block -- a mutation in one
1455
+ * caller's `extract` would silently rewrite it for every sibling, and both `list: true`
1456
+ * elements of `[id, id]` would be the same instance. The copy is what keeps the cache a pure
1457
+ * performance optimization instead of a semantic change.
1672
1458
  *
1673
- * Note the uncached path is NOT the clean baseline it looks like. For a json/yaml target it
1674
- * genuinely reparses fresh per occurrence, but for md/mdx gray-matter serves `data` from a
1675
- * process-global content-keyed cache, so `resolveSingleReferenceOnce`'s top-level spread
1676
- * severs exactly one level and NESTED frontmatter objects alias across occurrences, calls and
1677
- * requests. That is pre-existing `read()` behavior and out of scope here — deliberately, since
1678
- * touching it would change `read()` — but it means the cached path is the safer of the two,
1679
- * not a relaxation of a guarantee the uncached one provides. See
1680
- * `.claude/future-tasks/graymatter-cache-shared-frontmatter.md`.
1459
+ * The uncached path is NOT the clean baseline it looks like: for md/mdx, gray-matter serves
1460
+ * `data` from a process-global content-keyed cache, so `resolveSingleReferenceOnce`'s
1461
+ * top-level spread severs exactly one level and NESTED frontmatter objects alias across
1462
+ * occurrences, calls and requests. Pre-existing `read()` behavior, deliberately out of scope
1463
+ * here; see `.claude/future-tasks/graymatter-cache-shared-frontmatter.md`.
1681
1464
  *
1682
- * What the copy costs, measured rather than assumed (2000 occurrences of one target, local
1683
- * disk, so every read hits the page cache — the friendliest possible case for NOT caching):
1684
- * a snippet-sized target is ~63x cheaper to clone than to re-read-and-parse, while a 265KB
1685
- * JSON target is ~0.8x, i.e. cloning is marginally SLOWER than reparsing it. So the win is
1686
- * large in the case this exists for and roughly a wash at the pathological end, never a
1687
- * blow-up. Two things keep the bad end narrow: an md/mdx target resolves to its FRONTMATTER
1688
- * only *unless the field sets `includeBody`* (`read()` puts the body on `doc.body`, which
1689
- * `resolveSingleReferenceOnce` spreads in only for an embedding field), so by default body
1690
- * size is irrelevant no matter how long the document and only a genuinely huge JSON/YAML
1691
- * target reaches the wash. **`includeBody: true` is the case that CAN reach it on markdown**:
1692
- * the body then sits inside the memoized object and is cloned once per referencing entry, so
1693
- * a long document embedded by many pages pays that repeatedly — the reason `includeBody`
1694
- * exists as an opt-in per field rather than as resolution's default. And in the deployment this
1695
- * targets, content lives on EFS/NFS where the syscall the memo removes dominates parse and
1696
- * clone alike, which the local-disk numbers above understate badly. Correctness is the
1697
- * reason for the copy regardless; the numbers are here so nobody has to re-derive them
1698
- * before touching this.
1465
+ * Measured (2000 occurrences of one target, local disk, the friendliest case for NOT
1466
+ * caching): cloning a snippet-sized target is ~63x cheaper than re-reading and parsing it, a
1467
+ * 265KB JSON target ~0.8x -- a wash at the pathological end, never a blow-up, and on the
1468
+ * EFS/NFS this targets the syscall the memo removes dominates both. An md/mdx target
1469
+ * resolves to its FRONTMATTER unless the field sets `includeBody`, which is why
1470
+ * `includeBody` is opt-in per field: an embedded body sits inside the memoized object and is
1471
+ * cloned once per referencing entry.
1699
1472
  */
1700
1473
  resolveSingleReference(id, idIndex, includeBody, cache) {
1701
1474
  if (!cache)
1702
1475
  return this.resolveSingleReferenceUncached(id, idIndex, includeBody);
1703
1476
  // The key carries `includeBody`, not just the id: two fields can reference the SAME target
1704
1477
  // with different settings, and sharing one entry between them would make the shape depend
1705
- // on which field the walk reached first -- the traversal-order nondeterminism the
1706
- // per-occurrence copy already exists to prevent.
1478
+ // on which field the walk reached first.
1707
1479
  const key = includeBody ? `${id}:body` : id;
1708
1480
  let pending = cache.get(key);
1709
1481
  if (!pending) {
1710
- // Store the in-flight promise, and do it with no `await` in between: the whole point is
1711
- // that concurrent lookups from one Promise.all batch find it and collapse onto a single
1712
- // read. Memoizing the promise also keeps the self-healing retry below shared rather than
1713
- // repeated — see ReferenceResolveCache for why misses are cached too.
1482
+ // Store the in-flight promise with no `await` in between, so concurrent lookups from
1483
+ // one Promise.all batch find it and collapse onto a single read. Memoizing the promise
1484
+ // also shares the self-healing retry below rather than repeating it.
1714
1485
  pending = this.resolveSingleReferenceUncached(id, idIndex, includeBody);
1715
1486
  cache.set(key, pending);
1716
1487
  }
1717
1488
  // The cached promise always has this handler attached, so it is never an unhandled
1718
- // rejection; entry data is plain parsed JSON/YAML/frontmatter, so it is always cloneable
1719
- // (verified against real parser output incl. `!!timestamp` Dates — the sole shape not
1720
- // preserved exactly is a `!!binary` Buffer, which clones to a plain Uint8Array).
1489
+ // rejection; entry data is plain parsed JSON/YAML/frontmatter and so always cloneable (a
1490
+ // `!!binary` Buffer is the sole shape not preserved exactly: it clones to a Uint8Array).
1721
1491
  return pending.then((resolved) => (resolved === null ? null : structuredClone(resolved)));
1722
1492
  }
1723
1493
  /**
1724
- * Returns null if the reference is invalid or missing.
1725
- * Includes id, slug, and collection fields for debugging.
1494
+ * Returns null if the reference is invalid or missing. Includes id, slug and collection.
1726
1495
  *
1727
- * Suspicious results (ID missing from the index, or an index hit whose file
1728
- * is gone) trigger one forced index refresh and a retry — self-healing for
1729
- * mutations by other processes inside the marker's residual windows. The
1730
- * forced refresh itself is throttled (see refreshIndexForSuspiciousLookup),
1731
- * but the retry against the live index always runs regardless of whether
1732
- * this call won the throttle race: when a `list: true` reference array is
1733
- * resolved via Promise.all, every miss shares the same stale snapshot, and
1734
- * idIndex() dedupes concurrent builds — so a sibling call that wins the
1735
- * throttle and rebuilds heals every other miss in the same batch, not just
1736
- * the first.
1496
+ * A suspicious result (ID missing from the index, or an index hit whose file is gone)
1497
+ * triggers one forced index refresh and a retry — self-healing for mutations by other
1498
+ * processes inside the marker's residual windows. The forced refresh is itself throttled
1499
+ * (see refreshIndexForSuspiciousLookup) but the retry against the live index always runs:
1500
+ * every miss in a `list: true` Promise.all shares one stale snapshot, and idIndex() dedupes
1501
+ * concurrent builds, so a sibling that wins the throttle heals the whole batch.
1737
1502
  *
1738
- * A {@link ReferenceResolveCache} sits ABOVE this, never inside it, so every
1739
- * distinct id still runs the full refresh-and-retry above. What a cache changes
1740
- * is only that repeats of the SAME id in one batch share that one attempt's
1741
- * outcome instead of each getting an independent throw of the dice.
1503
+ * A {@link ReferenceResolveCache} sits ABOVE this, never inside it, so every distinct id
1504
+ * still runs the full refresh-and-retry; a cache only makes repeats of the SAME id share it.
1742
1505
  */
1743
1506
  async resolveSingleReferenceUncached(id, idIndex, includeBody) {
1744
1507
  const first = await this.resolveSingleReferenceOnce(id, idIndex, includeBody);
1745
1508
  if (first !== STALE_LOOKUP)
1746
1509
  return first;
1747
- // Force a rebuild (throttled). Even when this caller loses the throttle,
1748
- // retry against the live index: a sibling lookup in the same batch may have
1749
- // won it and invalidated/rebuilt (idIndex() dedupes in-flight builds), so
1750
- // every miss in a Promise.all batch heals, not just the first.
1510
+ // Force a rebuild (throttled). Even when this caller loses the throttle, retry against the
1511
+ // live index: a sibling lookup in the same batch may have won it and rebuilt (idIndex()
1512
+ // dedupes in-flight builds), so every miss in a Promise.all batch heals.
1751
1513
  await this.refreshIndexForSuspiciousLookup();
1752
1514
  const second = await this.resolveSingleReferenceOnce(id, await this.idIndex(), includeBody);
1753
1515
  return second === STALE_LOOKUP ? null : second;
@@ -1764,30 +1526,24 @@ export class ContentStore {
1764
1526
  const doc = await this.read(location.collection, location.slug, {
1765
1527
  resolveReferences: false,
1766
1528
  });
1767
- // `urlPath` is what makes a resolved reference linkable. Without it, a page rendering
1768
- // "see also: <Target>" as a real anchor had no way to get an href from the resolution
1769
- // it had already paid for — one adopter ran a SECOND full listEntries pass over the
1770
- // whole tree purely to build a contentId -> url table, and set `resolveReferences:
1771
- // false` on pages where paying for both was worse than hand-rolling it.
1529
+ // `urlPath` is what makes a resolved reference linkable: without it a page rendering
1530
+ // "see also: <Target>" as a real anchor has no href from the resolution it already paid
1531
+ // for, and had to run a SECOND full listing over the tree to build a contentId -> url
1532
+ // table.
1772
1533
  //
1773
1534
  // Deliberately `computeEntryUrl` (utils/entry-url.ts), the same forward
1774
1535
  // collection+slug -> url rule `listEntries` publishes as `item.urlPath` and
1775
- // `entry-link-resolver.ts` already uses for `entry:ID` links — NOT the reverse
1776
- // url -> entry resolver in url-path-resolver.ts. The two agree today (the reverse
1777
- // resolver was taught to skip its direct-entry candidate for a literal `index` slug,
1778
- // precisely so it stops answering at URLs this rule never emits — see
1779
- // .claude/future-tasks/resolved/url-resolver-index-entry-extra-url.md), but the
1780
- // direction still matters: this is the surface that DEFINES an entry's URL, and
1781
- // sourcing it from the resolver that consumes that definition would invert the
1782
- // dependency and let any future divergence propagate into every resolved reference.
1536
+ // `entry-link-resolver.ts` uses for `entry:ID` links -- NOT the reverse url -> entry
1537
+ // resolver in url-path-resolver.ts. They agree today, but direction matters: this is the
1538
+ // surface that DEFINES an entry's URL, so sourcing it from the resolver that consumes
1539
+ // that definition would invert the dependency.
1783
1540
  //
1784
- // The assembly itself — data, then the embedded body, then the reserved metadata — is
1785
- // `buildResolvedReference`'s job rather than this function's, because the editor's
1786
- // live-preview endpoint (api/resolve-references.ts) builds the same object and the two
1787
- // had already drifted. That doc comment carries the reasoning for the ordering.
1541
+ // The assembly -- data, then the embedded body, then the reserved metadata -- is
1542
+ // `buildResolvedReference`'s job, because api/resolve-references.ts builds the same
1543
+ // object for live preview and the two had drifted; its doc comment carries the ordering.
1788
1544
  //
1789
- // `'body' in doc` is what narrows the ContentDocument union to its markdown variant, so
1790
- // `doc.body`/`doc.bodyFieldName` are reachable at all — a type guard, not a redundant
1545
+ // `'body' in doc` narrows the ContentDocument union to its markdown variant, so
1546
+ // `doc.body`/`doc.bodyFieldName` are reachable at all -- a type guard, not a redundant
1791
1547
  // runtime check. Only a field that asked to EMBED its target passes a body at all.
1792
1548
  const bodyForEmbed = includeBody && 'body' in doc ? { fieldName: doc.bodyFieldName, value: doc.body } : undefined;
1793
1549
  return buildResolvedReference(doc.data, {