@dxos/echo-pipeline 0.8.4-main.ae835ea → 0.8.4-main.bc2380dfbc

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 (221) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/{browser/chunk-WJJXJTNS.mjs → neutral/chunk-46DCRDA7.mjs} +44 -13
  4. package/dist/lib/neutral/chunk-46DCRDA7.mjs.map +7 -0
  5. package/dist/lib/{browser/chunk-FR6GJ4S4.mjs → neutral/chunk-LR7UFVQX.mjs} +145 -618
  6. package/dist/lib/neutral/chunk-LR7UFVQX.mjs.map +7 -0
  7. package/dist/lib/{browser → neutral}/filter/index.mjs +1 -2
  8. package/dist/lib/neutral/index.mjs +7332 -0
  9. package/dist/lib/neutral/index.mjs.map +7 -0
  10. package/dist/lib/neutral/meta.json +1 -0
  11. package/dist/lib/{browser → neutral}/testing/index.mjs +49 -292
  12. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  13. package/dist/types/src/automerge/automerge-host-subduction.test.d.ts +2 -0
  14. package/dist/types/src/automerge/automerge-host-subduction.test.d.ts.map +1 -0
  15. package/dist/types/src/automerge/automerge-host.d.ts +105 -27
  16. package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
  17. package/dist/types/src/automerge/automerge-repo-subduction.test.d.ts +2 -0
  18. package/dist/types/src/automerge/automerge-repo-subduction.test.d.ts.map +1 -0
  19. package/dist/types/src/automerge/automerge-repo.test.d.ts +1 -1
  20. package/dist/types/src/automerge/automerge-repo.test.d.ts.map +1 -1
  21. package/dist/types/src/automerge/automerge-subduction.test.d.ts +2 -0
  22. package/dist/types/src/automerge/automerge-subduction.test.d.ts.map +1 -0
  23. package/dist/types/src/automerge/collection-synchronizer.d.ts +22 -2
  24. package/dist/types/src/automerge/collection-synchronizer.d.ts.map +1 -1
  25. package/dist/types/src/automerge/echo-data-monitor.d.ts.map +1 -1
  26. package/dist/types/src/automerge/echo-network-adapter.d.ts +17 -11
  27. package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
  28. package/dist/types/src/automerge/echo-replicator.d.ts +28 -16
  29. package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
  30. package/dist/types/src/automerge/errors.d.ts +35 -0
  31. package/dist/types/src/automerge/errors.d.ts.map +1 -0
  32. package/dist/types/src/automerge/handle-state.d.ts +52 -0
  33. package/dist/types/src/automerge/handle-state.d.ts.map +1 -0
  34. package/dist/types/src/automerge/heads-store.d.ts +9 -2
  35. package/dist/types/src/automerge/heads-store.d.ts.map +1 -1
  36. package/dist/types/src/automerge/index.d.ts +2 -0
  37. package/dist/types/src/automerge/index.d.ts.map +1 -1
  38. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +10 -4
  39. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts.map +1 -1
  40. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts +8 -8
  41. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts.map +1 -1
  42. package/dist/types/src/automerge/mesh-echo-replicator.d.ts +6 -5
  43. package/dist/types/src/automerge/mesh-echo-replicator.d.ts.map +1 -1
  44. package/dist/types/src/automerge/network-protocol.d.ts.map +1 -1
  45. package/dist/types/src/automerge/space-collection.d.ts.map +1 -1
  46. package/dist/types/src/common/feeds.d.ts.map +1 -1
  47. package/dist/types/src/common/space-id.d.ts.map +1 -1
  48. package/dist/types/src/db-host/automerge-data-source.d.ts +35 -0
  49. package/dist/types/src/db-host/automerge-data-source.d.ts.map +1 -0
  50. package/dist/types/src/db-host/automerge-data-source.test.d.ts +2 -0
  51. package/dist/types/src/db-host/automerge-data-source.test.d.ts.map +1 -0
  52. package/dist/types/src/db-host/automerge-metrics.d.ts.map +1 -1
  53. package/dist/types/src/db-host/data-service.d.ts +12 -6
  54. package/dist/types/src/db-host/data-service.d.ts.map +1 -1
  55. package/dist/types/src/db-host/database-root.d.ts +9 -3
  56. package/dist/types/src/db-host/database-root.d.ts.map +1 -1
  57. package/dist/types/src/db-host/documents-synchronizer.d.ts +30 -8
  58. package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
  59. package/dist/types/src/db-host/echo-host.d.ts +74 -28
  60. package/dist/types/src/db-host/echo-host.d.ts.map +1 -1
  61. package/dist/types/src/db-host/index.d.ts +2 -0
  62. package/dist/types/src/db-host/index.d.ts.map +1 -1
  63. package/dist/types/src/db-host/invalidation-hint.d.ts +19 -0
  64. package/dist/types/src/db-host/invalidation-hint.d.ts.map +1 -0
  65. package/dist/types/src/db-host/local-queue-service.d.ts +19 -0
  66. package/dist/types/src/db-host/local-queue-service.d.ts.map +1 -0
  67. package/dist/types/src/db-host/query-invalidation.test.d.ts +2 -0
  68. package/dist/types/src/db-host/query-invalidation.test.d.ts.map +1 -0
  69. package/dist/types/src/db-host/query-service.d.ts +18 -10
  70. package/dist/types/src/db-host/query-service.d.ts.map +1 -1
  71. package/dist/types/src/db-host/queue-data-source.d.ts +38 -0
  72. package/dist/types/src/db-host/queue-data-source.d.ts.map +1 -0
  73. package/dist/types/src/db-host/queue-service.test.d.ts +2 -0
  74. package/dist/types/src/db-host/queue-service.test.d.ts.map +1 -0
  75. package/dist/types/src/db-host/space-state-manager.d.ts +3 -2
  76. package/dist/types/src/db-host/space-state-manager.d.ts.map +1 -1
  77. package/dist/types/src/db-host/stub.d.ts +11 -0
  78. package/dist/types/src/db-host/stub.d.ts.map +1 -0
  79. package/dist/types/src/edge/echo-edge-replicator.d.ts +7 -6
  80. package/dist/types/src/edge/echo-edge-replicator.d.ts.map +1 -1
  81. package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts +39 -0
  82. package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts.map +1 -0
  83. package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts +2 -0
  84. package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts.map +1 -0
  85. package/dist/types/src/edge/index.d.ts +1 -0
  86. package/dist/types/src/edge/index.d.ts.map +1 -1
  87. package/dist/types/src/edge/inflight-request-limiter.d.ts.map +1 -1
  88. package/dist/types/src/filter/filter-match.d.ts +1 -1
  89. package/dist/types/src/filter/filter-match.d.ts.map +1 -1
  90. package/dist/types/src/metadata/metadata-store.d.ts.map +1 -1
  91. package/dist/types/src/pipeline/message-selector.d.ts.map +1 -1
  92. package/dist/types/src/pipeline/pipeline.d.ts +2 -2
  93. package/dist/types/src/pipeline/pipeline.d.ts.map +1 -1
  94. package/dist/types/src/pipeline/timeframe-clock.d.ts.map +1 -1
  95. package/dist/types/src/query/errors.d.ts +16 -20
  96. package/dist/types/src/query/errors.d.ts.map +1 -1
  97. package/dist/types/src/query/plan.d.ts +59 -8
  98. package/dist/types/src/query/plan.d.ts.map +1 -1
  99. package/dist/types/src/query/query-executor.d.ts +55 -12
  100. package/dist/types/src/query/query-executor.d.ts.map +1 -1
  101. package/dist/types/src/query/query-planner.d.ts +10 -1
  102. package/dist/types/src/query/query-planner.d.ts.map +1 -1
  103. package/dist/types/src/space/admission-discovery-extension.d.ts.map +1 -1
  104. package/dist/types/src/space/auth.d.ts +3 -3
  105. package/dist/types/src/space/auth.d.ts.map +1 -1
  106. package/dist/types/src/space/control-pipeline.d.ts +4 -3
  107. package/dist/types/src/space/control-pipeline.d.ts.map +1 -1
  108. package/dist/types/src/space/space-manager.d.ts +7 -7
  109. package/dist/types/src/space/space-manager.d.ts.map +1 -1
  110. package/dist/types/src/space/space-protocol.d.ts +9 -8
  111. package/dist/types/src/space/space-protocol.d.ts.map +1 -1
  112. package/dist/types/src/space/space.d.ts +5 -5
  113. package/dist/types/src/space/space.d.ts.map +1 -1
  114. package/dist/types/src/testing/change-metadata.d.ts.map +1 -1
  115. package/dist/types/src/testing/index.d.ts +0 -2
  116. package/dist/types/src/testing/index.d.ts.map +1 -1
  117. package/dist/types/src/testing/test-agent-builder.d.ts.map +1 -1
  118. package/dist/types/src/testing/test-feed-builder.d.ts.map +1 -1
  119. package/dist/types/src/testing/test-network-adapter.d.ts +1 -1
  120. package/dist/types/src/testing/test-network-adapter.d.ts.map +1 -1
  121. package/dist/types/src/testing/test-replicator.d.ts +13 -13
  122. package/dist/types/src/testing/test-replicator.d.ts.map +1 -1
  123. package/dist/types/src/util.d.ts.map +1 -1
  124. package/dist/types/tsconfig.tsbuildinfo +1 -1
  125. package/package.json +56 -54
  126. package/src/automerge/automerge-host-subduction.test.ts +263 -0
  127. package/src/automerge/automerge-host.test.ts +145 -32
  128. package/src/automerge/automerge-host.ts +442 -163
  129. package/src/automerge/automerge-repo-subduction.test.ts +1054 -0
  130. package/src/automerge/automerge-repo.test.ts +284 -77
  131. package/src/automerge/automerge-subduction.test.ts +330 -0
  132. package/src/automerge/collection-synchronizer.test.ts +96 -16
  133. package/src/automerge/collection-synchronizer.ts +125 -26
  134. package/src/automerge/echo-network-adapter.test.ts +2 -1
  135. package/src/automerge/echo-network-adapter.ts +44 -32
  136. package/src/automerge/echo-replicator.ts +30 -16
  137. package/src/automerge/errors.ts +10 -0
  138. package/src/automerge/handle-state.ts +62 -0
  139. package/src/automerge/heads-store.ts +42 -3
  140. package/src/automerge/index.ts +2 -0
  141. package/src/automerge/leveldb-storage-adapter.ts +70 -4
  142. package/src/automerge/mesh-echo-replicator-connection.ts +8 -8
  143. package/src/automerge/mesh-echo-replicator.ts +14 -11
  144. package/src/db-host/automerge-data-source.test.ts +299 -0
  145. package/src/db-host/automerge-data-source.ts +187 -0
  146. package/src/db-host/data-service.ts +27 -10
  147. package/src/db-host/database-root.ts +19 -7
  148. package/src/db-host/documents-synchronizer.test.ts +145 -14
  149. package/src/db-host/documents-synchronizer.ts +158 -59
  150. package/src/db-host/echo-host.ts +376 -90
  151. package/src/db-host/index.ts +2 -0
  152. package/src/db-host/invalidation-hint.ts +50 -0
  153. package/src/db-host/local-queue-service.ts +127 -0
  154. package/src/db-host/query-invalidation.test.ts +270 -0
  155. package/src/db-host/query-service.ts +108 -111
  156. package/src/db-host/queue-data-source.ts +160 -0
  157. package/src/db-host/queue-service.test.ts +145 -0
  158. package/src/db-host/space-state-manager.ts +11 -9
  159. package/src/db-host/stub.ts +33 -0
  160. package/src/edge/echo-edge-replicator.test.ts +9 -9
  161. package/src/edge/echo-edge-replicator.ts +50 -42
  162. package/src/edge/echo-edge-subduction-replicator.test.ts +158 -0
  163. package/src/edge/echo-edge-subduction-replicator.ts +377 -0
  164. package/src/edge/index.ts +1 -0
  165. package/src/filter/filter-match.test.ts +41 -25
  166. package/src/filter/filter-match.ts +75 -20
  167. package/src/metadata/metadata-store.ts +8 -5
  168. package/src/pipeline/pipeline-stress.test.ts +1 -3
  169. package/src/pipeline/pipeline.test.ts +0 -1
  170. package/src/pipeline/pipeline.ts +2 -3
  171. package/src/query/errors.ts +2 -2
  172. package/src/query/plan.ts +73 -9
  173. package/src/query/query-executor.ts +1064 -172
  174. package/src/query/query-planner.test.ts +854 -86
  175. package/src/query/query-planner.ts +444 -38
  176. package/src/space/admission-discovery-extension.ts +1 -1
  177. package/src/space/auth.ts +6 -6
  178. package/src/space/control-pipeline.test.ts +2 -2
  179. package/src/space/control-pipeline.ts +7 -7
  180. package/src/space/space-manager.ts +12 -17
  181. package/src/space/space-protocol.browser.test.ts +13 -12
  182. package/src/space/space-protocol.test.ts +13 -13
  183. package/src/space/space-protocol.ts +16 -15
  184. package/src/space/space.test.ts +2 -2
  185. package/src/space/space.ts +9 -10
  186. package/src/testing/index.ts +0 -2
  187. package/src/testing/test-agent-builder.ts +0 -1
  188. package/src/testing/test-network-adapter.ts +24 -3
  189. package/src/testing/test-replicator.ts +15 -20
  190. package/src/util.ts +7 -3
  191. package/dist/lib/browser/chunk-CGS2ULMK.mjs +0 -11
  192. package/dist/lib/browser/chunk-CGS2ULMK.mjs.map +0 -7
  193. package/dist/lib/browser/chunk-FR6GJ4S4.mjs.map +0 -7
  194. package/dist/lib/browser/chunk-WJJXJTNS.mjs.map +0 -7
  195. package/dist/lib/browser/index.mjs +0 -5492
  196. package/dist/lib/browser/index.mjs.map +0 -7
  197. package/dist/lib/browser/meta.json +0 -1
  198. package/dist/lib/browser/testing/index.mjs.map +0 -7
  199. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
  200. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
  201. package/dist/lib/node-esm/chunk-JQCCVDLT.mjs +0 -2144
  202. package/dist/lib/node-esm/chunk-JQCCVDLT.mjs.map +0 -7
  203. package/dist/lib/node-esm/chunk-LEQ77KAT.mjs +0 -240
  204. package/dist/lib/node-esm/chunk-LEQ77KAT.mjs.map +0 -7
  205. package/dist/lib/node-esm/filter/index.mjs +0 -13
  206. package/dist/lib/node-esm/filter/index.mjs.map +0 -7
  207. package/dist/lib/node-esm/index.mjs +0 -5492
  208. package/dist/lib/node-esm/index.mjs.map +0 -7
  209. package/dist/lib/node-esm/meta.json +0 -1
  210. package/dist/lib/node-esm/testing/index.mjs +0 -764
  211. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  212. package/dist/types/src/db-host/documents-iterator.d.ts +0 -7
  213. package/dist/types/src/db-host/documents-iterator.d.ts.map +0 -1
  214. package/dist/types/src/testing/test-data.d.ts +0 -18
  215. package/dist/types/src/testing/test-data.d.ts.map +0 -1
  216. package/dist/types/src/testing/test-schema.d.ts +0 -37
  217. package/dist/types/src/testing/test-schema.d.ts.map +0 -1
  218. package/src/db-host/documents-iterator.ts +0 -74
  219. package/src/testing/test-data.ts +0 -127
  220. package/src/testing/test-schema.ts +0 -53
  221. /package/dist/lib/{browser → neutral}/filter/index.mjs.map +0 -0
@@ -3,31 +3,39 @@
3
3
  //
4
4
 
5
5
  import type { AutomergeUrl, DocumentId } from '@automerge/automerge-repo';
6
- import * as Match from 'effect/Match';
7
- import * as Predicate from 'effect/Predicate';
8
-
9
- import { Context, ContextDisposedError, LifecycleState, Resource } from '@dxos/context';
10
- import { DatabaseDirectory, ObjectStructure, type QueryAST, isEncodedReference } from '@dxos/echo-protocol';
11
- import { EscapedPropPath, type FindResult, type Indexer } from '@dxos/indexing';
6
+ import type * as SqlClient from '@effect/sql/SqlClient';
7
+ import type * as Effect from 'effect/Effect';
8
+ import * as Runtime from 'effect/Runtime';
9
+
10
+ import { ContextDisposedError, LifecycleState, Resource } from '@dxos/context';
11
+ import { type Obj, Query } from '@dxos/echo';
12
+ import {
13
+ DatabaseDirectory,
14
+ EncodedReference,
15
+ type ObjectPropPath,
16
+ ObjectStructure,
17
+ type QueryAST,
18
+ isEncodedReference,
19
+ } from '@dxos/echo-protocol';
20
+ import { ATTR_PARENT, ATTR_RELATION_SOURCE, ATTR_RELATION_TARGET } from '@dxos/echo/internal';
21
+ import { type RuntimeProvider, runAndForwardErrors, unwrapExit } from '@dxos/effect';
22
+ import { EscapedPropPath, type IndexEngine, type ObjectMeta, type ReverseRef } from '@dxos/index-core';
12
23
  import { invariant } from '@dxos/invariant';
13
- import { DXN, type ObjectId, PublicKey, type SpaceId } from '@dxos/keys';
24
+ import { DXN, type ObjectId, type SpaceId } from '@dxos/keys';
14
25
  import { log } from '@dxos/log';
15
- import { objectPointerCodec } from '@dxos/protocols';
16
26
  import { type QueryReactivity, type QueryResult } from '@dxos/protocols/proto/dxos/echo/query';
17
- import { getDeep, isNonNullable } from '@dxos/util';
27
+ import { compositeKey, getDeep, isNonNullable } from '@dxos/util';
18
28
 
19
29
  import type { AutomergeHost } from '../automerge';
20
- import { createIdFromSpaceKey } from '../common';
21
- import type { SpaceStateManager } from '../db-host';
22
- import { filterMatchObject } from '../filter';
23
-
30
+ import type { InvalidationHint, SpaceStateManager } from '../db-host';
31
+ import { filterMatchObject, filterMatchObjectJSON } from '../filter';
32
+ import { QueryError } from './errors';
24
33
  import type { QueryPlan } from './plan';
25
34
  import { QueryPlanner } from './query-planner';
26
35
 
27
- const isNullable: Predicate.Refinement<unknown, null | undefined> = Predicate.isNullable;
28
-
29
36
  type QueryExecutorOptions = {
30
- indexer: Indexer;
37
+ indexEngine: IndexEngine;
38
+ runtime: RuntimeProvider.RuntimeProvider<SqlClient.SqlClient>;
31
39
  automergeHost: AutomergeHost;
32
40
  spaceStateManager: SpaceStateManager;
33
41
 
@@ -48,11 +56,84 @@ type QueryExecutionResult = {
48
56
  */
49
57
  type QueryItem = {
50
58
  objectId: ObjectId;
51
- documentId: DocumentId;
59
+
52
60
  spaceId: SpaceId;
53
- doc: ObjectStructure;
61
+ queueId: ObjectId | null;
62
+ queueNamespace: string | null;
63
+
64
+ // For objects from automerge documents.
65
+ documentId: DocumentId | null;
66
+
67
+ // For objects from automerge documents.
68
+ doc: ObjectStructure | null;
69
+
70
+ // For objects from queues.
71
+ data: Obj.JSON | null;
72
+
73
+ /**
74
+ * Relevance rank for this item.
75
+ * Higher values indicate better matches for FTS/vector searches.
76
+ * Defaults to 1 for non-ranked queries (predicate matches).
77
+ */
78
+ rank: number;
54
79
  };
55
80
 
81
+ const QueryItem = Object.freeze({
82
+ /**
83
+ * Checks if the item is deleted.
84
+ * Only applies to this item, not its parents.
85
+ */
86
+ isDeleted: (item: QueryItem) => {
87
+ if (item.doc) {
88
+ return ObjectStructure.isDeleted(item.doc);
89
+ } else if (item.data) {
90
+ return item.data['@deleted'] === true;
91
+ } else {
92
+ throw new Error('Invalid query item');
93
+ }
94
+ },
95
+
96
+ getProperty: (item: QueryItem, property: ObjectPropPath) => {
97
+ if (item.doc) {
98
+ return getDeep(item.doc.data, property);
99
+ } else if (item.data) {
100
+ return getDeep(item.data, property);
101
+ } else {
102
+ throw new Error('Invalid query item');
103
+ }
104
+ },
105
+
106
+ getParent: (item: QueryItem): DXN.String | undefined => {
107
+ if (item.doc) {
108
+ return ObjectStructure.getParent(item.doc)?.['/'] as DXN.String | undefined;
109
+ } else if (item.data) {
110
+ return item.data[ATTR_PARENT] as DXN.String;
111
+ } else {
112
+ throw new Error('Invalid query item');
113
+ }
114
+ },
115
+
116
+ getRelationSource: (item: QueryItem): DXN.String | undefined => {
117
+ if (item.doc) {
118
+ return ObjectStructure.getRelationSource(item.doc)?.['/'] as DXN.String | undefined;
119
+ } else if (item.data) {
120
+ return item.data[ATTR_RELATION_SOURCE] as DXN.String;
121
+ } else {
122
+ throw new Error('Invalid query item');
123
+ }
124
+ },
125
+
126
+ getRelationTarget: (item: QueryItem): DXN.String | undefined => {
127
+ if (item.doc) {
128
+ return ObjectStructure.getRelationTarget(item.doc)?.['/'] as DXN.String | undefined;
129
+ } else if (item.data) {
130
+ return item.data[ATTR_RELATION_TARGET] as DXN.String;
131
+ } else {
132
+ throw new Error('Invalid query item');
133
+ }
134
+ },
135
+ });
136
+
56
137
  /**
57
138
  * Recursive data structure that represents the execution trace of a query.
58
139
  */
@@ -135,10 +216,167 @@ type StepExecutionResult = {
135
216
  trace: ExecutionTrace;
136
217
  };
137
218
 
138
- const TRACE_QUERY_EXECUTION = false;
219
+ declare global {
220
+ interface ImportMeta {
221
+ env: ImportMetaEnv;
222
+ }
223
+
224
+ interface ImportMetaEnv {
225
+ DX_TRACE_QUERY_EXECUTION: string;
226
+ }
227
+ }
228
+
229
+ const TRACE_QUERY_EXECUTION = !!import.meta.env?.DX_TRACE_QUERY_EXECUTION;
230
+
231
+ const MAX_DEPTH_FOR_DELETION_TRACING = 10;
232
+ const MAX_DEPTH_FOR_CHILD_OF_TRACING = 10;
139
233
 
140
234
  /**
141
- * Executes query plans against the Indexer and AutomergeHost.
235
+ * Cached scope constraints extracted from a query plan.
236
+ * Used to quickly determine whether an invalidation hint can affect this query.
237
+ *
238
+ * A null dimension means the query is unconstrained on that dimension (matches any value).
239
+ */
240
+ type QueryScopes = {
241
+ /**
242
+ * False for text-search, traversal, union, set-difference, or child-of queries.
243
+ * When false, matchesHint always returns true (conservative: always re-execute).
244
+ */
245
+ isSimple: boolean;
246
+ spaceIds: Set<SpaceId> | null;
247
+ queueIds: Set<ObjectId> | null;
248
+ typenames: Set<string> | null;
249
+ objectIds: Set<ObjectId> | null;
250
+ };
251
+
252
+ const extractScopes = (plan: QueryPlan.Plan): QueryScopes => {
253
+ const scopes: QueryScopes = {
254
+ isSimple: true,
255
+ spaceIds: null,
256
+ queueIds: null,
257
+ typenames: null,
258
+ objectIds: null,
259
+ };
260
+
261
+ for (const step of plan.steps) {
262
+ switch (step._tag) {
263
+ case 'SelectStep': {
264
+ // Extract spaceIds from scope.
265
+ if (step.scope.spaceIds && step.scope.spaceIds.length > 0) {
266
+ if (!scopes.spaceIds) {
267
+ scopes.spaceIds = new Set();
268
+ }
269
+ for (const spaceId of step.scope.spaceIds) {
270
+ scopes.spaceIds.add(spaceId as SpaceId);
271
+ }
272
+ }
273
+
274
+ // Extract queueIds from explicit feed DXNs (queue-kinded) and derive spaceIds from them.
275
+ if (step.scope.feeds && step.scope.feeds.length > 0 && !step.scope.allFeedsFromSpaces) {
276
+ let parseFailed = false;
277
+ const derivedQueueIds = new Set<ObjectId>();
278
+ const derivedSpaceIds = new Set<SpaceId>();
279
+ for (const queueDxnStr of step.scope.feeds as DXN.String[]) {
280
+ try {
281
+ const queueDXN = DXN.parse(queueDxnStr).asQueueDXN();
282
+ if (queueDXN) {
283
+ derivedQueueIds.add(queueDXN.queueId as ObjectId);
284
+ derivedSpaceIds.add(queueDXN.spaceId as SpaceId);
285
+ }
286
+ } catch {
287
+ parseFailed = true;
288
+ }
289
+ }
290
+
291
+ if (!parseFailed) {
292
+ if (derivedQueueIds.size > 0) {
293
+ scopes.queueIds ??= new Set<ObjectId>();
294
+ for (const id of derivedQueueIds) {
295
+ scopes.queueIds.add(id);
296
+ }
297
+ }
298
+ if (derivedSpaceIds.size > 0) {
299
+ scopes.spaceIds ??= new Set<SpaceId>();
300
+ for (const id of derivedSpaceIds) {
301
+ // Derive spaceId from the queue DXN so space-scoped hints can skip this query.
302
+ scopes.spaceIds.add(id);
303
+ }
304
+ }
305
+ }
306
+ // On any parse error, leave queue-derived dimensions unconstrained.
307
+ }
308
+
309
+ // Extract typename / objectId constraints from selector.
310
+ switch (step.selector._tag) {
311
+ case 'TypeSelector': {
312
+ if (step.selector.inverted) {
313
+ // Inverted type selectors come from Filter.not — mark as non-simple.
314
+ scopes.isSimple = false;
315
+ } else {
316
+ if (!scopes.typenames) {
317
+ scopes.typenames = new Set();
318
+ }
319
+ for (const typename of step.selector.typename) {
320
+ scopes.typenames.add(typename as string);
321
+ }
322
+ }
323
+ break;
324
+ }
325
+ case 'IdSelector': {
326
+ if (!scopes.objectIds) {
327
+ scopes.objectIds = new Set();
328
+ }
329
+ for (const id of step.selector.objectIds) {
330
+ scopes.objectIds.add(id);
331
+ }
332
+ break;
333
+ }
334
+ case 'TextSelector': {
335
+ scopes.isSimple = false;
336
+ break;
337
+ }
338
+ default:
339
+ // WildcardSelector, TimestampSelector — no type/id constraint.
340
+ break;
341
+ }
342
+ break;
343
+ }
344
+ case 'FilterStep': {
345
+ // child-of filters require transitive parent traversal which can't be hinted.
346
+ if (step.filter.type === 'child-of') {
347
+ scopes.isSimple = false;
348
+ }
349
+ break;
350
+ }
351
+ case 'TraverseStep':
352
+ case 'UnionStep':
353
+ case 'SetDifferenceStep':
354
+ scopes.isSimple = false;
355
+ break;
356
+ default:
357
+ // ClearWorkingSetStep, FilterDeletedStep, OrderStep, LimitStep are fine.
358
+ break;
359
+ }
360
+ }
361
+
362
+ return scopes;
363
+ };
364
+
365
+ const setsOverlap = <T>(a: ReadonlySet<T>, b: ReadonlySet<T>): boolean => {
366
+ const [smaller, larger]: [ReadonlySet<T>, ReadonlySet<T>] = a.size <= b.size ? [a, b] : [b, a];
367
+ for (const item of smaller) {
368
+ if (larger.has(item)) {
369
+ return true;
370
+ }
371
+ }
372
+ return false;
373
+ };
374
+
375
+ const overlapsOrUnconstrained = <T>(hintSet: ReadonlySet<T> | undefined, scopeSet: Set<T> | null): boolean =>
376
+ hintSet === undefined || scopeSet === null || setsOverlap(hintSet, scopeSet);
377
+
378
+ /**
379
+ * Executes query plans against the IndexEngine and AutomergeHost.
142
380
  *
143
381
  * The QueryExecutor is responsible for:
144
382
  * - Executing query plans step by step
@@ -148,7 +386,8 @@ const TRACE_QUERY_EXECUTION = false;
148
386
  * - Handling different types of query operations (select, filter, traverse, etc.)
149
387
  */
150
388
  export class QueryExecutor extends Resource {
151
- private readonly _indexer: Indexer;
389
+ private readonly _indexEngine: IndexEngine;
390
+ private readonly _runtime: RuntimeProvider.RuntimeProvider<SqlClient.SqlClient>;
152
391
  private readonly _automergeHost: AutomergeHost;
153
392
  private readonly _spaceStateManager: SpaceStateManager;
154
393
  /**
@@ -160,13 +399,15 @@ export class QueryExecutor extends Resource {
160
399
  private readonly _reactivity: QueryReactivity;
161
400
 
162
401
  private _plan: QueryPlan.Plan;
402
+ #scopes: QueryScopes;
163
403
  private _trace: ExecutionTrace = ExecutionTrace.makeEmpty();
164
404
  private _lastResultSet: QueryItem[] = [];
165
405
 
166
406
  constructor(options: QueryExecutorOptions) {
167
407
  super();
168
408
 
169
- this._indexer = options.indexer;
409
+ this._indexEngine = options.indexEngine;
410
+ this._runtime = options.runtime;
170
411
  this._automergeHost = options.automergeHost;
171
412
  this._spaceStateManager = options.spaceStateManager;
172
413
 
@@ -176,6 +417,11 @@ export class QueryExecutor extends Resource {
176
417
 
177
418
  const queryPlanner = new QueryPlanner();
178
419
  this._plan = queryPlanner.createPlan(this._query);
420
+ this.#scopes = extractScopes(this._plan);
421
+ }
422
+
423
+ get queryId(): string {
424
+ return this._id;
179
425
  }
180
426
 
181
427
  get query(): QueryAST.Query {
@@ -190,31 +436,54 @@ export class QueryExecutor extends Resource {
190
436
  return this._trace;
191
437
  }
192
438
 
193
- protected override async _open(ctx: Context): Promise<void> {}
194
-
195
- protected override async _close(ctx: Context): Promise<void> {}
196
-
197
439
  getResults(): QueryResult[] {
198
440
  return this._lastResultSet.map(
199
441
  (item): QueryResult => ({
200
442
  id: item.objectId,
201
- documentId: item.documentId,
443
+ documentId: item.documentId ?? undefined,
444
+ queueId: item.queueId ?? undefined,
445
+ queueNamespace: item.queueNamespace ?? undefined,
202
446
  spaceId: item.spaceId,
203
447
 
204
- // TODO(dmaretskyi): Plumb through the rank.
205
- rank: 0,
448
+ rank: item.rank,
449
+
450
+ documentJson: item.doc ? JSON.stringify(item.doc) : item.data ? JSON.stringify(item.data) : undefined,
206
451
  }),
207
452
  );
208
453
  }
209
454
 
455
+ /**
456
+ * Returns true if the given invalidation hint could affect this query's result set.
457
+ * When false, the query can safely be skipped for this invalidation cycle.
458
+ *
459
+ * Conservative: returns true for complex queries (traversals, text-search, unions) and
460
+ * for any dimension where either the hint or the query is unconstrained.
461
+ */
462
+ matchesHint(hint: InvalidationHint): boolean {
463
+ if (!this.#scopes.isSimple) {
464
+ return true;
465
+ }
466
+ return (
467
+ overlapsOrUnconstrained(hint.spaceIds, this.#scopes.spaceIds) &&
468
+ overlapsOrUnconstrained(hint.queueIds, this.#scopes.queueIds) &&
469
+ overlapsOrUnconstrained(hint.typenames, this.#scopes.typenames) &&
470
+ overlapsOrUnconstrained(hint.objectIds, this.#scopes.objectIds)
471
+ );
472
+ }
473
+
210
474
  async execQuery(): Promise<QueryExecutionResult> {
211
475
  invariant(this._lifecycleState === LifecycleState.OPEN);
212
476
 
477
+ log('exec query', {
478
+ queryId: this._id,
479
+ query: Query.pretty(Query.fromAst(this._query)),
480
+ });
481
+
213
482
  const prevResultSet = this._lastResultSet;
214
483
  const { workingSet, trace } = await this._execPlan(this._plan, []);
215
484
  this._lastResultSet = workingSet;
216
485
  trace.name = 'Root';
217
- trace.details = JSON.stringify({ id: this._id });
486
+ trace.details = JSON.stringify({ id: this._id, query: Query.pretty(Query.fromAst(this._query)) });
218
487
  this._trace = trace;
219
488
 
220
489
  const changed =
@@ -223,13 +492,16 @@ export class QueryExecutor extends Resource {
223
492
  (item, index) =>
224
493
  workingSet[index].objectId !== item.objectId ||
225
494
  workingSet[index].spaceId !== item.spaceId ||
226
- workingSet[index].documentId !== item.documentId,
495
+ workingSet[index].documentId !== item.documentId ||
496
+ workingSet[index].queueId !== item.queueId ||
497
+ workingSet[index].queueNamespace !== item.queueNamespace,
227
498
  );
228
499
 
229
500
  // Disabled because concurrent queries don't print hierarchies correctly.
230
501
  // ExecutionTrace.putOnPerformanceTimeline(trace);
231
502
 
232
503
  if (TRACE_QUERY_EXECUTION) {
504
+ // eslint-disable-next-line no-console
233
505
  console.log(ExecutionTrace.format(trace));
234
506
  }
235
507
 
@@ -240,7 +512,6 @@ export class QueryExecutor extends Resource {
240
512
 
241
513
  private async _execPlan(plan: QueryPlan.Plan, workingSet: QueryItem[]): Promise<StepExecutionResult> {
242
514
  const trace = ExecutionTrace.makeEmpty();
243
- const begin = performance.now();
244
515
  for (const step of plan.steps) {
245
516
  if (this._ctx.disposed) {
246
517
  throw new ContextDisposedError();
@@ -288,6 +559,9 @@ export class QueryExecutor extends Resource {
288
559
  case 'OrderStep':
289
560
  ({ workingSet: newWorkingSet, trace } = await this._execOrderStep(step, workingSet));
290
561
  break;
562
+ case 'LimitStep':
563
+ ({ workingSet: newWorkingSet, trace } = await this._execLimitStep(step, workingSet));
564
+ break;
291
565
  default:
292
566
  throw new Error(`Unknown step type: ${(step as any)._tag}`);
293
567
  }
@@ -300,6 +574,10 @@ export class QueryExecutor extends Resource {
300
574
  private async _execSelectStep(step: QueryPlan.SelectStep, workingSet: QueryItem[]): Promise<StepExecutionResult> {
301
575
  workingSet = [...workingSet];
302
576
 
577
+ const spaces = (step.scope.spaceIds ?? []) as SpaceId[];
578
+ const queues = (step.scope.feeds ?? []) as DXN.String[];
579
+ const allQueuesFromSpaces = step.scope.allFeedsFromSpaces ?? false;
580
+
303
581
  const trace: ExecutionTrace = {
304
582
  ...ExecutionTrace.makeEmpty(),
305
583
  name: 'Select',
@@ -310,11 +588,9 @@ export class QueryExecutor extends Resource {
310
588
  switch (step.selector._tag) {
311
589
  case 'WildcardSelector': {
312
590
  const beginIndexQuery = performance.now();
313
- const indexHits = await this._indexer.execQuery({
314
- typenames: [],
315
- inverted: false,
316
- });
317
- trace.indexHits = +indexHits.length;
591
+ const queueIds = extractQueueIds(queues);
592
+ const metas = await this._queryAllFromSqlIndex(spaces, allQueuesFromSpaces, queueIds);
593
+ trace.indexHits = metas.length;
318
594
  trace.indexQueryTime += performance.now() - beginIndexQuery;
319
595
 
320
596
  if (this._ctx.disposed) {
@@ -322,11 +598,11 @@ export class QueryExecutor extends Resource {
322
598
  }
323
599
 
324
600
  const documentLoadStart = performance.now();
325
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
601
+ const results = await this._loadDocumentsAfterSqlQuery(metas);
326
602
  trace.documentsLoaded += results.length;
327
603
  trace.documentLoadTime += performance.now() - documentLoadStart;
328
604
 
329
- workingSet.push(...results.filter(isNonNullable).filter((item) => step.spaces.includes(item.spaceId)));
605
+ workingSet.push(...results.filter(isNonNullable));
330
606
  trace.objectCount = workingSet.length;
331
607
 
332
608
  break;
@@ -334,10 +610,18 @@ export class QueryExecutor extends Resource {
334
610
 
335
611
  case 'IdSelector': {
336
612
  const beginLoad = performance.now();
613
+
337
614
  const items = await Promise.all(
338
- step.selector.objectIds.map((id) =>
339
- this._loadFromDXN(DXN.fromLocalObjectId(id), { sourceSpaceId: step.spaces[0] }),
340
- ),
615
+ step.selector.objectIds.map((id) => {
616
+ if (queues.length > 0) {
617
+ const { spaceId } = DXN.parse(queues[0]).asQueueDXN()!;
618
+ return this._loadFromDXN(DXN.parse(queues[0]).extend([id]), { sourceSpaceId: spaceId });
619
+ } else if (spaces.length > 0) {
620
+ return this._loadFromDXN(DXN.fromLocalObjectId(id), { sourceSpaceId: spaces[0] });
621
+ } else {
622
+ return null; // Unknown scope.
623
+ }
624
+ }),
341
625
  );
342
626
  trace.documentLoadTime += performance.now() - beginLoad;
343
627
 
@@ -348,11 +632,15 @@ export class QueryExecutor extends Resource {
348
632
 
349
633
  case 'TypeSelector': {
350
634
  const beginIndexQuery = performance.now();
351
- const indexHits = await this._indexer.execQuery({
352
- typenames: step.selector.typename,
353
- inverted: step.selector.inverted,
354
- });
355
- trace.indexHits = +indexHits.length;
635
+ const queueIds = extractQueueIds(queues);
636
+ const metas = await this._queryTypesFromSqlIndex(
637
+ spaces,
638
+ step.selector.typename,
639
+ step.selector.inverted,
640
+ allQueuesFromSpaces,
641
+ queueIds,
642
+ );
643
+ trace.indexHits = metas.length;
356
644
  trace.indexQueryTime += performance.now() - beginIndexQuery;
357
645
 
358
646
  if (this._ctx.disposed) {
@@ -360,43 +648,144 @@ export class QueryExecutor extends Resource {
360
648
  }
361
649
 
362
650
  const documentLoadStart = performance.now();
363
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
651
+ const results = await this._loadDocumentsAfterSqlQuery(metas);
364
652
  trace.documentsLoaded += results.length;
365
653
  trace.documentLoadTime += performance.now() - documentLoadStart;
366
654
 
367
- workingSet.push(...results.filter(isNonNullable).filter((item) => step.spaces.includes(item.spaceId)));
655
+ workingSet.push(...results.filter(isNonNullable));
656
+ trace.objectCount = workingSet.length;
657
+
658
+ break;
659
+ }
660
+
661
+ case 'TimestampSelector': {
662
+ const beginIndexQuery = performance.now();
663
+ const queueIds = extractQueueIds(queues);
664
+ const metas = await this._runInRuntime(
665
+ this._indexEngine.queryByTimeRange({
666
+ spaceIds: spaces,
667
+ updatedAfter: step.selector.updatedAfter,
668
+ updatedBefore: step.selector.updatedBefore,
669
+ createdAfter: step.selector.createdAfter,
670
+ createdBefore: step.selector.createdBefore,
671
+ includeAllQueues: allQueuesFromSpaces,
672
+ queueIds,
673
+ }),
674
+ );
675
+ trace.indexHits = metas.length;
676
+ trace.indexQueryTime += performance.now() - beginIndexQuery;
677
+
678
+ if (this._ctx.disposed) {
679
+ return { workingSet, trace };
680
+ }
681
+
682
+ const documentLoadStart = performance.now();
683
+ const results = await this._loadDocumentsAfterSqlQuery(metas);
684
+ trace.documentsLoaded += results.length;
685
+ trace.documentLoadTime += performance.now() - documentLoadStart;
686
+
687
+ workingSet.push(...results.filter(isNonNullable));
368
688
  trace.objectCount = workingSet.length;
369
689
 
370
690
  break;
371
691
  }
372
692
 
373
693
  case 'TextSelector': {
694
+ // TODO(dmaretskyi): type + FTS queries would be very common so we should support those, maybe chunk the fts index.
695
+ // TODO(dmaretskyi): nice to have matched text snippets/highlighting.
696
+ if (step.selector.searchKind === 'vector') {
697
+ // Vector search is not currently supported.
698
+ log.warn('Vector search is not supported');
699
+ break;
700
+ }
701
+
702
+ // Full-text search using SQLite FTS5.
374
703
  const beginIndexQuery = performance.now();
375
- const indexHits = await this._indexer.execQuery({
376
- typenames: [],
377
- text: {
704
+ invariant(spaces.length <= 1, 'Multiple spaces are not supported for full-text search');
705
+ const queueIds = extractQueueIds(queues);
706
+ const textResults = await this._runInRuntime(
707
+ this._indexEngine.queryText({
378
708
  query: step.selector.text,
379
- kind: Match.type<QueryPlan.TextSearchKind>().pipe(
380
- Match.withReturnType<'text' | 'vector'>(),
381
- Match.when('full-text', () => 'text'),
382
- Match.when('vector', () => 'vector'),
383
- Match.orElseAbsurd,
384
- )(step.selector.searchKind),
385
- },
386
- });
387
- trace.indexHits = +indexHits.length;
709
+ spaceId: spaces,
710
+ includeAllQueues: allQueuesFromSpaces,
711
+ queueIds,
712
+ }),
713
+ );
714
+ trace.indexHits = textResults.length;
388
715
  trace.indexQueryTime += performance.now() - beginIndexQuery;
389
716
 
390
717
  if (this._ctx.disposed) {
391
718
  return { workingSet, trace };
392
719
  }
393
720
 
721
+ // Load documents from the results.
394
722
  const documentLoadStart = performance.now();
395
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
396
- trace.documentsLoaded += results.length;
723
+
724
+ // Separate queue items from space items.
725
+ const queueResults = textResults.filter((r) => r.queueId);
726
+ const spaceResults = textResults.filter((r) => !r.queueId);
727
+
728
+ // Build a map from recordId to rank for all FTS results.
729
+ const rankMap = new Map(textResults.map((r) => [r.recordId, r.rank]));
730
+
731
+ // Load queue items from indexed snapshots.
732
+ let queueItems: QueryItem[] = [];
733
+ if (queueResults.length > 0) {
734
+ const snapshots = await this._runInRuntime(
735
+ this._indexEngine.querySnapshotsJSON(queueResults.map((r) => r.recordId)),
736
+ );
737
+ const snapshotMap = new Map(snapshots.map((s) => [s.recordId, s.snapshot]));
738
+ queueItems = queueResults
739
+ .map((result): QueryItem | null => {
740
+ const snapshot = snapshotMap.get(result.recordId);
741
+ if (!snapshot || typeof snapshot !== 'object') {
742
+ return null;
743
+ }
744
+ return {
745
+ objectId: result.objectId as ObjectId,
746
+ spaceId: result.spaceId as SpaceId,
747
+ queueId: result.queueId as ObjectId,
748
+ queueNamespace: result.queueNamespace || null,
749
+ documentId: null,
750
+ doc: null,
751
+ data: snapshot as Obj.JSON,
752
+ rank: rankMap.get(result.recordId) ?? 1,
753
+ };
754
+ })
755
+ .filter(isNonNullable);
756
+ }
757
+
758
+ // Load space items from documents.
759
+ const spaceItems = await Promise.all(
760
+ spaceResults.map(async (result): Promise<QueryItem | null> => {
761
+ const dxn = DXN.fromLocalObjectId(result.objectId);
762
+ const item = await this._loadFromDXN(dxn, { sourceSpaceId: result.spaceId as SpaceId });
763
+ if (item) {
764
+ // Override the default rank with the FTS rank.
765
+ item.rank = rankMap.get(result.recordId) ?? 1;
766
+ }
767
+ return item;
768
+ }),
769
+ );
770
+
771
+ const items = [...queueItems, ...spaceItems.filter(isNonNullable)];
772
+ trace.documentsLoaded += items.length;
397
773
  trace.documentLoadTime += performance.now() - documentLoadStart;
398
774
 
399
- workingSet.push(...results.filter(isNonNullable).filter((item) => step.spaces.includes(item.spaceId)));
775
+ workingSet.push(
776
+ ...items.filter((item) => {
777
+ if (spaces.includes(item.spaceId)) {
778
+ return true;
779
+ }
780
+ if (item.queueId) {
781
+ return queues.some((dxn) => {
782
+ const { queueId, spaceId } = DXN.parse(dxn).asQueueDXN()!;
783
+ return queueId === item.queueId && spaceId === item.spaceId;
784
+ });
785
+ }
786
+ return false;
787
+ }),
788
+ );
400
789
  trace.objectCount = workingSet.length;
401
790
  break;
402
791
  }
@@ -405,17 +794,46 @@ export class QueryExecutor extends Resource {
405
794
  throw new Error(`Unknown selector type: ${(step.selector as any)._tag}`);
406
795
  }
407
796
 
797
+ // Apply limit if specified on the select step.
798
+ if (step.limit !== undefined && workingSet.length > step.limit) {
799
+ workingSet = workingSet.slice(0, step.limit);
800
+ trace.objectCount = workingSet.length;
801
+ }
802
+
408
803
  return { workingSet, trace };
409
804
  }
410
805
 
411
806
  private async _execFilterStep(step: QueryPlan.FilterStep, workingSet: QueryItem[]): Promise<StepExecutionResult> {
412
- const result = workingSet.filter((item) =>
413
- filterMatchObject(step.filter, {
414
- id: item.objectId,
415
- spaceId: item.spaceId,
416
- doc: item.doc,
417
- }),
418
- );
807
+ if (step.filter.type === 'child-of') {
808
+ return this._execChildOfFilterStep(step.filter, workingSet);
809
+ }
810
+
811
+ const timestampParams = extractTimestampParams(step.filter);
812
+ if (timestampParams !== null) {
813
+ return this._execTimestampFilterStep(step, workingSet, timestampParams);
814
+ }
815
+
816
+ if (filterContainsTimestamp(step.filter)) {
817
+ throw new QueryError({
818
+ message:
819
+ 'Timestamp filter in unsupported composition (not, or). Use Filter.updated/Filter.created with explicit range instead.',
820
+ context: {},
821
+ });
822
+ }
823
+
824
+ const result = workingSet.filter((item) => {
825
+ if (item.doc) {
826
+ return filterMatchObject(step.filter, {
827
+ id: item.objectId,
828
+ spaceId: item.spaceId,
829
+ doc: item.doc,
830
+ });
831
+ } else if (item.data) {
832
+ return filterMatchObjectJSON(step.filter, item.data);
833
+ } else {
834
+ return false;
835
+ }
836
+ });
419
837
 
420
838
  return {
421
839
  workingSet: result,
@@ -428,12 +846,52 @@ export class QueryExecutor extends Resource {
428
846
  };
429
847
  }
430
848
 
849
+ private async _execTimestampFilterStep(
850
+ step: QueryPlan.FilterStep,
851
+ workingSet: QueryItem[],
852
+ params: { updatedAfter?: number; updatedBefore?: number; createdAfter?: number; createdBefore?: number },
853
+ ): Promise<StepExecutionResult> {
854
+ const spaces = [...new Set(workingSet.map((item) => item.spaceId).filter(isNonNullable))];
855
+ const metas = await this._runInRuntime(
856
+ this._indexEngine.queryByTimeRange({
857
+ spaceIds: spaces,
858
+ ...params,
859
+ includeAllQueues: false,
860
+ queueIds: [],
861
+ }),
862
+ );
863
+ const matchingIds = new Set(metas.map((m) => m.objectId));
864
+ const result = workingSet.filter((item) => matchingIds.has(item.objectId));
865
+ return {
866
+ workingSet: result,
867
+ trace: {
868
+ ...ExecutionTrace.makeEmpty(),
869
+ name: 'Filter(timestamp)',
870
+ details: JSON.stringify(params),
871
+ objectCount: result.length,
872
+ },
873
+ };
874
+ }
875
+
431
876
  private async _execFilterDeletedStep(
432
877
  step: QueryPlan.FilterDeletedStep,
433
878
  workingSet: QueryItem[],
434
879
  ): Promise<StepExecutionResult> {
435
880
  const expected = step.mode === 'only-deleted';
436
- const result = workingSet.filter((item) => ObjectStructure.isDeleted(item.doc) === expected);
881
+
882
+ const deletedState = workingSet.map((item) => QueryItem.isDeleted(item));
883
+ await Promise.all(
884
+ workingSet.map(async (item, index) => {
885
+ if (deletedState[index]) {
886
+ return;
887
+ }
888
+ deletedState[index] ||= await this._getTransitiveDeletionState(item, MAX_DEPTH_FOR_DELETION_TRACING);
889
+ }),
890
+ );
891
+
892
+ const result = workingSet.filter((item, index) => deletedState[index] === expected);
893
+
894
+ // TODO(dmaretskyi): How do we handle items with parents and cascade deletions? -- perhaps we forbid queue items from having parents -- i.e. queue is their parent.
437
895
  return {
438
896
  workingSet: result,
439
897
  trace: {
@@ -445,6 +903,78 @@ export class QueryExecutor extends Resource {
445
903
  };
446
904
  }
447
905
 
906
+ private async _execChildOfFilterStep(
907
+ filter: QueryAST.FilterChildOf,
908
+ workingSet: QueryItem[],
909
+ ): Promise<StepExecutionResult> {
910
+ const parentObjectIds = new Set<string>();
911
+ for (const parentDxnStr of filter.parents) {
912
+ const dxn = DXN.parse(parentDxnStr);
913
+ const echoDXN = dxn.asEchoDXN();
914
+ if (echoDXN) {
915
+ parentObjectIds.add(echoDXN.echoId);
916
+ }
917
+ }
918
+ const maxDepth = filter.transitive ? MAX_DEPTH_FOR_CHILD_OF_TRACING : 1;
919
+
920
+ const matches = await Promise.all(
921
+ workingSet.map(async (item) => this._isChildOfAny(item, parentObjectIds, maxDepth)),
922
+ );
923
+ const result = workingSet.filter((_item, index) => matches[index]);
924
+
925
+ return {
926
+ workingSet: result,
927
+ trace: {
928
+ ...ExecutionTrace.makeEmpty(),
929
+ name: 'Filter(child-of)',
930
+ details: JSON.stringify({ parents: filter.parents, transitive: filter.transitive }),
931
+ objectCount: result.length,
932
+ },
933
+ };
934
+ }
935
+
936
+ /**
937
+ * Checks if an item is a child of any of the given parent object IDs.
938
+ * Walks up the parent chain (and feed ownership for queue items) until a match is found or depth is exhausted.
939
+ */
940
+ private async _isChildOfAny(item: QueryItem, parentObjectIds: Set<string>, remainingDepth: number): Promise<boolean> {
941
+ if (remainingDepth <= 0) {
942
+ return false;
943
+ }
944
+
945
+ const parentRefs: { dxnStr: string; objectId: string }[] = [];
946
+
947
+ const directParent = QueryItem.getParent(item);
948
+ if (directParent) {
949
+ const echoDXN = DXN.parse(directParent).asEchoDXN();
950
+ if (echoDXN) {
951
+ parentRefs.push({ dxnStr: directParent, objectId: echoDXN.echoId });
952
+ }
953
+ }
954
+
955
+ if (item.queueId && !directParent) {
956
+ parentRefs.push({
957
+ dxnStr: DXN.fromSpaceAndObjectId(item.spaceId, item.queueId).toString(),
958
+ objectId: item.queueId,
959
+ });
960
+ }
961
+
962
+ for (const ref of parentRefs) {
963
+ if (parentObjectIds.has(ref.objectId)) {
964
+ return true;
965
+ }
966
+ }
967
+
968
+ for (const ref of parentRefs) {
969
+ const parentItem = await this._loadFromDXN(DXN.parse(ref.dxnStr), { sourceSpaceId: item.spaceId });
970
+ if (parentItem && (await this._isChildOfAny(parentItem, parentObjectIds, remainingDepth - 1))) {
971
+ return true;
972
+ }
973
+ }
974
+
975
+ return false;
976
+ }
977
+
448
978
  // TODO(dmaretskyi): This needs to be completed.
449
979
  private async _execTraverseStep(step: QueryPlan.TraverseStep, workingSet: QueryItem[]): Promise<StepExecutionResult> {
450
980
  const trace: ExecutionTrace = {
@@ -459,11 +989,12 @@ export class QueryExecutor extends Resource {
459
989
  case 'ReferenceTraversal': {
460
990
  switch (step.traversal.direction) {
461
991
  case 'outgoing': {
992
+ invariant(step.traversal.property !== null, 'Outgoing reference traversal requires a property');
462
993
  const property = EscapedPropPath.unescape(step.traversal.property);
463
994
 
464
995
  const refs = workingSet
465
996
  .flatMap((item) => {
466
- const ref = getDeep(item.doc.data, property);
997
+ const ref = QueryItem.getProperty(item, property);
467
998
  const refs = Array.isArray(ref) ? ref : [ref];
468
999
  return refs.map((ref) => {
469
1000
  try {
@@ -493,19 +1024,13 @@ export class QueryExecutor extends Resource {
493
1024
  break;
494
1025
  }
495
1026
  case 'incoming': {
496
- const indexHits = await this._indexer.execQuery({
497
- typenames: [],
498
- inverted: false,
499
- graph: {
500
- kind: 'inbound-reference',
501
- property: step.traversal.property,
502
- anchors: workingSet.map((item) => item.objectId),
503
- },
504
- });
505
- trace.indexHits += indexHits.length;
1027
+ const beginIndexQuery = performance.now();
1028
+ const metas = await this._queryIncomingReferencesFromSqlIndex(workingSet, step.traversal.property);
1029
+ trace.indexHits += metas.length;
1030
+ trace.indexQueryTime += performance.now() - beginIndexQuery;
506
1031
 
507
1032
  const documentLoadStart = performance.now();
508
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
1033
+ const results = await this._loadDocumentsAfterSqlQuery(metas);
509
1034
  trace.documentsLoaded += results.length;
510
1035
  trace.documentLoadTime += performance.now() - documentLoadStart;
511
1036
 
@@ -523,21 +1048,20 @@ export class QueryExecutor extends Resource {
523
1048
  case 'relation-to-target': {
524
1049
  const refs = workingSet
525
1050
  .map((item) => {
526
- const ref =
1051
+ const dxn =
527
1052
  step.traversal.direction === 'relation-to-source'
528
- ? ObjectStructure.getRelationSource(item.doc)
529
- : ObjectStructure.getRelationTarget(item.doc);
530
-
531
- if (!isEncodedReference(ref)) {
1053
+ ? QueryItem.getRelationSource(item)
1054
+ : QueryItem.getRelationTarget(item);
1055
+ if (!dxn) {
532
1056
  return null;
533
1057
  }
534
1058
  try {
535
1059
  return {
536
- ref: DXN.parse(ref['/']),
1060
+ ref: DXN.parse(dxn),
537
1061
  spaceId: item.spaceId,
538
1062
  };
539
1063
  } catch {
540
- log.warn('invalid reference', { ref: ref['/'] });
1064
+ log.warn('invalid reference', { ref: dxn });
541
1065
  return null;
542
1066
  }
543
1067
  })
@@ -557,20 +1081,16 @@ export class QueryExecutor extends Resource {
557
1081
 
558
1082
  case 'source-to-relation':
559
1083
  case 'target-to-relation': {
560
- const indexHits = await this._indexer.execQuery({
561
- typenames: [],
562
- inverted: false,
563
- graph: {
564
- kind: step.traversal.direction === 'source-to-relation' ? 'relation-source' : 'relation-target',
565
- anchors: workingSet.map((item) => item.objectId),
566
- property: null,
567
- },
568
- });
569
-
570
- trace.indexHits += indexHits.length;
1084
+ const beginIndexQuery = performance.now();
1085
+ const metas = await this._queryRelationsFromSqlIndex(
1086
+ workingSet,
1087
+ step.traversal.direction === 'source-to-relation' ? 'source' : 'target',
1088
+ );
1089
+ trace.indexHits += metas.length;
1090
+ trace.indexQueryTime += performance.now() - beginIndexQuery;
571
1091
 
572
1092
  const documentLoadStart = performance.now();
573
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
1093
+ const results = await this._loadDocumentsAfterSqlQuery(metas);
574
1094
  trace.documentsLoaded += results.length;
575
1095
  trace.documentLoadTime += performance.now() - documentLoadStart;
576
1096
 
@@ -582,6 +1102,80 @@ export class QueryExecutor extends Resource {
582
1102
  }
583
1103
  break;
584
1104
  }
1105
+ case 'HierarchyTraversal': {
1106
+ switch (step.traversal.direction) {
1107
+ case 'to-parent': {
1108
+ // Traverse from child to parent using the parent reference in the document.
1109
+ const refs = workingSet
1110
+ .map((item) => {
1111
+ if (!item.doc) {
1112
+ return null; // TODO(dmaretskyi): Queue items not supported here.
1113
+ }
1114
+ const ref = ObjectStructure.getParent(item.doc);
1115
+ if (!EncodedReference.isEncodedReference(ref)) {
1116
+ return null;
1117
+ }
1118
+ try {
1119
+ return {
1120
+ ref: DXN.parse(ref['/']),
1121
+ spaceId: item.spaceId,
1122
+ };
1123
+ } catch {
1124
+ log.warn('invalid parent reference', { ref: ref['/'] });
1125
+ return null;
1126
+ }
1127
+ })
1128
+ .filter(isNonNullable);
1129
+
1130
+ const beginLoad = performance.now();
1131
+ const items = await Promise.all(
1132
+ refs.map(({ ref, spaceId }) => this._loadFromDXN(ref, { sourceSpaceId: spaceId })),
1133
+ );
1134
+ trace.documentLoadTime += performance.now() - beginLoad;
1135
+
1136
+ newWorkingSet.push(...items.filter(isNonNullable));
1137
+ trace.objectCount = newWorkingSet.length;
1138
+ break;
1139
+ }
1140
+
1141
+ case 'to-children': {
1142
+ // Traverse from parent to children using the SQL index.
1143
+ // Covers both standard parent/child hierarchy (via the `parent` field) and
1144
+ // feed -> queue items (via the `queueId` field — a feed's queue id matches the feed's object id).
1145
+ // Group working set by spaceId.
1146
+ const bySpace = new Map<SpaceId, ObjectId[]>();
1147
+ for (const item of workingSet) {
1148
+ const existing = bySpace.get(item.spaceId);
1149
+ if (existing) {
1150
+ existing.push(item.objectId);
1151
+ } else {
1152
+ bySpace.set(item.spaceId, [item.objectId]);
1153
+ }
1154
+ }
1155
+
1156
+ const beginIndexQuery = performance.now();
1157
+ const allMetas: ObjectMeta[] = [];
1158
+ for (const [spaceId, parentIds] of bySpace) {
1159
+ const children = await this._runInRuntime(
1160
+ this._indexEngine.queryChildren({ spaceId: [spaceId], parentIds }),
1161
+ );
1162
+ allMetas.push(...children);
1163
+ }
1164
+ trace.indexHits += allMetas.length;
1165
+ trace.indexQueryTime += performance.now() - beginIndexQuery;
1166
+
1167
+ const documentLoadStart = performance.now();
1168
+ const results = await this._loadDocumentsAfterSqlQuery(allMetas);
1169
+ trace.documentsLoaded += results.filter(isNonNullable).length;
1170
+ trace.documentLoadTime += performance.now() - documentLoadStart;
1171
+
1172
+ newWorkingSet.push(...results.filter(isNonNullable));
1173
+ trace.objectCount = newWorkingSet.length;
1174
+ break;
1175
+ }
1176
+ }
1177
+ break;
1178
+ }
585
1179
  default:
586
1180
  throw new Error(`Unknown traversal type: ${(step.traversal as any)._tag}`);
587
1181
  }
@@ -603,7 +1197,7 @@ export class QueryExecutor extends Resource {
603
1197
  for (const resultSet of resultSets) {
604
1198
  for (const item of resultSet.workingSet) {
605
1199
  // Could be duplicate object ids in different spaces or in different epochs of the same space.
606
- results.set(`${item.spaceId}:${item.documentId}:${item.objectId}`, item);
1200
+ results.set(compositeKey(item.spaceId, String(item.documentId), item.objectId), item);
607
1201
  }
608
1202
  trace.children.push(resultSet.trace);
609
1203
  }
@@ -637,19 +1231,38 @@ export class QueryExecutor extends Resource {
637
1231
  }
638
1232
 
639
1233
  private async _execOrderStep(step: QueryPlan.OrderStep, workingSet: QueryItem[]): Promise<StepExecutionResult> {
640
- const sortedWorkingSet = [...workingSet].sort((a, b) => this._compareMultiOrder(a, b, step.order));
1234
+ let sortedWorkingSet = [...workingSet].sort((a, b) => this._compareMultiOrder(a, b, step.order));
1235
+
1236
+ // Apply limit if specified on the order step.
1237
+ if (step.limit !== undefined && sortedWorkingSet.length > step.limit) {
1238
+ sortedWorkingSet = sortedWorkingSet.slice(0, step.limit);
1239
+ }
641
1240
 
642
1241
  return {
643
1242
  workingSet: sortedWorkingSet,
644
1243
  trace: {
645
1244
  ...ExecutionTrace.makeEmpty(),
646
1245
  name: 'Order',
647
- details: JSON.stringify(step.order),
1246
+ details: JSON.stringify({ order: step.order, limit: step.limit }),
648
1247
  objectCount: sortedWorkingSet.length,
649
1248
  },
650
1249
  };
651
1250
  }
652
1251
 
1252
+ private async _execLimitStep(step: QueryPlan.LimitStep, workingSet: QueryItem[]): Promise<StepExecutionResult> {
1253
+ const limitedWorkingSet = workingSet.slice(0, step.limit);
1254
+
1255
+ return {
1256
+ workingSet: limitedWorkingSet,
1257
+ trace: {
1258
+ ...ExecutionTrace.makeEmpty(),
1259
+ name: 'Limit',
1260
+ details: JSON.stringify({ limit: step.limit }),
1261
+ objectCount: limitedWorkingSet.length,
1262
+ },
1263
+ };
1264
+ }
1265
+
653
1266
  private _compareMultiOrder(a: QueryItem, b: QueryItem, orders: readonly QueryAST.Order[]): number {
654
1267
  // Short circuit for common cases.
655
1268
  if (orders.length === 0) {
@@ -675,15 +1288,20 @@ export class QueryExecutor extends Resource {
675
1288
  const comparison = this._compareByProperty(a, b, order.property);
676
1289
  return order.direction === 'desc' ? -comparison : comparison;
677
1290
  }
1291
+ case 'rank': {
1292
+ // Higher rank = better match. By default, descending order (best first).
1293
+ const comparison = a.rank - b.rank;
1294
+ return order.direction === 'desc' ? -comparison : comparison;
1295
+ }
678
1296
  default:
679
- // Should never reach here with proper TypeScript types
1297
+ // Should never reach here with proper TypeScript types.
680
1298
  return 0;
681
1299
  }
682
1300
  }
683
1301
 
684
1302
  private _compareByProperty(a: QueryItem, b: QueryItem, property: string): number {
685
- const aValue = a.doc.data[property];
686
- const bValue = b.doc.data[property];
1303
+ const aValue = QueryItem.getProperty(a, [property]);
1304
+ const bValue = QueryItem.getProperty(b, [property]);
687
1305
 
688
1306
  // Both null or undefined
689
1307
  if (aValue == null && bValue == null) {
@@ -719,103 +1337,377 @@ export class QueryExecutor extends Resource {
719
1337
  return String(aValue).localeCompare(String(bValue));
720
1338
  }
721
1339
 
722
- private async _loadDocumentsAfterIndexQuery(indexHits: FindResult[]): Promise<(QueryItem | null)[]> {
723
- return Promise.all(
724
- indexHits.map(async (hit): Promise<QueryItem | null> => {
725
- return this._loadFromIndexHit(hit);
726
- }),
1340
+ private async _runInRuntime<T>(effect: Effect.Effect<T, unknown, SqlClient.SqlClient>): Promise<T> {
1341
+ const runtimeProvider = this._runtime;
1342
+ invariant(runtimeProvider, 'SQL runtime is required.');
1343
+ const runtime = await runAndForwardErrors(runtimeProvider);
1344
+ return await unwrapExit(await effect.pipe(Runtime.runPromiseExit(runtime)));
1345
+ }
1346
+
1347
+ private async _queryAllFromSqlIndex(
1348
+ spaceIds: readonly SpaceId[],
1349
+ includeAllQueues: boolean,
1350
+ queueIds: readonly ObjectId[] | null,
1351
+ ): Promise<readonly ObjectMeta[]> {
1352
+ return await this._runInRuntime(this._indexEngine.queryAll({ spaceIds, includeAllQueues, queueIds }));
1353
+ }
1354
+
1355
+ private async _queryTypesFromSqlIndex(
1356
+ spaceIds: readonly SpaceId[],
1357
+ typeDxns: readonly string[],
1358
+ inverted: boolean,
1359
+ includeAllQueues: boolean,
1360
+ queueIds: readonly ObjectId[] | null,
1361
+ ): Promise<readonly ObjectMeta[]> {
1362
+ return await this._runInRuntime(
1363
+ this._indexEngine.queryTypes({ spaceIds, typeDxns, inverted, includeAllQueues, queueIds }),
727
1364
  );
728
1365
  }
729
1366
 
1367
+ private async _queryIncomingReferencesFromSqlIndex(
1368
+ workingSet: QueryItem[],
1369
+ property: EscapedPropPath | null,
1370
+ ): Promise<readonly ObjectMeta[]> {
1371
+ const anchorDxns = workingSet.map((item) => DXN.fromLocalObjectId(item.objectId).toString());
1372
+ const rows: readonly ReverseRef[] = (
1373
+ await Promise.all(
1374
+ anchorDxns.map((targetDXN) => this._runInRuntime(this._indexEngine.queryReverseRef({ targetDXN }))),
1375
+ )
1376
+ ).flat();
1377
+
1378
+ const recordIds = rows
1379
+ .filter((row) => {
1380
+ if (property === null) {
1381
+ return true;
1382
+ }
1383
+
1384
+ const queryPath = EscapedPropPath.unescape(property);
1385
+ const rowPath = EscapedPropPath.unescape(row.propPath);
1386
+ return QueryExecutor._matchesReferencePropertyPath(rowPath, queryPath);
1387
+ })
1388
+ .map((row) => row.recordId);
1389
+
1390
+ const uniqueRecordIds = Array.from(new Set<number>(recordIds));
1391
+ return await this._runInRuntime(this._indexEngine.lookupByRecordIds(uniqueRecordIds));
1392
+ }
1393
+
730
1394
  /**
731
- * Space key hex -> SpaceId.
1395
+ * Matches a reverse-reference row path against a query property path.
1396
+ * Allows numeric segments in the row path (array indices) that are not present in the query.
1397
+ *
1398
+ * Examples:
1399
+ * - query: ['assignee'] matches row: ['assignee'] and ['assignee', '0'].
1400
+ * - query: ['items', 'assignee'] matches row: ['items', '0', 'assignee'].
1401
+ * - query: ['a', 'b'] does NOT match row: ['a'].
1402
+ * - query: ['a'] does NOT match row: ['a', 'b'].
732
1403
  */
733
- private readonly _spaceIdCache = new Map<string, SpaceId>();
1404
+ private static _matchesReferencePropertyPath(rowPath: readonly string[], queryPath: readonly string[]): boolean {
1405
+ const isNumericSegment = (segment: string) => /^[0-9]+$/.test(segment);
734
1406
 
735
- private async _loadFromIndexHit(hit: FindResult): Promise<QueryItem | null> {
736
- const { objectId, documentId, spaceKey: spaceKeyInIndex } = objectPointerCodec.decode(hit.id);
1407
+ let i = 0; // queryPath index.
1408
+ let j = 0; // rowPath index.
737
1409
 
738
- const handle = await this._automergeHost.loadDoc<DatabaseDirectory>(Context.default(), documentId as DocumentId);
739
- const doc = handle.doc();
740
- if (!doc) {
741
- return null;
1410
+ while (i < queryPath.length && j < rowPath.length) {
1411
+ if (rowPath[j] === queryPath[i]) {
1412
+ i++;
1413
+ j++;
1414
+ continue;
1415
+ }
1416
+
1417
+ // Row may contain array indices that aren't present in the query path.
1418
+ if (isNumericSegment(rowPath[j])) {
1419
+ j++;
1420
+ continue;
1421
+ }
1422
+
1423
+ return false;
742
1424
  }
743
1425
 
744
- const spaceKey = spaceKeyInIndex ?? DatabaseDirectory.getSpaceKey(doc);
745
- if (!spaceKey) {
746
- return null;
1426
+ // Must consume full query path.
1427
+ if (i !== queryPath.length) {
1428
+ return false;
747
1429
  }
748
1430
 
749
- let spaceId = this._spaceIdCache.get(spaceKey);
750
- if (!spaceId) {
751
- spaceId = await createIdFromSpaceKey(PublicKey.from(spaceKey));
752
- this._spaceIdCache.set(spaceKey, spaceId);
1431
+ // Any remaining row segments must be numeric (array indices).
1432
+ for (; j < rowPath.length; j++) {
1433
+ if (!isNumericSegment(rowPath[j])) {
1434
+ return false;
1435
+ }
753
1436
  }
754
1437
 
755
- const object = DatabaseDirectory.getInlineObject(doc, objectId);
756
- if (!object) {
1438
+ return true;
1439
+ }
1440
+
1441
+ private async _queryRelationsFromSqlIndex(
1442
+ workingSet: QueryItem[],
1443
+ endpoint: 'source' | 'target',
1444
+ ): Promise<readonly ObjectMeta[]> {
1445
+ const anchorDxns = workingSet.map((item) => DXN.fromLocalObjectId(item.objectId).toString());
1446
+ return await this._runInRuntime(this._indexEngine.queryRelations({ endpoint, anchorDxns }));
1447
+ }
1448
+
1449
+ private async _loadDocumentsAfterSqlQuery(metas: readonly ObjectMeta[]): Promise<(QueryItem | null)[]> {
1450
+ const snapshotMap = await this._loadQueueSnapshotMap(metas);
1451
+ return await Promise.all(
1452
+ metas.map(async (meta) => {
1453
+ // Branch 1: Document-backed object.
1454
+ if (meta.documentId) {
1455
+ return this._loadFromAutomerge(meta);
1456
+ }
1457
+
1458
+ // Branch 2: Queue-backed object.
1459
+ if (meta.queueId) {
1460
+ return this._loadFromQueue(meta, snapshotMap);
1461
+ }
1462
+
1463
+ return null;
1464
+ }),
1465
+ );
1466
+ }
1467
+
1468
+ private async _loadQueueSnapshotMap(metas: readonly ObjectMeta[]): Promise<Map<number, unknown>> {
1469
+ const queueMetas = metas.filter((meta) => !meta.documentId && !!meta.queueId);
1470
+ if (queueMetas.length === 0) {
1471
+ return new Map();
1472
+ }
1473
+
1474
+ const snapshots = await this._runInRuntime(
1475
+ this._indexEngine.querySnapshotsJSON(queueMetas.map((meta) => meta.recordId)),
1476
+ );
1477
+ return new Map(snapshots.map((s) => [s.recordId, s.snapshot]));
1478
+ }
1479
+
1480
+ /**
1481
+ * Loads a queue-backed object from an indexed snapshot (by `recordId`).
1482
+ * Returns `null` when the snapshot is missing or is not a JSON object.
1483
+ */
1484
+ private _loadFromQueue(meta: ObjectMeta, snapshotMap: Map<number, unknown>): QueryItem | null {
1485
+ const snapshot = snapshotMap.get(meta.recordId);
1486
+ if (!snapshot || typeof snapshot !== 'object') {
757
1487
  return null;
758
1488
  }
759
1489
 
760
1490
  return {
761
- objectId,
762
- documentId: documentId as DocumentId,
763
- spaceId,
764
- doc: object,
1491
+ objectId: meta.objectId as ObjectId,
1492
+ spaceId: meta.spaceId as SpaceId,
1493
+ queueId: meta.queueId as ObjectId,
1494
+ queueNamespace: meta.queueNamespace || null,
1495
+ documentId: null,
1496
+ doc: null,
1497
+ data: snapshot as Obj.JSON,
1498
+ rank: 1,
765
1499
  };
766
1500
  }
767
1501
 
768
- private async _loadFromDXN(dxn: DXN, { sourceSpaceId }: { sourceSpaceId: SpaceId }): Promise<QueryItem | null> {
769
- const echoDxn = dxn.asEchoDXN();
770
- if (!echoDxn) {
771
- log.warn('unable to resolve DXN', { dxn });
1502
+ /**
1503
+ * Loads a document-backed object from an Automerge `DatabaseDirectory`.
1504
+ * Returns `null` if the document can't be loaded, the inline object isn't present, or if the meta does not have a
1505
+ * document id (e.g. queue-backed objects).
1506
+ */
1507
+ private async _loadFromAutomerge(meta: ObjectMeta): Promise<QueryItem | null> {
1508
+ if (!meta.documentId) {
772
1509
  return null;
773
1510
  }
774
-
775
- const spaceId = echoDxn.spaceId ?? sourceSpaceId;
776
-
777
- const spaceRoot = this._spaceStateManager.getRootBySpaceId(spaceId);
778
- if (!spaceRoot) {
779
- log.warn('no space state found for', { spaceId });
1511
+ const handle = await this._automergeHost.loadDoc<DatabaseDirectory>(this._ctx, meta.documentId as DocumentId, {
1512
+ fetchFromNetwork: false,
1513
+ });
1514
+ if (!handle) {
780
1515
  return null;
781
1516
  }
782
- const dbDirectory = spaceRoot.doc();
783
- if (!dbDirectory) {
784
- log.warn('no space state found for', { spaceId });
1517
+ const object = DatabaseDirectory.getInlineObject(handle.doc(), meta.objectId);
1518
+ if (!object) {
785
1519
  return null;
786
1520
  }
1521
+ return {
1522
+ objectId: meta.objectId,
1523
+ documentId: meta.documentId as DocumentId,
1524
+ spaceId: meta.spaceId as SpaceId,
1525
+ queueId: null,
1526
+ queueNamespace: null,
1527
+ doc: object,
1528
+ data: null,
1529
+ rank: 1,
1530
+ };
1531
+ }
1532
+
1533
+ private async _loadFromDXN(dxn: DXN, { sourceSpaceId }: { sourceSpaceId: SpaceId }): Promise<QueryItem | null> {
1534
+ switch (dxn.kind) {
1535
+ case DXN.kind.ECHO: {
1536
+ const echoDXN = dxn.asEchoDXN();
1537
+ if (!echoDXN) {
1538
+ log.warn('unable to resolve DXN', { dxn });
1539
+ return null;
1540
+ }
1541
+
1542
+ const spaceId = echoDXN.spaceId ?? sourceSpaceId;
787
1543
 
788
- const inlineObject = DatabaseDirectory.getInlineObject(dbDirectory, echoDxn.echoId);
789
- if (inlineObject) {
790
- return {
791
- objectId: echoDxn.echoId,
792
- documentId: spaceRoot.documentId,
793
- spaceId,
794
- doc: inlineObject,
795
- };
1544
+ const spaceRoot = this._spaceStateManager.getRootBySpaceId(spaceId);
1545
+ if (!spaceRoot) {
1546
+ log.warn('no space state found for', { spaceId });
1547
+ return null;
1548
+ }
1549
+ const dbDirectory = spaceRoot.doc();
1550
+ if (!dbDirectory) {
1551
+ log.warn('no space state found for', { spaceId });
1552
+ return null;
1553
+ }
1554
+
1555
+ const inlineObject = DatabaseDirectory.getInlineObject(dbDirectory, echoDXN.echoId);
1556
+ if (inlineObject) {
1557
+ return {
1558
+ objectId: echoDXN.echoId,
1559
+ documentId: spaceRoot.documentId,
1560
+ spaceId,
1561
+ queueId: null,
1562
+ queueNamespace: null,
1563
+ data: null,
1564
+ doc: inlineObject,
1565
+ rank: 1,
1566
+ };
1567
+ }
1568
+
1569
+ const link = DatabaseDirectory.getLink(dbDirectory, echoDXN.echoId);
1570
+ if (!link) {
1571
+ return null;
1572
+ }
1573
+
1574
+ const handle = await this._automergeHost.loadDoc<DatabaseDirectory>(this._ctx, link as AutomergeUrl, {
1575
+ fetchFromNetwork: false,
1576
+ });
1577
+ if (!handle) {
1578
+ return null;
1579
+ }
1580
+ const object = DatabaseDirectory.getInlineObject(handle.doc(), echoDXN.echoId);
1581
+ if (!object) {
1582
+ return null;
1583
+ }
1584
+
1585
+ return {
1586
+ objectId: echoDXN.echoId,
1587
+ documentId: handle.documentId,
1588
+ spaceId,
1589
+ queueId: null,
1590
+ queueNamespace: null,
1591
+ data: null,
1592
+ doc: object,
1593
+ rank: 1,
1594
+ };
1595
+ break;
1596
+ }
1597
+ case DXN.kind.QUEUE: {
1598
+ const queueDXN = dxn.asQueueDXN();
1599
+ if (!queueDXN || !queueDXN.objectId) {
1600
+ log.warn('unable to resolve queue DXN', { dxn });
1601
+ return null;
1602
+ }
1603
+
1604
+ const { spaceId, queueId, objectId } = queueDXN;
1605
+ const meta = await this._runInRuntime(
1606
+ this._indexEngine.lookupByObjectId({
1607
+ objectId,
1608
+ spaceId,
1609
+ queueId,
1610
+ }),
1611
+ );
1612
+ if (!meta) {
1613
+ return null;
1614
+ }
1615
+
1616
+ const snapshotMap = await this._loadQueueSnapshotMap([meta]);
1617
+ return this._loadFromQueue(meta, snapshotMap);
1618
+ }
1619
+ default: {
1620
+ log.warn('unable to resolve DXN', { dxn });
1621
+ return null;
1622
+ }
796
1623
  }
1624
+ }
797
1625
 
798
- const link = DatabaseDirectory.getLink(dbDirectory, echoDxn.echoId);
799
- if (!link) {
800
- return null;
1626
+ private async _getTransitiveDeletionState(item: QueryItem, remainingDepth: number): Promise<boolean> {
1627
+ const strongDeps = [
1628
+ QueryItem.getParent(item),
1629
+ QueryItem.getRelationSource(item),
1630
+ QueryItem.getRelationTarget(item),
1631
+ ].filter((x) => x !== undefined);
1632
+
1633
+ if (strongDeps.length === 0) {
1634
+ return false;
801
1635
  }
802
1636
 
803
- const handle = await this._automergeHost.loadDoc<DatabaseDirectory>(Context.default(), link as AutomergeUrl);
804
- const doc = handle.doc();
805
- if (!doc) {
806
- return null;
1637
+ // TODO(dmaretskyi): This could be optimized to bail early if any of the dependencies are deleted.
1638
+ const strongDepStates = await Promise.all(
1639
+ strongDeps.map(async (dxn) => {
1640
+ const dep = await this._loadFromDXN(DXN.parse(dxn), { sourceSpaceId: item.spaceId });
1641
+ if (!dep) {
1642
+ return false;
1643
+ }
1644
+ if (QueryItem.isDeleted(dep)) {
1645
+ return true;
1646
+ }
1647
+ if (remainingDepth > 0) {
1648
+ return this._getTransitiveDeletionState(dep, remainingDepth - 1);
1649
+ }
1650
+ return false;
1651
+ }),
1652
+ );
1653
+
1654
+ return strongDepStates.some((x) => x);
1655
+ }
1656
+ }
1657
+
1658
+ const extractQueueIds = (queues: readonly DXN.String[]): ObjectId[] | null => {
1659
+ if (queues.length === 0) {
1660
+ return null;
1661
+ }
1662
+ return queues.map((dxnStr) => DXN.parse(dxnStr).asQueueDXN()?.queueId).filter(Boolean) as ObjectId[];
1663
+ };
1664
+
1665
+ /**
1666
+ * Extract timestamp parameters from a filter AST node.
1667
+ * Returns null if the filter doesn't contain timestamp nodes.
1668
+ */
1669
+ const extractTimestampParams = (
1670
+ filter: QueryAST.Filter,
1671
+ ): { updatedAfter?: number; updatedBefore?: number; createdAfter?: number; createdBefore?: number } | null => {
1672
+ const collect = (f: QueryAST.Filter): QueryAST.FilterTimestamp[] => {
1673
+ if (f.type === 'timestamp') {
1674
+ return [f];
1675
+ }
1676
+ if (f.type === 'and') {
1677
+ return f.filters.flatMap(collect);
807
1678
  }
1679
+ return [];
1680
+ };
808
1681
 
809
- const object = DatabaseDirectory.getInlineObject(doc, echoDxn.echoId);
810
- if (!object) {
811
- return null;
1682
+ const timestamps = collect(filter);
1683
+ if (timestamps.length === 0) {
1684
+ return null;
1685
+ }
1686
+
1687
+ const params: { updatedAfter?: number; updatedBefore?: number; createdAfter?: number; createdBefore?: number } = {};
1688
+ for (const ts of timestamps) {
1689
+ if (ts.field === 'updatedAt' && (ts.operator === 'gt' || ts.operator === 'gte')) {
1690
+ params.updatedAfter = ts.value;
1691
+ } else if (ts.field === 'updatedAt' && (ts.operator === 'lt' || ts.operator === 'lte')) {
1692
+ params.updatedBefore = ts.value;
1693
+ } else if (ts.field === 'createdAt' && (ts.operator === 'gt' || ts.operator === 'gte')) {
1694
+ params.createdAfter = ts.value;
1695
+ } else if (ts.field === 'createdAt' && (ts.operator === 'lt' || ts.operator === 'lte')) {
1696
+ params.createdBefore = ts.value;
812
1697
  }
1698
+ }
1699
+ return params;
1700
+ };
813
1701
 
814
- return {
815
- objectId: echoDxn.echoId,
816
- documentId: handle.documentId,
817
- spaceId,
818
- doc: object,
819
- };
1702
+ function filterContainsTimestamp(filter: QueryAST.Filter): boolean {
1703
+ if (filter.type === 'timestamp') {
1704
+ return true;
1705
+ }
1706
+ if (filter.type === 'and' || filter.type === 'or') {
1707
+ return filter.filters.some(filterContainsTimestamp);
1708
+ }
1709
+ if (filter.type === 'not') {
1710
+ return filterContainsTimestamp(filter.filter);
820
1711
  }
1712
+ return false;
821
1713
  }