@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.
Files changed (642) hide show
  1. package/LICENSE +102 -5
  2. package/PLUGIN.mdl +784 -0
  3. package/dist/lib/GitHubCapabilities.mjs +10 -0
  4. package/dist/lib/GitHubCapabilities.mjs.map +1 -0
  5. package/dist/lib/GitHubEvents.mjs +15 -0
  6. package/dist/lib/GitHubEvents.mjs.map +1 -0
  7. package/dist/lib/GitHubOperation.mjs +384 -0
  8. package/dist/lib/GitHubOperation.mjs.map +1 -0
  9. package/dist/lib/GitHubPlugin.mjs +16 -0
  10. package/dist/lib/GitHubPlugin.mjs.map +1 -0
  11. package/dist/lib/Walkthrough.mjs +59 -0
  12. package/dist/lib/Walkthrough.mjs.map +1 -0
  13. package/dist/lib/capabilities.browser.mjs +54 -0
  14. package/dist/lib/capabilities.browser.mjs.map +1 -0
  15. package/dist/lib/capabilities.mjs +54 -0
  16. package/dist/lib/capabilities.mjs.map +1 -0
  17. package/dist/lib/capabilities.node.mjs +30 -0
  18. package/dist/lib/capabilities.node.mjs.map +1 -0
  19. package/dist/lib/capabilities.tauri.mjs +54 -0
  20. package/dist/lib/capabilities.tauri.mjs.map +1 -0
  21. package/dist/lib/capabilities.workerd.mjs +30 -0
  22. package/dist/lib/capabilities.workerd.mjs.map +1 -0
  23. package/dist/lib/chunk-ImportPullRequestDialog.mjs +116 -0
  24. package/dist/lib/chunk-ImportPullRequestDialog.mjs.map +1 -0
  25. package/dist/lib/chunk-PLUGIN.mjs +6 -0
  26. package/dist/lib/chunk-PLUGIN.mjs.map +1 -0
  27. package/dist/lib/chunk-PullRequestArticle.mjs +1506 -0
  28. package/dist/lib/chunk-PullRequestArticle.mjs.map +1 -0
  29. package/dist/lib/chunk-PullRequestArticle2.mjs +2 -0
  30. package/dist/lib/chunk-PullRequestCardMenu.mjs +123 -0
  31. package/dist/lib/chunk-PullRequestCardMenu.mjs.map +1 -0
  32. package/dist/lib/chunk-PullRequestsArticle.mjs +594 -0
  33. package/dist/lib/chunk-PullRequestsArticle.mjs.map +1 -0
  34. package/dist/lib/chunk-WalkthroughArticle.mjs +26 -0
  35. package/dist/lib/chunk-WalkthroughArticle.mjs.map +1 -0
  36. package/dist/lib/chunk-add-pull-request-comment.mjs +19 -0
  37. package/dist/lib/chunk-add-pull-request-comment.mjs.map +1 -0
  38. package/dist/lib/chunk-add-pull-request-review-comment.mjs +25 -0
  39. package/dist/lib/chunk-add-pull-request-review-comment.mjs.map +1 -0
  40. package/dist/lib/chunk-app-graph-builder.mjs +39 -0
  41. package/dist/lib/chunk-app-graph-builder.mjs.map +1 -0
  42. package/dist/lib/chunk-connector.mjs +70 -0
  43. package/dist/lib/chunk-connector.mjs.map +1 -0
  44. package/dist/lib/chunk-constants.mjs +11 -0
  45. package/dist/lib/chunk-constants.mjs.map +1 -0
  46. package/dist/lib/chunk-errors.mjs +29 -0
  47. package/dist/lib/chunk-errors.mjs.map +1 -0
  48. package/dist/lib/chunk-generate-walkthrough.mjs +86 -0
  49. package/dist/lib/chunk-generate-walkthrough.mjs.map +1 -0
  50. package/dist/lib/chunk-generate.mjs +719 -0
  51. package/dist/lib/chunk-generate.mjs.map +1 -0
  52. package/dist/lib/chunk-get-pull-request-diff.mjs +25 -0
  53. package/dist/lib/chunk-get-pull-request-diff.mjs.map +1 -0
  54. package/dist/lib/chunk-get-pull-request-status.mjs +29 -0
  55. package/dist/lib/chunk-get-pull-request-status.mjs.map +1 -0
  56. package/dist/lib/chunk-get-repositories.mjs +32 -0
  57. package/dist/lib/chunk-get-repositories.mjs.map +1 -0
  58. package/dist/lib/chunk-github-api.mjs +375 -0
  59. package/dist/lib/chunk-github-api.mjs.map +1 -0
  60. package/dist/lib/chunk-github-link.mjs +59 -0
  61. package/dist/lib/chunk-github-link.mjs.map +1 -0
  62. package/dist/lib/chunk-import-pull-request-from-snapshot.mjs +25 -0
  63. package/dist/lib/chunk-import-pull-request-from-snapshot.mjs.map +1 -0
  64. package/dist/lib/chunk-import-pull-request.mjs +171 -0
  65. package/dist/lib/chunk-import-pull-request.mjs.map +1 -0
  66. package/dist/lib/chunk-link-resolver.mjs +109 -0
  67. package/dist/lib/chunk-link-resolver.mjs.map +1 -0
  68. package/dist/lib/chunk-markdown-extension.mjs +92 -0
  69. package/dist/lib/chunk-markdown-extension.mjs.map +1 -0
  70. package/dist/lib/chunk-materialize-target.mjs +36 -0
  71. package/dist/lib/chunk-materialize-target.mjs.map +1 -0
  72. package/dist/lib/chunk-operation-handler.mjs +12 -0
  73. package/dist/lib/chunk-operation-handler.mjs.map +1 -0
  74. package/dist/lib/chunk-page-action.mjs +22 -0
  75. package/dist/lib/chunk-page-action.mjs.map +1 -0
  76. package/dist/lib/chunk-pull-request.mjs +58 -0
  77. package/dist/lib/chunk-pull-request.mjs.map +1 -0
  78. package/dist/lib/chunk-pull-request2.mjs +110 -0
  79. package/dist/lib/chunk-pull-request2.mjs.map +1 -0
  80. package/dist/lib/chunk-react-surface.mjs +233 -0
  81. package/dist/lib/chunk-react-surface.mjs.map +1 -0
  82. package/dist/lib/chunk-refresh-pull-requests.mjs +49 -0
  83. package/dist/lib/chunk-refresh-pull-requests.mjs.map +1 -0
  84. package/dist/lib/chunk-rolldown-runtime.mjs +13 -0
  85. package/dist/lib/chunk-schema.mjs +15 -0
  86. package/dist/lib/chunk-schema.mjs.map +1 -0
  87. package/dist/lib/chunk-skill-definition.mjs +10 -0
  88. package/dist/lib/chunk-skill-definition.mjs.map +1 -0
  89. package/dist/lib/chunk-submit-pull-request-approval.mjs +56 -0
  90. package/dist/lib/chunk-submit-pull-request-approval.mjs.map +1 -0
  91. package/dist/lib/chunk-sync-pull-request.mjs +26 -0
  92. package/dist/lib/chunk-sync-pull-request.mjs.map +1 -0
  93. package/dist/lib/chunk-sync.mjs +481 -0
  94. package/dist/lib/chunk-sync.mjs.map +1 -0
  95. package/dist/lib/chunk-useOpenObject.mjs +43 -0
  96. package/dist/lib/chunk-useOpenObject.mjs.map +1 -0
  97. package/dist/lib/extensions.mjs +132 -0
  98. package/dist/lib/extensions.mjs.map +1 -0
  99. package/dist/lib/index.mjs +4 -0
  100. package/dist/lib/meta.mjs +47 -0
  101. package/dist/lib/meta.mjs.map +1 -0
  102. package/dist/lib/ns/Extensions.mjs +18 -0
  103. package/dist/lib/ns/Extensions.mjs.map +1 -0
  104. package/dist/lib/operations.mjs +23 -0
  105. package/dist/lib/operations.mjs.map +1 -0
  106. package/dist/lib/plugin.mjs +9 -0
  107. package/dist/lib/plugin.mjs.map +1 -0
  108. package/dist/lib/skills.mjs +44 -0
  109. package/dist/lib/skills.mjs.map +1 -0
  110. package/dist/lib/testing.mjs +189 -0
  111. package/dist/lib/testing.mjs.map +1 -0
  112. package/dist/lib/translations.mjs +136 -0
  113. package/dist/lib/translations.mjs.map +1 -0
  114. package/dist/lib/types.mjs +5 -0
  115. package/dist/types/dx.config.d.ts +28 -0
  116. package/dist/types/dx.config.d.ts.map +1 -0
  117. package/dist/types/src/Extensions.d.ts +2 -0
  118. package/dist/types/src/Extensions.d.ts.map +1 -0
  119. package/dist/types/src/GitHubPlugin.d.ts +37 -0
  120. package/dist/types/src/GitHubPlugin.d.ts.map +1 -0
  121. package/dist/types/src/GitHubPlugin.workerd.test.d.ts +2 -0
  122. package/dist/types/src/GitHubPlugin.workerd.test.d.ts.map +1 -0
  123. package/dist/types/src/approval.d.ts +21 -0
  124. package/dist/types/src/approval.d.ts.map +1 -0
  125. package/dist/types/src/approval.test.d.ts +2 -0
  126. package/dist/types/src/approval.test.d.ts.map +1 -0
  127. package/dist/types/src/capabilities/app-graph-builder.d.ts +9 -0
  128. package/dist/types/src/capabilities/app-graph-builder.d.ts.map +1 -0
  129. package/dist/types/src/capabilities/connector.d.ts +17 -0
  130. package/dist/types/src/capabilities/connector.d.ts.map +1 -0
  131. package/dist/types/src/capabilities/gen/browser.d.ts +13 -0
  132. package/dist/types/src/capabilities/gen/browser.d.ts.map +1 -0
  133. package/dist/types/src/capabilities/gen/node.d.ts +13 -0
  134. package/dist/types/src/capabilities/gen/node.d.ts.map +1 -0
  135. package/dist/types/src/capabilities/gen/tauri.d.ts +13 -0
  136. package/dist/types/src/capabilities/gen/tauri.d.ts.map +1 -0
  137. package/dist/types/src/capabilities/gen/workerd.d.ts +13 -0
  138. package/dist/types/src/capabilities/gen/workerd.d.ts.map +1 -0
  139. package/dist/types/src/capabilities/index.d.ts +13 -0
  140. package/dist/types/src/capabilities/index.d.ts.map +1 -0
  141. package/dist/types/src/capabilities/link-resolver.d.ts +10 -0
  142. package/dist/types/src/capabilities/link-resolver.d.ts.map +1 -0
  143. package/dist/types/src/capabilities/markdown-extension.d.ts +14 -0
  144. package/dist/types/src/capabilities/markdown-extension.d.ts.map +1 -0
  145. package/dist/types/src/capabilities/operation-handler.d.ts +5 -0
  146. package/dist/types/src/capabilities/operation-handler.d.ts.map +1 -0
  147. package/dist/types/src/capabilities/page-action.d.ts +5 -0
  148. package/dist/types/src/capabilities/page-action.d.ts.map +1 -0
  149. package/dist/types/src/capabilities/react-surface.d.ts +5 -0
  150. package/dist/types/src/capabilities/react-surface.d.ts.map +1 -0
  151. package/dist/types/src/capabilities/schema.d.ts +12 -0
  152. package/dist/types/src/capabilities/schema.d.ts.map +1 -0
  153. package/dist/types/src/capabilities/skill-definition.d.ts +5 -0
  154. package/dist/types/src/capabilities/skill-definition.d.ts.map +1 -0
  155. package/dist/types/src/cards/GitHubCard.d.ts +11 -0
  156. package/dist/types/src/cards/GitHubCard.d.ts.map +1 -0
  157. package/dist/types/src/cards/GitHubCard.stories.d.ts +45 -0
  158. package/dist/types/src/cards/GitHubCard.stories.d.ts.map +1 -0
  159. package/dist/types/src/cards/PullRequestCard.d.ts +9 -0
  160. package/dist/types/src/cards/PullRequestCard.d.ts.map +1 -0
  161. package/dist/types/src/cards/index.d.ts +3 -0
  162. package/dist/types/src/cards/index.d.ts.map +1 -0
  163. package/dist/types/src/components/CommentComposer/CommentComposer.d.ts +30 -0
  164. package/dist/types/src/components/CommentComposer/CommentComposer.d.ts.map +1 -0
  165. package/dist/types/src/components/CommentComposer/index.d.ts +2 -0
  166. package/dist/types/src/components/CommentComposer/index.d.ts.map +1 -0
  167. package/dist/types/src/components/PullRequestFiles/DiffStat.d.ts +8 -0
  168. package/dist/types/src/components/PullRequestFiles/DiffStat.d.ts.map +1 -0
  169. package/dist/types/src/components/PullRequestFiles/FileTree.d.ts +17 -0
  170. package/dist/types/src/components/PullRequestFiles/FileTree.d.ts.map +1 -0
  171. package/dist/types/src/components/PullRequestFiles/PullRequestFiles.d.ts +23 -0
  172. package/dist/types/src/components/PullRequestFiles/PullRequestFiles.d.ts.map +1 -0
  173. package/dist/types/src/components/PullRequestFiles/files.d.ts +39 -0
  174. package/dist/types/src/components/PullRequestFiles/files.d.ts.map +1 -0
  175. package/dist/types/src/components/PullRequestFiles/files.test.d.ts +2 -0
  176. package/dist/types/src/components/PullRequestFiles/files.test.d.ts.map +1 -0
  177. package/dist/types/src/components/PullRequestFiles/index.d.ts +5 -0
  178. package/dist/types/src/components/PullRequestFiles/index.d.ts.map +1 -0
  179. package/dist/types/src/components/PullRequestOverview/ArtifactPill.d.ts +21 -0
  180. package/dist/types/src/components/PullRequestOverview/ArtifactPill.d.ts.map +1 -0
  181. package/dist/types/src/components/PullRequestOverview/CheckRunList.d.ts +18 -0
  182. package/dist/types/src/components/PullRequestOverview/CheckRunList.d.ts.map +1 -0
  183. package/dist/types/src/components/PullRequestOverview/CheckRunList.test.d.ts +2 -0
  184. package/dist/types/src/components/PullRequestOverview/CheckRunList.test.d.ts.map +1 -0
  185. package/dist/types/src/components/PullRequestOverview/PullRequestDetails.d.ts +10 -0
  186. package/dist/types/src/components/PullRequestOverview/PullRequestDetails.d.ts.map +1 -0
  187. package/dist/types/src/components/PullRequestOverview/PullRequestOverview.d.ts +16 -0
  188. package/dist/types/src/components/PullRequestOverview/PullRequestOverview.d.ts.map +1 -0
  189. package/dist/types/src/components/PullRequestOverview/RelatedCards.d.ts +28 -0
  190. package/dist/types/src/components/PullRequestOverview/RelatedCards.d.ts.map +1 -0
  191. package/dist/types/src/components/PullRequestOverview/index.d.ts +6 -0
  192. package/dist/types/src/components/PullRequestOverview/index.d.ts.map +1 -0
  193. package/dist/types/src/components/PullRequestStatus/PullRequestStatus.d.ts +20 -0
  194. package/dist/types/src/components/PullRequestStatus/PullRequestStatus.d.ts.map +1 -0
  195. package/dist/types/src/components/PullRequestStatus/index.d.ts +2 -0
  196. package/dist/types/src/components/PullRequestStatus/index.d.ts.map +1 -0
  197. package/dist/types/src/components/WalkthroughView/WalkthroughView.d.ts +18 -0
  198. package/dist/types/src/components/WalkthroughView/WalkthroughView.d.ts.map +1 -0
  199. package/dist/types/src/components/WalkthroughView/extensions.d.ts +16 -0
  200. package/dist/types/src/components/WalkthroughView/extensions.d.ts.map +1 -0
  201. package/dist/types/src/components/WalkthroughView/index.d.ts +3 -0
  202. package/dist/types/src/components/WalkthroughView/index.d.ts.map +1 -0
  203. package/dist/types/src/components/index.d.ts +5 -8
  204. package/dist/types/src/components/index.d.ts.map +1 -1
  205. package/dist/types/src/constants.d.ts +7 -0
  206. package/dist/types/src/constants.d.ts.map +1 -0
  207. package/dist/types/src/containers/ImportPullRequestDialog/ImportPullRequestDialog.d.ts +6 -0
  208. package/dist/types/src/containers/ImportPullRequestDialog/ImportPullRequestDialog.d.ts.map +1 -0
  209. package/dist/types/src/containers/ImportPullRequestDialog/index.d.ts +3 -0
  210. package/dist/types/src/containers/ImportPullRequestDialog/index.d.ts.map +1 -0
  211. package/dist/types/src/containers/PullRequestArticle/PullRequestArticle.d.ts +15 -0
  212. package/dist/types/src/containers/PullRequestArticle/PullRequestArticle.d.ts.map +1 -0
  213. package/dist/types/src/containers/PullRequestArticle/failure.d.ts +10 -0
  214. package/dist/types/src/containers/PullRequestArticle/failure.d.ts.map +1 -0
  215. package/dist/types/src/containers/PullRequestArticle/failure.test.d.ts +2 -0
  216. package/dist/types/src/containers/PullRequestArticle/failure.test.d.ts.map +1 -0
  217. package/dist/types/src/containers/PullRequestArticle/index.d.ts +2 -0
  218. package/dist/types/src/containers/PullRequestArticle/index.d.ts.map +1 -0
  219. package/dist/types/src/containers/PullRequestCardMenu/PullRequestCardMenu.d.ts +14 -0
  220. package/dist/types/src/containers/PullRequestCardMenu/PullRequestCardMenu.d.ts.map +1 -0
  221. package/dist/types/src/containers/PullRequestCardMenu/index.d.ts +2 -0
  222. package/dist/types/src/containers/PullRequestCardMenu/index.d.ts.map +1 -0
  223. package/dist/types/src/containers/PullRequestsArticle/PullRequestsArticle.d.ts +13 -0
  224. package/dist/types/src/containers/PullRequestsArticle/PullRequestsArticle.d.ts.map +1 -0
  225. package/dist/types/src/containers/PullRequestsArticle/arrange.d.ts +44 -0
  226. package/dist/types/src/containers/PullRequestsArticle/arrange.d.ts.map +1 -0
  227. package/dist/types/src/containers/PullRequestsArticle/arrange.test.d.ts +2 -0
  228. package/dist/types/src/containers/PullRequestsArticle/arrange.test.d.ts.map +1 -0
  229. package/dist/types/src/containers/PullRequestsArticle/format.d.ts +3 -0
  230. package/dist/types/src/containers/PullRequestsArticle/format.d.ts.map +1 -0
  231. package/dist/types/src/containers/PullRequestsArticle/index.d.ts +2 -0
  232. package/dist/types/src/containers/PullRequestsArticle/index.d.ts.map +1 -0
  233. package/dist/types/src/containers/WalkthroughArticle/WalkthroughArticle.d.ts +13 -0
  234. package/dist/types/src/containers/WalkthroughArticle/WalkthroughArticle.d.ts.map +1 -0
  235. package/dist/types/src/containers/WalkthroughArticle/index.d.ts +2 -0
  236. package/dist/types/src/containers/WalkthroughArticle/index.d.ts.map +1 -0
  237. package/dist/types/src/containers/index.d.ts +7 -0
  238. package/dist/types/src/containers/index.d.ts.map +1 -0
  239. package/dist/types/src/errors.d.ts +157 -0
  240. package/dist/types/src/errors.d.ts.map +1 -0
  241. package/dist/types/src/extensions/index.d.ts +3 -0
  242. package/dist/types/src/extensions/index.d.ts.map +1 -0
  243. package/dist/types/src/extensions/links.d.ts +21 -0
  244. package/dist/types/src/extensions/links.d.ts.map +1 -0
  245. package/dist/types/src/extensions/links.test.d.ts +2 -0
  246. package/dist/types/src/extensions/links.test.d.ts.map +1 -0
  247. package/dist/types/src/extensions/references.d.ts +23 -0
  248. package/dist/types/src/extensions/references.d.ts.map +1 -0
  249. package/dist/types/src/extensions/references.test.d.ts +2 -0
  250. package/dist/types/src/extensions/references.test.d.ts.map +1 -0
  251. package/dist/types/src/github-link.d.ts +29 -0
  252. package/dist/types/src/github-link.d.ts.map +1 -0
  253. package/dist/types/src/hooks/index.d.ts +5 -2
  254. package/dist/types/src/hooks/index.d.ts.map +1 -1
  255. package/dist/types/src/hooks/useOpenObject.d.ts +9 -0
  256. package/dist/types/src/hooks/useOpenObject.d.ts.map +1 -0
  257. package/dist/types/src/hooks/usePullRequestChecks.d.ts +10 -0
  258. package/dist/types/src/hooks/usePullRequestChecks.d.ts.map +1 -0
  259. package/dist/types/src/hooks/usePullRequestDiff.d.ts +12 -0
  260. package/dist/types/src/hooks/usePullRequestDiff.d.ts.map +1 -0
  261. package/dist/types/src/hooks/usePullRequestFiles.d.ts +22 -0
  262. package/dist/types/src/hooks/usePullRequestFiles.d.ts.map +1 -0
  263. package/dist/types/src/hooks/useSyncPullRequest.d.ts +10 -0
  264. package/dist/types/src/hooks/useSyncPullRequest.d.ts.map +1 -0
  265. package/dist/types/src/index.d.ts +3 -3
  266. package/dist/types/src/index.d.ts.map +1 -1
  267. package/dist/types/src/meta.d.ts +34 -11
  268. package/dist/types/src/meta.d.ts.map +1 -1
  269. package/dist/types/src/operations/add-pull-request-comment.d.ts +5 -0
  270. package/dist/types/src/operations/add-pull-request-comment.d.ts.map +1 -0
  271. package/dist/types/src/operations/add-pull-request-review-comment.d.ts +5 -0
  272. package/dist/types/src/operations/add-pull-request-review-comment.d.ts.map +1 -0
  273. package/dist/types/src/operations/generate-walkthrough.d.ts +11 -0
  274. package/dist/types/src/operations/generate-walkthrough.d.ts.map +1 -0
  275. package/dist/types/src/operations/get-pull-request-diff.d.ts +5 -0
  276. package/dist/types/src/operations/get-pull-request-diff.d.ts.map +1 -0
  277. package/dist/types/src/operations/get-pull-request-status.d.ts +5 -0
  278. package/dist/types/src/operations/get-pull-request-status.d.ts.map +1 -0
  279. package/dist/types/src/operations/get-repositories.d.ts +13 -0
  280. package/dist/types/src/operations/get-repositories.d.ts.map +1 -0
  281. package/dist/types/src/operations/import-pull-request-from-snapshot.d.ts +12 -0
  282. package/dist/types/src/operations/import-pull-request-from-snapshot.d.ts.map +1 -0
  283. package/dist/types/src/operations/import-pull-request.d.ts +63 -0
  284. package/dist/types/src/operations/import-pull-request.d.ts.map +1 -0
  285. package/dist/types/src/operations/import-pull-request.test.d.ts +2 -0
  286. package/dist/types/src/operations/import-pull-request.test.d.ts.map +1 -0
  287. package/dist/types/src/operations/index.d.ts +3 -0
  288. package/dist/types/src/operations/index.d.ts.map +1 -0
  289. package/dist/types/src/operations/materialize-target.d.ts +12 -0
  290. package/dist/types/src/operations/materialize-target.d.ts.map +1 -0
  291. package/dist/types/src/operations/pull-request.d.ts +48 -0
  292. package/dist/types/src/operations/pull-request.d.ts.map +1 -0
  293. package/dist/types/src/operations/pull-request.test.d.ts +2 -0
  294. package/dist/types/src/operations/pull-request.test.d.ts.map +1 -0
  295. package/dist/types/src/operations/refresh-pull-requests.d.ts +5 -0
  296. package/dist/types/src/operations/refresh-pull-requests.d.ts.map +1 -0
  297. package/dist/types/src/operations/refresh.d.ts +37 -0
  298. package/dist/types/src/operations/refresh.d.ts.map +1 -0
  299. package/dist/types/src/operations/refresh.test.d.ts +2 -0
  300. package/dist/types/src/operations/refresh.test.d.ts.map +1 -0
  301. package/dist/types/src/operations/submit-pull-request-approval.d.ts +5 -0
  302. package/dist/types/src/operations/submit-pull-request-approval.d.ts.map +1 -0
  303. package/dist/types/src/operations/sync-pull-request.d.ts +5 -0
  304. package/dist/types/src/operations/sync-pull-request.d.ts.map +1 -0
  305. package/dist/types/src/operations/sync.d.ts +123 -0
  306. package/dist/types/src/operations/sync.d.ts.map +1 -0
  307. package/dist/types/src/operations/sync.test.d.ts +2 -0
  308. package/dist/types/src/operations/sync.test.d.ts.map +1 -0
  309. package/dist/types/src/plugin.d.ts +4 -0
  310. package/dist/types/src/plugin.d.ts.map +1 -0
  311. package/dist/types/src/plugin.test.d.ts +2 -0
  312. package/dist/types/src/plugin.test.d.ts.map +1 -0
  313. package/dist/types/src/pull-request-body.d.ts +37 -0
  314. package/dist/types/src/pull-request-body.d.ts.map +1 -0
  315. package/dist/types/src/pull-request-body.test.d.ts +2 -0
  316. package/dist/types/src/pull-request-body.test.d.ts.map +1 -0
  317. package/dist/types/src/pull-request.d.ts +28 -0
  318. package/dist/types/src/pull-request.d.ts.map +1 -0
  319. package/dist/types/src/pull-request.test.d.ts +2 -0
  320. package/dist/types/src/pull-request.test.d.ts.map +1 -0
  321. package/dist/types/src/services/github-api.d.ts +332 -0
  322. package/dist/types/src/services/github-api.d.ts.map +1 -0
  323. package/dist/types/src/services/index.d.ts +2 -0
  324. package/dist/types/src/services/index.d.ts.map +1 -0
  325. package/dist/types/src/skills/GitHubSkill.d.ts +12 -0
  326. package/dist/types/src/skills/GitHubSkill.d.ts.map +1 -0
  327. package/dist/types/src/skills/index.d.ts +2 -0
  328. package/dist/types/src/skills/index.d.ts.map +1 -0
  329. package/dist/types/src/stories/Generate.stories.d.ts +27 -0
  330. package/dist/types/src/stories/Generate.stories.d.ts.map +1 -0
  331. package/dist/types/src/stories/Links.stories.d.ts +23 -0
  332. package/dist/types/src/stories/Links.stories.d.ts.map +1 -0
  333. package/dist/types/src/stories/PullRequestArticle.stories.d.ts +359 -0
  334. package/dist/types/src/stories/PullRequestArticle.stories.d.ts.map +1 -0
  335. package/dist/types/src/stories/PullRequestFiles.stories.d.ts +365 -0
  336. package/dist/types/src/stories/PullRequestFiles.stories.d.ts.map +1 -0
  337. package/dist/types/src/stories/PullRequestOverview.stories.d.ts +367 -0
  338. package/dist/types/src/stories/PullRequestOverview.stories.d.ts.map +1 -0
  339. package/dist/types/src/stories/PullRequestsArticle.stories.d.ts +357 -0
  340. package/dist/types/src/stories/PullRequestsArticle.stories.d.ts.map +1 -0
  341. package/dist/types/src/stories/Walkthrough.stories.d.ts +380 -0
  342. package/dist/types/src/stories/Walkthrough.stories.d.ts.map +1 -0
  343. package/dist/types/src/stories/github.d.ts +23 -0
  344. package/dist/types/src/stories/github.d.ts.map +1 -0
  345. package/dist/types/src/stories/github.test.d.ts +2 -0
  346. package/dist/types/src/stories/github.test.d.ts.map +1 -0
  347. package/dist/types/src/stories/walkthrough-fixture.d.ts +8 -0
  348. package/dist/types/src/stories/walkthrough-fixture.d.ts.map +1 -0
  349. package/dist/types/src/testing/fixtures.d.ts +14 -0
  350. package/dist/types/src/testing/fixtures.d.ts.map +1 -0
  351. package/dist/types/src/testing/index.d.ts +4 -0
  352. package/dist/types/src/testing/index.d.ts.map +1 -0
  353. package/dist/types/src/testing/plugin.d.ts +4 -0
  354. package/dist/types/src/testing/plugin.d.ts.map +1 -0
  355. package/dist/types/src/testing/pull-request-13348.d.ts +6 -0
  356. package/dist/types/src/testing/pull-request-13348.d.ts.map +1 -0
  357. package/dist/types/src/translations.d.ts +339 -53
  358. package/dist/types/src/translations.d.ts.map +1 -1
  359. package/dist/types/src/types/GitHubCapabilities.d.ts +13 -0
  360. package/dist/types/src/types/GitHubCapabilities.d.ts.map +1 -0
  361. package/dist/types/src/types/GitHubEvents.d.ts +8 -0
  362. package/dist/types/src/types/GitHubEvents.d.ts.map +1 -0
  363. package/dist/types/src/types/GitHubOperation.d.ts +281 -0
  364. package/dist/types/src/types/GitHubOperation.d.ts.map +1 -0
  365. package/dist/types/src/types/PullRequestsView.d.ts +35 -0
  366. package/dist/types/src/types/PullRequestsView.d.ts.map +1 -0
  367. package/dist/types/src/types/Walkthrough.d.ts +31 -0
  368. package/dist/types/src/types/Walkthrough.d.ts.map +1 -0
  369. package/dist/types/src/types/Walkthrough.test.d.ts +2 -0
  370. package/dist/types/src/types/Walkthrough.test.d.ts.map +1 -0
  371. package/dist/types/src/types/index.d.ts +5 -0
  372. package/dist/types/src/types/index.d.ts.map +1 -0
  373. package/dist/types/src/walkthrough/fill.d.ts +28 -0
  374. package/dist/types/src/walkthrough/fill.d.ts.map +1 -0
  375. package/dist/types/src/walkthrough/fill.test.d.ts +2 -0
  376. package/dist/types/src/walkthrough/fill.test.d.ts.map +1 -0
  377. package/dist/types/src/walkthrough/generate.d.ts +67 -0
  378. package/dist/types/src/walkthrough/generate.d.ts.map +1 -0
  379. package/dist/types/src/walkthrough/generate.test.d.ts +2 -0
  380. package/dist/types/src/walkthrough/generate.test.d.ts.map +1 -0
  381. package/dist/types/src/walkthrough/generated.d.ts +20 -0
  382. package/dist/types/src/walkthrough/generated.d.ts.map +1 -0
  383. package/dist/types/src/walkthrough/generated.test.d.ts +2 -0
  384. package/dist/types/src/walkthrough/generated.test.d.ts.map +1 -0
  385. package/dist/types/src/walkthrough/headings.d.ts +8 -0
  386. package/dist/types/src/walkthrough/headings.d.ts.map +1 -0
  387. package/dist/types/src/walkthrough/headings.test.d.ts +2 -0
  388. package/dist/types/src/walkthrough/headings.test.d.ts.map +1 -0
  389. package/dist/types/src/walkthrough/index.d.ts +9 -0
  390. package/dist/types/src/walkthrough/index.d.ts.map +1 -0
  391. package/dist/types/src/walkthrough/patch.d.ts +48 -0
  392. package/dist/types/src/walkthrough/patch.d.ts.map +1 -0
  393. package/dist/types/src/walkthrough/plan.d.ts +55 -0
  394. package/dist/types/src/walkthrough/plan.d.ts.map +1 -0
  395. package/dist/types/src/walkthrough/plan.test.d.ts +2 -0
  396. package/dist/types/src/walkthrough/plan.test.d.ts.map +1 -0
  397. package/dist/types/src/walkthrough/prompt.d.ts +33 -0
  398. package/dist/types/src/walkthrough/prompt.d.ts.map +1 -0
  399. package/dist/types/src/walkthrough/prompt.test.d.ts +2 -0
  400. package/dist/types/src/walkthrough/prompt.test.d.ts.map +1 -0
  401. package/dist/types/src/walkthrough/score.d.ts +43 -0
  402. package/dist/types/src/walkthrough/score.d.ts.map +1 -0
  403. package/dist/types/src/walkthrough/score.test.d.ts +2 -0
  404. package/dist/types/src/walkthrough/score.test.d.ts.map +1 -0
  405. package/dist/types/tsconfig.tsbuildinfo +1 -0
  406. package/dx.config.ts +39 -0
  407. package/package.json +167 -48
  408. package/src/Extensions.ts +7 -0
  409. package/src/GitHubPlugin.ts +15 -0
  410. package/src/GitHubPlugin.workerd.test.ts +23 -0
  411. package/src/approval.test.ts +40 -0
  412. package/src/approval.ts +39 -0
  413. package/src/capabilities/app-graph-builder.ts +47 -0
  414. package/src/capabilities/connector.ts +95 -0
  415. package/src/capabilities/gen/browser.ts +82 -0
  416. package/src/capabilities/gen/node.ts +43 -0
  417. package/src/capabilities/gen/tauri.ts +82 -0
  418. package/src/capabilities/gen/workerd.ts +43 -0
  419. package/src/capabilities/index.ts +71 -0
  420. package/src/capabilities/link-resolver.ts +137 -0
  421. package/src/capabilities/markdown-extension.ts +146 -0
  422. package/src/capabilities/operation-handler.ts +16 -0
  423. package/src/capabilities/page-action.ts +32 -0
  424. package/src/capabilities/react-surface.ts +98 -0
  425. package/src/capabilities/schema.ts +16 -0
  426. package/src/capabilities/skill-definition.ts +14 -0
  427. package/src/cards/GitHubCard.stories.tsx +165 -0
  428. package/src/cards/GitHubCard.tsx +93 -0
  429. package/src/cards/PullRequestCard.tsx +23 -0
  430. package/src/cards/index.ts +6 -0
  431. package/src/components/CommentComposer/CommentComposer.tsx +109 -0
  432. package/src/components/CommentComposer/index.ts +5 -0
  433. package/src/components/PullRequestFiles/DiffStat.tsx +15 -0
  434. package/src/components/PullRequestFiles/FileTree.tsx +102 -0
  435. package/src/components/PullRequestFiles/PullRequestFiles.tsx +87 -0
  436. package/src/components/PullRequestFiles/files.test.ts +59 -0
  437. package/src/components/PullRequestFiles/files.ts +115 -0
  438. package/src/components/PullRequestFiles/index.ts +8 -0
  439. package/src/components/PullRequestOverview/ArtifactPill.tsx +82 -0
  440. package/src/components/PullRequestOverview/CheckRunList.test.ts +29 -0
  441. package/src/components/PullRequestOverview/CheckRunList.tsx +141 -0
  442. package/src/components/PullRequestOverview/PullRequestDetails.tsx +15 -0
  443. package/src/components/PullRequestOverview/PullRequestOverview.tsx +110 -0
  444. package/src/components/PullRequestOverview/RelatedCards.tsx +147 -0
  445. package/src/components/PullRequestOverview/index.ts +9 -0
  446. package/src/components/PullRequestStatus/PullRequestStatus.tsx +84 -0
  447. package/src/components/PullRequestStatus/index.ts +5 -0
  448. package/src/components/WalkthroughView/WalkthroughView.tsx +62 -0
  449. package/src/components/WalkthroughView/extensions.ts +53 -0
  450. package/src/components/WalkthroughView/index.ts +6 -0
  451. package/src/components/index.ts +6 -9
  452. package/src/constants.ts +12 -0
  453. package/src/containers/ImportPullRequestDialog/ImportPullRequestDialog.tsx +160 -0
  454. package/src/containers/ImportPullRequestDialog/index.ts +7 -0
  455. package/src/containers/PullRequestArticle/PullRequestArticle.tsx +573 -0
  456. package/src/containers/PullRequestArticle/failure.test.ts +42 -0
  457. package/src/containers/PullRequestArticle/failure.ts +16 -0
  458. package/src/containers/PullRequestArticle/index.ts +5 -0
  459. package/src/containers/PullRequestCardMenu/PullRequestCardMenu.tsx +134 -0
  460. package/src/containers/PullRequestCardMenu/index.ts +5 -0
  461. package/src/containers/PullRequestsArticle/PullRequestsArticle.tsx +361 -0
  462. package/src/containers/PullRequestsArticle/arrange.test.ts +122 -0
  463. package/src/containers/PullRequestsArticle/arrange.ts +245 -0
  464. package/src/containers/PullRequestsArticle/format.ts +31 -0
  465. package/src/containers/PullRequestsArticle/index.ts +5 -0
  466. package/src/containers/WalkthroughArticle/WalkthroughArticle.tsx +31 -0
  467. package/src/containers/WalkthroughArticle/index.ts +5 -0
  468. package/src/containers/index.ts +11 -0
  469. package/src/errors.ts +61 -0
  470. package/src/extensions/index.ts +6 -0
  471. package/src/extensions/links.test.ts +133 -0
  472. package/src/extensions/links.ts +91 -0
  473. package/src/extensions/references.test.ts +83 -0
  474. package/src/extensions/references.ts +100 -0
  475. package/src/github-link.ts +70 -0
  476. package/src/hooks/index.ts +6 -3
  477. package/src/hooks/useOpenObject.ts +48 -0
  478. package/src/hooks/usePullRequestChecks.ts +81 -0
  479. package/src/hooks/usePullRequestDiff.ts +57 -0
  480. package/src/hooks/usePullRequestFiles.ts +98 -0
  481. package/src/hooks/useSyncPullRequest.ts +51 -0
  482. package/src/index.ts +4 -6
  483. package/src/meta.ts +8 -13
  484. package/src/operations/add-pull-request-comment.ts +31 -0
  485. package/src/operations/add-pull-request-review-comment.ts +32 -0
  486. package/src/operations/generate-walkthrough.ts +124 -0
  487. package/src/operations/get-pull-request-diff.ts +37 -0
  488. package/src/operations/get-pull-request-status.ts +44 -0
  489. package/src/operations/get-repositories.ts +53 -0
  490. package/src/operations/import-pull-request-from-snapshot.ts +48 -0
  491. package/src/operations/import-pull-request.test.ts +151 -0
  492. package/src/operations/import-pull-request.ts +149 -0
  493. package/src/operations/index.ts +30 -0
  494. package/src/operations/materialize-target.ts +60 -0
  495. package/src/operations/pull-request.test.ts +120 -0
  496. package/src/operations/pull-request.ts +141 -0
  497. package/src/operations/refresh-pull-requests.ts +69 -0
  498. package/src/operations/refresh.test.ts +112 -0
  499. package/src/operations/refresh.ts +111 -0
  500. package/src/operations/submit-pull-request-approval.ts +41 -0
  501. package/src/operations/sync-pull-request.ts +34 -0
  502. package/src/operations/sync.test.ts +453 -0
  503. package/src/operations/sync.ts +807 -0
  504. package/src/plugin.test.ts +40 -0
  505. package/src/plugin.ts +37 -0
  506. package/src/pull-request-body.test.ts +61 -0
  507. package/src/pull-request-body.ts +144 -0
  508. package/src/pull-request.test.ts +67 -0
  509. package/src/pull-request.ts +80 -0
  510. package/src/services/github-api.ts +735 -0
  511. package/src/services/index.ts +5 -0
  512. package/src/skills/GitHubSkill.ts +46 -0
  513. package/src/skills/index.ts +5 -0
  514. package/src/stories/Generate.stories.tsx +152 -0
  515. package/src/stories/Links.stories.tsx +153 -0
  516. package/src/stories/PullRequestArticle.stories.tsx +107 -0
  517. package/src/stories/PullRequestFiles.stories.tsx +69 -0
  518. package/src/stories/PullRequestOverview.stories.tsx +114 -0
  519. package/src/stories/PullRequestsArticle.stories.tsx +170 -0
  520. package/src/stories/Walkthrough.stories.tsx +110 -0
  521. package/src/stories/github.test.ts +65 -0
  522. package/src/stories/github.ts +70 -0
  523. package/src/stories/walkthrough-fixture.ts +115 -0
  524. package/src/testing/fixtures.ts +99 -0
  525. package/src/testing/index.ts +7 -0
  526. package/src/testing/plugin.ts +25 -0
  527. package/src/testing/pull-request-13348.ts +104 -0
  528. package/src/testing/pull-request-13363.patch +2981 -0
  529. package/src/translations.ts +135 -63
  530. package/src/types/GitHubCapabilities.ts +27 -0
  531. package/src/types/GitHubEvents.ts +16 -0
  532. package/src/types/GitHubOperation.ts +397 -0
  533. package/src/types/PullRequestsView.ts +45 -0
  534. package/src/types/Walkthrough.test.ts +75 -0
  535. package/src/types/Walkthrough.ts +66 -0
  536. package/src/types/index.ts +8 -0
  537. package/src/vite-env.d.ts +15 -0
  538. package/src/walkthrough/fill.test.ts +337 -0
  539. package/src/walkthrough/fill.ts +246 -0
  540. package/src/walkthrough/generate.test.ts +219 -0
  541. package/src/walkthrough/generate.ts +248 -0
  542. package/src/walkthrough/generated.test.ts +70 -0
  543. package/src/walkthrough/generated.ts +70 -0
  544. package/src/walkthrough/headings.test.ts +40 -0
  545. package/src/walkthrough/headings.ts +39 -0
  546. package/src/walkthrough/index.ts +12 -0
  547. package/src/walkthrough/patch.ts +154 -0
  548. package/src/walkthrough/plan.test.ts +178 -0
  549. package/src/walkthrough/plan.ts +251 -0
  550. package/src/walkthrough/prompt.test.ts +100 -0
  551. package/src/walkthrough/prompt.ts +147 -0
  552. package/src/walkthrough/score.test.ts +272 -0
  553. package/src/walkthrough/score.ts +430 -0
  554. package/README.md +0 -15
  555. package/dist/lib/browser/chunk-S4MPSBA5.mjs +0 -21
  556. package/dist/lib/browser/chunk-S4MPSBA5.mjs.map +0 -7
  557. package/dist/lib/browser/index.css +0 -7
  558. package/dist/lib/browser/index.css.map +0 -7
  559. package/dist/lib/browser/index.mjs +0 -1535
  560. package/dist/lib/browser/index.mjs.map +0 -7
  561. package/dist/lib/browser/meta.json +0 -1
  562. package/dist/lib/browser/meta.mjs +0 -11
  563. package/dist/lib/browser/meta.mjs.map +0 -7
  564. package/dist/types/src/GithubPlugin.d.ts +0 -4
  565. package/dist/types/src/GithubPlugin.d.ts.map +0 -1
  566. package/dist/types/src/components/EmbeddedMain/EmbeddedMain.d.ts +0 -4
  567. package/dist/types/src/components/EmbeddedMain/EmbeddedMain.d.ts.map +0 -1
  568. package/dist/types/src/components/EmbeddedMain/GfmPreview.d.ts +0 -9
  569. package/dist/types/src/components/EmbeddedMain/GfmPreview.d.ts.map +0 -1
  570. package/dist/types/src/components/EmbeddedMain/index.d.ts +0 -2
  571. package/dist/types/src/components/EmbeddedMain/index.d.ts.map +0 -1
  572. package/dist/types/src/components/ExportDialog.d.ts +0 -8
  573. package/dist/types/src/components/ExportDialog.d.ts.map +0 -1
  574. package/dist/types/src/components/GithubApiProviders/GitHubSettings.d.ts +0 -3
  575. package/dist/types/src/components/GithubApiProviders/GitHubSettings.d.ts.map +0 -1
  576. package/dist/types/src/components/GithubApiProviders/OctokitProvider.d.ts +0 -15
  577. package/dist/types/src/components/GithubApiProviders/OctokitProvider.d.ts.map +0 -1
  578. package/dist/types/src/components/GithubApiProviders/index.d.ts +0 -3
  579. package/dist/types/src/components/GithubApiProviders/index.d.ts.map +0 -1
  580. package/dist/types/src/components/GithubEchoResolverProviders/DocumentTreeItem.d.ts +0 -6
  581. package/dist/types/src/components/GithubEchoResolverProviders/DocumentTreeItem.d.ts.map +0 -1
  582. package/dist/types/src/components/GithubEchoResolverProviders/ResolverContext.d.ts +0 -7
  583. package/dist/types/src/components/GithubEchoResolverProviders/ResolverContext.d.ts.map +0 -1
  584. package/dist/types/src/components/GithubEchoResolverProviders/ResolverDialog.d.ts +0 -6
  585. package/dist/types/src/components/GithubEchoResolverProviders/ResolverDialog.d.ts.map +0 -1
  586. package/dist/types/src/components/GithubEchoResolverProviders/ResolverProps.d.ts +0 -16
  587. package/dist/types/src/components/GithubEchoResolverProviders/ResolverProps.d.ts.map +0 -1
  588. package/dist/types/src/components/GithubEchoResolverProviders/ResolverTree.d.ts +0 -3
  589. package/dist/types/src/components/GithubEchoResolverProviders/ResolverTree.d.ts.map +0 -1
  590. package/dist/types/src/components/GithubEchoResolverProviders/SpacePickerTreeItem.d.ts +0 -11
  591. package/dist/types/src/components/GithubEchoResolverProviders/SpacePickerTreeItem.d.ts.map +0 -1
  592. package/dist/types/src/components/GithubEchoResolverProviders/index.d.ts +0 -3
  593. package/dist/types/src/components/GithubEchoResolverProviders/index.d.ts.map +0 -1
  594. package/dist/types/src/components/GithubEchoResolverProviders/spaceResolvers.d.ts +0 -6
  595. package/dist/types/src/components/GithubEchoResolverProviders/spaceResolvers.d.ts.map +0 -1
  596. package/dist/types/src/components/ImportDialog.d.ts +0 -7
  597. package/dist/types/src/components/ImportDialog.d.ts.map +0 -1
  598. package/dist/types/src/components/Issue.d.ts +0 -5
  599. package/dist/types/src/components/Issue.d.ts.map +0 -1
  600. package/dist/types/src/components/Loading/Loading.d.ts +0 -15
  601. package/dist/types/src/components/Loading/Loading.d.ts.map +0 -1
  602. package/dist/types/src/components/Loading/Loading.stories.d.ts +0 -40
  603. package/dist/types/src/components/Loading/Loading.stories.d.ts.map +0 -1
  604. package/dist/types/src/components/Loading/index.d.ts +0 -2
  605. package/dist/types/src/components/Loading/index.d.ts.map +0 -1
  606. package/dist/types/src/components/MarkdownActions.d.ts +0 -7
  607. package/dist/types/src/components/MarkdownActions.d.ts.map +0 -1
  608. package/dist/types/src/components/UrlDialog.d.ts +0 -6
  609. package/dist/types/src/components/UrlDialog.d.ts.map +0 -1
  610. package/dist/types/src/hooks/useDocGhId.d.ts +0 -6
  611. package/dist/types/src/hooks/useDocGhId.d.ts.map +0 -1
  612. package/dist/types/src/hooks/useGhIdFromUrl.d.ts +0 -2
  613. package/dist/types/src/hooks/useGhIdFromUrl.d.ts.map +0 -1
  614. package/dist/types/src/types.d.ts +0 -30
  615. package/dist/types/src/types.d.ts.map +0 -1
  616. package/src/GithubPlugin.tsx +0 -127
  617. package/src/components/EmbeddedMain/EmbeddedMain.tsx +0 -294
  618. package/src/components/EmbeddedMain/GfmPreview.tsx +0 -110
  619. package/src/components/EmbeddedMain/embedded.css +0 -3
  620. package/src/components/EmbeddedMain/index.ts +0 -5
  621. package/src/components/ExportDialog.tsx +0 -157
  622. package/src/components/GithubApiProviders/GitHubSettings.tsx +0 -72
  623. package/src/components/GithubApiProviders/OctokitProvider.tsx +0 -60
  624. package/src/components/GithubApiProviders/index.ts +0 -6
  625. package/src/components/GithubEchoResolverProviders/DocumentTreeItem.tsx +0 -26
  626. package/src/components/GithubEchoResolverProviders/ResolverContext.tsx +0 -137
  627. package/src/components/GithubEchoResolverProviders/ResolverDialog.tsx +0 -34
  628. package/src/components/GithubEchoResolverProviders/ResolverProps.ts +0 -23
  629. package/src/components/GithubEchoResolverProviders/ResolverTree.tsx +0 -86
  630. package/src/components/GithubEchoResolverProviders/SpacePickerTreeItem.tsx +0 -105
  631. package/src/components/GithubEchoResolverProviders/index.ts +0 -6
  632. package/src/components/GithubEchoResolverProviders/spaceResolvers.ts +0 -86
  633. package/src/components/ImportDialog.tsx +0 -72
  634. package/src/components/Issue.tsx +0 -62
  635. package/src/components/Loading/Loading.stories.tsx +0 -32
  636. package/src/components/Loading/Loading.tsx +0 -70
  637. package/src/components/Loading/index.ts +0 -5
  638. package/src/components/MarkdownActions.tsx +0 -136
  639. package/src/components/UrlDialog.tsx +0 -52
  640. package/src/hooks/useDocGhId.ts +0 -38
  641. package/src/hooks/useGhIdFromUrl.ts +0 -26
  642. 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
+ +};