UNPKG

@tanstack/optimistic

Version:

Core optimistic updates library

587 lines (586 loc) 20.2 kB
import { Store, batch, Derived } from "@tanstack/store"; import { withArrayChangeTracking, withChangeTracking } from "./proxy.js"; import { getActiveTransaction } from "./transactions.js"; import { SortedMap } from "./SortedMap.js"; const collectionsStore = new Store(/* @__PURE__ */ new Map()); const loadingCollections = /* @__PURE__ */ new Map(); function preloadCollection(config) { if (collectionsStore.state.has(config.id) && !loadingCollections.has(config.id)) { return Promise.resolve( collectionsStore.state.get(config.id) ); } if (loadingCollections.has(config.id)) { return loadingCollections.get(config.id); } if (!collectionsStore.state.has(config.id)) { collectionsStore.setState((prev) => { const next = new Map(prev); next.set( config.id, new Collection({ id: config.id, sync: config.sync, schema: config.schema }) ); return next; }); } const collection = collectionsStore.state.get(config.id); let resolveFirstCommit; const firstCommitPromise = new Promise((resolve) => { resolveFirstCommit = () => { resolve(collection); }; }); collection.onFirstCommit(() => { if (loadingCollections.has(config.id)) { loadingCollections.delete(config.id); resolveFirstCommit(); } }); loadingCollections.set( config.id, firstCommitPromise ); 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 Collection { /** * Creates a new Collection instance * * @param config - Configuration object for the collection * @throws Error if sync config is missing */ constructor(config) { this.syncedData = new Store(/* @__PURE__ */ new Map()); this.syncedMetadata = new Store(/* @__PURE__ */ new Map()); this.pendingSyncedTransactions = []; this.syncedKeys = /* @__PURE__ */ new Set(); this.hasReceivedFirstCommit = false; this.objectKeyMap = /* @__PURE__ */ new WeakMap(); this.onFirstCommitCallbacks = []; this.id = crypto.randomUUID(); this.commitPendingTransactions = () => { if (!Array.from(this.transactions.state.values()).some( ({ state }) => state === `persisting` )) { const keys = /* @__PURE__ */ new Set(); batch(() => { for (const transaction of this.pendingSyncedTransactions) { for (const operation of transaction.operations) { keys.add(operation.key); this.syncedKeys.add(operation.key); this.syncedMetadata.setState((prevData) => { switch (operation.type) { case `insert`: prevData.set(operation.key, operation.metadata); break; case `update`: prevData.set(operation.key, { ...prevData.get(operation.key), ...operation.metadata }); break; case `delete`: prevData.delete(operation.key); break; } return prevData; }); this.syncedData.setState((prevData) => { switch (operation.type) { case `insert`: prevData.set(operation.key, operation.value); break; case `update`: prevData.set(operation.key, { ...prevData.get(operation.key), ...operation.value }); break; case `delete`: prevData.delete(operation.key); break; } return prevData; }); } } }); keys.forEach((key) => { const curValue = this.state.get(key); if (curValue) { this.objectKeyMap.set(curValue, key); } }); this.pendingSyncedTransactions = []; if (!this.hasReceivedFirstCommit) { this.hasReceivedFirstCommit = true; const callbacks = [...this.onFirstCommitCallbacks]; this.onFirstCommitCallbacks = []; callbacks.forEach((callback) => callback()); } } }; this.insert = (data, config2) => { const transaction = getActiveTransaction(); if (typeof transaction === `undefined`) { throw `no transaction found when calling collection.insert`; } const items = Array.isArray(data) ? data : [data]; const mutations = []; let keys; if (config2 == null ? void 0 : config2.key) { const configKeys = Array.isArray(config2.key) ? config2.key : [config2.key]; if (Array.isArray(config2.key) && configKeys.length > items.length) { throw new Error(`More keys provided than items to insert`); } keys = items.map((_, i) => configKeys[i] ?? this.generateKey(items[i])); } else { keys = items.map((item) => this.generateKey(item)); } items.forEach((item, index) => { var _a, _b; const validatedData = this.validateData(item, `insert`); const key = keys[index]; const mutation = { mutationId: crypto.randomUUID(), original: {}, modified: validatedData, changes: validatedData, 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); }); transaction.applyMutations(mutations); this.transactions.setState((sortedMap) => { sortedMap.set(transaction.id, transaction); return sortedMap; }); return transaction; }; this.delete = (items, config2) => { const transaction = getActiveTransaction(); if (typeof transaction === `undefined`) { throw `no transaction found when calling collection.delete`; } const itemsArray = Array.isArray(items) ? items : [items]; const mutations = []; for (const item of itemsArray) { let key; if (typeof item === `object` && item !== null) { const objectKey = this.objectKeyMap.get(item); if (objectKey === void 0) { throw new Error( `Object not found in collection: ${JSON.stringify(item)}` ); } key = objectKey; } else if (typeof item === `string`) { key = item; } else { throw new Error( `Invalid item type for delete - must be an object or string key` ); } const mutation = { mutationId: crypto.randomUUID(), original: this.state.get(key) || {}, modified: { _deleted: true }, changes: { _deleted: true }, key, metadata: config2 == null ? void 0 : config2.metadata, syncMetadata: this.syncedMetadata.state.get(key) || {}, type: `delete`, createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date(), collection: this }; mutations.push(mutation); } mutations.forEach((mutation) => { const curValue = this.state.get(mutation.key); if (curValue) { this.objectKeyMap.delete(curValue); } }); transaction.applyMutations(mutations); this.transactions.setState((sortedMap) => { sortedMap.set(transaction.id, transaction); return sortedMap; }); return transaction; }; if (!(config == null ? void 0 : config.sync)) { throw new Error(`Collection requires a sync config`); } this.transactions = new Store( new SortedMap( (a, b) => a.createdAt.getTime() - b.createdAt.getTime() ) ); this.optimisticOperations = new Derived({ fn: ({ currDepVals: [transactions] }) => { const result = Array.from(transactions.values()).map((transaction) => { const isActive = ![`completed`, `failed`].includes( transaction.state ); return transaction.mutations.map((mutation) => { const message = { type: mutation.type, key: mutation.key, value: mutation.modified, isActive }; if (mutation.metadata !== void 0 && mutation.metadata !== null) { message.metadata = mutation.metadata; } return message; }); }).flat(); return result; }, deps: [this.transactions] }); this.optimisticOperations.mount(); this.derivedState = new Derived({ fn: ({ currDepVals: [syncedData, operations] }) => { const combined = new Map(syncedData); const optimisticKeys = /* @__PURE__ */ new Set(); for (const operation of operations) { optimisticKeys.add(operation.key); if (operation.isActive) { switch (operation.type) { case `insert`: combined.set(operation.key, operation.value); break; case `update`: combined.set(operation.key, operation.value); break; case `delete`: combined.delete(operation.key); break; } } } optimisticKeys.forEach((key) => { if (combined.has(key)) { this.objectKeyMap.set(combined.get(key), key); } }); return combined; }, deps: [this.syncedData, this.optimisticOperations] }); this.derivedArray = new Derived({ fn: ({ currDepVals: [stateMap] }) => { return Array.from(stateMap.values()); }, deps: [this.derivedState] }); this.derivedArray.mount(); this.derivedChanges = new Derived({ fn: ({ currDepVals: [derivedState, optimisticOperations], prevDepVals }) => { const prevDerivedState = (prevDepVals == null ? void 0 : prevDepVals[0]) ?? /* @__PURE__ */ new Map(); const changedKeys = new Set(this.syncedKeys); optimisticOperations.flat().filter((op) => op.isActive).forEach((op) => changedKeys.add(op.key)); if (changedKeys.size === 0) { return []; } const changes = []; for (const key of changedKeys) { if (prevDerivedState.has(key) && !derivedState.has(key)) { changes.push({ type: `delete`, key, value: prevDerivedState.get(key) }); } else if (!prevDerivedState.has(key) && derivedState.has(key)) { changes.push({ type: `insert`, key, value: derivedState.get(key) }); } else if (prevDerivedState.has(key) && derivedState.has(key)) { changes.push({ type: `update`, key, value: derivedState.get(key), previousValue: prevDerivedState.get(key) }); } } this.syncedKeys.clear(); return changes; }, deps: [this.derivedState, this.optimisticOperations] }); this.derivedChanges.mount(); this.config = config; this.derivedState.mount(); config.sync.sync({ collection: this, begin: () => { this.pendingSyncedTransactions.push({ committed: false, operations: [] }); }, write: (message) => { 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.` ); } 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); } 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` ); } validateData(data, type, key) { if (!this.config.schema) return data; const standardSchema = this.ensureStandardSchema(this.config.schema); if (type === `update` && key) { const existingData = this.state.get(key); if (existingData && data && typeof data === `object` && typeof existingData === `object`) { const mergedData = { ...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; } generateKey(data) { const str = JSON.stringify(data); let h = 0; for (let i = 0; i < str.length; i++) { h = Math.imul(31, h) + str.charCodeAt(i) | 0; } return `${this.id}/${Math.abs(h).toString(36)}`; } update(items, configOrCallback, maybeCallback) { if (typeof items === `undefined`) { throw new Error(`The first argument to update is missing`); } const transaction = getActiveTransaction(); if (typeof transaction === `undefined`) { throw `no transaction found when calling collection.update`; } const isArray = Array.isArray(items); const itemsArray = Array.isArray(items) ? items : [items]; const callback = typeof configOrCallback === `function` ? configOrCallback : maybeCallback; const config = typeof configOrCallback === `function` ? {} : configOrCallback; const keys = itemsArray.map((item) => { if (typeof item === `object` && item !== null) { const key = this.objectKeyMap.get(item); if (key === void 0) { throw new Error(`Object not found in collection`); } return key; } throw new Error(`Invalid item type for update - must be an object`); }); const currentObjects = keys.map((key) => ({ ...this.state.get(key) || {} })); let changesArray; if (isArray) { changesArray = withArrayChangeTracking( currentObjects, callback ); } else { const result = withChangeTracking( currentObjects[0], callback ); changesArray = [result]; } const mutations = keys.map((key, index) => { const changes = changesArray[index]; if (!changes || Object.keys(changes).length === 0) { return null; } const validatedData = this.validateData(changes, `update`, key); return { mutationId: crypto.randomUUID(), original: this.state.get(key) || {}, modified: { ...this.state.get(key) || {}, ...validatedData }, changes: validatedData, key, metadata: config.metadata, syncMetadata: this.syncedMetadata.state.get(key) || {}, type: `update`, createdAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date(), collection: this }; }).filter(Boolean); if (mutations.length === 0) { throw new Error(`No changes were made to any of the objects`); } transaction.applyMutations(mutations); this.transactions.setState((sortedMap) => { sortedMap.set(transaction.id, transaction); return sortedMap; }); return transaction; } /** * 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() { return this.derivedState.state; } /** * 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.state.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() { return this.derivedArray.state; } /** * 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.toArray.length > 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 [...this.state.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) { callback(this.currentStateAsChanges()); return this.derivedChanges.subscribe((changes) => { if (changes.currentVal.length > 0) { callback(changes.currentVal); } }); } } export { Collection, SchemaValidationError, collectionsStore, preloadCollection }; //# sourceMappingURL=collection.js.map