@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,9 +1,15 @@
1
- import { MultiSet, serializeValue } from '@tanstack/db-ivm'
2
1
  import {
3
2
  normalizeExpressionPaths,
4
3
  normalizeOrderByPaths,
5
4
  } from '../compiler/expressions.js'
6
- import type { MultiSetArray, RootStreamBuilder } from '@tanstack/db-ivm'
5
+ import {
6
+ computeOrderedLoadCursor,
7
+ computeSubscriptionOrderByHints,
8
+ filterDuplicateInserts,
9
+ sendChangesToInput,
10
+ splitUpdates,
11
+ trackBiggestSentValue,
12
+ } from './utils.js'
7
13
  import type { Collection } from '../../collection/index.js'
8
14
  import type {
9
15
  ChangeMessage,
@@ -147,25 +153,11 @@ export class CollectionSubscriber<
147
153
  changes: Iterable<ChangeMessage<any, string | number>>,
148
154
  callback?: () => boolean,
149
155
  ) {
150
- // Filter changes to prevent duplicate inserts to D2 pipeline.
151
- // This ensures D2 multiplicity stays at 1 for visible items, so deletes
152
- // properly reduce multiplicity to 0 (triggering DELETE output).
153
156
  const changesArray = Array.isArray(changes) ? changes : [...changes]
154
- const filteredChanges: Array<ChangeMessage<any, string | number>> = []
155
- for (const change of changesArray) {
156
- if (change.type === `insert`) {
157
- if (this.sentToD2Keys.has(change.key)) {
158
- // Skip duplicate insert - already sent to D2
159
- continue
160
- }
161
- this.sentToD2Keys.add(change.key)
162
- } else if (change.type === `delete`) {
163
- // Remove from tracking so future re-inserts are allowed
164
- this.sentToD2Keys.delete(change.key)
165
- }
166
- // Updates are handled as delete+insert by splitUpdates, so no special handling needed
167
- filteredChanges.push(change)
168
- }
157
+ const filteredChanges = filterDuplicateInserts(
158
+ changesArray,
159
+ this.sentToD2Keys,
160
+ )
169
161
 
170
162
  // currentSyncState and input are always defined when this method is called
171
163
  // (only called from active subscriptions during a sync session)
@@ -202,27 +194,10 @@ export class CollectionSubscriber<
202
194
  }
203
195
 
204
196
  // Get the query's orderBy and limit to pass to loadSubset.
205
- // Only include orderBy when it is scoped to this alias and uses simple refs,
206
- // to avoid leaking cross-collection paths into backend-specific compilers.
207
- const { orderBy, limit, offset } = this.collectionConfigBuilder.query
208
- const effectiveLimit =
209
- limit !== undefined && offset !== undefined ? limit + offset : limit
210
- const normalizedOrderBy = orderBy
211
- ? normalizeOrderByPaths(orderBy, this.alias)
212
- : undefined
213
- const canPassOrderBy =
214
- normalizedOrderBy?.every((clause) => {
215
- const exp = clause.expression
216
- if (exp.type !== `ref`) {
217
- return false
218
- }
219
- const path = exp.path
220
- return Array.isArray(path) && path.length === 1
221
- }) ?? false
222
- const orderByForSubscription = canPassOrderBy
223
- ? normalizedOrderBy
224
- : undefined
225
- const limitForSubscription = canPassOrderBy ? effectiveLimit : undefined
197
+ const hints = computeSubscriptionOrderByHints(
198
+ this.collectionConfigBuilder.query,
199
+ this.alias,
200
+ )
226
201
 
227
202
  // Track loading via the loadSubset promise directly.
228
203
  // requestSnapshot uses trackLoadSubsetPromise: false (needed for truncate handling),
@@ -241,8 +216,8 @@ export class CollectionSubscriber<
241
216
  ...(includeInitialState && { includeInitialState }),
242
217
  whereExpression,
243
218
  onStatusChange,
244
- orderBy: orderByForSubscription,
245
- limit: limitForSubscription,
219
+ orderBy: hints.orderBy,
220
+ limit: hints.limit,
246
221
  onLoadSubsetResult,
247
222
  })
248
223
 
@@ -415,52 +390,28 @@ export class CollectionSubscriber<
415
390
  if (!orderByInfo) {
416
391
  return
417
392
  }
418
- const { orderBy, valueExtractorForRawRow, offset } = orderByInfo
419
- const biggestSentRow = this.biggest
420
-
421
- // Extract all orderBy column values from the biggest sent row
422
- // For single-column: returns single value, for multi-column: returns array
423
- const extractedValues = biggestSentRow
424
- ? valueExtractorForRawRow(biggestSentRow)
425
- : undefined
426
-
427
- // Normalize to array format for minValues
428
- let minValues: Array<unknown> | undefined
429
- if (extractedValues !== undefined) {
430
- minValues = Array.isArray(extractedValues)
431
- ? extractedValues
432
- : [extractedValues]
433
- }
434
-
435
- const loadRequestKey = this.getLoadRequestKey({
436
- minValues,
437
- offset,
438
- limit: n,
439
- })
440
393
 
441
- // Skip if we already requested a load for this cursor+window.
442
- // This prevents infinite loops from cached data re-writes while still allowing
443
- // window moves (offset/limit changes) to trigger new requests.
444
- if (this.lastLoadRequestKey === loadRequestKey) {
445
- return
446
- }
394
+ const cursor = computeOrderedLoadCursor(
395
+ orderByInfo,
396
+ this.biggest,
397
+ this.lastLoadRequestKey,
398
+ this.alias,
399
+ n,
400
+ )
401
+ if (!cursor) return // Duplicate request — skip
447
402
 
448
- // Normalize the orderBy clauses such that the references are relative to the collection
449
- const normalizedOrderBy = normalizeOrderByPaths(orderBy, this.alias)
403
+ this.lastLoadRequestKey = cursor.loadRequestKey
450
404
 
451
405
  // Take the `n` items after the biggest sent value
452
- // Pass the current window offset to ensure proper deduplication
406
+ // Omit offset so requestLimitedSnapshot can advance based on
407
+ // the number of rows already loaded (supports offset-based backends).
453
408
  subscription.requestLimitedSnapshot({
454
- orderBy: normalizedOrderBy,
409
+ orderBy: cursor.normalizedOrderBy,
455
410
  limit: n,
456
- minValues,
457
- // Omit offset so requestLimitedSnapshot can advance the offset based on
458
- // the number of rows already loaded (supports offset-based backends).
411
+ minValues: cursor.minValues,
459
412
  trackLoadSubsetPromise: false,
460
413
  onLoadSubsetResult: this.orderedLoadSubsetResult,
461
414
  })
462
-
463
- this.lastLoadRequestKey = loadRequestKey
464
415
  }
465
416
 
466
417
  private getWhereClauseForAlias(): BasicExpression<boolean> | undefined {
@@ -487,24 +438,15 @@ export class CollectionSubscriber<
487
438
  changes: Array<ChangeMessage<any, string | number>>,
488
439
  comparator: (a: any, b: any) => number,
489
440
  ): void {
490
- for (const change of changes) {
491
- if (change.type === `delete`) {
492
- continue
493
- }
494
-
495
- const isNewKey = !this.sentToD2Keys.has(change.key)
496
-
497
- // Only track inserts/updates for cursor positioning, not deletes
498
- if (!this.biggest) {
499
- this.biggest = change.value
500
- this.lastLoadRequestKey = undefined
501
- } else if (comparator(this.biggest, change.value) < 0) {
502
- this.biggest = change.value
503
- this.lastLoadRequestKey = undefined
504
- } else if (isNewKey) {
505
- // New key with same orderBy value - allow another load if needed
506
- this.lastLoadRequestKey = undefined
507
- }
441
+ const result = trackBiggestSentValue(
442
+ changes,
443
+ this.biggest,
444
+ this.sentToD2Keys,
445
+ comparator,
446
+ )
447
+ this.biggest = result.biggest
448
+ if (result.shouldResetLoadKey) {
449
+ this.lastLoadRequestKey = undefined
508
450
  }
509
451
  }
510
452
 
@@ -525,62 +467,4 @@ export class CollectionSubscriber<
525
467
  promise,
526
468
  )
527
469
  }
528
-
529
- private getLoadRequestKey(options: {
530
- minValues: Array<unknown> | undefined
531
- offset: number
532
- limit: number
533
- }): string {
534
- return serializeValue({
535
- minValues: options.minValues ?? null,
536
- offset: options.offset,
537
- limit: options.limit,
538
- })
539
- }
540
- }
541
-
542
- /**
543
- * Helper function to send changes to a D2 input stream
544
- */
545
- function sendChangesToInput(
546
- input: RootStreamBuilder<unknown>,
547
- changes: Iterable<ChangeMessage>,
548
- getKey: (item: ChangeMessage[`value`]) => any,
549
- ): number {
550
- const multiSetArray: MultiSetArray<unknown> = []
551
- for (const change of changes) {
552
- const key = getKey(change.value)
553
- if (change.type === `insert`) {
554
- multiSetArray.push([[key, change.value], 1])
555
- } else if (change.type === `update`) {
556
- multiSetArray.push([[key, change.previousValue], -1])
557
- multiSetArray.push([[key, change.value], 1])
558
- } else {
559
- // change.type === `delete`
560
- multiSetArray.push([[key, change.value], -1])
561
- }
562
- }
563
-
564
- if (multiSetArray.length !== 0) {
565
- input.sendData(new MultiSet(multiSetArray))
566
- }
567
-
568
- return multiSetArray.length
569
- }
570
-
571
- /** Splits updates into a delete of the old value and an insert of the new value */
572
- function* splitUpdates<
573
- T extends object = Record<string, unknown>,
574
- TKey extends string | number = string | number,
575
- >(
576
- changes: Iterable<ChangeMessage<T, TKey>>,
577
- ): Generator<ChangeMessage<T, TKey>> {
578
- for (const change of changes) {
579
- if (change.type === `update`) {
580
- yield { type: `delete`, key: change.key, value: change.previousValue! }
581
- yield { type: `insert`, key: change.key, value: change.value }
582
- } else {
583
- yield change
584
- }
585
- }
586
470
  }
@@ -5,7 +5,11 @@ import type {
5
5
  StringCollationConfig,
6
6
  } from '../../types.js'
7
7
  import type { InitialQueryBuilder, QueryBuilder } from '../builder/index.js'
8
- import type { Context, GetResult } from '../builder/types.js'
8
+ import type {
9
+ Context,
10
+ RootObjectResultConstraint,
11
+ RootQueryResult,
12
+ } from '../builder/types.js'
9
13
 
10
14
  export type Changes<T> = {
11
15
  deletes: number
@@ -54,7 +58,7 @@ export type FullSyncState = Required<Omit<SyncState, `flushPendingChanges`>> &
54
58
  */
55
59
  export interface LiveQueryCollectionConfig<
56
60
  TContext extends Context,
57
- TResult extends object = GetResult<TContext> & object,
61
+ TResult extends object = RootQueryResult<TContext>,
58
62
  > {
59
63
  /**
60
64
  * Unique identifier for the collection
@@ -66,8 +70,10 @@ export interface LiveQueryCollectionConfig<
66
70
  * Query builder function that defines the live query
67
71
  */
68
72
  query:
69
- | ((q: InitialQueryBuilder) => QueryBuilder<TContext>)
70
- | QueryBuilder<TContext>
73
+ | ((
74
+ q: InitialQueryBuilder,
75
+ ) => QueryBuilder<TContext> & RootObjectResultConstraint<TContext>)
76
+ | (QueryBuilder<TContext> & RootObjectResultConstraint<TContext>)
71
77
 
72
78
  /**
73
79
  * Function to extract the key from result items
@@ -0,0 +1,417 @@
1
+ import { MultiSet, serializeValue } from '@tanstack/db-ivm'
2
+ import { UnsupportedRootScalarSelectError } from '../../errors.js'
3
+ import { normalizeOrderByPaths } from '../compiler/expressions.js'
4
+ import { buildQuery, getQueryIR } from '../builder/index.js'
5
+ import { IncludesSubquery } from '../ir.js'
6
+ import type { MultiSetArray, RootStreamBuilder } from '@tanstack/db-ivm'
7
+ import type { Collection } from '../../collection/index.js'
8
+ import type { ChangeMessage } from '../../types.js'
9
+ import type { InitialQueryBuilder, QueryBuilder } from '../builder/index.js'
10
+ import type { Context } from '../builder/types.js'
11
+ import type { OrderBy, QueryIR } from '../ir.js'
12
+ import type { OrderByOptimizationInfo } from '../compiler/order-by.js'
13
+
14
+ /**
15
+ * Helper function to extract collections from a compiled query.
16
+ * Traverses the query IR to find all collection references.
17
+ * Maps collections by their ID (not alias) as expected by the compiler.
18
+ */
19
+ export function extractCollectionsFromQuery(
20
+ query: any,
21
+ ): Record<string, Collection<any, any, any>> {
22
+ const collections: Record<string, any> = {}
23
+
24
+ // Helper function to recursively extract collections from a query or source
25
+ function extractFromSource(source: any) {
26
+ if (source.type === `collectionRef`) {
27
+ collections[source.collection.id] = source.collection
28
+ } else if (source.type === `queryRef`) {
29
+ // Recursively extract from subquery
30
+ extractFromQuery(source.query)
31
+ }
32
+ }
33
+
34
+ // Helper function to recursively extract collections from a query
35
+ function extractFromQuery(q: any) {
36
+ // Extract from FROM clause
37
+ if (q.from) {
38
+ extractFromSource(q.from)
39
+ }
40
+
41
+ // Extract from JOIN clauses
42
+ if (q.join && Array.isArray(q.join)) {
43
+ for (const joinClause of q.join) {
44
+ if (joinClause.from) {
45
+ extractFromSource(joinClause.from)
46
+ }
47
+ }
48
+ }
49
+
50
+ // Extract from SELECT (for IncludesSubquery)
51
+ if (q.select) {
52
+ extractFromSelect(q.select)
53
+ }
54
+ }
55
+
56
+ function extractFromSelect(select: any) {
57
+ for (const [key, value] of Object.entries(select)) {
58
+ if (typeof key === `string` && key.startsWith(`__SPREAD_SENTINEL__`)) {
59
+ continue
60
+ }
61
+ if (value instanceof IncludesSubquery) {
62
+ extractFromQuery(value.query)
63
+ } else if (isNestedSelectObject(value)) {
64
+ extractFromSelect(value)
65
+ }
66
+ }
67
+ }
68
+
69
+ // Start extraction from the root query
70
+ extractFromQuery(query)
71
+
72
+ return collections
73
+ }
74
+
75
+ /**
76
+ * Helper function to extract the collection that is referenced in the query's FROM clause.
77
+ * The FROM clause may refer directly to a collection or indirectly to a subquery.
78
+ */
79
+ export function extractCollectionFromSource(
80
+ query: any,
81
+ ): Collection<any, any, any> {
82
+ const from = query.from
83
+
84
+ if (from.type === `collectionRef`) {
85
+ return from.collection
86
+ } else if (from.type === `queryRef`) {
87
+ // Recursively extract from subquery
88
+ return extractCollectionFromSource(from.query)
89
+ }
90
+
91
+ throw new Error(
92
+ `Failed to extract collection. Invalid FROM clause: ${JSON.stringify(query)}`,
93
+ )
94
+ }
95
+
96
+ /**
97
+ * Extracts all aliases used for each collection across the entire query tree.
98
+ *
99
+ * Traverses the QueryIR recursively to build a map from collection ID to all aliases
100
+ * that reference that collection. This is essential for self-join support, where the
101
+ * same collection may be referenced multiple times with different aliases.
102
+ *
103
+ * For example, given a query like:
104
+ * ```ts
105
+ * q.from({ employee: employeesCollection })
106
+ * .join({ manager: employeesCollection }, ({ employee, manager }) =>
107
+ * eq(employee.managerId, manager.id)
108
+ * )
109
+ * ```
110
+ *
111
+ * This function would return:
112
+ * ```
113
+ * Map { "employees" => Set { "employee", "manager" } }
114
+ * ```
115
+ *
116
+ * @param query - The query IR to extract aliases from
117
+ * @returns A map from collection ID to the set of all aliases referencing that collection
118
+ */
119
+ export function extractCollectionAliases(
120
+ query: QueryIR,
121
+ ): Map<string, Set<string>> {
122
+ const aliasesById = new Map<string, Set<string>>()
123
+
124
+ function recordAlias(source: any) {
125
+ if (!source) return
126
+
127
+ if (source.type === `collectionRef`) {
128
+ const { id } = source.collection
129
+ const existing = aliasesById.get(id)
130
+ if (existing) {
131
+ existing.add(source.alias)
132
+ } else {
133
+ aliasesById.set(id, new Set([source.alias]))
134
+ }
135
+ } else if (source.type === `queryRef`) {
136
+ traverse(source.query)
137
+ }
138
+ }
139
+
140
+ function traverseSelect(select: any) {
141
+ for (const [key, value] of Object.entries(select)) {
142
+ if (typeof key === `string` && key.startsWith(`__SPREAD_SENTINEL__`)) {
143
+ continue
144
+ }
145
+ if (value instanceof IncludesSubquery) {
146
+ traverse(value.query)
147
+ } else if (isNestedSelectObject(value)) {
148
+ traverseSelect(value)
149
+ }
150
+ }
151
+ }
152
+
153
+ function traverse(q?: QueryIR) {
154
+ if (!q) return
155
+
156
+ recordAlias(q.from)
157
+
158
+ if (q.join) {
159
+ for (const joinClause of q.join) {
160
+ recordAlias(joinClause.from)
161
+ }
162
+ }
163
+
164
+ if (q.select) {
165
+ traverseSelect(q.select)
166
+ }
167
+ }
168
+
169
+ traverse(query)
170
+
171
+ return aliasesById
172
+ }
173
+
174
+ /**
175
+ * Check if a value is a nested select object (plain object, not an expression)
176
+ */
177
+ function isNestedSelectObject(obj: any): boolean {
178
+ if (obj === null || typeof obj !== `object`) return false
179
+ if (obj instanceof IncludesSubquery) return false
180
+ // Expression-like objects have a .type property
181
+ if (`type` in obj && typeof obj.type === `string`) return false
182
+ // Ref proxies from spread operations
183
+ if (obj.__refProxy) return false
184
+ return true
185
+ }
186
+
187
+ /**
188
+ * Builds a query IR from a config object that contains either a query builder
189
+ * function or a QueryBuilder instance.
190
+ */
191
+ export function buildQueryFromConfig<TContext extends Context>(config: {
192
+ query:
193
+ | ((q: InitialQueryBuilder) => QueryBuilder<TContext>)
194
+ | QueryBuilder<TContext>
195
+ requireObjectResult?: boolean
196
+ }): QueryIR {
197
+ // Build the query using the provided query builder function or instance
198
+ const query =
199
+ typeof config.query === `function`
200
+ ? buildQuery<TContext>(config.query)
201
+ : getQueryIR(config.query)
202
+
203
+ if (
204
+ config.requireObjectResult &&
205
+ query.select &&
206
+ !isNestedSelectObject(query.select)
207
+ ) {
208
+ throw new UnsupportedRootScalarSelectError()
209
+ }
210
+
211
+ return query
212
+ }
213
+
214
+ /**
215
+ * Helper function to send changes to a D2 input stream.
216
+ * Converts ChangeMessages to D2 MultiSet data and sends to the input.
217
+ *
218
+ * @returns The number of multiset entries sent
219
+ */
220
+ export function sendChangesToInput(
221
+ input: RootStreamBuilder<unknown>,
222
+ changes: Iterable<ChangeMessage>,
223
+ getKey: (item: ChangeMessage[`value`]) => any,
224
+ ): number {
225
+ const multiSetArray: MultiSetArray<unknown> = []
226
+ for (const change of changes) {
227
+ const key = getKey(change.value)
228
+ if (change.type === `insert`) {
229
+ multiSetArray.push([[key, change.value], 1])
230
+ } else if (change.type === `update`) {
231
+ multiSetArray.push([[key, change.previousValue], -1])
232
+ multiSetArray.push([[key, change.value], 1])
233
+ } else {
234
+ // change.type === `delete`
235
+ multiSetArray.push([[key, change.value], -1])
236
+ }
237
+ }
238
+
239
+ if (multiSetArray.length !== 0) {
240
+ input.sendData(new MultiSet(multiSetArray))
241
+ }
242
+
243
+ return multiSetArray.length
244
+ }
245
+
246
+ /** Splits updates into a delete of the old value and an insert of the new value */
247
+ export function* splitUpdates<
248
+ T extends object = Record<string, unknown>,
249
+ TKey extends string | number = string | number,
250
+ >(
251
+ changes: Iterable<ChangeMessage<T, TKey>>,
252
+ ): Generator<ChangeMessage<T, TKey>> {
253
+ for (const change of changes) {
254
+ if (change.type === `update`) {
255
+ yield { type: `delete`, key: change.key, value: change.previousValue! }
256
+ yield { type: `insert`, key: change.key, value: change.value }
257
+ } else {
258
+ yield change
259
+ }
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Filter changes to prevent duplicate inserts to a D2 pipeline.
265
+ * Maintains D2 multiplicity at 1 for visible items so that deletes
266
+ * properly reduce multiplicity to 0.
267
+ *
268
+ * Mutates `sentKeys` in place: adds keys on insert, removes on delete.
269
+ */
270
+ export function filterDuplicateInserts(
271
+ changes: Array<ChangeMessage<any, string | number>>,
272
+ sentKeys: Set<string | number>,
273
+ ): Array<ChangeMessage<any, string | number>> {
274
+ const filtered: Array<ChangeMessage<any, string | number>> = []
275
+ for (const change of changes) {
276
+ if (change.type === `insert`) {
277
+ if (sentKeys.has(change.key)) {
278
+ continue // Skip duplicate
279
+ }
280
+ sentKeys.add(change.key)
281
+ } else if (change.type === `delete`) {
282
+ sentKeys.delete(change.key)
283
+ }
284
+ filtered.push(change)
285
+ }
286
+ return filtered
287
+ }
288
+
289
+ /**
290
+ * Track the biggest value seen in a stream of changes, used for cursor-based
291
+ * pagination in ordered subscriptions. Returns whether the load request key
292
+ * should be reset (allowing another load).
293
+ *
294
+ * @param changes - changes to process (deletes are skipped)
295
+ * @param current - the current biggest value (or undefined if none)
296
+ * @param sentKeys - set of keys already sent to D2 (for new-key detection)
297
+ * @param comparator - orderBy comparator
298
+ * @returns `{ biggest, shouldResetLoadKey }` — the new biggest value and
299
+ * whether the caller should clear its last-load-request-key
300
+ */
301
+ export function trackBiggestSentValue(
302
+ changes: Array<ChangeMessage<any, string | number>>,
303
+ current: unknown | undefined,
304
+ sentKeys: Set<string | number>,
305
+ comparator: (a: any, b: any) => number,
306
+ ): { biggest: unknown; shouldResetLoadKey: boolean } {
307
+ let biggest = current
308
+ let shouldResetLoadKey = false
309
+
310
+ for (const change of changes) {
311
+ if (change.type === `delete`) continue
312
+
313
+ const isNewKey = !sentKeys.has(change.key)
314
+
315
+ if (biggest === undefined) {
316
+ biggest = change.value
317
+ shouldResetLoadKey = true
318
+ } else if (comparator(biggest, change.value) < 0) {
319
+ biggest = change.value
320
+ shouldResetLoadKey = true
321
+ } else if (isNewKey) {
322
+ // New key at same sort position — allow another load if needed
323
+ shouldResetLoadKey = true
324
+ }
325
+ }
326
+
327
+ return { biggest, shouldResetLoadKey }
328
+ }
329
+
330
+ /**
331
+ * Compute orderBy/limit subscription hints for an alias.
332
+ * Returns normalised orderBy and effective limit suitable for passing to
333
+ * `subscribeChanges`, or `undefined` values when the query's orderBy cannot
334
+ * be scoped to the given alias (e.g. cross-collection refs or aggregates).
335
+ */
336
+ export function computeSubscriptionOrderByHints(
337
+ query: { orderBy?: OrderBy; limit?: number; offset?: number },
338
+ alias: string,
339
+ ): { orderBy: OrderBy | undefined; limit: number | undefined } {
340
+ const { orderBy, limit, offset } = query
341
+ const effectiveLimit =
342
+ limit !== undefined && offset !== undefined ? limit + offset : limit
343
+
344
+ const normalizedOrderBy = orderBy
345
+ ? normalizeOrderByPaths(orderBy, alias)
346
+ : undefined
347
+
348
+ // Only pass orderBy when it is scoped to this alias and uses simple refs,
349
+ // to avoid leaking cross-collection paths into backend-specific compilers.
350
+ const canPassOrderBy =
351
+ normalizedOrderBy?.every((clause) => {
352
+ const exp = clause.expression
353
+ if (exp.type !== `ref`) return false
354
+ const path = exp.path
355
+ return Array.isArray(path) && path.length === 1
356
+ }) ?? false
357
+
358
+ return {
359
+ orderBy: canPassOrderBy ? normalizedOrderBy : undefined,
360
+ limit: canPassOrderBy ? effectiveLimit : undefined,
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Compute the cursor for loading the next batch of ordered data.
366
+ * Extracts values from the biggest sent row and builds the `minValues`
367
+ * array and a deduplication key.
368
+ *
369
+ * @returns `undefined` if the load should be skipped (duplicate request),
370
+ * otherwise `{ minValues, normalizedOrderBy, loadRequestKey }`.
371
+ */
372
+ export function computeOrderedLoadCursor(
373
+ orderByInfo: Pick<
374
+ OrderByOptimizationInfo,
375
+ 'orderBy' | 'valueExtractorForRawRow' | 'offset'
376
+ >,
377
+ biggestSentRow: unknown | undefined,
378
+ lastLoadRequestKey: string | undefined,
379
+ alias: string,
380
+ limit: number,
381
+ ):
382
+ | {
383
+ minValues: Array<unknown> | undefined
384
+ normalizedOrderBy: OrderBy
385
+ loadRequestKey: string
386
+ }
387
+ | undefined {
388
+ const { orderBy, valueExtractorForRawRow, offset } = orderByInfo
389
+
390
+ // Extract all orderBy column values from the biggest sent row
391
+ // For single-column: returns single value, for multi-column: returns array
392
+ const extractedValues = biggestSentRow
393
+ ? valueExtractorForRawRow(biggestSentRow as Record<string, unknown>)
394
+ : undefined
395
+
396
+ // Normalize to array format for minValues
397
+ let minValues: Array<unknown> | undefined
398
+ if (extractedValues !== undefined) {
399
+ minValues = Array.isArray(extractedValues)
400
+ ? extractedValues
401
+ : [extractedValues]
402
+ }
403
+
404
+ // Deduplicate: skip if we already issued an identical load request
405
+ const loadRequestKey = serializeValue({
406
+ minValues: minValues ?? null,
407
+ offset,
408
+ limit,
409
+ })
410
+ if (lastLoadRequestKey === loadRequestKey) {
411
+ return undefined
412
+ }
413
+
414
+ const normalizedOrderBy = normalizeOrderByPaths(orderBy, alias)
415
+
416
+ return { minValues, normalizedOrderBy, loadRequestKey }
417
+ }