diff --git a/.changeset/flat-lions-dance.md b/.changeset/flat-lions-dance.md new file mode 100644 index 0000000000..870f945a40 --- /dev/null +++ b/.changeset/flat-lions-dance.md @@ -0,0 +1,6 @@ +--- +'@urql/exchange-graphcache': major +--- + +Use Map over plain JS object in StorageAdapter. Any custom StorageAdapter will have to update to +support `readData` and `writeData` as a Map. diff --git a/exchanges/graphcache/src/default-storage/index.ts b/exchanges/graphcache/src/default-storage/index.ts index 5da84cc0f8..5454fcd545 100644 --- a/exchanges/graphcache/src/default-storage/index.ts +++ b/exchanges/graphcache/src/default-storage/index.ts @@ -1,237 +1 @@ -import type { - SerializedEntries, - SerializedRequest, - StorageAdapter, -} from '../types'; - -const getRequestPromise = (request: IDBRequest): Promise => { - return new Promise((resolve, reject) => { - request.onerror = () => { - reject(request.error); - }; - - request.onsuccess = () => { - resolve(request.result); - }; - }); -}; - -const getTransactionPromise = (transaction: IDBTransaction): Promise => { - return new Promise((resolve, reject) => { - transaction.onerror = () => { - reject(transaction.error); - }; - - transaction.oncomplete = resolve; - }); -}; - -export interface StorageOptions { - /** Name of the IndexedDB database that will be used. - * @defaultValue `'graphcache-v4'` - */ - idbName?: string; - /** Maximum age of cache entries (in days) after which data is discarded. - * @defaultValue `7` days - */ - maxAge?: number; -} - -/** Sample storage adapter persisting to IndexedDB. */ -export interface DefaultStorage extends StorageAdapter { - /** Clears the entire IndexedDB storage. */ - clear(): Promise; -} - -/** Creates a default {@link StorageAdapter} which uses IndexedDB for storage. - * - * @param opts - A {@link StorageOptions} configuration object. - * @returns the created {@link StorageAdapter}. - * - * @remarks - * The default storage uses IndexedDB to persist the normalized cache for - * offline use. It demonstrates that the cache can be chunked by timestamps. - * - * Note: We have no data on stability of this storage and our Offline Support - * for large APIs or longterm use. Proceed with caution. - */ -export const makeDefaultStorage = (opts?: StorageOptions): DefaultStorage => { - if (!opts) opts = {}; - - let callback: (() => void) | undefined; - - const DB_NAME = opts.idbName || 'graphcache-v4'; - const ENTRIES_STORE_NAME = 'entries'; - const METADATA_STORE_NAME = 'metadata'; - - let batch: Record = Object.create(null); - const timestamp = Math.floor(new Date().valueOf() / (1000 * 60 * 60 * 24)); - const maxAge = timestamp - (opts.maxAge || 7); - - const req = indexedDB.open(DB_NAME, 1); - const database$ = getRequestPromise(req); - - req.onupgradeneeded = () => { - req.result.createObjectStore(ENTRIES_STORE_NAME); - req.result.createObjectStore(METADATA_STORE_NAME); - }; - - const serializeEntry = (entry: string): string => entry.replace(/:/g, '%3a'); - - const deserializeEntry = (entry: string): string => - entry.replace(/%3a/g, ':'); - - const serializeBatch = (): string => { - let data = ''; - for (const key in batch) { - const value = batch[key]; - data += serializeEntry(key); - data += ':'; - if (value) data += serializeEntry(value); - data += ':'; - } - - return data; - }; - - const deserializeBatch = (input: string) => { - const data = {}; - let char = ''; - let key = ''; - let entry = ''; - let mode = 0; - let index = 0; - while (index < input.length) { - entry = ''; - while ((char = input[index++]) !== ':' && char) { - entry += char; - } - - if (mode) { - data[key] = deserializeEntry(entry) || undefined; - mode = 0; - } else { - key = deserializeEntry(entry); - mode = 1; - } - } - - return data; - }; - - return { - clear() { - return database$.then(database => { - const transaction = database.transaction( - [METADATA_STORE_NAME, ENTRIES_STORE_NAME], - 'readwrite' - ); - transaction.objectStore(METADATA_STORE_NAME).clear(); - transaction.objectStore(ENTRIES_STORE_NAME).clear(); - batch = Object.create(null); - return getTransactionPromise(transaction); - }); - }, - - readMetadata(): Promise { - return database$.then( - database => { - return getRequestPromise( - database - .transaction(METADATA_STORE_NAME, 'readonly') - .objectStore(METADATA_STORE_NAME) - .get(METADATA_STORE_NAME) - ); - }, - () => null - ); - }, - - writeMetadata(metadata: SerializedRequest[]) { - database$.then( - database => { - return getRequestPromise( - database - .transaction(METADATA_STORE_NAME, 'readwrite') - .objectStore(METADATA_STORE_NAME) - .put(metadata, METADATA_STORE_NAME) - ); - }, - () => { - /* noop */ - } - ); - }, - - writeData(entries: SerializedEntries): Promise { - Object.assign(batch, entries); - const toUndefined = () => undefined; - - return database$ - .then(database => { - return getRequestPromise( - database - .transaction(ENTRIES_STORE_NAME, 'readwrite') - .objectStore(ENTRIES_STORE_NAME) - .put(serializeBatch(), timestamp) - ); - }) - .then(toUndefined, toUndefined); - }, - - readData(): Promise { - const chunks: string[] = []; - return database$ - .then(database => { - const transaction = database.transaction( - ENTRIES_STORE_NAME, - 'readwrite' - ); - - const store = transaction.objectStore(ENTRIES_STORE_NAME); - const request = (store.openKeyCursor || store.openCursor).call(store); - - request.onsuccess = function () { - if (this.result) { - const { key } = this.result; - if (typeof key !== 'number' || key < maxAge) { - store.delete(key); - } else { - const request = store.get(key); - const index = chunks.length; - chunks.push(''); - request.onsuccess = () => { - const result = '' + request.result; - if (key === timestamp) - Object.assign(batch, deserializeBatch(result)); - chunks[index] = result; - }; - } - - this.result.continue(); - } - }; - - return getTransactionPromise(transaction); - }) - .then( - () => deserializeBatch(chunks.join('')), - () => batch - ); - }, - - onOnline(cb: () => void) { - if (callback) { - window.removeEventListener('online', callback); - callback = undefined; - } - - window.addEventListener( - 'online', - (callback = () => { - cb(); - }) - ); - }, - }; -}; +// Removed. Not supported in Serialized Entries as Map diff --git a/exchanges/graphcache/src/offlineExchange.test.ts b/exchanges/graphcache/src/offlineExchange.test.ts index c4b13dbc09..87d8a600d3 100644 --- a/exchanges/graphcache/src/offlineExchange.test.ts +++ b/exchanges/graphcache/src/offlineExchange.test.ts @@ -56,7 +56,7 @@ const storage = { onOnline: vi.fn(), writeData: vi.fn(() => Promise.resolve(undefined)), writeMetadata: vi.fn(() => Promise.resolve(undefined)), - readData: vi.fn(() => Promise.resolve({})), + readData: () => Promise.resolve(new Map()), readMetadata: vi.fn(() => Promise.resolve([])), }; diff --git a/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap b/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap index 8e6e3a9067..1df990ef71 100644 --- a/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap +++ b/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap @@ -1,18 +1,18 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Store with storage > should be able to persist embedded data 1`] = ` -{ - "Query%2eappointment({\\"id\\":\\"1\\"}).__typename": "\\"Appointment\\"", - "Query%2eappointment({\\"id\\":\\"1\\"}).info": "\\"urql meeting\\"", - "Query.appointment({\\"id\\":\\"1\\"})": ":\\"Query.appointment({\\\\\\"id\\\\\\":\\\\\\"1\\\\\\"})\\"", +Map { + "Query%2eappointment({\\"id\\":\\"1\\"}).__typename" => "\\"Appointment\\"", + "Query%2eappointment({\\"id\\":\\"1\\"}).info" => "\\"urql meeting\\"", + "Query.appointment({\\"id\\":\\"1\\"})" => ":\\"Query.appointment({\\\\\\"id\\\\\\":\\\\\\"1\\\\\\"})\\"", } `; exports[`Store with storage > should be able to store and rehydrate data 1`] = ` -{ - "Appointment:1.__typename": "\\"Appointment\\"", - "Appointment:1.id": "\\"1\\"", - "Appointment:1.info": "\\"urql meeting\\"", - "Query.appointment({\\"id\\":\\"1\\"})": ":\\"Appointment:1\\"", +Map { + "Appointment:1.__typename" => "\\"Appointment\\"", + "Appointment:1.id" => "\\"1\\"", + "Appointment:1.info" => "\\"urql meeting\\"", + "Query.appointment({\\"id\\":\\"1\\"})" => ":\\"Appointment:1\\"", } `; diff --git a/exchanges/graphcache/src/store/data.ts b/exchanges/graphcache/src/store/data.ts index 7c3badefa8..06d31659fa 100644 --- a/exchanges/graphcache/src/store/data.ts +++ b/exchanges/graphcache/src/store/data.ts @@ -611,16 +611,16 @@ export const persistData = () => { if (currentData!.storage) { currentOptimistic = true; currentOperation = 'read'; - const entries: SerializedEntries = {}; + const entries: SerializedEntries = new Map(); for (const key of currentData!.persist.keys()) { const { entityKey, fieldKey } = deserializeKeyInfo(key); let x: void | Link | EntityField; if ((x = readLink(entityKey, fieldKey)) !== undefined) { - entries[key] = `:${stringifyVariables(x)}`; + entries.set(key, `:${stringifyVariables(x)}`); } else if ((x = readRecord(entityKey, fieldKey)) !== undefined) { - entries[key] = stringifyVariables(x); + entries.set(key, stringifyVariables(x)); } else { - entries[key] = undefined; + entries.set(key, undefined); } } @@ -637,8 +637,7 @@ export const hydrateData = ( ) => { initDataState('write', data, null); - for (const key in entries) { - const value = entries[key]; + for (const [key, value] of entries) { if (value !== undefined) { const { entityKey, fieldKey } = deserializeKeyInfo(key); if (value[0] === ':') { diff --git a/exchanges/graphcache/src/store/store.test.ts b/exchanges/graphcache/src/store/store.test.ts index 40222efe68..822953c418 100644 --- a/exchanges/graphcache/src/store/store.test.ts +++ b/exchanges/graphcache/src/store/store.test.ts @@ -980,9 +980,7 @@ describe('Store with storage', () => { expect(storage.writeData).toHaveBeenCalled(); const serialisedStore = (storage.writeData as any).mock.calls[0][0]; - expect(serialisedStore).toEqual({ - 'Query.base': 'true', - }); + expect(serialisedStore.get('Query.base')).toEqual('true'); store = new Store(); InMemoryData.hydrateData(store.data, storage, serialisedStore); diff --git a/exchanges/graphcache/src/types.ts b/exchanges/graphcache/src/types.ts index 0e2d158311..76e2fa5fdc 100644 --- a/exchanges/graphcache/src/types.ts +++ b/exchanges/graphcache/src/types.ts @@ -930,9 +930,7 @@ export type KeyingConfig = { }; /** Serialized normalized caching data. */ -export interface SerializedEntries { - [key: string]: string | undefined; -} +export type SerializedEntries = Map; /** A serialized GraphQL request for offline storage. */ export interface SerializedRequest {