@elytracms/studio 0.0.92 → 0.0.94
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.
- package/convex/analytics-cost.test.ts +817 -0
- package/convex/asset-alt-migration.test.ts +127 -0
- package/convex/asset-replace.test.ts +168 -0
- package/convex/asset-revalidate.test.ts +6 -6
- package/convex/assets.ts +41 -5
- package/convex/auth.ts +5 -2
- package/convex/authority.ts +35 -11
- package/convex/cms.ts +25 -11
- package/convex/contract-parity.test.ts +25 -3
- package/convex/delivery.ts +12 -9
- package/convex/document-revalidate.test.ts +10 -12
- package/convex/guard.ts +9 -7
- package/convex/http-routes.test.ts +18 -8
- package/convex/http.ts +23 -17
- package/convex/ingest.test.ts +326 -0
- package/convex/ingest.ts +281 -0
- package/convex/invites.ts +13 -5
- package/convex/members.ts +10 -4
- package/convex/orphan-hydration.test.ts +116 -0
- package/convex/rail-write-parity.test.ts +11 -8
- package/convex/record-functions.ts +325 -0
- package/convex/records.test.ts +156 -0
- package/convex/redirect-migration.test.ts +205 -0
- package/convex/redirect-revalidate.test.ts +5 -1
- package/convex/redirects.ts +120 -79
- package/convex/role-policy.test.ts +315 -0
- package/convex/roles.ts +83 -0
- package/convex/schema.ts +36 -0
- package/convex/triggers.ts +21 -1
- package/dist/addon.d.ts +71 -0
- package/dist/addon.js +67 -0
- package/dist/addon.js.map +1 -0
- package/dist/components/account/account-settings.js +2 -2
- package/dist/components/account/account-settings.js.map +1 -1
- package/dist/components/account/credential-panel.js +2 -2
- package/dist/components/account/credential-panel.js.map +1 -1
- package/dist/components/account/self-account-schema.d.ts +6 -15
- package/dist/components/account/self-account-schema.js +52 -34
- package/dist/components/account/self-account-schema.js.map +1 -1
- package/dist/components/account/self-account-view-source.d.ts +1 -1
- package/dist/components/account/self-account-view-source.js +6 -4
- package/dist/components/account/self-account-view-source.js.map +1 -1
- package/dist/components/ai/ai-chat-panel-lazy.js +1 -1
- package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
- package/dist/components/ai/ai-chat-panel.js +6 -6
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/ai/ai-cms-panel.js +2 -2
- package/dist/components/ai/ai-cms-panel.js.map +1 -1
- package/dist/components/ai/ai-panel-toggle.js +4 -4
- package/dist/components/ai/ai-panel-toggle.js.map +1 -1
- package/dist/components/ai/chat-ui.js +2 -2
- package/dist/components/ai/chat-ui.js.map +1 -1
- package/dist/components/ai/proposal-card.d.ts +2 -2
- package/dist/components/ai/proposal-card.js +4 -3
- package/dist/components/ai/proposal-card.js.map +1 -1
- package/dist/components/ai/proposal-issues.js +2 -2
- package/dist/components/ai/proposal-issues.js.map +1 -1
- package/dist/components/ai/translate-document-action.js +2 -2
- package/dist/components/ai/translate-document-action.js.map +1 -1
- package/dist/components/ai/translate-document-dialog.js +10 -8
- package/dist/components/ai/translate-document-dialog.js.map +1 -1
- package/dist/components/ai/translate-skipped.d.ts +19 -0
- package/dist/components/ai/translate-skipped.js +46 -0
- package/dist/components/ai/translate-skipped.js.map +1 -0
- package/dist/components/app-views/default-modules.d.ts +50 -2
- package/dist/components/app-views/default-modules.js +65 -20
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/module.d.ts +53 -3
- package/dist/components/app-views/module.js +14 -7
- package/dist/components/app-views/module.js.map +1 -1
- package/dist/components/app-views/sidebar.js +1 -1
- package/dist/components/app-views/sidebar.js.map +1 -1
- package/dist/components/app-views/studio-config.d.ts +11 -2
- package/dist/components/app-views/studio-config.js.map +1 -1
- package/dist/components/assets/asset-detail.js +20 -10
- package/dist/components/assets/asset-detail.js.map +1 -1
- package/dist/components/assets/asset-grid.js +2 -2
- package/dist/components/assets/asset-grid.js.map +1 -1
- package/dist/components/assets/asset-metadata.d.ts +15 -4
- package/dist/components/assets/asset-metadata.js +15 -2
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/asset-search.js +4 -1
- package/dist/components/assets/asset-search.js.map +1 -1
- package/dist/components/assets/asset-selection.d.ts +0 -8
- package/dist/components/assets/asset-selection.js +4 -23
- package/dist/components/assets/asset-selection.js.map +1 -1
- package/dist/components/assets/asset-thumb.js +6 -5
- package/dist/components/assets/asset-thumb.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +5 -5
- package/dist/components/assets/assets-collection-view-source.js +9 -5
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/assets/assets-module.js +1 -1
- package/dist/components/assets/assets-module.js.map +1 -1
- package/dist/components/assets/assets-store.d.ts +16 -2
- package/dist/components/assets/assets-store.js +28 -6
- package/dist/components/assets/assets-store.js.map +1 -1
- package/dist/components/assets/assets-view-preference.js +1 -1
- package/dist/components/assets/assets-view-preference.js.map +1 -1
- package/dist/components/assets/replace-file.d.ts +22 -0
- package/dist/components/assets/replace-file.js +59 -0
- package/dist/components/assets/replace-file.js.map +1 -0
- package/dist/components/auth-context.d.ts +1 -1
- package/dist/components/auth-context.js +6 -6
- package/dist/components/auth-context.js.map +1 -1
- package/dist/components/builder/blocks-model.d.ts +15 -27
- package/dist/components/builder/blocks-model.js +38 -44
- package/dist/components/builder/blocks-model.js.map +1 -1
- package/dist/components/builder/blocks-panel.d.ts +8 -4
- package/dist/components/builder/blocks-panel.js +10 -9
- package/dist/components/builder/blocks-panel.js.map +1 -1
- package/dist/components/builder/builder-store.d.ts +104 -15
- package/dist/components/builder/builder-store.js +157 -32
- package/dist/components/builder/builder-store.js.map +1 -1
- package/dist/components/builder/canvas-body.d.ts +30 -0
- package/dist/components/builder/canvas-body.js +38 -0
- package/dist/components/builder/canvas-body.js.map +1 -0
- package/dist/components/builder/canvas-breakpoints.js +1 -1
- package/dist/components/builder/canvas-breakpoints.js.map +1 -1
- package/dist/components/builder/canvas-chrome-css.js +7 -0
- package/dist/components/builder/canvas-chrome-css.js.map +1 -1
- package/dist/components/builder/canvas-dnd.d.ts +9 -9
- package/dist/components/builder/canvas-dnd.js +6 -6
- package/dist/components/builder/canvas-dnd.js.map +1 -1
- package/dist/components/builder/canvas-inline-editing.d.ts +1 -1
- package/dist/components/builder/canvas-inline-editing.js.map +1 -1
- package/dist/components/builder/canvas-overlay-geometry.d.ts +1 -1
- package/dist/components/builder/canvas-overlay-geometry.js.map +1 -1
- package/dist/components/builder/canvas-render-context.d.ts +28 -20
- package/dist/components/builder/canvas-render-context.js +46 -47
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/canvas-runs.d.ts +45 -0
- package/dist/components/builder/canvas-runs.js +51 -0
- package/dist/components/builder/canvas-runs.js.map +1 -0
- package/dist/components/builder/canvas-theme-notice.js +2 -2
- package/dist/components/builder/canvas-theme-notice.js.map +1 -1
- package/dist/components/builder/canvas.js +35 -8
- package/dist/components/builder/canvas.js.map +1 -1
- package/dist/components/builder/clipboard-actions.d.ts +16 -1
- package/dist/components/builder/clipboard-actions.js +48 -11
- package/dist/components/builder/clipboard-actions.js.map +1 -1
- package/dist/components/builder/composition-field-editor-lazy.js +1 -1
- package/dist/components/builder/composition-field-editor-lazy.js.map +1 -1
- package/dist/components/builder/composition-field-editor.d.ts +4 -16
- package/dist/components/builder/composition-field-editor.js +32 -43
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/delete-key.d.ts +5 -0
- package/dist/components/builder/delete-key.js +4 -0
- package/dist/components/builder/delete-key.js.map +1 -1
- package/dist/components/builder/drag-session.d.ts +1 -1
- package/dist/components/builder/drag-session.js.map +1 -1
- package/dist/components/builder/fixture-thumbnail.js +1 -1
- package/dist/components/builder/fixture-thumbnail.js.map +1 -1
- package/dist/components/builder/insert-context.d.ts +18 -26
- package/dist/components/builder/insert-context.js +47 -94
- package/dist/components/builder/insert-context.js.map +1 -1
- package/dist/components/builder/layout-inspector.js +1 -1
- package/dist/components/builder/layout-inspector.js.map +1 -1
- package/dist/components/builder/node-title.js +1 -1
- package/dist/components/builder/node-title.js.map +1 -1
- package/dist/components/builder/page-tree.d.ts +4 -4
- package/dist/components/builder/page-tree.js +8 -8
- package/dist/components/builder/page-tree.js.map +1 -1
- package/dist/components/builder/placement-refusal.d.ts +18 -0
- package/dist/components/builder/placement-refusal.js +46 -0
- package/dist/components/builder/placement-refusal.js.map +1 -0
- package/dist/components/builder/property-inspector.js +8 -4
- package/dist/components/builder/property-inspector.js.map +1 -1
- package/dist/components/builder/reuse-context.js +68 -30
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/builder/reuse.d.ts +79 -1
- package/dist/components/builder/reuse.js +70 -3
- package/dist/components/builder/reuse.js.map +1 -1
- package/dist/components/builder/rich-text-toolbar.js +1 -1
- package/dist/components/builder/rich-text-toolbar.js.map +1 -1
- package/dist/components/builder/run-inspector.d.ts +11 -0
- package/dist/components/builder/run-inspector.js +33 -0
- package/dist/components/builder/run-inspector.js.map +1 -0
- package/dist/components/builder/selection-overlay.js +3 -3
- package/dist/components/builder/selection-overlay.js.map +1 -1
- package/dist/components/builder/spacing-handles.d.ts +2 -2
- package/dist/components/builder/spacing-handles.js +3 -3
- package/dist/components/builder/spacing-handles.js.map +1 -1
- package/dist/components/cms/asset-field.d.ts +38 -1
- package/dist/components/cms/asset-field.js +184 -60
- package/dist/components/cms/asset-field.js.map +1 -1
- package/dist/components/cms/asset-picker-core.d.ts +16 -0
- package/dist/components/cms/asset-picker-core.js +20 -0
- package/dist/components/cms/asset-picker-core.js.map +1 -1
- package/dist/components/cms/asset-picker.js +14 -20
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/bulk-actions.d.ts +4 -20
- package/dist/components/cms/bulk-actions.js +5 -26
- package/dist/components/cms/bulk-actions.js.map +1 -1
- package/dist/components/cms/bulk-bar.d.ts +2 -2
- package/dist/components/cms/bulk-bar.js +2 -2
- package/dist/components/cms/bulk-bar.js.map +1 -1
- package/dist/components/cms/cell-edit.d.ts +1 -1
- package/dist/components/cms/cell-edit.js +1 -1
- package/dist/components/cms/cell-edit.js.map +1 -1
- package/dist/components/cms/cell.d.ts +1 -1
- package/dist/components/cms/cell.js +3 -3
- package/dist/components/cms/cell.js.map +1 -1
- package/dist/components/cms/cms-index.js +2 -2
- package/dist/components/cms/cms-index.js.map +1 -1
- package/dist/components/cms/cms-labels.js +2 -2
- package/dist/components/cms/cms-labels.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +7 -6
- package/dist/components/cms/cms-store.js +23 -43
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +7 -7
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/detail-editor.d.ts +5 -2
- package/dist/components/cms/detail-editor.js +20 -15
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/details-pane.js +1 -1
- package/dist/components/cms/details-pane.js.map +1 -1
- package/dist/components/cms/doc-state.js +1 -1
- package/dist/components/cms/doc-state.js.map +1 -1
- package/dist/components/cms/document-diff-view.d.ts +3 -1
- package/dist/components/cms/document-diff-view.js +17 -12
- package/dist/components/cms/document-diff-view.js.map +1 -1
- package/dist/components/cms/document-editor-context.js +1 -1
- package/dist/components/cms/document-editor-context.js.map +1 -1
- package/dist/components/cms/document-staging.js +1 -1
- package/dist/components/cms/document-staging.js.map +1 -1
- package/dist/components/cms/document-url-path.js +2 -2
- package/dist/components/cms/document-url-path.js.map +1 -1
- package/dist/components/cms/duplicate-document.d.ts +1 -1
- package/dist/components/cms/duplicate-document.js.map +1 -1
- package/dist/components/cms/editable-cell.js +3 -3
- package/dist/components/cms/editable-cell.js.map +1 -1
- package/dist/components/cms/field-anchors.d.ts +7 -4
- package/dist/components/cms/field-anchors.js +9 -7
- package/dist/components/cms/field-anchors.js.map +1 -1
- package/dist/components/cms/field-collaboration.d.ts +1 -1
- package/dist/components/cms/field-collaboration.js +1 -1
- package/dist/components/cms/field-collaboration.js.map +1 -1
- package/dist/components/cms/field-connection.d.ts +1 -1
- package/dist/components/cms/field-connection.js +2 -2
- package/dist/components/cms/field-connection.js.map +1 -1
- package/dist/components/cms/field-control-lib.d.ts +15 -1
- package/dist/components/cms/field-control-lib.js +23 -4
- package/dist/components/cms/field-control-lib.js.map +1 -1
- package/dist/components/cms/field-control.js +4 -4
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/field-renderer.d.ts +1 -1
- package/dist/components/cms/field-renderer.js +2 -2
- package/dist/components/cms/field-renderer.js.map +1 -1
- package/dist/components/cms/filter-builder.d.ts +1 -1
- package/dist/components/cms/filter-builder.js +3 -3
- package/dist/components/cms/filter-builder.js.map +1 -1
- package/dist/components/cms/gate-validation.d.ts +21 -30
- package/dist/components/cms/gate-validation.js +11 -32
- package/dist/components/cms/gate-validation.js.map +1 -1
- package/dist/components/cms/import-export.js +2 -2
- package/dist/components/cms/import-export.js.map +1 -1
- package/dist/components/cms/issue-copy.js +1 -1
- package/dist/components/cms/issue-copy.js.map +1 -1
- package/dist/components/cms/issues-pane.js +3 -3
- package/dist/components/cms/issues-pane.js.map +1 -1
- package/dist/components/cms/object-array-field.js +6 -6
- package/dist/components/cms/object-array-field.js.map +1 -1
- package/dist/components/cms/prop-fields.d.ts +7 -2
- package/dist/components/cms/prop-fields.js +14 -7
- package/dist/components/cms/prop-fields.js.map +1 -1
- package/dist/components/cms/reference-confirm.d.ts +1 -1
- package/dist/components/cms/reference-confirm.js +2 -2
- package/dist/components/cms/reference-confirm.js.map +1 -1
- package/dist/components/cms/relation-picker.js +2 -2
- package/dist/components/cms/relation-picker.js.map +1 -1
- package/dist/components/cms/rich-text/block-style.js +1 -1
- package/dist/components/cms/rich-text/block-style.js.map +1 -1
- package/dist/components/cms/rich-text/component-embed.js +4 -4
- package/dist/components/cms/rich-text/component-embed.js.map +1 -1
- package/dist/components/cms/rich-text/editor.js +3 -3
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/embed-picker.d.ts +1 -1
- package/dist/components/cms/rich-text/embed-picker.js +3 -3
- package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
- package/dist/components/cms/rich-text/embed-props.js +3 -3
- package/dist/components/cms/rich-text/embed-props.js.map +1 -1
- package/dist/components/cms/rich-text/extensions.js +1 -1
- package/dist/components/cms/rich-text/extensions.js.map +1 -1
- package/dist/components/cms/rich-text/font-size-control.d.ts +1 -1
- package/dist/components/cms/rich-text/font-size-control.js +2 -2
- package/dist/components/cms/rich-text/font-size-control.js.map +1 -1
- package/dist/components/cms/rich-text/insert.d.ts +20 -9
- package/dist/components/cms/rich-text/insert.js +25 -17
- package/dist/components/cms/rich-text/insert.js.map +1 -1
- package/dist/components/cms/rich-text/lazy.js +1 -1
- package/dist/components/cms/rich-text/lazy.js.map +1 -1
- package/dist/components/cms/rich-text/link-control.js +2 -2
- package/dist/components/cms/rich-text/link-control.js.map +1 -1
- package/dist/components/cms/rich-text/slash-command.js +3 -3
- package/dist/components/cms/rich-text/slash-command.js.map +1 -1
- package/dist/components/cms/rich-text/toolbar.js +1 -1
- package/dist/components/cms/rich-text/toolbar.js.map +1 -1
- package/dist/components/cms/rich-text/unknown-node.js +2 -2
- package/dist/components/cms/rich-text/unknown-node.js.map +1 -1
- package/dist/components/cms/rich-text-preview.js +2 -2
- package/dist/components/cms/rich-text-preview.js.map +1 -1
- package/dist/components/cms/row-actions.d.ts +1 -1
- package/dist/components/cms/row-actions.js.map +1 -1
- package/dist/components/cms/scalar-value-editor.d.ts +1 -1
- package/dist/components/cms/scalar-value-editor.js +2 -2
- package/dist/components/cms/scalar-value-editor.js.map +1 -1
- package/dist/components/cms/use-discard-controller.d.ts +1 -1
- package/dist/components/cms/use-discard-controller.js +1 -1
- package/dist/components/cms/use-discard-controller.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +7 -1
- package/dist/components/cms/use-document-draft.js +16 -2
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/cms/use-document-versions.js +1 -1
- package/dist/components/cms/use-document-versions.js.map +1 -1
- package/dist/components/cms/use-field-focus.d.ts +1 -1
- package/dist/components/cms/use-field-focus.js +2 -2
- package/dist/components/cms/use-field-focus.js.map +1 -1
- package/dist/components/cms/use-live-url.d.ts +1 -1
- package/dist/components/cms/use-live-url.js +3 -3
- package/dist/components/cms/use-live-url.js.map +1 -1
- package/dist/components/cms/use-publish-controller.d.ts +2 -3
- package/dist/components/cms/use-publish-controller.js +8 -20
- package/dist/components/cms/use-publish-controller.js.map +1 -1
- package/dist/components/cms/use-published-perspective.d.ts +1 -1
- package/dist/components/cms/use-published-perspective.js.map +1 -1
- package/dist/components/cms/use-restore-version.d.ts +1 -1
- package/dist/components/cms/use-restore-version.js +1 -1
- package/dist/components/cms/use-restore-version.js.map +1 -1
- package/dist/components/cms/version-compare-lazy.js +1 -1
- package/dist/components/cms/version-compare-lazy.js.map +1 -1
- package/dist/components/cms/version-compare.js +1 -1
- package/dist/components/cms/version-compare.js.map +1 -1
- package/dist/components/cms/versions-pane.js +11 -4
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/cms/view-bar.js +3 -3
- package/dist/components/cms/view-bar.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.d.ts +1 -1
- package/dist/components/collection-view/cms-collection-view-source.js +5 -3
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
- package/dist/components/collection-view/collection-view-context.d.ts +1 -1
- package/dist/components/collection-view/collection-view-context.js +1 -1
- package/dist/components/collection-view/collection-view-context.js.map +1 -1
- package/dist/components/collection-view/collection-view-surface.d.ts +13 -0
- package/dist/components/collection-view/collection-view-surface.js +18 -0
- package/dist/components/collection-view/collection-view-surface.js.map +1 -0
- package/dist/components/components/component-detail.js +10 -10
- package/dist/components/components/component-detail.js.map +1 -1
- package/dist/components/components/component-preview.d.ts +2 -2
- package/dist/components/components/component-preview.js +5 -5
- package/dist/components/components/component-preview.js.map +1 -1
- package/dist/components/components/component-status.d.ts +1 -1
- package/dist/components/components/component-status.js +1 -1
- package/dist/components/components/component-status.js.map +1 -1
- package/dist/components/components/components-index.js +2 -2
- package/dist/components/components/components-index.js.map +1 -1
- package/dist/components/components/field-inspector.js +1 -1
- package/dist/components/components/field-inspector.js.map +1 -1
- package/dist/components/components/registry-browser.js +3 -3
- package/dist/components/components/registry-browser.js.map +1 -1
- package/dist/components/components/taxonomy.d.ts +1 -1
- package/dist/components/components/taxonomy.js.map +1 -1
- package/dist/components/components/transfer-dialog.js +4 -3
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/components/components/transfer.d.ts +10 -10
- package/dist/components/components/transfer.js +13 -12
- package/dist/components/components/transfer.js.map +1 -1
- package/dist/components/current-user-menu.js +1 -1
- package/dist/components/current-user-menu.js.map +1 -1
- package/dist/components/delivery-health-banner.js +2 -2
- package/dist/components/delivery-health-banner.js.map +1 -1
- package/dist/components/deployment-setup-gate.js +3 -3
- package/dist/components/deployment-setup-gate.js.map +1 -1
- package/dist/components/dev-login-badge.js +2 -2
- package/dist/components/dev-login-badge.js.map +1 -1
- package/dist/components/guards.js +1 -1
- package/dist/components/guards.js.map +1 -1
- package/dist/components/inspector/document-panes.d.ts +54 -0
- package/dist/components/inspector/document-panes.js +41 -0
- package/dist/components/inspector/document-panes.js.map +1 -0
- package/dist/components/inspector/inspector-sidebar.js +1 -1
- package/dist/components/inspector/inspector-sidebar.js.map +1 -1
- package/dist/components/nav-issues-registry.d.ts +53 -0
- package/dist/components/nav-issues-registry.js +69 -0
- package/dist/components/nav-issues-registry.js.map +1 -0
- package/dist/components/nav-issues.js +4 -4
- package/dist/components/nav-issues.js.map +1 -1
- package/dist/components/operation-error.d.ts +1 -1
- package/dist/components/operation-error.js +4 -4
- package/dist/components/operation-error.js.map +1 -1
- package/dist/components/playground-badge.js +2 -2
- package/dist/components/playground-badge.js.map +1 -1
- package/dist/components/project-list.js +3 -3
- package/dist/components/project-list.js.map +1 -1
- package/dist/components/project-session.d.ts +2 -2
- package/dist/components/project-session.js +5 -5
- package/dist/components/project-session.js.map +1 -1
- package/dist/components/project-switcher.js +2 -2
- package/dist/components/project-switcher.js.map +1 -1
- package/dist/components/rail-store-status.js +1 -1
- package/dist/components/rail-store-status.js.map +1 -1
- package/dist/components/rail-view-source.d.ts +6 -5
- package/dist/components/rail-view-source.js +4 -3
- package/dist/components/rail-view-source.js.map +1 -1
- package/dist/components/records/record-lens.d.ts +51 -0
- package/dist/components/records/record-lens.js +156 -0
- package/dist/components/records/record-lens.js.map +1 -0
- package/dist/components/records/record-rail.d.ts +86 -0
- package/dist/components/records/record-rail.js +241 -0
- package/dist/components/records/record-rail.js.map +1 -0
- package/dist/components/redirects/redirects-schema.js +1 -1
- package/dist/components/redirects/redirects-schema.js.map +1 -1
- package/dist/components/references/references-panel.js +2 -2
- package/dist/components/references/references-panel.js.map +1 -1
- package/dist/components/section-nav.js +27 -11
- package/dist/components/section-nav.js.map +1 -1
- package/dist/components/section-state.js +2 -2
- package/dist/components/section-state.js.map +1 -1
- package/dist/components/section-surface.js +2 -2
- package/dist/components/section-surface.js.map +1 -1
- package/dist/components/session-expired-overlay.js +3 -3
- package/dist/components/session-expired-overlay.js.map +1 -1
- package/dist/components/settings-form.js +1 -1
- package/dist/components/settings-form.js.map +1 -1
- package/dist/components/sign-in-needed-badge.js +1 -1
- package/dist/components/sign-in-needed-badge.js.map +1 -1
- package/dist/components/sign-in-view.d.ts +2 -2
- package/dist/components/sign-in-view.js +5 -5
- package/dist/components/sign-in-view.js.map +1 -1
- package/dist/components/studio-context.js +3 -3
- package/dist/components/studio-context.js.map +1 -1
- package/dist/components/studio-project-screen.js +30 -3
- package/dist/components/studio-project-screen.js.map +1 -1
- package/dist/components/studio-shell.js +6 -5
- package/dist/components/studio-shell.js.map +1 -1
- package/dist/components/theme-context.d.ts +1 -1
- package/dist/components/theme-context.js +2 -2
- package/dist/components/theme-context.js.map +1 -1
- package/dist/components/use-hydration-phase.d.ts +1 -1
- package/dist/components/use-hydration-phase.js +1 -1
- package/dist/components/use-hydration-phase.js.map +1 -1
- package/dist/components/users/invite-user-bar.d.ts +27 -0
- package/dist/components/users/invite-user-bar.js +25 -9
- package/dist/components/users/invite-user-bar.js.map +1 -1
- package/dist/components/users/role-policy-drift-issues.d.ts +38 -0
- package/dist/components/users/role-policy-drift-issues.js +71 -0
- package/dist/components/users/role-policy-drift-issues.js.map +1 -0
- package/dist/components/users/users-collection-view-source.d.ts +1 -1
- package/dist/components/users/users-collection-view-source.js +4 -2
- package/dist/components/users/users-collection-view-source.js.map +1 -1
- package/dist/components/users/users-module.js +1 -1
- package/dist/components/users/users-module.js.map +1 -1
- package/dist/components/users/users-schema.d.ts +10 -1
- package/dist/components/users/users-schema.js +45 -25
- package/dist/components/users/users-schema.js.map +1 -1
- package/dist/components/users/users-store.js +3 -3
- package/dist/components/users/users-store.js.map +1 -1
- package/dist/components/workspace-projects.d.ts +1 -1
- package/dist/components/workspace-projects.js +1 -1
- package/dist/components/workspace-projects.js.map +1 -1
- package/dist/lib/ai/apply-content.js +56 -116
- package/dist/lib/ai/apply-content.js.map +1 -1
- package/dist/lib/ai/chat-context.d.ts +8 -0
- package/dist/lib/ai/chat-context.js +37 -6
- package/dist/lib/ai/chat-context.js.map +1 -1
- package/dist/lib/ai/chat-state.d.ts +6 -0
- package/dist/lib/ai/chat-state.js.map +1 -1
- package/dist/lib/ai/constraints.d.ts +20 -5
- package/dist/lib/ai/constraints.js +125 -33
- package/dist/lib/ai/constraints.js.map +1 -1
- package/dist/lib/ai/fake-provider.d.ts +16 -3
- package/dist/lib/ai/fake-provider.js +22 -6
- package/dist/lib/ai/fake-provider.js.map +1 -1
- package/dist/lib/ai/index.d.ts +3 -1
- package/dist/lib/ai/index.js +3 -1
- package/dist/lib/ai/index.js.map +1 -1
- package/dist/lib/ai/leash.d.ts +34 -0
- package/dist/lib/ai/leash.js +28 -0
- package/dist/lib/ai/leash.js.map +1 -0
- package/dist/lib/ai/node-handler.d.ts +1 -1
- package/dist/lib/ai/node-handler.js.map +1 -1
- package/dist/lib/ai/rich-text-input.d.ts +8 -8
- package/dist/lib/ai/rich-text-input.js +2 -1
- package/dist/lib/ai/rich-text-input.js.map +1 -1
- package/dist/lib/ai/rich-text-translate.d.ts +27 -4
- package/dist/lib/ai/rich-text-translate.js +28 -10
- package/dist/lib/ai/rich-text-translate.js.map +1 -1
- package/dist/lib/ai/shape.d.ts +13 -13
- package/dist/lib/ai/shape.js +69 -93
- package/dist/lib/ai/shape.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +36 -41
- package/dist/lib/ai/tools.js +83 -87
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/ai/translate-document.d.ts +32 -4
- package/dist/lib/ai/translate-document.js +145 -47
- package/dist/lib/ai/translate-document.js.map +1 -1
- package/dist/lib/ai/types.d.ts +34 -23
- package/dist/lib/ai/types.js.map +1 -1
- package/dist/lib/ai/update-fields.d.ts +15 -12
- package/dist/lib/ai/update-fields.js +112 -188
- package/dist/lib/ai/update-fields.js.map +1 -1
- package/dist/lib/ai/writer-form.d.ts +116 -0
- package/dist/lib/ai/writer-form.js +429 -0
- package/dist/lib/ai/writer-form.js.map +1 -0
- package/dist/lib/assets/upload.d.ts +31 -2
- package/dist/lib/assets/upload.js +61 -6
- package/dist/lib/assets/upload.js.map +1 -1
- package/dist/lib/clipboard/os-clipboard.d.ts +10 -2
- package/dist/lib/clipboard/os-clipboard.js +8 -4
- package/dist/lib/clipboard/os-clipboard.js.map +1 -1
- package/dist/lib/clipboard/paste-issues.js +1 -1
- package/dist/lib/clipboard/paste-issues.js.map +1 -1
- package/dist/lib/clipboard/paste-target.d.ts +18 -9
- package/dist/lib/clipboard/paste-target.js +10 -9
- package/dist/lib/clipboard/paste-target.js.map +1 -1
- package/dist/lib/cms/field-patch.d.ts +0 -10
- package/dist/lib/cms/field-patch.js +4 -30
- package/dist/lib/cms/field-patch.js.map +1 -1
- package/dist/lib/cms/published-documents.js +1 -1
- package/dist/lib/cms/published-documents.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +9 -9
- package/dist/lib/cms-views/view-model.js +2 -2
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +14 -5
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/data/collection-content.js +3 -2
- package/dist/lib/data/collection-content.js.map +1 -1
- package/dist/lib/data/content-session-refresh.d.ts +1 -1
- package/dist/lib/data/content-session-refresh.js.map +1 -1
- package/dist/lib/data/playground-unload-guard.d.ts +1 -1
- package/dist/lib/data/playground-unload-guard.js +1 -1
- package/dist/lib/data/playground-unload-guard.js.map +1 -1
- package/dist/lib/dnd/drop-rules.d.ts +25 -31
- package/dist/lib/dnd/drop-rules.js +53 -59
- package/dist/lib/dnd/drop-rules.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/i18n.js +1 -1
- package/dist/lib/i18n/i18n.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +44 -3
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +38 -2
- package/dist/lib/i18n/messages.en.js +51 -3
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/project-runtime-config.d.ts +34 -6
- package/dist/lib/operations/project-runtime-config.js +50 -10
- package/dist/lib/operations/project-runtime-config.js.map +1 -1
- package/dist/lib/operations/publish-with-revision.d.ts +33 -1
- package/dist/lib/operations/publish-with-revision.js +32 -0
- package/dist/lib/operations/publish-with-revision.js.map +1 -1
- package/dist/lib/operations/runtime-validator.d.ts +19 -68
- package/dist/lib/operations/runtime-validator.js +12 -138
- package/dist/lib/operations/runtime-validator.js.map +1 -1
- package/dist/lib/operations/studio-operations.d.ts +17 -2
- package/dist/lib/operations/studio-operations.js +18 -11
- package/dist/lib/operations/studio-operations.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.d.ts +16 -3
- package/dist/lib/persistence/convex-adapter.js +83 -47
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persistence/index.js +14 -1
- package/dist/lib/persistence/index.js.map +1 -1
- package/dist/lib/persona/labels.js +4 -2
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/query/operations-query.d.ts +1 -1
- package/dist/lib/query/operations-query.js +1 -1
- package/dist/lib/query/operations-query.js.map +1 -1
- package/dist/lib/references/reference-model.js +1 -1
- package/dist/lib/references/reference-model.js.map +1 -1
- package/dist/lib/references/references-store.js +1 -1
- package/dist/lib/references/references-store.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +14 -11
- package/dist/lib/workspace/index.js +20 -24
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/mount.d.ts +28 -0
- package/dist/mount.js +27 -0
- package/dist/mount.js.map +1 -0
- package/dist/router/root.js +10 -10
- package/dist/router/root.js.map +1 -1
- package/dist/ui/badge.d.ts +1 -1
- package/dist/ui/button.d.ts +2 -2
- package/package.json +15 -11
- package/dist/components/cms/cms-composition-validators.d.ts +0 -16
- package/dist/components/cms/cms-composition-validators.js +0 -133
- package/dist/components/cms/cms-composition-validators.js.map +0 -1
- package/dist/components/cms/publish-clean.d.ts +0 -28
- package/dist/components/cms/publish-clean.js +0 -38
- package/dist/components/cms/publish-clean.js.map +0 -1
- package/dist/components/redirects/redirects-collection-view-source.d.ts +0 -53
- package/dist/components/redirects/redirects-collection-view-source.js +0 -146
- package/dist/components/redirects/redirects-collection-view-source.js.map +0 -1
- package/dist/components/redirects/redirects-module.d.ts +0 -21
- package/dist/components/redirects/redirects-module.js +0 -40
- package/dist/components/redirects/redirects-module.js.map +0 -1
- package/dist/components/redirects/redirects-store.d.ts +0 -68
- package/dist/components/redirects/redirects-store.js +0 -132
- package/dist/components/redirects/redirects-store.js.map +0 -1
- package/dist/lib/assets/asset-placements.d.ts +0 -28
- package/dist/lib/assets/asset-placements.js +0 -83
- package/dist/lib/assets/asset-placements.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
|
+
})
|