Skip to content

Commit

Permalink
feat(graphcache): SerializedEntries as Map
Browse files Browse the repository at this point in the history
Swap SerializedEntries to pass data as a Map instead plain object.
The main motivation for this change is to support metro's low object
properties limit. facebook/hermes#851

Urql issue: urql-graphql#3425
  • Loading branch information
Mookiies committed Nov 13, 2023
1 parent f2c59c5 commit fcb9d11
Show file tree
Hide file tree
Showing 7 changed files with 24 additions and 259 deletions.
6 changes: 6 additions & 0 deletions .changeset/flat-lions-dance.md
Original file line number Diff line number Diff line change
@@ -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.
238 changes: 1 addition & 237 deletions exchanges/graphcache/src/default-storage/index.ts
Original file line number Diff line number Diff line change
@@ -1,237 +1 @@
import type {
SerializedEntries,
SerializedRequest,
StorageAdapter,
} from '../types';

const getRequestPromise = <T>(request: IDBRequest<T>): Promise<T> => {
return new Promise((resolve, reject) => {
request.onerror = () => {
reject(request.error);
};

request.onsuccess = () => {
resolve(request.result);
};
});
};

const getTransactionPromise = (transaction: IDBTransaction): Promise<any> => {
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<any>;
}

/** 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<string, string | undefined> = 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<null | SerializedRequest[]> {
return database$.then(
database => {
return getRequestPromise<SerializedRequest[]>(
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<void> {
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<SerializedEntries> {
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
2 changes: 1 addition & 1 deletion exchanges/graphcache/src/offlineExchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([])),
};

Expand Down
18 changes: 9 additions & 9 deletions exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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\\"",
}
`;
11 changes: 5 additions & 6 deletions exchanges/graphcache/src/store/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand All @@ -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] === ':') {
Expand Down
4 changes: 1 addition & 3 deletions exchanges/graphcache/src/store/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 1 addition & 3 deletions exchanges/graphcache/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,9 +930,7 @@ export type KeyingConfig = {
};

/** Serialized normalized caching data. */
export interface SerializedEntries {
[key: string]: string | undefined;
}
export type SerializedEntries = Map<string, string | undefined>;

/** A serialized GraphQL request for offline storage. */
export interface SerializedRequest {
Expand Down

0 comments on commit fcb9d11

Please sign in to comment.