@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
@@ -2,10 +2,15 @@ import { describe, expect, it } from 'vitest'
2
2
  import { convexTest } from 'convex-test'
3
3
  import type { ConvexHttpClient } from 'convex/browser'
4
4
  import type { FunctionReference } from 'convex/server'
5
- import { measureMaxDepth } from '@elytracms/core/cms-core'
6
- import { PERSISTENCE_CONTRACT_SCENARIOS } from '@elytracms/core/persistence'
5
+ import { fieldPath, measureMaxDepth, nodePropPath } from '@elytracms/core/cms-core'
6
+ import {
7
+ PERSISTENCE_CONTRACT_SCENARIOS,
8
+ publishCurrentDraft,
9
+ replaceAtCurrentRevision,
10
+ } from '@elytracms/core/persistence'
7
11
  import type { ContractContext } from '@elytracms/core/persistence'
8
12
  import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
13
+ import type { ReactiveDocumentClient } from '../src/lib/persistence/convex-client'
9
14
  import schema from './schema'
10
15
 
11
16
  /**
@@ -49,17 +54,55 @@ interface AdapterBackend {
49
54
  mutation: (ref: FunctionReference<'mutation'>, args: Record<string, unknown>) => Promise<unknown>
50
55
  }
51
56
 
57
+ /**
58
+ * EC-595 — the reactive-client DOUBLE a document subscription runs on: `onUpdate` runs
59
+ * the query now and RE-RUNS every active subscription after each mutation through the
60
+ * client double (convex-test has no push transport; this models Convex's reactive
61
+ * re-execution deterministically). The live two-tab push is owner-run.
62
+ */
63
+ function reactiveDouble(backend: AdapterBackend) {
64
+ interface Subscription {
65
+ ref: FunctionReference<'query'>
66
+ args: Record<string, unknown>
67
+ onResult: (value: unknown) => void
68
+ onError: (error: Error) => void
69
+ }
70
+ const active = new Set<Subscription>()
71
+ const run = async (subscription: Subscription) => {
72
+ try {
73
+ subscription.onResult(await backend.query(subscription.ref, subscription.args))
74
+ } catch (error) {
75
+ subscription.onError(error as Error)
76
+ }
77
+ }
78
+ const reactive: ReactiveDocumentClient = {
79
+ onUpdate: (ref, args, onResult, onError) => {
80
+ const subscription = { ref, args, onResult, onError }
81
+ active.add(subscription)
82
+ void run(subscription)
83
+ return () => {
84
+ active.delete(subscription)
85
+ }
86
+ },
87
+ onAuthLost: () => () => {},
88
+ }
89
+ return { reactive, rerun: async () => Promise.all([...active].map(run)) }
90
+ }
91
+
52
92
  /**
53
93
  * Adapt a convex-test accessor onto the `ConvexHttpClient` call surface the
54
94
  * adapter uses (`client.query(ref, args)` / `client.mutation(ref, args)` with
55
95
  * `makeFunctionReference` refs — which convex-test resolves natively).
56
96
  */
57
- function asAdapterClient(backend: AdapterBackend): ConvexHttpClient {
97
+ function asAdapterClient(backend: AdapterBackend, afterMutation?: () => Promise<unknown>): ConvexHttpClient {
58
98
  return {
59
99
  query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) =>
60
100
  backend.query(ref, args ?? {}),
61
- mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) =>
62
- backend.mutation(ref, args ?? {}),
101
+ mutation: async (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => {
102
+ const result = await backend.mutation(ref, args ?? {})
103
+ await afterMutation?.()
104
+ return result
105
+ },
63
106
  } as unknown as ConvexHttpClient
64
107
  }
65
108
 
@@ -90,15 +133,15 @@ async function freshBackend() {
90
133
  async function convexHarness(): Promise<ContractContext> {
91
134
  const t = await freshBackend()
92
135
  const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
93
- const adapter = new ConvexPersistenceAdapter(asAdapterClient(asAdmin), PROJECT_ID, [])
136
+ const push = reactiveDouble(asAdmin)
137
+ const adapter = new ConvexPersistenceAdapter(asAdapterClient(asAdmin, push.rerun), PROJECT_ID, {
138
+ reactive: () => push.reactive,
139
+ })
94
140
  return {
95
141
  adapter,
96
142
  projectId: PROJECT_ID,
97
143
  emptyProjectId: EMPTY_PROJECT_ID,
98
144
  sessionUserId: SESSION_USER_ID,
99
- // EC-183 (AD-11): the Convex adapter's CMS schema is config-owned (injected
100
- // at construction, no `cmsSchemas` table) — seeding it is a no-op here.
101
- async seedSchema() {},
102
145
  }
103
146
  }
104
147
 
@@ -140,7 +183,7 @@ describe('EC-286 write-chokepoint triggers (Convex-only: in-transaction referenc
140
183
 
141
184
  // A draft write derives draft rows in the same transaction — no published
142
185
  // rows until a live version is pinned.
143
- await adapter.cms.upsertDocument(projectId, doc)
186
+ await replaceAtCurrentRevision(adapter.cms, projectId, doc)
144
187
  const draft = await adapter.references.outbound(projectId, source, { variant: 'draft' })
145
188
  expect(draft.map((r) => `${r.target.type}:${r.target.id}:${r.kind}`).sort()).toEqual([
146
189
  'asset:asset_cover:asset-field',
@@ -151,14 +194,13 @@ describe('EC-286 write-chokepoint triggers (Convex-only: in-transaction referenc
151
194
  )
152
195
 
153
196
  // Pinning a version re-runs maintenance: published rows mirror the draft rows.
154
- const v = await adapter.cms.saveDocumentVersion(projectId, doc, { label: 'published' })
155
- await adapter.cms.publishDocumentVersion(projectId, ref, v.version)
197
+ await publishCurrentDraft(adapter.cms, projectId, ref)
156
198
  expect(
157
199
  (await adapter.references.outbound(projectId, source, { variant: 'published' })).length,
158
200
  ).toBe(2)
159
201
 
160
202
  // A retargeting save REPLACES the rows (replace-all-for-source, never appends).
161
- await adapter.cms.upsertDocument(projectId, {
203
+ await replaceAtCurrentRevision(adapter.cms, projectId, {
162
204
  ...doc,
163
205
  values: { author: { type: 'reference', collection: 'author', id: 'grace' } },
164
206
  })
@@ -169,6 +211,35 @@ describe('EC-286 write-chokepoint triggers (Convex-only: in-transaction referenc
169
211
  await adapter.cms.deleteDocument(projectId, ref)
170
212
  expect(await adapter.references.outbound(projectId, source)).toEqual([])
171
213
  })
214
+
215
+ it('EC-589 (Codex P01): the published rows are the PINNED SNAPSHOT’s — a draft-only edit does not move them', async () => {
216
+ const { adapter, projectId } = await convexHarness()
217
+ const doc = {
218
+ collection: 'post',
219
+ id: 'p01-probe',
220
+ values: { author: { type: 'reference', collection: 'author', id: 'A' } },
221
+ }
222
+ const ref = { collection: doc.collection, id: doc.id }
223
+ const source = { type: 'document' as const, id: 'post:p01-probe' }
224
+ const targets = async (variant: 'draft' | 'published') =>
225
+ (await adapter.references.outbound(projectId, source, { variant })).map((r) => r.target.id)
226
+
227
+ const created = await replaceAtCurrentRevision(adapter.cms, projectId, doc)
228
+ // The one-step publish (EC-595): version row + pin in one transaction; the trigger
229
+ // reads that row by `by_doc_version`.
230
+ const published = await adapter.cms.publishDocument(projectId, ref, created.revision)
231
+ expect(published.status).toBe('ok')
232
+ expect(await targets('published')).toEqual(['author:A']) // control: the publish is indexed
233
+
234
+ // A draft-only save to B: the draft rows move, the published rows stay on A — what
235
+ // delivery serves. (Before EC-589 both variants were extracted from the draft.)
236
+ await replaceAtCurrentRevision(adapter.cms, projectId, {
237
+ ...doc,
238
+ values: { author: { type: 'reference', collection: 'author', id: 'B' } },
239
+ })
240
+ expect(await targets('draft')).toEqual(['author:B'])
241
+ expect(await targets('published')).toEqual(['author:A'])
242
+ })
172
243
  })
173
244
 
174
245
  describe('EC-260 fold-at-rest (Convex-only: the stored form respects the depth cap)', () => {
@@ -179,25 +250,37 @@ describe('EC-260 fold-at-rest (Convex-only: the stored form respects the depth c
179
250
  it('stores an over-budget document folded to within 16 levels; the wire stays structured', async () => {
180
251
  const t = await freshBackend()
181
252
  const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
182
- const adapter = new ConvexPersistenceAdapter(asAdapterClient(asAdmin), PROJECT_ID, [])
253
+ const adapter = new ConvexPersistenceAdapter(asAdapterClient(asAdmin), PROJECT_ID)
183
254
 
184
255
  let deep: unknown = { type: 'leaf', text: 'bottom' }
185
256
  for (let i = 0; i < 24; i++) deep = { type: 'level', index: i, child: deep }
186
257
  const doc = { collection: 'post', id: 'ec260-at-rest', values: { title: 'Deep', body: deep } }
187
258
  expect(measureMaxDepth(doc).depth).toBeGreaterThan(16) // the premise: over-budget on the wire
188
259
 
189
- const stored = await adapter.cms.upsertDocument(PROJECT_ID, doc)
260
+ const stored = await replaceAtCurrentRevision(adapter.cms, PROJECT_ID, doc)
190
261
  expect(stored.document.values).toEqual(doc.values) // the wire never carries an envelope
191
262
 
192
263
  const atRest = await t.run(async (ctx) => {
193
264
  const row = await ctx.db
194
265
  .query('cmsDocuments')
195
- .withIndex('by_doc', (q) => q.eq('collection', doc.collection).eq('docId', doc.id))
266
+ .withIndex('by_doc', (q) => q.eq('collection', doc.collection).eq('docId', doc.id).eq('variant', 'draft'))
196
267
  .unique()
197
268
  return row?.document as unknown
198
269
  })
199
270
  expect(atRest).toBeDefined()
200
271
  expect(measureMaxDepth(atRest).depth).toBeLessThanOrEqual(16) // folded at rest
272
+
273
+ // EC-585: the publish step's version row now carries `kind` — a top-level scalar
274
+ // beside the folded snapshot, so the WHOLE stored row stays within the cap.
275
+ await publishCurrentDraft(adapter.cms, PROJECT_ID, { collection: doc.collection, id: doc.id })
276
+ const versionRow = await t.run(async (ctx) =>
277
+ ctx.db
278
+ .query('documentVersions')
279
+ .withIndex('by_doc_version', (q) => q.eq('collection', doc.collection).eq('docId', doc.id).eq('version', 1))
280
+ .unique(),
281
+ )
282
+ expect(versionRow?.kind).toBe('publish')
283
+ expect(measureMaxDepth(versionRow).depth).toBeLessThanOrEqual(16)
201
284
  })
202
285
  })
203
286
 
@@ -217,14 +300,20 @@ describe('guard capability gates through the real functions (EC-150/EC-240c, Con
217
300
  new ConvexPersistenceAdapter(
218
301
  asAdapterClient(t.withIdentity({ subject, issuer: 'https://convex.test' })),
219
302
  PROJECT_ID,
220
- [],
221
303
  )
222
304
 
223
305
  const viewer = forSubject('user_viewer')
224
306
  expect(await viewer.cms.listDocuments(PROJECT_ID)).toEqual([]) // content.read granted
225
307
  await expect(
226
- viewer.cms.upsertDocument(PROJECT_ID, { collection: 'post', id: 'x', values: {} }),
308
+ viewer.cms.replaceDocument(PROJECT_ID, { collection: 'post', id: 'x', values: {} }, { ifAbsent: true }),
227
309
  ).rejects.toThrow(/unauthorized/) // content.write denied
310
+ await expect(
311
+ viewer.cms.patchDocument(PROJECT_ID, { collection: 'post', id: 'x' }, [{ op: 'unset', path: fieldPath('t') }], 1),
312
+ ).rejects.toThrow(/unauthorized/) // the patch verb is content.write too
313
+ // …but presence is content.read: a viewer is present on a document too.
314
+ await expect(
315
+ viewer.cms.reportPresence(PROJECT_ID, { collection: 'post', id: 'x' }, { sessionId: 'v' }),
316
+ ).resolves.toBeUndefined()
228
317
  await expect(viewer.users.listUsers(PROJECT_ID)).rejects.toThrow(/unauthorized/) // project.administer denied
229
318
 
230
319
  // An authenticated NON-member is rejected even on reads …
@@ -232,7 +321,72 @@ describe('guard capability gates through the real functions (EC-150/EC-240c, Con
232
321
  /unauthorized/,
233
322
  )
234
323
  // … and so is an anonymous caller.
235
- const anonymous = new ConvexPersistenceAdapter(asAdapterClient(t), PROJECT_ID, [])
324
+ const anonymous = new ConvexPersistenceAdapter(asAdapterClient(t), PROJECT_ID)
236
325
  await expect(anonymous.cms.listDocuments(PROJECT_ID)).rejects.toThrow(/unauthorized/)
237
326
  })
238
327
  })
328
+
329
+ describe('EC-595 — the aggregate at the Convex store (Convex-only)', () => {
330
+ it('(c) the variant key and the revision are REQUIRED: a pre-EC-595 row is refused by the schema', async () => {
331
+ const t = await freshBackend()
332
+ await expect(
333
+ t.run(async (ctx) => {
334
+ await ctx.db.insert('cmsDocuments', {
335
+ collection: 'post',
336
+ docId: 'legacy',
337
+ document: { collection: 'post', id: 'legacy', values: {} },
338
+ createdAt: 'x',
339
+ updatedAt: 'x',
340
+ } as never)
341
+ }),
342
+ ).rejects.toThrow()
343
+ })
344
+
345
+ it('(d) hot-row OCC: two patches in quick succession both commit, serialised — revision +2, two log rows', async () => {
346
+ const { adapter, projectId } = await convexHarness()
347
+ const ref = { collection: 'post', id: 'hot' }
348
+ await adapter.cms.replaceDocument(projectId, { ...ref, values: { a: 0, b: 0 } }, { ifAbsent: true })
349
+ const [first, second] = await Promise.all([
350
+ adapter.cms.patchDocument(projectId, ref, [{ op: 'set', path: fieldPath('a'), value: 1 }], 1),
351
+ adapter.cms.patchDocument(projectId, ref, [{ op: 'set', path: fieldPath('b'), value: 1 }], 1),
352
+ ])
353
+ expect(first.status).toBe('ok')
354
+ expect(second.status).toBe('ok')
355
+ const record = await adapter.cms.getDocument(projectId, ref)
356
+ expect(record.revision).toBe(3)
357
+ expect(record.document.values).toEqual({ a: 1, b: 1 })
358
+ const log = await adapter.cms.listDocumentPatches(projectId, ref, 1)
359
+ expect(log.map((row) => row.kind)).toEqual(['patch', 'patch'])
360
+ })
361
+
362
+ it('critic 7: a patch-log row carrying a 24-level value is stored within 16 levels (folded like a document)', async () => {
363
+ const t = await freshBackend()
364
+ const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
365
+ const adapter = new ConvexPersistenceAdapter(asAdapterClient(asAdmin), PROJECT_ID)
366
+ let deep: unknown = { type: 'leaf', text: 'bottom' }
367
+ for (let i = 0; i < 24; i++) deep = { type: 'level', index: i, child: deep }
368
+ const ref = { collection: 'page', id: 'deep-log' }
369
+ const body = {
370
+ version: 2,
371
+ content: [{ type: 'elytra/component', id: 'n1', componentId: 'Text', props: { text: { value: 'x' } } }],
372
+ }
373
+ await adapter.cms.replaceDocument(PROJECT_ID, { ...ref, values: { body } }, { ifAbsent: true })
374
+ const patched = await adapter.cms.patchDocument(
375
+ PROJECT_ID,
376
+ ref,
377
+ [{ op: 'set', path: nodePropPath('body', undefined, 'n1', 'data'), value: deep }],
378
+ 1,
379
+ )
380
+ expect(patched.status).toBe('ok')
381
+ const rows = await t.run(async (ctx) => ctx.db.query('documentPatches').collect())
382
+ const patchRow = rows.find((row) => row.kind === 'patch')
383
+ expect(patchRow).toBeDefined()
384
+ // The premise: unfolded, the row would nest row → patches → patch → value (24 levels) far past 16.
385
+ expect(measureMaxDepth({ ...patchRow, patches: patched.status === 'ok' ? [{ value: deep }] : [] }).depth).toBeGreaterThan(16)
386
+ const { _id: _ignoredId, _creationTime: _ignoredTime, ...stored } = patchRow!
387
+ expect(measureMaxDepth(stored).depth).toBeLessThanOrEqual(16)
388
+ const doc = await t.run(async (ctx) => ctx.db.query('cmsDocuments').collect())
389
+ const { _id: _docId, _creationTime: _docTime, ...docRow } = doc[0]!
390
+ expect(measureMaxDepth(docRow).depth).toBeLessThanOrEqual(16) // the document folded at rest after the merge
391
+ })
392
+ })
@@ -1,5 +1,6 @@
1
1
  import { v } from 'convex/values'
2
- import { unfoldValue } from '@elytracms/core/cms-core'
2
+ import { DRAFT_PERSPECTIVE, DRAFT_VARIANT, unfoldValue, variantOf } from '@elytracms/core/cms-core'
3
+ import type { Perspective } from '@elytracms/core/cms-core'
3
4
  import { assetRecordFromRow, deliveryAssetRecord } from '@elytracms/core/persistence'
4
5
  import type { AssetRecord } from '@elytracms/core/persistence'
5
6
  import studioPackage from '../package.json'
@@ -46,8 +47,9 @@ import { requireCapability } from './guard'
46
47
  * (unpinned, so never on the public snapshot; on the draft snapshot as a plain
47
48
  * document the host's content client reports as `unknown-collection`).
48
49
  *
49
- * `draftSnapshot` is the draft-perspective equivalent (all stored document rows
50
- * including drafts) and is gated by the deployment env var `DRAFT_PREVIEW_TOKEN`
50
+ * `draftSnapshot` is the draft-perspective equivalent (every stored row of the
51
+ * draft VARIANT — the working drafts — EC-595) and is gated by the deployment env var
52
+ * `DRAFT_PREVIEW_TOKEN`
51
53
  * — set via `npx convex env set`. A missing or
52
54
  * mismatching token throws `unauthorized`; an unset env var closes the
53
55
  * endpoint entirely. The host server passes the token from its own env when
@@ -56,8 +58,12 @@ import { requireCapability } from './guard'
56
58
 
57
59
  interface DeliverySnapshot {
58
60
  projectId: string
59
- perspective: 'published' | 'draft'
60
- /** Stored document rows visible in the perspective (published: pinned versions only). */
61
+ /** The perspective served — `'published'` or the variant (`{ variant: 'draft' }`), EC-595. */
62
+ perspective: Perspective
63
+ /**
64
+ * Stored document rows visible in the perspective: published — pinned versions only;
65
+ * a variant — that variant's rows only.
66
+ */
61
67
  documents: unknown[]
62
68
  /** Asset metadata in the delivery `AssetRecord` shape (with serve URLs). */
63
69
  assets: unknown[]
@@ -102,7 +108,7 @@ export async function loadDeliveryAssets(ctx: QueryCtx, projectId: string): Prom
102
108
  export async function loadSnapshot(
103
109
  ctx: QueryCtx,
104
110
  args: { projectId: string },
105
- perspective: 'published' | 'draft',
111
+ perspective: Perspective,
106
112
  ): Promise<DeliverySnapshot> {
107
113
  const { projectId } = args
108
114
 
@@ -117,13 +123,20 @@ export async function loadSnapshot(
117
123
  // the continuously-edited draft); a row with no pin never leaves the
118
124
  // deployment. Publish-ness is the pointer, full stop — there is no flat-state
119
125
  // fallback (the `state` field is gone).
126
+ //
127
+ // EC-595 (binding point 1): the aggregate holds one row per (document, VARIANT), so
128
+ // both branches select rows by variant. A variant perspective serves the rows of
129
+ // exactly that variant (only `draft` exists in v1); the publish pointer lives on the
130
+ // draft variant's row — `cms.ts` `listPublishedDocuments` reads it the same way — so a
131
+ // variant row copied with its pin can never serve the pinned snapshot twice.
132
+ const variant = variantOf(perspective)
120
133
  let documents: unknown[]
121
- if (perspective === 'draft') {
122
- documents = documentRows.map(unfoldDraft)
134
+ if (variant !== undefined) {
135
+ documents = documentRows.filter((row) => row.variant === variant).map(unfoldDraft)
123
136
  } else {
124
137
  documents = []
125
138
  for (const row of documentRows) {
126
- if (row.publishedVersion === undefined) continue
139
+ if (row.variant !== DRAFT_VARIANT || row.publishedVersion === undefined) continue
127
140
  // EC-308: read THIS document's pinned version directly via `by_doc_version`
128
141
  // (EC-307) instead of collecting the entire append-only `documentVersions`
129
142
  // table and joining in memory — the published snapshot now scales with the
@@ -190,9 +203,9 @@ export const snapshot = query({
190
203
  })
191
204
 
192
205
  /**
193
- * Draft-perspective snapshot for token-holding preview hosts: all stored
194
- * document rows (drafts included). Gated by the `DRAFT_PREVIEW_TOKEN` deployment
195
- * env var.
206
+ * Draft-perspective snapshot for token-holding preview hosts: every stored row of
207
+ * the draft variant (the working drafts, EC-595). Gated by the `DRAFT_PREVIEW_TOKEN`
208
+ * deployment env var.
196
209
  */
197
210
  export const draftSnapshot = query({
198
211
  args: { projectId: v.string(), token: v.string() },
@@ -204,7 +217,7 @@ export const draftSnapshot = query({
204
217
  if (args.token !== expected) {
205
218
  throw new Error('unauthorized: invalid draft preview token')
206
219
  }
207
- return loadSnapshot(ctx, { projectId: args.projectId }, 'draft')
220
+ return loadSnapshot(ctx, { projectId: args.projectId }, DRAFT_PERSPECTIVE)
208
221
  },
209
222
  })
210
223
 
@@ -37,9 +37,9 @@ const modules = {
37
37
  './_generated/api.ts': async () => ({}),
38
38
  }
39
39
 
40
- const upsertDocument = makeFunctionReference<'mutation'>('cms:upsertDocument')
41
- const saveVersion = makeFunctionReference<'mutation'>('cms:saveDocumentVersion')
42
- const publishVersion = makeFunctionReference<'mutation'>('cms:publishDocumentVersion')
40
+ const replaceDocument = makeFunctionReference<'mutation'>('cms:replaceDocument')
41
+ const getDocument = makeFunctionReference<'query'>('cms:getDocument')
42
+ const publishDocument = makeFunctionReference<'mutation'>('cms:publishDocument')
43
43
  const unpublishDocument = makeFunctionReference<'mutation'>('cms:unpublishDocument')
44
44
  const deleteDocument = makeFunctionReference<'mutation'>('cms:deleteDocument')
45
45
  const deliverySnapshot = makeFunctionReference<'query'>('delivery:snapshot')
@@ -92,28 +92,29 @@ function doc(collection: string, id: string, values: Record<string, unknown>) {
92
92
  }
93
93
 
94
94
  async function saveDraft({ asAdmin }: Backend, collection: string, id: string, values: Record<string, unknown>) {
95
- await asAdmin.mutation(upsertDocument, {
95
+ // EC-595: a whole-document write carries its precondition (the revision it read, or create).
96
+ const current = (await asAdmin.query(getDocument, { collection, docId: id })) as { revision: number } | null
97
+ await asAdmin.mutation(replaceDocument, {
96
98
  collection,
97
99
  docId: id,
98
100
  document: doc(collection, id, values),
99
- createdAt: STAMP,
100
- updatedAt: STAMP,
101
+ precondition: current ? { revision: current.revision } : { ifAbsent: true },
102
+ at: STAMP,
101
103
  })
102
104
  }
103
105
 
106
+ /**
107
+ * Publish `values` the way the one publish operation does (EC-585): the draft holds them,
108
+ * then the one-step `publishDocument` records a publish-kind version and pins it.
109
+ */
104
110
  async function publish(backend: Backend, collection: string, id: string, values: Record<string, unknown>) {
105
- const version = (await backend.asAdmin.mutation(saveVersion, {
106
- collection,
107
- docId: id,
108
- snapshot: doc(collection, id, values),
109
- createdAt: STAMP,
110
- })) as { version: number }
111
- await backend.asAdmin.mutation(publishVersion, {
111
+ await saveDraft(backend, collection, id, values)
112
+ const current = (await backend.asAdmin.query(getDocument, { collection, docId: id })) as { revision: number }
113
+ await backend.asAdmin.mutation(publishDocument, {
112
114
  collection,
113
115
  docId: id,
114
- version: version.version,
115
- updatedAt: STAMP,
116
- publishedAt: STAMP,
116
+ expectedRevision: current.revision,
117
+ at: STAMP,
117
118
  })
118
119
  }
119
120
 
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import type { ConvexHttpClient } from 'convex/browser'
4
+ import type { FunctionReference } from 'convex/server'
5
+ import type { CollectionDef } from '@elytracms/core/cms-core'
6
+ import { createSequentialIdFactory } from '@elytracms/core/determinism'
7
+ import { createOperations } from '@elytracms/core/operations'
8
+ import { replaceAtCurrentRevision } from '@elytracms/core/persistence'
9
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
10
+ import { loadHydratedCms } from '../src/lib/operations/session-hydration'
11
+ import schema from './schema'
12
+
13
+ /**
14
+ * EC-606 close-out — the orphan count on the CONNECTED path, short of a signed-in
15
+ * connected studio (owner-run).
16
+ *
17
+ * The count itself is mode-independent: `loadHydratedCms` runs over whatever
18
+ * operations client the session has, and `project-session.test.tsx` already proves
19
+ * the content session carries its `orphans` to the rail foot. What only the connected
20
+ * mode adds is the backend between them — the real `cms:listDocuments` handler and the
21
+ * production `ConvexPersistenceAdapter`. This runs exactly that path on convex-test's
22
+ * in-memory backend (the harness of `contract-parity.test.ts`: the adapter's HTTP client
23
+ * is a call-through double onto `t.query` / `t.mutation`): a row of a collection the
24
+ * config does not declare must reach the session's orphan list, with its published
25
+ * count, and must not reach its documents.
26
+ */
27
+
28
+ const modules = {
29
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
30
+ './_generated/api.ts': async () => ({}),
31
+ }
32
+
33
+ const PROJECT_ID = 'orphan-hydration'
34
+ const SESSION_USER_ID = 'user_admin'
35
+
36
+ const DECLARED: CollectionDef[] = [{ id: 'post', kind: 'document', fields: [{ name: 'title', type: 'text' }] }]
37
+
38
+ interface AdapterBackend {
39
+ query: (ref: FunctionReference<'query'>, args: Record<string, unknown>) => Promise<unknown>
40
+ mutation: (ref: FunctionReference<'mutation'>, args: Record<string, unknown>) => Promise<unknown>
41
+ }
42
+
43
+ function asAdapterClient(backend: AdapterBackend): ConvexHttpClient {
44
+ return {
45
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => backend.query(ref, args ?? {}),
46
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => backend.mutation(ref, args ?? {}),
47
+ } as unknown as ConvexHttpClient
48
+ }
49
+
50
+ /** The production adapter over a fresh in-memory Convex backend, as the session admin. */
51
+ async function connectedAdapter() {
52
+ const t = convexTest(schema, modules)
53
+ const now = '2026-10-26T00:00:00.000Z'
54
+ await t.run(async (ctx) => {
55
+ await ctx.db.insert('members', { userId: SESSION_USER_ID, role: 'admin', createdAt: now, updatedAt: now })
56
+ })
57
+ const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
58
+ return new ConvexPersistenceAdapter(asAdapterClient(asAdmin), PROJECT_ID)
59
+ }
60
+
61
+ /** The operations client a headless embedder (or the session) builds over that adapter. */
62
+ function operationsOver(adapter: ConvexPersistenceAdapter) {
63
+ return createOperations({
64
+ adapter,
65
+ collections: () => DECLARED,
66
+ validateDocument: () => [],
67
+ idFactory: createSequentialIdFactory('doc'),
68
+ })
69
+ }
70
+
71
+ describe('EC-606 — orphans reach a connected session through the Convex functions', () => {
72
+ it('a stored row of an undeclared collection is counted (published too), and kept out of the documents', async () => {
73
+ const adapter = await connectedAdapter()
74
+ await replaceAtCurrentRevision(adapter.cms, PROJECT_ID, {
75
+ collection: 'post',
76
+ id: 'kept',
77
+ values: { title: 'Kept' },
78
+ })
79
+ await replaceAtCurrentRevision(adapter.cms, PROJECT_ID, {
80
+ collection: 'cms-data-sources',
81
+ id: 'legacy-draft',
82
+ values: { name: 'left behind by the data-binding subsystem' },
83
+ })
84
+ const published = await replaceAtCurrentRevision(adapter.cms, PROJECT_ID, {
85
+ collection: 'cms-data-sources',
86
+ id: 'legacy-live',
87
+ values: { name: 'and published' },
88
+ })
89
+ const pinned = await adapter.cms.publishDocument(
90
+ PROJECT_ID,
91
+ { collection: 'cms-data-sources', id: 'legacy-live' },
92
+ published.revision,
93
+ )
94
+ expect(pinned.status).toBe('ok')
95
+
96
+ const result = await loadHydratedCms(operationsOver(adapter), PROJECT_ID, DECLARED)
97
+ expect(result.ok).toBe(true)
98
+ if (!result.ok || !result.value) throw new Error('hydration must answer')
99
+ expect(result.value.orphans).toEqual([{ collection: 'cms-data-sources', count: 2, published: 1 }])
100
+ expect(result.value.documents.map((document) => `${document.collection}:${document.id}`)).toEqual(['post:kept'])
101
+ })
102
+
103
+ it('CONTROL: declared rows only → no orphans (the count is not a constant)', async () => {
104
+ const adapter = await connectedAdapter()
105
+ await replaceAtCurrentRevision(adapter.cms, PROJECT_ID, {
106
+ collection: 'post',
107
+ id: 'kept',
108
+ values: { title: 'Kept' },
109
+ })
110
+
111
+ const result = await loadHydratedCms(operationsOver(adapter), PROJECT_ID, DECLARED)
112
+ if (!result.ok || !result.value) throw new Error('hydration must answer')
113
+ expect(result.value.orphans).toEqual([])
114
+ expect(result.value.documents).toHaveLength(1)
115
+ })
116
+ })
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import { makeFunctionReference } from 'convex/server'
4
+ import { PRESENCE_TTL_MS } from '@elytracms/core/persistence'
5
+ import schema from './schema'
6
+
7
+ /**
8
+ * EC-595 A2 — the thin presence's COST (critic, operator-read). A heartbeat writes a
9
+ * `documentPresence` row; every reactive query whose read set holds that row re-runs and
10
+ * pushes its WHOLE result to every subscriber. Measured here: the bytes one re-run of the
11
+ * document subscription pushes, for a ~20 KB document with two editors present — the
12
+ * payload a heartbeat costs per subscriber.
13
+ *
14
+ * Reported (EC-595 Done, part A2): when the row and the presence rode ONE query
15
+ * (`watchDocument` returned `{ row, presence }`), every heartbeat re-pushed the 20 KB row
16
+ * to both editors. The split (`watchDocument` = the row, `watchPresence` = the presence,
17
+ * merged by the adapter behind the same `subscribeDocument`) makes a heartbeat re-run
18
+ * only the presence query: its read set no longer includes the row, and the row query's
19
+ * read set no longer includes presence (Convex re-runs a query only when data it read
20
+ * changed — https://docs.convex.dev/realtime).
21
+ */
22
+
23
+ // The same module map contract-parity.test.ts uses (no `_generated` dir; a stub key
24
+ // satisfies convex-test's root lookup).
25
+ const modules = {
26
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
27
+ './_generated/api.ts': async () => ({}),
28
+ }
29
+
30
+ const SESSION_USER_ID = 'user_admin'
31
+ const watchDocument = makeFunctionReference<'query'>('cms:watchDocument')
32
+ const watchPresence = makeFunctionReference<'query'>('cms:watchPresence')
33
+ const replaceDocument = makeFunctionReference<'mutation'>('cms:replaceDocument')
34
+ const reportPresence = makeFunctionReference<'mutation'>('cms:reportPresence')
35
+
36
+ /** A body of roughly `bytes` bytes of prose (a long rich-text-ish field). */
37
+ function prose(bytes: number): string {
38
+ const sentence = 'The analytical engine weaves algebraic patterns as the Jacquard loom weaves flowers. '
39
+ return sentence.repeat(Math.ceil(bytes / sentence.length)).slice(0, bytes)
40
+ }
41
+
42
+ async function backend() {
43
+ const t = convexTest(schema, modules)
44
+ const now = new Date().toISOString()
45
+ await t.run(async (ctx) => {
46
+ await ctx.db.insert('members', { userId: SESSION_USER_ID, role: 'admin', createdAt: now, updatedAt: now })
47
+ })
48
+ return t.withIdentity({ subject: SESSION_USER_ID })
49
+ }
50
+
51
+ describe('the presence heartbeat’s cost (EC-595 A2)', () => {
52
+ it('a heartbeat re-pushes only the presence — never the 20 KB document', async () => {
53
+ const t = await backend()
54
+ const document = { collection: 'post', id: 'p1', values: { title: 'Ada', body: prose(20_000) } }
55
+ await t.mutation(replaceDocument, {
56
+ collection: 'post',
57
+ docId: 'p1',
58
+ document,
59
+ precondition: { ifAbsent: true },
60
+ at: new Date().toISOString(),
61
+ })
62
+ const at = new Date().toISOString()
63
+ for (const [sessionId, actorName] of [
64
+ ['tab-a', 'Ada'],
65
+ ['tab-b', 'Grace'],
66
+ ] as const) {
67
+ await t.mutation(reportPresence, {
68
+ collection: 'post',
69
+ docId: 'p1',
70
+ sessionId,
71
+ actorName,
72
+ path: [{ kind: 'field', name: 'title' }],
73
+ at,
74
+ ttlMs: PRESENCE_TTL_MS,
75
+ })
76
+ }
77
+ const args = { collection: 'post', docId: 'p1' }
78
+ const rowBytes = JSON.stringify(await t.query(watchDocument, args)).length
79
+ const presenceBytes = JSON.stringify(await t.query(watchPresence, args)).length
80
+ // Recorded for the Done section (bytes per push per subscriber).
81
+ console.info(`[presence-cost] row query ${rowBytes} B · presence query ${presenceBytes} B`)
82
+ // The document query carries the row and NOTHING of presence…
83
+ expect(rowBytes).toBeGreaterThan(20_000)
84
+ expect(JSON.stringify(await t.query(watchDocument, args))).not.toContain('tab-a')
85
+ // …the presence query carries the heartbeats and nothing of the row.
86
+ expect(presenceBytes).toBeLessThan(1_000)
87
+ expect(JSON.stringify(await t.query(watchPresence, args))).not.toContain('analytical engine')
88
+ })
89
+ })