UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

899 lines (805 loc) 27.2 kB
import { createCollection } from './collection/index.js' import { collectionOptionsBrand, collectionOptionsFactory, hasCollectionOptionsBrand, } from './collection-options.js' import { TransactionScope } from './transactions.js' import { getBuilderFromConfig } from './query/live/collection-registry.js' import { createLiveQueryCollection } from './query/live-query-collection.js' import { createLiveQueryObserver } from './live-query-observer.js' import { getLiveQueryHash, prepareLiveQueryValue, } from './live-query-options.js' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { Collection } from './collection/index.js' import type { CollectionOptionsIdentity } from './collection-options.js' import type { CollectionConfig, InferSchemaInput, InferSchemaOutput, NonSingleResult, SingleResult, TransactionConfig, UtilsRecord, } from './types.js' import type { DeferredLiveQueryCollections, LiveQueryOptions, } from './live-query-options.js' const collectionConfigFactory: unique symbol = Symbol.for( `@tanstack/db.collectionConfig.factory`, ) as never type AnyCollectionConfig = CollectionConfig<any, any, any, any> export type CollectionOptions< T extends object = Record<string, unknown>, TKey extends string | number = string | number, TSchema extends StandardSchemaV1 = never, TUtils extends UtilsRecord = UtilsRecord, > = CollectionOptionsIdentity<T, TKey, TSchema, TUtils, DbClient> type AnyCollectionOptions = CollectionOptions<any, any, any, any> type AnyCollection = Collection<any, any, any, any, any> type DescriptorFromConfig<TConfig extends AnyCollectionConfig> = TConfig extends { getKey: (item: infer T) => infer TKey } ? CollectionOptions< Extract<T, object>, Extract<TKey, string | number>, TConfig extends { schema: infer TSchema extends StandardSchemaV1 } ? TSchema : never, TConfig extends { utils: infer TUtils extends UtilsRecord } ? TUtils : UtilsRecord > & (TConfig extends SingleResult ? SingleResult : NonSingleResult) : never type CollectionConfigWithFactory<TConfig extends AnyCollectionConfig> = TConfig & { readonly [collectionConfigFactory]: (client: DbClient) => TConfig } /** * Adds a fresh-config materializer to an adapter options object. * * Adapter option creators should use this so a module-scoped descriptor can be * materialized safely by more than one DbClient. */ export function withCollectionConfigFactory< TConfig extends AnyCollectionConfig, >( config: TConfig, factory: (client: DbClient) => TConfig, ): CollectionConfigWithFactory<TConfig> { Object.defineProperty(config, collectionConfigFactory, { value: factory, enumerable: false, }) return config as CollectionConfigWithFactory<TConfig> } export type CollectionMaterializeOptions<T extends object> = { initialData?: Array<T> } export type DehydratedCollectionRow< T extends object = Record<string, unknown>, TKey extends string | number = string | number, > = { key: TKey value: T metadata?: unknown } export type DehydratedCollectionChunk< T extends object = Record<string, unknown>, TKey extends string | number = string | number, > = { collectionId: string rows: Array<DehydratedCollectionRow<T, TKey>> syncMeta?: unknown } export type DehydratedLiveQuery = { queryHash: string dehydratedAt: number snapshot?: DehydratedLiveQueryResult promise?: Promise<DehydratedLiveQueryResult> } export type DehydratedLiveQueryResult< T extends object = object, TKey extends string | number = string | number, > = { rows: Array<DehydratedCollectionRow<T, TKey>> } export type DehydratedDbState = { collections: Array<DehydratedCollectionChunk> liveQueries?: Array<DehydratedLiveQuery> } export type DbClientLiveQueryState = `pending` | `success` | `error` export type DbClientLiveQuery = { readonly queryHash: string readonly dehydratedAt: number readonly status: DbClientLiveQueryState readonly promise: Promise<void> readonly snapshot?: DehydratedLiveQueryResult readonly error?: unknown } export type DbClientEvent = | { type: `liveQueryAdded` | `liveQueryUpdated` query: DbClientLiveQuery } | { type: `liveQueryStreamError` error: unknown } export type DehydrateDbClientOptions = { shouldDehydrateCollection?: (collection: Collection) => boolean shouldDehydrateLiveQuery?: (query: DbClientLiveQuery) => boolean } type CollectionRecord = { collection: AnyCollection shouldDehydrate: boolean } type LiveQueryRecord = { queryHash: string dehydratedAt: number status: DbClientLiveQueryState promise: Promise<void> resultPromise: Promise<DehydratedLiveQueryResult> succeed: (snapshot: DehydratedLiveQueryResult) => void fail: (error: unknown) => void snapshot?: DehydratedLiveQueryResult error?: unknown } export type DbClientOptions = Record<string, unknown> export function collectionOptions< T extends StandardSchemaV1, TKey extends string | number, TUtils extends UtilsRecord, >( options: CollectionConfig<InferSchemaOutput<T>, TKey, T, TUtils> & { schema: T } & NonSingleResult, ): CollectionOptions<InferSchemaOutput<T>, TKey, T, TUtils> & NonSingleResult export function collectionOptions< T extends StandardSchemaV1, TKey extends string | number, TUtils extends UtilsRecord, >( options: CollectionConfig<InferSchemaOutput<T>, TKey, T, TUtils> & { schema: T } & SingleResult, ): CollectionOptions<InferSchemaOutput<T>, TKey, T, TUtils> & SingleResult export function collectionOptions< T extends object, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, >( options: CollectionConfig<T, TKey, never, TUtils> & { schema?: never } & NonSingleResult, ): CollectionOptions<T, TKey, never, TUtils> & NonSingleResult export function collectionOptions< T extends object, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, >( options: CollectionConfig<T, TKey, never, TUtils> & { schema?: never } & SingleResult, ): CollectionOptions<T, TKey, never, TUtils> & SingleResult export function collectionOptions<TConfig extends AnyCollectionConfig>( id: string, factory: (client: DbClient) => TConfig, ): DescriptorFromConfig<TConfig> export function collectionOptions( optionsOrId: AnyCollectionConfig | string, explicitFactory?: (client: DbClient) => unknown, ): any { const config = typeof optionsOrId === `string` ? undefined : optionsOrId const id = typeof optionsOrId === `string` ? optionsOrId : optionsOrId.id if (!id) { throw new Error( `collectionOptions requires a non-empty explicit id so the descriptor is stable across DbClient instances and SSR boundaries.`, ) } if (typeof optionsOrId === `string` && !explicitFactory) { throw new Error( `collectionOptions("${id}") requires a factory as its second argument.`, ) } const reusableFactory: | ((client: DbClient) => AnyCollectionConfig) | undefined = config ? (config as CollectionConfigWithFactory<AnyCollectionConfig>)[ collectionConfigFactory ] : (explicitFactory as | ((client: DbClient) => AnyCollectionConfig) | undefined) let owner: DbClient | undefined const materialize = (client: DbClient): AnyCollectionConfig => { let materialized: AnyCollectionConfig if (reusableFactory) { materialized = reusableFactory(client) } else { if (owner && owner !== client) { throw new Error( `Collection descriptor "${id}" was created from a concrete config that cannot be safely reused across DbClient instances. ` + `Use collectionOptions("${id}", (client) => adapterCollectionOptions(...)) or an adapter options creator that supports DbClient materialization.`, ) } owner = client materialized = config! } if (materialized.id !== undefined && materialized.id !== id) { throw new Error( `Collection descriptor "${id}" materialized a config with id "${materialized.id}". Descriptor and collection ids must match.`, ) } return materialized.id === id ? materialized : { ...materialized, id } } const descriptor = { id, ...((config as { singleResult?: boolean } | undefined)?.singleResult === true ? { singleResult: true as const } : {}), } as Record<PropertyKey, unknown> Object.defineProperties(descriptor, { [collectionOptionsBrand]: { value: true, enumerable: false, }, [collectionOptionsFactory]: { value: materialize, enumerable: false, }, }) return Object.freeze(descriptor) as CollectionOptions< any, string | number, any, UtilsRecord > } export function isCollectionOptions( value: unknown, ): value is CollectionOptions<any, string | number, any, UtilsRecord> { return hasCollectionOptionsBrand(value) } export class DbClient { private collectionsByOptions = new WeakMap<object, AnyCollection>() private collectionsById = new Map<string, CollectionRecord>() private pendingHydration = new Map<string, Array<DehydratedCollectionChunk>>() private liveQueries = new Map<string, LiveQueryRecord>() private preloadedLiveQueries = new Map< string, { collection: AnyCollection observer: { dispose: () => void } } >() private liveQueryResources = new Map<object, () => Promise<void>>() private listeners = new Set<(event: DbClientEvent) => void>() private ssrStreamingEnabled = false private ssrServerCleanupEnabled = false private lastLiveQueryTimestamp = 0 private readonly transactionScope = new TransactionScope() constructor(private readonly options: DbClientOptions = {}) {} getDependency<T>(key: string): T | undefined { return this.options[key] as T | undefined } requireDependency<T>(key: string): T { const dependency = this.getDependency<T>(key) if (dependency === undefined) { throw new Error( `DbClient is missing the required "${key}" dependency. Pass it explicitly when constructing the client: new DbClient({ ${key} }).`, ) } return dependency } get activeTransaction() { return this.transactionScope.getActiveTransaction() } createTransaction<T extends object = Record<string, unknown>>( config: TransactionConfig<T>, ) { return this.transactionScope.createTransaction(config) } preloadLiveQuery(options: LiveQueryOptions): Promise<void> { const deferredCollections: DeferredLiveQueryCollections = new Set() try { const prepared = prepareLiveQueryValue(options, this, deferredCollections) const queryHash = getLiveQueryHash(prepared, options.queryKey) const existing = this.liveQueries.get(queryHash) if (existing && existing.status !== `error`) return existing.promise const failedPreload = this.preloadedLiveQueries.get(queryHash) if (failedPreload) { failedPreload.observer.dispose() void failedPreload.collection.cleanup().catch(() => {}) this.preloadedLiveQueries.delete(queryHash) } const collection = createLiveQueryCollection({ ...(prepared as LiveQueryOptions), startSync: true, }) as AnyCollection const observer = createLiveQueryObserver(collection, { client: this, queryHash, mode: `wholesale`, }) this.preloadedLiveQueries.set(queryHash, { collection, observer }) return this._registerLiveQuery( queryHash, collection.preload().then(() => observer.dehydrate()), ) } finally { for (const source of deferredCollections) source._resumeSyncStart() deferredCollections.clear() } } collection< T extends StandardSchemaV1, TKey extends string | number, TUtils extends UtilsRecord, >( options: CollectionOptions<InferSchemaOutput<T>, TKey, T, TUtils> & NonSingleResult, materializeOptions?: CollectionMaterializeOptions<InferSchemaInput<T>>, ): Collection<InferSchemaOutput<T>, TKey, TUtils, T, InferSchemaInput<T>> & NonSingleResult collection< T extends StandardSchemaV1, TKey extends string | number, TUtils extends UtilsRecord, >( options: CollectionOptions<InferSchemaOutput<T>, TKey, T, TUtils> & SingleResult, materializeOptions?: CollectionMaterializeOptions<InferSchemaInput<T>>, ): Collection<InferSchemaOutput<T>, TKey, TUtils, T, InferSchemaInput<T>> & SingleResult collection< T extends object, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, >( options: CollectionOptions<T, TKey, never, TUtils> & NonSingleResult, materializeOptions?: CollectionMaterializeOptions<T>, ): Collection<T, TKey, TUtils, never, T> & NonSingleResult collection< T extends object, TKey extends string | number = string | number, TUtils extends UtilsRecord = UtilsRecord, >( options: CollectionOptions<T, TKey, never, TUtils> & SingleResult, materializeOptions?: CollectionMaterializeOptions<T>, ): Collection<T, TKey, TUtils, never, T> & SingleResult collection( options: AnyCollectionOptions, materializeOptions?: CollectionMaterializeOptions<any>, ): AnyCollection { return this.materializeCollection(options, materializeOptions, false) } /** @internal */ _materializeCollectionForRender< T extends object, TKey extends string | number, TSchema extends StandardSchemaV1, TUtils extends UtilsRecord, >( options: CollectionOptions<T, TKey, TSchema, TUtils>, ): Collection< T, TKey, TUtils, TSchema, [TSchema] extends [never] ? T : InferSchemaInput<TSchema> > { return this.materializeCollection(options, undefined, true) } private materializeCollection( options: AnyCollectionOptions, materializeOptions: CollectionMaterializeOptions<any> | undefined, deferSyncStart: boolean, ): AnyCollection { const existing = this.collectionsByOptions.get(options) if (existing) { if (!deferSyncStart) { this.collectionsById.get(existing.id)!.shouldDehydrate = true } if (deferSyncStart) { existing._deferSyncStart() } return existing } if (this.collectionsById.has(options.id)) { throw new Error( `Cannot materialize collection "${options.id}" because this DbClient already has a different collection with that id. SSR hydration requires collection ids to be unique per DbClient.`, ) } const config = options[collectionOptionsFactory](this) const shouldStartSync = config.startSync === true const collection = createCollection({ ...config, startSync: false, } as any) collection._setTransactionScope(this.transactionScope) if (deferSyncStart) { collection._deferSyncStart() } this.collectionsByOptions.set(options, collection) this.collectionsById.set(collection.id, { collection, shouldDehydrate: !deferSyncStart, }) if (materializeOptions?.initialData?.length) { this.applyRows( collection, { collectionId: collection.id, rows: materializeOptions.initialData.map((value) => ({ value })), }, `initialData`, ) } const pendingChunks = this.pendingHydration.get(collection.id) if (pendingChunks) { for (const chunk of pendingChunks) { this.applyRows(collection, chunk, `hydration`) } this.pendingHydration.delete(collection.id) } if (shouldStartSync) { collection.startSyncImmediate() } return collection } dehydrate(options: DehydrateDbClientOptions = {}): DehydratedDbState { const collections: Array<DehydratedCollectionChunk> = [] for (const { collection, shouldDehydrate, } of this.collectionsById.values()) { const collectionDecision = options.shouldDehydrateCollection?.(collection) if ( getBuilderFromConfig(collection.config) || collectionDecision === false || (!shouldDehydrate && collectionDecision !== true) ) { continue } const rows = Array.from(collection._state.syncedData.entries()).map( ([key, value]) => { const metadata = collection._state.syncedMetadata.get(key) return { key, value, ...(metadata === undefined ? {} : { metadata }), } }, ) collections.push({ collectionId: collection.id, rows, syncMeta: collection.config.sync.exportSyncMeta?.(), }) } const liveQueries = Array.from(this.liveQueries.values()).flatMap( (query): Array<DehydratedLiveQuery> => { const shouldDehydrate = options.shouldDehydrateLiveQuery?.(query) ?? query.status === `success` if (!shouldDehydrate || query.status === `error`) { return [] } return [ { queryHash: query.queryHash, dehydratedAt: query.dehydratedAt, ...(query.snapshot ? { snapshot: query.snapshot } : { promise: query.resultPromise }), }, ] }, ) return { collections, ...(liveQueries.length > 0 ? { liveQueries } : {}), } } hydrate(state: DehydratedDbState): void { for (const chunk of state.collections) { const record = this.collectionsById.get(chunk.collectionId) if (record) { this.applyRows(record.collection, chunk, `hydration`) continue } const pendingChunks = this.pendingHydration.get(chunk.collectionId) ?? [] pendingChunks.push(chunk) this.pendingHydration.set(chunk.collectionId, pendingChunks) } for (const dehydratedQuery of state.liveQueries ?? []) { this.hydrateLiveQuery(dehydratedQuery) } } applyCollectionChunk(chunk: DehydratedCollectionChunk): void { this.hydrate({ collections: [chunk] }) } subscribe(listener: (event: DbClientEvent) => void): () => void { this.listeners.add(listener) return () => this.listeners.delete(listener) } /** @internal */ _setSsrStreamingEnabled(enabled: boolean): void { this.ssrStreamingEnabled = enabled } /** @internal */ _isSsrStreamingEnabled(): boolean { return this.ssrStreamingEnabled } /** @internal */ _setSsrServerCleanupEnabled(enabled: boolean): void { this.ssrServerCleanupEnabled = enabled } /** @internal */ _isSsrServerCleanupEnabled(): boolean { return this.ssrServerCleanupEnabled } /** @internal */ _getLiveQuery(queryHash: string): DbClientLiveQuery | undefined { return this.liveQueries.get(queryHash) } /** @internal */ _consumeLiveQueryResult(queryHash: string, dehydratedAt: number): void { const record = this.liveQueries.get(queryHash) if (record?.dehydratedAt === dehydratedAt) { this.liveQueries.delete(queryHash) } } /** @internal */ _registerLiveQuery( queryHash: string, promise: Promise<DehydratedLiveQueryResult>, ): Promise<void> { const existing = this.liveQueries.get(queryHash) if (existing && existing.status !== `error`) { void Promise.resolve(promise).catch(() => {}) return existing.promise } const record = this.createLiveQueryRecord( queryHash, this.nextLiveQueryTimestamp(), ) this.liveQueries.set(queryHash, record) this.emit({ type: `liveQueryAdded`, query: record }) Promise.resolve(promise).then(record.succeed, record.fail) return record.promise } /** @internal */ _registerLiveQueryResource( owner: object, cleanup: () => Promise<void>, ): () => void { this.liveQueryResources.set(owner, cleanup) return () => { if (this.liveQueryResources.get(owner) === cleanup) { this.liveQueryResources.delete(owner) } } } /** @internal */ _failPendingLiveQueries(error: unknown): void { for (const record of this.liveQueries.values()) { if (record.status === `pending`) record.fail(error) } this.emit({ type: `liveQueryStreamError`, error }) } async cleanup(): Promise<void> { try { const materializedCollections = Array.from( this.collectionsById.values(), ({ collection }) => collection, ) const preloadedQueries = Array.from(this.preloadedLiveQueries.values()) const liveQueryCollections = new Set([ ...preloadedQueries.map(({ collection }) => collection), ...materializedCollections.filter((collection) => getBuilderFromConfig(collection.config), ), ]) for (const { observer } of preloadedQueries) observer.dispose() const cleanupResults = [ ...(await Promise.allSettled( Array.from(this.liveQueryResources.values(), (cleanup) => cleanup()), )), ...(await Promise.allSettled( Array.from(liveQueryCollections, (collection) => collection.cleanup(), ), )), ...(await Promise.allSettled( materializedCollections .filter((collection) => !liveQueryCollections.has(collection)) .map((collection) => collection.cleanup()), )), ] const failure = cleanupResults.find( (result): result is PromiseRejectedResult => result.status === `rejected`, ) if (failure) throw failure.reason } finally { this.transactionScope.clear() this.collectionsByOptions = new WeakMap() this.collectionsById.clear() this.pendingHydration.clear() this.liveQueries.clear() this.preloadedLiveQueries.clear() this.liveQueryResources.clear() this.listeners.clear() this.ssrStreamingEnabled = false this.ssrServerCleanupEnabled = false this.lastLiveQueryTimestamp = 0 } } private hydrateLiveQuery(dehydratedQuery: DehydratedLiveQuery): void { const existing = this.liveQueries.get(dehydratedQuery.queryHash) if (existing && existing.dehydratedAt >= dehydratedQuery.dehydratedAt) { return } const record = this.createLiveQueryRecord( dehydratedQuery.queryHash, dehydratedQuery.dehydratedAt, ) this.liveQueries.set(record.queryHash, record) if (existing?.status === `pending`) { void record.resultPromise.then(existing.succeed, existing.fail) } this.emit({ type: `liveQueryAdded`, query: record }) if (dehydratedQuery.snapshot) { record.succeed(dehydratedQuery.snapshot) } else if (dehydratedQuery.promise) { Promise.resolve(dehydratedQuery.promise).then(record.succeed, record.fail) } else { record.fail( new Error( `Dehydrated live query "${dehydratedQuery.queryHash}" has neither a snapshot nor a promise.`, ), ) } } private nextLiveQueryTimestamp(): number { this.lastLiveQueryTimestamp = Math.max( Date.now(), this.lastLiveQueryTimestamp + 1, ) return this.lastLiveQueryTimestamp } private createLiveQueryRecord( queryHash: string, dehydratedAt: number, ): LiveQueryRecord { this.lastLiveQueryTimestamp = Math.max( this.lastLiveQueryTimestamp, dehydratedAt, ) let resolveResult!: (snapshot: DehydratedLiveQueryResult) => void let rejectResult!: (error: unknown) => void let settled = false const resultPromise = new Promise<DehydratedLiveQueryResult>( (resolve, reject) => { resolveResult = resolve rejectResult = reject }, ) const promise = resultPromise.then(() => undefined) resultPromise.catch(() => {}) promise.catch(() => {}) const record: LiveQueryRecord = { queryHash, dehydratedAt, status: `pending`, promise, resultPromise, succeed: (snapshot) => { if (settled) return settled = true record.status = `success` record.snapshot = snapshot resolveResult(snapshot) if (this.liveQueries.get(queryHash) === record) { this.emit({ type: `liveQueryUpdated`, query: record }) } }, fail: (error) => { if (settled) return settled = true record.status = `error` record.error = error rejectResult(error) if (this.liveQueries.get(queryHash) === record) { this.emit({ type: `liveQueryUpdated`, query: record }) } }, } return record } private emit(event: DbClientEvent): void { for (const listener of this.listeners) { listener(event) } } private applyRows( collection: Collection<any, string | number, any, any, any>, chunk: Omit<DehydratedCollectionChunk, `rows`> & { rows: Array< Omit<DehydratedCollectionRow, `key`> & { key?: string | number } > }, seedKind?: `initialData` | `hydration`, ): void { const rows = chunk.rows.flatMap((row) => { const value = collection.validateData(row.value, `insert`) const key = collection.config.getKey(value) const isAdapterAuthoritative = seedKind === `hydration` && collection._state.syncedData.has(key) && !collection._state.hydrationSeedKeys.has(key) return isAdapterAuthoritative ? [] : [{ ...row, key, value }] }) const rowMetadataWrites = new Map< string | number, { type: `set`; value: unknown } | { type: `delete` } >() for (const row of rows) { if (row.metadata !== undefined) { rowMetadataWrites.set(row.key, { type: `set`, value: row.metadata }) } } if (seedKind) { for (const row of rows) { collection._state.hydrationSeedKeys.add(row.key) if (seedKind === `hydration`) { collection._state.hydratedKeys.add(row.key) } } } if (rows.length > 0) { collection._state.pendingSyncedTransactions.push({ committed: true, layoutChanged: false, operations: rows.map((row) => ({ type: collection._state.syncedData.has(row.key) ? (`update` as const) : (`insert` as const), key: row.key, value: row.value, })), deletedKeys: new Set(), rowMetadataWrites, collectionMetadataWrites: new Map(), immediate: true, preserveHydrationSeedKeys: seedKind !== undefined, }) collection._state.commitPendingTransactions() } if (chunk.syncMeta !== undefined) { const currentMeta = collection.config.sync.exportSyncMeta?.() const mergedMeta = currentMeta === undefined ? chunk.syncMeta : (collection.config.sync.mergeSyncMeta?.( currentMeta, chunk.syncMeta, ) ?? chunk.syncMeta) collection.config.sync.importSyncMeta?.(mergedMeta) } } }