@tanstack/db
Version:
A reactive client store for building super fast apps on sync
353 lines (334 loc) • 11.8 kB
text/typescript
import { getLiveQueryRowOptions } from './live-query-options.js'
import { codedMessage, devBuild } from './error-message.js'
import { getBuilderFromConfig } from './query/live/collection-registry.js'
import { getPooledLiveQuerySource } from './query/pooled-source.js'
import { isFailedStatus } from './live-query-primitives.js'
import type { Collection } from './collection/index.js'
import type { DbClient, DehydratedLiveQueryResult } from './client.js'
import type {
LiveQueryObserver,
LiveQuerySnapshot,
} from './live-query-observer.js'
import type { ChangeMessage } from './types.js'
/**
* DbClient supplies transport behavior to the shared observer. Keeping this
* implementation behind the client means ordinary framework queries do not
* import server admission, hydration reconciliation, or streaming machinery.
*/
export interface LiveQueryObserverClientHost<
T extends object,
TKey extends string | number,
> {
observer: LiveQueryObserver<T, TKey>
read: (forServerSnapshot: boolean) => LiveQuerySnapshot<T, TKey>
entries: () => Array<[TKey, T]>
isAttached: () => boolean
isDisposed: () => boolean
dirty: () => void
publish: (
changes: Array<ChangeMessage<T, TKey>>,
entries?: Array<[TKey, T]>,
) => void
preload: () => Promise<void>
}
export class LiveQueryObserverClient<
T extends object,
TKey extends string | number,
> {
private hydrationSeed:
| {
dehydratedAt: number
entries: Array<[TKey, T]>
snapshot: DehydratedLiveQueryResult
}
| undefined
error: unknown
failed = false
private authoritative = false
private handoffScheduled = false
private unregisterResource: (() => void) | undefined
private preloadPromise: Promise<void> | undefined
constructor(
private readonly client: DbClient,
private readonly collection: Collection<T, TKey, any> | null,
private readonly queryHash: string | undefined,
private readonly usesSettledHydration: boolean,
private readonly host: LiveQueryObserverClientHost<T, TKey>,
) {}
get seed() {
return this.authoritative ? undefined : this.hydrationSeed
}
get query() {
return this.queryHash
? this.client._getLiveQuery(this.queryHash)
: undefined
}
sync(forServerSnapshot = false): boolean {
if (!this.queryHash || this.authoritative) return false
this.client._waitForSsrLiveQuery(this.queryHash)
const query = this.client._getLiveQuery(this.queryHash, forServerSnapshot)
if (!query) return false
if (
this.host.isAttached() &&
!this.hydrationSeed &&
this.collection?.status === `ready` &&
!this.collection.isLoadingSubset
) {
return this.markAuthoritative(query.dehydratedAt)
}
if (
query.status === `error` &&
(this.usesSettledHydration ||
!forServerSnapshot ||
!query.initialSnapshot)
) {
const changed = !this.failed || this.error !== query.error
this.error = query.error
this.failed = true
if (changed) this.host.dirty()
return changed
}
const snapshot =
(!this.usesSettledHydration && query.initialSnapshot) || query.snapshot
if (!snapshot || this.hydrationSeed?.snapshot === snapshot) return false
this.hydrationSeed = {
dehydratedAt: query.dehydratedAt,
snapshot,
entries: snapshot.rows.map((row) => [row.key as TKey, row.value as T]),
}
this.error = undefined
this.failed = false
this.host.dirty()
return true
}
getServerSnapshot(preloadRequested: boolean): LiveQuerySnapshot<T, TKey> {
preloadRequested &&= this.client._isSsrStreamingEnabled()
if (
typeof window !== `undefined` &&
!this.authoritative &&
this.queryHash
) {
const waiting = this.client._waitForSsrLiveQuery(this.queryHash, true)
if (waiting) throw waiting
const query = this.client._getLiveQuery(this.queryHash, true)
const snapshot = this.usesSettledHydration
? query?.snapshot
: (query?.initialSnapshot ?? query?.snapshot)
// A later boundary may hydrate before its result promise arrives.
// Local rows cannot substitute for the missing server-rendered view.
if (query?.status === `pending` && !snapshot) {
if (this.usesSettledHydration)
void this.host.observer.preload().catch(() => {})
throw query.promise
}
}
if (preloadRequested) {
void this.host.observer.preload().catch(() => {})
}
if (typeof window === `undefined`) this.assertSources()
const snapshot = this.host.read(true)
const collection = this.collection
if (!collection || this.seed) return snapshot
// React also calls this getter in the browser during hydration. Only
// server reads perform admission; transported seeds win over local activity.
if (typeof window !== `undefined`) return snapshot
// A suspended component has not rendered this loading view. Its settled
// stream will supply the first rendered result.
if (preloadRequested && !snapshot.isReady) return snapshot
const { sources } = this.getSourceInfo()
const entries = this.host.entries()
if (
!this.queryHash ||
(!preloadRequested &&
(entries.length > 0 || snapshot.isReady) &&
!(sources ?? [collection]).every((source) =>
this.client._hasSsrCollectionSnapshot(source),
))
) {
throw new Error(
devBuild() && process.env.NODE_ENV !== `production`
? `SSR cannot render server-only live-query rows or readiness. Await DbClient.preloadLiveQuery() for this query, or explicitly preload its source Collections for hydration. Opaque queries require an explicit serializable queryKey.`
: codedMessage(252),
)
}
// Capture at render, rather than sampling mutable source rows at dehydrate.
// Loading views transfer rows without answering a later data preload.
this.client._captureServerQuery(
this.queryHash,
{
rows: entries.map(([key, value]) => ({ key, value })),
...(snapshot.isReady
? {}
: {
status:
snapshot.isLoadingError || snapshot.isCrashed
? (`error` as const)
: (`loading` as const),
}),
},
sources,
getLiveQueryRowOptions(collection),
)
return this.host.read(true)
}
private diffEntries(
previous: Array<[TKey, T]>,
next: Array<[TKey, T]>,
): Array<ChangeMessage<T, TKey>> {
const previousByKey = new Map(previous)
const nextByKey = new Map(next)
const changes: Array<ChangeMessage<T, TKey>> = []
for (const [key, value] of previous) {
if (!nextByKey.has(key)) changes.push({ type: `delete`, key, value })
}
for (const [key, value] of next) {
const previousValue = previousByKey.get(key)
if (previousValue === undefined)
changes.push({ type: `insert`, key, value })
else if (previousValue !== value)
changes.push({ type: `update`, key, value, previousValue })
}
return changes
}
markAuthoritative(dehydratedAt?: number): boolean {
if (this.authoritative) return false
dehydratedAt ??= this.query?.dehydratedAt
const changed = this.failed
this.error = undefined
this.failed = false
this.authoritative = true
if (dehydratedAt !== undefined && this.queryHash)
this.client._consumeLiveQueryResult(this.queryHash, dehydratedAt)
if (changed) this.host.dirty()
return changed
}
scheduleHandoff(): void {
if (this.handoffScheduled) return
this.handoffScheduled = true
queueMicrotask(() => {
this.handoffScheduled = false
this.handoff()
})
}
handoff(): void {
const collection = this.collection
if (
this.host.isDisposed() ||
!this.host.isAttached() ||
!collection ||
!this.seed ||
// A failure ends the sync run whose result the seed stood in for.
(!isFailedStatus(collection.status) &&
(collection.status !== `ready` || collection.isLoadingSubset))
)
return
const previous = this.seed.entries
const dehydratedAt = this.seed.dehydratedAt
const entries = Array.from(collection.entries()) as Array<[TKey, T]>
this.hydrationSeed = undefined
this.markAuthoritative(dehydratedAt)
this.host.publish(this.diffEntries(previous, entries), entries)
}
assertSources(): void {
const { sources } = this.getSourceInfo()
if (this.queryHash && sources)
this.client._assertLiveQuerySources(
this.queryHash,
sources,
getLiveQueryRowOptions(this.collection),
)
}
subscribe(): () => void {
if (!this.queryHash) return () => {}
return this.client.subscribe((event) => {
if (
event.type === `liveQueryStreamError` ||
event.query.queryHash !== this.queryHash
)
return
const previous = this.host.entries()
if (this.sync())
this.host.publish(this.diffEntries(previous, this.host.entries()))
})
}
registerResource(): void {
const config: object | undefined = this.collection?.config
if (
this.unregisterResource ||
!this.client._isSsrServerCleanupEnabled() ||
!config ||
!getBuilderFromConfig(config)
)
return
this.unregisterResource = this.client._registerLiveQueryResource(
this.host.observer,
async () => {
this.host.observer.dispose()
await this.collection?.cleanup({ dependents: true })
},
)
}
takeResourceRelease(): (() => void) | undefined {
const release = this.unregisterResource
this.unregisterResource = undefined
return release
}
private getSourceInfo() {
const collection = this.collection
const config = collection?.config
const pooledSource = collection && getPooledLiveQuerySource(collection)
const sources = config
? getBuilderFromConfig(config)?.getSourceCollections()
: pooledSource
? [pooledSource]
: undefined
return { sources, pooledSource: pooledSource || undefined }
}
preload(): Promise<void> {
if (this.preloadPromise) return this.preloadPromise
const { sources, pooledSource } = this.getSourceInfo()
if (this.queryHash) {
const query = this.query
try {
this.client._waitForSsrLiveQuery(this.queryHash)
if (sources)
this.client._assertLiveQuerySources(
this.queryHash,
sources,
getLiveQueryRowOptions(this.collection),
)
if (
query?.status === `pending` ||
(query?.status === `success` && query.snapshot?.status === undefined)
) {
if (pooledSource && this.collection)
return Promise.all([query.promise, this.collection.preload()]).then(
() => {},
)
// Stream reuse remains explicit demand for the local Collection.
this.collection?._markPreload()
return query.status === `pending` ? query.promise : Promise.resolve()
}
} catch (error) {
return Promise.reject(error)
}
}
this.registerResource()
const collectionPromise = this.host.preload()
const promise =
this.client._isSsrStreamingEnabled() && this.queryHash
? this.client._registerLiveQuery(
this.queryHash,
collectionPromise.then(() => this.host.observer.dehydrate()),
sources,
getLiveQueryRowOptions(this.collection),
)
: collectionPromise
this.preloadPromise = promise
const clear = () => {
if (this.preloadPromise === promise) this.preloadPromise = undefined
}
void promise.then(clear, clear)
return promise
}
}