@elytracms/studio 0.0.93 → 0.0.95

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 (541) hide show
  1. package/convex/analytics-cost.test.ts +817 -0
  2. package/convex/asset-alt-migration.test.ts +2 -2
  3. package/convex/asset-meta-patch.test.ts +218 -0
  4. package/convex/asset-replace-delete-race.test.ts +127 -0
  5. package/convex/assets.ts +27 -2
  6. package/convex/auth.ts +5 -2
  7. package/convex/authority.ts +35 -11
  8. package/convex/contract-parity.test.ts +7 -0
  9. package/convex/delivery-redirects.test.ts +167 -0
  10. package/convex/delivery.ts +23 -10
  11. package/convex/guard.ts +9 -7
  12. package/convex/http-routes.test.ts +18 -8
  13. package/convex/http.ts +23 -17
  14. package/convex/ingest.test.ts +326 -0
  15. package/convex/ingest.ts +281 -0
  16. package/convex/invites.ts +13 -5
  17. package/convex/members.ts +10 -4
  18. package/convex/rail-write-parity.test.ts +6 -6
  19. package/convex/record-functions.ts +325 -0
  20. package/convex/records.test.ts +156 -0
  21. package/convex/redirect-migration.test.ts +205 -0
  22. package/convex/redirect-revalidate.test.ts +5 -1
  23. package/convex/redirects.ts +120 -79
  24. package/convex/role-policy.test.ts +315 -0
  25. package/convex/roles.ts +83 -0
  26. package/convex/schema.ts +36 -0
  27. package/convex/triggers.ts +21 -1
  28. package/dist/addon.d.ts +71 -0
  29. package/dist/addon.js +67 -0
  30. package/dist/addon.js.map +1 -0
  31. package/dist/components/account/account-settings.js +2 -2
  32. package/dist/components/account/account-settings.js.map +1 -1
  33. package/dist/components/account/credential-panel.js +2 -2
  34. package/dist/components/account/credential-panel.js.map +1 -1
  35. package/dist/components/account/self-account-schema.d.ts +6 -15
  36. package/dist/components/account/self-account-schema.js +52 -34
  37. package/dist/components/account/self-account-schema.js.map +1 -1
  38. package/dist/components/account/self-account-view-source.d.ts +1 -1
  39. package/dist/components/account/self-account-view-source.js +6 -4
  40. package/dist/components/account/self-account-view-source.js.map +1 -1
  41. package/dist/components/ai/ai-chat-panel-lazy.js +1 -1
  42. package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
  43. package/dist/components/ai/ai-chat-panel.js +6 -6
  44. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  45. package/dist/components/ai/ai-cms-panel.js +2 -2
  46. package/dist/components/ai/ai-cms-panel.js.map +1 -1
  47. package/dist/components/ai/ai-panel-toggle.js +4 -4
  48. package/dist/components/ai/ai-panel-toggle.js.map +1 -1
  49. package/dist/components/ai/chat-ui.js +2 -2
  50. package/dist/components/ai/chat-ui.js.map +1 -1
  51. package/dist/components/ai/proposal-card.d.ts +2 -2
  52. package/dist/components/ai/proposal-card.js +2 -2
  53. package/dist/components/ai/proposal-card.js.map +1 -1
  54. package/dist/components/ai/proposal-issues.js +2 -2
  55. package/dist/components/ai/proposal-issues.js.map +1 -1
  56. package/dist/components/ai/translate-document-action.js +2 -2
  57. package/dist/components/ai/translate-document-action.js.map +1 -1
  58. package/dist/components/ai/translate-document-dialog.js +6 -6
  59. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  60. package/dist/components/ai/translate-skipped.d.ts +1 -1
  61. package/dist/components/ai/translate-skipped.js +2 -2
  62. package/dist/components/ai/translate-skipped.js.map +1 -1
  63. package/dist/components/app-views/default-modules.d.ts +50 -2
  64. package/dist/components/app-views/default-modules.js +65 -20
  65. package/dist/components/app-views/default-modules.js.map +1 -1
  66. package/dist/components/app-views/module.d.ts +53 -3
  67. package/dist/components/app-views/module.js +14 -7
  68. package/dist/components/app-views/module.js.map +1 -1
  69. package/dist/components/app-views/sidebar.js +1 -1
  70. package/dist/components/app-views/sidebar.js.map +1 -1
  71. package/dist/components/app-views/studio-config.d.ts +11 -2
  72. package/dist/components/app-views/studio-config.js.map +1 -1
  73. package/dist/components/assets/asset-detail.js +28 -13
  74. package/dist/components/assets/asset-detail.js.map +1 -1
  75. package/dist/components/assets/asset-grid.js +2 -2
  76. package/dist/components/assets/asset-grid.js.map +1 -1
  77. package/dist/components/assets/asset-locale.d.ts +9 -0
  78. package/dist/components/assets/asset-locale.js +16 -0
  79. package/dist/components/assets/asset-locale.js.map +1 -0
  80. package/dist/components/assets/asset-meta-label.d.ts +12 -0
  81. package/dist/components/assets/asset-meta-label.js +15 -0
  82. package/dist/components/assets/asset-meta-label.js.map +1 -0
  83. package/dist/components/assets/asset-metadata.d.ts +69 -15
  84. package/dist/components/assets/asset-metadata.js +99 -11
  85. package/dist/components/assets/asset-metadata.js.map +1 -1
  86. package/dist/components/assets/asset-search.js +8 -2
  87. package/dist/components/assets/asset-search.js.map +1 -1
  88. package/dist/components/assets/asset-thumb.js +9 -5
  89. package/dist/components/assets/asset-thumb.js.map +1 -1
  90. package/dist/components/assets/assets-collection-view-source.d.ts +25 -11
  91. package/dist/components/assets/assets-collection-view-source.js +78 -26
  92. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  93. package/dist/components/assets/assets-module.js +1 -1
  94. package/dist/components/assets/assets-module.js.map +1 -1
  95. package/dist/components/assets/assets-store.d.ts +2 -2
  96. package/dist/components/assets/assets-store.js +22 -9
  97. package/dist/components/assets/assets-store.js.map +1 -1
  98. package/dist/components/assets/assets-view-preference.js +1 -1
  99. package/dist/components/assets/assets-view-preference.js.map +1 -1
  100. package/dist/components/assets/replace-file.js +1 -1
  101. package/dist/components/assets/replace-file.js.map +1 -1
  102. package/dist/components/auth-context.d.ts +1 -1
  103. package/dist/components/auth-context.js +6 -6
  104. package/dist/components/auth-context.js.map +1 -1
  105. package/dist/components/builder/blocks-model.d.ts +1 -1
  106. package/dist/components/builder/blocks-model.js +3 -3
  107. package/dist/components/builder/blocks-model.js.map +1 -1
  108. package/dist/components/builder/blocks-panel.d.ts +1 -1
  109. package/dist/components/builder/blocks-panel.js +4 -4
  110. package/dist/components/builder/blocks-panel.js.map +1 -1
  111. package/dist/components/builder/builder-store.d.ts +21 -0
  112. package/dist/components/builder/builder-store.js +24 -2
  113. package/dist/components/builder/builder-store.js.map +1 -1
  114. package/dist/components/builder/canvas-breakpoints.js +1 -1
  115. package/dist/components/builder/canvas-breakpoints.js.map +1 -1
  116. package/dist/components/builder/canvas-commit-boundary.d.ts +22 -0
  117. package/dist/components/builder/canvas-commit-boundary.js +27 -0
  118. package/dist/components/builder/canvas-commit-boundary.js.map +1 -0
  119. package/dist/components/builder/canvas-dnd.d.ts +2 -2
  120. package/dist/components/builder/canvas-dnd.js +1 -1
  121. package/dist/components/builder/canvas-dnd.js.map +1 -1
  122. package/dist/components/builder/canvas-inline-editing.d.ts +1 -1
  123. package/dist/components/builder/canvas-inline-editing.js.map +1 -1
  124. package/dist/components/builder/canvas-overlay-geometry.d.ts +1 -1
  125. package/dist/components/builder/canvas-overlay-geometry.js.map +1 -1
  126. package/dist/components/builder/canvas-rebase.d.ts +77 -0
  127. package/dist/components/builder/canvas-rebase.js +169 -0
  128. package/dist/components/builder/canvas-rebase.js.map +1 -0
  129. package/dist/components/builder/canvas-render-context.d.ts +7 -4
  130. package/dist/components/builder/canvas-render-context.js +21 -13
  131. package/dist/components/builder/canvas-render-context.js.map +1 -1
  132. package/dist/components/builder/canvas-theme-notice.js +2 -2
  133. package/dist/components/builder/canvas-theme-notice.js.map +1 -1
  134. package/dist/components/builder/canvas.js +3 -3
  135. package/dist/components/builder/canvas.js.map +1 -1
  136. package/dist/components/builder/clipboard-actions.d.ts +2 -2
  137. package/dist/components/builder/clipboard-actions.js +4 -4
  138. package/dist/components/builder/clipboard-actions.js.map +1 -1
  139. package/dist/components/builder/composition-field-editor-lazy.js +1 -1
  140. package/dist/components/builder/composition-field-editor-lazy.js.map +1 -1
  141. package/dist/components/builder/composition-field-editor.d.ts +21 -3
  142. package/dist/components/builder/composition-field-editor.js +48 -30
  143. package/dist/components/builder/composition-field-editor.js.map +1 -1
  144. package/dist/components/builder/drag-session.d.ts +1 -1
  145. package/dist/components/builder/drag-session.js.map +1 -1
  146. package/dist/components/builder/fixture-thumbnail.js +1 -1
  147. package/dist/components/builder/fixture-thumbnail.js.map +1 -1
  148. package/dist/components/builder/insert-context.d.ts +1 -1
  149. package/dist/components/builder/insert-context.js +3 -3
  150. package/dist/components/builder/insert-context.js.map +1 -1
  151. package/dist/components/builder/layout-inspector.js +1 -1
  152. package/dist/components/builder/layout-inspector.js.map +1 -1
  153. package/dist/components/builder/node-factory.js +15 -3
  154. package/dist/components/builder/node-factory.js.map +1 -1
  155. package/dist/components/builder/node-title.js +1 -1
  156. package/dist/components/builder/node-title.js.map +1 -1
  157. package/dist/components/builder/page-tree.d.ts +1 -1
  158. package/dist/components/builder/page-tree.js +5 -5
  159. package/dist/components/builder/page-tree.js.map +1 -1
  160. package/dist/components/builder/placement-refusal.js +3 -3
  161. package/dist/components/builder/placement-refusal.js.map +1 -1
  162. package/dist/components/builder/property-inspector.js +7 -5
  163. package/dist/components/builder/property-inspector.js.map +1 -1
  164. package/dist/components/builder/reuse-context.js +4 -4
  165. package/dist/components/builder/reuse-context.js.map +1 -1
  166. package/dist/components/builder/reuse.js +1 -1
  167. package/dist/components/builder/reuse.js.map +1 -1
  168. package/dist/components/builder/rich-text-toolbar.js +1 -1
  169. package/dist/components/builder/rich-text-toolbar.js.map +1 -1
  170. package/dist/components/builder/run-inspector.js +11 -7
  171. package/dist/components/builder/run-inspector.js.map +1 -1
  172. package/dist/components/builder/selection-overlay.js +3 -3
  173. package/dist/components/builder/selection-overlay.js.map +1 -1
  174. package/dist/components/builder/spacing-handles.d.ts +2 -2
  175. package/dist/components/builder/spacing-handles.js +3 -3
  176. package/dist/components/builder/spacing-handles.js.map +1 -1
  177. package/dist/components/cms/asset-field.d.ts +5 -2
  178. package/dist/components/cms/asset-field.js +36 -19
  179. package/dist/components/cms/asset-field.js.map +1 -1
  180. package/dist/components/cms/asset-picker.js +5 -3
  181. package/dist/components/cms/asset-picker.js.map +1 -1
  182. package/dist/components/cms/bulk-actions.d.ts +1 -1
  183. package/dist/components/cms/bulk-actions.js +2 -2
  184. package/dist/components/cms/bulk-actions.js.map +1 -1
  185. package/dist/components/cms/bulk-bar.d.ts +2 -2
  186. package/dist/components/cms/bulk-bar.js +2 -2
  187. package/dist/components/cms/bulk-bar.js.map +1 -1
  188. package/dist/components/cms/canvas-commit.d.ts +56 -0
  189. package/dist/components/cms/canvas-commit.js +35 -0
  190. package/dist/components/cms/canvas-commit.js.map +1 -0
  191. package/dist/components/cms/cell-edit.d.ts +1 -1
  192. package/dist/components/cms/cell-edit.js +1 -1
  193. package/dist/components/cms/cell-edit.js.map +1 -1
  194. package/dist/components/cms/cell.d.ts +1 -1
  195. package/dist/components/cms/cell.js +3 -3
  196. package/dist/components/cms/cell.js.map +1 -1
  197. package/dist/components/cms/cms-index.js +2 -2
  198. package/dist/components/cms/cms-index.js.map +1 -1
  199. package/dist/components/cms/cms-labels.js +2 -2
  200. package/dist/components/cms/cms-labels.js.map +1 -1
  201. package/dist/components/cms/cms-store.d.ts +2 -2
  202. package/dist/components/cms/cms-store.js +10 -10
  203. package/dist/components/cms/cms-store.js.map +1 -1
  204. package/dist/components/cms/collection-table.js +30 -14
  205. package/dist/components/cms/collection-table.js.map +1 -1
  206. package/dist/components/cms/commit-policy.d.ts +6 -2
  207. package/dist/components/cms/commit-policy.js +1 -1
  208. package/dist/components/cms/commit-policy.js.map +1 -1
  209. package/dist/components/cms/detail-editor.d.ts +5 -2
  210. package/dist/components/cms/detail-editor.js +31 -15
  211. package/dist/components/cms/detail-editor.js.map +1 -1
  212. package/dist/components/cms/details-pane.js +1 -1
  213. package/dist/components/cms/details-pane.js.map +1 -1
  214. package/dist/components/cms/doc-state.js +1 -1
  215. package/dist/components/cms/doc-state.js.map +1 -1
  216. package/dist/components/cms/document-diff-view.js +5 -5
  217. package/dist/components/cms/document-diff-view.js.map +1 -1
  218. package/dist/components/cms/document-editor-context.js +1 -1
  219. package/dist/components/cms/document-editor-context.js.map +1 -1
  220. package/dist/components/cms/document-staging.js +1 -1
  221. package/dist/components/cms/document-staging.js.map +1 -1
  222. package/dist/components/cms/document-url-path.js +2 -2
  223. package/dist/components/cms/document-url-path.js.map +1 -1
  224. package/dist/components/cms/duplicate-document.d.ts +1 -1
  225. package/dist/components/cms/duplicate-document.js.map +1 -1
  226. package/dist/components/cms/editable-cell.js +3 -3
  227. package/dist/components/cms/editable-cell.js.map +1 -1
  228. package/dist/components/cms/field-collaboration.d.ts +1 -1
  229. package/dist/components/cms/field-collaboration.js +1 -1
  230. package/dist/components/cms/field-collaboration.js.map +1 -1
  231. package/dist/components/cms/field-connection.d.ts +1 -1
  232. package/dist/components/cms/field-connection.js +2 -2
  233. package/dist/components/cms/field-connection.js.map +1 -1
  234. package/dist/components/cms/field-control-lib.js +1 -1
  235. package/dist/components/cms/field-control-lib.js.map +1 -1
  236. package/dist/components/cms/field-control.js +4 -4
  237. package/dist/components/cms/field-control.js.map +1 -1
  238. package/dist/components/cms/field-renderer.d.ts +1 -1
  239. package/dist/components/cms/field-renderer.js +2 -2
  240. package/dist/components/cms/field-renderer.js.map +1 -1
  241. package/dist/components/cms/filter-builder.d.ts +1 -1
  242. package/dist/components/cms/filter-builder.js +3 -3
  243. package/dist/components/cms/filter-builder.js.map +1 -1
  244. package/dist/components/cms/gate-validation.js +1 -1
  245. package/dist/components/cms/gate-validation.js.map +1 -1
  246. package/dist/components/cms/import-export.js +2 -2
  247. package/dist/components/cms/import-export.js.map +1 -1
  248. package/dist/components/cms/issue-copy.js +1 -1
  249. package/dist/components/cms/issue-copy.js.map +1 -1
  250. package/dist/components/cms/issues-pane.js +3 -3
  251. package/dist/components/cms/issues-pane.js.map +1 -1
  252. package/dist/components/cms/object-array-field.js +2 -2
  253. package/dist/components/cms/object-array-field.js.map +1 -1
  254. package/dist/components/cms/prop-fields.js +2 -2
  255. package/dist/components/cms/prop-fields.js.map +1 -1
  256. package/dist/components/cms/reference-confirm.d.ts +1 -1
  257. package/dist/components/cms/reference-confirm.js +2 -2
  258. package/dist/components/cms/reference-confirm.js.map +1 -1
  259. package/dist/components/cms/relation-picker.js +2 -2
  260. package/dist/components/cms/relation-picker.js.map +1 -1
  261. package/dist/components/cms/rich-text/block-style.js +1 -1
  262. package/dist/components/cms/rich-text/block-style.js.map +1 -1
  263. package/dist/components/cms/rich-text/component-embed.js +4 -4
  264. package/dist/components/cms/rich-text/component-embed.js.map +1 -1
  265. package/dist/components/cms/rich-text/editor.js +3 -3
  266. package/dist/components/cms/rich-text/editor.js.map +1 -1
  267. package/dist/components/cms/rich-text/embed-picker.js +3 -3
  268. package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
  269. package/dist/components/cms/rich-text/embed-props.js +3 -3
  270. package/dist/components/cms/rich-text/embed-props.js.map +1 -1
  271. package/dist/components/cms/rich-text/extensions.js +1 -1
  272. package/dist/components/cms/rich-text/extensions.js.map +1 -1
  273. package/dist/components/cms/rich-text/font-size-control.d.ts +1 -1
  274. package/dist/components/cms/rich-text/font-size-control.js +2 -2
  275. package/dist/components/cms/rich-text/font-size-control.js.map +1 -1
  276. package/dist/components/cms/rich-text/lazy.js +1 -1
  277. package/dist/components/cms/rich-text/lazy.js.map +1 -1
  278. package/dist/components/cms/rich-text/link-control.js +2 -2
  279. package/dist/components/cms/rich-text/link-control.js.map +1 -1
  280. package/dist/components/cms/rich-text/slash-command.js +3 -3
  281. package/dist/components/cms/rich-text/slash-command.js.map +1 -1
  282. package/dist/components/cms/rich-text/toolbar.js +1 -1
  283. package/dist/components/cms/rich-text/toolbar.js.map +1 -1
  284. package/dist/components/cms/rich-text/unknown-node.js +2 -2
  285. package/dist/components/cms/rich-text/unknown-node.js.map +1 -1
  286. package/dist/components/cms/rich-text-preview.js +2 -2
  287. package/dist/components/cms/rich-text-preview.js.map +1 -1
  288. package/dist/components/cms/row-actions.d.ts +1 -1
  289. package/dist/components/cms/row-actions.js.map +1 -1
  290. package/dist/components/cms/scalar-value-editor.d.ts +1 -1
  291. package/dist/components/cms/scalar-value-editor.js +2 -2
  292. package/dist/components/cms/scalar-value-editor.js.map +1 -1
  293. package/dist/components/cms/use-discard-controller.d.ts +1 -1
  294. package/dist/components/cms/use-discard-controller.js +1 -1
  295. package/dist/components/cms/use-discard-controller.js.map +1 -1
  296. package/dist/components/cms/use-document-draft.d.ts +69 -5
  297. package/dist/components/cms/use-document-draft.js +118 -34
  298. package/dist/components/cms/use-document-draft.js.map +1 -1
  299. package/dist/components/cms/use-document-versions.js +1 -1
  300. package/dist/components/cms/use-document-versions.js.map +1 -1
  301. package/dist/components/cms/use-field-focus.d.ts +1 -1
  302. package/dist/components/cms/use-field-focus.js +2 -2
  303. package/dist/components/cms/use-field-focus.js.map +1 -1
  304. package/dist/components/cms/use-live-url.d.ts +7 -2
  305. package/dist/components/cms/use-live-url.js +15 -12
  306. package/dist/components/cms/use-live-url.js.map +1 -1
  307. package/dist/components/cms/use-publish-controller.d.ts +1 -1
  308. package/dist/components/cms/use-publish-controller.js +1 -1
  309. package/dist/components/cms/use-publish-controller.js.map +1 -1
  310. package/dist/components/cms/use-published-perspective.d.ts +1 -1
  311. package/dist/components/cms/use-published-perspective.js.map +1 -1
  312. package/dist/components/cms/use-restore-version.d.ts +1 -1
  313. package/dist/components/cms/use-restore-version.js +1 -1
  314. package/dist/components/cms/use-restore-version.js.map +1 -1
  315. package/dist/components/cms/version-compare-lazy.js +1 -1
  316. package/dist/components/cms/version-compare-lazy.js.map +1 -1
  317. package/dist/components/cms/version-compare.js +1 -1
  318. package/dist/components/cms/version-compare.js.map +1 -1
  319. package/dist/components/cms/versions-pane.js +3 -3
  320. package/dist/components/cms/versions-pane.js.map +1 -1
  321. package/dist/components/cms/view-bar.js +3 -3
  322. package/dist/components/cms/view-bar.js.map +1 -1
  323. package/dist/components/collection-view/cms-collection-view-source.d.ts +1 -1
  324. package/dist/components/collection-view/cms-collection-view-source.js +11 -7
  325. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  326. package/dist/components/collection-view/collection-view-context.d.ts +1 -1
  327. package/dist/components/collection-view/collection-view-context.js +1 -1
  328. package/dist/components/collection-view/collection-view-context.js.map +1 -1
  329. package/dist/components/collection-view/collection-view-surface.d.ts +13 -0
  330. package/dist/components/collection-view/collection-view-surface.js +18 -0
  331. package/dist/components/collection-view/collection-view-surface.js.map +1 -0
  332. package/dist/components/components/component-detail.js +10 -10
  333. package/dist/components/components/component-detail.js.map +1 -1
  334. package/dist/components/components/component-preview.d.ts +2 -2
  335. package/dist/components/components/component-preview.js +5 -5
  336. package/dist/components/components/component-preview.js.map +1 -1
  337. package/dist/components/components/component-status.d.ts +1 -1
  338. package/dist/components/components/component-status.js +1 -1
  339. package/dist/components/components/component-status.js.map +1 -1
  340. package/dist/components/components/components-index.js +2 -2
  341. package/dist/components/components/components-index.js.map +1 -1
  342. package/dist/components/components/field-inspector.js +1 -1
  343. package/dist/components/components/field-inspector.js.map +1 -1
  344. package/dist/components/components/registry-browser.js +3 -3
  345. package/dist/components/components/registry-browser.js.map +1 -1
  346. package/dist/components/components/taxonomy.d.ts +1 -1
  347. package/dist/components/components/taxonomy.js.map +1 -1
  348. package/dist/components/components/transfer-dialog.js +3 -3
  349. package/dist/components/components/transfer-dialog.js.map +1 -1
  350. package/dist/components/current-user-menu.js +1 -1
  351. package/dist/components/current-user-menu.js.map +1 -1
  352. package/dist/components/delivery-health-banner.js +2 -2
  353. package/dist/components/delivery-health-banner.js.map +1 -1
  354. package/dist/components/deployment-setup-gate.js +13 -4
  355. package/dist/components/deployment-setup-gate.js.map +1 -1
  356. package/dist/components/dev-login-badge.js +2 -2
  357. package/dist/components/dev-login-badge.js.map +1 -1
  358. package/dist/components/guards.js +1 -1
  359. package/dist/components/guards.js.map +1 -1
  360. package/dist/components/inspector/document-panes.d.ts +54 -0
  361. package/dist/components/inspector/document-panes.js +41 -0
  362. package/dist/components/inspector/document-panes.js.map +1 -0
  363. package/dist/components/inspector/inspector-sidebar.js +1 -1
  364. package/dist/components/inspector/inspector-sidebar.js.map +1 -1
  365. package/dist/components/nav-issues-registry.d.ts +53 -0
  366. package/dist/components/nav-issues-registry.js +69 -0
  367. package/dist/components/nav-issues-registry.js.map +1 -0
  368. package/dist/components/nav-issues.js +4 -4
  369. package/dist/components/nav-issues.js.map +1 -1
  370. package/dist/components/operation-error.d.ts +1 -1
  371. package/dist/components/operation-error.js +4 -4
  372. package/dist/components/operation-error.js.map +1 -1
  373. package/dist/components/playground-badge.js +2 -2
  374. package/dist/components/playground-badge.js.map +1 -1
  375. package/dist/components/project-list.js +3 -3
  376. package/dist/components/project-list.js.map +1 -1
  377. package/dist/components/project-session.d.ts +2 -2
  378. package/dist/components/project-session.js +5 -5
  379. package/dist/components/project-session.js.map +1 -1
  380. package/dist/components/project-switcher.js +2 -2
  381. package/dist/components/project-switcher.js.map +1 -1
  382. package/dist/components/rail-store-status.js +1 -1
  383. package/dist/components/rail-store-status.js.map +1 -1
  384. package/dist/components/rail-view-source.d.ts +6 -5
  385. package/dist/components/rail-view-source.js +4 -3
  386. package/dist/components/rail-view-source.js.map +1 -1
  387. package/dist/components/records/record-lens.d.ts +51 -0
  388. package/dist/components/records/record-lens.js +151 -0
  389. package/dist/components/records/record-lens.js.map +1 -0
  390. package/dist/components/records/record-rail.d.ts +86 -0
  391. package/dist/components/records/record-rail.js +241 -0
  392. package/dist/components/records/record-rail.js.map +1 -0
  393. package/dist/components/redirects/redirects-schema.js +1 -1
  394. package/dist/components/redirects/redirects-schema.js.map +1 -1
  395. package/dist/components/references/references-panel.js +2 -2
  396. package/dist/components/references/references-panel.js.map +1 -1
  397. package/dist/components/section-nav.js +27 -11
  398. package/dist/components/section-nav.js.map +1 -1
  399. package/dist/components/section-state.js +2 -2
  400. package/dist/components/section-state.js.map +1 -1
  401. package/dist/components/section-surface.js +2 -2
  402. package/dist/components/section-surface.js.map +1 -1
  403. package/dist/components/session-expired-overlay.js +3 -3
  404. package/dist/components/session-expired-overlay.js.map +1 -1
  405. package/dist/components/settings-form.js +1 -1
  406. package/dist/components/settings-form.js.map +1 -1
  407. package/dist/components/sign-in-needed-badge.js +1 -1
  408. package/dist/components/sign-in-needed-badge.js.map +1 -1
  409. package/dist/components/sign-in-view.d.ts +2 -2
  410. package/dist/components/sign-in-view.js +5 -5
  411. package/dist/components/sign-in-view.js.map +1 -1
  412. package/dist/components/studio-context.js +3 -3
  413. package/dist/components/studio-context.js.map +1 -1
  414. package/dist/components/studio-project-screen.js +30 -3
  415. package/dist/components/studio-project-screen.js.map +1 -1
  416. package/dist/components/studio-shell.js +6 -5
  417. package/dist/components/studio-shell.js.map +1 -1
  418. package/dist/components/theme-context.d.ts +1 -1
  419. package/dist/components/theme-context.js +2 -2
  420. package/dist/components/theme-context.js.map +1 -1
  421. package/dist/components/use-hydration-phase.d.ts +1 -1
  422. package/dist/components/use-hydration-phase.js +1 -1
  423. package/dist/components/use-hydration-phase.js.map +1 -1
  424. package/dist/components/users/invite-user-bar.d.ts +27 -0
  425. package/dist/components/users/invite-user-bar.js +25 -9
  426. package/dist/components/users/invite-user-bar.js.map +1 -1
  427. package/dist/components/users/role-policy-drift-issues.d.ts +38 -0
  428. package/dist/components/users/role-policy-drift-issues.js +71 -0
  429. package/dist/components/users/role-policy-drift-issues.js.map +1 -0
  430. package/dist/components/users/users-collection-view-source.d.ts +1 -1
  431. package/dist/components/users/users-collection-view-source.js +4 -2
  432. package/dist/components/users/users-collection-view-source.js.map +1 -1
  433. package/dist/components/users/users-module.js +1 -1
  434. package/dist/components/users/users-module.js.map +1 -1
  435. package/dist/components/users/users-schema.d.ts +10 -1
  436. package/dist/components/users/users-schema.js +45 -25
  437. package/dist/components/users/users-schema.js.map +1 -1
  438. package/dist/components/users/users-store.js +3 -3
  439. package/dist/components/users/users-store.js.map +1 -1
  440. package/dist/components/workspace-projects.d.ts +1 -1
  441. package/dist/components/workspace-projects.js +1 -1
  442. package/dist/components/workspace-projects.js.map +1 -1
  443. package/dist/lib/ai/index.d.ts +1 -1
  444. package/dist/lib/ai/index.js +1 -1
  445. package/dist/lib/ai/index.js.map +1 -1
  446. package/dist/lib/ai/node-handler.d.ts +1 -1
  447. package/dist/lib/ai/node-handler.js.map +1 -1
  448. package/dist/lib/ai/translate-document.js +1 -1
  449. package/dist/lib/ai/translate-document.js.map +1 -1
  450. package/dist/lib/assets/upload.d.ts +25 -7
  451. package/dist/lib/assets/upload.js +30 -21
  452. package/dist/lib/assets/upload.js.map +1 -1
  453. package/dist/lib/clipboard/paste-issues.js +1 -1
  454. package/dist/lib/clipboard/paste-issues.js.map +1 -1
  455. package/dist/lib/clipboard/paste-target.js +2 -2
  456. package/dist/lib/clipboard/paste-target.js.map +1 -1
  457. package/dist/lib/cms/published-documents.d.ts +24 -6
  458. package/dist/lib/cms/published-documents.js +39 -6
  459. package/dist/lib/cms/published-documents.js.map +1 -1
  460. package/dist/lib/cms-views/view-model.d.ts +12 -99
  461. package/dist/lib/cms-views/view-model.js +22 -103
  462. package/dist/lib/cms-views/view-model.js.map +1 -1
  463. package/dist/lib/collection-view/collection-view-source.d.ts +13 -3
  464. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  465. package/dist/lib/data/content-session-refresh.d.ts +1 -1
  466. package/dist/lib/data/content-session-refresh.js.map +1 -1
  467. package/dist/lib/data/playground-unload-guard.d.ts +1 -1
  468. package/dist/lib/data/playground-unload-guard.js +1 -1
  469. package/dist/lib/data/playground-unload-guard.js.map +1 -1
  470. package/dist/lib/engine-version.d.ts +1 -1
  471. package/dist/lib/engine-version.js +1 -1
  472. package/dist/lib/engine-version.js.map +1 -1
  473. package/dist/lib/i18n/i18n.js +1 -1
  474. package/dist/lib/i18n/i18n.js.map +1 -1
  475. package/dist/lib/i18n/messages.de.js +11 -3
  476. package/dist/lib/i18n/messages.de.js.map +1 -1
  477. package/dist/lib/i18n/messages.en.d.ts +9 -1
  478. package/dist/lib/i18n/messages.en.js +16 -4
  479. package/dist/lib/i18n/messages.en.js.map +1 -1
  480. package/dist/lib/operations/config-manifest-lookup.d.ts +1 -1
  481. package/dist/lib/operations/config-manifest-lookup.js +3 -3
  482. package/dist/lib/operations/config-manifest-lookup.js.map +1 -1
  483. package/dist/lib/operations/project-runtime-config.d.ts +9 -0
  484. package/dist/lib/operations/project-runtime-config.js +13 -0
  485. package/dist/lib/operations/project-runtime-config.js.map +1 -1
  486. package/dist/lib/operations/studio-operations.d.ts +9 -1
  487. package/dist/lib/operations/studio-operations.js +6 -1
  488. package/dist/lib/operations/studio-operations.js.map +1 -1
  489. package/dist/lib/persistence/convex-adapter.d.ts +16 -3
  490. package/dist/lib/persistence/convex-adapter.js +78 -47
  491. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  492. package/dist/lib/persistence/deployment-readiness.js +14 -11
  493. package/dist/lib/persistence/deployment-readiness.js.map +1 -1
  494. package/dist/lib/persistence/index.js +14 -1
  495. package/dist/lib/persistence/index.js.map +1 -1
  496. package/dist/lib/persona/labels.js +2 -1
  497. package/dist/lib/persona/labels.js.map +1 -1
  498. package/dist/lib/query/index.d.ts +2 -2
  499. package/dist/lib/query/index.js +1 -1
  500. package/dist/lib/query/index.js.map +1 -1
  501. package/dist/lib/query/operations-query.d.ts +27 -1
  502. package/dist/lib/query/operations-query.js +13 -1
  503. package/dist/lib/query/operations-query.js.map +1 -1
  504. package/dist/lib/references/reference-model.js +1 -1
  505. package/dist/lib/references/reference-model.js.map +1 -1
  506. package/dist/lib/references/references-store.js +1 -1
  507. package/dist/lib/references/references-store.js.map +1 -1
  508. package/dist/lib/registry/index.js +2 -2
  509. package/dist/lib/registry/index.js.map +1 -1
  510. package/dist/lib/registry/preview.d.ts +10 -0
  511. package/dist/lib/registry/preview.js +13 -4
  512. package/dist/lib/registry/preview.js.map +1 -1
  513. package/dist/lib/routes/route-issues.js +6 -3
  514. package/dist/lib/routes/route-issues.js.map +1 -1
  515. package/dist/lib/workspace/index.d.ts +6 -0
  516. package/dist/lib/workspace/index.js +8 -0
  517. package/dist/lib/workspace/index.js.map +1 -1
  518. package/dist/lib/workspace/workspace-source.js +6 -7
  519. package/dist/lib/workspace/workspace-source.js.map +1 -1
  520. package/dist/mount.d.ts +28 -0
  521. package/dist/mount.js +27 -0
  522. package/dist/mount.js.map +1 -0
  523. package/dist/previews/entry-node.d.ts +14 -0
  524. package/dist/previews/entry-node.js +22 -0
  525. package/dist/previews/entry-node.js.map +1 -0
  526. package/dist/previews/harness.js +2 -16
  527. package/dist/previews/harness.js.map +1 -1
  528. package/dist/router/root.js +10 -10
  529. package/dist/router/root.js.map +1 -1
  530. package/dist/ui/badge.d.ts +1 -1
  531. package/dist/ui/button.d.ts +2 -2
  532. package/package.json +15 -11
  533. package/dist/components/redirects/redirects-collection-view-source.d.ts +0 -53
  534. package/dist/components/redirects/redirects-collection-view-source.js +0 -146
  535. package/dist/components/redirects/redirects-collection-view-source.js.map +0 -1
  536. package/dist/components/redirects/redirects-module.d.ts +0 -21
  537. package/dist/components/redirects/redirects-module.js +0 -40
  538. package/dist/components/redirects/redirects-module.js.map +0 -1
  539. package/dist/components/redirects/redirects-store.d.ts +0 -68
  540. package/dist/components/redirects/redirects-store.js +0 -132
  541. package/dist/components/redirects/redirects-store.js.map +0 -1
@@ -80,7 +80,7 @@ describe('EC-587 — assets:liftLegacyAlt moves the legacy column home, once', (
80
80
  it('lifts the column into meta.alt (meta.alt wins when both), unsets it, and keeps every alt reading', async () => {
81
81
  const t = await seededBackend()
82
82
  const before = await storedRows(t)
83
- expect(Object.fromEntries(Object.entries(before).map(([id, r]) => [id, assetAlt(r)]))).toEqual(READ_BEFORE)
83
+ expect(Object.fromEntries(Object.entries(before).map(([id, r]) => [id, assetAlt(r, null)]))).toEqual(READ_BEFORE)
84
84
 
85
85
  const report = (await t.mutation(liftLegacyAlt, {})) as { scanned: number; lifted: string[] }
86
86
  expect(report.scanned).toBe(4)
@@ -94,7 +94,7 @@ describe('EC-587 — assets:liftLegacyAlt moves the legacy column home, once', (
94
94
  expect(measureMaxDepth(stored.meta).depth).toBeLessThanOrEqual(16)
95
95
  }
96
96
  // The lift changes no reading: every alt is what it was.
97
- expect(Object.fromEntries(Object.entries(after).map(([id, r]) => [id, assetAlt(r)]))).toEqual(READ_BEFORE)
97
+ expect(Object.fromEntries(Object.entries(after).map(([id, r]) => [id, assetAlt(r, null)]))).toEqual(READ_BEFORE)
98
98
  })
99
99
 
100
100
  it('is idempotent: a second run lifts nothing and writes nothing', async () => {
@@ -0,0 +1,218 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { convexTest } from 'convex-test'
4
+ import type { ConvexHttpClient } from 'convex/browser'
5
+ import type { FunctionReference } from 'convex/server'
6
+ import { defaultAssetCollection, measureMaxDepth } from '@elytracms/core/cms-core'
7
+ import { InMemoryBlobStorage, InMemoryPersistenceAdapter, PersistenceValidationError, assetAlt } from '@elytracms/core/persistence'
8
+ import type { AssetRepository } from '@elytracms/core/persistence'
9
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
10
+ import { runUpload } from '../src/lib/assets/upload'
11
+ import {
12
+ assetMetaDraft,
13
+ assetMetaDraftPatch,
14
+ setAssetMetaField,
15
+ updateAssetMetadata,
16
+ } from '../src/components/assets/asset-metadata'
17
+ import schema from './schema'
18
+
19
+ // convex-test fires a `runAfter(0)` on a REAL timer; fake timers keep the scheduled
20
+ // revalidation queued (its row written, the `webhooks:deliver` fetch never fired).
21
+ beforeEach(() => {
22
+ vi.useFakeTimers()
23
+ })
24
+ afterEach(() => {
25
+ vi.useRealTimers()
26
+ })
27
+
28
+ /**
29
+ * EC-634 — an asset's `meta` is written per key, inside the mutation, on BOTH adapters:
30
+ * two sessions (two adapters over ONE backend — two studio tabs on one deployment) whose
31
+ * dialog saves interleave keep both keys; a dialog save landing while a file replace is in
32
+ * flight survives the replace; a not-yet-migrated legacy `alt` column is lifted into the
33
+ * stored bag by the same transaction. convex-test serialises mutations, so this proves the
34
+ * per-key MERGE in `assets:upsert`, not Convex's OCC retry (which re-runs the same handler
35
+ * against the newer row — the merge is what makes that retry correct).
36
+ */
37
+
38
+ const modules = {
39
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
40
+ './_generated/api.ts': async () => ({}),
41
+ }
42
+
43
+ const PROJECT_ID = 'asset-meta-patch'
44
+ const SESSION_USER_ID = 'user_admin'
45
+ const STAMP = '2026-10-27T00:00:00.000Z'
46
+
47
+ async function convexBackend() {
48
+ const t = convexTest(schema, modules)
49
+ const now = new Date().toISOString()
50
+ await t.run(async (ctx) => {
51
+ await ctx.db.insert('members', { userId: SESSION_USER_ID, role: 'admin', createdAt: now, updatedAt: now })
52
+ })
53
+ const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
54
+ const session = () =>
55
+ new ConvexPersistenceAdapter(
56
+ {
57
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin.query(ref, args ?? {}),
58
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) =>
59
+ asAdmin.mutation(ref, args ?? {}),
60
+ } as unknown as ConvexHttpClient,
61
+ PROJECT_ID,
62
+ ).assets
63
+ return { t, a: session(), b: session() }
64
+ }
65
+
66
+ function memoryBackend() {
67
+ const adapter = new InMemoryPersistenceAdapter()
68
+ return { a: adapter.assets, b: adapter.assets }
69
+ }
70
+
71
+ async function seed(assets: AssetRepository, meta: Record<string, unknown>) {
72
+ await assets.upsertAsset(PROJECT_ID, {
73
+ id: 'hero',
74
+ filename: 'hero.jpg',
75
+ contentType: 'image/jpeg',
76
+ byteSize: 1,
77
+ storageKey: 'blob_old',
78
+ url: 'https://assets.test/old.jpg',
79
+ meta,
80
+ })
81
+ }
82
+
83
+ async function openDialog(assets: AssetRepository) {
84
+ const opened = assetMetaDraft(await assets.getAsset(PROJECT_ID, 'hero'))
85
+ let draft = opened
86
+ return {
87
+ edit: (key: string, value: unknown) => {
88
+ draft = setAssetMetaField(draft, { name: key }, null, value)
89
+ },
90
+ commit: () => updateAssetMetadata({ assets }, PROJECT_ID, 'hero', assetMetaDraftPatch(opened, draft)),
91
+ }
92
+ }
93
+
94
+ /** Two dialogs open on one asset, each edits one key, the commits interleave. */
95
+ async function interleavedDialogs({ a, b }: { a: AssetRepository; b: AssetRepository }) {
96
+ await seed(a, { alt: 'old alt', credit: 'old credit' })
97
+ const dialogA = await openDialog(a)
98
+ const dialogB = await openDialog(b)
99
+ dialogA.edit('alt', 'A alt')
100
+ dialogB.edit('credit', 'B credit')
101
+ expect((await dialogA.commit()).ok).toBe(true)
102
+ expect((await dialogB.commit()).ok).toBe(true)
103
+ return (await b.getAsset(PROJECT_ID, 'hero')).meta
104
+ }
105
+
106
+ const iptcJpeg = new Uint8Array(
107
+ readFileSync(new URL('../../core/src/cms-core/__fixtures__/embedded-metadata/iptc-xmp.jpg', import.meta.url)),
108
+ )
109
+
110
+ /** A replace whose read is followed — before its write — by another session's dialog save. */
111
+ async function replaceRacingADialog({ a, b }: { a: AssetRepository; b: AssetRepository }) {
112
+ await seed(a, { alt: 'old alt', note: 'old note' })
113
+ const dialogB = await openDialog(b)
114
+ dialogB.edit('note', 'B note')
115
+ const racing: AssetRepository = {
116
+ // B's dialog commits at the worst moment: right before A's replace writes.
117
+ upsertAsset: async (projectId, input) => {
118
+ await dialogB.commit()
119
+ return a.upsertAsset(projectId, input)
120
+ },
121
+ getAsset: (projectId, id) => a.getAsset(projectId, id),
122
+ listAssets: (projectId) => a.listAssets(projectId),
123
+ removeAsset: (projectId, id) => a.removeAsset(projectId, id),
124
+ resolveMany: (projectId, ids) => a.resolveMany(projectId, ids),
125
+ findAsset: (projectId, id) => a.findAsset(projectId, id),
126
+ }
127
+ const result = await runUpload(
128
+ {
129
+ blobs: new InMemoryBlobStorage(),
130
+ assets: racing,
131
+ assetFields: defaultAssetCollection().fields,
132
+ detectDimensions: async () => ({ width: 8, height: 6 }),
133
+ generateVariants: async () => ({}),
134
+ },
135
+ {
136
+ projectId: PROJECT_ID,
137
+ file: {
138
+ name: 'new.jpg',
139
+ type: 'image/jpeg',
140
+ size: iptcJpeg.byteLength,
141
+ arrayBuffer: async () => iptcJpeg.slice().buffer as ArrayBuffer,
142
+ },
143
+ replace: { assetId: 'hero' },
144
+ },
145
+ )
146
+ expect(result.ok).toBe(true)
147
+ return (await a.getAsset(PROJECT_ID, 'hero')).meta
148
+ }
149
+
150
+ describe('EC-634 — asset meta is patched per key in the write, on both adapters', () => {
151
+ it('two sessions’ interleaved dialog saves of different keys keep both (the worst row)', async () => {
152
+ const expected = { alt: 'A alt', credit: 'B credit' }
153
+ expect(await interleavedDialogs(memoryBackend())).toEqual(expected)
154
+ expect(await interleavedDialogs(await convexBackend())).toEqual(expected)
155
+ })
156
+
157
+ it('a dialog save landing just before a replace’s write survives the replace', async () => {
158
+ const memory = await replaceRacingADialog(memoryBackend())
159
+ const convex = await replaceRacingADialog(await convexBackend())
160
+ expect(convex).toEqual(memory)
161
+ expect(convex).toMatchObject({ alt: 'A harbour at dusk & boats', note: 'B note' })
162
+ })
163
+
164
+ it('a patch lifts a not-yet-migrated legacy alt column into the stored bag, in the same mutation', async () => {
165
+ const { t, a } = await convexBackend()
166
+ await t.run(async (ctx) => {
167
+ await ctx.db.insert('assets', {
168
+ assetId: 'legacy',
169
+ filename: 'l.jpg',
170
+ contentType: 'image/jpeg',
171
+ byteSize: 1,
172
+ storageKey: 'k_legacy',
173
+ alt: 'Column alt',
174
+ createdAt: STAMP,
175
+ updatedAt: STAMP,
176
+ })
177
+ })
178
+ // An edit of ANOTHER key: the alt it did not name moves home and is kept.
179
+ const kept = await a.upsertAsset(PROJECT_ID, { id: 'legacy', metaPatch: { credit: 'Ada' } })
180
+ expect(kept.meta).toEqual({ alt: 'Column alt', credit: 'Ada' })
181
+ expect(kept.alt).toBeUndefined()
182
+ expect(assetAlt(kept, null)).toBe('Column alt')
183
+ // A clear of the alt stays cleared — no column left to re-surface it (ASSETS D21).
184
+ const cleared = await a.upsertAsset(PROJECT_ID, { id: 'legacy', metaPatch: { alt: null } })
185
+ expect(assetAlt(cleared, null)).toBeNull()
186
+ const row = await t.run((ctx) => ctx.db.query('assets').filter((q) => q.eq(q.field('assetId'), 'legacy')).first())
187
+ expect(row?.alt).toBeUndefined()
188
+ expect(row?.meta).toEqual({ credit: 'Ada' })
189
+ })
190
+
191
+ it('EC-632 — per-locale buckets are stored by the real mutation, and the stored row measures its depth', async () => {
192
+ const { t, a, b } = await convexBackend()
193
+ await seed(a, { credit: 'Ada' })
194
+ // Two translators, two sessions, interleaved: both locales are kept.
195
+ await a.upsertAsset(PROJECT_ID, { id: 'hero', localizedMetaPatch: { de: { alt: 'Hafen' } } })
196
+ await b.upsertAsset(PROJECT_ID, {
197
+ id: 'hero',
198
+ localizedMetaPatch: { en: { alt: 'Harbour', disclosure: { shown: true, badge: { corner: 'tl', tone: 'dark' } } } },
199
+ })
200
+ const row = await t.run((ctx) => ctx.db.query('assets').filter((q) => q.eq(q.field('assetId'), 'hero')).first())
201
+ expect(row?.localizedMeta).toEqual({
202
+ de: { alt: 'Hafen' },
203
+ en: { alt: 'Harbour', disclosure: { shown: true, badge: { corner: 'tl', tone: 'dark' } } },
204
+ })
205
+ expect(row?.meta).toEqual({ credit: 'Ada' })
206
+ // The stored Convex document (system fields included): 5 of Convex's 16 levels.
207
+ expect(measureMaxDepth(row).depth).toBe(5)
208
+ })
209
+
210
+ it('refuses meta and metaPatch together, through the rule (a PersistenceValidationError naming metaPatch)', async () => {
211
+ const { a } = await convexBackend()
212
+ await seed(a, { alt: 'x' })
213
+ await expect(
214
+ a.upsertAsset(PROJECT_ID, { id: 'hero', meta: { alt: 'y' }, metaPatch: { credit: 'z' } }),
215
+ ).rejects.toBeInstanceOf(PersistenceValidationError)
216
+ expect((await a.getAsset(PROJECT_ID, 'hero')).meta).toEqual({ alt: 'x' })
217
+ })
218
+ })
@@ -0,0 +1,127 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import type { ConvexHttpClient } from 'convex/browser'
4
+ import type { FunctionReference } from 'convex/server'
5
+ import { InMemoryBlobStorage, InMemoryPersistenceAdapter } from '@elytracms/core/persistence'
6
+ import type { AssetRepository } from '@elytracms/core/persistence'
7
+ import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
8
+ import { runUpload } from '../src/lib/assets/upload'
9
+ import schema from './schema'
10
+
11
+ // convex-test fires a `runAfter(0)` on a REAL timer; fake timers keep the scheduled
12
+ // revalidation queued (its row written, the `webhooks:deliver` fetch never fired).
13
+ beforeEach(() => {
14
+ vi.useFakeTimers()
15
+ })
16
+ afterEach(() => {
17
+ vi.useRealTimers()
18
+ })
19
+
20
+ /**
21
+ * EC-634 follow-on — a replace is an UPDATE of a stored asset, never a create. Session A
22
+ * replaces the file; session B deletes the asset in the window between A's check and A's
23
+ * write. The replace used to carry every column a create needs, so it silently RE-CREATED
24
+ * the asset B had deleted (a colleague's delete undone). Two sessions over ONE backend,
25
+ * in-memory AND Convex (the real `assets:upsert` over convex-test).
26
+ */
27
+
28
+ const modules = {
29
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
30
+ './_generated/api.ts': async () => ({}),
31
+ }
32
+
33
+ const PROJECT_ID = 'asset-replace-race'
34
+ const SESSION_USER_ID = 'user_admin'
35
+
36
+ async function convexSessions() {
37
+ const t = convexTest(schema, modules)
38
+ const now = new Date().toISOString()
39
+ await t.run(async (ctx) => {
40
+ await ctx.db.insert('members', { userId: SESSION_USER_ID, role: 'admin', createdAt: now, updatedAt: now })
41
+ })
42
+ const asAdmin = t.withIdentity({ subject: SESSION_USER_ID, issuer: 'https://convex.test' })
43
+ const session = () =>
44
+ new ConvexPersistenceAdapter(
45
+ {
46
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin.query(ref, args ?? {}),
47
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) =>
48
+ asAdmin.mutation(ref, args ?? {}),
49
+ } as unknown as ConvexHttpClient,
50
+ PROJECT_ID,
51
+ ).assets
52
+ return { a: session(), b: session() }
53
+ }
54
+
55
+ function memorySessions() {
56
+ const adapter = new InMemoryPersistenceAdapter()
57
+ return { a: adapter.assets, b: adapter.assets }
58
+ }
59
+
60
+ /** A's replace, with B's delete landing right after A checked that the asset exists. */
61
+ async function replaceRacingADelete({ a, b }: { a: AssetRepository; b: AssetRepository }) {
62
+ await a.upsertAsset(PROJECT_ID, {
63
+ id: 'hero',
64
+ filename: 'old.jpg',
65
+ contentType: 'image/jpeg',
66
+ byteSize: 1,
67
+ storageKey: 'blob_old',
68
+ url: 'https://assets.test/old.jpg',
69
+ })
70
+ // B's delete lands immediately before A's write — after anything A checked or read.
71
+ const racing: AssetRepository = {
72
+ upsertAsset: async (projectId, input) => {
73
+ await b.removeAsset(projectId, 'hero')
74
+ return a.upsertAsset(projectId, input)
75
+ },
76
+ getAsset: (projectId, id) => a.getAsset(projectId, id),
77
+ listAssets: (projectId) => a.listAssets(projectId),
78
+ removeAsset: (projectId, id) => a.removeAsset(projectId, id),
79
+ resolveMany: (projectId, ids) => a.resolveMany(projectId, ids),
80
+ findAsset: (projectId, id) => a.findAsset(projectId, id),
81
+ }
82
+ const result = await runUpload(
83
+ {
84
+ blobs: new InMemoryBlobStorage(),
85
+ assets: racing,
86
+ detectDimensions: async () => ({ width: 8, height: 6 }),
87
+ generateVariants: async () => ({}),
88
+ },
89
+ {
90
+ projectId: PROJECT_ID,
91
+ file: { name: 'new.jpg', type: 'image/jpeg', size: 3, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer },
92
+ replace: { assetId: 'hero' },
93
+ },
94
+ )
95
+ return { result, stored: await b.findAsset(PROJECT_ID, 'hero') }
96
+ }
97
+
98
+ describe('EC-634 follow-on — a replace never re-creates an asset another session deleted', () => {
99
+ for (const [name, backend] of [
100
+ ['in-memory', async () => memorySessions()],
101
+ ['Convex', convexSessions],
102
+ ] as const) {
103
+ it(`${name}: the replace is refused, visibly, and the delete stands`, async () => {
104
+ const { result, stored } = await replaceRacingADelete(await backend())
105
+ expect(stored).toBeUndefined()
106
+ expect(result.ok).toBe(false)
107
+ if (!result.ok) {
108
+ expect(result.failure.stage).toBe('confirm')
109
+ expect(result.failure.message).toContain('hero')
110
+ }
111
+ })
112
+ }
113
+
114
+ it('control: without the delete the replace lands (same id, the new file)', async () => {
115
+ const { a } = memorySessions()
116
+ await a.upsertAsset(PROJECT_ID, { id: 'hero', filename: 'old.jpg', contentType: 'image/jpeg', byteSize: 1 })
117
+ const result = await runUpload(
118
+ { blobs: new InMemoryBlobStorage(), assets: a, detectDimensions: async () => undefined, generateVariants: async () => ({}) },
119
+ {
120
+ projectId: PROJECT_ID,
121
+ file: { name: 'new.jpg', type: 'image/jpeg', size: 3, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer },
122
+ replace: { assetId: 'hero' },
123
+ },
124
+ )
125
+ expect(result.ok && result.asset).toMatchObject({ id: 'hero', filename: 'new.jpg' })
126
+ })
127
+ })
package/convex/assets.ts CHANGED
@@ -25,8 +25,9 @@ const clearable = <T, F extends string>(validator: Validator<T, 'required', F>)
25
25
  /**
26
26
  * Write one asset (EC-577). The ONE write rule (`resolveAssetWrite`, shared with
27
27
  * the in-memory adapter) runs here, inside the transaction: a column absent from
28
- * the args keeps its stored value, `null` clears it, a value sets it — and the legacy
29
- * `alt` column can only be cleared (EC-587: an asset's alt is `meta.alt`). An edit is
28
+ * the args keeps its stored value, `null` clears it, a value sets it — the legacy
29
+ * `alt` column can only be cleared (EC-587: an asset's alt is `meta.alt`), and a
30
+ * `metaPatch` changes `meta` per key against the row read here (EC-634). An edit is
30
31
  * ONE `db.patch` — the rule's store patch, where a cleared column is `undefined`,
31
32
  * which `db.patch` removes — so the write-chokepoint triggers fire once. No stored
32
33
  * asset → ONE `db.insert` of the complete row (the `defaults` fill what the write
@@ -65,6 +66,30 @@ export const upsert = mutation({
65
66
  lqip: clearable(v.string()),
66
67
  /** Schema-driven asset metadata bag (EC-301): alt, copyright, credit, …. `null` clears it. */
67
68
  meta: v.optional(v.any()),
69
+ /**
70
+ * EC-634 — a per-key change to `meta` (a value sets the key, `null` removes it), applied
71
+ * by the rule to the row as stored HERE, in this transaction — so an editor's save never
72
+ * carries a stale copy of a key another session changed. Validated by the rule (an
73
+ * object of keys, never beside `meta`), as `meta` is.
74
+ */
75
+ metaPatch: v.optional(v.any()),
76
+ /**
77
+ * EC-632 — the per-locale buckets of the asset collection's `localized: true` fields
78
+ * (`{ [locale]: { [field]: value } }`): `null` clears them, an object replaces them
79
+ * whole (a create, an import); an editor sends `localizedMetaPatch` instead.
80
+ */
81
+ localizedMeta: v.optional(v.any()),
82
+ /**
83
+ * EC-632 — a per-locale, per-key change to those buckets, applied by the rule to the
84
+ * row as stored HERE (as `metaPatch` is to `meta`). Validated by the rule.
85
+ */
86
+ localizedMetaPatch: v.optional(v.any()),
87
+ /**
88
+ * EC-634 follow-on — this write only UPDATES a stored asset (a file replace): with the
89
+ * row gone (deleted by another session) the rule refuses it here, so it never re-creates
90
+ * what a colleague deleted.
91
+ */
92
+ mustExist: v.optional(v.boolean()),
68
93
  /**
69
94
  * What a CREATE fills in when the write leaves it absent (`assetCreateDefaults`
70
95
  * in core). Sent by the adapter because only it knows the project — rows carry
package/convex/auth.ts CHANGED
@@ -4,8 +4,9 @@ import { actionGeneric } from 'convex/server'
4
4
  import type { AnyDataModel, GenericMutationCtx } from 'convex/server'
5
5
  import { ConvexError, v } from 'convex/values'
6
6
  import type { GenericId } from 'convex/values'
7
- import { classifyInviteSignup, normalizeInviteEmail } from '@elytracms/core/persistence'
7
+ import { bootstrapRole, classifyInviteSignup, normalizeInviteEmail } from '@elytracms/core/persistence'
8
8
  import type { InviteSignupDecision } from '@elytracms/core/persistence'
9
+ import { deployedRolePolicy } from './roles'
9
10
 
10
11
  /** The provider list `convexAuth` accepts, derived from its own signature. */
11
12
  type ConvexAuthProviders = Parameters<typeof convexAuth>[0]['providers']
@@ -210,7 +211,9 @@ export async function gateSessionCreation(
210
211
  case 'bootstrap-admin':
211
212
  await ctx.db.insert('members', {
212
213
  userId: args.userId,
213
- role: 'admin',
214
+ // EC-605: the deployed policy's strongest role (which the policy rules require
215
+ // to grant `project.administer`) — `admin` under the default policy.
216
+ role: bootstrapRole(deployedRolePolicy()),
214
217
  createdAt: now,
215
218
  updatedAt: now,
216
219
  })
@@ -1,12 +1,14 @@
1
1
  import { checkAuthorityFloor } from '@elytracms/core/operations'
2
2
  import {
3
3
  buildAuthorityAuditEntry,
4
- defaultRolePolicy,
4
+ isKnownRole,
5
+ policyRoleIds,
5
6
  roleGrantsCapability,
6
7
  } from '@elytracms/core/persistence'
7
8
  import type { AuthorityAuditOutcome, ProjectRole } from '@elytracms/core/persistence'
8
9
  import type { MutationCtx } from './functions'
9
10
  import { memberRole, type GuardCtx } from './guard'
11
+ import { deployedRolePolicy } from './roles'
10
12
 
11
13
  /**
12
14
  * The engine integrity FLOOR at the Convex trust boundary (EC-240 / AD-18 tenet
@@ -16,9 +18,10 @@ import { memberRole, type GuardCtx } from './guard'
16
18
  * invariants on authority-mutating handlers (`members:set`, `members:remove` —
17
19
  * and therefore `users.save`/`users.remove`, which delegate to them):
18
20
  *
19
- * - **(a) assign only roles you hold** — future-proofs the boundary for when
20
- * policy opens member-management to non-admins; today an administrator can
21
- * grant any role, so this never fires under the default policy.
21
+ * - **(a) assign only roles you hold** — never fires under the default policy (the
22
+ * only administrator, `admin`, is the strongest role); under a declared policy
23
+ * with an administering role below the strongest (EC-605) it is what stops that
24
+ * role granting — or inviting someone into (`invites:create`) — a rank above its own.
22
25
  * - **(b) the last admin can't be removed or demoted** — the hole this closes:
23
26
  * before EC-240 the handlers did a blind upsert/delete, so an admin could
24
27
  * remove the sole admin and lock everyone out (the empty-table bootstrap in
@@ -27,28 +30,32 @@ import { memberRole, type GuardCtx } from './guard'
27
30
  * The decision logic is the ONE shared `checkAuthorityFloor` primitive that the
28
31
  * engine resolver also calls — this file only supplies the primitives it reads
29
32
  * from `ctx.db`, so client and server can never drift. Denials throw
30
- * `unauthorized: …`, matching the guard convention.
33
+ * `unauthorized: …`, matching the guard convention. The policy is the deployment's
34
+ * (EC-605, `deployedRolePolicy`) — the one `requireCapability` granted by.
31
35
  */
32
36
  export async function assertAuthorityFloor(
33
37
  ctx: GuardCtx,
34
38
  params: {
35
39
  /** The caller (already `requireCapability('project.administer')`-checked). */
36
40
  readonly callerId: string
37
- /** The member whose role/membership this op changes. */
38
- readonly targetUserId: string
41
+ /**
42
+ * The member whose role/membership this op changes; absent for an invite (EC-605 —
43
+ * it grants a role to someone who is not a member yet, so only (a) can apply).
44
+ */
45
+ readonly targetUserId?: string
39
46
  /** Roles this op grants the target (empty for a pure removal). */
40
47
  readonly rolesGranted: readonly ProjectRole[]
41
48
  /** True when the op removes the membership entirely. */
42
49
  readonly removesMembership: boolean
43
50
  },
44
51
  ): Promise<void> {
52
+ const policy = deployedRolePolicy()
45
53
  const callerRole = await memberRole(ctx, params.callerId)
46
- const targetRole = await memberRole(ctx, params.targetUserId)
54
+ const targetRole = params.targetUserId !== undefined ? await memberRole(ctx, params.targetUserId) : null
47
55
  const members = await ctx.db.query('members').collect()
48
56
  // "admin" generalizes to "grants project.administer" (EC-240c) — the same
49
57
  // policy the engine resolver uses, so client and server can never drift.
50
- const isAdministrator = (role: string) =>
51
- roleGrantsCapability(role, 'project.administer', defaultRolePolicy)
58
+ const isAdministrator = (role: string) => roleGrantsCapability(role, 'project.administer', policy)
52
59
  const adminCount = members.filter((m) => isAdministrator(m.role)).length
53
60
 
54
61
  const decision = checkAuthorityFloor(
@@ -59,11 +66,28 @@ export async function assertAuthorityFloor(
59
66
  removesMembership: params.removesMembership,
60
67
  adminCount,
61
68
  },
62
- defaultRolePolicy,
69
+ policy,
63
70
  )
64
71
  if (!decision.allow) throw new Error(`unauthorized: ${decision.reason}`)
65
72
  }
66
73
 
74
+ /**
75
+ * EC-605 — refuse a role the deployed policy does not declare, at the WRITE (a member's
76
+ * role, an invite's role), visibly: `invalid-role: …` naming the declared set. The
77
+ * studio validates against the project's config first; this is the trust boundary's own
78
+ * check, so a hand-crafted client or a drifted deployment can never store a role nobody
79
+ * can enforce (an unknown role grants nothing — it would fail closed on every read,
80
+ * silently).
81
+ */
82
+ export function assertDeclaredRole(role: string): void {
83
+ const policy = deployedRolePolicy()
84
+ if (!isKnownRole(role, policy)) {
85
+ throw new Error(
86
+ `invalid-role: role "${role}" is not declared in this deployment's role policy (declared: ${policyRoleIds(policy).join(', ')}). Declare it in elytra.config.ts \`roles\` and re-provision (\`elytra init\`), or pick a declared role.`,
87
+ )
88
+ }
89
+ }
90
+
67
91
  /**
68
92
  * Append one {@link buildAuthorityAuditEntry} row to the `authorityAudit` table
69
93
  * (EC-240 H-tail item 6). Called at the floor chokepoint (`members:set` /
@@ -4,6 +4,7 @@ import type { ConvexHttpClient } from 'convex/browser'
4
4
  import type { FunctionReference } from 'convex/server'
5
5
  import { fieldPath, measureMaxDepth, nodePropPath } from '@elytracms/core/cms-core'
6
6
  import {
7
+ CONTRACT_RECORD_KINDS,
7
8
  PERSISTENCE_CONTRACT_SCENARIOS,
8
9
  publishCurrentDraft,
9
10
  replaceAtCurrentRevision,
@@ -12,6 +13,7 @@ import type { ContractContext } from '@elytracms/core/persistence'
12
13
  import { ConvexPersistenceAdapter } from '../src/lib/persistence/convex-adapter'
13
14
  import type { ReactiveDocumentClient } from '../src/lib/persistence/convex-client'
14
15
  import schema from './schema'
16
+ import { defineRecordFunctions } from './record-functions'
15
17
 
16
18
  /**
17
19
  * EC-454 — Convex-side contract parity, restored.
@@ -40,6 +42,11 @@ import schema from './schema'
40
42
  const modules = {
41
43
  ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
42
44
  './_generated/api.ts': async () => ({}),
45
+ // EC-616: the contract's FIXTURE record kinds, registered as test-only modules exactly as
46
+ // an addon's convex module registers its kind (one module per kind, named by the kind).
47
+ ...Object.fromEntries(
48
+ CONTRACT_RECORD_KINDS.map((kind) => [`./${kind.module}.ts`, async () => defineRecordFunctions(kind)]),
49
+ ),
43
50
  }
44
51
 
45
52
  /** The config-owned slug this deployment's records are stamped with (EC-179). */