Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
1import type {
2 SerializedEntries,
3 SerializedRequest,
4 StorageAdapter,
5} from '../types';
6
7const getRequestPromise = <T>(request: IDBRequest<T>): Promise<T> => {
8 return new Promise((resolve, reject) => {
9 request.onerror = () => {
10 reject(request.error);
11 };
12
13 request.onsuccess = () => {
14 resolve(request.result);
15 };
16 });
17};
18
19const getTransactionPromise = (transaction: IDBTransaction): Promise<any> => {
20 return new Promise((resolve, reject) => {
21 transaction.onerror = () => {
22 reject(transaction.error);
23 };
24
25 transaction.oncomplete = resolve;
26 });
27};
28
29export interface StorageOptions {
30 /** Name of the IndexedDB database that will be used.
31 * @defaultValue `'graphcache-v4'`
32 */
33 idbName?: string;
34 /** Maximum age of cache entries (in days) after which data is discarded.
35 * @defaultValue `7` days
36 */
37 maxAge?: number;
38 /** Gets Called when the exchange has hydrated the data from storage. */
39 onCacheHydrated?: () => void;
40}
41
42/** Sample storage adapter persisting to IndexedDB. */
43export interface DefaultStorage extends StorageAdapter {
44 /** Clears the entire IndexedDB storage. */
45 clear(): Promise<any>;
46}
47
48/** Creates a default {@link StorageAdapter} which uses IndexedDB for storage.
49 *
50 * @param opts - A {@link StorageOptions} configuration object.
51 * @returns the created {@link StorageAdapter}.
52 *
53 * @remarks
54 * The default storage uses IndexedDB to persist the normalized cache for
55 * offline use. It demonstrates that the cache can be chunked by timestamps.
56 *
57 * Note: We have no data on stability of this storage and our Offline Support
58 * for large APIs or longterm use. Proceed with caution.
59 */
60export const makeDefaultStorage = (opts?: StorageOptions): DefaultStorage => {
61 if (!opts) opts = {};
62
63 let callback: (() => void) | undefined;
64
65 const DB_NAME = opts.idbName || 'graphcache-v4';
66 const ENTRIES_STORE_NAME = 'entries';
67 const METADATA_STORE_NAME = 'metadata';
68
69 let batch: Record<string, string | undefined> = Object.create(null);
70 const timestamp = Math.floor(new Date().valueOf() / (1000 * 60 * 60 * 24));
71 const maxAge = timestamp - (opts.maxAge || 7);
72
73 const req = indexedDB.open(DB_NAME, 1);
74 const database$ = getRequestPromise(req);
75
76 req.onupgradeneeded = () => {
77 req.result.createObjectStore(ENTRIES_STORE_NAME);
78 req.result.createObjectStore(METADATA_STORE_NAME);
79 };
80
81 const serializeEntry = (entry: string): string => entry.replace(/:/g, '%3a');
82
83 const deserializeEntry = (entry: string): string =>
84 entry.replace(/%3a/g, ':');
85
86 const serializeBatch = (): string => {
87 let data = '';
88 for (const key in batch) {
89 const value = batch[key];
90 data += serializeEntry(key);
91 data += ':';
92 if (value) data += serializeEntry(value);
93 data += ':';
94 }
95
96 return data;
97 };
98
99 const deserializeBatch = (input: string) => {
100 const data = {};
101 let char = '';
102 let key = '';
103 let entry = '';
104 let mode = 0;
105 let index = 0;
106 while (index < input.length) {
107 entry = '';
108 while ((char = input[index++]) !== ':' && char) {
109 entry += char;
110 }
111
112 if (mode) {
113 data[key] = deserializeEntry(entry) || undefined;
114 mode = 0;
115 } else {
116 key = deserializeEntry(entry);
117 mode = 1;
118 }
119 }
120
121 return data;
122 };
123
124 return {
125 clear() {
126 return database$.then(database => {
127 const transaction = database.transaction(
128 [METADATA_STORE_NAME, ENTRIES_STORE_NAME],
129 'readwrite'
130 );
131 transaction.objectStore(METADATA_STORE_NAME).clear();
132 transaction.objectStore(ENTRIES_STORE_NAME).clear();
133 batch = Object.create(null);
134 return getTransactionPromise(transaction);
135 });
136 },
137
138 readMetadata(): Promise<null | SerializedRequest[]> {
139 return database$.then(
140 database => {
141 return getRequestPromise<SerializedRequest[]>(
142 database
143 .transaction(METADATA_STORE_NAME, 'readonly')
144 .objectStore(METADATA_STORE_NAME)
145 .get(METADATA_STORE_NAME)
146 );
147 },
148 () => null
149 );
150 },
151
152 writeMetadata(metadata: SerializedRequest[]) {
153 database$.then(
154 database => {
155 return getRequestPromise(
156 database
157 .transaction(METADATA_STORE_NAME, 'readwrite')
158 .objectStore(METADATA_STORE_NAME)
159 .put(metadata, METADATA_STORE_NAME)
160 );
161 },
162 () => {
163 /* noop */
164 }
165 );
166 },
167
168 writeData(entries: SerializedEntries): Promise<void> {
169 Object.assign(batch, entries);
170 const toUndefined = () => undefined;
171
172 return database$
173 .then(database => {
174 return getRequestPromise(
175 database
176 .transaction(ENTRIES_STORE_NAME, 'readwrite')
177 .objectStore(ENTRIES_STORE_NAME)
178 .put(serializeBatch(), timestamp)
179 );
180 })
181 .then(toUndefined, toUndefined);
182 },
183
184 readData(): Promise<SerializedEntries> {
185 const chunks: string[] = [];
186 return database$
187 .then(database => {
188 const transaction = database.transaction(
189 ENTRIES_STORE_NAME,
190 'readwrite'
191 );
192
193 const store = transaction.objectStore(ENTRIES_STORE_NAME);
194 const request = (store.openKeyCursor || store.openCursor).call(store);
195
196 request.onsuccess = function () {
197 if (this.result) {
198 const { key } = this.result;
199 if (typeof key !== 'number' || key < maxAge) {
200 store.delete(key);
201 } else {
202 const request = store.get(key);
203 const index = chunks.length;
204 chunks.push('');
205 request.onsuccess = () => {
206 const result = '' + request.result;
207 if (key === timestamp)
208 Object.assign(batch, deserializeBatch(result));
209 chunks[index] = result;
210 };
211 }
212
213 this.result.continue();
214 }
215 };
216
217 return getTransactionPromise(transaction);
218 })
219 .then(
220 () => deserializeBatch(chunks.join('')),
221 () => batch
222 );
223 },
224 onCacheHydrated: opts.onCacheHydrated,
225 onOnline(cb: () => void) {
226 if (callback) {
227 window.removeEventListener('online', callback);
228 callback = undefined;
229 }
230
231 window.addEventListener(
232 'online',
233 (callback = () => {
234 cb();
235 })
236 );
237 },
238 };
239};