UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

1,120 lines (976 loc) 37.2 kB
import { D2, output } from '@tanstack/db-ivm' import { transactionScopedScheduler } from '../scheduler.js' import { getActiveTransaction } from '../transactions.js' import { compileQuery } from './compiler/index.js' import { normalizeExpressionPaths, normalizeOrderByPaths, } from './compiler/expressions.js' import { getCollectionBuilder } from './live/collection-registry.js' import { buildQueryFromConfig, computeOrderedLoadCursor, computeSubscriptionOrderByHints, extractCollectionAliases, extractCollectionsFromQuery, filterDuplicateInserts, sendChangesToInput, splitUpdates, trackBiggestSentValue, } from './live/utils.js' import type { RootStreamBuilder } from '@tanstack/db-ivm' import type { Collection } from '../collection/index.js' import type { CollectionSubscription } from '../collection/subscription.js' import type { InitialQueryBuilder, QueryBuilder } from './builder/index.js' import type { Context } from './builder/types.js' import type { BasicExpression, QueryIR } from './ir.js' import type { OrderByOptimizationInfo } from './compiler/order-by.js' import type { ChangeMessage, KeyedStream, ResultStream } from '../types.js' // --------------------------------------------------------------------------- // Public Types // --------------------------------------------------------------------------- /** Event types for query result deltas */ export type DeltaType = 'enter' | 'exit' | 'update' /** Delta event emitted when a row enters, exits, or updates within a query result */ export type DeltaEvent< TRow extends object = Record<string, unknown>, TKey extends string | number = string | number, > = | { type: 'enter' key: TKey /** Current value for the entering row */ value: TRow metadata?: Record<string, unknown> } | { type: 'exit' key: TKey /** Current value for the exiting row */ value: TRow metadata?: Record<string, unknown> } | { type: 'update' key: TKey /** Current value after the update */ value: TRow /** Previous value before the batch */ previousValue: TRow metadata?: Record<string, unknown> } /** Context passed to effect handlers */ export interface EffectContext { /** ID of this effect (auto-generated if not provided) */ effectId: string /** Aborted when effect.dispose() is called */ signal: AbortSignal } /** Query input - can be a builder function or a prebuilt query */ export type EffectQueryInput<TContext extends Context> = | ((q: InitialQueryBuilder) => QueryBuilder<TContext>) | QueryBuilder<TContext> type EffectEventHandler< TRow extends object = Record<string, unknown>, TKey extends string | number = string | number, > = (event: DeltaEvent<TRow, TKey>, ctx: EffectContext) => void | Promise<void> type EffectBatchHandler< TRow extends object = Record<string, unknown>, TKey extends string | number = string | number, > = ( events: Array<DeltaEvent<TRow, TKey>>, ctx: EffectContext, ) => void | Promise<void> /** Effect configuration */ export interface EffectConfig< TRow extends object = Record<string, unknown>, TKey extends string | number = string | number, > { /** Optional ID for debugging/tracing */ id?: string /** Query to watch for deltas */ query: EffectQueryInput<any> /** Called once for each row entering the query result */ onEnter?: EffectEventHandler<TRow, TKey> /** Called once for each row updating within the query result */ onUpdate?: EffectEventHandler<TRow, TKey> /** Called once for each row exiting the query result */ onExit?: EffectEventHandler<TRow, TKey> /** Called once per graph run with all delta events from that batch */ onBatch?: EffectBatchHandler<TRow, TKey> /** Error handler for exceptions thrown by effect callbacks */ onError?: (error: Error, event: DeltaEvent<TRow, TKey>) => void /** * Called when a source collection enters an error or cleaned-up state. * The effect is automatically disposed after this callback fires. * If not provided, the error is logged to console.error. */ onSourceError?: (error: Error) => void /** * Skip deltas during initial collection load. * Defaults to false (process all deltas including initial sync). * Set to true for effects that should only process new changes. */ skipInitial?: boolean } /** Handle returned by createEffect */ export interface Effect { /** Dispose the effect. Returns a promise that resolves when in-flight handlers complete. */ dispose: () => Promise<void> /** Whether this effect has been disposed */ readonly disposed: boolean } // --------------------------------------------------------------------------- // Internal Types // --------------------------------------------------------------------------- /** Accumulated changes for a single key within a graph run */ interface EffectChanges<T> { deletes: number inserts: number /** Value from the latest insert (the newest/current value) */ insertValue?: T /** Value from the first delete (the oldest/previous value before the batch) */ deleteValue?: T } // --------------------------------------------------------------------------- // Global Counter // --------------------------------------------------------------------------- let effectCounter = 0 // --------------------------------------------------------------------------- // createEffect // --------------------------------------------------------------------------- /** * Creates a reactive effect that fires handlers when rows enter, exit, or * update within a query result. Effects process deltas only — they do not * maintain or require the full materialised query result. * * @example * ```typescript * const effect = createEffect({ * query: (q) => q.from({ msg: messagesCollection }) * .where(({ msg }) => eq(msg.role, 'user')), * onEnter: async (event) => { * await generateResponse(event.value) * }, * }) * * // Later: stop the effect * await effect.dispose() * ``` */ export function createEffect< TRow extends object = Record<string, unknown>, TKey extends string | number = string | number, >(config: EffectConfig<TRow, TKey>): Effect { const id = config.id ?? `live-query-effect-${++effectCounter}` // AbortController for signalling disposal to handlers const abortController = new AbortController() const ctx: EffectContext = { effectId: id, signal: abortController.signal, } // Track in-flight async handler promises so dispose() can await them const inFlightHandlers = new Set<Promise<void>>() let disposed = false // Callback invoked by the pipeline runner with each batch of delta events const onBatchProcessed = (events: Array<DeltaEvent<TRow, TKey>>) => { if (disposed) return if (events.length === 0) return // Batch handler if (config.onBatch) { try { const result = config.onBatch(events, ctx) if (result instanceof Promise) { const tracked = result.catch((error) => { reportError(error, events[0]!, config.onError) }) trackPromise(tracked, inFlightHandlers) } } catch (error) { // For batch handler errors, report with first event as context reportError(error, events[0]!, config.onError) } } for (const event of events) { if (abortController.signal.aborted) break const handler = getHandlerForEvent(event, config) if (!handler) continue try { const result = handler(event, ctx) if (result instanceof Promise) { const tracked = result.catch((error) => { reportError(error, event, config.onError) }) trackPromise(tracked, inFlightHandlers) } } catch (error) { reportError(error, event, config.onError) } } } // The dispose function is referenced by both the returned Effect object // and the onSourceError callback, so we define it first. const dispose = async () => { if (disposed) return disposed = true // Abort signal for in-flight handlers abortController.abort() // Tear down the pipeline (unsubscribe from sources, etc.) runner.dispose() // Wait for any in-flight async handlers to settle if (inFlightHandlers.size > 0) { await Promise.allSettled([...inFlightHandlers]) } } // Create and start the pipeline const runner = new EffectPipelineRunner<TRow, TKey>({ query: config.query, skipInitial: config.skipInitial ?? false, onBatchProcessed, onSourceError: (error: Error) => { if (disposed) return if (config.onSourceError) { try { config.onSourceError(error) } catch (callbackError) { console.error( `[Effect '${id}'] onSourceError callback threw:`, callbackError, ) } } else { console.error(`[Effect '${id}'] ${error.message}. Disposing effect.`) } // Auto-dispose — the effect can no longer function dispose() }, }) runner.start() return { dispose, get disposed() { return disposed }, } } // --------------------------------------------------------------------------- // EffectPipelineRunner // --------------------------------------------------------------------------- interface EffectPipelineRunnerConfig< TRow extends object, TKey extends string | number, > { query: EffectQueryInput<any> skipInitial: boolean onBatchProcessed: (events: Array<DeltaEvent<TRow, TKey>>) => void /** Called when a source collection enters error or cleaned-up state */ onSourceError: (error: Error) => void } /** * Internal class that manages a D2 pipeline for effect delta processing. * * Sets up the IVM graph, subscribes to source collections, runs the graph * when changes arrive, and classifies output multiplicities into DeltaEvents. * * Unlike CollectionConfigBuilder, this does NOT: * - Create or write to a collection (no materialisation) * - Manage ordering, windowing, or lazy loading */ class EffectPipelineRunner<TRow extends object, TKey extends string | number> { private readonly query: QueryIR private readonly collections: Record<string, Collection<any, any, any>> private readonly collectionByAlias: Record<string, Collection<any, any, any>> private graph: D2 | undefined private inputs: Record<string, RootStreamBuilder<unknown>> | undefined private pipeline: ResultStream | undefined private sourceWhereClauses: Map<string, BasicExpression<boolean>> | undefined private compiledAliasToCollectionId: Record<string, string> = {} // Mutable objects passed to compileQuery by reference. // The join compiler captures these references and reads them later when // the graph runs, so they must be populated before the first graph run. private readonly subscriptions: Record<string, CollectionSubscription> = {} private readonly lazySourcesCallbacks: Record<string, any> = {} private readonly lazySources = new Set<string>() // OrderBy optimization info populated by the compiler when limit is present private readonly optimizableOrderByCollections: Record< string, OrderByOptimizationInfo > = {} // Ordered subscription state for cursor-based loading private readonly biggestSentValue = new Map<string, any>() private readonly lastLoadRequestKey = new Map<string, string>() private pendingOrderedLoadPromise: Promise<void> | undefined // Subscription management private readonly unsubscribeCallbacks = new Set<() => void>() // Duplicate insert prevention per alias private readonly sentToD2KeysByAlias = new Map<string, Set<string | number>>() // Output accumulator private pendingChanges: Map<unknown, EffectChanges<TRow>> = new Map() // skipInitial state private readonly skipInitial: boolean private initialLoadComplete = false // Scheduler integration private subscribedToAllCollections = false private readonly builderDependencies = new Set<unknown>() private readonly aliasDependencies: Record<string, Array<unknown>> = {} // Reentrance guard private isGraphRunning = false private disposed = false // When dispose() is called mid-graph-run, defer heavy cleanup until the run completes private deferredCleanup = false private readonly onBatchProcessed: ( events: Array<DeltaEvent<TRow, TKey>>, ) => void private readonly onSourceError: (error: Error) => void constructor(config: EffectPipelineRunnerConfig<TRow, TKey>) { this.skipInitial = config.skipInitial this.onBatchProcessed = config.onBatchProcessed this.onSourceError = config.onSourceError // Parse query this.query = buildQueryFromConfig({ query: config.query }) // Extract source collections this.collections = extractCollectionsFromQuery(this.query) const aliasesById = extractCollectionAliases(this.query) // Build alias → collection map this.collectionByAlias = {} for (const [collectionId, aliases] of aliasesById.entries()) { const collection = this.collections[collectionId] if (!collection) continue for (const alias of aliases) { this.collectionByAlias[alias] = collection } } // Compile the pipeline this.compilePipeline() } /** Compile the D2 graph and query pipeline */ private compilePipeline(): void { this.graph = new D2() this.inputs = Object.fromEntries( Object.keys(this.collectionByAlias).map((alias) => [ alias, this.graph!.newInput<any>(), ]), ) const compilation = compileQuery( this.query, this.inputs as Record<string, KeyedStream>, this.collections, // These mutable objects are captured by reference. The join compiler // reads them later when the graph runs, so they must be populated // (in start()) before the first graph run. this.subscriptions, this.lazySourcesCallbacks, this.lazySources, this.optimizableOrderByCollections, () => {}, // setWindowFn (no-op — effects don't paginate) ) this.pipeline = compilation.pipeline this.sourceWhereClauses = compilation.sourceWhereClauses this.compiledAliasToCollectionId = compilation.aliasToCollectionId // Attach the output operator that accumulates changes this.pipeline.pipe( output((data) => { const messages = data.getInner() messages.reduce(accumulateEffectChanges<TRow>, this.pendingChanges) }), ) this.graph.finalize() } /** Subscribe to source collections and start processing */ start(): void { // Use compiled aliases as the source of truth const compiledAliases = Object.entries(this.compiledAliasToCollectionId) if (compiledAliases.length === 0) { // Nothing to subscribe to return } // When not skipping initial, we always process events immediately if (!this.skipInitial) { this.initialLoadComplete = true } // We need to defer initial data processing until ALL subscriptions are // created, because join pipelines look up subscriptions by alias during // the graph run. If we run the graph while some aliases are still missing, // the join tap operator will throw. // // Strategy: subscribe to each collection but buffer incoming changes. // After all subscriptions are in place, flush the buffers and switch to // direct processing mode. const pendingBuffers = new Map< string, Array<Array<ChangeMessage<any, string | number>>> >() for (const [alias, collectionId] of compiledAliases) { const collection = this.collectionByAlias[alias] ?? this.collections[collectionId]! // Initialise per-alias duplicate tracking this.sentToD2KeysByAlias.set(alias, new Set()) // Discover dependencies: if source collection is itself a live query // collection, its builder must run first during transaction flushes. const dependencyBuilder = getCollectionBuilder(collection) if (dependencyBuilder) { this.aliasDependencies[alias] = [dependencyBuilder] this.builderDependencies.add(dependencyBuilder) } else { this.aliasDependencies[alias] = [] } // Get where clause for this alias (for predicate push-down) const whereClause = this.sourceWhereClauses?.get(alias) const whereExpression = whereClause ? normalizeExpressionPaths(whereClause, alias) : undefined // Initialise buffer for this alias const buffer: Array<Array<ChangeMessage<any, string | number>>> = [] pendingBuffers.set(alias, buffer) // Lazy aliases (marked by the join compiler) should NOT load initial state // eagerly — the join tap operator will load exactly the rows it needs on demand. // For on-demand collections, eager loading would trigger a full server fetch // for data that should be lazily loaded based on join keys. const isLazy = this.lazySources.has(alias) // Check if this alias has orderBy optimization (cursor-based loading) const orderByInfo = this.getOrderByInfoForAlias(alias) // Build the change callback — for ordered aliases, split updates into // delete+insert and track the biggest sent value for cursor positioning. const changeCallback = orderByInfo ? (changes: Array<ChangeMessage<any, string | number>>) => { if (pendingBuffers.has(alias)) { pendingBuffers.get(alias)!.push(changes) } else { this.trackSentValues(alias, changes, orderByInfo.comparator) const split = [...splitUpdates(changes)] this.handleSourceChanges(alias, split) } } : (changes: Array<ChangeMessage<any, string | number>>) => { if (pendingBuffers.has(alias)) { pendingBuffers.get(alias)!.push(changes) } else { this.handleSourceChanges(alias, changes) } } // Determine subscription options based on ordered vs unordered path const subscriptionOptions = this.buildSubscriptionOptions( alias, isLazy, orderByInfo, whereExpression, ) // Subscribe to source changes const subscription = collection.subscribeChanges( changeCallback, subscriptionOptions, ) // Store subscription immediately so the join compiler can find it this.subscriptions[alias] = subscription // For ordered aliases with an index, trigger the initial limited snapshot. // This loads only the top N rows rather than the entire collection. if (orderByInfo) { this.requestInitialOrderedSnapshot(alias, orderByInfo, subscription) } this.unsubscribeCallbacks.add(() => { subscription.unsubscribe() delete this.subscriptions[alias] }) // Listen for status changes on source collections const statusUnsubscribe = collection.on(`status:change`, (event) => { if (this.disposed) return const { status } = event // Source entered error state — effect can no longer function if (status === `error`) { this.onSourceError( new Error( `Source collection '${collectionId}' entered error state`, ), ) return } // Source was manually cleaned up — effect can no longer function if (status === `cleaned-up`) { this.onSourceError( new Error( `Source collection '${collectionId}' was cleaned up while effect depends on it`, ), ) return } // Track source readiness for skipInitial if ( this.skipInitial && !this.initialLoadComplete && this.checkAllCollectionsReady() ) { this.initialLoadComplete = true } }) this.unsubscribeCallbacks.add(statusUnsubscribe) } // Mark as subscribed so the graph can start running this.subscribedToAllCollections = true // All subscriptions are now in place. Flush buffered changes by sending // data to D2 inputs first (without running the graph), then run the graph // once. This prevents intermediate join states from producing duplicates. // // We remove each alias from pendingBuffers *before* draining, which // switches that alias to direct-processing mode. Any new callbacks that // fire during the drain (e.g. from requestLimitedSnapshot) will go // through handleSourceChanges directly instead of being lost. for (const [alias] of pendingBuffers) { const buffer = pendingBuffers.get(alias)! pendingBuffers.delete(alias) const orderByInfo = this.getOrderByInfoForAlias(alias) // Drain all buffered batches. Since we deleted the alias from // pendingBuffers above, any new changes arriving during drain go // through handleSourceChanges directly (not back into this buffer). for (const changes of buffer) { if (orderByInfo) { this.trackSentValues(alias, changes, orderByInfo.comparator) const split = [...splitUpdates(changes)] this.sendChangesToD2(alias, split) } else { this.sendChangesToD2(alias, changes) } } } // Initial graph run to process any synchronously-available data. // For skipInitial, this run's output is discarded (initialLoadComplete is still false). this.runGraph() // After the initial graph run, if all sources are ready, // mark initial load as complete so future events are processed. if (this.skipInitial && !this.initialLoadComplete) { if (this.checkAllCollectionsReady()) { this.initialLoadComplete = true } } } /** Handle incoming changes from a source collection */ private handleSourceChanges( alias: string, changes: Array<ChangeMessage<any, string | number>>, ): void { this.sendChangesToD2(alias, changes) this.scheduleGraphRun(alias) } /** * Schedule a graph run via the transaction-scoped scheduler. * * When called within a transaction, the run is deferred until the * transaction flushes, coalescing multiple changes into a single graph * execution. Without a transaction, the graph runs immediately. * * Dependencies are discovered from source collections that are themselves * live query collections, ensuring parent queries run before effects. */ private scheduleGraphRun(alias?: string): void { const contextId = getActiveTransaction()?.id // Collect dependencies for this schedule call const deps = new Set(this.builderDependencies) if (alias) { const aliasDeps = this.aliasDependencies[alias] if (aliasDeps) { for (const dep of aliasDeps) { deps.add(dep) } } } // Ensure dependent builders are scheduled in this context so that // dependency edges always point to a real job. if (contextId) { for (const dep of deps) { if ( typeof dep === `object` && dep !== null && `scheduleGraphRun` in dep && typeof (dep as any).scheduleGraphRun === `function` ) { ;(dep as any).scheduleGraphRun(undefined, { contextId }) } } } transactionScopedScheduler.schedule({ contextId, jobId: this, dependencies: deps, run: () => this.executeScheduledGraphRun(), }) } /** * Called by the scheduler when dependencies are satisfied. * Checks that the effect is still active before running. */ private executeScheduledGraphRun(): void { if (this.disposed || !this.subscribedToAllCollections) return this.runGraph() } /** * Send changes to the D2 input for the given alias. * Returns the number of multiset entries sent. */ private sendChangesToD2( alias: string, changes: Array<ChangeMessage<any, string | number>>, ): number { if (this.disposed || !this.inputs || !this.graph) return 0 const input = this.inputs[alias] if (!input) return 0 const collection = this.collectionByAlias[alias] if (!collection) return 0 // Filter duplicates per alias const sentKeys = this.sentToD2KeysByAlias.get(alias)! const filtered = filterDuplicateInserts(changes, sentKeys) return sendChangesToInput(input, filtered, collection.config.getKey) } /** * Run the D2 graph until quiescence, then emit accumulated events once. * * All output across the entire while-loop is accumulated into a single * batch so that users see one `onBatchProcessed` invocation per scheduler * run, even when ordered loading causes multiple graph steps. */ private runGraph(): void { if (this.isGraphRunning || this.disposed || !this.graph) return this.isGraphRunning = true try { while (this.graph.pendingWork()) { this.graph.run() // A handler (via onBatchProcessed) or source error callback may have // called dispose() during graph.run(). Stop early to avoid operating // on stale state. TS narrows disposed to false from the guard above // but it can change during graph.run() via callbacks. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (this.disposed) break // After each step, check if ordered queries need more data. // loadMoreIfNeeded may send data to D2 inputs (via requestLimitedSnapshot), // causing pendingWork() to return true for the next iteration. this.loadMoreIfNeeded() } // Emit all accumulated events once the graph reaches quiescence this.flushPendingChanges() } finally { this.isGraphRunning = false // If dispose() was called during this graph run, it deferred the heavy // cleanup (clearing graph/inputs/pipeline) to avoid nulling references // mid-loop. Complete that cleanup now. if (this.deferredCleanup) { this.deferredCleanup = false this.finalCleanup() } } } /** Classify accumulated changes into DeltaEvents and invoke the callback */ private flushPendingChanges(): void { if (this.pendingChanges.size === 0) return // If skipInitial and initial load isn't complete yet, discard if (this.skipInitial && !this.initialLoadComplete) { this.pendingChanges = new Map() return } const events: Array<DeltaEvent<TRow, TKey>> = [] for (const [key, changes] of this.pendingChanges) { const event = classifyDelta<TRow, TKey>(key as TKey, changes) if (event) { events.push(event) } } this.pendingChanges = new Map() if (events.length > 0) { this.onBatchProcessed(events) } } /** Check if all source collections are in the ready state */ private checkAllCollectionsReady(): boolean { return Object.values(this.collections).every((collection) => collection.isReady(), ) } /** * Build subscription options for an alias based on whether it uses ordered * loading, is lazy, or should pass orderBy/limit hints. */ private buildSubscriptionOptions( alias: string, isLazy: boolean, orderByInfo: OrderByOptimizationInfo | undefined, whereExpression: BasicExpression<boolean> | undefined, ): { includeInitialState?: boolean whereExpression?: BasicExpression<boolean> orderBy?: any limit?: number } { // Ordered aliases explicitly disable initial state — data is loaded // via requestLimitedSnapshot/requestSnapshot after subscription setup. if (orderByInfo) { return { includeInitialState: false, whereExpression } } const includeInitialState = !isLazy // For unordered subscriptions, pass orderBy/limit hints so on-demand // collections can optimise server-side fetching. const hints = computeSubscriptionOrderByHints(this.query, alias) return { includeInitialState, whereExpression, ...(hints.orderBy ? { orderBy: hints.orderBy } : {}), ...(hints.limit !== undefined ? { limit: hints.limit } : {}), } } /** * Request the initial ordered snapshot for an alias. * Uses requestLimitedSnapshot (index-based cursor) or requestSnapshot * (full load with limit) depending on whether an index is available. */ private requestInitialOrderedSnapshot( alias: string, orderByInfo: OrderByOptimizationInfo, subscription: CollectionSubscription, ): void { const { orderBy, offset, limit, index } = orderByInfo const normalizedOrderBy = normalizeOrderByPaths(orderBy, alias) if (index) { subscription.setOrderByIndex(index) subscription.requestLimitedSnapshot({ limit: offset + limit, orderBy: normalizedOrderBy, trackLoadSubsetPromise: false, }) } else { subscription.requestSnapshot({ orderBy: normalizedOrderBy, limit: offset + limit, trackLoadSubsetPromise: false, }) } } /** * Get orderBy optimization info for a given alias. * Returns undefined if no optimization exists for this alias. */ private getOrderByInfoForAlias( alias: string, ): OrderByOptimizationInfo | undefined { // optimizableOrderByCollections is keyed by collection ID const collectionId = this.compiledAliasToCollectionId[alias] if (!collectionId) return undefined const info = this.optimizableOrderByCollections[collectionId] if (info && info.alias === alias) { return info } return undefined } /** * After each graph run step, check if any ordered query's topK operator * needs more data. If so, load more rows via requestLimitedSnapshot. */ private loadMoreIfNeeded(): void { for (const [, orderByInfo] of Object.entries( this.optimizableOrderByCollections, )) { if (!orderByInfo.dataNeeded) continue if (this.pendingOrderedLoadPromise) { // Wait for in-flight loads to complete before requesting more continue } const n = orderByInfo.dataNeeded() if (n > 0) { this.loadNextItems(orderByInfo, n) } } } /** * Load n more items from the source collection, starting from the cursor * position (the biggest value sent so far). */ private loadNextItems(orderByInfo: OrderByOptimizationInfo, n: number): void { const { alias } = orderByInfo const subscription = this.subscriptions[alias] if (!subscription) return const cursor = computeOrderedLoadCursor( orderByInfo, this.biggestSentValue.get(alias), this.lastLoadRequestKey.get(alias), alias, n, ) if (!cursor) return // Duplicate request — skip this.lastLoadRequestKey.set(alias, cursor.loadRequestKey) subscription.requestLimitedSnapshot({ orderBy: cursor.normalizedOrderBy, limit: n, minValues: cursor.minValues, trackLoadSubsetPromise: false, onLoadSubsetResult: (loadResult: Promise<void> | true) => { // Track in-flight load to prevent redundant concurrent requests if (loadResult instanceof Promise) { this.pendingOrderedLoadPromise = loadResult loadResult.finally(() => { if (this.pendingOrderedLoadPromise === loadResult) { this.pendingOrderedLoadPromise = undefined } }) } }, }) } /** * Track the biggest value sent for a given ordered alias. * Used for cursor-based pagination in loadNextItems. */ private trackSentValues( alias: string, changes: Array<ChangeMessage<any, string | number>>, comparator: (a: any, b: any) => number, ): void { const sentKeys = this.sentToD2KeysByAlias.get(alias) ?? new Set() const result = trackBiggestSentValue( changes, this.biggestSentValue.get(alias), sentKeys, comparator, ) this.biggestSentValue.set(alias, result.biggest) if (result.shouldResetLoadKey) { this.lastLoadRequestKey.delete(alias) } } /** Tear down subscriptions and clear state */ dispose(): void { if (this.disposed) return this.disposed = true this.subscribedToAllCollections = false // Immediately unsubscribe from sources and clear cheap state this.unsubscribeCallbacks.forEach((fn) => fn()) this.unsubscribeCallbacks.clear() this.sentToD2KeysByAlias.clear() this.pendingChanges.clear() this.lazySources.clear() this.builderDependencies.clear() this.biggestSentValue.clear() this.lastLoadRequestKey.clear() this.pendingOrderedLoadPromise = undefined // Clear mutable objects for (const key of Object.keys(this.lazySourcesCallbacks)) { delete this.lazySourcesCallbacks[key] } for (const key of Object.keys(this.aliasDependencies)) { delete this.aliasDependencies[key] } for (const key of Object.keys(this.optimizableOrderByCollections)) { delete this.optimizableOrderByCollections[key] } // If the graph is currently running, defer clearing graph/inputs/pipeline // until runGraph() completes — otherwise we'd null references mid-loop. if (this.isGraphRunning) { this.deferredCleanup = true } else { this.finalCleanup() } } /** Clear graph references — called after graph run completes or immediately from dispose */ private finalCleanup(): void { this.graph = undefined this.inputs = undefined this.pipeline = undefined this.sourceWhereClauses = undefined } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function getHandlerForEvent<TRow extends object, TKey extends string | number>( event: DeltaEvent<TRow, TKey>, config: EffectConfig<TRow, TKey>, ): EffectEventHandler<TRow, TKey> | undefined { switch (event.type) { case `enter`: return config.onEnter case `exit`: return config.onExit case `update`: return config.onUpdate } } /** * Accumulate D2 output multiplicities into per-key effect changes. * Tracks both insert values (new) and delete values (old) separately * so that update and exit events can include previousValue. */ function accumulateEffectChanges<T>( acc: Map<unknown, EffectChanges<T>>, [[key, tupleData], multiplicity]: [ [unknown, [any, string | undefined]], number, ], ): Map<unknown, EffectChanges<T>> { const [value] = tupleData as [T, string | undefined] const changes: EffectChanges<T> = acc.get(key) || { deletes: 0, inserts: 0, } if (multiplicity < 0) { changes.deletes += Math.abs(multiplicity) // Keep only the first delete value — this is the pre-batch state changes.deleteValue ??= value } else if (multiplicity > 0) { changes.inserts += multiplicity // Always overwrite with the latest insert — this is the post-batch state changes.insertValue = value } acc.set(key, changes) return acc } /** Classify accumulated per-key changes into a DeltaEvent */ function classifyDelta<TRow extends object, TKey extends string | number>( key: TKey, changes: EffectChanges<TRow>, ): DeltaEvent<TRow, TKey> | undefined { const { inserts, deletes, insertValue, deleteValue } = changes if (inserts > 0 && deletes === 0) { // Row entered the query result return { type: `enter`, key, value: insertValue! } } if (deletes > 0 && inserts === 0) { // Row exited the query result — value is the exiting value, // previousValue is omitted (it would be identical to value) return { type: `exit`, key, value: deleteValue! } } if (inserts > 0 && deletes > 0) { // Row updated within the query result return { type: `update`, key, value: insertValue!, previousValue: deleteValue!, } } // inserts === 0 && deletes === 0 — no net change (should not happen) return undefined } /** Track a promise in the in-flight set, automatically removing on settlement */ function trackPromise( promise: Promise<void>, inFlightHandlers: Set<Promise<void>>, ): void { inFlightHandlers.add(promise) promise.finally(() => { inFlightHandlers.delete(promise) }) } /** Report an error to the onError callback or console */ function reportError<TRow extends object, TKey extends string | number>( error: unknown, event: DeltaEvent<TRow, TKey>, onError?: (error: Error, event: DeltaEvent<TRow, TKey>) => void, ): void { const normalised = error instanceof Error ? error : new Error(String(error)) if (onError) { try { onError(normalised, event) } catch (onErrorError) { // Don't let onError errors propagate console.error(`[Effect] Error in onError handler:`, onErrorError) console.error(`[Effect] Original error:`, normalised) } } else { console.error(`[Effect] Unhandled error in handler:`, normalised) } }