@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
@@ -1,66 +1,73 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
- import { createCollectionRegistry, createDocumentStore, documentKey, documentReachabilityIssues, documentSchema, resolveDocumentTitle, seedDefaults, resolveSingletonDocument, titleFieldOf, validateDocument, valuesInLocale, writeField, } from '@elytracms/core/cms-core';
3
+ import { applyDocumentPatches, createCollectionRegistry, createDocumentStore, documentKey, documentReachabilityIssues, documentSchema, resolveDocumentTitle, seedDefaults, titleFieldOf, validateDocument, valuesInLocale, writeField, } from '@elytracms/core/cms-core';
4
+ import { createUuidIdFactory } from '@elytracms/core/persistence';
4
5
  import { translate } from '@elytracms/studio/lib/i18n';
5
6
  import { configureStudioLocales, useStudioLocale } from '@elytracms/studio/lib/locale';
6
7
  import { studioManifestLookup } from '@elytracms/studio/lib/registry';
7
8
  import { resolveDeploymentCollections, resolveDeploymentLocaleConfig, resolveDeploymentRoutes, } from '@elytracms/studio/lib/workspace';
8
9
  import { Heading, Panel, Stack, Text } from '@elytracms/studio/ui';
9
10
  import { assetLibraryOracle, gateValidateOptions } from './gate-validation.js';
10
- import { clearSessionDocumentValidator, setSessionDocumentValidator, } from '@elytracms/studio/lib/cms/document-gate';
11
11
  import { matchesRef, removeDoc, replaceDoc, upsertDoc } from './document-ref-utils.js';
12
12
  import { createRequiredContext } from '@elytracms/studio/lib/required-context';
13
- import { createCmsPersister, getStudioOperations } from '@elytracms/studio/lib/operations';
13
+ import { createCmsPersister, getStudioActor, getStudioOperations } from '@elytracms/studio/lib/operations';
14
+ import { publishInSession, publishWithRevision as publishRevision, } from '@elytracms/studio/lib/operations/publish-with-revision';
15
+ import { operationErrorMessage } from '@elytracms/studio/lib/persona/labels';
16
+ import { fieldWritePatch } from '@elytracms/studio/lib/cms/field-patch';
14
17
  import { ALL_VIEW_ID, DEFAULT_VIEW_OVERRIDE_ID, canonicalViewState, configViewIssues, defaultConfigViewId, defaultViewState, deprecatedDefaultFlagViewIds, hasDefaultViewOverride, pseudoColumns, resolveCollectionViews, viewStatesEqual, } from '@elytracms/studio/lib/cms-views';
15
18
  import { useOptionalAssets } from '../assets/assets-store.js';
16
19
  import { consumeStagedDocuments } from './document-staging.js';
17
20
  import { commitCellEdit } from './cell-edit.js';
18
21
  import { runBulkAction } from './bulk-actions.js';
19
- import { cleanHiddenForPublish } from './publish-clean.js';
20
22
  const { Context: CmsContext, useRequired: useCms } = createRequiredContext('Cms');
21
- function primaryField(collection) {
22
- return titleFieldOf(collection)?.name ?? collection.fields[0]?.name ?? 'id';
23
- }
23
+ /** The stable empty conflict list (referential stability for the surfaces' memos). */
24
+ const NO_CONFLICTS = [];
24
25
  /**
25
- * EC-316 (bug fix) — mint a fresh, COLLISION-FREE document id for `+ New`.
26
- *
27
- * `seq` is an in-memory, per-session counter (reset to 0 on every reload). A
28
- * document minted `new-<collection>-<n>` in a PRIOR session can already occupy
29
- * whatever `n` this session's counter would next reissue — the pre-fix bug:
30
- * `+ New` on a `post` collection that already held a (possibly published)
31
- * `new-post-1` handed out that SAME id again, silently opening the existing
32
- * document for editing instead of creating a fresh draft. Walking the counter
33
- * past every id already in `existingIds` makes the mint collision-free
34
- * regardless of what a prior session left behind.
26
+ * EC-595 thin presence — ONE id per tab (per page load): the heartbeat's identity, so a
27
+ * second tab of the same user is a second presence, and the editor leaves itself out.
35
28
  */
36
- export function mintDocumentId(collectionId, seq, existingIds) {
37
- let id;
38
- do {
39
- seq.current += 1;
40
- id = `new-${collectionId}-${seq.current}`;
41
- } while (existingIds.has(id));
42
- return id;
29
+ const PRESENCE_SESSION_ID = createUuidIdFactory()();
30
+ function primaryField(collection) {
31
+ return titleFieldOf(collection)?.name ?? collection.fields[0]?.name ?? 'id';
43
32
  }
44
33
  /**
45
- * Build a fresh document for `collectionId`: a collision-free id (EC-316, via
46
- * {@link mintDocumentId} against every id `existingDocs` already holds for
47
- * this collection) and the collection's declared field defaults (EC-297, via
48
- * `seedDefaults` — a localized default lands in the project's default-locale
49
- * bucket, EC-580). Pure — the sensitive part of the insert sequence shared by
50
- * `createDocument` and `ensureSingletonDocument`'s empty branch (via the
51
- * private `insertNewDocument` below), factored out so it's unit-testable
52
- * without a `CmsProvider`.
34
+ * Build a fresh document for `collectionId` (EC-297 / EC-578): an id from THE injected
35
+ * id factory — UUIDs in the studio, so two sessions can never mint the same id (the old
36
+ * per-session `new-<collection>-<n>` counter minted `new-post-1` in both, and the second
37
+ * create overwrote the first) — and the collection's declared field defaults
38
+ * (`seedDefaults`; a localized default lands in the project's default-locale bucket,
39
+ * EC-580). Pure — unit-testable without a `CmsProvider`.
53
40
  */
54
- export function buildNewDocument(collectionId, registry, seq, existingDocs, locales) {
55
- const existingIds = new Set(existingDocs.filter((d) => d.collection === collectionId).map((d) => d.id));
56
- const id = mintDocumentId(collectionId, seq, existingIds);
41
+ export function buildNewDocument(collectionId, registry, newId, locales) {
57
42
  const collection = registry.get(collectionId);
58
43
  return {
59
44
  collection: collectionId,
60
- id,
45
+ id: newId(),
61
46
  ...(collection ? seedDefaults(collection, locales) : { values: {} }),
62
47
  };
63
48
  }
49
+ /**
50
+ * EC-274 → EC-595 A2 (critic correction 9) — a singleton's first document:
51
+ * `documents.create({ singleton: true })` (the store's `ifCollectionEmpty`
52
+ * precondition). The race loser's `conflict` carries the winner's row, which it ADOPTS
53
+ * — two sessions opening an empty singleton end up on ONE document. Exported for tests.
54
+ */
55
+ export async function createOrAdoptSingleton(operations, projectId, collectionId, seeded) {
56
+ const result = await operations.documents.create({
57
+ projectId,
58
+ collection: collectionId,
59
+ values: seeded.values ?? {},
60
+ ...(seeded.localized ? { localized: seeded.localized } : {}),
61
+ singleton: true,
62
+ });
63
+ if (result.ok)
64
+ return { ok: true, record: result.value, adopted: false };
65
+ const error = result.error;
66
+ if (error.code === 'conflict' && 'current' in error && error.current) {
67
+ return { ok: true, record: error.current, adopted: true };
68
+ }
69
+ return { ok: false, error };
70
+ }
64
71
  /**
65
72
  * Does `docs` already hold a document for `collectionId`? `ensureSingletonDocument`'s
66
73
  * empty branch re-checks this INSIDE the `setDocuments` updater (against the
@@ -148,7 +155,9 @@ export function CmsProvider(props) {
148
155
  function MissingProjectLocales() {
149
156
  return (_jsx(Panel, { className: "m-6 p-6", children: _jsxs(Stack, { gap: "2", children: [_jsx(Heading, { level: 2, size: "h4", children: translate('cms.session.noLocales.title') }), _jsx(Text, { size: "sm", tone: "subtle", children: translate('cms.session.noLocales.body') })] }) }));
150
157
  }
151
- function CmsSession({ children, projectId, initialCollections, initialDocuments, initialPublishedKeys, initialTimestamps, initialPublishTimestamps, initialChangedKeys, seedReflectsWrites, localeConfig, }) {
158
+ function CmsSession({ children, projectId, initialCollections, initialDocuments, initialPublishedKeys, initialTimestamps, initialPublishTimestamps, initialChangedKeys, initialRevisions, idFactory, seedReflectsWrites, localeConfig, }) {
159
+ // EC-578: THE id factory for this session (documents created here).
160
+ const [newId] = useState(() => idFactory ?? createUuidIdFactory());
152
161
  const [documents, setDocuments] = useState(() => {
153
162
  const seeded = (initialDocuments ?? []).map((d) => structuredClone(d));
154
163
  // EC-320 (0.0.44 follow-up): merge any documents STAGED by the
@@ -259,8 +268,12 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
259
268
  // changed verdict for the record's ref. Referentially stable — the persister
260
269
  // closes over it once.
261
270
  const adoptSavedRecord = useCallback((record) => {
262
- stampHasUnpublishedChanges({ collection: record.document.collection, id: record.document.id }, record.hasUnpublishedChanges);
263
- }, [stampHasUnpublishedChanges]);
271
+ const ref = { collection: record.document.collection, id: record.document.id };
272
+ stampHasUnpublishedChanges(ref, record.hasUnpublishedChanges);
273
+ // EC-595 A2: an adopted row also carries the chokepoint's last-write instant.
274
+ if (record.updatedAt)
275
+ stampUpdatedAt(ref, record.updatedAt);
276
+ }, [stampHasUnpublishedChanges, stampUpdatedAt]);
264
277
  const [activeViews, setActiveViews] = useState({});
265
278
  // The live table state per collection where it diverges from the active
266
279
  // view; absent key = exactly the active view's state (not dirty).
@@ -277,10 +290,11 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
277
290
  ...(initialCollections ?? []),
278
291
  ]);
279
292
  const [persistError, setPersistError] = useState(null);
280
- const seq = useRef(0);
281
- // EC-274: collection ids whose lone singleton document this session has already
282
- // created — a synchronous guard so a re-invoked effect (or StrictMode's double
283
- // mount) never materializes a second document under a freshly generated id.
293
+ // EC-595 A2: the open per-field conflicts, by document key.
294
+ const [conflictsByKey, setConflictsByKey] = useState(() => new Map());
295
+ // EC-274 → EC-595 A2: collection ids whose singleton create this session already
296
+ // issued — a synchronous guard so a re-invoked effect (or StrictMode's double mount)
297
+ // never issues a second create (the store's `ifCollectionEmpty` would refuse it anyway).
284
298
  const ensuredSingletons = useRef(new Set());
285
299
  // Latest session state for the persister's lazy baseline (no re-subscribing).
286
300
  const collectionsRef = useRef(collections);
@@ -293,17 +307,34 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
293
307
  projectId,
294
308
  getOperations: async () => (await getStudioOperations()).operations,
295
309
  getCollections: () => collectionsRef.current,
296
- getDocuments: () => documentsRef.current,
297
310
  // EC-277: self-type composition static props on write (the manifest half of
298
311
  // the lens), so extraction stays schema-free. Document fields self-type via
299
312
  // the schema alone (above); the registry adds blocks/embed prop coverage.
300
313
  lookupManifest: studioManifestLookup(),
301
314
  onError: setPersistError,
302
- // EC-449: adopt the server-computed `hasUnpublishedChanges` verdict from
303
- // every queued upsert's authoritative record (saves, imports, bulk-cleaned
304
- // drafts) — the amber list dot follows the chokepoint, never a client guess.
305
- onDocumentSaved: adoptSavedRecord,
315
+ // EC-595 A2: the persister owns the session's view of every document it writes —
316
+ // the adopted server row with the unflushed local patches re-applied — so an
317
+ // adopted row (a write result, a subscription push, a conflict's current) shows
318
+ // here at once, canonical values included (EC-586 found-but-left).
319
+ onDocument: (ref, doc) => setDocuments((prev) => (doc ? upsertDoc(prev, doc) : removeDoc(prev, ref))),
320
+ // EC-449: adopt the server-computed `hasUnpublishedChanges` verdict (and the
321
+ // timestamps) from every adopted row — the chokepoint's, never a client guess.
322
+ onRecord: adoptSavedRecord,
323
+ onConflicts: (ref, conflicts) => setConflictsByKey((prev) => {
324
+ const next = new Map(prev);
325
+ if (conflicts.length === 0)
326
+ next.delete(documentKey(ref));
327
+ else
328
+ next.set(documentKey(ref), conflicts);
329
+ return next;
330
+ }),
306
331
  });
332
+ // The base every hydrated document's patches are made against (EC-595 A2).
333
+ for (const doc of initialDocuments ?? []) {
334
+ const revision = initialRevisions?.[documentKey(doc)];
335
+ if (revision !== undefined)
336
+ persisterRef.current.track({ document: doc, revision });
337
+ }
307
338
  }
308
339
  // EC-224: published-ness IS the version pointer (`publishedKeys`) — the SOLE
309
340
  // studio source of truth. Declared up here (EC-497) because it is part of THE
@@ -311,10 +342,6 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
311
342
  // helpers — now also takes.
312
343
  const isPublished = useCallback((ref) => publishedKeys.has(documentKey(ref)), [publishedKeys]);
313
344
  const registry = useMemo(() => createCollectionRegistry(collections), [collections]);
314
- // EC-497: the gate seam registers ONCE and reads through refs, so it always sees
315
- // the latest session without re-registering on every keystroke.
316
- const registryRef = useRef(registry);
317
- registryRef.current = registry;
318
345
  const store = useMemo(() => createDocumentStore(documents), [documents]);
319
346
  // Asset references are validated against the REAL asset library (assets live in
320
347
  // their own repository, not as CMS documents). EC-588: the oracle returns the
@@ -348,18 +375,36 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
348
375
  // detail header, which read the same view.
349
376
  return resolveDocumentTitle(collection, valuesInLocale(collection, doc, activeLocale, localeConfig), ref.id);
350
377
  }, [documents, registry, localeConfig, activeLocale]);
351
- const saveDocument = useCallback((doc) => {
352
- // Replace-or-append (upsert): a brand-new document must still land even
353
- // though nothing in the session matches its ref yet.
354
- setDocuments((prev) => upsertDoc(prev, doc));
355
- stampUpdatedAt(doc); // EC-445: a save bumps the $updatedAt column
356
- persisterRef.current?.upsertDocument(doc);
378
+ const commitPatches = useCallback((ref, patches, bases) => {
379
+ if (patches.length === 0)
380
+ return Promise.resolve(undefined);
381
+ stampUpdatedAt(ref); // EC-445: a write bumps the $updatedAt column (the row's own stamp follows)
382
+ const persister = persisterRef.current;
383
+ const current = documentsRef.current.find((d) => matchesRef(d, ref));
384
+ if (!persister) {
385
+ // A fixture session (no project): apply locally, the same merge the stores run.
386
+ if (current) {
387
+ const next = applyDocumentPatches({ document: current, patches, since: [] }).document;
388
+ setDocuments((prev) => replaceDoc(prev, next));
389
+ }
390
+ return Promise.resolve(undefined);
391
+ }
392
+ return persister.commit(ref, patches, current, bases);
393
+ }, [stampUpdatedAt]);
394
+ const insertDocument = useCallback((doc) => {
395
+ stampUpdatedAt(doc); // EC-445: a fresh document's $updatedAt starts at creation
396
+ const persister = persisterRef.current;
397
+ if (persister)
398
+ persister.create(doc);
399
+ else
400
+ setDocuments((prev) => upsertDoc(prev, doc));
357
401
  }, [stampUpdatedAt]);
358
402
  const commitFieldEdit = useCallback((input) => {
359
403
  // Pure map-replace: an edit always targets a document already open in
360
404
  // the session, so a miss (already gone) is a no-op, never an append.
361
405
  const replaceWith = (doc) => setDocuments((prev) => replaceDoc(prev, doc));
362
406
  const persister = persisterRef.current;
407
+ const ref = { collection: input.doc.collection, id: input.doc.id };
363
408
  return commitCellEdit({
364
409
  registry,
365
410
  documents: documentsRef.current,
@@ -375,20 +420,35 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
375
420
  replaceWith(doc);
376
421
  stampUpdatedAt(doc);
377
422
  },
378
- rollbackSession: replaceWith,
423
+ // EC-595 A2: in a persisted session the persister's view is the truth — a
424
+ // refused cell shows theirs (mine is dropped, see below), a failed one keeps
425
+ // the local value queued for the next flush (no silent loss).
426
+ rollbackSession: (previous) => replaceWith(persister?.documentOf(ref) ?? previous),
379
427
  ...(persister && projectId !== undefined
380
428
  ? {
429
+ // EC-595 A2: ONE field patch through the persister's queue — at the
430
+ // revision the row was at — never a whole-document side channel.
381
431
  dispatch: async (next) => {
382
- // Make sure the persisted baseline + every queued mutation
383
- // landed first, then issue the commit's single command.
384
- await persister.ensureBaseline();
385
- const { operations } = await getStudioOperations();
386
- const result = await operations.documents.upsert({ projectId, document: next });
387
- // EC-449: adopt the chokepoint's changed verdict (inline edits
388
- // bypass the persister queue, so its hook doesn't see them).
389
- if (result.ok)
390
- adoptSavedRecord(result.value);
391
- return result;
432
+ const patch = fieldWritePatch(input.collection, next, input.field.name, input.locale);
433
+ const outcome = await persister.commit(ref, [patch], input.doc);
434
+ if (outcome.status === 'applied')
435
+ return { ok: true, value: outcome.record };
436
+ if (outcome.status === 'failed')
437
+ return { ok: false, error: outcome.error };
438
+ // Refused: someone changed this cell's field since the row loaded. A
439
+ // table cell has no room for take/keep, so it takes theirs and says so;
440
+ // the editor offers the choice.
441
+ for (const conflict of outcome.conflicts)
442
+ persister.resolveConflict(ref, conflict.id, 'theirs');
443
+ return {
444
+ ok: false,
445
+ error: {
446
+ code: 'conflict',
447
+ key: documentKey(ref),
448
+ message: 'This field was changed by someone else since you loaded it — their value is shown.',
449
+ messageRef: { key: 'cms.conflict.cellRefused' },
450
+ },
451
+ };
392
452
  },
393
453
  }
394
454
  : {}),
@@ -400,61 +460,47 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
400
460
  isPublished,
401
461
  findAsset,
402
462
  stampUpdatedAt,
403
- adoptSavedRecord,
404
463
  ]);
405
- // Shared insert sequence (EC-326): existingIds/mint/seedDefaults (via
406
- // `buildNewDocument`) + optimistic append + persist, consumed by both
407
- // `createDocument` (always inserts) and `ensureSingletonDocument`'s empty
408
- // branch (`guardExisting: true` — re-checks "no document for this collection
409
- // yet" against the LATEST `prev` inside the `setDocuments` updater, not the
410
- // `documentsRef.current` snapshot the caller already checked, so a
411
- // concurrent invocation — StrictMode's double-invoked effect — can never
412
- // materialize a second singleton document).
413
- const insertNewDocument = useCallback((collectionId, options) => {
414
- const doc = buildNewDocument(collectionId, registry, seq, documentsRef.current, localeConfig);
415
- setDocuments((prev) => options?.guardExisting && hasDocumentFor(prev, collectionId) ? prev : [...prev, doc]);
416
- stampUpdatedAt(doc); // EC-445: a fresh draft's $updatedAt starts at creation time
417
- persisterRef.current?.upsertDocument(doc);
418
- return doc;
419
- }, [registry, stampUpdatedAt, localeConfig]);
420
464
  const createDocument = useCallback((collectionId) => {
421
- const doc = insertNewDocument(collectionId);
465
+ // EC-578: the id comes from the injected factory; create is insert-if-absent.
466
+ const doc = buildNewDocument(collectionId, registry, newId, localeConfig);
467
+ insertDocument(doc);
422
468
  return { collection: collectionId, id: doc.id };
423
- }, [insertNewDocument]);
424
- // EC-274 (re-scopes EC-217): a singleton is a UI lens, not a fixed id. Resolve
425
- // the collection's lone document and return its ref; if none exists yet, create
426
- // one with a NORMAL generated id (never the collection id — no code ever names a
427
- // literal doc id). Idempotent under React effects/StrictMode: the create guard
428
- // keys on "any document in this collection", so a re-invocation never adds a
429
- // second. On drift (>1 doc) the studio routes to the list, so this defensively
430
- // returns the first; the empty draft persists like any create.
469
+ }, [registry, newId, localeConfig, insertDocument]);
470
+ // EC-274 (re-scopes EC-217): a singleton is a UI lens, not a fixed id. When the
471
+ // session holds none of the collection's documents, create one through
472
+ // `documents.create({ singleton: true })` (EC-595 A2) — the store's
473
+ // `ifCollectionEmpty` precondition, so two sessions on an empty singleton make ONE
474
+ // document; the loser's conflict carries the winner's row, which it adopts. Either
475
+ // way the document lands in the session and the gate opens it. Idempotent (the ref
476
+ // guard); a fixture session (no project) creates locally.
431
477
  const ensureSingletonDocument = useCallback((collectionId) => {
478
+ if (documentsRef.current.some((d) => d.collection === collectionId))
479
+ return;
480
+ if (ensuredSingletons.current.has(collectionId))
481
+ return;
482
+ ensuredSingletons.current.add(collectionId);
432
483
  const collection = registry.get(collectionId);
433
- const resolution = collection
434
- ? resolveSingletonDocument(collection, documentsRef.current)
435
- : { kind: 'empty' };
436
- if (resolution.kind === 'ready') {
437
- return { collection: collectionId, id: resolution.doc.id };
438
- }
439
- if (resolution.kind === 'drift') {
440
- return { collection: collectionId, id: resolution.docs[0].id };
441
- }
442
- // Empty: create the lone document once. The ref guard (synchronous) plus the
443
- // updater's "any doc in collection" check make this idempotent under a
444
- // re-invoked effect or StrictMode's double mount — never a second draft.
445
- const existing = documentsRef.current.find((d) => d.collection === collectionId);
446
- if (existing)
447
- return { collection: collectionId, id: existing.id };
448
- if (ensuredSingletons.current.has(collectionId)) {
449
- return { collection: collectionId, id: `new-${collectionId}-pending` };
484
+ const seeded = collection ? seedDefaults(collection, localeConfig) : { values: {} };
485
+ const persister = persisterRef.current;
486
+ if (!persister || projectId === undefined) {
487
+ const doc = { collection: collectionId, id: newId(), ...seeded };
488
+ setDocuments((prev) => (hasDocumentFor(prev, collectionId) ? prev : [...prev, doc]));
489
+ return;
450
490
  }
451
- ensuredSingletons.current.add(collectionId);
452
- // EC-316: same collision-free mint as `createDocument` (via the shared
453
- // `insertNewDocument` — defensive here, the singleton-empty branch only
454
- // runs when no document exists yet).
455
- const doc = insertNewDocument(collectionId, { guardExisting: true });
456
- return { collection: collectionId, id: doc.id };
457
- }, [registry, insertNewDocument]);
491
+ void (async () => {
492
+ const { operations } = await getStudioOperations();
493
+ const outcome = await createOrAdoptSingleton(operations, projectId, collectionId, seeded);
494
+ if (!outcome.ok) {
495
+ setPersistError(outcome.error);
496
+ ensuredSingletons.current.delete(collectionId);
497
+ return;
498
+ }
499
+ persister.track(outcome.record);
500
+ adoptSavedRecord(outcome.record);
501
+ setDocuments((prev) => upsertDoc(prev, outcome.record.document));
502
+ })();
503
+ }, [registry, localeConfig, projectId, newId, adoptSavedRecord]);
458
504
  const deleteDocument = useCallback((ref) => {
459
505
  setDocuments((prev) => removeDoc(prev, ref));
460
506
  persisterRef.current?.deleteDocument(ref);
@@ -501,19 +547,47 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
501
547
  stampHasUnpublishedChanges(ref, record.value.hasUnpublishedChanges);
502
548
  }
503
549
  }, [projectId, setPublished, stampUpdatedAt, stampPublishedAt, stampHasUnpublishedChanges]);
504
- const publishDocument = useCallback(async (ref) => {
505
- setPublished(ref, true); // optimistic
506
- // EC-273: no clearing here — the draft is already free of conditionally-hidden
507
- // values (the editor clears them on write), so the snapshot the publish op
508
- // pins is clean by construction.
509
- persisterRef.current?.publishDocument(ref);
510
- await persisterRef.current?.flush();
511
- await reconcilePublished(ref);
512
- }, [setPublished, reconcilePublished]);
550
+ /**
551
+ * EC-595 binding point 6 — publish the revision the client validated. FORCED FLUSH
552
+ * first (the pending patches land, the session adopts the committed draft), then
553
+ * `documents.publish({ expectedRevision })`: the store refuses when the draft moved
554
+ * since (a colleague's save landed in between — Codex P05). On that refusal the
555
+ * session adopts the current row, RE-VALIDATES it, and retries — bounded, and never
556
+ * past a validation error the new draft carries. Returns the outcome so a bulk run
557
+ * can report it.
558
+ */
559
+ const publishWithRevision = useCallback(async (ref) => {
560
+ const persister = persisterRef.current;
561
+ if (!persister || projectId === undefined)
562
+ return { ok: true, value: undefined };
563
+ const { operations } = await getStudioOperations();
564
+ return publishRevision(ref, {
565
+ flush: (target) => persister.flush(target),
566
+ revisionOf: (target) => persister.revisionOf(target),
567
+ publish: (target, expectedRevision) => operations.documents.publish({
568
+ projectId,
569
+ ref: target,
570
+ ...(expectedRevision !== undefined ? { expectedRevision } : {}),
571
+ }),
572
+ adopt: (record) => persister.adoptRecord(record),
573
+ documentOf: (target) => persister.documentOf(target),
574
+ validate: (doc) => validateRef.current(doc),
575
+ });
576
+ }, [projectId]);
577
+ const publishDocument = useCallback((ref) =>
578
+ // EC-585: the hidden-clear runs INSIDE `documents.publish` (for this and every
579
+ // other caller), and a refusal rejects — see `publishInSession`.
580
+ publishInSession(ref, {
581
+ markPublished: (target) => setPublished(target, true), // optimistic
582
+ publish: publishWithRevision,
583
+ reconcile: reconcilePublished,
584
+ report: setPersistError,
585
+ describe: operationErrorMessage,
586
+ }), [setPublished, reconcilePublished, publishWithRevision]);
513
587
  const unpublishDocument = useCallback(async (ref) => {
514
588
  setPublished(ref, false); // optimistic
515
589
  persisterRef.current?.unpublishDocument(ref);
516
- await persisterRef.current?.flush();
590
+ await persisterRef.current?.flush(ref);
517
591
  await reconcilePublished(ref);
518
592
  }, [setPublished, reconcilePublished]);
519
593
  const loadDocumentVersions = useCallback(async (ref) => {
@@ -540,10 +614,10 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
540
614
  const restoreDocumentVersion = useCallback(async (ref, version, scope) => {
541
615
  if (projectId === undefined)
542
616
  return null;
543
- // Land any queued edits before the restore reads the current draft to
544
- // snapshot it (same ordering contract as commitFieldEdit/runBulk). This is
545
- // what makes a granular (field/block) restore merge over the LATEST draft.
546
- await persisterRef.current?.ensureBaseline();
617
+ // FORCED FLUSH: land any queued edits before the restore reads the current draft
618
+ // to snapshot it. This is what makes a granular (field/block) restore merge over
619
+ // the LATEST draft — and the restore replaces AT the revision it read (EC-595).
620
+ await persisterRef.current?.flush(ref);
547
621
  const { operations } = await getStudioOperations();
548
622
  const result = await operations.documents.restore({
549
623
  projectId,
@@ -555,15 +629,15 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
555
629
  setPersistError(result.error);
556
630
  return null;
557
631
  }
558
- const restored = result.value.document;
559
- // Pure map-replace: a restore always targets a document already open in
560
- // the session.
561
- setDocuments((prev) => replaceDoc(prev, restored));
562
- // EC-449: a restore is a save-shaped write — adopt the chokepoint's fresh
563
- // changed verdict from the returned record (e.g. restoring a published
564
- // doc's draft to an older version flips the dot to amber immediately).
632
+ // EC-595 A2: the restored row is adopted as the new base (its revision, its
633
+ // canonical values, its changed flag — EC-449) and shows in the session.
634
+ const persister = persisterRef.current;
635
+ if (persister)
636
+ persister.adoptRecord(result.value);
637
+ else
638
+ setDocuments((prev) => replaceDoc(prev, result.value.document));
565
639
  adoptSavedRecord(result.value);
566
- return restored;
640
+ return persister?.documentOf(ref) ?? result.value.document;
567
641
  }, [projectId, adoptSavedRecord]);
568
642
  /**
569
643
  * EC-497 — THE option bag (AD-497a), built once and used by every surface that
@@ -596,22 +670,15 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
596
670
  ...documentReachabilityIssues({ routes: resolveDeploymentRoutes(), localeConfig }, collection, doc, { siblings: documents.filter((sibling) => sibling.collection === doc.collection) }),
597
671
  ];
598
672
  }, [registry, validateOptions, documents, localeConfig]);
599
- /**
600
- * EC-497 — publish the session's canonical validator to the gate seam, so the
601
- * operations-layer `documents.publish` gate judges a document with exactly these
602
- * rules instead of the weaker, mode-dependent ones it read off the adapter's
603
- * schema record. `[]` for a collection the config does not declare preserves the
604
- * EC-183/AD-11 data-store rule (studio system collections carry no config field
605
- * rules); a validator kept fresh through a ref means the seam sees the latest
606
- * session without re-registering per keystroke.
607
- */
608
- const gateRef = useRef(validate);
609
- gateRef.current = validate;
610
- useEffect(() => {
611
- const owned = (doc) => registryRef.current.has(doc.collection) ? gateRef.current(doc) : [];
612
- setSessionDocumentValidator(owned);
613
- return () => clearSessionDocumentValidator(owned);
614
- }, []);
673
+ // The publish retry re-validates with the session's latest rules (a ref: no churn).
674
+ const validateRef = useRef(validate);
675
+ validateRef.current = validate;
676
+ // EC-595 A2 (F23): this session no longer publishes its validator to a module-global
677
+ // gate seam. The publish gate validates with the operations bundle's runtime
678
+ // validator (`lib/operations/runtime-validator.ts` — the operation's project config +
679
+ // oracles prefetched through the adapter), so mounting a second provider (the
680
+ // transfer dialog) can no longer change a verdict. `validate` above stays the
681
+ // session's DISPLAY validation (the editor's issues, cell edit, import, AI).
615
682
  const importDocuments = useCallback((collectionId, raw) => {
616
683
  const report = { added: 0, rejected: [] };
617
684
  // Pre-parse so intra-batch relation/asset targets resolve during validation.
@@ -659,9 +726,20 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
659
726
  // Replace-or-append (upsert): an imported row may be brand new to the
660
727
  // session, so each accepted document must land even with no prior
661
728
  // entry for its ref.
662
- setDocuments((prev) => accepted.reduce((acc, doc) => upsertDoc(acc, doc), prev));
663
- for (const doc of accepted)
664
- persisterRef.current?.upsertDocument(doc);
729
+ // EC-595 A2: a row new to the store is CREATED (insert-if-absent); a row the
730
+ // session already holds is REPLACED at the revision it was loaded at — an import
731
+ // never overwrites a change made since.
732
+ const persister = persisterRef.current;
733
+ if (!persister)
734
+ setDocuments((prev) => accepted.reduce((acc, doc) => upsertDoc(acc, doc), prev));
735
+ for (const doc of accepted) {
736
+ if (!persister)
737
+ continue;
738
+ if (persister.revisionOf(doc) !== undefined)
739
+ persister.replace(doc);
740
+ else
741
+ persister.create(doc);
742
+ }
665
743
  // EC-224: imports land as the working DRAFT. Publish-ness is the version
666
744
  // pointer (`publishedKeys`), set by publishing — never carried in the
667
745
  // imported row — so an import never auto-publishes; the editor publishes
@@ -762,20 +840,19 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
762
840
  let dispatch;
763
841
  const persister = persisterRef.current;
764
842
  if (persister && projectId !== undefined) {
765
- // Pending queued mutations + baseline land first, then one awaited
766
- // command per item (same ordering contract as commitFieldEdit).
767
- await persister.ensureBaseline();
843
+ // Pending queued writes land first, then one awaited command per item.
844
+ await persister.flush();
768
845
  const { operations } = await getStudioOperations();
769
- dispatch = (bulkAction, ref) => {
846
+ dispatch = async (bulkAction, ref) => {
770
847
  if (bulkAction === 'delete')
771
848
  return operations.documents.delete({ projectId, ref });
849
+ // EC-595 point 6: publish the revision validated, with the same retry.
772
850
  if (bulkAction === 'publish')
773
- return operations.documents.publish({ projectId, ref });
851
+ return publishWithRevision(ref);
774
852
  if (bulkAction === 'unpublish')
775
853
  return operations.documents.unpublish({ projectId, ref });
776
- // set-field: upsert the document carrying the new value. The write
777
- // path self-types it through the schema lens (EC-277); a scalar field
778
- // stores its scalar as-is.
854
+ // set-field: ONE field patch per document through the persister (EC-595 A2) —
855
+ // the operation canonicalises the value by the field (EC-277/EC-586).
779
856
  const current = documentsRef.current.find((d) => matchesRef(d, ref));
780
857
  if (!current) {
781
858
  return Promise.resolve({
@@ -788,15 +865,24 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
788
865
  },
789
866
  });
790
867
  }
791
- return operations.documents
792
- .upsert({ projectId, document: setFieldDoc(current) })
793
- .then((result) => {
794
- // EC-449: adopt the chokepoint's changed verdict per set-field item
795
- // (bulk dispatch bypasses the persister queue and its hook).
796
- if (result.ok)
797
- adoptSavedRecord(result.value);
798
- return result;
799
- });
868
+ const written = setFieldDoc(current);
869
+ const outcome = await persister.commit(ref, [fieldWritePatch(collection, written, setField.field, activeLocale)], current);
870
+ if (outcome.status === 'applied')
871
+ return { ok: true, value: outcome.record };
872
+ if (outcome.status === 'failed')
873
+ return { ok: false, error: outcome.error };
874
+ // Refused: the field changed since — the item fails visibly; theirs stays.
875
+ for (const conflict of outcome.conflicts)
876
+ persister.resolveConflict(ref, conflict.id, 'theirs');
877
+ return {
878
+ ok: false,
879
+ error: {
880
+ code: 'conflict',
881
+ key: documentKey(ref),
882
+ message: 'This field was changed by someone else since you loaded it — their value is kept.',
883
+ messageRef: { key: 'cms.conflict.cellRefused' },
884
+ },
885
+ };
800
886
  };
801
887
  }
802
888
  return runBulkAction({
@@ -813,27 +899,9 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
813
899
  isPublished,
814
900
  ...(findAsset ? { findAsset } : {}),
815
901
  })),
816
- // EC-425: bulk publish joins the single-document clean point — the
817
- // SAME shared hidden-strip `doPublish` runs (EC-273 field values +
818
- // EC-394 component props, session registry), per item.
819
- // EC-580: in the session's locale views — the single publish's same step.
820
- ...(collection
821
- ? {
822
- cleanForPublish: (doc) => cleanHiddenForPublish(collection, doc, undefined, localeConfig),
823
- }
824
- : {}),
825
- // Land a cleaned draft exactly the way the editor's publish does:
826
- // session apply + the persister's self-typing upsert (EC-277 manifest
827
- // lens), flushed so the serial queue lands the upsert BEFORE the
828
- // awaited publish command snapshots the stored draft.
829
- applyCleaned: async (_ref, cleaned) => {
830
- setDocuments((prev) => replaceDoc(prev, cleaned));
831
- const persister = persisterRef.current;
832
- if (persister && projectId !== undefined) {
833
- persister.upsertDocument(cleaned);
834
- await persister.flush();
835
- }
836
- },
902
+ // EC-585: no clean step here — the hidden-clear runs inside the one publish
903
+ // operation each item's `publishWithRevision` dispatches (EC-425's shared
904
+ // clean point, moved to where every publish path crosses it).
837
905
  // EC-224: a succeeded publish/unpublish moves the version pointer
838
906
  // (`publishedKeys`) via setPublished — there is no separate flat
839
907
  // `state` mirror to keep in sync (published-ness is derived from the
@@ -850,7 +918,13 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
850
918
  // locale-aware path the dispatched upsert used). Not a plain
851
919
  // `replaceDoc` — the replacement is computed FROM each matched
852
920
  // document (`setFieldDoc(d)`), not a fixed value to swap in.
853
- applySetField: (ref) => setDocuments((prev) => prev.map((d) => (matchesRef(d, ref) ? setFieldDoc(d) : d))),
921
+ // A persisted session's view already holds the committed patch (the persister);
922
+ // a fixture session folds the value locally.
923
+ applySetField: (ref) => {
924
+ if (persisterRef.current)
925
+ return;
926
+ setDocuments((prev) => prev.map((d) => (matchesRef(d, ref) ? setFieldDoc(d) : d)));
927
+ },
854
928
  ...(dispatch ? { dispatch } : {}),
855
929
  }, collectionId, ids, action, setField);
856
930
  }, [
@@ -862,9 +936,62 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
862
936
  setPublished,
863
937
  isPublished,
864
938
  findAsset,
865
- adoptSavedRecord,
866
939
  stampHasUnpublishedChanges,
940
+ publishWithRevision,
867
941
  ]);
942
+ // EC-595 point 7 — follow one open document: pushed rows go to the persister (applied
943
+ // under the local edits), presence to the caller. The capability lives on the
944
+ // operations bundle (the UI never holds a reactive Convex client).
945
+ const subscribeDocument = useCallback((ref, onPresence) => {
946
+ if (projectId === undefined)
947
+ return () => { };
948
+ let stop = () => { };
949
+ let stopped = false;
950
+ void getStudioOperations().then((studio) => {
951
+ if (stopped)
952
+ return;
953
+ stop = studio.subscribeDocument(projectId, ref, {
954
+ onSnapshot: (snapshot) => {
955
+ persisterRef.current?.adoptSnapshot(ref, snapshot.record);
956
+ onPresence(snapshot.presence);
957
+ },
958
+ });
959
+ });
960
+ return () => {
961
+ stopped = true;
962
+ stop();
963
+ };
964
+ }, [projectId]);
965
+ // EC-595 thin presence: this tab's heartbeat on a document (the focused field's path).
966
+ const reportPresence = useCallback((ref, path) => {
967
+ if (projectId === undefined)
968
+ return;
969
+ const actor = getStudioActor();
970
+ void getStudioOperations()
971
+ .then((studio) => studio.reportPresence(projectId, ref, {
972
+ sessionId: PRESENCE_SESSION_ID,
973
+ ...(actor ? { actor: actor.id } : {}),
974
+ ...(actor?.name ? { actorName: actor.name } : {}),
975
+ ...(path ? { path } : {}),
976
+ }))
977
+ .catch(() => {
978
+ // Reported on the operations bus (EC-403 sees an expiry); presence is advisory.
979
+ });
980
+ }, [projectId]);
981
+ const clearPresence = useCallback((ref) => {
982
+ if (projectId === undefined)
983
+ return;
984
+ void getStudioOperations()
985
+ .then((studio) => studio.clearPresence(projectId, ref, PRESENCE_SESSION_ID))
986
+ .catch(() => { });
987
+ }, [projectId]);
988
+ const replaceDocument = useCallback((doc) => {
989
+ const persister = persisterRef.current;
990
+ if (persister)
991
+ persister.replace(doc);
992
+ else
993
+ setDocuments((prev) => replaceDoc(prev, doc));
994
+ }, []);
868
995
  const store$ = useMemo(() => ({
869
996
  ...(projectId !== undefined ? { projectId } : {}),
870
997
  collections,
@@ -874,10 +1001,21 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
874
1001
  documentsOf: (collectionId) => documents.filter((d) => d.collection === collectionId),
875
1002
  getDocument,
876
1003
  titleOf,
877
- saveDocument,
1004
+ commitPatches,
1005
+ revisionOf: (ref) => persisterRef.current?.revisionOf(ref),
1006
+ insertDocument,
1007
+ newDocumentId: newId,
878
1008
  commitFieldEdit,
879
1009
  createDocument,
880
1010
  ensureSingletonDocument,
1011
+ documentConflicts: (ref) => conflictsByKey.get(documentKey(ref)) ?? NO_CONFLICTS,
1012
+ resolveConflict: (ref, conflictId, choice) => persisterRef.current?.resolveConflict(ref, conflictId, choice),
1013
+ adoptDocumentSnapshot: (ref, record) => persisterRef.current?.adoptSnapshot(ref, record),
1014
+ subscribeDocument,
1015
+ reportPresence,
1016
+ clearPresence,
1017
+ presenceSessionId: PRESENCE_SESSION_ID,
1018
+ replaceDocument,
881
1019
  deleteDocument,
882
1020
  isPublished,
883
1021
  documentUpdatedAt,
@@ -919,8 +1057,7 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
919
1057
  addCollection: (def) => setCollections((prev) => [...prev.filter((c) => c.id !== def.id), def]),
920
1058
  persistError,
921
1059
  clearPersistError: () => setPersistError(null),
922
- ensurePersistedBaseline: () => persisterRef.current?.ensureBaseline() ?? Promise.resolve(),
923
- flushPersistence: () => persisterRef.current?.flush() ?? Promise.resolve(),
1060
+ flushPersistence: (ref) => persisterRef.current?.flush(ref) ?? Promise.resolve(),
924
1061
  }), [
925
1062
  projectId,
926
1063
  collections,
@@ -930,10 +1067,17 @@ function CmsSession({ children, projectId, initialCollections, initialDocuments,
930
1067
  getCollection,
931
1068
  getDocument,
932
1069
  titleOf,
933
- saveDocument,
1070
+ commitPatches,
1071
+ insertDocument,
1072
+ newId,
934
1073
  commitFieldEdit,
935
1074
  createDocument,
936
1075
  ensureSingletonDocument,
1076
+ conflictsByKey,
1077
+ subscribeDocument,
1078
+ reportPresence,
1079
+ clearPresence,
1080
+ replaceDocument,
937
1081
  deleteDocument,
938
1082
  isPublished,
939
1083
  documentUpdatedAt,