@dxos/echo-pipeline 0.8.3 → 0.8.4-main.05e74ebcff

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 (244) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/neutral/chunk-46DCRDA7.mjs +271 -0
  4. package/dist/lib/neutral/chunk-46DCRDA7.mjs.map +7 -0
  5. package/dist/lib/{browser/chunk-35I6ERLG.mjs → neutral/chunk-LR7UFVQX.mjs} +313 -709
  6. package/dist/lib/neutral/chunk-LR7UFVQX.mjs.map +7 -0
  7. package/dist/lib/{browser → neutral}/filter/index.mjs +3 -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 +90 -311
  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 +119 -54
  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 +25 -4
  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 +23 -10
  27. package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
  28. package/dist/types/src/automerge/echo-replicator.d.ts +46 -15
  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 +3 -1
  37. package/dist/types/src/automerge/index.d.ts.map +1 -1
  38. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +11 -5
  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 +9 -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/codec.d.ts +1 -1
  47. package/dist/types/src/common/codec.d.ts.map +1 -1
  48. package/dist/types/src/common/feeds.d.ts.map +1 -1
  49. package/dist/types/src/common/space-id.d.ts.map +1 -1
  50. package/dist/types/src/db-host/automerge-data-source.d.ts +35 -0
  51. package/dist/types/src/db-host/automerge-data-source.d.ts.map +1 -0
  52. package/dist/types/src/db-host/automerge-data-source.test.d.ts +2 -0
  53. package/dist/types/src/db-host/automerge-data-source.test.d.ts.map +1 -0
  54. package/dist/types/src/db-host/automerge-metrics.d.ts.map +1 -1
  55. package/dist/types/src/db-host/data-service.d.ts +13 -7
  56. package/dist/types/src/db-host/data-service.d.ts.map +1 -1
  57. package/dist/types/src/db-host/database-root.d.ts +9 -3
  58. package/dist/types/src/db-host/database-root.d.ts.map +1 -1
  59. package/dist/types/src/db-host/documents-synchronizer.d.ts +30 -8
  60. package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
  61. package/dist/types/src/db-host/echo-host.d.ts +74 -28
  62. package/dist/types/src/db-host/echo-host.d.ts.map +1 -1
  63. package/dist/types/src/db-host/index.d.ts +2 -0
  64. package/dist/types/src/db-host/index.d.ts.map +1 -1
  65. package/dist/types/src/db-host/invalidation-hint.d.ts +19 -0
  66. package/dist/types/src/db-host/invalidation-hint.d.ts.map +1 -0
  67. package/dist/types/src/db-host/local-queue-service.d.ts +19 -0
  68. package/dist/types/src/db-host/local-queue-service.d.ts.map +1 -0
  69. package/dist/types/src/db-host/query-invalidation.test.d.ts +2 -0
  70. package/dist/types/src/db-host/query-invalidation.test.d.ts.map +1 -0
  71. package/dist/types/src/db-host/query-service.d.ts +19 -11
  72. package/dist/types/src/db-host/query-service.d.ts.map +1 -1
  73. package/dist/types/src/db-host/queue-data-source.d.ts +38 -0
  74. package/dist/types/src/db-host/queue-data-source.d.ts.map +1 -0
  75. package/dist/types/src/db-host/queue-service.test.d.ts +2 -0
  76. package/dist/types/src/db-host/queue-service.test.d.ts.map +1 -0
  77. package/dist/types/src/db-host/space-state-manager.d.ts +4 -3
  78. package/dist/types/src/db-host/space-state-manager.d.ts.map +1 -1
  79. package/dist/types/src/db-host/stub.d.ts +11 -0
  80. package/dist/types/src/db-host/stub.d.ts.map +1 -0
  81. package/dist/types/src/edge/echo-edge-replicator.d.ts +10 -7
  82. package/dist/types/src/edge/echo-edge-replicator.d.ts.map +1 -1
  83. package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts +39 -0
  84. package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts.map +1 -0
  85. package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts +2 -0
  86. package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts.map +1 -0
  87. package/dist/types/src/edge/index.d.ts +1 -0
  88. package/dist/types/src/edge/index.d.ts.map +1 -1
  89. package/dist/types/src/edge/inflight-request-limiter.d.ts.map +1 -1
  90. package/dist/types/src/filter/filter-match.d.ts +4 -1
  91. package/dist/types/src/filter/filter-match.d.ts.map +1 -1
  92. package/dist/types/src/metadata/metadata-store.d.ts +1 -1
  93. package/dist/types/src/metadata/metadata-store.d.ts.map +1 -1
  94. package/dist/types/src/pipeline/message-selector.d.ts.map +1 -1
  95. package/dist/types/src/pipeline/pipeline.d.ts +3 -3
  96. package/dist/types/src/pipeline/pipeline.d.ts.map +1 -1
  97. package/dist/types/src/pipeline/timeframe-clock.d.ts.map +1 -1
  98. package/dist/types/src/query/errors.d.ts +22 -10
  99. package/dist/types/src/query/errors.d.ts.map +1 -1
  100. package/dist/types/src/query/plan.d.ts +66 -8
  101. package/dist/types/src/query/plan.d.ts.map +1 -1
  102. package/dist/types/src/query/query-executor.d.ts +66 -11
  103. package/dist/types/src/query/query-executor.d.ts.map +1 -1
  104. package/dist/types/src/query/query-planner.d.ts +12 -1
  105. package/dist/types/src/query/query-planner.d.ts.map +1 -1
  106. package/dist/types/src/space/admission-discovery-extension.d.ts.map +1 -1
  107. package/dist/types/src/space/auth.d.ts +3 -3
  108. package/dist/types/src/space/auth.d.ts.map +1 -1
  109. package/dist/types/src/space/control-pipeline.d.ts +5 -4
  110. package/dist/types/src/space/control-pipeline.d.ts.map +1 -1
  111. package/dist/types/src/space/space-manager.d.ts +8 -8
  112. package/dist/types/src/space/space-manager.d.ts.map +1 -1
  113. package/dist/types/src/space/space-protocol.d.ts +10 -9
  114. package/dist/types/src/space/space-protocol.d.ts.map +1 -1
  115. package/dist/types/src/space/space.d.ts +6 -6
  116. package/dist/types/src/space/space.d.ts.map +1 -1
  117. package/dist/types/src/testing/change-metadata.d.ts.map +1 -1
  118. package/dist/types/src/testing/index.d.ts +0 -2
  119. package/dist/types/src/testing/index.d.ts.map +1 -1
  120. package/dist/types/src/testing/test-agent-builder.d.ts +2 -2
  121. package/dist/types/src/testing/test-agent-builder.d.ts.map +1 -1
  122. package/dist/types/src/testing/test-feed-builder.d.ts.map +1 -1
  123. package/dist/types/src/testing/test-network-adapter.d.ts +1 -1
  124. package/dist/types/src/testing/test-network-adapter.d.ts.map +1 -1
  125. package/dist/types/src/testing/test-replicator.d.ts +14 -13
  126. package/dist/types/src/testing/test-replicator.d.ts.map +1 -1
  127. package/dist/types/src/util.d.ts +1 -1
  128. package/dist/types/src/util.d.ts.map +1 -1
  129. package/dist/types/tsconfig.tsbuildinfo +1 -1
  130. package/package.json +59 -55
  131. package/src/automerge/automerge-host-subduction.test.ts +263 -0
  132. package/src/automerge/automerge-host.test.ts +162 -39
  133. package/src/automerge/automerge-host.ts +679 -201
  134. package/src/automerge/automerge-repo-subduction.test.ts +1054 -0
  135. package/src/automerge/automerge-repo.test.ts +349 -91
  136. package/src/automerge/automerge-subduction.test.ts +330 -0
  137. package/src/automerge/collection-synchronizer.test.ts +98 -17
  138. package/src/automerge/collection-synchronizer.ts +141 -37
  139. package/src/automerge/echo-data-monitor.ts +1 -1
  140. package/src/automerge/echo-network-adapter.test.ts +5 -4
  141. package/src/automerge/echo-network-adapter.ts +76 -31
  142. package/src/automerge/echo-replicator.ts +50 -15
  143. package/src/automerge/errors.ts +10 -0
  144. package/src/automerge/handle-state.ts +62 -0
  145. package/src/automerge/heads-store.ts +42 -3
  146. package/src/automerge/index.ts +3 -1
  147. package/src/automerge/leveldb-storage-adapter.ts +77 -11
  148. package/src/automerge/mesh-echo-replicator-connection.ts +12 -8
  149. package/src/automerge/mesh-echo-replicator.ts +15 -11
  150. package/src/automerge/storage-adapter.test.ts +1 -1
  151. package/src/common/space-id.ts +1 -1
  152. package/src/db-host/automerge-data-source.test.ts +299 -0
  153. package/src/db-host/automerge-data-source.ts +187 -0
  154. package/src/db-host/data-service.ts +35 -26
  155. package/src/db-host/database-root.ts +20 -8
  156. package/src/db-host/documents-synchronizer.test.ts +145 -14
  157. package/src/db-host/documents-synchronizer.ts +163 -51
  158. package/src/db-host/echo-host.ts +384 -99
  159. package/src/db-host/index.ts +2 -0
  160. package/src/db-host/invalidation-hint.ts +50 -0
  161. package/src/db-host/local-queue-service.ts +127 -0
  162. package/src/db-host/query-invalidation.test.ts +270 -0
  163. package/src/db-host/query-service.ts +111 -106
  164. package/src/db-host/queue-data-source.ts +160 -0
  165. package/src/db-host/queue-service.test.ts +145 -0
  166. package/src/db-host/space-state-manager.ts +12 -10
  167. package/src/db-host/stub.ts +33 -0
  168. package/src/edge/echo-edge-replicator.test.ts +12 -10
  169. package/src/edge/echo-edge-replicator.ts +144 -58
  170. package/src/edge/echo-edge-subduction-replicator.test.ts +158 -0
  171. package/src/edge/echo-edge-subduction-replicator.ts +377 -0
  172. package/src/edge/index.ts +1 -0
  173. package/src/filter/filter-match.test.ts +68 -32
  174. package/src/filter/filter-match.ts +230 -21
  175. package/src/metadata/metadata-store.ts +11 -8
  176. package/src/pipeline/pipeline-stress.test.ts +1 -1
  177. package/src/pipeline/pipeline.test.ts +2 -2
  178. package/src/pipeline/pipeline.ts +9 -7
  179. package/src/query/errors.ts +3 -1
  180. package/src/query/plan.ts +84 -9
  181. package/src/query/query-executor.ts +1197 -165
  182. package/src/query/query-planner.test.ts +993 -81
  183. package/src/query/query-planner.ts +503 -27
  184. package/src/space/admission-discovery-extension.ts +3 -3
  185. package/src/space/auth.ts +6 -6
  186. package/src/space/control-pipeline.test.ts +5 -4
  187. package/src/space/control-pipeline.ts +16 -13
  188. package/src/space/space-manager.browser.test.ts +1 -1
  189. package/src/space/space-manager.ts +16 -20
  190. package/src/space/space-protocol.browser.test.ts +15 -14
  191. package/src/space/space-protocol.test.ts +15 -14
  192. package/src/space/space-protocol.ts +22 -18
  193. package/src/space/space.test.ts +3 -3
  194. package/src/space/space.ts +11 -11
  195. package/src/testing/index.ts +0 -2
  196. package/src/testing/test-agent-builder.ts +3 -3
  197. package/src/testing/test-network-adapter.ts +24 -3
  198. package/src/testing/test-replicator.ts +19 -20
  199. package/src/util.ts +7 -3
  200. package/dist/lib/browser/chunk-35I6ERLG.mjs.map +0 -7
  201. package/dist/lib/browser/chunk-CGS2ULMK.mjs +0 -11
  202. package/dist/lib/browser/chunk-CGS2ULMK.mjs.map +0 -7
  203. package/dist/lib/browser/chunk-TQJTKNMS.mjs +0 -126
  204. package/dist/lib/browser/chunk-TQJTKNMS.mjs.map +0 -7
  205. package/dist/lib/browser/index.mjs +0 -4743
  206. package/dist/lib/browser/index.mjs.map +0 -7
  207. package/dist/lib/browser/meta.json +0 -1
  208. package/dist/lib/browser/testing/index.mjs.map +0 -7
  209. package/dist/lib/node/chunk-HOPOFWAL.cjs +0 -147
  210. package/dist/lib/node/chunk-HOPOFWAL.cjs.map +0 -7
  211. package/dist/lib/node/chunk-JXX6LF5U.cjs +0 -2084
  212. package/dist/lib/node/chunk-JXX6LF5U.cjs.map +0 -7
  213. package/dist/lib/node/chunk-Q7SFCCGT.cjs +0 -33
  214. package/dist/lib/node/chunk-Q7SFCCGT.cjs.map +0 -7
  215. package/dist/lib/node/filter/index.cjs +0 -32
  216. package/dist/lib/node/filter/index.cjs.map +0 -7
  217. package/dist/lib/node/index.cjs +0 -4699
  218. package/dist/lib/node/index.cjs.map +0 -7
  219. package/dist/lib/node/meta.json +0 -1
  220. package/dist/lib/node/testing/index.cjs +0 -753
  221. package/dist/lib/node/testing/index.cjs.map +0 -7
  222. package/dist/lib/node-esm/chunk-5BHLPT24.mjs +0 -2067
  223. package/dist/lib/node-esm/chunk-5BHLPT24.mjs.map +0 -7
  224. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
  225. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
  226. package/dist/lib/node-esm/chunk-RVK35BS7.mjs +0 -126
  227. package/dist/lib/node-esm/chunk-RVK35BS7.mjs.map +0 -7
  228. package/dist/lib/node-esm/filter/index.mjs +0 -11
  229. package/dist/lib/node-esm/filter/index.mjs.map +0 -7
  230. package/dist/lib/node-esm/index.mjs +0 -4743
  231. package/dist/lib/node-esm/index.mjs.map +0 -7
  232. package/dist/lib/node-esm/meta.json +0 -1
  233. package/dist/lib/node-esm/testing/index.mjs +0 -742
  234. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  235. package/dist/types/src/db-host/documents-iterator.d.ts +0 -7
  236. package/dist/types/src/db-host/documents-iterator.d.ts.map +0 -1
  237. package/dist/types/src/testing/test-data.d.ts +0 -18
  238. package/dist/types/src/testing/test-data.d.ts.map +0 -1
  239. package/dist/types/src/testing/test-schema.d.ts +0 -37
  240. package/dist/types/src/testing/test-schema.d.ts.map +0 -1
  241. package/src/db-host/documents-iterator.ts +0 -74
  242. package/src/testing/test-data.ts +0 -127
  243. package/src/testing/test-schema.ts +0 -53
  244. /package/dist/lib/{browser → neutral}/filter/index.mjs.map +0 -0
@@ -3,27 +3,39 @@
3
3
  //
4
4
 
5
5
  import type { AutomergeUrl, DocumentId } from '@automerge/automerge-repo';
6
- import { Match } from 'effect';
7
-
8
- import { Context, ContextDisposedError, LifecycleState, Resource } from '@dxos/context';
9
- import { DatabaseDirectory, isEncodedReference, ObjectStructure, type QueryAST } from '@dxos/echo-protocol';
10
- 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';
11
23
  import { invariant } from '@dxos/invariant';
12
- import { DXN, type ObjectId, PublicKey, type SpaceId } from '@dxos/keys';
24
+ import { DXN, type ObjectId, type SpaceId } from '@dxos/keys';
13
25
  import { log } from '@dxos/log';
14
- import { objectPointerCodec } from '@dxos/protocols';
15
26
  import { type QueryReactivity, type QueryResult } from '@dxos/protocols/proto/dxos/echo/query';
16
- import { getDeep, isNonNullable } from '@dxos/util';
27
+ import { compositeKey, getDeep, isNonNullable } from '@dxos/util';
17
28
 
29
+ import type { AutomergeHost } from '../automerge';
30
+ import type { InvalidationHint, SpaceStateManager } from '../db-host';
31
+ import { filterMatchObject, filterMatchObjectJSON } from '../filter';
32
+ import { QueryError } from './errors';
18
33
  import type { QueryPlan } from './plan';
19
34
  import { QueryPlanner } from './query-planner';
20
- import type { AutomergeHost } from '../automerge';
21
- import { createIdFromSpaceKey } from '../common';
22
- import type { SpaceStateManager } from '../db-host';
23
- import { filterMatchObject } from '../filter';
24
35
 
25
36
  type QueryExecutorOptions = {
26
- indexer: Indexer;
37
+ indexEngine: IndexEngine;
38
+ runtime: RuntimeProvider.RuntimeProvider<SqlClient.SqlClient>;
27
39
  automergeHost: AutomergeHost;
28
40
  spaceStateManager: SpaceStateManager;
29
41
 
@@ -44,11 +56,84 @@ type QueryExecutionResult = {
44
56
  */
45
57
  type QueryItem = {
46
58
  objectId: ObjectId;
47
- documentId: DocumentId;
59
+
48
60
  spaceId: SpaceId;
49
- 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;
50
79
  };
51
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
+
52
137
  /**
53
138
  * Recursive data structure that represents the execution trace of a query.
54
139
  */
@@ -60,6 +145,9 @@ export type ExecutionTrace = {
60
145
  documentsLoaded: number;
61
146
  indexHits: number;
62
147
 
148
+ beginTs: number;
149
+ endTs: number;
150
+
63
151
  executionTime: number;
64
152
  indexQueryTime: number;
65
153
  documentLoadTime: number;
@@ -74,11 +162,42 @@ export const ExecutionTrace = Object.freeze({
74
162
  objectCount: 0,
75
163
  documentsLoaded: 0,
76
164
  indexHits: 0,
165
+ beginTs: 0,
166
+ endTs: 0,
77
167
  indexQueryTime: 0,
78
168
  documentLoadTime: 0,
79
169
  executionTime: 0,
80
170
  children: [],
81
171
  }),
172
+ markEnd: (trace: ExecutionTrace) => {
173
+ trace.endTs = performance.now();
174
+ trace.executionTime = trace.endTs - trace.beginTs;
175
+ },
176
+ putOnPerformanceTimeline: (trace: ExecutionTrace) => {
177
+ performance.measure(trace.name, {
178
+ start: trace.beginTs,
179
+ end: trace.endTs,
180
+ detail: {
181
+ devtools: {
182
+ dataType: 'track-entry',
183
+ track: 'Query Execution',
184
+ trackGroup: 'ECHO', // Group related tracks together
185
+ color: 'tertiary-dark',
186
+ properties: [
187
+ ['objectCount', trace.objectCount],
188
+ ['documentsLoaded', trace.documentsLoaded],
189
+ ['index hits', trace.indexHits],
190
+ ['indexQueryTime', trace.indexQueryTime],
191
+ ['documentLoadTime', trace.documentLoadTime],
192
+ ],
193
+ tooltipText: trace.details,
194
+ },
195
+ },
196
+ });
197
+ for (const child of trace.children) {
198
+ ExecutionTrace.putOnPerformanceTimeline(child);
199
+ }
200
+ },
82
201
  format: (trace: ExecutionTrace): string => {
83
202
  const go = (trace: ExecutionTrace, indent: number): string => {
84
203
  return [
@@ -97,10 +216,167 @@ type StepExecutionResult = {
97
216
  trace: ExecutionTrace;
98
217
  };
99
218
 
100
- 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;
233
+
234
+ /**
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);
101
377
 
102
378
  /**
103
- * Executes query plans against the Indexer and AutomergeHost.
379
+ * Executes query plans against the IndexEngine and AutomergeHost.
104
380
  *
105
381
  * The QueryExecutor is responsible for:
106
382
  * - Executing query plans step by step
@@ -110,7 +386,8 @@ const TRACE_QUERY_EXECUTION = false;
110
386
  * - Handling different types of query operations (select, filter, traverse, etc.)
111
387
  */
112
388
  export class QueryExecutor extends Resource {
113
- private readonly _indexer: Indexer;
389
+ private readonly _indexEngine: IndexEngine;
390
+ private readonly _runtime: RuntimeProvider.RuntimeProvider<SqlClient.SqlClient>;
114
391
  private readonly _automergeHost: AutomergeHost;
115
392
  private readonly _spaceStateManager: SpaceStateManager;
116
393
  /**
@@ -122,13 +399,15 @@ export class QueryExecutor extends Resource {
122
399
  private readonly _reactivity: QueryReactivity;
123
400
 
124
401
  private _plan: QueryPlan.Plan;
402
+ #scopes: QueryScopes;
125
403
  private _trace: ExecutionTrace = ExecutionTrace.makeEmpty();
126
404
  private _lastResultSet: QueryItem[] = [];
127
405
 
128
406
  constructor(options: QueryExecutorOptions) {
129
407
  super();
130
408
 
131
- this._indexer = options.indexer;
409
+ this._indexEngine = options.indexEngine;
410
+ this._runtime = options.runtime;
132
411
  this._automergeHost = options.automergeHost;
133
412
  this._spaceStateManager = options.spaceStateManager;
134
413
 
@@ -138,6 +417,11 @@ export class QueryExecutor extends Resource {
138
417
 
139
418
  const queryPlanner = new QueryPlanner();
140
419
  this._plan = queryPlanner.createPlan(this._query);
420
+ this.#scopes = extractScopes(this._plan);
421
+ }
422
+
423
+ get queryId(): string {
424
+ return this._id;
141
425
  }
142
426
 
143
427
  get query(): QueryAST.Query {
@@ -152,31 +436,54 @@ export class QueryExecutor extends Resource {
152
436
  return this._trace;
153
437
  }
154
438
 
155
- protected override async _open(ctx: Context): Promise<void> {}
156
-
157
- protected override async _close(ctx: Context): Promise<void> {}
158
-
159
439
  getResults(): QueryResult[] {
160
440
  return this._lastResultSet.map(
161
441
  (item): QueryResult => ({
162
442
  id: item.objectId,
163
- documentId: item.documentId,
443
+ documentId: item.documentId ?? undefined,
444
+ queueId: item.queueId ?? undefined,
445
+ queueNamespace: item.queueNamespace ?? undefined,
164
446
  spaceId: item.spaceId,
165
447
 
166
- // TODO(dmaretskyi): Plumb through the rank.
167
- rank: 0,
448
+ rank: item.rank,
449
+
450
+ documentJson: item.doc ? JSON.stringify(item.doc) : item.data ? JSON.stringify(item.data) : undefined,
168
451
  }),
169
452
  );
170
453
  }
171
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
+
172
474
  async execQuery(): Promise<QueryExecutionResult> {
173
475
  invariant(this._lifecycleState === LifecycleState.OPEN);
174
476
 
477
+ log('exec query', {
478
+ queryId: this._id,
479
+ query: Query.pretty(Query.fromAst(this._query)),
480
+ });
481
+
175
482
  const prevResultSet = this._lastResultSet;
176
483
  const { workingSet, trace } = await this._execPlan(this._plan, []);
177
484
  this._lastResultSet = workingSet;
178
485
  trace.name = 'Root';
179
- trace.details = JSON.stringify({ id: this._id });
486
+ trace.details = JSON.stringify({ id: this._id, query: Query.pretty(Query.fromAst(this._query)) });
180
487
  this._trace = trace;
181
488
 
182
489
  const changed =
@@ -185,9 +492,14 @@ export class QueryExecutor extends Resource {
185
492
  (item, index) =>
186
493
  workingSet[index].objectId !== item.objectId ||
187
494
  workingSet[index].spaceId !== item.spaceId ||
188
- workingSet[index].documentId !== item.documentId,
495
+ workingSet[index].documentId !== item.documentId ||
496
+ workingSet[index].queueId !== item.queueId ||
497
+ workingSet[index].queueNamespace !== item.queueNamespace,
189
498
  );
190
499
 
500
+ // Disabled because concurrent queries don't print hierarchies correctly.
501
+ // ExecutionTrace.putOnPerformanceTimeline(trace);
502
+
191
503
  if (TRACE_QUERY_EXECUTION) {
192
504
  // eslint-disable-next-line no-console
193
505
  console.log(ExecutionTrace.format(trace));
@@ -200,7 +512,6 @@ export class QueryExecutor extends Resource {
200
512
 
201
513
  private async _execPlan(plan: QueryPlan.Plan, workingSet: QueryItem[]): Promise<StepExecutionResult> {
202
514
  const trace = ExecutionTrace.makeEmpty();
203
- const begin = performance.now();
204
515
  for (const step of plan.steps) {
205
516
  if (this._ctx.disposed) {
206
517
  throw new ContextDisposedError();
@@ -211,7 +522,7 @@ export class QueryExecutor extends Resource {
211
522
  trace.children.push(result.trace);
212
523
  }
213
524
  trace.objectCount = workingSet.length;
214
- trace.executionTime = performance.now() - begin;
525
+ ExecutionTrace.markEnd(trace);
215
526
  return { workingSet, trace };
216
527
  }
217
528
 
@@ -245,10 +556,17 @@ export class QueryExecutor extends Resource {
245
556
  case 'TraverseStep':
246
557
  ({ workingSet: newWorkingSet, trace } = await this._execTraverseStep(step, workingSet));
247
558
  break;
559
+ case 'OrderStep':
560
+ ({ workingSet: newWorkingSet, trace } = await this._execOrderStep(step, workingSet));
561
+ break;
562
+ case 'LimitStep':
563
+ ({ workingSet: newWorkingSet, trace } = await this._execLimitStep(step, workingSet));
564
+ break;
248
565
  default:
249
566
  throw new Error(`Unknown step type: ${(step as any)._tag}`);
250
567
  }
251
- trace.executionTime = performance.now() - begin;
568
+ trace.beginTs = begin;
569
+ ExecutionTrace.markEnd(trace);
252
570
 
253
571
  return { workingSet: newWorkingSet, trace };
254
572
  }
@@ -256,20 +574,23 @@ export class QueryExecutor extends Resource {
256
574
  private async _execSelectStep(step: QueryPlan.SelectStep, workingSet: QueryItem[]): Promise<StepExecutionResult> {
257
575
  workingSet = [...workingSet];
258
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
+
259
581
  const trace: ExecutionTrace = {
260
582
  ...ExecutionTrace.makeEmpty(),
261
583
  name: 'Select',
262
584
  details: JSON.stringify(step.selector),
585
+ beginTs: performance.now(),
263
586
  };
264
587
 
265
588
  switch (step.selector._tag) {
266
589
  case 'WildcardSelector': {
267
590
  const beginIndexQuery = performance.now();
268
- const indexHits = await this._indexer.execQuery({
269
- typenames: [],
270
- inverted: false,
271
- });
272
- trace.indexHits = +indexHits.length;
591
+ const queueIds = extractQueueIds(queues);
592
+ const metas = await this._queryAllFromSqlIndex(spaces, allQueuesFromSpaces, queueIds);
593
+ trace.indexHits = metas.length;
273
594
  trace.indexQueryTime += performance.now() - beginIndexQuery;
274
595
 
275
596
  if (this._ctx.disposed) {
@@ -277,21 +598,30 @@ export class QueryExecutor extends Resource {
277
598
  }
278
599
 
279
600
  const documentLoadStart = performance.now();
280
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
601
+ const results = await this._loadDocumentsAfterSqlQuery(metas);
281
602
  trace.documentsLoaded += results.length;
282
603
  trace.documentLoadTime += performance.now() - documentLoadStart;
283
604
 
284
- workingSet.push(...results.filter(isNonNullable).filter((item) => step.spaces.includes(item.spaceId)));
605
+ workingSet.push(...results.filter(isNonNullable));
285
606
  trace.objectCount = workingSet.length;
286
607
 
287
608
  break;
288
609
  }
610
+
289
611
  case 'IdSelector': {
290
612
  const beginLoad = performance.now();
613
+
291
614
  const items = await Promise.all(
292
- step.selector.objectIds.map((id) =>
293
- this._loadFromDXN(DXN.fromLocalObjectId(id), { sourceSpaceId: step.spaces[0] }),
294
- ),
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
+ }),
295
625
  );
296
626
  trace.documentLoadTime += performance.now() - beginLoad;
297
627
 
@@ -299,13 +629,50 @@ export class QueryExecutor extends Resource {
299
629
  trace.objectCount = workingSet.length;
300
630
  break;
301
631
  }
632
+
302
633
  case 'TypeSelector': {
303
634
  const beginIndexQuery = performance.now();
304
- const indexHits = await this._indexer.execQuery({
305
- typenames: step.selector.typename,
306
- inverted: step.selector.inverted,
307
- });
308
- 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;
644
+ trace.indexQueryTime += performance.now() - beginIndexQuery;
645
+
646
+ if (this._ctx.disposed) {
647
+ return { workingSet, trace };
648
+ }
649
+
650
+ const documentLoadStart = performance.now();
651
+ const results = await this._loadDocumentsAfterSqlQuery(metas);
652
+ trace.documentsLoaded += results.length;
653
+ trace.documentLoadTime += performance.now() - documentLoadStart;
654
+
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;
309
676
  trace.indexQueryTime += performance.now() - beginIndexQuery;
310
677
 
311
678
  if (this._ctx.disposed) {
@@ -313,60 +680,161 @@ export class QueryExecutor extends Resource {
313
680
  }
314
681
 
315
682
  const documentLoadStart = performance.now();
316
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
683
+ const results = await this._loadDocumentsAfterSqlQuery(metas);
317
684
  trace.documentsLoaded += results.length;
318
685
  trace.documentLoadTime += performance.now() - documentLoadStart;
319
686
 
320
- workingSet.push(...results.filter(isNonNullable).filter((item) => step.spaces.includes(item.spaceId)));
687
+ workingSet.push(...results.filter(isNonNullable));
321
688
  trace.objectCount = workingSet.length;
322
689
 
323
690
  break;
324
691
  }
692
+
325
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.
326
703
  const beginIndexQuery = performance.now();
327
- const indexHits = await this._indexer.execQuery({
328
- typenames: [],
329
- 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({
330
708
  query: step.selector.text,
331
- kind: Match.type<QueryPlan.TextSearchKind>().pipe(
332
- Match.withReturnType<'text' | 'vector'>(),
333
- Match.when('full-text', () => 'text'),
334
- Match.when('vector', () => 'vector'),
335
- Match.orElseAbsurd,
336
- )(step.selector.searchKind),
337
- },
338
- });
339
- trace.indexHits = +indexHits.length;
709
+ spaceId: spaces,
710
+ includeAllQueues: allQueuesFromSpaces,
711
+ queueIds,
712
+ }),
713
+ );
714
+ trace.indexHits = textResults.length;
340
715
  trace.indexQueryTime += performance.now() - beginIndexQuery;
341
716
 
342
717
  if (this._ctx.disposed) {
343
718
  return { workingSet, trace };
344
719
  }
345
720
 
721
+ // Load documents from the results.
346
722
  const documentLoadStart = performance.now();
347
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
348
- 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;
349
773
  trace.documentLoadTime += performance.now() - documentLoadStart;
350
774
 
351
- 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
+ );
352
789
  trace.objectCount = workingSet.length;
353
790
  break;
354
791
  }
792
+
355
793
  default:
356
794
  throw new Error(`Unknown selector type: ${(step.selector as any)._tag}`);
357
795
  }
358
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
+
359
803
  return { workingSet, trace };
360
804
  }
361
805
 
362
806
  private async _execFilterStep(step: QueryPlan.FilterStep, workingSet: QueryItem[]): Promise<StepExecutionResult> {
363
- const result = workingSet.filter((item) =>
364
- filterMatchObject(step.filter, {
365
- id: item.objectId,
366
- spaceId: item.spaceId,
367
- doc: item.doc,
368
- }),
369
- );
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
+ });
837
+
370
838
  return {
371
839
  workingSet: result,
372
840
  trace: {
@@ -378,16 +846,52 @@ export class QueryExecutor extends Resource {
378
846
  };
379
847
  }
380
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
+
381
876
  private async _execFilterDeletedStep(
382
877
  step: QueryPlan.FilterDeletedStep,
383
878
  workingSet: QueryItem[],
384
879
  ): Promise<StepExecutionResult> {
385
- if (workingSet.length === 6) {
386
- log.info('FilterDeletedStep', { step, workingSet });
387
- }
388
-
389
880
  const expected = step.mode === 'only-deleted';
390
- 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.
391
895
  return {
392
896
  workingSet: result,
393
897
  trace: {
@@ -399,6 +903,78 @@ export class QueryExecutor extends Resource {
399
903
  };
400
904
  }
401
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
+
402
978
  // TODO(dmaretskyi): This needs to be completed.
403
979
  private async _execTraverseStep(step: QueryPlan.TraverseStep, workingSet: QueryItem[]): Promise<StepExecutionResult> {
404
980
  const trace: ExecutionTrace = {
@@ -413,11 +989,12 @@ export class QueryExecutor extends Resource {
413
989
  case 'ReferenceTraversal': {
414
990
  switch (step.traversal.direction) {
415
991
  case 'outgoing': {
992
+ invariant(step.traversal.property !== null, 'Outgoing reference traversal requires a property');
416
993
  const property = EscapedPropPath.unescape(step.traversal.property);
417
994
 
418
995
  const refs = workingSet
419
996
  .flatMap((item) => {
420
- const ref = getDeep(item.doc.data, property);
997
+ const ref = QueryItem.getProperty(item, property);
421
998
  const refs = Array.isArray(ref) ? ref : [ref];
422
999
  return refs.map((ref) => {
423
1000
  try {
@@ -428,7 +1005,7 @@ export class QueryExecutor extends Resource {
428
1005
  }
429
1006
  : null;
430
1007
  } catch {
431
- log.warn('Invalid reference', { ref: ref['/'] });
1008
+ log.warn('invalid reference', { ref: ref['/'] });
432
1009
  return null;
433
1010
  }
434
1011
  });
@@ -447,19 +1024,13 @@ export class QueryExecutor extends Resource {
447
1024
  break;
448
1025
  }
449
1026
  case 'incoming': {
450
- const indexHits = await this._indexer.execQuery({
451
- typenames: [],
452
- inverted: false,
453
- graph: {
454
- kind: 'inbound-reference',
455
- property: step.traversal.property,
456
- anchors: workingSet.map((item) => item.objectId),
457
- },
458
- });
459
- 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;
460
1031
 
461
1032
  const documentLoadStart = performance.now();
462
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
1033
+ const results = await this._loadDocumentsAfterSqlQuery(metas);
463
1034
  trace.documentsLoaded += results.length;
464
1035
  trace.documentLoadTime += performance.now() - documentLoadStart;
465
1036
 
@@ -477,21 +1048,20 @@ export class QueryExecutor extends Resource {
477
1048
  case 'relation-to-target': {
478
1049
  const refs = workingSet
479
1050
  .map((item) => {
480
- const ref =
1051
+ const dxn =
481
1052
  step.traversal.direction === 'relation-to-source'
482
- ? ObjectStructure.getRelationSource(item.doc)
483
- : ObjectStructure.getRelationTarget(item.doc);
484
-
485
- if (!isEncodedReference(ref)) {
1053
+ ? QueryItem.getRelationSource(item)
1054
+ : QueryItem.getRelationTarget(item);
1055
+ if (!dxn) {
486
1056
  return null;
487
1057
  }
488
1058
  try {
489
1059
  return {
490
- ref: DXN.parse(ref['/']),
1060
+ ref: DXN.parse(dxn),
491
1061
  spaceId: item.spaceId,
492
1062
  };
493
1063
  } catch {
494
- log.warn('Invalid reference', { ref: ref['/'] });
1064
+ log.warn('invalid reference', { ref: dxn });
495
1065
  return null;
496
1066
  }
497
1067
  })
@@ -511,20 +1081,16 @@ export class QueryExecutor extends Resource {
511
1081
 
512
1082
  case 'source-to-relation':
513
1083
  case 'target-to-relation': {
514
- const indexHits = await this._indexer.execQuery({
515
- typenames: [],
516
- inverted: false,
517
- graph: {
518
- kind: step.traversal.direction === 'source-to-relation' ? 'relation-source' : 'relation-target',
519
- anchors: workingSet.map((item) => item.objectId),
520
- property: null,
521
- },
522
- });
523
-
524
- 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;
525
1091
 
526
1092
  const documentLoadStart = performance.now();
527
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
1093
+ const results = await this._loadDocumentsAfterSqlQuery(metas);
528
1094
  trace.documentsLoaded += results.length;
529
1095
  trace.documentLoadTime += performance.now() - documentLoadStart;
530
1096
 
@@ -536,6 +1102,80 @@ export class QueryExecutor extends Resource {
536
1102
  }
537
1103
  break;
538
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
+ }
539
1179
  default:
540
1180
  throw new Error(`Unknown traversal type: ${(step.traversal as any)._tag}`);
541
1181
  }
@@ -557,7 +1197,7 @@ export class QueryExecutor extends Resource {
557
1197
  for (const resultSet of resultSets) {
558
1198
  for (const item of resultSet.workingSet) {
559
1199
  // Could be duplicate object ids in different spaces or in different epochs of the same space.
560
- results.set(`${item.spaceId}:${item.documentId}:${item.objectId}`, item);
1200
+ results.set(compositeKey(item.spaceId, String(item.documentId), item.objectId), item);
561
1201
  }
562
1202
  trace.children.push(resultSet.trace);
563
1203
  }
@@ -590,92 +1230,484 @@ export class QueryExecutor extends Resource {
590
1230
  };
591
1231
  }
592
1232
 
593
- private async _loadDocumentsAfterIndexQuery(indexHits: FindResult[]): Promise<(QueryItem | null)[]> {
594
- return Promise.all(
595
- indexHits.map(async (hit): Promise<QueryItem | null> => {
596
- return this._loadFromIndexHit(hit);
597
- }),
1233
+ private async _execOrderStep(step: QueryPlan.OrderStep, workingSet: QueryItem[]): Promise<StepExecutionResult> {
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
+ }
1240
+
1241
+ return {
1242
+ workingSet: sortedWorkingSet,
1243
+ trace: {
1244
+ ...ExecutionTrace.makeEmpty(),
1245
+ name: 'Order',
1246
+ details: JSON.stringify({ order: step.order, limit: step.limit }),
1247
+ objectCount: sortedWorkingSet.length,
1248
+ },
1249
+ };
1250
+ }
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
+
1266
+ private _compareMultiOrder(a: QueryItem, b: QueryItem, orders: readonly QueryAST.Order[]): number {
1267
+ // Short circuit for common cases.
1268
+ if (orders.length === 0) {
1269
+ return 0;
1270
+ } else if (orders.length === 1) {
1271
+ return this._compareByOrder(a, b, orders[0]);
1272
+ }
1273
+
1274
+ for (const order of orders) {
1275
+ const comparison = this._compareByOrder(a, b, order);
1276
+ if (comparison !== 0) {
1277
+ return comparison;
1278
+ }
1279
+ }
1280
+ return 0;
1281
+ }
1282
+
1283
+ private _compareByOrder(a: QueryItem, b: QueryItem, order: QueryAST.Order): number {
1284
+ switch (order.kind) {
1285
+ case 'natural':
1286
+ return a.objectId.localeCompare(b.objectId);
1287
+ case 'property': {
1288
+ const comparison = this._compareByProperty(a, b, order.property);
1289
+ return order.direction === 'desc' ? -comparison : comparison;
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
+ }
1296
+ default:
1297
+ // Should never reach here with proper TypeScript types.
1298
+ return 0;
1299
+ }
1300
+ }
1301
+
1302
+ private _compareByProperty(a: QueryItem, b: QueryItem, property: string): number {
1303
+ const aValue = QueryItem.getProperty(a, [property]);
1304
+ const bValue = QueryItem.getProperty(b, [property]);
1305
+
1306
+ // Both null or undefined
1307
+ if (aValue == null && bValue == null) {
1308
+ return 0;
1309
+ }
1310
+
1311
+ // Only a is null/undefined
1312
+ if (aValue == null) {
1313
+ return 1;
1314
+ }
1315
+
1316
+ // Only b is null/undefined
1317
+ if (bValue == null) {
1318
+ return -1;
1319
+ }
1320
+
1321
+ // Both strings
1322
+ if (typeof aValue === 'string' && typeof bValue === 'string') {
1323
+ return aValue.localeCompare(bValue);
1324
+ }
1325
+
1326
+ // Both numbers
1327
+ if (typeof aValue === 'number' && typeof bValue === 'number') {
1328
+ return aValue - bValue;
1329
+ }
1330
+
1331
+ // Both booleans
1332
+ if (typeof aValue === 'boolean' && typeof bValue === 'boolean') {
1333
+ return aValue === bValue ? 0 : aValue ? 1 : -1;
1334
+ }
1335
+
1336
+ // Fallback: convert to strings and compare
1337
+ return String(aValue).localeCompare(String(bValue));
1338
+ }
1339
+
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 }),
598
1364
  );
599
1365
  }
600
1366
 
601
- private async _loadFromIndexHit(hit: FindResult): Promise<QueryItem | null> {
602
- const { objectId, documentId, spaceKey: spaceKeyInIndex } = objectPointerCodec.decode(hit.id);
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
+ }
603
1383
 
604
- const handle = await this._automergeHost.loadDoc<DatabaseDirectory>(Context.default(), documentId as DocumentId);
605
- const doc = handle.doc();
606
- if (!doc) {
607
- return null;
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
+
1394
+ /**
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'].
1403
+ */
1404
+ private static _matchesReferencePropertyPath(rowPath: readonly string[], queryPath: readonly string[]): boolean {
1405
+ const isNumericSegment = (segment: string) => /^[0-9]+$/.test(segment);
1406
+
1407
+ let i = 0; // queryPath index.
1408
+ let j = 0; // rowPath index.
1409
+
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;
608
1424
  }
609
1425
 
610
- const spaceKey = spaceKeyInIndex ?? DatabaseDirectory.getSpaceKey(doc);
611
- if (!spaceKey) {
612
- return null;
1426
+ // Must consume full query path.
1427
+ if (i !== queryPath.length) {
1428
+ return false;
613
1429
  }
614
1430
 
615
- const object = DatabaseDirectory.getInlineObject(doc, objectId);
616
- if (!object) {
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
+ }
1436
+ }
1437
+
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') {
617
1487
  return null;
618
1488
  }
619
1489
 
620
1490
  return {
621
- objectId,
622
- documentId: documentId as DocumentId,
623
- spaceId: await createIdFromSpaceKey(PublicKey.from(spaceKey)),
624
- 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,
625
1499
  };
626
1500
  }
627
1501
 
628
- private async _loadFromDXN(dxn: DXN, { sourceSpaceId }: { sourceSpaceId: SpaceId }): Promise<QueryItem | null> {
629
- const echoDxn = dxn.asEchoDXN();
630
- if (!echoDxn) {
631
- 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) {
632
1509
  return null;
633
1510
  }
634
-
635
- const spaceId = echoDxn.spaceId ?? sourceSpaceId;
636
-
637
- const spaceRoot = this._spaceStateManager.getRootBySpaceId(spaceId);
638
- if (!spaceRoot) {
639
- 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) {
640
1515
  return null;
641
1516
  }
642
- const dbDirectory = spaceRoot.doc();
643
- if (!dbDirectory) {
644
- log.warn('no space state found for', { spaceId });
1517
+ const object = DatabaseDirectory.getInlineObject(handle.doc(), meta.objectId);
1518
+ if (!object) {
645
1519
  return null;
646
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;
647
1543
 
648
- const inlineObject = DatabaseDirectory.getInlineObject(dbDirectory, echoDxn.echoId);
649
- if (inlineObject) {
650
- return {
651
- objectId: echoDxn.echoId,
652
- documentId: spaceRoot.documentId,
653
- spaceId,
654
- doc: inlineObject,
655
- };
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
+ }
656
1623
  }
1624
+ }
657
1625
 
658
- const link = DatabaseDirectory.getLink(dbDirectory, echoDxn.echoId);
659
- if (!link) {
660
- 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;
661
1635
  }
662
1636
 
663
- const handle = await this._automergeHost.loadDoc<DatabaseDirectory>(Context.default(), link as AutomergeUrl);
664
- const doc = handle.doc();
665
- if (!doc) {
666
- 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];
667
1675
  }
1676
+ if (f.type === 'and') {
1677
+ return f.filters.flatMap(collect);
1678
+ }
1679
+ return [];
1680
+ };
668
1681
 
669
- const object = DatabaseDirectory.getInlineObject(doc, echoDxn.echoId);
670
- if (!object) {
671
- 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;
672
1697
  }
1698
+ }
1699
+ return params;
1700
+ };
673
1701
 
674
- return {
675
- objectId: echoDxn.echoId,
676
- documentId: handle.documentId,
677
- spaceId,
678
- doc: object,
679
- };
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);
680
1711
  }
1712
+ return false;
681
1713
  }