UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

406 lines (363 loc) 13.9 kB
import { CollectionConfigurationError, CollectionIsInErrorStateError, DuplicateKeySyncError, NoPendingSyncTransactionCommitError, NoPendingSyncTransactionWriteError, SyncCleanupError, SyncTransactionAlreadyCommittedError, SyncTransactionAlreadyCommittedWriteError, } from '../errors' import { deepEquals } from '../utils' import { LIVE_QUERY_INTERNAL } from '../query/live/internal.js' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { ChangeMessageOrDeleteKeyMessage, CleanupFn, CollectionConfig, LoadSubsetOptions, OptimisticChangeMessage, SyncConfigRes, } from '../types' import type { CollectionImpl } from './index.js' import type { CollectionStateManager } from './state' import type { CollectionLifecycleManager } from './lifecycle' import type { CollectionEventsManager } from './events.js' import type { LiveQueryCollectionUtils } from '../query/live/collection-config-builder.js' export class CollectionSyncManager< TOutput extends object = Record<string, unknown>, TKey extends string | number = string | number, TSchema extends StandardSchemaV1 = StandardSchemaV1, TInput extends object = TOutput, > { private collection!: CollectionImpl<TOutput, TKey, any, TSchema, TInput> private state!: CollectionStateManager<TOutput, TKey, TSchema, TInput> private lifecycle!: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput> private _events!: CollectionEventsManager private config!: CollectionConfig<TOutput, TKey, TSchema> private id: string private syncMode: `eager` | `on-demand` public preloadPromise: Promise<void> | null = null public syncCleanupFn: (() => void) | null = null public syncLoadSubsetFn: | ((options: LoadSubsetOptions) => true | Promise<void>) | null = null public syncUnloadSubsetFn: ((options: LoadSubsetOptions) => void) | null = null private pendingLoadSubsetPromises: Set<Promise<void>> = new Set() /** * Creates a new CollectionSyncManager instance */ constructor(config: CollectionConfig<TOutput, TKey, TSchema>, id: string) { this.config = config this.id = id this.syncMode = config.syncMode ?? `eager` } setDeps(deps: { collection: CollectionImpl<TOutput, TKey, any, TSchema, TInput> state: CollectionStateManager<TOutput, TKey, TSchema, TInput> lifecycle: CollectionLifecycleManager<TOutput, TKey, TSchema, TInput> events: CollectionEventsManager }) { this.collection = deps.collection this.state = deps.state this.lifecycle = deps.lifecycle this._events = deps.events } /** * Start the sync process for this collection * This is called when the collection is first accessed or preloaded */ public startSync(): void { if ( this.lifecycle.status !== `idle` && this.lifecycle.status !== `cleaned-up` ) { return // Already started or in progress } this.lifecycle.setStatus(`loading`) try { const syncRes = normalizeSyncFnResult( this.config.sync.sync({ collection: this.collection, begin: () => { this.state.pendingSyncedTransactions.push({ committed: false, operations: [], deletedKeys: new Set(), }) }, write: ( messageWithOptionalKey: ChangeMessageOrDeleteKeyMessage< TOutput, TKey >, ) => { const pendingTransaction = this.state.pendingSyncedTransactions[ this.state.pendingSyncedTransactions.length - 1 ] if (!pendingTransaction) { throw new NoPendingSyncTransactionWriteError() } if (pendingTransaction.committed) { throw new SyncTransactionAlreadyCommittedWriteError() } let key: TKey | undefined = undefined if (`key` in messageWithOptionalKey) { key = messageWithOptionalKey.key } else { key = this.config.getKey(messageWithOptionalKey.value) } let messageType = messageWithOptionalKey.type // Check if an item with this key already exists when inserting if (messageWithOptionalKey.type === `insert`) { const insertingIntoExistingSynced = this.state.syncedData.has(key) const hasPendingDeleteForKey = pendingTransaction.deletedKeys.has(key) const isTruncateTransaction = pendingTransaction.truncate === true // Allow insert after truncate in the same transaction even if it existed in syncedData if ( insertingIntoExistingSynced && !hasPendingDeleteForKey && !isTruncateTransaction ) { const existingValue = this.state.syncedData.get(key) if ( existingValue !== undefined && deepEquals(existingValue, messageWithOptionalKey.value) ) { // The "insert" is an echo of a value we already have locally. // Treat it as an update so we preserve optimistic intent without // throwing a duplicate-key error during reconciliation. messageType = `update` } else { const utils = this.config .utils as Partial<LiveQueryCollectionUtils> const internal = utils[LIVE_QUERY_INTERNAL] throw new DuplicateKeySyncError(key, this.id, { hasCustomGetKey: internal?.hasCustomGetKey ?? false, hasJoins: internal?.hasJoins ?? false, }) } } } const message = { ...messageWithOptionalKey, type: messageType, key, } as OptimisticChangeMessage<TOutput, TKey> pendingTransaction.operations.push(message) if (messageType === `delete`) { pendingTransaction.deletedKeys.add(key) } }, commit: () => { const pendingTransaction = this.state.pendingSyncedTransactions[ this.state.pendingSyncedTransactions.length - 1 ] if (!pendingTransaction) { throw new NoPendingSyncTransactionCommitError() } if (pendingTransaction.committed) { throw new SyncTransactionAlreadyCommittedError() } pendingTransaction.committed = true this.state.commitPendingTransactions() }, markReady: () => { this.lifecycle.markReady() }, truncate: () => { const pendingTransaction = this.state.pendingSyncedTransactions[ this.state.pendingSyncedTransactions.length - 1 ] if (!pendingTransaction) { throw new NoPendingSyncTransactionWriteError() } if (pendingTransaction.committed) { throw new SyncTransactionAlreadyCommittedWriteError() } // Clear all operations from the current transaction pendingTransaction.operations = [] pendingTransaction.deletedKeys.clear() // Mark the transaction as a truncate operation. During commit, this triggers: // - Delete events for all previously synced keys (excluding optimistic-deleted keys) // - Clearing of syncedData/syncedMetadata // - Subsequent synced ops applied on the fresh base // - Finally, optimistic mutations re-applied on top (single batch) pendingTransaction.truncate = true // Capture optimistic state NOW to preserve it even if transactions complete // before this truncate transaction is committed pendingTransaction.optimisticSnapshot = { upserts: new Map(this.state.optimisticUpserts), deletes: new Set(this.state.optimisticDeletes), } }, }), ) // Store cleanup function if provided this.syncCleanupFn = syncRes?.cleanup ?? null // Store loadSubset function if provided this.syncLoadSubsetFn = syncRes?.loadSubset ?? null // Store unloadSubset function if provided this.syncUnloadSubsetFn = syncRes?.unloadSubset ?? null // Validate: on-demand mode requires a loadSubset function if (this.syncMode === `on-demand` && !this.syncLoadSubsetFn) { throw new CollectionConfigurationError( `Collection "${this.id}" is configured with syncMode "on-demand" but the sync function did not return a loadSubset handler. ` + `Either provide a loadSubset handler or use syncMode "eager".`, ) } } catch (error) { this.lifecycle.setStatus(`error`) throw error } } /** * Preload the collection data by starting sync if not already started * Multiple concurrent calls will share the same promise */ public preload(): Promise<void> { if (this.preloadPromise) { return this.preloadPromise } // Warn when calling preload on an on-demand collection if (this.syncMode === `on-demand`) { console.warn( `${this.id ? `[${this.id}] ` : ``}Calling .preload() on a collection with syncMode "on-demand" is a no-op. ` + `In on-demand mode, data is only loaded when queries request it. ` + `Instead, create a live query and call .preload() on that to load the specific data you need. ` + `See https://tanstack.com/blog/tanstack-db-0.5-query-driven-sync for more details.`, ) } this.preloadPromise = new Promise<void>((resolve, reject) => { if (this.lifecycle.status === `ready`) { resolve() return } if (this.lifecycle.status === `error`) { reject(new CollectionIsInErrorStateError()) return } // Register callback BEFORE starting sync to avoid race condition this.lifecycle.onFirstReady(() => { resolve() }) // Start sync if collection hasn't started yet or was cleaned up if ( this.lifecycle.status === `idle` || this.lifecycle.status === `cleaned-up` ) { try { this.startSync() } catch (error) { reject(error) return } } }) return this.preloadPromise } /** * Gets whether the collection is currently loading more data */ public get isLoadingSubset(): boolean { return this.pendingLoadSubsetPromises.size > 0 } /** * Tracks a load promise for isLoadingSubset state. * @internal This is for internal coordination (e.g., live-query glue code), not for general use. */ public trackLoadPromise(promise: Promise<void>): void { const loadingStarting = !this.isLoadingSubset this.pendingLoadSubsetPromises.add(promise) if (loadingStarting) { this._events.emit(`loadingSubset:change`, { type: `loadingSubset:change`, collection: this.collection, isLoadingSubset: true, previousIsLoadingSubset: false, loadingSubsetTransition: `start`, }) } promise.finally(() => { const loadingEnding = this.pendingLoadSubsetPromises.size === 1 && this.pendingLoadSubsetPromises.has(promise) this.pendingLoadSubsetPromises.delete(promise) if (loadingEnding) { this._events.emit(`loadingSubset:change`, { type: `loadingSubset:change`, collection: this.collection, isLoadingSubset: false, previousIsLoadingSubset: true, loadingSubsetTransition: `end`, }) } }) } /** * Requests the sync layer to load more data. * @param options Options to control what data is being loaded * @returns If data loading is asynchronous, this method returns a promise that resolves when the data is loaded. * Returns true if no sync function is configured, if syncMode is 'eager', or if there is no work to do. */ public loadSubset(options: LoadSubsetOptions): Promise<void> | true { // Bypass loadSubset when syncMode is 'eager' if (this.syncMode === `eager`) { return true } if (this.syncLoadSubsetFn) { const result = this.syncLoadSubsetFn(options) // If the result is a promise, track it if (result instanceof Promise) { this.trackLoadPromise(result) return result } } return true } /** * Notifies the sync layer that a subset is no longer needed. * @param options Options that identify what data is being unloaded */ public unloadSubset(options: LoadSubsetOptions): void { if (this.syncUnloadSubsetFn) { this.syncUnloadSubsetFn(options) } } public cleanup(): void { try { if (this.syncCleanupFn) { this.syncCleanupFn() this.syncCleanupFn = null } } catch (error) { // Re-throw in a microtask to surface the error after cleanup completes queueMicrotask(() => { if (error instanceof Error) { // Preserve the original error and stack trace const wrappedError = new SyncCleanupError(this.id, error) wrappedError.cause = error wrappedError.stack = error.stack throw wrappedError } else { throw new SyncCleanupError(this.id, error as Error | string) } }) } this.preloadPromise = null } } function normalizeSyncFnResult(result: void | CleanupFn | SyncConfigRes) { if (typeof result === `function`) { return { cleanup: result } } if (typeof result === `object`) { return result } return undefined }