@tanstack/db 0.4.8 → 0.4.10

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 (134) hide show
  1. package/dist/cjs/collection/events.cjs +9 -51
  2. package/dist/cjs/collection/events.cjs.map +1 -1
  3. package/dist/cjs/collection/events.d.cts +18 -7
  4. package/dist/cjs/collection/index.cjs +9 -12
  5. package/dist/cjs/collection/index.cjs.map +1 -1
  6. package/dist/cjs/collection/index.d.cts +13 -14
  7. package/dist/cjs/collection/subscription.cjs +62 -6
  8. package/dist/cjs/collection/subscription.cjs.map +1 -1
  9. package/dist/cjs/collection/subscription.d.cts +16 -3
  10. package/dist/cjs/collection/sync.cjs +58 -6
  11. package/dist/cjs/collection/sync.cjs.map +1 -1
  12. package/dist/cjs/collection/sync.d.cts +18 -4
  13. package/dist/cjs/errors.cjs +59 -17
  14. package/dist/cjs/errors.cjs.map +1 -1
  15. package/dist/cjs/errors.d.cts +44 -8
  16. package/dist/cjs/event-emitter.cjs +94 -0
  17. package/dist/cjs/event-emitter.cjs.map +1 -0
  18. package/dist/cjs/event-emitter.d.cts +45 -0
  19. package/dist/cjs/index.cjs +9 -4
  20. package/dist/cjs/index.cjs.map +1 -1
  21. package/dist/cjs/local-only.cjs.map +1 -1
  22. package/dist/cjs/local-only.d.cts +2 -5
  23. package/dist/cjs/query/builder/types.d.cts +1 -1
  24. package/dist/cjs/query/compiler/index.cjs +46 -19
  25. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  26. package/dist/cjs/query/compiler/index.d.cts +35 -9
  27. package/dist/cjs/query/compiler/joins.cjs +91 -66
  28. package/dist/cjs/query/compiler/joins.cjs.map +1 -1
  29. package/dist/cjs/query/compiler/joins.d.cts +6 -3
  30. package/dist/cjs/query/compiler/order-by.cjs +20 -4
  31. package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
  32. package/dist/cjs/query/compiler/order-by.d.cts +3 -1
  33. package/dist/cjs/query/compiler/select.cjs.map +1 -1
  34. package/dist/cjs/query/compiler/types.d.cts +4 -0
  35. package/dist/cjs/query/index.d.cts +1 -0
  36. package/dist/cjs/query/live/collection-config-builder.cjs +306 -46
  37. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  38. package/dist/cjs/query/live/collection-config-builder.d.cts +97 -9
  39. package/dist/cjs/query/live/collection-registry.cjs +16 -0
  40. package/dist/cjs/query/live/collection-registry.cjs.map +1 -0
  41. package/dist/cjs/query/live/collection-registry.d.cts +26 -0
  42. package/dist/cjs/query/live/collection-subscriber.cjs +86 -58
  43. package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
  44. package/dist/cjs/query/live/collection-subscriber.d.cts +5 -7
  45. package/dist/cjs/query/live-query-collection.cjs +11 -5
  46. package/dist/cjs/query/live-query-collection.cjs.map +1 -1
  47. package/dist/cjs/query/live-query-collection.d.cts +12 -5
  48. package/dist/cjs/query/optimizer.cjs +44 -7
  49. package/dist/cjs/query/optimizer.cjs.map +1 -1
  50. package/dist/cjs/query/optimizer.d.cts +4 -4
  51. package/dist/cjs/scheduler.cjs +137 -0
  52. package/dist/cjs/scheduler.cjs.map +1 -0
  53. package/dist/cjs/scheduler.d.cts +56 -0
  54. package/dist/cjs/transactions.cjs +7 -1
  55. package/dist/cjs/transactions.cjs.map +1 -1
  56. package/dist/cjs/types.d.cts +82 -11
  57. package/dist/esm/collection/events.d.ts +18 -7
  58. package/dist/esm/collection/events.js +9 -51
  59. package/dist/esm/collection/events.js.map +1 -1
  60. package/dist/esm/collection/index.d.ts +13 -14
  61. package/dist/esm/collection/index.js +9 -12
  62. package/dist/esm/collection/index.js.map +1 -1
  63. package/dist/esm/collection/subscription.d.ts +16 -3
  64. package/dist/esm/collection/subscription.js +62 -6
  65. package/dist/esm/collection/subscription.js.map +1 -1
  66. package/dist/esm/collection/sync.d.ts +18 -4
  67. package/dist/esm/collection/sync.js +59 -7
  68. package/dist/esm/collection/sync.js.map +1 -1
  69. package/dist/esm/errors.d.ts +44 -8
  70. package/dist/esm/errors.js +60 -18
  71. package/dist/esm/errors.js.map +1 -1
  72. package/dist/esm/event-emitter.d.ts +45 -0
  73. package/dist/esm/event-emitter.js +94 -0
  74. package/dist/esm/event-emitter.js.map +1 -0
  75. package/dist/esm/index.js +10 -5
  76. package/dist/esm/local-only.d.ts +2 -5
  77. package/dist/esm/local-only.js.map +1 -1
  78. package/dist/esm/query/builder/types.d.ts +1 -1
  79. package/dist/esm/query/compiler/index.d.ts +35 -9
  80. package/dist/esm/query/compiler/index.js +46 -19
  81. package/dist/esm/query/compiler/index.js.map +1 -1
  82. package/dist/esm/query/compiler/joins.d.ts +6 -3
  83. package/dist/esm/query/compiler/joins.js +93 -68
  84. package/dist/esm/query/compiler/joins.js.map +1 -1
  85. package/dist/esm/query/compiler/order-by.d.ts +3 -1
  86. package/dist/esm/query/compiler/order-by.js +20 -4
  87. package/dist/esm/query/compiler/order-by.js.map +1 -1
  88. package/dist/esm/query/compiler/select.js.map +1 -1
  89. package/dist/esm/query/compiler/types.d.ts +4 -0
  90. package/dist/esm/query/index.d.ts +1 -0
  91. package/dist/esm/query/live/collection-config-builder.d.ts +97 -9
  92. package/dist/esm/query/live/collection-config-builder.js +306 -46
  93. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  94. package/dist/esm/query/live/collection-registry.d.ts +26 -0
  95. package/dist/esm/query/live/collection-registry.js +16 -0
  96. package/dist/esm/query/live/collection-registry.js.map +1 -0
  97. package/dist/esm/query/live/collection-subscriber.d.ts +5 -7
  98. package/dist/esm/query/live/collection-subscriber.js +86 -58
  99. package/dist/esm/query/live/collection-subscriber.js.map +1 -1
  100. package/dist/esm/query/live-query-collection.d.ts +12 -5
  101. package/dist/esm/query/live-query-collection.js +11 -5
  102. package/dist/esm/query/live-query-collection.js.map +1 -1
  103. package/dist/esm/query/optimizer.d.ts +4 -4
  104. package/dist/esm/query/optimizer.js +44 -7
  105. package/dist/esm/query/optimizer.js.map +1 -1
  106. package/dist/esm/scheduler.d.ts +56 -0
  107. package/dist/esm/scheduler.js +137 -0
  108. package/dist/esm/scheduler.js.map +1 -0
  109. package/dist/esm/transactions.js +7 -1
  110. package/dist/esm/transactions.js.map +1 -1
  111. package/dist/esm/types.d.ts +82 -11
  112. package/package.json +2 -2
  113. package/src/collection/events.ts +25 -74
  114. package/src/collection/index.ts +15 -19
  115. package/src/collection/subscription.ts +88 -6
  116. package/src/collection/sync.ts +81 -9
  117. package/src/errors.ts +91 -13
  118. package/src/event-emitter.ts +118 -0
  119. package/src/local-only.ts +5 -12
  120. package/src/query/builder/types.ts +1 -1
  121. package/src/query/compiler/index.ts +124 -33
  122. package/src/query/compiler/joins.ts +187 -128
  123. package/src/query/compiler/order-by.ts +30 -2
  124. package/src/query/compiler/select.ts +2 -3
  125. package/src/query/compiler/types.ts +5 -0
  126. package/src/query/index.ts +1 -0
  127. package/src/query/live/collection-config-builder.ts +501 -60
  128. package/src/query/live/collection-registry.ts +47 -0
  129. package/src/query/live/collection-subscriber.ts +137 -105
  130. package/src/query/live-query-collection.ts +47 -18
  131. package/src/query/optimizer.ts +85 -15
  132. package/src/scheduler.ts +198 -0
  133. package/src/transactions.ts +12 -1
  134. package/src/types.ts +93 -11
@@ -1,18 +1,13 @@
1
- import {
2
- consolidate,
3
- filter,
4
- join as joinOperator,
5
- map,
6
- tap,
7
- } from "@tanstack/db-ivm"
1
+ import { filter, join as joinOperator, map, tap } from "@tanstack/db-ivm"
8
2
  import {
9
3
  CollectionInputNotFoundError,
10
4
  InvalidJoinCondition,
11
- InvalidJoinConditionLeftTableError,
12
- InvalidJoinConditionRightTableError,
13
- InvalidJoinConditionSameTableError,
14
- InvalidJoinConditionTableMismatchError,
5
+ InvalidJoinConditionLeftSourceError,
6
+ InvalidJoinConditionRightSourceError,
7
+ InvalidJoinConditionSameSourceError,
8
+ InvalidJoinConditionSourceMismatchError,
15
9
  JoinCollectionNotFoundError,
10
+ SubscriptionNotFoundError,
16
11
  UnsupportedJoinSourceTypeError,
17
12
  UnsupportedJoinTypeError,
18
13
  } from "../../errors.js"
@@ -36,34 +31,41 @@ import type {
36
31
  NamespacedAndKeyedStream,
37
32
  NamespacedRow,
38
33
  } from "../../types.js"
39
- import type { QueryCache, QueryMapping } from "./types.js"
34
+ import type { QueryCache, QueryMapping, WindowOptions } from "./types.js"
40
35
  import type { CollectionSubscription } from "../../collection/subscription.js"
41
36
 
37
+ /** Function type for loading specific keys into a lazy collection */
42
38
  export type LoadKeysFn = (key: Set<string | number>) => void
39
+
40
+ /** Callbacks for managing lazy-loaded collections in optimized joins */
43
41
  export type LazyCollectionCallbacks = {
44
42
  loadKeys: LoadKeysFn
45
43
  loadInitialState: () => void
46
44
  }
47
45
 
48
46
  /**
49
- * Processes all join clauses in a query
47
+ * Processes all join clauses, applying lazy loading optimizations and maintaining
48
+ * alias tracking for per-alias subscriptions (enables self-joins).
50
49
  */
51
50
  export function processJoins(
52
51
  pipeline: NamespacedAndKeyedStream,
53
52
  joinClauses: Array<JoinClause>,
54
- tables: Record<string, KeyedStream>,
55
- mainTableId: string,
56
- mainTableAlias: string,
53
+ sources: Record<string, KeyedStream>,
54
+ mainCollectionId: string,
55
+ mainSource: string,
57
56
  allInputs: Record<string, KeyedStream>,
58
57
  cache: QueryCache,
59
58
  queryMapping: QueryMapping,
60
59
  collections: Record<string, Collection>,
61
60
  subscriptions: Record<string, CollectionSubscription>,
62
61
  callbacks: Record<string, LazyCollectionCallbacks>,
63
- lazyCollections: Set<string>,
62
+ lazySources: Set<string>,
64
63
  optimizableOrderByCollections: Record<string, OrderByOptimizationInfo>,
64
+ setWindowFn: (windowFn: (options: WindowOptions) => void) => void,
65
65
  rawQuery: QueryIR,
66
- onCompileSubquery: CompileQueryFn
66
+ onCompileSubquery: CompileQueryFn,
67
+ aliasToCollectionId: Record<string, string>,
68
+ aliasRemapping: Record<string, string>
67
69
  ): NamespacedAndKeyedStream {
68
70
  let resultPipeline = pipeline
69
71
 
@@ -71,19 +73,22 @@ export function processJoins(
71
73
  resultPipeline = processJoin(
72
74
  resultPipeline,
73
75
  joinClause,
74
- tables,
75
- mainTableId,
76
- mainTableAlias,
76
+ sources,
77
+ mainCollectionId,
78
+ mainSource,
77
79
  allInputs,
78
80
  cache,
79
81
  queryMapping,
80
82
  collections,
81
83
  subscriptions,
82
84
  callbacks,
83
- lazyCollections,
85
+ lazySources,
84
86
  optimizableOrderByCollections,
87
+ setWindowFn,
85
88
  rawQuery,
86
- onCompileSubquery
89
+ onCompileSubquery,
90
+ aliasToCollectionId,
91
+ aliasRemapping
87
92
  )
88
93
  }
89
94
 
@@ -91,28 +96,34 @@ export function processJoins(
91
96
  }
92
97
 
93
98
  /**
94
- * Processes a single join clause
99
+ * Processes a single join clause with lazy loading optimization.
100
+ * For LEFT/RIGHT/INNER joins, marks one side as "lazy" (loads on-demand based on join keys).
95
101
  */
96
102
  function processJoin(
97
103
  pipeline: NamespacedAndKeyedStream,
98
104
  joinClause: JoinClause,
99
- tables: Record<string, KeyedStream>,
100
- mainTableId: string,
101
- mainTableAlias: string,
105
+ sources: Record<string, KeyedStream>,
106
+ mainCollectionId: string,
107
+ mainSource: string,
102
108
  allInputs: Record<string, KeyedStream>,
103
109
  cache: QueryCache,
104
110
  queryMapping: QueryMapping,
105
111
  collections: Record<string, Collection>,
106
112
  subscriptions: Record<string, CollectionSubscription>,
107
113
  callbacks: Record<string, LazyCollectionCallbacks>,
108
- lazyCollections: Set<string>,
114
+ lazySources: Set<string>,
109
115
  optimizableOrderByCollections: Record<string, OrderByOptimizationInfo>,
116
+ setWindowFn: (windowFn: (options: WindowOptions) => void) => void,
110
117
  rawQuery: QueryIR,
111
- onCompileSubquery: CompileQueryFn
118
+ onCompileSubquery: CompileQueryFn,
119
+ aliasToCollectionId: Record<string, string>,
120
+ aliasRemapping: Record<string, string>
112
121
  ): NamespacedAndKeyedStream {
113
- // Get the joined table alias and input stream
122
+ const isCollectionRef = joinClause.from.type === `collectionRef`
123
+
124
+ // Get the joined source alias and input stream
114
125
  const {
115
- alias: joinedTableAlias,
126
+ alias: joinedSource,
116
127
  input: joinedInput,
117
128
  collectionId: joinedCollectionId,
118
129
  } = processJoinSource(
@@ -121,40 +132,48 @@ function processJoin(
121
132
  collections,
122
133
  subscriptions,
123
134
  callbacks,
124
- lazyCollections,
135
+ lazySources,
125
136
  optimizableOrderByCollections,
137
+ setWindowFn,
126
138
  cache,
127
139
  queryMapping,
128
- onCompileSubquery
140
+ onCompileSubquery,
141
+ aliasToCollectionId,
142
+ aliasRemapping
129
143
  )
130
144
 
131
- // Add the joined table to the tables map
132
- tables[joinedTableAlias] = joinedInput
145
+ // Add the joined source to the sources map
146
+ sources[joinedSource] = joinedInput
147
+ if (isCollectionRef) {
148
+ // Only direct collection references form new alias bindings. Subquery
149
+ // aliases reuse the mapping returned from the recursive compilation above.
150
+ aliasToCollectionId[joinedSource] = joinedCollectionId
151
+ }
133
152
 
134
- const mainCollection = collections[mainTableId]
153
+ const mainCollection = collections[mainCollectionId]
135
154
  const joinedCollection = collections[joinedCollectionId]
136
155
 
137
156
  if (!mainCollection) {
138
- throw new JoinCollectionNotFoundError(mainTableId)
157
+ throw new JoinCollectionNotFoundError(mainCollectionId)
139
158
  }
140
159
 
141
160
  if (!joinedCollection) {
142
161
  throw new JoinCollectionNotFoundError(joinedCollectionId)
143
162
  }
144
163
 
145
- const { activeCollection, lazyCollection } = getActiveAndLazyCollections(
164
+ const { activeSource, lazySource } = getActiveAndLazySources(
146
165
  joinClause.type,
147
166
  mainCollection,
148
167
  joinedCollection
149
168
  )
150
169
 
151
- // Analyze which table each expression refers to and swap if necessary
152
- const availableTableAliases = Object.keys(tables)
170
+ // Analyze which source each expression refers to and swap if necessary
171
+ const availableSources = Object.keys(sources)
153
172
  const { mainExpr, joinedExpr } = analyzeJoinExpressions(
154
173
  joinClause.left,
155
174
  joinClause.right,
156
- availableTableAliases,
157
- joinedTableAlias
175
+ availableSources,
176
+ joinedSource
158
177
  )
159
178
 
160
179
  // Pre-compile the join expressions
@@ -164,7 +183,7 @@ function processJoin(
164
183
  // Prepare the main pipeline for joining
165
184
  let mainPipeline = pipeline.pipe(
166
185
  map(([currentKey, namespacedRow]) => {
167
- // Extract the join key from the main table expression
186
+ // Extract the join key from the main source expression
168
187
  const mainKey = compiledMainExpr(namespacedRow)
169
188
 
170
189
  // Return [joinKey, [originalKey, namespacedRow]]
@@ -179,9 +198,9 @@ function processJoin(
179
198
  let joinedPipeline = joinedInput.pipe(
180
199
  map(([currentKey, row]) => {
181
200
  // Wrap the row in a namespaced structure
182
- const namespacedRow: NamespacedRow = { [joinedTableAlias]: row }
201
+ const namespacedRow: NamespacedRow = { [joinedSource]: row }
183
202
 
184
- // Extract the join key from the joined table expression
203
+ // Extract the join key from the joined source expression
185
204
  const joinedKey = compiledJoinedExpr(namespacedRow)
186
205
 
187
206
  // Return [joinKey, [originalKey, namespacedRow]]
@@ -197,13 +216,12 @@ function processJoin(
197
216
  throw new UnsupportedJoinTypeError(joinClause.type)
198
217
  }
199
218
 
200
- if (activeCollection) {
219
+ if (activeSource) {
201
220
  // If the lazy collection comes from a subquery that has a limit and/or an offset clause
202
221
  // then we need to deoptimize the join because we don't know which rows are in the result set
203
222
  // since we simply lookup matching keys in the index but the index contains all rows
204
223
  // (not just the ones that pass the limit and offset clauses)
205
- const lazyFrom =
206
- activeCollection === `main` ? joinClause.from : rawQuery.from
224
+ const lazyFrom = activeSource === `main` ? joinClause.from : rawQuery.from
207
225
  const limitedSubquery =
208
226
  lazyFrom.type === `queryRef` &&
209
227
  (lazyFrom.query.limit || lazyFrom.query.offset)
@@ -219,24 +237,25 @@ function processJoin(
219
237
  // based on the value of the joinKey and by looking up
220
238
  // matching rows in the index of the lazy collection
221
239
 
222
- // Mark the lazy collection as lazy
240
+ // Mark the lazy source alias as lazy
223
241
  // this Set is passed by the liveQueryCollection to the compiler
224
242
  // such that the liveQueryCollection can check it after compilation
225
- // to know which collections are lazy collections
226
- lazyCollections.add(lazyCollection.id)
243
+ // to know which source aliases should load data lazily (not initially)
244
+ const lazyAlias = activeSource === `main` ? joinedSource : mainSource
245
+ lazySources.add(lazyAlias)
227
246
 
228
247
  const activePipeline =
229
- activeCollection === `main` ? mainPipeline : joinedPipeline
248
+ activeSource === `main` ? mainPipeline : joinedPipeline
230
249
 
231
- const lazyCollectionJoinExpr =
232
- activeCollection === `main`
250
+ const lazySourceJoinExpr =
251
+ activeSource === `main`
233
252
  ? (joinedExpr as PropRef)
234
253
  : (mainExpr as PropRef)
235
254
 
236
255
  const followRefResult = followRef(
237
256
  rawQuery,
238
- lazyCollectionJoinExpr,
239
- lazyCollection
257
+ lazySourceJoinExpr,
258
+ lazySource
240
259
  )!
241
260
  const followRefCollection = followRefResult.collection
242
261
 
@@ -249,38 +268,51 @@ function processJoin(
249
268
  )
250
269
  }
251
270
 
271
+ // Set up lazy loading: intercept active side's stream and dynamically load
272
+ // matching rows from lazy side based on join keys.
252
273
  const activePipelineWithLoading: IStreamBuilder<
253
274
  [key: unknown, [originalKey: string, namespacedRow: NamespacedRow]]
254
275
  > = activePipeline.pipe(
255
276
  tap((data) => {
256
- const lazyCollectionSubscription = subscriptions[lazyCollection.id]
257
-
258
- if (!lazyCollectionSubscription) {
259
- throw new Error(
260
- `Internal error: subscription for collection is missing in join pipeline. Make sure the live query collection sets the subscription before running the pipeline.`
277
+ // Find the subscription for lazy loading.
278
+ // Subscriptions are keyed by the innermost alias (where the collection subscription
279
+ // was actually created). For subqueries, the join alias may differ from the inner alias.
280
+ // aliasRemapping provides a flattened one-hop lookup from outer → innermost alias.
281
+ // Example: .join({ activeUser: subquery }) where subquery uses .from({ user: collection })
282
+ // → aliasRemapping['activeUser'] = 'user' (always maps directly to innermost, never recursive)
283
+ const resolvedAlias = aliasRemapping[lazyAlias] || lazyAlias
284
+ const lazySourceSubscription = subscriptions[resolvedAlias]
285
+
286
+ if (!lazySourceSubscription) {
287
+ throw new SubscriptionNotFoundError(
288
+ resolvedAlias,
289
+ lazyAlias,
290
+ lazySource.id,
291
+ Object.keys(subscriptions)
261
292
  )
262
293
  }
263
294
 
264
- if (lazyCollectionSubscription.hasLoadedInitialState()) {
295
+ if (lazySourceSubscription.hasLoadedInitialState()) {
265
296
  // Entire state was already loaded because we deoptimized the join
266
297
  return
267
298
  }
268
299
 
300
+ // Request filtered snapshot from lazy collection for matching join keys
269
301
  const joinKeys = data.getInner().map(([[joinKey]]) => joinKey)
270
302
  const lazyJoinRef = new PropRef(followRefResult.path)
271
- const loaded = lazyCollectionSubscription.requestSnapshot({
303
+ const loaded = lazySourceSubscription.requestSnapshot({
272
304
  where: inArray(lazyJoinRef, joinKeys),
273
305
  optimizedOnly: true,
274
306
  })
275
307
 
276
308
  if (!loaded) {
277
309
  // Snapshot wasn't sent because it could not be loaded from the indexes
278
- lazyCollectionSubscription.requestSnapshot()
310
+ lazySourceSubscription.requestSnapshot()
279
311
  }
280
312
  })
281
313
  )
282
314
 
283
- if (activeCollection === `main`) {
315
+ if (activeSource === `main`) {
284
316
  mainPipeline = activePipelineWithLoading
285
317
  } else {
286
318
  joinedPipeline = activePipelineWithLoading
@@ -290,68 +322,66 @@ function processJoin(
290
322
 
291
323
  return mainPipeline.pipe(
292
324
  joinOperator(joinedPipeline, joinClause.type as JoinType),
293
- consolidate(),
294
325
  processJoinResults(joinClause.type)
295
326
  )
296
327
  }
297
328
 
298
329
  /**
299
- * Analyzes join expressions to determine which refers to which table
300
- * and returns them in the correct order (available table expression first, joined table expression second)
330
+ * Analyzes join expressions to determine which refers to which source
331
+ * and returns them in the correct order (available source expression first, joined source expression second)
301
332
  */
302
333
  function analyzeJoinExpressions(
303
334
  left: BasicExpression,
304
335
  right: BasicExpression,
305
- allAvailableTableAliases: Array<string>,
306
- joinedTableAlias: string
336
+ allAvailableSourceAliases: Array<string>,
337
+ joinedSource: string
307
338
  ): { mainExpr: BasicExpression; joinedExpr: BasicExpression } {
308
- // Filter out the joined table alias from the available table aliases
309
- const availableTableAliases = allAvailableTableAliases.filter(
310
- (alias) => alias !== joinedTableAlias
339
+ // Filter out the joined source alias from the available source aliases
340
+ const availableSources = allAvailableSourceAliases.filter(
341
+ (alias) => alias !== joinedSource
311
342
  )
312
343
 
313
- const leftTableAlias = getTableAliasFromExpression(left)
314
- const rightTableAlias = getTableAliasFromExpression(right)
344
+ const leftSourceAlias = getSourceAliasFromExpression(left)
345
+ const rightSourceAlias = getSourceAliasFromExpression(right)
315
346
 
316
- // If left expression refers to an available table and right refers to joined table, keep as is
347
+ // If left expression refers to an available source and right refers to joined source, keep as is
317
348
  if (
318
- leftTableAlias &&
319
- availableTableAliases.includes(leftTableAlias) &&
320
- rightTableAlias === joinedTableAlias
349
+ leftSourceAlias &&
350
+ availableSources.includes(leftSourceAlias) &&
351
+ rightSourceAlias === joinedSource
321
352
  ) {
322
353
  return { mainExpr: left, joinedExpr: right }
323
354
  }
324
355
 
325
- // If left expression refers to joined table and right refers to an available table, swap them
356
+ // If left expression refers to joined source and right refers to an available source, swap them
326
357
  if (
327
- leftTableAlias === joinedTableAlias &&
328
- rightTableAlias &&
329
- availableTableAliases.includes(rightTableAlias)
358
+ leftSourceAlias === joinedSource &&
359
+ rightSourceAlias &&
360
+ availableSources.includes(rightSourceAlias)
330
361
  ) {
331
362
  return { mainExpr: right, joinedExpr: left }
332
363
  }
333
364
 
334
- // If one expression doesn't refer to any table, this is an invalid join
335
- if (!leftTableAlias || !rightTableAlias) {
336
- // For backward compatibility, use the first available table alias in error message
337
- throw new InvalidJoinConditionTableMismatchError()
365
+ // If one expression doesn't refer to any source, this is an invalid join
366
+ if (!leftSourceAlias || !rightSourceAlias) {
367
+ throw new InvalidJoinConditionSourceMismatchError()
338
368
  }
339
369
 
340
370
  // If both expressions refer to the same alias, this is an invalid join
341
- if (leftTableAlias === rightTableAlias) {
342
- throw new InvalidJoinConditionSameTableError(leftTableAlias)
371
+ if (leftSourceAlias === rightSourceAlias) {
372
+ throw new InvalidJoinConditionSameSourceError(leftSourceAlias)
343
373
  }
344
374
 
345
- // Left side must refer to an available table
375
+ // Left side must refer to an available source
346
376
  // This cannot happen with the query builder as there is no way to build a ref
347
- // to an unavailable table, but just in case, but could happen with the IR
348
- if (!availableTableAliases.includes(leftTableAlias)) {
349
- throw new InvalidJoinConditionLeftTableError(leftTableAlias)
377
+ // to an unavailable source, but just in case, but could happen with the IR
378
+ if (!availableSources.includes(leftSourceAlias)) {
379
+ throw new InvalidJoinConditionLeftSourceError(leftSourceAlias)
350
380
  }
351
381
 
352
- // Right side must refer to the joined table
353
- if (rightTableAlias !== joinedTableAlias) {
354
- throw new InvalidJoinConditionRightTableError(joinedTableAlias)
382
+ // Right side must refer to the joined source
383
+ if (rightSourceAlias !== joinedSource) {
384
+ throw new InvalidJoinConditionRightSourceError(joinedSource)
355
385
  }
356
386
 
357
387
  // This should not be reachable given the logic above, but just in case
@@ -359,27 +389,27 @@ function analyzeJoinExpressions(
359
389
  }
360
390
 
361
391
  /**
362
- * Extracts the table alias from a join expression
392
+ * Extracts the source alias from a join expression
363
393
  */
364
- function getTableAliasFromExpression(expr: BasicExpression): string | null {
394
+ function getSourceAliasFromExpression(expr: BasicExpression): string | null {
365
395
  switch (expr.type) {
366
396
  case `ref`:
367
- // PropRef path has the table alias as the first element
397
+ // PropRef path has the source alias as the first element
368
398
  return expr.path[0] || null
369
399
  case `func`: {
370
- // For function expressions, we need to check if all arguments refer to the same table
371
- const tableAliases = new Set<string>()
400
+ // For function expressions, we need to check if all arguments refer to the same source
401
+ const sourceAliases = new Set<string>()
372
402
  for (const arg of expr.args) {
373
- const alias = getTableAliasFromExpression(arg)
403
+ const alias = getSourceAliasFromExpression(arg)
374
404
  if (alias) {
375
- tableAliases.add(alias)
405
+ sourceAliases.add(alias)
376
406
  }
377
407
  }
378
- // If all arguments refer to the same table, return that table alias
379
- return tableAliases.size === 1 ? Array.from(tableAliases)[0]! : null
408
+ // If all arguments refer to the same source, return that source alias
409
+ return sourceAliases.size === 1 ? Array.from(sourceAliases)[0]! : null
380
410
  }
381
411
  default:
382
- // Values (type='val') don't reference any table
412
+ // Values (type='val') don't reference any source
383
413
  return null
384
414
  }
385
415
  }
@@ -393,18 +423,26 @@ function processJoinSource(
393
423
  collections: Record<string, Collection>,
394
424
  subscriptions: Record<string, CollectionSubscription>,
395
425
  callbacks: Record<string, LazyCollectionCallbacks>,
396
- lazyCollections: Set<string>,
426
+ lazySources: Set<string>,
397
427
  optimizableOrderByCollections: Record<string, OrderByOptimizationInfo>,
428
+ setWindowFn: (windowFn: (options: WindowOptions) => void) => void,
398
429
  cache: QueryCache,
399
430
  queryMapping: QueryMapping,
400
- onCompileSubquery: CompileQueryFn
431
+ onCompileSubquery: CompileQueryFn,
432
+ aliasToCollectionId: Record<string, string>,
433
+ aliasRemapping: Record<string, string>
401
434
  ): { alias: string; input: KeyedStream; collectionId: string } {
402
435
  switch (from.type) {
403
436
  case `collectionRef`: {
404
- const input = allInputs[from.collection.id]
437
+ const input = allInputs[from.alias]
405
438
  if (!input) {
406
- throw new CollectionInputNotFoundError(from.collection.id)
439
+ throw new CollectionInputNotFoundError(
440
+ from.alias,
441
+ from.collection.id,
442
+ Object.keys(allInputs)
443
+ )
407
444
  }
445
+ aliasToCollectionId[from.alias] = from.collection.id
408
446
  return { alias: from.alias, input, collectionId: from.collection.id }
409
447
  }
410
448
  case `queryRef`: {
@@ -418,12 +456,39 @@ function processJoinSource(
418
456
  collections,
419
457
  subscriptions,
420
458
  callbacks,
421
- lazyCollections,
459
+ lazySources,
422
460
  optimizableOrderByCollections,
461
+ setWindowFn,
423
462
  cache,
424
463
  queryMapping
425
464
  )
426
465
 
466
+ // Pull up alias mappings from subquery to parent scope.
467
+ // This includes both the innermost alias-to-collection mappings AND
468
+ // any existing remappings from nested subquery levels.
469
+ Object.assign(aliasToCollectionId, subQueryResult.aliasToCollectionId)
470
+ Object.assign(aliasRemapping, subQueryResult.aliasRemapping)
471
+
472
+ // Create a flattened remapping from outer alias to innermost alias.
473
+ // For nested subqueries, this ensures one-hop lookups (not recursive chains).
474
+ //
475
+ // Example with 3-level nesting:
476
+ // Inner: .from({ user: usersCollection })
477
+ // Middle: .from({ activeUser: innerSubquery }) → creates: activeUser → user
478
+ // Outer: .join({ author: middleSubquery }, ...) → creates: author → user (not author → activeUser)
479
+ //
480
+ // We search through the PULLED-UP aliasToCollectionId (which contains the
481
+ // innermost 'user' alias), so we always map directly to the deepest level.
482
+ // This means aliasRemapping[lazyAlias] is always a single lookup, never recursive.
483
+ const innerAlias = Object.keys(subQueryResult.aliasToCollectionId).find(
484
+ (alias) =>
485
+ subQueryResult.aliasToCollectionId[alias] ===
486
+ subQueryResult.collectionId
487
+ )
488
+ if (innerAlias && innerAlias !== from.alias) {
489
+ aliasRemapping[from.alias] = innerAlias
490
+ }
491
+
427
492
  // Extract the pipeline from the compilation result
428
493
  const subQueryInput = subQueryResult.pipeline
429
494
 
@@ -517,41 +582,35 @@ function processJoinResults(joinType: string) {
517
582
  /**
518
583
  * Returns the active and lazy collections for a join clause.
519
584
  * The active collection is the one that we need to fully iterate over
520
- * and it can be the main table (i.e. left collection) or the joined table (i.e. right collection).
585
+ * and it can be the main source (i.e. left collection) or the joined source (i.e. right collection).
521
586
  * The lazy collection is the one that we should join-in lazily based on matches in the active collection.
522
587
  * @param joinClause - The join clause to analyze
523
588
  * @param leftCollection - The left collection
524
589
  * @param rightCollection - The right collection
525
590
  * @returns The active and lazy collections. They are undefined if we need to loop over both collections (i.e. both are active)
526
591
  */
527
- function getActiveAndLazyCollections(
592
+ function getActiveAndLazySources(
528
593
  joinType: JoinClause[`type`],
529
594
  leftCollection: Collection,
530
595
  rightCollection: Collection
531
596
  ):
532
- | { activeCollection: `main` | `joined`; lazyCollection: Collection }
533
- | { activeCollection: undefined; lazyCollection: undefined } {
534
- if (leftCollection.id === rightCollection.id) {
535
- // We can't apply this optimization if there's only one collection
536
- // because `liveQueryCollection` will detect that the collection is lazy
537
- // and treat it lazily (because the collection is shared)
538
- // and thus it will not load any keys because both sides of the join
539
- // will be handled lazily
540
- return { activeCollection: undefined, lazyCollection: undefined }
541
- }
597
+ | { activeSource: `main` | `joined`; lazySource: Collection }
598
+ | { activeSource: undefined; lazySource: undefined } {
599
+ // Self-joins can now be optimized since we track lazy loading by source alias
600
+ // rather than collection ID. Each alias has its own subscription and lazy state.
542
601
 
543
602
  switch (joinType) {
544
603
  case `left`:
545
- return { activeCollection: `main`, lazyCollection: rightCollection }
604
+ return { activeSource: `main`, lazySource: rightCollection }
546
605
  case `right`:
547
- return { activeCollection: `joined`, lazyCollection: leftCollection }
606
+ return { activeSource: `joined`, lazySource: leftCollection }
548
607
  case `inner`:
549
608
  // The smallest collection should be the active collection
550
609
  // and the biggest collection should be lazy
551
610
  return leftCollection.size < rightCollection.size
552
- ? { activeCollection: `main`, lazyCollection: rightCollection }
553
- : { activeCollection: `joined`, lazyCollection: leftCollection }
611
+ ? { activeSource: `main`, lazySource: rightCollection }
612
+ : { activeSource: `joined`, lazySource: leftCollection }
554
613
  default:
555
- return { activeCollection: undefined, lazyCollection: undefined }
614
+ return { activeSource: undefined, lazySource: undefined }
556
615
  }
557
616
  }
@@ -5,6 +5,7 @@ import { ensureIndexForField } from "../../indexes/auto-index.js"
5
5
  import { findIndexForField } from "../../utils/index-optimization.js"
6
6
  import { compileExpression } from "./evaluators.js"
7
7
  import { replaceAggregatesByRefs } from "./group-by.js"
8
+ import type { WindowOptions } from "./types.js"
8
9
  import type { CompiledSingleRowExpression } from "./evaluators.js"
9
10
  import type { OrderBy, OrderByClause, QueryIR, Select } from "../ir.js"
10
11
  import type { NamespacedAndKeyedStream, NamespacedRow } from "../../types.js"
@@ -13,6 +14,7 @@ import type { IndexInterface } from "../../indexes/base-index.js"
13
14
  import type { Collection } from "../../collection/index.js"
14
15
 
15
16
  export type OrderByOptimizationInfo = {
17
+ alias: string
16
18
  orderBy: OrderBy
17
19
  offset: number
18
20
  limit: number
@@ -37,6 +39,7 @@ export function processOrderBy(
37
39
  selectClause: Select,
38
40
  collection: Collection,
39
41
  optimizableOrderByCollections: Record<string, OrderByOptimizationInfo>,
42
+ setWindowFn: (windowFn: (options: WindowOptions) => void) => void,
40
43
  limit?: number,
41
44
  offset?: number
42
45
  ): IStreamBuilder<KeyValue<unknown, [NamespacedRow, string]>> {
@@ -106,6 +109,8 @@ export function processOrderBy(
106
109
 
107
110
  let setSizeCallback: ((getSize: () => number) => void) | undefined
108
111
 
112
+ let orderByOptimizationInfo: OrderByOptimizationInfo | undefined
113
+
109
114
  // Optimize the orderBy operator to lazily load elements
110
115
  // by using the range index of the collection.
111
116
  // Only for orderBy clause on a single column for now (no composite ordering)
@@ -155,7 +160,13 @@ export function processOrderBy(
155
160
 
156
161
  if (index && index.supports(`gt`)) {
157
162
  // We found an index that we can use to lazily load ordered data
158
- const orderByOptimizationInfo = {
163
+ const orderByAlias =
164
+ orderByExpression.path.length > 1
165
+ ? String(orderByExpression.path[0])
166
+ : rawQuery.from.alias
167
+
168
+ orderByOptimizationInfo = {
169
+ alias: orderByAlias,
159
170
  offset: offset ?? 0,
160
171
  limit,
161
172
  comparator,
@@ -172,7 +183,7 @@ export function processOrderBy(
172
183
  ...optimizableOrderByCollections[followRefCollection.id]!,
173
184
  dataNeeded: () => {
174
185
  const size = getSize()
175
- return Math.max(0, limit - size)
186
+ return Math.max(0, orderByOptimizationInfo!.limit - size)
176
187
  },
177
188
  }
178
189
  }
@@ -187,6 +198,23 @@ export function processOrderBy(
187
198
  offset,
188
199
  comparator: compare,
189
200
  setSizeCallback,
201
+ setWindowFn: (
202
+ windowFn: (options: { offset?: number; limit?: number }) => void
203
+ ) => {
204
+ setWindowFn(
205
+ // We wrap the move function such that we update the orderByOptimizationInfo
206
+ // because that is used by the `dataNeeded` callback to determine if we need to load more data
207
+ (options) => {
208
+ windowFn(options)
209
+ if (orderByOptimizationInfo) {
210
+ orderByOptimizationInfo.offset =
211
+ options.offset ?? orderByOptimizationInfo.offset
212
+ orderByOptimizationInfo.limit =
213
+ options.limit ?? orderByOptimizationInfo.limit
214
+ }
215
+ }
216
+ )
217
+ },
190
218
  })
191
219
  // orderByWithFractionalIndex returns [key, [value, index]] - we keep this format
192
220
  )