@tanstack/db
Version:
A reactive client store for building super fast apps on sync
246 lines (214 loc) • 7.58 kB
text/typescript
import { runAllCallbacks } from './utils/callbacks.js'
/**
* 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. A job leaves the pending map before its callback runs, so work
* queued by that callback is a new pending dependency.
*/
interface SchedulerContextState {
queue: Array<unknown>
jobs: Map<unknown, () => void>
dependencies: Map<unknown, 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(),
}
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())
}
}
/**
* 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 } = 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)
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. 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) || depHasPending) {
ready = false
break
}
}
}
if (ready) {
jobs.delete(jobId)
dependencies.delete(jobId)
// A reentrant schedule now owns a fresh pending job; finishing this
// callback must not mark that replacement as complete.
run()
ranThisPass = true
} else {
queue.push(jobId)
}
}
if (!ranThisPass) {
throw new Error(
`Scheduler detected unresolved dependencies for context ${String(
contextId,
)}.`,
)
}
}
this.contexts.delete(contextId)
}
/** Clear all scheduled jobs for a context. */
clear(contextId: SchedulerContextId): void {
this.contexts.delete(contextId)
runAllCallbacks(
[...this.clearListeners].map((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)
}
}
export const transactionScopedScheduler = new Scheduler()
let activePublicationContext: SchedulerContextId | undefined
let activePublicationFailure: { error: unknown } | undefined
function getActivePublicationFailure(): { error: unknown } | undefined {
return activePublicationFailure
}
/**
* Returns the Collection publication that currently owns synchronous change
* delivery. Live-query jobs use it to coalesce all source subscriptions that
* observe one committed batch.
*/
export function getActivePublicationContext(): SchedulerContextId | undefined {
return activePublicationContext
}
/** Report a listener failure after the whole publication graph has drained. */
export function recordPublicationError(error: unknown): void {
if (activePublicationContext === undefined) throw error
activePublicationFailure ??= { error }
}
/**
* Runs one synchronous Collection publication inside a scheduler context.
* Nested publications share the outer context, so downstream live queries run
* only after every subscriber to the original committed batch has observed it.
*/
export function withPublicationContext<T>(publish: () => T): T {
if (activePublicationContext !== undefined) return publish()
const contextId = Symbol(`collection-publication`)
activePublicationContext = contextId
activePublicationFailure = undefined
let result!: T
let listenerFailure: { error: unknown } | undefined
try {
result = publish()
transactionScopedScheduler.flush(contextId)
listenerFailure = getActivePublicationFailure()
} catch (error) {
try {
transactionScopedScheduler.clear(contextId)
} catch {
// Keep the earlier publication or graph failure.
}
// Keep the first reported failure, including one from an earlier listener.
const publicationFailure = getActivePublicationFailure()
if (publicationFailure) {
throw publicationFailure.error
}
throw error
} finally {
activePublicationContext = undefined
activePublicationFailure = undefined
}
if (listenerFailure) throw listenerFailure.error
return result
}