@tanstack/db 0.4.7 → 0.4.9

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 (115) hide show
  1. package/dist/cjs/collection/index.cjs.map +1 -1
  2. package/dist/cjs/collection/index.d.cts +2 -1
  3. package/dist/cjs/collection/lifecycle.cjs +2 -3
  4. package/dist/cjs/collection/lifecycle.cjs.map +1 -1
  5. package/dist/cjs/collection/state.cjs +22 -33
  6. package/dist/cjs/collection/state.cjs.map +1 -1
  7. package/dist/cjs/collection/state.d.cts +6 -2
  8. package/dist/cjs/collection/sync.cjs +4 -3
  9. package/dist/cjs/collection/sync.cjs.map +1 -1
  10. package/dist/cjs/errors.cjs +51 -17
  11. package/dist/cjs/errors.cjs.map +1 -1
  12. package/dist/cjs/errors.d.cts +38 -8
  13. package/dist/cjs/index.cjs +8 -4
  14. package/dist/cjs/index.cjs.map +1 -1
  15. package/dist/cjs/indexes/auto-index.cjs +0 -3
  16. package/dist/cjs/indexes/auto-index.cjs.map +1 -1
  17. package/dist/cjs/query/builder/types.d.cts +1 -1
  18. package/dist/cjs/query/compiler/index.cjs +42 -19
  19. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  20. package/dist/cjs/query/compiler/index.d.cts +33 -8
  21. package/dist/cjs/query/compiler/joins.cjs +88 -66
  22. package/dist/cjs/query/compiler/joins.cjs.map +1 -1
  23. package/dist/cjs/query/compiler/joins.d.cts +5 -2
  24. package/dist/cjs/query/compiler/order-by.cjs +2 -0
  25. package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
  26. package/dist/cjs/query/compiler/order-by.d.cts +1 -0
  27. package/dist/cjs/query/compiler/select.cjs.map +1 -1
  28. package/dist/cjs/query/live/collection-config-builder.cjs +322 -46
  29. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  30. package/dist/cjs/query/live/collection-config-builder.d.cts +98 -7
  31. package/dist/cjs/query/live/collection-registry.cjs +16 -0
  32. package/dist/cjs/query/live/collection-registry.cjs.map +1 -0
  33. package/dist/cjs/query/live/collection-registry.d.cts +26 -0
  34. package/dist/cjs/query/live/collection-subscriber.cjs +57 -58
  35. package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
  36. package/dist/cjs/query/live/collection-subscriber.d.cts +4 -7
  37. package/dist/cjs/query/live-query-collection.cjs +11 -5
  38. package/dist/cjs/query/live-query-collection.cjs.map +1 -1
  39. package/dist/cjs/query/live-query-collection.d.cts +10 -3
  40. package/dist/cjs/query/optimizer.cjs +44 -7
  41. package/dist/cjs/query/optimizer.cjs.map +1 -1
  42. package/dist/cjs/query/optimizer.d.cts +4 -4
  43. package/dist/cjs/scheduler.cjs +137 -0
  44. package/dist/cjs/scheduler.cjs.map +1 -0
  45. package/dist/cjs/scheduler.d.cts +56 -0
  46. package/dist/cjs/transactions.cjs +7 -1
  47. package/dist/cjs/transactions.cjs.map +1 -1
  48. package/dist/cjs/types.d.cts +3 -5
  49. package/dist/esm/collection/index.d.ts +2 -1
  50. package/dist/esm/collection/index.js.map +1 -1
  51. package/dist/esm/collection/lifecycle.js +2 -3
  52. package/dist/esm/collection/lifecycle.js.map +1 -1
  53. package/dist/esm/collection/state.d.ts +6 -2
  54. package/dist/esm/collection/state.js +22 -33
  55. package/dist/esm/collection/state.js.map +1 -1
  56. package/dist/esm/collection/sync.js +4 -3
  57. package/dist/esm/collection/sync.js.map +1 -1
  58. package/dist/esm/errors.d.ts +38 -8
  59. package/dist/esm/errors.js +52 -18
  60. package/dist/esm/errors.js.map +1 -1
  61. package/dist/esm/index.js +9 -5
  62. package/dist/esm/indexes/auto-index.js +0 -3
  63. package/dist/esm/indexes/auto-index.js.map +1 -1
  64. package/dist/esm/query/builder/types.d.ts +1 -1
  65. package/dist/esm/query/compiler/index.d.ts +33 -8
  66. package/dist/esm/query/compiler/index.js +42 -19
  67. package/dist/esm/query/compiler/index.js.map +1 -1
  68. package/dist/esm/query/compiler/joins.d.ts +5 -2
  69. package/dist/esm/query/compiler/joins.js +90 -68
  70. package/dist/esm/query/compiler/joins.js.map +1 -1
  71. package/dist/esm/query/compiler/order-by.d.ts +1 -0
  72. package/dist/esm/query/compiler/order-by.js +2 -0
  73. package/dist/esm/query/compiler/order-by.js.map +1 -1
  74. package/dist/esm/query/compiler/select.js.map +1 -1
  75. package/dist/esm/query/live/collection-config-builder.d.ts +98 -7
  76. package/dist/esm/query/live/collection-config-builder.js +322 -46
  77. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  78. package/dist/esm/query/live/collection-registry.d.ts +26 -0
  79. package/dist/esm/query/live/collection-registry.js +16 -0
  80. package/dist/esm/query/live/collection-registry.js.map +1 -0
  81. package/dist/esm/query/live/collection-subscriber.d.ts +4 -7
  82. package/dist/esm/query/live/collection-subscriber.js +57 -58
  83. package/dist/esm/query/live/collection-subscriber.js.map +1 -1
  84. package/dist/esm/query/live-query-collection.d.ts +10 -3
  85. package/dist/esm/query/live-query-collection.js +11 -5
  86. package/dist/esm/query/live-query-collection.js.map +1 -1
  87. package/dist/esm/query/optimizer.d.ts +4 -4
  88. package/dist/esm/query/optimizer.js +44 -7
  89. package/dist/esm/query/optimizer.js.map +1 -1
  90. package/dist/esm/scheduler.d.ts +56 -0
  91. package/dist/esm/scheduler.js +137 -0
  92. package/dist/esm/scheduler.js.map +1 -0
  93. package/dist/esm/transactions.js +7 -1
  94. package/dist/esm/transactions.js.map +1 -1
  95. package/dist/esm/types.d.ts +3 -5
  96. package/package.json +2 -2
  97. package/src/collection/index.ts +1 -1
  98. package/src/collection/lifecycle.ts +3 -4
  99. package/src/collection/state.ts +52 -48
  100. package/src/collection/sync.ts +7 -6
  101. package/src/errors.ts +79 -13
  102. package/src/indexes/auto-index.ts +0 -8
  103. package/src/query/builder/types.ts +1 -1
  104. package/src/query/compiler/index.ts +115 -32
  105. package/src/query/compiler/joins.ts +180 -127
  106. package/src/query/compiler/order-by.ts +7 -0
  107. package/src/query/compiler/select.ts +2 -3
  108. package/src/query/live/collection-config-builder.ts +542 -71
  109. package/src/query/live/collection-registry.ts +47 -0
  110. package/src/query/live/collection-subscriber.ts +87 -105
  111. package/src/query/live-query-collection.ts +39 -14
  112. package/src/query/optimizer.ts +85 -15
  113. package/src/scheduler.ts +198 -0
  114. package/src/transactions.ts +12 -1
  115. package/src/types.ts +3 -5
@@ -0,0 +1,47 @@
1
+ import type { Collection } from "../../collection/index.js"
2
+ import type { CollectionConfigBuilder } from "./collection-config-builder.js"
3
+
4
+ const collectionBuilderRegistry = new WeakMap<
5
+ Collection<any, any, any>,
6
+ CollectionConfigBuilder<any, any>
7
+ >()
8
+
9
+ /**
10
+ * Retrieves the builder attached to a config object via its utils.getBuilder() method.
11
+ *
12
+ * @param config - The collection config object
13
+ * @returns The attached builder, or `undefined` if none exists
14
+ */
15
+ export function getBuilderFromConfig(
16
+ config: object
17
+ ): CollectionConfigBuilder<any, any> | undefined {
18
+ return (config as any).utils?.getBuilder?.()
19
+ }
20
+
21
+ /**
22
+ * Registers a builder for a collection in the global registry.
23
+ * Used to detect when a live query depends on another live query,
24
+ * enabling the scheduler to ensure parent queries run first.
25
+ *
26
+ * @param collection - The collection to register the builder for
27
+ * @param builder - The builder that produces this collection
28
+ */
29
+ export function registerCollectionBuilder(
30
+ collection: Collection<any, any, any>,
31
+ builder: CollectionConfigBuilder<any, any>
32
+ ): void {
33
+ collectionBuilderRegistry.set(collection, builder)
34
+ }
35
+
36
+ /**
37
+ * Retrieves the builder registered for a collection.
38
+ * Used to discover dependencies when a live query subscribes to another live query.
39
+ *
40
+ * @param collection - The collection to look up
41
+ * @returns The registered builder, or `undefined` if none exists
42
+ */
43
+ export function getCollectionBuilder(
44
+ collection: Collection<any, any, any>
45
+ ): CollectionConfigBuilder<any, any> | undefined {
46
+ return collectionBuilderRegistry.get(collection)
47
+ }
@@ -3,15 +3,20 @@ import {
3
3
  convertOrderByToBasicExpression,
4
4
  convertToBasicExpression,
5
5
  } from "../compiler/expressions.js"
6
- import type { FullSyncState } from "./types.js"
6
+ import { WhereClauseConversionError } from "../../errors.js"
7
7
  import type { MultiSetArray, RootStreamBuilder } from "@tanstack/db-ivm"
8
8
  import type { Collection } from "../../collection/index.js"
9
- import type { ChangeMessage, SyncConfig } from "../../types.js"
9
+ import type { ChangeMessage } from "../../types.js"
10
10
  import type { Context, GetResult } from "../builder/types.js"
11
11
  import type { BasicExpression } from "../ir.js"
12
+ import type { OrderByOptimizationInfo } from "../compiler/order-by.js"
12
13
  import type { CollectionConfigBuilder } from "./collection-config-builder.js"
13
14
  import type { CollectionSubscription } from "../../collection/subscription.js"
14
15
 
16
+ const loadMoreCallbackSymbol = Symbol.for(
17
+ `@tanstack/db.collection-config-builder`
18
+ )
19
+
15
20
  export class CollectionSubscriber<
16
21
  TContext extends Context,
17
22
  TResult extends object = GetResult<TContext>,
@@ -19,61 +24,42 @@ export class CollectionSubscriber<
19
24
  // Keep track of the biggest value we've sent so far (needed for orderBy optimization)
20
25
  private biggest: any = undefined
21
26
 
22
- private collectionAlias: string
23
-
24
27
  constructor(
28
+ private alias: string,
25
29
  private collectionId: string,
26
30
  private collection: Collection,
27
- private config: Parameters<SyncConfig<TResult>[`sync`]>[0],
28
- private syncState: FullSyncState,
29
31
  private collectionConfigBuilder: CollectionConfigBuilder<TContext, TResult>
30
- ) {
31
- this.collectionAlias = findCollectionAlias(
32
- this.collectionId,
33
- this.collectionConfigBuilder.query
34
- )!
35
- }
32
+ ) {}
36
33
 
37
34
  subscribe(): CollectionSubscription {
38
- const whereClause = this.getWhereClauseFromAlias(this.collectionAlias)
35
+ const whereClause = this.getWhereClauseForAlias()
39
36
 
40
37
  if (whereClause) {
41
- // Convert WHERE clause to BasicExpression format for collection subscription
42
- const whereExpression = convertToBasicExpression(
43
- whereClause,
44
- this.collectionAlias
45
- )
38
+ const whereExpression = convertToBasicExpression(whereClause, this.alias)
46
39
 
47
40
  if (whereExpression) {
48
- // Use index optimization for this collection
49
41
  return this.subscribeToChanges(whereExpression)
50
- } else {
51
- // This should not happen - if we have a whereClause but can't create whereExpression,
52
- // it indicates a bug in our optimization logic
53
- throw new Error(
54
- `Failed to convert WHERE clause to collection filter for collection '${this.collectionId}'. ` +
55
- `This indicates a bug in the query optimization logic.`
56
- )
57
42
  }
58
- } else {
59
- // No WHERE clause for this collection, use regular subscription
60
- return this.subscribeToChanges()
43
+
44
+ throw new WhereClauseConversionError(this.collectionId, this.alias)
61
45
  }
46
+
47
+ return this.subscribeToChanges()
62
48
  }
63
49
 
64
50
  private subscribeToChanges(whereExpression?: BasicExpression<boolean>) {
65
51
  let subscription: CollectionSubscription
66
- if (
67
- Object.hasOwn(
68
- this.collectionConfigBuilder.optimizableOrderByCollections,
69
- this.collectionId
52
+ const orderByInfo = this.getOrderByInfo()
53
+ if (orderByInfo) {
54
+ subscription = this.subscribeToOrderedChanges(
55
+ whereExpression,
56
+ orderByInfo
70
57
  )
71
- ) {
72
- subscription = this.subscribeToOrderedChanges(whereExpression)
73
58
  } else {
74
- // If the collection is lazy then we should not include the initial state
75
- const includeInitialState =
76
- !this.collectionConfigBuilder.lazyCollections.has(this.collectionId)
59
+ // If the source alias is lazy then we should not include the initial state
60
+ const includeInitialState = !this.collectionConfigBuilder.isLazyAlias(
61
+ this.alias
62
+ )
77
63
 
78
64
  subscription = this.subscribeToMatchingChanges(
79
65
  whereExpression,
@@ -83,7 +69,11 @@ export class CollectionSubscriber<
83
69
  const unsubscribe = () => {
84
70
  subscription.unsubscribe()
85
71
  }
86
- this.syncState.unsubscribeCallbacks.add(unsubscribe)
72
+ // currentSyncState is always defined when subscribe() is called
73
+ // (called during sync session setup)
74
+ this.collectionConfigBuilder.currentSyncState!.unsubscribeCallbacks.add(
75
+ unsubscribe
76
+ )
87
77
  return subscription
88
78
  }
89
79
 
@@ -91,7 +81,10 @@ export class CollectionSubscriber<
91
81
  changes: Iterable<ChangeMessage<any, string | number>>,
92
82
  callback?: () => boolean
93
83
  ) {
94
- const input = this.syncState.inputs[this.collectionId]!
84
+ // currentSyncState and input are always defined when this method is called
85
+ // (only called from active subscriptions during a sync session)
86
+ const input =
87
+ this.collectionConfigBuilder.currentSyncState!.inputs[this.alias]!
95
88
  const sentChanges = sendChangesToInput(
96
89
  input,
97
90
  changes,
@@ -103,14 +96,12 @@ export class CollectionSubscriber<
103
96
  // otherwise we end up in an infinite loop trying to load more data
104
97
  const dataLoader = sentChanges > 0 ? callback : undefined
105
98
 
106
- // We need to call `maybeRunGraph` even if there's no data to load
99
+ // We need to schedule a graph run even if there's no data to load
107
100
  // because we need to mark the collection as ready if it's not already
108
- // and that's only done in `maybeRunGraph`
109
- this.collectionConfigBuilder.maybeRunGraph(
110
- this.config,
111
- this.syncState,
112
- dataLoader
113
- )
101
+ // and that's only done in `scheduleGraphRun`
102
+ this.collectionConfigBuilder.scheduleGraphRun(dataLoader, {
103
+ alias: this.alias,
104
+ })
114
105
  }
115
106
 
116
107
  private subscribeToMatchingChanges(
@@ -132,12 +123,11 @@ export class CollectionSubscriber<
132
123
  }
133
124
 
134
125
  private subscribeToOrderedChanges(
135
- whereExpression: BasicExpression<boolean> | undefined
126
+ whereExpression: BasicExpression<boolean> | undefined,
127
+ orderByInfo: OrderByOptimizationInfo
136
128
  ) {
137
129
  const { orderBy, offset, limit, comparator, dataNeeded, index } =
138
- this.collectionConfigBuilder.optimizableOrderByCollections[
139
- this.collectionId
140
- ]!
130
+ orderByInfo
141
131
 
142
132
  const sendChangesInRange = (
143
133
  changes: Iterable<ChangeMessage<any, string | number>>
@@ -147,7 +137,7 @@ export class CollectionSubscriber<
147
137
  // because they can't affect the topK (and if later we need more data, we will dynamically load more data)
148
138
  const splittedChanges = splitUpdates(changes)
149
139
  let filteredChanges = splittedChanges
150
- if (dataNeeded!() === 0) {
140
+ if (dataNeeded && dataNeeded() === 0) {
151
141
  // If the topK is full [..., maxSentValue] then we do not need to send changes > maxSentValue
152
142
  // because they can never make it into the topK.
153
143
  // However, if the topK isn't full yet, we need to also send changes > maxSentValue
@@ -173,7 +163,7 @@ export class CollectionSubscriber<
173
163
  // Normalize the orderBy clauses such that the references are relative to the collection
174
164
  const normalizedOrderBy = convertOrderByToBasicExpression(
175
165
  orderBy,
176
- this.collectionAlias
166
+ this.alias
177
167
  )
178
168
 
179
169
  // Load the first `offset + limit` values from the index
@@ -190,10 +180,7 @@ export class CollectionSubscriber<
190
180
  // after each iteration of the query pipeline
191
181
  // to ensure that the orderBy operator has enough data to work with
192
182
  loadMoreIfNeeded(subscription: CollectionSubscription) {
193
- const orderByInfo =
194
- this.collectionConfigBuilder.optimizableOrderByCollections[
195
- this.collectionId
196
- ]
183
+ const orderByInfo = this.getOrderByInfo()
197
184
 
198
185
  if (!orderByInfo) {
199
186
  // This query has no orderBy operator
@@ -224,24 +211,40 @@ export class CollectionSubscriber<
224
211
  changes: Iterable<ChangeMessage<any, string | number>>,
225
212
  subscription: CollectionSubscription
226
213
  ) {
227
- const { comparator } =
228
- this.collectionConfigBuilder.optimizableOrderByCollections[
229
- this.collectionId
230
- ]!
231
- const trackedChanges = this.trackSentValues(changes, comparator)
214
+ const orderByInfo = this.getOrderByInfo()
215
+ if (!orderByInfo) {
216
+ this.sendChangesToPipeline(changes)
217
+ return
218
+ }
219
+
220
+ const trackedChanges = this.trackSentValues(changes, orderByInfo.comparator)
221
+
222
+ // Cache the loadMoreIfNeeded callback on the subscription using a symbol property.
223
+ // This ensures we pass the same function instance to the scheduler each time,
224
+ // allowing it to deduplicate callbacks when multiple changes arrive during a transaction.
225
+ type SubscriptionWithLoader = CollectionSubscription & {
226
+ [loadMoreCallbackSymbol]?: () => boolean
227
+ }
228
+
229
+ const subscriptionWithLoader = subscription as SubscriptionWithLoader
230
+
231
+ subscriptionWithLoader[loadMoreCallbackSymbol] ??=
232
+ this.loadMoreIfNeeded.bind(this, subscription)
233
+
232
234
  this.sendChangesToPipeline(
233
235
  trackedChanges,
234
- this.loadMoreIfNeeded.bind(this, subscription)
236
+ subscriptionWithLoader[loadMoreCallbackSymbol]
235
237
  )
236
238
  }
237
239
 
238
240
  // Loads the next `n` items from the collection
239
241
  // starting from the biggest item it has sent
240
242
  private loadNextItems(n: number, subscription: CollectionSubscription) {
241
- const { orderBy, valueExtractorForRawRow } =
242
- this.collectionConfigBuilder.optimizableOrderByCollections[
243
- this.collectionId
244
- ]!
243
+ const orderByInfo = this.getOrderByInfo()
244
+ if (!orderByInfo) {
245
+ return
246
+ }
247
+ const { orderBy, valueExtractorForRawRow } = orderByInfo
245
248
  const biggestSentRow = this.biggest
246
249
  const biggestSentValue = biggestSentRow
247
250
  ? valueExtractorForRawRow(biggestSentRow)
@@ -250,7 +253,7 @@ export class CollectionSubscriber<
250
253
  // Normalize the orderBy clauses such that the references are relative to the collection
251
254
  const normalizedOrderBy = convertOrderByToBasicExpression(
252
255
  orderBy,
253
- this.collectionAlias
256
+ this.alias
254
257
  )
255
258
 
256
259
  // Take the `n` items after the biggest sent value
@@ -261,13 +264,22 @@ export class CollectionSubscriber<
261
264
  })
262
265
  }
263
266
 
264
- private getWhereClauseFromAlias(
265
- collectionAlias: string | undefined
266
- ): BasicExpression<boolean> | undefined {
267
- const collectionWhereClausesCache =
268
- this.collectionConfigBuilder.collectionWhereClausesCache
269
- if (collectionAlias && collectionWhereClausesCache) {
270
- return collectionWhereClausesCache.get(collectionAlias)
267
+ private getWhereClauseForAlias(): BasicExpression<boolean> | undefined {
268
+ const sourceWhereClausesCache =
269
+ this.collectionConfigBuilder.sourceWhereClausesCache
270
+ if (!sourceWhereClausesCache) {
271
+ return undefined
272
+ }
273
+ return sourceWhereClausesCache.get(this.alias)
274
+ }
275
+
276
+ private getOrderByInfo(): OrderByOptimizationInfo | undefined {
277
+ const info =
278
+ this.collectionConfigBuilder.optimizableOrderByCollections[
279
+ this.collectionId
280
+ ]
281
+ if (info && info.alias === this.alias) {
282
+ return info
271
283
  }
272
284
  return undefined
273
285
  }
@@ -288,36 +300,6 @@ export class CollectionSubscriber<
288
300
  }
289
301
  }
290
302
 
291
- /**
292
- * Finds the alias for a collection ID in the query
293
- */
294
- function findCollectionAlias(
295
- collectionId: string,
296
- query: any
297
- ): string | undefined {
298
- // Check FROM clause
299
- if (
300
- query.from?.type === `collectionRef` &&
301
- query.from.collection?.id === collectionId
302
- ) {
303
- return query.from.alias
304
- }
305
-
306
- // Check JOIN clauses
307
- if (query.join) {
308
- for (const joinClause of query.join) {
309
- if (
310
- joinClause.from?.type === `collectionRef` &&
311
- joinClause.from.collection?.id === collectionId
312
- ) {
313
- return joinClause.from.alias
314
- }
315
- }
316
- }
317
-
318
- return undefined
319
- }
320
-
321
303
  /**
322
304
  * Helper function to send changes to a D2 input stream
323
305
  */
@@ -1,5 +1,10 @@
1
1
  import { createCollection } from "../collection/index.js"
2
2
  import { CollectionConfigBuilder } from "./live/collection-config-builder.js"
3
+ import {
4
+ getBuilderFromConfig,
5
+ registerCollectionBuilder,
6
+ } from "./live/collection-registry.js"
7
+ import type { LiveQueryCollectionUtils } from "./live/collection-config-builder.js"
3
8
  import type { LiveQueryCollectionConfig } from "./live/types.js"
4
9
  import type { InitialQueryBuilder, QueryBuilder } from "./builder/index.js"
5
10
  import type { Collection } from "../collection/index.js"
@@ -55,7 +60,9 @@ export function liveQueryCollectionOptions<
55
60
  TResult extends object = GetResult<TContext>,
56
61
  >(
57
62
  config: LiveQueryCollectionConfig<TContext, TResult>
58
- ): CollectionConfigForContext<TContext, TResult> {
63
+ ): CollectionConfigForContext<TContext, TResult> & {
64
+ utils: LiveQueryCollectionUtils
65
+ } {
59
66
  const collectionConfigBuilder = new CollectionConfigBuilder<
60
67
  TContext,
61
68
  TResult
@@ -63,7 +70,7 @@ export function liveQueryCollectionOptions<
63
70
  return collectionConfigBuilder.getConfig() as CollectionConfigForContext<
64
71
  TContext,
65
72
  TResult
66
- >
73
+ > & { utils: LiveQueryCollectionUtils }
67
74
  }
68
75
 
69
76
  /**
@@ -106,7 +113,9 @@ export function createLiveQueryCollection<
106
113
  TResult extends object = GetResult<TContext>,
107
114
  >(
108
115
  query: (q: InitialQueryBuilder) => QueryBuilder<TContext>
109
- ): CollectionForContext<TContext, TResult>
116
+ ): CollectionForContext<TContext, TResult> & {
117
+ utils: LiveQueryCollectionUtils
118
+ }
110
119
 
111
120
  // Overload 2: Accept full config object with optional utilities
112
121
  export function createLiveQueryCollection<
@@ -115,7 +124,9 @@ export function createLiveQueryCollection<
115
124
  TUtils extends UtilsRecord = {},
116
125
  >(
117
126
  config: LiveQueryCollectionConfig<TContext, TResult> & { utils?: TUtils }
118
- ): CollectionForContext<TContext, TResult>
127
+ ): CollectionForContext<TContext, TResult> & {
128
+ utils: LiveQueryCollectionUtils & TUtils
129
+ }
119
130
 
120
131
  // Implementation
121
132
  export function createLiveQueryCollection<
@@ -126,7 +137,9 @@ export function createLiveQueryCollection<
126
137
  configOrQuery:
127
138
  | (LiveQueryCollectionConfig<TContext, TResult> & { utils?: TUtils })
128
139
  | ((q: InitialQueryBuilder) => QueryBuilder<TContext>)
129
- ): CollectionForContext<TContext, TResult> {
140
+ ): CollectionForContext<TContext, TResult> & {
141
+ utils: LiveQueryCollectionUtils & TUtils
142
+ } {
130
143
  // Determine if the argument is a function (query) or a config object
131
144
  if (typeof configOrQuery === `function`) {
132
145
  // Simple query function case
@@ -139,7 +152,7 @@ export function createLiveQueryCollection<
139
152
  return bridgeToCreateCollection(options) as CollectionForContext<
140
153
  TContext,
141
154
  TResult
142
- >
155
+ > & { utils: LiveQueryCollectionUtils & TUtils }
143
156
  } else {
144
157
  // Config object case
145
158
  const config = configOrQuery as LiveQueryCollectionConfig<
@@ -147,10 +160,16 @@ export function createLiveQueryCollection<
147
160
  TResult
148
161
  > & { utils?: TUtils }
149
162
  const options = liveQueryCollectionOptions<TContext, TResult>(config)
150
- return bridgeToCreateCollection({
151
- ...options,
152
- utils: config.utils,
153
- }) as CollectionForContext<TContext, TResult>
163
+
164
+ // Merge custom utils if provided, preserving the getBuilder() method for dependency tracking
165
+ if (config.utils) {
166
+ options.utils = { ...options.utils, ...config.utils }
167
+ }
168
+
169
+ return bridgeToCreateCollection(options) as CollectionForContext<
170
+ TContext,
171
+ TResult
172
+ > & { utils: LiveQueryCollectionUtils & TUtils }
154
173
  }
155
174
  }
156
175
 
@@ -162,12 +181,18 @@ function bridgeToCreateCollection<
162
181
  TResult extends object,
163
182
  TUtils extends UtilsRecord = {},
164
183
  >(
165
- options: CollectionConfig<TResult> & { utils?: TUtils }
184
+ options: CollectionConfig<TResult> & { utils: TUtils }
166
185
  ): Collection<TResult, string | number, TUtils> {
167
- // This is the only place we need a type assertion, hidden from user API
168
- return createCollection(options as any) as unknown as Collection<
186
+ const collection = createCollection(options as any) as unknown as Collection<
169
187
  TResult,
170
188
  string | number,
171
- TUtils
189
+ LiveQueryCollectionUtils
172
190
  >
191
+
192
+ const builder = getBuilderFromConfig(options)
193
+ if (builder) {
194
+ registerCollectionBuilder(collection, builder)
195
+ }
196
+
197
+ return collection as unknown as Collection<TResult, string | number, TUtils>
173
198
  }
@@ -162,8 +162,8 @@ export interface GroupedWhereClauses {
162
162
  export interface OptimizationResult {
163
163
  /** The optimized query with WHERE clauses potentially moved to subqueries */
164
164
  optimizedQuery: QueryIR
165
- /** Map of collection aliases to their extracted WHERE clauses for index optimization */
166
- collectionWhereClauses: Map<string, BasicExpression<boolean>>
165
+ /** Map of source aliases to their extracted WHERE clauses for index optimization */
166
+ sourceWhereClauses: Map<string, BasicExpression<boolean>>
167
167
  }
168
168
 
169
169
  /**
@@ -184,14 +184,14 @@ export interface OptimizationResult {
184
184
  * where: [eq(u.dept_id, 1), gt(p.views, 100)]
185
185
  * }
186
186
  *
187
- * const { optimizedQuery, collectionWhereClauses } = optimizeQuery(originalQuery)
187
+ * const { optimizedQuery, sourceWhereClauses } = optimizeQuery(originalQuery)
188
188
  * // Result: Single-source clauses moved to deepest possible subqueries
189
- * // collectionWhereClauses: Map { 'u' => eq(u.dept_id, 1), 'p' => gt(p.views, 100) }
189
+ * // sourceWhereClauses: Map { 'u' => eq(u.dept_id, 1), 'p' => gt(p.views, 100) }
190
190
  * ```
191
191
  */
192
192
  export function optimizeQuery(query: QueryIR): OptimizationResult {
193
- // First, extract collection WHERE clauses before optimization
194
- const collectionWhereClauses = extractCollectionWhereClauses(query)
193
+ // First, extract source WHERE clauses before optimization
194
+ const sourceWhereClauses = extractSourceWhereClauses(query)
195
195
 
196
196
  // Apply multi-level predicate pushdown with iterative convergence
197
197
  let optimized = query
@@ -214,7 +214,7 @@ export function optimizeQuery(query: QueryIR): OptimizationResult {
214
214
 
215
215
  return {
216
216
  optimizedQuery: cleaned,
217
- collectionWhereClauses,
217
+ sourceWhereClauses,
218
218
  }
219
219
  }
220
220
 
@@ -224,16 +224,16 @@ export function optimizeQuery(query: QueryIR): OptimizationResult {
224
224
  * to specific collections, but only for simple queries without joins.
225
225
  *
226
226
  * @param query - The original QueryIR to analyze
227
- * @returns Map of collection aliases to their WHERE clauses
227
+ * @returns Map of source aliases to their WHERE clauses
228
228
  */
229
- function extractCollectionWhereClauses(
229
+ function extractSourceWhereClauses(
230
230
  query: QueryIR
231
231
  ): Map<string, BasicExpression<boolean>> {
232
- const collectionWhereClauses = new Map<string, BasicExpression<boolean>>()
232
+ const sourceWhereClauses = new Map<string, BasicExpression<boolean>>()
233
233
 
234
234
  // Only analyze queries that have WHERE clauses
235
235
  if (!query.where || query.where.length === 0) {
236
- return collectionWhereClauses
236
+ return sourceWhereClauses
237
237
  }
238
238
 
239
239
  // Split all AND clauses at the root level for granular analysis
@@ -254,12 +254,12 @@ function extractCollectionWhereClauses(
254
254
  if (isCollectionReference(query, sourceAlias)) {
255
255
  // Check if the WHERE clause can be converted to collection-compatible format
256
256
  if (isConvertibleToCollectionFilter(whereClause)) {
257
- collectionWhereClauses.set(sourceAlias, whereClause)
257
+ sourceWhereClauses.set(sourceAlias, whereClause)
258
258
  }
259
259
  }
260
260
  }
261
261
 
262
- return collectionWhereClauses
262
+ return sourceWhereClauses
263
263
  }
264
264
 
265
265
  /**
@@ -782,8 +782,6 @@ function optimizeFromWithTracking(
782
782
  return new QueryRefClass(subQuery, from.alias)
783
783
  }
784
784
 
785
- // Must be queryRef due to type system
786
-
787
785
  // SAFETY CHECK: Only check safety when pushing WHERE clauses into existing subqueries
788
786
  // We need to be careful about pushing WHERE clauses into subqueries that already have
789
787
  // aggregates, HAVING, or ORDER BY + LIMIT since that could change their semantics
@@ -793,6 +791,12 @@ function optimizeFromWithTracking(
793
791
  return new QueryRefClass(deepCopyQuery(from.query), from.alias)
794
792
  }
795
793
 
794
+ // Skip pushdown when a clause references a field that only exists via a renamed
795
+ // projection inside the subquery; leaving it outside preserves the alias mapping.
796
+ if (referencesAliasWithRemappedSelect(from.query, whereClause, from.alias)) {
797
+ return new QueryRefClass(deepCopyQuery(from.query), from.alias)
798
+ }
799
+
796
800
  // Add the WHERE clause to the existing subquery
797
801
  // Create a deep copy to ensure immutability
798
802
  const existingWhere = from.query.where || []
@@ -943,6 +947,72 @@ function whereReferencesComputedSelectFields(
943
947
  return false
944
948
  }
945
949
 
950
+ /**
951
+ * Detects whether a WHERE clause references the subquery alias through fields that
952
+ * are re-exposed under different names (renamed SELECT projections or fnSelect output).
953
+ * In those cases we keep the clause at the outer level to avoid alias remapping bugs.
954
+ * TODO: in future we should handle this by rewriting the clause to use the subquery's
955
+ * internal field references, but it likely needs a wider refactor to do cleanly.
956
+ */
957
+ function referencesAliasWithRemappedSelect(
958
+ subquery: QueryIR,
959
+ whereClause: BasicExpression<boolean>,
960
+ outerAlias: string
961
+ ): boolean {
962
+ const refs = collectRefs(whereClause)
963
+ // Only care about clauses that actually reference the outer alias.
964
+ if (refs.every((ref) => ref.path[0] !== outerAlias)) {
965
+ return false
966
+ }
967
+
968
+ // fnSelect always rewrites the row shape, so alias-safe pushdown is impossible.
969
+ if (subquery.fnSelect) {
970
+ return true
971
+ }
972
+
973
+ const select = subquery.select
974
+ // Without an explicit SELECT the clause still refers to the original collection.
975
+ if (!select) {
976
+ return false
977
+ }
978
+
979
+ for (const ref of refs) {
980
+ const path = ref.path
981
+ // Need at least alias + field to matter.
982
+ if (path.length < 2) continue
983
+ if (path[0] !== outerAlias) continue
984
+
985
+ const projected = select[path[1]!]
986
+ // Unselected fields can't be remapped, so skip - only care about fields in the SELECT.
987
+ if (!projected) continue
988
+
989
+ // Non-PropRef projections are computed values; cannot push down.
990
+ if (!(projected instanceof PropRef)) {
991
+ return true
992
+ }
993
+
994
+ // If the projection is just the alias (whole row) without a specific field,
995
+ // we can't verify whether the field we're referencing is being preserved or remapped.
996
+ if (projected.path.length < 2) {
997
+ return true
998
+ }
999
+
1000
+ const [innerAlias, innerField] = projected.path
1001
+
1002
+ // Safe only when the projection points straight back to the same alias or the
1003
+ // underlying source alias and preserves the field name.
1004
+ if (innerAlias !== outerAlias && innerAlias !== subquery.from.alias) {
1005
+ return true
1006
+ }
1007
+
1008
+ if (innerField !== path[1]) {
1009
+ return true
1010
+ }
1011
+ }
1012
+
1013
+ return false
1014
+ }
1015
+
946
1016
  /**
947
1017
  * Helper function to combine multiple expressions with AND.
948
1018
  *