@tanstack/db 0.5.32 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/dist/cjs/collection/change-events.cjs.map +1 -1
  2. package/dist/cjs/collection/change-events.d.cts +3 -2
  3. package/dist/cjs/collection/changes.cjs +13 -4
  4. package/dist/cjs/collection/changes.cjs.map +1 -1
  5. package/dist/cjs/collection/changes.d.cts +10 -1
  6. package/dist/cjs/collection/cleanup-queue.cjs +89 -0
  7. package/dist/cjs/collection/cleanup-queue.cjs.map +1 -0
  8. package/dist/cjs/collection/cleanup-queue.d.cts +30 -0
  9. package/dist/cjs/collection/events.cjs +14 -0
  10. package/dist/cjs/collection/events.cjs.map +1 -1
  11. package/dist/cjs/collection/events.d.cts +39 -1
  12. package/dist/cjs/collection/index.cjs +66 -28
  13. package/dist/cjs/collection/index.cjs.map +1 -1
  14. package/dist/cjs/collection/index.d.cts +49 -36
  15. package/dist/cjs/collection/indexes.cjs +211 -62
  16. package/dist/cjs/collection/indexes.cjs.map +1 -1
  17. package/dist/cjs/collection/indexes.d.cts +27 -17
  18. package/dist/cjs/collection/lifecycle.cjs +5 -22
  19. package/dist/cjs/collection/lifecycle.cjs.map +1 -1
  20. package/dist/cjs/collection/lifecycle.d.cts +0 -1
  21. package/dist/cjs/collection/mutations.cjs +18 -0
  22. package/dist/cjs/collection/mutations.cjs.map +1 -1
  23. package/dist/cjs/collection/mutations.d.cts +1 -0
  24. package/dist/cjs/collection/state.cjs +381 -53
  25. package/dist/cjs/collection/state.cjs.map +1 -1
  26. package/dist/cjs/collection/state.d.cts +65 -1
  27. package/dist/cjs/collection/subscription.cjs +6 -0
  28. package/dist/cjs/collection/subscription.cjs.map +1 -1
  29. package/dist/cjs/collection/subscription.d.cts +4 -0
  30. package/dist/cjs/collection/sync.cjs +108 -1
  31. package/dist/cjs/collection/sync.cjs.map +1 -1
  32. package/dist/cjs/collection/sync.d.cts +2 -0
  33. package/dist/cjs/collection/transaction-metadata.cjs +5 -0
  34. package/dist/cjs/collection/transaction-metadata.cjs.map +1 -0
  35. package/dist/cjs/collection/transaction-metadata.d.cts +1 -0
  36. package/dist/cjs/errors.cjs +8 -0
  37. package/dist/cjs/errors.cjs.map +1 -1
  38. package/dist/cjs/errors.d.cts +3 -0
  39. package/dist/cjs/index.cjs +24 -4
  40. package/dist/cjs/index.cjs.map +1 -1
  41. package/dist/cjs/index.d.cts +12 -3
  42. package/dist/cjs/indexes/auto-index.cjs +13 -6
  43. package/dist/cjs/indexes/auto-index.cjs.map +1 -1
  44. package/dist/cjs/indexes/base-index.cjs +0 -3
  45. package/dist/cjs/indexes/base-index.cjs.map +1 -1
  46. package/dist/cjs/indexes/base-index.d.cts +2 -6
  47. package/dist/cjs/indexes/basic-index.cjs +361 -0
  48. package/dist/cjs/indexes/basic-index.cjs.map +1 -0
  49. package/dist/cjs/indexes/basic-index.d.cts +102 -0
  50. package/dist/cjs/indexes/btree-index.cjs.map +1 -1
  51. package/dist/cjs/indexes/btree-index.d.cts +1 -1
  52. package/dist/cjs/indexes/index-options.d.cts +8 -9
  53. package/dist/cjs/indexes/index-registry.cjs +89 -0
  54. package/dist/cjs/indexes/index-registry.cjs.map +1 -0
  55. package/dist/cjs/indexes/index-registry.d.cts +61 -0
  56. package/dist/cjs/local-only.cjs +5 -0
  57. package/dist/cjs/local-only.cjs.map +1 -1
  58. package/dist/cjs/query/builder/functions.cjs +27 -11
  59. package/dist/cjs/query/builder/functions.cjs.map +1 -1
  60. package/dist/cjs/query/builder/functions.d.cts +25 -3
  61. package/dist/cjs/query/builder/index.cjs +200 -39
  62. package/dist/cjs/query/builder/index.cjs.map +1 -1
  63. package/dist/cjs/query/builder/index.d.cts +4 -3
  64. package/dist/cjs/query/builder/ref-proxy.cjs.map +1 -1
  65. package/dist/cjs/query/builder/ref-proxy.d.cts +14 -3
  66. package/dist/cjs/query/builder/types.d.cts +84 -19
  67. package/dist/cjs/query/compiler/evaluators.cjs +51 -0
  68. package/dist/cjs/query/compiler/evaluators.cjs.map +1 -1
  69. package/dist/cjs/query/compiler/group-by.cjs +100 -28
  70. package/dist/cjs/query/compiler/group-by.cjs.map +1 -1
  71. package/dist/cjs/query/compiler/group-by.d.cts +4 -2
  72. package/dist/cjs/query/compiler/index.cjs +283 -11
  73. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  74. package/dist/cjs/query/compiler/index.d.cts +30 -2
  75. package/dist/cjs/query/compiler/order-by.cjs +29 -10
  76. package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
  77. package/dist/cjs/query/compiler/order-by.d.cts +1 -1
  78. package/dist/cjs/query/compiler/select.cjs +8 -0
  79. package/dist/cjs/query/compiler/select.cjs.map +1 -1
  80. package/dist/cjs/query/effect.cjs +602 -0
  81. package/dist/cjs/query/effect.cjs.map +1 -0
  82. package/dist/cjs/query/effect.d.cts +94 -0
  83. package/dist/cjs/query/index.d.cts +2 -1
  84. package/dist/cjs/query/ir.cjs +18 -1
  85. package/dist/cjs/query/ir.cjs.map +1 -1
  86. package/dist/cjs/query/ir.d.cts +21 -1
  87. package/dist/cjs/query/live/collection-config-builder.cjs +493 -66
  88. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  89. package/dist/cjs/query/live/collection-config-builder.d.cts +7 -0
  90. package/dist/cjs/query/live/collection-subscriber.cjs +33 -100
  91. package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
  92. package/dist/cjs/query/live/collection-subscriber.d.cts +0 -1
  93. package/dist/cjs/query/live/types.d.cts +3 -3
  94. package/dist/cjs/query/live/utils.cjs +219 -0
  95. package/dist/cjs/query/live/utils.cjs.map +1 -0
  96. package/dist/cjs/query/live/utils.d.cts +110 -0
  97. package/dist/cjs/query/live-query-collection.cjs.map +1 -1
  98. package/dist/cjs/query/live-query-collection.d.cts +9 -6
  99. package/dist/cjs/query/query-once.cjs.map +1 -1
  100. package/dist/cjs/query/query-once.d.cts +7 -5
  101. package/dist/cjs/query/subset-dedupe.cjs +9 -3
  102. package/dist/cjs/query/subset-dedupe.cjs.map +1 -1
  103. package/dist/cjs/types.d.cts +42 -8
  104. package/dist/cjs/utils/array-utils.cjs +27 -0
  105. package/dist/cjs/utils/array-utils.cjs.map +1 -0
  106. package/dist/cjs/utils/array-utils.d.cts +16 -0
  107. package/dist/cjs/utils/comparison.cjs +11 -0
  108. package/dist/cjs/utils/comparison.cjs.map +1 -1
  109. package/dist/cjs/utils/index-optimization.cjs +4 -0
  110. package/dist/cjs/utils/index-optimization.cjs.map +1 -1
  111. package/dist/cjs/utils.cjs +7 -9
  112. package/dist/cjs/utils.cjs.map +1 -1
  113. package/dist/cjs/utils.d.cts +6 -1
  114. package/dist/cjs/virtual-props.cjs +33 -0
  115. package/dist/cjs/virtual-props.cjs.map +1 -0
  116. package/dist/cjs/virtual-props.d.cts +196 -0
  117. package/dist/esm/collection/change-events.d.ts +3 -2
  118. package/dist/esm/collection/change-events.js.map +1 -1
  119. package/dist/esm/collection/changes.d.ts +10 -1
  120. package/dist/esm/collection/changes.js +13 -4
  121. package/dist/esm/collection/changes.js.map +1 -1
  122. package/dist/esm/collection/cleanup-queue.d.ts +30 -0
  123. package/dist/esm/collection/cleanup-queue.js +89 -0
  124. package/dist/esm/collection/cleanup-queue.js.map +1 -0
  125. package/dist/esm/collection/events.d.ts +39 -1
  126. package/dist/esm/collection/events.js +14 -0
  127. package/dist/esm/collection/events.js.map +1 -1
  128. package/dist/esm/collection/index.d.ts +49 -36
  129. package/dist/esm/collection/index.js +67 -29
  130. package/dist/esm/collection/index.js.map +1 -1
  131. package/dist/esm/collection/indexes.d.ts +27 -17
  132. package/dist/esm/collection/indexes.js +211 -62
  133. package/dist/esm/collection/indexes.js.map +1 -1
  134. package/dist/esm/collection/lifecycle.d.ts +0 -1
  135. package/dist/esm/collection/lifecycle.js +5 -22
  136. package/dist/esm/collection/lifecycle.js.map +1 -1
  137. package/dist/esm/collection/mutations.d.ts +1 -0
  138. package/dist/esm/collection/mutations.js +18 -0
  139. package/dist/esm/collection/mutations.js.map +1 -1
  140. package/dist/esm/collection/state.d.ts +65 -1
  141. package/dist/esm/collection/state.js +381 -53
  142. package/dist/esm/collection/state.js.map +1 -1
  143. package/dist/esm/collection/subscription.d.ts +4 -0
  144. package/dist/esm/collection/subscription.js +6 -0
  145. package/dist/esm/collection/subscription.js.map +1 -1
  146. package/dist/esm/collection/sync.d.ts +2 -0
  147. package/dist/esm/collection/sync.js +108 -1
  148. package/dist/esm/collection/sync.js.map +1 -1
  149. package/dist/esm/collection/transaction-metadata.d.ts +1 -0
  150. package/dist/esm/collection/transaction-metadata.js +5 -0
  151. package/dist/esm/collection/transaction-metadata.js.map +1 -0
  152. package/dist/esm/errors.d.ts +3 -0
  153. package/dist/esm/errors.js +8 -0
  154. package/dist/esm/errors.js.map +1 -1
  155. package/dist/esm/index.d.ts +12 -3
  156. package/dist/esm/index.js +27 -7
  157. package/dist/esm/index.js.map +1 -1
  158. package/dist/esm/indexes/auto-index.js +13 -6
  159. package/dist/esm/indexes/auto-index.js.map +1 -1
  160. package/dist/esm/indexes/base-index.d.ts +2 -6
  161. package/dist/esm/indexes/base-index.js +1 -4
  162. package/dist/esm/indexes/base-index.js.map +1 -1
  163. package/dist/esm/indexes/basic-index.d.ts +102 -0
  164. package/dist/esm/indexes/basic-index.js +361 -0
  165. package/dist/esm/indexes/basic-index.js.map +1 -0
  166. package/dist/esm/indexes/btree-index.d.ts +1 -1
  167. package/dist/esm/indexes/btree-index.js.map +1 -1
  168. package/dist/esm/indexes/index-options.d.ts +8 -9
  169. package/dist/esm/indexes/index-registry.d.ts +61 -0
  170. package/dist/esm/indexes/index-registry.js +89 -0
  171. package/dist/esm/indexes/index-registry.js.map +1 -0
  172. package/dist/esm/local-only.js +5 -0
  173. package/dist/esm/local-only.js.map +1 -1
  174. package/dist/esm/query/builder/functions.d.ts +25 -3
  175. package/dist/esm/query/builder/functions.js +27 -11
  176. package/dist/esm/query/builder/functions.js.map +1 -1
  177. package/dist/esm/query/builder/index.d.ts +4 -3
  178. package/dist/esm/query/builder/index.js +201 -40
  179. package/dist/esm/query/builder/index.js.map +1 -1
  180. package/dist/esm/query/builder/ref-proxy.d.ts +14 -3
  181. package/dist/esm/query/builder/ref-proxy.js.map +1 -1
  182. package/dist/esm/query/builder/types.d.ts +84 -19
  183. package/dist/esm/query/compiler/evaluators.js +51 -0
  184. package/dist/esm/query/compiler/evaluators.js.map +1 -1
  185. package/dist/esm/query/compiler/group-by.d.ts +4 -2
  186. package/dist/esm/query/compiler/group-by.js +101 -29
  187. package/dist/esm/query/compiler/group-by.js.map +1 -1
  188. package/dist/esm/query/compiler/index.d.ts +30 -2
  189. package/dist/esm/query/compiler/index.js +285 -13
  190. package/dist/esm/query/compiler/index.js.map +1 -1
  191. package/dist/esm/query/compiler/order-by.d.ts +1 -1
  192. package/dist/esm/query/compiler/order-by.js +30 -11
  193. package/dist/esm/query/compiler/order-by.js.map +1 -1
  194. package/dist/esm/query/compiler/select.js +8 -0
  195. package/dist/esm/query/compiler/select.js.map +1 -1
  196. package/dist/esm/query/effect.d.ts +94 -0
  197. package/dist/esm/query/effect.js +602 -0
  198. package/dist/esm/query/effect.js.map +1 -0
  199. package/dist/esm/query/index.d.ts +2 -1
  200. package/dist/esm/query/ir.d.ts +21 -1
  201. package/dist/esm/query/ir.js +18 -1
  202. package/dist/esm/query/ir.js.map +1 -1
  203. package/dist/esm/query/live/collection-config-builder.d.ts +7 -0
  204. package/dist/esm/query/live/collection-config-builder.js +492 -65
  205. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  206. package/dist/esm/query/live/collection-subscriber.d.ts +0 -1
  207. package/dist/esm/query/live/collection-subscriber.js +31 -98
  208. package/dist/esm/query/live/collection-subscriber.js.map +1 -1
  209. package/dist/esm/query/live/types.d.ts +3 -3
  210. package/dist/esm/query/live/utils.d.ts +110 -0
  211. package/dist/esm/query/live/utils.js +219 -0
  212. package/dist/esm/query/live/utils.js.map +1 -0
  213. package/dist/esm/query/live-query-collection.d.ts +9 -6
  214. package/dist/esm/query/live-query-collection.js.map +1 -1
  215. package/dist/esm/query/query-once.d.ts +7 -5
  216. package/dist/esm/query/query-once.js.map +1 -1
  217. package/dist/esm/query/subset-dedupe.js +9 -3
  218. package/dist/esm/query/subset-dedupe.js.map +1 -1
  219. package/dist/esm/types.d.ts +42 -8
  220. package/dist/esm/utils/array-utils.d.ts +16 -0
  221. package/dist/esm/utils/array-utils.js +27 -0
  222. package/dist/esm/utils/array-utils.js.map +1 -0
  223. package/dist/esm/utils/comparison.js +11 -0
  224. package/dist/esm/utils/comparison.js.map +1 -1
  225. package/dist/esm/utils/index-optimization.js +4 -0
  226. package/dist/esm/utils/index-optimization.js.map +1 -1
  227. package/dist/esm/utils.d.ts +6 -1
  228. package/dist/esm/utils.js +7 -9
  229. package/dist/esm/utils.js.map +1 -1
  230. package/dist/esm/virtual-props.d.ts +196 -0
  231. package/dist/esm/virtual-props.js +33 -0
  232. package/dist/esm/virtual-props.js.map +1 -0
  233. package/package.json +2 -2
  234. package/skills/db-core/collection-setup/references/electric-adapter.md +1 -1
  235. package/src/collection/change-events.ts +13 -9
  236. package/src/collection/changes.ts +30 -7
  237. package/src/collection/cleanup-queue.ts +105 -0
  238. package/src/collection/events.ts +65 -0
  239. package/src/collection/index.ts +110 -45
  240. package/src/collection/indexes.ts +283 -76
  241. package/src/collection/lifecycle.ts +5 -26
  242. package/src/collection/mutations.ts +21 -0
  243. package/src/collection/state.ts +545 -71
  244. package/src/collection/subscription.ts +7 -0
  245. package/src/collection/sync.ts +137 -0
  246. package/src/collection/transaction-metadata.ts +1 -0
  247. package/src/errors.ts +9 -0
  248. package/src/index.ts +57 -3
  249. package/src/indexes/auto-index.ts +18 -8
  250. package/src/indexes/base-index.ts +2 -10
  251. package/src/indexes/basic-index.ts +507 -0
  252. package/src/indexes/btree-index.ts +1 -1
  253. package/src/indexes/index-options.ts +17 -37
  254. package/src/indexes/index-registry.ts +174 -0
  255. package/src/local-only.ts +7 -0
  256. package/src/query/builder/functions.ts +84 -7
  257. package/src/query/builder/index.ts +329 -9
  258. package/src/query/builder/ref-proxy.ts +22 -4
  259. package/src/query/builder/types.ts +257 -62
  260. package/src/query/compiler/evaluators.ts +57 -0
  261. package/src/query/compiler/group-by.ts +156 -35
  262. package/src/query/compiler/index.ts +445 -15
  263. package/src/query/compiler/order-by.ts +51 -12
  264. package/src/query/compiler/select.ts +9 -0
  265. package/src/query/effect.ts +1119 -0
  266. package/src/query/index.ts +7 -0
  267. package/src/query/ir.ts +23 -2
  268. package/src/query/live/collection-config-builder.ts +778 -104
  269. package/src/query/live/collection-subscriber.ts +40 -156
  270. package/src/query/live/types.ts +10 -4
  271. package/src/query/live/utils.ts +417 -0
  272. package/src/query/live-query-collection.ts +43 -18
  273. package/src/query/query-once.ts +31 -12
  274. package/src/query/subset-dedupe.ts +11 -7
  275. package/src/types.ts +49 -9
  276. package/src/utils/array-utils.ts +49 -0
  277. package/src/utils/comparison.ts +14 -0
  278. package/src/utils/index-optimization.ts +4 -0
  279. package/src/utils.ts +12 -9
  280. package/src/virtual-props.ts +282 -0
  281. package/dist/cjs/indexes/lazy-index.cjs +0 -190
  282. package/dist/cjs/indexes/lazy-index.cjs.map +0 -1
  283. package/dist/cjs/indexes/lazy-index.d.cts +0 -96
  284. package/dist/esm/indexes/lazy-index.d.ts +0 -96
  285. package/dist/esm/indexes/lazy-index.js +0 -190
  286. package/dist/esm/indexes/lazy-index.js.map +0 -1
  287. package/src/indexes/lazy-index.ts +0 -251
@@ -1,4 +1,10 @@
1
- import { distinct, filter, map } from '@tanstack/db-ivm'
1
+ import {
2
+ distinct,
3
+ filter,
4
+ join as joinOperator,
5
+ map,
6
+ reduce,
7
+ } from '@tanstack/db-ivm'
2
8
  import { optimizeQuery } from '../optimizer.js'
3
9
  import {
4
10
  CollectionInputNotFoundError,
@@ -9,7 +15,13 @@ import {
9
15
  LimitOffsetRequireOrderByError,
10
16
  UnsupportedFromTypeError,
11
17
  } from '../../errors.js'
12
- import { PropRef, Value as ValClass, getWhereExpression } from '../ir.js'
18
+ import { VIRTUAL_PROP_NAMES } from '../../virtual-props.js'
19
+ import {
20
+ IncludesSubquery,
21
+ PropRef,
22
+ Value as ValClass,
23
+ getWhereExpression,
24
+ } from '../ir.js'
13
25
  import { compileExpression, toBooleanPredicate } from './evaluators.js'
14
26
  import { processJoins } from './joins.js'
15
27
  import { containsAggregate, processGroupBy } from './group-by.js'
@@ -20,6 +32,7 @@ import type { OrderByOptimizationInfo } from './order-by.js'
20
32
  import type {
21
33
  BasicExpression,
22
34
  CollectionRef,
35
+ IncludesMaterialization,
23
36
  QueryIR,
24
37
  QueryRef,
25
38
  } from '../ir.js'
@@ -34,6 +47,34 @@ import type { QueryCache, QueryMapping, WindowOptions } from './types.js'
34
47
 
35
48
  export type { WindowOptions } from './types.js'
36
49
 
50
+ /** Symbol used to tag parent $selected with routing metadata for includes */
51
+ export const INCLUDES_ROUTING = Symbol(`includesRouting`)
52
+
53
+ /**
54
+ * Result of compiling an includes subquery, including the child pipeline
55
+ * and metadata needed to route child results to parent-scoped Collections.
56
+ */
57
+ export interface IncludesCompilationResult {
58
+ /** Filtered child pipeline (post inner-join with parent keys) */
59
+ pipeline: ResultStream
60
+ /** Result field name on parent (e.g., "issues") */
61
+ fieldName: string
62
+ /** Parent-side correlation ref (e.g., project.id) */
63
+ correlationField: PropRef
64
+ /** Child-side correlation ref (e.g., issue.projectId) */
65
+ childCorrelationField: PropRef
66
+ /** Whether the child query has an ORDER BY clause */
67
+ hasOrderBy: boolean
68
+ /** Full compilation result for the child query (for nested includes + alias tracking) */
69
+ childCompilationResult: CompilationResult
70
+ /** Parent-side projection refs for parent-referencing filters */
71
+ parentProjection?: Array<PropRef>
72
+ /** How the output layer materializes the child result on the parent row */
73
+ materialization: IncludesMaterialization
74
+ /** Internal field used to unwrap scalar child selects */
75
+ scalarField?: string
76
+ }
77
+
37
78
  /**
38
79
  * Result of query compilation including both the pipeline and source-specific WHERE clauses
39
80
  */
@@ -68,6 +109,9 @@ export interface CompilationResult {
68
109
  * the inner aliases where collection subscriptions were created.
69
110
  */
70
111
  aliasRemapping: Record<string, string>
112
+
113
+ /** Child pipelines for includes subqueries */
114
+ includes?: Array<IncludesCompilationResult>
71
115
  }
72
116
 
73
117
  /**
@@ -94,6 +138,9 @@ export function compileQuery(
94
138
  setWindowFn: (windowFn: (options: WindowOptions) => void) => void,
95
139
  cache: QueryCache = new WeakMap(),
96
140
  queryMapping: QueryMapping = new WeakMap(),
141
+ // For includes: parent key stream to inner-join with this query's FROM
142
+ parentKeyStream?: KeyedStream,
143
+ childCorrelationField?: PropRef,
97
144
  ): CompilationResult {
98
145
  // Check if the original raw query has already been compiled
99
146
  const cachedResult = cache.get(rawQuery)
@@ -107,7 +154,9 @@ export function compileQuery(
107
154
  validateQueryStructure(rawQuery)
108
155
 
109
156
  // Optimize the query before compilation
110
- const { optimizedQuery: query, sourceWhereClauses } = optimizeQuery(rawQuery)
157
+ const { optimizedQuery, sourceWhereClauses } = optimizeQuery(rawQuery)
158
+ // Use a mutable binding so we can shallow-clone select before includes mutation
159
+ let query = optimizedQuery
111
160
 
112
161
  // Create mapping from optimized query to original for caching
113
162
  queryMapping.set(query, rawQuery)
@@ -153,14 +202,62 @@ export function compileQuery(
153
202
  )
154
203
  sources[mainSource] = mainInput
155
204
 
205
+ // If this is an includes child query, inner-join the raw input with parent keys.
206
+ // This filters the child collection to only rows matching parents in the result set.
207
+ // The inner join happens BEFORE namespace wrapping / WHERE / SELECT / ORDER BY,
208
+ // so the child pipeline only processes rows that match parents.
209
+ let filteredMainInput = mainInput
210
+ if (parentKeyStream && childCorrelationField) {
211
+ // Re-key child input by correlation field: [correlationValue, [childKey, childRow]]
212
+ const childFieldPath = childCorrelationField.path.slice(1) // remove alias prefix
213
+ const childRekeyed = mainInput.pipe(
214
+ map(([key, row]: [unknown, any]) => {
215
+ const correlationValue = getNestedValue(row, childFieldPath)
216
+ return [correlationValue, [key, row]] as [unknown, [unknown, any]]
217
+ }),
218
+ )
219
+
220
+ // Inner join: only children whose correlation key exists in parent keys pass through
221
+ const joined = childRekeyed.pipe(joinOperator(parentKeyStream, `inner`))
222
+
223
+ // Extract: [correlationValue, [[childKey, childRow], parentContext]] → [childKey, childRow]
224
+ // Tag the row with __correlationKey for output routing
225
+ // If parentSide is non-null (parent context projected), attach as __parentContext
226
+ filteredMainInput = joined.pipe(
227
+ filter(([_correlationValue, [childSide]]: any) => {
228
+ return childSide != null
229
+ }),
230
+ map(([correlationValue, [childSide, parentSide]]: any) => {
231
+ const [childKey, childRow] = childSide
232
+ const tagged: any = { ...childRow, __correlationKey: correlationValue }
233
+ if (parentSide != null) {
234
+ tagged.__parentContext = parentSide
235
+ }
236
+ const effectiveKey =
237
+ parentSide != null
238
+ ? `${String(childKey)}::${JSON.stringify(parentSide)}`
239
+ : childKey
240
+ return [effectiveKey, tagged]
241
+ }),
242
+ )
243
+
244
+ // Update sources so the rest of the pipeline uses the filtered input
245
+ sources[mainSource] = filteredMainInput
246
+ }
247
+
156
248
  // Prepare the initial pipeline with the main source wrapped in its alias
157
- let pipeline: NamespacedAndKeyedStream = mainInput.pipe(
249
+ let pipeline: NamespacedAndKeyedStream = filteredMainInput.pipe(
158
250
  map(([key, row]) => {
159
251
  // Initialize the record with a nested structure
160
- const ret = [key, { [mainSource]: row }] as [
161
- string,
162
- Record<string, typeof row>,
163
- ]
252
+ // If __parentContext exists (from parent-referencing includes), merge parent
253
+ // aliases into the namespaced row so WHERE can resolve parent refs
254
+ const { __parentContext, ...cleanRow } = row as any
255
+ const nsRow: Record<string, any> = { [mainSource]: cleanRow }
256
+ if (__parentContext) {
257
+ Object.assign(nsRow, __parentContext)
258
+ ;(nsRow as any).__parentContext = __parentContext
259
+ }
260
+ const ret = [key, nsRow] as [string, Record<string, typeof row>]
164
261
  return ret
165
262
  }),
166
263
  )
@@ -215,6 +312,163 @@ export function compileQuery(
215
312
  }
216
313
  }
217
314
 
315
+ // Extract includes from SELECT, compile child pipelines, and replace with placeholders.
316
+ // This must happen AFTER WHERE (so parent pipeline is filtered) but BEFORE processSelect
317
+ // (so IncludesSubquery nodes are stripped before select compilation).
318
+ const includesResults: Array<IncludesCompilationResult> = []
319
+ const includesRoutingFns: Array<{
320
+ fieldName: string
321
+ getRouting: (nsRow: any) => {
322
+ correlationKey: unknown
323
+ parentContext: Record<string, any> | null
324
+ }
325
+ }> = []
326
+ if (query.select) {
327
+ const includesEntries = extractIncludesFromSelect(query.select)
328
+ // Shallow-clone select before mutating so we don't modify the shared IR
329
+ // (the optimizer copies select by reference, so rawQuery.select === query.select)
330
+ if (includesEntries.length > 0) {
331
+ query = { ...query, select: { ...query.select } }
332
+ }
333
+ for (const { key, subquery } of includesEntries) {
334
+ // Branch parent pipeline: map to [correlationValue, parentContext]
335
+ // When parentProjection exists, project referenced parent fields; otherwise null (zero overhead)
336
+ const compiledCorrelation = compileExpression(subquery.correlationField)
337
+ let parentKeys: any
338
+ if (subquery.parentProjection && subquery.parentProjection.length > 0) {
339
+ const compiledProjections = subquery.parentProjection.map((ref) => ({
340
+ alias: ref.path[0]!,
341
+ field: ref.path.slice(1),
342
+ compiled: compileExpression(ref),
343
+ }))
344
+ parentKeys = pipeline.pipe(
345
+ map(([_key, nsRow]: any) => {
346
+ const parentContext: Record<string, Record<string, any>> = {}
347
+ for (const proj of compiledProjections) {
348
+ if (!parentContext[proj.alias]) {
349
+ parentContext[proj.alias] = {}
350
+ }
351
+ const value = proj.compiled(nsRow)
352
+ // Set nested field in the alias namespace
353
+ let target = parentContext[proj.alias]!
354
+ for (let i = 0; i < proj.field.length - 1; i++) {
355
+ if (!target[proj.field[i]!]) {
356
+ target[proj.field[i]!] = {}
357
+ }
358
+ target = target[proj.field[i]!]
359
+ }
360
+ target[proj.field[proj.field.length - 1]!] = value
361
+ }
362
+ return [compiledCorrelation(nsRow), parentContext] as any
363
+ }),
364
+ )
365
+ } else {
366
+ parentKeys = pipeline.pipe(
367
+ map(
368
+ ([_key, nsRow]: any) => [compiledCorrelation(nsRow), null] as any,
369
+ ),
370
+ )
371
+ }
372
+
373
+ // Deduplicate: when multiple parents share the same correlation key (and
374
+ // parentContext), clamp multiplicity to 1 so the inner join doesn't
375
+ // produce duplicate child entries that cause incorrect deletions.
376
+ parentKeys = parentKeys.pipe(
377
+ reduce((values: Array<[any, number]>) =>
378
+ values.map(([v, mult]) => [v, mult > 0 ? 1 : 0] as [any, number]),
379
+ ),
380
+ )
381
+
382
+ // If parent filters exist, append them to the child query's WHERE
383
+ const childQuery =
384
+ subquery.parentFilters && subquery.parentFilters.length > 0
385
+ ? {
386
+ ...subquery.query,
387
+ where: [
388
+ ...(subquery.query.where || []),
389
+ ...subquery.parentFilters,
390
+ ],
391
+ }
392
+ : subquery.query
393
+
394
+ // Recursively compile child query WITH the parent key stream
395
+ const childResult = compileQuery(
396
+ childQuery,
397
+ allInputs,
398
+ collections,
399
+ subscriptions,
400
+ callbacks,
401
+ lazySources,
402
+ optimizableOrderByCollections,
403
+ setWindowFn,
404
+ cache,
405
+ queryMapping,
406
+ parentKeys,
407
+ subquery.childCorrelationField,
408
+ )
409
+
410
+ // Merge child's alias metadata into parent's
411
+ Object.assign(aliasToCollectionId, childResult.aliasToCollectionId)
412
+ Object.assign(aliasRemapping, childResult.aliasRemapping)
413
+
414
+ includesResults.push({
415
+ pipeline: childResult.pipeline,
416
+ fieldName: subquery.fieldName,
417
+ correlationField: subquery.correlationField,
418
+ childCorrelationField: subquery.childCorrelationField,
419
+ hasOrderBy: !!(
420
+ subquery.query.orderBy && subquery.query.orderBy.length > 0
421
+ ),
422
+ childCompilationResult: childResult,
423
+ parentProjection: subquery.parentProjection,
424
+ materialization: subquery.materialization,
425
+ scalarField: subquery.scalarField,
426
+ })
427
+
428
+ // Capture routing function for INCLUDES_ROUTING tagging
429
+ if (subquery.parentProjection && subquery.parentProjection.length > 0) {
430
+ const compiledProjs = subquery.parentProjection.map((ref) => ({
431
+ alias: ref.path[0]!,
432
+ field: ref.path.slice(1),
433
+ compiled: compileExpression(ref),
434
+ }))
435
+ const compiledCorr = compiledCorrelation
436
+ includesRoutingFns.push({
437
+ fieldName: subquery.fieldName,
438
+ getRouting: (nsRow: any) => {
439
+ const parentContext: Record<string, Record<string, any>> = {}
440
+ for (const proj of compiledProjs) {
441
+ if (!parentContext[proj.alias]) {
442
+ parentContext[proj.alias] = {}
443
+ }
444
+ const value = proj.compiled(nsRow)
445
+ let target = parentContext[proj.alias]!
446
+ for (let i = 0; i < proj.field.length - 1; i++) {
447
+ if (!target[proj.field[i]!]) {
448
+ target[proj.field[i]!] = {}
449
+ }
450
+ target = target[proj.field[i]!]
451
+ }
452
+ target[proj.field[proj.field.length - 1]!] = value
453
+ }
454
+ return { correlationKey: compiledCorr(nsRow), parentContext }
455
+ },
456
+ })
457
+ } else {
458
+ includesRoutingFns.push({
459
+ fieldName: subquery.fieldName,
460
+ getRouting: (nsRow: any) => ({
461
+ correlationKey: compiledCorrelation(nsRow),
462
+ parentContext: null,
463
+ }),
464
+ })
465
+ }
466
+
467
+ // Replace includes entry in select with a null placeholder
468
+ replaceIncludesInSelect(query.select!, key)
469
+ }
470
+ }
471
+
218
472
  if (query.distinct && !query.fnSelect && !query.select) {
219
473
  throw new DistinctRequiresSelectError()
220
474
  }
@@ -261,7 +515,29 @@ export function compileQuery(
261
515
  )
262
516
  }
263
517
 
264
- // Process the GROUP BY clause if it exists
518
+ // Tag $selected with routing metadata for includes.
519
+ // This lets collection-config-builder extract routing info (correlationKey + parentContext)
520
+ // from parent results without depending on the user's select.
521
+ if (includesRoutingFns.length > 0) {
522
+ pipeline = pipeline.pipe(
523
+ map(([key, namespacedRow]: any) => {
524
+ const routing: Record<
525
+ string,
526
+ { correlationKey: unknown; parentContext: Record<string, any> | null }
527
+ > = {}
528
+ for (const { fieldName, getRouting } of includesRoutingFns) {
529
+ routing[fieldName] = getRouting(namespacedRow)
530
+ }
531
+ namespacedRow.$selected[INCLUDES_ROUTING] = routing
532
+ return [key, namespacedRow]
533
+ }),
534
+ )
535
+ }
536
+
537
+ // Process the GROUP BY clause if it exists.
538
+ // When in includes mode (parentKeyStream), pass mainSource so that groupBy
539
+ // preserves __correlationKey for per-parent aggregation.
540
+ const groupByMainSource = parentKeyStream ? mainSource : undefined
265
541
  if (query.groupBy && query.groupBy.length > 0) {
266
542
  pipeline = processGroupBy(
267
543
  pipeline,
@@ -269,6 +545,8 @@ export function compileQuery(
269
545
  query.having,
270
546
  query.select,
271
547
  query.fnHaving,
548
+ mainCollectionId,
549
+ groupByMainSource,
272
550
  )
273
551
  } else if (query.select) {
274
552
  // Check if SELECT contains aggregates but no GROUP BY (implicit single-group aggregation)
@@ -283,6 +561,8 @@ export function compileQuery(
283
561
  query.having,
284
562
  query.select,
285
563
  query.fnHaving,
564
+ mainCollectionId,
565
+ groupByMainSource,
286
566
  )
287
567
  }
288
568
  }
@@ -322,6 +602,21 @@ export function compileQuery(
322
602
 
323
603
  // Process orderBy parameter if it exists
324
604
  if (query.orderBy && query.orderBy.length > 0) {
605
+ // When in includes mode with limit/offset, use grouped ordering so that
606
+ // the limit is applied per parent (per correlation key), not globally.
607
+ const includesGroupKeyFn =
608
+ parentKeyStream &&
609
+ (query.limit !== undefined || query.offset !== undefined)
610
+ ? (_key: unknown, row: unknown) => {
611
+ const correlationKey = (row as any)?.[mainSource]?.__correlationKey
612
+ const parentContext = (row as any)?.__parentContext
613
+ if (parentContext != null) {
614
+ return JSON.stringify([correlationKey, parentContext])
615
+ }
616
+ return correlationKey
617
+ }
618
+ : undefined
619
+
325
620
  const orderedPipeline = processOrderBy(
326
621
  rawQuery,
327
622
  pipeline,
@@ -332,26 +627,43 @@ export function compileQuery(
332
627
  setWindowFn,
333
628
  query.limit,
334
629
  query.offset,
630
+ includesGroupKeyFn,
335
631
  )
336
632
 
337
633
  // Final step: extract the $selected and include orderBy index
338
- const resultPipeline = orderedPipeline.pipe(
634
+ const resultPipeline: ResultStream = orderedPipeline.pipe(
339
635
  map(([key, [row, orderByIndex]]) => {
340
636
  // Extract the final results from $selected and include orderBy index
341
637
  const raw = (row as any).$selected
342
- const finalResults = unwrapValue(raw)
638
+ const finalResults = attachVirtualPropsToSelected(
639
+ unwrapValue(raw),
640
+ row as Record<string, any>,
641
+ )
642
+ // When in includes mode, embed the correlation key and parentContext
643
+ if (parentKeyStream) {
644
+ const correlationKey = (row as any)[mainSource]?.__correlationKey
645
+ const parentContext = (row as any).__parentContext ?? null
646
+ // Strip internal routing properties that may leak via spread selects
647
+ delete finalResults.__correlationKey
648
+ delete finalResults.__parentContext
649
+ return [
650
+ key,
651
+ [finalResults, orderByIndex, correlationKey, parentContext],
652
+ ] as any
653
+ }
343
654
  return [key, [finalResults, orderByIndex]] as [unknown, [any, string]]
344
655
  }),
345
- )
656
+ ) as ResultStream
346
657
 
347
658
  const result = resultPipeline
348
659
  // Cache the result before returning (use original query as key)
349
- const compilationResult = {
660
+ const compilationResult: CompilationResult = {
350
661
  collectionId: mainCollectionId,
351
662
  pipeline: result,
352
663
  sourceWhereClauses,
353
664
  aliasToCollectionId,
354
665
  aliasRemapping,
666
+ includes: includesResults.length > 0 ? includesResults : undefined,
355
667
  }
356
668
  cache.set(rawQuery, compilationResult)
357
669
 
@@ -366,7 +678,22 @@ export function compileQuery(
366
678
  map(([key, row]) => {
367
679
  // Extract the final results from $selected and return [key, [results, undefined]]
368
680
  const raw = (row as any).$selected
369
- const finalResults = unwrapValue(raw)
681
+ const finalResults = attachVirtualPropsToSelected(
682
+ unwrapValue(raw),
683
+ row as Record<string, any>,
684
+ )
685
+ // When in includes mode, embed the correlation key and parentContext
686
+ if (parentKeyStream) {
687
+ const correlationKey = (row as any)[mainSource]?.__correlationKey
688
+ const parentContext = (row as any).__parentContext ?? null
689
+ // Strip internal routing properties that may leak via spread selects
690
+ delete finalResults.__correlationKey
691
+ delete finalResults.__parentContext
692
+ return [
693
+ key,
694
+ [finalResults, undefined, correlationKey, parentContext],
695
+ ] as any
696
+ }
370
697
  return [key, [finalResults, undefined]] as [
371
698
  unknown,
372
699
  [any, string | undefined],
@@ -376,12 +703,13 @@ export function compileQuery(
376
703
 
377
704
  const result = resultPipeline
378
705
  // Cache the result before returning (use original query as key)
379
- const compilationResult = {
706
+ const compilationResult: CompilationResult = {
380
707
  collectionId: mainCollectionId,
381
708
  pipeline: result,
382
709
  sourceWhereClauses,
383
710
  aliasToCollectionId,
384
711
  aliasRemapping,
712
+ includes: includesResults.length > 0 ? includesResults : undefined,
385
713
  }
386
714
  cache.set(rawQuery, compilationResult)
387
715
 
@@ -593,6 +921,35 @@ function unwrapValue(value: any): any {
593
921
  return isValue(value) ? value.value : value
594
922
  }
595
923
 
924
+ function attachVirtualPropsToSelected(
925
+ selected: any,
926
+ row: Record<string, any>,
927
+ ): any {
928
+ if (!selected || typeof selected !== `object`) {
929
+ return selected
930
+ }
931
+
932
+ let needsMerge = false
933
+ for (const prop of VIRTUAL_PROP_NAMES) {
934
+ if (selected[prop] == null && prop in row) {
935
+ needsMerge = true
936
+ break
937
+ }
938
+ }
939
+
940
+ if (!needsMerge) {
941
+ return selected
942
+ }
943
+
944
+ for (const prop of VIRTUAL_PROP_NAMES) {
945
+ if (selected[prop] == null && prop in row) {
946
+ selected[prop] = row[prop]
947
+ }
948
+ }
949
+
950
+ return selected
951
+ }
952
+
596
953
  /**
597
954
  * Recursively maps optimized subqueries to their original queries for proper caching.
598
955
  * This ensures that when we encounter the same QueryRef object in different contexts,
@@ -709,4 +1066,77 @@ export function followRef(
709
1066
  }
710
1067
  }
711
1068
 
1069
+ /**
1070
+ * Walks a Select object to find IncludesSubquery entries at the top level.
1071
+ * Throws if an IncludesSubquery is found nested inside a sub-object, since
1072
+ * the compiler only supports includes at the top level of a select.
1073
+ */
1074
+ function extractIncludesFromSelect(
1075
+ select: Record<string, any>,
1076
+ ): Array<{ key: string; subquery: IncludesSubquery }> {
1077
+ const results: Array<{ key: string; subquery: IncludesSubquery }> = []
1078
+ for (const [key, value] of Object.entries(select)) {
1079
+ if (key.startsWith(`__SPREAD_SENTINEL__`)) continue
1080
+ if (value instanceof IncludesSubquery) {
1081
+ results.push({ key, subquery: value })
1082
+ } else if (isNestedSelectObject(value)) {
1083
+ // Check nested objects for IncludesSubquery — not supported yet
1084
+ assertNoNestedIncludes(value, key)
1085
+ }
1086
+ }
1087
+ return results
1088
+ }
1089
+
1090
+ /** Check if a value is a nested plain object in a select (not an IR expression node) */
1091
+ function isNestedSelectObject(value: any): value is Record<string, any> {
1092
+ return (
1093
+ value != null &&
1094
+ typeof value === `object` &&
1095
+ !Array.isArray(value) &&
1096
+ typeof value.type !== `string`
1097
+ )
1098
+ }
1099
+
1100
+ function assertNoNestedIncludes(
1101
+ obj: Record<string, any>,
1102
+ parentPath: string,
1103
+ ): void {
1104
+ for (const [key, value] of Object.entries(obj)) {
1105
+ if (key.startsWith(`__SPREAD_SENTINEL__`)) continue
1106
+ if (value instanceof IncludesSubquery) {
1107
+ throw new Error(
1108
+ `Includes subqueries must be at the top level of select(). ` +
1109
+ `Found nested includes at "${parentPath}.${key}".`,
1110
+ )
1111
+ }
1112
+ if (isNestedSelectObject(value)) {
1113
+ assertNoNestedIncludes(value, `${parentPath}.${key}`)
1114
+ }
1115
+ }
1116
+ }
1117
+
1118
+ /**
1119
+ * Replaces an IncludesSubquery entry in the select object with a null Value placeholder.
1120
+ * This ensures processSelect() doesn't encounter it.
1121
+ */
1122
+ function replaceIncludesInSelect(
1123
+ select: Record<string, any>,
1124
+ key: string,
1125
+ ): void {
1126
+ select[key] = new ValClass(null)
1127
+ }
1128
+
1129
+ /**
1130
+ * Gets a nested value from an object by path segments.
1131
+ * For v1 with single-level correlation fields (e.g., `projectId`), it's just `obj[path[0]]`.
1132
+ */
1133
+ function getNestedValue(obj: any, path: Array<string>): any {
1134
+ let value = obj
1135
+ for (const segment of path) {
1136
+ if (value == null) return value
1137
+ value = value[segment]
1138
+ }
1139
+ return value
1140
+ }
1141
+
712
1142
  export type CompileQueryFn = typeof compileQuery
@@ -1,4 +1,7 @@
1
- import { orderByWithFractionalIndex } from '@tanstack/db-ivm'
1
+ import {
2
+ groupedOrderByWithFractionalIndex,
3
+ orderByWithFractionalIndex,
4
+ } from '@tanstack/db-ivm'
2
5
  import { defaultComparator, makeComparator } from '../../utils/comparison.js'
3
6
  import { PropRef, followRef } from '../ir.js'
4
7
  import { ensureIndexForField } from '../../indexes/auto-index.js'
@@ -51,6 +54,7 @@ export function processOrderBy(
51
54
  setWindowFn: (windowFn: (options: WindowOptions) => void) => void,
52
55
  limit?: number,
53
56
  offset?: number,
57
+ groupKeyFn?: (key: unknown, value: unknown) => unknown,
54
58
  ): IStreamBuilder<KeyValue<unknown, [NamespacedRow, string]>> {
55
59
  // Pre-compile all order by expressions
56
60
  const compiledOrderBy = orderByClause.map((clause) => {
@@ -126,7 +130,9 @@ export function processOrderBy(
126
130
  // to loadSubset so the sync layer can optimize the query.
127
131
  // We try to use an index on the FIRST orderBy column for lazy loading,
128
132
  // even for multi-column orderBy (using wider bounds on first column).
129
- if (limit) {
133
+ // Skip this optimization when using grouped ordering (includes with limit),
134
+ // because the limit is per-group, not global — the child collection needs all data loaded.
135
+ if (limit && !groupKeyFn) {
130
136
  let index: IndexInterface<string | number> | undefined
131
137
  let followRefCollection: Collection | undefined
132
138
  let firstColumnValueExtractor: CompiledSingleRowExpression | undefined
@@ -152,12 +158,19 @@ export function processOrderBy(
152
158
  )
153
159
 
154
160
  if (fieldName) {
161
+ // Use a single-column comparator for the index, not the
162
+ // multi-column `compare` function. The multi-column comparator
163
+ // expects array values [col1, col2, ...] but the index stores
164
+ // individual field values. Passing `compare` here causes the
165
+ // BTree to treat all single values as equal (since number[0]
166
+ // === undefined for both sides of the comparison).
167
+ const firstColumnCompareFn = makeComparator(compareOpts)
155
168
  ensureIndexForField(
156
169
  fieldName,
157
170
  followRefResult.path,
158
171
  followRefCollection,
159
172
  compareOpts,
160
- compare,
173
+ firstColumnCompareFn,
161
174
  )
162
175
  }
163
176
 
@@ -277,19 +290,45 @@ export function processOrderBy(
277
290
  optimizableOrderByCollections[targetCollectionId] =
278
291
  orderByOptimizationInfo
279
292
 
280
- // Set up lazy loading callback if we have an index
281
- if (index) {
282
- setSizeCallback = (getSize: () => number) => {
283
- optimizableOrderByCollections[targetCollectionId]![`dataNeeded`] =
284
- () => {
285
- const size = getSize()
286
- return Math.max(0, orderByOptimizationInfo!.limit - size)
287
- }
288
- }
293
+ // Set up lazy loading callback to track how much more data is needed
294
+ // This is used by loadMoreIfNeeded to determine if more data should be loaded
295
+ setSizeCallback = (getSize: () => number) => {
296
+ optimizableOrderByCollections[targetCollectionId]![`dataNeeded`] =
297
+ () => {
298
+ const size = getSize()
299
+ return Math.max(0, orderByOptimizationInfo!.limit - size)
300
+ }
289
301
  }
290
302
  }
291
303
  }
292
304
 
305
+ // Use grouped ordering when a groupKeyFn is provided (includes with limit/offset),
306
+ // otherwise use the standard global ordering operator.
307
+ if (groupKeyFn) {
308
+ return pipeline.pipe(
309
+ groupedOrderByWithFractionalIndex(valueExtractor, {
310
+ limit,
311
+ offset,
312
+ comparator: compare,
313
+ setSizeCallback,
314
+ groupKeyFn,
315
+ setWindowFn: (
316
+ windowFn: (options: { offset?: number; limit?: number }) => void,
317
+ ) => {
318
+ setWindowFn((options) => {
319
+ windowFn(options)
320
+ if (orderByOptimizationInfo) {
321
+ orderByOptimizationInfo.offset =
322
+ options.offset ?? orderByOptimizationInfo.offset
323
+ orderByOptimizationInfo.limit =
324
+ options.limit ?? orderByOptimizationInfo.limit
325
+ }
326
+ })
327
+ },
328
+ }),
329
+ )
330
+ }
331
+
293
332
  // Use fractional indexing and return the tuple [value, index]
294
333
  return pipeline.pipe(
295
334
  orderByWithFractionalIndex(valueExtractor, {