@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
@@ -0,0 +1,817 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import { internalMutationGeneric, makeFunctionReference } from 'convex/server'
4
+ import type { GenericDatabaseWriter, MutationBuilder } from 'convex/server'
5
+ import { getConvexSize, getDocumentSize, v } from 'convex/values'
6
+ import type { Value } from 'convex/values'
7
+ import { customCtx, customMutation } from 'convex-helpers/server/customFunctions'
8
+ import { z } from 'zod'
9
+ import { CONVEX_MAX_NESTING_DEPTH, measureMaxDepth } from '@elytracms/core/cms-core'
10
+ import { createUuidIdFactory } from '@elytracms/core/determinism'
11
+ import { INGEST_RECEIVER_PATH, signedIngestRequest } from '@elytracms/core/ingest'
12
+ import type { IngestHandlerResult } from '@elytracms/core/ingest'
13
+ import { defineRecordKind, redirectRecordKind, resolveRecordQuery } from '@elytracms/core/persistence'
14
+ import schema from './schema'
15
+ import type { DataModel, MutationCtx } from './functions'
16
+ import { defineStudioHttp } from './http'
17
+ import { ingestHandler, ingestHandlerArgs, parseIngestPayload } from './ingest'
18
+ import type { IngestHandlers } from './ingest'
19
+ import { findRecordDoc, queryRecordDocs, saveRecord } from './record-functions'
20
+ import { triggers } from './triggers'
21
+
22
+ /**
23
+ * EC-618 GATE 1 — what one pageview costs on Convex, MODELLED on convex-test against the
24
+ * engine's real receiver (`POST /elytra/ingest`, EC-617) and the real record store
25
+ * (`saveRecord` through the real `triggers.ts` write chokepoint, EC-616). No analytics code
26
+ * ships from here: the handlers below are TEST-ONLY models of the two storage designs the
27
+ * operator rules between — they are not the addon.
28
+ *
29
+ * - **(A) raw hit per pageview + a scheduled rollup** (the ruling's default): the handler
30
+ * inserts one short-lived hit row; a batched rollup folds hits into daily per-document
31
+ * rows and deletes them.
32
+ * - **(B) the receiver aggregates before writing** (the ruling's fallback): the handler
33
+ * increments the day's per-document row in place.
34
+ *
35
+ * Both count uniques the one way the ruling allows ("a daily-rotating salted hash, never
36
+ * persisted"): the SITE turns the hash into HyperLogLog coordinates (`u: [bucket, rank]`)
37
+ * and only those travel; the day row carries a 1024-register sketch. The hash itself is
38
+ * never sent, never stored.
39
+ *
40
+ * What is MEASURED here (convex-test, Convex's own size formula `getDocumentSize` /
41
+ * `getConvexSize` from `convex/values` — "matches how Convex calculates document size for
42
+ * bandwidth tracking"): the function executions per hit (the receiver's `httpAction` + the
43
+ * `runMutation` it makes + any scheduled function), every document read and written per
44
+ * transaction INCLUDING the trigger wrapper's own reads (the meter sits below
45
+ * `triggers.wrapDB`), the stored rows' sizes and depth, and the contention shape. What is
46
+ * MODELLED: the index-entry bytes (key = indexed values + `_creationTime` + `_id`, inferred),
47
+ * that every `db.get`/document read is billed I/O (convex-test has no billing), and the
48
+ * prices (read below). The operator confirms the per-hit numbers on the dev deployment
49
+ * (`usage.database_io_*` / `database_write_index_rows` / `occ_info` in the function-execution
50
+ * log events) — EC-618 § Gate 1 has the commands.
51
+ */
52
+
53
+ // --- Convex's published prices (read 2026-10-26) --------------------------------------------
54
+ // https://www.convex.dev/pricing and https://docs.convex.dev/production/state/limits
55
+ // ("Explicit client calls, scheduled executions, subscription updates, and file accesses count
56
+ // as function calls"; Database I/O = "Document and index data transferred between Convex
57
+ // functions and the underlying database"; storage "Includes database rows and indexes … Each
58
+ // index is priced as another copy of the table"; query/mutation compute free on Serverless).
59
+ // https://docs.convex.dev/functions/actions: runQuery/runMutation from an action — "you're
60
+ // paying for multiple function calls".
61
+ interface PlanPrices {
62
+ name: string
63
+ calls: { included: number; perMillion: number }
64
+ ioGB: { included: number; perGB: number }
65
+ storageGB: { included: number; perGB: number }
66
+ actionGBh: { included: number; perGBh: number }
67
+ }
68
+ const PLANS: PlanPrices[] = [
69
+ {
70
+ name: 'Starter',
71
+ calls: { included: 1_000_000, perMillion: 2.2 },
72
+ ioGB: { included: 1, perGB: 0.22 },
73
+ storageGB: { included: 0.5, perGB: 0.22 },
74
+ actionGBh: { included: 20, perGBh: 0.33 },
75
+ },
76
+ {
77
+ name: 'Professional',
78
+ calls: { included: 25_000_000, perMillion: 2 },
79
+ ioGB: { included: 50, perGB: 0.2 },
80
+ storageGB: { included: 50, perGB: 0.2 },
81
+ actionGBh: { included: 250, perGBh: 0.3 },
82
+ },
83
+ ]
84
+ /** Convex runtime action memory (limits page: "Convex runtime: 64 MiB RAM"). */
85
+ const ACTION_MEMORY_GB = 64 / 1024
86
+ /** ASSUMED receiver wall time per hit (HMAC verify + one runMutation) — confirm on dev. */
87
+ const ASSUMED_RECEIVER_MS = 50
88
+ const GB = 1024 ** 3
89
+
90
+ // --- The meter: every read and write of one transaction, below the trigger wrapper ----------
91
+
92
+ type Doc = Record<string, Value | undefined> & { _id: string; _creationTime: number }
93
+
94
+ interface TxMeter {
95
+ label: string
96
+ /** `db.get` + `db.query(…)` terminal calls — Convex's "index ranges read". */
97
+ ranges: number
98
+ readDocs: number
99
+ readBytes: number
100
+ readIds: string[]
101
+ writeDocs: number
102
+ writeBytes: number
103
+ /** MODELLED: index entries added, removed or changed by this transaction's writes. */
104
+ indexRows: number
105
+ indexBytes: number
106
+ }
107
+
108
+ const ledger: TxMeter[] = []
109
+
110
+ type TableName = keyof typeof schema.tables & string
111
+ const TABLES = Object.keys(schema.tables) as TableName[]
112
+
113
+ /** Each index's key fields — the schema's own declaration, plus the two system indexes. */
114
+ function indexKeys(table: TableName): string[][] {
115
+ // Convex appends `_creationTime` (then `_id`) to every index key (inferred from the docs).
116
+ const declared = schema.tables[table][' indexes']().map((index) => [...index.fields, '_creationTime'])
117
+ return [...declared, [] /* by_id */, ['_creationTime'] /* by_creation_time */]
118
+ }
119
+
120
+ function entryBytes(doc: Doc, fields: string[]): number {
121
+ return fields.reduce((sum, field) => sum + (doc[field] === undefined ? 1 : getConvexSize(doc[field])), 0) + getConvexSize(doc._id)
122
+ }
123
+
124
+ function entryKey(doc: Doc, fields: string[]): string {
125
+ return JSON.stringify([...fields.map((field) => doc[field] ?? null), doc._id])
126
+ }
127
+
128
+ interface RawDb {
129
+ get(...args: unknown[]): Promise<Doc | null>
130
+ query(table: string): object
131
+ insert(table: string, value: unknown): Promise<string>
132
+ patch(...args: unknown[]): Promise<void>
133
+ replace(...args: unknown[]): Promise<void>
134
+ delete(...args: unknown[]): Promise<void>
135
+ normalizeId(table: string, id: string): string | null
136
+ }
137
+
138
+ function meter(db: GenericDatabaseWriter<DataModel>, label: string): GenericDatabaseWriter<DataModel> {
139
+ const tx: TxMeter = {
140
+ label,
141
+ ranges: 0,
142
+ readDocs: 0,
143
+ readBytes: 0,
144
+ readIds: [],
145
+ writeDocs: 0,
146
+ writeBytes: 0,
147
+ indexRows: 0,
148
+ indexBytes: 0,
149
+ }
150
+ ledger.push(tx)
151
+ const raw = db as unknown as RawDb
152
+ const read = (doc: Doc | null): void => {
153
+ if (!doc) return
154
+ tx.readDocs += 1
155
+ tx.readBytes += getDocumentSize(doc as Record<string, Value>)
156
+ tx.readIds.push(doc._id)
157
+ }
158
+ const wrote = (table: TableName, before: Doc | null, after: Doc | null): void => {
159
+ if (after) {
160
+ tx.writeDocs += 1
161
+ tx.writeBytes += getDocumentSize(after as Record<string, Value>)
162
+ }
163
+ for (const fields of indexKeys(table)) {
164
+ const changed = !before || !after || entryKey(before, fields) !== entryKey(after, fields)
165
+ if (!changed) continue
166
+ tx.indexRows += 1
167
+ tx.indexBytes += entryBytes((after ?? before)!, fields)
168
+ }
169
+ }
170
+ const tableOf = (id: string): TableName => TABLES.find((table) => raw.normalizeId(table, id) !== null)!
171
+ /** `(table, id, …rest)` or `(id, …rest)` — the two call forms Convex accepts. */
172
+ const split = (args: unknown[], arity: number): { table: TableName; id: string } =>
173
+ args.length === arity + 1
174
+ ? { table: args[0] as TableName, id: args[1] as string }
175
+ : { table: tableOf(args[0] as string), id: args[0] as string }
176
+ const meterQuery = (query: object): object =>
177
+ new Proxy(query, {
178
+ get(target, prop) {
179
+ const value: unknown = Reflect.get(target, prop, target)
180
+ if (typeof value !== 'function') return value
181
+ const fn = value as (...args: unknown[]) => unknown
182
+ if (prop === 'first' || prop === 'unique') {
183
+ return async (...args: unknown[]) => {
184
+ tx.ranges += 1
185
+ const doc = (await fn.apply(target, args)) as Doc | null
186
+ read(doc)
187
+ return doc
188
+ }
189
+ }
190
+ if (prop === 'collect' || prop === 'take') {
191
+ return async (...args: unknown[]) => {
192
+ tx.ranges += 1
193
+ const docs = (await fn.apply(target, args)) as Doc[]
194
+ docs.forEach(read)
195
+ return docs
196
+ }
197
+ }
198
+ return (...args: unknown[]) => {
199
+ const out = fn.apply(target, args)
200
+ return out !== null && typeof out === 'object' && !(out instanceof Promise) ? meterQuery(out) : out
201
+ }
202
+ },
203
+ })
204
+ const metered = {
205
+ async get(...args: unknown[]) {
206
+ tx.ranges += 1
207
+ const doc = await raw.get(...args)
208
+ read(doc)
209
+ return doc
210
+ },
211
+ query(table: string) {
212
+ return meterQuery(raw.query(table))
213
+ },
214
+ async insert(table: TableName, value: unknown) {
215
+ const id = await raw.insert(table, value)
216
+ wrote(table, null, await raw.get(id))
217
+ return id
218
+ },
219
+ async patch(...args: unknown[]) {
220
+ const { table, id } = split(args, 2)
221
+ const before = await raw.get(id)
222
+ await raw.patch(...args)
223
+ wrote(table, before, await raw.get(id))
224
+ },
225
+ async replace(...args: unknown[]) {
226
+ const { table, id } = split(args, 2)
227
+ const before = await raw.get(id)
228
+ await raw.replace(...args)
229
+ wrote(table, before, await raw.get(id))
230
+ },
231
+ async delete(...args: unknown[]) {
232
+ const { table, id } = split(args, 1)
233
+ const before = await raw.get(id)
234
+ await raw.delete(...args)
235
+ wrote(table, before, null)
236
+ },
237
+ normalizeId: (table: string, id: string) => raw.normalizeId(table, id),
238
+ system: (db as unknown as { system: unknown }).system,
239
+ }
240
+ return metered as unknown as GenericDatabaseWriter<DataModel>
241
+ }
242
+
243
+ /** An internal-mutation builder whose db is metered BELOW the real trigger wrapper. */
244
+ function metered(label: string, withTriggers = true) {
245
+ return customMutation(
246
+ internalMutationGeneric as MutationBuilder<DataModel, 'internal'>,
247
+ customCtx((ctx: MutationCtx) => {
248
+ const db = meter(ctx.db, label)
249
+ return { db: withTriggers ? triggers.wrapDB({ ...ctx, db }).db : db }
250
+ }),
251
+ )
252
+ }
253
+
254
+ // --- The two storage designs (TEST-ONLY models, not the addon) ------------------------------
255
+
256
+ const MODEL_MODULE = 'analyticsCostModel'
257
+
258
+ /** The beacon's pageview as the site route would forward it (`event.data`). */
259
+ const pageviewSchema = z.strictObject({
260
+ name: z.literal('pageview'),
261
+ collection: z.string(),
262
+ path: z.string(),
263
+ locale: z.string(),
264
+ status: z.number().int(),
265
+ country: z.string().optional(),
266
+ referrer: z.string().optional(),
267
+ /** HyperLogLog coordinates of the day's salted visitor hash — the hash never leaves the site. */
268
+ u: z.tuple([z.number().int(), z.number().int()]),
269
+ })
270
+ type PageviewData = z.infer<typeof pageviewSchema>
271
+
272
+ /** (A) one short-lived row per pageview. */
273
+ const hitKind = defineRecordKind({
274
+ kind: 'analyticsHitModel',
275
+ module: MODEL_MODULE,
276
+ time: 'receivedAt',
277
+ fields: {
278
+ collection: z.string(),
279
+ documentId: z.string(),
280
+ path: z.string(),
281
+ locale: z.string(),
282
+ status: z.number().int(),
283
+ country: z.string().optional(),
284
+ referrer: z.string().optional(),
285
+ bucket: z.number().int(),
286
+ rank: z.number().int(),
287
+ receivedAt: z.string(),
288
+ },
289
+ })
290
+
291
+ /** The daily per-document aggregate — the same row in both designs. */
292
+ const dayKind = defineRecordKind({
293
+ kind: 'analyticsDayModel',
294
+ module: MODEL_MODULE,
295
+ keys: ['day', 'documentId'],
296
+ time: 'day',
297
+ fields: {
298
+ day: z.string(),
299
+ collection: z.string(),
300
+ documentId: z.string(),
301
+ views: z.number().int(),
302
+ countries: z.record(z.string(), z.number()),
303
+ referrers: z.record(z.string(), z.number()),
304
+ sketch: z.string(),
305
+ },
306
+ })
307
+ type DayColumns = {
308
+ day: string
309
+ collection: string
310
+ documentId: string
311
+ views: number
312
+ countries: Record<string, number>
313
+ referrers: Record<string, number>
314
+ sketch: string
315
+ }
316
+
317
+ const SKETCH_REGISTERS = 1024
318
+ const SKETCH_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_'
319
+ const EMPTY_SKETCH = '0'.repeat(SKETCH_REGISTERS)
320
+
321
+ function raise(sketch: string, bucket: number, rank: number): string {
322
+ const current = SKETCH_ALPHABET.indexOf(sketch[bucket]!)
323
+ return rank > current ? sketch.slice(0, bucket) + SKETCH_ALPHABET[rank]! + sketch.slice(bucket + 1) : sketch
324
+ }
325
+
326
+ /** The HyperLogLog estimate (with the small-range linear-counting correction). */
327
+ function estimateUniques(sketch: string): number {
328
+ let sum = 0
329
+ let zeros = 0
330
+ for (const char of sketch) {
331
+ const register = SKETCH_ALPHABET.indexOf(char)
332
+ sum += 2 ** -register
333
+ if (register === 0) zeros += 1
334
+ }
335
+ const m = SKETCH_REGISTERS
336
+ const raw = ((0.7213 / (1 + 1.079 / m)) * m * m) / sum
337
+ return raw <= 2.5 * m && zeros > 0 ? m * Math.log(m / zeros) : raw
338
+ }
339
+
340
+ function bump(map: Record<string, number>, key: string): Record<string, number> {
341
+ return { ...map, [key]: (map[key] ?? 0) + 1 }
342
+ }
343
+
344
+ interface Pageview extends Omit<PageviewData, 'name' | 'u'> {
345
+ documentId: string
346
+ bucket: number
347
+ rank: number
348
+ }
349
+
350
+ function foldPageview(previous: DayColumns | undefined, pageview: Pageview, day: string): DayColumns {
351
+ const base: DayColumns = previous ?? {
352
+ day,
353
+ collection: pageview.collection,
354
+ documentId: pageview.documentId,
355
+ views: 0,
356
+ countries: {},
357
+ referrers: {},
358
+ sketch: EMPTY_SKETCH,
359
+ }
360
+ return {
361
+ ...base,
362
+ views: base.views + 1,
363
+ countries: bump(base.countries, pageview.country ?? '??'),
364
+ referrers: bump(base.referrers, pageview.referrer ?? '(direct)'),
365
+ sketch: raise(base.sketch, pageview.bucket, pageview.rank),
366
+ }
367
+ }
368
+
369
+ function pageviewOf(payload: unknown): Pageview | undefined {
370
+ const event = parseIngestPayload(payload)
371
+ if (event.kind !== 'event' || !event.target) return undefined
372
+ const data = pageviewSchema.parse(event.data)
373
+ const { name: _name, u, ...rest } = data
374
+ return { ...rest, documentId: event.target, bucket: u[0], rank: u[1] }
375
+ }
376
+
377
+ const dayId = (day: string, documentId: string) => `${day}|${documentId}`
378
+ // Deterministic bytes for the one production id factory (EC-578) — same length, same shape.
379
+ let idSeed = 1
380
+ const newHitId = createUuidIdFactory((bytes) => {
381
+ for (let i = 0; i < bytes.length; i += 1) bytes[i] = (idSeed * 131 + i * 17) & 0xff
382
+ idSeed += 1
383
+ })
384
+
385
+ async function writeHitA(ctx: MutationCtx, payload: unknown, receivedAt: string): Promise<IngestHandlerResult> {
386
+ const pageview = pageviewOf(payload)
387
+ if (!pageview) return { ok: false, status: 422, message: 'Pageview events only.' }
388
+ const { bucket, rank, ...columns } = pageview
389
+ const id = `hit_${newHitId()}`
390
+ await saveRecord(ctx, hitKind, id, { ...columns, bucket, rank, receivedAt }, { createdAt: receivedAt, updatedAt: receivedAt })
391
+ return { ok: true, id }
392
+ }
393
+
394
+ async function upsertDay(ctx: MutationCtx, day: string, pageviews: readonly Pageview[], at: string): Promise<void> {
395
+ const id = dayId(day, pageviews[0]!.documentId)
396
+ const existing = await findRecordDoc(ctx, dayKind, id)
397
+ const folded = pageviews.reduce<DayColumns | undefined>(
398
+ (row, pageview) => foldPageview(row, pageview, day),
399
+ existing?.data as DayColumns | undefined,
400
+ )!
401
+ const write = existing
402
+ ? { views: folded.views, countries: folded.countries, referrers: folded.referrers, sketch: folded.sketch }
403
+ : folded
404
+ await saveRecord(ctx, dayKind, id, write, { createdAt: at, updatedAt: at })
405
+ }
406
+
407
+ const modelModule = {
408
+ /** (A) the receiver's handler: one insert. */
409
+ hitA: metered('A:hit')({
410
+ args: ingestHandlerArgs,
411
+ handler: (ctx, args): Promise<IngestHandlerResult> => writeHitA(ctx, args.payload, args.receivedAt),
412
+ }),
413
+ /** CONTROL for the meter: the same write with NO trigger wrapper. */
414
+ hitABare: metered('A:hit:no-triggers', false)({
415
+ args: ingestHandlerArgs,
416
+ handler: (ctx, args): Promise<IngestHandlerResult> => writeHitA(ctx, args.payload, args.receivedAt),
417
+ }),
418
+ /** (A) one rollup batch: fold up to `limit` hits before `before` into day rows, delete them. */
419
+ rollupA: metered('A:rollup')({
420
+ args: { before: v.string(), limit: v.number(), at: v.string() },
421
+ handler: async (ctx, { before, limit, at }) => {
422
+ const hits = await queryRecordDocs(ctx, hitKind, resolveRecordQuery(hitKind, { before, limit, order: 'asc' }))
423
+ const groups = new Map<string, { day: string; pageviews: Pageview[] }>()
424
+ for (const hit of hits) {
425
+ const columns = hit.data as unknown as Pageview & { receivedAt: string }
426
+ const day = columns.receivedAt.slice(0, 10)
427
+ const key = dayId(day, columns.documentId)
428
+ const group = groups.get(key) ?? { day, pageviews: [] }
429
+ group.pageviews.push(columns)
430
+ groups.set(key, group)
431
+ }
432
+ for (const { day, pageviews } of groups.values()) await upsertDay(ctx, day, pageviews, at)
433
+ for (const hit of hits) await ctx.db.delete(hit._id)
434
+ return { processed: hits.length }
435
+ },
436
+ }),
437
+ /** (B) the receiver's handler: read-modify-write of the day row. */
438
+ hitB: metered('B:hit')({
439
+ args: ingestHandlerArgs,
440
+ handler: async (ctx, args): Promise<IngestHandlerResult> => {
441
+ const pageview = pageviewOf(args.payload)
442
+ if (!pageview) return { ok: false, status: 422, message: 'Pageview events only.' }
443
+ await upsertDay(ctx, args.receivedAt.slice(0, 10), [pageview], args.receivedAt)
444
+ return { ok: true }
445
+ },
446
+ }),
447
+ /** CONTROL for the scheduled-work counter: a redirect write DOES schedule (EC-510). */
448
+ redirectControl: metered('control:redirect')({
449
+ args: {},
450
+ handler: async (ctx) => {
451
+ const at = '2026-10-26T12:00:00.000Z'
452
+ await saveRecord(ctx, redirectRecordKind, 'redirect_ec618', { from: '/old', to: '/new' }, { createdAt: at, updatedAt: at })
453
+ },
454
+ }),
455
+ }
456
+
457
+ const hitA = ingestHandler(`${MODEL_MODULE}:hitA`)
458
+ const hitABare = ingestHandler(`${MODEL_MODULE}:hitABare`)
459
+ const hitB = ingestHandler(`${MODEL_MODULE}:hitB`)
460
+ const rollupA = makeFunctionReference<'mutation', { before: string; limit: number; at: string }, { processed: number }>(
461
+ `${MODEL_MODULE}:rollupA`,
462
+ )
463
+ const redirectControl = makeFunctionReference<'mutation'>(`${MODEL_MODULE}:redirectControl`)
464
+
465
+ // --- Deterministic traffic (an SMB day: few pages, a long tail, mostly one country) ---------
466
+
467
+ function mulberry32(seed: number): () => number {
468
+ let state = seed
469
+ return () => {
470
+ state = (state + 0x6d2b79f5) | 0
471
+ let t = Math.imul(state ^ (state >>> 15), 1 | state)
472
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
473
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
474
+ }
475
+ }
476
+
477
+ function pick<T>(random: () => number, weighted: readonly (readonly [T, number])[]): T {
478
+ const total = weighted.reduce((sum, [, weight]) => sum + weight, 0)
479
+ let roll = random() * total
480
+ for (const [value, weight] of weighted) {
481
+ roll -= weight
482
+ if (roll < 0) return value
483
+ }
484
+ return weighted[weighted.length - 1]![0]
485
+ }
486
+
487
+ const SLUGS = [
488
+ 'sommerfest-im-garten-der-konditorei',
489
+ 'unsere-torten-fuer-hochzeiten',
490
+ 'ueber-uns',
491
+ 'kontakt-und-anfahrt',
492
+ 'saisonale-spezialitaeten-im-herbst',
493
+ 'rezept-apfelstrudel-wie-bei-oma',
494
+ 'oeffnungszeiten-an-feiertagen',
495
+ 'catering-fuer-firmenfeiern',
496
+ 'neue-filiale-in-der-altstadt',
497
+ 'impressum',
498
+ ]
499
+ const DOCUMENTS = SLUGS.map((slug, index) => ({
500
+ collection: index % 3 === 0 ? 'post' : 'page',
501
+ documentId: createUuidIdFactory((bytes) => bytes.fill(index + 1))(),
502
+ slug,
503
+ }))
504
+ const COUNTRIES = [['DE', 70], ['AT', 10], ['CH', 8], ['US', 5], ['NL', 3], ['FR', 2], ['GB', 1], ['IT', 1]] as const
505
+ const REFERRERS = [
506
+ [undefined, 45],
507
+ ['www.google.com', 30],
508
+ ['www.instagram.com', 8],
509
+ ['l.facebook.com', 5],
510
+ ['duckduckgo.com', 3],
511
+ ['www.bing.com', 3],
512
+ ['www.ecosia.org', 2],
513
+ ['www.tripadvisor.de', 2],
514
+ ['maps.google.com', 2],
515
+ ] as const
516
+ const VISITORS = 150
517
+
518
+ /** `count` pageviews of one day; `onlyDocument` pins them all to one page (the hot row). */
519
+ function traffic(count: number, seed: number, onlyDocument?: number): Pageview[] {
520
+ const random = mulberry32(seed)
521
+ const docWeights = DOCUMENTS.map((doc, index) => [index, 1 / (index + 1)] as const)
522
+ return Array.from({ length: count }, () => {
523
+ const doc = DOCUMENTS[onlyDocument ?? pick(random, docWeights)]!
524
+ const visitor = Math.floor(random() ** 1.5 * VISITORS)
525
+ // The visitor's daily hash → HLL coordinates (a good hash's distribution, per visitor).
526
+ const coords = mulberry32(10_000 + visitor)
527
+ const bucket = Math.floor(coords() * SKETCH_REGISTERS)
528
+ let rank = 1
529
+ while (rank < 54 && coords() < 0.5) rank += 1
530
+ const locale = random() < 0.8 ? 'de' : 'en'
531
+ const country = pick(random, COUNTRIES)
532
+ const referrer = pick(random, REFERRERS)
533
+ return {
534
+ collection: doc.collection,
535
+ documentId: doc.documentId,
536
+ path: `/${locale}/${doc.collection === 'post' ? 'blog/' : ''}${doc.slug}`,
537
+ locale,
538
+ status: 200,
539
+ country,
540
+ ...(referrer ? { referrer } : {}),
541
+ bucket,
542
+ rank,
543
+ }
544
+ })
545
+ }
546
+
547
+ // --- The deployment: configured exactly like a consumer (ingest secret + revalidation) -------
548
+
549
+ const SECRET = 'ec618-cost-secret'
550
+ const silentAuth = { addHttpRoutes: () => {} }
551
+ function env(): Record<string, string | undefined> {
552
+ return (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
553
+ }
554
+
555
+ beforeEach(() => {
556
+ vi.useFakeTimers()
557
+ vi.setSystemTime(new Date('2026-10-26T12:00:00.000Z'))
558
+ ledger.length = 0
559
+ env()['ELYTRA_INGEST_SECRET'] = SECRET
560
+ // A revalidation-configured deployment: a trigger that WOULD schedule work does (the control).
561
+ env()['CANVAS_REVALIDATE_ENDPOINT'] = 'https://host.invalid/api/revalidate'
562
+ env()['CANVAS_REVALIDATE_SECRET'] = 'ec618-revalidate-secret'
563
+ })
564
+ afterEach(() => {
565
+ vi.useRealTimers()
566
+ for (const key of ['ELYTRA_INGEST_SECRET', 'CANVAS_REVALIDATE_ENDPOINT', 'CANVAS_REVALIDATE_SECRET']) delete env()[key]
567
+ })
568
+
569
+ function backend(ingest: IngestHandlers) {
570
+ return convexTest(schema, {
571
+ ...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
572
+ './_generated/api.ts': async () => ({}),
573
+ [`./${MODEL_MODULE}.ts`]: async () => modelModule,
574
+ './http.ts': async () => ({ default: defineStudioHttp({ auth: silentAuth, ingest }) }),
575
+ })
576
+ }
577
+ type Backend = ReturnType<typeof backend>
578
+
579
+ function eventPayload(pageview: Pageview) {
580
+ const { documentId, bucket, rank, ...rest } = pageview
581
+ return { kind: 'event' as const, target: documentId, data: { name: 'pageview', ...rest, u: [bucket, rank] } }
582
+ }
583
+
584
+ /** One beacon through the receiver; returns the response and the signed body's bytes. */
585
+ async function sendPageview(t: Backend, pageview: Pageview): Promise<{ status: number; bodyBytes: number }> {
586
+ const signed = await signedIngestRequest(eventPayload(pageview), SECRET)
587
+ const response = await t.fetch(INGEST_RECEIVER_PATH, { method: 'POST', headers: signed.headers, body: signed.body })
588
+ return { status: response.status, bodyBytes: new TextEncoder().encode(signed.body).byteLength }
589
+ }
590
+
591
+ async function scheduledCount(t: Backend): Promise<number> {
592
+ return t.run(async (ctx) => (await ctx.db.system.query('_scheduled_functions').collect()).length)
593
+ }
594
+
595
+ async function recordsOf(t: Backend, kind: string): Promise<Doc[]> {
596
+ return t.run(async (ctx) => (await ctx.db.query('records').collect()).filter((row) => row.kind === kind) as unknown as Doc[])
597
+ }
598
+
599
+ function sum(txs: readonly TxMeter[], field: 'ranges' | 'readDocs' | 'readBytes' | 'writeDocs' | 'writeBytes' | 'indexRows' | 'indexBytes') {
600
+ return txs.reduce((total, tx) => total + tx[field], 0)
601
+ }
602
+
603
+ const txs = (label: string) => ledger.filter((tx) => tx.label === label)
604
+
605
+ // --- The tests ------------------------------------------------------------------------------
606
+
607
+ describe('EC-618 gate 1 — function calls per pageview', () => {
608
+ it('either design: the receiver httpAction + ONE runMutation, and no scheduled work on a configured deployment', async () => {
609
+ const [pageview] = traffic(1, 7)
610
+ for (const [label, handler] of [['A:hit', hitA], ['B:hit', hitB]] as const) {
611
+ ledger.length = 0
612
+ const t = backend({ event: handler })
613
+ const sent = await sendPageview(t, pageview!)
614
+ expect(sent.status).toBe(200)
615
+ // The ledger holds one entry per metered mutation EXECUTION: the receiver ran one.
616
+ expect(ledger.map((tx) => tx.label)).toEqual([label])
617
+ expect(await scheduledCount(t)).toBe(0)
618
+
619
+ // CONTROL (the scheduled counter can see trigger-scheduled work): a redirect write on
620
+ // the same configured deployment schedules exactly one revalidation (EC-510).
621
+ await t.mutation(redirectControl, {})
622
+ expect(await scheduledCount(t)).toBe(1)
623
+ }
624
+
625
+ // CONTROL (the ledger can see "no execution"): with no `event` handler the receiver refuses
626
+ // the beacon (EC-617's 422) and runs no mutation; an unsigned beacon is refused before that.
627
+ ledger.length = 0
628
+ const closed = backend({})
629
+ expect((await sendPageview(closed, pageview!)).status).toBe(422)
630
+ const unsigned = await closed.fetch(INGEST_RECEIVER_PATH, { method: 'POST', body: JSON.stringify(eventPayload(pageview!)) })
631
+ expect(unsigned.status).toBe(401)
632
+ expect(ledger).toEqual([])
633
+ })
634
+ })
635
+
636
+ describe('EC-618 gate 1 — bytes per pageview, the trigger work, the rollup', () => {
637
+ it('measures (A) raw hit + batched rollup against (B) aggregate-before-write over the same day of traffic', { timeout: 120_000 }, async () => {
638
+ const N = 240
639
+ const ROLLUP_LIMIT = 100
640
+ const day = traffic(N, 42)
641
+
642
+ // ---- (A) raw hits, then the rollup ----
643
+ ledger.length = 0
644
+ const a = backend({ event: hitA })
645
+ let bodyBytes = 0
646
+ for (const pageview of day) {
647
+ const sent = await sendPageview(a, pageview)
648
+ expect(sent.status).toBe(200)
649
+ bodyBytes += sent.bodyBytes
650
+ }
651
+ const hitTxs = txs('A:hit')
652
+ expect(hitTxs).toHaveLength(N)
653
+ const hitRows = await recordsOf(a, hitKind.kind)
654
+ expect(hitRows).toHaveLength(N)
655
+ const hitRowBytes = hitRows.reduce((total, row) => total + getDocumentSize(row as Record<string, Value>), 0) / N
656
+ // Every hit transaction: one empty range (saveRecord's lookup of its fresh id), one insert,
657
+ // and ONE document read — the trigger wrapper's `get` of the new row (convex-helpers).
658
+ for (const tx of hitTxs) {
659
+ expect(tx).toMatchObject({ ranges: 2, readDocs: 1, writeDocs: 1, indexRows: indexKeys('records').length })
660
+ }
661
+ expect(await scheduledCount(a)).toBe(0)
662
+ const depthA = Math.max(...hitRows.map((row) => measureMaxDepth(row).depth))
663
+ expect(depthA).toBeLessThanOrEqual(CONVEX_MAX_NESTING_DEPTH)
664
+ // The hash never reaches storage: the row holds the HLL coordinates, no visitor column.
665
+ expect(Object.keys(hitRows[0]!['data'] as object).sort()).toEqual(
666
+ ['bucket', 'collection', 'country', 'documentId', 'locale', 'path', 'rank', 'receivedAt', 'status', ...(hitRows[0]!['data'] as { referrer?: string }).referrer ? ['referrer'] : []].sort(),
667
+ )
668
+
669
+ // CONTROL (the meter sees the trigger's work): the same write with NO trigger wrapper reads
670
+ // no document — so the one read above is the write chokepoint's, measured, not assumed.
671
+ ledger.length = 0
672
+ const bare = backend({ event: hitABare })
673
+ expect((await sendPageview(bare, day[0]!)).status).toBe(200)
674
+ expect(txs('A:hit:no-triggers')[0]).toMatchObject({ ranges: 1, readDocs: 0, writeDocs: 1 })
675
+
676
+ // The rollup: batches until a short batch says the period is drained.
677
+ ledger.length = 0
678
+ const before = '2026-10-27T00:00:00.000Z'
679
+ let batches = 0
680
+ for (;;) {
681
+ batches += 1
682
+ const { processed } = await a.mutation(rollupA, { before, limit: ROLLUP_LIMIT, at: before })
683
+ if (processed < ROLLUP_LIMIT) break
684
+ }
685
+ expect(batches).toBe(Math.floor(N / ROLLUP_LIMIT) + 1)
686
+ expect(await recordsOf(a, hitKind.kind)).toHaveLength(0)
687
+ const dayRowsA = await recordsOf(a, dayKind.kind)
688
+ const viewsA = dayRowsA.reduce((total, row) => total + (row['data'] as DayColumns).views, 0)
689
+ expect(viewsA).toBe(N)
690
+ const rollupTxs = txs('A:rollup')
691
+
692
+ // ---- (B) aggregate before writing ----
693
+ ledger.length = 0
694
+ const b = backend({ event: hitB })
695
+ for (const pageview of day) expect((await sendPageview(b, pageview)).status).toBe(200)
696
+ const hitBTxs = txs('B:hit')
697
+ expect(hitBTxs).toHaveLength(N)
698
+ expect(await scheduledCount(b)).toBe(0)
699
+ const dayRowsB = await recordsOf(b, dayKind.kind)
700
+ expect(dayRowsB.reduce((total, row) => total + (row['data'] as DayColumns).views, 0)).toBe(N)
701
+ // The same aggregates either way (A folds in batches, B per hit — the sketch merge is exact).
702
+ const byId = (rows: Doc[]) => Object.fromEntries(rows.map((row) => [row['recordId'] as string, row['data']]))
703
+ expect(byId(dayRowsB)).toEqual(byId(dayRowsA))
704
+ // An update of the day row: two lookups (the handler's, saveRecord's), the trigger's
705
+ // `get` before and after the patch, ONE document written, and no index entry changed.
706
+ // (A create reads one document too — the trigger's `get` of the row it just inserted.)
707
+ const creates = hitBTxs.filter((tx) => tx.indexRows > 0)
708
+ for (const tx of creates) expect(tx).toMatchObject({ ranges: 3, readDocs: 1, writeDocs: 1, indexRows: indexKeys('records').length })
709
+ const updates = hitBTxs.filter((tx) => tx.indexRows === 0)
710
+ expect(creates).toHaveLength(dayRowsB.length)
711
+ expect(updates).toHaveLength(N - dayRowsB.length)
712
+ for (const tx of updates) expect(tx).toMatchObject({ ranges: 4, readDocs: 4, writeDocs: 1, indexRows: 0 })
713
+ const depthB = Math.max(...dayRowsB.map((row) => measureMaxDepth(row).depth))
714
+ expect(depthB).toBeLessThanOrEqual(CONVEX_MAX_NESTING_DEPTH)
715
+ const dayRowBytes = Math.max(...dayRowsB.map((row) => getDocumentSize(row as Record<string, Value>)))
716
+ const distinct = new Set(day.map((pageview) => `${pageview.documentId}|${pageview.bucket}|${pageview.rank}`)).size
717
+ const estimated = dayRowsB.reduce((total, row) => total + estimateUniques((row['data'] as DayColumns).sketch), 0)
718
+
719
+ // ---- per hit ----
720
+ const perHit = (list: readonly TxMeter[], over = N) => ({
721
+ ranges: sum(list, 'ranges') / over,
722
+ readBytes: sum(list, 'readBytes') / over,
723
+ writeBytes: sum(list, 'writeBytes') / over,
724
+ indexRows: sum(list, 'indexRows') / over,
725
+ indexBytes: sum(list, 'indexBytes') / over,
726
+ })
727
+ const aHit = perHit(hitTxs)
728
+ const aRollup = perHit(rollupTxs)
729
+ const bHit = perHit(hitBTxs)
730
+ const io = (m: ReturnType<typeof perHit>) => m.readBytes + m.writeBytes + m.indexBytes
731
+ const ioA = io(aHit) + io(aRollup)
732
+ const ioB = io(bHit)
733
+ const userIndexes = schema.tables.records[' indexes']().length
734
+
735
+ const designs = [
736
+ {
737
+ name: 'A raw hit + rollup',
738
+ // 2 per hit + one rollup call per batch (+ one trigger call per period: daily cron or self-schedule)
739
+ calls: (pageviews: number) => 2 * pageviews + Math.ceil(pageviews / ROLLUP_LIMIT) + 30,
740
+ ioBytes: ioA,
741
+ // standing raw rows for a one-day rollup window, each priced as (1 + indexes) copies
742
+ storageBytes: (pageviews: number) => (pageviews / 30) * hitRowBytes * (1 + userIndexes),
743
+ },
744
+ { name: 'B aggregate before write', calls: (pageviews: number) => 2 * pageviews, ioBytes: ioB, storageBytes: () => 0 },
745
+ ]
746
+ const volumes = [10_000, 100_000, 1_000_000, 10_000_000]
747
+ const costLines: string[] = []
748
+ for (const plan of PLANS) {
749
+ for (const design of designs) {
750
+ const cells = volumes.map((pageviews) => {
751
+ const calls = design.calls(pageviews)
752
+ const ioGB = (pageviews * design.ioBytes) / GB
753
+ const storageGB = design.storageBytes(pageviews) / GB
754
+ const actionGBh = pageviews * ACTION_MEMORY_GB * (ASSUMED_RECEIVER_MS / 3_600_000)
755
+ const marginal =
756
+ (calls / 1e6) * plan.calls.perMillion + ioGB * plan.ioGB.perGB + storageGB * plan.storageGB.perGB + actionGBh * plan.actionGBh.perGBh
757
+ const standalone =
758
+ (Math.max(0, calls - plan.calls.included) / 1e6) * plan.calls.perMillion +
759
+ Math.max(0, ioGB - plan.ioGB.included) * plan.ioGB.perGB +
760
+ Math.max(0, storageGB - plan.storageGB.included) * plan.storageGB.perGB +
761
+ Math.max(0, actionGBh - plan.actionGBh.included) * plan.actionGBh.perGBh
762
+ return `$${standalone.toFixed(2)} / $${marginal.toFixed(2)}`
763
+ })
764
+ costLines.push(`${plan.name.padEnd(12)} ${design.name.padEnd(26)} ${cells.map((cell) => cell.padEnd(20)).join('')}`)
765
+ }
766
+ }
767
+
768
+ const round = (value: number) => Math.round(value)
769
+ console.info(
770
+ [
771
+ `[analytics-cost] ${N} pageviews, ${DOCUMENTS.length} documents, 1 day — MODELLED (convex-test + Convex's size formula)`,
772
+ ` signed beacon body: ${round(bodyBytes / N)} B/hit · hit row ${round(hitRowBytes)} B · day row ≤ ${dayRowBytes} B · depth A ${depthA} / B ${depthB} (≤ ${CONVEX_MAX_NESTING_DEPTH})`,
773
+ ` records indexes: ${userIndexes} declared + 2 system → ${indexKeys('records').length} index rows per insert`,
774
+ ` A hit : ranges ${aHit.ranges} · read ${round(aHit.readBytes)} B · write ${round(aHit.writeBytes)} B · index rows ${aHit.indexRows} (${round(aHit.indexBytes)} B)`,
775
+ ` A rollup : ${batches} batches of ≤ ${ROLLUP_LIMIT} · per hit: ranges ${aRollup.ranges.toFixed(2)} · read ${round(aRollup.readBytes)} B · write ${round(aRollup.writeBytes)} B · index rows ${aRollup.indexRows.toFixed(2)} (${round(aRollup.indexBytes)} B)`,
776
+ ` B hit : ranges ${bHit.ranges.toFixed(2)} · read ${round(bHit.readBytes)} B · write ${round(bHit.writeBytes)} B · index rows ${bHit.indexRows.toFixed(2)} (${round(bHit.indexBytes)} B)`,
777
+ ` I/O per hit: A ${round(ioA)} B · B ${round(ioB)} B (B at the largest day row: ${round(4 * dayRowBytes + dayRowBytes)} B)`,
778
+ ` uniques: ${distinct} distinct (document, visitor) pairs · sketch estimate ${estimated.toFixed(1)}`,
779
+ ` monthly $ (standalone vs included quota / marginal), pageviews ${volumes.map((volume) => String(volume).padEnd(20)).join('')}`,
780
+ ...costLines.map((line) => ` ${line}`),
781
+ ].join('\n'),
782
+ )
783
+ })
784
+ })
785
+
786
+ describe('EC-618 gate 1 — the contention shape (N concurrent hits on one page)', () => {
787
+ it('A writes N disjoint rows (no shared read); B serialises every hit on ONE row', { timeout: 60_000 }, async () => {
788
+ const K = 40
789
+ const burst = traffic(K, 99, 0)
790
+
791
+ ledger.length = 0
792
+ const a = backend({ event: hitA })
793
+ const sentA = await Promise.all(burst.map((pageview) => sendPageview(a, pageview)))
794
+ expect(sentA.every((sent) => sent.status === 200)).toBe(true)
795
+ expect(await recordsOf(a, hitKind.kind)).toHaveLength(K)
796
+ // No document is read by two hit transactions: under OCC nothing can conflict.
797
+ const readsA = txs('A:hit').flatMap((tx) => tx.readIds)
798
+ expect(new Set(readsA).size).toBe(readsA.length)
799
+
800
+ ledger.length = 0
801
+ const b = backend({ event: hitB })
802
+ const sentB = await Promise.all(burst.map((pageview) => sendPageview(b, pageview)))
803
+ expect(sentB.every((sent) => sent.status === 200)).toBe(true)
804
+ const [row] = await recordsOf(b, dayKind.kind)
805
+ // convex-test SERIALISES mutations, so this proves the merge (no lost update), not Convex's
806
+ // OCC retry; the shape is what matters: every hit after the first read AND wrote one row.
807
+ expect((row!['data'] as DayColumns).views).toBe(K)
808
+ // Every transaction reads the row (the first: the trigger's read of its own insert);
809
+ // K − 1 of them read it BEFORE writing it — the read-modify-write OCC retries.
810
+ const readers = txs('B:hit').filter((tx) => tx.readIds.includes(row!._id))
811
+ expect(readers).toHaveLength(K)
812
+ expect(readers.filter((tx) => tx.indexRows === 0)).toHaveLength(K - 1)
813
+ console.info(
814
+ `[analytics-cost] contention: ${K} concurrent hits on one page — A: ${K} rows, 0 shared reads · B: 1 row read+written by ${readers.length} transactions`,
815
+ )
816
+ })
817
+ })