UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

846 lines (845 loc) 28.3 kB
"use strict"; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); const store = require("@tanstack/store"); const proxy = require("./proxy.cjs"); const transactions = require("./transactions.cjs"); const SortedMap = require("./SortedMap.cjs"); const collectionsStore = /* @__PURE__ */ new Map(); const loadingCollectionResolvers = /* @__PURE__ */ new Map(); function createCollection(options) { const collection = new CollectionImpl(options); if (options.utils) { collection.utils = { ...options.utils }; } else { collection.utils = {}; } return collection; } function preloadCollection(config) { if (!config.id) { throw new Error(`The id property is required for preloadCollection`); } if (collectionsStore.has(config.id) && !loadingCollectionResolvers.has(config.id)) { return Promise.resolve( collectionsStore.get(config.id) ); } if (loadingCollectionResolvers.has(config.id)) { return loadingCollectionResolvers.get(config.id).promise; } if (!collectionsStore.has(config.id)) { collectionsStore.set( config.id, createCollection({ id: config.id, getKey: config.getKey, sync: config.sync, schema: config.schema }) ); } const collection = collectionsStore.get(config.id); let resolveFirstCommit; const firstCommitPromise = new Promise((resolve) => { resolveFirstCommit = resolve; }); loadingCollectionResolvers.set(config.id, { promise: firstCommitPromise, resolve: resolveFirstCommit }); collection.onFirstCommit(() => { if (!config.id) { throw new Error(`The id property is required for preloadCollection`); } if (loadingCollectionResolvers.has(config.id)) { const resolver = loadingCollectionResolvers.get(config.id); loadingCollectionResolvers.delete(config.id); resolver.resolve(collection); } }); return firstCommitPromise; } class SchemaValidationError extends Error { constructor(type, issues, message) { const defaultMessage = `${type === `insert` ? `Insert` : `Update`} validation failed: ${issues.map((issue) => issue.message).join(`, `)}`; super(message || defaultMessage); this.name = `SchemaValidationError`; this.type = type; this.issues = issues; } } class CollectionImpl { /** * Creates a new Collection instance * * @param config - Configuration object for the collection * @throws Error if sync config is missing */ constructor(config) { this.syncedData = /* @__PURE__ */ new Map(); this.syncedMetadata = /* @__PURE__ */ new Map(); this.derivedUpserts = /* @__PURE__ */ new Map(); this.derivedDeletes = /* @__PURE__ */ new Set(); this._size = 0; this.changeListeners = /* @__PURE__ */ new Set(); this.changeKeyListeners = /* @__PURE__ */ new Map(); this.utils = {}; this.pendingSyncedTransactions = []; this.syncedKeys = /* @__PURE__ */ new Set(); this.hasReceivedFirstCommit = false; this.onFirstCommitCallbacks = []; this.id = ``; this.commitPendingTransactions = () => { if (!Array.from(this.transactions.values()).some( ({ state }) => state === `persisting` )) { const changedKeys = /* @__PURE__ */ new Set(); const events = []; for (const transaction of this.pendingSyncedTransactions) { for (const operation of transaction.operations) { const key = operation.key; changedKeys.add(key); this.syncedKeys.add(key); switch (operation.type) { case `insert`: this.syncedMetadata.set(key, operation.metadata); break; case `update`: this.syncedMetadata.set( key, Object.assign( {}, this.syncedMetadata.get(key), operation.metadata ) ); break; case `delete`: this.syncedMetadata.delete(key); break; } const previousValue = this.syncedData.get(key); switch (operation.type) { case `insert`: this.syncedData.set(key, operation.value); if (!this.derivedDeletes.has(key) && !this.derivedUpserts.has(key)) { events.push({ type: `insert`, key, value: operation.value }); } break; case `update`: { const updatedValue = Object.assign( {}, this.syncedData.get(key), operation.value ); this.syncedData.set(key, updatedValue); if (!this.derivedDeletes.has(key) && !this.derivedUpserts.has(key)) { events.push({ type: `update`, key, value: updatedValue, previousValue }); } break; } case `delete`: this.syncedData.delete(key); if (!this.derivedDeletes.has(key) && !this.derivedUpserts.has(key)) { if (previousValue) { events.push({ type: `delete`, key, value: previousValue }); } } break; } } } this._size = this.calculateSize(); this.emitEvents(events); this.pendingSyncedTransactions = []; if (!this.hasReceivedFirstCommit) { this.hasReceivedFirstCommit = true; const callbacks = [...this.onFirstCommitCallbacks]; this.onFirstCommitCallbacks = []; callbacks.forEach((callback) => callback()); } } }; this.insert = (data, config2) => { const ambientTransaction = transactions.getActiveTransaction(); if (!ambientTransaction && !this.config.onInsert) { throw new Error( `Collection.insert called directly (not within an explicit transaction) but no 'onInsert' handler is configured.` ); } const items = Array.isArray(data) ? data : [data]; const mutations = []; items.forEach((item) => { var _a, _b; const validatedData = this.validateData(item, `insert`); const key = this.getKeyFromItem(item); if (this.has(key)) { throw `Cannot insert document with ID "${key}" because it already exists in the collection`; } const globalKey = this.generateGlobalKey(key, item); const mutation = { mutationId: crypto.randomUUID(), original: {}, modified: validatedData, changes: validatedData, globalKey, key, metadata: config2 == null ? void 0 : config2.metadata, syncMetadata: ((_b = (_a = this.config.sync).getSyncMetadata) == null ? void 0 : _b.call(_a)) || {}, type: `insert`, createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date(), collection: this }; mutations.push(mutation); }); if (ambientTransaction) { ambientTransaction.applyMutations(mutations); this.transactions.set(ambientTransaction.id, ambientTransaction); this.recomputeOptimisticState(); return ambientTransaction; } else { const directOpTransaction = new transactions.Transaction({ mutationFn: async (params) => { return this.config.onInsert(params); } }); directOpTransaction.applyMutations(mutations); directOpTransaction.commit(); this.transactions.set(directOpTransaction.id, directOpTransaction); this.recomputeOptimisticState(); return directOpTransaction; } }; this.delete = (keys, config2) => { const ambientTransaction = transactions.getActiveTransaction(); if (!ambientTransaction && !this.config.onDelete) { throw new Error( `Collection.delete called directly (not within an explicit transaction) but no 'onDelete' handler is configured.` ); } if (Array.isArray(keys) && keys.length === 0) { throw new Error(`No keys were passed to delete`); } const keysArray = Array.isArray(keys) ? keys : [keys]; const mutations = []; for (const key of keysArray) { const globalKey = this.generateGlobalKey(key, this.get(key)); const mutation = { mutationId: crypto.randomUUID(), original: this.get(key) || {}, modified: this.get(key), changes: this.get(key) || {}, globalKey, key, metadata: config2 == null ? void 0 : config2.metadata, syncMetadata: this.syncedMetadata.get(key) || {}, type: `delete`, createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date(), collection: this }; mutations.push(mutation); } if (ambientTransaction) { ambientTransaction.applyMutations(mutations); this.transactions.set(ambientTransaction.id, ambientTransaction); this.recomputeOptimisticState(); return ambientTransaction; } const directOpTransaction = new transactions.Transaction({ autoCommit: true, mutationFn: async (params) => { return this.config.onDelete(params); } }); directOpTransaction.applyMutations(mutations); directOpTransaction.commit(); this.transactions.set(directOpTransaction.id, directOpTransaction); this.recomputeOptimisticState(); return directOpTransaction; }; if (!config) { throw new Error(`Collection requires a config`); } if (config.id) { this.id = config.id; } else { this.id = crypto.randomUUID(); } if (!config.sync) { throw new Error(`Collection requires a sync config`); } this.transactions = new SortedMap.SortedMap( (a, b) => a.createdAt.getTime() - b.createdAt.getTime() ); this.config = config; config.sync.sync({ collection: this, begin: () => { this.pendingSyncedTransactions.push({ committed: false, operations: [] }); }, write: (messageWithoutKey) => { const pendingTransaction = this.pendingSyncedTransactions[this.pendingSyncedTransactions.length - 1]; if (!pendingTransaction) { throw new Error(`No pending sync transaction to write to`); } if (pendingTransaction.committed) { throw new Error( `The pending sync transaction is already committed, you can't still write to it.` ); } const key = this.getKeyFromItem(messageWithoutKey.value); if (messageWithoutKey.type === `insert`) { if (this.syncedData.has(key) && !pendingTransaction.operations.some( (op) => op.key === key && op.type === `delete` )) { throw new Error( `Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"` ); } } const message = { ...messageWithoutKey, key }; pendingTransaction.operations.push(message); }, commit: () => { const pendingTransaction = this.pendingSyncedTransactions[this.pendingSyncedTransactions.length - 1]; if (!pendingTransaction) { throw new Error(`No pending sync transaction to commit`); } if (pendingTransaction.committed) { throw new Error( `The pending sync transaction is already committed, you can't commit it again.` ); } pendingTransaction.committed = true; this.commitPendingTransactions(); } }); } /** * Register a callback to be executed on the next commit * Useful for preloading collections * @param callback Function to call after the next commit */ onFirstCommit(callback) { this.onFirstCommitCallbacks.push(callback); } /** * Recompute optimistic state from active transactions */ recomputeOptimisticState() { const previousState = new Map(this.derivedUpserts); const previousDeletes = new Set(this.derivedDeletes); this.derivedUpserts.clear(); this.derivedDeletes.clear(); const activeTransactions = Array.from(this.transactions.values()); for (const transaction of activeTransactions) { if (![`completed`, `failed`].includes(transaction.state)) { for (const mutation of transaction.mutations) { if (mutation.collection === this) { switch (mutation.type) { case `insert`: case `update`: this.derivedUpserts.set(mutation.key, mutation.modified); this.derivedDeletes.delete(mutation.key); break; case `delete`: this.derivedUpserts.delete(mutation.key); this.derivedDeletes.add(mutation.key); break; } } } } } this._size = this.calculateSize(); const events = []; this.collectOptimisticChanges(previousState, previousDeletes, events); this.emitEvents(events); } /** * Calculate the current size based on synced data and optimistic changes */ calculateSize() { const syncedSize = this.syncedData.size; const deletesFromSynced = Array.from(this.derivedDeletes).filter( (key) => this.syncedData.has(key) && !this.derivedUpserts.has(key) ).length; const upsertsNotInSynced = Array.from(this.derivedUpserts.keys()).filter( (key) => !this.syncedData.has(key) ).length; return syncedSize - deletesFromSynced + upsertsNotInSynced; } /** * Collect events for optimistic changes */ collectOptimisticChanges(previousUpserts, previousDeletes, events) { const allKeys = /* @__PURE__ */ new Set([ ...previousUpserts.keys(), ...this.derivedUpserts.keys(), ...previousDeletes, ...this.derivedDeletes ]); for (const key of allKeys) { const currentValue = this.get(key); const previousValue = this.getPreviousValue( key, previousUpserts, previousDeletes ); if (previousValue !== void 0 && currentValue === void 0) { events.push({ type: `delete`, key, value: previousValue }); } else if (previousValue === void 0 && currentValue !== void 0) { events.push({ type: `insert`, key, value: currentValue }); } else if (previousValue !== void 0 && currentValue !== void 0 && previousValue !== currentValue) { events.push({ type: `update`, key, value: currentValue, previousValue }); } } } /** * Get the previous value for a key given previous optimistic state */ getPreviousValue(key, previousUpserts, previousDeletes) { if (previousDeletes.has(key)) { return void 0; } if (previousUpserts.has(key)) { return previousUpserts.get(key); } return this.syncedData.get(key); } /** * Emit multiple events at once to all listeners */ emitEvents(changes) { if (changes.length > 0) { for (const listener of this.changeListeners) { listener(changes); } if (this.changeKeyListeners.size > 0) { const changesByKey = /* @__PURE__ */ new Map(); for (const change of changes) { if (this.changeKeyListeners.has(change.key)) { if (!changesByKey.has(change.key)) { changesByKey.set(change.key, []); } changesByKey.get(change.key).push(change); } } for (const [key, keyChanges] of changesByKey) { const keyListeners = this.changeKeyListeners.get(key); for (const listener of keyListeners) { listener(keyChanges); } } } } } /** * Get the current value for a key (virtual derived state) */ get(key) { if (this.derivedDeletes.has(key)) { return void 0; } if (this.derivedUpserts.has(key)) { return this.derivedUpserts.get(key); } return this.syncedData.get(key); } /** * Check if a key exists in the collection (virtual derived state) */ has(key) { if (this.derivedDeletes.has(key)) { return false; } if (this.derivedUpserts.has(key)) { return true; } return this.syncedData.has(key); } /** * Get the current size of the collection (cached) */ get size() { return this._size; } /** * Get all keys (virtual derived state) */ *keys() { for (const key of this.syncedData.keys()) { if (!this.derivedDeletes.has(key)) { yield key; } } for (const key of this.derivedUpserts.keys()) { if (!this.syncedData.has(key) && !this.derivedDeletes.has(key)) { yield key; } } } /** * Get all values (virtual derived state) */ *values() { for (const key of this.keys()) { const value = this.get(key); if (value !== void 0) { yield value; } } } /** * Get all entries (virtual derived state) */ *entries() { for (const key of this.keys()) { const value = this.get(key); if (value !== void 0) { yield [key, value]; } } } ensureStandardSchema(schema) { if (schema && typeof schema === `object` && `~standard` in schema) { return schema; } throw new Error( `Schema must either implement the standard-schema interface or be a Zod schema` ); } getKeyFromItem(item) { return this.config.getKey(item); } generateGlobalKey(key, item) { if (typeof key === `undefined`) { throw new Error( `An object was created without a defined key: ${JSON.stringify(item)}` ); } return `KEY::${this.id}/${key}`; } validateData(data, type, key) { if (!this.config.schema) return data; const standardSchema = this.ensureStandardSchema(this.config.schema); if (type === `update` && key) { const existingData = this.get(key); if (existingData && data && typeof data === `object` && typeof existingData === `object`) { const mergedData = Object.assign({}, existingData, data); const result2 = standardSchema[`~standard`].validate(mergedData); if (result2 instanceof Promise) { throw new TypeError(`Schema validation must be synchronous`); } if (`issues` in result2 && result2.issues) { const typedIssues = result2.issues.map((issue) => { var _a; return { message: issue.message, path: (_a = issue.path) == null ? void 0 : _a.map((p) => String(p)) }; }); throw new SchemaValidationError(type, typedIssues); } return data; } } const result = standardSchema[`~standard`].validate(data); if (result instanceof Promise) { throw new TypeError(`Schema validation must be synchronous`); } if (`issues` in result && result.issues) { const typedIssues = result.issues.map((issue) => { var _a; return { message: issue.message, path: (_a = issue.path) == null ? void 0 : _a.map((p) => String(p)) }; }); throw new SchemaValidationError(type, typedIssues); } return result.value; } update(keys, configOrCallback, maybeCallback) { if (typeof keys === `undefined`) { throw new Error(`The first argument to update is missing`); } const ambientTransaction = transactions.getActiveTransaction(); if (!ambientTransaction && !this.config.onUpdate) { throw new Error( `Collection.update called directly (not within an explicit transaction) but no 'onUpdate' handler is configured.` ); } const isArray = Array.isArray(keys); const keysArray = isArray ? keys : [keys]; if (isArray && keysArray.length === 0) { throw new Error(`No keys were passed to update`); } const callback = typeof configOrCallback === `function` ? configOrCallback : maybeCallback; const config = typeof configOrCallback === `function` ? {} : configOrCallback; const currentObjects = keysArray.map((key) => { const item = this.get(key); if (!item) { throw new Error( `The key "${key}" was passed to update but an object for this key was not found in the collection` ); } return item; }); let changesArray; if (isArray) { changesArray = proxy.withArrayChangeTracking( currentObjects, callback ); } else { const result = proxy.withChangeTracking( currentObjects[0], callback ); changesArray = [result]; } const mutations = keysArray.map((key, index) => { const itemChanges = changesArray[index]; if (!itemChanges || Object.keys(itemChanges).length === 0) { return null; } const originalItem = currentObjects[index]; const validatedUpdatePayload = this.validateData( itemChanges, `update`, key ); const modifiedItem = Object.assign( {}, originalItem, validatedUpdatePayload ); const originalItemId = this.getKeyFromItem(originalItem); const modifiedItemId = this.getKeyFromItem(modifiedItem); if (originalItemId !== modifiedItemId) { throw new Error( `Updating the key of an item is not allowed. Original key: "${originalItemId}", Attempted new key: "${modifiedItemId}". Please delete the old item and create a new one if a key change is necessary.` ); } const globalKey = this.generateGlobalKey(modifiedItemId, modifiedItem); return { mutationId: crypto.randomUUID(), original: originalItem, modified: modifiedItem, changes: validatedUpdatePayload, globalKey, key, metadata: config.metadata, syncMetadata: this.syncedMetadata.get(key) || {}, type: `update`, createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date(), collection: this }; }).filter(Boolean); if (mutations.length === 0) { const emptyTransaction = new transactions.Transaction({ mutationFn: async () => { } }); emptyTransaction.commit(); return emptyTransaction; } if (ambientTransaction) { ambientTransaction.applyMutations(mutations); this.transactions.set(ambientTransaction.id, ambientTransaction); this.recomputeOptimisticState(); return ambientTransaction; } const directOpTransaction = new transactions.Transaction({ mutationFn: async (params) => { return this.config.onUpdate(params); } }); directOpTransaction.applyMutations(mutations); directOpTransaction.commit(); this.transactions.set(directOpTransaction.id, directOpTransaction); this.recomputeOptimisticState(); return directOpTransaction; } /** * Gets the current state of the collection as a Map * * @returns A Map containing all items in the collection, with keys as identifiers */ get state() { const result = /* @__PURE__ */ new Map(); for (const [key, value] of this.entries()) { result.set(key, value); } return result; } /** * Gets the current state of the collection as a Map, but only resolves when data is available * Waits for the first sync commit to complete before resolving * * @returns Promise that resolves to a Map containing all items in the collection */ stateWhenReady() { if (this.size > 0 || this.hasReceivedFirstCommit === true) { return Promise.resolve(this.state); } return new Promise((resolve) => { this.onFirstCommit(() => { resolve(this.state); }); }); } /** * Gets the current state of the collection as an Array * * @returns An Array containing all items in the collection */ get toArray() { const array = Array.from(this.values()); if (array[0] && array[0]._orderByIndex) { return array.sort( (a, b) => a._orderByIndex - b._orderByIndex ); } return array; } /** * Gets the current state of the collection as an Array, but only resolves when data is available * Waits for the first sync commit to complete before resolving * * @returns Promise that resolves to an Array containing all items in the collection */ toArrayWhenReady() { if (this.size > 0 || this.hasReceivedFirstCommit === true) { return Promise.resolve(this.toArray); } return new Promise((resolve) => { this.onFirstCommit(() => { resolve(this.toArray); }); }); } /** * Returns the current state of the collection as an array of changes * @returns An array of changes */ currentStateAsChanges() { return Array.from(this.entries()).map(([key, value]) => ({ type: `insert`, key, value })); } /** * Subscribe to changes in the collection * @param callback - A function that will be called with the changes in the collection * @returns A function that can be called to unsubscribe from the changes */ subscribeChanges(callback, { includeInitialState = false } = {}) { if (includeInitialState) { callback(this.currentStateAsChanges()); } this.changeListeners.add(callback); return () => { this.changeListeners.delete(callback); }; } /** * Subscribe to changes for a specific key */ subscribeChangesKey(key, listener, { includeInitialState = false } = {}) { if (!this.changeKeyListeners.has(key)) { this.changeKeyListeners.set(key, /* @__PURE__ */ new Set()); } if (includeInitialState) { listener([ { type: `insert`, key, value: this.get(key) } ]); } this.changeKeyListeners.get(key).add(listener); return () => { const listeners = this.changeKeyListeners.get(key); if (listeners) { listeners.delete(listener); if (listeners.size === 0) { this.changeKeyListeners.delete(key); } } }; } /** * Trigger a recomputation when transactions change * This method should be called by the Transaction class when state changes */ onTransactionStateChange() { this.recomputeOptimisticState(); } /** * Returns a Tanstack Store Map that is updated when the collection changes * This is a temporary solution to enable the existing framework hooks to work * with the new internals of Collection until they are rewritten. * TODO: Remove this once the framework hooks are rewritten. */ asStoreMap() { if (!this._storeMap) { this._storeMap = new store.Store(new Map(this.entries())); this.subscribeChanges(() => { this._storeMap.setState(() => new Map(this.entries())); }); } return this._storeMap; } /** * Returns a Tanstack Store Array that is updated when the collection changes * This is a temporary solution to enable the existing framework hooks to work * with the new internals of Collection until they are rewritten. * TODO: Remove this once the framework hooks are rewritten. */ asStoreArray() { if (!this._storeArray) { this._storeArray = new store.Store(this.toArray); this.subscribeChanges(() => { this._storeArray.setState(() => this.toArray); }); } return this._storeArray; } } exports.CollectionImpl = CollectionImpl; exports.SchemaValidationError = SchemaValidationError; exports.collectionsStore = collectionsStore; exports.createCollection = createCollection; exports.preloadCollection = preloadCollection; //# sourceMappingURL=collection.cjs.map