@tanstack/db
Version:
A reactive client store for building super fast apps on sync
735 lines (667 loc) • 23 kB
text/typescript
import {
LiveQueryWindowControllerDisposedError,
SetWindowRequiresOrderByError,
} from './errors.js'
import { getLiveQueryStatusFlags } from './live-query-adapter.js'
import { createLiveQueryObserver } from './live-query-observer.js'
import type {
LiveQueryObserver,
LiveQuerySnapshot,
} from './live-query-observer.js'
import type { Collection } from './collection/index.js'
import type { CollectionStatus } from './types.js'
const DEFAULT_PAGE_SIZE = 20
type WindowResult = true | Promise<void>
type WindowTarget = object & {
utils?: {
setWindow?: (options: { offset: number; limit: number }) => WindowResult
getWindow?: () => { offset: number; limit: number } | undefined
}
}
type PendingWindow = {
generation: number
limit: number
promise: Promise<void>
}
class WindowCoordinator {
private readonly leases = new Map<symbol, number>()
private readonly leaseVersions = new Map<symbol, number>()
private readonly initialWindow: { offset: number; limit: number } | undefined
private appliedLimit: number | undefined
private pending: PendingWindow | undefined
private generation = 0
private leaseVersion = 0
constructor(private readonly target: WindowTarget) {
this.initialWindow = target.utils?.getWindow?.()
}
request(lease: symbol, limit: number): WindowResult {
const previousLimit = this.leases.get(lease)
const previousVersion = this.leaseVersions.get(lease)
const version = ++this.leaseVersion
this.leases.set(lease, limit)
this.leaseVersions.set(lease, version)
let result: WindowResult
try {
result = this.applyDesiredWindow()
} catch (error) {
this.rollbackLease(lease, version, previousLimit, previousVersion)
this.appliedLimit = undefined
throw error
}
if (result === true) return true
return result.catch(async (error: unknown) => {
if (this.rollbackLease(lease, version, previousLimit, previousVersion)) {
this.generation++
this.pending = undefined
this.appliedLimit = undefined
try {
if (this.leases.size === 0) {
this.restoreInitialWindow()
} else {
const rollback = this.applyDesiredWindow()
if (rollback !== true) await rollback
}
} catch {
// Preserve the failure from the requested window.
}
}
throw error
})
}
isLeaseSatisfied(lease: symbol, minimumLimit: number): boolean {
const limit = this.leases.get(lease)
if (limit === undefined || limit < minimumLimit) return false
const desiredLimit = this.getDesiredLimit()
const currentWindow = this.target.utils?.getWindow?.()
return (
currentWindow === undefined ||
(currentWindow.offset === 0 && currentWindow.limit === desiredLimit)
)
}
release(lease: symbol): void {
if (!this.leases.delete(lease)) return
this.leaseVersions.delete(lease)
// A pending request may still mutate the physical operator, but it no longer
// establishes the accepted window for the remaining lease set.
this.generation++
this.pending = undefined
this.appliedLimit = undefined
if (this.leases.size === 0) {
return
}
try {
const result = this.applyDesiredWindow()
if (result !== true) {
void result.catch(() => {
// Unsubscribe has no async error channel. Leave the physical window
// unaccepted so the next request retries it.
this.appliedLimit = undefined
})
}
} catch {
// The remaining controller will retry on its next request.
this.appliedLimit = undefined
}
}
private getDesiredLimit(): number | undefined {
let desired: number | undefined
for (const limit of this.leases.values()) {
desired = desired === undefined ? limit : Math.max(desired, limit)
}
return desired
}
private rollbackLease(
lease: symbol,
version: number,
previousLimit: number | undefined,
previousVersion: number | undefined,
): boolean {
if (this.leaseVersions.get(lease) !== version) return false
if (previousLimit === undefined) {
this.leases.delete(lease)
this.leaseVersions.delete(lease)
} else {
this.leases.set(lease, previousLimit)
if (previousVersion === undefined) {
this.leaseVersions.delete(lease)
} else {
this.leaseVersions.set(lease, previousVersion)
}
}
return true
}
private restoreInitialWindow(): void {
const setWindow = this.target.utils?.setWindow
if (!this.initialWindow || typeof setWindow !== `function`) return
try {
const result = setWindow.call(this.target.utils, this.initialWindow)
if (result !== true) void result.catch(() => {})
} catch {
// Release has no error channel. A future lease will retry its own window.
}
}
private applyDesiredWindow(): WindowResult {
const limit = this.getDesiredLimit()
if (limit === undefined) return true
if (this.pending?.limit === limit) return this.pending.promise
if (this.pending) {
// `setWindow` mutates the physical operator before its load promise
// settles. A different desired window must therefore be applied again,
// even when it matches the last settled limit.
this.generation++
this.pending = undefined
this.appliedLimit = undefined
}
const currentWindow = this.target.utils?.getWindow?.()
if (
limit === this.appliedLimit &&
currentWindow?.offset === 0 &&
currentWindow.limit === limit
) {
return true
}
const setWindow = this.target.utils?.setWindow
if (typeof setWindow !== `function`) {
throw new SetWindowRequiresOrderByError()
}
const generation = ++this.generation
const result = setWindow.call(this.target.utils, { offset: 0, limit })
if (result === true) {
if (generation === this.generation && this.getDesiredLimit() === limit) {
this.appliedLimit = limit
}
return true
}
const promise = result.then(
() => {
if (
generation === this.generation &&
this.getDesiredLimit() === limit
) {
this.appliedLimit = limit
}
if (this.pending?.generation === generation) {
this.pending = undefined
}
},
(error: unknown) => {
if (this.pending?.generation === generation) {
this.pending = undefined
}
throw error
},
)
this.pending = { generation, limit, promise }
return promise
}
}
const windowCoordinators = new WeakMap<object, WindowCoordinator>()
function getWindowCoordinator(target: WindowTarget): WindowCoordinator {
let coordinator = windowCoordinators.get(target)
if (!coordinator) {
coordinator = new WindowCoordinator(target)
windowCoordinators.set(target, coordinator)
}
return coordinator
}
/**
* A page-windowed view of a live query at a point in time.
*
* @internal This contract is unstable while RFC #1623 is being implemented.
*/
export interface LiveQueryWindowSnapshot<
T extends object,
TKey extends string | number,
> {
/** Rows across all committed pages, with the peek-ahead row removed. */
data: ReadonlyArray<T>
/** Rows grouped into committed pages of `pageSize`. */
pages: ReadonlyArray<ReadonlyArray<T>>
/** `initialPageParam + i` for each committed page. */
pageParams: ReadonlyArray<number>
hasNextPage: boolean
isFetchingNextPage: boolean
/** The last pagination failure, cleared when a retry begins. */
error: unknown
/** Keyed results for the physical window, or `undefined` when disabled. */
state: ReadonlyMap<TKey, T> | undefined
collection: Collection<T, TKey, any> | undefined
status: CollectionStatus | `disabled`
isLoading: boolean
isReady: boolean
isIdle: boolean
isError: boolean
isCleanedUp: boolean
isEnabled: boolean
}
/** @internal This contract is unstable while RFC #1623 is being implemented. */
export interface CreateLiveQueryWindowControllerOptions {
/** Rows per page (default 20). Non-positive values use the default. */
pageSize?: number
/** Value of the first page's `pageParam` (default 0). */
initialPageParam?: number
/** Committed pages to preserve when a framework binding changes page shape. */
initialPageCount?: number
}
/** @internal This contract is unstable while RFC #1623 is being implemented. */
export interface LiveQueryWindowController<
T extends object,
TKey extends string | number,
> {
getSnapshot: () => LiveQueryWindowSnapshot<T, TKey>
subscribe: (listener: () => void) => () => void
/** Load one more page, resolving only after that page is committed. */
fetchNextPage: () => Promise<void>
/** Reset to the first page, resolving after the smaller window is accepted. */
reset: () => Promise<void>
preload: () => Promise<void>
dispose: () => void
}
interface CachedFrom {
observerSnapshot: unknown
committedPageCount: number
isFetchingNextPage: boolean
hasPaginationError: boolean
paginationError: unknown
failedHasNextPage: boolean
}
interface SubscriptionRecord {
listener: () => void
active: boolean
}
interface Publication {
targets: Array<SubscriptionRecord>
}
class LiveQueryWindowControllerImpl<
T extends object,
TKey extends string | number,
> implements LiveQueryWindowController<T, TKey> {
private readonly observer: LiveQueryObserver<T, TKey>
private readonly collection: Collection<T, TKey, any> | null
private readonly coordinator: WindowCoordinator | null
private readonly lease = Symbol(`liveQueryWindowLease`)
private readonly pageSize: number
private readonly initialPageParam: number
private committedPageCount: number
private isFetchingNextPage = false
private hasPaginationError = false
private paginationError: unknown
private failedHasNextPage = false
private windowGeneration = 0
private pendingWindowGeneration: number | undefined
private leaseActive = false
private leaseGeneration = 0
private inFlightLeaseHolders = 0
private readonly subscriptions = new Set<SubscriptionRecord>()
private readonly publicationQueue: Array<Publication> = []
private dispatching = false
private blockDelivery = false
private transitionDepth = 0
private transitionNeedsNotify = false
private observerUnsub: (() => void) | null = null
private cachedSnapshot: LiveQueryWindowSnapshot<T, TKey> | null = null
private cachedFrom: CachedFrom | null = null
private disposed = false
constructor(
collection: Collection<T, TKey, any> | null,
options: CreateLiveQueryWindowControllerOptions,
) {
this.collection = collection
this.coordinator = collection
? getWindowCoordinator(collection as unknown as WindowTarget)
: null
this.pageSize =
options.pageSize !== undefined && options.pageSize > 0
? options.pageSize
: DEFAULT_PAGE_SIZE
this.initialPageParam = options.initialPageParam ?? 0
const initialPageCount = Math.floor(options.initialPageCount ?? 1)
this.committedPageCount = Number.isFinite(initialPageCount)
? Math.max(1, initialPageCount)
: 1
// The controller listener carries no delta payload, so wholesale is the
// only coherent observer contract and guarantees non-reentrant subscribe.
this.observer = createLiveQueryObserver<T, TKey>(collection, {
mode: `wholesale`,
})
}
getSnapshot(): LiveQueryWindowSnapshot<T, TKey> {
const observerSnapshot = this.observer.getSnapshot()
const cached = this.cachedSnapshot
if (
cached &&
this.cachedFrom &&
this.cachedFrom.observerSnapshot === observerSnapshot &&
this.cachedFrom.committedPageCount === this.committedPageCount &&
this.cachedFrom.isFetchingNextPage === this.isFetchingNextPage &&
this.cachedFrom.hasPaginationError === this.hasPaginationError &&
this.cachedFrom.paginationError === this.paginationError &&
this.cachedFrom.failedHasNextPage === this.failedHasNextPage
) {
return cached
}
const enabled = observerSnapshot.isEnabled
const rows =
enabled && Array.isArray(observerSnapshot.data)
? (observerSnapshot.data as ReadonlyArray<T>)
: []
const totalRequested = this.committedPageCount * this.pageSize
const computedHasNextPage = enabled && rows.length > totalRequested
const hasNextPage = this.hasPaginationError
? this.failedHasNextPage
: computedHasNextPage
const pageCount = enabled ? this.committedPageCount : 0
const pages: Array<ReadonlyArray<T>> = []
const pageParams: Array<number> = []
for (let i = 0; i < pageCount; i++) {
pages.push(rows.slice(i * this.pageSize, (i + 1) * this.pageSize))
pageParams.push(this.initialPageParam + i)
}
const status = this.hasPaginationError ? `error` : observerSnapshot.status
const statusFlags = this.hasPaginationError
? getLiveQueryStatusFlags(`error`)
: observerSnapshot
this.cachedSnapshot = {
data: rows.slice(0, totalRequested),
pages,
pageParams,
hasNextPage,
isFetchingNextPage: this.isFetchingNextPage,
error: this.hasPaginationError ? this.paginationError : undefined,
state: observerSnapshot.state,
collection: observerSnapshot.collection,
status,
isLoading: statusFlags.isLoading,
isReady: statusFlags.isReady,
isIdle: statusFlags.isIdle,
isError: statusFlags.isError,
isCleanedUp: observerSnapshot.isCleanedUp,
isEnabled: observerSnapshot.isEnabled,
}
this.cachedFrom = {
observerSnapshot,
committedPageCount: this.committedPageCount,
isFetchingNextPage: this.isFetchingNextPage,
hasPaginationError: this.hasPaginationError,
paginationError: this.paginationError,
failedHasNextPage: this.failedHasNextPage,
}
return this.cachedSnapshot
}
subscribe(listener: () => void): () => void {
if (this.disposed) throw new LiveQueryWindowControllerDisposedError()
const record: SubscriptionRecord = { listener, active: true }
this.subscriptions.add(record)
if (this.subscriptions.size === 1) {
this.blockDelivery = true
let observerUnsub: (() => void) | null = null
try {
// Store the desired physical window before observer activation can
// compile or restart the live-query pipeline.
const windowResult = this.ensureLeaseActive(this.committedPageCount)
const leaseGeneration = this.leaseGeneration
observerUnsub = this.observer.subscribe(() => this.onObserverNotify())
this.observerUnsub = observerUnsub
if (windowResult !== true) {
this.trackAttachmentFailure(windowResult, leaseGeneration)
}
} catch (error) {
observerUnsub?.()
this.observerUnsub = null
this.deactivateLease()
record.active = false
this.subscriptions.delete(record)
throw error
} finally {
this.blockDelivery = false
}
}
return () => {
if (!record.active) return
record.active = false
this.subscriptions.delete(record)
if (this.subscriptions.size === 0) {
this.observerUnsub?.()
this.observerUnsub = null
if (this.inFlightLeaseHolders === 0) this.deactivateLease()
}
}
}
fetchNextPage(): Promise<void> {
if (this.disposed || this.isFetchingNextPage) return Promise.resolve()
if (!this.getSnapshot().hasNextPage) return Promise.resolve()
return this.requestPageCount(this.committedPageCount + 1, true)
}
reset(): Promise<void> {
if (this.disposed) return Promise.resolve()
if (
this.committedPageCount === 1 &&
!this.hasPaginationError &&
!this.isFetchingNextPage &&
this.pendingWindowGeneration === undefined
) {
return Promise.resolve()
}
return this.requestPageCount(1, false)
}
async preload(): Promise<void> {
if (this.disposed) throw new LiveQueryWindowControllerDisposedError()
const hadPaginationError = this.hasPaginationError
this.hasPaginationError = false
this.paginationError = undefined
this.acquireInFlightLease()
try {
const result = this.ensureLeaseActive(this.committedPageCount)
if (result !== true) await result
await this.observer.preload()
this.failedHasNextPage = false
if (hadPaginationError) this.notify()
} catch (error) {
this.hasPaginationError = true
this.paginationError = error
this.failedHasNextPage = this.getComputedHasNextPage()
this.notify()
throw error
} finally {
this.releaseInFlightLease()
}
}
dispose(): void {
if (this.disposed) return
this.disposed = true
this.windowGeneration++
this.pendingWindowGeneration = undefined
this.observerUnsub?.()
this.observerUnsub = null
this.deactivateLease()
this.observer.dispose()
for (const record of this.subscriptions) record.active = false
this.subscriptions.clear()
this.publicationQueue.length = 0
}
private requestPageCount(
requestedPageCount: number,
fetchingNextPage: boolean,
): Promise<void> {
const generation = ++this.windowGeneration
const previousHasNextPage = this.getSnapshot().hasNextPage
this.pendingWindowGeneration = undefined
this.acquireInFlightLease()
this.beginTransition()
this.isFetchingNextPage = fetchingNextPage
this.hasPaginationError = false
this.paginationError = undefined
if (fetchingNextPage) this.notify()
let result: WindowResult
try {
result = this.activateLease(requestedPageCount)
} catch (error) {
this.isFetchingNextPage = false
this.hasPaginationError = true
this.paginationError = error
this.failedHasNextPage = previousHasNextPage
this.notify()
this.endTransition()
this.releaseInFlightLease()
return Promise.reject(error)
}
if (result === true) {
if (!this.disposed && generation === this.windowGeneration) {
this.committedPageCount = requestedPageCount
this.isFetchingNextPage = false
this.failedHasNextPage = false
this.notify()
}
this.endTransition()
this.releaseInFlightLease()
return Promise.resolve()
}
this.pendingWindowGeneration = generation
this.endTransition()
return result
.then(
() => {
if (this.disposed || generation !== this.windowGeneration) return
this.beginTransition()
this.pendingWindowGeneration = undefined
this.committedPageCount = requestedPageCount
this.isFetchingNextPage = false
this.failedHasNextPage = false
this.notify()
this.endTransition()
},
(error: unknown) => {
if (!this.disposed && generation === this.windowGeneration) {
this.beginTransition()
this.pendingWindowGeneration = undefined
this.isFetchingNextPage = false
this.hasPaginationError = true
this.paginationError = error
this.failedHasNextPage = previousHasNextPage
this.notify()
this.endTransition()
}
throw error
},
)
.finally(() => {
this.releaseInFlightLease()
})
}
private acquireInFlightLease(): void {
this.inFlightLeaseHolders++
}
private releaseInFlightLease(): void {
this.inFlightLeaseHolders--
if (this.inFlightLeaseHolders === 0 && this.subscriptions.size === 0) {
this.deactivateLease()
}
}
private activateLease(pageCount: number): WindowResult {
this.leaseGeneration++
if (!this.coordinator || !this.collection) return true
this.leaseActive = true
return this.coordinator.request(this.lease, pageCount * this.pageSize + 1)
}
private ensureLeaseActive(pageCount: number): WindowResult {
const minimumLimit = pageCount * this.pageSize + 1
if (
this.leaseActive &&
this.coordinator?.isLeaseSatisfied(this.lease, minimumLimit)
) {
return true
}
return this.activateLease(pageCount)
}
private deactivateLease(): void {
if (!this.leaseActive || !this.coordinator) return
this.leaseGeneration++
this.leaseActive = false
this.coordinator.release(this.lease)
}
private trackAttachmentFailure(
result: Promise<void>,
leaseGeneration: number,
): void {
void result.catch((error: unknown) => {
if (
this.disposed ||
!this.leaseActive ||
leaseGeneration !== this.leaseGeneration
) {
return
}
this.beginTransition()
this.hasPaginationError = true
this.paginationError = error
this.failedHasNextPage = this.getComputedHasNextPage()
this.notify()
this.endTransition()
})
}
private getComputedHasNextPage(): boolean {
const snapshot: LiveQuerySnapshot<T, TKey> = this.observer.getSnapshot()
return (
snapshot.isEnabled &&
Array.isArray(snapshot.data) &&
snapshot.data.length > this.committedPageCount * this.pageSize
)
}
private onObserverNotify(): void {
this.notify()
}
private beginTransition(): void {
this.transitionDepth++
}
private endTransition(): void {
this.transitionDepth--
if (this.transitionDepth === 0 && this.transitionNeedsNotify) {
this.transitionNeedsNotify = false
this.publish()
}
}
private notify(): void {
if (this.transitionDepth > 0) {
this.transitionNeedsNotify = true
return
}
this.publish()
}
private publish(): void {
if (this.disposed || this.blockDelivery || this.subscriptions.size === 0) {
return
}
this.publicationQueue.push({ targets: [...this.subscriptions] })
if (this.dispatching) return
this.dispatching = true
try {
while (this.publicationQueue.length > 0) {
const publication = this.publicationQueue.shift()!
for (const record of publication.targets) {
if (this.hasBeenDisposed()) return
if (!record.active) continue
record.listener()
}
}
} finally {
this.dispatching = false
}
}
private hasBeenDisposed(): boolean {
return this.disposed
}
}
/**
* Create an internal forward-window controller for an ordered live query.
*
* @internal This factory is unstable while RFC #1623 is being implemented.
*/
export function createLiveQueryWindowController<
T extends object,
TKey extends string | number,
>(
collection: Collection<T, TKey, any> | null | undefined,
options: CreateLiveQueryWindowControllerOptions = {},
): LiveQueryWindowController<T, TKey> {
return new LiveQueryWindowControllerImpl<T, TKey>(collection ?? null, options)
}