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 return {
82 clear() {
83 return database$.then(database => {
84 const transaction = database.transaction(
85 [METADATA_STORE_NAME, ENTRIES_STORE_NAME],
86 'readwrite'
87 );
88 transaction.objectStore(METADATA_STORE_NAME).clear();
89 transaction.objectStore(ENTRIES_STORE_NAME).clear();
90 batch = Object.create(null);
91 return getTransactionPromise(transaction);
92 });
93 },
94
95 readMetadata(): Promise<null | SerializedRequest[]> {
96 return database$.then(
97 database => {
98 return getRequestPromise<SerializedRequest[]>(
99 database
100 .transaction(METADATA_STORE_NAME, 'readonly')
101 .objectStore(METADATA_STORE_NAME)
102 .get(METADATA_STORE_NAME)
103 );
104 },
105 () => null
106 );
107 },
108
109 writeMetadata(metadata: SerializedRequest[]) {
110 database$.then(
111 database => {
112 return getRequestPromise(
113 database
114 .transaction(METADATA_STORE_NAME, 'readwrite')
115 .objectStore(METADATA_STORE_NAME)
116 .put(metadata, METADATA_STORE_NAME)
117 );
118 },
119 () => {
120 /* noop */
121 }
122 );
123 },
124
125 writeData(entries: SerializedEntries): Promise<void> {
126 Object.assign(batch, entries);
127 const toUndefined = () => undefined;
128
129 return database$
130 .then(database => {
131 return getRequestPromise(
132 database
133 .transaction(ENTRIES_STORE_NAME, 'readwrite')
134 .objectStore(ENTRIES_STORE_NAME)
135 .put(batch, timestamp)
136 );
137 })
138 .then(toUndefined, toUndefined);
139 },
140
141 readData(): Promise<SerializedEntries> {
142 const data: SerializedEntries = {};
143 return database$
144 .then(database => {
145 const transaction = database.transaction(
146 ENTRIES_STORE_NAME,
147 'readwrite'
148 );
149
150 const store = transaction.objectStore(ENTRIES_STORE_NAME);
151 const request = (store.openKeyCursor || store.openCursor).call(store);
152
153 request.onsuccess = function () {
154 if (this.result) {
155 const { key } = this.result;
156 if (typeof key !== 'number' || key < maxAge) {
157 store.delete(key);
158 } else {
159 const request = store.get(key);
160 request.onsuccess = () => {
161 const result = request.result;
162 if (key === timestamp) Object.assign(batch, result);
163 Object.assign(data, result);
164 };
165 }
166
167 this.result.continue();
168 }
169 };
170
171 return getTransactionPromise(transaction);
172 })
173 .then(
174 () => data,
175 () => batch
176 );
177 },
178 onCacheHydrated: opts.onCacheHydrated,
179 onOnline(cb: () => void) {
180 if (callback) {
181 window.removeEventListener('online', callback);
182 callback = undefined;
183 }
184
185 window.addEventListener(
186 'online',
187 (callback = () => {
188 cb();
189 })
190 );
191 },
192 };
193};