@tanstack/db
Version:
A reactive client store for building super fast apps on sync
289 lines (288 loc) • 14.4 kB
TypeScript
import { EventEmitter } from '../event-emitter.js';
import { BasicExpression, OrderBy } from '../query/ir.js';
import { IndexReader } from '../indexes/base-index.js';
import { ChangeMessage, CollectionDependent, LoadSubsetOptions, LoadSubsetRequestResult, SubscribeChangesOptions, Subscription, SubscriptionEvents, SubscriptionLoadSubsetErrorEvent, SubscriptionStatus, SubscriptionUnsubscribedEvent } from '../types.js';
import { CollectionImpl } from './index.js';
type RequestSnapshotOptions = {
refetch?: boolean;
where?: BasicExpression<boolean>;
signal?: AbortSignal;
optimizedOnly?: boolean;
trackLoadSubsetPromise?: boolean;
/** Optional orderBy to pass to loadSubset for backend optimization */
orderBy?: OrderBy;
/** Optional limit to pass to loadSubset for backend optimization */
limit?: number;
/** Read only the first `limit` local rows: the source holds every row and an index serves the order. */
boundedPrefix?: boolean;
/** Callback that receives the normalized loadSubset result for internal tracking */
onLoadSubsetResult?: SubsetResultObserver;
/** Called when the local snapshot must fall back from an index to a scan. */
onUnoptimized?: () => void;
};
type RequestLimitedSnapshotOptions = {
refetch?: boolean;
orderBy: OrderBy;
limit: number;
/** A single cursor value; composite cursor inputs are rejected. */
minValues?: Array<unknown>;
/** Row offset for offset-based pagination (passed to sync layer) */
offset?: number;
/** Whether to track the loadSubset promise on this subscription (default: true) */
trackLoadSubsetPromise?: boolean;
/** Callback that receives the normalized loadSubset result for internal tracking */
onLoadSubsetResult?: SubsetResultObserver;
};
export type ReleaseLoadSubset = (primaryFailure?: {
error: unknown;
}) => void;
type SubsetResultObserver = (result: LoadSubsetRequestResult, options: LoadSubsetOptions, release: ReleaseLoadSubset) => void;
type CollectionSubscriptionOptions = {
includeInitialState?: boolean;
/** Pre-compiled expression for filtering changes */
whereExpression?: BasicExpression<boolean>;
/** Callback to call when the subscription is unsubscribed */
onUnsubscribe?: (event: SubscriptionUnsubscribedEvent) => void;
/** Callback for subset-load failures scoped to this subscription. */
onLoadSubsetError?: (event: SubscriptionLoadSubsetErrorEvent) => void;
truncateReplayPublication?: TruncateReplayPublicationControl;
/**
* Whether this subscription defers provider work: it starts no idle source
* sync run and no acquisition attempt until its acquisition resumes. A
* live-query Collection defers its source subscriptions until it has a
* subscriber or a preload in its current sync run.
*/
deferAcquisition?: boolean;
/** Start sync, then return the ownership notification for a live resumption. */
onResumeAcquisition?: () => () => void;
/** The live query or Effect this subscription reads for. */
owner?: CollectionDependent;
};
type TruncateReplayPublicationControl = NonNullable<SubscribeChangesOptions[`truncateReplayPublication`]>;
export declare class CollectionSubscription extends EventEmitter<SubscriptionEvents> implements Subscription {
private collection;
private callback;
private options;
private loadedInitialState;
private skipFiltering;
private snapshotSent;
/**
* Track all loadSubset calls made by this subscription so we can unload them on cleanup.
* We store the exact LoadSubsetOptions we passed to loadSubset to ensure symmetric unload.
*/
private subsetDemands;
private readonly requestedSubsetWhere;
private sentKeys;
private publishedRows;
private stalePublishedRows;
private filteredCallback;
private orderByIndex;
private _status;
private statusRevision;
private _lastError;
private pendingLoadSubsetParticipants;
private truncateCleanup;
private collectionCleanup;
private collectionRestartCleanup;
private truncateReplayState;
private readonly loadSubsetPromiseErrors;
private truncateReplacementPending;
private unsubscribed;
private defersAcquisition;
/** @internal The live query or Effect this subscription reads for. */
get owner(): CollectionSubscriptionOptions[`owner`];
get status(): SubscriptionStatus;
get lastError(): unknown | undefined;
constructor(collection: CollectionImpl<any, any, any, any, any>, callback: (changes: Array<ChangeMessage<any, any>>) => void, options: CollectionSubscriptionOptions);
/** Whether this subscription still defers provider work. */
isDeferringAcquisition(): boolean;
/**
* Resume deferred acquisition: provider work may now start. This may start
* an idle source Collection's sync run, then starts acquisition attempts for
* the on-demand demand that stayed detached while acquisition was deferred.
*/
resumeDeferredAcquisition(): void;
/**
* @internal A reload retired the sync run. Its demand detaches from the
* retired run, and on-demand demand replays through the new run at once,
* as after a truncate, so a consumer of an aborted load follows that
* replay. The rows stay: the new run's writes apply on them.
*/
retireSyncRun(): void;
/** Whether a truncate acquires this subscription's demand again. */
private replaysDemand;
/**
* Detach logical demand from work owned by a discarded sync run. A reload
* keeps waiting callers for its replay, and the rows.
*/
private handleCollectionCleanup;
/** Acquire detached demand after startup or initial-error recovery. */
private restartDetachedDemands;
/**
* Handle collection truncate event by resetting state and re-requesting subsets.
* This is called when the sync layer receives a must-refetch and clears all data.
*
* To prevent a flash of missing content, we buffer all changes (deletes from truncate
* and inserts from refetch) until all loadSubset calls succeed, then emit them together.
* A failed replay keeps the last published snapshot private until a later
* authoritative replay succeeds.
*/
private handleTruncate;
/** Make tentative replay ownership visible before adapter code can reenter. */
private startTruncateReplayDemand;
private settleTruncateReplay;
/**
* Keep every acquisition begun during recovery inside its publication
* barrier until it settles or aborts. An abort ends the load: the replay
* no longer waits for it, and its later settlement decides nothing.
*/
private trackTruncateReplayParticipant;
/** Stop obsolete logical demand from pinning a replay barrier. */
private removeTruncateReplayParticipant;
/** Publish only after every overlapping replay attempt has settled. */
private checkTruncateReplayComplete;
/**
* Keep an incomplete replay private. The source no longer proves a complete
* state, so only a later successful truncate replay may reopen publication.
*/
private abandonTruncateReplay;
/** Publish the buffered replacement as one batch, or release the delegate. */
private flushTruncateReplay;
/** Replace the public snapshot with a replay's private rows in one batch. */
private publishReplayRows;
private restorePublishedSnapshotTracking;
/** Fold changes into the private replacement; false when they publish now. */
private bufferPrivately;
private createStateDiff;
private get isBufferingForTruncate();
private setReadyIfIdle;
private isSyncRunGenerationCurrent;
private retireStaleTruncateReplay;
/**
* A lazy demand that failed outside this subscription's replay, while a
* failed replay of another of the graph's subscriptions to this collection
* holds publication, fails this subscription's replacement too. It keeps
* the last publication until every demand load that joins this replay
* loads, a later replay succeeds, or this subscription's last demand
* retires. The failed demand's retry joins it, so it cannot publish
* without that demand's rows.
*/
failReplacement(error: Error): void;
/**
* A graph's subscriptions to one collection share a barrier. While another
* one's replay still loads, this replay stays open, so demand this one
* starts meanwhile joins it; it finishes once those replays settle.
*/
private awaitsSiblingReplays;
/** Whether this subscription's replay still waits on its own work. */
get hasTruncateReplayWork(): boolean;
/** Drop the replay without publishing; an unfinished wait rejects as aborted. */
private discardTruncateReplay;
private resetSnapshotTracking;
/** One replay state; only direct subscribers buffer a private replacement. */
private createTruncateReplayState;
/** Start one attempt's demands, then release the setup hold on publication. */
private startTruncateReplayAttempt;
get hasPendingTruncateReplacement(): boolean;
/** Whether the source accepted sync rows that are not yet visible. */
get hasHeldSyncTransaction(): boolean;
get pendingTruncateReplacement(): Promise<void> | undefined;
get hasFailedTruncateReplacement(): boolean;
setOrderByIndex(index: IndexReader<any>): void;
/**
* Set subscription status and emit events if changed
*/
private setStatus;
/** Observe an asynchronous subset load and restore status on settlement. */
private observeLoadSubsetResult;
/** Give every logical observer of one transport rejection the same Error. */
private normalizeLoadSubsetPromiseError;
private stopDemandStatusParticipants;
/** Create the record for a fresh, abortable acquisition attempt. */
private createSubsetAcquisitionRecord;
/**
* Retire an acquisition before user code; failed cleanup is not retryable.
* An `unloadSubset` that throws is an adapter bug: it is reported uncaught
* and fails no load, replay, or caller.
*/
private releaseAcquisition;
/** Start and retain the first acquisition for one logical subset demand. */
private startSubsetDemand;
/** Re-check ownership after adapter and event callbacks that may reenter. */
private isDemandActive;
private recordLoadSubsetError;
emitEvents(changes: Array<ChangeMessage<any, any>>): boolean;
/** Keep direct snapshot reads private while an authoritative replay is open. */
private publishSnapshot;
/**
* Sends the snapshot to the callback.
* Returns a boolean indicating if it succeeded.
* It can only fail if there is no index to fulfill the request
* and the optimizedOnly option is set to true,
* or, the entire state was already loaded or the request was cancelled.
*/
requestSnapshot(opts?: RequestSnapshotOptions): boolean;
/**
* Release exact subset requests while keeping the subscription alive. The
* releases are one demand change (law 9): an open replay is judged once,
* after all of them.
*/
releaseSnapshot(...wheres: Array<BasicExpression<boolean>>): void;
/**
* @internal The snapshot for `where` replaces those for `replaced` for
* their owner, which releases them once it applies. Until then they keep
* their older work in the replay.
*/
replaceSnapshots(where: BasicExpression<boolean>, replaced: Array<BasicExpression<boolean>>): void;
private requestedWhere;
private releaseDemand;
/** Retire held demands as one step, then judge an open replay once. */
private releaseDemands;
/**
* A replay with no remaining logical demand cannot establish more rows. One
* that has not failed publishes the rows it holds, so the subscriber does
* not keep values the source has replaced or deleted. A failed replay keeps
* the last public snapshot until an authoritative replay succeeds.
*/
private retireEmptyReplay;
/** Read source-derived ordered rows without starting demand. */
readOrderedSnapshot(options: LoadSubsetOptions): Array<ChangeMessage<Record<string, unknown>, string | number>>;
/**
* Sends a snapshot that fulfills the `where` clause and all rows are bigger or equal to the cursor.
* Requires a range index to be set with `setOrderByIndex` prior to calling this method.
* It uses that range index to load the items in the order of the index.
*
* Cursor requests use one minValue for the leading order term. For a
* multi-column order, a separate equality request loads the complete
* leading-value tie before local ordering applies the trailing terms.
*
* Note 1: it may load more rows than the provided LIMIT because it loads all values equal to the first cursor value + limit values greater.
* This is needed to ensure that it does not accidentally skip duplicate values when the limit falls in the middle of some duplicated values.
* Note 2: it does not send keys that have already been sent before.
*/
requestLimitedSnapshot({ refetch, orderBy, limit, minValues, offset, trackLoadSubsetPromise: shouldTrackLoadSubsetPromise, onLoadSubsetResult, }: RequestLimitedSnapshotOptions): void;
/**
* Filters and flips changes for keys that have not been sent yet.
* Deletes are filtered out for keys that have not been sent yet.
* Updates are flipped into inserts for keys that have not been sent yet.
* Duplicate inserts are filtered out to prevent D2 multiplicity > 1.
*/
private filterAndFlipChanges;
/**
* After a failed replay, the source collection is empty but subscribers still
* hold the last good publication. Reconcile the first later source delta for
* each retained key against that publication instead of treating it as a
* duplicate insert.
*/
private reconcileStalePublishedChanges;
private trackPublishedRows;
private trackSentKeys;
/**
* Mark that the subscription should not filter any changes.
* This is used when includeInitialState is explicitly set to false,
* meaning the caller doesn't want initial state but does want ALL future changes.
*/
markAllStateAsSeen(): void;
unsubscribe(): void;
}
export {};