@tanstack/db 0.4.7 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/cjs/collection/index.cjs.map +1 -1
  2. package/dist/cjs/collection/index.d.cts +2 -1
  3. package/dist/cjs/collection/lifecycle.cjs +2 -3
  4. package/dist/cjs/collection/lifecycle.cjs.map +1 -1
  5. package/dist/cjs/collection/state.cjs +22 -33
  6. package/dist/cjs/collection/state.cjs.map +1 -1
  7. package/dist/cjs/collection/state.d.cts +6 -2
  8. package/dist/cjs/collection/sync.cjs +4 -3
  9. package/dist/cjs/collection/sync.cjs.map +1 -1
  10. package/dist/cjs/errors.cjs +51 -17
  11. package/dist/cjs/errors.cjs.map +1 -1
  12. package/dist/cjs/errors.d.cts +38 -8
  13. package/dist/cjs/index.cjs +8 -4
  14. package/dist/cjs/index.cjs.map +1 -1
  15. package/dist/cjs/indexes/auto-index.cjs +0 -3
  16. package/dist/cjs/indexes/auto-index.cjs.map +1 -1
  17. package/dist/cjs/query/builder/types.d.cts +1 -1
  18. package/dist/cjs/query/compiler/index.cjs +42 -19
  19. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  20. package/dist/cjs/query/compiler/index.d.cts +33 -8
  21. package/dist/cjs/query/compiler/joins.cjs +88 -66
  22. package/dist/cjs/query/compiler/joins.cjs.map +1 -1
  23. package/dist/cjs/query/compiler/joins.d.cts +5 -2
  24. package/dist/cjs/query/compiler/order-by.cjs +2 -0
  25. package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
  26. package/dist/cjs/query/compiler/order-by.d.cts +1 -0
  27. package/dist/cjs/query/compiler/select.cjs.map +1 -1
  28. package/dist/cjs/query/live/collection-config-builder.cjs +322 -46
  29. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  30. package/dist/cjs/query/live/collection-config-builder.d.cts +98 -7
  31. package/dist/cjs/query/live/collection-registry.cjs +16 -0
  32. package/dist/cjs/query/live/collection-registry.cjs.map +1 -0
  33. package/dist/cjs/query/live/collection-registry.d.cts +26 -0
  34. package/dist/cjs/query/live/collection-subscriber.cjs +57 -58
  35. package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
  36. package/dist/cjs/query/live/collection-subscriber.d.cts +4 -7
  37. package/dist/cjs/query/live-query-collection.cjs +11 -5
  38. package/dist/cjs/query/live-query-collection.cjs.map +1 -1
  39. package/dist/cjs/query/live-query-collection.d.cts +10 -3
  40. package/dist/cjs/query/optimizer.cjs +44 -7
  41. package/dist/cjs/query/optimizer.cjs.map +1 -1
  42. package/dist/cjs/query/optimizer.d.cts +4 -4
  43. package/dist/cjs/scheduler.cjs +137 -0
  44. package/dist/cjs/scheduler.cjs.map +1 -0
  45. package/dist/cjs/scheduler.d.cts +56 -0
  46. package/dist/cjs/transactions.cjs +7 -1
  47. package/dist/cjs/transactions.cjs.map +1 -1
  48. package/dist/cjs/types.d.cts +3 -5
  49. package/dist/esm/collection/index.d.ts +2 -1
  50. package/dist/esm/collection/index.js.map +1 -1
  51. package/dist/esm/collection/lifecycle.js +2 -3
  52. package/dist/esm/collection/lifecycle.js.map +1 -1
  53. package/dist/esm/collection/state.d.ts +6 -2
  54. package/dist/esm/collection/state.js +22 -33
  55. package/dist/esm/collection/state.js.map +1 -1
  56. package/dist/esm/collection/sync.js +4 -3
  57. package/dist/esm/collection/sync.js.map +1 -1
  58. package/dist/esm/errors.d.ts +38 -8
  59. package/dist/esm/errors.js +52 -18
  60. package/dist/esm/errors.js.map +1 -1
  61. package/dist/esm/index.js +9 -5
  62. package/dist/esm/indexes/auto-index.js +0 -3
  63. package/dist/esm/indexes/auto-index.js.map +1 -1
  64. package/dist/esm/query/builder/types.d.ts +1 -1
  65. package/dist/esm/query/compiler/index.d.ts +33 -8
  66. package/dist/esm/query/compiler/index.js +42 -19
  67. package/dist/esm/query/compiler/index.js.map +1 -1
  68. package/dist/esm/query/compiler/joins.d.ts +5 -2
  69. package/dist/esm/query/compiler/joins.js +90 -68
  70. package/dist/esm/query/compiler/joins.js.map +1 -1
  71. package/dist/esm/query/compiler/order-by.d.ts +1 -0
  72. package/dist/esm/query/compiler/order-by.js +2 -0
  73. package/dist/esm/query/compiler/order-by.js.map +1 -1
  74. package/dist/esm/query/compiler/select.js.map +1 -1
  75. package/dist/esm/query/live/collection-config-builder.d.ts +98 -7
  76. package/dist/esm/query/live/collection-config-builder.js +322 -46
  77. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  78. package/dist/esm/query/live/collection-registry.d.ts +26 -0
  79. package/dist/esm/query/live/collection-registry.js +16 -0
  80. package/dist/esm/query/live/collection-registry.js.map +1 -0
  81. package/dist/esm/query/live/collection-subscriber.d.ts +4 -7
  82. package/dist/esm/query/live/collection-subscriber.js +57 -58
  83. package/dist/esm/query/live/collection-subscriber.js.map +1 -1
  84. package/dist/esm/query/live-query-collection.d.ts +10 -3
  85. package/dist/esm/query/live-query-collection.js +11 -5
  86. package/dist/esm/query/live-query-collection.js.map +1 -1
  87. package/dist/esm/query/optimizer.d.ts +4 -4
  88. package/dist/esm/query/optimizer.js +44 -7
  89. package/dist/esm/query/optimizer.js.map +1 -1
  90. package/dist/esm/scheduler.d.ts +56 -0
  91. package/dist/esm/scheduler.js +137 -0
  92. package/dist/esm/scheduler.js.map +1 -0
  93. package/dist/esm/transactions.js +7 -1
  94. package/dist/esm/transactions.js.map +1 -1
  95. package/dist/esm/types.d.ts +3 -5
  96. package/package.json +2 -2
  97. package/src/collection/index.ts +1 -1
  98. package/src/collection/lifecycle.ts +3 -4
  99. package/src/collection/state.ts +52 -48
  100. package/src/collection/sync.ts +7 -6
  101. package/src/errors.ts +79 -13
  102. package/src/indexes/auto-index.ts +0 -8
  103. package/src/query/builder/types.ts +1 -1
  104. package/src/query/compiler/index.ts +115 -32
  105. package/src/query/compiler/joins.ts +180 -127
  106. package/src/query/compiler/order-by.ts +7 -0
  107. package/src/query/compiler/select.ts +2 -3
  108. package/src/query/live/collection-config-builder.ts +542 -71
  109. package/src/query/live/collection-registry.ts +47 -0
  110. package/src/query/live/collection-subscriber.ts +87 -105
  111. package/src/query/live-query-collection.ts +39 -14
  112. package/src/query/optimizer.ts +85 -15
  113. package/src/scheduler.ts +198 -0
  114. package/src/transactions.ts +12 -1
  115. package/src/types.ts +3 -5
@@ -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"
@@ -21,10 +27,24 @@ import type {
21
27
  LiveQueryCollectionConfig,
22
28
  SyncState,
23
29
  } from "./types.js"
30
+ import type { AllCollectionEvents } from "../../collection/events.js"
31
+
32
+ export type LiveQueryCollectionUtils = UtilsRecord & {
33
+ getRunCount: () => number
34
+ getBuilder: () => CollectionConfigBuilder<any, any>
35
+ }
36
+
37
+ type PendingGraphRun = {
38
+ loadCallbacks: Set<() => boolean>
39
+ }
24
40
 
25
41
  // Global counter for auto-generated collection IDs
26
42
  let liveQueryCollectionCounter = 0
27
43
 
44
+ type SyncMethods<TResult extends object> = Parameters<
45
+ SyncConfig<TResult>[`sync`]
46
+ >[0]
47
+
28
48
  export class CollectionConfigBuilder<
29
49
  TContext extends Context,
30
50
  TResult extends object = GetResult<TContext>,
@@ -32,6 +52,9 @@ export class CollectionConfigBuilder<
32
52
  private readonly id: string
33
53
  readonly query: QueryIR
34
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> = {}
35
58
 
36
59
  // WeakMap to store the keys of the results
37
60
  // so that we can retrieve them in the getKey function
@@ -43,20 +66,56 @@ export class CollectionConfigBuilder<
43
66
  private readonly compare?: (val1: TResult, val2: TResult) => number
44
67
 
45
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
77
+
78
+ // Error state tracking
79
+ private isInErrorState = false
80
+
81
+ // Reference to the live query collection for error state transitions
82
+ private liveQueryCollection?: Collection<TResult, any, any>
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
46
105
 
47
106
  private graphCache: D2 | undefined
48
107
  private inputsCache: Record<string, RootStreamBuilder<unknown>> | undefined
49
108
  private pipelineCache: ResultStream | undefined
50
- public collectionWhereClausesCache:
109
+ public sourceWhereClausesCache:
51
110
  | Map<string, BasicExpression<boolean>>
52
111
  | undefined
53
112
 
54
- // Map of collection ID to subscription
113
+ // Map of source alias to subscription
55
114
  readonly subscriptions: Record<string, CollectionSubscription> = {}
56
- // Map of collection IDs to functions that load keys for that lazy collection
57
- lazyCollectionsCallbacks: Record<string, LazyCollectionCallbacks> = {}
58
- // Set of collection IDs that are lazy collections
59
- 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>()
60
119
  // Set of collection IDs that include an optimizable ORDER BY clause
61
120
  optimizableOrderByCollections: Record<string, OrderByOptimizationInfo> = {}
62
121
 
@@ -68,6 +127,19 @@ export class CollectionConfigBuilder<
68
127
 
69
128
  this.query = buildQueryFromConfig(config)
70
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
+ }
71
143
 
72
144
  // Create compare function for ordering if the query has orderBy
73
145
  if (this.query.orderBy && this.query.orderBy.length > 0) {
@@ -79,7 +151,9 @@ export class CollectionConfigBuilder<
79
151
  this.compileBasePipeline()
80
152
  }
81
153
 
82
- getConfig(): CollectionConfigSingleRowOption<TResult> {
154
+ getConfig(): CollectionConfigSingleRowOption<TResult> & {
155
+ utils: LiveQueryCollectionUtils
156
+ } {
83
157
  return {
84
158
  id: this.id,
85
159
  getKey:
@@ -94,22 +168,49 @@ export class CollectionConfigBuilder<
94
168
  onDelete: this.config.onDelete,
95
169
  startSync: this.config.startSync,
96
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
97
197
  }
198
+ throw new Error(`Unknown source alias "${alias}"`)
199
+ }
200
+
201
+ isLazyAlias(alias: string): boolean {
202
+ return this.lazySources.has(alias)
98
203
  }
99
204
 
100
205
  // The callback function is called after the graph has run.
101
206
  // This gives the callback a chance to load more data if needed,
102
207
  // that's used to optimize orderBy operators that set a limit,
103
208
  // in order to load some more data if we still don't have enough rows after the pipeline has run.
104
- // That can happend because even though we load N rows, the pipeline might filter some of these rows out
209
+ // That can happen because even though we load N rows, the pipeline might filter some of these rows out
105
210
  // causing the orderBy operator to receive less than N rows or even no rows at all.
106
211
  // So this callback would notice that it doesn't have enough rows and load some more.
107
212
  // The callback returns a boolean, when it's true it's done loading data and we can mark the collection as ready.
108
- maybeRunGraph(
109
- config: Parameters<SyncConfig<TResult>[`sync`]>[0],
110
- syncState: FullSyncState,
111
- callback?: () => boolean
112
- ) {
213
+ maybeRunGraph(callback?: () => boolean) {
113
214
  if (this.isGraphRunning) {
114
215
  // no nested runs of the graph
115
216
  // which is possible if the `callback`
@@ -117,16 +218,26 @@ export class CollectionConfigBuilder<
117
218
  return
118
219
  }
119
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
+
120
228
  this.isGraphRunning = true
121
229
 
122
230
  try {
123
- const { begin, commit, markReady } = config
231
+ const { begin, commit } = this.currentSyncConfig
232
+ const syncState = this.currentSyncState
233
+
234
+ // Don't run if the live query is in an error state
235
+ if (this.isInErrorState) {
236
+ return
237
+ }
124
238
 
125
- // We only run the graph if all the collections are ready
126
- if (
127
- this.allCollectionsReadyOrInitialCommit() &&
128
- syncState.subscribedToAllCollections
129
- ) {
239
+ // Always run the graph if subscribed (eager execution)
240
+ if (syncState.subscribedToAllCollections) {
130
241
  while (syncState.graph.pendingWork()) {
131
242
  syncState.graph.run()
132
243
  callback?.()
@@ -137,10 +248,9 @@ export class CollectionConfigBuilder<
137
248
  if (syncState.messagesCount === 0) {
138
249
  begin()
139
250
  commit()
140
- }
141
- // Mark the collection as ready after the first successful run
142
- if (this.allCollectionsReady()) {
143
- markReady()
251
+ // After initial commit, check if we should mark ready
252
+ // (in case all sources were already ready before we subscribed)
253
+ this.updateLiveQueryStatus(this.currentSyncConfig)
144
254
  }
145
255
  }
146
256
  } finally {
@@ -148,6 +258,158 @@ export class CollectionConfigBuilder<
148
258
  }
149
259
  }
150
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
+
151
413
  private getSyncConfig(): SyncConfig<TResult> {
152
414
  return {
153
415
  rowUpdateMode: `full`,
@@ -155,7 +417,20 @@ export class CollectionConfigBuilder<
155
417
  }
156
418
  }
157
419
 
158
- private syncFn(config: Parameters<SyncConfig<TResult>[`sync`]>[0]) {
420
+ incrementRunCount() {
421
+ this.runCount++
422
+ }
423
+
424
+ getRunCount() {
425
+ return this.runCount
426
+ }
427
+
428
+ private syncFn(config: SyncMethods<TResult>) {
429
+ // Store reference to the live query collection for error state transitions
430
+ this.liveQueryCollection = config.collection
431
+ // Store config and syncState as instance properties for the duration of this sync session
432
+ this.currentSyncConfig = config
433
+
159
434
  const syncState: SyncState = {
160
435
  messagesCount: 0,
161
436
  subscribedToAllCollections: false,
@@ -167,6 +442,15 @@ export class CollectionConfigBuilder<
167
442
  config,
168
443
  syncState
169
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
+ )
170
454
 
171
455
  const loadMoreDataCallbacks = this.subscribeToAllCollections(
172
456
  config,
@@ -174,51 +458,81 @@ export class CollectionConfigBuilder<
174
458
  )
175
459
 
176
460
  // Initial run with callback to load more data if needed
177
- this.maybeRunGraph(config, fullSyncState, loadMoreDataCallbacks)
461
+ this.scheduleGraphRun(loadMoreDataCallbacks)
178
462
 
179
463
  // Return the unsubscribe function
180
464
  return () => {
181
465
  syncState.unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe())
182
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
+
183
475
  // Reset caches so a fresh graph/pipeline is compiled on next start
184
476
  // This avoids reusing a finalized D2 graph across GC restarts
185
477
  this.graphCache = undefined
186
478
  this.inputsCache = undefined
187
479
  this.pipelineCache = undefined
188
- this.collectionWhereClausesCache = undefined
480
+ this.sourceWhereClausesCache = undefined
189
481
 
190
- // Reset lazy collection state
191
- this.lazyCollections.clear()
482
+ // Reset lazy source alias state
483
+ this.lazySources.clear()
192
484
  this.optimizableOrderByCollections = {}
193
- 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
194
498
  }
195
499
  }
196
500
 
501
+ /**
502
+ * Compiles the query pipeline with all declared aliases.
503
+ */
197
504
  private compileBasePipeline() {
198
505
  this.graphCache = new D2()
199
506
  this.inputsCache = Object.fromEntries(
200
- Object.entries(this.collections).map(([key]) => [
201
- key,
507
+ Object.keys(this.collectionByAlias).map((alias) => [
508
+ alias,
202
509
  this.graphCache!.newInput<any>(),
203
510
  ])
204
511
  )
205
512
 
206
- // Compile the query and get both pipeline and collection WHERE clauses
207
- const {
208
- pipeline: pipelineCache,
209
- collectionWhereClauses: collectionWhereClausesCache,
210
- } = compileQuery(
513
+ const compilation = compileQuery(
211
514
  this.query,
212
515
  this.inputsCache as Record<string, KeyedStream>,
213
516
  this.collections,
214
517
  this.subscriptions,
215
- this.lazyCollectionsCallbacks,
216
- this.lazyCollections,
518
+ this.lazySourcesCallbacks,
519
+ this.lazySources,
217
520
  this.optimizableOrderByCollections
218
521
  )
219
522
 
220
- this.pipelineCache = pipelineCache
221
- 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
+ }
222
536
  }
223
537
 
224
538
  private maybeCompileBasePipeline() {
@@ -233,7 +547,7 @@ export class CollectionConfigBuilder<
233
547
  }
234
548
 
235
549
  private extendPipelineWithChangeProcessing(
236
- config: Parameters<SyncConfig<TResult>[`sync`]>[0],
550
+ config: SyncMethods<TResult>,
237
551
  syncState: SyncState
238
552
  ): FullSyncState {
239
553
  const { begin, commit } = config
@@ -266,7 +580,7 @@ export class CollectionConfigBuilder<
266
580
  }
267
581
 
268
582
  private applyChanges(
269
- config: Parameters<SyncConfig<TResult>[`sync`]>[0],
583
+ config: SyncMethods<TResult>,
270
584
  changes: {
271
585
  deletes: number
272
586
  inserts: number
@@ -317,53 +631,151 @@ export class CollectionConfigBuilder<
317
631
  }
318
632
  }
319
633
 
634
+ /**
635
+ * Handle status changes from source collections
636
+ */
637
+ private handleSourceStatusChange(
638
+ config: SyncMethods<TResult>,
639
+ collectionId: string,
640
+ event: AllCollectionEvents[`status:change`]
641
+ ) {
642
+ const { status } = event
643
+
644
+ // Handle error state - any source collection in error puts live query in error
645
+ if (status === `error`) {
646
+ this.transitionToError(
647
+ `Source collection '${collectionId}' entered error state`
648
+ )
649
+ return
650
+ }
651
+
652
+ // Handle manual cleanup - this should not happen due to GC prevention,
653
+ // but could happen if user manually calls cleanup()
654
+ if (status === `cleaned-up`) {
655
+ this.transitionToError(
656
+ `Source collection '${collectionId}' was manually cleaned up while live query '${this.id}' depends on it. ` +
657
+ `Live queries prevent automatic GC, so this was likely a manual cleanup() call.`
658
+ )
659
+ return
660
+ }
661
+
662
+ // Update ready status based on all source collections
663
+ this.updateLiveQueryStatus(config)
664
+ }
665
+
666
+ /**
667
+ * Update the live query status based on source collection statuses
668
+ */
669
+ private updateLiveQueryStatus(config: SyncMethods<TResult>) {
670
+ const { markReady } = config
671
+
672
+ // Don't update status if already in error
673
+ if (this.isInErrorState) {
674
+ return
675
+ }
676
+
677
+ // Mark ready when all source collections are ready
678
+ if (this.allCollectionsReady()) {
679
+ markReady()
680
+ }
681
+ }
682
+
683
+ /**
684
+ * Transition the live query to error state
685
+ */
686
+ private transitionToError(message: string) {
687
+ this.isInErrorState = true
688
+
689
+ // Log error to console for debugging
690
+ console.error(`[Live Query Error] ${message}`)
691
+
692
+ // Transition live query collection to error state
693
+ this.liveQueryCollection?._lifecycle.setStatus(`error`)
694
+ }
695
+
320
696
  private allCollectionsReady() {
321
697
  return Object.values(this.collections).every((collection) =>
322
698
  collection.isReady()
323
699
  )
324
700
  }
325
701
 
326
- private allCollectionsReadyOrInitialCommit() {
327
- return Object.values(this.collections).every(
328
- (collection) =>
329
- collection.status === `ready` || collection.status === `initialCommit`
330
- )
331
- }
332
-
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
+ */
333
707
  private subscribeToAllCollections(
334
- config: Parameters<SyncConfig<TResult>[`sync`]>[0],
708
+ config: SyncMethods<TResult>,
335
709
  syncState: FullSyncState
336
710
  ) {
337
- const loaders = Object.entries(this.collections).map(
338
- ([collectionId, collection]) => {
339
- const collectionSubscriber = new CollectionSubscriber(
340
- collectionId,
341
- collection,
342
- config,
343
- syncState,
344
- this
345
- )
346
-
347
- const subscription = collectionSubscriber.subscribe()
348
- this.subscriptions[collectionId] = subscription
349
-
350
- const loadMore = collectionSubscriber.loadMoreIfNeeded.bind(
351
- collectionSubscriber,
352
- subscription
353
- )
354
-
355
- 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] = []
356
733
  }
357
- )
358
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.
359
767
  const loadMoreDataCallback = () => {
360
768
  loaders.map((loader) => loader())
361
769
  return true
362
770
  }
363
771
 
364
- // 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)
365
774
  syncState.subscribedToAllCollections = true
366
775
 
776
+ // Initial status check after all subscriptions are set up
777
+ this.updateLiveQueryStatus(config)
778
+
367
779
  return loadMoreDataCallback
368
780
  }
369
781
  }
@@ -445,6 +857,65 @@ function extractCollectionsFromQuery(
445
857
  return collections
446
858
  }
447
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
+
448
919
  function accumulateChanges<T>(
449
920
  acc: Map<unknown, Changes<T>>,
450
921
  [[key, tupleData], multiplicity]: [