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