@tanstack/db
Version:
A reactive client store for building super fast apps on sync
623 lines (622 loc) • 21.2 kB
JavaScript
;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const errors = require("../errors.cjs");
const browserPolyfills = require("../utils/browser-polyfills.cjs");
const callbacks = require("../utils/callbacks.cjs");
const deferred = require("../deferred.cjs");
const errorMessage = require("../error-message.cjs");
const liveQueryPrimitives = require("../live-query-primitives.cjs");
const cleanupQueue = require("./cleanup-queue.cjs");
const UNSUBSCRIBED_GC_FLOOR_MS = 50;
const DEFAULT_GC_TIME_MS = 3e5;
class CollectionLifecycleManager {
/**
* Creates a new CollectionLifecycleManager instance
*/
constructor(config, id, cleanupConfig = () => {
}) {
this.status = `idle`;
this.hasBeenReady = false;
this.hasReceivedFirstCommit = false;
this.onFirstReadyCallbacks = [];
this.aI = null;
this.failureReason = null;
this.a6 = false;
this.dk = null;
this.cc = null;
this.cE = new RequestErrors();
this.bw = false;
this.bI = false;
this.g2 = false;
this.aP = 0;
this.aV = 0;
this.dr = false;
this.b5 = null;
this.bO = /* @__PURE__ */ new Set();
this.config = config;
this.id = id;
this.fX = cleanupConfig;
}
setDeps(deps) {
this.indexes = deps.indexes;
this.events = deps.events;
this.changes = deps.changes;
this.sync = deps.sync;
this.state = deps.state;
}
/**
* Validates state transitions to prevent invalid status changes
*/
validateStatusTransition(from, to) {
if (from === to) {
return;
}
const validTransitions = {
idle: [`loading`, `error`, `crashed`, `cleaned-up`],
loading: [`ready`, `error`, `crashed`, `cleaned-up`],
// After the first ready only a crash changes the status; a sync
// problem is health tree detail.
ready: [`cleaned-up`, `crashed`],
// A failed first load is final for its sync run. Only a reload leaves
// it for `ready` (see `recovering`).
error: [`cleaned-up`, `idle`, `crashed`],
// Nothing recovers a crash in place; only cleanup leaves it.
crashed: [`cleaned-up`],
"cleaned-up": [`loading`, `error`, `crashed`]
};
if (!validTransitions[from].includes(to) && !(this.g2 && from === `error` && to === `ready`)) {
throw new errors.InvalidCollectionStatusTransitionError(from, to, this.id);
}
}
/**
* Safely update the collection status with validation
* @private
*/
setStatus(newStatus, allowReady = false, beforeEmit) {
if (newStatus === `ready` && !allowReady) {
throw new errors.CollectionStateError(
errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `You can't directly call "setStatus('ready'). You must use markReady instead.` : errorMessage.codedMessage(208)
);
}
this.validateStatusTransition(this.status, newStatus);
if (newStatus === `loading`) this.b5 = null;
const revision = ++this.aV;
const previousStatus = this.status;
this.status = newStatus;
beforeEmit?.();
const isCurrent = () => this.aV === revision;
this.events.emitStatusChange(newStatus, previousStatus, isCurrent);
if (isCurrent()) this.events.emitHealthChange();
}
/**
* Validates that the collection is in a usable state for data operations
* @private
*/
validateCollectionUsable(operation) {
switch (this.status) {
case `error`:
case `crashed`:
throw new errors.CollectionInErrorStateError(operation, this.id);
case `cleaned-up`:
this.sync.startSync();
break;
}
}
/**
* Mark the collection as ready for use
* This is called by sync implementations to explicitly signal that the collection is ready,
* providing a more intuitive alternative to using commits for readiness signaling
* @private - Should only be called by sync implementations
*
* `beforeEffects` runs after the status reads `ready` and before status
* listeners and ready callbacks, so their writes follow it.
*/
markReady(beforeEffects) {
if (this.status === `crashed` || this.bI) return;
if (this.b6) {
this.completeReload(beforeEffects);
return;
}
const failure = this.d_(beforeEffects);
if (!failure) return;
if (this.gn) this.gn(failure);
else throw failure.error;
}
/**
* @internal Hold ready-effect failures while a sync function runs, whether
* it calls markReady or commits a truncate. The returned function stops
* holding them and returns the first one.
*/
deferReadyFailures() {
let first;
this.gn = (failure) => {
first ??= failure;
};
return () => {
this.gn = void 0;
return first;
};
}
d_(beforeEffects) {
this.validateStatusTransition(this.status, `ready`);
if (this.status === `loading` || this.status === `error`) {
const readyRevision = this.aV + 1;
this.setStatus(`ready`, true, beforeEffects);
if (this.status !== `ready` || this.aV !== readyRevision) {
return void 0;
}
const readyEffects = [];
if (!this.hasBeenReady) {
this.hasBeenReady = true;
this.hasReceivedFirstCommit = true;
readyEffects.push(...this.onFirstReadyCallbacks);
this.onFirstReadyCallbacks = [];
}
readyEffects.push(() => this.changes.emitEmptyReadyEvent());
try {
callbacks.runAllCallbacks(readyEffects);
} catch (error) {
return { error };
}
}
return void 0;
}
/**
* The sync adapter stopped. Before the first ready this fails the sync
* run's first load; after it, the rows stay and the error shows until a
* reload. A second fail() in the same sync run changes nothing: the adapter
* already stopped, and the first error stays.
*/
fail(error) {
if (this.status === `crashed` || this.bI) return;
this.bI = true;
const reload = this.b6;
this.b6 = void 0;
if (this.status === `ready`) {
this.failureReason = null;
this.dk = problemOf([describeProblem(error)]);
this.events.emitHealthChange();
} else if (reload && this.status === `error`) {
this.cc = problemOf([describeProblem(error)]);
this.events.emitHealthChange();
} else {
this.validateStatusTransition(this.status, `error`);
this.cc = problemOf([describeProblem(error)]);
this.setStatus(`error`);
}
reload?.reject(error);
}
/** Whether the current sync run's adapter called `fail()`. */
get stoppedThisRun() {
return this.bI;
}
/**
* Whether the current sync run is in its first load: `loading`, or a reload
* of a failed first load that is not ready yet. A failure now fails that
* first load.
*/
get inFirstLoad() {
return this.status === `loading` || this.reloading && this.status === `error`;
}
/** Whether a reload's new sync run is in flight. */
get reloading() {
return this.b6 !== void 0;
}
/**
* A reload starts a new sync run while everything visible stays: the rows,
* the status, and the current problem. The node shows `reloading`.
*/
beginReload() {
if (this.b6) return this.b6.promise;
const run = deferred.createDeferred();
void run.promise.catch(() => void 0);
this.b6 = run;
this.a6 = false;
this.bw = false;
this.bI = false;
this.events.emitHealthChange();
return run.promise;
}
/**
* The reload's new run replaced the rows with a snapshot or a truncate, or
* marked ready: the retired run's problems clear, the new run's stay, and a
* failed first load becomes ready.
*/
completeReload(beforeEffects) {
const run = this.b6;
if (!run) return;
this.b6 = void 0;
if (!this.a6) this.failureReason = null;
this.dk = null;
if (!this.bw) this.cE = new RequestErrors();
if (this.status === `ready`) this.events.emitHealthChange();
else {
this.cc = null;
this.g2 = true;
try {
this.markReady(beforeEffects);
} catch (error) {
if (this.status === `ready`) run.resolve();
else run.reject(error);
throw error;
} finally {
this.g2 = false;
}
}
run.resolve();
}
/** A reload retired the sync run; subscriptions detach its demand. */
retireSubscriptionDemand() {
for (const subscription of [...this.changes.changeSubscriptions])
subscription.retireSyncRun();
}
/** The reload ended without a commit, for example by cleanup. */
abandonReload(error) {
if (!this.g8(error)) return;
if (this.status !== `cleaned-up`) this.events.emitHealthChange();
}
g8(error) {
const run = this.b6;
if (!run) return false;
this.b6 = void 0;
run.reject(error);
return true;
}
/**
* Local storage or an internal invariant broke. The Collection can no
* longer serve reads or accept writes, from any active status. Nothing
* recovers a crash in place: only cleanup, which discards all state, leaves
* it.
*/
crash(error) {
if (this.status === `crashed`) return;
this.validateStatusTransition(this.status, `crashed`);
this.g8(error);
this.cc = problemOf([describeProblem(error)]);
this.setStatus(`crashed`);
}
/** The Collection's own problems, as health tree detail. */
get problems() {
const error = liveQueryPrimitives.isFailedStatus(this.status) ? this.cc : this.dk ?? this.cE.problem;
return { failureReason: error ? null : this.failureReason, error };
}
/** The cause of status `error` or `crashed`, as health tree detail. */
get statusProblem() {
return liveQueryPrimitives.isFailedStatus(this.status) ? this.cc : null;
}
/** The sync adapter still tries after a problem. Status and rows do not change. */
reportError(error) {
if (this.status === `crashed`) return;
this.d4(`reportError`);
this.g5(`reportError`);
const kept = this.reloading && !this.a6 ? [] : this.failureReason?.errors ?? [];
if (kept.some((failure) => failure.cause === error)) return;
this.failureReason = problemOf([...kept, describeProblem(error)]);
this.a6 = this.reloading;
this.events.emitHealthChange();
}
/**
* The source synced successfully again. With no error it clears the
* failure reason; with one it removes the failure whose cause is that
* value, and the failure reason clears once none is left. With nothing to
* remove it does nothing. `undefined` is no error: it clears every
* failure, also one that `reportError(undefined)` listed.
*/
reportHealthy(error) {
if (this.status === `crashed`) return;
this.d4(`reportHealthy`);
this.g5(`reportHealthy`);
const listed = this.failureReason?.errors ?? [];
const open = error === void 0 ? [] : listed.filter((failure) => failure.cause !== error);
if (open.length === listed.length) return;
this.failureReason = open.length > 0 ? problemOf(open) : null;
this.events.emitHealthChange();
}
/**
* @internal A live query's request failed after initial-query readiness,
* with each of its failures in failure order.
*/
reportRequestError(key, errors2) {
if (this.reloading && !this.bw) {
this.cE = new RequestErrors();
this.bw = true;
}
if (this.cE.fail(key, errors2)) this.events.emitHealthChange();
}
/** @internal The failed request settled or was released. */
clearRequestError(key) {
if (this.cE.settle(key)) this.events.emitHealthChange();
}
/** fail() ends the sync adapter's reporting for its sync run. */
d4(operation) {
if (this.bI) {
throw new errors.CollectionStateError(
errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `${operation}() was called after fail() in the same sync run of Collection "${this.id}". fail() means the sync adapter stopped; a reload starts a new sync run.` : errorMessage.codedMessage(259, { operation, id: this.id })
);
}
}
g5(operation) {
if (this.status !== `ready`) {
throw new errors.CollectionStateError(
errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `${operation}() reports on sync after the first ready, but Collection "${this.id}" is ${this.status}. Before the first ready, retry without reporting, or call fail() when the sync adapter stops.` : errorMessage.codedMessage(257, { operation, id: this.id, status: this.status })
);
}
}
/** Return the cause of status `error` or `crashed` in this sync run, if any. */
getSyncError() {
return this.cc?.cause;
}
/** Cleanup discards the current projection until a new sync run starts. */
hasStartedCleanup() {
return this.dr || this.status === `cleaned-up`;
}
assertCanStartSync() {
if (this.dr) {
throw new errors.CollectionStateError(
errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `Cannot start collection "${this.id}" during cleanup. Restart after cleanup() completes.` : errorMessage.codedMessage(209, { id: this.id })
);
}
}
isCleaningUp() {
return this.dr;
}
/**
* Observe the synchronous start of cleanup without treating it as terminal
* resource settlement. Internal dependents use this to retire work before
* an asynchronous adapter cleanup publishes `cleaned-up`.
*/
onCleanupStart(callback) {
this.bO.add(callback);
if (this.dr) {
try {
callback();
} catch (error) {
this.bO.delete(callback);
throw error;
}
}
return () => this.bO.delete(callback);
}
/**
* Start the garbage collection timer for a collection with no subscribers
* Called when sync starts outside a subscription
*/
startGCTimerIfUnsubscribed() {
this.aP = cleanupQueue.cleanupNow() + UNSUBSCRIBED_GC_FLOOR_MS;
this.startGCTimer(true);
}
// A persisting transaction holds the Collection: collecting it would drop
// the rows the transaction shows and end the sync run its handler may wait on.
dA() {
return !this.dr && this.changes.activeSubscribersCount === 0 && !this.sync.hasPendingPreload && !this.state.hasPersistingTransaction();
}
/**
* Renew inactive retention. Settlement keeps the remaining startup/preload
* handoff; a real last unsubscribe clears it and uses gcTime exactly.
*/
startGCTimer(preserveHandoff = false) {
if (!preserveHandoff) this.aP = 0;
if (this.status === `idle` || this.status === `cleaned-up`) return;
this.cancelGCTimer();
if (!this.dA()) return;
const gcTime = this.config.gcTime ?? DEFAULT_GC_TIME_MS;
if (gcTime <= 0 || !Number.isFinite(gcTime)) {
return;
}
cleanupQueue.CleanupQueue.getInstance().schedule(
this,
Math.max(gcTime, this.aP - cleanupQueue.cleanupNow()),
() => {
if (this.dA()) {
this.eg();
}
}
);
}
/**
* Cancel the garbage collection timer
* Called when the collection becomes active again
*/
cancelGCTimer() {
cleanupQueue.CleanupQueue.getInstance().cancel(this);
if (this.aI !== null) {
browserPolyfills.safeCancelIdleCallback(this.aI);
this.aI = null;
}
}
/**
* Schedule cleanup to run during browser idle time
* This prevents blocking the UI thread during cleanup operations
*/
eg() {
if (this.aI !== null) {
browserPolyfills.safeCancelIdleCallback(this.aI);
}
this.aI = browserPolyfills.safeRequestIdleCallback(
(deadline) => {
if (this.dA()) {
if (!(deadline.timeRemaining() > 0 || deadline.didTimeout)) {
this.eg();
return;
}
void this.fe().catch(() => void 0);
}
this.aI = null;
},
{ timeout: 1e3 }
);
}
fe() {
if (this.b5) return this.b5;
const completion = deferred.createDeferred();
this.b5 = completion.promise;
this.dr = true;
const localFailures = [];
let synchronousSyncFailure;
let syncCleanupComplete = true;
const attempt = (callback) => {
try {
callback();
} catch (error) {
localFailures.push(error);
}
};
for (const callback of [...this.bO]) {
attempt(callback);
}
const finish = (syncFailure) => {
this.dr = false;
this.cc = null;
attempt(() => this.setStatus(`cleaned-up`));
if (this.changes.activeSubscribersCount === 0) {
attempt(() => this.events.cleanup());
}
const failures = syncFailure ? [syncFailure.error, ...localFailures] : localFailures;
const failure = failures.length === 0 ? void 0 : {
error: failures.length === 1 ? failures[0] : new AggregateError(
failures,
errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? syncFailure ? `Adapter cleanup and local teardown both failed` : `Multiple local teardown steps failed` : errorMessage.codedMessage(173, {
syncFailure: syncFailure !== void 0
}),
{ cause: failures[0] }
)
};
void Promise.resolve().then(() => Promise.resolve()).then(() => {
if (this.b5 === completion.promise) {
this.b5 = null;
}
if (failure) completion.reject(failure.error);
else completion.resolve();
});
};
attempt(() => this.fX());
try {
syncCleanupComplete = this.sync.cleanup(finish);
} catch (error) {
synchronousSyncFailure = { error };
}
attempt(() => this.state.cleanup());
attempt(() => this.changes.cleanup());
attempt(() => this.indexes.cleanup());
this.hasBeenReady = false;
const before = this.problems;
const reloading = this.reloading;
this.bI = false;
this.failureReason = null;
this.dk = null;
this.cE = new RequestErrors();
this.abandonReload(new errors.CollectionPreloadAbortedError());
const after = this.problems;
if (!reloading && (before.error !== after.error || before.failureReason !== after.failureReason))
this.events.emitHealthChange();
this.onFirstReadyCallbacks = [];
if (syncCleanupComplete) finish(synchronousSyncFailure);
return completion.promise;
}
/**
* Register a callback to be executed when the collection first becomes ready
* Useful for preloading collections
* @param callback Function to call when the collection first becomes ready
*/
onFirstReady(callback) {
if (this.hasBeenReady) {
callback();
return () => {
};
}
this.onFirstReadyCallbacks.push(callback);
return () => {
const index = this.onFirstReadyCallbacks.indexOf(callback);
if (index !== -1) {
this.onFirstReadyCallbacks.splice(index, 1);
}
};
}
cleanup() {
this.cancelGCTimer();
return this.fe();
}
}
class RequestErrors {
constructor() {
this.failed = /* @__PURE__ */ new Map();
this.open = /* @__PURE__ */ new Map();
this.problem = null;
}
/** Returns whether the problem changed. */
fail(key, errors2) {
let changed = false;
for (const error of errors2) {
const entry = this.open.get(error);
if (entry) entry.requests++;
else {
this.open.set(error, { failure: describeProblem(error), requests: 1 });
changed = true;
}
}
changed = this.release(key) || changed;
this.failed.set(key, errors2);
return changed && this.hd();
}
/** Returns whether the problem changed. */
settle(key) {
return this.release(key) && this.hd();
}
// Removes a request's failures; returns whether one stopped failing.
release(key) {
let changed = false;
for (const error of this.failed.get(key) ?? []) {
const entry = this.open.get(error);
if (--entry.requests === 0) changed = this.open.delete(error);
}
this.failed.delete(key);
return changed;
}
// The newest failure still failing shows, and the problem lists every
// failure still failing, oldest first. It is a new object only when that
// list changed.
hd() {
const errors2 = Array.from(this.open.values(), ({ failure }) => failure);
this.problem = errors2.length > 0 ? problemOf(errors2) : null;
return true;
}
}
function problemOf(errors2) {
const newest = errors2[errors2.length - 1];
return withCause({ ...newest, errors: errors2 }, newest.cause);
}
function withCause(info, cause) {
return Object.defineProperty(info, `cause`, {
value: cause,
enumerable: false
});
}
function describeProblem(error) {
let name = `Error`;
let message = `Collection is in an error state`;
let code;
try {
if (error instanceof Error) {
name = error.name;
message = error.message;
} else if (error !== void 0) message = String(error);
code = error?.code;
} catch {
}
return withCause(
{
name,
message,
...typeof code === `string` ? { code } : {},
at: Date.now()
},
error
);
}
exports.CollectionLifecycleManager = CollectionLifecycleManager;
exports.DEFAULT_GC_TIME_MS = DEFAULT_GC_TIME_MS;
exports.RequestErrors = RequestErrors;
exports.UNSUBSCRIBED_GC_FLOOR_MS = UNSUBSCRIBED_GC_FLOOR_MS;
exports.describeProblem = describeProblem;
exports.problemOf = problemOf;
//# sourceMappingURL=lifecycle.cjs.map