UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

559 lines (558 loc) • 18.3 kB
"use strict"; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); const uuid = require("./utils/uuid.cjs"); const utils = require("./utils.cjs"); const client = require("./client.cjs"); const collectionOptions = require("./collection-options.cjs"); const transactionCommitWork = require("./transaction-commit-work.cjs"); const errors = require("./errors.cjs"); const errorMessage = require("./error-message.cjs"); const sameTabListeners = /* @__PURE__ */ new WeakMap(); function subscribeToSameTabWrites(storage, key, refresh) { let byKey = sameTabListeners.get(storage); if (!byKey) { byKey = /* @__PURE__ */ new Map(); sameTabListeners.set(storage, byKey); } let listeners = byKey.get(key); if (!listeners) { listeners = /* @__PURE__ */ new Set(); byKey.set(key, listeners); } listeners.add(refresh); return () => { listeners.delete(refresh); if (listeners.size === 0) byKey.delete(key); if (byKey.size === 0) sameTabListeners.delete(storage); }; } function publishSameTabWrite(storage, key, writer) { for (const refresh of [...sameTabListeners.get(storage)?.get(key) ?? []]) { try { if (refresh === writer?.listener) writer.refresh(); else refresh(); } catch (error) { console.warn( errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `[LocalStorageCollection] Error refreshing a same-tab peer for storage key "${key}":` : errorMessage.codedWarning(232, { storageKey: key }), error ); } } } function validateJsonSerializable(parser, value, operation) { try { parser.stringify(value); } catch (error) { throw new errors.SerializationError( operation, error instanceof Error ? error.message : String(error) ); } } function generateUuid() { return uuid.safeRandomUUID(); } function encodeStorageKey(key) { if (typeof key === `number`) { return `n:${key}`; } return `s:${key}`; } function decodeStorageKey(encodedKey) { if (encodedKey.startsWith(`n:`)) { return Number(encodedKey.slice(2)); } if (encodedKey.startsWith(`s:`)) { return encodedKey.slice(2); } return encodedKey; } function sameStorageKey(left, right) { return left === right || Number.isNaN(left) && Number.isNaN(right); } function createInMemoryStorage() { const storage = /* @__PURE__ */ new Map(); return { getItem(key) { return storage.get(key) ?? null; }, setItem(key, value) { storage.set(key, value); }, removeItem(key) { storage.delete(key); } }; } function createNoOpStorageEventApi() { return { addEventListener: () => { }, removeEventListener: () => { } }; } function localStorageCollectionOptions(config) { if (!config.storageKey) { throw new errors.StorageKeyRequiredError(); } const storage = config.storage || (typeof window !== `undefined` ? window.localStorage : null) || createInMemoryStorage(); const storageEventApi = config.storageEventApi || (typeof window !== `undefined` ? window : null) || createNoOpStorageEventApi(); const parser = config.parser || JSON; const lastKnownData = /* @__PURE__ */ new Map(); const sync = createLocalStorageSync( config.storageKey, storage, storageEventApi, parser, config.getKey, lastKnownData ); const saveToStorage = (dataMap, mutations) => { try { const objectData = {}; dataMap.forEach((storedItem, key) => { objectData[encodeStorageKey(key)] = storedItem; }); const serialized = parser.stringify(objectData); const persisted = parser === JSON ? dataMap : readFromStorage( config.storageKey, storage, parser, config.getKey, serialized ); for (const mutation of mutations) { const storedItem = persisted.get(mutation.key); if (mutation.type === `delete` ? storedItem : !storedItem) { throw new errors.InvalidStorageDataFormatError( config.storageKey, encodeStorageKey(mutation.key) ); } } storage.setItem(config.storageKey, serialized); return { serialized, persisted }; } catch (error) { console.error( errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `[LocalStorageCollection] Error saving data to storage key "${config.storageKey}":` : errorMessage.codedMessage(218, { storageKey: config.storageKey }), error ); throw error; } }; const clearStorage = () => { storage.removeItem(config.storageKey); publishSameTabWrite(storage, config.storageKey); }; const getStorageSize = () => { const data = storage.getItem(config.storageKey); return data ? new Blob([data]).size : 0; }; const persistMutations = (mutations) => { const staged = readFromStorage( config.storageKey, storage, parser, config.getKey ); for (const mutation of mutations) { if (mutation.type === `delete`) staged.delete(mutation.key); else { if (parser === JSON) { const restored = JSON.parse(JSON.stringify(mutation.modified)); if (!sameStorageKey(config.getKey(restored), mutation.key)) { throw new errors.InvalidStorageDataFormatError( config.storageKey, encodeStorageKey(mutation.key) ); } } staged.set(mutation.key, { versionKey: generateUuid(), data: mutation.modified }); } } const { serialized: savedRaw, persisted } = saveToStorage(staged, mutations); try { for (const mutation of mutations) { const storedItem = persisted.get(mutation.key); if (storedItem) lastKnownData.set(mutation.key, storedItem); else lastKnownData.delete(mutation.key); } sync.confirmOperationsSync(mutations, persisted); } finally { const writerRefresh = sync.manualTrigger; publishSameTabWrite( storage, config.storageKey, writerRefresh ? { listener: writerRefresh, refresh: () => writerRefresh( storage.getItem(config.storageKey) === savedRaw ? persisted : void 0 ) } : void 0 ); } }; let writeTail; const reserveWrite = () => { const previous = writeTail; let release; const slot = new Promise((resolve) => { release = resolve; }); writeTail = slot; const retire = () => { release(); if (writeTail === slot) writeTail = void 0; }; return { previous, retire }; }; const persistAutomatic = (mutations, handler) => { const { previous, retire } = reserveWrite(); if (!handler && previous === void 0) { try { persistMutations(mutations); return Promise.resolve({}); } catch (error) { return Promise.reject(error); } finally { retire(); } } let result; try { result = handler?.(); } catch (error) { if (previous) void previous.then(retire); else retire(); return Promise.reject(error); } return Promise.resolve(result).then( async (value) => { await previous; try { persistMutations(mutations); return value ?? {}; } finally { retire(); } }, (error) => { if (previous) void previous.then(retire); else retire(); throw error; } ); }; const persistManual = (mutations) => { const { previous, retire } = reserveWrite(); if (previous === void 0) { try { persistMutations(mutations); return Promise.resolve(); } finally { retire(); } } return previous.then(() => { try { persistMutations(mutations); } finally { retire(); } }); }; const wrappedOnInsert = (params) => { params.transaction.mutations.forEach((mutation) => { validateJsonSerializable(parser, mutation.modified, `insert`); }); return persistAutomatic( params.transaction.mutations, config.onInsert ? () => config.onInsert(params) : void 0 ); }; const wrappedOnUpdate = (params) => { params.transaction.mutations.forEach((mutation) => { validateJsonSerializable(parser, mutation.modified, `update`); }); return persistAutomatic( params.transaction.mutations, config.onUpdate ? () => config.onUpdate(params) : void 0 ); }; const wrappedOnDelete = (params) => persistAutomatic( params.transaction.mutations, config.onDelete ? () => config.onDelete(params) : void 0 ); const { storageKey: _storageKey, storage: _storage, storageEventApi: _storageEventApi, parser: _parser, onInsert: _onInsert, onUpdate: _onUpdate, onDelete: _onDelete, id, ...restConfig } = config; const collectionId = id ?? `local-collection:${config.storageKey}`; const persistAcceptedMutations = async (transaction, owner) => { const collectionMutations = transaction.mutations.filter( (mutation) => mutation.collection === owner ); if (collectionMutations.length === 0) { return Promise.resolve(); } for (const mutation of collectionMutations) { switch (mutation.type) { case `insert`: case `update`: validateJsonSerializable(parser, mutation.modified, mutation.type); break; case `delete`: validateJsonSerializable(parser, mutation.original, mutation.type); break; } } await persistManual(collectionMutations); }; const materializedAcceptors = /* @__PURE__ */ new WeakSet(); const acceptMutations = (transaction) => { const candidates = new Set( transaction.mutations.filter((mutation) => mutation.collection.id === collectionId).map((mutation) => mutation.collection) ); const owned = [...candidates].filter((candidate) => { const candidateAccept = candidate.utils.acceptMutations; return candidateAccept === acceptMutations || materializedAcceptors.has(candidateAccept); }); if (owned.length > 1 || candidates.size > 0 && owned.length === 0) { const work2 = Promise.reject( new errors.LocalStorageCollectionError( errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `LocalStorage manual acceptance belongs to a different Collection.` : errorMessage.codedMessage(231) ) ); transactionCommitWork.registerTransactionCommitWork(transaction, work2); return work2; } const owner = owned[0]; const ownerAccept = owner ? owner.utils.acceptMutations : void 0; if (ownerAccept && ownerAccept !== acceptMutations) return ownerAccept(transaction); const work = persistAcceptedMutations(transaction, owner); transactionCommitWork.registerTransactionCommitWork(transaction, work); return work; }; const options = { ...restConfig, id: collectionId, sync, onInsert: wrappedOnInsert, onUpdate: wrappedOnUpdate, onDelete: wrappedOnDelete, utils: { clearStorage, getStorageSize, acceptMutations } }; let claimed = false; Object.defineProperty(options, collectionOptions.collectionOptionsClaim, { enumerable: true, value: () => { if (claimed) throw new errors.LocalStorageCollectionError( errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `LocalStorage options can create only one Collection. Create fresh options for each Collection.` : errorMessage.codedMessage(230) ); claimed = true; } }); return client.withCollectionConfigFactory(options, () => { const materialized = localStorageCollectionOptions({ ...config, id: collectionId }); materializedAcceptors.add(materialized.utils.acceptMutations); return materialized; }); } function readFromStorage(storageKey, storage, parser, getKey, knownRaw) { const rawData = knownRaw ?? storage.getItem(storageKey); if (rawData === null) { return /* @__PURE__ */ new Map(); } const parsed = parser.parse(rawData); const dataMap = /* @__PURE__ */ new Map(); if (typeof parsed === `object` && parsed !== null && !Array.isArray(parsed)) { Object.entries(parsed).forEach(([encodedKey, value]) => { if (value && typeof value === `object` && `versionKey` in value && typeof value.versionKey === `string` && `data` in value && value.data !== null && typeof value.data === `object`) { const storedItem = value; const decodedKey = decodeStorageKey(encodedKey); if (!sameStorageKey(getKey(storedItem.data), decodedKey) || dataMap.has(decodedKey)) { throw new errors.InvalidStorageDataFormatError(storageKey, encodedKey); } dataMap.set(decodedKey, storedItem); } else { throw new errors.InvalidStorageDataFormatError(storageKey, encodedKey); } }); } else { throw new errors.InvalidStorageObjectFormatError(storageKey); } return dataMap; } function createLocalStorageSync(storageKey, storage, storageEventApi, parser, getKey, lastKnownData) { let syncParams = null; let collection = null; const findChanges = (oldData, newData, compareContent) => { const changes = []; oldData.forEach((oldStoredItem, key) => { const newStoredItem = newData.get(key); if (!newStoredItem) { changes.push({ type: `delete`, key, value: oldStoredItem.data }); } else if (oldStoredItem.versionKey !== newStoredItem.versionKey || compareContent && !utils.deepEquals(oldStoredItem.data, newStoredItem.data) && // A default-JSON writer may retain a native Date while the stored // row contains its ISO string. A peer's unrelated write must not // publish that representation change as an update. (parser !== JSON || JSON.stringify(oldStoredItem.data) !== JSON.stringify(newStoredItem.data))) { changes.push({ type: `update`, key, value: newStoredItem.data }); } }); newData.forEach((newStoredItem, key) => { if (!oldData.has(key)) { changes.push({ type: `insert`, key, value: newStoredItem.data }); } }); return changes; }; const processStorageChanges = (knownSnapshot) => { if (!syncParams) return; const { begin, write, commit } = syncParams; let newData; try { newData = knownSnapshot ?? readFromStorage(storageKey, storage, parser, getKey); } catch (error) { console.warn( errorMessage.devBuild() && process.env.NODE_ENV !== `production` ? `[LocalStorageCollection] Error loading data from storage key "${storageKey}":` : errorMessage.codedWarning(219, { storageKey }), error ); return; } const changes = findChanges( lastKnownData, newData, knownSnapshot === void 0 || parser !== JSON ); if (changes.length > 0) { changes.forEach(({ type, value }) => { if (value) validateJsonSerializable(parser, value, type); }); begin(); changes.forEach(({ type, value }) => { if (value) { write({ type, value }); } }); lastKnownData.clear(); newData.forEach((storedItem, key) => { lastKnownData.set(key, storedItem); }); commit(); } }; const syncConfig = { rowUpdateMode: `full`, sync: (params) => { const { begin, write, commit, markReady } = params; const initialData = readFromStorage( storageKey, storage, parser, getKey ); initialData.forEach((storedItem) => { validateJsonSerializable(parser, storedItem.data, `load`); }); syncParams = params; collection = params.collection; lastKnownData.clear(); initialData.forEach((storedItem, key) => { lastKnownData.set(key, storedItem); }); const handleStorageEvent = (event) => { if (event.key !== storageKey || event.storageArea !== storage) { return; } processStorageChanges(); }; const unsubscribeSameTab = subscribeToSameTabWrites( storage, storageKey, processStorageChanges ); try { if (initialData.size > 0) { begin(); initialData.forEach((storedItem) => { write({ type: `insert`, value: storedItem.data }); }); commit(); } markReady(); storageEventApi.addEventListener(`storage`, handleStorageEvent); } catch (error) { unsubscribeSameTab(); if (syncParams === params) { syncParams = null; } throw error; } return { cleanup: () => { storageEventApi.removeEventListener(`storage`, handleStorageEvent); unsubscribeSameTab(); if (syncParams === params) { syncParams = null; } } }; }, /** * Get sync metadata - returns storage key information * @returns Object containing storage key and storage type metadata */ getSyncMetadata: () => ({ storageKey, storageType: storage === (typeof window !== `undefined` ? window.localStorage : null) ? `localStorage` : `custom` }), // Manual trigger function for local updates manualTrigger: processStorageChanges }; const confirmOperationsSync = (mutations, persisted) => { if (!syncParams) { return; } const { begin, write, commit } = syncParams; begin(); mutations.forEach((mutation) => { write({ // A peer may have inserted the same key while this local mutation // was pending. Full-row update confirms the accepted stored value in // either case without a duplicate insert. type: mutation.type === `delete` ? `delete` : `update`, value: mutation.type === `delete` ? mutation.original : persisted.get(mutation.key).data }); }); commit(); }; return { ...syncConfig, get collection() { return collection; }, confirmOperationsSync }; } exports.localStorageCollectionOptions = localStorageCollectionOptions; //# sourceMappingURL=local-storage.cjs.map