@tanstack/db
Version:
A reactive client store for building super fast apps on sync
343 lines (342 loc) • 10.7 kB
JavaScript
import { InvalidCollectionStatusTransitionError, CollectionStateError, CollectionInErrorStateError } from "../errors.js";
import { safeCancelIdleCallback, safeRequestIdleCallback } from "../utils/browser-polyfills.js";
import { runAllCallbacks } from "../utils/callbacks.js";
import { createDeferred } from "../deferred.js";
import { CleanupQueue } from "./cleanup-queue.js";
const UNSUBSCRIBED_GC_FLOOR_MS = 50;
class CollectionLifecycleManager {
/**
* Creates a new CollectionLifecycleManager instance
*/
constructor(config, id, cleanupConfig = () => {
}) {
this.status = `idle`;
this.hasBeenReady = false;
this.hasReceivedFirstCommit = false;
this.onFirstReadyCallbacks = [];
this.idleCallbackId = null;
this.statusRevision = 0;
this.cleaningUp = false;
this.cleanupPromise = null;
this.cleanupStartCallbacks = /* @__PURE__ */ new Set();
this.config = config;
this.id = id;
this.cleanupConfig = 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`, `cleaned-up`],
loading: [`ready`, `error`, `cleaned-up`],
ready: [`cleaned-up`, `error`],
error: [`ready`, `cleaned-up`, `idle`],
"cleaned-up": [`loading`, `error`]
};
if (!validTransitions[from].includes(to)) {
throw new InvalidCollectionStatusTransitionError(from, to, this.id);
}
}
/**
* Safely update the collection status with validation
* @private
*/
setStatus(newStatus, allowReady = false) {
if (newStatus === `ready` && !allowReady) {
throw new CollectionStateError(
`You can't directly call "setStatus('ready'). You must use markReady instead.`
);
}
this.validateStatusTransition(this.status, newStatus);
const revision = ++this.statusRevision;
const previousStatus = this.status;
this.status = newStatus;
this.events.emitStatusChange(
newStatus,
previousStatus,
() => this.statusRevision === revision
);
}
/**
* Validates that the collection is in a usable state for data operations
* @private
*/
validateCollectionUsable(operation) {
switch (this.status) {
case `error`:
throw new 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
*/
markReady() {
const failure = this.applyReadyTransition();
if (failure) throw failure.error;
}
/** @internal Capture ready-effect failures while the sync entry completes. */
markReadyDuringSyncStart() {
return this.applyReadyTransition();
}
applyReadyTransition() {
this.validateStatusTransition(this.status, `ready`);
if (this.status === `loading` || this.status === `error`) {
this.syncError = void 0;
const readyRevision = this.statusRevision + 1;
this.setStatus(`ready`, true);
if (this.status !== `ready` || this.statusRevision !== readyRevision) {
return void 0;
}
const readyEffects = [];
if (!this.hasBeenReady) {
this.hasBeenReady = true;
if (!this.hasReceivedFirstCommit) {
this.hasReceivedFirstCommit = true;
}
readyEffects.push(...this.onFirstReadyCallbacks);
this.onFirstReadyCallbacks = [];
}
readyEffects.push(() => this.changes.emitEmptyReadyEvent());
try {
runAllCallbacks(readyEffects);
} catch (error) {
return { error };
}
}
return void 0;
}
/** Mark an asynchronous sync failure after sync has started. */
markError(error) {
this.validateStatusTransition(this.status, `error`);
this.syncError = error;
this.setStatus(`error`);
}
/** Return the cause supplied by the current sync run, if any. */
getSyncError() {
return this.syncError;
}
assertCanStartSync() {
if (this.cleaningUp) {
throw new CollectionStateError(
`Cannot start collection "${this.id}" during cleanup. Restart after cleanup() completes.`
);
}
this.cleanupPromise = null;
}
/**
* 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.cleanupStartCallbacks.add(callback);
if (this.cleaningUp) {
try {
callback();
} catch (error) {
this.cleanupStartCallbacks.delete(callback);
throw error;
}
}
return () => this.cleanupStartCallbacks.delete(callback);
}
/**
* Start the garbage collection timer for a collection with no subscribers
* Called when sync starts outside a subscription
*/
startGCTimerIfUnsubscribed() {
this.startGCTimer(UNSUBSCRIBED_GC_FLOOR_MS);
}
canGarbageCollect() {
return !this.cleaningUp && this.changes.activeSubscribersCount === 0 && !this.sync.hasPendingPreload;
}
/**
* Start the garbage collection timer
* Called when the collection becomes inactive (no subscribers)
*/
startGCTimer(minDelay = 0) {
if (!this.canGarbageCollect()) return;
const gcTime = this.config.gcTime ?? 3e5;
if (gcTime <= 0 || !Number.isFinite(gcTime)) {
return;
}
CleanupQueue.getInstance().schedule(
this,
Math.max(gcTime, minDelay),
() => {
if (this.canGarbageCollect()) {
this.scheduleIdleCleanup();
}
}
);
}
/**
* Cancel the garbage collection timer
* Called when the collection becomes active again
*/
cancelGCTimer() {
CleanupQueue.getInstance().cancel(this);
if (this.idleCallbackId !== null) {
safeCancelIdleCallback(this.idleCallbackId);
this.idleCallbackId = null;
}
}
/**
* Schedule cleanup to run during browser idle time
* This prevents blocking the UI thread during cleanup operations
*/
scheduleIdleCleanup() {
if (this.idleCallbackId !== null) {
safeCancelIdleCallback(this.idleCallbackId);
}
this.idleCallbackId = safeRequestIdleCallback(
(deadline) => {
if (this.canGarbageCollect()) {
const cleanupCompleted = this.performCleanup(deadline);
if (cleanupCompleted) {
this.idleCallbackId = null;
}
} else {
this.idleCallbackId = null;
}
},
{ timeout: 1e3 }
);
}
/**
* Perform cleanup operations, optionally in chunks during idle time
* @returns true if cleanup was completed, false if it was rescheduled
*/
performCleanup(deadline) {
if (this.cleaningUp) return true;
const hasTime = !deadline || deadline.timeRemaining() > 0 || deadline.didTimeout;
if (hasTime) {
const cleanup = this.beginCleanup();
void cleanup.catch(() => void 0);
return true;
} else {
this.scheduleIdleCleanup();
return false;
}
}
beginCleanup() {
if (this.cleanupPromise) return this.cleanupPromise;
const completion = createDeferred();
this.cleanupPromise = completion.promise;
this.cleaningUp = true;
const localFailures = [];
let synchronousSyncFailure;
let syncCleanupComplete = true;
let finished = false;
const attempt = (callback) => {
try {
callback();
} catch (error) {
localFailures.push(error);
}
};
for (const callback of [...this.cleanupStartCallbacks]) {
attempt(callback);
}
const finish = (syncFailure) => {
if (finished) return;
finished = true;
this.cleaningUp = false;
attempt(() => this.setStatus(`cleaned-up`));
if (this.changes.activeSubscribersCount === 0) {
attempt(() => this.events.cleanup());
}
let failure;
if (syncFailure && localFailures.length > 0) {
failure = {
error: new AggregateError(
[syncFailure.error, ...localFailures],
`Adapter cleanup and local teardown both failed`,
{ cause: syncFailure.error }
)
};
} else if (syncFailure) {
failure = syncFailure;
} else if (localFailures.length === 1) {
failure = { error: localFailures[0] };
} else if (localFailures.length > 1) {
failure = {
error: new AggregateError(
localFailures,
`Multiple local teardown steps failed`,
{ cause: localFailures[0] }
)
};
}
void Promise.resolve().then(() => Promise.resolve()).then(() => {
if (this.cleanupPromise === completion.promise) {
this.cleanupPromise = null;
}
if (failure) completion.reject(failure.error);
else completion.resolve();
});
};
attempt(() => this.cleanupConfig());
try {
syncCleanupComplete = this.sync.cleanup(finish);
} catch (error) {
synchronousSyncFailure = { error };
}
attempt(() => this.state.cleanup());
attempt(() => this.changes.cleanup());
attempt(() => this.indexes.cleanup());
CleanupQueue.getInstance().cancel(this);
this.hasBeenReady = false;
this.syncError = void 0;
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() {
if (this.idleCallbackId !== null) {
safeCancelIdleCallback(this.idleCallbackId);
this.idleCallbackId = null;
}
return this.beginCleanup();
}
}
export {
CollectionLifecycleManager
};
//# sourceMappingURL=lifecycle.js.map