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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/{browser/chunk-WJJXJTNS.mjs → neutral/chunk-46DCRDA7.mjs} +44 -13
  4. package/dist/lib/neutral/chunk-46DCRDA7.mjs.map +7 -0
  5. package/dist/lib/{browser/chunk-FR6GJ4S4.mjs → neutral/chunk-LR7UFVQX.mjs} +145 -618
  6. package/dist/lib/neutral/chunk-LR7UFVQX.mjs.map +7 -0
  7. package/dist/lib/{browser → neutral}/filter/index.mjs +1 -2
  8. package/dist/lib/neutral/index.mjs +7332 -0
  9. package/dist/lib/neutral/index.mjs.map +7 -0
  10. package/dist/lib/neutral/meta.json +1 -0
  11. package/dist/lib/{browser → neutral}/testing/index.mjs +49 -292
  12. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  13. package/dist/types/src/automerge/automerge-host-subduction.test.d.ts +2 -0
  14. package/dist/types/src/automerge/automerge-host-subduction.test.d.ts.map +1 -0
  15. package/dist/types/src/automerge/automerge-host.d.ts +105 -27
  16. package/dist/types/src/automerge/automerge-host.d.ts.map +1 -1
  17. package/dist/types/src/automerge/automerge-repo-subduction.test.d.ts +2 -0
  18. package/dist/types/src/automerge/automerge-repo-subduction.test.d.ts.map +1 -0
  19. package/dist/types/src/automerge/automerge-repo.test.d.ts +1 -1
  20. package/dist/types/src/automerge/automerge-repo.test.d.ts.map +1 -1
  21. package/dist/types/src/automerge/automerge-subduction.test.d.ts +2 -0
  22. package/dist/types/src/automerge/automerge-subduction.test.d.ts.map +1 -0
  23. package/dist/types/src/automerge/collection-synchronizer.d.ts +22 -2
  24. package/dist/types/src/automerge/collection-synchronizer.d.ts.map +1 -1
  25. package/dist/types/src/automerge/echo-data-monitor.d.ts.map +1 -1
  26. package/dist/types/src/automerge/echo-network-adapter.d.ts +17 -11
  27. package/dist/types/src/automerge/echo-network-adapter.d.ts.map +1 -1
  28. package/dist/types/src/automerge/echo-replicator.d.ts +28 -16
  29. package/dist/types/src/automerge/echo-replicator.d.ts.map +1 -1
  30. package/dist/types/src/automerge/errors.d.ts +35 -0
  31. package/dist/types/src/automerge/errors.d.ts.map +1 -0
  32. package/dist/types/src/automerge/handle-state.d.ts +52 -0
  33. package/dist/types/src/automerge/handle-state.d.ts.map +1 -0
  34. package/dist/types/src/automerge/heads-store.d.ts +9 -2
  35. package/dist/types/src/automerge/heads-store.d.ts.map +1 -1
  36. package/dist/types/src/automerge/index.d.ts +2 -0
  37. package/dist/types/src/automerge/index.d.ts.map +1 -1
  38. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts +10 -4
  39. package/dist/types/src/automerge/leveldb-storage-adapter.d.ts.map +1 -1
  40. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts +8 -8
  41. package/dist/types/src/automerge/mesh-echo-replicator-connection.d.ts.map +1 -1
  42. package/dist/types/src/automerge/mesh-echo-replicator.d.ts +6 -5
  43. package/dist/types/src/automerge/mesh-echo-replicator.d.ts.map +1 -1
  44. package/dist/types/src/automerge/network-protocol.d.ts.map +1 -1
  45. package/dist/types/src/automerge/space-collection.d.ts.map +1 -1
  46. package/dist/types/src/common/feeds.d.ts.map +1 -1
  47. package/dist/types/src/common/space-id.d.ts.map +1 -1
  48. package/dist/types/src/db-host/automerge-data-source.d.ts +35 -0
  49. package/dist/types/src/db-host/automerge-data-source.d.ts.map +1 -0
  50. package/dist/types/src/db-host/automerge-data-source.test.d.ts +2 -0
  51. package/dist/types/src/db-host/automerge-data-source.test.d.ts.map +1 -0
  52. package/dist/types/src/db-host/automerge-metrics.d.ts.map +1 -1
  53. package/dist/types/src/db-host/data-service.d.ts +12 -6
  54. package/dist/types/src/db-host/data-service.d.ts.map +1 -1
  55. package/dist/types/src/db-host/database-root.d.ts +9 -3
  56. package/dist/types/src/db-host/database-root.d.ts.map +1 -1
  57. package/dist/types/src/db-host/documents-synchronizer.d.ts +30 -8
  58. package/dist/types/src/db-host/documents-synchronizer.d.ts.map +1 -1
  59. package/dist/types/src/db-host/echo-host.d.ts +74 -28
  60. package/dist/types/src/db-host/echo-host.d.ts.map +1 -1
  61. package/dist/types/src/db-host/index.d.ts +2 -0
  62. package/dist/types/src/db-host/index.d.ts.map +1 -1
  63. package/dist/types/src/db-host/invalidation-hint.d.ts +19 -0
  64. package/dist/types/src/db-host/invalidation-hint.d.ts.map +1 -0
  65. package/dist/types/src/db-host/local-queue-service.d.ts +19 -0
  66. package/dist/types/src/db-host/local-queue-service.d.ts.map +1 -0
  67. package/dist/types/src/db-host/query-invalidation.test.d.ts +2 -0
  68. package/dist/types/src/db-host/query-invalidation.test.d.ts.map +1 -0
  69. package/dist/types/src/db-host/query-service.d.ts +18 -10
  70. package/dist/types/src/db-host/query-service.d.ts.map +1 -1
  71. package/dist/types/src/db-host/queue-data-source.d.ts +38 -0
  72. package/dist/types/src/db-host/queue-data-source.d.ts.map +1 -0
  73. package/dist/types/src/db-host/queue-service.test.d.ts +2 -0
  74. package/dist/types/src/db-host/queue-service.test.d.ts.map +1 -0
  75. package/dist/types/src/db-host/space-state-manager.d.ts +3 -2
  76. package/dist/types/src/db-host/space-state-manager.d.ts.map +1 -1
  77. package/dist/types/src/db-host/stub.d.ts +11 -0
  78. package/dist/types/src/db-host/stub.d.ts.map +1 -0
  79. package/dist/types/src/edge/echo-edge-replicator.d.ts +7 -6
  80. package/dist/types/src/edge/echo-edge-replicator.d.ts.map +1 -1
  81. package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts +39 -0
  82. package/dist/types/src/edge/echo-edge-subduction-replicator.d.ts.map +1 -0
  83. package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts +2 -0
  84. package/dist/types/src/edge/echo-edge-subduction-replicator.test.d.ts.map +1 -0
  85. package/dist/types/src/edge/index.d.ts +1 -0
  86. package/dist/types/src/edge/index.d.ts.map +1 -1
  87. package/dist/types/src/edge/inflight-request-limiter.d.ts.map +1 -1
  88. package/dist/types/src/filter/filter-match.d.ts +1 -1
  89. package/dist/types/src/filter/filter-match.d.ts.map +1 -1
  90. package/dist/types/src/metadata/metadata-store.d.ts.map +1 -1
  91. package/dist/types/src/pipeline/message-selector.d.ts.map +1 -1
  92. package/dist/types/src/pipeline/pipeline.d.ts +2 -2
  93. package/dist/types/src/pipeline/pipeline.d.ts.map +1 -1
  94. package/dist/types/src/pipeline/timeframe-clock.d.ts.map +1 -1
  95. package/dist/types/src/query/errors.d.ts +16 -20
  96. package/dist/types/src/query/errors.d.ts.map +1 -1
  97. package/dist/types/src/query/plan.d.ts +59 -8
  98. package/dist/types/src/query/plan.d.ts.map +1 -1
  99. package/dist/types/src/query/query-executor.d.ts +55 -12
  100. package/dist/types/src/query/query-executor.d.ts.map +1 -1
  101. package/dist/types/src/query/query-planner.d.ts +10 -1
  102. package/dist/types/src/query/query-planner.d.ts.map +1 -1
  103. package/dist/types/src/space/admission-discovery-extension.d.ts.map +1 -1
  104. package/dist/types/src/space/auth.d.ts +3 -3
  105. package/dist/types/src/space/auth.d.ts.map +1 -1
  106. package/dist/types/src/space/control-pipeline.d.ts +4 -3
  107. package/dist/types/src/space/control-pipeline.d.ts.map +1 -1
  108. package/dist/types/src/space/space-manager.d.ts +7 -7
  109. package/dist/types/src/space/space-manager.d.ts.map +1 -1
  110. package/dist/types/src/space/space-protocol.d.ts +9 -8
  111. package/dist/types/src/space/space-protocol.d.ts.map +1 -1
  112. package/dist/types/src/space/space.d.ts +5 -5
  113. package/dist/types/src/space/space.d.ts.map +1 -1
  114. package/dist/types/src/testing/change-metadata.d.ts.map +1 -1
  115. package/dist/types/src/testing/index.d.ts +0 -2
  116. package/dist/types/src/testing/index.d.ts.map +1 -1
  117. package/dist/types/src/testing/test-agent-builder.d.ts.map +1 -1
  118. package/dist/types/src/testing/test-feed-builder.d.ts.map +1 -1
  119. package/dist/types/src/testing/test-network-adapter.d.ts +1 -1
  120. package/dist/types/src/testing/test-network-adapter.d.ts.map +1 -1
  121. package/dist/types/src/testing/test-replicator.d.ts +13 -13
  122. package/dist/types/src/testing/test-replicator.d.ts.map +1 -1
  123. package/dist/types/src/util.d.ts.map +1 -1
  124. package/dist/types/tsconfig.tsbuildinfo +1 -1
  125. package/package.json +56 -54
  126. package/src/automerge/automerge-host-subduction.test.ts +263 -0
  127. package/src/automerge/automerge-host.test.ts +145 -32
  128. package/src/automerge/automerge-host.ts +442 -163
  129. package/src/automerge/automerge-repo-subduction.test.ts +1054 -0
  130. package/src/automerge/automerge-repo.test.ts +284 -77
  131. package/src/automerge/automerge-subduction.test.ts +330 -0
  132. package/src/automerge/collection-synchronizer.test.ts +96 -16
  133. package/src/automerge/collection-synchronizer.ts +125 -26
  134. package/src/automerge/echo-network-adapter.test.ts +2 -1
  135. package/src/automerge/echo-network-adapter.ts +44 -32
  136. package/src/automerge/echo-replicator.ts +30 -16
  137. package/src/automerge/errors.ts +10 -0
  138. package/src/automerge/handle-state.ts +62 -0
  139. package/src/automerge/heads-store.ts +42 -3
  140. package/src/automerge/index.ts +2 -0
  141. package/src/automerge/leveldb-storage-adapter.ts +70 -4
  142. package/src/automerge/mesh-echo-replicator-connection.ts +8 -8
  143. package/src/automerge/mesh-echo-replicator.ts +14 -11
  144. package/src/db-host/automerge-data-source.test.ts +299 -0
  145. package/src/db-host/automerge-data-source.ts +187 -0
  146. package/src/db-host/data-service.ts +27 -10
  147. package/src/db-host/database-root.ts +19 -7
  148. package/src/db-host/documents-synchronizer.test.ts +145 -14
  149. package/src/db-host/documents-synchronizer.ts +158 -59
  150. package/src/db-host/echo-host.ts +376 -90
  151. package/src/db-host/index.ts +2 -0
  152. package/src/db-host/invalidation-hint.ts +50 -0
  153. package/src/db-host/local-queue-service.ts +127 -0
  154. package/src/db-host/query-invalidation.test.ts +270 -0
  155. package/src/db-host/query-service.ts +108 -111
  156. package/src/db-host/queue-data-source.ts +160 -0
  157. package/src/db-host/queue-service.test.ts +145 -0
  158. package/src/db-host/space-state-manager.ts +11 -9
  159. package/src/db-host/stub.ts +33 -0
  160. package/src/edge/echo-edge-replicator.test.ts +9 -9
  161. package/src/edge/echo-edge-replicator.ts +50 -42
  162. package/src/edge/echo-edge-subduction-replicator.test.ts +158 -0
  163. package/src/edge/echo-edge-subduction-replicator.ts +377 -0
  164. package/src/edge/index.ts +1 -0
  165. package/src/filter/filter-match.test.ts +41 -25
  166. package/src/filter/filter-match.ts +75 -20
  167. package/src/metadata/metadata-store.ts +8 -5
  168. package/src/pipeline/pipeline-stress.test.ts +1 -3
  169. package/src/pipeline/pipeline.test.ts +0 -1
  170. package/src/pipeline/pipeline.ts +2 -3
  171. package/src/query/errors.ts +2 -2
  172. package/src/query/plan.ts +73 -9
  173. package/src/query/query-executor.ts +1064 -172
  174. package/src/query/query-planner.test.ts +854 -86
  175. package/src/query/query-planner.ts +444 -38
  176. package/src/space/admission-discovery-extension.ts +1 -1
  177. package/src/space/auth.ts +6 -6
  178. package/src/space/control-pipeline.test.ts +2 -2
  179. package/src/space/control-pipeline.ts +7 -7
  180. package/src/space/space-manager.ts +12 -17
  181. package/src/space/space-protocol.browser.test.ts +13 -12
  182. package/src/space/space-protocol.test.ts +13 -13
  183. package/src/space/space-protocol.ts +16 -15
  184. package/src/space/space.test.ts +2 -2
  185. package/src/space/space.ts +9 -10
  186. package/src/testing/index.ts +0 -2
  187. package/src/testing/test-agent-builder.ts +0 -1
  188. package/src/testing/test-network-adapter.ts +24 -3
  189. package/src/testing/test-replicator.ts +15 -20
  190. package/src/util.ts +7 -3
  191. package/dist/lib/browser/chunk-CGS2ULMK.mjs +0 -11
  192. package/dist/lib/browser/chunk-CGS2ULMK.mjs.map +0 -7
  193. package/dist/lib/browser/chunk-FR6GJ4S4.mjs.map +0 -7
  194. package/dist/lib/browser/chunk-WJJXJTNS.mjs.map +0 -7
  195. package/dist/lib/browser/index.mjs +0 -5492
  196. package/dist/lib/browser/index.mjs.map +0 -7
  197. package/dist/lib/browser/meta.json +0 -1
  198. package/dist/lib/browser/testing/index.mjs.map +0 -7
  199. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
  200. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
  201. package/dist/lib/node-esm/chunk-JQCCVDLT.mjs +0 -2144
  202. package/dist/lib/node-esm/chunk-JQCCVDLT.mjs.map +0 -7
  203. package/dist/lib/node-esm/chunk-LEQ77KAT.mjs +0 -240
  204. package/dist/lib/node-esm/chunk-LEQ77KAT.mjs.map +0 -7
  205. package/dist/lib/node-esm/filter/index.mjs +0 -13
  206. package/dist/lib/node-esm/filter/index.mjs.map +0 -7
  207. package/dist/lib/node-esm/index.mjs +0 -5492
  208. package/dist/lib/node-esm/index.mjs.map +0 -7
  209. package/dist/lib/node-esm/meta.json +0 -1
  210. package/dist/lib/node-esm/testing/index.mjs +0 -764
  211. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  212. package/dist/types/src/db-host/documents-iterator.d.ts +0 -7
  213. package/dist/types/src/db-host/documents-iterator.d.ts.map +0 -1
  214. package/dist/types/src/testing/test-data.d.ts +0 -18
  215. package/dist/types/src/testing/test-data.d.ts.map +0 -1
  216. package/dist/types/src/testing/test-schema.d.ts +0 -37
  217. package/dist/types/src/testing/test-schema.d.ts.map +0 -1
  218. package/src/db-host/documents-iterator.ts +0 -74
  219. package/src/testing/test-data.ts +0 -127
  220. package/src/testing/test-schema.ts +0 -53
  221. /package/dist/lib/{browser → neutral}/filter/index.mjs.map +0 -0
@@ -2,14 +2,31 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { Order } from '@dxos/echo';
6
- import { type QueryAST } from '@dxos/echo-protocol';
5
+ import { Order, Query } from '@dxos/echo';
6
+ import { QueryAST } from '@dxos/echo-protocol';
7
7
  import { invariant } from '@dxos/invariant';
8
- import type { DXN, SpaceId } from '@dxos/keys';
8
+ import type { DXN } from '@dxos/keys';
9
9
 
10
10
  import { QueryError } from './errors';
11
11
  import { QueryPlan } from './plan';
12
12
 
13
+ /**
14
+ * Creates a QueryError with "Query too complex" message and includes the prettified query in the context.
15
+ */
16
+ const queryTooComplexError = (query: QueryAST.Query | null): QueryError => {
17
+ if (query === null) {
18
+ return new QueryError({
19
+ message: 'Query too complex',
20
+ context: { query: null },
21
+ });
22
+ }
23
+ const prettyQuery = Query.pretty(Query.fromAst(query));
24
+ return new QueryError({
25
+ message: `Query too complex: ${prettyQuery}`,
26
+ context: { query, prettyQuery },
27
+ });
28
+ };
29
+
13
30
  export type QueryPlannerOptions = {
14
31
  defaultTextSearchKind: QueryPlan.TextSearchKind;
15
32
  };
@@ -33,10 +50,12 @@ export class QueryPlanner {
33
50
  }
34
51
 
35
52
  createPlan(query: QueryAST.Query): QueryPlan.Plan {
53
+ this._validateQueryScoped(query);
36
54
  let plan = this._generate(query, { ...DEFAULT_CONTEXT, originalQuery: query });
37
55
  plan = this._optimizeEmptyFilters(plan);
38
56
  plan = this._optimizeSoloUnions(plan);
39
57
  plan = this._ensureOrderStep(plan);
58
+ plan = this._optimizeLimits(plan);
40
59
  return plan;
41
60
  }
42
61
 
@@ -56,12 +75,18 @@ export class QueryPlanner {
56
75
  return this._generateRelationTraversalClause(query, context);
57
76
  case 'reference-traversal':
58
77
  return this._generateReferenceTraversalClause(query, context);
78
+ case 'hierarchy-traversal':
79
+ return this._generateHierarchyTraversalClause(query, context);
59
80
  case 'union':
60
81
  return this._generateUnionClause(query, context);
61
82
  case 'set-difference':
62
83
  return this._generateSetDifferenceClause(query, context);
63
84
  case 'order':
64
85
  return this._generateOrderClause(query, context);
86
+ case 'limit':
87
+ return this._generateLimitClause(query, context);
88
+ case 'from':
89
+ return this._generateFromClause(query, context);
65
90
  default:
66
91
  throw new QueryError({
67
92
  message: `Unsupported query type: ${(query as any).type}`,
@@ -74,15 +99,25 @@ export class QueryPlanner {
74
99
  const newContext = {
75
100
  ...context,
76
101
  };
77
- if (query.options.spaceIds) {
78
- newContext.selectionSpaces = query.options.spaceIds as readonly SpaceId[];
79
- }
80
102
  if (query.options.deleted) {
81
103
  newContext.deletedHandling = query.options.deleted;
82
104
  }
83
105
  return this._generate(query.query, newContext);
84
106
  }
85
107
 
108
+ private _generateFromClause(query: QueryAST.QueryFromClause, context: GenerationContext): QueryPlan.Plan {
109
+ if (query.from._tag === 'scope') {
110
+ return this._generate(query.query, { ...context, scope: query.from.scope });
111
+ }
112
+
113
+ // Subquery from: flatten by rewriting the outer query's leaf select into a filter on the subquery.
114
+ const subquery = query.from.query;
115
+ const rewritten = QueryAST.map(query.query, (node) =>
116
+ node.type === 'select' ? { type: 'filter', selection: subquery, filter: node.filter } : node,
117
+ );
118
+ return this._generate(rewritten, context);
119
+ }
120
+
86
121
  private _generateSelectClause(query: QueryAST.QuerySelectClause, context: GenerationContext): QueryPlan.Plan {
87
122
  return this._generateSelectionFromFilter(query.filter, context);
88
123
  }
@@ -108,10 +143,7 @@ export class QueryPlanner {
108
143
  ]);
109
144
  }
110
145
  if (context.selectionInverted) {
111
- throw new QueryError({
112
- message: 'Query too complex',
113
- context: { query: context.originalQuery },
114
- });
146
+ throw queryTooComplexError(context.originalQuery);
115
147
  }
116
148
 
117
149
  // Try to utilize indexes during selection, prioritizing selecting by id, then by typename.
@@ -120,7 +152,7 @@ export class QueryPlanner {
120
152
  return QueryPlan.Plan.make([
121
153
  {
122
154
  _tag: 'SelectStep',
123
- spaces: context.selectionSpaces,
155
+ scope: context.scope,
124
156
  selector: {
125
157
  _tag: 'IdSelector',
126
158
  objectIds: filter.id,
@@ -136,7 +168,7 @@ export class QueryPlanner {
136
168
  return QueryPlan.Plan.make([
137
169
  {
138
170
  _tag: 'SelectStep',
139
- spaces: context.selectionSpaces,
171
+ scope: context.scope,
140
172
  selector: {
141
173
  _tag: 'TypeSelector',
142
174
  typename: [filter.typename as DXN.String],
@@ -144,16 +176,17 @@ export class QueryPlanner {
144
176
  },
145
177
  },
146
178
  ...this._generateDeletedHandlingSteps(context),
179
+ // TODO(dmaretskyi): Normally we could skip filtering by typename here, but since the index does not separate schema versions, we need to do additional filter to only select the correct version.
147
180
  {
148
181
  _tag: 'FilterStep',
149
- filter: { ...filter, typename: null },
182
+ filter: { ...filter },
150
183
  },
151
184
  ]);
152
185
  } else {
153
186
  return QueryPlan.Plan.make([
154
187
  {
155
188
  _tag: 'SelectStep',
156
- spaces: context.selectionSpaces,
189
+ scope: context.scope,
157
190
  selector: {
158
191
  _tag: 'WildcardSelector',
159
192
  },
@@ -172,7 +205,7 @@ export class QueryPlanner {
172
205
  return QueryPlan.Plan.make([
173
206
  {
174
207
  _tag: 'SelectStep',
175
- spaces: context.selectionSpaces,
208
+ scope: context.scope,
176
209
  selector: {
177
210
  _tag: 'WildcardSelector',
178
211
  },
@@ -190,24 +223,62 @@ export class QueryPlanner {
190
223
  return QueryPlan.Plan.make([
191
224
  {
192
225
  _tag: 'SelectStep',
193
- spaces: context.selectionSpaces,
226
+ scope: context.scope,
194
227
  selector: {
195
228
  _tag: 'TextSelector',
196
229
  text: filter.text,
197
230
  searchKind: filter.searchKind ?? this._options.defaultTextSearchKind,
231
+ typename: null,
198
232
  },
199
233
  },
200
234
  ...this._generateDeletedHandlingSteps(context),
201
235
  ]);
202
236
  }
203
237
 
238
+ // Timestamp
239
+ case 'timestamp': {
240
+ if (context.selectionInverted) {
241
+ throw new QueryError({
242
+ message:
243
+ 'Negated timestamp filters are not supported. Use Filter.updated/Filter.created with the inverted range instead.',
244
+ context: { query: context.originalQuery },
245
+ });
246
+ }
247
+ return QueryPlan.Plan.make([
248
+ {
249
+ _tag: 'SelectStep',
250
+ scope: context.scope,
251
+ selector: _timestampFilterToSelector(filter),
252
+ },
253
+ ...this._generateDeletedHandlingSteps(context),
254
+ ]);
255
+ }
256
+
257
+ // ChildOf
258
+ case 'child-of': {
259
+ return QueryPlan.Plan.make([
260
+ {
261
+ _tag: 'SelectStep',
262
+ scope: context.scope,
263
+ selector: {
264
+ _tag: 'WildcardSelector',
265
+ },
266
+ },
267
+ ...this._generateDeletedHandlingSteps(context),
268
+ {
269
+ _tag: 'FilterStep',
270
+ filter: { ...filter },
271
+ },
272
+ ]);
273
+ }
274
+
204
275
  // Compare
205
276
  case 'compare':
206
- throw new QueryError({ message: 'Query too complex', context: { query: context.originalQuery } });
277
+ throw queryTooComplexError(context.originalQuery);
207
278
  case 'in':
208
- throw new QueryError({ message: 'Query too complex', context: { query: context.originalQuery } });
279
+ throw queryTooComplexError(context.originalQuery);
209
280
  case 'range':
210
- throw new QueryError({ message: 'Query too complex', context: { query: context.originalQuery } });
281
+ throw queryTooComplexError(context.originalQuery);
211
282
 
212
283
  // Boolean
213
284
  case 'not':
@@ -215,8 +286,124 @@ export class QueryPlanner {
215
286
  ...context,
216
287
  selectionInverted: !context.selectionInverted,
217
288
  });
218
- case 'and':
219
- throw new QueryError({ message: 'Query too complex', context: { query: context.originalQuery } });
289
+ case 'and': {
290
+ const flatFilters = _flattenAnd(filter.filters);
291
+ const timestampFilters = flatFilters.filter((f): f is QueryAST.FilterTimestamp => f.type === 'timestamp');
292
+ const childOfFilters = flatFilters.filter((f): f is QueryAST.FilterChildOf => f.type === 'child-of');
293
+ const otherFilters = flatFilters.filter((f) => f.type !== 'timestamp' && f.type !== 'child-of');
294
+
295
+ if (timestampFilters.length > 0 && context.selectionInverted) {
296
+ throw new QueryError({
297
+ message:
298
+ 'Negated timestamp filters are not supported. Use Filter.updated/Filter.created with the inverted range instead.',
299
+ context: { query: context.originalQuery },
300
+ });
301
+ }
302
+
303
+ if (timestampFilters.length > 0 && otherFilters.length <= 1 && childOfFilters.length === 0) {
304
+ const innerFilter = otherFilters[0];
305
+ const innerPlan = innerFilter
306
+ ? this._generateSelectionFromFilter(innerFilter, context)
307
+ : QueryPlan.Plan.make([
308
+ {
309
+ _tag: 'SelectStep',
310
+ scope: context.scope,
311
+ selector: { _tag: 'WildcardSelector' },
312
+ },
313
+ ...this._generateDeletedHandlingSteps(context),
314
+ ]);
315
+
316
+ const selectIdx = innerPlan.steps.findIndex((s) => s._tag === 'SelectStep');
317
+ if (selectIdx !== -1) {
318
+ const newSteps = [...innerPlan.steps];
319
+ newSteps.splice(selectIdx + 1, 0, {
320
+ _tag: 'FilterStep',
321
+ filter: { type: 'and', filters: timestampFilters },
322
+ });
323
+ return QueryPlan.Plan.make(newSteps);
324
+ }
325
+
326
+ const selector = _mergeTimestampSelectors(timestampFilters.map(_timestampFilterToSelector));
327
+ return QueryPlan.Plan.make([
328
+ {
329
+ _tag: 'SelectStep',
330
+ scope: context.scope,
331
+ selector,
332
+ },
333
+ ...this._generateDeletedHandlingSteps(context),
334
+ ]);
335
+ }
336
+
337
+ if (timestampFilters.length > 0 && childOfFilters.length === 0) {
338
+ throw new QueryError({
339
+ message:
340
+ 'Timestamp filters can only be combined with a single type or property filter via AND. Split complex filters into a subquery.',
341
+ context: { query: context.originalQuery },
342
+ });
343
+ }
344
+
345
+ if (childOfFilters.length > 0) {
346
+ const remainingFilters = flatFilters.filter((f) => f.type !== 'child-of');
347
+ const innerPlan =
348
+ remainingFilters.length === 1
349
+ ? this._generateSelectionFromFilter(remainingFilters[0], context)
350
+ : remainingFilters.length > 1
351
+ ? this._generateSelectionFromFilter({ type: 'and', filters: remainingFilters }, context)
352
+ : QueryPlan.Plan.make([
353
+ {
354
+ _tag: 'SelectStep',
355
+ scope: context.scope,
356
+ selector: { _tag: 'WildcardSelector' },
357
+ },
358
+ ...this._generateDeletedHandlingSteps(context),
359
+ ]);
360
+
361
+ const childOfSteps: QueryPlan.Step[] = childOfFilters.map((f) => ({
362
+ _tag: 'FilterStep' as const,
363
+ filter: f,
364
+ }));
365
+
366
+ return QueryPlan.Plan.make([...innerPlan.steps, ...childOfSteps]);
367
+ }
368
+
369
+ // Simple AND: all filters are root-executable against in-memory objects after a wildcard select.
370
+ // Supports combining true root selectors (`object`, `tag`) plus negations / unions of those.
371
+ // Property-level filters (`compare`, `in`, `range`, `contains`) are intentionally excluded because
372
+ // `filterMatchObject` only handles them as nested predicates inside an `object` filter, not at the root.
373
+ const isRootExecutable = (filter: QueryAST.Filter): boolean => {
374
+ switch (filter.type) {
375
+ case 'object':
376
+ case 'tag':
377
+ return true;
378
+ case 'not':
379
+ return isRootExecutable(filter.filter);
380
+ case 'or':
381
+ return filter.filters.every(isRootExecutable);
382
+ default:
383
+ return false;
384
+ }
385
+ };
386
+ if (flatFilters.every(isRootExecutable)) {
387
+ const innerFilter: QueryAST.Filter = { type: 'and', filters: flatFilters };
388
+ const plannedFilter: QueryAST.Filter = context.selectionInverted
389
+ ? { type: 'not', filter: innerFilter }
390
+ : innerFilter;
391
+ return QueryPlan.Plan.make([
392
+ {
393
+ _tag: 'SelectStep',
394
+ scope: context.scope,
395
+ selector: { _tag: 'WildcardSelector' },
396
+ },
397
+ ...this._generateDeletedHandlingSteps(context),
398
+ {
399
+ _tag: 'FilterStep',
400
+ filter: plannedFilter,
401
+ },
402
+ ]);
403
+ }
404
+
405
+ throw queryTooComplexError(context.originalQuery);
406
+ }
220
407
  case 'or':
221
408
  // Optimized case
222
409
  if (filter.filters.every(isTrivialTypenameFilter)) {
@@ -228,7 +415,7 @@ export class QueryPlanner {
228
415
  return QueryPlan.Plan.make([
229
416
  {
230
417
  _tag: 'SelectStep',
231
- spaces: context.selectionSpaces,
418
+ scope: context.scope,
232
419
  selector: {
233
420
  _tag: 'TypeSelector',
234
421
  typename: typenames as DXN.String[],
@@ -236,9 +423,13 @@ export class QueryPlanner {
236
423
  },
237
424
  },
238
425
  ...this._generateDeletedHandlingSteps(context),
426
+ {
427
+ _tag: 'FilterStep',
428
+ filter: { ...filter },
429
+ },
239
430
  ]);
240
431
  } else {
241
- throw new QueryError({ message: 'Query too complex', context: { query: context.originalQuery } });
432
+ throw queryTooComplexError(context.originalQuery);
242
433
  }
243
434
 
244
435
  default:
@@ -321,7 +512,7 @@ export class QueryPlanner {
321
512
  traversal: {
322
513
  _tag: 'ReferenceTraversal',
323
514
  direction: 'incoming',
324
- property: query.property,
515
+ property: query.property ?? null,
325
516
  },
326
517
  },
327
518
  ...this._generateDeletedHandlingSteps(context),
@@ -427,6 +618,38 @@ export class QueryPlanner {
427
618
  ]);
428
619
  }
429
620
 
621
+ private _generateHierarchyTraversalClause(
622
+ query: QueryAST.QueryHierarchyTraversalClause,
623
+ context: GenerationContext,
624
+ ): QueryPlan.Plan {
625
+ return QueryPlan.Plan.make([
626
+ ...this._generate(query.anchor, context).steps,
627
+ {
628
+ _tag: 'TraverseStep',
629
+ traversal: {
630
+ _tag: 'HierarchyTraversal',
631
+ direction: query.direction,
632
+ },
633
+ },
634
+ ...this._generateDeletedHandlingSteps(context),
635
+ ]);
636
+ }
637
+
638
+ private _validateQueryScoped(query: QueryAST.Query): void {
639
+ let hasScope = false;
640
+ QueryAST.visit(query, (node) => {
641
+ if (node.type === 'from') {
642
+ hasScope = true;
643
+ }
644
+ });
645
+ if (!hasScope) {
646
+ throw new QueryError({
647
+ message: 'Query must be scoped with a from() clause',
648
+ context: { query },
649
+ });
650
+ }
651
+ }
652
+
430
653
  /**
431
654
  * Removes filter steps that have no predicates.
432
655
  */
@@ -471,29 +694,164 @@ export class QueryPlanner {
471
694
  ]);
472
695
  }
473
696
 
697
+ private _generateLimitClause(query: QueryAST.QueryLimitClause, context: GenerationContext): QueryPlan.Plan {
698
+ return QueryPlan.Plan.make([
699
+ ...this._generate(query.query, context).steps,
700
+ {
701
+ _tag: 'LimitStep',
702
+ limit: query.limit,
703
+ },
704
+ ]);
705
+ }
706
+
474
707
  // After complete plan is built, inspect it from the end:
475
708
  // - Walk backwards until hitting an object set changer.
476
709
  // - If an order step is found, skip.
477
- // - Otherwise append natural order to the end.
710
+ // - Otherwise insert natural order before any limit steps (since limit should be applied after ordering).
711
+ // This method is recursive and also processes sub-plans in unions and set differences.
478
712
  private _ensureOrderStep(plan: QueryPlan.Plan): QueryPlan.Plan {
713
+ // First, recursively process any sub-plans.
714
+ const processedSteps = plan.steps.map((step): QueryPlan.Step => {
715
+ if (step._tag === 'UnionStep') {
716
+ return {
717
+ _tag: 'UnionStep',
718
+ plans: step.plans.map((subPlan) => this._ensureOrderStep(subPlan)),
719
+ };
720
+ } else if (step._tag === 'SetDifferenceStep') {
721
+ return {
722
+ _tag: 'SetDifferenceStep',
723
+ source: this._ensureOrderStep(step.source),
724
+ exclude: this._ensureOrderStep(step.exclude),
725
+ };
726
+ }
727
+ return step;
728
+ });
729
+
730
+ const processedPlan = QueryPlan.Plan.make(processedSteps);
731
+
479
732
  const OBJECT_SET_CHANGERS = new Set(['SelectStep', 'TraverseStep', 'UnionStep', 'SetDifferenceStep']);
480
- for (let i = plan.steps.length - 1; i >= 0; i--) {
481
- const step = plan.steps[i];
733
+ for (let i = processedPlan.steps.length - 1; i >= 0; i--) {
734
+ const step = processedPlan.steps[i];
482
735
  if (step._tag === 'OrderStep') {
483
- return plan;
736
+ return processedPlan;
484
737
  }
485
738
  if (OBJECT_SET_CHANGERS.has(step._tag)) {
486
739
  break;
487
740
  }
488
741
  }
489
742
 
490
- return QueryPlan.Plan.make([
491
- ...plan.steps,
492
- {
493
- _tag: 'OrderStep',
494
- order: [Order.natural.ast],
495
- },
496
- ]);
743
+ // Find the position to insert the order step (before any limit steps).
744
+ let insertIndex = processedPlan.steps.length;
745
+ for (let i = processedPlan.steps.length - 1; i >= 0; i--) {
746
+ if (processedPlan.steps[i]._tag === 'LimitStep') {
747
+ insertIndex = i;
748
+ } else {
749
+ break;
750
+ }
751
+ }
752
+
753
+ const newSteps = [...processedPlan.steps];
754
+ newSteps.splice(insertIndex, 0, {
755
+ _tag: 'OrderStep',
756
+ order: [Order.natural.ast],
757
+ });
758
+
759
+ return QueryPlan.Plan.make(newSteps);
760
+ }
761
+
762
+ /**
763
+ * Propagates limits from LimitStep to SelectStep and OrderStep when possible.
764
+ * Limits can only be propagated if there are no unions or traversals between the LimitStep and SelectStep/OrderStep.
765
+ */
766
+ private _optimizeLimits(plan: QueryPlan.Plan): QueryPlan.Plan {
767
+ // First, recursively process any sub-plans.
768
+ const processedSteps = plan.steps.map((step): QueryPlan.Step => {
769
+ if (step._tag === 'UnionStep') {
770
+ return {
771
+ _tag: 'UnionStep',
772
+ plans: step.plans.map((subPlan) => this._optimizeLimits(subPlan)),
773
+ };
774
+ } else if (step._tag === 'SetDifferenceStep') {
775
+ return {
776
+ _tag: 'SetDifferenceStep',
777
+ source: this._optimizeLimits(step.source),
778
+ exclude: this._optimizeLimits(step.exclude),
779
+ };
780
+ }
781
+ return step;
782
+ });
783
+
784
+ // Find if there's a LimitStep at the end (possibly after OrderStep).
785
+ let limitStepIndex = -1;
786
+ let limitValue: number | undefined;
787
+
788
+ for (let i = processedSteps.length - 1; i >= 0; i--) {
789
+ const step = processedSteps[i];
790
+ if (step._tag === 'LimitStep') {
791
+ limitStepIndex = i;
792
+ limitValue = step.limit;
793
+ break;
794
+ }
795
+ // Only allow OrderStep after LimitStep when looking backwards.
796
+ if (step._tag !== 'OrderStep') {
797
+ break;
798
+ }
799
+ }
800
+
801
+ if (limitStepIndex === -1 || limitValue === undefined) {
802
+ return QueryPlan.Plan.make(processedSteps);
803
+ }
804
+
805
+ // Check if we can propagate the limit to a SelectStep and/or OrderStep.
806
+ // We can only do this if there are no unions, traversals, or set differences between them.
807
+ const BLOCKERS = new Set(['UnionStep', 'TraverseStep', 'SetDifferenceStep']);
808
+
809
+ let selectStepIndex = -1;
810
+ let orderStepIndex = -1;
811
+ for (let i = 0; i < limitStepIndex; i++) {
812
+ const step = processedSteps[i];
813
+ if (step._tag === 'SelectStep') {
814
+ selectStepIndex = i;
815
+ }
816
+ if (step._tag === 'OrderStep') {
817
+ orderStepIndex = i;
818
+ }
819
+ if (BLOCKERS.has(step._tag)) {
820
+ // Found a blocker after the select/order step - can't propagate.
821
+ selectStepIndex = -1;
822
+ orderStepIndex = -1;
823
+ }
824
+ }
825
+
826
+ if (selectStepIndex === -1 && orderStepIndex === -1) {
827
+ return QueryPlan.Plan.make(processedSteps);
828
+ }
829
+
830
+ // Create a mutable copy of steps to modify.
831
+ const newSteps = [...processedSteps];
832
+
833
+ // Propagate the limit to the SelectStep if found.
834
+ if (selectStepIndex !== -1) {
835
+ const selectStep = newSteps[selectStepIndex] as QueryPlan.SelectStep;
836
+ newSteps[selectStepIndex] = {
837
+ ...selectStep,
838
+ limit: limitValue,
839
+ };
840
+ }
841
+
842
+ // Propagate the limit to the OrderStep if found.
843
+ if (orderStepIndex !== -1) {
844
+ const orderStep = newSteps[orderStepIndex] as QueryPlan.OrderStep;
845
+ newSteps[orderStepIndex] = {
846
+ ...orderStep,
847
+ limit: limitValue,
848
+ };
849
+ }
850
+
851
+ // Remove the LimitStep since limit is now propagated.
852
+ newSteps.splice(limitStepIndex, 1);
853
+
854
+ return QueryPlan.Plan.make(newSteps);
497
855
  }
498
856
  }
499
857
 
@@ -507,9 +865,9 @@ type GenerationContext = {
507
865
  originalQuery: QueryAST.Query | null;
508
866
 
509
867
  /**
510
- * Which spaces to select from.
868
+ * The scope to select from (space IDs, queues, etc.).
511
869
  */
512
- selectionSpaces: readonly SpaceId[];
870
+ scope: QueryAST.Scope;
513
871
 
514
872
  /**
515
873
  * How to handle deleted objects.
@@ -524,7 +882,7 @@ type GenerationContext = {
524
882
 
525
883
  const DEFAULT_CONTEXT: GenerationContext = {
526
884
  originalQuery: null,
527
- selectionSpaces: [],
885
+ scope: {},
528
886
  deletedHandling: 'exclude',
529
887
  selectionInverted: false,
530
888
  };
@@ -544,6 +902,54 @@ const createRelationTraversalStep = (direction: QueryPlan.RelationTraversal['dir
544
902
  },
545
903
  });
546
904
 
905
+ /**
906
+ * Recursively flattens nested `and` filters into a single list.
907
+ */
908
+ const _flattenAnd = (filters: readonly QueryAST.Filter[]): QueryAST.Filter[] => {
909
+ const result: QueryAST.Filter[] = [];
910
+ for (const f of filters) {
911
+ if (f.type === 'and') {
912
+ result.push(..._flattenAnd(f.filters));
913
+ } else {
914
+ result.push(f);
915
+ }
916
+ }
917
+ return result;
918
+ };
919
+
920
+ const _timestampFilterToSelector = (filter: QueryAST.FilterTimestamp): QueryPlan.TimestampSelector => {
921
+ const selector: QueryPlan.TimestampSelector = { _tag: 'TimestampSelector' };
922
+ const key =
923
+ filter.field === 'updatedAt'
924
+ ? filter.operator === 'gte' || filter.operator === 'gt'
925
+ ? 'updatedAfter'
926
+ : 'updatedBefore'
927
+ : filter.operator === 'gte' || filter.operator === 'gt'
928
+ ? 'createdAfter'
929
+ : 'createdBefore';
930
+ selector[key] = filter.value;
931
+ return selector;
932
+ };
933
+
934
+ const _mergeTimestampSelectors = (selectors: QueryPlan.TimestampSelector[]): QueryPlan.TimestampSelector => {
935
+ const merged: QueryPlan.TimestampSelector = { _tag: 'TimestampSelector' };
936
+ for (const s of selectors) {
937
+ if (s.updatedAfter != null) {
938
+ merged.updatedAfter = Math.max(merged.updatedAfter ?? 0, s.updatedAfter);
939
+ }
940
+ if (s.updatedBefore != null) {
941
+ merged.updatedBefore = Math.min(merged.updatedBefore ?? Infinity, s.updatedBefore);
942
+ }
943
+ if (s.createdAfter != null) {
944
+ merged.createdAfter = Math.max(merged.createdAfter ?? 0, s.createdAfter);
945
+ }
946
+ if (s.createdBefore != null) {
947
+ merged.createdBefore = Math.min(merged.createdBefore ?? Infinity, s.createdBefore);
948
+ }
949
+ }
950
+ return merged;
951
+ };
952
+
547
953
  const isTrivialTypenameFilter = (filter: QueryAST.Filter): boolean => {
548
954
  return (
549
955
  filter.type === 'object' &&
@@ -81,7 +81,7 @@ export class CredentialServerExtension extends RpcExtension<
81
81
  ): Promise<GetAdmissionCredentialResponse> => {
82
82
  const memberInfo = this._space.spaceState.members.get(request.memberKey);
83
83
  if (!memberInfo?.credential) {
84
- throw new ProtocolError('Space member not found.', request);
84
+ throw new ProtocolError({ message: 'Space member not found.', context: { ...request } });
85
85
  }
86
86
  return { admissionCredential: memberInfo.credential };
87
87
  },