@elytracms/studio 0.0.91 → 0.0.93

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 (353) hide show
  1. package/convex/asset-alt-migration.test.ts +127 -0
  2. package/convex/asset-replace.test.ts +168 -0
  3. package/convex/asset-revalidate.test.ts +6 -6
  4. package/convex/assets.ts +41 -5
  5. package/convex/cms.ts +537 -177
  6. package/convex/contract-parity.test.ts +173 -19
  7. package/convex/delivery.ts +26 -13
  8. package/convex/document-revalidate.test.ts +17 -16
  9. package/convex/orphan-hydration.test.ts +116 -0
  10. package/convex/presence-cost.test.ts +89 -0
  11. package/convex/rail-write-parity.test.ts +17 -14
  12. package/convex/referenceMaintenance.ts +15 -15
  13. package/convex/schema.ts +70 -5
  14. package/convex/triggers.ts +21 -8
  15. package/convex/write-limits.ts +1 -1
  16. package/dist/components/ai/ai-chat-panel.js +33 -22
  17. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  18. package/dist/components/ai/proposal-card.js +2 -1
  19. package/dist/components/ai/proposal-card.js.map +1 -1
  20. package/dist/components/ai/translate-document-action.d.ts +8 -2
  21. package/dist/components/ai/translate-document-action.js.map +1 -1
  22. package/dist/components/ai/translate-document-dialog.d.ts +1 -1
  23. package/dist/components/ai/translate-document-dialog.js +14 -8
  24. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  25. package/dist/components/ai/translate-skipped.d.ts +19 -0
  26. package/dist/components/ai/translate-skipped.js +46 -0
  27. package/dist/components/ai/translate-skipped.js.map +1 -0
  28. package/dist/components/app-views/default-modules.d.ts +23 -6
  29. package/dist/components/app-views/default-modules.js +33 -14
  30. package/dist/components/app-views/default-modules.js.map +1 -1
  31. package/dist/components/assets/asset-detail.js +18 -8
  32. package/dist/components/assets/asset-detail.js.map +1 -1
  33. package/dist/components/assets/asset-metadata.d.ts +15 -4
  34. package/dist/components/assets/asset-metadata.js +15 -2
  35. package/dist/components/assets/asset-metadata.js.map +1 -1
  36. package/dist/components/assets/asset-search.js +4 -1
  37. package/dist/components/assets/asset-search.js.map +1 -1
  38. package/dist/components/assets/asset-selection.d.ts +0 -8
  39. package/dist/components/assets/asset-selection.js +4 -23
  40. package/dist/components/assets/asset-selection.js.map +1 -1
  41. package/dist/components/assets/asset-thumb.js +3 -2
  42. package/dist/components/assets/asset-thumb.js.map +1 -1
  43. package/dist/components/assets/assets-collection-view-source.d.ts +3 -3
  44. package/dist/components/assets/assets-collection-view-source.js +9 -5
  45. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  46. package/dist/components/assets/assets-store.d.ts +14 -0
  47. package/dist/components/assets/assets-store.js +23 -1
  48. package/dist/components/assets/assets-store.js.map +1 -1
  49. package/dist/components/assets/replace-file.d.ts +22 -0
  50. package/dist/components/assets/replace-file.js +59 -0
  51. package/dist/components/assets/replace-file.js.map +1 -0
  52. package/dist/components/builder/blocks-model.d.ts +14 -26
  53. package/dist/components/builder/blocks-model.js +35 -41
  54. package/dist/components/builder/blocks-model.js.map +1 -1
  55. package/dist/components/builder/blocks-panel.d.ts +7 -3
  56. package/dist/components/builder/blocks-panel.js +6 -5
  57. package/dist/components/builder/blocks-panel.js.map +1 -1
  58. package/dist/components/builder/builder-store.d.ts +126 -30
  59. package/dist/components/builder/builder-store.js +169 -67
  60. package/dist/components/builder/builder-store.js.map +1 -1
  61. package/dist/components/builder/canvas-body.d.ts +30 -0
  62. package/dist/components/builder/canvas-body.js +38 -0
  63. package/dist/components/builder/canvas-body.js.map +1 -0
  64. package/dist/components/builder/canvas-chrome-css.js +7 -0
  65. package/dist/components/builder/canvas-chrome-css.js.map +1 -1
  66. package/dist/components/builder/canvas-dnd.d.ts +7 -7
  67. package/dist/components/builder/canvas-dnd.js +5 -5
  68. package/dist/components/builder/canvas-dnd.js.map +1 -1
  69. package/dist/components/builder/canvas-render-context.d.ts +30 -22
  70. package/dist/components/builder/canvas-render-context.js +44 -44
  71. package/dist/components/builder/canvas-render-context.js.map +1 -1
  72. package/dist/components/builder/canvas-runs.d.ts +45 -0
  73. package/dist/components/builder/canvas-runs.js +51 -0
  74. package/dist/components/builder/canvas-runs.js.map +1 -0
  75. package/dist/components/builder/canvas.d.ts +5 -4
  76. package/dist/components/builder/canvas.js +34 -6
  77. package/dist/components/builder/canvas.js.map +1 -1
  78. package/dist/components/builder/clipboard-actions.d.ts +15 -0
  79. package/dist/components/builder/clipboard-actions.js +50 -13
  80. package/dist/components/builder/clipboard-actions.js.map +1 -1
  81. package/dist/components/builder/composition-field-editor.d.ts +10 -16
  82. package/dist/components/builder/composition-field-editor.js +30 -40
  83. package/dist/components/builder/composition-field-editor.js.map +1 -1
  84. package/dist/components/builder/composition-patches.d.ts +7 -0
  85. package/dist/components/builder/composition-patches.js +256 -0
  86. package/dist/components/builder/composition-patches.js.map +1 -0
  87. package/dist/components/builder/delete-key.d.ts +5 -0
  88. package/dist/components/builder/delete-key.js +4 -0
  89. package/dist/components/builder/delete-key.js.map +1 -1
  90. package/dist/components/builder/insert-context.d.ts +17 -25
  91. package/dist/components/builder/insert-context.js +45 -92
  92. package/dist/components/builder/insert-context.js.map +1 -1
  93. package/dist/components/builder/page-tree.d.ts +3 -3
  94. package/dist/components/builder/page-tree.js +3 -3
  95. package/dist/components/builder/page-tree.js.map +1 -1
  96. package/dist/components/builder/placement-refusal.d.ts +18 -0
  97. package/dist/components/builder/placement-refusal.js +46 -0
  98. package/dist/components/builder/placement-refusal.js.map +1 -0
  99. package/dist/components/builder/property-inspector.js +4 -0
  100. package/dist/components/builder/property-inspector.js.map +1 -1
  101. package/dist/components/builder/reuse-context.js +65 -26
  102. package/dist/components/builder/reuse-context.js.map +1 -1
  103. package/dist/components/builder/reuse.d.ts +79 -8
  104. package/dist/components/builder/reuse.js +71 -23
  105. package/dist/components/builder/reuse.js.map +1 -1
  106. package/dist/components/builder/run-inspector.d.ts +11 -0
  107. package/dist/components/builder/run-inspector.js +33 -0
  108. package/dist/components/builder/run-inspector.js.map +1 -0
  109. package/dist/components/cms/asset-field.d.ts +38 -1
  110. package/dist/components/cms/asset-field.js +182 -58
  111. package/dist/components/cms/asset-field.js.map +1 -1
  112. package/dist/components/cms/asset-picker-core.d.ts +16 -0
  113. package/dist/components/cms/asset-picker-core.js +20 -0
  114. package/dist/components/cms/asset-picker-core.js.map +1 -1
  115. package/dist/components/cms/asset-picker.js +12 -18
  116. package/dist/components/cms/asset-picker.js.map +1 -1
  117. package/dist/components/cms/bulk-actions.d.ts +4 -20
  118. package/dist/components/cms/bulk-actions.js +3 -24
  119. package/dist/components/cms/bulk-actions.js.map +1 -1
  120. package/dist/components/cms/cell-edit.d.ts +5 -5
  121. package/dist/components/cms/cell-edit.js.map +1 -1
  122. package/dist/components/cms/cell.js.map +1 -1
  123. package/dist/components/cms/cms-store.d.ts +87 -45
  124. package/dist/components/cms/cms-store.js +347 -203
  125. package/dist/components/cms/cms-store.js.map +1 -1
  126. package/dist/components/cms/collection-table.js +7 -2
  127. package/dist/components/cms/collection-table.js.map +1 -1
  128. package/dist/components/cms/commit-policy.d.ts +24 -0
  129. package/dist/components/cms/commit-policy.js +37 -0
  130. package/dist/components/cms/commit-policy.js.map +1 -0
  131. package/dist/components/cms/detail-editor.js +83 -137
  132. package/dist/components/cms/detail-editor.js.map +1 -1
  133. package/dist/components/cms/document-diff-view.d.ts +3 -1
  134. package/dist/components/cms/document-diff-view.js +12 -7
  135. package/dist/components/cms/document-diff-view.js.map +1 -1
  136. package/dist/components/cms/draft-commit.d.ts +70 -0
  137. package/dist/components/cms/draft-commit.js +82 -0
  138. package/dist/components/cms/draft-commit.js.map +1 -0
  139. package/dist/components/cms/duplicate-document.d.ts +11 -13
  140. package/dist/components/cms/duplicate-document.js +26 -34
  141. package/dist/components/cms/duplicate-document.js.map +1 -1
  142. package/dist/components/cms/field-anchors.d.ts +7 -4
  143. package/dist/components/cms/field-anchors.js +9 -7
  144. package/dist/components/cms/field-anchors.js.map +1 -1
  145. package/dist/components/cms/field-collaboration.d.ts +47 -0
  146. package/dist/components/cms/field-collaboration.js +87 -0
  147. package/dist/components/cms/field-collaboration.js.map +1 -0
  148. package/dist/components/cms/field-control-lib.d.ts +22 -1
  149. package/dist/components/cms/field-control-lib.js +33 -4
  150. package/dist/components/cms/field-control-lib.js.map +1 -1
  151. package/dist/components/cms/field-control.js +17 -14
  152. package/dist/components/cms/field-control.js.map +1 -1
  153. package/dist/components/cms/gate-validation.d.ts +21 -30
  154. package/dist/components/cms/gate-validation.js +11 -22
  155. package/dist/components/cms/gate-validation.js.map +1 -1
  156. package/dist/components/cms/object-array-field.js +4 -4
  157. package/dist/components/cms/object-array-field.js.map +1 -1
  158. package/dist/components/cms/prop-fields.d.ts +7 -2
  159. package/dist/components/cms/prop-fields.js +13 -6
  160. package/dist/components/cms/prop-fields.js.map +1 -1
  161. package/dist/components/cms/reference-confirm.js +10 -4
  162. package/dist/components/cms/reference-confirm.js.map +1 -1
  163. package/dist/components/cms/rich-text/embed-picker.d.ts +1 -1
  164. package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
  165. package/dist/components/cms/rich-text/insert.d.ts +20 -9
  166. package/dist/components/cms/rich-text/insert.js +25 -17
  167. package/dist/components/cms/rich-text/insert.js.map +1 -1
  168. package/dist/components/cms/use-document-draft.d.ts +96 -0
  169. package/dist/components/cms/use-document-draft.js +283 -0
  170. package/dist/components/cms/use-document-draft.js.map +1 -0
  171. package/dist/components/cms/use-document-versions.js +2 -2
  172. package/dist/components/cms/use-document-versions.js.map +1 -1
  173. package/dist/components/cms/use-live-url.d.ts +6 -12
  174. package/dist/components/cms/use-live-url.js +16 -15
  175. package/dist/components/cms/use-live-url.js.map +1 -1
  176. package/dist/components/cms/use-publish-controller.d.ts +13 -18
  177. package/dist/components/cms/use-publish-controller.js +24 -41
  178. package/dist/components/cms/use-publish-controller.js.map +1 -1
  179. package/dist/components/cms/use-published-perspective.d.ts +2 -3
  180. package/dist/components/cms/use-published-perspective.js +4 -3
  181. package/dist/components/cms/use-published-perspective.js.map +1 -1
  182. package/dist/components/cms/versions-pane.js +8 -1
  183. package/dist/components/cms/versions-pane.js.map +1 -1
  184. package/dist/components/collection-view/cms-collection-view-source.js +24 -7
  185. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  186. package/dist/components/components/transfer-dialog.js +28 -23
  187. package/dist/components/components/transfer-dialog.js.map +1 -1
  188. package/dist/components/components/transfer.d.ts +31 -21
  189. package/dist/components/components/transfer.js +49 -35
  190. package/dist/components/components/transfer.js.map +1 -1
  191. package/dist/components/project-list.js.map +1 -1
  192. package/dist/components/project-session.d.ts +6 -3
  193. package/dist/components/project-session.js +14 -8
  194. package/dist/components/project-session.js.map +1 -1
  195. package/dist/components/project-switcher.js.map +1 -1
  196. package/dist/components/rail-view-source.js +2 -1
  197. package/dist/components/rail-view-source.js.map +1 -1
  198. package/dist/components/section-nav.js +1 -1
  199. package/dist/components/section-nav.js.map +1 -1
  200. package/dist/lib/ai/apply-content.js +56 -116
  201. package/dist/lib/ai/apply-content.js.map +1 -1
  202. package/dist/lib/ai/chat-context.d.ts +8 -0
  203. package/dist/lib/ai/chat-context.js +37 -6
  204. package/dist/lib/ai/chat-context.js.map +1 -1
  205. package/dist/lib/ai/chat-state.d.ts +14 -1
  206. package/dist/lib/ai/chat-state.js.map +1 -1
  207. package/dist/lib/ai/constraints.d.ts +20 -5
  208. package/dist/lib/ai/constraints.js +125 -33
  209. package/dist/lib/ai/constraints.js.map +1 -1
  210. package/dist/lib/ai/fake-provider.d.ts +16 -3
  211. package/dist/lib/ai/fake-provider.js +22 -6
  212. package/dist/lib/ai/fake-provider.js.map +1 -1
  213. package/dist/lib/ai/index.d.ts +2 -0
  214. package/dist/lib/ai/index.js +2 -0
  215. package/dist/lib/ai/index.js.map +1 -1
  216. package/dist/lib/ai/leash.d.ts +34 -0
  217. package/dist/lib/ai/leash.js +28 -0
  218. package/dist/lib/ai/leash.js.map +1 -0
  219. package/dist/lib/ai/provenance.d.ts +1 -1
  220. package/dist/lib/ai/provenance.js.map +1 -1
  221. package/dist/lib/ai/rich-text-input.d.ts +8 -8
  222. package/dist/lib/ai/rich-text-input.js +2 -1
  223. package/dist/lib/ai/rich-text-input.js.map +1 -1
  224. package/dist/lib/ai/rich-text-translate.d.ts +27 -4
  225. package/dist/lib/ai/rich-text-translate.js +28 -10
  226. package/dist/lib/ai/rich-text-translate.js.map +1 -1
  227. package/dist/lib/ai/shape.d.ts +13 -13
  228. package/dist/lib/ai/shape.js +69 -93
  229. package/dist/lib/ai/shape.js.map +1 -1
  230. package/dist/lib/ai/tools.d.ts +36 -41
  231. package/dist/lib/ai/tools.js +85 -87
  232. package/dist/lib/ai/tools.js.map +1 -1
  233. package/dist/lib/ai/translate-document.d.ts +39 -5
  234. package/dist/lib/ai/translate-document.js +160 -51
  235. package/dist/lib/ai/translate-document.js.map +1 -1
  236. package/dist/lib/ai/types.d.ts +34 -23
  237. package/dist/lib/ai/types.js.map +1 -1
  238. package/dist/lib/ai/update-fields.d.ts +21 -12
  239. package/dist/lib/ai/update-fields.js +118 -177
  240. package/dist/lib/ai/update-fields.js.map +1 -1
  241. package/dist/lib/ai/writer-form.d.ts +116 -0
  242. package/dist/lib/ai/writer-form.js +429 -0
  243. package/dist/lib/ai/writer-form.js.map +1 -0
  244. package/dist/lib/ai-current-document.d.ts +11 -11
  245. package/dist/lib/ai-current-document.js +4 -4
  246. package/dist/lib/ai-current-document.js.map +1 -1
  247. package/dist/lib/assets/upload.d.ts +31 -2
  248. package/dist/lib/assets/upload.js +61 -6
  249. package/dist/lib/assets/upload.js.map +1 -1
  250. package/dist/lib/clipboard/os-clipboard.d.ts +10 -2
  251. package/dist/lib/clipboard/os-clipboard.js +8 -4
  252. package/dist/lib/clipboard/os-clipboard.js.map +1 -1
  253. package/dist/lib/clipboard/paste-target.d.ts +18 -9
  254. package/dist/lib/clipboard/paste-target.js +8 -7
  255. package/dist/lib/clipboard/paste-target.js.map +1 -1
  256. package/dist/lib/cms/field-patch.d.ts +15 -0
  257. package/dist/lib/cms/field-patch.js +25 -0
  258. package/dist/lib/cms/field-patch.js.map +1 -0
  259. package/dist/lib/cms/published-documents.d.ts +1 -1
  260. package/dist/lib/cms/published-documents.js +1 -1
  261. package/dist/lib/cms/published-documents.js.map +1 -1
  262. package/dist/lib/cms-views/view-model.d.ts +3 -3
  263. package/dist/lib/collection-view/__fixtures__/document-writes.d.ts +6 -0
  264. package/dist/lib/collection-view/__fixtures__/document-writes.js +23 -0
  265. package/dist/lib/collection-view/__fixtures__/document-writes.js.map +1 -0
  266. package/dist/lib/collection-view/collection-view-source.d.ts +69 -6
  267. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  268. package/dist/lib/data/collection-content.d.ts +4 -13
  269. package/dist/lib/data/collection-content.js +11 -5
  270. package/dist/lib/data/collection-content.js.map +1 -1
  271. package/dist/lib/dnd/drop-rules.d.ts +25 -31
  272. package/dist/lib/dnd/drop-rules.js +53 -59
  273. package/dist/lib/dnd/drop-rules.js.map +1 -1
  274. package/dist/lib/engine-version.d.ts +1 -1
  275. package/dist/lib/engine-version.js +1 -1
  276. package/dist/lib/engine-version.js.map +1 -1
  277. package/dist/lib/i18n/messages.de.js +72 -15
  278. package/dist/lib/i18n/messages.de.js.map +1 -1
  279. package/dist/lib/i18n/messages.en.d.ts +61 -12
  280. package/dist/lib/i18n/messages.en.js +76 -16
  281. package/dist/lib/i18n/messages.en.js.map +1 -1
  282. package/dist/lib/operations/__fixtures__/write-document.d.ts +30 -0
  283. package/dist/lib/operations/__fixtures__/write-document.js +16 -0
  284. package/dist/lib/operations/__fixtures__/write-document.js.map +1 -0
  285. package/dist/lib/operations/cms-persistence.d.ts +124 -31
  286. package/dist/lib/operations/cms-persistence.js +447 -68
  287. package/dist/lib/operations/cms-persistence.js.map +1 -1
  288. package/dist/lib/operations/config-manifest-lookup.d.ts +35 -0
  289. package/dist/lib/operations/config-manifest-lookup.js +56 -0
  290. package/dist/lib/operations/config-manifest-lookup.js.map +1 -0
  291. package/dist/lib/operations/project-runtime-config.d.ts +39 -0
  292. package/dist/lib/operations/project-runtime-config.js +54 -0
  293. package/dist/lib/operations/project-runtime-config.js.map +1 -0
  294. package/dist/lib/operations/publish-with-revision.d.ts +64 -0
  295. package/dist/lib/operations/publish-with-revision.js +66 -0
  296. package/dist/lib/operations/publish-with-revision.js.map +1 -0
  297. package/dist/lib/operations/runtime-validator.d.ts +32 -0
  298. package/dist/lib/operations/runtime-validator.js +18 -0
  299. package/dist/lib/operations/runtime-validator.js.map +1 -0
  300. package/dist/lib/operations/session-hydration.d.ts +31 -14
  301. package/dist/lib/operations/session-hydration.js +12 -16
  302. package/dist/lib/operations/session-hydration.js.map +1 -1
  303. package/dist/lib/operations/studio-operations.d.ts +59 -2
  304. package/dist/lib/operations/studio-operations.js +76 -14
  305. package/dist/lib/operations/studio-operations.js.map +1 -1
  306. package/dist/lib/persistence/convex-adapter.d.ts +13 -5
  307. package/dist/lib/persistence/convex-adapter.js +189 -74
  308. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  309. package/dist/lib/persistence/convex-client.d.ts +65 -5
  310. package/dist/lib/persistence/convex-client.js +106 -3
  311. package/dist/lib/persistence/convex-client.js.map +1 -1
  312. package/dist/lib/persistence/index.js +17 -18
  313. package/dist/lib/persistence/index.js.map +1 -1
  314. package/dist/lib/persona/labels.js +4 -1
  315. package/dist/lib/persona/labels.js.map +1 -1
  316. package/dist/lib/query/operations-query.d.ts +1 -1
  317. package/dist/lib/query/operations-query.js +1 -1
  318. package/dist/lib/query/operations-query.js.map +1 -1
  319. package/dist/lib/references/impact.d.ts +21 -15
  320. package/dist/lib/references/impact.js +13 -26
  321. package/dist/lib/references/impact.js.map +1 -1
  322. package/dist/lib/references/references-store.d.ts +9 -0
  323. package/dist/lib/references/references-store.js +24 -4
  324. package/dist/lib/references/references-store.js.map +1 -1
  325. package/dist/lib/routes/route-issues.d.ts +6 -6
  326. package/dist/lib/routes/route-issues.js +26 -80
  327. package/dist/lib/routes/route-issues.js.map +1 -1
  328. package/dist/lib/workspace/config-issues.js +2 -1
  329. package/dist/lib/workspace/config-issues.js.map +1 -1
  330. package/dist/lib/workspace/index.d.ts +31 -1
  331. package/dist/lib/workspace/index.js +64 -17
  332. package/dist/lib/workspace/index.js.map +1 -1
  333. package/dist/lib/workspace/switcher-source.d.ts +3 -3
  334. package/dist/lib/workspace/switcher-source.js.map +1 -1
  335. package/dist/lib/workspace/workspace-source.d.ts +2 -2
  336. package/dist/lib/workspace/workspace-source.js +2 -1
  337. package/dist/lib/workspace/workspace-source.js.map +1 -1
  338. package/package.json +3 -3
  339. package/dist/components/cms/cms-composition-validators.d.ts +0 -16
  340. package/dist/components/cms/cms-composition-validators.js +0 -133
  341. package/dist/components/cms/cms-composition-validators.js.map +0 -1
  342. package/dist/components/cms/publish-clean.d.ts +0 -28
  343. package/dist/components/cms/publish-clean.js +0 -38
  344. package/dist/components/cms/publish-clean.js.map +0 -1
  345. package/dist/components/cms/use-save-mode.d.ts +0 -35
  346. package/dist/components/cms/use-save-mode.js +0 -54
  347. package/dist/components/cms/use-save-mode.js.map +0 -1
  348. package/dist/lib/assets/asset-placements.d.ts +0 -28
  349. package/dist/lib/assets/asset-placements.js +0 -83
  350. package/dist/lib/assets/asset-placements.js.map +0 -1
  351. package/dist/lib/cms/document-gate.d.ts +0 -47
  352. package/dist/lib/cms/document-gate.js +0 -61
  353. package/dist/lib/cms/document-gate.js.map +0 -1
package/convex/cms.ts CHANGED
@@ -1,28 +1,57 @@
1
1
  import { v } from 'convex/values'
2
- import { documentsSemanticEqual, foldDocument, unfoldDocument } from '@elytracms/core/cms-core'
3
- import type { CmsDocument } from '@elytracms/core/cms-core'
2
+ import {
3
+ DRAFT_VARIANT,
4
+ applyDocumentPatches,
5
+ documentsSemanticEqual,
6
+ foldDocument,
7
+ foldPatchesForStorage,
8
+ replacePreconditionHolds,
9
+ unfoldDocument,
10
+ unfoldValue,
11
+ } from '@elytracms/core/cms-core'
12
+ import type { CmsDocument, DocumentPatch, PatchLogKind, ReplacePrecondition } from '@elytracms/core/cms-core'
13
+ import { isPinnableVersion } from '@elytracms/core/persistence'
14
+ import type { DocumentVersionKind } from '@elytracms/core/persistence'
4
15
  import { mutation, query } from './functions'
16
+ import type { MutationCtx, QueryCtx } from './functions'
5
17
  import { requireCapability } from './guard'
6
18
  import { assertWithinWriteLimits } from './write-limits'
7
19
 
8
20
  /**
9
- * CMS document persistence (EC-043). The backend stores **documents only**
10
- * (AD-11, EC-183): CMS collection schemas are config-owned — they live in
11
- * `elytra.config.ts`, read by the studio + host — so there is no `cmsSchemas`
12
- * table and no schema read/write function here. Collection-membership
13
- * validation lives in `@elytracms/core/cms-core` `validateDocument` (the studio runs it
14
- * pre-dispatch); this layer is a pure document store.
21
+ * CMS document persistence (EC-043) — the revisioned document AGGREGATE (EC-595,
22
+ * ratified 2026-10-24). The backend stores **documents only** (AD-11, EC-183): there is
23
+ * no `cmsSchemas` table and no schema function here.
24
+ *
25
+ * ## Server-side trust (EC-565 1e — the boundary EC-304 Phase B inherits)
26
+ *
27
+ * Owner, verbatim: "yes keep validation client-side. server-side validation enforces
28
+ * migrations or could lead to invalid/lost data (the payload-sql-approach). schema should
29
+ * be the lens and the mask to put on the data." So these handlers enforce exactly three
30
+ * things, all data-only: **the revision** (every write and publish carries the one it was
31
+ * made against; a moved document refuses with `conflict`), **pin integrity** (a pinned
32
+ * version exists and belongs to this document) and **authority** (`requireCapability`).
33
+ * They never validate a document against a schema and never canonicalise a value — the
34
+ * client gate does both (a `content.write` member bypassing the studio can publish an
35
+ * invalid document; delivery parse raises it visibly, AD-14).
36
+ *
37
+ * ## Two write verbs, both revision-aware
38
+ *
39
+ * `replaceDocument` (whole document under a precondition: `{ revision }`, `{ ifAbsent }`,
40
+ * `{ ifCollectionEmpty }`) and `patchDocument` (path-level patches against a base
41
+ * revision, merged per path IN the transaction by THE merge, `applyDocumentPatches` from
42
+ * cms-core — the same function the in-memory store calls). Convex OCC makes the
43
+ * check-and-write atomic; the check itself is the revision argument. A refusal is a
44
+ * returned `conflict` outcome carrying the current row — never a throw. Every write
45
+ * appends ONE `documentPatches` row (the patch log) and bumps `revision`.
15
46
  *
16
47
  * ## EC-260 depth-budget codec (the storage boundary)
17
48
  *
18
49
  * Convex caps STORED documents at 16 nesting levels but allows 64 in function
19
- * args/returns. Ordinary content (deep rich text, nested compositions) trips 16 at
20
- * rest, so every write here FOLDS over-budget field values into self-describing
21
- * leaf envelopes immediately before `ctx.db`, and every read UNFOLDS before the
22
- * value leaves the function. Mutations receive the UNFOLDED value over the wire, so
23
- * the AD-15 reference walker (in the write-chokepoint trigger, which unfolds first)
24
- * always sees structured content. The wire never carries an envelope; only raw
25
- * `ctx.db` reads / the dashboard see the folded storage form.
50
+ * args/returns. Every write FOLDS over-budget field values into self-describing leaf
51
+ * envelopes immediately before `ctx.db`, and every read UNFOLDS before the value leaves
52
+ * the function. A patch merge unfolds the stored draft, applies, and re-folds; the patch
53
+ * log folds the values it carries the same way (`foldPatchesForStorage`). The wire never
54
+ * carries an envelope.
26
55
  */
27
56
 
28
57
  /** Unfold the `document` blob of a stored row before it leaves the function. */
@@ -35,74 +64,228 @@ function withUnfoldedSnapshot<R extends { snapshot: unknown }>(row: R): R {
35
64
  return { ...row, snapshot: unfoldDocument(row.snapshot as CmsDocument) }
36
65
  }
37
66
 
38
- export const upsertDocument = mutation({
67
+ type Ctx = QueryCtx | MutationCtx
68
+
69
+ function draftRow(ctx: Ctx, collection: string, docId: string) {
70
+ return ctx.db
71
+ .query('cmsDocuments')
72
+ .withIndex('by_doc', (q) => q.eq('collection', collection).eq('docId', docId).eq('variant', DRAFT_VARIANT))
73
+ .first()
74
+ }
75
+
76
+ async function versionRow(ctx: Ctx, collection: string, docId: string, version: number) {
77
+ return ctx.db
78
+ .query('documentVersions')
79
+ .withIndex('by_doc_version', (q) =>
80
+ q.eq('collection', collection).eq('docId', docId).eq('version', version),
81
+ )
82
+ .unique()
83
+ }
84
+
85
+ /**
86
+ * EC-449 / EC-595 binding point 6: the changed flag DERIVED in the transaction — ONE
87
+ * semantic compare of the draft against the snapshot the pin selects. Unpublished → no
88
+ * flag; a damaged pin (no version row) keeps the previous verdict.
89
+ */
90
+ async function changedFlag(
91
+ ctx: Ctx,
92
+ collection: string,
93
+ docId: string,
94
+ publishedVersion: number | undefined,
95
+ draft: CmsDocument,
96
+ previous: boolean | undefined,
97
+ ): Promise<{ hasUnpublishedChanges?: boolean }> {
98
+ if (publishedVersion === undefined) return {}
99
+ const pinned = await versionRow(ctx, collection, docId, publishedVersion)
100
+ if (!pinned) return previous === undefined ? {} : { hasUnpublishedChanges: previous }
101
+ return {
102
+ hasUnpublishedChanges: !documentsSemanticEqual(unfoldDocument(pinned.snapshot as CmsDocument), draft),
103
+ }
104
+ }
105
+
106
+ /** Append one patch-log row (values folded, EC-260). */
107
+ async function appendLog(
108
+ ctx: MutationCtx,
109
+ entry: { collection: string; docId: string; revision: number; kind: PatchLogKind; actor: string; at: string },
110
+ patches: readonly DocumentPatch[] = [],
111
+ ): Promise<void> {
112
+ await ctx.db.insert('documentPatches', {
113
+ collection: entry.collection,
114
+ docId: entry.docId,
115
+ variant: DRAFT_VARIANT,
116
+ revision: entry.revision,
117
+ kind: entry.kind,
118
+ actor: entry.actor,
119
+ at: entry.at,
120
+ patches: foldPatchesForStorage(patches),
121
+ })
122
+ }
123
+
124
+ /** The log rows above `afterRevision`, ascending (patch values unfolded). */
125
+ async function logAfter(ctx: Ctx, collection: string, docId: string, afterRevision: number) {
126
+ const rows = await ctx.db
127
+ .query('documentPatches')
128
+ .withIndex('by_doc_revision', (q) =>
129
+ q.eq('collection', collection).eq('docId', docId).eq('variant', DRAFT_VARIANT).gt('revision', afterRevision),
130
+ )
131
+ .collect()
132
+ return rows.map((row) => ({
133
+ ...row,
134
+ kind: row.kind as PatchLogKind,
135
+ patches: unfoldValue(row.patches) as DocumentPatch[],
136
+ }))
137
+ }
138
+
139
+ /** Delete every row of a document's patch log and presence (delete / clear cascade). */
140
+ async function deleteBookkeeping(ctx: MutationCtx, collection: string, docId: string): Promise<void> {
141
+ const log = await ctx.db
142
+ .query('documentPatches')
143
+ .withIndex('by_doc_revision', (q) => q.eq('collection', collection).eq('docId', docId).eq('variant', DRAFT_VARIANT))
144
+ .collect()
145
+ for (const row of log) await ctx.db.delete(row._id)
146
+ const presence = await ctx.db
147
+ .query('documentPresence')
148
+ .withIndex('by_doc', (q) => q.eq('collection', collection).eq('docId', docId).eq('variant', DRAFT_VARIANT))
149
+ .collect()
150
+ for (const row of presence) await ctx.db.delete(row._id)
151
+ }
152
+
153
+ const preconditionValidator = v.union(
154
+ v.object({ revision: v.number() }),
155
+ v.object({ ifAbsent: v.literal(true) }),
156
+ v.object({ ifCollectionEmpty: v.literal(true) }),
157
+ )
158
+
159
+ /**
160
+ * The whole-document write (EC-595 binding point 2, EC-578): create (`ifAbsent`), a
161
+ * singleton's first document (`ifCollectionEmpty`), or a replace at an exact revision —
162
+ * never an unconditional overwrite. Returns `{ status: 'conflict', current, existing? }`
163
+ * when the precondition does not hold.
164
+ */
165
+ export const replaceDocument = mutation({
39
166
  args: {
40
167
  collection: v.string(),
41
168
  docId: v.string(),
42
169
  document: v.any(),
43
- createdAt: v.string(),
44
- updatedAt: v.string(),
170
+ precondition: preconditionValidator,
171
+ at: v.string(),
45
172
  },
46
173
  handler: async (ctx, args) => {
47
- await requireCapability(ctx, 'content.write') // EC-150
48
- // EC-304 B-4: defensive server-side depth/byte clamp at the `v.any()` chokepoint,
49
- // BEFORE fold/write. Rejects abusive structurally-deep / oversized payloads that a
50
- // direct Convex call (bypassing the client-side lens) could otherwise land. Limits
51
- // are generous — legitimate content never trips them (see write-limits.ts).
174
+ const actor = await requireCapability(ctx, 'content.write') // EC-150
175
+ // EC-304 B-4: defensive server-side depth/byte clamp at the `v.any()` chokepoint.
52
176
  assertWithinWriteLimits(args.document, 'document')
53
- // EC-260: fold for storage. `args.document` arrives UNFOLDED over the 64-level
54
- // arg wire; the trigger unfolds before extracting references, so folding here
55
- // (strictly before the `ctx.db` write) never hides a reference from the walker.
56
- const stored = { ...args, document: foldDocument(args.document as CmsDocument) }
57
- const existing = await ctx.db
58
- .query('cmsDocuments')
59
- .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
60
- .first()
177
+ const precondition = args.precondition as ReplacePrecondition
178
+ const existing = await draftRow(ctx, args.collection, args.docId)
179
+ const inCollection =
180
+ 'ifCollectionEmpty' in precondition
181
+ ? await ctx.db
182
+ .query('cmsDocuments')
183
+ .withIndex('by_collection', (q) => q.eq('collection', args.collection))
184
+ .first()
185
+ : null
186
+ if (!replacePreconditionHolds(precondition, existing ?? undefined, inCollection !== null)) {
187
+ return {
188
+ status: 'conflict' as const,
189
+ current: existing ? withUnfoldedDocument(existing) : null,
190
+ ...(inCollection ? { existing: withUnfoldedDocument(inCollection) } : {}),
191
+ }
192
+ }
193
+ const draft = args.document as CmsDocument
194
+ const revision = (existing?.revision ?? 0) + 1
195
+ // EC-260: fold for storage, strictly before `ctx.db` (the trigger unfolds before
196
+ // extracting references, so folding never hides one from the walker).
197
+ const stored = foldDocument(draft)
61
198
  if (existing) {
62
- // EC-449: a draft save while PUBLISHED re-materializes `hasUnpublishedChanges`
63
- // by comparing the incoming draft (unfolded, off the arg wire) against the
64
- // pinned published snapshot — the mutation has both sides, so the compare runs
65
- // ONCE here, in-transaction (never per list render). A no-op save compares
66
- // equal → `false` (never flips to true); a revert back to the published
67
- // content clears a previously-true flag the same way. Schema-free (AD-11 — the
68
- // deployment stores documents only); the compare is the editor's exact
69
- // `documentsSemanticEqual`, the ONE def-less verdict over canonical forms (EC-586). Unpublished doc / missing pinned row →
70
- // absent-key patch preserves the row as-is (the flag doesn't apply).
71
- let changedFlag = {}
72
- if (existing.publishedVersion !== undefined) {
73
- const pinned = await ctx.db
74
- .query('documentVersions')
75
- .withIndex('by_doc_version', (q) =>
76
- q
77
- .eq('collection', args.collection)
78
- .eq('docId', args.docId)
79
- .eq('version', existing.publishedVersion as number),
80
- )
81
- .unique()
82
- if (pinned) {
83
- changedFlag = {
84
- hasUnpublishedChanges: !documentsSemanticEqual(
85
- unfoldDocument(pinned.snapshot as CmsDocument),
86
- args.document as CmsDocument,
87
- ),
88
- }
89
- }
199
+ const patch = {
200
+ document: stored,
201
+ revision,
202
+ updatedAt: args.at,
203
+ ...(await changedFlag(
204
+ ctx,
205
+ args.collection,
206
+ args.docId,
207
+ existing.publishedVersion,
208
+ draft,
209
+ existing.hasUnpublishedChanges,
210
+ )),
90
211
  }
91
- // EC-153 parity: updates preserve `createdAt`, exactly like in-memory.
92
- // EC-286: the write-chokepoint trigger reindexes references in-transaction.
93
- const patch = { ...stored, createdAt: existing.createdAt, ...changedFlag }
94
212
  await ctx.db.patch(existing._id, patch)
95
- // EC-322: return the merged row directly instead of re-reading the full
96
- // (up-to-~20 KB) document body via `ctx.db.get`. `patch` only overwrites the
97
- // supplied fields, so `{ ...existing, ...patch }` is byte-identical to the
98
- // stored row (system fields + untouched `publishedVersion` carried from
99
- // `existing`) — cutting one full-document read off the hottest write path.
100
- // EC-260: unfold the folded body back to structured form for the return.
101
- return withUnfoldedDocument({ ...existing, ...patch })
213
+ await appendLog(ctx, { collection: args.collection, docId: args.docId, revision, kind: 'replace', actor, at: args.at })
214
+ return { status: 'ok' as const, row: withUnfoldedDocument({ ...existing, ...patch }) }
102
215
  }
103
- const id = await ctx.db.insert('cmsDocuments', stored)
216
+ const id = await ctx.db.insert('cmsDocuments', {
217
+ collection: args.collection,
218
+ docId: args.docId,
219
+ variant: DRAFT_VARIANT,
220
+ revision,
221
+ document: stored,
222
+ createdAt: args.at,
223
+ updatedAt: args.at,
224
+ })
225
+ await appendLog(ctx, { collection: args.collection, docId: args.docId, revision, kind: 'replace', actor, at: args.at })
104
226
  const row = await ctx.db.get(id)
105
- return row ? withUnfoldedDocument(row) : row
227
+ return { status: 'ok' as const, row: row ? withUnfoldedDocument(row) : null }
228
+ },
229
+ })
230
+
231
+ /**
232
+ * Path-level patches against a base revision (EC-595 binding point 4): THE merge runs
233
+ * here, in the transaction, against the current draft and the log rows in
234
+ * `(baseRevision, revision]`. Refused positions come back with their current values;
235
+ * the rest apply in one write (one revision bump, one log row). `missing` when the
236
+ * document does not exist.
237
+ */
238
+ export const patchDocument = mutation({
239
+ args: {
240
+ collection: v.string(),
241
+ docId: v.string(),
242
+ patches: v.any(),
243
+ baseRevision: v.number(),
244
+ at: v.string(),
245
+ },
246
+ handler: async (ctx, args) => {
247
+ const actor = await requireCapability(ctx, 'content.write') // EC-150
248
+ assertWithinWriteLimits(args.patches, 'patches')
249
+ const existing = await draftRow(ctx, args.collection, args.docId)
250
+ if (!existing) return { status: 'missing' as const }
251
+ const merged = applyDocumentPatches({
252
+ document: unfoldDocument(existing.document as CmsDocument),
253
+ patches: args.patches as DocumentPatch[],
254
+ since: await logAfter(ctx, args.collection, args.docId, args.baseRevision),
255
+ staleBase: args.baseRevision < (existing.patchLogFloor ?? 0) || args.baseRevision > existing.revision,
256
+ })
257
+ if (merged.applied.length === 0) {
258
+ return {
259
+ status: 'conflict' as const,
260
+ row: withUnfoldedDocument(existing),
261
+ refused: merged.refused,
262
+ appliedCount: 0,
263
+ }
264
+ }
265
+ const revision = existing.revision + 1
266
+ const patch = {
267
+ document: foldDocument(merged.document),
268
+ revision,
269
+ updatedAt: args.at,
270
+ ...(await changedFlag(
271
+ ctx,
272
+ args.collection,
273
+ args.docId,
274
+ existing.publishedVersion,
275
+ merged.document,
276
+ existing.hasUnpublishedChanges,
277
+ )),
278
+ }
279
+ await ctx.db.patch(existing._id, patch)
280
+ await appendLog(
281
+ ctx,
282
+ { collection: args.collection, docId: args.docId, revision, kind: 'patch', actor, at: args.at },
283
+ merged.applied,
284
+ )
285
+ const row = withUnfoldedDocument({ ...existing, ...patch })
286
+ return merged.refused.length === 0
287
+ ? { status: 'ok' as const, row }
288
+ : { status: 'conflict' as const, row, refused: merged.refused, appliedCount: merged.applied.length }
106
289
  },
107
290
  })
108
291
 
@@ -110,14 +293,112 @@ export const getDocument = query({
110
293
  args: { collection: v.string(), docId: v.string() },
111
294
  handler: async (ctx, args) => {
112
295
  await requireCapability(ctx, 'content.read') // EC-150
113
- const row = await ctx.db
114
- .query('cmsDocuments')
115
- .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
116
- .first()
296
+ const row = await draftRow(ctx, args.collection, args.docId)
117
297
  return row ? withUnfoldedDocument(row) : row // EC-260: unfold before returning
118
298
  },
119
299
  })
120
300
 
301
+ /**
302
+ * The narrow document subscription (EC-595 binding point 7, EC-309's v1 slice): the draft
303
+ * row. The studio's reactive client re-runs it on every change of the row.
304
+ *
305
+ * EC-595 A2 — the row and the presence are TWO reactive queries ({@link watchPresence})
306
+ * behind the one `subscribeDocument` (the adapter merges them). Measured
307
+ * (`presence-cost.test.ts`): as one query, a ~20 KB document with two editors pushed
308
+ * 20,583 B to every subscriber on every presence heartbeat (2 editors × 360 heartbeats/h
309
+ * × 2 subscribers ≈ 29.6 MB/h per document, each push a re-run reading the row). Split,
310
+ * a heartbeat re-runs only the presence query (its read set holds no row, and this
311
+ * query's holds no presence) and pushes a few hundred bytes.
312
+ */
313
+ export const watchDocument = query({
314
+ args: { collection: v.string(), docId: v.string() },
315
+ handler: async (ctx, args) => {
316
+ await requireCapability(ctx, 'content.read') // EC-150
317
+ const row = await draftRow(ctx, args.collection, args.docId)
318
+ return { row: row ? withUnfoldedDocument(row) : null }
319
+ },
320
+ })
321
+
322
+ /**
323
+ * The presence heartbeats on one document (EC-595 thin presence) — its own reactive
324
+ * query so a heartbeat never re-pushes the document (see {@link watchDocument}).
325
+ * Liveness is the READER's call (its injected clock); this query never reads
326
+ * `Date.now()`.
327
+ */
328
+ export const watchPresence = query({
329
+ args: { collection: v.string(), docId: v.string() },
330
+ handler: async (ctx, args) => {
331
+ await requireCapability(ctx, 'content.read') // EC-150
332
+ const presence = await ctx.db
333
+ .query('documentPresence')
334
+ .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId).eq('variant', DRAFT_VARIANT))
335
+ .collect()
336
+ return presence.map((entry) => ({
337
+ sessionId: entry.sessionId,
338
+ ...(entry.actor !== undefined ? { actor: entry.actor } : {}),
339
+ ...(entry.actorName !== undefined ? { actorName: entry.actorName } : {}),
340
+ ...(entry.path !== undefined ? { path: entry.path } : {}),
341
+ at: entry.at,
342
+ }))
343
+ },
344
+ })
345
+
346
+ /**
347
+ * A field-presence heartbeat (EC-595 Stage A): upsert this session's row and delete the
348
+ * document's STALE heartbeats (older than the TTL relative to this heartbeat's `at`), so
349
+ * presence needs no timer. `content.read` suffices — a viewer is present too.
350
+ */
351
+ export const reportPresence = mutation({
352
+ args: {
353
+ collection: v.string(),
354
+ docId: v.string(),
355
+ sessionId: v.string(),
356
+ actorName: v.optional(v.string()),
357
+ path: v.optional(v.any()),
358
+ at: v.string(),
359
+ ttlMs: v.number(),
360
+ },
361
+ handler: async (ctx, args) => {
362
+ const actor = await requireCapability(ctx, 'content.read')
363
+ const rows = await ctx.db
364
+ .query('documentPresence')
365
+ .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId).eq('variant', DRAFT_VARIANT))
366
+ .collect()
367
+ const now = Date.parse(args.at)
368
+ let own: (typeof rows)[number] | undefined
369
+ for (const row of rows) {
370
+ if (row.sessionId === args.sessionId) own = row
371
+ else if (now - Date.parse(row.at) > args.ttlMs) await ctx.db.delete(row._id)
372
+ }
373
+ const entry = {
374
+ collection: args.collection,
375
+ docId: args.docId,
376
+ variant: DRAFT_VARIANT,
377
+ sessionId: args.sessionId,
378
+ actor,
379
+ ...(args.actorName !== undefined ? { actorName: args.actorName } : {}),
380
+ path: args.path,
381
+ at: args.at,
382
+ }
383
+ if (own) await ctx.db.replace(own._id, entry)
384
+ else await ctx.db.insert('documentPresence', entry)
385
+ },
386
+ })
387
+
388
+ export const clearPresence = mutation({
389
+ args: { collection: v.string(), docId: v.string(), sessionId: v.string() },
390
+ handler: async (ctx, args) => {
391
+ await requireCapability(ctx, 'content.read')
392
+ const own = await ctx.db
393
+ .query('documentPresence')
394
+ .withIndex('by_doc_session', (q) =>
395
+ q.eq('collection', args.collection).eq('docId', args.docId).eq('variant', DRAFT_VARIANT).eq('sessionId', args.sessionId),
396
+ )
397
+ .first()
398
+ if (own) await ctx.db.delete(own._id)
399
+ },
400
+ })
401
+
121
402
  export const listDocuments = query({
122
403
  args: { collection: v.optional(v.string()) },
123
404
  handler: async (ctx, args) => {
@@ -128,7 +409,15 @@ export const listDocuments = query({
128
409
  .withIndex('by_collection', (q) => q.eq('collection', args.collection as string))
129
410
  .collect()
130
411
  : await ctx.db.query('cmsDocuments').collect()
131
- return rows.map(withUnfoldedDocument) // EC-260: unfold before returning
412
+ return rows.filter((row) => row.variant === DRAFT_VARIANT).map(withUnfoldedDocument) // EC-260
413
+ },
414
+ })
415
+
416
+ export const listDocumentPatches = query({
417
+ args: { collection: v.string(), docId: v.string(), afterRevision: v.optional(v.number()) },
418
+ handler: async (ctx, args) => {
419
+ await requireCapability(ctx, 'content.read') // EC-150
420
+ return logAfter(ctx, args.collection, args.docId, args.afterRevision ?? 0)
132
421
  },
133
422
  })
134
423
 
@@ -136,34 +425,25 @@ export const deleteDocument = mutation({
136
425
  args: { collection: v.string(), docId: v.string() },
137
426
  handler: async (ctx, args) => {
138
427
  await requireCapability(ctx, 'content.write') // EC-150
139
- const existing = await ctx.db
140
- .query('cmsDocuments')
141
- .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
142
- .first()
428
+ const existing = await draftRow(ctx, args.collection, args.docId)
143
429
  if (existing) await ctx.db.delete(existing._id)
144
- // Cascade the version history (EC-224) — no orphaned snapshots survive a delete.
430
+ // Cascade the version history (EC-224), the patch log and presence (EC-595).
145
431
  const versions = await ctx.db
146
432
  .query('documentVersions')
147
433
  .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
148
434
  .collect()
149
435
  for (const row of versions) await ctx.db.delete(row._id)
436
+ await deleteBookkeeping(ctx, args.collection, args.docId)
150
437
  // EC-286: the write-chokepoint trigger drops this document's index rows (and
151
438
  // revalidates if it was published) when the `cmsDocuments` delete commits.
152
439
  },
153
440
  })
154
441
 
155
442
  /**
156
- * EC-576: drop every document and every version snapshot of the deployment — the
157
- * explicit drop a seeder runs before writing a known world. One transaction, so a
158
- * seed never observes a half-cleared store. Administration, not a content write:
159
- * it requires `project.administer`. Each row delete still passes through the EC-286
160
- * write-chokepoint trigger, which drops the reference-index rows and schedules the
161
- * host sweep for the published ones — a seeder re-publishes afterwards anyway.
162
- *
163
- * Convex bounds one transaction at 8192 written documents (its documented limit);
164
- * a store past that size fails loudly here rather than clearing half. That is far
165
- * above any v1 deployment (the largest measured, Konditorei, holds 331 rows across
166
- * both tables), so it is documented, not paginated.
443
+ * EC-576: drop every document, version snapshot, patch-log row and presence heartbeat of
444
+ * the deployment — the explicit drop a seeder runs before writing a known world. One
445
+ * transaction (Convex bounds it at 8192 written documents — far above any v1
446
+ * deployment; documented, not paginated). Requires `project.administer`.
167
447
  */
168
448
  export const clearDocuments = mutation({
169
449
  args: {},
@@ -171,14 +451,49 @@ export const clearDocuments = mutation({
171
451
  await requireCapability(ctx, 'project.administer')
172
452
  for (const row of await ctx.db.query('cmsDocuments').collect()) await ctx.db.delete(row._id)
173
453
  for (const row of await ctx.db.query('documentVersions').collect()) await ctx.db.delete(row._id)
454
+ for (const row of await ctx.db.query('documentPatches').collect()) await ctx.db.delete(row._id)
455
+ for (const row of await ctx.db.query('documentPresence').collect()) await ctx.db.delete(row._id)
174
456
  },
175
457
  })
176
458
 
177
459
  /**
178
- * Document version history (EC-224) — the document analogue of `graphs:*`.
179
- * Append-only snapshots in `documentVersions`; the live one is pinned by
180
- * `cmsDocuments.publishedVersion`. Version numbers are 1-based, monotonic per
181
- * document, computed atomically inside the mutation.
460
+ * Insert the next monotonic version row of a document (folded); returns it. `kind` is the
461
+ * mutation that records it (EC-585) — decided HERE, never taken from a client argument, so
462
+ * a `content.write` caller cannot mint a pinnable row through `saveDocumentVersion`.
463
+ */
464
+ async function insertVersion(
465
+ ctx: MutationCtx,
466
+ args: { collection: string; docId: string; snapshot: unknown; label?: string; origin?: string; createdAt: string },
467
+ kind: DocumentVersionKind,
468
+ ) {
469
+ // EC-302: versions are appended monotonically, so the latest-created row holds the
470
+ // highest version — read just that one instead of the whole history.
471
+ const latest = await ctx.db
472
+ .query('documentVersions')
473
+ .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
474
+ .order('desc')
475
+ .first()
476
+ const version = (latest?.version ?? 0) + 1
477
+ const id = await ctx.db.insert('documentVersions', {
478
+ collection: args.collection,
479
+ docId: args.docId,
480
+ version,
481
+ // EC-260: the snapshot folds at rest like a draft. No trigger on this table.
482
+ snapshot: foldDocument(args.snapshot as CmsDocument),
483
+ ...(args.label ? { label: args.label } : {}),
484
+ ...(args.origin ? { origin: args.origin } : {}),
485
+ kind,
486
+ createdAt: args.createdAt,
487
+ })
488
+ return (await ctx.db.get(id))!
489
+ }
490
+
491
+ /**
492
+ * Document version history (EC-224): append-only snapshots in `documentVersions`; the
493
+ * live one is pinned by `cmsDocuments.publishedVersion`. Version numbers are 1-based,
494
+ * monotonic per document. Used by the pre-restore snapshot and `documents.snapshot`;
495
+ * publish records its version inside `publishDocument` (one step). A row written here is
496
+ * a `'snapshot'` (EC-585): a restore point `publishDocumentVersion` refuses to pin.
182
497
  */
183
498
  export const saveDocumentVersion = mutation({
184
499
  args: {
@@ -191,23 +506,80 @@ export const saveDocumentVersion = mutation({
191
506
  },
192
507
  handler: async (ctx, args) => {
193
508
  await requireCapability(ctx, 'content.write') // EC-150
194
- // EC-304 B-4: same defensive depth/byte clamp on the version snapshot chokepoint.
195
- assertWithinWriteLimits(args.snapshot, 'snapshot')
196
- // EC-302: versions are appended monotonically, so the latest-created row holds
197
- // the highest version. Read just that one (index-ordered, newest first) instead
198
- // of collecting the whole — unbounded — history to compute `max+1`.
199
- const latest = await ctx.db
200
- .query('documentVersions')
201
- .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
202
- .order('desc')
203
- .first()
204
- const version = (latest?.version ?? 0) + 1
205
- // EC-260: the snapshot embeds the CmsDocument at draft depth, so it folds at rest
206
- // identically. `documentVersions` has no trigger — no walker to keep structured.
207
- const stored = { ...args, snapshot: foldDocument(args.snapshot as CmsDocument), version }
208
- const id = await ctx.db.insert('documentVersions', stored)
209
- const row = await ctx.db.get(id)
210
- return row ? withUnfoldedSnapshot(row) : row
509
+ assertWithinWriteLimits(args.snapshot, 'snapshot') // EC-304 B-4
510
+ const row = await insertVersion(ctx, args, 'snapshot')
511
+ return withUnfoldedSnapshot(row)
512
+ },
513
+ })
514
+
515
+ /**
516
+ * Publish ONE atomic step (EC-595 binding point 6, Codex P05): if the draft is still at
517
+ * `expectedRevision`, record it as the next version, pin it, derive the changed flag,
518
+ * bump the revision and log the publish — all in this transaction; otherwise refuse with
519
+ * `conflict` and the current row. The `cmsDocuments` trigger sees the pin move and
520
+ * reindexes + revalidates (EC-286 / EC-146) — the version row it reads exists already.
521
+ */
522
+ export const publishDocument = mutation({
523
+ args: {
524
+ collection: v.string(),
525
+ docId: v.string(),
526
+ expectedRevision: v.number(),
527
+ label: v.optional(v.string()),
528
+ origin: v.optional(v.string()),
529
+ at: v.string(),
530
+ },
531
+ handler: async (ctx, args) => {
532
+ const actor = await requireCapability(ctx, 'content.write') // EC-150
533
+ const existing = await draftRow(ctx, args.collection, args.docId)
534
+ if (!existing) return { status: 'missing' as const }
535
+ if (existing.revision !== args.expectedRevision) {
536
+ return { status: 'conflict' as const, current: withUnfoldedDocument(existing) }
537
+ }
538
+ const draft = unfoldDocument(existing.document as CmsDocument)
539
+ const version = await insertVersion(ctx, {
540
+ collection: args.collection,
541
+ docId: args.docId,
542
+ snapshot: draft,
543
+ label: args.label ?? 'published',
544
+ ...(args.origin ? { origin: args.origin } : {}),
545
+ createdAt: args.at,
546
+ }, 'publish')
547
+ const revision = existing.revision + 1
548
+ // EC-595 retention: compact the log behind the PREVIOUS publish (one cycle kept).
549
+ const previousPublish = (await logAfter(ctx, args.collection, args.docId, 0))
550
+ .reverse()
551
+ .find((row) => row.kind === 'publish')
552
+ let patchLogFloor = existing.patchLogFloor
553
+ if (previousPublish) {
554
+ const compacted = await ctx.db
555
+ .query('documentPatches')
556
+ .withIndex('by_doc_revision', (q) =>
557
+ q
558
+ .eq('collection', args.collection)
559
+ .eq('docId', args.docId)
560
+ .eq('variant', DRAFT_VARIANT)
561
+ .lte('revision', previousPublish.revision),
562
+ )
563
+ .collect()
564
+ for (const row of compacted) await ctx.db.delete(row._id)
565
+ patchLogFloor = Math.max(patchLogFloor ?? 0, previousPublish.revision)
566
+ }
567
+ const patch = {
568
+ revision,
569
+ publishedVersion: version.version,
570
+ // EC-447: SET-ONCE first-publish instant (server-side, in-transaction).
571
+ ...(existing.publishedAt === undefined ? { publishedAt: args.at } : {}),
572
+ ...(await changedFlag(ctx, args.collection, args.docId, version.version, draft, existing.hasUnpublishedChanges)),
573
+ ...(patchLogFloor !== undefined ? { patchLogFloor } : {}),
574
+ updatedAt: args.at,
575
+ }
576
+ await ctx.db.patch(existing._id, patch)
577
+ await appendLog(ctx, { collection: args.collection, docId: args.docId, revision, kind: 'publish', actor, at: args.at })
578
+ return {
579
+ status: 'ok' as const,
580
+ row: withUnfoldedDocument({ ...existing, ...patch }),
581
+ version: withUnfoldedSnapshot(version),
582
+ }
211
583
  },
212
584
  })
213
585
 
@@ -228,25 +600,14 @@ export const getDocumentVersion = query({
228
600
  args: { collection: v.string(), docId: v.string(), version: v.number() },
229
601
  handler: async (ctx, args) => {
230
602
  await requireCapability(ctx, 'content.read') // EC-150
231
- // EC-307: address the row directly via `by_doc_version` instead of collecting
232
- // the whole — unbounded — per-document history and finding in JS. `version` is
233
- // unique per (collection, docId), so `.unique()` both fetches and asserts that.
234
- const row = await ctx.db
235
- .query('documentVersions')
236
- .withIndex('by_doc_version', (q) =>
237
- q.eq('collection', args.collection).eq('docId', args.docId).eq('version', args.version),
238
- )
239
- .unique()
603
+ const row = await versionRow(ctx, args.collection, args.docId, args.version)
240
604
  return row ? withUnfoldedSnapshot(row) : row // EC-260: unfold before returning
241
605
  },
242
606
  })
243
607
 
244
608
  /**
245
- * The pinned published snapshot of every currently-published document (EC-307) —
246
- * one bulk read collapsing the per-document `listVersions` fan-out the studio's
247
- * published pool used to issue (`published-documents.ts`). Reads each published
248
- * document's pinned version row directly via `by_doc_version`. Returns the
249
- * `CmsDocument` snapshots (the live perspective); unpublished documents are absent.
609
+ * The pinned published snapshot of every currently-published document (EC-307) — one
610
+ * bulk read. Returns the `CmsDocument` snapshots; unpublished documents are absent.
250
611
  */
251
612
  export const listPublishedDocuments = query({
252
613
  args: { collection: v.optional(v.string()) },
@@ -262,13 +623,8 @@ export const listPublishedDocuments = query({
262
623
  const snapshots: unknown[] = []
263
624
  for (const doc of docs) {
264
625
  const version = doc.publishedVersion
265
- if (version === undefined) continue
266
- const pinned = await ctx.db
267
- .query('documentVersions')
268
- .withIndex('by_doc_version', (q) =>
269
- q.eq('collection', doc.collection).eq('docId', doc.docId).eq('version', version),
270
- )
271
- .unique()
626
+ if (doc.variant !== DRAFT_VARIANT || version === undefined) continue
627
+ const pinned = await versionRow(ctx, doc.collection, doc.docId, version)
272
628
  // EC-260: unfold the pinned snapshot before it leaves the function.
273
629
  if (pinned) snapshots.push(unfoldDocument(pinned.snapshot as CmsDocument))
274
630
  }
@@ -286,78 +642,82 @@ export const listAllDocumentVersions = query({
286
642
  },
287
643
  })
288
644
 
645
+ /**
646
+ * Pin an EXISTING version live (EC-224 `pinVersion`, changeset revert). Pin integrity is
647
+ * enforced here, in the transaction: the version must exist for THIS document
648
+ * (`missing-version` otherwise) and must have been recorded by `publishDocument`
649
+ * (`not-pinnable` otherwise — EC-585: a `saveDocumentVersion` row was never validated, so
650
+ * an editor calling this mutation directly cannot put one live; Codex P03). The changed
651
+ * flag is DERIVED against the pinned snapshot (a pin of an older version against a newer
652
+ * draft reads `true` — Codex P05's sibling), the revision bumps, a `pin` row is logged.
653
+ *
654
+ * Server-side trust (EC-565 1e, owner-ratified): the checks here are DATA-ONLY — revision,
655
+ * pin integrity, authority. Schema validation stays the client gate (`documents.publish`
656
+ * runs it, with the hidden-clear, before `publishDocument` records the row this pins).
657
+ */
289
658
  export const publishDocumentVersion = mutation({
290
659
  args: {
291
660
  collection: v.string(),
292
661
  docId: v.string(),
293
662
  version: v.number(),
294
663
  updatedAt: v.string(),
295
- // EC-447: the candidate first-publish instant (a fresh publish-action timestamp,
296
- // generated client-side like `updatedAt`). The handler writes it SET-ONCE below.
664
+ // EC-447: the candidate first-publish instant, written SET-ONCE below.
297
665
  publishedAt: v.string(),
298
666
  },
299
667
  handler: async (ctx, args) => {
300
- await requireCapability(ctx, 'content.write') // EC-150
301
- const doc = await ctx.db
302
- .query('cmsDocuments')
303
- .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
304
- .first()
305
- if (!doc) return null
306
- // EC-286: setting the live pin (`publishedVersion`) is a change the
307
- // write-chokepoint trigger sees — it reindexes the published reference rows
308
- // and fires host revalidation (EC-146 instant publish). No manual calls here.
309
- // EC-447: materialize `publishedAt` SET-ONCE — only when the row has none yet, so
310
- // the FIRST-publish instant is captured and PRESERVED across republishes (the
311
- // guard is server-side + in-transaction, so it can't race).
312
- // EC-449: the pinned version IS the published baseline now — publish always
313
- // resolves `hasUnpublishedChanges` to `false`.
314
- await ctx.db.patch(doc._id, {
668
+ const actor = await requireCapability(ctx, 'content.write') // EC-150
669
+ const doc = await draftRow(ctx, args.collection, args.docId)
670
+ if (!doc) return { status: 'missing' as const }
671
+ const target = await versionRow(ctx, args.collection, args.docId, args.version)
672
+ if (!target) return { status: 'missing-version' as const }
673
+ if (!isPinnableVersion(target)) return { status: 'not-pinnable' as const }
674
+ const revision = doc.revision + 1
675
+ const patch = {
676
+ revision,
315
677
  publishedVersion: args.version,
316
678
  updatedAt: args.updatedAt,
317
679
  ...(doc.publishedAt === undefined ? { publishedAt: args.publishedAt } : {}),
318
- hasUnpublishedChanges: false,
319
- })
320
- const row = await ctx.db.get(doc._id)
321
- return row ? withUnfoldedDocument(row) : row // EC-260: unfold before returning
680
+ ...(await changedFlag(
681
+ ctx,
682
+ args.collection,
683
+ args.docId,
684
+ args.version,
685
+ unfoldDocument(doc.document as CmsDocument),
686
+ doc.hasUnpublishedChanges,
687
+ )),
688
+ }
689
+ await ctx.db.patch(doc._id, patch)
690
+ await appendLog(ctx, { collection: args.collection, docId: args.docId, revision, kind: 'pin', actor, at: args.updatedAt })
691
+ return { status: 'ok' as const, row: withUnfoldedDocument({ ...doc, ...patch }) }
322
692
  },
323
693
  })
324
694
 
325
695
  export const unpublishDocument = mutation({
326
696
  args: { collection: v.string(), docId: v.string(), updatedAt: v.string() },
327
697
  handler: async (ctx, args) => {
328
- await requireCapability(ctx, 'content.write') // EC-150
329
- const doc = await ctx.db
330
- .query('cmsDocuments')
331
- .withIndex('by_doc', (q) => q.eq('collection', args.collection).eq('docId', args.docId))
332
- .first()
698
+ const actor = await requireCapability(ctx, 'content.write') // EC-150
699
+ const doc = await draftRow(ctx, args.collection, args.docId)
333
700
  if (!doc) return null
334
- // Convex patch removes a field set to `undefined` — clears the live pin.
335
- // EC-286: clearing the pin is a change the write-chokepoint trigger sees — it
336
- // drops the published index rows and revalidates the host. No manual calls.
337
- // EC-447: unpublish CLEARS `publishedAt` too (a later republish records a fresh
338
- // first-publish stamp); `createdAt` is untouched.
339
- // EC-449: `hasUnpublishedChanges` is cleared too — with no published baseline
340
- // there is nothing to differ from (the dot reads `draft`/muted anyway).
701
+ const revision = doc.revision + 1
702
+ // Convex patch removes a field set to `undefined` — clears the live pin. EC-286: the
703
+ // trigger drops the published index rows and revalidates. EC-447/EC-449: the
704
+ // first-publish instant and the changed flag clear with it.
341
705
  await ctx.db.patch(doc._id, {
706
+ revision,
342
707
  publishedVersion: undefined,
343
708
  publishedAt: undefined,
344
709
  hasUnpublishedChanges: undefined,
345
710
  updatedAt: args.updatedAt,
346
711
  })
712
+ await appendLog(ctx, { collection: args.collection, docId: args.docId, revision, kind: 'unpublish', actor, at: args.updatedAt })
347
713
  const row = await ctx.db.get(doc._id)
348
714
  return row ? withUnfoldedDocument(row) : row // EC-260: unfold before returning
349
715
  },
350
716
  })
351
717
 
352
718
  /**
353
- * One-time data migrations (EC-450 principle): a migration mutation ships
354
- * transiently — added when a schema/behavior change needs to backfill existing
355
- * deployments, then REMOVED once every deployment has confirmed running it. It
356
- * never lingers in the permanent `@elytracms/*` shipped surface (a brand-new
357
- * consumer never needs it — nothing pre-dates the change it backfills). Pattern:
358
- * an `internalMutation` here delegating its per-row decision to a pure, unit-tested
359
- * core in `@elytracms/core/persistence`; the operator runs it once per deployment
360
- * via `npx convex run cms:<name>` after `convex deploy`, confirms all deployments,
361
- * then deletes both the mutation and its core in one removal pass (see EC-447/449
362
- * → EC-450 for the worked example).
719
+ * One-time data migrations (EC-450 principle): a migration mutation ships transiently —
720
+ * added when a schema/behavior change needs to backfill existing deployments, then
721
+ * REMOVED once every deployment has confirmed running it. EC-595 needs none: its schema
722
+ * change is a drop-and-reseed (the owner's ruling for pre-v1 data, EC-576).
363
723
  */