@dxos/echo-pipeline 0.8.3 → 0.8.4-main.1068cf700f

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 (199) hide show
  1. package/dist/lib/neutral/chunk-FJPXA75J.mjs +235 -0
  2. package/dist/lib/neutral/chunk-FJPXA75J.mjs.map +7 -0
  3. package/dist/lib/{browser/chunk-35I6ERLG.mjs → neutral/chunk-WIQUYO7H.mjs} +300 -213
  4. package/dist/lib/neutral/chunk-WIQUYO7H.mjs.map +7 -0
  5. package/dist/lib/{browser → neutral}/filter/index.mjs +3 -2
  6. package/dist/lib/{browser → neutral}/index.mjs +2832 -946
  7. package/dist/lib/neutral/index.mjs.map +7 -0
  8. package/dist/lib/neutral/meta.json +1 -0
  9. package/dist/lib/{browser → neutral}/testing/index.mjs +73 -224
  10. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  11. package/dist/types/src/automerge/automerge-host.d.ts +53 -42
  12. package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
  13. package/dist/types/src/automerge/automerge-repo.test.d.ts +1 -1
  14. package/dist/types/src/automerge/automerge-repo.test.d.ts.map +1 -1
  15. package/dist/types/src/automerge/collection-synchronizer.d.ts +6 -4
  16. package/dist/types/src/automerge/collection-synchronizer.d.ts.map +1 -1
  17. package/dist/types/src/automerge/echo-network-adapter.d.ts +22 -10
  18. package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
  19. package/dist/types/src/automerge/echo-replicator.d.ts +37 -15
  20. package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
  21. package/dist/types/src/automerge/heads-store.d.ts +9 -2
  22. package/dist/types/src/automerge/heads-store.d.ts.map +1 -1
  23. package/dist/types/src/automerge/index.d.ts +1 -1
  24. package/dist/types/src/automerge/index.d.ts.map +1 -1
  25. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +5 -5
  26. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts.map +1 -1
  27. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts +9 -8
  28. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts.map +1 -1
  29. package/dist/types/src/automerge/mesh-echo-replicator.d.ts +5 -5
  30. package/dist/types/src/automerge/mesh-echo-replicator.d.ts.map +1 -1
  31. package/dist/types/src/common/codec.d.ts +1 -1
  32. package/dist/types/src/common/codec.d.ts.map +1 -1
  33. package/dist/types/src/db-host/automerge-data-source.d.ts +34 -0
  34. package/dist/types/src/db-host/automerge-data-source.d.ts.map +1 -0
  35. package/dist/types/src/db-host/automerge-data-source.test.d.ts +2 -0
  36. package/dist/types/src/db-host/automerge-data-source.test.d.ts.map +1 -0
  37. package/dist/types/src/db-host/data-service.d.ts +5 -4
  38. package/dist/types/src/db-host/data-service.d.ts.map +1 -1
  39. package/dist/types/src/db-host/database-root.d.ts.map +1 -1
  40. package/dist/types/src/db-host/documents-synchronizer.d.ts +6 -5
  41. package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
  42. package/dist/types/src/db-host/echo-host.d.ts +44 -23
  43. package/dist/types/src/db-host/echo-host.d.ts.map +1 -1
  44. package/dist/types/src/db-host/index.d.ts +1 -0
  45. package/dist/types/src/db-host/index.d.ts.map +1 -1
  46. package/dist/types/src/db-host/local-queue-service.d.ts +16 -0
  47. package/dist/types/src/db-host/local-queue-service.d.ts.map +1 -0
  48. package/dist/types/src/db-host/query-service.d.ts +14 -9
  49. package/dist/types/src/db-host/query-service.d.ts.map +1 -1
  50. package/dist/types/src/db-host/queue-data-source.d.ts +28 -0
  51. package/dist/types/src/db-host/queue-data-source.d.ts.map +1 -0
  52. package/dist/types/src/db-host/queue-service.test.d.ts +2 -0
  53. package/dist/types/src/db-host/queue-service.test.d.ts.map +1 -0
  54. package/dist/types/src/db-host/space-state-manager.d.ts +2 -1
  55. package/dist/types/src/db-host/space-state-manager.d.ts.map +1 -1
  56. package/dist/types/src/db-host/stub.d.ts +10 -0
  57. package/dist/types/src/db-host/stub.d.ts.map +1 -0
  58. package/dist/types/src/edge/echo-edge-replicator.d.ts +8 -6
  59. package/dist/types/src/edge/echo-edge-replicator.d.ts.map +1 -1
  60. package/dist/types/src/filter/filter-match.d.ts +4 -1
  61. package/dist/types/src/filter/filter-match.d.ts.map +1 -1
  62. package/dist/types/src/metadata/metadata-store.d.ts +1 -1
  63. package/dist/types/src/metadata/metadata-store.d.ts.map +1 -1
  64. package/dist/types/src/pipeline/pipeline.d.ts +3 -3
  65. package/dist/types/src/pipeline/pipeline.d.ts.map +1 -1
  66. package/dist/types/src/query/errors.d.ts +21 -9
  67. package/dist/types/src/query/errors.d.ts.map +1 -1
  68. package/dist/types/src/query/plan.d.ts +57 -5
  69. package/dist/types/src/query/plan.d.ts.map +1 -1
  70. package/dist/types/src/query/query-executor.d.ts +49 -10
  71. package/dist/types/src/query/query-executor.d.ts.map +1 -1
  72. package/dist/types/src/query/query-planner.d.ts +9 -0
  73. package/dist/types/src/query/query-planner.d.ts.map +1 -1
  74. package/dist/types/src/space/admission-discovery-extension.d.ts.map +1 -1
  75. package/dist/types/src/space/auth.d.ts +3 -3
  76. package/dist/types/src/space/auth.d.ts.map +1 -1
  77. package/dist/types/src/space/control-pipeline.d.ts +3 -3
  78. package/dist/types/src/space/control-pipeline.d.ts.map +1 -1
  79. package/dist/types/src/space/space-manager.d.ts +7 -7
  80. package/dist/types/src/space/space-manager.d.ts.map +1 -1
  81. package/dist/types/src/space/space-protocol.d.ts +6 -6
  82. package/dist/types/src/space/space-protocol.d.ts.map +1 -1
  83. package/dist/types/src/space/space.d.ts +4 -4
  84. package/dist/types/src/space/space.d.ts.map +1 -1
  85. package/dist/types/src/testing/index.d.ts +0 -2
  86. package/dist/types/src/testing/index.d.ts.map +1 -1
  87. package/dist/types/src/testing/test-agent-builder.d.ts +2 -2
  88. package/dist/types/src/testing/test-agent-builder.d.ts.map +1 -1
  89. package/dist/types/src/testing/test-network-adapter.d.ts +1 -1
  90. package/dist/types/src/testing/test-network-adapter.d.ts.map +1 -1
  91. package/dist/types/src/testing/test-replicator.d.ts +14 -13
  92. package/dist/types/src/testing/test-replicator.d.ts.map +1 -1
  93. package/dist/types/src/util.d.ts +1 -1
  94. package/dist/types/src/util.d.ts.map +1 -1
  95. package/dist/types/tsconfig.tsbuildinfo +1 -1
  96. package/package.json +53 -45
  97. package/src/automerge/automerge-host.test.ts +55 -24
  98. package/src/automerge/automerge-host.ts +399 -126
  99. package/src/automerge/automerge-repo.test.ts +214 -46
  100. package/src/automerge/collection-synchronizer.test.ts +22 -15
  101. package/src/automerge/collection-synchronizer.ts +57 -22
  102. package/src/automerge/echo-data-monitor.ts +1 -1
  103. package/src/automerge/echo-network-adapter.test.ts +3 -3
  104. package/src/automerge/echo-network-adapter.ts +73 -28
  105. package/src/automerge/echo-replicator.ts +40 -15
  106. package/src/automerge/heads-store.ts +42 -3
  107. package/src/automerge/index.ts +1 -1
  108. package/src/automerge/leveldb-storage-adapter.ts +11 -11
  109. package/src/automerge/mesh-echo-replicator-connection.ts +12 -8
  110. package/src/automerge/mesh-echo-replicator.ts +15 -11
  111. package/src/automerge/storage-adapter.test.ts +1 -1
  112. package/src/common/space-id.ts +1 -1
  113. package/src/db-host/automerge-data-source.test.ts +292 -0
  114. package/src/db-host/automerge-data-source.ts +144 -0
  115. package/src/db-host/data-service.ts +19 -20
  116. package/src/db-host/database-root.ts +2 -2
  117. package/src/db-host/documents-synchronizer.test.ts +73 -13
  118. package/src/db-host/documents-synchronizer.ts +58 -44
  119. package/src/db-host/echo-host.ts +194 -84
  120. package/src/db-host/index.ts +1 -0
  121. package/src/db-host/local-queue-service.ts +147 -0
  122. package/src/db-host/query-service.ts +28 -95
  123. package/src/db-host/queue-data-source.ts +132 -0
  124. package/src/db-host/queue-service.test.ts +145 -0
  125. package/src/db-host/space-state-manager.ts +6 -2
  126. package/src/db-host/stub.ts +28 -0
  127. package/src/edge/echo-edge-replicator.test.ts +8 -6
  128. package/src/edge/echo-edge-replicator.ts +124 -42
  129. package/src/filter/filter-match.test.ts +58 -32
  130. package/src/filter/filter-match.ts +163 -15
  131. package/src/metadata/metadata-store.ts +8 -5
  132. package/src/pipeline/pipeline-stress.test.ts +4 -2
  133. package/src/pipeline/pipeline.test.ts +3 -2
  134. package/src/pipeline/pipeline.ts +10 -7
  135. package/src/query/errors.ts +3 -1
  136. package/src/query/plan.ts +75 -5
  137. package/src/query/query-executor.ts +812 -117
  138. package/src/query/query-planner.test.ts +652 -34
  139. package/src/query/query-planner.ts +276 -13
  140. package/src/space/admission-discovery-extension.ts +3 -3
  141. package/src/space/auth.ts +6 -6
  142. package/src/space/control-pipeline.test.ts +4 -3
  143. package/src/space/control-pipeline.ts +11 -8
  144. package/src/space/space-manager.browser.test.ts +1 -1
  145. package/src/space/space-manager.ts +11 -10
  146. package/src/space/space-protocol.browser.test.ts +2 -2
  147. package/src/space/space-protocol.test.ts +3 -2
  148. package/src/space/space-protocol.ts +17 -14
  149. package/src/space/space.test.ts +1 -1
  150. package/src/space/space.ts +6 -5
  151. package/src/testing/index.ts +0 -2
  152. package/src/testing/test-agent-builder.ts +4 -3
  153. package/src/testing/test-network-adapter.ts +16 -3
  154. package/src/testing/test-replicator.ts +23 -18
  155. package/src/util.ts +7 -3
  156. package/dist/lib/browser/chunk-35I6ERLG.mjs.map +0 -7
  157. package/dist/lib/browser/chunk-CGS2ULMK.mjs +0 -11
  158. package/dist/lib/browser/chunk-CGS2ULMK.mjs.map +0 -7
  159. package/dist/lib/browser/chunk-TQJTKNMS.mjs +0 -126
  160. package/dist/lib/browser/chunk-TQJTKNMS.mjs.map +0 -7
  161. package/dist/lib/browser/index.mjs.map +0 -7
  162. package/dist/lib/browser/meta.json +0 -1
  163. package/dist/lib/browser/testing/index.mjs.map +0 -7
  164. package/dist/lib/node/chunk-HOPOFWAL.cjs +0 -147
  165. package/dist/lib/node/chunk-HOPOFWAL.cjs.map +0 -7
  166. package/dist/lib/node/chunk-JXX6LF5U.cjs +0 -2084
  167. package/dist/lib/node/chunk-JXX6LF5U.cjs.map +0 -7
  168. package/dist/lib/node/chunk-Q7SFCCGT.cjs +0 -33
  169. package/dist/lib/node/chunk-Q7SFCCGT.cjs.map +0 -7
  170. package/dist/lib/node/filter/index.cjs +0 -32
  171. package/dist/lib/node/filter/index.cjs.map +0 -7
  172. package/dist/lib/node/index.cjs +0 -4699
  173. package/dist/lib/node/index.cjs.map +0 -7
  174. package/dist/lib/node/meta.json +0 -1
  175. package/dist/lib/node/testing/index.cjs +0 -753
  176. package/dist/lib/node/testing/index.cjs.map +0 -7
  177. package/dist/lib/node-esm/chunk-5BHLPT24.mjs +0 -2067
  178. package/dist/lib/node-esm/chunk-5BHLPT24.mjs.map +0 -7
  179. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
  180. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
  181. package/dist/lib/node-esm/chunk-RVK35BS7.mjs +0 -126
  182. package/dist/lib/node-esm/chunk-RVK35BS7.mjs.map +0 -7
  183. package/dist/lib/node-esm/filter/index.mjs +0 -11
  184. package/dist/lib/node-esm/filter/index.mjs.map +0 -7
  185. package/dist/lib/node-esm/index.mjs +0 -4743
  186. package/dist/lib/node-esm/index.mjs.map +0 -7
  187. package/dist/lib/node-esm/meta.json +0 -1
  188. package/dist/lib/node-esm/testing/index.mjs +0 -742
  189. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  190. package/dist/types/src/db-host/documents-iterator.d.ts +0 -7
  191. package/dist/types/src/db-host/documents-iterator.d.ts.map +0 -1
  192. package/dist/types/src/testing/test-data.d.ts +0 -18
  193. package/dist/types/src/testing/test-data.d.ts.map +0 -1
  194. package/dist/types/src/testing/test-schema.d.ts +0 -37
  195. package/dist/types/src/testing/test-schema.d.ts.map +0 -1
  196. package/src/db-host/documents-iterator.ts +0 -74
  197. package/src/testing/test-data.ts +0 -127
  198. package/src/testing/test-schema.ts +0 -53
  199. /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';
6
+ import type * as SqlClient from '@effect/sql/SqlClient';
7
+ import type * as Effect from 'effect/Effect';
8
+ import * as Runtime from 'effect/Runtime';
7
9
 
8
10
  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';
11
+ import type { Obj } from '@dxos/echo';
12
+ import { ATTR_PARENT, ATTR_RELATION_SOURCE, ATTR_RELATION_TARGET } from '@dxos/echo/internal';
13
+ import {
14
+ DatabaseDirectory,
15
+ EncodedReference,
16
+ type ObjectPropPath,
17
+ ObjectStructure,
18
+ type QueryAST,
19
+ isEncodedReference,
20
+ } from '@dxos/echo-protocol';
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
27
  import { getDeep, isNonNullable } from '@dxos/util';
17
28
 
18
- import type { QueryPlan } from './plan';
19
- import { QueryPlanner } from './query-planner';
20
29
  import type { AutomergeHost } from '../automerge';
21
- import { createIdFromSpaceKey } from '../common';
22
30
  import type { SpaceStateManager } from '../db-host';
23
- import { filterMatchObject } from '../filter';
31
+ import { filterMatchObject, filterMatchObjectJSON } from '../filter';
32
+
33
+ import type { QueryPlan } from './plan';
34
+ import { QueryPlanner } from './query-planner';
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 [
@@ -99,8 +218,10 @@ type StepExecutionResult = {
99
218
 
100
219
  const TRACE_QUERY_EXECUTION = false;
101
220
 
221
+ const MAX_DEPTH_FOR_DELETION_TRACING = 10;
222
+
102
223
  /**
103
- * Executes query plans against the Indexer and AutomergeHost.
224
+ * Executes query plans against the IndexEngine and AutomergeHost.
104
225
  *
105
226
  * The QueryExecutor is responsible for:
106
227
  * - Executing query plans step by step
@@ -110,7 +231,8 @@ const TRACE_QUERY_EXECUTION = false;
110
231
  * - Handling different types of query operations (select, filter, traverse, etc.)
111
232
  */
112
233
  export class QueryExecutor extends Resource {
113
- private readonly _indexer: Indexer;
234
+ private readonly _indexEngine: IndexEngine;
235
+ private readonly _runtime: RuntimeProvider.RuntimeProvider<SqlClient.SqlClient>;
114
236
  private readonly _automergeHost: AutomergeHost;
115
237
  private readonly _spaceStateManager: SpaceStateManager;
116
238
  /**
@@ -128,7 +250,8 @@ export class QueryExecutor extends Resource {
128
250
  constructor(options: QueryExecutorOptions) {
129
251
  super();
130
252
 
131
- this._indexer = options.indexer;
253
+ this._indexEngine = options.indexEngine;
254
+ this._runtime = options.runtime;
132
255
  this._automergeHost = options.automergeHost;
133
256
  this._spaceStateManager = options.spaceStateManager;
134
257
 
@@ -152,19 +275,18 @@ export class QueryExecutor extends Resource {
152
275
  return this._trace;
153
276
  }
154
277
 
155
- protected override async _open(ctx: Context): Promise<void> {}
156
-
157
- protected override async _close(ctx: Context): Promise<void> {}
158
-
159
278
  getResults(): QueryResult[] {
160
279
  return this._lastResultSet.map(
161
280
  (item): QueryResult => ({
162
281
  id: item.objectId,
163
- documentId: item.documentId,
282
+ documentId: item.documentId ?? undefined,
283
+ queueId: item.queueId ?? undefined,
284
+ queueNamespace: item.queueNamespace ?? undefined,
164
285
  spaceId: item.spaceId,
165
286
 
166
- // TODO(dmaretskyi): Plumb through the rank.
167
- rank: 0,
287
+ rank: item.rank,
288
+
289
+ documentJson: item.doc ? JSON.stringify(item.doc) : item.data ? JSON.stringify(item.data) : undefined,
168
290
  }),
169
291
  );
170
292
  }
@@ -176,7 +298,7 @@ export class QueryExecutor extends Resource {
176
298
  const { workingSet, trace } = await this._execPlan(this._plan, []);
177
299
  this._lastResultSet = workingSet;
178
300
  trace.name = 'Root';
179
- trace.details = JSON.stringify({ id: this._id });
301
+ trace.details = JSON.stringify({ id: this._id, query: prettyQuery(this._query) });
180
302
  this._trace = trace;
181
303
 
182
304
  const changed =
@@ -188,6 +310,9 @@ export class QueryExecutor extends Resource {
188
310
  workingSet[index].documentId !== item.documentId,
189
311
  );
190
312
 
313
+ // Disabled because concurrent queries don't print hierarchies correctly.
314
+ // ExecutionTrace.putOnPerformanceTimeline(trace);
315
+
191
316
  if (TRACE_QUERY_EXECUTION) {
192
317
  // eslint-disable-next-line no-console
193
318
  console.log(ExecutionTrace.format(trace));
@@ -200,7 +325,6 @@ export class QueryExecutor extends Resource {
200
325
 
201
326
  private async _execPlan(plan: QueryPlan.Plan, workingSet: QueryItem[]): Promise<StepExecutionResult> {
202
327
  const trace = ExecutionTrace.makeEmpty();
203
- const begin = performance.now();
204
328
  for (const step of plan.steps) {
205
329
  if (this._ctx.disposed) {
206
330
  throw new ContextDisposedError();
@@ -211,7 +335,7 @@ export class QueryExecutor extends Resource {
211
335
  trace.children.push(result.trace);
212
336
  }
213
337
  trace.objectCount = workingSet.length;
214
- trace.executionTime = performance.now() - begin;
338
+ ExecutionTrace.markEnd(trace);
215
339
  return { workingSet, trace };
216
340
  }
217
341
 
@@ -245,10 +369,17 @@ export class QueryExecutor extends Resource {
245
369
  case 'TraverseStep':
246
370
  ({ workingSet: newWorkingSet, trace } = await this._execTraverseStep(step, workingSet));
247
371
  break;
372
+ case 'OrderStep':
373
+ ({ workingSet: newWorkingSet, trace } = await this._execOrderStep(step, workingSet));
374
+ break;
375
+ case 'LimitStep':
376
+ ({ workingSet: newWorkingSet, trace } = await this._execLimitStep(step, workingSet));
377
+ break;
248
378
  default:
249
379
  throw new Error(`Unknown step type: ${(step as any)._tag}`);
250
380
  }
251
- trace.executionTime = performance.now() - begin;
381
+ trace.beginTs = begin;
382
+ ExecutionTrace.markEnd(trace);
252
383
 
253
384
  return { workingSet: newWorkingSet, trace };
254
385
  }
@@ -260,16 +391,14 @@ export class QueryExecutor extends Resource {
260
391
  ...ExecutionTrace.makeEmpty(),
261
392
  name: 'Select',
262
393
  details: JSON.stringify(step.selector),
394
+ beginTs: performance.now(),
263
395
  };
264
396
 
265
397
  switch (step.selector._tag) {
266
398
  case 'WildcardSelector': {
267
399
  const beginIndexQuery = performance.now();
268
- const indexHits = await this._indexer.execQuery({
269
- typenames: [],
270
- inverted: false,
271
- });
272
- trace.indexHits = +indexHits.length;
400
+ const metas = await this._queryAllFromSqlIndex(step.spaces);
401
+ trace.indexHits = metas.length;
273
402
  trace.indexQueryTime += performance.now() - beginIndexQuery;
274
403
 
275
404
  if (this._ctx.disposed) {
@@ -277,15 +406,16 @@ export class QueryExecutor extends Resource {
277
406
  }
278
407
 
279
408
  const documentLoadStart = performance.now();
280
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
409
+ const results = await this._loadDocumentsAfterSqlQuery(metas);
281
410
  trace.documentsLoaded += results.length;
282
411
  trace.documentLoadTime += performance.now() - documentLoadStart;
283
412
 
284
- workingSet.push(...results.filter(isNonNullable).filter((item) => step.spaces.includes(item.spaceId)));
413
+ workingSet.push(...results.filter(isNonNullable));
285
414
  trace.objectCount = workingSet.length;
286
415
 
287
416
  break;
288
417
  }
418
+
289
419
  case 'IdSelector': {
290
420
  const beginLoad = performance.now();
291
421
  const items = await Promise.all(
@@ -299,13 +429,11 @@ export class QueryExecutor extends Resource {
299
429
  trace.objectCount = workingSet.length;
300
430
  break;
301
431
  }
432
+
302
433
  case 'TypeSelector': {
303
434
  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;
435
+ const metas = await this._queryTypesFromSqlIndex(step.spaces, step.selector.typename, step.selector.inverted);
436
+ trace.indexHits = metas.length;
309
437
  trace.indexQueryTime += performance.now() - beginIndexQuery;
310
438
 
311
439
  if (this._ctx.disposed) {
@@ -313,60 +441,135 @@ export class QueryExecutor extends Resource {
313
441
  }
314
442
 
315
443
  const documentLoadStart = performance.now();
316
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
444
+ const results = await this._loadDocumentsAfterSqlQuery(metas);
317
445
  trace.documentsLoaded += results.length;
318
446
  trace.documentLoadTime += performance.now() - documentLoadStart;
319
447
 
320
- workingSet.push(...results.filter(isNonNullable).filter((item) => step.spaces.includes(item.spaceId)));
448
+ workingSet.push(...results.filter(isNonNullable));
321
449
  trace.objectCount = workingSet.length;
322
450
 
323
451
  break;
324
452
  }
453
+
325
454
  case 'TextSelector': {
455
+ // TODO(dmaretskyi): type + FTS queries would be very common so we should support those, maybe chunk the fts index.
456
+ // TODO(dmaretskyi): nice to have matched text snippets/highlighting.
457
+ if (step.selector.searchKind === 'vector') {
458
+ // Vector search is not currently supported.
459
+ log.warn('Vector search is not supported');
460
+ break;
461
+ }
462
+
463
+ // Full-text search using SQLite FTS5.
326
464
  const beginIndexQuery = performance.now();
327
- const indexHits = await this._indexer.execQuery({
328
- typenames: [],
329
- text: {
465
+ invariant(step.spaces.length <= 1, 'Multiple spaces are not supported for full-text search');
466
+ // Extract queue IDs from DXN strings.
467
+ const queueIds =
468
+ step.queues.length > 0
469
+ ? (step.queues.map((dxnStr) => DXN.parse(dxnStr).asQueueDXN()?.queueId).filter(Boolean) as ObjectId[])
470
+ : null;
471
+ const textResults = await this._runInRuntime(
472
+ this._indexEngine.queryText({
330
473
  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;
474
+ spaceId: step.spaces,
475
+ includeAllQueues: step.allQueuesFromSpaces,
476
+ queueIds,
477
+ }),
478
+ );
479
+ trace.indexHits = textResults.length;
340
480
  trace.indexQueryTime += performance.now() - beginIndexQuery;
341
481
 
342
482
  if (this._ctx.disposed) {
343
483
  return { workingSet, trace };
344
484
  }
345
485
 
486
+ // Load documents from the results.
346
487
  const documentLoadStart = performance.now();
347
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
348
- trace.documentsLoaded += results.length;
488
+
489
+ // Separate queue items from space items.
490
+ const queueResults = textResults.filter((r) => r.queueId);
491
+ const spaceResults = textResults.filter((r) => !r.queueId);
492
+
493
+ // Build a map from recordId to rank for all FTS results.
494
+ const rankMap = new Map(textResults.map((r) => [r.recordId, r.rank]));
495
+
496
+ // Load queue items from indexed snapshots.
497
+ let queueItems: QueryItem[] = [];
498
+ if (queueResults.length > 0) {
499
+ const snapshots = await this._runInRuntime(
500
+ this._indexEngine.querySnapshotsJSON(queueResults.map((r) => r.recordId)),
501
+ );
502
+ const snapshotMap = new Map(snapshots.map((s) => [s.recordId, s.snapshot]));
503
+ queueItems = queueResults
504
+ .map((result): QueryItem | null => {
505
+ const snapshot = snapshotMap.get(result.recordId);
506
+ if (!snapshot || typeof snapshot !== 'object') {
507
+ return null;
508
+ }
509
+ return {
510
+ objectId: result.objectId as ObjectId,
511
+ spaceId: result.spaceId as SpaceId,
512
+ queueId: result.queueId as ObjectId,
513
+ queueNamespace: 'data',
514
+ documentId: null,
515
+ doc: null,
516
+ data: snapshot as Obj.JSON,
517
+ rank: rankMap.get(result.recordId) ?? 1,
518
+ };
519
+ })
520
+ .filter(isNonNullable);
521
+ }
522
+
523
+ // Load space items from documents.
524
+ const spaceItems = await Promise.all(
525
+ spaceResults.map(async (result): Promise<QueryItem | null> => {
526
+ const dxn = DXN.fromLocalObjectId(result.objectId);
527
+ const item = await this._loadFromDXN(dxn, { sourceSpaceId: result.spaceId as SpaceId });
528
+ if (item) {
529
+ // Override the default rank with the FTS rank.
530
+ item.rank = rankMap.get(result.recordId) ?? 1;
531
+ }
532
+ return item;
533
+ }),
534
+ );
535
+
536
+ const items = [...queueItems, ...spaceItems.filter(isNonNullable)];
537
+ trace.documentsLoaded += items.length;
349
538
  trace.documentLoadTime += performance.now() - documentLoadStart;
350
539
 
351
- workingSet.push(...results.filter(isNonNullable).filter((item) => step.spaces.includes(item.spaceId)));
540
+ workingSet.push(...items.filter((item) => step.spaces.includes(item.spaceId)));
352
541
  trace.objectCount = workingSet.length;
353
542
  break;
354
543
  }
544
+
355
545
  default:
356
546
  throw new Error(`Unknown selector type: ${(step.selector as any)._tag}`);
357
547
  }
358
548
 
549
+ // Apply limit if specified on the select step.
550
+ if (step.limit !== undefined && workingSet.length > step.limit) {
551
+ workingSet = workingSet.slice(0, step.limit);
552
+ trace.objectCount = workingSet.length;
553
+ }
554
+
359
555
  return { workingSet, trace };
360
556
  }
361
557
 
362
558
  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
- );
559
+ const result = workingSet.filter((item) => {
560
+ if (item.doc) {
561
+ return filterMatchObject(step.filter, {
562
+ id: item.objectId,
563
+ spaceId: item.spaceId,
564
+ doc: item.doc,
565
+ });
566
+ } else if (item.data) {
567
+ return filterMatchObjectJSON(step.filter, item.data);
568
+ } else {
569
+ return false;
570
+ }
571
+ });
572
+
370
573
  return {
371
574
  workingSet: result,
372
575
  trace: {
@@ -382,12 +585,21 @@ export class QueryExecutor extends Resource {
382
585
  step: QueryPlan.FilterDeletedStep,
383
586
  workingSet: QueryItem[],
384
587
  ): Promise<StepExecutionResult> {
385
- if (workingSet.length === 6) {
386
- log.info('FilterDeletedStep', { step, workingSet });
387
- }
388
-
389
588
  const expected = step.mode === 'only-deleted';
390
- const result = workingSet.filter((item) => ObjectStructure.isDeleted(item.doc) === expected);
589
+
590
+ const deletedState = workingSet.map((item) => QueryItem.isDeleted(item));
591
+ await Promise.all(
592
+ workingSet.map(async (item, index) => {
593
+ if (deletedState[index]) {
594
+ return;
595
+ }
596
+ deletedState[index] ||= await this._getTransitiveDeletionState(item, MAX_DEPTH_FOR_DELETION_TRACING);
597
+ }),
598
+ );
599
+
600
+ const result = workingSet.filter((item, index) => deletedState[index] === expected);
601
+
602
+ // 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
603
  return {
392
604
  workingSet: result,
393
605
  trace: {
@@ -413,11 +625,12 @@ export class QueryExecutor extends Resource {
413
625
  case 'ReferenceTraversal': {
414
626
  switch (step.traversal.direction) {
415
627
  case 'outgoing': {
628
+ invariant(step.traversal.property !== null, 'Outgoing reference traversal requires a property');
416
629
  const property = EscapedPropPath.unescape(step.traversal.property);
417
630
 
418
631
  const refs = workingSet
419
632
  .flatMap((item) => {
420
- const ref = getDeep(item.doc.data, property);
633
+ const ref = QueryItem.getProperty(item, property);
421
634
  const refs = Array.isArray(ref) ? ref : [ref];
422
635
  return refs.map((ref) => {
423
636
  try {
@@ -428,7 +641,7 @@ export class QueryExecutor extends Resource {
428
641
  }
429
642
  : null;
430
643
  } catch {
431
- log.warn('Invalid reference', { ref: ref['/'] });
644
+ log.warn('invalid reference', { ref: ref['/'] });
432
645
  return null;
433
646
  }
434
647
  });
@@ -447,19 +660,13 @@ export class QueryExecutor extends Resource {
447
660
  break;
448
661
  }
449
662
  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;
663
+ const beginIndexQuery = performance.now();
664
+ const metas = await this._queryIncomingReferencesFromSqlIndex(workingSet, step.traversal.property);
665
+ trace.indexHits += metas.length;
666
+ trace.indexQueryTime += performance.now() - beginIndexQuery;
460
667
 
461
668
  const documentLoadStart = performance.now();
462
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
669
+ const results = await this._loadDocumentsAfterSqlQuery(metas);
463
670
  trace.documentsLoaded += results.length;
464
671
  trace.documentLoadTime += performance.now() - documentLoadStart;
465
672
 
@@ -477,21 +684,20 @@ export class QueryExecutor extends Resource {
477
684
  case 'relation-to-target': {
478
685
  const refs = workingSet
479
686
  .map((item) => {
480
- const ref =
687
+ const dxn =
481
688
  step.traversal.direction === 'relation-to-source'
482
- ? ObjectStructure.getRelationSource(item.doc)
483
- : ObjectStructure.getRelationTarget(item.doc);
484
-
485
- if (!isEncodedReference(ref)) {
689
+ ? QueryItem.getRelationSource(item)
690
+ : QueryItem.getRelationTarget(item);
691
+ if (!dxn) {
486
692
  return null;
487
693
  }
488
694
  try {
489
695
  return {
490
- ref: DXN.parse(ref['/']),
696
+ ref: DXN.parse(dxn),
491
697
  spaceId: item.spaceId,
492
698
  };
493
699
  } catch {
494
- log.warn('Invalid reference', { ref: ref['/'] });
700
+ log.warn('invalid reference', { ref: dxn });
495
701
  return null;
496
702
  }
497
703
  })
@@ -511,20 +717,16 @@ export class QueryExecutor extends Resource {
511
717
 
512
718
  case 'source-to-relation':
513
719
  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;
720
+ const beginIndexQuery = performance.now();
721
+ const metas = await this._queryRelationsFromSqlIndex(
722
+ workingSet,
723
+ step.traversal.direction === 'source-to-relation' ? 'source' : 'target',
724
+ );
725
+ trace.indexHits += metas.length;
726
+ trace.indexQueryTime += performance.now() - beginIndexQuery;
525
727
 
526
728
  const documentLoadStart = performance.now();
527
- const results = await this._loadDocumentsAfterIndexQuery(indexHits);
729
+ const results = await this._loadDocumentsAfterSqlQuery(metas);
528
730
  trace.documentsLoaded += results.length;
529
731
  trace.documentLoadTime += performance.now() - documentLoadStart;
530
732
 
@@ -536,6 +738,85 @@ export class QueryExecutor extends Resource {
536
738
  }
537
739
  break;
538
740
  }
741
+ case 'HierarchyTraversal': {
742
+ switch (step.traversal.direction) {
743
+ case 'to-parent': {
744
+ // Traverse from child to parent using the parent reference in the document.
745
+ const refs = workingSet
746
+ .map((item) => {
747
+ if (!item.doc) {
748
+ return null; // TODO(dmaretskyi): Queue items not supported here.
749
+ }
750
+ const ref = ObjectStructure.getParent(item.doc);
751
+ if (!EncodedReference.isEncodedReference(ref)) {
752
+ return null;
753
+ }
754
+ try {
755
+ return {
756
+ ref: DXN.parse(ref['/']),
757
+ spaceId: item.spaceId,
758
+ };
759
+ } catch {
760
+ log.warn('invalid parent reference', { ref: ref['/'] });
761
+ return null;
762
+ }
763
+ })
764
+ .filter(isNonNullable);
765
+
766
+ const beginLoad = performance.now();
767
+ const items = await Promise.all(
768
+ refs.map(({ ref, spaceId }) => this._loadFromDXN(ref, { sourceSpaceId: spaceId })),
769
+ );
770
+ trace.documentLoadTime += performance.now() - beginLoad;
771
+
772
+ newWorkingSet.push(...items.filter(isNonNullable));
773
+ trace.objectCount = newWorkingSet.length;
774
+ break;
775
+ }
776
+
777
+ case 'to-children': {
778
+ // Traverse from parent to children using the SQL index.
779
+ // Group working set by spaceId.
780
+ const bySpace = new Map<SpaceId, ObjectId[]>();
781
+ for (const item of workingSet) {
782
+ const existing = bySpace.get(item.spaceId);
783
+ if (existing) {
784
+ existing.push(item.objectId);
785
+ } else {
786
+ bySpace.set(item.spaceId, [item.objectId]);
787
+ }
788
+ }
789
+
790
+ // Query children for each space.
791
+ const allChildren: { spaceId: SpaceId; objectId: ObjectId }[] = [];
792
+ for (const [spaceId, parentIds] of bySpace) {
793
+ const children = await this._runInRuntime(
794
+ this._indexEngine.queryChildren({ spaceId: [spaceId], parentIds }),
795
+ );
796
+
797
+ for (const child of children) {
798
+ allChildren.push({ spaceId, objectId: child.objectId as ObjectId });
799
+ }
800
+ }
801
+
802
+ trace.indexHits += allChildren.length;
803
+
804
+ const documentLoadStart = performance.now();
805
+ const results = await Promise.all(
806
+ allChildren.map(({ spaceId, objectId }) =>
807
+ this._loadFromDXN(DXN.fromLocalObjectId(objectId), { sourceSpaceId: spaceId }),
808
+ ),
809
+ );
810
+ trace.documentsLoaded += results.filter(isNonNullable).length;
811
+ trace.documentLoadTime += performance.now() - documentLoadStart;
812
+
813
+ newWorkingSet.push(...results.filter(isNonNullable));
814
+ trace.objectCount = newWorkingSet.length;
815
+ break;
816
+ }
817
+ }
818
+ break;
819
+ }
539
820
  default:
540
821
  throw new Error(`Unknown traversal type: ${(step.traversal as any)._tag}`);
541
822
  }
@@ -590,38 +871,300 @@ export class QueryExecutor extends Resource {
590
871
  };
591
872
  }
592
873
 
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);
874
+ private async _execOrderStep(step: QueryPlan.OrderStep, workingSet: QueryItem[]): Promise<StepExecutionResult> {
875
+ let sortedWorkingSet = [...workingSet].sort((a, b) => this._compareMultiOrder(a, b, step.order));
876
+
877
+ // Apply limit if specified on the order step.
878
+ if (step.limit !== undefined && sortedWorkingSet.length > step.limit) {
879
+ sortedWorkingSet = sortedWorkingSet.slice(0, step.limit);
880
+ }
881
+
882
+ return {
883
+ workingSet: sortedWorkingSet,
884
+ trace: {
885
+ ...ExecutionTrace.makeEmpty(),
886
+ name: 'Order',
887
+ details: JSON.stringify({ order: step.order, limit: step.limit }),
888
+ objectCount: sortedWorkingSet.length,
889
+ },
890
+ };
891
+ }
892
+
893
+ private async _execLimitStep(step: QueryPlan.LimitStep, workingSet: QueryItem[]): Promise<StepExecutionResult> {
894
+ const limitedWorkingSet = workingSet.slice(0, step.limit);
895
+
896
+ return {
897
+ workingSet: limitedWorkingSet,
898
+ trace: {
899
+ ...ExecutionTrace.makeEmpty(),
900
+ name: 'Limit',
901
+ details: JSON.stringify({ limit: step.limit }),
902
+ objectCount: limitedWorkingSet.length,
903
+ },
904
+ };
905
+ }
906
+
907
+ private _compareMultiOrder(a: QueryItem, b: QueryItem, orders: readonly QueryAST.Order[]): number {
908
+ // Short circuit for common cases.
909
+ if (orders.length === 0) {
910
+ return 0;
911
+ } else if (orders.length === 1) {
912
+ return this._compareByOrder(a, b, orders[0]);
913
+ }
914
+
915
+ for (const order of orders) {
916
+ const comparison = this._compareByOrder(a, b, order);
917
+ if (comparison !== 0) {
918
+ return comparison;
919
+ }
920
+ }
921
+ return 0;
922
+ }
923
+
924
+ private _compareByOrder(a: QueryItem, b: QueryItem, order: QueryAST.Order): number {
925
+ switch (order.kind) {
926
+ case 'natural':
927
+ return a.objectId.localeCompare(b.objectId);
928
+ case 'property': {
929
+ const comparison = this._compareByProperty(a, b, order.property);
930
+ return order.direction === 'desc' ? -comparison : comparison;
931
+ }
932
+ case 'rank': {
933
+ // Higher rank = better match. By default, descending order (best first).
934
+ const comparison = a.rank - b.rank;
935
+ return order.direction === 'desc' ? -comparison : comparison;
936
+ }
937
+ default:
938
+ // Should never reach here with proper TypeScript types.
939
+ return 0;
940
+ }
941
+ }
942
+
943
+ private _compareByProperty(a: QueryItem, b: QueryItem, property: string): number {
944
+ const aValue = QueryItem.getProperty(a, [property]);
945
+ const bValue = QueryItem.getProperty(b, [property]);
946
+
947
+ // Both null or undefined
948
+ if (aValue == null && bValue == null) {
949
+ return 0;
950
+ }
951
+
952
+ // Only a is null/undefined
953
+ if (aValue == null) {
954
+ return 1;
955
+ }
956
+
957
+ // Only b is null/undefined
958
+ if (bValue == null) {
959
+ return -1;
960
+ }
961
+
962
+ // Both strings
963
+ if (typeof aValue === 'string' && typeof bValue === 'string') {
964
+ return aValue.localeCompare(bValue);
965
+ }
966
+
967
+ // Both numbers
968
+ if (typeof aValue === 'number' && typeof bValue === 'number') {
969
+ return aValue - bValue;
970
+ }
971
+
972
+ // Both booleans
973
+ if (typeof aValue === 'boolean' && typeof bValue === 'boolean') {
974
+ return aValue === bValue ? 0 : aValue ? 1 : -1;
975
+ }
976
+
977
+ // Fallback: convert to strings and compare
978
+ return String(aValue).localeCompare(String(bValue));
979
+ }
980
+
981
+ private async _runInRuntime<T>(effect: Effect.Effect<T, unknown, SqlClient.SqlClient>): Promise<T> {
982
+ const runtimeProvider = this._runtime;
983
+ invariant(runtimeProvider, 'SQL runtime is required.');
984
+ const runtime = await runAndForwardErrors(runtimeProvider);
985
+ return await unwrapExit(await effect.pipe(Runtime.runPromiseExit(runtime)));
986
+ }
987
+
988
+ private async _queryAllFromSqlIndex(spaceIds: readonly SpaceId[]): Promise<readonly ObjectMeta[]> {
989
+ return await this._runInRuntime(this._indexEngine.queryAll({ spaceIds }));
990
+ }
991
+
992
+ private async _queryTypesFromSqlIndex(
993
+ spaceIds: readonly SpaceId[],
994
+ typeDxns: readonly string[],
995
+ inverted: boolean,
996
+ ): Promise<readonly ObjectMeta[]> {
997
+ return await this._runInRuntime(this._indexEngine.queryTypes({ spaceIds, typeDxns, inverted }));
998
+ }
999
+
1000
+ private async _queryIncomingReferencesFromSqlIndex(
1001
+ workingSet: QueryItem[],
1002
+ property: EscapedPropPath | null,
1003
+ ): Promise<readonly ObjectMeta[]> {
1004
+ const anchorDxns = workingSet.map((item) => DXN.fromLocalObjectId(item.objectId).toString());
1005
+ const rows: readonly ReverseRef[] = (
1006
+ await Promise.all(
1007
+ anchorDxns.map((targetDxn) => this._runInRuntime(this._indexEngine.queryReverseRef({ targetDxn }))),
1008
+ )
1009
+ ).flat();
1010
+
1011
+ const recordIds = rows
1012
+ .filter((row) => {
1013
+ if (property === null) {
1014
+ return true;
1015
+ }
1016
+
1017
+ const queryPath = EscapedPropPath.unescape(property);
1018
+ const rowPath = EscapedPropPath.unescape(row.propPath);
1019
+ return QueryExecutor._matchesReferencePropertyPath(rowPath, queryPath);
1020
+ })
1021
+ .map((row) => row.recordId);
1022
+
1023
+ const uniqueRecordIds = Array.from(new Set<number>(recordIds));
1024
+ return await this._runInRuntime(this._indexEngine.lookupByRecordIds(uniqueRecordIds));
1025
+ }
1026
+
1027
+ /**
1028
+ * Matches a reverse-reference row path against a query property path.
1029
+ * Allows numeric segments in the row path (array indices) that are not present in the query.
1030
+ *
1031
+ * Examples:
1032
+ * - query: ['assignee'] matches row: ['assignee'] and ['assignee', '0'].
1033
+ * - query: ['items', 'assignee'] matches row: ['items', '0', 'assignee'].
1034
+ * - query: ['a', 'b'] does NOT match row: ['a'].
1035
+ * - query: ['a'] does NOT match row: ['a', 'b'].
1036
+ */
1037
+ private static _matchesReferencePropertyPath(rowPath: readonly string[], queryPath: readonly string[]): boolean {
1038
+ const isNumericSegment = (segment: string) => /^[0-9]+$/.test(segment);
1039
+
1040
+ let i = 0; // queryPath index.
1041
+ let j = 0; // rowPath index.
1042
+
1043
+ while (i < queryPath.length && j < rowPath.length) {
1044
+ if (rowPath[j] === queryPath[i]) {
1045
+ i++;
1046
+ j++;
1047
+ continue;
1048
+ }
1049
+
1050
+ // Row may contain array indices that aren't present in the query path.
1051
+ if (isNumericSegment(rowPath[j])) {
1052
+ j++;
1053
+ continue;
1054
+ }
1055
+
1056
+ return false;
1057
+ }
1058
+
1059
+ // Must consume full query path.
1060
+ if (i !== queryPath.length) {
1061
+ return false;
1062
+ }
1063
+
1064
+ // Any remaining row segments must be numeric (array indices).
1065
+ for (; j < rowPath.length; j++) {
1066
+ if (!isNumericSegment(rowPath[j])) {
1067
+ return false;
1068
+ }
1069
+ }
1070
+
1071
+ return true;
1072
+ }
1073
+
1074
+ private async _queryRelationsFromSqlIndex(
1075
+ workingSet: QueryItem[],
1076
+ endpoint: 'source' | 'target',
1077
+ ): Promise<readonly ObjectMeta[]> {
1078
+ const anchorDxns = workingSet.map((item) => DXN.fromLocalObjectId(item.objectId).toString());
1079
+ return await this._runInRuntime(this._indexEngine.queryRelations({ endpoint, anchorDxns }));
1080
+ }
1081
+
1082
+ private async _loadDocumentsAfterSqlQuery(metas: readonly ObjectMeta[]): Promise<(QueryItem | null)[]> {
1083
+ const snapshotMap = await this._loadQueueSnapshotMap(metas);
1084
+ return await Promise.all(
1085
+ metas.map(async (meta) => {
1086
+ // Branch 1: Document-backed object.
1087
+ if (meta.documentId) {
1088
+ return this._loadFromAutomerge(meta);
1089
+ }
1090
+
1091
+ // Branch 2: Queue-backed object.
1092
+ if (meta.queueId) {
1093
+ return this._loadFromQueue(meta, snapshotMap);
1094
+ }
1095
+
1096
+ return null;
597
1097
  }),
598
1098
  );
599
1099
  }
600
1100
 
601
- private async _loadFromIndexHit(hit: FindResult): Promise<QueryItem | null> {
602
- const { objectId, documentId, spaceKey: spaceKeyInIndex } = objectPointerCodec.decode(hit.id);
1101
+ private async _loadQueueSnapshotMap(metas: readonly ObjectMeta[]): Promise<Map<number, unknown>> {
1102
+ const queueMetas = metas.filter((meta) => !meta.documentId && !!meta.queueId);
1103
+ if (queueMetas.length === 0) {
1104
+ return new Map();
1105
+ }
603
1106
 
604
- const handle = await this._automergeHost.loadDoc<DatabaseDirectory>(Context.default(), documentId as DocumentId);
605
- const doc = handle.doc();
606
- if (!doc) {
1107
+ const snapshots = await this._runInRuntime(
1108
+ this._indexEngine.querySnapshotsJSON(queueMetas.map((meta) => meta.recordId)),
1109
+ );
1110
+ return new Map(snapshots.map((s) => [s.recordId, s.snapshot]));
1111
+ }
1112
+
1113
+ /**
1114
+ * Loads a queue-backed object from an indexed snapshot (by `recordId`).
1115
+ * Returns `null` when the snapshot is missing or is not a JSON object.
1116
+ */
1117
+ private _loadFromQueue(meta: ObjectMeta, snapshotMap: Map<number, unknown>): QueryItem | null {
1118
+ const snapshot = snapshotMap.get(meta.recordId);
1119
+ if (!snapshot || typeof snapshot !== 'object') {
607
1120
  return null;
608
1121
  }
609
1122
 
610
- const spaceKey = spaceKeyInIndex ?? DatabaseDirectory.getSpaceKey(doc);
611
- if (!spaceKey) {
1123
+ return {
1124
+ objectId: meta.objectId as ObjectId,
1125
+ spaceId: meta.spaceId as SpaceId,
1126
+ queueId: meta.queueId as ObjectId,
1127
+ queueNamespace: 'data',
1128
+ documentId: null,
1129
+ doc: null,
1130
+ data: snapshot as Obj.JSON,
1131
+ rank: 1,
1132
+ };
1133
+ }
1134
+
1135
+ /**
1136
+ * Loads a document-backed object from an Automerge `DatabaseDirectory`.
1137
+ * Returns `null` if the document can't be loaded, the inline object isn't present, or if the meta does not have a
1138
+ * document id (e.g. queue-backed objects).
1139
+ */
1140
+ private async _loadFromAutomerge(meta: ObjectMeta): Promise<QueryItem | null> {
1141
+ if (!meta.documentId) {
612
1142
  return null;
613
1143
  }
614
-
615
- const object = DatabaseDirectory.getInlineObject(doc, objectId);
1144
+ const handle = await this._automergeHost.loadDoc<DatabaseDirectory>(
1145
+ Context.default(),
1146
+ meta.documentId as DocumentId,
1147
+ {
1148
+ fetchFromNetwork: true,
1149
+ },
1150
+ );
1151
+ const doc = handle.doc();
1152
+ if (!doc) {
1153
+ return null;
1154
+ }
1155
+ const object = DatabaseDirectory.getInlineObject(doc, meta.objectId);
616
1156
  if (!object) {
617
1157
  return null;
618
1158
  }
619
-
620
1159
  return {
621
- objectId,
622
- documentId: documentId as DocumentId,
623
- spaceId: await createIdFromSpaceKey(PublicKey.from(spaceKey)),
1160
+ objectId: meta.objectId,
1161
+ documentId: meta.documentId as DocumentId,
1162
+ spaceId: meta.spaceId as SpaceId,
1163
+ queueId: null,
1164
+ queueNamespace: null,
624
1165
  doc: object,
1166
+ data: null,
1167
+ rank: 1,
625
1168
  };
626
1169
  }
627
1170
 
@@ -651,7 +1194,11 @@ export class QueryExecutor extends Resource {
651
1194
  objectId: echoDxn.echoId,
652
1195
  documentId: spaceRoot.documentId,
653
1196
  spaceId,
1197
+ queueId: null,
1198
+ queueNamespace: null,
1199
+ data: null,
654
1200
  doc: inlineObject,
1201
+ rank: 1,
655
1202
  };
656
1203
  }
657
1204
 
@@ -660,7 +1207,9 @@ export class QueryExecutor extends Resource {
660
1207
  return null;
661
1208
  }
662
1209
 
663
- const handle = await this._automergeHost.loadDoc<DatabaseDirectory>(Context.default(), link as AutomergeUrl);
1210
+ const handle = await this._automergeHost.loadDoc<DatabaseDirectory>(Context.default(), link as AutomergeUrl, {
1211
+ fetchFromNetwork: true,
1212
+ });
664
1213
  const doc = handle.doc();
665
1214
  if (!doc) {
666
1215
  return null;
@@ -675,7 +1224,153 @@ export class QueryExecutor extends Resource {
675
1224
  objectId: echoDxn.echoId,
676
1225
  documentId: handle.documentId,
677
1226
  spaceId,
1227
+ queueId: null,
1228
+ queueNamespace: null,
1229
+ data: null,
678
1230
  doc: object,
1231
+ rank: 1,
679
1232
  };
680
1233
  }
1234
+
1235
+ private async _getTransitiveDeletionState(item: QueryItem, remainingDepth: number): Promise<boolean> {
1236
+ const strongDeps = [
1237
+ QueryItem.getParent(item),
1238
+ QueryItem.getRelationSource(item),
1239
+ QueryItem.getRelationTarget(item),
1240
+ ].filter((x) => x !== undefined);
1241
+
1242
+ if (strongDeps.length === 0) {
1243
+ return false;
1244
+ }
1245
+
1246
+ // TODO(dmaretskyi): This could be optimized to bail early if any of the dependencies are deleted.
1247
+ const strongDepStates = await Promise.all(
1248
+ strongDeps.map(async (dxn) => {
1249
+ const dep = await this._loadFromDXN(DXN.parse(dxn), { sourceSpaceId: item.spaceId });
1250
+ if (!dep) {
1251
+ return false;
1252
+ }
1253
+ if (QueryItem.isDeleted(dep)) {
1254
+ return true;
1255
+ }
1256
+ if (remainingDepth > 0) {
1257
+ return this._getTransitiveDeletionState(dep, remainingDepth - 1);
1258
+ }
1259
+ return false;
1260
+ }),
1261
+ );
1262
+
1263
+ return strongDepStates.some((x) => x);
1264
+ }
681
1265
  }
1266
+
1267
+ const prettyFilter = (filter: QueryAST.Filter): string => {
1268
+ switch (filter.type) {
1269
+ case 'object': {
1270
+ const parts: string[] = [];
1271
+ if (filter.typename !== null) {
1272
+ parts.push(String(filter.typename));
1273
+ }
1274
+ if (filter.id !== undefined && filter.id.length > 0) {
1275
+ parts.push(`id: [${filter.id.join(', ')}]`);
1276
+ }
1277
+ const propEntries = Object.entries(filter.props);
1278
+ if (propEntries.length > 0) {
1279
+ const propsStr = propEntries.map(([k, v]) => `${k}: ${prettyFilter(v)}`).join(', ');
1280
+ parts.push(`{ ${propsStr} }`);
1281
+ }
1282
+ if (filter.foreignKeys !== undefined && filter.foreignKeys.length > 0) {
1283
+ parts.push(`foreignKeys: [${filter.foreignKeys.map((fk) => JSON.stringify(fk)).join(', ')}]`);
1284
+ }
1285
+ return parts.length > 0 ? `Filter.type(${parts.join(', ')})` : 'Filter.everything()';
1286
+ }
1287
+ case 'compare':
1288
+ return `Filter.${filter.operator}(${JSON.stringify(filter.value)})`;
1289
+ case 'in':
1290
+ return `Filter.in([${filter.values.map((v) => JSON.stringify(v)).join(', ')}])`;
1291
+ case 'contains':
1292
+ return `Filter.contains(${JSON.stringify(filter.value)})`;
1293
+ case 'tag':
1294
+ return `Filter.tag(${JSON.stringify(filter.tag)})`;
1295
+ case 'range':
1296
+ return `Filter.range(${JSON.stringify(filter.from)}, ${JSON.stringify(filter.to)})`;
1297
+ case 'text-search':
1298
+ return filter.searchKind
1299
+ ? `Filter.textSearch(${JSON.stringify(filter.text)}, ${JSON.stringify(filter.searchKind)})`
1300
+ : `Filter.textSearch(${JSON.stringify(filter.text)})`;
1301
+ case 'not':
1302
+ return `Filter.not(${prettyFilter(filter.filter)})`;
1303
+ case 'and':
1304
+ return `Filter.and(${filter.filters.map(prettyFilter).join(', ')})`;
1305
+ case 'or':
1306
+ return `Filter.or(${filter.filters.map(prettyFilter).join(', ')})`;
1307
+ }
1308
+ };
1309
+
1310
+ const prettyQuery = (query: QueryAST.Query): string => {
1311
+ switch (query.type) {
1312
+ case 'select':
1313
+ return `Query.select(${prettyFilter(query.filter)})`;
1314
+ case 'filter':
1315
+ return `${prettyQuery(query.selection)}.select(${prettyFilter(query.filter)})`;
1316
+ case 'reference-traversal':
1317
+ return `${prettyQuery(query.anchor)}.reference(${JSON.stringify(query.property)})`;
1318
+ case 'incoming-references': {
1319
+ const args: string[] = [];
1320
+ if (query.typename !== null) {
1321
+ args.push(String(query.typename));
1322
+ }
1323
+ if (query.property !== null) {
1324
+ args.push(JSON.stringify(query.property));
1325
+ }
1326
+ return `${prettyQuery(query.anchor)}.referencedBy(${args.join(', ')})`;
1327
+ }
1328
+ case 'relation': {
1329
+ const method =
1330
+ query.direction === 'outgoing' ? 'sourceOf' : query.direction === 'incoming' ? 'targetOf' : 'relationOf';
1331
+ const filterStr = query.filter !== undefined ? prettyFilter(query.filter) : '';
1332
+ return `${prettyQuery(query.anchor)}.${method}(${filterStr})`;
1333
+ }
1334
+ case 'relation-traversal':
1335
+ return `${prettyQuery(query.anchor)}.${query.direction}()`;
1336
+ case 'hierarchy-traversal':
1337
+ return query.direction === 'to-parent'
1338
+ ? `${prettyQuery(query.anchor)}.parent()`
1339
+ : `${prettyQuery(query.anchor)}.children()`;
1340
+ case 'union':
1341
+ return `Query.all(${query.queries.map(prettyQuery).join(', ')})`;
1342
+ case 'set-difference':
1343
+ return `Query.without(${prettyQuery(query.source)}, ${prettyQuery(query.exclude)})`;
1344
+ case 'order': {
1345
+ const orders = query.order.map((o) => {
1346
+ if (o.kind === 'natural') {
1347
+ return 'Order.natural()';
1348
+ } else if (o.kind === 'rank') {
1349
+ return `Order.rank(${JSON.stringify(o.direction)})`;
1350
+ } else {
1351
+ return `Order.property(${JSON.stringify(o.property)}, ${JSON.stringify(o.direction)})`;
1352
+ }
1353
+ });
1354
+ return `${prettyQuery(query.query)}.orderBy(${orders.join(', ')})`;
1355
+ }
1356
+ case 'options': {
1357
+ const opts = query.options;
1358
+ const parts: string[] = [];
1359
+ if (opts.spaceIds !== undefined) {
1360
+ parts.push(`spaceIds: [${opts.spaceIds.map((s) => JSON.stringify(s)).join(', ')}]`);
1361
+ }
1362
+ if (opts.queues !== undefined) {
1363
+ parts.push(`queues: [${opts.queues.map(String).join(', ')}]`);
1364
+ }
1365
+ if (opts.deleted !== undefined) {
1366
+ parts.push(`deleted: ${JSON.stringify(opts.deleted)}`);
1367
+ }
1368
+ if (opts.allQueuesFromSpaces !== undefined) {
1369
+ parts.push(`allQueuesFromSpaces: ${opts.allQueuesFromSpaces}`);
1370
+ }
1371
+ return `${prettyQuery(query.query)}.options({ ${parts.join(', ')} })`;
1372
+ }
1373
+ case 'limit':
1374
+ return `${prettyQuery(query.query)}.limit(${query.limit})`;
1375
+ }
1376
+ };