@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,7 +1,12 @@
1
1
  import { D2, output } from "@tanstack/db-ivm"
2
2
  import { compileQuery } from "../compiler/index.js"
3
3
  import { buildQuery, getQueryIR } from "../builder/index.js"
4
+ import { MissingAliasInputsError } from "../../errors.js"
5
+ import { transactionScopedScheduler } from "../../scheduler.js"
6
+ import { getActiveTransaction } from "../../transactions.js"
4
7
  import { CollectionSubscriber } from "./collection-subscriber.js"
8
+ import { getCollectionBuilder } from "./collection-registry.js"
9
+ import type { SchedulerContextId } from "../../scheduler.js"
5
10
  import type { CollectionSubscription } from "../../collection/subscription.js"
6
11
  import type { RootStreamBuilder } from "@tanstack/db-ivm"
7
12
  import type { OrderByOptimizationInfo } from "../compiler/order-by.js"
@@ -11,6 +16,7 @@ import type {
11
16
  KeyedStream,
12
17
  ResultStream,
13
18
  SyncConfig,
19
+ UtilsRecord,
14
20
  } from "../../types.js"
15
21
  import type { Context, GetResult } from "../builder/types.js"
16
22
  import type { BasicExpression, QueryIR } from "../ir.js"
@@ -23,6 +29,15 @@ import type {
23
29
  } from "./types.js"
24
30
  import type { AllCollectionEvents } from "../../collection/events.js"
25
31
 
32
+ export type LiveQueryCollectionUtils = UtilsRecord & {
33
+ getRunCount: () => number
34
+ getBuilder: () => CollectionConfigBuilder<any, any>
35
+ }
36
+
37
+ type PendingGraphRun = {
38
+ loadCallbacks: Set<() => boolean>
39
+ }
40
+
26
41
  // Global counter for auto-generated collection IDs
27
42
  let liveQueryCollectionCounter = 0
28
43
 
@@ -37,6 +52,9 @@ export class CollectionConfigBuilder<
37
52
  private readonly id: string
38
53
  readonly query: QueryIR
39
54
  private readonly collections: Record<string, Collection<any, any, any>>
55
+ private readonly collectionByAlias: Record<string, Collection<any, any, any>>
56
+ // Populated during compilation with all aliases (including subquery inner aliases)
57
+ private compiledAliasToCollectionId: Record<string, string> = {}
40
58
 
41
59
  // WeakMap to store the keys of the results
42
60
  // so that we can retrieve them in the getKey function
@@ -48,6 +66,14 @@ export class CollectionConfigBuilder<
48
66
  private readonly compare?: (val1: TResult, val2: TResult) => number
49
67
 
50
68
  private isGraphRunning = false
69
+ private runCount = 0
70
+
71
+ // Current sync session state (set when sync starts, cleared when it stops)
72
+ // Public for testing purposes (CollectionConfigBuilder is internal, not public API)
73
+ public currentSyncConfig:
74
+ | Parameters<SyncConfig<TResult>[`sync`]>[0]
75
+ | undefined
76
+ public currentSyncState: FullSyncState | undefined
51
77
 
52
78
  // Error state tracking
53
79
  private isInErrorState = false
@@ -55,19 +81,41 @@ export class CollectionConfigBuilder<
55
81
  // Reference to the live query collection for error state transitions
56
82
  private liveQueryCollection?: Collection<TResult, any, any>
57
83
 
84
+ private readonly aliasDependencies: Record<
85
+ string,
86
+ Array<CollectionConfigBuilder<any, any>>
87
+ > = {}
88
+
89
+ private readonly builderDependencies = new Set<
90
+ CollectionConfigBuilder<any, any>
91
+ >()
92
+
93
+ // Pending graph runs per scheduler context (e.g., per transaction)
94
+ // The builder manages its own state; the scheduler just orchestrates execution order
95
+ // Only stores callbacks - if sync ends, pending jobs gracefully no-op
96
+ private readonly pendingGraphRuns = new Map<
97
+ SchedulerContextId,
98
+ PendingGraphRun
99
+ >()
100
+
101
+ // Unsubscribe function for scheduler's onClear listener
102
+ // Registered when sync starts, unregistered when sync stops
103
+ // Prevents memory leaks by releasing the scheduler's reference to this builder
104
+ private unsubscribeFromSchedulerClears?: () => void
105
+
58
106
  private graphCache: D2 | undefined
59
107
  private inputsCache: Record<string, RootStreamBuilder<unknown>> | undefined
60
108
  private pipelineCache: ResultStream | undefined
61
- public collectionWhereClausesCache:
109
+ public sourceWhereClausesCache:
62
110
  | Map<string, BasicExpression<boolean>>
63
111
  | undefined
64
112
 
65
- // Map of collection ID to subscription
113
+ // Map of source alias to subscription
66
114
  readonly subscriptions: Record<string, CollectionSubscription> = {}
67
- // Map of collection IDs to functions that load keys for that lazy collection
68
- lazyCollectionsCallbacks: Record<string, LazyCollectionCallbacks> = {}
69
- // Set of collection IDs that are lazy collections
70
- readonly lazyCollections = new Set<string>()
115
+ // Map of source aliases to functions that load keys for that lazy source
116
+ lazySourcesCallbacks: Record<string, LazyCollectionCallbacks> = {}
117
+ // Set of source aliases that are lazy (don't load initial state)
118
+ readonly lazySources = new Set<string>()
71
119
  // Set of collection IDs that include an optimizable ORDER BY clause
72
120
  optimizableOrderByCollections: Record<string, OrderByOptimizationInfo> = {}
73
121
 
@@ -79,6 +127,19 @@ export class CollectionConfigBuilder<
79
127
 
80
128
  this.query = buildQueryFromConfig(config)
81
129
  this.collections = extractCollectionsFromQuery(this.query)
130
+ const collectionAliasesById = extractCollectionAliases(this.query)
131
+
132
+ // Build a reverse lookup map from alias to collection instance.
133
+ // This enables self-join support where the same collection can be referenced
134
+ // multiple times with different aliases (e.g., { employee: col, manager: col })
135
+ this.collectionByAlias = {}
136
+ for (const [collectionId, aliases] of collectionAliasesById.entries()) {
137
+ const collection = this.collections[collectionId]
138
+ if (!collection) continue
139
+ for (const alias of aliases) {
140
+ this.collectionByAlias[alias] = collection
141
+ }
142
+ }
82
143
 
83
144
  // Create compare function for ordering if the query has orderBy
84
145
  if (this.query.orderBy && this.query.orderBy.length > 0) {
@@ -90,7 +151,9 @@ export class CollectionConfigBuilder<
90
151
  this.compileBasePipeline()
91
152
  }
92
153
 
93
- getConfig(): CollectionConfigSingleRowOption<TResult> {
154
+ getConfig(): CollectionConfigSingleRowOption<TResult> & {
155
+ utils: LiveQueryCollectionUtils
156
+ } {
94
157
  return {
95
158
  id: this.id,
96
159
  getKey:
@@ -105,7 +168,38 @@ export class CollectionConfigBuilder<
105
168
  onDelete: this.config.onDelete,
106
169
  startSync: this.config.startSync,
107
170
  singleResult: this.query.singleResult,
171
+ utils: {
172
+ getRunCount: this.getRunCount.bind(this),
173
+ getBuilder: () => this,
174
+ },
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Resolves a collection alias to its collection ID.
180
+ *
181
+ * Uses a two-tier lookup strategy:
182
+ * 1. First checks compiled aliases (includes subquery inner aliases)
183
+ * 2. Falls back to declared aliases from the query's from/join clauses
184
+ *
185
+ * @param alias - The alias to resolve (e.g., "employee", "manager")
186
+ * @returns The collection ID that the alias references
187
+ * @throws {Error} If the alias is not found in either lookup
188
+ */
189
+ getCollectionIdForAlias(alias: string): string {
190
+ const compiled = this.compiledAliasToCollectionId[alias]
191
+ if (compiled) {
192
+ return compiled
193
+ }
194
+ const collection = this.collectionByAlias[alias]
195
+ if (collection) {
196
+ return collection.id
108
197
  }
198
+ throw new Error(`Unknown source alias "${alias}"`)
199
+ }
200
+
201
+ isLazyAlias(alias: string): boolean {
202
+ return this.lazySources.has(alias)
109
203
  }
110
204
 
111
205
  // The callback function is called after the graph has run.
@@ -115,12 +209,8 @@ export class CollectionConfigBuilder<
115
209
  // That can happen because even though we load N rows, the pipeline might filter some of these rows out
116
210
  // causing the orderBy operator to receive less than N rows or even no rows at all.
117
211
  // So this callback would notice that it doesn't have enough rows and load some more.
118
- // The callback returns a boolean, when it's true it's done loading data.
119
- maybeRunGraph(
120
- config: SyncMethods<TResult>,
121
- syncState: FullSyncState,
122
- callback?: () => boolean
123
- ) {
212
+ // The callback returns a boolean, when it's true it's done loading data and we can mark the collection as ready.
213
+ maybeRunGraph(callback?: () => boolean) {
124
214
  if (this.isGraphRunning) {
125
215
  // no nested runs of the graph
126
216
  // which is possible if the `callback`
@@ -128,10 +218,18 @@ export class CollectionConfigBuilder<
128
218
  return
129
219
  }
130
220
 
221
+ // Should only be called when sync is active
222
+ if (!this.currentSyncConfig || !this.currentSyncState) {
223
+ throw new Error(
224
+ `maybeRunGraph called without active sync session. This should not happen.`
225
+ )
226
+ }
227
+
131
228
  this.isGraphRunning = true
132
229
 
133
230
  try {
134
- const { begin, commit } = config
231
+ const { begin, commit } = this.currentSyncConfig
232
+ const syncState = this.currentSyncState
135
233
 
136
234
  // Don't run if the live query is in an error state
137
235
  if (this.isInErrorState) {
@@ -152,7 +250,7 @@ export class CollectionConfigBuilder<
152
250
  commit()
153
251
  // After initial commit, check if we should mark ready
154
252
  // (in case all sources were already ready before we subscribed)
155
- this.updateLiveQueryStatus(config)
253
+ this.updateLiveQueryStatus(this.currentSyncConfig)
156
254
  }
157
255
  }
158
256
  } finally {
@@ -160,6 +258,158 @@ export class CollectionConfigBuilder<
160
258
  }
161
259
  }
162
260
 
261
+ /**
262
+ * Schedules a graph run with the transaction-scoped scheduler.
263
+ * Ensures each builder runs at most once per transaction, with automatic dependency tracking
264
+ * to run parent queries before child queries. Outside a transaction, runs immediately.
265
+ *
266
+ * Multiple calls during a transaction are coalesced into a single execution.
267
+ * Dependencies are auto-discovered from subscribed live queries, or can be overridden.
268
+ * Load callbacks are combined when entries merge.
269
+ *
270
+ * Uses the current sync session's config and syncState from instance properties.
271
+ *
272
+ * @param callback - Optional callback to load more data if needed (returns true when done)
273
+ * @param options - Optional scheduling configuration
274
+ * @param options.contextId - Transaction ID to group work; defaults to active transaction
275
+ * @param options.jobId - Unique identifier for this job; defaults to this builder instance
276
+ * @param options.alias - Source alias that triggered this schedule; adds alias-specific dependencies
277
+ * @param options.dependencies - Explicit dependency list; overrides auto-discovered dependencies
278
+ */
279
+ scheduleGraphRun(
280
+ callback?: () => boolean,
281
+ options?: {
282
+ contextId?: SchedulerContextId
283
+ jobId?: unknown
284
+ alias?: string
285
+ dependencies?: Array<CollectionConfigBuilder<any, any>>
286
+ }
287
+ ) {
288
+ const contextId = options?.contextId ?? getActiveTransaction()?.id
289
+ // Use the builder instance as the job ID for deduplication. This is memory-safe
290
+ // because the scheduler's context Map is deleted after flushing (no long-term retention).
291
+ const jobId = options?.jobId ?? this
292
+ const dependentBuilders = (() => {
293
+ if (options?.dependencies) {
294
+ return options.dependencies
295
+ }
296
+
297
+ const deps = new Set(this.builderDependencies)
298
+ if (options?.alias) {
299
+ const aliasDeps = this.aliasDependencies[options.alias]
300
+ if (aliasDeps) {
301
+ for (const dep of aliasDeps) {
302
+ deps.add(dep)
303
+ }
304
+ }
305
+ }
306
+
307
+ deps.delete(this)
308
+
309
+ return Array.from(deps)
310
+ })()
311
+
312
+ // We intentionally scope deduplication to the builder instance. Each instance
313
+ // owns caches and compiled pipelines, so sharing work across instances that
314
+ // merely reuse the same string id would execute the wrong builder's graph.
315
+
316
+ if (!this.currentSyncConfig || !this.currentSyncState) {
317
+ throw new Error(
318
+ `scheduleGraphRun called without active sync session. This should not happen.`
319
+ )
320
+ }
321
+
322
+ // Manage our own state - get or create pending callbacks for this context
323
+ let pending = contextId ? this.pendingGraphRuns.get(contextId) : undefined
324
+ if (!pending) {
325
+ pending = {
326
+ loadCallbacks: new Set(),
327
+ }
328
+ if (contextId) {
329
+ this.pendingGraphRuns.set(contextId, pending)
330
+ }
331
+ }
332
+
333
+ // Add callback if provided (this is what accumulates between schedules)
334
+ if (callback) {
335
+ pending.loadCallbacks.add(callback)
336
+ }
337
+
338
+ // Schedule execution (scheduler just orchestrates order, we manage state)
339
+ // For immediate execution (no contextId), pass pending directly since it won't be in the map
340
+ const pendingToPass = contextId ? undefined : pending
341
+ transactionScopedScheduler.schedule({
342
+ contextId,
343
+ jobId,
344
+ dependencies: dependentBuilders,
345
+ run: () => this.executeGraphRun(contextId, pendingToPass),
346
+ })
347
+ }
348
+
349
+ /**
350
+ * Clears pending graph run state for a specific context.
351
+ * Called when the scheduler clears a context (e.g., transaction rollback/abort).
352
+ */
353
+ clearPendingGraphRun(contextId: SchedulerContextId): void {
354
+ this.pendingGraphRuns.delete(contextId)
355
+ }
356
+
357
+ /**
358
+ * Executes a pending graph run. Called by the scheduler when dependencies are satisfied.
359
+ * Clears the pending state BEFORE execution so that any re-schedules during the run
360
+ * create fresh state and don't interfere with the current execution.
361
+ * Uses instance sync state - if sync has ended, gracefully returns without executing.
362
+ *
363
+ * @param contextId - Optional context ID to look up pending state
364
+ * @param pendingParam - For immediate execution (no context), pending state is passed directly
365
+ */
366
+ private executeGraphRun(
367
+ contextId?: SchedulerContextId,
368
+ pendingParam?: PendingGraphRun
369
+ ): void {
370
+ // Get pending state: either from parameter (no context) or from map (with context)
371
+ // Remove from map BEFORE checking sync state to prevent leaking entries when sync ends
372
+ // before the transaction flushes (e.g., unsubscribe during in-flight transaction)
373
+ const pending =
374
+ pendingParam ??
375
+ (contextId ? this.pendingGraphRuns.get(contextId) : undefined)
376
+ if (contextId) {
377
+ this.pendingGraphRuns.delete(contextId)
378
+ }
379
+
380
+ // If no pending state, nothing to execute (context was cleared)
381
+ if (!pending) {
382
+ return
383
+ }
384
+
385
+ // If sync session has ended, don't execute (graph is finalized, subscriptions cleared)
386
+ if (!this.currentSyncConfig || !this.currentSyncState) {
387
+ return
388
+ }
389
+
390
+ this.incrementRunCount()
391
+
392
+ const combinedLoader = () => {
393
+ let allDone = true
394
+ let firstError: unknown
395
+ pending.loadCallbacks.forEach((loader) => {
396
+ try {
397
+ allDone = loader() && allDone
398
+ } catch (error) {
399
+ allDone = false
400
+ firstError ??= error
401
+ }
402
+ })
403
+ if (firstError) {
404
+ throw firstError
405
+ }
406
+ // Returning false signals that callers should schedule another pass.
407
+ return allDone
408
+ }
409
+
410
+ this.maybeRunGraph(combinedLoader)
411
+ }
412
+
163
413
  private getSyncConfig(): SyncConfig<TResult> {
164
414
  return {
165
415
  rowUpdateMode: `full`,
@@ -167,9 +417,19 @@ export class CollectionConfigBuilder<
167
417
  }
168
418
  }
169
419
 
420
+ incrementRunCount() {
421
+ this.runCount++
422
+ }
423
+
424
+ getRunCount() {
425
+ return this.runCount
426
+ }
427
+
170
428
  private syncFn(config: SyncMethods<TResult>) {
171
429
  // Store reference to the live query collection for error state transitions
172
430
  this.liveQueryCollection = config.collection
431
+ // Store config and syncState as instance properties for the duration of this sync session
432
+ this.currentSyncConfig = config
173
433
 
174
434
  const syncState: SyncState = {
175
435
  messagesCount: 0,
@@ -182,6 +442,15 @@ export class CollectionConfigBuilder<
182
442
  config,
183
443
  syncState
184
444
  )
445
+ this.currentSyncState = fullSyncState
446
+
447
+ // Listen for scheduler context clears to clean up our pending state
448
+ // Re-register on each sync start so the listener is active for the sync session's lifetime
449
+ this.unsubscribeFromSchedulerClears = transactionScopedScheduler.onClear(
450
+ (contextId) => {
451
+ this.clearPendingGraphRun(contextId)
452
+ }
453
+ )
185
454
 
186
455
  const loadMoreDataCallbacks = this.subscribeToAllCollections(
187
456
  config,
@@ -189,51 +458,81 @@ export class CollectionConfigBuilder<
189
458
  )
190
459
 
191
460
  // Initial run with callback to load more data if needed
192
- this.maybeRunGraph(config, fullSyncState, loadMoreDataCallbacks)
461
+ this.scheduleGraphRun(loadMoreDataCallbacks)
193
462
 
194
463
  // Return the unsubscribe function
195
464
  return () => {
196
465
  syncState.unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe())
197
466
 
467
+ // Clear current sync session state
468
+ this.currentSyncConfig = undefined
469
+ this.currentSyncState = undefined
470
+
471
+ // Clear all pending graph runs to prevent memory leaks from in-flight transactions
472
+ // that may flush after the sync session ends
473
+ this.pendingGraphRuns.clear()
474
+
198
475
  // Reset caches so a fresh graph/pipeline is compiled on next start
199
476
  // This avoids reusing a finalized D2 graph across GC restarts
200
477
  this.graphCache = undefined
201
478
  this.inputsCache = undefined
202
479
  this.pipelineCache = undefined
203
- this.collectionWhereClausesCache = undefined
480
+ this.sourceWhereClausesCache = undefined
204
481
 
205
- // Reset lazy collection state
206
- this.lazyCollections.clear()
482
+ // Reset lazy source alias state
483
+ this.lazySources.clear()
207
484
  this.optimizableOrderByCollections = {}
208
- this.lazyCollectionsCallbacks = {}
485
+ this.lazySourcesCallbacks = {}
486
+
487
+ // Clear subscription references to prevent memory leaks
488
+ // Note: Individual subscriptions are already unsubscribed via unsubscribeCallbacks
489
+ Object.keys(this.subscriptions).forEach(
490
+ (key) => delete this.subscriptions[key]
491
+ )
492
+ this.compiledAliasToCollectionId = {}
493
+
494
+ // Unregister from scheduler's onClear listener to prevent memory leaks
495
+ // The scheduler's listener Set would otherwise keep a strong reference to this builder
496
+ this.unsubscribeFromSchedulerClears?.()
497
+ this.unsubscribeFromSchedulerClears = undefined
209
498
  }
210
499
  }
211
500
 
501
+ /**
502
+ * Compiles the query pipeline with all declared aliases.
503
+ */
212
504
  private compileBasePipeline() {
213
505
  this.graphCache = new D2()
214
506
  this.inputsCache = Object.fromEntries(
215
- Object.entries(this.collections).map(([key]) => [
216
- key,
507
+ Object.keys(this.collectionByAlias).map((alias) => [
508
+ alias,
217
509
  this.graphCache!.newInput<any>(),
218
510
  ])
219
511
  )
220
512
 
221
- // Compile the query and get both pipeline and collection WHERE clauses
222
- const {
223
- pipeline: pipelineCache,
224
- collectionWhereClauses: collectionWhereClausesCache,
225
- } = compileQuery(
513
+ const compilation = compileQuery(
226
514
  this.query,
227
515
  this.inputsCache as Record<string, KeyedStream>,
228
516
  this.collections,
229
517
  this.subscriptions,
230
- this.lazyCollectionsCallbacks,
231
- this.lazyCollections,
518
+ this.lazySourcesCallbacks,
519
+ this.lazySources,
232
520
  this.optimizableOrderByCollections
233
521
  )
234
522
 
235
- this.pipelineCache = pipelineCache
236
- this.collectionWhereClausesCache = collectionWhereClausesCache
523
+ this.pipelineCache = compilation.pipeline
524
+ this.sourceWhereClausesCache = compilation.sourceWhereClauses
525
+ this.compiledAliasToCollectionId = compilation.aliasToCollectionId
526
+
527
+ // Defensive check: verify all compiled aliases have corresponding inputs
528
+ // This should never happen since all aliases come from user declarations,
529
+ // but catch it early if the assumption is violated in the future.
530
+ const missingAliases = Object.keys(this.compiledAliasToCollectionId).filter(
531
+ (alias) => !Object.hasOwn(this.inputsCache!, alias)
532
+ )
533
+ if (missingAliases.length > 0) {
534
+ throw new MissingAliasInputsError(missingAliases)
535
+ }
237
536
  }
238
537
 
239
538
  private maybeCompileBasePipeline() {
@@ -400,44 +699,78 @@ export class CollectionConfigBuilder<
400
699
  )
401
700
  }
402
701
 
702
+ /**
703
+ * Creates per-alias subscriptions enabling self-join support.
704
+ * Each alias gets its own subscription with independent filters, even for the same collection.
705
+ * Example: `{ employee: col, manager: col }` creates two separate subscriptions.
706
+ */
403
707
  private subscribeToAllCollections(
404
708
  config: SyncMethods<TResult>,
405
709
  syncState: FullSyncState
406
710
  ) {
407
- const loaders = Object.entries(this.collections).map(
408
- ([collectionId, collection]) => {
409
- const collectionSubscriber = new CollectionSubscriber(
410
- collectionId,
411
- collection,
412
- config,
413
- syncState,
414
- this
415
- )
416
-
417
- const subscription = collectionSubscriber.subscribe()
418
- this.subscriptions[collectionId] = subscription
419
-
420
- // Subscribe to status changes for status flow
421
- const statusUnsubscribe = collection.on(`status:change`, (event) => {
422
- this.handleSourceStatusChange(config, collectionId, event)
423
- })
424
- syncState.unsubscribeCallbacks.add(statusUnsubscribe)
425
-
426
- const loadMore = collectionSubscriber.loadMoreIfNeeded.bind(
427
- collectionSubscriber,
428
- subscription
429
- )
430
-
431
- return loadMore
711
+ // Use compiled aliases as the source of truth - these include all aliases from the query
712
+ // including those from subqueries, which may not be in collectionByAlias
713
+ const compiledAliases = Object.entries(this.compiledAliasToCollectionId)
714
+ if (compiledAliases.length === 0) {
715
+ throw new Error(
716
+ `Compiler returned no alias metadata for query '${this.id}'. This should not happen; please report.`
717
+ )
718
+ }
719
+
720
+ // Create a separate subscription for each alias, enabling self-joins where the same
721
+ // collection can be used multiple times with different filters and subscriptions
722
+ const loaders = compiledAliases.map(([alias, collectionId]) => {
723
+ // Try collectionByAlias first (for declared aliases), fall back to collections (for subquery aliases)
724
+ const collection =
725
+ this.collectionByAlias[alias] ?? this.collections[collectionId]!
726
+
727
+ const dependencyBuilder = getCollectionBuilder(collection)
728
+ if (dependencyBuilder && dependencyBuilder !== this) {
729
+ this.aliasDependencies[alias] = [dependencyBuilder]
730
+ this.builderDependencies.add(dependencyBuilder)
731
+ } else {
732
+ this.aliasDependencies[alias] = []
432
733
  }
433
- )
434
734
 
735
+ // CollectionSubscriber handles the actual subscription to the source collection
736
+ // and feeds data into the D2 graph inputs for this specific alias
737
+ const collectionSubscriber = new CollectionSubscriber(
738
+ alias,
739
+ collectionId,
740
+ collection,
741
+ this
742
+ )
743
+
744
+ // Subscribe to status changes for status flow
745
+ const statusUnsubscribe = collection.on(`status:change`, (event) => {
746
+ this.handleSourceStatusChange(config, collectionId, event)
747
+ })
748
+ syncState.unsubscribeCallbacks.add(statusUnsubscribe)
749
+
750
+ const subscription = collectionSubscriber.subscribe()
751
+ // Store subscription by alias (not collection ID) to support lazy loading
752
+ // which needs to look up subscriptions by their query alias
753
+ this.subscriptions[alias] = subscription
754
+
755
+ // Create a callback for loading more data if needed (used by OrderBy optimization)
756
+ const loadMore = collectionSubscriber.loadMoreIfNeeded.bind(
757
+ collectionSubscriber,
758
+ subscription
759
+ )
760
+
761
+ return loadMore
762
+ })
763
+
764
+ // Combine all loaders into a single callback that initiates loading more data
765
+ // from any source that needs it. Returns true once all loaders have been called,
766
+ // but the actual async loading may still be in progress.
435
767
  const loadMoreDataCallback = () => {
436
768
  loaders.map((loader) => loader())
437
769
  return true
438
770
  }
439
771
 
440
- // Mark the collections as subscribed in the sync state
772
+ // Mark as subscribed so the graph can start running
773
+ // (graph only runs when all collections are subscribed)
441
774
  syncState.subscribedToAllCollections = true
442
775
 
443
776
  // Initial status check after all subscriptions are set up
@@ -524,6 +857,65 @@ function extractCollectionsFromQuery(
524
857
  return collections
525
858
  }
526
859
 
860
+ /**
861
+ * Extracts all aliases used for each collection across the entire query tree.
862
+ *
863
+ * Traverses the QueryIR recursively to build a map from collection ID to all aliases
864
+ * that reference that collection. This is essential for self-join support, where the
865
+ * same collection may be referenced multiple times with different aliases.
866
+ *
867
+ * For example, given a query like:
868
+ * ```ts
869
+ * q.from({ employee: employeesCollection })
870
+ * .join({ manager: employeesCollection }, ({ employee, manager }) =>
871
+ * eq(employee.managerId, manager.id)
872
+ * )
873
+ * ```
874
+ *
875
+ * This function would return:
876
+ * ```
877
+ * Map { "employees" => Set { "employee", "manager" } }
878
+ * ```
879
+ *
880
+ * @param query - The query IR to extract aliases from
881
+ * @returns A map from collection ID to the set of all aliases referencing that collection
882
+ */
883
+ function extractCollectionAliases(query: QueryIR): Map<string, Set<string>> {
884
+ const aliasesById = new Map<string, Set<string>>()
885
+
886
+ function recordAlias(source: any) {
887
+ if (!source) return
888
+
889
+ if (source.type === `collectionRef`) {
890
+ const { id } = source.collection
891
+ const existing = aliasesById.get(id)
892
+ if (existing) {
893
+ existing.add(source.alias)
894
+ } else {
895
+ aliasesById.set(id, new Set([source.alias]))
896
+ }
897
+ } else if (source.type === `queryRef`) {
898
+ traverse(source.query)
899
+ }
900
+ }
901
+
902
+ function traverse(q?: QueryIR) {
903
+ if (!q) return
904
+
905
+ recordAlias(q.from)
906
+
907
+ if (q.join) {
908
+ for (const joinClause of q.join) {
909
+ recordAlias(joinClause.from)
910
+ }
911
+ }
912
+ }
913
+
914
+ traverse(query)
915
+
916
+ return aliasesById
917
+ }
918
+
527
919
  function accumulateChanges<T>(
528
920
  acc: Map<unknown, Changes<T>>,
529
921
  [[key, tupleData], multiplicity]: [