@dxos/plugin-github 0.7.4 → 0.14.0
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/LICENSE +102 -5
- package/PLUGIN.mdl +784 -0
- package/dist/lib/GitHubCapabilities.mjs +10 -0
- package/dist/lib/GitHubCapabilities.mjs.map +1 -0
- package/dist/lib/GitHubEvents.mjs +15 -0
- package/dist/lib/GitHubEvents.mjs.map +1 -0
- package/dist/lib/GitHubOperation.mjs +384 -0
- package/dist/lib/GitHubOperation.mjs.map +1 -0
- package/dist/lib/GitHubPlugin.mjs +16 -0
- package/dist/lib/GitHubPlugin.mjs.map +1 -0
- package/dist/lib/Walkthrough.mjs +59 -0
- package/dist/lib/Walkthrough.mjs.map +1 -0
- package/dist/lib/capabilities.browser.mjs +54 -0
- package/dist/lib/capabilities.browser.mjs.map +1 -0
- package/dist/lib/capabilities.mjs +54 -0
- package/dist/lib/capabilities.mjs.map +1 -0
- package/dist/lib/capabilities.node.mjs +30 -0
- package/dist/lib/capabilities.node.mjs.map +1 -0
- package/dist/lib/capabilities.tauri.mjs +54 -0
- package/dist/lib/capabilities.tauri.mjs.map +1 -0
- package/dist/lib/capabilities.workerd.mjs +30 -0
- package/dist/lib/capabilities.workerd.mjs.map +1 -0
- package/dist/lib/chunk-ImportPullRequestDialog.mjs +116 -0
- package/dist/lib/chunk-ImportPullRequestDialog.mjs.map +1 -0
- package/dist/lib/chunk-PLUGIN.mjs +6 -0
- package/dist/lib/chunk-PLUGIN.mjs.map +1 -0
- package/dist/lib/chunk-PullRequestArticle.mjs +1506 -0
- package/dist/lib/chunk-PullRequestArticle.mjs.map +1 -0
- package/dist/lib/chunk-PullRequestArticle2.mjs +2 -0
- package/dist/lib/chunk-PullRequestCardMenu.mjs +123 -0
- package/dist/lib/chunk-PullRequestCardMenu.mjs.map +1 -0
- package/dist/lib/chunk-PullRequestsArticle.mjs +594 -0
- package/dist/lib/chunk-PullRequestsArticle.mjs.map +1 -0
- package/dist/lib/chunk-WalkthroughArticle.mjs +26 -0
- package/dist/lib/chunk-WalkthroughArticle.mjs.map +1 -0
- package/dist/lib/chunk-add-pull-request-comment.mjs +19 -0
- package/dist/lib/chunk-add-pull-request-comment.mjs.map +1 -0
- package/dist/lib/chunk-add-pull-request-review-comment.mjs +25 -0
- package/dist/lib/chunk-add-pull-request-review-comment.mjs.map +1 -0
- package/dist/lib/chunk-app-graph-builder.mjs +39 -0
- package/dist/lib/chunk-app-graph-builder.mjs.map +1 -0
- package/dist/lib/chunk-connector.mjs +70 -0
- package/dist/lib/chunk-connector.mjs.map +1 -0
- package/dist/lib/chunk-constants.mjs +11 -0
- package/dist/lib/chunk-constants.mjs.map +1 -0
- package/dist/lib/chunk-errors.mjs +29 -0
- package/dist/lib/chunk-errors.mjs.map +1 -0
- package/dist/lib/chunk-generate-walkthrough.mjs +86 -0
- package/dist/lib/chunk-generate-walkthrough.mjs.map +1 -0
- package/dist/lib/chunk-generate.mjs +719 -0
- package/dist/lib/chunk-generate.mjs.map +1 -0
- package/dist/lib/chunk-get-pull-request-diff.mjs +25 -0
- package/dist/lib/chunk-get-pull-request-diff.mjs.map +1 -0
- package/dist/lib/chunk-get-pull-request-status.mjs +29 -0
- package/dist/lib/chunk-get-pull-request-status.mjs.map +1 -0
- package/dist/lib/chunk-get-repositories.mjs +32 -0
- package/dist/lib/chunk-get-repositories.mjs.map +1 -0
- package/dist/lib/chunk-github-api.mjs +375 -0
- package/dist/lib/chunk-github-api.mjs.map +1 -0
- package/dist/lib/chunk-github-link.mjs +59 -0
- package/dist/lib/chunk-github-link.mjs.map +1 -0
- package/dist/lib/chunk-import-pull-request-from-snapshot.mjs +25 -0
- package/dist/lib/chunk-import-pull-request-from-snapshot.mjs.map +1 -0
- package/dist/lib/chunk-import-pull-request.mjs +171 -0
- package/dist/lib/chunk-import-pull-request.mjs.map +1 -0
- package/dist/lib/chunk-link-resolver.mjs +109 -0
- package/dist/lib/chunk-link-resolver.mjs.map +1 -0
- package/dist/lib/chunk-markdown-extension.mjs +92 -0
- package/dist/lib/chunk-markdown-extension.mjs.map +1 -0
- package/dist/lib/chunk-materialize-target.mjs +36 -0
- package/dist/lib/chunk-materialize-target.mjs.map +1 -0
- package/dist/lib/chunk-operation-handler.mjs +12 -0
- package/dist/lib/chunk-operation-handler.mjs.map +1 -0
- package/dist/lib/chunk-page-action.mjs +22 -0
- package/dist/lib/chunk-page-action.mjs.map +1 -0
- package/dist/lib/chunk-pull-request.mjs +58 -0
- package/dist/lib/chunk-pull-request.mjs.map +1 -0
- package/dist/lib/chunk-pull-request2.mjs +110 -0
- package/dist/lib/chunk-pull-request2.mjs.map +1 -0
- package/dist/lib/chunk-react-surface.mjs +233 -0
- package/dist/lib/chunk-react-surface.mjs.map +1 -0
- package/dist/lib/chunk-refresh-pull-requests.mjs +49 -0
- package/dist/lib/chunk-refresh-pull-requests.mjs.map +1 -0
- package/dist/lib/chunk-rolldown-runtime.mjs +13 -0
- package/dist/lib/chunk-schema.mjs +15 -0
- package/dist/lib/chunk-schema.mjs.map +1 -0
- package/dist/lib/chunk-skill-definition.mjs +10 -0
- package/dist/lib/chunk-skill-definition.mjs.map +1 -0
- package/dist/lib/chunk-submit-pull-request-approval.mjs +56 -0
- package/dist/lib/chunk-submit-pull-request-approval.mjs.map +1 -0
- package/dist/lib/chunk-sync-pull-request.mjs +26 -0
- package/dist/lib/chunk-sync-pull-request.mjs.map +1 -0
- package/dist/lib/chunk-sync.mjs +481 -0
- package/dist/lib/chunk-sync.mjs.map +1 -0
- package/dist/lib/chunk-useOpenObject.mjs +43 -0
- package/dist/lib/chunk-useOpenObject.mjs.map +1 -0
- package/dist/lib/extensions.mjs +132 -0
- package/dist/lib/extensions.mjs.map +1 -0
- package/dist/lib/index.mjs +4 -0
- package/dist/lib/meta.mjs +47 -0
- package/dist/lib/meta.mjs.map +1 -0
- package/dist/lib/ns/Extensions.mjs +18 -0
- package/dist/lib/ns/Extensions.mjs.map +1 -0
- package/dist/lib/operations.mjs +23 -0
- package/dist/lib/operations.mjs.map +1 -0
- package/dist/lib/plugin.mjs +9 -0
- package/dist/lib/plugin.mjs.map +1 -0
- package/dist/lib/skills.mjs +44 -0
- package/dist/lib/skills.mjs.map +1 -0
- package/dist/lib/testing.mjs +189 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/lib/translations.mjs +136 -0
- package/dist/lib/translations.mjs.map +1 -0
- package/dist/lib/types.mjs +5 -0
- package/dist/types/dx.config.d.ts +28 -0
- package/dist/types/dx.config.d.ts.map +1 -0
- package/dist/types/src/Extensions.d.ts +2 -0
- package/dist/types/src/Extensions.d.ts.map +1 -0
- package/dist/types/src/GitHubPlugin.d.ts +37 -0
- package/dist/types/src/GitHubPlugin.d.ts.map +1 -0
- package/dist/types/src/GitHubPlugin.workerd.test.d.ts +2 -0
- package/dist/types/src/GitHubPlugin.workerd.test.d.ts.map +1 -0
- package/dist/types/src/approval.d.ts +21 -0
- package/dist/types/src/approval.d.ts.map +1 -0
- package/dist/types/src/approval.test.d.ts +2 -0
- package/dist/types/src/approval.test.d.ts.map +1 -0
- package/dist/types/src/capabilities/app-graph-builder.d.ts +9 -0
- package/dist/types/src/capabilities/app-graph-builder.d.ts.map +1 -0
- package/dist/types/src/capabilities/connector.d.ts +17 -0
- package/dist/types/src/capabilities/connector.d.ts.map +1 -0
- package/dist/types/src/capabilities/gen/browser.d.ts +13 -0
- package/dist/types/src/capabilities/gen/browser.d.ts.map +1 -0
- package/dist/types/src/capabilities/gen/node.d.ts +13 -0
- package/dist/types/src/capabilities/gen/node.d.ts.map +1 -0
- package/dist/types/src/capabilities/gen/tauri.d.ts +13 -0
- package/dist/types/src/capabilities/gen/tauri.d.ts.map +1 -0
- package/dist/types/src/capabilities/gen/workerd.d.ts +13 -0
- package/dist/types/src/capabilities/gen/workerd.d.ts.map +1 -0
- package/dist/types/src/capabilities/index.d.ts +13 -0
- package/dist/types/src/capabilities/index.d.ts.map +1 -0
- package/dist/types/src/capabilities/link-resolver.d.ts +10 -0
- package/dist/types/src/capabilities/link-resolver.d.ts.map +1 -0
- package/dist/types/src/capabilities/markdown-extension.d.ts +14 -0
- package/dist/types/src/capabilities/markdown-extension.d.ts.map +1 -0
- package/dist/types/src/capabilities/operation-handler.d.ts +5 -0
- package/dist/types/src/capabilities/operation-handler.d.ts.map +1 -0
- package/dist/types/src/capabilities/page-action.d.ts +5 -0
- package/dist/types/src/capabilities/page-action.d.ts.map +1 -0
- package/dist/types/src/capabilities/react-surface.d.ts +5 -0
- package/dist/types/src/capabilities/react-surface.d.ts.map +1 -0
- package/dist/types/src/capabilities/schema.d.ts +12 -0
- package/dist/types/src/capabilities/schema.d.ts.map +1 -0
- package/dist/types/src/capabilities/skill-definition.d.ts +5 -0
- package/dist/types/src/capabilities/skill-definition.d.ts.map +1 -0
- package/dist/types/src/cards/GitHubCard.d.ts +11 -0
- package/dist/types/src/cards/GitHubCard.d.ts.map +1 -0
- package/dist/types/src/cards/GitHubCard.stories.d.ts +45 -0
- package/dist/types/src/cards/GitHubCard.stories.d.ts.map +1 -0
- package/dist/types/src/cards/PullRequestCard.d.ts +9 -0
- package/dist/types/src/cards/PullRequestCard.d.ts.map +1 -0
- package/dist/types/src/cards/index.d.ts +3 -0
- package/dist/types/src/cards/index.d.ts.map +1 -0
- package/dist/types/src/components/CommentComposer/CommentComposer.d.ts +30 -0
- package/dist/types/src/components/CommentComposer/CommentComposer.d.ts.map +1 -0
- package/dist/types/src/components/CommentComposer/index.d.ts +2 -0
- package/dist/types/src/components/CommentComposer/index.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestFiles/DiffStat.d.ts +8 -0
- package/dist/types/src/components/PullRequestFiles/DiffStat.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestFiles/FileTree.d.ts +17 -0
- package/dist/types/src/components/PullRequestFiles/FileTree.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestFiles/PullRequestFiles.d.ts +23 -0
- package/dist/types/src/components/PullRequestFiles/PullRequestFiles.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestFiles/files.d.ts +39 -0
- package/dist/types/src/components/PullRequestFiles/files.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestFiles/files.test.d.ts +2 -0
- package/dist/types/src/components/PullRequestFiles/files.test.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestFiles/index.d.ts +5 -0
- package/dist/types/src/components/PullRequestFiles/index.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestOverview/ArtifactPill.d.ts +21 -0
- package/dist/types/src/components/PullRequestOverview/ArtifactPill.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestOverview/CheckRunList.d.ts +18 -0
- package/dist/types/src/components/PullRequestOverview/CheckRunList.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestOverview/CheckRunList.test.d.ts +2 -0
- package/dist/types/src/components/PullRequestOverview/CheckRunList.test.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestOverview/PullRequestDetails.d.ts +10 -0
- package/dist/types/src/components/PullRequestOverview/PullRequestDetails.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestOverview/PullRequestOverview.d.ts +16 -0
- package/dist/types/src/components/PullRequestOverview/PullRequestOverview.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestOverview/RelatedCards.d.ts +28 -0
- package/dist/types/src/components/PullRequestOverview/RelatedCards.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestOverview/index.d.ts +6 -0
- package/dist/types/src/components/PullRequestOverview/index.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestStatus/PullRequestStatus.d.ts +20 -0
- package/dist/types/src/components/PullRequestStatus/PullRequestStatus.d.ts.map +1 -0
- package/dist/types/src/components/PullRequestStatus/index.d.ts +2 -0
- package/dist/types/src/components/PullRequestStatus/index.d.ts.map +1 -0
- package/dist/types/src/components/WalkthroughView/WalkthroughView.d.ts +18 -0
- package/dist/types/src/components/WalkthroughView/WalkthroughView.d.ts.map +1 -0
- package/dist/types/src/components/WalkthroughView/extensions.d.ts +16 -0
- package/dist/types/src/components/WalkthroughView/extensions.d.ts.map +1 -0
- package/dist/types/src/components/WalkthroughView/index.d.ts +3 -0
- package/dist/types/src/components/WalkthroughView/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +5 -8
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/constants.d.ts +7 -0
- package/dist/types/src/constants.d.ts.map +1 -0
- package/dist/types/src/containers/ImportPullRequestDialog/ImportPullRequestDialog.d.ts +6 -0
- package/dist/types/src/containers/ImportPullRequestDialog/ImportPullRequestDialog.d.ts.map +1 -0
- package/dist/types/src/containers/ImportPullRequestDialog/index.d.ts +3 -0
- package/dist/types/src/containers/ImportPullRequestDialog/index.d.ts.map +1 -0
- package/dist/types/src/containers/PullRequestArticle/PullRequestArticle.d.ts +15 -0
- package/dist/types/src/containers/PullRequestArticle/PullRequestArticle.d.ts.map +1 -0
- package/dist/types/src/containers/PullRequestArticle/failure.d.ts +10 -0
- package/dist/types/src/containers/PullRequestArticle/failure.d.ts.map +1 -0
- package/dist/types/src/containers/PullRequestArticle/failure.test.d.ts +2 -0
- package/dist/types/src/containers/PullRequestArticle/failure.test.d.ts.map +1 -0
- package/dist/types/src/containers/PullRequestArticle/index.d.ts +2 -0
- package/dist/types/src/containers/PullRequestArticle/index.d.ts.map +1 -0
- package/dist/types/src/containers/PullRequestCardMenu/PullRequestCardMenu.d.ts +14 -0
- package/dist/types/src/containers/PullRequestCardMenu/PullRequestCardMenu.d.ts.map +1 -0
- package/dist/types/src/containers/PullRequestCardMenu/index.d.ts +2 -0
- package/dist/types/src/containers/PullRequestCardMenu/index.d.ts.map +1 -0
- package/dist/types/src/containers/PullRequestsArticle/PullRequestsArticle.d.ts +13 -0
- package/dist/types/src/containers/PullRequestsArticle/PullRequestsArticle.d.ts.map +1 -0
- package/dist/types/src/containers/PullRequestsArticle/arrange.d.ts +44 -0
- package/dist/types/src/containers/PullRequestsArticle/arrange.d.ts.map +1 -0
- package/dist/types/src/containers/PullRequestsArticle/arrange.test.d.ts +2 -0
- package/dist/types/src/containers/PullRequestsArticle/arrange.test.d.ts.map +1 -0
- package/dist/types/src/containers/PullRequestsArticle/format.d.ts +3 -0
- package/dist/types/src/containers/PullRequestsArticle/format.d.ts.map +1 -0
- package/dist/types/src/containers/PullRequestsArticle/index.d.ts +2 -0
- package/dist/types/src/containers/PullRequestsArticle/index.d.ts.map +1 -0
- package/dist/types/src/containers/WalkthroughArticle/WalkthroughArticle.d.ts +13 -0
- package/dist/types/src/containers/WalkthroughArticle/WalkthroughArticle.d.ts.map +1 -0
- package/dist/types/src/containers/WalkthroughArticle/index.d.ts +2 -0
- package/dist/types/src/containers/WalkthroughArticle/index.d.ts.map +1 -0
- package/dist/types/src/containers/index.d.ts +7 -0
- package/dist/types/src/containers/index.d.ts.map +1 -0
- package/dist/types/src/errors.d.ts +157 -0
- package/dist/types/src/errors.d.ts.map +1 -0
- package/dist/types/src/extensions/index.d.ts +3 -0
- package/dist/types/src/extensions/index.d.ts.map +1 -0
- package/dist/types/src/extensions/links.d.ts +21 -0
- package/dist/types/src/extensions/links.d.ts.map +1 -0
- package/dist/types/src/extensions/links.test.d.ts +2 -0
- package/dist/types/src/extensions/links.test.d.ts.map +1 -0
- package/dist/types/src/extensions/references.d.ts +23 -0
- package/dist/types/src/extensions/references.d.ts.map +1 -0
- package/dist/types/src/extensions/references.test.d.ts +2 -0
- package/dist/types/src/extensions/references.test.d.ts.map +1 -0
- package/dist/types/src/github-link.d.ts +29 -0
- package/dist/types/src/github-link.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +5 -2
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useOpenObject.d.ts +9 -0
- package/dist/types/src/hooks/useOpenObject.d.ts.map +1 -0
- package/dist/types/src/hooks/usePullRequestChecks.d.ts +10 -0
- package/dist/types/src/hooks/usePullRequestChecks.d.ts.map +1 -0
- package/dist/types/src/hooks/usePullRequestDiff.d.ts +12 -0
- package/dist/types/src/hooks/usePullRequestDiff.d.ts.map +1 -0
- package/dist/types/src/hooks/usePullRequestFiles.d.ts +22 -0
- package/dist/types/src/hooks/usePullRequestFiles.d.ts.map +1 -0
- package/dist/types/src/hooks/useSyncPullRequest.d.ts +10 -0
- package/dist/types/src/hooks/useSyncPullRequest.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +3 -3
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/meta.d.ts +34 -11
- package/dist/types/src/meta.d.ts.map +1 -1
- package/dist/types/src/operations/add-pull-request-comment.d.ts +5 -0
- package/dist/types/src/operations/add-pull-request-comment.d.ts.map +1 -0
- package/dist/types/src/operations/add-pull-request-review-comment.d.ts +5 -0
- package/dist/types/src/operations/add-pull-request-review-comment.d.ts.map +1 -0
- package/dist/types/src/operations/generate-walkthrough.d.ts +11 -0
- package/dist/types/src/operations/generate-walkthrough.d.ts.map +1 -0
- package/dist/types/src/operations/get-pull-request-diff.d.ts +5 -0
- package/dist/types/src/operations/get-pull-request-diff.d.ts.map +1 -0
- package/dist/types/src/operations/get-pull-request-status.d.ts +5 -0
- package/dist/types/src/operations/get-pull-request-status.d.ts.map +1 -0
- package/dist/types/src/operations/get-repositories.d.ts +13 -0
- package/dist/types/src/operations/get-repositories.d.ts.map +1 -0
- package/dist/types/src/operations/import-pull-request-from-snapshot.d.ts +12 -0
- package/dist/types/src/operations/import-pull-request-from-snapshot.d.ts.map +1 -0
- package/dist/types/src/operations/import-pull-request.d.ts +63 -0
- package/dist/types/src/operations/import-pull-request.d.ts.map +1 -0
- package/dist/types/src/operations/import-pull-request.test.d.ts +2 -0
- package/dist/types/src/operations/import-pull-request.test.d.ts.map +1 -0
- package/dist/types/src/operations/index.d.ts +3 -0
- package/dist/types/src/operations/index.d.ts.map +1 -0
- package/dist/types/src/operations/materialize-target.d.ts +12 -0
- package/dist/types/src/operations/materialize-target.d.ts.map +1 -0
- package/dist/types/src/operations/pull-request.d.ts +48 -0
- package/dist/types/src/operations/pull-request.d.ts.map +1 -0
- package/dist/types/src/operations/pull-request.test.d.ts +2 -0
- package/dist/types/src/operations/pull-request.test.d.ts.map +1 -0
- package/dist/types/src/operations/refresh-pull-requests.d.ts +5 -0
- package/dist/types/src/operations/refresh-pull-requests.d.ts.map +1 -0
- package/dist/types/src/operations/refresh.d.ts +37 -0
- package/dist/types/src/operations/refresh.d.ts.map +1 -0
- package/dist/types/src/operations/refresh.test.d.ts +2 -0
- package/dist/types/src/operations/refresh.test.d.ts.map +1 -0
- package/dist/types/src/operations/submit-pull-request-approval.d.ts +5 -0
- package/dist/types/src/operations/submit-pull-request-approval.d.ts.map +1 -0
- package/dist/types/src/operations/sync-pull-request.d.ts +5 -0
- package/dist/types/src/operations/sync-pull-request.d.ts.map +1 -0
- package/dist/types/src/operations/sync.d.ts +123 -0
- package/dist/types/src/operations/sync.d.ts.map +1 -0
- package/dist/types/src/operations/sync.test.d.ts +2 -0
- package/dist/types/src/operations/sync.test.d.ts.map +1 -0
- package/dist/types/src/plugin.d.ts +4 -0
- package/dist/types/src/plugin.d.ts.map +1 -0
- package/dist/types/src/plugin.test.d.ts +2 -0
- package/dist/types/src/plugin.test.d.ts.map +1 -0
- package/dist/types/src/pull-request-body.d.ts +37 -0
- package/dist/types/src/pull-request-body.d.ts.map +1 -0
- package/dist/types/src/pull-request-body.test.d.ts +2 -0
- package/dist/types/src/pull-request-body.test.d.ts.map +1 -0
- package/dist/types/src/pull-request.d.ts +28 -0
- package/dist/types/src/pull-request.d.ts.map +1 -0
- package/dist/types/src/pull-request.test.d.ts +2 -0
- package/dist/types/src/pull-request.test.d.ts.map +1 -0
- package/dist/types/src/services/github-api.d.ts +332 -0
- package/dist/types/src/services/github-api.d.ts.map +1 -0
- package/dist/types/src/services/index.d.ts +2 -0
- package/dist/types/src/services/index.d.ts.map +1 -0
- package/dist/types/src/skills/GitHubSkill.d.ts +12 -0
- package/dist/types/src/skills/GitHubSkill.d.ts.map +1 -0
- package/dist/types/src/skills/index.d.ts +2 -0
- package/dist/types/src/skills/index.d.ts.map +1 -0
- package/dist/types/src/stories/Generate.stories.d.ts +27 -0
- package/dist/types/src/stories/Generate.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Links.stories.d.ts +23 -0
- package/dist/types/src/stories/Links.stories.d.ts.map +1 -0
- package/dist/types/src/stories/PullRequestArticle.stories.d.ts +359 -0
- package/dist/types/src/stories/PullRequestArticle.stories.d.ts.map +1 -0
- package/dist/types/src/stories/PullRequestFiles.stories.d.ts +365 -0
- package/dist/types/src/stories/PullRequestFiles.stories.d.ts.map +1 -0
- package/dist/types/src/stories/PullRequestOverview.stories.d.ts +367 -0
- package/dist/types/src/stories/PullRequestOverview.stories.d.ts.map +1 -0
- package/dist/types/src/stories/PullRequestsArticle.stories.d.ts +357 -0
- package/dist/types/src/stories/PullRequestsArticle.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Walkthrough.stories.d.ts +380 -0
- package/dist/types/src/stories/Walkthrough.stories.d.ts.map +1 -0
- package/dist/types/src/stories/github.d.ts +23 -0
- package/dist/types/src/stories/github.d.ts.map +1 -0
- package/dist/types/src/stories/github.test.d.ts +2 -0
- package/dist/types/src/stories/github.test.d.ts.map +1 -0
- package/dist/types/src/stories/walkthrough-fixture.d.ts +8 -0
- package/dist/types/src/stories/walkthrough-fixture.d.ts.map +1 -0
- package/dist/types/src/testing/fixtures.d.ts +14 -0
- package/dist/types/src/testing/fixtures.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +4 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/plugin.d.ts +4 -0
- package/dist/types/src/testing/plugin.d.ts.map +1 -0
- package/dist/types/src/testing/pull-request-13348.d.ts +6 -0
- package/dist/types/src/testing/pull-request-13348.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +339 -53
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/types/GitHubCapabilities.d.ts +13 -0
- package/dist/types/src/types/GitHubCapabilities.d.ts.map +1 -0
- package/dist/types/src/types/GitHubEvents.d.ts +8 -0
- package/dist/types/src/types/GitHubEvents.d.ts.map +1 -0
- package/dist/types/src/types/GitHubOperation.d.ts +281 -0
- package/dist/types/src/types/GitHubOperation.d.ts.map +1 -0
- package/dist/types/src/types/PullRequestsView.d.ts +35 -0
- package/dist/types/src/types/PullRequestsView.d.ts.map +1 -0
- package/dist/types/src/types/Walkthrough.d.ts +31 -0
- package/dist/types/src/types/Walkthrough.d.ts.map +1 -0
- package/dist/types/src/types/Walkthrough.test.d.ts +2 -0
- package/dist/types/src/types/Walkthrough.test.d.ts.map +1 -0
- package/dist/types/src/types/index.d.ts +5 -0
- package/dist/types/src/types/index.d.ts.map +1 -0
- package/dist/types/src/walkthrough/fill.d.ts +28 -0
- package/dist/types/src/walkthrough/fill.d.ts.map +1 -0
- package/dist/types/src/walkthrough/fill.test.d.ts +2 -0
- package/dist/types/src/walkthrough/fill.test.d.ts.map +1 -0
- package/dist/types/src/walkthrough/generate.d.ts +67 -0
- package/dist/types/src/walkthrough/generate.d.ts.map +1 -0
- package/dist/types/src/walkthrough/generate.test.d.ts +2 -0
- package/dist/types/src/walkthrough/generate.test.d.ts.map +1 -0
- package/dist/types/src/walkthrough/generated.d.ts +20 -0
- package/dist/types/src/walkthrough/generated.d.ts.map +1 -0
- package/dist/types/src/walkthrough/generated.test.d.ts +2 -0
- package/dist/types/src/walkthrough/generated.test.d.ts.map +1 -0
- package/dist/types/src/walkthrough/headings.d.ts +8 -0
- package/dist/types/src/walkthrough/headings.d.ts.map +1 -0
- package/dist/types/src/walkthrough/headings.test.d.ts +2 -0
- package/dist/types/src/walkthrough/headings.test.d.ts.map +1 -0
- package/dist/types/src/walkthrough/index.d.ts +9 -0
- package/dist/types/src/walkthrough/index.d.ts.map +1 -0
- package/dist/types/src/walkthrough/patch.d.ts +48 -0
- package/dist/types/src/walkthrough/patch.d.ts.map +1 -0
- package/dist/types/src/walkthrough/plan.d.ts +55 -0
- package/dist/types/src/walkthrough/plan.d.ts.map +1 -0
- package/dist/types/src/walkthrough/plan.test.d.ts +2 -0
- package/dist/types/src/walkthrough/plan.test.d.ts.map +1 -0
- package/dist/types/src/walkthrough/prompt.d.ts +33 -0
- package/dist/types/src/walkthrough/prompt.d.ts.map +1 -0
- package/dist/types/src/walkthrough/prompt.test.d.ts +2 -0
- package/dist/types/src/walkthrough/prompt.test.d.ts.map +1 -0
- package/dist/types/src/walkthrough/score.d.ts +43 -0
- package/dist/types/src/walkthrough/score.d.ts.map +1 -0
- package/dist/types/src/walkthrough/score.test.d.ts +2 -0
- package/dist/types/src/walkthrough/score.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/dx.config.ts +39 -0
- package/package.json +167 -48
- package/src/Extensions.ts +7 -0
- package/src/GitHubPlugin.ts +15 -0
- package/src/GitHubPlugin.workerd.test.ts +23 -0
- package/src/approval.test.ts +40 -0
- package/src/approval.ts +39 -0
- package/src/capabilities/app-graph-builder.ts +47 -0
- package/src/capabilities/connector.ts +95 -0
- package/src/capabilities/gen/browser.ts +82 -0
- package/src/capabilities/gen/node.ts +43 -0
- package/src/capabilities/gen/tauri.ts +82 -0
- package/src/capabilities/gen/workerd.ts +43 -0
- package/src/capabilities/index.ts +71 -0
- package/src/capabilities/link-resolver.ts +137 -0
- package/src/capabilities/markdown-extension.ts +146 -0
- package/src/capabilities/operation-handler.ts +16 -0
- package/src/capabilities/page-action.ts +32 -0
- package/src/capabilities/react-surface.ts +98 -0
- package/src/capabilities/schema.ts +16 -0
- package/src/capabilities/skill-definition.ts +14 -0
- package/src/cards/GitHubCard.stories.tsx +165 -0
- package/src/cards/GitHubCard.tsx +93 -0
- package/src/cards/PullRequestCard.tsx +23 -0
- package/src/cards/index.ts +6 -0
- package/src/components/CommentComposer/CommentComposer.tsx +109 -0
- package/src/components/CommentComposer/index.ts +5 -0
- package/src/components/PullRequestFiles/DiffStat.tsx +15 -0
- package/src/components/PullRequestFiles/FileTree.tsx +102 -0
- package/src/components/PullRequestFiles/PullRequestFiles.tsx +87 -0
- package/src/components/PullRequestFiles/files.test.ts +59 -0
- package/src/components/PullRequestFiles/files.ts +115 -0
- package/src/components/PullRequestFiles/index.ts +8 -0
- package/src/components/PullRequestOverview/ArtifactPill.tsx +82 -0
- package/src/components/PullRequestOverview/CheckRunList.test.ts +29 -0
- package/src/components/PullRequestOverview/CheckRunList.tsx +141 -0
- package/src/components/PullRequestOverview/PullRequestDetails.tsx +15 -0
- package/src/components/PullRequestOverview/PullRequestOverview.tsx +110 -0
- package/src/components/PullRequestOverview/RelatedCards.tsx +147 -0
- package/src/components/PullRequestOverview/index.ts +9 -0
- package/src/components/PullRequestStatus/PullRequestStatus.tsx +84 -0
- package/src/components/PullRequestStatus/index.ts +5 -0
- package/src/components/WalkthroughView/WalkthroughView.tsx +62 -0
- package/src/components/WalkthroughView/extensions.ts +53 -0
- package/src/components/WalkthroughView/index.ts +6 -0
- package/src/components/index.ts +6 -9
- package/src/constants.ts +12 -0
- package/src/containers/ImportPullRequestDialog/ImportPullRequestDialog.tsx +160 -0
- package/src/containers/ImportPullRequestDialog/index.ts +7 -0
- package/src/containers/PullRequestArticle/PullRequestArticle.tsx +573 -0
- package/src/containers/PullRequestArticle/failure.test.ts +42 -0
- package/src/containers/PullRequestArticle/failure.ts +16 -0
- package/src/containers/PullRequestArticle/index.ts +5 -0
- package/src/containers/PullRequestCardMenu/PullRequestCardMenu.tsx +134 -0
- package/src/containers/PullRequestCardMenu/index.ts +5 -0
- package/src/containers/PullRequestsArticle/PullRequestsArticle.tsx +361 -0
- package/src/containers/PullRequestsArticle/arrange.test.ts +122 -0
- package/src/containers/PullRequestsArticle/arrange.ts +245 -0
- package/src/containers/PullRequestsArticle/format.ts +31 -0
- package/src/containers/PullRequestsArticle/index.ts +5 -0
- package/src/containers/WalkthroughArticle/WalkthroughArticle.tsx +31 -0
- package/src/containers/WalkthroughArticle/index.ts +5 -0
- package/src/containers/index.ts +11 -0
- package/src/errors.ts +61 -0
- package/src/extensions/index.ts +6 -0
- package/src/extensions/links.test.ts +133 -0
- package/src/extensions/links.ts +91 -0
- package/src/extensions/references.test.ts +83 -0
- package/src/extensions/references.ts +100 -0
- package/src/github-link.ts +70 -0
- package/src/hooks/index.ts +6 -3
- package/src/hooks/useOpenObject.ts +48 -0
- package/src/hooks/usePullRequestChecks.ts +81 -0
- package/src/hooks/usePullRequestDiff.ts +57 -0
- package/src/hooks/usePullRequestFiles.ts +98 -0
- package/src/hooks/useSyncPullRequest.ts +51 -0
- package/src/index.ts +4 -6
- package/src/meta.ts +8 -13
- package/src/operations/add-pull-request-comment.ts +31 -0
- package/src/operations/add-pull-request-review-comment.ts +32 -0
- package/src/operations/generate-walkthrough.ts +124 -0
- package/src/operations/get-pull-request-diff.ts +37 -0
- package/src/operations/get-pull-request-status.ts +44 -0
- package/src/operations/get-repositories.ts +53 -0
- package/src/operations/import-pull-request-from-snapshot.ts +48 -0
- package/src/operations/import-pull-request.test.ts +151 -0
- package/src/operations/import-pull-request.ts +149 -0
- package/src/operations/index.ts +30 -0
- package/src/operations/materialize-target.ts +60 -0
- package/src/operations/pull-request.test.ts +120 -0
- package/src/operations/pull-request.ts +141 -0
- package/src/operations/refresh-pull-requests.ts +69 -0
- package/src/operations/refresh.test.ts +112 -0
- package/src/operations/refresh.ts +111 -0
- package/src/operations/submit-pull-request-approval.ts +41 -0
- package/src/operations/sync-pull-request.ts +34 -0
- package/src/operations/sync.test.ts +453 -0
- package/src/operations/sync.ts +807 -0
- package/src/plugin.test.ts +40 -0
- package/src/plugin.ts +37 -0
- package/src/pull-request-body.test.ts +61 -0
- package/src/pull-request-body.ts +144 -0
- package/src/pull-request.test.ts +67 -0
- package/src/pull-request.ts +80 -0
- package/src/services/github-api.ts +735 -0
- package/src/services/index.ts +5 -0
- package/src/skills/GitHubSkill.ts +46 -0
- package/src/skills/index.ts +5 -0
- package/src/stories/Generate.stories.tsx +152 -0
- package/src/stories/Links.stories.tsx +153 -0
- package/src/stories/PullRequestArticle.stories.tsx +107 -0
- package/src/stories/PullRequestFiles.stories.tsx +69 -0
- package/src/stories/PullRequestOverview.stories.tsx +114 -0
- package/src/stories/PullRequestsArticle.stories.tsx +170 -0
- package/src/stories/Walkthrough.stories.tsx +110 -0
- package/src/stories/github.test.ts +65 -0
- package/src/stories/github.ts +70 -0
- package/src/stories/walkthrough-fixture.ts +115 -0
- package/src/testing/fixtures.ts +99 -0
- package/src/testing/index.ts +7 -0
- package/src/testing/plugin.ts +25 -0
- package/src/testing/pull-request-13348.ts +104 -0
- package/src/testing/pull-request-13363.patch +2981 -0
- package/src/translations.ts +135 -63
- package/src/types/GitHubCapabilities.ts +27 -0
- package/src/types/GitHubEvents.ts +16 -0
- package/src/types/GitHubOperation.ts +397 -0
- package/src/types/PullRequestsView.ts +45 -0
- package/src/types/Walkthrough.test.ts +75 -0
- package/src/types/Walkthrough.ts +66 -0
- package/src/types/index.ts +8 -0
- package/src/vite-env.d.ts +15 -0
- package/src/walkthrough/fill.test.ts +337 -0
- package/src/walkthrough/fill.ts +246 -0
- package/src/walkthrough/generate.test.ts +219 -0
- package/src/walkthrough/generate.ts +248 -0
- package/src/walkthrough/generated.test.ts +70 -0
- package/src/walkthrough/generated.ts +70 -0
- package/src/walkthrough/headings.test.ts +40 -0
- package/src/walkthrough/headings.ts +39 -0
- package/src/walkthrough/index.ts +12 -0
- package/src/walkthrough/patch.ts +154 -0
- package/src/walkthrough/plan.test.ts +178 -0
- package/src/walkthrough/plan.ts +251 -0
- package/src/walkthrough/prompt.test.ts +100 -0
- package/src/walkthrough/prompt.ts +147 -0
- package/src/walkthrough/score.test.ts +272 -0
- package/src/walkthrough/score.ts +430 -0
- package/README.md +0 -15
- package/dist/lib/browser/chunk-S4MPSBA5.mjs +0 -21
- package/dist/lib/browser/chunk-S4MPSBA5.mjs.map +0 -7
- package/dist/lib/browser/index.css +0 -7
- package/dist/lib/browser/index.css.map +0 -7
- package/dist/lib/browser/index.mjs +0 -1535
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/meta.mjs +0 -11
- package/dist/lib/browser/meta.mjs.map +0 -7
- package/dist/types/src/GithubPlugin.d.ts +0 -4
- package/dist/types/src/GithubPlugin.d.ts.map +0 -1
- package/dist/types/src/components/EmbeddedMain/EmbeddedMain.d.ts +0 -4
- package/dist/types/src/components/EmbeddedMain/EmbeddedMain.d.ts.map +0 -1
- package/dist/types/src/components/EmbeddedMain/GfmPreview.d.ts +0 -9
- package/dist/types/src/components/EmbeddedMain/GfmPreview.d.ts.map +0 -1
- package/dist/types/src/components/EmbeddedMain/index.d.ts +0 -2
- package/dist/types/src/components/EmbeddedMain/index.d.ts.map +0 -1
- package/dist/types/src/components/ExportDialog.d.ts +0 -8
- package/dist/types/src/components/ExportDialog.d.ts.map +0 -1
- package/dist/types/src/components/GithubApiProviders/GitHubSettings.d.ts +0 -3
- package/dist/types/src/components/GithubApiProviders/GitHubSettings.d.ts.map +0 -1
- package/dist/types/src/components/GithubApiProviders/OctokitProvider.d.ts +0 -15
- package/dist/types/src/components/GithubApiProviders/OctokitProvider.d.ts.map +0 -1
- package/dist/types/src/components/GithubApiProviders/index.d.ts +0 -3
- package/dist/types/src/components/GithubApiProviders/index.d.ts.map +0 -1
- package/dist/types/src/components/GithubEchoResolverProviders/DocumentTreeItem.d.ts +0 -6
- package/dist/types/src/components/GithubEchoResolverProviders/DocumentTreeItem.d.ts.map +0 -1
- package/dist/types/src/components/GithubEchoResolverProviders/ResolverContext.d.ts +0 -7
- package/dist/types/src/components/GithubEchoResolverProviders/ResolverContext.d.ts.map +0 -1
- package/dist/types/src/components/GithubEchoResolverProviders/ResolverDialog.d.ts +0 -6
- package/dist/types/src/components/GithubEchoResolverProviders/ResolverDialog.d.ts.map +0 -1
- package/dist/types/src/components/GithubEchoResolverProviders/ResolverProps.d.ts +0 -16
- package/dist/types/src/components/GithubEchoResolverProviders/ResolverProps.d.ts.map +0 -1
- package/dist/types/src/components/GithubEchoResolverProviders/ResolverTree.d.ts +0 -3
- package/dist/types/src/components/GithubEchoResolverProviders/ResolverTree.d.ts.map +0 -1
- package/dist/types/src/components/GithubEchoResolverProviders/SpacePickerTreeItem.d.ts +0 -11
- package/dist/types/src/components/GithubEchoResolverProviders/SpacePickerTreeItem.d.ts.map +0 -1
- package/dist/types/src/components/GithubEchoResolverProviders/index.d.ts +0 -3
- package/dist/types/src/components/GithubEchoResolverProviders/index.d.ts.map +0 -1
- package/dist/types/src/components/GithubEchoResolverProviders/spaceResolvers.d.ts +0 -6
- package/dist/types/src/components/GithubEchoResolverProviders/spaceResolvers.d.ts.map +0 -1
- package/dist/types/src/components/ImportDialog.d.ts +0 -7
- package/dist/types/src/components/ImportDialog.d.ts.map +0 -1
- package/dist/types/src/components/Issue.d.ts +0 -5
- package/dist/types/src/components/Issue.d.ts.map +0 -1
- package/dist/types/src/components/Loading/Loading.d.ts +0 -15
- package/dist/types/src/components/Loading/Loading.d.ts.map +0 -1
- package/dist/types/src/components/Loading/Loading.stories.d.ts +0 -40
- package/dist/types/src/components/Loading/Loading.stories.d.ts.map +0 -1
- package/dist/types/src/components/Loading/index.d.ts +0 -2
- package/dist/types/src/components/Loading/index.d.ts.map +0 -1
- package/dist/types/src/components/MarkdownActions.d.ts +0 -7
- package/dist/types/src/components/MarkdownActions.d.ts.map +0 -1
- package/dist/types/src/components/UrlDialog.d.ts +0 -6
- package/dist/types/src/components/UrlDialog.d.ts.map +0 -1
- package/dist/types/src/hooks/useDocGhId.d.ts +0 -6
- package/dist/types/src/hooks/useDocGhId.d.ts.map +0 -1
- package/dist/types/src/hooks/useGhIdFromUrl.d.ts +0 -2
- package/dist/types/src/hooks/useGhIdFromUrl.d.ts.map +0 -1
- package/dist/types/src/types.d.ts +0 -30
- package/dist/types/src/types.d.ts.map +0 -1
- package/src/GithubPlugin.tsx +0 -127
- package/src/components/EmbeddedMain/EmbeddedMain.tsx +0 -294
- package/src/components/EmbeddedMain/GfmPreview.tsx +0 -110
- package/src/components/EmbeddedMain/embedded.css +0 -3
- package/src/components/EmbeddedMain/index.ts +0 -5
- package/src/components/ExportDialog.tsx +0 -157
- package/src/components/GithubApiProviders/GitHubSettings.tsx +0 -72
- package/src/components/GithubApiProviders/OctokitProvider.tsx +0 -60
- package/src/components/GithubApiProviders/index.ts +0 -6
- package/src/components/GithubEchoResolverProviders/DocumentTreeItem.tsx +0 -26
- package/src/components/GithubEchoResolverProviders/ResolverContext.tsx +0 -137
- package/src/components/GithubEchoResolverProviders/ResolverDialog.tsx +0 -34
- package/src/components/GithubEchoResolverProviders/ResolverProps.ts +0 -23
- package/src/components/GithubEchoResolverProviders/ResolverTree.tsx +0 -86
- package/src/components/GithubEchoResolverProviders/SpacePickerTreeItem.tsx +0 -105
- package/src/components/GithubEchoResolverProviders/index.ts +0 -6
- package/src/components/GithubEchoResolverProviders/spaceResolvers.ts +0 -86
- package/src/components/ImportDialog.tsx +0 -72
- package/src/components/Issue.tsx +0 -62
- package/src/components/Loading/Loading.stories.tsx +0 -32
- package/src/components/Loading/Loading.tsx +0 -70
- package/src/components/Loading/index.ts +0 -5
- package/src/components/MarkdownActions.tsx +0 -136
- package/src/components/UrlDialog.tsx +0 -52
- package/src/hooks/useDocGhId.ts +0 -38
- package/src/hooks/useGhIdFromUrl.ts +0 -26
- package/src/types.ts +0 -51
|
@@ -0,0 +1,2981 @@
|
|
|
1
|
+
diff --git a/.changeset/filter-changes.md b/.changeset/filter-changes.md
|
|
2
|
+
new file mode 100644
|
|
3
|
+
index 00000000..906f373a
|
|
4
|
+
--- /dev/null
|
|
5
|
+
+++ b/.changeset/filter-changes.md
|
|
6
|
+
@@ -0,0 +1,5 @@
|
|
7
|
+
+---
|
|
8
|
+
+'@dxos/echo': minor
|
|
9
|
+
+---
|
|
10
|
+
+
|
|
11
|
+
+`Filter.changes(targets?)` selects Automerge changes instead of objects. Each result is a frozen `Change.Change` record (`key`, `source`, `time`, `actor`, `seq`, `ops`), not an entity. A space-wide query must aggregate with `Aggregate.time('time', …)`, `Aggregate.group('source')`, `Aggregate.count()` and `Aggregate.sum('ops')`. The host answers those from hourly buckets it keeps per document, which survive garbage collection. With targets, any shape works, including a plain ordered history: the host replays the documents holding those entities. Two new aggregates work on any type, and the SQL executor compiles both: `Aggregate.sum(property)` and `Aggregate.time(property, unit)`. Change queries always run on their own executor, since changes are not stored in SQLite. **Breaking:** the `timeZone` option is removed from `Aggregate.updated` and `Aggregate.created`; every hour and day group is UTC. To show local days, group by `'hour'` and roll the hours into days in the caller, as the space heatmap now does. `Query.AggregateResult` is renamed to `Query.RecordResult`. The space home heatmap now counts every change on the day it was made, so objects edited on several days, or since deleted, show on each of those days.
|
|
12
|
+
diff --git a/packages/core/echo/echo-client-e2e/src/query.test.ts b/packages/core/echo/echo-client-e2e/src/query.test.ts
|
|
13
|
+
index b6fa59d8..80d7545f 100644
|
|
14
|
+
--- a/packages/core/echo/echo-client-e2e/src/query.test.ts
|
|
15
|
+
+++ b/packages/core/echo/echo-client-e2e/src/query.test.ts
|
|
16
|
+
@@ -377,6 +377,117 @@ describe('Query', () => {
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
+ describe.each(['memory', 'sql'] as const)('changes (%s executor)', (queryExecutor) => {
|
|
21
|
+
+ const createDatabase = async () => (await builder.createPeer({ queryExecutor })).createDatabase();
|
|
22
|
+
+ const total = (rows: readonly { changes: number }[]) => rows.reduce((sum, row) => sum + row.changes, 0);
|
|
23
|
+
+
|
|
24
|
+
+ test('a space-wide count by day follows new edits', async () => {
|
|
25
|
+
+ const db = await createDatabase();
|
|
26
|
+
+ const object = db.add(Obj.make(TestSchema.Expando, { value: 1 }));
|
|
27
|
+
+ await db.flush({ indexes: true });
|
|
28
|
+
+
|
|
29
|
+
+ let rows: readonly { day: number | null; changes: number; ops: number }[] = [];
|
|
30
|
+
+ const unsubscribe = db
|
|
31
|
+
+ .query(
|
|
32
|
+
+ Query.select(Filter.changes()).aggregate({
|
|
33
|
+
+ day: Aggregate.time('time', 'day'),
|
|
34
|
+
+ changes: Aggregate.count(),
|
|
35
|
+
+ ops: Aggregate.sum('ops'),
|
|
36
|
+
+ }),
|
|
37
|
+
+ )
|
|
38
|
+
+ .subscribe((result) => {
|
|
39
|
+
+ rows = result.results;
|
|
40
|
+
+ });
|
|
41
|
+
+ onTestFinished(unsubscribe);
|
|
42
|
+
+
|
|
43
|
+
+ await waitForCondition({ condition: () => total(rows) > 0, timeout: 5000 });
|
|
44
|
+
+ const before = total(rows);
|
|
45
|
+
+ expect(rows.every((row) => row.ops >= row.changes)).to.be.true;
|
|
46
|
+
+
|
|
47
|
+
+ Obj.update(object, (object) => {
|
|
48
|
+
+ object.value = 2;
|
|
49
|
+
+ });
|
|
50
|
+
+ await db.flush({ indexes: true });
|
|
51
|
+
+ await waitForCondition({ condition: () => total(rows) > before, timeout: 5000 });
|
|
52
|
+
+ });
|
|
53
|
+
+
|
|
54
|
+
+ test("an object's history comes back as frozen change records, newest first", async () => {
|
|
55
|
+
+ const db = await createDatabase();
|
|
56
|
+
+ const object = db.add(Obj.make(TestSchema.Expando, { value: 1 }));
|
|
57
|
+
+ for (const value of [2, 3]) {
|
|
58
|
+
+ Obj.update(object, (object) => {
|
|
59
|
+
+ object.value = value;
|
|
60
|
+
+ });
|
|
61
|
+
+ }
|
|
62
|
+
+ await db.flush({ indexes: true });
|
|
63
|
+
+
|
|
64
|
+
+ const changes = await db
|
|
65
|
+
+ .query(Query.select(Filter.changes(object)).orderBy(Order.property('time', 'desc')).limit(2))
|
|
66
|
+
+ .run();
|
|
67
|
+
+
|
|
68
|
+
+ expect(changes).to.have.length(2);
|
|
69
|
+
+ expect(changes[0].time).to.be.greaterThanOrEqual(changes[1].time);
|
|
70
|
+
+ for (const change of changes) {
|
|
71
|
+
+ expect(change).to.include({ source: 'document' });
|
|
72
|
+
+ expect(change).to.include.keys('key', 'time', 'actor', 'seq', 'ops');
|
|
73
|
+
+ expect(Object.isFrozen(change)).to.be.true;
|
|
74
|
+
+ expect(Reflect.set(change, 'ops', 0)).to.be.false;
|
|
75
|
+
+ }
|
|
76
|
+
+ });
|
|
77
|
+
+
|
|
78
|
+
+ test("the index and a replay agree on an object's changes", async () => {
|
|
79
|
+
+ const db = await createDatabase();
|
|
80
|
+
+ const object = db.add(Obj.make(TestSchema.Expando, { value: 1 }));
|
|
81
|
+
+ Obj.update(object, (object) => {
|
|
82
|
+
+ object.value = 2;
|
|
83
|
+
+ });
|
|
84
|
+
+ await db.flush({ indexes: true });
|
|
85
|
+
+
|
|
86
|
+
+ const [indexed, replayed] = await Promise.all([
|
|
87
|
+
+ db
|
|
88
|
+
+ .query(
|
|
89
|
+
+ Query.select(Filter.changes(object)).aggregate({ changes: Aggregate.count(), ops: Aggregate.sum('ops') }),
|
|
90
|
+
+ )
|
|
91
|
+
+ .run(),
|
|
92
|
+
+ db
|
|
93
|
+
+ .query(
|
|
94
|
+
+ Query.select(Filter.changes(object)).aggregate({
|
|
95
|
+
+ actor: Aggregate.group('actor'),
|
|
96
|
+
+ changes: Aggregate.count(),
|
|
97
|
+
+ ops: Aggregate.sum('ops'),
|
|
98
|
+
+ }),
|
|
99
|
+
+ )
|
|
100
|
+
+ .run(),
|
|
101
|
+
+ ]);
|
|
102
|
+
+
|
|
103
|
+
+ expect(total(indexed)).to.be.greaterThan(0);
|
|
104
|
+
+ expect(total(replayed)).to.equal(total(indexed));
|
|
105
|
+
+ expect(replayed.reduce((sum, row) => sum + row.ops, 0)).to.equal(indexed[0].ops);
|
|
106
|
+
+ });
|
|
107
|
+
+
|
|
108
|
+
+ test('sums and time buckets apply to ordinary objects', async () => {
|
|
109
|
+
+ const db = await createDatabase();
|
|
110
|
+
+ const day = Date.UTC(2026, 0, 2);
|
|
111
|
+
+ db.add(Obj.make(TestSchema.Expando, { at: day + 1_000, amount: 2 }));
|
|
112
|
+
+ db.add(Obj.make(TestSchema.Expando, { at: day + 2_000, amount: 3 }));
|
|
113
|
+
+ db.add(Obj.make(TestSchema.Expando, { at: day - 1_000, amount: 7 }));
|
|
114
|
+
+ await db.flush({ indexes: true });
|
|
115
|
+
+
|
|
116
|
+
+ const rows = await db
|
|
117
|
+
+ .query(
|
|
118
|
+
+ Query.select(Filter.type(TestSchema.Expando))
|
|
119
|
+
+ .aggregate({ day: Aggregate.time('at', 'day'), amount: Aggregate.sum('amount') })
|
|
120
|
+
+ .orderBy(Order.property('day', 'asc')),
|
|
121
|
+
+ )
|
|
122
|
+
+ .run();
|
|
123
|
+
+
|
|
124
|
+
+ expect(rows.map(({ day, amount }) => ({ day, amount }))).to.deep.equal([
|
|
125
|
+
+ { day: day - 86_400_000, amount: 7 },
|
|
126
|
+
+ { day, amount: 5 },
|
|
127
|
+
+ ]);
|
|
128
|
+
+ });
|
|
129
|
+
+ });
|
|
130
|
+
+
|
|
131
|
+
describe('aggregate', () => {
|
|
132
|
+
test('groups by a single property, with per-group counts', async () => {
|
|
133
|
+
const { db } = await builder.createDatabase();
|
|
134
|
+
diff --git a/packages/core/echo/echo-client/src/client/index-query-source-provider.ts b/packages/core/echo/echo-client/src/client/index-query-source-provider.ts
|
|
135
|
+
index ca57f98e..e2577852 100644
|
|
136
|
+
--- a/packages/core/echo/echo-client/src/client/index-query-source-provider.ts
|
|
137
|
+
+++ b/packages/core/echo/echo-client/src/client/index-query-source-provider.ts
|
|
138
|
+
@@ -571,6 +571,15 @@ export class IndexQuerySource implements QuerySource {
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
+ if (result.recordJson !== undefined) {
|
|
143
|
+
+ return {
|
|
144
|
+
+ id: result.id,
|
|
145
|
+
+ match: { rank: result.rank },
|
|
146
|
+
+ resolution: { source: 'index', time: Date.now() - queryStartTimestamp },
|
|
147
|
+
+ record: Object.freeze(JSON.parse(result.recordJson)),
|
|
148
|
+
+ };
|
|
149
|
+
+ }
|
|
150
|
+
+
|
|
151
|
+
recordObjectDiagnostic(result.id, () => ({
|
|
152
|
+
objectId: result.id,
|
|
153
|
+
spaceId: result.spaceId,
|
|
154
|
+
diff --git a/packages/core/echo/echo-client/src/query/graph-query-context.ts b/packages/core/echo/echo-client/src/query/graph-query-context.ts
|
|
155
|
+
index c80db4d0..5c52efd2 100644
|
|
156
|
+
--- a/packages/core/echo/echo-client/src/query/graph-query-context.ts
|
|
157
|
+
+++ b/packages/core/echo/echo-client/src/query/graph-query-context.ts
|
|
158
|
+
@@ -6,7 +6,7 @@ import { Event, asyncTimeout } from '@dxos/async';
|
|
159
|
+
import { Context } from '@dxos/context';
|
|
160
|
+
import { Obj, Query, type QueryResult } from '@dxos/echo';
|
|
161
|
+
import { filterMatchDoc } from '@dxos/echo-host/filter';
|
|
162
|
+
-import { GroupBy, QueryPlanner } from '@dxos/echo-host/query';
|
|
163
|
+
+import { GroupBy, QueryPlanner, queryContainsChanges } from '@dxos/echo-host/query';
|
|
164
|
+
import { QueryAST } from '@dxos/echo-protocol';
|
|
165
|
+
import { type SpaceId } from '@dxos/keys';
|
|
166
|
+
import { log } from '@dxos/log';
|
|
167
|
+
@@ -403,6 +403,10 @@ export class SpaceQuerySource implements QuerySource {
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private _isValidSourceForQuery(query: QueryAST.Query): boolean {
|
|
171
|
+
+ if (queryContainsChanges(query)) {
|
|
172
|
+
+ return false;
|
|
173
|
+
+ }
|
|
174
|
+
+
|
|
175
|
+
const targetSpaces = getTargetSpacesForQuery(query);
|
|
176
|
+
// Disabled by spaces filter.
|
|
177
|
+
if (targetSpaces.length > 0 && !targetSpaces.includes(this.spaceId)) {
|
|
178
|
+
diff --git a/packages/core/echo/echo-client/src/query/query-context.ts b/packages/core/echo/echo-client/src/query/query-context.ts
|
|
179
|
+
index 77149a52..686f9bf7 100644
|
|
180
|
+
--- a/packages/core/echo/echo-client/src/query/query-context.ts
|
|
181
|
+
+++ b/packages/core/echo/echo-client/src/query/query-context.ts
|
|
182
|
+
@@ -28,6 +28,7 @@ export type EntryGroup = {
|
|
183
|
+
*/
|
|
184
|
+
export type SourceEntry<O extends Entity.Unknown = Entity.Unknown> = QueryResult.EntityEntry<O> & {
|
|
185
|
+
group?: EntryGroup;
|
|
186
|
+
+ record?: Readonly<Record<string, unknown>>;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export interface QueryContext<T extends AnyProperties = AnyProperties, O extends Entity.Entity<T> = Entity.Entity<T>> {
|
|
190
|
+
diff --git a/packages/core/echo/echo-client/src/query/query-result.ts b/packages/core/echo/echo-client/src/query/query-result.ts
|
|
191
|
+
index 411097c1..85c965d0 100644
|
|
192
|
+
--- a/packages/core/echo/echo-client/src/query/query-result.ts
|
|
193
|
+
+++ b/packages/core/echo/echo-client/src/query/query-result.ts
|
|
194
|
+
@@ -219,25 +219,30 @@ export class QueryResultImpl<T extends Entity.Unknown = Entity.Unknown> implemen
|
|
195
|
+
const results = this._queryContext.getResults();
|
|
196
|
+
const presented = this._presentResults(results);
|
|
197
|
+
|
|
198
|
+
- const changed = presented.grouped
|
|
199
|
+
- ? // Same T-is-erased-Group boundary as `_presentResults` — `_objectCache`/`presented.objects`
|
|
200
|
+
- // are really `GroupResult[]` here, just typed as `T[]` at this generic class's surface.
|
|
201
|
+
- !_groupsEqual(
|
|
202
|
+
- this._objectCache as unknown as GroupResult[] | undefined,
|
|
203
|
+
- presented.objects as unknown as GroupResult[],
|
|
204
|
+
- )
|
|
205
|
+
- : !this._objectCache ||
|
|
206
|
+
- this._objectCache.length !== presented.objects.length ||
|
|
207
|
+
- this._objectCache.some((obj, index) => obj.id !== presented.objects[index].id);
|
|
208
|
+
+ const changed =
|
|
209
|
+
+ presented.kind === 'groups'
|
|
210
|
+
+ ? // Same T-is-erased-Group boundary as `_presentResults` — `_objectCache`/`presented.objects`
|
|
211
|
+
+ // are really `GroupResult[]` here, just typed as `T[]` at this generic class's surface.
|
|
212
|
+
+ !_groupsEqual(
|
|
213
|
+
+ this._objectCache as unknown as GroupResult[] | undefined,
|
|
214
|
+
+ presented.objects as unknown as GroupResult[],
|
|
215
|
+
+ )
|
|
216
|
+
+ : presented.kind === 'records'
|
|
217
|
+
+ ? !this._resultCache ||
|
|
218
|
+
+ this._resultCache.length !== presented.entries.length ||
|
|
219
|
+
+ this._resultCache.some((entry, index) => entry.id !== presented.entries[index].id)
|
|
220
|
+
+ : !this._objectCache ||
|
|
221
|
+
+ this._objectCache.length !== presented.objects.length ||
|
|
222
|
+
+ this._objectCache.some((obj, index) => obj.id !== presented.objects[index].id);
|
|
223
|
+
|
|
224
|
+
log('recomputeResult', { changed });
|
|
225
|
+
|
|
226
|
+
- // An aggregate query assembles its group records fresh on every recompute, so an unchanged result
|
|
227
|
+
+ // An aggregate or change query assembles its records fresh on every recompute, so an unchanged result
|
|
228
|
+
// still yields a new array — and `useQuery` reads `results` as its `useSyncExternalStore` snapshot
|
|
229
|
+
// on every render, which would then see a new reference for identical data. Hold the previous
|
|
230
|
+
// arrays in that case only: on the flat path `changed` compares ids and order alone, so pinning
|
|
231
|
+
// would serve stale entities and stale per-row match metadata.
|
|
232
|
+
- if (!presented.grouped || changed) {
|
|
233
|
+
+ if (presented.kind === 'entities' || changed) {
|
|
234
|
+
this._resultCache = presented.entries;
|
|
235
|
+
this._objectCache = presented.objects;
|
|
236
|
+
}
|
|
237
|
+
@@ -250,9 +255,9 @@ export class QueryResultImpl<T extends Entity.Unknown = Entity.Unknown> implemen
|
|
238
|
+
* uniformly across all entries or none), assembles flat aggregate records instead of deduped rows.
|
|
239
|
+
*/
|
|
240
|
+
private _presentResults(entries: SourceEntry<T>[]): {
|
|
241
|
+
+ kind: 'entities' | 'groups' | 'records';
|
|
242
|
+
objects: T[];
|
|
243
|
+
entries: QueryResult.EntityEntry<T>[];
|
|
244
|
+
- grouped: boolean;
|
|
245
|
+
} {
|
|
246
|
+
const { kept, removed } = this._collapseDuplicates(entries);
|
|
247
|
+
entries = kept;
|
|
248
|
+
@@ -263,17 +268,18 @@ export class QueryResultImpl<T extends Entity.Unknown = Entity.Unknown> implemen
|
|
249
|
+
_groupAggregatesFromQuery(this._query.ast),
|
|
250
|
+
removed,
|
|
251
|
+
);
|
|
252
|
+
- // Boundary cast: T is the flat aggregate record for aggregate queries (per Query.aggregate's
|
|
253
|
+
- // return type), but this class is written generically over the row type — aggregation is a
|
|
254
|
+
- // presentation transform applied on top of row-level entries, with the row type erased at runtime.
|
|
255
|
+
return {
|
|
256
|
+
- objects: groups as unknown as T[],
|
|
257
|
+
+ kind: 'groups',
|
|
258
|
+
+ objects: _asResultRows<T>(groups),
|
|
259
|
+
entries: groupEntries as unknown as QueryResult.EntityEntry<T>[],
|
|
260
|
+
- grouped: true,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
- return { objects: this._uniqueObjects(entries), entries, grouped: false };
|
|
265
|
+
+ if (entries.length > 0 && entries[0].record !== undefined) {
|
|
266
|
+
+ return { kind: 'records', objects: _asResultRows<T>(entries.map((entry) => entry.record)), entries };
|
|
267
|
+
+ }
|
|
268
|
+
+
|
|
269
|
+
+ return { kind: 'entities', objects: this._uniqueObjects(entries), entries };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
@@ -361,6 +367,13 @@ export class QueryResultImpl<T extends Entity.Unknown = Entity.Unknown> implemen
|
|
274
|
+
*/
|
|
275
|
+
type GroupResult = { [field: string]: unknown };
|
|
276
|
+
|
|
277
|
+
+/**
|
|
278
|
+
+ * Boundary cast: T is a plain record for aggregate and change queries (`Query.RecordResult`), but
|
|
279
|
+
+ * this class is written generically over the entity type — records are a presentation transform
|
|
280
|
+
+ * applied on top of row-level entries, with the row type erased at runtime.
|
|
281
|
+
+ */
|
|
282
|
+
+const _asResultRows = <T>(rows: readonly unknown[]): T[] => rows as unknown as T[];
|
|
283
|
+
+
|
|
284
|
+
/**
|
|
285
|
+
* Buckets flat row-level entries into flat aggregate records, in the order groups first appear in
|
|
286
|
+
* `entries`. The host/local query sources already deliver an aggregate query's entries with groups
|
|
287
|
+
@@ -457,7 +470,10 @@ const _computeAggregate = (aggregate: QueryAST.GroupAggregate, members: readonly
|
|
288
|
+
case 'group':
|
|
289
|
+
case 'type':
|
|
290
|
+
case 'timestamp':
|
|
291
|
+
+ case 'time':
|
|
292
|
+
return undefined; // Group-key fields are assembled from the source key, not here.
|
|
293
|
+
+ case 'sum':
|
|
294
|
+
+ return GroupBy.sum(members.map((value) => getDeep(value as Record<string, unknown>, [aggregate.property])));
|
|
295
|
+
case 'items':
|
|
296
|
+
return aggregate.limit !== undefined ? members.slice(0, aggregate.limit) : members;
|
|
297
|
+
case 'count':
|
|
298
|
+
diff --git a/packages/core/echo/echo-client/src/query/working-set-executor.ts b/packages/core/echo/echo-client/src/query/working-set-executor.ts
|
|
299
|
+
index bbd8fc5e..0edff9fb 100644
|
|
300
|
+
--- a/packages/core/echo/echo-client/src/query/working-set-executor.ts
|
|
301
|
+
+++ b/packages/core/echo/echo-client/src/query/working-set-executor.ts
|
|
302
|
+
@@ -77,6 +77,12 @@ const WorkingSetItem = Object.freeze({
|
|
303
|
+
// A core carries no index timestamps; `tryExecute` already declines these plans.
|
|
304
|
+
key[aggregate.name] = null;
|
|
305
|
+
break;
|
|
306
|
+
+ case 'time':
|
|
307
|
+
+ key[aggregate.name] = GroupBy.truncateTime(
|
|
308
|
+
+ WorkingSetItem.getAggregateProperty(item, aggregate.property),
|
|
309
|
+
+ aggregate.unit,
|
|
310
|
+
+ );
|
|
311
|
+
+ break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return key;
|
|
315
|
+
diff --git a/packages/core/echo/echo-host/src/db-host/automerge-data-source.test.ts b/packages/core/echo/echo-host/src/db-host/automerge-data-source.test.ts
|
|
316
|
+
index 160601b0..36fcfba2 100644
|
|
317
|
+
--- a/packages/core/echo/echo-host/src/db-host/automerge-data-source.test.ts
|
|
318
|
+
+++ b/packages/core/echo/echo-host/src/db-host/automerge-data-source.test.ts
|
|
319
|
+
@@ -327,4 +327,106 @@ describe('AutomergeDataSource', () => {
|
|
320
|
+
expect(result.objects).toHaveLength(1);
|
|
321
|
+
expect(result.objects[0].spaceId).toBe(spaceId);
|
|
322
|
+
});
|
|
323
|
+
+
|
|
324
|
+
+ test('a late-arriving old change lands in its own hour and is counted once', async () => {
|
|
325
|
+
+ const host = await setupAutomergeHost();
|
|
326
|
+
+ const spaceId = SpaceId.random();
|
|
327
|
+
+
|
|
328
|
+
+ const handle = await createDatabaseDirectory(host, spaceId, {
|
|
329
|
+
+ 'obj-1': EntityStructure.makeObject({ type: TEST_TYPE, data: { title: 'Original' } }),
|
|
330
|
+
+ });
|
|
331
|
+
+ await host.flush(Context.default());
|
|
332
|
+
+
|
|
333
|
+
+ const dataSource = new AutomergeDataSource(host);
|
|
334
|
+
+
|
|
335
|
+
+ const firstResult = await EffectEx.runAndForwardErrors(
|
|
336
|
+
+ dataSource.getChangedObjects(Context.default(), [], { activity: true }),
|
|
337
|
+
+ );
|
|
338
|
+
+ expect(firstResult.activity).toEqual([expect.objectContaining({ documentId: handle.documentId, full: true })]);
|
|
339
|
+
+ const firstChanges = firstResult.activity![0].changes;
|
|
340
|
+
+ expect(firstChanges.length).toBeGreaterThan(0);
|
|
341
|
+
+ const now = Date.now();
|
|
342
|
+
+ for (const change of firstChanges) {
|
|
343
|
+
+ expect(Math.abs(change.time - now)).toBeLessThan(5_000);
|
|
344
|
+
+ }
|
|
345
|
+
+ const firstCursor = firstResult.cursors[0];
|
|
346
|
+
+
|
|
347
|
+
+ const OLD_MS = Date.now() - 30 * 24 * 3_600_000;
|
|
348
|
+
+ const OLD_S = Math.floor(OLD_MS / 1000);
|
|
349
|
+
+ handle.change(
|
|
350
|
+
+ (doc: DatabaseDirectory) => {
|
|
351
|
+
+ // A string field is spliced character-by-character in this Automerge build and would not exercise `ops === 1`.
|
|
352
|
+
+ doc.objects!['obj-1'].data.count = 42;
|
|
353
|
+
+ },
|
|
354
|
+
+ { time: OLD_S },
|
|
355
|
+
+ );
|
|
356
|
+
+ await host.flush(Context.default());
|
|
357
|
+
+
|
|
358
|
+
+ const cursorsAfterFirstCall: IndexCursor[] = [
|
|
359
|
+
+ {
|
|
360
|
+
+ indexName: 'activity',
|
|
361
|
+
+ spaceId: null,
|
|
362
|
+
+ sourceName: 'automerge',
|
|
363
|
+
+ resourceId: handle.documentId,
|
|
364
|
+
+ cursor: firstCursor.cursor,
|
|
365
|
+
+ },
|
|
366
|
+
+ ];
|
|
367
|
+
+ const secondResult = await EffectEx.runAndForwardErrors(
|
|
368
|
+
+ dataSource.getChangedObjects(Context.default(), cursorsAfterFirstCall, { activity: true }),
|
|
369
|
+
+ );
|
|
370
|
+
+ expect(secondResult.activity).toEqual([
|
|
371
|
+
+ { spaceId, documentId: handle.documentId, full: false, changes: [{ time: OLD_S * 1000, ops: 1 }] },
|
|
372
|
+
+ ]);
|
|
373
|
+
+ const secondCursor = secondResult.cursors[0];
|
|
374
|
+
+
|
|
375
|
+
+ const cursorsAfterSecondCall: IndexCursor[] = [
|
|
376
|
+
+ {
|
|
377
|
+
+ indexName: 'activity',
|
|
378
|
+
+ spaceId: null,
|
|
379
|
+
+ sourceName: 'automerge',
|
|
380
|
+
+ resourceId: handle.documentId,
|
|
381
|
+
+ cursor: secondCursor.cursor,
|
|
382
|
+
+ },
|
|
383
|
+
+ ];
|
|
384
|
+
+ const thirdResult = await EffectEx.runAndForwardErrors(
|
|
385
|
+
+ dataSource.getChangedObjects(Context.default(), cursorsAfterSecondCall, { activity: true }),
|
|
386
|
+
+ );
|
|
387
|
+
+ expect(thirdResult.activity).toEqual([]);
|
|
388
|
+
+ });
|
|
389
|
+
+
|
|
390
|
+
+ test('a branch document counted before its root was read is discarded once the root names it', async () => {
|
|
391
|
+
+ const host = await setupAutomergeHost();
|
|
392
|
+
+ const spaceId = SpaceId.random();
|
|
393
|
+
+ const dataSource = new AutomergeDataSource(host);
|
|
394
|
+
+ const toCursors = (result: { cursors: { resourceId: string | null; cursor: string | number }[] }): IndexCursor[] =>
|
|
395
|
+
+ result.cursors.map(({ resourceId, cursor }) => ({
|
|
396
|
+
+ indexName: 'activity',
|
|
397
|
+
+ spaceId: null,
|
|
398
|
+
+ sourceName: 'automerge',
|
|
399
|
+
+ resourceId,
|
|
400
|
+
+ cursor,
|
|
401
|
+
+ }));
|
|
402
|
+
+
|
|
403
|
+
+ const branch = await createDatabaseDirectory(host, spaceId, {
|
|
404
|
+
+ 'obj-1': EntityStructure.makeObject({ type: TEST_TYPE, data: { title: 'On a branch' } }),
|
|
405
|
+
+ });
|
|
406
|
+
+ await host.flush(Context.default());
|
|
407
|
+
+ const first = await EffectEx.runAndForwardErrors(
|
|
408
|
+
+ dataSource.getChangedObjects(Context.default(), [], { activity: true }),
|
|
409
|
+
+ );
|
|
410
|
+
+ expect(first.activity).toEqual([expect.objectContaining({ documentId: branch.documentId, full: true })]);
|
|
411
|
+
+ expect(first.activity![0].changes.length).toBeGreaterThan(0);
|
|
412
|
+
+
|
|
413
|
+
+ const root = await createDatabaseDirectory(host, spaceId, {});
|
|
414
|
+
+ root.change((doc: DatabaseDirectory) => {
|
|
415
|
+
+ doc.branches = { 'obj-1': { feature: { members: { 'obj-1': branch.url } } } };
|
|
416
|
+
+ });
|
|
417
|
+
+ await host.flush(Context.default());
|
|
418
|
+
+ await EffectEx.runAndForwardErrors(dataSource.getChangedObjects(Context.default(), []));
|
|
419
|
+
+ const second = await EffectEx.runAndForwardErrors(
|
|
420
|
+
+ dataSource.getChangedObjects(Context.default(), toCursors(first), { activity: true }),
|
|
421
|
+
+ );
|
|
422
|
+
+ expect(second.activity).toContainEqual({ spaceId, documentId: branch.documentId, full: true, changes: [] });
|
|
423
|
+
+ expect(second.activity).toContainEqual(expect.objectContaining({ documentId: root.documentId }));
|
|
424
|
+
+ });
|
|
425
|
+
});
|
|
426
|
+
diff --git a/packages/core/echo/echo-host/src/db-host/automerge-data-source.ts b/packages/core/echo/echo-host/src/db-host/automerge-data-source.ts
|
|
427
|
+
index bacbb91e..caf4b011 100644
|
|
428
|
+
--- a/packages/core/echo/echo-host/src/db-host/automerge-data-source.ts
|
|
429
|
+
+++ b/packages/core/echo/echo-host/src/db-host/automerge-data-source.ts
|
|
430
|
+
@@ -3,16 +3,22 @@
|
|
431
|
+
//
|
|
432
|
+
|
|
433
|
+
import * as A from '@automerge/automerge';
|
|
434
|
+
-import { type DocumentId } from '@automerge/automerge-repo';
|
|
435
|
+
+import { type DocumentId, interpretAsDocumentId, isValidAutomergeUrl } from '@automerge/automerge-repo';
|
|
436
|
+
import * as Effect from 'effect/Effect';
|
|
437
|
+
|
|
438
|
+
import { type Context } from '@dxos/context';
|
|
439
|
+
import { DatabaseDirectory, SpaceDocVersion } from '@dxos/echo-protocol';
|
|
440
|
+
import { objectStructureToJson } from '@dxos/echo/internal';
|
|
441
|
+
-import { type DataSourceCursor, type IndexDataSource, type IndexerObject } from '@dxos/index-core';
|
|
442
|
+
+import {
|
|
443
|
+
+ type DataSourceCursor,
|
|
444
|
+
+ type DocumentActivity,
|
|
445
|
+
+ type IndexDataSource,
|
|
446
|
+
+ type IndexerObject,
|
|
447
|
+
+} from '@dxos/index-core';
|
|
448
|
+
import { log } from '@dxos/log';
|
|
449
|
+
|
|
450
|
+
import { type AutomergeHost } from '../automerge/index.ts';
|
|
451
|
+
+import { toChangeRecord } from './change-record.ts';
|
|
452
|
+
|
|
453
|
+
const HEADS_DELIMITER = '|';
|
|
454
|
+
|
|
455
|
+
@@ -42,6 +48,10 @@ const hasChanged = (cursor: string | undefined, currentHeads: A.Heads): boolean
|
|
456
|
+
return cursor !== headsCodec.encode(currentHeads);
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
+export type AutomergeDataSourceOptions = {
|
|
460
|
+
+ isBranchDocument?: (documentId: DocumentId) => boolean;
|
|
461
|
+
+};
|
|
462
|
+
+
|
|
463
|
+
/**
|
|
464
|
+
* Data source that fetches objects from AutomergeHost.
|
|
465
|
+
* Iterates all documents from SqliteHeadsStore and tracks document heads as cursors to detect changes.
|
|
466
|
+
@@ -50,6 +60,7 @@ export class AutomergeDataSource implements IndexDataSource {
|
|
467
|
+
readonly sourceName = 'automerge';
|
|
468
|
+
|
|
469
|
+
readonly #automergeHost: AutomergeHost;
|
|
470
|
+
+ readonly #isBranchDocument: ((documentId: DocumentId) => boolean) | undefined;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Heads for every document, captured once per `IndexEngine.update` pass. `listDocumentHeads()` is
|
|
474
|
+
@@ -60,8 +71,16 @@ export class AutomergeDataSource implements IndexDataSource {
|
|
475
|
+
#passHeads: Promise<{ documentId: DocumentId; heads: A.Heads }[]> | null = null;
|
|
476
|
+
#passActive = false;
|
|
477
|
+
|
|
478
|
+
- constructor(automergeHost: AutomergeHost) {
|
|
479
|
+
+ /**
|
|
480
|
+
+ * Branch documents seen as a space root's registry, across every pass. A merge lands a branch's
|
|
481
|
+
+ * changes in the main document under the same hashes, so counting the branch document too would
|
|
482
|
+
+ * double-count every merged change.
|
|
483
|
+
+ */
|
|
484
|
+
+ readonly #branchDocumentIds = new Set<DocumentId>();
|
|
485
|
+
+
|
|
486
|
+
+ constructor(automergeHost: AutomergeHost, options?: AutomergeDataSourceOptions) {
|
|
487
|
+
this.#automergeHost = automergeHost;
|
|
488
|
+
+ this.#isBranchDocument = options?.isBranchDocument;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
beginPass(): void {
|
|
492
|
+
@@ -95,8 +114,8 @@ export class AutomergeDataSource implements IndexDataSource {
|
|
493
|
+
getChangedObjects(
|
|
494
|
+
ctx: Context,
|
|
495
|
+
cursors: DataSourceCursor[],
|
|
496
|
+
- opts?: { limit?: number },
|
|
497
|
+
- ): Effect.Effect<{ objects: IndexerObject[]; cursors: DataSourceCursor[] }> {
|
|
498
|
+
+ opts?: { limit?: number; activity?: boolean; objects?: boolean },
|
|
499
|
+
+ ): Effect.Effect<{ objects: IndexerObject[]; cursors: DataSourceCursor[]; activity?: DocumentActivity[] }> {
|
|
500
|
+
return Effect.gen({ self: this }, function* () {
|
|
501
|
+
// Build a map of documentId -> cursor for quick lookup.
|
|
502
|
+
const cursorMap = new Map<string, string>();
|
|
503
|
+
@@ -123,6 +142,9 @@ export class AutomergeDataSource implements IndexDataSource {
|
|
504
|
+
// Load changed documents and extract objects.
|
|
505
|
+
const objects: IndexerObject[] = [];
|
|
506
|
+
const updatedCursors: DataSourceCursor[] = [];
|
|
507
|
+
+ const pendingActivity = new Map<DocumentId, DocumentActivity>();
|
|
508
|
+
+ const branchDiscards = new Map<DocumentId, DocumentActivity>();
|
|
509
|
+
+ const extractObjects = opts?.objects !== false;
|
|
510
|
+
|
|
511
|
+
for (const { documentId, heads: docHeads } of changedDocuments) {
|
|
512
|
+
try {
|
|
513
|
+
@@ -144,10 +166,24 @@ export class AutomergeDataSource implements IndexDataSource {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
+ if (doc.branches) {
|
|
518
|
+
+ for (const url of DatabaseDirectory.getAllBranchDocUrls(doc)) {
|
|
519
|
+
+ if (!isValidAutomergeUrl(url)) {
|
|
520
|
+
+ continue;
|
|
521
|
+
+ }
|
|
522
|
+
+ const branchId = interpretAsDocumentId(url);
|
|
523
|
+
+ this.#branchDocumentIds.add(branchId);
|
|
524
|
+
+ branchDiscards.set(branchId, { spaceId, documentId: branchId, full: true, changes: [] });
|
|
525
|
+
+ }
|
|
526
|
+
+ }
|
|
527
|
+
+
|
|
528
|
+
const existingCursor = cursorMap.get(documentId);
|
|
529
|
+
- const { changedObjectIds, updatedAt } = inspectDocChanges(doc, existingCursor);
|
|
530
|
+
+ const { changedObjectIds, updatedAt, changesMeta } = inspectDocChanges(doc, existingCursor, {
|
|
531
|
+
+ changes: !!opts?.activity,
|
|
532
|
+
+ objects: extractObjects,
|
|
533
|
+
+ });
|
|
534
|
+
|
|
535
|
+
- const docObjects = doc.objects ?? {};
|
|
536
|
+
+ const docObjects = extractObjects ? (doc.objects ?? {}) : {};
|
|
537
|
+
for (const [objectId, structure] of Object.entries(docObjects)) {
|
|
538
|
+
if (changedObjectIds && !changedObjectIds.has(objectId)) {
|
|
539
|
+
continue;
|
|
540
|
+
@@ -172,52 +208,79 @@ export class AutomergeDataSource implements IndexDataSource {
|
|
541
|
+
resourceId: documentId,
|
|
542
|
+
cursor: headsCodec.encode(docHeads),
|
|
543
|
+
});
|
|
544
|
+
+ if (opts?.activity) {
|
|
545
|
+
+ pendingActivity.set(documentId, {
|
|
546
|
+
+ spaceId,
|
|
547
|
+
+ documentId,
|
|
548
|
+
+ full: existingCursor === undefined,
|
|
549
|
+
+ changes: changesMeta.flatMap((meta) => {
|
|
550
|
+
+ const change = toChangeRecord(meta);
|
|
551
|
+
+ return change ? [{ time: change.time, ops: change.ops }] : [];
|
|
552
|
+
+ }),
|
|
553
|
+
+ });
|
|
554
|
+
+ }
|
|
555
|
+
} catch (error) {
|
|
556
|
+
log.error('Error loading document for indexing', { documentId, error });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
- return { objects, cursors: updatedCursors };
|
|
561
|
+
+ if (!opts?.activity) {
|
|
562
|
+
+ return { objects, cursors: updatedCursors };
|
|
563
|
+
+ }
|
|
564
|
+
+ const activity: DocumentActivity[] = [...branchDiscards.values()];
|
|
565
|
+
+ for (const [documentId, entry] of pendingActivity) {
|
|
566
|
+
+ if (branchDiscards.has(documentId)) {
|
|
567
|
+
+ continue;
|
|
568
|
+
+ }
|
|
569
|
+
+ const isBranch = this.#branchDocumentIds.has(documentId) || this.#isBranchDocument?.(documentId);
|
|
570
|
+
+ activity.push(isBranch ? { ...entry, full: true, changes: [] } : entry);
|
|
571
|
+
+ }
|
|
572
|
+
+ return { objects, cursors: updatedCursors, activity };
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
- * Determines which ECHO objects changed and the document-level max change timestamp.
|
|
579
|
+
+ * Determines which ECHO objects changed, the document-level max change timestamp, and (when
|
|
580
|
+
+ * `opts.changes` is set) the Automerge changes behind them.
|
|
581
|
+
*
|
|
582
|
+
* Uses `A.diff` to extract changed objectIds from patch paths (`["objects", objectId, ...]`),
|
|
583
|
+
- * and `A.getChangesMetaSince` for the max timestamp (second-level precision).
|
|
584
|
+
+ * and `A.getChangesMetaSince` for both the max timestamp (second-level precision) and the change
|
|
585
|
+
+ * metadata.
|
|
586
|
+
* Returns `changedObjectIds: null` when all objects should be indexed (new document).
|
|
587
|
+
*/
|
|
588
|
+
const inspectDocChanges = (
|
|
589
|
+
doc: DatabaseDirectory,
|
|
590
|
+
existingCursor: string | undefined,
|
|
591
|
+
-): { changedObjectIds: Set<string> | null; updatedAt: number } => {
|
|
592
|
+
+ opts: { changes: boolean; objects: boolean },
|
|
593
|
+
+): { changedObjectIds: Set<string> | null; updatedAt: number; changesMeta: A.ChangeMetadata[] } => {
|
|
594
|
+
if (!existingCursor) {
|
|
595
|
+
// On first indexing we don't have a prior cursor so we can't isolate per-object
|
|
596
|
+
// change timestamps. Fall back to the current wall-clock time so that freshly
|
|
597
|
+
// indexed objects sort "recent" in `Order.updated` queries.
|
|
598
|
+
- return { changedObjectIds: null, updatedAt: Date.now() };
|
|
599
|
+
+ const changesMeta = opts.changes ? A.getChangesMetaSince(doc, []) : [];
|
|
600
|
+
+ return { changedObjectIds: null, updatedAt: Date.now(), changesMeta };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const oldHeads = headsCodec.decode(existingCursor);
|
|
604
|
+
|
|
605
|
+
- const patches = A.diff(doc, oldHeads, A.getHeads(doc));
|
|
606
|
+
const changedObjectIds = new Set<string>();
|
|
607
|
+
- for (const patch of patches) {
|
|
608
|
+
- if (patch.path.length >= 2 && patch.path[0] === 'objects') {
|
|
609
|
+
- changedObjectIds.add(String(patch.path[1]));
|
|
610
|
+
+ if (opts.objects) {
|
|
611
|
+
+ for (const patch of A.diff(doc, oldHeads, A.getHeads(doc))) {
|
|
612
|
+
+ if (patch.path.length >= 2 && patch.path[0] === 'objects') {
|
|
613
|
+
+ changedObjectIds.add(String(patch.path[1]));
|
|
614
|
+
+ }
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
- const changes = A.getChangesMetaSince(doc, oldHeads);
|
|
619
|
+
+ const changesMeta = A.getChangesMetaSince(doc, oldHeads);
|
|
620
|
+
let maxTime = 0;
|
|
621
|
+
- for (const change of changes) {
|
|
622
|
+
+ for (const change of changesMeta) {
|
|
623
|
+
if (change.time > maxTime) {
|
|
624
|
+
maxTime = change.time;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const updatedAt = maxTime > 0 ? maxTime * 1000 : Date.now();
|
|
628
|
+
|
|
629
|
+
- return { changedObjectIds, updatedAt };
|
|
630
|
+
+ return { changedObjectIds, updatedAt, changesMeta };
|
|
631
|
+
};
|
|
632
|
+
diff --git a/packages/core/echo/echo-host/src/db-host/change-record.ts b/packages/core/echo/echo-host/src/db-host/change-record.ts
|
|
633
|
+
new file mode 100644
|
|
634
|
+
index 00000000..46dc77ab
|
|
635
|
+
--- /dev/null
|
|
636
|
+
+++ b/packages/core/echo/echo-host/src/db-host/change-record.ts
|
|
637
|
+
@@ -0,0 +1,23 @@
|
|
638
|
+
+//
|
|
639
|
+
+// Copyright 2026 DXOS.org
|
|
640
|
+
+//
|
|
641
|
+
+
|
|
642
|
+
+import type * as A from '@automerge/automerge';
|
|
643
|
+
+import type * as Schema from 'effect/Schema';
|
|
644
|
+
+
|
|
645
|
+
+import { type Change } from '@dxos/echo';
|
|
646
|
+
+
|
|
647
|
+
+export type ChangeRecord = Schema.Schema.Type<typeof Change.Change>;
|
|
648
|
+
+
|
|
649
|
+
+/** A document change as `Filter.changes` reports it, or `null` for one made without a clock (time 0). */
|
|
650
|
+
+export const toChangeRecord = (meta: A.ChangeMetadata): ChangeRecord | null =>
|
|
651
|
+
+ meta.time > 0
|
|
652
|
+
+ ? {
|
|
653
|
+
+ key: meta.hash,
|
|
654
|
+
+ source: 'document',
|
|
655
|
+
+ time: meta.time * 1000,
|
|
656
|
+
+ actor: meta.actor,
|
|
657
|
+
+ seq: meta.seq,
|
|
658
|
+
+ ops: meta.maxOp - meta.startOp + 1,
|
|
659
|
+
+ }
|
|
660
|
+
+ : null;
|
|
661
|
+
diff --git a/packages/core/echo/echo-host/src/db-host/echo-host.ts b/packages/core/echo/echo-host/src/db-host/echo-host.ts
|
|
662
|
+
index 5f0b73c7..de2241e7 100644
|
|
663
|
+
--- a/packages/core/echo/echo-host/src/db-host/echo-host.ts
|
|
664
|
+
+++ b/packages/core/echo/echo-host/src/db-host/echo-host.ts
|
|
665
|
+
@@ -233,7 +233,9 @@ export class EchoHost extends Resource {
|
|
666
|
+
|
|
667
|
+
this._runtime = runtime;
|
|
668
|
+
this._spaceStateManager = new SpaceStateManager({ runtime });
|
|
669
|
+
- this._automergeDataSource = new AutomergeDataSource(this._automergeHost);
|
|
670
|
+
+ this._automergeDataSource = new AutomergeDataSource(this._automergeHost, {
|
|
671
|
+
+ isBranchDocument: (documentId) => this._spaceStateManager.isBranchDocument(documentId),
|
|
672
|
+
+ });
|
|
673
|
+
|
|
674
|
+
this._feedStore = new FeedStore({ assignPositions: assignQueuePositions, localActorId: crypto.randomUUID() });
|
|
675
|
+
this._feedDataSource = new FeedDataSource({
|
|
676
|
+
diff --git a/packages/core/echo/echo-host/src/db-host/query-service.ts b/packages/core/echo/echo-host/src/db-host/query-service.ts
|
|
677
|
+
index 5ce9af4f..44d85a5d 100644
|
|
678
|
+
--- a/packages/core/echo/echo-host/src/db-host/query-service.ts
|
|
679
|
+
+++ b/packages/core/echo/echo-host/src/db-host/query-service.ts
|
|
680
|
+
@@ -188,9 +188,8 @@ export class QueryServiceImpl extends Resource implements QueryService.Handlers
|
|
681
|
+
);
|
|
682
|
+
scheduleMicroTask(ctx, async () => {
|
|
683
|
+
await queryEntry.executor.open();
|
|
684
|
+
- // Only the compiled path reads the snapshot store directly, so only it has to wait for the
|
|
685
|
+
- // store to fill; a memory-path query loads the documents itself and is not affected.
|
|
686
|
+
- if (queryEntry.feedScoped || (queryEntry.executor.mode === 'sql' && !(await this.#snapshotsComplete()))) {
|
|
687
|
+
+ const readsSnapshotStore = queryEntry.executor.compiled;
|
|
688
|
+
+ if (queryEntry.feedScoped || (readsSnapshotStore && !(await this.#snapshotsComplete()))) {
|
|
689
|
+
await this._params.updateIndexes();
|
|
690
|
+
}
|
|
691
|
+
queryEntry.open = true;
|
|
692
|
+
diff --git a/packages/core/echo/echo-host/src/db-host/space-state-manager.ts b/packages/core/echo/echo-host/src/db-host/space-state-manager.ts
|
|
693
|
+
index 529cfece..c454b65e 100644
|
|
694
|
+
--- a/packages/core/echo/echo-host/src/db-host/space-state-manager.ts
|
|
695
|
+
+++ b/packages/core/echo/echo-host/src/db-host/space-state-manager.ts
|
|
696
|
+
@@ -2,7 +2,12 @@
|
|
697
|
+
// Copyright 2024 DXOS.org
|
|
698
|
+
//
|
|
699
|
+
|
|
700
|
+
-import { type AutomergeUrl, type DocumentId, interpretAsDocumentId } from '@automerge/automerge-repo';
|
|
701
|
+
+import {
|
|
702
|
+
+ type AutomergeUrl,
|
|
703
|
+
+ type DocumentId,
|
|
704
|
+
+ interpretAsDocumentId,
|
|
705
|
+
+ isValidAutomergeUrl,
|
|
706
|
+
+} from '@automerge/automerge-repo';
|
|
707
|
+
import * as Effect from 'effect/Effect';
|
|
708
|
+
import * as Migrator from 'effect/unstable/sql/Migrator';
|
|
709
|
+
import * as SqlClient from 'effect/unstable/sql/SqlClient';
|
|
710
|
+
@@ -11,7 +16,7 @@ import isEqual from 'fast-deep-equal';
|
|
711
|
+
|
|
712
|
+
import { Event, UpdateScheduler } from '@dxos/async';
|
|
713
|
+
import { Context, LifecycleState, Resource } from '@dxos/context';
|
|
714
|
+
-import { type DatabaseDirectory } from '@dxos/echo-protocol';
|
|
715
|
+
+import { DatabaseDirectory } from '@dxos/echo-protocol';
|
|
716
|
+
import { RuntimeProvider } from '@dxos/effect';
|
|
717
|
+
import { invariant } from '@dxos/invariant';
|
|
718
|
+
import { type SpaceId } from '@dxos/keys';
|
|
719
|
+
@@ -103,6 +108,21 @@ export class SpaceStateManager extends Resource {
|
|
720
|
+
return this._roots.get(documentId);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
+ isBranchDocument(documentId: DocumentId): boolean {
|
|
724
|
+
+ for (const root of this._roots.values()) {
|
|
725
|
+
+ const doc = root.doc();
|
|
726
|
+
+ if (!doc) {
|
|
727
|
+
+ continue;
|
|
728
|
+
+ }
|
|
729
|
+
+ for (const url of DatabaseDirectory.getAllBranchDocUrls(doc)) {
|
|
730
|
+
+ if (isValidAutomergeUrl(url) && interpretAsDocumentId(url) === documentId) {
|
|
731
|
+
+ return true;
|
|
732
|
+
+ }
|
|
733
|
+
+ }
|
|
734
|
+
+ }
|
|
735
|
+
+ return false;
|
|
736
|
+
+ }
|
|
737
|
+
+
|
|
738
|
+
/**
|
|
739
|
+
* References carried by the space root document, or undefined for a space that has none yet
|
|
740
|
+
* (every space created before the root document existed).
|
|
741
|
+
diff --git a/packages/core/echo/echo-host/src/query/changes-executor.ts b/packages/core/echo/echo-host/src/query/changes-executor.ts
|
|
742
|
+
new file mode 100644
|
|
743
|
+
index 00000000..0e05723b
|
|
744
|
+
--- /dev/null
|
|
745
|
+
+++ b/packages/core/echo/echo-host/src/query/changes-executor.ts
|
|
746
|
+
@@ -0,0 +1,222 @@
|
|
747
|
+
+//
|
|
748
|
+
+// Copyright 2026 DXOS.org
|
|
749
|
+
+//
|
|
750
|
+
+
|
|
751
|
+
+import * as A from '@automerge/automerge';
|
|
752
|
+
+import { type DocumentId } from '@automerge/automerge-repo';
|
|
753
|
+
+import type * as Effect from 'effect/Effect';
|
|
754
|
+
+import type * as SqlClient from 'effect/unstable/sql/SqlClient';
|
|
755
|
+
+
|
|
756
|
+
+import { type Context } from '@dxos/context';
|
|
757
|
+
+import { type QueryAST } from '@dxos/echo-protocol';
|
|
758
|
+
+import { type IndexEngine } from '@dxos/index-core';
|
|
759
|
+
+import { invariant } from '@dxos/invariant';
|
|
760
|
+
+import { EID, type SpaceId } from '@dxos/keys';
|
|
761
|
+
+import { type QueryService } from '@dxos/protocols/rpc';
|
|
762
|
+
+
|
|
763
|
+
+import type { AutomergeHost } from '../automerge/index.ts';
|
|
764
|
+
+import { type ChangeRecord, toChangeRecord } from '../db-host/change-record.ts';
|
|
765
|
+
+import type { SpaceStateManager } from '../db-host/index.ts';
|
|
766
|
+
+import { type GroupAggregates, GroupBy, type GroupKeyValue } from './group-by.ts';
|
|
767
|
+
+import { type QueryPlan } from './plan.ts';
|
|
768
|
+
+
|
|
769
|
+
+/** A replayed change, or an hourly activity bucket standing for its `changes`. */
|
|
770
|
+
+type ChangeRow =
|
|
771
|
+
+ | { kind: 'change'; change: ChangeRecord }
|
|
772
|
+
+ | { kind: 'bucket'; hour: number; changes: number; ops: number };
|
|
773
|
+
+
|
|
774
|
+
+export type ChangeItem = {
|
|
775
|
+
+ spaceId: SpaceId;
|
|
776
|
+
+ row: ChangeRow;
|
|
777
|
+
+ weight: number;
|
|
778
|
+
+ groupKey?: GroupKeyValue;
|
|
779
|
+
+ aggregates?: GroupAggregates;
|
|
780
|
+
+ collapsed?: { size: number };
|
|
781
|
+
+};
|
|
782
|
+
+
|
|
783
|
+
+export type ChangesExecutorDeps = {
|
|
784
|
+
+ indexEngine: IndexEngine;
|
|
785
|
+
+ automergeHost: AutomergeHost;
|
|
786
|
+
+ spaceStateManager: SpaceStateManager;
|
|
787
|
+
+ runInRuntime: <T>(effect: Effect.Effect<T, unknown, SqlClient.SqlClient>) => Promise<T>;
|
|
788
|
+
+};
|
|
789
|
+
+
|
|
790
|
+
+export const executeChangesPlan = async (
|
|
791
|
+
+ ctx: Context,
|
|
792
|
+
+ plan: QueryPlan.Plan,
|
|
793
|
+
+ deps: ChangesExecutorDeps,
|
|
794
|
+
+): Promise<ChangeItem[]> => {
|
|
795
|
+
+ const [select, ...rest] = plan.steps;
|
|
796
|
+
+ if (select._tag !== 'SelectStep' || select.selector._tag !== 'ChangesSelector') {
|
|
797
|
+
+ throw new Error('Expected a changes select step.');
|
|
798
|
+
+ }
|
|
799
|
+
+ const spaceIds = select.scope.flatMap((scope) => (scope._tag === 'space' ? [scope.spaceId as SpaceId] : []));
|
|
800
|
+
+ let workingSet = await selectChanges(ctx, select.selector, spaceIds, deps);
|
|
801
|
+
+ for (const step of rest) {
|
|
802
|
+
+ workingSet = execStep(step, workingSet);
|
|
803
|
+
+ }
|
|
804
|
+
+ return workingSet;
|
|
805
|
+
+};
|
|
806
|
+
+
|
|
807
|
+
+export const changeResults = (items: readonly ChangeItem[]): QueryService.QueryResult[] =>
|
|
808
|
+
+ items.map((item): QueryService.QueryResult => {
|
|
809
|
+
+ if (item.collapsed !== undefined && item.groupKey !== undefined) {
|
|
810
|
+
+ const groupKey = GroupBy.serializeGroupKey(item.groupKey);
|
|
811
|
+
+ return {
|
|
812
|
+
+ id: groupKey,
|
|
813
|
+
+ spaceId: item.spaceId,
|
|
814
|
+
+ rank: 1,
|
|
815
|
+
+ groupKey,
|
|
816
|
+
+ groupCount: item.collapsed.size,
|
|
817
|
+
+ aggregates: JSON.stringify(item.aggregates ?? {}),
|
|
818
|
+
+ };
|
|
819
|
+
+ }
|
|
820
|
+
+ invariant(item.row.kind === 'change', 'Only a replayed change ships as a record.');
|
|
821
|
+
+ const { change } = item.row;
|
|
822
|
+
+ return { id: change.key, spaceId: item.spaceId, rank: 1, recordJson: JSON.stringify(change) };
|
|
823
|
+
+ });
|
|
824
|
+
+
|
|
825
|
+
+export const serializeChangeResults = (items: readonly ChangeItem[]): string =>
|
|
826
|
+
+ JSON.stringify(changeResults(items).map(({ id, groupCount, aggregates }) => [id, groupCount, aggregates]));
|
|
827
|
+
+
|
|
828
|
+
+const selectChanges = async (
|
|
829
|
+
+ ctx: Context,
|
|
830
|
+
+ selector: QueryPlan.ChangesSelector,
|
|
831
|
+
+ spaceIds: readonly SpaceId[],
|
|
832
|
+
+ deps: ChangesExecutorDeps,
|
|
833
|
+
+): Promise<ChangeItem[]> => {
|
|
834
|
+
+ const documents = selector.targets && (await resolveDocuments(selector.targets, spaceIds, deps));
|
|
835
|
+
+ const items: ChangeItem[] = [];
|
|
836
|
+
+
|
|
837
|
+
+ if (selector.source === 'index') {
|
|
838
|
+
+ for (const spaceId of spaceIds) {
|
|
839
|
+
+ const documentIds = documents?.filter((document) => document.spaceId === spaceId).map((doc) => doc.documentId);
|
|
840
|
+
+ const rows = await deps.runInRuntime(deps.indexEngine.queryActivity({ spaceId, documentIds }));
|
|
841
|
+
+ for (const { hour, changes, ops } of rows) {
|
|
842
|
+
+ items.push({ spaceId, row: { kind: 'bucket', hour, changes, ops }, weight: changes });
|
|
843
|
+
+ }
|
|
844
|
+
+ }
|
|
845
|
+
+ return items;
|
|
846
|
+
+ }
|
|
847
|
+
+
|
|
848
|
+
+ for (const { spaceId, documentId } of documents ?? []) {
|
|
849
|
+
+ if (deps.spaceStateManager.isBranchDocument(documentId)) {
|
|
850
|
+
+ continue;
|
|
851
|
+
+ }
|
|
852
|
+
+ using lease = await deps.automergeHost.loadDoc<Record<string, unknown>>(ctx, documentId, {
|
|
853
|
+
+ fetchFromNetwork: false,
|
|
854
|
+
+ });
|
|
855
|
+
+ if (!lease) {
|
|
856
|
+
+ continue;
|
|
857
|
+
+ }
|
|
858
|
+
+ for (const meta of A.getChangesMetaSince(lease.doc(), [])) {
|
|
859
|
+
+ const change = toChangeRecord(meta);
|
|
860
|
+
+ if (change) {
|
|
861
|
+
+ items.push({ spaceId, row: { kind: 'change', change }, weight: 1 });
|
|
862
|
+
+ }
|
|
863
|
+
+ }
|
|
864
|
+
+ }
|
|
865
|
+
+ return items.sort(compareNatural);
|
|
866
|
+
+};
|
|
867
|
+
+
|
|
868
|
+
+const resolveDocuments = async (
|
|
869
|
+
+ targets: readonly EID.EID[],
|
|
870
|
+
+ spaceIds: readonly SpaceId[],
|
|
871
|
+
+ deps: ChangesExecutorDeps,
|
|
872
|
+
+): Promise<{ spaceId: SpaceId; documentId: DocumentId }[]> => {
|
|
873
|
+
+ const objectIds = targets.flatMap((target) => {
|
|
874
|
+
+ const entityId = EID.getEntityId(target);
|
|
875
|
+
+ return entityId ? [entityId] : [];
|
|
876
|
+
+ });
|
|
877
|
+
+ if (objectIds.length === 0 || spaceIds.length === 0) {
|
|
878
|
+
+ return [];
|
|
879
|
+
+ }
|
|
880
|
+
+ const metas = await deps.runInRuntime(deps.indexEngine.queryObjectIds({ spaceIds, objectIds }));
|
|
881
|
+
+ const documents = new Map<string, { spaceId: SpaceId; documentId: DocumentId }>();
|
|
882
|
+
+ for (const meta of metas) {
|
|
883
|
+
+ if (meta.documentId) {
|
|
884
|
+
+ documents.set(`${meta.spaceId}/${meta.documentId}`, {
|
|
885
|
+
+ spaceId: meta.spaceId as SpaceId,
|
|
886
|
+
+ documentId: meta.documentId as DocumentId,
|
|
887
|
+
+ });
|
|
888
|
+
+ }
|
|
889
|
+
+ }
|
|
890
|
+
+ return [...documents.values()];
|
|
891
|
+
+};
|
|
892
|
+
+
|
|
893
|
+
+const execStep = (step: QueryPlan.Step, workingSet: ChangeItem[]): ChangeItem[] => {
|
|
894
|
+
+ const grouped = workingSet.length > 0 && workingSet[0].groupKey !== undefined;
|
|
895
|
+
+ switch (step._tag) {
|
|
896
|
+
+ case 'OrderStep': {
|
|
897
|
+
+ const compare = (a: ChangeItem, b: ChangeItem) => compareMultiOrder(a, b, step.order);
|
|
898
|
+
+ const sorted = grouped ? GroupBy.orderGroups(workingSet, getGroupKey, compare) : [...workingSet].sort(compare);
|
|
899
|
+
+ if (step.limit === undefined) {
|
|
900
|
+
+ return sorted;
|
|
901
|
+
+ }
|
|
902
|
+
+ return grouped ? GroupBy.takeGroups(sorted, step.limit, getGroupKey) : sorted.slice(0, step.limit);
|
|
903
|
+
+ }
|
|
904
|
+
+ case 'LimitStep':
|
|
905
|
+
+ return grouped ? GroupBy.takeGroups(workingSet, step.limit, getGroupKey) : workingSet.slice(0, step.limit);
|
|
906
|
+
+ case 'SkipStep':
|
|
907
|
+
+ return grouped ? GroupBy.dropGroups(workingSet, step.skip, getGroupKey) : workingSet.slice(step.skip);
|
|
908
|
+
+ case 'AggregateStep': {
|
|
909
|
+
+ const withKeys = workingSet.map((item) => ({ ...item, groupKey: groupKeyOf(item, step.aggregates) }));
|
|
910
|
+
+ const partitioned = GroupBy.partitionByGroupKey(withKeys, getGroupKey);
|
|
911
|
+
+ const stamped = GroupBy.withGroupAggregates(partitioned, getGroupKey, step.aggregates, (item, property) =>
|
|
912
|
+
+ fieldOf(item.row, property),
|
|
913
|
+
+ );
|
|
914
|
+
+ return GroupBy.collapseGroups(stamped, getGroupKey);
|
|
915
|
+
+ }
|
|
916
|
+
+ default:
|
|
917
|
+
+ throw new Error(`Unexpected step after a changes select: ${step._tag}`);
|
|
918
|
+
+ }
|
|
919
|
+
+};
|
|
920
|
+
+
|
|
921
|
+
+const getGroupKey = (item: ChangeItem): string => {
|
|
922
|
+
+ invariant(item.groupKey, 'Grouped steps run only after an aggregate keyed every item.');
|
|
923
|
+
+ return GroupBy.serializeGroupKey(item.groupKey);
|
|
924
|
+
+};
|
|
925
|
+
+
|
|
926
|
+
+/** The row's `Change.Change` fields; a bucket has only `source`, `time` and `ops`, all the planner lets it read. */
|
|
927
|
+
+const fieldsOf = (row: ChangeRow): Readonly<Record<string, unknown>> =>
|
|
928
|
+
+ row.kind === 'change' ? row.change : { source: 'document', time: row.hour, ops: row.ops };
|
|
929
|
+
+
|
|
930
|
+
+const fieldOf = (row: ChangeRow, property: string): unknown => fieldsOf(row)[property];
|
|
931
|
+
+
|
|
932
|
+
+const groupKeyOf = (item: ChangeItem, aggregates: readonly QueryAST.GroupAggregate[]): GroupKeyValue => {
|
|
933
|
+
+ const key: GroupKeyValue = {};
|
|
934
|
+
+ for (const aggregate of aggregates) {
|
|
935
|
+
+ if (aggregate.kind === 'group') {
|
|
936
|
+
+ key[aggregate.name] = GroupBy.resolveKeyComponent(aggregate.properties, (property) =>
|
|
937
|
+
+ fieldOf(item.row, property),
|
|
938
|
+
+ );
|
|
939
|
+
+ } else if (aggregate.kind === 'time') {
|
|
940
|
+
+ key[aggregate.name] = GroupBy.truncateTime(fieldOf(item.row, aggregate.property), aggregate.unit);
|
|
941
|
+
+ }
|
|
942
|
+
+ }
|
|
943
|
+
+ return key;
|
|
944
|
+
+};
|
|
945
|
+
+
|
|
946
|
+
+const compareNatural = (a: ChangeItem, b: ChangeItem): number =>
|
|
947
|
+
+ Number(fieldOf(a.row, 'time')) - Number(fieldOf(b.row, 'time')) ||
|
|
948
|
+
+ String(fieldOf(a.row, 'key') ?? '').localeCompare(String(fieldOf(b.row, 'key') ?? ''));
|
|
949
|
+
+
|
|
950
|
+
+const compareMultiOrder = (a: ChangeItem, b: ChangeItem, orders: readonly QueryAST.Order[]): number => {
|
|
951
|
+
+ for (const order of orders) {
|
|
952
|
+
+ const comparison =
|
|
953
|
+
+ order.kind === 'natural'
|
|
954
|
+
+ ? compareNatural(a, b)
|
|
955
|
+
+ : order.kind === 'property'
|
|
956
|
+
+ ? GroupBy.compareScalar(propertyOf(a, order.property), propertyOf(b, order.property))
|
|
957
|
+
+ : 0;
|
|
958
|
+
+ if (comparison !== 0) {
|
|
959
|
+
+ return order.direction === 'desc' ? -comparison : comparison;
|
|
960
|
+
+ }
|
|
961
|
+
+ }
|
|
962
|
+
+ return 0;
|
|
963
|
+
+};
|
|
964
|
+
+
|
|
965
|
+
+const propertyOf = (item: ChangeItem, property: string) =>
|
|
966
|
+
+ item.aggregates && property in item.aggregates
|
|
967
|
+
+ ? item.aggregates[property]
|
|
968
|
+
+ : GroupBy.coerceKeyComponent(fieldOf(item.row, property));
|
|
969
|
+
diff --git a/packages/core/echo/echo-host/src/query/group-by.test.ts b/packages/core/echo/echo-host/src/query/group-by.test.ts
|
|
970
|
+
index a61a919c..bb243b52 100644
|
|
971
|
+
--- a/packages/core/echo/echo-host/src/query/group-by.test.ts
|
|
972
|
+
+++ b/packages/core/echo/echo-host/src/query/group-by.test.ts
|
|
973
|
+
@@ -6,26 +6,29 @@ import { describe, expect, test } from 'vitest';
|
|
974
|
+
|
|
975
|
+
import { GroupBy } from './group-by.ts';
|
|
976
|
+
|
|
977
|
+
-describe('GroupBy.truncateTimestamp', () => {
|
|
978
|
+
+describe('GroupBy.truncateTime', () => {
|
|
979
|
+
const at = (iso: string) => Date.parse(iso);
|
|
980
|
+
|
|
981
|
+
- test('an hour starts on the UTC hour', () => {
|
|
982
|
+
- expect(GroupBy.truncateTimestamp(at('2026-01-05T18:45:12Z'), 'hour')).toBe(at('2026-01-05T18:00:00Z'));
|
|
983
|
+
+ test('floors to the UTC hour or day, before 1970 too', () => {
|
|
984
|
+
+ expect(GroupBy.truncateTime(at('2026-01-05T18:45:12Z'), 'hour')).toBe(at('2026-01-05T18:00:00Z'));
|
|
985
|
+
+ expect(GroupBy.truncateTime(at('2026-01-05T18:45:00Z'), 'day')).toBe(at('2026-01-05T00:00:00Z'));
|
|
986
|
+
+ expect(GroupBy.truncateTime(at('1969-12-31T23:00:00Z'), 'day')).toBe(at('1969-12-31T00:00:00Z'));
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
- test('a day starts at local midnight, including half-hour zones and daylight-saving days', () => {
|
|
990
|
+
- // 00:15 on 6 January in India (+05:30).
|
|
991
|
+
- expect(GroupBy.truncateTimestamp(at('2026-01-05T18:45:00Z'), 'day', 'Asia/Kolkata')).toBe(
|
|
992
|
+
- at('2026-01-05T18:30:00Z'),
|
|
993
|
+
- );
|
|
994
|
+
- // 13:00 PDT on 8 March, the day clocks go forward; that day began at midnight PST (-08:00).
|
|
995
|
+
- expect(GroupBy.truncateTimestamp(at('2026-03-08T20:00:00Z'), 'day', 'America/Los_Angeles')).toBe(
|
|
996
|
+
- at('2026-03-08T08:00:00Z'),
|
|
997
|
+
- );
|
|
998
|
+
- // Santiago's clocks go forward at midnight on 6 September, so that day starts at 01:00 local.
|
|
999
|
+
- expect(GroupBy.truncateTimestamp(at('2026-09-06T12:00:00Z'), 'day', 'America/Santiago')).toBe(
|
|
1000
|
+
- at('2026-09-06T04:00:00Z'),
|
|
1001
|
+
- );
|
|
1002
|
+
- expect(GroupBy.truncateTimestamp(at('2026-01-05T18:45:00Z'), 'day')).toBe(at('2026-01-05T00:00:00Z'));
|
|
1003
|
+
+ test('is null for anything but a finite number', () => {
|
|
1004
|
+
+ expect(GroupBy.truncateTime('2026-01-05', 'day')).toBeNull();
|
|
1005
|
+
+ expect(GroupBy.truncateTime(null, 'day')).toBeNull();
|
|
1006
|
+
+ expect(GroupBy.truncateTime(Number.NaN, 'hour')).toBeNull();
|
|
1007
|
+
+ });
|
|
1008
|
+
+});
|
|
1009
|
+
+
|
|
1010
|
+
+describe('GroupBy aggregates', () => {
|
|
1011
|
+
+ test('sum adds finite numbers and counts anything else as 0', () => {
|
|
1012
|
+
+ expect(GroupBy.sum([1, 2.5, null, undefined, 'x', Number.NaN, Number.POSITIVE_INFINITY])).toBe(3.5);
|
|
1013
|
+
+ expect(GroupBy.sum([])).toBe(0);
|
|
1014
|
+
+ });
|
|
1015
|
+
+
|
|
1016
|
+
+ test('a weighted member counts as the changes it stands for', () => {
|
|
1017
|
+
+ expect(GroupBy.countMembers([{ weight: 3 }, {}, { weight: 2 }])).toBe(6);
|
|
1018
|
+
});
|
|
1019
|
+
});
|
|
1020
|
+
diff --git a/packages/core/echo/echo-host/src/query/group-by.ts b/packages/core/echo/echo-host/src/query/group-by.ts
|
|
1021
|
+
index 671e438e..508b023a 100644
|
|
1022
|
+
--- a/packages/core/echo/echo-host/src/query/group-by.ts
|
|
1023
|
+
+++ b/packages/core/echo/echo-host/src/query/group-by.ts
|
|
1024
|
+
@@ -26,41 +26,7 @@ export type GroupAggregates = Record<string, AggregateValue>;
|
|
1025
|
+
*/
|
|
1026
|
+
const HOUR_MS = 3_600_000;
|
|
1027
|
+
|
|
1028
|
+
-const dateTimeFormats = new Map<string, Intl.DateTimeFormat>();
|
|
1029
|
+
-
|
|
1030
|
+
-/** Wall-clock fields of `timestamp` in `timeZone`, from a formatter cached per zone. */
|
|
1031
|
+
-const wallClock = (timestamp: number, timeZone: string) => {
|
|
1032
|
+
- let format = dateTimeFormats.get(timeZone);
|
|
1033
|
+
- if (!format) {
|
|
1034
|
+
- format = new Intl.DateTimeFormat('en-US', {
|
|
1035
|
+
- timeZone,
|
|
1036
|
+
- hourCycle: 'h23',
|
|
1037
|
+
- year: 'numeric',
|
|
1038
|
+
- month: 'numeric',
|
|
1039
|
+
- day: 'numeric',
|
|
1040
|
+
- hour: 'numeric',
|
|
1041
|
+
- minute: 'numeric',
|
|
1042
|
+
- second: 'numeric',
|
|
1043
|
+
- });
|
|
1044
|
+
- dateTimeFormats.set(timeZone, format);
|
|
1045
|
+
- }
|
|
1046
|
+
- const parts = format.formatToParts(timestamp);
|
|
1047
|
+
- const field = (type: Intl.DateTimeFormatPartTypes): number => Number(parts.find((part) => part.type === type)?.value);
|
|
1048
|
+
- return {
|
|
1049
|
+
- year: field('year'),
|
|
1050
|
+
- month: field('month'),
|
|
1051
|
+
- day: field('day'),
|
|
1052
|
+
- hour: field('hour'),
|
|
1053
|
+
- minute: field('minute'),
|
|
1054
|
+
- second: field('second'),
|
|
1055
|
+
- };
|
|
1056
|
+
-};
|
|
1057
|
+
-
|
|
1058
|
+
-/** How far `timeZone` is ahead of UTC at `timestamp`, in ms. */
|
|
1059
|
+
-const zoneOffset = (timestamp: number, timeZone: string): number => {
|
|
1060
|
+
- const { year, month, day, hour, minute, second } = wallClock(timestamp, timeZone);
|
|
1061
|
+
- return Date.UTC(year, month - 1, day, hour, minute, second) - Math.floor(timestamp / 1000) * 1000;
|
|
1062
|
+
-};
|
|
1063
|
+
+const DAY_MS = 86_400_000;
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Code-unit order, the collation SQLite's `BINARY` applies. Both executors order strings through
|
|
1067
|
+
@@ -71,27 +37,15 @@ export const compareCodeUnits = (a: string, b: string): number => (a < b ? -1 :
|
|
1068
|
+
|
|
1069
|
+
export const GroupBy = Object.freeze({
|
|
1070
|
+
/**
|
|
1071
|
+
- * The start of the hour or calendar day `timestamp` falls in, in unix ms, or `null` when unknown.
|
|
1072
|
+
- * Hours are UTC. Days follow `timeZone` (UTC when absent), so a day that starts or ends on a
|
|
1073
|
+
- * daylight-saving change still begins at that zone's local midnight.
|
|
1074
|
+
+ * The start of the UTC hour or day a unix-ms `value` falls in, or `null` when it is not a finite
|
|
1075
|
+
+ * number: the key of a `timestamp` or `time` aggregate.
|
|
1076
|
+
*/
|
|
1077
|
+
- truncateTimestamp: (timestamp: number | null | undefined, unit: 'hour' | 'day', timeZone = 'UTC'): number | null => {
|
|
1078
|
+
- if (timestamp == null) {
|
|
1079
|
+
+ truncateTime: (value: unknown, unit: 'hour' | 'day'): number | null => {
|
|
1080
|
+
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
- if (unit === 'hour') {
|
|
1084
|
+
- return Math.floor(timestamp / HOUR_MS) * HOUR_MS;
|
|
1085
|
+
- }
|
|
1086
|
+
- const { year, month, day } = wallClock(timestamp, timeZone);
|
|
1087
|
+
- const midnightAsUtc = Date.UTC(year, month - 1, day);
|
|
1088
|
+
- // The offset at local midnight can differ from the offset at `timestamp` across a DST change, so
|
|
1089
|
+
- // correct again unless the first candidate already falls on the day asked for. A zone that skips
|
|
1090
|
+
- // midnight (Santiago in September) has no 00:00, and its first candidate is the day's first instant.
|
|
1091
|
+
- const candidate = midnightAsUtc - zoneOffset(midnightAsUtc, timeZone);
|
|
1092
|
+
- const onDay = wallClock(candidate, timeZone);
|
|
1093
|
+
- return onDay.year === year && onDay.month === month && onDay.day === day
|
|
1094
|
+
- ? candidate
|
|
1095
|
+
- : midnightAsUtc - zoneOffset(candidate, timeZone);
|
|
1096
|
+
+ const size = unit === 'hour' ? HOUR_MS : DAY_MS;
|
|
1097
|
+
+ return Math.floor(value / size) * size;
|
|
1098
|
+
},
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
@@ -219,6 +173,17 @@ export const GroupBy = Object.freeze({
|
|
1102
|
+
return compareCodeUnits(String(a), String(b));
|
|
1103
|
+
},
|
|
1104
|
+
|
|
1105
|
+
+ /** Members a group stands for: an item carrying a `weight` (an index bucket) counts that many times. */
|
|
1106
|
+
+ countMembers: (members: readonly { weight?: number }[]): number =>
|
|
1107
|
+
+ members.reduce((total, member) => total + (member.weight ?? 1), 0),
|
|
1108
|
+
+
|
|
1109
|
+
+ /** Adds the numeric values of a `sum` aggregate; anything else counts as 0. */
|
|
1110
|
+
+ sum: (values: readonly unknown[]): number =>
|
|
1111
|
+
+ values.reduce<number>(
|
|
1112
|
+
+ (total, value) => (typeof value === 'number' && Number.isFinite(value) ? total + value : total),
|
|
1113
|
+
+ 0,
|
|
1114
|
+
+ ),
|
|
1115
|
+
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Reduces a group's member values under a `max`/`min` aggregate. Ignores `null`s (values missing
|
|
1118
|
+
* or non-scalar); a group with no scalar values yields `null`.
|
|
1119
|
+
@@ -258,7 +223,7 @@ export const GroupBy = Object.freeze({
|
|
1120
|
+
* requested across the group's `items`-kind aggregates — two conflicting orders are rejected
|
|
1121
|
+
* rather than silently honoring only one of them.
|
|
1122
|
+
*/
|
|
1123
|
+
- withGroupAggregates: <T extends { groupKey?: GroupKeyValue; aggregates?: GroupAggregates }>(
|
|
1124
|
+
+ withGroupAggregates: <T extends { groupKey?: GroupKeyValue; aggregates?: GroupAggregates; weight?: number }>(
|
|
1125
|
+
items: readonly T[],
|
|
1126
|
+
getKey: (item: T) => string,
|
|
1127
|
+
aggregates: readonly QueryAST.GroupAggregate[],
|
|
1128
|
+
@@ -310,14 +275,16 @@ export const GroupBy = Object.freeze({
|
|
1129
|
+
}
|
|
1130
|
+
computed[aggregate.name] =
|
|
1131
|
+
aggregate.kind === 'count'
|
|
1132
|
+
- ? members.length
|
|
1133
|
+
- : isGroupKeyAggregate(aggregate)
|
|
1134
|
+
- ? // All members of a group share the key, so read the component off any member.
|
|
1135
|
+
- (members[0].groupKey?.[aggregate.name] ?? null)
|
|
1136
|
+
- : GroupBy.reduceAggregate(
|
|
1137
|
+
- members.map((member) => coerceScalar(getProperty(member, aggregate.property))),
|
|
1138
|
+
- aggregate.kind,
|
|
1139
|
+
- );
|
|
1140
|
+
+ ? GroupBy.countMembers(members)
|
|
1141
|
+
+ : aggregate.kind === 'sum'
|
|
1142
|
+
+ ? GroupBy.sum(members.map((member) => getProperty(member, aggregate.property)))
|
|
1143
|
+
+ : isGroupKeyAggregate(aggregate)
|
|
1144
|
+
+ ? // All members of a group share the key, so read the component off any member.
|
|
1145
|
+
+ (members[0].groupKey?.[aggregate.name] ?? null)
|
|
1146
|
+
+ : GroupBy.reduceAggregate(
|
|
1147
|
+
+ members.map((member) => coerceScalar(getProperty(member, aggregate.property))),
|
|
1148
|
+
+ aggregate.kind,
|
|
1149
|
+
+ );
|
|
1150
|
+
}
|
|
1151
|
+
for (const member of members) {
|
|
1152
|
+
result.push({ ...member, aggregates: computed });
|
|
1153
|
+
@@ -332,7 +299,7 @@ export const GroupBy = Object.freeze({
|
|
1154
|
+
* for no members, so nothing downstream has to carry or ship the objects.
|
|
1155
|
+
* Assumes `items` are already partitioned into contiguous groups (see {@link partitionByGroupKey}).
|
|
1156
|
+
*/
|
|
1157
|
+
- collapseGroups: <T extends { collapsed?: { size: number } }>(
|
|
1158
|
+
+ collapseGroups: <T extends { collapsed?: { size: number }; weight?: number }>(
|
|
1159
|
+
items: readonly T[],
|
|
1160
|
+
getKey: (item: T) => string,
|
|
1161
|
+
): T[] => {
|
|
1162
|
+
@@ -344,7 +311,7 @@ export const GroupBy = Object.freeze({
|
|
1163
|
+
while (end < items.length && getKey(items[end]) === key) {
|
|
1164
|
+
end += 1;
|
|
1165
|
+
}
|
|
1166
|
+
- result.push({ ...items[index], collapsed: { size: end - index } });
|
|
1167
|
+
+ result.push({ ...items[index], collapsed: { size: GroupBy.countMembers(items.slice(index, end)) } });
|
|
1168
|
+
index = end;
|
|
1169
|
+
}
|
|
1170
|
+
return result;
|
|
1171
|
+
diff --git a/packages/core/echo/echo-host/src/query/plan.ts b/packages/core/echo/echo-host/src/query/plan.ts
|
|
1172
|
+
index bf3318ad..8499cf7f 100644
|
|
1173
|
+
--- a/packages/core/echo/echo-host/src/query/plan.ts
|
|
1174
|
+
+++ b/packages/core/echo/echo-host/src/query/plan.ts
|
|
1175
|
+
@@ -4,7 +4,7 @@
|
|
1176
|
+
|
|
1177
|
+
import type { QueryAST } from '@dxos/echo-protocol';
|
|
1178
|
+
import type { EscapedPropPath } from '@dxos/index-core';
|
|
1179
|
+
-import type { EntityId, URI } from '@dxos/keys';
|
|
1180
|
+
+import type { EID, EntityId, URI } from '@dxos/keys';
|
|
1181
|
+
|
|
1182
|
+
export namespace QueryPlan {
|
|
1183
|
+
export type TextSearchKind = 'full-text' | 'vector' | 'hybrid';
|
|
1184
|
+
@@ -129,7 +129,25 @@ export namespace QueryPlan {
|
|
1185
|
+
| TypeSelector
|
|
1186
|
+
| TextSelector
|
|
1187
|
+
| TimestampSelector
|
|
1188
|
+
- | IncomingReferenceSelector;
|
|
1189
|
+
+ | IncomingReferenceSelector
|
|
1190
|
+
+ | ChangesSelector;
|
|
1191
|
+
+
|
|
1192
|
+
+ /**
|
|
1193
|
+
+ * Select Automerge changes (`Filter.changes`) rather than objects. The working set holds change
|
|
1194
|
+
+ * records, so only ordering, paging and aggregation may follow.
|
|
1195
|
+
+ */
|
|
1196
|
+
+ export type ChangesSelector = {
|
|
1197
|
+
+ _tag: 'ChangesSelector';
|
|
1198
|
+
+
|
|
1199
|
+
+ /** Entities whose documents to read; every document in scope when absent. */
|
|
1200
|
+
+ targets?: readonly EID.EID[];
|
|
1201
|
+
+
|
|
1202
|
+
+ /**
|
|
1203
|
+
+ * `index` reads hourly buckets from the activity index, valid only for the aggregates it can
|
|
1204
|
+
+ * answer; `replay` lists each target document's change history.
|
|
1205
|
+
+ */
|
|
1206
|
+
+ source: 'index' | 'replay';
|
|
1207
|
+
+ };
|
|
1208
|
+
|
|
1209
|
+
export type WildcardSelector = {
|
|
1210
|
+
_tag: 'WildcardSelector';
|
|
1211
|
+
diff --git a/packages/core/echo/echo-host/src/query/query-executor.ts b/packages/core/echo/echo-host/src/query/query-executor.ts
|
|
1212
|
+
index 1ddb3e13..f1f1c630 100644
|
|
1213
|
+
--- a/packages/core/echo/echo-host/src/query/query-executor.ts
|
|
1214
|
+
+++ b/packages/core/echo/echo-host/src/query/query-executor.ts
|
|
1215
|
+
@@ -38,6 +38,7 @@ import type { AutomergeHost } from '../automerge/index.ts';
|
|
1216
|
+
import type { SpaceStateManager } from '../db-host/index.ts';
|
|
1217
|
+
import { type InvalidationHint, canonicalTypename } from '../db-host/invalidation-hint.ts';
|
|
1218
|
+
import { filterMatchDoc, filterMatchEntityMeta, filterMatchObjectJSON, getEntityMetaTypeURI } from '../filter/index.ts';
|
|
1219
|
+
+import { type ChangeItem, changeResults, executeChangesPlan, serializeChangeResults } from './changes-executor.ts';
|
|
1220
|
+
import { QueryError } from './errors.ts';
|
|
1221
|
+
import { type GroupAggregates, GroupBy, type GroupKeyValue, compareCodeUnits } from './group-by.ts';
|
|
1222
|
+
import { QueryPlan } from './plan.ts';
|
|
1223
|
+
@@ -197,7 +198,13 @@ const QueryItem = Object.freeze({
|
|
1224
|
+
key[aggregate.name] = QueryItem.getTypeUri(item);
|
|
1225
|
+
break;
|
|
1226
|
+
case 'timestamp':
|
|
1227
|
+
- key[aggregate.name] = GroupBy.truncateTimestamp(item[aggregate.field], aggregate.unit, aggregate.timeZone);
|
|
1228
|
+
+ key[aggregate.name] = GroupBy.truncateTime(item[aggregate.field], aggregate.unit);
|
|
1229
|
+
+ break;
|
|
1230
|
+
+ case 'time':
|
|
1231
|
+
+ key[aggregate.name] = GroupBy.truncateTime(
|
|
1232
|
+
+ QueryItem.getAggregateProperty(item, aggregate.property),
|
|
1233
|
+
+ aggregate.unit,
|
|
1234
|
+
+ );
|
|
1235
|
+
break;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
@@ -633,6 +640,7 @@ export class QueryExecutor extends Resource {
|
|
1239
|
+
readonly #includeAllFeeds: boolean;
|
|
1240
|
+
private _trace: ExecutionTrace = ExecutionTrace.makeEmpty();
|
|
1241
|
+
private _lastResultSet: QueryItem[] = [];
|
|
1242
|
+
+ #changeResultSet: ChangeItem[] | undefined;
|
|
1243
|
+
readonly #planner: QueryPlanner;
|
|
1244
|
+
readonly #mode: QueryExecutorMode;
|
|
1245
|
+
|
|
1246
|
+
@@ -689,16 +697,14 @@ export class QueryExecutor extends Resource {
|
|
1247
|
+
return this._trace;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
- /**
|
|
1251
|
+
- * The path this host asked for. Answerable before the first execution, because the caller gating
|
|
1252
|
+
- * on indexing has to know which store the query may read; a plan the compiler declines still
|
|
1253
|
+
- * reports `sql` here and runs in memory, which only makes that gate conservative.
|
|
1254
|
+
- */
|
|
1255
|
+
- get mode(): QueryExecutorMode {
|
|
1256
|
+
- return this.#mode;
|
|
1257
|
+
+ get compiled(): boolean {
|
|
1258
|
+
+ return this._plan.steps.some((step) => step._tag === 'SqlStep');
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
getResults(): QueryService.QueryResult[] {
|
|
1262
|
+
+ if (this.#changeResultSet) {
|
|
1263
|
+
+ return changeResults(this.#changeResultSet);
|
|
1264
|
+
+ }
|
|
1265
|
+
// Computed over the final (post-filter) result set so counts always match shipped records.
|
|
1266
|
+
const groupCounts = new Map<string, number>();
|
|
1267
|
+
for (const item of this._lastResultSet) {
|
|
1268
|
+
@@ -766,7 +772,7 @@ export class QueryExecutor extends Resource {
|
|
1269
|
+
log('exec query', {
|
|
1270
|
+
queryId: this._id,
|
|
1271
|
+
query: Query.pretty(Query.fromAst(this._query)),
|
|
1272
|
+
- mode: this.mode,
|
|
1273
|
+
+ mode: this.#mode,
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
// Subquery results can change between reactive runs, so resolved `in-query` sets must not
|
|
1277
|
+
@@ -774,6 +780,19 @@ export class QueryExecutor extends Resource {
|
|
1278
|
+
this.#inQuerySetCache = new Map();
|
|
1279
|
+
this.#inQueryTracesAttached = new Set();
|
|
1280
|
+
|
|
1281
|
+
+ const [select] = this._plan.steps;
|
|
1282
|
+
+ if (select?._tag === 'SelectStep' && select.selector._tag === 'ChangesSelector') {
|
|
1283
|
+
+ const previous = this.#changeResultSet;
|
|
1284
|
+
+ const next = await executeChangesPlan(this._ctx, this._plan, {
|
|
1285
|
+
+ indexEngine: this._indexEngine,
|
|
1286
|
+
+ automergeHost: this._automergeHost,
|
|
1287
|
+
+ spaceStateManager: this._spaceStateManager,
|
|
1288
|
+
+ runInRuntime: (effect) => this._runInRuntime(effect),
|
|
1289
|
+
+ });
|
|
1290
|
+
+ this.#changeResultSet = next;
|
|
1291
|
+
+ return { changed: serializeChangeResults(previous ?? []) !== serializeChangeResults(next) };
|
|
1292
|
+
+ }
|
|
1293
|
+
+
|
|
1294
|
+
const previous = this._lastResultSet;
|
|
1295
|
+
const { workingSet, trace } = await this._resolveWorkingSet();
|
|
1296
|
+
this._lastResultSet = workingSet;
|
|
1297
|
+
@@ -804,7 +823,7 @@ export class QueryExecutor extends Resource {
|
|
1298
|
+
const { workingSet, trace } = await this._execPlan(this._plan, []);
|
|
1299
|
+
// A compiled plan resolved these in SQL. Keyed on the plan rather than the mode, because a plan
|
|
1300
|
+
// the compiler declined runs step by step even under `sql` and needs the filter.
|
|
1301
|
+
- if (this._plan.steps.some((step) => step._tag === 'SqlStep')) {
|
|
1302
|
+
+ if (this.compiled) {
|
|
1303
|
+
return { workingSet, trace };
|
|
1304
|
+
}
|
|
1305
|
+
// Unresolvable items never reach the client, where hydration would fail or stall on them.
|
|
1306
|
+
@@ -1030,6 +1049,9 @@ export class QueryExecutor extends Resource {
|
|
1307
|
+
break;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
+ case 'ChangesSelector':
|
|
1311
|
+
+ throw new Error('A changes plan runs through executeChangesPlan.');
|
|
1312
|
+
+
|
|
1313
|
+
case 'IncomingReferenceSelector': {
|
|
1314
|
+
const beginIndexQuery = performance.now();
|
|
1315
|
+
const metas = await this._queryIncomingReferencesByTarget([step.selector.targetDXN], step.selector.property);
|
|
1316
|
+
diff --git a/packages/core/echo/echo-host/src/query/query-planner.test.ts b/packages/core/echo/echo-host/src/query/query-planner.test.ts
|
|
1317
|
+
index d360d510..a5b98d71 100644
|
|
1318
|
+
--- a/packages/core/echo/echo-host/src/query/query-planner.test.ts
|
|
1319
|
+
+++ b/packages/core/echo/echo-host/src/query/query-planner.test.ts
|
|
1320
|
+
@@ -4,9 +4,10 @@
|
|
1321
|
+
|
|
1322
|
+
import { describe, expect, test } from 'vitest';
|
|
1323
|
+
|
|
1324
|
+
-import { Aggregate, Feed, Filter, Order, Query, Ref } from '@dxos/echo';
|
|
1325
|
+
+import { Aggregate, Feed, Filter, Obj, Order, Query, Ref } from '@dxos/echo';
|
|
1326
|
+
import { type QueryAST } from '@dxos/echo-protocol';
|
|
1327
|
+
import { TestSchema } from '@dxos/echo/testing';
|
|
1328
|
+
+import { invariant } from '@dxos/invariant';
|
|
1329
|
+
import { EID, EntityId, SpaceId, URI } from '@dxos/keys';
|
|
1330
|
+
|
|
1331
|
+
import { type QueryPlan } from './plan.ts';
|
|
1332
|
+
@@ -2354,6 +2355,66 @@ describe('QueryPlanner', () => {
|
|
1333
|
+
expect(step).toMatchObject({ limit: 5, feedCursorRange: { begin: '3' } });
|
|
1334
|
+
});
|
|
1335
|
+
});
|
|
1336
|
+
+
|
|
1337
|
+
+ describe('changes', () => {
|
|
1338
|
+
+ const task = Obj.make(TestSchema.Task, { title: 'Task' });
|
|
1339
|
+
+ const plan = (query: Query.Any) => planner.createPlan(withSpaceIdOptions(query.ast));
|
|
1340
|
+
+ const selectorOf = (query: Query.Any) => {
|
|
1341
|
+
+ const [select] = plan(query).steps;
|
|
1342
|
+
+ invariant(select._tag === 'SelectStep');
|
|
1343
|
+
+ return select.selector;
|
|
1344
|
+
+ };
|
|
1345
|
+
+
|
|
1346
|
+
+ test('a space-wide count by day reads the activity index', () => {
|
|
1347
|
+
+ const query = Query.select(Filter.changes()).aggregate({
|
|
1348
|
+
+ day: Aggregate.time('time', 'day'),
|
|
1349
|
+
+ source: Aggregate.group('source'),
|
|
1350
|
+
+ changes: Aggregate.count(),
|
|
1351
|
+
+ ops: Aggregate.sum('ops'),
|
|
1352
|
+
+ });
|
|
1353
|
+
+ expect(selectorOf(query)).toEqual({ _tag: 'ChangesSelector', targets: undefined, source: 'index' });
|
|
1354
|
+
+ expect(plan(query).steps.map((step) => step._tag)).toEqual(['SelectStep', 'OrderStep', 'AggregateStep']);
|
|
1355
|
+
+ });
|
|
1356
|
+
+
|
|
1357
|
+
+ test('targets narrow the index to their documents', () => {
|
|
1358
|
+
+ const query = Query.select(Filter.changes(task)).aggregate({ hour: Aggregate.time('time', 'hour') });
|
|
1359
|
+
+ expect(selectorOf(query)).toMatchObject({ source: 'index', targets: [expect.stringContaining(task.id)] });
|
|
1360
|
+
+ });
|
|
1361
|
+
+
|
|
1362
|
+
+ test('anything the index cannot answer replays the targets', () => {
|
|
1363
|
+
+ const history = Query.select(Filter.changes(task)).orderBy(Order.property('time', 'desc')).limit(10);
|
|
1364
|
+
+ const byActor = Query.select(Filter.changes(task)).aggregate({
|
|
1365
|
+
+ actor: Aggregate.group('actor'),
|
|
1366
|
+
+ changes: Aggregate.count(),
|
|
1367
|
+
+ });
|
|
1368
|
+
+ const limitedFirst = Query.select(Filter.changes(task)).limit(5).aggregate({ changes: Aggregate.count() });
|
|
1369
|
+
+ expect(selectorOf(history)).toMatchObject({ source: 'replay' });
|
|
1370
|
+
+ expect(selectorOf(byActor)).toMatchObject({ source: 'replay' });
|
|
1371
|
+
+ expect(selectorOf(limitedFirst)).toMatchObject({ source: 'replay' });
|
|
1372
|
+
+ expect(plan(history).steps.at(-1)).toMatchObject({ _tag: 'LimitStep', limit: 10 });
|
|
1373
|
+
+ });
|
|
1374
|
+
+
|
|
1375
|
+
+ test('a space-wide query must be one the index answers', () => {
|
|
1376
|
+
+ expect(() => plan(Query.select(Filter.changes()))).toThrow('space-wide');
|
|
1377
|
+
+ expect(() =>
|
|
1378
|
+
+ plan(Query.select(Filter.changes()).aggregate({ actor: Aggregate.group('actor'), n: Aggregate.count() })),
|
|
1379
|
+
+ ).toThrow('space-wide');
|
|
1380
|
+
+ expect(() => plan(Query.select(Filter.changes()).aggregate({ max: Aggregate.max('ops') }))).toThrow('space-wide');
|
|
1381
|
+
+ });
|
|
1382
|
+
+
|
|
1383
|
+
+ test('changes cannot mix with object filters, member lists or system timestamps', () => {
|
|
1384
|
+
+ expect(() => plan(Query.select(Filter.and(Filter.changes(task), Filter.type(TestSchema.Task))))).toThrow(
|
|
1385
|
+
+ 'cannot be combined',
|
|
1386
|
+
+ );
|
|
1387
|
+
+ expect(() => plan(Query.select(Filter.not(Filter.changes(task))))).toThrow('cannot be combined');
|
|
1388
|
+
+ expect(() => plan(Query.select(Filter.changes(task)).aggregate({ items: Aggregate.items() }))).toThrow(
|
|
1389
|
+
+ 'Aggregate.items()',
|
|
1390
|
+
+ );
|
|
1391
|
+
+ expect(() => plan(Query.select(Filter.changes(task)).aggregate({ day: Aggregate.updated('day') }))).toThrow(
|
|
1392
|
+
+ 'Aggregate.updated()',
|
|
1393
|
+
+ );
|
|
1394
|
+
+ });
|
|
1395
|
+
+ });
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
const SPACE_ID = SpaceId.make('B2NJDFNVZIW77OQSXUBNAD7BUMBD3G5PO'); // Stable id for inline snapshots.
|
|
1399
|
+
diff --git a/packages/core/echo/echo-host/src/query/query-planner.ts b/packages/core/echo/echo-host/src/query/query-planner.ts
|
|
1400
|
+
index 11c4ef11..b12bddf7 100644
|
|
1401
|
+
--- a/packages/core/echo/echo-host/src/query/query-planner.ts
|
|
1402
|
+
+++ b/packages/core/echo/echo-host/src/query/query-planner.ts
|
|
1403
|
+
@@ -90,15 +90,91 @@ export class QueryPlanner {
|
|
1404
|
+
#buildSteps(query: QueryAST.Query): QueryPlan.Plan {
|
|
1405
|
+
this._validateQueryScoped(query);
|
|
1406
|
+
this._validateAggregatePlacement(query);
|
|
1407
|
+
+ const selectsChanges = queryContainsChanges(query);
|
|
1408
|
+
+ if (selectsChanges) {
|
|
1409
|
+
+ this._validateChangesFilters(query);
|
|
1410
|
+
+ }
|
|
1411
|
+
let plan = this._generate(query, { ...DEFAULT_CONTEXT, originalQuery: query });
|
|
1412
|
+
plan = this._optimizeEmptyFilters(plan);
|
|
1413
|
+
plan = this._optimizeSoloUnions(plan);
|
|
1414
|
+
plan = this._ensureOrderStep(plan);
|
|
1415
|
+
+ if (selectsChanges) {
|
|
1416
|
+
+ return this._routeChanges(plan, query);
|
|
1417
|
+
+ }
|
|
1418
|
+
plan = this._optimizeLimits(plan);
|
|
1419
|
+
plan = this._optimizeBareAggregate(plan);
|
|
1420
|
+
return plan;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
+ private _validateChangesFilters(query: QueryAST.Query): void {
|
|
1424
|
+
+ QueryAST.visit(query, (node) => {
|
|
1425
|
+
+ if (
|
|
1426
|
+
+ (node.type === 'filter' && filterContainsChanges(node.filter)) ||
|
|
1427
|
+
+ (node.type === 'select' && node.filter.type !== 'changes' && filterContainsChanges(node.filter))
|
|
1428
|
+
+ ) {
|
|
1429
|
+
+ throw new QueryError({
|
|
1430
|
+
+ message: 'Filter.changes() cannot be combined with other filters, traversals or unions.',
|
|
1431
|
+
+ context: { query: Query.pretty(Query.fromAst(query)) },
|
|
1432
|
+
+ });
|
|
1433
|
+
+ }
|
|
1434
|
+
+ });
|
|
1435
|
+
+ }
|
|
1436
|
+
+
|
|
1437
|
+
+ private _routeChanges(plan: QueryPlan.Plan, query: QueryAST.Query): QueryPlan.Plan {
|
|
1438
|
+
+ const fail = (message: string): never => {
|
|
1439
|
+
+ throw new QueryError({ message, context: { query: Query.pretty(Query.fromAst(query)) } });
|
|
1440
|
+
+ };
|
|
1441
|
+
+ const [select, ...rest] = plan.steps;
|
|
1442
|
+
+ if (select?._tag !== 'SelectStep' || select.selector._tag !== 'ChangesSelector') {
|
|
1443
|
+
+ return fail('Filter.changes() cannot be combined with other filters, traversals or unions.');
|
|
1444
|
+
+ }
|
|
1445
|
+
+ if (!select.scope.every((scope) => scope._tag === 'space')) {
|
|
1446
|
+
+ return fail('Filter.changes() selects from spaces, not feeds.');
|
|
1447
|
+
+ }
|
|
1448
|
+
+ for (const step of rest) {
|
|
1449
|
+
+ switch (step._tag) {
|
|
1450
|
+
+ case 'LimitStep':
|
|
1451
|
+
+ case 'SkipStep':
|
|
1452
|
+
+ break;
|
|
1453
|
+
+ case 'OrderStep':
|
|
1454
|
+
+ if (!step.order.every((order) => order.kind === 'natural' || order.kind === 'property')) {
|
|
1455
|
+
+ return fail('Changes can only be ordered by a property or naturally (by time).');
|
|
1456
|
+
+ }
|
|
1457
|
+
+ break;
|
|
1458
|
+
+ case 'AggregateStep': {
|
|
1459
|
+
+ const unsupported = step.aggregates.find(
|
|
1460
|
+
+ (aggregate) => aggregate.kind === 'items' || aggregate.kind === 'type' || aggregate.kind === 'timestamp',
|
|
1461
|
+
+ );
|
|
1462
|
+
+ if (unsupported) {
|
|
1463
|
+
+ const name =
|
|
1464
|
+
+ unsupported.kind === 'timestamp'
|
|
1465
|
+
+ ? unsupported.field === 'updatedAt'
|
|
1466
|
+
+ ? 'updated'
|
|
1467
|
+
+ : 'created'
|
|
1468
|
+
+ : unsupported.kind;
|
|
1469
|
+
+ return fail(`Aggregate.${name}() does not apply to changes.`);
|
|
1470
|
+
+ }
|
|
1471
|
+
+ break;
|
|
1472
|
+
+ }
|
|
1473
|
+
+ default:
|
|
1474
|
+
+ return fail('Filter.changes() cannot be combined with other filters, traversals or unions.');
|
|
1475
|
+
+ }
|
|
1476
|
+
+ }
|
|
1477
|
+
+
|
|
1478
|
+
+ const indexAnswers = activityIndexAnswers(rest);
|
|
1479
|
+
+ if (!indexAnswers && select.selector.targets === undefined) {
|
|
1480
|
+
+ return fail(
|
|
1481
|
+
+ "A space-wide Filter.changes() query must aggregate with only Aggregate.time('time', …), " +
|
|
1482
|
+
+ "Aggregate.group('source'), Aggregate.count() and Aggregate.sum('ops'); pass targets to Filter.changes() " +
|
|
1483
|
+
+ 'for anything else.',
|
|
1484
|
+
+ );
|
|
1485
|
+
+ }
|
|
1486
|
+
+ return QueryPlan.Plan.make([
|
|
1487
|
+
+ { ...select, selector: { ...select.selector, source: indexAnswers ? 'index' : 'replay' } },
|
|
1488
|
+
+ ...rest,
|
|
1489
|
+
+ ]);
|
|
1490
|
+
+ }
|
|
1491
|
+
+
|
|
1492
|
+
/**
|
|
1493
|
+
* Marks the select of a plan that only counts objects by index fields as {@link QueryPlan.SelectStep.bare},
|
|
1494
|
+
* so the executor groups index rows instead of loading documents. Eligible shape: a space-scoped
|
|
1495
|
+
@@ -409,6 +485,19 @@ export class QueryPlanner {
|
|
1496
|
+
]);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
+ case 'changes': {
|
|
1500
|
+
+ if (context.selectionInverted) {
|
|
1501
|
+
+ throw queryTooComplexError(context.originalQuery);
|
|
1502
|
+
+ }
|
|
1503
|
+
+ return QueryPlan.Plan.make([
|
|
1504
|
+
+ {
|
|
1505
|
+
+ _tag: 'SelectStep',
|
|
1506
|
+
+ scope: context.scope,
|
|
1507
|
+
+ selector: { _tag: 'ChangesSelector', targets: filter.targets, source: 'replay' },
|
|
1508
|
+
+ },
|
|
1509
|
+
+ ]);
|
|
1510
|
+
+ }
|
|
1511
|
+
+
|
|
1512
|
+
// Mnemonic — a local predicate on the object's own id, so it runs as a filter step over
|
|
1513
|
+
// a wildcard select. Inversion cannot fold into the value, so it is re-wrapped as `not`.
|
|
1514
|
+
case 'mnemonic': {
|
|
1515
|
+
@@ -919,7 +1008,7 @@ export class QueryPlanner {
|
|
1516
|
+
* pagination clauses `limit()`/`skip()` may wrap it. At most one `aggregate` may appear anywhere
|
|
1517
|
+
* in the tree — including inside a `.from(subquery)` source, which the planner flattens (so an
|
|
1518
|
+
* aggregated subquery would otherwise produce an unsupported double-`aggregate`). The DSL's types
|
|
1519
|
+
- * can't enforce this (`Query<AggregateResult & ...>` still exposes `orderBy`/`select`/etc.), so
|
|
1520
|
+
+ * can't enforce this (`Query<RecordResult & ...>` still exposes `orderBy`/`select`/etc.), so
|
|
1521
|
+
* it's validated here at plan time.
|
|
1522
|
+
*
|
|
1523
|
+
* `limit`/`skip` above an `aggregate` page over whole groups (see the group-aware `LimitStep`/
|
|
1524
|
+
@@ -1485,6 +1574,50 @@ const _filterContainsPostSelectPrune = (filter: QueryAST.Filter): boolean => {
|
|
1525
|
+
}
|
|
1526
|
+
};
|
|
1527
|
+
|
|
1528
|
+
+/** Whether hourly activity buckets answer the steps after a changes select as individual changes would. */
|
|
1529
|
+
+const activityIndexAnswers = (steps: readonly QueryPlan.Step[]): boolean => {
|
|
1530
|
+
+ const aggregateIndex = steps.findIndex((step) => step._tag === 'AggregateStep');
|
|
1531
|
+
+ const aggregate = steps[aggregateIndex];
|
|
1532
|
+
+ return (
|
|
1533
|
+
+ aggregate?._tag === 'AggregateStep' &&
|
|
1534
|
+
+ steps.slice(0, aggregateIndex).every(isNaturalOrderStep) &&
|
|
1535
|
+
+ aggregate.aggregates.every(readsOnlyBucketFields)
|
|
1536
|
+
+ );
|
|
1537
|
+
+};
|
|
1538
|
+
+
|
|
1539
|
+
+const isNaturalOrderStep = (step: QueryPlan.Step): boolean =>
|
|
1540
|
+
+ step._tag === 'OrderStep' && step.order.every((order) => order.kind === 'natural');
|
|
1541
|
+
+
|
|
1542
|
+
+const readsOnlyBucketFields = (aggregate: QueryAST.GroupAggregate): boolean =>
|
|
1543
|
+
+ aggregate.kind === 'count' ||
|
|
1544
|
+
+ (aggregate.kind === 'sum' && aggregate.property === 'ops') ||
|
|
1545
|
+
+ (aggregate.kind === 'time' && aggregate.property === 'time') ||
|
|
1546
|
+
+ (aggregate.kind === 'group' && aggregate.properties.length === 1 && aggregate.properties[0] === 'source');
|
|
1547
|
+
+
|
|
1548
|
+
+const filterContainsChanges = (filter: QueryAST.Filter): boolean => {
|
|
1549
|
+
+ switch (filter.type) {
|
|
1550
|
+
+ case 'changes':
|
|
1551
|
+
+ return true;
|
|
1552
|
+
+ case 'not':
|
|
1553
|
+
+ return filterContainsChanges(filter.filter);
|
|
1554
|
+
+ case 'and':
|
|
1555
|
+
+ case 'or':
|
|
1556
|
+
+ return filter.filters.some(filterContainsChanges);
|
|
1557
|
+
+ default:
|
|
1558
|
+
+ return false;
|
|
1559
|
+
+ }
|
|
1560
|
+
+};
|
|
1561
|
+
+
|
|
1562
|
+
+export const queryContainsChanges = (query: QueryAST.Query): boolean => {
|
|
1563
|
+
+ let found = false;
|
|
1564
|
+
+ QueryAST.visit(query, (node) => {
|
|
1565
|
+
+ if ((node.type === 'select' || node.type === 'filter') && filterContainsChanges(node.filter)) {
|
|
1566
|
+
+ found = true;
|
|
1567
|
+
+ }
|
|
1568
|
+
+ });
|
|
1569
|
+
+ return found;
|
|
1570
|
+
+};
|
|
1571
|
+
+
|
|
1572
|
+
/**
|
|
1573
|
+
* Returns true if the filter is a subquery-membership predicate (`Filter.in(query.project(...))`,
|
|
1574
|
+
* AST type `in-query`) or composes one via `and` / `or` / `not` / a nested `object` prop. Unlike
|
|
1575
|
+
diff --git a/packages/core/echo/echo-host/src/query/sql/compile.test.ts b/packages/core/echo/echo-host/src/query/sql/compile.test.ts
|
|
1576
|
+
index eefcf6a2..dcb6df1c 100644
|
|
1577
|
+
--- a/packages/core/echo/echo-host/src/query/sql/compile.test.ts
|
|
1578
|
+
+++ b/packages/core/echo/echo-host/src/query/sql/compile.test.ts
|
|
1579
|
+
@@ -81,7 +81,7 @@ const seed = Effect.gen(function* () {
|
|
1580
|
+
doc(
|
|
1581
|
+
ids.t1,
|
|
1582
|
+
TASK,
|
|
1583
|
+
- { title: 'alpha', priority: 2, tags: ['a', 'b'], assignee: ref(ids.alice) },
|
|
1584
|
+
+ { title: 'alpha', priority: 2, due: -1, tags: ['a', 'b'], assignee: ref(ids.alice) },
|
|
1585
|
+
{ [ATTR_PARENT]: EID.make({ entityId: ids.project }) },
|
|
1586
|
+
),
|
|
1587
|
+
doc(
|
|
1588
|
+
@@ -90,9 +90,10 @@ const seed = Effect.gen(function* () {
|
|
1589
|
+
{ title: 'beta', priority: 1, tags: ['b'], assignee: ref(ids.bob) },
|
|
1590
|
+
{ [ATTR_PARENT]: EID.make({ entityId: ids.deletedProject }) },
|
|
1591
|
+
),
|
|
1592
|
+
- doc(ids.t3, TASK, { title: 'gamma', priority: 3, tags: [], assignee: ref(ids.alice) }),
|
|
1593
|
+
+ doc(ids.t3, TASK, { title: 'gamma', priority: 3, due: 3_600_001, tags: [], assignee: ref(ids.alice) }),
|
|
1594
|
+
doc(ids.t4, TASK, {
|
|
1595
|
+
title: 'delta',
|
|
1596
|
+
+ due: 'soon',
|
|
1597
|
+
tags: ['a'],
|
|
1598
|
+
done: true,
|
|
1599
|
+
[ATTR_META]: { keys: [{ source: 'github.com', id: '42' }] },
|
|
1600
|
+
@@ -409,32 +410,52 @@ describe('SqlPlanCompiler', () => {
|
|
1601
|
+
}).pipe(Effect.provide(TestLayer)),
|
|
1602
|
+
);
|
|
1603
|
+
|
|
1604
|
+
- // Placing local day boundaries needs the store's timestamp range, which would both make
|
|
1605
|
+
- // compilation impure and bake a range that goes stale as the store grows — so the compiler
|
|
1606
|
+
- // declines these and the in-memory executor, which recomputes them per run, answers instead.
|
|
1607
|
+
- it.effect('declines a day group in a named time zone', () =>
|
|
1608
|
+
+ it.effect('sums a property and buckets a time property by floored hour', () =>
|
|
1609
|
+
Effect.gen(function* () {
|
|
1610
|
+
const fixture = yield* seed;
|
|
1611
|
+
const scope = [{ _tag: 'space' as const, spaceId: fixture.spaceId }];
|
|
1612
|
+
- const planner = new QueryPlanner();
|
|
1613
|
+
- const planSubquery = (query: QueryAST.Query) => planner.createPlan(query);
|
|
1614
|
+
- const named = planner.createPlan(
|
|
1615
|
+
- Query.select(Filter.type(TASK))
|
|
1616
|
+
- .aggregate({ day: Aggregate.created('day', { timeZone: 'Asia/Kolkata' }), items: Aggregate.items() })
|
|
1617
|
+
- .from(scope).ast,
|
|
1618
|
+
- );
|
|
1619
|
+
- expect(planDeclinedByCompiler(named, planSubquery)).toBe(true);
|
|
1620
|
+
-
|
|
1621
|
+
- // UTC needs no boundary table, so it still compiles.
|
|
1622
|
+
- const utc = planner.createPlan(
|
|
1623
|
+
+ const { rows } = yield* run(
|
|
1624
|
+
+ fixture,
|
|
1625
|
+
Query.select(Filter.type(TASK))
|
|
1626
|
+
- .aggregate({ day: Aggregate.created('day'), items: Aggregate.items() })
|
|
1627
|
+
- .from(scope).ast,
|
|
1628
|
+
+ .aggregate({ hour: Aggregate.time('due', 'hour'), priority: Aggregate.sum('priority'), n: Aggregate.count() })
|
|
1629
|
+
+ .from(scope),
|
|
1630
|
+
);
|
|
1631
|
+
- expect(planDeclinedByCompiler(utc, planSubquery)).toBe(false);
|
|
1632
|
+
+ const groups = rows
|
|
1633
|
+
+ .map((row) => ({ ...JSON.parse(row.groupKey ?? '{}'), ...JSON.parse(row.aggregates ?? '{}') }))
|
|
1634
|
+
+ .sort((a, b) => (a.hour ?? Infinity) - (b.hour ?? Infinity));
|
|
1635
|
+
+ expect(groups).toEqual([
|
|
1636
|
+
+ { hour: -3_600_000, priority: 2, n: 1 },
|
|
1637
|
+
+ { hour: 3_600_000, priority: 3, n: 1 },
|
|
1638
|
+
+ { hour: null, priority: 0, n: 1 },
|
|
1639
|
+
+ ]);
|
|
1640
|
+
}).pipe(Effect.provide(TestLayer)),
|
|
1641
|
+
);
|
|
1642
|
+
|
|
1643
|
+
+ it('declines change queries but compiles sums and time buckets', () => {
|
|
1644
|
+
+ const scope = [{ _tag: 'space' as const, spaceId: SpaceId.random() }];
|
|
1645
|
+
+ const plan = (query: Query.Any) => new QueryPlanner().createPlan(query.from(scope).ast);
|
|
1646
|
+
+ expect(
|
|
1647
|
+
+ planDeclinedByCompiler(
|
|
1648
|
+
+ plan(Query.select(Filter.changes()).aggregate({ day: Aggregate.time('time', 'day'), n: Aggregate.count() })),
|
|
1649
|
+
+ planSubquery,
|
|
1650
|
+
+ ),
|
|
1651
|
+
+ ).toBe(true);
|
|
1652
|
+
+ expect(
|
|
1653
|
+
+ planDeclinedByCompiler(
|
|
1654
|
+
+ plan(
|
|
1655
|
+
+ Query.select(Filter.type(TASK)).aggregate({
|
|
1656
|
+
+ day: Aggregate.time('due', 'day'),
|
|
1657
|
+
+ total: Aggregate.sum('estimate'),
|
|
1658
|
+
+ }),
|
|
1659
|
+
+ ),
|
|
1660
|
+
+ planSubquery,
|
|
1661
|
+
+ ),
|
|
1662
|
+
+ ).toBe(false);
|
|
1663
|
+
+ expect(
|
|
1664
|
+
+ planDeclinedByCompiler(plan(Query.select(Filter.type(TASK)).aggregate({ n: Aggregate.count() })), planSubquery),
|
|
1665
|
+
+ ).toBe(false);
|
|
1666
|
+
+ });
|
|
1667
|
+
+
|
|
1668
|
+
// Result field names come from the query author, so one that is not an identifier must still
|
|
1669
|
+
// compile: it used to throw here while the in-memory executor accepted it.
|
|
1670
|
+
it.effect('compiles an aggregate whose name is not an identifier', () =>
|
|
1671
|
+
diff --git a/packages/core/echo/echo-host/src/query/sql/compile.ts b/packages/core/echo/echo-host/src/query/sql/compile.ts
|
|
1672
|
+
index 274f1937..27a953a6 100644
|
|
1673
|
+
--- a/packages/core/echo/echo-host/src/query/sql/compile.ts
|
|
1674
|
+
+++ b/packages/core/echo/echo-host/src/query/sql/compile.ts
|
|
1675
|
+
@@ -986,7 +986,10 @@ export class SqlPlanCompiler {
|
|
1676
|
+
case 'group':
|
|
1677
|
+
case 'type':
|
|
1678
|
+
case 'timestamp':
|
|
1679
|
+
+ case 'time':
|
|
1680
|
+
return sql`k.${sql.literal(keyColumn(aggregate.name))} AS ${name}`;
|
|
1681
|
+
+ case 'sum':
|
|
1682
|
+
+ return sql`TOTAL(${this.#numericProperty(aggregate.property)}) OVER (PARTITION BY k.groupKey) AS ${name}`;
|
|
1683
|
+
case 'count':
|
|
1684
|
+
return sql`COUNT(*) OVER (PARTITION BY k.groupKey) AS ${name}`;
|
|
1685
|
+
case 'max':
|
|
1686
|
+
@@ -1031,31 +1034,17 @@ export class SqlPlanCompiler {
|
|
1687
|
+
return sql`CASE WHEN m.typeDXN = ${UNTYPED_INDEX_TYPE} THEN NULL ELSE m.typeDXN END`;
|
|
1688
|
+
case 'timestamp':
|
|
1689
|
+
return this.#truncatedTimestamp(aggregate);
|
|
1690
|
+
+ case 'time':
|
|
1691
|
+
+ return this.#truncatedProperty(aggregate);
|
|
1692
|
+
default:
|
|
1693
|
+
throw new QueryError({ message: 'Not a group key aggregate', context: { kind: aggregate.kind } });
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
- /**
|
|
1698
|
+
- * The start of the hour or day a system timestamp falls in, as unix ms. Hours and UTC days are
|
|
1699
|
+
- * arithmetic; a day in a named zone is looked up in the boundaries `compilePlan` computed with
|
|
1700
|
+
- * `GroupBy.truncateTimestamp`, so both executors agree on daylight-saving days.
|
|
1701
|
+
- */
|
|
1702
|
+
#truncatedTimestamp(aggregate: QueryAST.GroupAggregate & { kind: 'timestamp' }): Fragment {
|
|
1703
|
+
const sql = this.#sql;
|
|
1704
|
+
const column = aggregate.field === 'updatedAt' ? sql`m.updatedAt` : sql`m.createdAt`;
|
|
1705
|
+
- if (aggregate.unit === 'hour') {
|
|
1706
|
+
- return sql`CAST(${column} / ${HOUR_MS} AS INTEGER) * ${HOUR_MS}`;
|
|
1707
|
+
- }
|
|
1708
|
+
- if (!aggregate.timeZone || aggregate.timeZone === 'UTC') {
|
|
1709
|
+
- return sql`CAST(${column} / ${DAY_MS} AS INTEGER) * ${DAY_MS}`;
|
|
1710
|
+
- }
|
|
1711
|
+
- // Local day boundaries need the store's timestamp range, and bake it in, so
|
|
1712
|
+
- // `planDeclinedByCompiler` sends these plans to the in-memory executor before compilation starts.
|
|
1713
|
+
- throw new QueryError({
|
|
1714
|
+
- message: 'Day grouping in a named time zone is not compilable',
|
|
1715
|
+
- context: { timeZone: aggregate.timeZone },
|
|
1716
|
+
- });
|
|
1717
|
+
+ return this.#floorTo(column, aggregate.unit === 'hour' ? HOUR_MS : DAY_MS);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/** The grouped working-set columns, with `groupOrd` (and optionally `ord`) replaced. */
|
|
1721
|
+
@@ -1075,6 +1064,26 @@ export class SqlPlanCompiler {
|
|
1722
|
+
return sql.csv(columns);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
+ #truncatedProperty(aggregate: QueryAST.GroupAggregate & { kind: 'time' }): Fragment {
|
|
1726
|
+
+ return this.#floorTo(this.#numericProperty(aggregate.property), aggregate.unit === 'hour' ? HOUR_MS : DAY_MS);
|
|
1727
|
+
+ }
|
|
1728
|
+
+
|
|
1729
|
+
+ #floorTo(value: Fragment, size: number): Fragment {
|
|
1730
|
+
+ const sql = this.#sql;
|
|
1731
|
+
+ // `CAST` truncates toward zero; stepping back one below a negative fraction makes it a floor.
|
|
1732
|
+
+ const floored = sql`(CAST(${value} AS INTEGER) - (${value} < CAST(${value} AS INTEGER)))`;
|
|
1733
|
+
+ return sql`(${floored} - ((${floored} % ${size}) + ${size}) % ${size})`;
|
|
1734
|
+
+ }
|
|
1735
|
+
+
|
|
1736
|
+
+ #numericProperty(property: string): Fragment {
|
|
1737
|
+
+ const sql = this.#sql;
|
|
1738
|
+
+ if (property === 'id') {
|
|
1739
|
+
+ return sql`NULL`;
|
|
1740
|
+
+ }
|
|
1741
|
+
+ const at = jsonPathLiteral(sql, [property]);
|
|
1742
|
+
+ return sql`CASE WHEN json_type(d.snapshot, ${at}) IN ('integer', 'real') THEN json_extract(d.snapshot, ${at}) END`;
|
|
1743
|
+
+ }
|
|
1744
|
+
+
|
|
1745
|
+
/** A property coerced to the scalar domain (`null` for anything else); `id` is the entity id. */
|
|
1746
|
+
#coercedProperty(property: string, row: Fragment = this.#sql`m`): Fragment {
|
|
1747
|
+
const sql = this.#sql;
|
|
1748
|
+
@@ -1151,40 +1160,6 @@ export const compilePlan = (
|
|
1749
|
+
|
|
1750
|
+
const metaVersionKey = (key: string, range: string): string => `${key}\0${range}`;
|
|
1751
|
+
|
|
1752
|
+
-/** IANA zones of every `timestamp` group key that truncates to a local day, sub-plans included. */
|
|
1753
|
+
-const collectDayTimeZones = (plan: QueryPlan.Plan): Set<string> => {
|
|
1754
|
+
- const zones = new Set<string>();
|
|
1755
|
+
- const visit = (plan: QueryPlan.Plan) => {
|
|
1756
|
+
- for (const step of plan.steps) {
|
|
1757
|
+
- switch (step._tag) {
|
|
1758
|
+
- case 'AggregateStep':
|
|
1759
|
+
- for (const aggregate of step.aggregates) {
|
|
1760
|
+
- if (
|
|
1761
|
+
- aggregate.kind === 'timestamp' &&
|
|
1762
|
+
- aggregate.unit === 'day' &&
|
|
1763
|
+
- aggregate.timeZone &&
|
|
1764
|
+
- aggregate.timeZone !== 'UTC'
|
|
1765
|
+
- ) {
|
|
1766
|
+
- zones.add(aggregate.timeZone);
|
|
1767
|
+
- }
|
|
1768
|
+
- }
|
|
1769
|
+
- break;
|
|
1770
|
+
- case 'UnionStep':
|
|
1771
|
+
- step.plans.forEach(visit);
|
|
1772
|
+
- break;
|
|
1773
|
+
- case 'SetDifferenceStep':
|
|
1774
|
+
- visit(step.source);
|
|
1775
|
+
- visit(step.exclude);
|
|
1776
|
+
- break;
|
|
1777
|
+
- default:
|
|
1778
|
+
- break;
|
|
1779
|
+
- }
|
|
1780
|
+
- }
|
|
1781
|
+
- };
|
|
1782
|
+
- visit(plan);
|
|
1783
|
+
- return zones;
|
|
1784
|
+
-};
|
|
1785
|
+
-
|
|
1786
|
+
/** True when any select step, sub-plans included, scopes a space with `includeAllFeeds`. */
|
|
1787
|
+
const planIncludesAllFeeds = (plan: QueryPlan.Plan): boolean =>
|
|
1788
|
+
plan.steps.some((step) => {
|
|
1789
|
+
@@ -1211,16 +1186,17 @@ const planIncludesAllFeeds = (plan: QueryPlan.Plan): boolean =>
|
|
1790
|
+
/**
|
|
1791
|
+
* Whether the compiler declines this plan, leaving it for the in-memory executor.
|
|
1792
|
+
*
|
|
1793
|
+
- * Three reasons, all of which would otherwise need the store read before the statement is built:
|
|
1794
|
+
- * `objectSnapshot` drops `@meta` for document rows; a `metaVersion` semver range has to be resolved
|
|
1795
|
+
- * to the versions actually present; and a named-zone day group needs the store's timestamp range to
|
|
1796
|
+
- * place local day boundaries, which also goes stale as the store grows. Declining them keeps
|
|
1797
|
+
- * compilation pure — steps in, SQL out — and the plan correct.
|
|
1798
|
+
+ * Two reasons would need the store read before the statement is built: `objectSnapshot` drops
|
|
1799
|
+
+ * `@meta` for document rows, and a `metaVersion` semver range has to be resolved to the versions
|
|
1800
|
+
+ * actually present. Declining them keeps compilation pure — steps in, SQL out — and the plan correct.
|
|
1801
|
+
*/
|
|
1802
|
+
export const planDeclinedByCompiler = (plan: QueryPlan.Plan, planSubquery: PlanSubquery): boolean =>
|
|
1803
|
+
+ planSelectsChanges(plan) ||
|
|
1804
|
+
planReadsObjectMeta(plan, planSubquery) ||
|
|
1805
|
+
- collectMetaVersionFilters(plan, planSubquery).length > 0 ||
|
|
1806
|
+
- collectDayTimeZones(plan).size > 0;
|
|
1807
|
+
+ collectMetaVersionFilters(plan, planSubquery).length > 0;
|
|
1808
|
+
+
|
|
1809
|
+
+const planSelectsChanges = (plan: QueryPlan.Plan): boolean =>
|
|
1810
|
+
+ plan.steps.some((step) => step._tag === 'SelectStep' && step.selector._tag === 'ChangesSelector');
|
|
1811
|
+
|
|
1812
|
+
const planReadsObjectMeta = (plan: QueryPlan.Plan, planSubquery: PlanSubquery): boolean => {
|
|
1813
|
+
const readsMeta = (filter: QueryAST.Filter): boolean => {
|
|
1814
|
+
diff --git a/packages/core/echo/echo-protocol/src/query/ast.ts b/packages/core/echo/echo-protocol/src/query/ast.ts
|
|
1815
|
+
index 219f2a64..5c4bebf4 100644
|
|
1816
|
+
--- a/packages/core/echo/echo-protocol/src/query/ast.ts
|
|
1817
|
+
+++ b/packages/core/echo/echo-protocol/src/query/ast.ts
|
|
1818
|
+
@@ -259,6 +259,19 @@ const FilterMnemonic_ = Schema.Struct({
|
|
1819
|
+
export interface FilterMnemonic extends Schema.Schema.Type<typeof FilterMnemonic_> {}
|
|
1820
|
+
export const FilterMnemonic: Schema.Codec<FilterMnemonic> = FilterMnemonic_;
|
|
1821
|
+
|
|
1822
|
+
+/**
|
|
1823
|
+
+ * Select Automerge changes rather than objects: one record per change to the documents holding
|
|
1824
|
+
+ * `targets`, or to every document in the space when `targets` is absent. Only the host answers it,
|
|
1825
|
+
+ * and it cannot be combined with object predicates.
|
|
1826
|
+
+ */
|
|
1827
|
+
+const FilterChanges_ = Schema.Struct({
|
|
1828
|
+
+ type: Schema.Literal('changes'),
|
|
1829
|
+
+ targets: Schema.optional(Schema.Array(EID.Schema)),
|
|
1830
|
+
+});
|
|
1831
|
+
+
|
|
1832
|
+
+export interface FilterChanges extends Schema.Schema.Type<typeof FilterChanges_> {}
|
|
1833
|
+
+export const FilterChanges: Schema.Codec<FilterChanges> = FilterChanges_;
|
|
1834
|
+
+
|
|
1835
|
+
/**
|
|
1836
|
+
* Union of filters.
|
|
1837
|
+
*/
|
|
1838
|
+
@@ -276,6 +289,7 @@ export const Filter = Schema.Union([
|
|
1839
|
+
FilterChildOf,
|
|
1840
|
+
FilterHasParent,
|
|
1841
|
+
FilterMnemonic,
|
|
1842
|
+
+ FilterChanges,
|
|
1843
|
+
FilterNot,
|
|
1844
|
+
FilterAnd,
|
|
1845
|
+
FilterOr,
|
|
1846
|
+
@@ -504,8 +518,10 @@ export const QuerySkipClause: Schema.Codec<QuerySkipClause> = QuerySkipClause_;
|
|
1847
|
+
* input stream / the resulting groups, not this aggregate's member selection).
|
|
1848
|
+
* - `count` yields the member count. Opt-in — a row carries no count otherwise.
|
|
1849
|
+
* - `type` partitions members by their type URI; the field carries the URI string.
|
|
1850
|
+
- * - `timestamp` partitions members by the hour or calendar day a system timestamp falls in; the field
|
|
1851
|
+
- * carries the start of that interval in unix ms. Days are local to `timeZone` (UTC when absent).
|
|
1852
|
+
+ * - `timestamp` partitions members by the UTC hour or day a system timestamp falls in; the field
|
|
1853
|
+
+ * carries the start of that interval in unix ms.
|
|
1854
|
+
+ * - `time` is `timestamp` over a unix-ms member `property` instead of a system timestamp.
|
|
1855
|
+
+ * - `sum` adds a numeric member `property`; non-numeric values count as 0.
|
|
1856
|
+
*/
|
|
1857
|
+
const GroupAggregateGroup_ = Schema.Struct({
|
|
1858
|
+
name: Schema.String,
|
|
1859
|
+
@@ -533,10 +549,16 @@ const GroupAggregateTimestamp_ = Schema.Struct({
|
|
1860
|
+
kind: Schema.Literal('timestamp'),
|
|
1861
|
+
field: Schema.Literals(['createdAt', 'updatedAt']),
|
|
1862
|
+
unit: Schema.Literals(['hour', 'day']),
|
|
1863
|
+
- /** IANA time zone that `day` boundaries follow. */
|
|
1864
|
+
- timeZone: Schema.optional(Schema.String),
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
+const GroupAggregateTime_ = Schema.Struct({
|
|
1868
|
+
+ name: Schema.String,
|
|
1869
|
+
+ kind: Schema.Literal('time'),
|
|
1870
|
+
+ property: Schema.String,
|
|
1871
|
+
+ unit: Schema.Literals(['hour', 'day']),
|
|
1872
|
+
+});
|
|
1873
|
+
+const GroupAggregateSum_ = Schema.Struct({ name: Schema.String, kind: Schema.Literal('sum'), property: Schema.String });
|
|
1874
|
+
+
|
|
1875
|
+
const GroupAggregate_ = Schema.Union([
|
|
1876
|
+
GroupAggregateGroup_,
|
|
1877
|
+
GroupAggregateMax_,
|
|
1878
|
+
@@ -545,13 +567,18 @@ const GroupAggregate_ = Schema.Union([
|
|
1879
|
+
GroupAggregateCount_,
|
|
1880
|
+
GroupAggregateType_,
|
|
1881
|
+
GroupAggregateTimestamp_,
|
|
1882
|
+
+ GroupAggregateTime_,
|
|
1883
|
+
+ GroupAggregateSum_,
|
|
1884
|
+
]);
|
|
1885
|
+
|
|
1886
|
+
/** Aggregate kinds that contribute a component to the group key. */
|
|
1887
|
+
export const isGroupKeyAggregate = (
|
|
1888
|
+
aggregate: GroupAggregate,
|
|
1889
|
+
-): aggregate is Extract<GroupAggregate, { kind: 'group' | 'type' | 'timestamp' }> =>
|
|
1890
|
+
- aggregate.kind === 'group' || aggregate.kind === 'type' || aggregate.kind === 'timestamp';
|
|
1891
|
+
+): aggregate is Extract<GroupAggregate, { kind: 'group' | 'type' | 'timestamp' | 'time' }> =>
|
|
1892
|
+
+ aggregate.kind === 'group' ||
|
|
1893
|
+
+ aggregate.kind === 'type' ||
|
|
1894
|
+
+ aggregate.kind === 'timestamp' ||
|
|
1895
|
+
+ aggregate.kind === 'time';
|
|
1896
|
+
|
|
1897
|
+
export type GroupAggregate = Schema.Schema.Type<typeof GroupAggregate_>;
|
|
1898
|
+
export const GroupAggregate: Schema.Codec<GroupAggregate> = GroupAggregate_;
|
|
1899
|
+
diff --git a/packages/core/echo/echo-react/src/usePagination.ts b/packages/core/echo/echo-react/src/usePagination.ts
|
|
1900
|
+
index 6f549107..be40340d 100644
|
|
1901
|
+
--- a/packages/core/echo/echo-react/src/usePagination.ts
|
|
1902
|
+
+++ b/packages/core/echo/echo-react/src/usePagination.ts
|
|
1903
|
+
@@ -39,12 +39,8 @@ export type PaginationResult<T> = {
|
|
1904
|
+
/** Reactive snapshot handed to React by `useSyncExternalStore`; a fresh object only on real change. */
|
|
1905
|
+
type Snapshot<O> = { items: O[]; skip: number; limit: number; isLoading: boolean };
|
|
1906
|
+
|
|
1907
|
+
-/**
|
|
1908
|
+
- * The element type a paginated query yields: the flat record for an aggregate query (branded with
|
|
1909
|
+
- * {@link Query.AggregateResult}), otherwise the query's entity type. Mirrors `useQuery`'s overloads.
|
|
1910
|
+
- */
|
|
1911
|
+
type PaginationElement<Q extends Query.Any> =
|
|
1912
|
+
- Query.Type<Q> extends Query.AggregateResult ? Query.Type<Q> : Entity.Entity<Query.Type<Q>>;
|
|
1913
|
+
+ Query.Type<Q> extends Query.RecordResult ? Query.Type<Q> : Entity.Entity<Query.Type<Q>>;
|
|
1914
|
+
|
|
1915
|
+
/**
|
|
1916
|
+
* Builds the external store backing `usePagination` for one query identity (filter/order/page
|
|
1917
|
+
diff --git a/packages/core/echo/echo-react/src/useQuery.ts b/packages/core/echo/echo-react/src/useQuery.ts
|
|
1918
|
+
index 2af91147..616dbd7d 100644
|
|
1919
|
+
--- a/packages/core/echo/echo-react/src/useQuery.ts
|
|
1920
|
+
+++ b/packages/core/echo/echo-react/src/useQuery.ts
|
|
1921
|
+
@@ -11,9 +11,8 @@ const EMPTY_ARRAY: never[] = [];
|
|
1922
|
+
const noop = () => {};
|
|
1923
|
+
|
|
1924
|
+
interface UseQueryFn {
|
|
1925
|
+
- // Aggregate queries produce flat records branded with `Query.AggregateResult`; return them as-is,
|
|
1926
|
+
- // never wrapped in `Entity.Entity` (they are not entities).
|
|
1927
|
+
- <R extends Query.AggregateResult>(resource: Database.Queryable | undefined, query: Query.Query<R>): R[];
|
|
1928
|
+
+ <R extends Query.RecordResult>(resource: Database.Queryable | undefined, query: Query.Query<R>): R[];
|
|
1929
|
+
+ <R extends Query.RecordResult>(resource: Database.Queryable | undefined, filter: Filter.Filter<R>): R[];
|
|
1930
|
+
|
|
1931
|
+
<Q extends Query.Any, O extends Entity.Entity<Query.Type<Q>> = Entity.Entity<Query.Type<Q>>>(
|
|
1932
|
+
resource: Database.Queryable | undefined,
|
|
1933
|
+
@@ -32,7 +31,7 @@ interface UseQueryFn {
|
|
1934
|
+
* @param queryOrFilter - The query or filter to apply. Query is memoized based on the AST. No need to call useMemo.
|
|
1935
|
+
*/
|
|
1936
|
+
// The overloaded public signature returns `O[]` (entities) or the flat record `R[]` (aggregate
|
|
1937
|
+
-// queries) depending on the query shape; the implementation forwards whatever the query result
|
|
1938
|
+
+// rows and changes) depending on the query shape; the implementation forwards whatever the query result
|
|
1939
|
+
// produces, so — as with any implementation of a multi-signature overloaded function type — it is
|
|
1940
|
+
// typed as `any[]` here rather than picking one of the mutually-incompatible overload return shapes.
|
|
1941
|
+
export const useQuery: UseQueryFn = (
|
|
1942
|
+
diff --git a/packages/core/echo/echo/package.json b/packages/core/echo/echo/package.json
|
|
1943
|
+
index fb998355..50930ef3 100644
|
|
1944
|
+
--- a/packages/core/echo/echo/package.json
|
|
1945
|
+
+++ b/packages/core/echo/echo/package.json
|
|
1946
|
+
@@ -43,6 +43,11 @@
|
|
1947
|
+
"types": "./dist/types/src/Blob.d.ts",
|
|
1948
|
+
"import": "./dist/lib/Blob.mjs"
|
|
1949
|
+
},
|
|
1950
|
+
+ "./Change": {
|
|
1951
|
+
+ "source": "./src/Change.ts",
|
|
1952
|
+
+ "types": "./dist/types/src/Change.d.ts",
|
|
1953
|
+
+ "import": "./dist/lib/Change.mjs"
|
|
1954
|
+
+ },
|
|
1955
|
+
"./Collection": {
|
|
1956
|
+
"source": "./src/Collection.ts",
|
|
1957
|
+
"types": "./dist/types/src/Collection.d.ts",
|
|
1958
|
+
diff --git a/packages/core/echo/echo/src/Aggregate.ts b/packages/core/echo/echo/src/Aggregate.ts
|
|
1959
|
+
index 5a24f1f2..7c520335 100644
|
|
1960
|
+
--- a/packages/core/echo/echo/src/Aggregate.ts
|
|
1961
|
+
+++ b/packages/core/echo/echo/src/Aggregate.ts
|
|
1962
|
+
@@ -23,7 +23,9 @@ export type Spec =
|
|
1963
|
+
| { kind: 'items'; limit?: number; order?: readonly QueryAST.Order[] }
|
|
1964
|
+
| { kind: 'count' }
|
|
1965
|
+
| { kind: 'type' }
|
|
1966
|
+
- | { kind: 'timestamp'; field: 'createdAt' | 'updatedAt'; unit: TimeUnit; timeZone?: string };
|
|
1967
|
+
+ | { kind: 'timestamp'; field: 'createdAt' | 'updatedAt'; unit: TimeUnit }
|
|
1968
|
+
+ | { kind: 'time'; property: string; unit: TimeUnit }
|
|
1969
|
+
+ | { kind: 'sum'; property: string };
|
|
1970
|
+
|
|
1971
|
+
export const AggregateTypeId = '~@dxos/echo/Aggregate' as const;
|
|
1972
|
+
export type AggregateTypeId = typeof AggregateTypeId;
|
|
1973
|
+
@@ -50,7 +52,7 @@ export type ValueOf<A> = A extends Aggregate<any, infer V> ? V : never;
|
|
1974
|
+
|
|
1975
|
+
/** Computes the flat result type of `Query.aggregate(aggregates)` for a given aggregate record `A`. */
|
|
1976
|
+
export type AggregationResult<A extends Record<string, Any>> = EffectTypes.Simplify<
|
|
1977
|
+
- Query.AggregateResult & { readonly [N in keyof A]: ValueOf<A[N]> }
|
|
1978
|
+
+ Query.RecordResult & { readonly [N in keyof A]: ValueOf<A[N]> }
|
|
1979
|
+
>;
|
|
1980
|
+
|
|
1981
|
+
class AggregateClass<T, V> implements Aggregate<T, V> {
|
|
1982
|
+
@@ -113,6 +115,16 @@ export const min = <T, K extends keyof T & string>(property: K): Aggregate<T, T[
|
|
1983
|
+
export const items = <T>(options?: { limit?: number; order?: Order.Any[] }): Aggregate<T, T[]> =>
|
|
1984
|
+
new AggregateClass({ kind: 'items', limit: options?.limit, order: options?.order?.map((order) => order.ast) });
|
|
1985
|
+
|
|
1986
|
+
+export type NumericKeys<T> = { [K in keyof T & string]: T[K] extends number | null | undefined ? K : never }[keyof T &
|
|
1987
|
+
+ string];
|
|
1988
|
+
+
|
|
1989
|
+
+/**
|
|
1990
|
+
+ * Sum a numeric property across the group's members; values that are not numbers count as 0, so
|
|
1991
|
+
+ * a group always sums to a number.
|
|
1992
|
+
+ */
|
|
1993
|
+
+export const sum = <T, K extends NumericKeys<T>>(property: K): Aggregate<T, number> =>
|
|
1994
|
+
+ new AggregateClass({ kind: 'sum', property });
|
|
1995
|
+
+
|
|
1996
|
+
/**
|
|
1997
|
+
* Count the group's members. Opt-in — groups carry no count unless this aggregate is declared.
|
|
1998
|
+
*/
|
|
1999
|
+
@@ -124,31 +136,28 @@ export const count = <T>(): Aggregate<T, number> => new AggregateClass({ kind: '
|
|
2000
|
+
*/
|
|
2001
|
+
export const type = <T>(): Aggregate<T, string | null> => new AggregateClass({ kind: 'type' });
|
|
2002
|
+
|
|
2003
|
+
-/** Time units a timestamp aggregate can group by. */
|
|
2004
|
+
+/**
|
|
2005
|
+
+ * Time units a timestamp aggregate can group by, both in UTC. To show local days, group by `hour` and
|
|
2006
|
+
+ * roll the hours into the viewer's days; an hour straddles local midnight in a zone offset by a
|
|
2007
|
+
+ * fraction of an hour (India, Newfoundland), so its members land on one side of it.
|
|
2008
|
+
+ */
|
|
2009
|
+
export type TimeUnit = 'hour' | 'day';
|
|
2010
|
+
|
|
2011
|
+
-export type TimeUnitOptions = {
|
|
2012
|
+
- /** IANA time zone that day boundaries follow, e.g. the viewer's. Defaults to UTC. */
|
|
2013
|
+
- timeZone?: string;
|
|
2014
|
+
-};
|
|
2015
|
+
-
|
|
2016
|
+
/**
|
|
2017
|
+
- * Group members by the hour or calendar day their system `updatedAt` falls in. The field carries the
|
|
2018
|
+
- * start of that interval in unix ms, or `null` when the timestamp is unknown.
|
|
2019
|
+
+ * Group members by the UTC hour or day their system `updatedAt` falls in. The field carries the start
|
|
2020
|
+
+ * of that interval in unix ms, or `null` when the timestamp is unknown.
|
|
2021
|
+
*/
|
|
2022
|
+
-export const updated = <T>(unit: TimeUnit, options?: TimeUnitOptions): Aggregate<T, number | null> =>
|
|
2023
|
+
- new AggregateClass({
|
|
2024
|
+
- kind: 'timestamp',
|
|
2025
|
+
- field: 'updatedAt',
|
|
2026
|
+
- unit,
|
|
2027
|
+
- ...(options?.timeZone ? { timeZone: options.timeZone } : {}),
|
|
2028
|
+
- });
|
|
2029
|
+
+export const updated = <T>(unit: TimeUnit): Aggregate<T, number | null> =>
|
|
2030
|
+
+ new AggregateClass({ kind: 'timestamp', field: 'updatedAt', unit });
|
|
2031
|
+
|
|
2032
|
+
/** Like {@link updated}, over the system `createdAt` timestamp. */
|
|
2033
|
+
-export const created = <T>(unit: TimeUnit, options?: TimeUnitOptions): Aggregate<T, number | null> =>
|
|
2034
|
+
- new AggregateClass({
|
|
2035
|
+
- kind: 'timestamp',
|
|
2036
|
+
- field: 'createdAt',
|
|
2037
|
+
- unit,
|
|
2038
|
+
- ...(options?.timeZone ? { timeZone: options.timeZone } : {}),
|
|
2039
|
+
- });
|
|
2040
|
+
+export const created = <T>(unit: TimeUnit): Aggregate<T, number | null> =>
|
|
2041
|
+
+ new AggregateClass({ kind: 'timestamp', field: 'createdAt', unit });
|
|
2042
|
+
+
|
|
2043
|
+
+/**
|
|
2044
|
+
+ * Group members by the UTC hour or day a unix-ms property falls in, like {@link updated} over a
|
|
2045
|
+
+ * property instead of a system timestamp. The field carries the start of that interval in unix ms,
|
|
2046
|
+
+ * or `null` when the property is not a number.
|
|
2047
|
+
+ */
|
|
2048
|
+
+export const time = <T, K extends NumericKeys<T>>(property: K, unit: TimeUnit): Aggregate<T, number | null> =>
|
|
2049
|
+
+ new AggregateClass({ kind: 'time', property, unit });
|
|
2050
|
+
diff --git a/packages/core/echo/echo/src/Change.ts b/packages/core/echo/echo/src/Change.ts
|
|
2051
|
+
new file mode 100644
|
|
2052
|
+
index 00000000..bc1ce5ce
|
|
2053
|
+
--- /dev/null
|
|
2054
|
+
+++ b/packages/core/echo/echo/src/Change.ts
|
|
2055
|
+
@@ -0,0 +1,34 @@
|
|
2056
|
+
+//
|
|
2057
|
+
+// Copyright 2026 DXOS.org
|
|
2058
|
+
+//
|
|
2059
|
+
+
|
|
2060
|
+
+// @import-as-namespace
|
|
2061
|
+
+
|
|
2062
|
+
+import * as Schema from 'effect/Schema';
|
|
2063
|
+
+
|
|
2064
|
+
+import type * as Query from './Query.ts';
|
|
2065
|
+
+
|
|
2066
|
+
+/**
|
|
2067
|
+
+ * One change to an object, as {@link Filter.changes} returns it: a plain, frozen record, not an
|
|
2068
|
+
+ * entity. It cannot be added to a database, referenced or mutated.
|
|
2069
|
+
+ */
|
|
2070
|
+
+export const Change = Schema.Struct({
|
|
2071
|
+
+ /** Unique among a query's results: the Automerge change hash for a document change. */
|
|
2072
|
+
+ key: Schema.String,
|
|
2073
|
+
+ /** Where the change is stored; only documents report changes today. */
|
|
2074
|
+
+ source: Schema.Literals(['document', 'feed']),
|
|
2075
|
+
+ /** The author's clock when the change was made, unix ms. */
|
|
2076
|
+
+ time: Schema.Number,
|
|
2077
|
+
+ /** Writer that made the change; for a document change, an Automerge actor, which is a device, not a member. */
|
|
2078
|
+
+ actor: Schema.String,
|
|
2079
|
+
+ /** The actor's sequence number for this change. */
|
|
2080
|
+
+ seq: Schema.Number,
|
|
2081
|
+
+ /**
|
|
2082
|
+
+ * Primitive writes in the change, at the storage's granularity: Automerge ops (a field set, a
|
|
2083
|
+
+ * character inserted) for a document change; 1 for a feed block, which writes one whole object.
|
|
2084
|
+
+ * Compare sums within one `source`.
|
|
2085
|
+
+ */
|
|
2086
|
+
+ ops: Schema.Number,
|
|
2087
|
+
+});
|
|
2088
|
+
+
|
|
2089
|
+
+export type Change = Schema.Schema.Type<typeof Change> & Query.RecordResult;
|
|
2090
|
+
diff --git a/packages/core/echo/echo/src/Filter.ts b/packages/core/echo/echo/src/Filter.ts
|
|
2091
|
+
index b63d6be2..89b0bcc9 100644
|
|
2092
|
+
--- a/packages/core/echo/echo/src/Filter.ts
|
|
2093
|
+
+++ b/packages/core/echo/echo/src/Filter.ts
|
|
2094
|
+
@@ -14,6 +14,7 @@ import { SchemaAST } from '@dxos/effect';
|
|
2095
|
+
import { assertArgument } from '@dxos/invariant';
|
|
2096
|
+
import { EID, EntityId, type URI } from '@dxos/keys';
|
|
2097
|
+
|
|
2098
|
+
+import type * as Change from './Change.ts';
|
|
2099
|
+
import type * as Entity from './Entity.ts';
|
|
2100
|
+
import type * as Feed from './Feed.ts';
|
|
2101
|
+
import * as internal from './internal/index.ts';
|
|
2102
|
+
@@ -493,6 +494,34 @@ export const childOf = (
|
|
2103
|
+
});
|
|
2104
|
+
};
|
|
2105
|
+
|
|
2106
|
+
+/**
|
|
2107
|
+
+ * Select Automerge changes instead of objects: one {@link Change.Change} per change to the documents
|
|
2108
|
+
+ * holding `targets`, or to every document in the space when called with no argument.
|
|
2109
|
+
+ *
|
|
2110
|
+
+ * Changes belong to documents, not entities. Entities that share a document share its changes, and
|
|
2111
|
+
+ * content held by another entity (a document's `Text`) needs its own target. Only the host answers
|
|
2112
|
+
+ * these queries; a space-wide query must aggregate, and the index answers it only at hour
|
|
2113
|
+
+ * granularity (see `Aggregate.time`).
|
|
2114
|
+
+ *
|
|
2115
|
+
+ * @example
|
|
2116
|
+
+ * ```ts
|
|
2117
|
+
+ * Query.select(Filter.changes()).aggregate({ day: Aggregate.time('time', 'day'), changes: Aggregate.count() });
|
|
2118
|
+
+ * Query.select(Filter.changes([doc, doc.content])).orderBy(Order.property('time', 'desc')).limit(50);
|
|
2119
|
+
+ * ```
|
|
2120
|
+
+ */
|
|
2121
|
+
+export const changes = (
|
|
2122
|
+
+ targets?: Obj.Unknown | Ref.Unknown | readonly (Obj.Unknown | Ref.Unknown)[],
|
|
2123
|
+
+): Filter<Change.Change> => {
|
|
2124
|
+
+ if (targets === undefined) {
|
|
2125
|
+
+ return new FilterClass({ type: 'changes' });
|
|
2126
|
+
+ }
|
|
2127
|
+
+ const items = Array.isArray(targets) ? targets : [targets];
|
|
2128
|
+
+ return new FilterClass({
|
|
2129
|
+
+ type: 'changes',
|
|
2130
|
+
+ targets: items.map((item) => (Ref.isRef(item) ? EID.parse(item.uri) : EID.parse(internal.getUri(item)))),
|
|
2131
|
+
+ });
|
|
2132
|
+
+};
|
|
2133
|
+
+
|
|
2134
|
+
/**
|
|
2135
|
+
* Filter objects by whether they have a parent, regardless of which object it is.
|
|
2136
|
+
* `Filter.hasParent(false)` selects root objects — those never passed to `Obj.setParent`.
|
|
2137
|
+
diff --git a/packages/core/echo/echo/src/Query.ts b/packages/core/echo/echo/src/Query.ts
|
|
2138
|
+
index 57de261e..58961dfb 100644
|
|
2139
|
+
--- a/packages/core/echo/echo/src/Query.ts
|
|
2140
|
+
+++ b/packages/core/echo/echo/src/Query.ts
|
|
2141
|
+
@@ -47,14 +47,14 @@ type ReferenceTraversalTarget<P> = P extends Ref.Unknown
|
|
2142
|
+
: never;
|
|
2143
|
+
|
|
2144
|
+
/**
|
|
2145
|
+
- * Phantom brand on the flat row produced by {@link Query.aggregate}. Present only at the type level
|
|
2146
|
+
- * (never at runtime), it lets hooks like `useQuery`/`usePagination` distinguish an aggregate-row
|
|
2147
|
+
- * query from an entity query and avoid wrapping the row in `Entity.Entity`. The brand is a required
|
|
2148
|
+
- * property so `T extends AggregateResult` discriminates — an optional one would be satisfied by any
|
|
2149
|
+
- * type. Consumers never read it.
|
|
2150
|
+
+ * Phantom brand on query results that are plain records rather than entities: the flat row
|
|
2151
|
+
+ * {@link Query.aggregate} produces, and {@link Change.Change}. Present only at the type level (never
|
|
2152
|
+
+ * at runtime), it lets hooks like `useQuery`/`usePagination` return the record as-is instead of
|
|
2153
|
+
+ * wrapping it in `Entity.Entity`. The brand is a required property so `T extends RecordResult`
|
|
2154
|
+
+ * discriminates — an optional one would be satisfied by any type. Consumers never read it.
|
|
2155
|
+
*/
|
|
2156
|
+
-export interface AggregateResult {
|
|
2157
|
+
- readonly '~@dxos/echo/Query.AggregateResult': true;
|
|
2158
|
+
+export interface RecordResult {
|
|
2159
|
+
+ readonly '~@dxos/echo/Query.RecordResult': true;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
export const QueryTypeId = '~@dxos/echo/Query' as const;
|
|
2163
|
+
diff --git a/packages/core/echo/echo/src/index.ts b/packages/core/echo/echo/src/index.ts
|
|
2164
|
+
index 7497326e..ab88fcf1 100644
|
|
2165
|
+
--- a/packages/core/echo/echo/src/index.ts
|
|
2166
|
+
+++ b/packages/core/echo/echo/src/index.ts
|
|
2167
|
+
@@ -8,6 +8,7 @@ export { DXN, EID, URI } from '@dxos/keys';
|
|
2168
|
+
export * as Aggregate from './Aggregate.ts';
|
|
2169
|
+
export * as Annotation from './Annotation.ts';
|
|
2170
|
+
export * as Blob from './Blob.ts';
|
|
2171
|
+
+export * as Change from './Change.ts';
|
|
2172
|
+
export * as Database from './Database.ts';
|
|
2173
|
+
export * as Entity from './Entity.ts';
|
|
2174
|
+
export * as Error from './Error.ts';
|
|
2175
|
+
diff --git a/packages/core/echo/echo/src/internal/Filter/match.ts b/packages/core/echo/echo/src/internal/Filter/match.ts
|
|
2176
|
+
index af7a7660..dce0be50 100644
|
|
2177
|
+
--- a/packages/core/echo/echo/src/internal/Filter/match.ts
|
|
2178
|
+
+++ b/packages/core/echo/echo/src/internal/Filter/match.ts
|
|
2179
|
+
@@ -336,6 +336,10 @@ export const makeFilterMatcher = <T>(
|
|
2180
|
+
throw new Error('child-of filters must be handled at the executor level, not in-memory matching.');
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
+ case 'changes': {
|
|
2184
|
+
+ throw new Error('changes filters select change records on the host; they never match an object.');
|
|
2185
|
+
+ }
|
|
2186
|
+
+
|
|
2187
|
+
case 'has-parent': {
|
|
2188
|
+
return accessor.hasParent(record) === filter.value;
|
|
2189
|
+
}
|
|
2190
|
+
diff --git a/packages/core/echo/echo/src/internal/Query/pretty.ts b/packages/core/echo/echo/src/internal/Query/pretty.ts
|
|
2191
|
+
index 5c4ce43a..4a01717a 100644
|
|
2192
|
+
--- a/packages/core/echo/echo/src/internal/Query/pretty.ts
|
|
2193
|
+
+++ b/packages/core/echo/echo/src/internal/Query/pretty.ts
|
|
2194
|
+
@@ -70,6 +70,10 @@ export const prettyFilter = (filter: QueryAST.Filter): string => {
|
|
2195
|
+
return `Filter.childOf([${filter.parents.map((p) => JSON.stringify(p)).join(', ')}], { transitive: ${filter.transitive} })`;
|
|
2196
|
+
case 'mnemonic':
|
|
2197
|
+
return `Filter.mnemonic(${JSON.stringify(filter.mnemonic)})`;
|
|
2198
|
+
+ case 'changes':
|
|
2199
|
+
+ return filter.targets
|
|
2200
|
+
+ ? `Filter.changes([${filter.targets.map((target) => JSON.stringify(target)).join(', ')}])`
|
|
2201
|
+
+ : 'Filter.changes()';
|
|
2202
|
+
case 'has-parent':
|
|
2203
|
+
return `Filter.hasParent(${filter.value})`;
|
|
2204
|
+
case 'not':
|
|
2205
|
+
@@ -188,9 +192,9 @@ const prettyAggregateArg = (aggregate: QueryAST.GroupAggregate): string => {
|
|
2206
|
+
case 'type':
|
|
2207
|
+
return '';
|
|
2208
|
+
case 'timestamp':
|
|
2209
|
+
- return aggregate.timeZone !== undefined
|
|
2210
|
+
- ? `${JSON.stringify(aggregate.unit)}, { timeZone: ${JSON.stringify(aggregate.timeZone)} }`
|
|
2211
|
+
- : JSON.stringify(aggregate.unit);
|
|
2212
|
+
+ return JSON.stringify(aggregate.unit);
|
|
2213
|
+
+ case 'time':
|
|
2214
|
+
+ return `${JSON.stringify(aggregate.property)}, ${JSON.stringify(aggregate.unit)}`;
|
|
2215
|
+
case 'items':
|
|
2216
|
+
return aggregate.limit !== undefined ? `{ limit: ${aggregate.limit} }` : '';
|
|
2217
|
+
case 'group':
|
|
2218
|
+
@@ -199,6 +203,7 @@ const prettyAggregateArg = (aggregate: QueryAST.GroupAggregate): string => {
|
|
2219
|
+
: `{ coalesce: ${JSON.stringify(aggregate.properties)} }`;
|
|
2220
|
+
case 'max':
|
|
2221
|
+
case 'min':
|
|
2222
|
+
+ case 'sum':
|
|
2223
|
+
return JSON.stringify(aggregate.property);
|
|
2224
|
+
}
|
|
2225
|
+
};
|
|
2226
|
+
diff --git a/packages/core/echo/echo/vite.config.ts b/packages/core/echo/echo/vite.config.ts
|
|
2227
|
+
index fdecee41..4cc8ceac 100644
|
|
2228
|
+
--- a/packages/core/echo/echo/vite.config.ts
|
|
2229
|
+
+++ b/packages/core/echo/echo/vite.config.ts
|
|
2230
|
+
@@ -13,6 +13,7 @@ export default defineConfig({
|
|
2231
|
+
Obj: 'src/Obj.ts',
|
|
2232
|
+
Annotation: 'src/Annotation.ts',
|
|
2233
|
+
Blob: 'src/Blob.ts',
|
|
2234
|
+
+ Change: 'src/Change.ts',
|
|
2235
|
+
Database: 'src/Database.ts',
|
|
2236
|
+
Error: 'src/Error.ts',
|
|
2237
|
+
Feed: 'src/Feed.ts',
|
|
2238
|
+
diff --git a/packages/core/echo/index-core/src/data-source.ts b/packages/core/echo/index-core/src/data-source.ts
|
|
2239
|
+
index 08079cb5..21b44bf7 100644
|
|
2240
|
+
--- a/packages/core/echo/index-core/src/data-source.ts
|
|
2241
|
+
+++ b/packages/core/echo/index-core/src/data-source.ts
|
|
2242
|
+
@@ -8,7 +8,7 @@ import type * as SqlError from 'effect/unstable/sql/SqlError';
|
|
2243
|
+
import { type Context } from '@dxos/context';
|
|
2244
|
+
import type { SpaceId } from '@dxos/keys';
|
|
2245
|
+
|
|
2246
|
+
-import { type IndexerObject } from './indexes/interface.ts';
|
|
2247
|
+
+import { type DocumentActivity, type IndexerObject } from './indexes/interface.ts';
|
|
2248
|
+
|
|
2249
|
+
/**
|
|
2250
|
+
* Cursor into indexable data-source.
|
|
2251
|
+
@@ -46,9 +46,18 @@ export interface IndexDataSource {
|
|
2252
|
+
beginPass?(): void;
|
|
2253
|
+
endPass?(): void;
|
|
2254
|
+
|
|
2255
|
+
+ /**
|
|
2256
|
+
+ * Objects changed since `cursors`, and, when `opts.activity` is set, the changes behind them per
|
|
2257
|
+
+ * document (the activity index's input). Both are relative to the same cursors, so a source
|
|
2258
|
+
+ * reporting a change once per cursor advance reports it exactly once. `opts.objects === false`
|
|
2259
|
+
+ * asks for the activity alone, so an activity-only caller does not pay for object extraction.
|
|
2260
|
+
+ */
|
|
2261
|
+
getChangedObjects(
|
|
2262
|
+
ctx: Context,
|
|
2263
|
+
cursors: DataSourceCursor[],
|
|
2264
|
+
- opts?: { limit?: number },
|
|
2265
|
+
- ): Effect.Effect<{ objects: IndexerObject[]; cursors: DataSourceCursor[] }, SqlError.SqlError>;
|
|
2266
|
+
+ opts?: { limit?: number; activity?: boolean; objects?: boolean },
|
|
2267
|
+
+ ): Effect.Effect<
|
|
2268
|
+
+ { objects: IndexerObject[]; cursors: DataSourceCursor[]; activity?: DocumentActivity[] },
|
|
2269
|
+
+ SqlError.SqlError
|
|
2270
|
+
+ >;
|
|
2271
|
+
}
|
|
2272
|
+
diff --git a/packages/core/echo/index-core/src/index-engine.test.ts b/packages/core/echo/index-core/src/index-engine.test.ts
|
|
2273
|
+
index 2a0665a3..bfd92177 100644
|
|
2274
|
+
--- a/packages/core/echo/index-core/src/index-engine.test.ts
|
|
2275
|
+
+++ b/packages/core/echo/index-core/src/index-engine.test.ts
|
|
2276
|
+
@@ -17,7 +17,7 @@ import { DXN, EntityId, SpaceId } from '@dxos/keys';
|
|
2277
|
+
import { type DataSourceCursor, type IndexDataSource } from './data-source.ts';
|
|
2278
|
+
import { IndexEngine, type IndexingResult } from './index-engine.ts';
|
|
2279
|
+
import { type IndexCursor } from './index-tracker.ts';
|
|
2280
|
+
-import { EntityMetaIndex, type IndexerObject } from './indexes/index.ts';
|
|
2281
|
+
+import { type DocumentActivity, EntityMetaIndex, type IndexerObject } from './indexes/index.ts';
|
|
2282
|
+
|
|
2283
|
+
const TYPE_DEFAULT = DXN.make('com.example.type.Type', '0.1.0');
|
|
2284
|
+
const TYPE_A = DXN.make('com.example.type.TypeA', '0.1.0');
|
|
2285
|
+
@@ -87,6 +87,53 @@ class MockIndexDataSource implements IndexDataSource {
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
+class ActivityMockDataSource implements IndexDataSource {
|
|
2290
|
+
+ readonly sourceName = 'activity-mock-source';
|
|
2291
|
+
+
|
|
2292
|
+
+ constructor(
|
|
2293
|
+
+ private readonly spaceId: SpaceId,
|
|
2294
|
+
+ private readonly documentId: string,
|
|
2295
|
+
+ private readonly objectId: EntityId,
|
|
2296
|
+
+ ) {}
|
|
2297
|
+
+
|
|
2298
|
+
+ getChangedObjects(
|
|
2299
|
+
+ _ctx: Context,
|
|
2300
|
+
+ cursors: IndexCursor[],
|
|
2301
|
+
+ opts?: { limit?: number; activity?: boolean },
|
|
2302
|
+
+ ): Effect.Effect<{ objects: IndexerObject[]; cursors: DataSourceCursor[]; activity?: DocumentActivity[] }> {
|
|
2303
|
+
+ return Effect.sync(() => {
|
|
2304
|
+
+ const seen = cursors.some((cursor) => cursor.resourceId === this.documentId && cursor.cursor === 'v1');
|
|
2305
|
+
+ if (seen) {
|
|
2306
|
+
+ return { objects: [], cursors: [], activity: [] };
|
|
2307
|
+
+ }
|
|
2308
|
+
+
|
|
2309
|
+
+ const object: IndexerObject = {
|
|
2310
|
+
+ spaceId: this.spaceId,
|
|
2311
|
+
+ documentId: this.documentId,
|
|
2312
|
+
+ queueId: null,
|
|
2313
|
+
+ queueNamespace: null,
|
|
2314
|
+
+ recordId: null,
|
|
2315
|
+
+ createdAt: null,
|
|
2316
|
+
+ updatedAt: Date.now(),
|
|
2317
|
+
+ data: { id: this.objectId, [ATTR_TYPE]: TYPE_DEFAULT, title: 'Activity' },
|
|
2318
|
+
+ };
|
|
2319
|
+
+ const newCursors: DataSourceCursor[] = [{ spaceId: this.spaceId, resourceId: this.documentId, cursor: 'v1' }];
|
|
2320
|
+
+ const activity: DocumentActivity[] | undefined = opts?.activity
|
|
2321
|
+
+ ? [
|
|
2322
|
+
+ {
|
|
2323
|
+
+ spaceId: this.spaceId,
|
|
2324
|
+
+ documentId: this.documentId,
|
|
2325
|
+
+ full: true,
|
|
2326
|
+
+ changes: [{ time: 1700000000000, ops: 1 }],
|
|
2327
|
+
+ },
|
|
2328
|
+
+ ]
|
|
2329
|
+
+ : undefined;
|
|
2330
|
+
+
|
|
2331
|
+
+ return { objects: [object], cursors: newCursors, activity };
|
|
2332
|
+
+ });
|
|
2333
|
+
+ }
|
|
2334
|
+
+}
|
|
2335
|
+
+
|
|
2336
|
+
describe('IndexEngine', () => {
|
|
2337
|
+
const setup = Effect.gen(function* () {
|
|
2338
|
+
const engine = new IndexEngine(yield* SqlClient.SqlClient);
|
|
2339
|
+
@@ -407,6 +454,32 @@ describe('IndexEngine', () => {
|
|
2340
|
+
}, Effect.provide(TestLayer)),
|
|
2341
|
+
);
|
|
2342
|
+
|
|
2343
|
+
+ it.effect(
|
|
2344
|
+
+ 'activity survives garbage collection and counts a replicated-again document once',
|
|
2345
|
+
+ Effect.fnUntraced(function* () {
|
|
2346
|
+
+ const { engine } = yield* setup;
|
|
2347
|
+
+ const spaceId = SpaceId.random();
|
|
2348
|
+
+ const documentId = 'doc-activity';
|
|
2349
|
+
+ const objectId = EntityId.random();
|
|
2350
|
+
+ const dataSource = new ActivityMockDataSource(spaceId, documentId, objectId);
|
|
2351
|
+
+ const indexAll = Effect.gen(function* () {
|
|
2352
|
+
+ let done = false;
|
|
2353
|
+
+ while (!done) {
|
|
2354
|
+
+ done = (yield* engine.update(Context.default(), dataSource, { spaceId: null })).done;
|
|
2355
|
+
+ }
|
|
2356
|
+
+ });
|
|
2357
|
+
+
|
|
2358
|
+
+ yield* indexAll;
|
|
2359
|
+
+ expect(yield* engine.queryActivity({ spaceId })).toEqual([expect.objectContaining({ documentId, changes: 1 })]);
|
|
2360
|
+
+
|
|
2361
|
+
+ yield* engine.deleteObjects({ spaceId, documentIds: [documentId], objects: [] });
|
|
2362
|
+
+ expect(yield* engine.queryActivity({ spaceId })).toHaveLength(1);
|
|
2363
|
+
+
|
|
2364
|
+
+ yield* indexAll;
|
|
2365
|
+
+ expect(yield* engine.queryActivity({ spaceId })).toEqual([expect.objectContaining({ documentId, changes: 1 })]);
|
|
2366
|
+
+ }, Effect.provide(TestLayer)),
|
|
2367
|
+
+ );
|
|
2368
|
+
+
|
|
2369
|
+
describe('secondary indexes', () => {
|
|
2370
|
+
const makeObject = (spaceId: SpaceId, documentId: string, title: string): IndexerObject => ({
|
|
2371
|
+
spaceId,
|
|
2372
|
+
diff --git a/packages/core/echo/index-core/src/index-engine.ts b/packages/core/echo/index-core/src/index-engine.ts
|
|
2373
|
+
index 291525cf..4da4e811 100644
|
|
2374
|
+
--- a/packages/core/echo/index-core/src/index-engine.ts
|
|
2375
|
+
+++ b/packages/core/echo/index-core/src/index-engine.ts
|
|
2376
|
+
@@ -12,10 +12,13 @@ import { SpanAttributes } from '@dxos/effect';
|
|
2377
|
+
import type { EntityId, SpaceId, URI } from '@dxos/keys';
|
|
2378
|
+
|
|
2379
|
+
import { ConvergenceKeyIntentStore } from './convergence-key-intent-store.ts';
|
|
2380
|
+
-import { type IndexDataSource } from './data-source.ts';
|
|
2381
|
+
+import { type DataSourceCursor, type IndexDataSource } from './data-source.ts';
|
|
2382
|
+
import { type IndexCursor, IndexTracker } from './index-tracker.ts';
|
|
2383
|
+
import { IndexedObjectSource } from './indexed-object-source.ts';
|
|
2384
|
+
import {
|
|
2385
|
+
+ ActivityIndex,
|
|
2386
|
+
+ type ActivityQuery,
|
|
2387
|
+
+ type ActivityRow,
|
|
2388
|
+
type EntityMeta,
|
|
2389
|
+
EntityMetaIndex,
|
|
2390
|
+
FtsIndex,
|
|
2391
|
+
@@ -106,6 +109,7 @@ const INDEX_NAMES = {
|
|
2392
|
+
// Bumped for `propPathNormalized`, which the compiled query path matches reference paths on.
|
|
2393
|
+
reverseRef: 'reverseRef3',
|
|
2394
|
+
fts: 'fts7',
|
|
2395
|
+
+ activity: 'activity',
|
|
2396
|
+
} as const;
|
|
2397
|
+
|
|
2398
|
+
/** `json_type(x, path)`, which the compiled query path relies on, returns NULL for a missing path only from here. */
|
|
2399
|
+
@@ -122,6 +126,20 @@ const compareVersions = (version: string, minimum: readonly number[]): number =>
|
|
2400
|
+
return 0;
|
|
2401
|
+
};
|
|
2402
|
+
|
|
2403
|
+
+/** A source may hand back every cursor it holds, so progress is a cursor whose position changed. */
|
|
2404
|
+
+const anyCursorMoved = (
|
|
2405
|
+
+ sourceName: string,
|
|
2406
|
+
+ previous: readonly IndexCursor[],
|
|
2407
|
+
+ updated: readonly DataSourceCursor[],
|
|
2408
|
+
+): boolean => {
|
|
2409
|
+
+ const positions = new Map(
|
|
2410
|
+
+ previous
|
|
2411
|
+
+ .filter((cursor) => cursor.sourceName === sourceName)
|
|
2412
|
+
+ .map((cursor) => [`${cursor.spaceId}/${cursor.resourceId}`, cursor.cursor]),
|
|
2413
|
+
+ );
|
|
2414
|
+
+ return updated.some((cursor) => positions.get(`${cursor.spaceId}/${cursor.resourceId}`) !== cursor.cursor);
|
|
2415
|
+
+};
|
|
2416
|
+
+
|
|
2417
|
+
export class IndexEngine {
|
|
2418
|
+
readonly #sql: SqlClient.SqlClient;
|
|
2419
|
+
|
|
2420
|
+
@@ -132,6 +150,7 @@ export class IndexEngine {
|
|
2421
|
+
readonly #ftsIndex: FtsIndex;
|
|
2422
|
+
readonly #objectSnapshotIndex: ObjectSnapshotIndex;
|
|
2423
|
+
readonly #reverseRefIndex: ReverseRefIndex;
|
|
2424
|
+
+ readonly #activityIndex: ActivityIndex;
|
|
2425
|
+
readonly #convergenceKeyIntents: ConvergenceKeyIntentStore;
|
|
2426
|
+
readonly #indexedObjectSource: IndexedObjectSource;
|
|
2427
|
+
|
|
2428
|
+
@@ -142,6 +161,7 @@ export class IndexEngine {
|
|
2429
|
+
this.#ftsIndex = new FtsIndex(sql);
|
|
2430
|
+
this.#objectSnapshotIndex = new ObjectSnapshotIndex(sql);
|
|
2431
|
+
this.#reverseRefIndex = new ReverseRefIndex(sql);
|
|
2432
|
+
+ this.#activityIndex = new ActivityIndex(sql);
|
|
2433
|
+
this.#convergenceKeyIntents = new ConvergenceKeyIntentStore(sql);
|
|
2434
|
+
this.#indexedObjectSource = new IndexedObjectSource(sql);
|
|
2435
|
+
}
|
|
2436
|
+
@@ -164,6 +184,7 @@ export class IndexEngine {
|
|
2437
|
+
yield* this.#ftsIndex.migrate();
|
|
2438
|
+
yield* this.#objectSnapshotIndex.migrate();
|
|
2439
|
+
yield* this.#reverseRefIndex.migrate();
|
|
2440
|
+
+ yield* this.#activityIndex.migrate();
|
|
2441
|
+
yield* this.#convergenceKeyIntents.migrate();
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
@@ -184,6 +205,10 @@ export class IndexEngine {
|
|
2445
|
+
return this.#reverseRefIndex.query(query);
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
+ queryActivity(query: ActivityQuery): Effect.Effect<readonly ActivityRow[], SqlError.SqlError> {
|
|
2449
|
+
+ return this.#activityIndex.query(query);
|
|
2450
|
+
+ }
|
|
2451
|
+
+
|
|
2452
|
+
/**
|
|
2453
|
+
* Referrers of one target in one space, joined to the object metadata for the referrer's
|
|
2454
|
+
* document (see {@link ReverseRefIndex.queryReferrers}).
|
|
2455
|
+
@@ -421,6 +446,18 @@ export class IndexEngine {
|
|
2456
|
+
result.done = result.done && doneReverseRefIndex;
|
|
2457
|
+
accumulateIndexingResult(result, reverseRefObjects);
|
|
2458
|
+
|
|
2459
|
+
+ const activity = yield* this.#updateActivity(ctx, dataSource, {
|
|
2460
|
+
+ spaceId: opts.spaceId,
|
|
2461
|
+
+ limit: opts.limit,
|
|
2462
|
+
+ cursors: cursorsByIndex.get(INDEX_NAMES.activity) ?? [],
|
|
2463
|
+
+ });
|
|
2464
|
+
+ result.updated += activity.updated;
|
|
2465
|
+
+ result.done = result.done && activity.done;
|
|
2466
|
+
+ for (const { spaceId, documentId } of activity.documents) {
|
|
2467
|
+
+ result.spaces.add(spaceId);
|
|
2468
|
+
+ result.documents.add(documentId);
|
|
2469
|
+
+ }
|
|
2470
|
+
+
|
|
2471
|
+
return result as IndexingResult;
|
|
2472
|
+
}).pipe(
|
|
2473
|
+
// The snapshot must be dropped even when a pass fails, or the next pass would diff against
|
|
2474
|
+
@@ -506,4 +543,48 @@ export class IndexEngine {
|
|
2475
|
+
);
|
|
2476
|
+
}).pipe(Effect.withSpan('IndexEngine.#update'), SpanAttributes.annotateSpace(opts.spaceId));
|
|
2477
|
+
}
|
|
2478
|
+
+
|
|
2479
|
+
+ #updateActivity(
|
|
2480
|
+
+ ctx: Context,
|
|
2481
|
+
+ source: IndexDataSource,
|
|
2482
|
+
+ opts: { spaceId: SpaceId | null; limit?: number; cursors: IndexCursor[] },
|
|
2483
|
+
+ ): Effect.Effect<
|
|
2484
|
+
+ { updated: number; done: boolean; documents: readonly { spaceId: SpaceId; documentId: string }[] },
|
|
2485
|
+
+ SqlError.SqlError
|
|
2486
|
+
+ > {
|
|
2487
|
+
+ return Effect.gen({ self: this }, function* () {
|
|
2488
|
+
+ const sql = this.#sql;
|
|
2489
|
+
+
|
|
2490
|
+
+ const { cursors: updatedCursors, activity = [] } = yield* source.getChangedObjects(ctx, opts.cursors, {
|
|
2491
|
+
+ limit: opts.limit,
|
|
2492
|
+
+ activity: true,
|
|
2493
|
+
+ objects: false,
|
|
2494
|
+
+ });
|
|
2495
|
+
+
|
|
2496
|
+
+ if (updatedCursors.length === 0) {
|
|
2497
|
+
+ return { updated: 0, done: true, documents: [] };
|
|
2498
|
+
+ }
|
|
2499
|
+
+
|
|
2500
|
+
+ return yield* sql.withTransaction(
|
|
2501
|
+
+ Effect.gen({ self: this }, function* () {
|
|
2502
|
+
+ yield* this.#activityIndex.record(activity);
|
|
2503
|
+
+ yield* this.#tracker.updateCursors(
|
|
2504
|
+
+ updatedCursors.map((_): IndexCursor => ({
|
|
2505
|
+
+ indexName: INDEX_NAMES.activity,
|
|
2506
|
+
+ spaceId: _.spaceId,
|
|
2507
|
+
+ sourceName: source.sourceName,
|
|
2508
|
+
+ resourceId: _.resourceId,
|
|
2509
|
+
+ cursor: _.cursor,
|
|
2510
|
+
+ })),
|
|
2511
|
+
+ );
|
|
2512
|
+
+ const updated = activity.reduce((sum, entry) => sum + entry.changes.length, 0);
|
|
2513
|
+
+ return {
|
|
2514
|
+
+ updated,
|
|
2515
|
+
+ done: !anyCursorMoved(source.sourceName, opts.cursors, updatedCursors),
|
|
2516
|
+
+ documents: activity,
|
|
2517
|
+
+ };
|
|
2518
|
+
+ }),
|
|
2519
|
+
+ );
|
|
2520
|
+
+ }).pipe(Effect.withSpan('IndexEngine.#updateActivity'), SpanAttributes.annotateSpace(opts.spaceId));
|
|
2521
|
+
+ }
|
|
2522
|
+
}
|
|
2523
|
+
diff --git a/packages/core/echo/index-core/src/index.ts b/packages/core/echo/index-core/src/index.ts
|
|
2524
|
+
index 16438b3e..48811927 100644
|
|
2525
|
+
--- a/packages/core/echo/index-core/src/index.ts
|
|
2526
|
+
+++ b/packages/core/echo/index-core/src/index.ts
|
|
2527
|
+
@@ -7,7 +7,8 @@ export { IndexEngine, type IndexingResult } from './index-engine.ts';
|
|
2528
|
+
export { IndexedObjectSource } from './indexed-object-source.ts';
|
|
2529
|
+
export { type IndexCursor, IndexTracker } from './index-tracker.ts';
|
|
2530
|
+
export { ConvergenceKeyIntentStore } from './convergence-key-intent-store.ts';
|
|
2531
|
+
-export { type Index, type IndexerObject } from './indexes/interface.ts';
|
|
2532
|
+
+export { ActivityIndex, type ActivityRow } from './indexes/activity-index.ts';
|
|
2533
|
+
+export { type ChangeSummary, type DocumentActivity, type Index, type IndexerObject } from './indexes/interface.ts';
|
|
2534
|
+
export { FtsIndex, type FtsQuery, buildFtsCondition } from './indexes/fts-index.ts';
|
|
2535
|
+
export { ObjectSnapshotIndex } from './indexes/object-snapshot-index.ts';
|
|
2536
|
+
export {
|
|
2537
|
+
diff --git a/packages/core/echo/index-core/src/indexes/activity-index.test.ts b/packages/core/echo/index-core/src/indexes/activity-index.test.ts
|
|
2538
|
+
new file mode 100644
|
|
2539
|
+
index 00000000..d4d3a908
|
|
2540
|
+
--- /dev/null
|
|
2541
|
+
+++ b/packages/core/echo/index-core/src/indexes/activity-index.test.ts
|
|
2542
|
+
@@ -0,0 +1,72 @@
|
|
2543
|
+
+//
|
|
2544
|
+
+// Copyright 2026 DXOS.org
|
|
2545
|
+
+//
|
|
2546
|
+
+
|
|
2547
|
+
+import * as SqliteClient from '@effect/sql-sqlite-node/SqliteClient';
|
|
2548
|
+
+import { describe, expect, it } from '@effect/vitest';
|
|
2549
|
+
+import * as Effect from 'effect/Effect';
|
|
2550
|
+
+import * as Layer from 'effect/Layer';
|
|
2551
|
+
+import * as Reactivity from 'effect/unstable/reactivity/Reactivity';
|
|
2552
|
+
+import * as SqlClient from 'effect/unstable/sql/SqlClient';
|
|
2553
|
+
+
|
|
2554
|
+
+import { SpaceId } from '@dxos/keys';
|
|
2555
|
+
+
|
|
2556
|
+
+import { ActivityIndex, HOUR_MS } from './activity-index.ts';
|
|
2557
|
+
+
|
|
2558
|
+
+const TestLayer = SqliteClient.layer({ filename: ':memory:' }).pipe(Layer.provideMerge(Reactivity.layer));
|
|
2559
|
+
+
|
|
2560
|
+
+const HOUR = Math.floor(1700000000000 / HOUR_MS) * HOUR_MS;
|
|
2561
|
+
+
|
|
2562
|
+
+const setup = Effect.gen(function* () {
|
|
2563
|
+
+ const index = new ActivityIndex(yield* SqlClient.SqlClient);
|
|
2564
|
+
+ yield* index.migrate();
|
|
2565
|
+
+ return index;
|
|
2566
|
+
+});
|
|
2567
|
+
+
|
|
2568
|
+
+describe('ActivityIndex', () => {
|
|
2569
|
+
+ it.effect('buckets each document by hour and accumulates across record calls', () =>
|
|
2570
|
+
+ Effect.gen(function* () {
|
|
2571
|
+
+ const index = yield* setup;
|
|
2572
|
+
+ const spaceId = SpaceId.random();
|
|
2573
|
+
+
|
|
2574
|
+
+ yield* index.record([
|
|
2575
|
+
+ {
|
|
2576
|
+
+ spaceId,
|
|
2577
|
+
+ documentId: 'a',
|
|
2578
|
+
+ full: true,
|
|
2579
|
+
+ changes: [
|
|
2580
|
+
+ { time: HOUR + 1, ops: 3 },
|
|
2581
|
+
+ { time: HOUR + 60_000, ops: 2 },
|
|
2582
|
+
+ ],
|
|
2583
|
+
+ },
|
|
2584
|
+
+ { spaceId, documentId: 'b', full: true, changes: [{ time: HOUR + 5, ops: 1 }] },
|
|
2585
|
+
+ ]);
|
|
2586
|
+
+ yield* index.record([{ spaceId, documentId: 'a', full: false, changes: [{ time: HOUR + HOUR_MS, ops: 1 }] }]);
|
|
2587
|
+
+
|
|
2588
|
+
+ expect(yield* index.query({ spaceId })).toEqual([
|
|
2589
|
+
+ { documentId: 'a', hour: HOUR, changes: 2, ops: 5 },
|
|
2590
|
+
+ { documentId: 'b', hour: HOUR, changes: 1, ops: 1 },
|
|
2591
|
+
+ { documentId: 'a', hour: HOUR + HOUR_MS, changes: 1, ops: 1 },
|
|
2592
|
+
+ ]);
|
|
2593
|
+
+ expect(yield* index.query({ spaceId, documentIds: ['b'] })).toEqual([
|
|
2594
|
+
+ { documentId: 'b', hour: HOUR, changes: 1, ops: 1 },
|
|
2595
|
+
+ ]);
|
|
2596
|
+
+ expect(yield* index.query({ spaceId, documentIds: [] })).toEqual([]);
|
|
2597
|
+
+ }).pipe(Effect.provide(TestLayer)),
|
|
2598
|
+
+ );
|
|
2599
|
+
+
|
|
2600
|
+
+ it.effect('a full entry replaces the document, so a replayed history counts once', () =>
|
|
2601
|
+
+ Effect.gen(function* () {
|
|
2602
|
+
+ const index = yield* setup;
|
|
2603
|
+
+ const spaceId = SpaceId.random();
|
|
2604
|
+
+ const history = { spaceId, documentId: 'a', full: true, changes: [{ time: HOUR, ops: 1 }] };
|
|
2605
|
+
+
|
|
2606
|
+
+ yield* index.record([history]);
|
|
2607
|
+
+ yield* index.record([history]);
|
|
2608
|
+
+ expect(yield* index.query({ spaceId })).toEqual([{ documentId: 'a', hour: HOUR, changes: 1, ops: 1 }]);
|
|
2609
|
+
+
|
|
2610
|
+
+ yield* index.record([{ spaceId, documentId: 'a', full: true, changes: [] }]);
|
|
2611
|
+
+ expect(yield* index.query({ spaceId })).toEqual([]);
|
|
2612
|
+
+ }).pipe(Effect.provide(TestLayer)),
|
|
2613
|
+
+ );
|
|
2614
|
+
+});
|
|
2615
|
+
diff --git a/packages/core/echo/index-core/src/indexes/activity-index.ts b/packages/core/echo/index-core/src/indexes/activity-index.ts
|
|
2616
|
+
new file mode 100644
|
|
2617
|
+
index 00000000..051ad834
|
|
2618
|
+
--- /dev/null
|
|
2619
|
+
+++ b/packages/core/echo/index-core/src/indexes/activity-index.ts
|
|
2620
|
+
@@ -0,0 +1,113 @@
|
|
2621
|
+
+//
|
|
2622
|
+
+// Copyright 2026 DXOS.org
|
|
2623
|
+
+//
|
|
2624
|
+
+
|
|
2625
|
+
+import * as Effect from 'effect/Effect';
|
|
2626
|
+
+import * as Migrator from 'effect/unstable/sql/Migrator';
|
|
2627
|
+
+import * as SqlClient from 'effect/unstable/sql/SqlClient';
|
|
2628
|
+
+import type * as SqlError from 'effect/unstable/sql/SqlError';
|
|
2629
|
+
+
|
|
2630
|
+
+import type { SpaceId } from '@dxos/keys';
|
|
2631
|
+
+
|
|
2632
|
+
+import { MIGRATIONS, MIGRATIONS_TABLE } from '../migrations/activity/index.ts';
|
|
2633
|
+
+import { chunkArray } from '../utils.ts';
|
|
2634
|
+
+import type { DocumentActivity } from './interface.ts';
|
|
2635
|
+
+
|
|
2636
|
+
+export const HOUR_MS = 3_600_000;
|
|
2637
|
+
+
|
|
2638
|
+
+/** One document's changes in one UTC hour. */
|
|
2639
|
+
+export type ActivityRow = {
|
|
2640
|
+
+ readonly documentId: string;
|
|
2641
|
+
+ /** Start of the hour, unix ms. */
|
|
2642
|
+
+ readonly hour: number;
|
|
2643
|
+
+ readonly changes: number;
|
|
2644
|
+
+ readonly ops: number;
|
|
2645
|
+
+};
|
|
2646
|
+
+
|
|
2647
|
+
+export type ActivityQuery = {
|
|
2648
|
+
+ spaceId: SpaceId;
|
|
2649
|
+
+ /** Restrict to these documents; all documents in the space when omitted. */
|
|
2650
|
+
+ documentIds?: readonly string[];
|
|
2651
|
+
+};
|
|
2652
|
+
+
|
|
2653
|
+
+/**
|
|
2654
|
+
+ * Counts Automerge changes (and their ops) per document per UTC hour.
|
|
2655
|
+
+ *
|
|
2656
|
+
+ * Rows outlive their document: deleting an object does not undo the edits made to it, so garbage
|
|
2657
|
+
+ * collection leaves this index alone.
|
|
2658
|
+
+ */
|
|
2659
|
+
+export class ActivityIndex {
|
|
2660
|
+
+ readonly #sql: SqlClient.SqlClient;
|
|
2661
|
+
+
|
|
2662
|
+
+ constructor(sql: SqlClient.SqlClient) {
|
|
2663
|
+
+ this.#sql = sql;
|
|
2664
|
+
+ }
|
|
2665
|
+
+
|
|
2666
|
+
+ migrate = Effect.fn('ActivityIndex.migrate')(() =>
|
|
2667
|
+
+ Migrator.make({})({ loader: Migrator.fromRecord(MIGRATIONS), table: MIGRATIONS_TABLE }).pipe(
|
|
2668
|
+
+ Effect.catchTag('MigrationError', (error) => Effect.die(error)),
|
|
2669
|
+
+ Effect.asVoid,
|
|
2670
|
+
+ Effect.provideService(SqlClient.SqlClient, this.#sql),
|
|
2671
|
+
+ ),
|
|
2672
|
+
+ );
|
|
2673
|
+
+
|
|
2674
|
+
+ /** Accumulates each document's changes into its hour buckets; a `full` entry replaces the document's rows. */
|
|
2675
|
+
+ record = Effect.fn('ActivityIndex.record')(
|
|
2676
|
+
+ (activity: readonly DocumentActivity[]): Effect.Effect<void, SqlError.SqlError> =>
|
|
2677
|
+
+ Effect.gen({ self: this }, function* () {
|
|
2678
|
+
+ const sql = this.#sql;
|
|
2679
|
+
+ for (const entry of activity) {
|
|
2680
|
+
+ if (entry.full) {
|
|
2681
|
+
+ yield* sql`DELETE FROM activity WHERE spaceId = ${entry.spaceId} AND documentId = ${entry.documentId}`;
|
|
2682
|
+
+ }
|
|
2683
|
+
+
|
|
2684
|
+
+ const buckets = new Map<number, { changes: number; ops: number }>();
|
|
2685
|
+
+ for (const change of entry.changes) {
|
|
2686
|
+
+ const hour = Math.floor(change.time / HOUR_MS);
|
|
2687
|
+
+ const bucket = buckets.get(hour) ?? { changes: 0, ops: 0 };
|
|
2688
|
+
+ bucket.changes += 1;
|
|
2689
|
+
+ bucket.ops += change.ops;
|
|
2690
|
+
+ buckets.set(hour, bucket);
|
|
2691
|
+
+ }
|
|
2692
|
+
+
|
|
2693
|
+
+ yield* Effect.forEach(
|
|
2694
|
+
+ buckets,
|
|
2695
|
+
+ ([hour, bucket]) => sql`
|
|
2696
|
+
+ INSERT INTO activity (spaceId, documentId, hour, changes, ops)
|
|
2697
|
+
+ VALUES (${entry.spaceId}, ${entry.documentId}, ${hour}, ${bucket.changes}, ${bucket.ops})
|
|
2698
|
+
+ ON CONFLICT(spaceId, documentId, hour)
|
|
2699
|
+
+ DO UPDATE SET changes = changes + excluded.changes, ops = ops + excluded.ops
|
|
2700
|
+
+ `,
|
|
2701
|
+
+ { discard: true },
|
|
2702
|
+
+ );
|
|
2703
|
+
+ }
|
|
2704
|
+
+ }),
|
|
2705
|
+
+ );
|
|
2706
|
+
+
|
|
2707
|
+
+ /** Hour buckets for one space, ordered by hour then document. */
|
|
2708
|
+
+ query = Effect.fn('ActivityIndex.query')(
|
|
2709
|
+
+ ({ spaceId, documentIds }: ActivityQuery): Effect.Effect<readonly ActivityRow[], SqlError.SqlError> =>
|
|
2710
|
+
+ Effect.gen({ self: this }, function* () {
|
|
2711
|
+
+ const sql = this.#sql;
|
|
2712
|
+
+ const select = (documents: readonly string[] | null) => sql<{
|
|
2713
|
+
+ documentId: string;
|
|
2714
|
+
+ hour: number;
|
|
2715
|
+
+ changes: number;
|
|
2716
|
+
+ ops: number;
|
|
2717
|
+
+ }>`
|
|
2718
|
+
+ SELECT documentId, hour, changes, ops FROM activity
|
|
2719
|
+
+ WHERE spaceId = ${spaceId}
|
|
2720
|
+
+ ${documents ? sql`AND documentId IN ${sql.in(documents)}` : sql``}
|
|
2721
|
+
+ `;
|
|
2722
|
+
+
|
|
2723
|
+
+ const rows =
|
|
2724
|
+
+ documentIds === undefined
|
|
2725
|
+
+ ? yield* select(null)
|
|
2726
|
+
+ : (yield* Effect.forEach(chunkArray(documentIds), (chunk) => select(chunk))).flat();
|
|
2727
|
+
+
|
|
2728
|
+
+ return rows
|
|
2729
|
+
+ .map((row) => ({ ...row, hour: row.hour * HOUR_MS }))
|
|
2730
|
+
+ .sort((a, b) => a.hour - b.hour || a.documentId.localeCompare(b.documentId));
|
|
2731
|
+
+ }),
|
|
2732
|
+
+ );
|
|
2733
|
+
+}
|
|
2734
|
+
diff --git a/packages/core/echo/index-core/src/indexes/index.ts b/packages/core/echo/index-core/src/indexes/index.ts
|
|
2735
|
+
index 247c2a07..cfce5ce0 100644
|
|
2736
|
+
--- a/packages/core/echo/index-core/src/indexes/index.ts
|
|
2737
|
+
+++ b/packages/core/echo/index-core/src/indexes/index.ts
|
|
2738
|
+
@@ -2,9 +2,10 @@
|
|
2739
|
+
// Copyright 2026 DXOS.org
|
|
2740
|
+
//
|
|
2741
|
+
|
|
2742
|
+
+export * from './activity-index.ts';
|
|
2743
|
+
+export * from './entity-meta-index.ts';
|
|
2744
|
+
export * from './fts-index.ts';
|
|
2745
|
+
+export * from './interface.ts';
|
|
2746
|
+
export * from './object-snapshot-index.ts';
|
|
2747
|
+
-export * from './entity-meta-index.ts';
|
|
2748
|
+
export * from './reverse-ref-index.ts';
|
|
2749
|
+
-export * from './interface.ts';
|
|
2750
|
+
export * from './text-extractor.ts';
|
|
2751
|
+
diff --git a/packages/core/echo/index-core/src/indexes/interface.ts b/packages/core/echo/index-core/src/indexes/interface.ts
|
|
2752
|
+
index 3c4c9f1f..07cd2ce9 100644
|
|
2753
|
+
--- a/packages/core/echo/index-core/src/indexes/interface.ts
|
|
2754
|
+
+++ b/packages/core/echo/index-core/src/indexes/interface.ts
|
|
2755
|
+
@@ -61,6 +61,26 @@ export interface IndexerObject {
|
|
2756
|
+
updatedAt: number;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
+export interface ChangeSummary {
|
|
2760
|
+
+ /** Author's clock, unix ms. */
|
|
2761
|
+
+ time: number;
|
|
2762
|
+
+ ops: number;
|
|
2763
|
+
+}
|
|
2764
|
+
+
|
|
2765
|
+
+/**
|
|
2766
|
+
+ * Changes to one document since its activity cursor.
|
|
2767
|
+
+ */
|
|
2768
|
+
+export interface DocumentActivity {
|
|
2769
|
+
+ spaceId: SpaceId;
|
|
2770
|
+
+ documentId: string;
|
|
2771
|
+
+ /**
|
|
2772
|
+
+ * `changes` is the document's whole history and replaces whatever was recorded for it; with no
|
|
2773
|
+
+ * changes the document's rows are discarded (a branch document).
|
|
2774
|
+
+ */
|
|
2775
|
+
+ full: boolean;
|
|
2776
|
+
+ changes: readonly ChangeSummary[];
|
|
2777
|
+
+}
|
|
2778
|
+
+
|
|
2779
|
+
/**
|
|
2780
|
+
* SQLite-based index for storing and querying object data.
|
|
2781
|
+
*/
|
|
2782
|
+
diff --git a/packages/core/echo/index-core/src/migrations/activity/0001_init.sql b/packages/core/echo/index-core/src/migrations/activity/0001_init.sql
|
|
2783
|
+
new file mode 100644
|
|
2784
|
+
index 00000000..7df383e1
|
|
2785
|
+
--- /dev/null
|
|
2786
|
+
+++ b/packages/core/echo/index-core/src/migrations/activity/0001_init.sql
|
|
2787
|
+
@@ -0,0 +1,11 @@
|
|
2788
|
+
+--
|
|
2789
|
+
+-- Immutable: recorded in `activity_migrations` and never re-run.
|
|
2790
|
+
+--
|
|
2791
|
+
+CREATE TABLE IF NOT EXISTS activity (
|
|
2792
|
+
+ spaceId TEXT NOT NULL,
|
|
2793
|
+
+ documentId TEXT NOT NULL,
|
|
2794
|
+
+ hour INTEGER NOT NULL,
|
|
2795
|
+
+ changes INTEGER NOT NULL,
|
|
2796
|
+
+ ops INTEGER NOT NULL,
|
|
2797
|
+
+ PRIMARY KEY (spaceId, documentId, hour)
|
|
2798
|
+
+);
|
|
2799
|
+
diff --git a/packages/core/echo/index-core/src/migrations/activity/index.ts b/packages/core/echo/index-core/src/migrations/activity/index.ts
|
|
2800
|
+
new file mode 100644
|
|
2801
|
+
index 00000000..07115fae
|
|
2802
|
+
--- /dev/null
|
|
2803
|
+
+++ b/packages/core/echo/index-core/src/migrations/activity/index.ts
|
|
2804
|
+
@@ -0,0 +1,13 @@
|
|
2805
|
+
+//
|
|
2806
|
+
+// Copyright 2026 DXOS.org
|
|
2807
|
+
+//
|
|
2808
|
+
+
|
|
2809
|
+
+import { SqlMigrations } from '@dxos/sql-sqlite';
|
|
2810
|
+
+
|
|
2811
|
+
+import init from './0001_init.sql?raw';
|
|
2812
|
+
+
|
|
2813
|
+
+export const MIGRATIONS = {
|
|
2814
|
+
+ '0001_init': SqlMigrations.apply(init),
|
|
2815
|
+
+};
|
|
2816
|
+
+
|
|
2817
|
+
+export const MIGRATIONS_TABLE = 'activity_migrations';
|
|
2818
|
+
diff --git a/packages/core/echo/index-core/src/migrations/migrations.test.ts b/packages/core/echo/index-core/src/migrations/migrations.test.ts
|
|
2819
|
+
index 8106a53f..71cc0e20 100644
|
|
2820
|
+
--- a/packages/core/echo/index-core/src/migrations/migrations.test.ts
|
|
2821
|
+
+++ b/packages/core/echo/index-core/src/migrations/migrations.test.ts
|
|
2822
|
+
@@ -12,6 +12,8 @@ import { test } from 'vitest';
|
|
2823
|
+
|
|
2824
|
+
import { SqlMigrations } from '@dxos/sql-sqlite';
|
|
2825
|
+
|
|
2826
|
+
+import activityInit from './activity/0001_init.sql?raw';
|
|
2827
|
+
+import { MIGRATIONS as ACTIVITY } from './activity/index.ts';
|
|
2828
|
+
import entityMetaInit from './entity-meta/0001_init.sql?raw';
|
|
2829
|
+
import { MIGRATIONS as ENTITY_META, MIGRATIONS_TABLE as ENTITY_META_TABLE } from './entity-meta/index.ts';
|
|
2830
|
+
import ftsInit from './fts/0001_init.sql?raw';
|
|
2831
|
+
@@ -26,6 +28,7 @@ import { MIGRATIONS as TRACKER } from './tracker/index.ts';
|
|
2832
|
+
const TestLayer = SqliteClient.layer({ filename: ':memory:' });
|
|
2833
|
+
|
|
2834
|
+
const STORES = [
|
|
2835
|
+
+ { name: 'activity', init: activityInit, manifest: ACTIVITY },
|
|
2836
|
+
{ name: 'entity-meta', init: entityMetaInit, manifest: ENTITY_META },
|
|
2837
|
+
{ name: 'fts', init: ftsInit, manifest: FTS },
|
|
2838
|
+
{ name: 'object-snapshot', init: objectSnapshotInit, manifest: OBJECT_SNAPSHOT },
|
|
2839
|
+
diff --git a/packages/core/protocols/src/QueryService.ts b/packages/core/protocols/src/QueryService.ts
|
|
2840
|
+
index 7d78cc0f..3ee51e3c 100644
|
|
2841
|
+
--- a/packages/core/protocols/src/QueryService.ts
|
|
2842
|
+
+++ b/packages/core/protocols/src/QueryService.ts
|
|
2843
|
+
@@ -96,6 +96,11 @@ export const QueryResult = Schema.Struct({
|
|
2844
|
+
* serialized group key.
|
|
2845
|
+
*/
|
|
2846
|
+
aggregates: Schema.optional(Schema.String),
|
|
2847
|
+
+ /**
|
|
2848
|
+
+ * JSON-encoded plain record for a result that is not an entity (an Automerge change selected by
|
|
2849
|
+
+ * `Filter.changes`); no object fields are sent, and `id` is the record's own identity.
|
|
2850
|
+
+ */
|
|
2851
|
+
+ recordJson: Schema.optional(Schema.String),
|
|
2852
|
+
});
|
|
2853
|
+
export interface QueryResult extends Schema.Schema.Type<typeof QueryResult> {}
|
|
2854
|
+
|
|
2855
|
+
diff --git a/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/SpaceHomeDashboard.stories.tsx b/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/SpaceHomeDashboard.stories.tsx
|
|
2856
|
+
index fa3afd24..345e7f39 100644
|
|
2857
|
+
--- a/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/SpaceHomeDashboard.stories.tsx
|
|
2858
|
+
+++ b/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/SpaceHomeDashboard.stories.tsx
|
|
2859
|
+
@@ -93,7 +93,6 @@ export default meta;
|
|
2860
|
+
|
|
2861
|
+
type Story = StoryObj<typeof meta>;
|
|
2862
|
+
|
|
2863
|
+
-/** Counts every object by type and places them all on today, the day they were written. */
|
|
2864
|
+
export const Default: Story = {
|
|
2865
|
+
play: async ({ canvasElement }) => {
|
|
2866
|
+
const canvas = within(canvasElement);
|
|
2867
|
+
@@ -103,7 +102,7 @@ export const Default: Story = {
|
|
2868
|
+
await canvas.findByTestId('space-home-dashboard.objects', {}, { timeout: 10_000 });
|
|
2869
|
+
await waitFor(() => expect(valueOf('objects')).toBe(String(OBJECT_COUNT + 1)), { timeout: 10_000 });
|
|
2870
|
+
await expect(valueOf('types')).toBe('3');
|
|
2871
|
+
- await expect(valueOf('active-days')).toBe('1');
|
|
2872
|
+
+ await waitFor(() => expect(valueOf('active-days')).toBe('1'), { timeout: 10_000 });
|
|
2873
|
+
await expect(canvasElement.querySelectorAll('[data-level]:not([data-level="0"])')).toHaveLength(1);
|
|
2874
|
+
},
|
|
2875
|
+
};
|
|
2876
|
+
diff --git a/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/SpaceHomeDashboard.tsx b/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/SpaceHomeDashboard.tsx
|
|
2877
|
+
index 23406db1..5bc32545 100644
|
|
2878
|
+
--- a/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/SpaceHomeDashboard.tsx
|
|
2879
|
+
+++ b/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/SpaceHomeDashboard.tsx
|
|
2880
|
+
@@ -15,7 +15,7 @@ import { Dashboard } from '@dxos/react-ui-dashboard';
|
|
2881
|
+
import { SPACE_STATS_QUERY, countObjects, countTypenames } from '#dashboard';
|
|
2882
|
+
import { meta } from '#meta';
|
|
2883
|
+
|
|
2884
|
+
-import { dailyActivityQuery, toActivity } from './activity.ts';
|
|
2885
|
+
+import { HOURLY_ACTIVITY_QUERY, toActivity } from './activity.ts';
|
|
2886
|
+
|
|
2887
|
+
const STAT_IDS = ['objects', 'types', 'collections', 'members', 'active-days', 'plugins'] as const;
|
|
2888
|
+
|
|
2889
|
+
@@ -30,10 +30,6 @@ type SpaceHomeDashboardProps = {
|
|
2890
|
+
|
|
2891
|
+
const COLLECTION_TYPENAME = Type.getTypename(Collection.Collection);
|
|
2892
|
+
|
|
2893
|
+
-/**
|
|
2894
|
+
- * Space stats and activity matrix for the Home article: a count of objects by type and a count by
|
|
2895
|
+
- * local last-updated day, both answered by the host from index rows, so no object is loaded to draw them.
|
|
2896
|
+
- */
|
|
2897
|
+
export const SpaceHomeDashboard = ({ space, stats = STAT_IDS, onClose }: SpaceHomeDashboardProps) => {
|
|
2898
|
+
const { t } = useTranslation(meta.profile.key);
|
|
2899
|
+
const members = useMembers(space?.key);
|
|
2900
|
+
@@ -43,10 +39,9 @@ export const SpaceHomeDashboard = ({ space, stats = STAT_IDS, onClose }: SpaceHo
|
|
2901
|
+
const enabled = useAtomValue(manager.enabled);
|
|
2902
|
+
const plugins = useMemo(() => enabled.filter((id) => !core.includes(id)).length, [core, enabled]);
|
|
2903
|
+
|
|
2904
|
+
- const dailyQuery = useMemo(() => dailyActivityQuery(Intl.DateTimeFormat().resolvedOptions().timeZone), []);
|
|
2905
|
+
const counts = useQuery(space?.db, SPACE_STATS_QUERY);
|
|
2906
|
+
- const days = useQuery(space?.db, dailyQuery);
|
|
2907
|
+
- const activity = useMemo(() => toActivity(days), [days]);
|
|
2908
|
+
+ const hours = useQuery(space?.db, HOURLY_ACTIVITY_QUERY);
|
|
2909
|
+
+ const activity = useMemo(() => toActivity(hours), [hours]);
|
|
2910
|
+
|
|
2911
|
+
const values: Record<SpaceStatId, number> = {
|
|
2912
|
+
'objects': countObjects(counts),
|
|
2913
|
+
diff --git a/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/activity.test.ts b/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/activity.test.ts
|
|
2914
|
+
index 9428ca73..42321efc 100644
|
|
2915
|
+
--- a/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/activity.test.ts
|
|
2916
|
+
+++ b/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/activity.test.ts
|
|
2917
|
+
@@ -7,15 +7,17 @@ import { describe, test } from 'vitest';
|
|
2918
|
+
import { toActivity } from './activity.ts';
|
|
2919
|
+
|
|
2920
|
+
describe('toActivity', () => {
|
|
2921
|
+
- test('gives each day one calendar entry and drops unknown days', ({ expect }) => {
|
|
2922
|
+
+ test('rolls hours into local days and drops unknown hours', ({ expect }) => {
|
|
2923
|
+
+ const at = (day: number, hour: number) => new Date(2026, 0, day, hour).getTime();
|
|
2924
|
+
const rows = [
|
|
2925
|
+
- { day: new Date(2026, 0, 5).getTime(), count: 3 },
|
|
2926
|
+
- { day: null, count: 4 },
|
|
2927
|
+
- { day: new Date(2026, 0, 6).getTime(), count: 5 },
|
|
2928
|
+
+ { hour: at(6, 9), count: 5 },
|
|
2929
|
+
+ { hour: at(5, 1), count: 3 },
|
|
2930
|
+
+ { hour: null, count: 4 },
|
|
2931
|
+
+ { hour: at(5, 23), count: 2 },
|
|
2932
|
+
];
|
|
2933
|
+
|
|
2934
|
+
expect(toActivity(rows)).toEqual([
|
|
2935
|
+
- { date: new Date(2026, 0, 5), value: 3 },
|
|
2936
|
+
+ { date: new Date(2026, 0, 5), value: 5 },
|
|
2937
|
+
{ date: new Date(2026, 0, 6), value: 5 },
|
|
2938
|
+
]);
|
|
2939
|
+
});
|
|
2940
|
+
diff --git a/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/activity.ts b/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/activity.ts
|
|
2941
|
+
index 84dbcd99..a82a9f5b 100644
|
|
2942
|
+
--- a/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/activity.ts
|
|
2943
|
+
+++ b/packages/plugins/plugin-space/src/containers/SpaceHomeDashboard/activity.ts
|
|
2944
|
+
@@ -5,19 +5,25 @@
|
|
2945
|
+
import { Aggregate, Filter, Query } from '@dxos/echo';
|
|
2946
|
+
import { type ActivityDatum } from '@dxos/react-ui-dashboard';
|
|
2947
|
+
|
|
2948
|
+
-/** Counts live objects by the local day in `timeZone` they were last updated; one row per day. */
|
|
2949
|
+
-export const dailyActivityQuery = (timeZone: string) =>
|
|
2950
|
+
- Query.select(Filter.everything()).aggregate({
|
|
2951
|
+
- day: Aggregate.updated('day', { timeZone }),
|
|
2952
|
+
- count: Aggregate.count(),
|
|
2953
|
+
- });
|
|
2954
|
+
+export const HOURLY_ACTIVITY_QUERY = Query.select(Filter.changes()).aggregate({
|
|
2955
|
+
+ hour: Aggregate.time('time', 'hour'),
|
|
2956
|
+
+ count: Aggregate.count(),
|
|
2957
|
+
+});
|
|
2958
|
+
|
|
2959
|
+
-/** A row of {@link dailyActivityQuery}. */
|
|
2960
|
+
-export type DayCount = {
|
|
2961
|
+
- readonly day: number | null;
|
|
2962
|
+
+export type HourCount = {
|
|
2963
|
+
+ readonly hour: number | null;
|
|
2964
|
+
readonly count: number;
|
|
2965
|
+
};
|
|
2966
|
+
|
|
2967
|
+
-/** One calendar entry per day; a `null` day (no timestamp recorded) has no square and is dropped. */
|
|
2968
|
+
-export const toActivity = (rows: readonly DayCount[]): ActivityDatum[] =>
|
|
2969
|
+
- rows.flatMap(({ day, count }) => (day === null ? [] : [{ date: new Date(day), value: count }]));
|
|
2970
|
+
+export const toActivity = (rows: readonly HourCount[]): ActivityDatum[] => {
|
|
2971
|
+
+ const days = new Map<number, number>();
|
|
2972
|
+
+ for (const { hour, count } of rows) {
|
|
2973
|
+
+ if (hour === null) {
|
|
2974
|
+
+ continue;
|
|
2975
|
+
+ }
|
|
2976
|
+
+ const date = new Date(hour);
|
|
2977
|
+
+ const day = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
2978
|
+
+ days.set(day, (days.get(day) ?? 0) + count);
|
|
2979
|
+
+ }
|
|
2980
|
+
+ return [...days].sort(([a], [b]) => a - b).map(([day, value]) => ({ date: new Date(day), value }));
|
|
2981
|
+
+};
|