UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

223 lines (192 loc) 6.67 kB
/** * Identifier used to scope scheduled work. Maps to a transaction id for live queries. */ export type SchedulerContextId = string | symbol /** * Options for {@link Scheduler.schedule}. Jobs are identified by `jobId` within a context * and may declare dependencies. */ interface ScheduleOptions { contextId?: SchedulerContextId jobId: unknown dependencies?: Iterable<unknown> run: () => void } /** * State per context. Queue preserves order, jobs hold run functions, dependencies track * prerequisites, and completed records which jobs have run during the current flush. */ interface SchedulerContextState { queue: Array<unknown> jobs: Map<unknown, () => void> dependencies: Map<unknown, Set<unknown>> completed: Set<unknown> } interface PendingAwareJob { hasPendingGraphRun: (contextId: SchedulerContextId) => boolean } function isPendingAwareJob(dep: any): dep is PendingAwareJob { return ( typeof dep === `object` && dep !== null && typeof dep.hasPendingGraphRun === `function` ) } /** * Scoped scheduler that coalesces work by context and job. * * - **context** (e.g. transaction id) defines the batching boundary; work is queued until flushed. * - **job id** deduplicates work within a context; scheduling the same job replaces the previous run function. * - Without a context id, work executes immediately. * * Callers manage their own state; the scheduler only orchestrates execution order. */ export class Scheduler { private contexts = new Map<SchedulerContextId, SchedulerContextState>() private clearListeners = new Set<(contextId: SchedulerContextId) => void>() /** * Get or create the state bucket for a context. */ private getOrCreateContext( contextId: SchedulerContextId, ): SchedulerContextState { let context = this.contexts.get(contextId) if (!context) { context = { queue: [], jobs: new Map(), dependencies: new Map(), completed: new Set(), } this.contexts.set(contextId, context) } return context } /** * Schedule work. Without a context id, executes immediately. * Otherwise queues the job to be flushed once dependencies are satisfied. * Scheduling the same jobId again replaces the previous run function. */ schedule({ contextId, jobId, dependencies, run }: ScheduleOptions): void { if (typeof contextId === `undefined`) { run() return } const context = this.getOrCreateContext(contextId) // If this is a new job, add it to the queue if (!context.jobs.has(jobId)) { context.queue.push(jobId) } // Store or replace the run function context.jobs.set(jobId, run) // Update dependencies if (dependencies) { const depSet = new Set<unknown>(dependencies) depSet.delete(jobId) context.dependencies.set(jobId, depSet) } else if (!context.dependencies.has(jobId)) { context.dependencies.set(jobId, new Set()) } // Clear completion status since we're rescheduling context.completed.delete(jobId) } /** * Flush all queued work for a context. Jobs with unmet dependencies are retried. * Throws if a pass completes without running any job (dependency cycle). */ flush(contextId: SchedulerContextId): void { const context = this.contexts.get(contextId) if (!context) return const { queue, jobs, dependencies, completed } = context while (queue.length > 0) { let ranThisPass = false const jobsThisPass = queue.length for (let i = 0; i < jobsThisPass; i++) { const jobId = queue.shift()! const run = jobs.get(jobId) if (!run) { dependencies.delete(jobId) completed.delete(jobId) continue } const deps = dependencies.get(jobId) let ready = !deps if (deps) { ready = true for (const dep of deps) { if (dep === jobId) continue const depHasPending = isPendingAwareJob(dep) && dep.hasPendingGraphRun(contextId) // Treat dependencies as blocking if the dep has a pending run in this // context or if it's enqueued and not yet complete. If the dep is // neither pending nor enqueued, consider it satisfied to avoid deadlocks // on lazy sources that never schedule work. if ( (jobs.has(dep) && !completed.has(dep)) || (!jobs.has(dep) && depHasPending) ) { ready = false break } } } if (ready) { jobs.delete(jobId) dependencies.delete(jobId) // Run the job. If it throws, we don't mark it complete, allowing the // error to propagate while maintaining scheduler state consistency. run() completed.add(jobId) ranThisPass = true } else { queue.push(jobId) } } if (!ranThisPass) { throw new Error( `Scheduler detected unresolved dependencies for context ${String( contextId, )}.`, ) } } this.contexts.delete(contextId) } /** * Flush all contexts with pending work. Useful during tear-down. */ flushAll(): void { for (const contextId of Array.from(this.contexts.keys())) { this.flush(contextId) } } /** Clear all scheduled jobs for a context. */ clear(contextId: SchedulerContextId): void { this.contexts.delete(contextId) // Notify listeners that this context was cleared this.clearListeners.forEach((listener) => listener(contextId)) } /** Register a listener to be notified when a context is cleared. */ onClear(listener: (contextId: SchedulerContextId) => void): () => void { this.clearListeners.add(listener) return () => this.clearListeners.delete(listener) } /** Check if a context has pending jobs. */ hasPendingJobs(contextId: SchedulerContextId): boolean { const context = this.contexts.get(contextId) return !!context && context.jobs.size > 0 } /** Remove a single job from a context and clean up its dependencies. */ clearJob(contextId: SchedulerContextId, jobId: unknown): void { const context = this.contexts.get(contextId) if (!context) return context.jobs.delete(jobId) context.dependencies.delete(jobId) context.completed.delete(jobId) context.queue = context.queue.filter((id) => id !== jobId) if (context.jobs.size === 0) { this.contexts.delete(contextId) } } } export const transactionScopedScheduler = new Scheduler()