@tanstack/db
Version:
A reactive client store for building super fast apps on sync
910 lines (836 loc) • 33.1 kB
text/typescript
import {
CollectionInErrorStateError,
CollectionPreloadAbortedError,
CollectionStateError,
InvalidCollectionStatusTransitionError,
} from '../errors'
import {
safeCancelIdleCallback,
safeRequestIdleCallback,
} from '../utils/browser-polyfills'
import { runAllCallbacks } from '../utils/callbacks'
import { createDeferred } from '../deferred'
import { codedMessage, devBuild } from '../error-message'
import { isFailedStatus } from '../live-query-primitives.js'
import { CleanupQueue, cleanupNow } from './cleanup-queue'
import type { Deferred } from '../deferred'
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type {
CollectionConfig,
CollectionErrorInfo,
CollectionStatus,
HealthProblem,
} from '../types'
import type { CollectionEventsManager } from './events'
import type { CollectionIndexesManager } from './indexes'
import type { CollectionChangesManager } from './changes'
import type { CollectionSyncManager } from './sync'
import type { CollectionStateManager } from './state'
/**
* Floor applied to the GC delay of a collection that started syncing before
* anything subscribed. Adapters build their live query while rendering and
* subscribe when that render commits. This grace period reduces cleanup
* during that gap; a later subscriber can still restart sync. Adapters pass
* a near-zero `gcTime` to make teardown on unmount immediate. Does not apply
* to the timer armed when the last subscriber leaves, which still honours
* `gcTime` exactly.
*/
export const UNSUBSCRIBED_GC_FLOOR_MS = 50
/** A Collection's gcTime when its config names none (5 minutes). */
export const DEFAULT_GC_TIME_MS = 300000
export class CollectionLifecycleManager<
TOutput extends object = Record<string, unknown>,
TKey extends string | number = string | number,
TSchema extends StandardSchemaV1 = StandardSchemaV1,
TInput extends object = TOutput,
> {
private config: CollectionConfig<TOutput, TKey, TSchema, any>
private id: string
private indexes!: CollectionIndexesManager<TOutput, TKey, TSchema, TInput>
private events!: CollectionEventsManager
private changes!: CollectionChangesManager<TOutput, TKey, TSchema, TInput>
private sync!: CollectionSyncManager<TOutput, TKey, TSchema, TInput>
private state!: CollectionStateManager<TOutput, TKey, TSchema, TInput>
public status: CollectionStatus = `idle`
public hasBeenReady = false
public hasReceivedFirstCommit = false
private readyFailureSink: ((failure: { error: unknown }) => void) | undefined
public onFirstReadyCallbacks: Array<() => void> = []
private idleCallbackId: number | null = null
// After the first ready, the sync adapter's problem is health tree detail:
// `failureReason` while it still tries, `stoppedError` once it called fail().
// A live query's request failures are kept apart, so settling a request
// cannot clear the sync adapter's problem. The failure reason lists every
// reportError() of its sync run since it began.
private failureReason: HealthProblem | null = null
// A reload's new run reported the failure reason, so its completion keeps it.
private failureReasonFromReload = false
private stoppedError: HealthProblem | null = null
// The cause of status `error` or `crashed`, kept so readers see the same
// object until the sync run ends.
private statusError: HealthProblem | null = null
private requestErrors = new RequestErrors()
// A reload's new run reported the request errors, so its completion keeps
// them.
private requestErrorsFromReload = false
// fail() ends the sync adapter's reporting for its sync run; a reload or
// cleanup starts a new one.
private adapterStopped = false
// A reload's new sync run, until it completes or fails.
private reloadRun: Deferred<void> | undefined
// A reload is moving the Collection out of `error`.
private recovering = false
private gcGraceUntil = 0
private cleanupConfig: () => void
private statusRevision = 0
private cleaningUp = false
private cleanupPromise: Promise<void> | null = null
private readonly cleanupStartCallbacks = new Set<() => void>()
/**
* Creates a new CollectionLifecycleManager instance
*/
constructor(
config: CollectionConfig<TOutput, TKey, TSchema, any>,
id: string,
cleanupConfig: () => void = () => {},
) {
this.config = config
this.id = id
this.cleanupConfig = cleanupConfig
}
setDeps(deps: {
indexes: CollectionIndexesManager<TOutput, TKey, TSchema, TInput>
events: CollectionEventsManager
changes: CollectionChangesManager<TOutput, TKey, TSchema, TInput>
sync: CollectionSyncManager<TOutput, TKey, TSchema, TInput>
state: CollectionStateManager<TOutput, TKey, TSchema, TInput>
}) {
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
*/
public validateStatusTransition(
from: CollectionStatus,
to: CollectionStatus,
): void {
if (from === to) {
// Allow same state transitions
return
}
const validTransitions: Record<
CollectionStatus,
Array<CollectionStatus>
> = {
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.recovering && from === `error` && to === `ready`)
) {
throw new InvalidCollectionStatusTransitionError(from, to, this.id)
}
}
/**
* Safely update the collection status with validation
* @private
*/
public setStatus(
newStatus: CollectionStatus,
allowReady: boolean = false,
beforeEmit?: () => void,
): void {
if (newStatus === `ready` && !allowReady) {
// setStatus('ready') is an internal method that should not be called directly
// Instead, use markReady to transition to ready triggering the necessary events
// and side effects.
throw new CollectionStateError(
devBuild() && process.env.NODE_ENV !== `production`
? `You can't directly call "setStatus('ready'). You must use markReady instead.`
: codedMessage(208),
)
}
this.validateStatusTransition(this.status, newStatus)
// Only an actual replacement run transfers later cleanup to a new run.
// Validation, deferred requests and failed admission retain the old promise.
if (newStatus === `loading`) this.cleanupPromise = null
const revision = ++this.statusRevision
const previousStatus = this.status
this.status = newStatus
beforeEmit?.()
// Emit event
const isCurrent = () => this.statusRevision === revision
this.events.emitStatusChange(newStatus, previousStatus, isCurrent)
// The status is part of the health tree. status:change fires first, and
// the tree is already current when either event fires.
if (isCurrent()) this.events.emitHealthChange()
}
/**
* Validates that the collection is in a usable state for data operations
* @private
*/
public validateCollectionUsable(operation: string): void {
switch (this.status) {
case `error`:
case `crashed`:
throw new CollectionInErrorStateError(operation, this.id)
case `cleaned-up`:
// Automatically restart the collection when operations are called on cleaned-up collections
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.
*/
public markReady(beforeEffects?: () => void): void {
// A crash ended the sync run from core's side, and fail() from the
// adapter's: after either, and once the run is ready, markReady() does
// nothing.
if (this.status === `crashed` || this.adapterStopped) return
// A reload's new run is ready: the reload completes.
if (this.reloadRun) {
this.completeReload(beforeEffects)
return
}
const failure = this.applyReadyTransition(beforeEffects)
if (!failure) return
if (this.readyFailureSink) this.readyFailureSink(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.
*/
public deferReadyFailures(): () => { error: unknown } | undefined {
let first: { error: unknown } | undefined
this.readyFailureSink = (failure) => {
first ??= failure
}
return () => {
this.readyFailureSink = undefined
return first
}
}
private applyReadyTransition(
beforeEffects?: () => void,
): { error: unknown } | undefined {
this.validateStatusTransition(this.status, `ready`)
// A successful initial sync establishes a ready snapshot, as does a
// reload of a failed first load.
if (this.status === `loading` || this.status === `error`) {
const readyRevision = this.statusRevision + 1
this.setStatus(`ready`, true, beforeEffects)
// A status listener can synchronously supersede this transition, even
// when it restarts the Collection back to ready before returning.
if (
(this.status as CollectionStatus) !== `ready` ||
this.statusRevision !== readyRevision
) {
return undefined
}
const readyEffects: Array<() => void> = []
// Call any registered first ready callbacks (only on first time becoming ready)
if (!this.hasBeenReady) {
this.hasBeenReady = true
// Also mark as having received first commit for backwards compatibility
this.hasReceivedFirstCommit = true
readyEffects.push(...this.onFirstReadyCallbacks)
this.onFirstReadyCallbacks = []
}
// Notify dependents when markReady is called, after status is set
// This ensures live queries get notified when their dependencies become ready
readyEffects.push(() => this.changes.emitEmptyReadyEvent())
try {
runAllCallbacks(readyEffects)
} catch (error) {
return { error }
}
}
return undefined
}
/**
* 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.
*/
public fail(error: unknown): void {
if (this.status === `crashed` || this.adapterStopped) return
this.adapterStopped = true
const reload = this.reloadRun
this.reloadRun = undefined
if (this.status === `ready`) {
// The rows stay; a reload that failed shows its new error instead.
this.failureReason = null
this.stoppedError = problemOf([describeProblem(error)])
this.events.emitHealthChange()
} else if (reload && this.status === `error`) {
// A reload of a failed first load keeps status `error` with the new
// cause.
this.statusError = problemOf([describeProblem(error)])
this.events.emitHealthChange()
} else {
this.validateStatusTransition(this.status, `error`)
this.statusError = problemOf([describeProblem(error)])
this.setStatus(`error`)
}
reload?.reject(error)
}
/** Whether the current sync run's adapter called `fail()`. */
public get stoppedThisRun(): boolean {
return this.adapterStopped
}
/**
* 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.
*/
public get inFirstLoad(): boolean {
return (
this.status === `loading` || (this.reloading && this.status === `error`)
)
}
/** Whether a reload's new sync run is in flight. */
public get reloading(): boolean {
return this.reloadRun !== undefined
}
/**
* A reload starts a new sync run while everything visible stays: the rows,
* the status, and the current problem. The node shows `reloading`.
*/
public beginReload(): Promise<void> {
if (this.reloadRun) return this.reloadRun.promise
const run = createDeferred<void>()
void run.promise.catch(() => undefined)
this.reloadRun = run
this.failureReasonFromReload = false
this.requestErrorsFromReload = false
this.adapterStopped = 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.
*/
public completeReload(beforeEffects?: () => void): void {
const run = this.reloadRun
if (!run) return
this.reloadRun = undefined
if (!this.failureReasonFromReload) this.failureReason = null
this.stoppedError = null
if (!this.requestErrorsFromReload) this.requestErrors = new RequestErrors()
if (this.status === `ready`) this.events.emitHealthChange()
else {
// A failed first load, or a reload during the first load, becomes
// ready with the new run's rows.
this.statusError = null
this.recovering = true
try {
this.markReady(beforeEffects)
} catch (error) {
// A ready callback that throws reaches the adapter's markReady(); the
// reload succeeded once the status is ready.
if ((this.status as CollectionStatus) === `ready`) run.resolve()
else run.reject(error)
throw error
} finally {
this.recovering = false
}
}
run.resolve()
}
/** A reload retired the sync run; subscriptions detach its demand. */
public retireSubscriptionDemand(): void {
for (const subscription of [...this.changes.changeSubscriptions])
subscription.retireSyncRun()
}
/** The reload ended without a commit, for example by cleanup. */
public abandonReload(error: unknown): void {
if (!this.endReload(error)) return
if (this.status !== `cleaned-up`) this.events.emitHealthChange()
}
private endReload(error: unknown): boolean {
const run = this.reloadRun
if (!run) return false
this.reloadRun = undefined
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.
*/
public crash(error?: unknown): void {
if (this.status === `crashed`) return
this.validateStatusTransition(this.status, `crashed`)
// A crash ends a reload in flight with it; its status change announces
// the tree.
this.endReload(error)
this.statusError = problemOf([describeProblem(error)])
this.setStatus(`crashed`)
}
/** The Collection's own problems, as health tree detail. */
public get problems(): {
failureReason: HealthProblem | null
error: HealthProblem | null
} {
const error = isFailedStatus(this.status)
? this.statusError
: (this.stoppedError ?? this.requestErrors.problem)
return { failureReason: error ? null : this.failureReason, error }
}
/** The cause of status `error` or `crashed`, as health tree detail. */
public get statusProblem(): HealthProblem | null {
return isFailedStatus(this.status) ? this.statusError : null
}
/** The sync adapter still tries after a problem. Status and rows do not change. */
public reportError(error: unknown): void {
if (this.status === `crashed`) return
this.assertAdapterActive(`reportError`)
this.assertReady(`reportError`)
// A reload's new run that reports starts its own list: the retired run's
// failures leave with that run.
const kept =
this.reloading && !this.failureReasonFromReload
? []
: (this.failureReason?.errors ?? [])
// One error value is one failure: one already listed is still open, so
// it keeps its place and its `at`, and nothing changes.
if (kept.some((failure) => failure.cause === error)) return
this.failureReason = problemOf([...kept, describeProblem(error)])
this.failureReasonFromReload = 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.
*/
public reportHealthy(error?: unknown): void {
if (this.status === `crashed`) return
this.assertAdapterActive(`reportHealthy`)
this.assertReady(`reportHealthy`)
const listed = this.failureReason?.errors ?? []
const open =
error === undefined
? []
: 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.
*/
public reportRequestError(key: string, errors: Array<unknown>): void {
// A reload's new run that reports starts its own list: the retired run's
// request errors leave with that run.
if (this.reloading && !this.requestErrorsFromReload) {
this.requestErrors = new RequestErrors()
this.requestErrorsFromReload = true
}
if (this.requestErrors.fail(key, errors)) this.events.emitHealthChange()
}
/** @internal The failed request settled or was released. */
public clearRequestError(key: string): void {
if (this.requestErrors.settle(key)) this.events.emitHealthChange()
}
/** fail() ends the sync adapter's reporting for its sync run. */
private assertAdapterActive(operation: string): void {
if (this.adapterStopped) {
throw new CollectionStateError(
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.`
: codedMessage(259, { operation, id: this.id }),
)
}
}
private assertReady(operation: string): void {
if (this.status !== `ready`) {
throw new CollectionStateError(
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.`
: codedMessage(257, { operation, id: this.id, status: this.status }),
)
}
}
/** Return the cause of status `error` or `crashed` in this sync run, if any. */
public getSyncError(): unknown {
return this.statusError?.cause
}
/** Cleanup discards the current projection until a new sync run starts. */
public hasStartedCleanup(): boolean {
return this.cleaningUp || this.status === `cleaned-up`
}
public assertCanStartSync(): void {
if (this.cleaningUp) {
throw new CollectionStateError(
devBuild() && process.env.NODE_ENV !== `production`
? `Cannot start collection "${this.id}" during cleanup. Restart after cleanup() completes.`
: codedMessage(209, { id: this.id }),
)
}
}
public isCleaningUp(): boolean {
return this.cleaningUp
}
/**
* 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`.
*/
public onCleanupStart(callback: () => void): () => void {
this.cleanupStartCallbacks.add(callback)
if (this.cleaningUp) {
try {
callback()
} catch (error) {
// Registration did not return its ownership handle. Do not retain an
// observer that its caller has no way to unsubscribe.
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
*/
public startGCTimerIfUnsubscribed(): void {
this.gcGraceUntil = 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.
private canGarbageCollect(): boolean {
return (
!this.cleaningUp &&
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.
*/
public startGCTimer(preserveHandoff = false): void {
if (!preserveHandoff) this.gcGraceUntil = 0
if (this.status === `idle` || this.status === `cleaned-up`) return
this.cancelGCTimer()
if (!this.canGarbageCollect()) return
const gcTime = this.config.gcTime ?? DEFAULT_GC_TIME_MS
// If gcTime is 0, negative, or non-finite (Infinity, -Infinity, NaN), GC is disabled.
// Note: setTimeout with Infinity coerces to 0 via ToInt32, causing immediate GC,
// so we must explicitly check for non-finite values here.
if (gcTime <= 0 || !Number.isFinite(gcTime)) {
return
}
CleanupQueue.getInstance().schedule(
this,
Math.max(gcTime, this.gcGraceUntil - cleanupNow()),
() => {
if (this.canGarbageCollect()) {
// Schedule cleanup during idle time to avoid blocking the UI thread
this.scheduleIdleCleanup()
}
},
)
}
/**
* Cancel the garbage collection timer
* Called when the collection becomes active again
*/
public cancelGCTimer(): void {
CleanupQueue.getInstance().cancel(this)
// Also cancel any pending idle cleanup
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
*/
private scheduleIdleCleanup(): void {
// Cancel any existing idle callback
if (this.idleCallbackId !== null) {
safeCancelIdleCallback(this.idleCallbackId)
}
// Schedule cleanup with a timeout of 1 second
// This ensures cleanup happens even if the browser is busy
this.idleCallbackId = safeRequestIdleCallback(
(deadline) => {
// Keep the callback ID only when cleanup schedules another idle period.
if (this.canGarbageCollect()) {
if (!(deadline.timeRemaining() > 0 || deadline.didTimeout)) {
this.scheduleIdleCleanup()
return
}
// Automatic GC has no caller to receive cleanup rejection. Core still
// finalizes the lifecycle; explicit cleanup returns adapter diagnostics.
void this.beginCleanup().catch(() => undefined)
}
this.idleCallbackId = null
},
{ timeout: 1000 },
)
}
private beginCleanup(): Promise<void> {
if (this.cleanupPromise) return this.cleanupPromise
const completion = createDeferred<void>()
this.cleanupPromise = completion.promise
this.cleaningUp = true
const localFailures: Array<unknown> = []
let synchronousSyncFailure: { error: unknown } | undefined
let syncCleanupComplete = true
const attempt = (callback: () => void): void => {
try {
callback()
} catch (error) {
localFailures.push(error)
}
}
// Dependents must stop using the discarded sync run immediately, while
// the public status and cleanup promise still wait for adapter settlement.
for (const callback of [...this.cleanupStartCallbacks]) {
attempt(callback)
}
const finish = (syncFailure?: { error: unknown }) => {
// Sync cleanup either completes synchronously or calls this once after
// its promise settles. Reentrant callers share cleanupPromise above.
this.cleaningUp = false
this.statusError = null
attempt(() => this.setStatus(`cleaned-up`))
// Active collection subscriptions still depend on lifecycle events.
// Once the last subscriber leaves, its GC cleanup clears the handlers.
if (this.changes.activeSubscribersCount === 0) {
attempt(() => this.events.cleanup())
}
// Keep cleanup observably asynchronous even when every release is
// synchronous. Existing callers may use this turn to let optimistic
// settlement finish before starting the next operation.
const failures = syncFailure
? [syncFailure.error, ...localFailures]
: localFailures
const failure =
failures.length === 0
? undefined
: {
error:
failures.length === 1
? failures[0]
: new AggregateError(
failures,
devBuild() && process.env.NODE_ENV !== `production`
? syncFailure
? `Adapter cleanup and local teardown both failed`
: `Multiple local teardown steps failed`
: codedMessage(173, {
syncFailure: syncFailure !== undefined,
}),
{ cause: failures[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()
})
}
// Sync cleanup invalidates the sync run before invoking the adapter. The
// remaining managers retire their local state synchronously while the
// adapter's resource-release promise is pending.
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())
this.hasBeenReady = false
// Problems belong to the discarded sync run. The cause of a `crashed` or
// `error` status stays while that status shows; finish() clears it.
const before = this.problems
const reloading = this.reloading
this.adapterStopped = false
this.failureReason = null
this.stoppedError = null
this.requestErrors = new RequestErrors()
// An abandoned reload announces the tree; otherwise announce dropped
// problems here, since the status changes only when cleanup settles.
this.abandonReload(new CollectionPreloadAbortedError())
const after = this.problems
if (
!reloading &&
(before.error !== after.error ||
before.failureReason !== after.failureReason)
)
this.events.emitHealthChange()
// Cleanup is not readiness. Sync cleanup rejects pending preload callers;
// first-ready listeners belong to the discarded run.
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
*/
public onFirstReady(callback: () => void): () => void {
// If already ready, call immediately
if (this.hasBeenReady) {
callback()
return () => {}
}
this.onFirstReadyCallbacks.push(callback)
return () => {
const index = this.onFirstReadyCallbacks.indexOf(callback)
if (index !== -1) {
this.onFirstReadyCallbacks.splice(index, 1)
}
}
}
public cleanup(): Promise<void> {
this.cancelGCTimer()
// Return this exact retirement operation even if an event listener starts
// a replacement run and cleanup while the first promise is still pending.
return this.beginCleanup()
}
}
/** @internal Requests that failed after initial-query readiness, by issuer. */
export class RequestErrors {
// Each failed request's errors.
private readonly failed = new Map<string, Array<unknown>>()
// Each failure still failing, by its error, with how many failed requests
// list it. Two requests that failed with one error share one failure, and
// it keeps its place among the failures: insertion order is failure order.
private readonly open = new Map<
unknown,
{ failure: CollectionErrorInfo; requests: number }
>()
problem: HealthProblem | null = null
/** Returns whether the problem changed. */
fail(key: string, errors: Array<unknown>): boolean {
// The request's failures count before its earlier ones leave, so a
// failure it lists again stays open: it keeps its place and its `at`.
let changed = false
for (const error of errors) {
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, errors)
return changed && this.show()
}
/** Returns whether the problem changed. */
settle(key: string): boolean {
return this.release(key) && this.show()
}
// Removes a request's failures; returns whether one stopped failing.
private release(key: string): boolean {
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.
private show(): true {
const errors = Array.from(this.open.values(), ({ failure }) => failure)
this.problem = errors.length > 0 ? problemOf(errors) : null
return true
}
}
/**
* @internal A health problem: its newest failure's fields, with every
* failure of its frame listed, oldest first.
*/
export function problemOf(
errors: ReadonlyArray<CollectionErrorInfo>,
): HealthProblem {
const newest = errors[errors.length - 1]!
return withCause({ ...newest, errors }, newest.cause)
}
// The original value stays available but out of JSON.
function withCause<T extends object>(
info: T,
cause: unknown,
): T & { cause: unknown } {
return Object.defineProperty(info, `cause`, {
value: cause,
enumerable: false,
}) as T & { cause: unknown }
}
/** @internal Health tree detail for a problem value. */
export function describeProblem(error: unknown): CollectionErrorInfo {
// Any value can be a problem. One that cannot describe itself, such as an
// object without a prototype or a throwing getter, keeps what was read.
let name = `Error`
let message = `Collection is in an error state`
let code: unknown
try {
if (error instanceof Error) {
name = error.name
message = error.message
} else if (error !== undefined) message = String(error)
code = (error as { code?: unknown } | null)?.code
} catch {
// The problem still shows, with the generic parts.
}
return withCause(
{
name,
message,
...(typeof code === `string` ? { code } : {}),
at: Date.now(),
},
error,
)
}