UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

580 lines (536 loc) 21.2 kB
import { LiveQueryObserverDisposedError } from './errors.js' import { getLiveQueryStatusFlags, isSingleResultCollection, } from './live-query-adapter.js' import type { Collection } from './collection/index.js' import type { ChangeMessage, CollectionStatus } from './types.js' /** * The canonical, adapter-agnostic view of a live query at a point in time. * * `getSnapshot()` returns a stable object identity that only changes when the * query changes, so `useSyncExternalStore`-style consumers can compare by * reference. Each snapshot owns a captured view of `state`/`data`, so reading * an older snapshot cannot expose rows from a later revision. */ export interface LiveQuerySnapshot< T extends object, TKey extends string | number, > { /** Keyed results, or `undefined` for a disabled query. */ state: ReadonlyMap<TKey, T> | undefined /** Ordered results (single row for `findOne`), or `undefined` when disabled. */ data: T | ReadonlyArray<T> | undefined /** The underlying collection, or `undefined` when disabled. */ collection: Collection<T, TKey, any> | undefined /** * Monotonic counter bumped whenever the visible layout (the ordered key * sequence) changes — membership, ordering, or an order-only move. Lets * consumers detect a reorder that changed no row value (which `data`/`state` * identity alone can't express once row values are structurally shared). * * It is NOT in lockstep with snapshot identity: a value-only update produces a * new snapshot while `layoutRevision` stays put. A `layoutRevision` change * always accompanies a new snapshot, but not vice versa. */ layoutRevision: number status: CollectionStatus | `disabled` isLoading: boolean isReady: boolean isIdle: boolean isError: boolean isCleanedUp: boolean isEnabled: boolean } /** * Listener payload: changes, `[]` for an internal layout-only publication, or * `undefined` for a synthetic status/ready notification. */ export type LiveQueryObserverListener< T extends object, TKey extends string | number, > = (changes: Array<ChangeMessage<T, TKey>> | undefined) => void /** * Wraps a resolved live-query `Collection` (or `null` for a disabled query) with * the shared lifecycle every framework adapter needs: start sync on first * subscribe, subscribe to changes and status transitions, expose a stable * snapshot for wholesale consumers, and deliver the raw change set for * granular consumers. * * Input resolution (query fn / config / collection / disabled) stays in the * adapter — it is framework-reactive. The observer owns everything after the * input is resolved to a concrete collection. * * @internal Unstable contract for TanStack DB's official framework adapters — * not a public extension point yet; may change in any release. */ export interface LiveQueryObserver< T extends object, TKey extends string | number, > { /** Stable per-revision snapshot for wholesale materialization. */ getSnapshot: () => LiveQuerySnapshot<T, TKey> /** * Subscribe to changes. The listener receives the change set (or `undefined` * for the synthetic notify a ready collection emits on attach). Granular * adapters apply the changes; wholesale adapters can ignore them and re-read * `getSnapshot()`. Returns an unsubscribe function. */ subscribe: (listener: LiveQueryObserverListener<T, TKey>) => () => void /** Resolve once the collection has loaded its first data. */ preload: () => Promise<void> /** Idempotent teardown. */ dispose: () => void } /** * One logical subscription. Records — not raw callbacks — identify * subscriptions, so the same listener function can be subscribed twice and * each subscription tears down independently. */ interface SubscriptionRecord<T extends object, TKey extends string | number> { listener: LiveQueryObserverListener<T, TKey> active: boolean } interface Publication<T extends object, TKey extends string | number> { changes: Array<ChangeMessage<T, TKey>> | undefined targets: Array<SubscriptionRecord<T, TKey>> entries?: Array<[TKey, T]> status: CollectionStatus collectionRevision?: number collectionLayoutRevision?: number layoutChanged: boolean } const DISABLED_SNAPSHOT: LiveQuerySnapshot<any, any> = { state: undefined, data: undefined, collection: undefined, layoutRevision: 0, status: `disabled`, isLoading: false, isReady: true, isIdle: false, isError: false, isCleanedUp: false, isEnabled: false, } class LiveQueryObserverImpl< T extends object, TKey extends string | number, > implements LiveQueryObserver<T, TKey> { private readonly collection: Collection<T, TKey, any> | null private readonly wholesale: boolean private visibleStatus: CollectionStatus | undefined private cachedEntries: Array<[TKey, T]> | undefined private cachedCollectionRevision: number | undefined private cachedCollectionLayoutRevision: number | undefined private snapshotDirty = true private cachedSnapshot: LiveQuerySnapshot<T, TKey> = DISABLED_SNAPSHOT private layoutRevision = 0 private lastLayoutKeys: Array<TKey> | undefined private deliveredLayoutRevision: number | undefined private readonly subscriptions = new Set<SubscriptionRecord<T, TKey>>() // Publications are dispatched FIFO: an emit that happens while another // publication is being delivered (a listener mutating the collection // synchronously) is queued, never delivered reentrantly. private readonly publicationQueue: Array<Publication<T, TKey>> = [] private dispatching = false private blockDelivery = false private attached = false private collectionUnsub: (() => void) | null = null private disposed = false // Construction is side-effect-free: sync activation belongs to the first // subscription (attach), so building an observer — e.g. in a React render // that may be abandoned — cannot activate resources on its own. constructor(collection: Collection<T, TKey, any> | null, wholesale: boolean) { this.collection = collection this.wholesale = wholesale } getSnapshot(): LiveQuerySnapshot<T, TKey> { const collection = this.collection if (!collection) return DISABLED_SNAPSHOT if (!this.attached) this.refreshDetachedState(collection) if (this.snapshotDirty) { const entries = this.cachedEntries ?? this.captureEntries(collection).entries const state = new Map(entries) const data = entries.map(([, value]) => value) const singleResult = isSingleResultCollection(collection) const status = this.visibleStatus ?? collection.status // Bump the layout revision when the ordered key sequence changes // (membership, ordering, or an order-only move). Compare the key sequence // directly rather than via a serialized signature: a joined-with-separator // signature can collide when a key value equals the concatenation of // neighboring keys around the separator. Comparing keys also avoids // materializing a large string on every rebuild; a new key array is only // allocated when the layout actually moved. const prevKeys = this.lastLayoutKeys let layoutChanged = prevKeys === undefined || prevKeys.length !== entries.length if (!layoutChanged) { for (let i = 0; i < entries.length; i++) { if (prevKeys![i] !== entries[i]![0]) { layoutChanged = true break } } } if (layoutChanged) { this.lastLayoutKeys = entries.map(([key]) => key) this.layoutRevision++ } this.cachedSnapshot = { state, data: singleResult ? data[0] : data, collection, layoutRevision: this.layoutRevision, status, ...getLiveQueryStatusFlags(status), isEnabled: true, } this.snapshotDirty = false } return this.cachedSnapshot } private getCollectionRevision( collection: Collection<T, TKey, any>, ): number | undefined { const revision = (collection as { _stateRevision?: unknown })._stateRevision return typeof revision === `number` ? revision : undefined } private getCollectionLayoutRevision( collection: Collection<T, TKey, any>, ): number | undefined { const revision = (collection as { _layoutRevision?: unknown }) ._layoutRevision return typeof revision === `number` ? revision : undefined } private readEntries(collection: Collection<T, TKey, any>): { entries: Array<[TKey, T]> revision?: number } { const entries = Array.from(collection.entries()) as Array<[TKey, T]> const revision = this.getCollectionRevision(collection) return { entries, revision } } private captureEntries(collection: Collection<T, TKey, any>): { entries: Array<[TKey, T]> revision?: number } { const { entries, revision } = this.readEntries(collection) this.updateCachedEntries(entries, revision) return { entries, revision } } private updateCachedEntries( entries: Array<[TKey, T]>, revision: number | undefined, ): void { const changed = revision !== undefined ? this.cachedEntries === undefined || revision !== this.cachedCollectionRevision : !this.entriesEqual(this.cachedEntries, entries) this.cachedEntries = entries this.cachedCollectionRevision = revision if (changed) this.snapshotDirty = true } private entriesEqual( left: Array<[TKey, T]> | undefined, right: Array<[TKey, T]>, ): boolean { if (!left || left.length !== right.length) return false return left.every( ([key, value], index) => right[index]![0] === key && right[index]![1] === value, ) } /** * While detached there is no delivered-publication clock, so fall back to * the collection revision. Compatible cross-copy collections that predate * `_stateRevision` are compared structurally instead. */ private refreshDetachedState(collection: Collection<T, TKey, any>): void { const status = collection.status const revision = this.getCollectionRevision(collection) const layoutRevision = this.getCollectionLayoutRevision(collection) if (revision !== undefined) { if ( this.cachedEntries === undefined || revision !== this.cachedCollectionRevision || layoutRevision !== this.cachedCollectionLayoutRevision ) { this.captureEntries(collection) this.cachedCollectionLayoutRevision = layoutRevision this.snapshotDirty = true } } else { const entries = Array.from(collection.entries()) as Array<[TKey, T]> this.updateCachedEntries(entries, undefined) } if (this.visibleStatus !== status) { this.visibleStatus = status this.snapshotDirty = true } } subscribe(listener: LiveQueryObserverListener<T, TKey>): () => void { if (this.disposed) throw new LiveQueryObserverDisposedError() const record: SubscriptionRecord<T, TKey> = { listener, active: true } this.subscriptions.add(record) if (this.subscriptions.size === 1) { this.attach() } else { // The initial-state replay only happens on attach, so a granular // subscriber that arrives while already attached is seeded with the // current rows — delivered to this subscription alone, without advancing // the observer's revision (the collection state did not change). // Wholesale consumers read getSnapshot() instead and need no seed. if (!this.wholesale) this.seed(record) } return () => { if (!record.active) return record.active = false this.subscriptions.delete(record) if (this.subscriptions.size === 0) this.detach() } } /** Deliver the collection's current rows to one late subscription as inserts. */ private seed(record: SubscriptionRecord<T, TKey>): void { const collection = this.collection if (!collection) return const seedChanges: Array<ChangeMessage<T, TKey>> = [] for (const [key, value] of collection.entries() as IterableIterator< [TKey, T] >) { seedChanges.push({ type: `insert`, key, value }) } if (seedChanges.length === 0) return this.emit(seedChanges, [record]) } private attach(): void { const collection = this.collection if (!collection || this.disposed) return this.refreshDetachedState(collection) this.attached = true this.visibleStatus ??= collection.status this.deliveredLayoutRevision = this.getCollectionLayoutRevision(collection) this.blockDelivery = this.wholesale // Sync activation happens inside subscribeChanges (addSubscriber starts // an idle/cleaned-up collection) — the same startSync path the old // constructor-time startSyncImmediate() took, but now owned by the first // committed subscription and observed by the status listener below. // Granular consumers subscribe with initial state so they receive the // current rows as inserts followed by deltas through one consistent // channel (the collection's per-subscriber change stream requires this to // align deltas). Wholesale consumers subscribe WITHOUT initial state — // preserving their pre-observer loading policy: no snapshot request means // no unfiltered loadSubset({ where: undefined }) against on-demand // collections. The explicit `false` marks all state as seen so deletes // still flow through as notifies. const notify = ( changes: Array<ChangeMessage<T, TKey>> | undefined, status: CollectionStatus = collection.status, explicitLayoutChange = false, ) => { if (this.disposed || this.subscriptions.size === 0) return const layoutRevision = this.getCollectionLayoutRevision(collection) let layoutChanged = explicitLayoutChange if ( !explicitLayoutChange && changes !== undefined && changes.length === 0 ) { // Empty ready events predate the explicit layout signal and share its // empty-array payload. Only forward an empty batch when the collection // confirms that a new layout-only publication occurred. if ( layoutRevision === undefined || layoutRevision === this.deliveredLayoutRevision ) { return } layoutChanged = true } if (changes !== undefined && layoutRevision !== undefined) { this.deliveredLayoutRevision = layoutRevision } const captured = changes !== undefined ? this.readEntries(collection) : status === `cleaned-up` ? this.readEntries(collection) : undefined this.emit( changes, undefined, captured?.entries, status, captured?.revision, layoutRevision, layoutChanged, ) } // Status transitions that carry no change events (loading→ready with no // rows, error, cleaned-up) are part of the canonical publication path: // any status change publishes a synthetic notify so consumers re-read the // snapshot. Unlike onFirstReady, `on` returns a real unsubscribe, so a // detached attachment leaves nothing behind. const statusUnsub = collection.on(`status:change`, ({ status }) => notify(undefined, status), ) const subscribeLayoutChanges = ( collection as Collection<T, TKey, any> & { _subscribeLayoutChanges?: (listener: () => void) => () => void } )._subscribeLayoutChanges const layoutUnsub = typeof subscribeLayoutChanges === `function` ? subscribeLayoutChanges.call(collection, () => notify([], collection.status, true), ) : () => {} // `subscribeChanges` delivers the initial state synchronously, so a // listener can dispose the observer while the collection subscription is // still being created. Register the release hook up front; if detach() // ran during that replay (collectionUnsub no longer points at our hook), // undo the subscription as soon as the call returns. let subscription: { unsubscribe: () => void } | null = null const release = () => { statusUnsub() layoutUnsub() subscription?.unsubscribe() } this.collectionUnsub = release subscription = collection.subscribeChanges( (changes) => notify(changes as Array<ChangeMessage<T, TKey>>), { includeInitialState: !this.wholesale }, ) this.blockDelivery = false if (this.collectionUnsub !== release) { subscription.unsubscribe() return } if (this.wholesale) { // Publications raised while subscribeChanges starts sync are part of the // subscribe handshake. Apply their final snapshot state now, but suppress // listener delivery: useSyncExternalStore performs its consistency read // immediately after subscribe returns. this.flushPublications(false) const { entries, revision } = this.readEntries(collection) this.updateCachedEntries(entries, revision) } } private detach(): void { this.collectionUnsub?.() this.collectionUnsub = null this.attached = false this.blockDelivery = false this.publicationQueue.length = 0 } private emit( changes: Array<ChangeMessage<T, TKey>> | undefined, targets = Array.from(this.subscriptions), entries?: Array<[TKey, T]>, status = this.collection?.status ?? `cleaned-up`, collectionRevision?: number, collectionLayoutRevision?: number, layoutChanged = false, ): void { this.publicationQueue.push({ changes, targets, entries, status, collectionRevision, collectionLayoutRevision, layoutChanged, }) if (this.dispatching || this.blockDelivery) return this.flushPublications() } private flushPublications(deliver = true): void { if (this.dispatching) return this.dispatching = true try { // A dispose() during dispatch empties the queue, ending this loop. while (this.publicationQueue.length > 0) { const publication = this.publicationQueue.shift()! if (publication.entries) { this.updateCachedEntries( publication.entries, publication.collectionRevision, ) } if (publication.collectionLayoutRevision !== undefined) { this.cachedCollectionLayoutRevision = publication.collectionLayoutRevision } if (publication.layoutChanged) { this.snapshotDirty = true } if (this.visibleStatus !== publication.status) { this.visibleStatus = publication.status this.snapshotDirty = true } // Targets are captured when the publication is queued: a subscription // removed mid-delivery still receives the in-flight publication, and // one added later does not. Late-subscriber seeds use the same queue. if (deliver) { for (const subRecord of publication.targets) { if (this.disposed) return subRecord.listener(publication.changes) } } } } finally { this.dispatching = false } } async preload(): Promise<void> { await this.collection?.preload() } dispose(): void { if (this.disposed) return this.disposed = true this.detach() for (const subRecord of this.subscriptions) subRecord.active = false this.subscriptions.clear() this.publicationQueue.length = 0 } } export interface CreateLiveQueryObserverOptions { /** * How subscribers consume the observer: * * - `granular` (default): subscribers apply the delivered `ChangeMessage[]` * deltas to their own keyed state (Vue/Svelte/Solid). The observer * subscribes with initial state and seeds late subscribers, so every * subscriber converges from deltas alone. * - `wholesale`: subscribers treat notifications as a wake-up and re-read * `getSnapshot()` (React/Angular). The observer subscribes WITHOUT initial * state, preserving those adapters' loading policy — no snapshot request, * so no unfiltered `loadSubset` against on-demand collections. Nothing is * delivered synchronously during `subscribe`, which keeps * `useSyncExternalStore`-style consumers safe by construction. */ mode?: `granular` | `wholesale` } /** * Create a {@link LiveQueryObserver} for a resolved live-query collection, or a * disabled observer when `collection` is `null`/`undefined`. * * @internal This is an unstable contract shared by TanStack DB's official * framework adapters. It is exported so the adapter packages can use it, but * it is not a public extension point yet: its API may change in any release * without a semver major. */ export function createLiveQueryObserver< T extends object, TKey extends string | number, >( collection: Collection<T, TKey, any> | null | undefined, options: CreateLiveQueryObserverOptions = {}, ): LiveQueryObserver<T, TKey> { return new LiveQueryObserverImpl<T, TKey>( collection ?? null, options.mode === `wholesale`, ) }