@tanstack/db 0.5.32 → 0.5.33

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 (34) hide show
  1. package/dist/cjs/index.cjs +2 -0
  2. package/dist/cjs/index.cjs.map +1 -1
  3. package/dist/cjs/index.d.cts +1 -0
  4. package/dist/cjs/query/effect.cjs +602 -0
  5. package/dist/cjs/query/effect.cjs.map +1 -0
  6. package/dist/cjs/query/effect.d.cts +94 -0
  7. package/dist/cjs/query/live/collection-config-builder.cjs +5 -74
  8. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  9. package/dist/cjs/query/live/collection-subscriber.cjs +33 -100
  10. package/dist/cjs/query/live/collection-subscriber.cjs.map +1 -1
  11. package/dist/cjs/query/live/collection-subscriber.d.cts +0 -1
  12. package/dist/cjs/query/live/utils.cjs +179 -0
  13. package/dist/cjs/query/live/utils.cjs.map +1 -0
  14. package/dist/cjs/query/live/utils.d.cts +109 -0
  15. package/dist/esm/index.d.ts +1 -0
  16. package/dist/esm/index.js +2 -0
  17. package/dist/esm/index.js.map +1 -1
  18. package/dist/esm/query/effect.d.ts +94 -0
  19. package/dist/esm/query/effect.js +602 -0
  20. package/dist/esm/query/effect.js.map +1 -0
  21. package/dist/esm/query/live/collection-config-builder.js +1 -70
  22. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  23. package/dist/esm/query/live/collection-subscriber.d.ts +0 -1
  24. package/dist/esm/query/live/collection-subscriber.js +31 -98
  25. package/dist/esm/query/live/collection-subscriber.js.map +1 -1
  26. package/dist/esm/query/live/utils.d.ts +109 -0
  27. package/dist/esm/query/live/utils.js +179 -0
  28. package/dist/esm/query/live/utils.js.map +1 -0
  29. package/package.json +1 -1
  30. package/src/index.ts +11 -0
  31. package/src/query/effect.ts +1119 -0
  32. package/src/query/live/collection-config-builder.ts +6 -132
  33. package/src/query/live/collection-subscriber.ts +40 -156
  34. package/src/query/live/utils.ts +356 -0
@@ -0,0 +1,1119 @@
1
+ import { D2, output } from '@tanstack/db-ivm'
2
+ import { transactionScopedScheduler } from '../scheduler.js'
3
+ import { getActiveTransaction } from '../transactions.js'
4
+ import { compileQuery } from './compiler/index.js'
5
+ import {
6
+ normalizeExpressionPaths,
7
+ normalizeOrderByPaths,
8
+ } from './compiler/expressions.js'
9
+ import { getCollectionBuilder } from './live/collection-registry.js'
10
+ import {
11
+ buildQueryFromConfig,
12
+ computeOrderedLoadCursor,
13
+ computeSubscriptionOrderByHints,
14
+ extractCollectionAliases,
15
+ extractCollectionsFromQuery,
16
+ filterDuplicateInserts,
17
+ sendChangesToInput,
18
+ splitUpdates,
19
+ trackBiggestSentValue,
20
+ } from './live/utils.js'
21
+ import type { RootStreamBuilder } from '@tanstack/db-ivm'
22
+ import type { Collection } from '../collection/index.js'
23
+ import type { CollectionSubscription } from '../collection/subscription.js'
24
+ import type { InitialQueryBuilder, QueryBuilder } from './builder/index.js'
25
+ import type { Context } from './builder/types.js'
26
+ import type { BasicExpression, QueryIR } from './ir.js'
27
+ import type { OrderByOptimizationInfo } from './compiler/order-by.js'
28
+ import type { ChangeMessage, KeyedStream, ResultStream } from '../types.js'
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Public Types
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** Event types for query result deltas */
35
+ export type DeltaType = 'enter' | 'exit' | 'update'
36
+
37
+ /** Delta event emitted when a row enters, exits, or updates within a query result */
38
+ export type DeltaEvent<
39
+ TRow extends object = Record<string, unknown>,
40
+ TKey extends string | number = string | number,
41
+ > =
42
+ | {
43
+ type: 'enter'
44
+ key: TKey
45
+ /** Current value for the entering row */
46
+ value: TRow
47
+ metadata?: Record<string, unknown>
48
+ }
49
+ | {
50
+ type: 'exit'
51
+ key: TKey
52
+ /** Current value for the exiting row */
53
+ value: TRow
54
+ metadata?: Record<string, unknown>
55
+ }
56
+ | {
57
+ type: 'update'
58
+ key: TKey
59
+ /** Current value after the update */
60
+ value: TRow
61
+ /** Previous value before the batch */
62
+ previousValue: TRow
63
+ metadata?: Record<string, unknown>
64
+ }
65
+
66
+ /** Context passed to effect handlers */
67
+ export interface EffectContext {
68
+ /** ID of this effect (auto-generated if not provided) */
69
+ effectId: string
70
+ /** Aborted when effect.dispose() is called */
71
+ signal: AbortSignal
72
+ }
73
+
74
+ /** Query input - can be a builder function or a prebuilt query */
75
+ export type EffectQueryInput<TContext extends Context> =
76
+ | ((q: InitialQueryBuilder) => QueryBuilder<TContext>)
77
+ | QueryBuilder<TContext>
78
+
79
+ type EffectEventHandler<
80
+ TRow extends object = Record<string, unknown>,
81
+ TKey extends string | number = string | number,
82
+ > = (event: DeltaEvent<TRow, TKey>, ctx: EffectContext) => void | Promise<void>
83
+
84
+ type EffectBatchHandler<
85
+ TRow extends object = Record<string, unknown>,
86
+ TKey extends string | number = string | number,
87
+ > = (
88
+ events: Array<DeltaEvent<TRow, TKey>>,
89
+ ctx: EffectContext,
90
+ ) => void | Promise<void>
91
+
92
+ /** Effect configuration */
93
+ export interface EffectConfig<
94
+ TRow extends object = Record<string, unknown>,
95
+ TKey extends string | number = string | number,
96
+ > {
97
+ /** Optional ID for debugging/tracing */
98
+ id?: string
99
+
100
+ /** Query to watch for deltas */
101
+ query: EffectQueryInput<any>
102
+
103
+ /** Called once for each row entering the query result */
104
+ onEnter?: EffectEventHandler<TRow, TKey>
105
+
106
+ /** Called once for each row updating within the query result */
107
+ onUpdate?: EffectEventHandler<TRow, TKey>
108
+
109
+ /** Called once for each row exiting the query result */
110
+ onExit?: EffectEventHandler<TRow, TKey>
111
+
112
+ /** Called once per graph run with all delta events from that batch */
113
+ onBatch?: EffectBatchHandler<TRow, TKey>
114
+
115
+ /** Error handler for exceptions thrown by effect callbacks */
116
+ onError?: (error: Error, event: DeltaEvent<TRow, TKey>) => void
117
+
118
+ /**
119
+ * Called when a source collection enters an error or cleaned-up state.
120
+ * The effect is automatically disposed after this callback fires.
121
+ * If not provided, the error is logged to console.error.
122
+ */
123
+ onSourceError?: (error: Error) => void
124
+
125
+ /**
126
+ * Skip deltas during initial collection load.
127
+ * Defaults to false (process all deltas including initial sync).
128
+ * Set to true for effects that should only process new changes.
129
+ */
130
+ skipInitial?: boolean
131
+ }
132
+
133
+ /** Handle returned by createEffect */
134
+ export interface Effect {
135
+ /** Dispose the effect. Returns a promise that resolves when in-flight handlers complete. */
136
+ dispose: () => Promise<void>
137
+ /** Whether this effect has been disposed */
138
+ readonly disposed: boolean
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Internal Types
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /** Accumulated changes for a single key within a graph run */
146
+ interface EffectChanges<T> {
147
+ deletes: number
148
+ inserts: number
149
+ /** Value from the latest insert (the newest/current value) */
150
+ insertValue?: T
151
+ /** Value from the first delete (the oldest/previous value before the batch) */
152
+ deleteValue?: T
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Global Counter
157
+ // ---------------------------------------------------------------------------
158
+
159
+ let effectCounter = 0
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // createEffect
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Creates a reactive effect that fires handlers when rows enter, exit, or
167
+ * update within a query result. Effects process deltas only — they do not
168
+ * maintain or require the full materialised query result.
169
+ *
170
+ * @example
171
+ * ```typescript
172
+ * const effect = createEffect({
173
+ * query: (q) => q.from({ msg: messagesCollection })
174
+ * .where(({ msg }) => eq(msg.role, 'user')),
175
+ * onEnter: async (event) => {
176
+ * await generateResponse(event.value)
177
+ * },
178
+ * })
179
+ *
180
+ * // Later: stop the effect
181
+ * await effect.dispose()
182
+ * ```
183
+ */
184
+ export function createEffect<
185
+ TRow extends object = Record<string, unknown>,
186
+ TKey extends string | number = string | number,
187
+ >(config: EffectConfig<TRow, TKey>): Effect {
188
+ const id = config.id ?? `live-query-effect-${++effectCounter}`
189
+
190
+ // AbortController for signalling disposal to handlers
191
+ const abortController = new AbortController()
192
+
193
+ const ctx: EffectContext = {
194
+ effectId: id,
195
+ signal: abortController.signal,
196
+ }
197
+
198
+ // Track in-flight async handler promises so dispose() can await them
199
+ const inFlightHandlers = new Set<Promise<void>>()
200
+ let disposed = false
201
+
202
+ // Callback invoked by the pipeline runner with each batch of delta events
203
+ const onBatchProcessed = (events: Array<DeltaEvent<TRow, TKey>>) => {
204
+ if (disposed) return
205
+ if (events.length === 0) return
206
+
207
+ // Batch handler
208
+ if (config.onBatch) {
209
+ try {
210
+ const result = config.onBatch(events, ctx)
211
+ if (result instanceof Promise) {
212
+ const tracked = result.catch((error) => {
213
+ reportError(error, events[0]!, config.onError)
214
+ })
215
+ trackPromise(tracked, inFlightHandlers)
216
+ }
217
+ } catch (error) {
218
+ // For batch handler errors, report with first event as context
219
+ reportError(error, events[0]!, config.onError)
220
+ }
221
+ }
222
+
223
+ for (const event of events) {
224
+ if (abortController.signal.aborted) break
225
+
226
+ const handler = getHandlerForEvent(event, config)
227
+ if (!handler) continue
228
+
229
+ try {
230
+ const result = handler(event, ctx)
231
+ if (result instanceof Promise) {
232
+ const tracked = result.catch((error) => {
233
+ reportError(error, event, config.onError)
234
+ })
235
+ trackPromise(tracked, inFlightHandlers)
236
+ }
237
+ } catch (error) {
238
+ reportError(error, event, config.onError)
239
+ }
240
+ }
241
+ }
242
+
243
+ // The dispose function is referenced by both the returned Effect object
244
+ // and the onSourceError callback, so we define it first.
245
+ const dispose = async () => {
246
+ if (disposed) return
247
+ disposed = true
248
+
249
+ // Abort signal for in-flight handlers
250
+ abortController.abort()
251
+
252
+ // Tear down the pipeline (unsubscribe from sources, etc.)
253
+ runner.dispose()
254
+
255
+ // Wait for any in-flight async handlers to settle
256
+ if (inFlightHandlers.size > 0) {
257
+ await Promise.allSettled([...inFlightHandlers])
258
+ }
259
+ }
260
+
261
+ // Create and start the pipeline
262
+ const runner = new EffectPipelineRunner<TRow, TKey>({
263
+ query: config.query,
264
+ skipInitial: config.skipInitial ?? false,
265
+ onBatchProcessed,
266
+ onSourceError: (error: Error) => {
267
+ if (disposed) return
268
+
269
+ if (config.onSourceError) {
270
+ try {
271
+ config.onSourceError(error)
272
+ } catch (callbackError) {
273
+ console.error(
274
+ `[Effect '${id}'] onSourceError callback threw:`,
275
+ callbackError,
276
+ )
277
+ }
278
+ } else {
279
+ console.error(`[Effect '${id}'] ${error.message}. Disposing effect.`)
280
+ }
281
+
282
+ // Auto-dispose — the effect can no longer function
283
+ dispose()
284
+ },
285
+ })
286
+ runner.start()
287
+
288
+ return {
289
+ dispose,
290
+ get disposed() {
291
+ return disposed
292
+ },
293
+ }
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // EffectPipelineRunner
298
+ // ---------------------------------------------------------------------------
299
+
300
+ interface EffectPipelineRunnerConfig<
301
+ TRow extends object,
302
+ TKey extends string | number,
303
+ > {
304
+ query: EffectQueryInput<any>
305
+ skipInitial: boolean
306
+ onBatchProcessed: (events: Array<DeltaEvent<TRow, TKey>>) => void
307
+ /** Called when a source collection enters error or cleaned-up state */
308
+ onSourceError: (error: Error) => void
309
+ }
310
+
311
+ /**
312
+ * Internal class that manages a D2 pipeline for effect delta processing.
313
+ *
314
+ * Sets up the IVM graph, subscribes to source collections, runs the graph
315
+ * when changes arrive, and classifies output multiplicities into DeltaEvents.
316
+ *
317
+ * Unlike CollectionConfigBuilder, this does NOT:
318
+ * - Create or write to a collection (no materialisation)
319
+ * - Manage ordering, windowing, or lazy loading
320
+ */
321
+ class EffectPipelineRunner<TRow extends object, TKey extends string | number> {
322
+ private readonly query: QueryIR
323
+ private readonly collections: Record<string, Collection<any, any, any>>
324
+ private readonly collectionByAlias: Record<string, Collection<any, any, any>>
325
+
326
+ private graph: D2 | undefined
327
+ private inputs: Record<string, RootStreamBuilder<unknown>> | undefined
328
+ private pipeline: ResultStream | undefined
329
+ private sourceWhereClauses: Map<string, BasicExpression<boolean>> | undefined
330
+ private compiledAliasToCollectionId: Record<string, string> = {}
331
+
332
+ // Mutable objects passed to compileQuery by reference.
333
+ // The join compiler captures these references and reads them later when
334
+ // the graph runs, so they must be populated before the first graph run.
335
+ private readonly subscriptions: Record<string, CollectionSubscription> = {}
336
+ private readonly lazySourcesCallbacks: Record<string, any> = {}
337
+ private readonly lazySources = new Set<string>()
338
+ // OrderBy optimization info populated by the compiler when limit is present
339
+ private readonly optimizableOrderByCollections: Record<
340
+ string,
341
+ OrderByOptimizationInfo
342
+ > = {}
343
+
344
+ // Ordered subscription state for cursor-based loading
345
+ private readonly biggestSentValue = new Map<string, any>()
346
+ private readonly lastLoadRequestKey = new Map<string, string>()
347
+ private pendingOrderedLoadPromise: Promise<void> | undefined
348
+
349
+ // Subscription management
350
+ private readonly unsubscribeCallbacks = new Set<() => void>()
351
+ // Duplicate insert prevention per alias
352
+ private readonly sentToD2KeysByAlias = new Map<string, Set<string | number>>()
353
+
354
+ // Output accumulator
355
+ private pendingChanges: Map<unknown, EffectChanges<TRow>> = new Map()
356
+
357
+ // skipInitial state
358
+ private readonly skipInitial: boolean
359
+ private initialLoadComplete = false
360
+
361
+ // Scheduler integration
362
+ private subscribedToAllCollections = false
363
+ private readonly builderDependencies = new Set<unknown>()
364
+ private readonly aliasDependencies: Record<string, Array<unknown>> = {}
365
+
366
+ // Reentrance guard
367
+ private isGraphRunning = false
368
+ private disposed = false
369
+ // When dispose() is called mid-graph-run, defer heavy cleanup until the run completes
370
+ private deferredCleanup = false
371
+
372
+ private readonly onBatchProcessed: (
373
+ events: Array<DeltaEvent<TRow, TKey>>,
374
+ ) => void
375
+ private readonly onSourceError: (error: Error) => void
376
+
377
+ constructor(config: EffectPipelineRunnerConfig<TRow, TKey>) {
378
+ this.skipInitial = config.skipInitial
379
+ this.onBatchProcessed = config.onBatchProcessed
380
+ this.onSourceError = config.onSourceError
381
+
382
+ // Parse query
383
+ this.query = buildQueryFromConfig({ query: config.query })
384
+
385
+ // Extract source collections
386
+ this.collections = extractCollectionsFromQuery(this.query)
387
+ const aliasesById = extractCollectionAliases(this.query)
388
+
389
+ // Build alias → collection map
390
+ this.collectionByAlias = {}
391
+ for (const [collectionId, aliases] of aliasesById.entries()) {
392
+ const collection = this.collections[collectionId]
393
+ if (!collection) continue
394
+ for (const alias of aliases) {
395
+ this.collectionByAlias[alias] = collection
396
+ }
397
+ }
398
+
399
+ // Compile the pipeline
400
+ this.compilePipeline()
401
+ }
402
+
403
+ /** Compile the D2 graph and query pipeline */
404
+ private compilePipeline(): void {
405
+ this.graph = new D2()
406
+ this.inputs = Object.fromEntries(
407
+ Object.keys(this.collectionByAlias).map((alias) => [
408
+ alias,
409
+ this.graph!.newInput<any>(),
410
+ ]),
411
+ )
412
+
413
+ const compilation = compileQuery(
414
+ this.query,
415
+ this.inputs as Record<string, KeyedStream>,
416
+ this.collections,
417
+ // These mutable objects are captured by reference. The join compiler
418
+ // reads them later when the graph runs, so they must be populated
419
+ // (in start()) before the first graph run.
420
+ this.subscriptions,
421
+ this.lazySourcesCallbacks,
422
+ this.lazySources,
423
+ this.optimizableOrderByCollections,
424
+ () => {}, // setWindowFn (no-op — effects don't paginate)
425
+ )
426
+
427
+ this.pipeline = compilation.pipeline
428
+ this.sourceWhereClauses = compilation.sourceWhereClauses
429
+ this.compiledAliasToCollectionId = compilation.aliasToCollectionId
430
+
431
+ // Attach the output operator that accumulates changes
432
+ this.pipeline.pipe(
433
+ output((data) => {
434
+ const messages = data.getInner()
435
+ messages.reduce(accumulateEffectChanges<TRow>, this.pendingChanges)
436
+ }),
437
+ )
438
+
439
+ this.graph.finalize()
440
+ }
441
+
442
+ /** Subscribe to source collections and start processing */
443
+ start(): void {
444
+ // Use compiled aliases as the source of truth
445
+ const compiledAliases = Object.entries(this.compiledAliasToCollectionId)
446
+ if (compiledAliases.length === 0) {
447
+ // Nothing to subscribe to
448
+ return
449
+ }
450
+
451
+ // When not skipping initial, we always process events immediately
452
+ if (!this.skipInitial) {
453
+ this.initialLoadComplete = true
454
+ }
455
+
456
+ // We need to defer initial data processing until ALL subscriptions are
457
+ // created, because join pipelines look up subscriptions by alias during
458
+ // the graph run. If we run the graph while some aliases are still missing,
459
+ // the join tap operator will throw.
460
+ //
461
+ // Strategy: subscribe to each collection but buffer incoming changes.
462
+ // After all subscriptions are in place, flush the buffers and switch to
463
+ // direct processing mode.
464
+
465
+ const pendingBuffers = new Map<
466
+ string,
467
+ Array<Array<ChangeMessage<any, string | number>>>
468
+ >()
469
+
470
+ for (const [alias, collectionId] of compiledAliases) {
471
+ const collection =
472
+ this.collectionByAlias[alias] ?? this.collections[collectionId]!
473
+
474
+ // Initialise per-alias duplicate tracking
475
+ this.sentToD2KeysByAlias.set(alias, new Set())
476
+
477
+ // Discover dependencies: if source collection is itself a live query
478
+ // collection, its builder must run first during transaction flushes.
479
+ const dependencyBuilder = getCollectionBuilder(collection)
480
+ if (dependencyBuilder) {
481
+ this.aliasDependencies[alias] = [dependencyBuilder]
482
+ this.builderDependencies.add(dependencyBuilder)
483
+ } else {
484
+ this.aliasDependencies[alias] = []
485
+ }
486
+
487
+ // Get where clause for this alias (for predicate push-down)
488
+ const whereClause = this.sourceWhereClauses?.get(alias)
489
+ const whereExpression = whereClause
490
+ ? normalizeExpressionPaths(whereClause, alias)
491
+ : undefined
492
+
493
+ // Initialise buffer for this alias
494
+ const buffer: Array<Array<ChangeMessage<any, string | number>>> = []
495
+ pendingBuffers.set(alias, buffer)
496
+
497
+ // Lazy aliases (marked by the join compiler) should NOT load initial state
498
+ // eagerly — the join tap operator will load exactly the rows it needs on demand.
499
+ // For on-demand collections, eager loading would trigger a full server fetch
500
+ // for data that should be lazily loaded based on join keys.
501
+ const isLazy = this.lazySources.has(alias)
502
+
503
+ // Check if this alias has orderBy optimization (cursor-based loading)
504
+ const orderByInfo = this.getOrderByInfoForAlias(alias)
505
+
506
+ // Build the change callback — for ordered aliases, split updates into
507
+ // delete+insert and track the biggest sent value for cursor positioning.
508
+ const changeCallback = orderByInfo
509
+ ? (changes: Array<ChangeMessage<any, string | number>>) => {
510
+ if (pendingBuffers.has(alias)) {
511
+ pendingBuffers.get(alias)!.push(changes)
512
+ } else {
513
+ this.trackSentValues(alias, changes, orderByInfo.comparator)
514
+ const split = [...splitUpdates(changes)]
515
+ this.handleSourceChanges(alias, split)
516
+ }
517
+ }
518
+ : (changes: Array<ChangeMessage<any, string | number>>) => {
519
+ if (pendingBuffers.has(alias)) {
520
+ pendingBuffers.get(alias)!.push(changes)
521
+ } else {
522
+ this.handleSourceChanges(alias, changes)
523
+ }
524
+ }
525
+
526
+ // Determine subscription options based on ordered vs unordered path
527
+ const subscriptionOptions = this.buildSubscriptionOptions(
528
+ alias,
529
+ isLazy,
530
+ orderByInfo,
531
+ whereExpression,
532
+ )
533
+
534
+ // Subscribe to source changes
535
+ const subscription = collection.subscribeChanges(
536
+ changeCallback,
537
+ subscriptionOptions,
538
+ )
539
+
540
+ // Store subscription immediately so the join compiler can find it
541
+ this.subscriptions[alias] = subscription
542
+
543
+ // For ordered aliases with an index, trigger the initial limited snapshot.
544
+ // This loads only the top N rows rather than the entire collection.
545
+ if (orderByInfo) {
546
+ this.requestInitialOrderedSnapshot(alias, orderByInfo, subscription)
547
+ }
548
+
549
+ this.unsubscribeCallbacks.add(() => {
550
+ subscription.unsubscribe()
551
+ delete this.subscriptions[alias]
552
+ })
553
+
554
+ // Listen for status changes on source collections
555
+ const statusUnsubscribe = collection.on(`status:change`, (event) => {
556
+ if (this.disposed) return
557
+
558
+ const { status } = event
559
+
560
+ // Source entered error state — effect can no longer function
561
+ if (status === `error`) {
562
+ this.onSourceError(
563
+ new Error(
564
+ `Source collection '${collectionId}' entered error state`,
565
+ ),
566
+ )
567
+ return
568
+ }
569
+
570
+ // Source was manually cleaned up — effect can no longer function
571
+ if (status === `cleaned-up`) {
572
+ this.onSourceError(
573
+ new Error(
574
+ `Source collection '${collectionId}' was cleaned up while effect depends on it`,
575
+ ),
576
+ )
577
+ return
578
+ }
579
+
580
+ // Track source readiness for skipInitial
581
+ if (
582
+ this.skipInitial &&
583
+ !this.initialLoadComplete &&
584
+ this.checkAllCollectionsReady()
585
+ ) {
586
+ this.initialLoadComplete = true
587
+ }
588
+ })
589
+ this.unsubscribeCallbacks.add(statusUnsubscribe)
590
+ }
591
+
592
+ // Mark as subscribed so the graph can start running
593
+ this.subscribedToAllCollections = true
594
+
595
+ // All subscriptions are now in place. Flush buffered changes by sending
596
+ // data to D2 inputs first (without running the graph), then run the graph
597
+ // once. This prevents intermediate join states from producing duplicates.
598
+ //
599
+ // We remove each alias from pendingBuffers *before* draining, which
600
+ // switches that alias to direct-processing mode. Any new callbacks that
601
+ // fire during the drain (e.g. from requestLimitedSnapshot) will go
602
+ // through handleSourceChanges directly instead of being lost.
603
+ for (const [alias] of pendingBuffers) {
604
+ const buffer = pendingBuffers.get(alias)!
605
+ pendingBuffers.delete(alias)
606
+
607
+ const orderByInfo = this.getOrderByInfoForAlias(alias)
608
+
609
+ // Drain all buffered batches. Since we deleted the alias from
610
+ // pendingBuffers above, any new changes arriving during drain go
611
+ // through handleSourceChanges directly (not back into this buffer).
612
+ for (const changes of buffer) {
613
+ if (orderByInfo) {
614
+ this.trackSentValues(alias, changes, orderByInfo.comparator)
615
+ const split = [...splitUpdates(changes)]
616
+ this.sendChangesToD2(alias, split)
617
+ } else {
618
+ this.sendChangesToD2(alias, changes)
619
+ }
620
+ }
621
+ }
622
+
623
+ // Initial graph run to process any synchronously-available data.
624
+ // For skipInitial, this run's output is discarded (initialLoadComplete is still false).
625
+ this.runGraph()
626
+
627
+ // After the initial graph run, if all sources are ready,
628
+ // mark initial load as complete so future events are processed.
629
+ if (this.skipInitial && !this.initialLoadComplete) {
630
+ if (this.checkAllCollectionsReady()) {
631
+ this.initialLoadComplete = true
632
+ }
633
+ }
634
+ }
635
+
636
+ /** Handle incoming changes from a source collection */
637
+ private handleSourceChanges(
638
+ alias: string,
639
+ changes: Array<ChangeMessage<any, string | number>>,
640
+ ): void {
641
+ this.sendChangesToD2(alias, changes)
642
+ this.scheduleGraphRun(alias)
643
+ }
644
+
645
+ /**
646
+ * Schedule a graph run via the transaction-scoped scheduler.
647
+ *
648
+ * When called within a transaction, the run is deferred until the
649
+ * transaction flushes, coalescing multiple changes into a single graph
650
+ * execution. Without a transaction, the graph runs immediately.
651
+ *
652
+ * Dependencies are discovered from source collections that are themselves
653
+ * live query collections, ensuring parent queries run before effects.
654
+ */
655
+ private scheduleGraphRun(alias?: string): void {
656
+ const contextId = getActiveTransaction()?.id
657
+
658
+ // Collect dependencies for this schedule call
659
+ const deps = new Set(this.builderDependencies)
660
+ if (alias) {
661
+ const aliasDeps = this.aliasDependencies[alias]
662
+ if (aliasDeps) {
663
+ for (const dep of aliasDeps) {
664
+ deps.add(dep)
665
+ }
666
+ }
667
+ }
668
+
669
+ // Ensure dependent builders are scheduled in this context so that
670
+ // dependency edges always point to a real job.
671
+ if (contextId) {
672
+ for (const dep of deps) {
673
+ if (
674
+ typeof dep === `object` &&
675
+ dep !== null &&
676
+ `scheduleGraphRun` in dep &&
677
+ typeof (dep as any).scheduleGraphRun === `function`
678
+ ) {
679
+ ;(dep as any).scheduleGraphRun(undefined, { contextId })
680
+ }
681
+ }
682
+ }
683
+
684
+ transactionScopedScheduler.schedule({
685
+ contextId,
686
+ jobId: this,
687
+ dependencies: deps,
688
+ run: () => this.executeScheduledGraphRun(),
689
+ })
690
+ }
691
+
692
+ /**
693
+ * Called by the scheduler when dependencies are satisfied.
694
+ * Checks that the effect is still active before running.
695
+ */
696
+ private executeScheduledGraphRun(): void {
697
+ if (this.disposed || !this.subscribedToAllCollections) return
698
+ this.runGraph()
699
+ }
700
+
701
+ /**
702
+ * Send changes to the D2 input for the given alias.
703
+ * Returns the number of multiset entries sent.
704
+ */
705
+ private sendChangesToD2(
706
+ alias: string,
707
+ changes: Array<ChangeMessage<any, string | number>>,
708
+ ): number {
709
+ if (this.disposed || !this.inputs || !this.graph) return 0
710
+
711
+ const input = this.inputs[alias]
712
+ if (!input) return 0
713
+
714
+ const collection = this.collectionByAlias[alias]
715
+ if (!collection) return 0
716
+
717
+ // Filter duplicates per alias
718
+ const sentKeys = this.sentToD2KeysByAlias.get(alias)!
719
+ const filtered = filterDuplicateInserts(changes, sentKeys)
720
+
721
+ return sendChangesToInput(input, filtered, collection.config.getKey)
722
+ }
723
+
724
+ /**
725
+ * Run the D2 graph until quiescence, then emit accumulated events once.
726
+ *
727
+ * All output across the entire while-loop is accumulated into a single
728
+ * batch so that users see one `onBatchProcessed` invocation per scheduler
729
+ * run, even when ordered loading causes multiple graph steps.
730
+ */
731
+ private runGraph(): void {
732
+ if (this.isGraphRunning || this.disposed || !this.graph) return
733
+
734
+ this.isGraphRunning = true
735
+ try {
736
+ while (this.graph.pendingWork()) {
737
+ this.graph.run()
738
+ // A handler (via onBatchProcessed) or source error callback may have
739
+ // called dispose() during graph.run(). Stop early to avoid operating
740
+ // on stale state. TS narrows disposed to false from the guard above
741
+ // but it can change during graph.run() via callbacks.
742
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
743
+ if (this.disposed) break
744
+ // After each step, check if ordered queries need more data.
745
+ // loadMoreIfNeeded may send data to D2 inputs (via requestLimitedSnapshot),
746
+ // causing pendingWork() to return true for the next iteration.
747
+ this.loadMoreIfNeeded()
748
+ }
749
+ // Emit all accumulated events once the graph reaches quiescence
750
+ this.flushPendingChanges()
751
+ } finally {
752
+ this.isGraphRunning = false
753
+ // If dispose() was called during this graph run, it deferred the heavy
754
+ // cleanup (clearing graph/inputs/pipeline) to avoid nulling references
755
+ // mid-loop. Complete that cleanup now.
756
+ if (this.deferredCleanup) {
757
+ this.deferredCleanup = false
758
+ this.finalCleanup()
759
+ }
760
+ }
761
+ }
762
+
763
+ /** Classify accumulated changes into DeltaEvents and invoke the callback */
764
+ private flushPendingChanges(): void {
765
+ if (this.pendingChanges.size === 0) return
766
+
767
+ // If skipInitial and initial load isn't complete yet, discard
768
+ if (this.skipInitial && !this.initialLoadComplete) {
769
+ this.pendingChanges = new Map()
770
+ return
771
+ }
772
+
773
+ const events: Array<DeltaEvent<TRow, TKey>> = []
774
+
775
+ for (const [key, changes] of this.pendingChanges) {
776
+ const event = classifyDelta<TRow, TKey>(key as TKey, changes)
777
+ if (event) {
778
+ events.push(event)
779
+ }
780
+ }
781
+
782
+ this.pendingChanges = new Map()
783
+
784
+ if (events.length > 0) {
785
+ this.onBatchProcessed(events)
786
+ }
787
+ }
788
+
789
+ /** Check if all source collections are in the ready state */
790
+ private checkAllCollectionsReady(): boolean {
791
+ return Object.values(this.collections).every((collection) =>
792
+ collection.isReady(),
793
+ )
794
+ }
795
+
796
+ /**
797
+ * Build subscription options for an alias based on whether it uses ordered
798
+ * loading, is lazy, or should pass orderBy/limit hints.
799
+ */
800
+ private buildSubscriptionOptions(
801
+ alias: string,
802
+ isLazy: boolean,
803
+ orderByInfo: OrderByOptimizationInfo | undefined,
804
+ whereExpression: BasicExpression<boolean> | undefined,
805
+ ): {
806
+ includeInitialState?: boolean
807
+ whereExpression?: BasicExpression<boolean>
808
+ orderBy?: any
809
+ limit?: number
810
+ } {
811
+ // Ordered aliases explicitly disable initial state — data is loaded
812
+ // via requestLimitedSnapshot/requestSnapshot after subscription setup.
813
+ if (orderByInfo) {
814
+ return { includeInitialState: false, whereExpression }
815
+ }
816
+
817
+ const includeInitialState = !isLazy
818
+
819
+ // For unordered subscriptions, pass orderBy/limit hints so on-demand
820
+ // collections can optimise server-side fetching.
821
+ const hints = computeSubscriptionOrderByHints(this.query, alias)
822
+
823
+ return {
824
+ includeInitialState,
825
+ whereExpression,
826
+ ...(hints.orderBy ? { orderBy: hints.orderBy } : {}),
827
+ ...(hints.limit !== undefined ? { limit: hints.limit } : {}),
828
+ }
829
+ }
830
+
831
+ /**
832
+ * Request the initial ordered snapshot for an alias.
833
+ * Uses requestLimitedSnapshot (index-based cursor) or requestSnapshot
834
+ * (full load with limit) depending on whether an index is available.
835
+ */
836
+ private requestInitialOrderedSnapshot(
837
+ alias: string,
838
+ orderByInfo: OrderByOptimizationInfo,
839
+ subscription: CollectionSubscription,
840
+ ): void {
841
+ const { orderBy, offset, limit, index } = orderByInfo
842
+ const normalizedOrderBy = normalizeOrderByPaths(orderBy, alias)
843
+
844
+ if (index) {
845
+ subscription.setOrderByIndex(index)
846
+ subscription.requestLimitedSnapshot({
847
+ limit: offset + limit,
848
+ orderBy: normalizedOrderBy,
849
+ trackLoadSubsetPromise: false,
850
+ })
851
+ } else {
852
+ subscription.requestSnapshot({
853
+ orderBy: normalizedOrderBy,
854
+ limit: offset + limit,
855
+ trackLoadSubsetPromise: false,
856
+ })
857
+ }
858
+ }
859
+
860
+ /**
861
+ * Get orderBy optimization info for a given alias.
862
+ * Returns undefined if no optimization exists for this alias.
863
+ */
864
+ private getOrderByInfoForAlias(
865
+ alias: string,
866
+ ): OrderByOptimizationInfo | undefined {
867
+ // optimizableOrderByCollections is keyed by collection ID
868
+ const collectionId = this.compiledAliasToCollectionId[alias]
869
+ if (!collectionId) return undefined
870
+
871
+ const info = this.optimizableOrderByCollections[collectionId]
872
+ if (info && info.alias === alias) {
873
+ return info
874
+ }
875
+ return undefined
876
+ }
877
+
878
+ /**
879
+ * After each graph run step, check if any ordered query's topK operator
880
+ * needs more data. If so, load more rows via requestLimitedSnapshot.
881
+ */
882
+ private loadMoreIfNeeded(): void {
883
+ for (const [, orderByInfo] of Object.entries(
884
+ this.optimizableOrderByCollections,
885
+ )) {
886
+ if (!orderByInfo.dataNeeded) continue
887
+
888
+ if (this.pendingOrderedLoadPromise) {
889
+ // Wait for in-flight loads to complete before requesting more
890
+ continue
891
+ }
892
+
893
+ const n = orderByInfo.dataNeeded()
894
+ if (n > 0) {
895
+ this.loadNextItems(orderByInfo, n)
896
+ }
897
+ }
898
+ }
899
+
900
+ /**
901
+ * Load n more items from the source collection, starting from the cursor
902
+ * position (the biggest value sent so far).
903
+ */
904
+ private loadNextItems(orderByInfo: OrderByOptimizationInfo, n: number): void {
905
+ const { alias } = orderByInfo
906
+ const subscription = this.subscriptions[alias]
907
+ if (!subscription) return
908
+
909
+ const cursor = computeOrderedLoadCursor(
910
+ orderByInfo,
911
+ this.biggestSentValue.get(alias),
912
+ this.lastLoadRequestKey.get(alias),
913
+ alias,
914
+ n,
915
+ )
916
+ if (!cursor) return // Duplicate request — skip
917
+
918
+ this.lastLoadRequestKey.set(alias, cursor.loadRequestKey)
919
+
920
+ subscription.requestLimitedSnapshot({
921
+ orderBy: cursor.normalizedOrderBy,
922
+ limit: n,
923
+ minValues: cursor.minValues,
924
+ trackLoadSubsetPromise: false,
925
+ onLoadSubsetResult: (loadResult: Promise<void> | true) => {
926
+ // Track in-flight load to prevent redundant concurrent requests
927
+ if (loadResult instanceof Promise) {
928
+ this.pendingOrderedLoadPromise = loadResult
929
+ loadResult.finally(() => {
930
+ if (this.pendingOrderedLoadPromise === loadResult) {
931
+ this.pendingOrderedLoadPromise = undefined
932
+ }
933
+ })
934
+ }
935
+ },
936
+ })
937
+ }
938
+
939
+ /**
940
+ * Track the biggest value sent for a given ordered alias.
941
+ * Used for cursor-based pagination in loadNextItems.
942
+ */
943
+ private trackSentValues(
944
+ alias: string,
945
+ changes: Array<ChangeMessage<any, string | number>>,
946
+ comparator: (a: any, b: any) => number,
947
+ ): void {
948
+ const sentKeys = this.sentToD2KeysByAlias.get(alias) ?? new Set()
949
+ const result = trackBiggestSentValue(
950
+ changes,
951
+ this.biggestSentValue.get(alias),
952
+ sentKeys,
953
+ comparator,
954
+ )
955
+ this.biggestSentValue.set(alias, result.biggest)
956
+ if (result.shouldResetLoadKey) {
957
+ this.lastLoadRequestKey.delete(alias)
958
+ }
959
+ }
960
+
961
+ /** Tear down subscriptions and clear state */
962
+ dispose(): void {
963
+ if (this.disposed) return
964
+ this.disposed = true
965
+ this.subscribedToAllCollections = false
966
+
967
+ // Immediately unsubscribe from sources and clear cheap state
968
+ this.unsubscribeCallbacks.forEach((fn) => fn())
969
+ this.unsubscribeCallbacks.clear()
970
+ this.sentToD2KeysByAlias.clear()
971
+ this.pendingChanges.clear()
972
+ this.lazySources.clear()
973
+ this.builderDependencies.clear()
974
+ this.biggestSentValue.clear()
975
+ this.lastLoadRequestKey.clear()
976
+ this.pendingOrderedLoadPromise = undefined
977
+
978
+ // Clear mutable objects
979
+ for (const key of Object.keys(this.lazySourcesCallbacks)) {
980
+ delete this.lazySourcesCallbacks[key]
981
+ }
982
+ for (const key of Object.keys(this.aliasDependencies)) {
983
+ delete this.aliasDependencies[key]
984
+ }
985
+ for (const key of Object.keys(this.optimizableOrderByCollections)) {
986
+ delete this.optimizableOrderByCollections[key]
987
+ }
988
+
989
+ // If the graph is currently running, defer clearing graph/inputs/pipeline
990
+ // until runGraph() completes — otherwise we'd null references mid-loop.
991
+ if (this.isGraphRunning) {
992
+ this.deferredCleanup = true
993
+ } else {
994
+ this.finalCleanup()
995
+ }
996
+ }
997
+
998
+ /** Clear graph references — called after graph run completes or immediately from dispose */
999
+ private finalCleanup(): void {
1000
+ this.graph = undefined
1001
+ this.inputs = undefined
1002
+ this.pipeline = undefined
1003
+ this.sourceWhereClauses = undefined
1004
+ }
1005
+ }
1006
+
1007
+ // ---------------------------------------------------------------------------
1008
+ // Helpers
1009
+ // ---------------------------------------------------------------------------
1010
+
1011
+ function getHandlerForEvent<TRow extends object, TKey extends string | number>(
1012
+ event: DeltaEvent<TRow, TKey>,
1013
+ config: EffectConfig<TRow, TKey>,
1014
+ ): EffectEventHandler<TRow, TKey> | undefined {
1015
+ switch (event.type) {
1016
+ case `enter`:
1017
+ return config.onEnter
1018
+ case `exit`:
1019
+ return config.onExit
1020
+ case `update`:
1021
+ return config.onUpdate
1022
+ }
1023
+ }
1024
+
1025
+ /**
1026
+ * Accumulate D2 output multiplicities into per-key effect changes.
1027
+ * Tracks both insert values (new) and delete values (old) separately
1028
+ * so that update and exit events can include previousValue.
1029
+ */
1030
+ function accumulateEffectChanges<T>(
1031
+ acc: Map<unknown, EffectChanges<T>>,
1032
+ [[key, tupleData], multiplicity]: [
1033
+ [unknown, [any, string | undefined]],
1034
+ number,
1035
+ ],
1036
+ ): Map<unknown, EffectChanges<T>> {
1037
+ const [value] = tupleData as [T, string | undefined]
1038
+
1039
+ const changes: EffectChanges<T> = acc.get(key) || {
1040
+ deletes: 0,
1041
+ inserts: 0,
1042
+ }
1043
+
1044
+ if (multiplicity < 0) {
1045
+ changes.deletes += Math.abs(multiplicity)
1046
+ // Keep only the first delete value — this is the pre-batch state
1047
+ changes.deleteValue ??= value
1048
+ } else if (multiplicity > 0) {
1049
+ changes.inserts += multiplicity
1050
+ // Always overwrite with the latest insert — this is the post-batch state
1051
+ changes.insertValue = value
1052
+ }
1053
+
1054
+ acc.set(key, changes)
1055
+ return acc
1056
+ }
1057
+
1058
+ /** Classify accumulated per-key changes into a DeltaEvent */
1059
+ function classifyDelta<TRow extends object, TKey extends string | number>(
1060
+ key: TKey,
1061
+ changes: EffectChanges<TRow>,
1062
+ ): DeltaEvent<TRow, TKey> | undefined {
1063
+ const { inserts, deletes, insertValue, deleteValue } = changes
1064
+
1065
+ if (inserts > 0 && deletes === 0) {
1066
+ // Row entered the query result
1067
+ return { type: `enter`, key, value: insertValue! }
1068
+ }
1069
+
1070
+ if (deletes > 0 && inserts === 0) {
1071
+ // Row exited the query result — value is the exiting value,
1072
+ // previousValue is omitted (it would be identical to value)
1073
+ return { type: `exit`, key, value: deleteValue! }
1074
+ }
1075
+
1076
+ if (inserts > 0 && deletes > 0) {
1077
+ // Row updated within the query result
1078
+ return {
1079
+ type: `update`,
1080
+ key,
1081
+ value: insertValue!,
1082
+ previousValue: deleteValue!,
1083
+ }
1084
+ }
1085
+
1086
+ // inserts === 0 && deletes === 0 — no net change (should not happen)
1087
+ return undefined
1088
+ }
1089
+
1090
+ /** Track a promise in the in-flight set, automatically removing on settlement */
1091
+ function trackPromise(
1092
+ promise: Promise<void>,
1093
+ inFlightHandlers: Set<Promise<void>>,
1094
+ ): void {
1095
+ inFlightHandlers.add(promise)
1096
+ promise.finally(() => {
1097
+ inFlightHandlers.delete(promise)
1098
+ })
1099
+ }
1100
+
1101
+ /** Report an error to the onError callback or console */
1102
+ function reportError<TRow extends object, TKey extends string | number>(
1103
+ error: unknown,
1104
+ event: DeltaEvent<TRow, TKey>,
1105
+ onError?: (error: Error, event: DeltaEvent<TRow, TKey>) => void,
1106
+ ): void {
1107
+ const normalised = error instanceof Error ? error : new Error(String(error))
1108
+ if (onError) {
1109
+ try {
1110
+ onError(normalised, event)
1111
+ } catch (onErrorError) {
1112
+ // Don't let onError errors propagate
1113
+ console.error(`[Effect] Error in onError handler:`, onErrorError)
1114
+ console.error(`[Effect] Original error:`, normalised)
1115
+ }
1116
+ } else {
1117
+ console.error(`[Effect] Unhandled error in handler:`, normalised)
1118
+ }
1119
+ }