@tanstack/db
Version:
A reactive client store for building super fast apps on sync
1,309 lines (1,308 loc) • 44.9 kB
JavaScript
import { ensureIndexForExpression } from "../indexes/auto-index.js";
import { and, eq } from "../query/builder/functions.js";
import { Value, PropRef, getPropRefPropertyPath } from "../query/ir.js";
import { EventEmitter } from "../event-emitter.js";
import { compileExpression } from "../query/compiler/evaluators.js";
import { buildCursor, buildCursorCurrent } from "../utils/cursor.js";
import { deepEquals } from "../utils.js";
import { normalizeError } from "../utils/error.js";
import { reportUncaught, runAllCallbacks } from "../utils/callbacks.js";
import { createDeferred } from "../deferred.js";
import { LoadSubsetOperationAbortedError } from "../errors.js";
import { isFailedStatus } from "../live-query-primitives.js";
import { devBuild, codedMessage } from "../error-message.js";
import { createFilteredCallback, createFilterFunctionFromExpression } from "./change-events.js";
function createReplayCompletion() {
const completion = createDeferred();
void completion.promise.catch(() => {
});
return completion;
}
function cancelAcquisition(acquisition) {
acquisition.abortController?.abort();
acquisition.removeRequestAbortListener?.();
}
function onAbortBeforeSettled(signal, result, onAbort) {
signal?.addEventListener(`abort`, onAbort, { once: true });
const settled = () => signal?.removeEventListener(`abort`, onAbort);
void result.then(settled, settled);
}
class CollectionSubscription extends EventEmitter {
constructor(collection, callback, options) {
super();
this.collection = collection;
this.callback = callback;
this.options = options;
this.loadedInitialState = false;
this.ej = false;
this.snapshotSent = false;
this.C = [];
this.d8 = /* @__PURE__ */ new WeakMap();
this.aC = /* @__PURE__ */ new Set();
this.ac = /* @__PURE__ */ new Map();
this.ad = /* @__PURE__ */ new Map();
this.fk = `ready`;
this.aV = 0;
this.aq = /* @__PURE__ */ new Set();
this.dx = /* @__PURE__ */ new WeakMap();
this.aR = false;
this.unsubscribed = false;
this.c_ = options.deferAcquisition === true;
if (options.onUnsubscribe) {
this.on(`unsubscribed`, options.onUnsubscribe);
}
if (options.onLoadSubsetError) {
this.on(`loadSubset:error`, options.onLoadSubsetError);
}
if (options.whereExpression) {
ensureIndexForExpression(options.whereExpression, this.collection);
}
const callbackWithSentKeysTracking = (changes) => {
this.fj(changes);
this.e9(changes);
callback(changes);
};
this.callback = callbackWithSentKeysTracking;
this.dN = options.whereExpression ? createFilteredCallback(this.callback, options) : (changes) => {
this.callback(changes);
return true;
};
this.ec = this.collection.on(
`truncate`,
() => this.fC()
);
this.dB = this.collection._onCleanupStart(() => {
this.eE();
});
this.cb = this.collection.on(
`status:change`,
({ status }) => {
if (status !== `loading` && status !== `ready`) return;
const syncRunGeneration = this.collection._sync.getSyncRunGeneration();
const replayState = this.o;
if (this.C.some(
(demand) => demand.acquisitionState === `detached`
)) {
this.setStatus(`loadingSubset`);
}
queueMicrotask(() => {
if (this.o === replayState) {
this.eM(syncRunGeneration);
}
});
}
);
}
/** @internal The live query or Effect this subscription reads for. */
get owner() {
return this.options.owner;
}
get status() {
return this.fk;
}
get lastError() {
return this.fu;
}
/** Whether this subscription still defers provider work. */
isDeferringAcquisition() {
return this.c_;
}
/**
* 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() {
if (!this.c_ || this.unsubscribed) return;
const notifyResumed = this.options.onResumeAcquisition?.();
if (this.unsubscribed) return;
this.c_ = false;
notifyResumed?.();
if (this.collection.status !== `idle`) {
this.eM(this.collection._sync.getSyncRunGeneration());
}
}
/**
* @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() {
const replays = this.g3() && this.C.length > 0;
this.eE(true, replays);
if (replays) this.fC(true);
}
/** Whether a truncate acquires this subscription's demand again. */
g3() {
return this.collection.config.syncMode === `on-demand` && !this.c_;
}
/**
* Detach logical demand from work owned by a discarded sync run. A reload
* keeps waiting callers for its replay, and the rows.
*/
eE(reload = false, holdReplacement = false) {
this.bP();
this.aR = holdReplacement;
if (!reload) this.ad = new Map(this.ac);
this.aq.clear();
for (const demand of [...this.C]) {
if (!reload || demand.acquisitionState === `starting`) {
demand.initialResult?.reject(new LoadSubsetOperationAbortedError());
}
cancelAcquisition(demand.acquisition);
if (demand.acquisitionState === `starting`) {
const index = this.C.indexOf(demand);
if (index !== -1) this.C.splice(index, 1);
} else {
demand.acquisitionState = `detached`;
demand.acquisition = {
options: demand.requestOptions,
syncRunGeneration: demand.acquisition.syncRunGeneration
};
}
}
this.b9();
}
/** Acquire detached demand after startup or initial-error recovery. */
eM(syncRunGeneration) {
if (this.unsubscribed || // Deferred demand stays detached until acquisition resumes, even when
// another consumer starts the source.
this.c_ || !this.Z(syncRunGeneration)) {
return;
}
if (isFailedStatus(this.collection.status) || this.collection.config.syncMode === `on-demand` && this.collection._sync.syncLoadSubsetFn === null) {
this.b9();
return;
}
const demands = this.C.filter(
(demand) => demand.acquisitionState === `detached` && !demand.requestOptions.signal?.aborted
);
if (demands.length === 0) {
this.b9();
return;
}
if (this.collection.config.syncMode !== `on-demand` && this.collection.status !== `ready`) {
return;
}
const open = this.o;
const joins = open?.syncRunGeneration === syncRunGeneration;
const replayState = joins ? open : this.c4(syncRunGeneration, () => {
const currentRows = this.collection.currentStateAsChanges({
optimizedOnly: false
});
return new Map(
// The API returns void for unavailable snapshots, not just undefined.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(currentRows ?? []).filter((change) => change.type !== `delete`).map((change) => [change.key, change.value])
);
});
if (joins) {
open.pendingSetups++;
if (open.failed) {
if (!open.completion.isPending())
open.completion = createReplayCompletion();
open.currentAttempt = { pendingCount: 0, setupComplete: false };
}
}
const attempt = replayState.currentAttempt;
this.o = replayState;
this.setStatus(`loadingSubset`);
if (this.o !== replayState) return;
this.cX(replayState, attempt, demands);
}
/**
* 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.
*/
fC(retired = false) {
const demandsToReload = this.g3() ? [...this.C] : [];
if (demandsToReload.length === 0 && this.ad.size === 0) {
this.dU();
return;
}
let replayState = this.o;
if (replayState) {
if (!replayState.completion.isPending()) {
replayState.completion = createReplayCompletion();
}
replayState.pendingSetups++;
replayState.failures.clear();
replayState.unloaded.clear();
replayState.failed = false;
replayState.currentAttempt = { pendingCount: 0, setupComplete: false };
} else {
replayState = this.c4(
this.collection._sync.getSyncRunGeneration(),
() => new Map(this.ac)
);
this.o = replayState;
}
const attempt = replayState.currentAttempt;
this.setStatus(`loadingSubset`);
if (this.o !== replayState) return;
if (this.options.truncateReplayPublication) {
this.aR = true;
this.options.truncateReplayPublication.start(retired);
}
for (const demand of demandsToReload) {
demand.acquisition.abortController?.abort();
}
this.dU();
queueMicrotask(() => {
if (this.o !== replayState) return;
if (!this.Z(replayState.syncRunGeneration)) {
this.c8(replayState);
return;
}
this.cX(
replayState,
attempt,
replayState.currentAttempt === attempt ? demandsToReload : []
);
});
}
/** Make tentative replay ownership visible before adapter code can reenter. */
eu(replayState, attempt, demand) {
const isCurrentAttempt = () => this.o === replayState && replayState.currentAttempt === attempt;
const isCurrent = () => isCurrentAttempt() && this.Z(replayState.syncRunGeneration) && this.a4(demand);
const waiting = demand.initialResult;
const parked = demand.parkedParticipant;
demand.parkedParticipant = void 0;
if (parked && replayState.pending.delete(parked))
parked.attempt.pendingCount--;
if (waiting) {
void replayState.completion.promise.then(waiting.resolve, (error) => {
if (!(error instanceof LoadSubsetOperationAbortedError))
waiting.reject(error);
});
}
const previous = demand.acquisition;
const hadPreviousAcquisition = demand.acquisitionState === `active`;
demand.acquisitionState = `detached`;
if (hadPreviousAcquisition) this.releaseAcquisition(previous);
if (!isCurrent() || demand.requestOptions.signal?.aborted) return;
const next = this.cF(demand);
next.attempt = attempt;
demand.acquisition = next;
demand.acquisitionState = `starting`;
const result = this.collection._sync.loadSubset(next.options);
if (!isCurrent()) {
if (demand.acquisition === next) demand.acquisitionState = `detached`;
this.releaseAcquisition(next);
return;
}
demand.acquisitionState = `active`;
this.co(
result,
demand,
next.options,
true,
() => isCurrent() && !next.options.signal?.aborted
);
this.cB(
replayState,
attempt,
demand,
result,
next.options.signal
);
}
ea(replayState, pending) {
try {
if (this.o !== replayState) return;
if (!this.Z(replayState.syncRunGeneration)) {
this.c8(replayState);
return;
}
if (replayState.pending.delete(pending)) pending.attempt.pendingCount--;
this.bJ(replayState);
} catch (error) {
queueMicrotask(() => {
throw error;
});
}
}
/**
* 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.
*/
cB(replayState, attempt, demand, result, signal) {
if (this.o !== replayState || replayState.currentAttempt !== attempt && attempt.setupComplete && attempt.pendingCount === 0 || !(result instanceof Promise)) {
return void 0;
}
const pending = { demand, attempt };
attempt.pendingCount++;
replayState.pending.add(pending);
const settle = () => this.ea(replayState, pending);
onAbortBeforeSettled(signal, result, () => {
if (replayState.currentAttempt === attempt)
replayState.unloaded.add(demand);
queueMicrotask(settle);
});
void result.then(settle, (error) => {
if (!signal?.aborted && this.o === replayState && replayState.currentAttempt === attempt && this.Z(replayState.syncRunGeneration) && this.C.includes(demand)) {
const normalized = this.cl(result, error);
replayState.failures.set(demand, normalized);
replayState.failed = true;
}
settle();
});
return pending;
}
/** Stop obsolete logical demand from pinning a replay barrier. */
dX(demand) {
const replayState = this.o;
if (!replayState) return;
replayState.failures.delete(demand);
for (const pending of replayState.pending) {
if (pending.demand === demand) {
replayState.pending.delete(pending);
pending.attempt.pendingCount--;
}
}
}
/** Publish only after every overlapping replay attempt has settled. */
bJ(replayState) {
if (this.o !== replayState) return;
if (replayState.pendingSetups > 0 || replayState.pending.size > 0) return;
const activeFailures = [...replayState.failures].filter(
([demand]) => this.C.includes(demand) && !this.C.some(
(replacement) => replacement.replaces?.includes(demand) && !replayState.failures.has(replacement) && !replayState.unloaded.has(replacement)
)
).map(([, error]) => error);
try {
if (activeFailures.length > 0) {
this.eP(replayState, activeFailures);
} else {
if (this.gN(
replayState,
() => this.bJ(replayState)
))
return;
this.fb(replayState);
}
} finally {
this.b9();
}
}
/**
* Keep an incomplete replay private. The source no longer proves a complete
* state, so only a later successful truncate replay may reopen publication.
*/
eP(replayState, failures) {
if (this.o !== replayState) return;
const first = replayState.completion.isPending();
replayState.completion.reject(failures[0]);
if (first) this.options.truncateReplayPublication?.fail?.(failures);
if (!replayState.privateRows) return;
const publicationState = replayState.publicationState;
this.loadedInitialState = publicationState.loadedInitialState;
this.snapshotSent = publicationState.snapshotSent;
}
/** Publish the buffered replacement as one batch, or release the delegate. */
fb(replayState) {
if (this.o !== replayState) return;
this.o = void 0;
this.aR = false;
try {
this.gy(replayState.privateRows);
} finally {
replayState.completion.resolve();
this.options.truncateReplayPublication?.succeed();
}
}
/** Replace the public snapshot with a replay's private rows in one batch. */
gy(privateRows) {
for (const key of this.ad.keys()) privateRows?.delete(key);
this.ad.clear();
try {
if (privateRows) {
const replacement = this.fx(
this.ac,
privateRows
);
if (replacement.length > 0) this.dN(replacement);
}
} finally {
this.cg();
}
}
cg() {
this.aC = new Set(this.ac.keys());
}
/** Fold changes into the private replacement; false when they publish now. */
eG(changes) {
const privateRows = this.o?.privateRows;
if (!privateRows) return false;
for (const change of changes) {
if (change.type === `delete`) privateRows.delete(change.key);
else privateRows.set(change.key, change.value);
}
return true;
}
fx(baseline, finalRows) {
const replacement = [];
for (const [key, previousValue] of baseline) {
const value = finalRows.get(key);
if (value === void 0) {
replacement.push({
type: `delete`,
key,
value: previousValue
});
} else if (!deepEquals(value, previousValue)) {
replacement.push({
type: `update`,
key,
value,
previousValue
});
}
}
for (const [key, value] of finalRows) {
if (!baseline.has(key)) replacement.push({ type: `insert`, key, value });
}
return replacement;
}
get fS() {
return this.o !== void 0;
}
b9() {
if (this.aq.size === 0 && !this.hasTruncateReplayWork) {
this.setStatus(`ready`);
}
}
Z(syncRunGeneration) {
return syncRunGeneration === this.collection._sync.getSyncRunGeneration();
}
c8(replayState) {
if (this.o !== replayState) return;
this.bP();
this.ad.clear();
}
/**
* 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) {
if (this.o) return;
const replayState = this.c4(
this.collection._sync.getSyncRunGeneration(),
() => /* @__PURE__ */ new Map()
);
replayState.pendingSetups = 0;
replayState.failed = true;
replayState.completion.reject(error);
this.o = replayState;
this.aR = true;
}
/**
* 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.
*/
gN(replayState, finish) {
const siblings = this.options.truncateReplayPublication?.siblingReplays();
void siblings?.then(() => {
if (this.o === replayState) finish();
});
return siblings !== void 0;
}
/** Whether this subscription's replay still waits on its own work. */
get hasTruncateReplayWork() {
const replayState = this.o;
return replayState !== void 0 && (replayState.pendingSetups > 0 || replayState.pending.size > 0);
}
/** Drop the replay without publishing; an unfinished wait rejects as aborted. */
bP() {
const replayState = this.o;
if (replayState?.completion.isPending()) {
replayState.completion.reject(new LoadSubsetOperationAbortedError());
}
this.o = void 0;
this.aR = false;
}
dU() {
this.snapshotSent = false;
this.loadedInitialState = false;
}
/** One replay state; only direct subscribers buffer a private replacement. */
c4(syncRunGeneration, privateRows) {
return {
syncRunGeneration,
publicationState: {
loadedInitialState: this.loadedInitialState,
snapshotSent: this.snapshotSent
},
privateRows: this.options.truncateReplayPublication ? void 0 : privateRows(),
pending: /* @__PURE__ */ new Set(),
// Setup itself holds publication: adapter/status callbacks may reenter
// before a request returns its promise and joins the pending set.
pendingSetups: 1,
currentAttempt: { pendingCount: 0, setupComplete: false },
failures: /* @__PURE__ */ new Map(),
unloaded: /* @__PURE__ */ new Set(),
failed: false,
completion: createReplayCompletion()
};
}
/** Start one attempt's demands, then release the setup hold on publication. */
cX(replayState, attempt, demands) {
for (const demand of demands) {
if (!this.C.includes(demand) || demand.acquisition.attempt === attempt)
continue;
this.eu(replayState, attempt, demand);
if (this.o !== replayState || replayState.currentAttempt !== attempt) {
break;
}
}
attempt.setupComplete = true;
replayState.pendingSetups--;
this.bJ(replayState);
}
get hasPendingTruncateReplacement() {
return this.aR;
}
/** Whether the source accepted sync rows that are not yet visible. */
get hasHeldSyncTransaction() {
return this.collection._hasHeldSyncTransaction();
}
get pendingTruncateReplacement() {
const completion = this.o?.completion;
return completion?.isPending() ? completion.promise : void 0;
}
get hasFailedTruncateReplacement() {
const completion = this.o?.completion;
return this.aR && completion !== void 0 && !completion.isPending();
}
setOrderByIndex(index) {
this.orderByIndex = index;
}
/**
* Set subscription status and emit events if changed
*/
setStatus(newStatus) {
if (this.unsubscribed) return;
if (this.fk === newStatus) {
return;
}
const previousStatus = this.fk;
this.fk = newStatus;
const revision = ++this.aV;
this.emitInnerWhile(
`status:change`,
{
type: `status:change`,
subscription: this,
previousStatus,
status: newStatus
},
() => this.aV === revision
);
if (this.aV !== revision) return;
const eventKey = `status:${newStatus}`;
this.emitInnerWhile(
eventKey,
{
type: eventKey,
subscription: this,
previousStatus,
status: newStatus
},
() => this.aV === revision
);
}
/** Observe an asynchronous subset load and restore status on settlement. */
co(syncResult, demand, options, trackStatus, shouldReportError = () => true) {
if (!(syncResult instanceof Promise)) return;
const syncRunGeneration = this.collection._sync.getSyncRunGeneration();
const participant = { demand, promise: syncResult };
if (trackStatus) {
this.aq.add(participant);
this.setStatus(`loadingSubset`);
}
const finish = () => {
if (trackStatus) {
this.aq.delete(participant);
if (this.Z(syncRunGeneration)) {
this.b9();
}
}
};
if (trackStatus)
onAbortBeforeSettled(
options.signal,
syncResult,
() => queueMicrotask(finish)
);
void syncResult.then(finish, (error) => {
if (this.Z(syncRunGeneration) && shouldReportError()) {
this.bR(
options,
this.cl(syncResult, error)
);
}
finish();
});
}
/** Give every logical observer of one transport rejection the same Error. */
cl(promise, error) {
const existing = this.dx.get(promise);
if (existing) return existing;
const normalized = normalizeError(error);
this.dx.set(promise, normalized);
return normalized;
}
cO(demand) {
for (const participant of this.aq) {
if (participant.demand === demand) {
this.aq.delete(participant);
}
}
this.b9();
}
/** Create the record for a fresh, abortable acquisition attempt. */
cF(demand) {
if (this.collection.config.syncMode !== `on-demand`) {
return {
options: demand.requestOptions,
syncRunGeneration: this.collection._sync.getSyncRunGeneration()
};
}
const abortController = new AbortController();
const requestSignal = demand.requestOptions.signal;
let removeRequestAbortListener;
if (requestSignal?.aborted) {
abortController.abort(requestSignal.reason);
} else if (requestSignal) {
const abort = () => abortController.abort(requestSignal.reason);
requestSignal.addEventListener(`abort`, abort, { once: true });
removeRequestAbortListener = () => requestSignal.removeEventListener(`abort`, abort);
}
return {
options: {
...demand.requestOptions,
signal: abortController.signal
},
syncRunGeneration: this.collection._sync.getSyncRunGeneration(),
abortController,
removeRequestAbortListener
};
}
/**
* 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.
*/
releaseAcquisition(acquisition) {
if (acquisition.releaseAttempted) return;
acquisition.releaseAttempted = true;
try {
acquisition.abortController?.abort();
if (this.Z(acquisition.syncRunGeneration)) {
this.collection._sync.unloadSubset(acquisition.options);
}
} catch (error) {
reportUncaught(error);
} finally {
acquisition.removeRequestAbortListener?.();
}
}
/** Start and retain the first acquisition for one logical subset demand. */
dD(requestOptions) {
const demand = {
requestOptions,
acquisition: {
options: requestOptions,
syncRunGeneration: this.collection._sync.getSyncRunGeneration()
},
acquisitionState: `starting`
};
const replayState = this.o;
const replayAttempt = replayState?.currentAttempt;
if (this.collection.status === `cleaned-up` || // A deferring subscription starts no acquisition attempt until it resumes.
this.c_ && this.collection.config.syncMode === `on-demand` || // Ready/error callbacks can run before sync returns its loader. Idle
// deferred starts still acquire through the sync manager's queue.
this.collection.config.syncMode === `on-demand` && (isFailedStatus(this.collection.status) || this.collection.status !== `idle` && this.collection._sync.syncLoadSubsetFn === null)) {
demand.acquisitionState = `detached`;
this.C.push(demand);
const initialResult = createDeferred();
demand.initialResult = initialResult;
const abort = () => initialResult.reject(new LoadSubsetOperationAbortedError());
requestOptions.signal?.addEventListener(`abort`, abort, { once: true });
const finish = () => {
requestOptions.signal?.removeEventListener(`abort`, abort);
demand.initialResult = void 0;
};
void initialResult.promise.then(finish, finish);
if (replayState && replayAttempt)
demand.parkedParticipant = this.cB(
replayState,
replayAttempt,
demand,
initialResult.promise,
requestOptions.signal
);
return { demand, result: initialResult.promise, started: false };
}
const acquisition = this.cF(demand);
demand.acquisition = acquisition;
const syncRunGeneration = this.collection._sync.getSyncRunGeneration();
this.C.push(demand);
const result = this.collection._sync.loadSubset(acquisition.options);
if (!this.Z(syncRunGeneration)) {
const demandIndex = this.C.indexOf(demand);
if (demandIndex !== -1) this.C.splice(demandIndex, 1);
cancelAcquisition(acquisition);
return { demand, result, started: true };
}
demand.acquisitionState = `active`;
if (!this.C.includes(demand)) {
this.releaseAcquisition(acquisition);
return { demand, result, started: true };
}
if (replayState && replayAttempt) {
this.cB(
replayState,
replayAttempt,
demand,
result,
acquisition.options.signal
);
}
return { demand, result, started: true };
}
/** Re-check ownership after adapter and event callbacks that may reenter. */
a4(demand) {
return !this.unsubscribed && this.C.includes(demand);
}
bR(options, error, reportAborted = false) {
const normalized = normalizeError(error);
if (options.signal?.aborted && !reportAborted) return normalized;
this.fu = normalized;
this.emitInner(`loadSubset:error`, {
type: `loadSubset:error`,
subscription: this,
options,
error: normalized
});
return normalized;
}
emitEvents(changes) {
if (this.unsubscribed) return false;
const newChanges = this.d0(changes);
if (changes.length > 0 && newChanges.length === 0) return false;
if (this.eG(newChanges)) return false;
return this.dN(newChanges);
}
/** Keep direct snapshot reads private while an authoritative replay is open. */
eH(changes) {
if (!this.eG(changes)) this.callback(changes);
}
/**
* 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) {
if (this.unsubscribed || opts?.signal?.aborted) return false;
if (this.loadedInitialState) {
return false;
}
const stateOpts = {
where: this.options.whereExpression,
optimizedOnly: opts?.optimizedOnly ?? false
};
if (opts) {
if (`where` in opts) {
const snapshotWhereExp = opts.where;
if (stateOpts.where) {
const subWhereExp = stateOpts.where;
const combinedWhereExp = and(subWhereExp, snapshotWhereExp);
stateOpts.where = combinedWhereExp;
} else {
stateOpts.where = snapshotWhereExp;
}
}
if (opts.boundedPrefix) {
stateOpts.orderBy = opts.orderBy;
stateOpts.limit = opts.limit;
}
} else {
this.loadedInitialState = true;
}
const loadOptions = {
...opts?.refetch ? { refetch: true } : {},
where: stateOpts.where,
signal: opts?.signal,
subscription: this,
// Include orderBy and limit if provided so sync layer can optimize the query
orderBy: opts?.orderBy,
// A local optimistic row can hide one source row from a bounded prefix.
limit: opts?.limit === void 0 ? void 0 : opts.limit + this.collection._optimisticOverlaySize()
};
if (opts?.where) this.d8.set(loadOptions, opts.where);
const {
demand,
result: syncResult,
started
} = this.dD(loadOptions);
if (!this.a4(demand)) return false;
opts?.onLoadSubsetResult?.(
syncResult,
demand.acquisition.options,
(primaryFailure) => this.e6(demand, primaryFailure)
);
if (!this.a4(demand)) return false;
if (started) {
this.co(
syncResult,
demand,
demand.acquisition.options,
opts?.trackLoadSubsetPromise ?? true
);
}
if (!this.a4(demand)) return false;
let snapshot;
if (opts?.onUnoptimized) {
snapshot = this.collection.currentStateAsChanges({
...stateOpts,
optimizedOnly: true
});
if (snapshot === void 0) {
opts.onUnoptimized();
if (!this.a4(demand)) return false;
snapshot = this.collection.currentStateAsChanges({
...stateOpts,
optimizedOnly: false
});
}
} else {
snapshot = this.collection.currentStateAsChanges(stateOpts);
}
if (!this.a4(demand)) return false;
if (snapshot === void 0) {
return false;
}
const knownRows = this.o?.privateRows ?? this.ac;
const filteredSnapshot = snapshot.filter(
(change) => !this.fS && this.ad.has(change.key) || !this.aC.has(change.key) && !knownRows.has(change.key)
);
for (const change of filteredSnapshot) {
this.aC.add(change.key);
}
this.snapshotSent = true;
this.eH(
this.fS ? filteredSnapshot : this.cy(filteredSnapshot)
);
return true;
}
/**
* 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) {
const demands = [];
for (const where of wheres) {
const demand = this.C.find(
(candidate) => !demands.includes(candidate) && this.d7(candidate, where)
);
if (demand) demands.push(demand);
}
if (demands.length > 0) this.g$(demands);
}
/**
* @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, replaced) {
const replacement = this.C.find(
(demand) => this.d7(demand, where)
);
if (!replacement) return;
replacement.replaces = this.C.filter(
(demand) => replaced.some((other) => this.d7(demand, other))
);
}
d7(demand, where) {
return demand.requestOptions.where === where || this.d8.get(demand.requestOptions) === where;
}
e6(demand, primaryFailure) {
try {
if (primaryFailure)
this.bR(
demand.acquisition.options,
primaryFailure.error,
true
);
} finally {
if (this.C.includes(demand)) this.g$([demand]);
}
}
/** Retire held demands as one step, then judge an open replay once. */
g$(demands) {
const replayState = this.o;
const releaseCallbacks = [];
for (const demand of demands) {
const acquisition = demand.acquisition;
this.C.splice(this.C.indexOf(demand), 1);
demand.initialResult?.reject(new LoadSubsetOperationAbortedError());
releaseCallbacks.push(() => this.dX(demand));
if (demand.acquisitionState === `active`)
releaseCallbacks.push(() => this.releaseAcquisition(acquisition));
}
runAllCallbacks([
...releaseCallbacks,
() => this.fm(),
() => {
if (replayState) this.bJ(replayState);
},
// Ready follows replacement publication, never the delete half of it.
...demands.map(
(demand) => () => this.cO(demand)
)
]);
}
/**
* 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.
*/
fm() {
const replayState = this.o;
if (this.C.length !== 0 || !replayState) return;
if (replayState.privateRows && !replayState.failed) {
this.fb(replayState);
return;
}
if (this.gN(replayState, () => this.fm()))
return;
this.bP();
this.ad = new Map(this.ac);
this.cg();
this.options.truncateReplayPublication?.succeed();
}
/** Read source-derived ordered rows without starting demand. */
readOrderedSnapshot(options) {
const predicates = [
this.options.whereExpression,
options.where,
options.cursor?.whereFrom
].filter((where) => where !== void 0);
const snapshot = this.collection.currentStateAsChanges({
orderBy: options.orderBy,
limit: options.limit,
where: predicates.length > 0 ? predicates.reduce((left, right) => and(left, right)) : void 0
});
return Array.isArray(snapshot) ? snapshot.filter(
({ key }) => !this.collection._hasOptimisticOverlay(key)
) : [];
}
/**
* 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 = true,
onLoadSubsetResult
}) {
if (this.unsubscribed) return;
if (!limit)
throw new Error(
devBuild() && process.env.NODE_ENV !== `production` ? `limit is required` : codedMessage(96)
);
if (!this.orderByIndex) {
throw new Error(
devBuild() && process.env.NODE_ENV !== `production` ? `Ordered snapshot was requested but no index was found. You have to call setOrderByIndex before requesting an ordered snapshot.` : codedMessage(97)
);
}
const whereFromCursor = minValues ? buildCursor(orderBy, minValues) : void 0;
const hasMinValue = minValues !== void 0 && minValues.length > 0;
const minValue = minValues?.[0];
const minValueForIndex = minValue;
const index = this.orderByIndex;
const where = this.options.whereExpression;
const whereFilterFn = where ? createFilterFunctionFromExpression(where) : void 0;
const filterFn = (key) => {
if (key !== void 0 && this.aC.has(key)) {
return false;
}
const value = this.collection.get(key);
if (value === void 0) {
return false;
}
return whereFilterFn?.(value) ?? true;
};
let biggestObservedValue = minValueForIndex;
const changes = [];
let keys = [];
if (hasMinValue) {
const { expression } = orderBy[0];
const allRowsWithMinValue = this.collection.currentStateAsChanges({
where: eq(expression, new Value(minValueForIndex))
});
if (allRowsWithMinValue) {
const keysWithMinValue = allRowsWithMinValue.map((change) => change.key).filter(filterFn);
keys.push(...keysWithMinValue);
const keysGreaterThanMin = index.take(
limit - keys.length,
minValueForIndex,
filterFn
);
keys.push(...keysGreaterThanMin);
} else {
keys = index.take(limit, minValueForIndex, filterFn);
}
} else {
keys = index.takeFromStart(limit, filterFn);
}
const valuesNeeded = () => Math.max(limit - changes.length, 0);
const collectionExhausted = () => keys.length === 0;
const orderByExpression = orderBy[0].expression;
const valueExtractor = orderByExpression.type === `ref` ? compileExpression(
new PropRef(getPropRefPropertyPath(orderByExpression)),
true
) : null;
while (valuesNeeded() > 0 && !collectionExhausted()) {
for (const key of keys) {
const value = this.collection.get(key);
changes.push({
type: `insert`,
key,
value
});
biggestObservedValue = valueExtractor ? valueExtractor(value) : value;
}
keys = index.take(valuesNeeded(), biggestObservedValue, filterFn);
}
for (const change of changes) {
this.aC.add(change.key);
}
this.eH(changes);
if (this.unsubscribed) return;
let cursorExpressions;
if (whereFromCursor && minValues) {
const whereCurrentCursor = buildCursorCurrent(orderBy, minValues);
if (whereCurrentCursor) {
cursorExpressions = {
whereFrom: whereFromCursor,
whereCurrent: whereCurrentCursor
};
}
}
const overlaySize = this.collection._optimisticOverlaySize();
const providerLimit = offset !== void 0 ? limit + overlaySize : hasMinValue ? void 0 : limit + this.aC.size + overlaySize;
const loadOptions = {
...refetch ? { refetch: true } : {},
where,
// Main filter only, no cursor
limit: providerLimit,
orderBy,
cursor: cursorExpressions,
// Cursor expressions passed separately
offset: offset ?? 0,
subscription: this
};
const {
demand,
result: syncResult,
started
} = this.dD(loadOptions);
if (!this.a4(demand)) return;
onLoadSubsetResult?.(
syncResult,
demand.acquisition.options,
(primaryFailure) => this.e6(demand, primaryFailure)
);
if (!this.a4(demand)) return;
if (started) {
this.co(
syncResult,
demand,
demand.acquisition.options,
shouldTrackLoadSubsetPromise
);
}
if (!this.a4(demand)) return;
}
// TODO: also add similar test but that checks that it can also load it from the collection's loadSubset function
// and that that also works properly (i.e. does not skip duplicate values)
/**
* 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.
*/
d0(changes) {
changes = this.cy(changes);
if (this.loadedInitialState || this.ej) {
return changes;
}
const skipDeleteFilter = this.fS;
const insertedInBatch = /* @__PURE__ */ new Set();
const newChanges = [];
for (const change of changes) {
let newChange = change;
const keyInSentKeys = this.aC.has(change.key) || insertedInBatch.has(change.key);
if (!keyInSentKeys) {
if (change.type === `update`) {
newChange = { ...change, type: `insert`, previousValue: void 0 };
insertedInBatch.add(change.key);
} else if (change.type === `delete`) {
if (!skipDeleteFilter) {
continue;
}
} else {
insertedInBatch.add(change.key);
}
} else {
if (change.type === `insert`) {
continue;
} else if (change.type === `delete`) {
this.aC.delete(change.key);
insertedInBatch.delete(change.key);
}
}
newChanges.push(newChange);
}
return newChanges;
}
/**
* 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.
*/
cy(changes) {
if (this.ad.size === 0) return changes;
const reconciled = [];
for (const change of changes) {
const previous = this.ad.get(change.key);
if (previous === void 0) {
reconciled.push(change);
continue;
}
this.ad.delete(change.key);
if (change.type === `delete`) {
reconciled.push({
...change,
value: previous,
previousValue: void 0
});
} else if (!deepEquals(previous, change.value)) {
reconciled.push({
...change,
type: `update`,
previousValue: previous
});
}
}
if (this.collection.config.syncMode !== `on-demand` && !this.fS) {
for (const [key, value] of this.ad) {
if (this.collection.has(key)) continue;
this.ad.delete(key);
reconciled.push({ type: `delete`, key, value });
}
}
return reconciled;
}
fj(changes) {
for (const change of changes) {
if (change.type === `delete`) {
this.ac.delete(change.key);
} else {
this.ac.set(change.key, change.value);
}
}
}
e9(changes) {
for (const change of changes) {
if (change.type === `delete`) {
this.aC.delete(change.key);
} else {
this.aC.add(change.key);
}
}
}
/**
* 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() {
this.ej = true;
}
unsubscribe() {
if (this.unsubscribed) return;
this.unsubscribed = true;
this.aV++;
const sourceListenerCleanups = [
this.ec,
this.dB,
this.cb
];
this.ec = void 0;
this.dB = void 0;
this.cb = void 0;
runAllCallbacks([
...sourceListenerCleanups.map((cleanup) => () => cleanup?.()),
() => {
this.bP();
this.ad.clear();
const acquisitions = this.C.filter((demand) => demand.acquisitionState === `active`).map((demand) => demand.acquisition);
for (const demand of this.C) {
demand.initialResult?.reject(new LoadSubsetOperationAbortedError());
this.cO(demand);
if (demand.acquisitionState === `starting`) {
cancelAcquisition(demand.acquisition);
}
}
this.C = [];
for (const acquisition of acquisitions) {
this.releaseAcquisition(acquisition);
}
},
() => this.emitInner(`unsubscribed`, {
type: `unsubscribed`,
subscription: this
}),
// Clear all event listeners to prevent memory leaks
() => this.clearListeners()
]);
}
}
export {
CollectionSubscription
};
//# sourceMappingURL=subscription.js.map