UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

266 lines (265 loc) 9.87 kB
import { NoPendingSyncTransactionWriteError, SyncTransactionAlreadyCommittedWriteError, NoPendingSyncTransactionCommitError, SyncTransactionAlreadyCommittedError, DuplicateKeySyncError, CollectionConfigurationError, CollectionIsInErrorStateError, SyncCleanupError } from "../errors.js"; import { deepEquals } from "../utils.js"; import { LIVE_QUERY_INTERNAL } from "../query/live/internal.js"; class CollectionSyncManager { /** * Creates a new CollectionSyncManager instance */ constructor(config, id) { this.preloadPromise = null; this.syncCleanupFn = null; this.syncLoadSubsetFn = null; this.syncUnloadSubsetFn = null; this.pendingLoadSubsetPromises = /* @__PURE__ */ new Set(); this.config = config; this.id = id; this.syncMode = config.syncMode ?? `eager`; } setDeps(deps) { 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 */ startSync() { if (this.lifecycle.status !== `idle` && this.lifecycle.status !== `cleaned-up`) { return; } this.lifecycle.setStatus(`loading`); try { const syncRes = normalizeSyncFnResult( this.config.sync.sync({ collection: this.collection, begin: () => { this.state.pendingSyncedTransactions.push({ committed: false, operations: [], deletedKeys: /* @__PURE__ */ new Set() }); }, write: (messageWithOptionalKey) => { const pendingTransaction = this.state.pendingSyncedTransactions[this.state.pendingSyncedTransactions.length - 1]; if (!pendingTransaction) { throw new NoPendingSyncTransactionWriteError(); } if (pendingTransaction.committed) { throw new SyncTransactionAlreadyCommittedWriteError(); } let key = void 0; if (`key` in messageWithOptionalKey) { key = messageWithOptionalKey.key; } else { key = this.config.getKey(messageWithOptionalKey.value); } let messageType = messageWithOptionalKey.type; if (messageWithOptionalKey.type === `insert`) { const insertingIntoExistingSynced = this.state.syncedData.has(key); const hasPendingDeleteForKey = pendingTransaction.deletedKeys.has(key); const isTruncateTransaction = pendingTransaction.truncate === true; if (insertingIntoExistingSynced && !hasPendingDeleteForKey && !isTruncateTransaction) { const existingValue = this.state.syncedData.get(key); if (existingValue !== void 0 && deepEquals(existingValue, messageWithOptionalKey.value)) { messageType = `update`; } else { const utils = this.config.utils; 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 }; 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(); } pendingTransaction.operations = []; pendingTransaction.deletedKeys.clear(); pendingTransaction.truncate = true; pendingTransaction.optimisticSnapshot = { upserts: new Map(this.state.optimisticUpserts), deletes: new Set(this.state.optimisticDeletes) }; } }) ); this.syncCleanupFn = syncRes?.cleanup ?? null; this.syncLoadSubsetFn = syncRes?.loadSubset ?? null; this.syncUnloadSubsetFn = syncRes?.unloadSubset ?? null; 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 */ preload() { if (this.preloadPromise) { return this.preloadPromise; } 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((resolve, reject) => { if (this.lifecycle.status === `ready`) { resolve(); return; } if (this.lifecycle.status === `error`) { reject(new CollectionIsInErrorStateError()); return; } this.lifecycle.onFirstReady(() => { resolve(); }); 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 */ get isLoadingSubset() { 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. */ trackLoadPromise(promise) { 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. */ loadSubset(options) { if (this.syncMode === `eager`) { return true; } if (this.syncLoadSubsetFn) { const result = this.syncLoadSubsetFn(options); 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 */ unloadSubset(options) { if (this.syncUnloadSubsetFn) { this.syncUnloadSubsetFn(options); } } cleanup() { try { if (this.syncCleanupFn) { this.syncCleanupFn(); this.syncCleanupFn = null; } } catch (error) { queueMicrotask(() => { if (error instanceof Error) { const wrappedError = new SyncCleanupError(this.id, error); wrappedError.cause = error; wrappedError.stack = error.stack; throw wrappedError; } else { throw new SyncCleanupError(this.id, error); } }); } this.preloadPromise = null; } } function normalizeSyncFnResult(result) { if (typeof result === `function`) { return { cleanup: result }; } if (typeof result === `object`) { return result; } return void 0; } export { CollectionSyncManager }; //# sourceMappingURL=sync.js.map