Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.

(graphcache) offline storage for React Native (#1949)

* Initial setup for graphcache-rn-async-storage

* Add readme docs for @urql/graphcache-rn-async-storage

* Add a note on @urql/exchange-graphcache/default-storage to the docs site

* Add a changelog and initial release

* Make default keys less shouty

* Export storage options type

* Remove optional chaining

* Add arg to catch block (else build fails with TypeError: Cannot read property 'type' of null)

* Clean up NetInfo if the exchange is reinitialised

* Rename package to @urql/storage-rn

* Add tests for read and write metadata

* Add tests for write data

* Add tests for onOnline

* Save data under a single key

* Simplify and add tests for readData

* Add a clear method to rn storage

* Merge data from all days

* Move async storage calls inline

* Ensure deleted keys are also removed from old cache

* Use forEach instead of spread

* Simplify assignment logic

Co-authored-by: Grant Sander <gksander93@gmail.com>

authored by

Kadi Kraman
Grant Sander
and committed by
GitHub
0fe23c53 d0c13768

+736 -1
+16
docs/graphcache/offline.md
··· 87 87 }); 88 88 ``` 89 89 90 + ## React Native 91 + 92 + For React Native, we can use the async storage package `@urql/storage-rn`. 93 + 94 + Before installing the [library](https://github.com/FormidableLabs/urql/tree/main/packages/storage-rn), ensure you have installed the necessary peer dependencies: NetInfo ([RN](https://github.com/react-native-netinfo/react-native-netinfo) | [Expo](https://docs.expo.dev/versions/latest/sdk/netinfo/)) and AsyncStorage ([RN](https://react-native-async-storage.github.io/async-storage/docs/install) | [Expo](https://docs.expo.dev/versions/v42.0.0/sdk/async-storage/)). 95 + 96 + ```js 97 + import { makeAsyncStorage } from '@urql/storage-rn'; 98 + 99 + const storage = makeAsyncStorage({ 100 + dataKey: 'graphcache-data', // The AsyncStorage key used for the data (defaults to graphcache-data) 101 + metadataKey: 'graphcache-metadata', // The AsyncStorage key used for the metadata (defaults to graphcache-metadata) 102 + maxAge: 7 // How long to persist the data in storage (defaults to 7 days) 103 + }); 104 + ``` 105 + 90 106 ## Offline Behavior 91 107 92 108 _Graphcache_ applies several mechanisms that improve the consistency of the cache and how it behaves
+5
packages/storage-rn/CHANGELOG.md
··· 1 + # Changelog 2 + 3 + ## v0.1.0 4 + 5 + **Initial Release**
+21
packages/storage-rn/LICENCE
··· 1 + MIT License 2 + 3 + Copyright (c) 2018–2020 Formidable 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+47
packages/storage-rn/README.md
··· 1 + # @urql/storage-rn 2 + 3 + `@urql/storage-rn` is a Graphcache offline storage for React Native. 4 + 5 + It is compatible for both plain React Native and Expo apps (including managed workflow), but it has a two peer dependencies - [Async Storage](https://react-native-async-storage.github.io/async-storage/) and [NetInfo](https://github.com/react-native-netinfo/react-native-netinfo) - which must be installed separately. AsyncStorage will be used to persist the data, and NetInfo will be used to determine when the app is online and offline. 6 + 7 + ## Quick Start Guide 8 + 9 + Install NetInfo ([RN](https://github.com/react-native-netinfo/react-native-netinfo) | [Expo](https://docs.expo.dev/versions/latest/sdk/netinfo/)) and AsyncStorage ([RN](https://react-native-async-storage.github.io/async-storage/docs/install) | [Expo](https://docs.expo.dev/versions/v42.0.0/sdk/async-storage/)). 10 + 11 + Install `@urql/storage-rn` alongside `urql` and `@urql/exchange-graphcache`: 12 + 13 + ```sh 14 + yarn add @urql/graphcache-rn-async-storage 15 + # or 16 + npm install --save @urql/graphcache-rn-async-storage 17 + ``` 18 + 19 + Then add it to the offline exchange: 20 + 21 + ```js 22 + import { createClient, dedupExchange, fetchExchange } from 'urql'; 23 + import { offlineExchange } from '@urql/exchange-graphcache'; 24 + import { makeAsyncStorage } from '@urql/storage-rn'; 25 + 26 + const storage = makeAsyncStorage({ 27 + dataKey: 'graphcache-data', // tTe AsyncStorage key used for the data (defaults to graphcache-data) 28 + metadataKey: 'graphcache-metadata', // The AsyncStorage key used for the metadata (defaults to graphcache-metadata) 29 + maxAge: 7 // How long to persist the data in storage (defaults to 7 days) 30 + }); 31 + 32 + const cache = offlineExchange({ 33 + schema, 34 + storage, 35 + updates: { 36 + /* ... */ 37 + }, 38 + optimistic: { 39 + /* ... */ 40 + }, 41 + }); 42 + 43 + const client = createClient({ 44 + url: 'http://localhost:3000/graphql', 45 + exchanges: [dedupExchange, cache, fetchExchange], 46 + }); 47 + ```
+61
packages/storage-rn/package.json
··· 1 + { 2 + "name": "@urql/storage-rn", 3 + "version": "0.1.0", 4 + "sideEffects": false, 5 + "description": "Graphcache offline storage for React Native", 6 + "homepage": "https://formidable.com/open-source/urql/docs/", 7 + "bugs": "https://github.com/FormidableLabs/urql/issues", 8 + "license": "MIT", 9 + "repository": { 10 + "type": "git", 11 + "url": "https://github.com/FormidableLabs/urql.git", 12 + "directory": "packages/storage-rn" 13 + }, 14 + "keywords": [ 15 + "urql", 16 + "graphql client", 17 + "formidablelabs", 18 + "exchanges", 19 + "react native", 20 + "offline", 21 + "storage" 22 + ], 23 + "main": "dist/urql-storage-rn", 24 + "module": "dist/urql-storage-rn.mjs", 25 + "types": "dist/types/index.d.ts", 26 + "source": "src/index.ts", 27 + "files": [ 28 + "LICENSE", 29 + "CHANGELOG.md", 30 + "README.md", 31 + "dist/" 32 + ], 33 + "exports": { 34 + ".": { 35 + "import": "./dist/urql-storage-rn.mjs", 36 + "require": "./dist/urql-storage-rn.js", 37 + "types": "./dist/types/index.d.ts", 38 + "source": "./src/index.ts" 39 + }, 40 + "./package.json": "./package.json" 41 + }, 42 + "scripts": { 43 + "clean": "rimraf dist", 44 + "check": "tsc --noEmit", 45 + "lint": "eslint --ext=js,jsx,ts,tsx .", 46 + "build": "rollup -c ../../scripts/rollup/config.js", 47 + "prepare": "node ../../scripts/prepare/index.js", 48 + "prepublishOnly": "run-s clean build" 49 + }, 50 + "jest": { 51 + "preset": "../../scripts/jest/preset" 52 + }, 53 + "devDependencies": { 54 + "@react-native-async-storage/async-storage": "^1.15.5", 55 + "@react-native-community/netinfo": "^6.0.0", 56 + "@urql/exchange-graphcache": ">=4.2.1" 57 + }, 58 + "publishConfig": { 59 + "access": "public" 60 + } 61 + }
+1
packages/storage-rn/src/index.ts
··· 1 + export { makeAsyncStorage } from './makeAsyncStorage';
+413
packages/storage-rn/src/makeAsyncStorage.test.ts
··· 1 + import AsyncStorage from '@react-native-async-storage/async-storage'; 2 + import NetInfo from '@react-native-community/netinfo'; 3 + import { makeAsyncStorage } from './makeAsyncStorage'; 4 + 5 + jest.mock('@react-native-community/netinfo', () => ({ 6 + addEventListener: () => 'addEventListener', 7 + })); 8 + 9 + jest.mock('@react-native-async-storage/async-storage', () => ({ 10 + setItem: () => 'setItem', 11 + getItem: () => 'getItem', 12 + getAllKeys: () => 'getAllKeys', 13 + removeItem: () => 'removeItem', 14 + })); 15 + 16 + const request = [ 17 + { 18 + query: 'something something', 19 + variables: { foo: 'bar' }, 20 + }, 21 + ]; 22 + 23 + const serializedRequest = 24 + '[{"query":"something something","variables":{"foo":"bar"}}]'; 25 + 26 + const entires = { 27 + hello: 'world', 28 + }; 29 + const serializedEntries = '{"hello":"world"}'; 30 + 31 + describe('makeAsyncStorage', () => { 32 + describe('writeMetadata', () => { 33 + it('writes metadata to async storage', async () => { 34 + const setItemSpy = jest.fn(); 35 + jest.spyOn(AsyncStorage, 'setItem').mockImplementationOnce(setItemSpy); 36 + 37 + const storage = makeAsyncStorage(); 38 + 39 + if (storage && storage.writeMetadata) { 40 + await storage.writeMetadata(request); 41 + } 42 + 43 + expect(setItemSpy).toHaveBeenCalledWith( 44 + 'graphcache-metadata', 45 + serializedRequest 46 + ); 47 + }); 48 + 49 + it('writes metadata using a custom key', async () => { 50 + const setItemSpy = jest.fn(); 51 + jest.spyOn(AsyncStorage, 'setItem').mockImplementationOnce(setItemSpy); 52 + 53 + const storage = makeAsyncStorage({ metadataKey: 'my-custom-key' }); 54 + 55 + if (storage && storage.writeMetadata) { 56 + await storage.writeMetadata(request); 57 + } 58 + 59 + expect(setItemSpy).toHaveBeenCalledWith( 60 + 'my-custom-key', 61 + serializedRequest 62 + ); 63 + }); 64 + }); 65 + 66 + describe('readMetadata', () => { 67 + it('returns an empty array if no metadata is found', async () => { 68 + const getItemSpy = jest.fn().mockResolvedValue(null); 69 + jest.spyOn(AsyncStorage, 'getItem').mockImplementationOnce(getItemSpy); 70 + 71 + const storage = makeAsyncStorage(); 72 + 73 + if (storage && storage.readMetadata) { 74 + const result = await storage.readMetadata(); 75 + expect(getItemSpy).toHaveBeenCalledWith('graphcache-metadata'); 76 + expect(result).toEqual([]); 77 + } 78 + }); 79 + 80 + it('returns the parsed JSON correctly', async () => { 81 + const getItemSpy = jest.fn().mockResolvedValue(serializedRequest); 82 + jest.spyOn(AsyncStorage, 'getItem').mockImplementationOnce(getItemSpy); 83 + 84 + const storage = makeAsyncStorage(); 85 + 86 + if (storage && storage.readMetadata) { 87 + const result = await storage.readMetadata(); 88 + expect(getItemSpy).toHaveBeenCalledWith('graphcache-metadata'); 89 + expect(result).toEqual(request); 90 + } 91 + }); 92 + 93 + it('reads metadata using a custom key', async () => { 94 + const getItemSpy = jest.fn().mockResolvedValue(serializedRequest); 95 + jest.spyOn(AsyncStorage, 'getItem').mockImplementationOnce(getItemSpy); 96 + 97 + const storage = makeAsyncStorage({ metadataKey: 'my-custom-key' }); 98 + 99 + if (storage && storage.readMetadata) { 100 + const result = await storage.readMetadata(); 101 + expect(getItemSpy).toHaveBeenCalledWith('my-custom-key'); 102 + expect(result).toEqual(request); 103 + } 104 + }); 105 + 106 + it('returns an empty array if json.parse errors', async () => { 107 + const getItemSpy = jest.fn().mockResolvedValue('surprise!'); 108 + jest.spyOn(AsyncStorage, 'getItem').mockImplementationOnce(getItemSpy); 109 + const storage = makeAsyncStorage(); 110 + 111 + if (storage && storage.readMetadata) { 112 + const result = await storage.readMetadata(); 113 + expect(getItemSpy).toHaveBeenCalledWith('graphcache-metadata'); 114 + expect(result).toEqual([]); 115 + } 116 + }); 117 + }); 118 + 119 + describe('writeData', () => { 120 + it('writes data to async storage', async () => { 121 + jest.spyOn(Date.prototype, 'valueOf').mockReturnValueOnce(1632209690641); 122 + const dayStamp = 18891; 123 + 124 + const setItemSpy = jest.fn(); 125 + jest.spyOn(AsyncStorage, 'setItem').mockImplementationOnce(setItemSpy); 126 + 127 + const storage = makeAsyncStorage(); 128 + 129 + if (storage && storage.writeData) { 130 + await storage.writeData(entires); 131 + } 132 + 133 + expect(setItemSpy).toHaveBeenCalledWith( 134 + 'graphcache-data', 135 + `{"${dayStamp}":${serializedEntries}}` 136 + ); 137 + }); 138 + 139 + it('writes data to async storage using custom key', async () => { 140 + jest.spyOn(Date.prototype, 'valueOf').mockReturnValueOnce(1632209690641); 141 + const dayStamp = 18891; 142 + 143 + const setItemSpy = jest.fn(); 144 + jest.spyOn(AsyncStorage, 'setItem').mockImplementationOnce(setItemSpy); 145 + 146 + const storage = makeAsyncStorage({ dataKey: 'my-custom-key' }); 147 + 148 + if (storage && storage.writeData) { 149 + await storage.writeData(entires); 150 + } 151 + 152 + expect(setItemSpy).toHaveBeenCalledWith( 153 + 'my-custom-key', 154 + `{"${dayStamp}":${serializedEntries}}` 155 + ); 156 + }); 157 + 158 + it('merges previous writes', async () => { 159 + jest.spyOn(Date.prototype, 'valueOf').mockReturnValueOnce(1632209690641); 160 + const dayStamp = 18891; 161 + 162 + const setItemSpy = jest.fn(); 163 + jest.spyOn(AsyncStorage, 'setItem').mockImplementationOnce(setItemSpy); 164 + 165 + const storage = makeAsyncStorage(); 166 + 167 + // write once 168 + if (storage && storage.writeData) { 169 + await storage.writeData(entires); 170 + } 171 + 172 + expect(setItemSpy).toHaveBeenCalledWith( 173 + 'graphcache-data', 174 + `{"${dayStamp}":${serializedEntries}}` 175 + ); 176 + 177 + // write twice 178 + const secondSetItemSpy = jest.fn(); 179 + jest 180 + .spyOn(AsyncStorage, 'setItem') 181 + .mockImplementationOnce(secondSetItemSpy); 182 + 183 + if (storage && storage.writeData) { 184 + storage.writeData({ foo: 'bar' }); 185 + } 186 + expect(secondSetItemSpy).toHaveBeenCalledWith( 187 + 'graphcache-data', 188 + `{"${dayStamp}":${JSON.stringify({ hello: 'world', foo: 'bar' })}}` 189 + ); 190 + }); 191 + 192 + it('keeps items from previous days', async () => { 193 + jest.spyOn(Date.prototype, 'valueOf').mockReturnValueOnce(1632209690641); 194 + const dayStamp = 18891; 195 + const oldDayStamp = 18857; 196 + jest 197 + .spyOn(AsyncStorage, 'getItem') 198 + .mockResolvedValueOnce( 199 + JSON.stringify({ [oldDayStamp]: { foo: 'bar' } }) 200 + ); 201 + 202 + const setItemSpy = jest.fn(); 203 + jest.spyOn(AsyncStorage, 'setItem').mockImplementationOnce(setItemSpy); 204 + 205 + const storage = makeAsyncStorage(); 206 + 207 + if (storage && storage.writeData) { 208 + await storage.writeData(entires); 209 + } 210 + 211 + expect(setItemSpy).toHaveBeenCalledWith( 212 + 'graphcache-data', 213 + JSON.stringify({ [oldDayStamp]: { foo: 'bar' }, [dayStamp]: entires }) 214 + ); 215 + }); 216 + 217 + it('propagates deleted keys to previous days', async () => { 218 + jest.spyOn(Date.prototype, 'valueOf').mockReturnValueOnce(1632209690641); 219 + const dayStamp = 18891; 220 + jest.spyOn(AsyncStorage, 'getItem').mockResolvedValueOnce( 221 + JSON.stringify({ 222 + [dayStamp]: { foo: 'bar', hello: 'world' }, 223 + [dayStamp - 1]: { foo: 'bar', hello: 'world' }, 224 + [dayStamp - 2]: { foo: 'bar', hello: 'world' }, 225 + }) 226 + ); 227 + 228 + const setItemSpy = jest.fn(); 229 + jest.spyOn(AsyncStorage, 'setItem').mockImplementationOnce(setItemSpy); 230 + 231 + const storage = makeAsyncStorage(); 232 + 233 + if (storage && storage.writeData) { 234 + await storage.writeData({ foo: 'new', hello: undefined }); 235 + } 236 + 237 + expect(setItemSpy).toHaveBeenCalledWith( 238 + 'graphcache-data', 239 + JSON.stringify({ 240 + [dayStamp]: { foo: 'new' }, 241 + [dayStamp - 1]: { foo: 'bar' }, 242 + [dayStamp - 2]: { foo: 'bar' }, 243 + }) 244 + ); 245 + }); 246 + }); 247 + 248 + describe('readData', () => { 249 + it('returns an empty object if no data is found', async () => { 250 + const getItemSpy = jest.fn().mockResolvedValue(null); 251 + jest.spyOn(AsyncStorage, 'getItem').mockImplementationOnce(getItemSpy); 252 + 253 + const storage = makeAsyncStorage(); 254 + 255 + if (storage && storage.readData) { 256 + const result = await storage.readData(); 257 + expect(getItemSpy).toHaveBeenCalledWith('graphcache-data'); 258 + expect(result).toEqual({}); 259 + } 260 + }); 261 + 262 + it("returns today's data correctly", async () => { 263 + jest.spyOn(Date.prototype, 'valueOf').mockReturnValueOnce(1632209690641); 264 + const dayStamp = 18891; 265 + const mockData = JSON.stringify({ [dayStamp]: entires }); 266 + const getItemSpy = jest.fn().mockResolvedValue(mockData); 267 + jest.spyOn(AsyncStorage, 'getItem').mockImplementationOnce(getItemSpy); 268 + 269 + const storage = makeAsyncStorage(); 270 + 271 + if (storage && storage.readData) { 272 + const result = await storage.readData(); 273 + expect(getItemSpy).toHaveBeenCalledWith('graphcache-data'); 274 + expect(result).toEqual(entires); 275 + } 276 + }); 277 + 278 + it('merges data from past days correctly', async () => { 279 + jest.spyOn(Date.prototype, 'valueOf').mockReturnValueOnce(1632209690641); 280 + const dayStamp = 18891; 281 + const mockData = JSON.stringify({ 282 + [dayStamp]: { one: 'one' }, 283 + [dayStamp - 1]: { two: 'two' }, 284 + [dayStamp - 3]: { three: 'three' }, 285 + [dayStamp - 4]: { two: 'old' }, 286 + }); 287 + const getItemSpy = jest.fn().mockResolvedValue(mockData); 288 + jest.spyOn(AsyncStorage, 'getItem').mockImplementationOnce(getItemSpy); 289 + 290 + const storage = makeAsyncStorage(); 291 + 292 + if (storage && storage.readData) { 293 + const result = await storage.readData(); 294 + expect(getItemSpy).toHaveBeenCalledWith('graphcache-data'); 295 + expect(result).toEqual({ 296 + one: 'one', 297 + two: 'two', 298 + three: 'three', 299 + }); 300 + } 301 + }); 302 + 303 + it('cleans up old data', async () => { 304 + jest.spyOn(Date.prototype, 'valueOf').mockReturnValueOnce(1632209690641); 305 + const dayStamp = 18891; 306 + const maxAge = 5; 307 + const mockData = JSON.stringify({ 308 + [dayStamp]: entires, // should be kept 309 + [dayStamp - maxAge + 1]: entires, // should be kept 310 + [dayStamp - maxAge - 1]: { old: 'data' }, // should get deleted 311 + }); 312 + jest.spyOn(AsyncStorage, 'getItem').mockResolvedValueOnce(mockData); 313 + const setItemSpy = jest.fn(); 314 + jest.spyOn(AsyncStorage, 'setItem').mockImplementationOnce(setItemSpy); 315 + 316 + const storage = makeAsyncStorage({ maxAge }); 317 + 318 + if (storage && storage.readData) { 319 + const result = await storage.readData(); 320 + expect(result).toEqual(entires); 321 + expect(setItemSpy).toBeCalledWith( 322 + 'graphcache-data', 323 + JSON.stringify({ 324 + [dayStamp]: entires, 325 + [dayStamp - maxAge + 1]: entires, 326 + }) 327 + ); 328 + } 329 + }); 330 + }); 331 + 332 + describe('onOnline', () => { 333 + it('sets up an event listener for the network change event', () => { 334 + const addEventListenerSpy = jest.fn(); 335 + jest 336 + .spyOn(NetInfo, 'addEventListener') 337 + .mockImplementationOnce(addEventListenerSpy); 338 + 339 + const storage = makeAsyncStorage(); 340 + 341 + if (storage && storage.onOnline) { 342 + storage.onOnline(() => null); 343 + } 344 + 345 + expect(addEventListenerSpy).toBeCalledTimes(1); 346 + }); 347 + 348 + it('calls the callback when the device comes online', () => { 349 + const callbackSpy = jest.fn(); 350 + let networkCallback; 351 + jest 352 + .spyOn(NetInfo, 'addEventListener') 353 + .mockImplementationOnce(callback => { 354 + networkCallback = callback; 355 + return () => null; 356 + }); 357 + 358 + const storage = makeAsyncStorage(); 359 + 360 + if (storage && storage.onOnline) { 361 + storage.onOnline(callbackSpy); 362 + } 363 + 364 + networkCallback({ isConnected: true }); 365 + 366 + expect(callbackSpy).toBeCalledTimes(1); 367 + }); 368 + 369 + it('does not call the callback when the device is offline', () => { 370 + const callbackSpy = jest.fn(); 371 + let networkCallback; 372 + jest 373 + .spyOn(NetInfo, 'addEventListener') 374 + .mockImplementationOnce(callback => { 375 + networkCallback = callback; 376 + return () => null; 377 + }); 378 + 379 + const storage = makeAsyncStorage(); 380 + 381 + if (storage && storage.onOnline) { 382 + storage.onOnline(callbackSpy); 383 + } 384 + 385 + networkCallback({ isConnected: false }); 386 + 387 + expect(callbackSpy).toBeCalledTimes(0); 388 + }); 389 + }); 390 + 391 + describe('clear', () => { 392 + it('clears all data and metadata', async () => { 393 + const removeItemSpy = jest.fn(); 394 + const secondRemoveItemSpy = jest.fn(); 395 + jest 396 + .spyOn(AsyncStorage, 'removeItem') 397 + .mockImplementationOnce(removeItemSpy) 398 + .mockImplementationOnce(secondRemoveItemSpy); 399 + 400 + const storage = makeAsyncStorage({ 401 + dataKey: 'my-data', 402 + metadataKey: 'my-metadata', 403 + }); 404 + 405 + if (storage && storage.clear) { 406 + await storage.clear(); 407 + } 408 + 409 + expect(removeItemSpy).toHaveBeenCalledWith('my-data'); 410 + expect(secondRemoveItemSpy).toHaveBeenCalledWith('my-metadata'); 411 + }); 412 + }); 413 + });
+137
packages/storage-rn/src/makeAsyncStorage.ts
··· 1 + import { StorageAdapter } from '@urql/exchange-graphcache'; 2 + import AsyncStorage from '@react-native-async-storage/async-storage'; 3 + import NetInfo from '@react-native-community/netinfo'; 4 + 5 + export type StorageOptions = { 6 + dataKey?: string; 7 + metadataKey?: string; 8 + maxAge?: number; // Number of days 9 + }; 10 + 11 + const parseData = (persistedData: any, fallback: any) => { 12 + try { 13 + if (persistedData) { 14 + return JSON.parse(persistedData); 15 + } 16 + } catch (_err) {} 17 + 18 + return fallback; 19 + }; 20 + 21 + let disconnect; 22 + 23 + export interface DefaultAsyncStorage extends StorageAdapter { 24 + clear(): Promise<any>; 25 + } 26 + 27 + export const makeAsyncStorage: ( 28 + ops?: StorageOptions 29 + ) => DefaultAsyncStorage = ({ 30 + dataKey = 'graphcache-data', 31 + metadataKey = 'graphcache-metadata', 32 + maxAge = 7, 33 + } = {}) => { 34 + const todayDayStamp = Math.floor( 35 + new Date().valueOf() / (1000 * 60 * 60 * 24) 36 + ); 37 + const allData = {}; 38 + 39 + return { 40 + readData: async () => { 41 + if (!Object.keys(allData).length) { 42 + let persistedData: string | null = null; 43 + try { 44 + persistedData = await AsyncStorage.getItem(dataKey); 45 + } catch (_err) {} 46 + const parsed = parseData(persistedData, {}); 47 + 48 + Object.assign(allData, parsed); 49 + } 50 + 51 + // clean up old data 52 + let syncNeeded = false; 53 + Object.keys(allData).forEach(dayStamp => { 54 + if (todayDayStamp - Number(dayStamp) > maxAge) { 55 + syncNeeded = true; 56 + delete allData[dayStamp]; 57 + } 58 + }); 59 + 60 + if (syncNeeded) { 61 + try { 62 + await AsyncStorage.setItem(dataKey, JSON.stringify(allData)); 63 + } catch (_err) {} 64 + } 65 + 66 + return Object.assign( 67 + {}, 68 + ...Object.keys(allData).map(key => allData[key]) 69 + ); 70 + }, 71 + 72 + writeData: async delta => { 73 + if (!Object.keys(allData).length) { 74 + let persistedData: string | null = null; 75 + try { 76 + persistedData = await AsyncStorage.getItem(dataKey); 77 + } catch (_err) {} 78 + const parsed = parseData(persistedData, {}); 79 + Object.assign(allData, parsed); 80 + } 81 + 82 + const deletedKeys = {}; 83 + Object.keys(delta).forEach(key => { 84 + if (delta[key] === undefined) { 85 + deletedKeys[key] = undefined; 86 + } 87 + }); 88 + 89 + for (const key in allData) { 90 + allData[key] = Object.assign(allData[key], deletedKeys); 91 + } 92 + 93 + allData[todayDayStamp] = Object.assign( 94 + allData[todayDayStamp] || {}, 95 + delta 96 + ); 97 + 98 + try { 99 + await AsyncStorage.setItem(dataKey, JSON.stringify(allData)); 100 + } catch (_err) {} 101 + }, 102 + 103 + writeMetadata: async data => { 104 + try { 105 + await AsyncStorage.setItem(metadataKey, JSON.stringify(data)); 106 + } catch (_err) {} 107 + }, 108 + 109 + readMetadata: async () => { 110 + let persistedData: string | null = null; 111 + try { 112 + persistedData = await AsyncStorage.getItem(metadataKey); 113 + } catch (_err) {} 114 + return parseData(persistedData, []); 115 + }, 116 + 117 + onOnline: cb => { 118 + if (disconnect) { 119 + disconnect(); 120 + disconnect = undefined; 121 + } 122 + 123 + disconnect = NetInfo.addEventListener(({ isConnected }) => { 124 + if (isConnected) { 125 + cb(); 126 + } 127 + }); 128 + }, 129 + 130 + clear: async () => { 131 + try { 132 + await AsyncStorage.removeItem(dataKey); 133 + await AsyncStorage.removeItem(metadataKey); 134 + } catch (_err) {} 135 + }, 136 + }; 137 + };
+15
packages/storage-rn/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.json", 3 + "include": [ 4 + "src" 5 + ], 6 + "compilerOptions": { 7 + "baseUrl": "./", 8 + "paths": { 9 + "urql": ["../../node_modules/urql/src"], 10 + "*-urql": ["../../node_modules/*-urql/src"], 11 + "@urql/core/*": ["../../node_modules/@urql/core/src/*"], 12 + "@urql/*": ["../../node_modules/@urql/*/src"] 13 + } 14 + } 15 + }
+20 -1
yarn.lock
··· 1967 1967 prop-types "^15.6.1" 1968 1968 react-lifecycles-compat "^3.0.4" 1969 1969 1970 + "@react-native-async-storage/async-storage@^1.15.5": 1971 + version "1.15.5" 1972 + resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-1.15.5.tgz#0d221a5ef1cd7a6494a42fcaad43136d68379afb" 1973 + integrity sha512-4AYehLH39B9a8UXCMf3ieOK+G61wGMP72ikx6/XSMA0DUnvx0PgaeaT2Wyt06kTrDTy8edewKnbrbeqwaM50TQ== 1974 + dependencies: 1975 + deep-assign "^3.0.0" 1976 + 1977 + "@react-native-community/netinfo@^6.0.0": 1978 + version "6.0.0" 1979 + resolved "https://registry.yarnpkg.com/@react-native-community/netinfo/-/netinfo-6.0.0.tgz#2a4d7190b508dd0c2293656c9c1aa068f6f60a71" 1980 + integrity sha512-Z9M8VGcF2IZVOo2x+oUStvpCW/8HjIRi4+iQCu5n+PhC7OqCQX58KYAzdBr///alIfRXiu6oMb+lK+rXQH1FvQ== 1981 + 1970 1982 "@rollup/plugin-babel@^5.3.0": 1971 1983 version "5.3.0" 1972 1984 resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz#9cb1c5146ddd6a4968ad96f209c50c62f92f9879" ··· 5985 5997 resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" 5986 5998 integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw= 5987 5999 6000 + deep-assign@^3.0.0: 6001 + version "3.0.0" 6002 + resolved "https://registry.yarnpkg.com/deep-assign/-/deep-assign-3.0.0.tgz#c8e4c4d401cba25550a2f0f486a2e75bc5f219a2" 6003 + integrity sha512-YX2i9XjJ7h5q/aQ/IM9PEwEnDqETAIYbggmdDB3HLTlSgo1CxPsj6pvhPG68rq6SVE0+p+6Ywsm5fTYNrYtBWw== 6004 + dependencies: 6005 + is-obj "^1.0.0" 6006 + 5988 6007 deep-equal@^1.0.1: 5989 6008 version "1.1.1" 5990 6009 resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" ··· 9269 9288 resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" 9270 9289 integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== 9271 9290 9272 - is-obj@^1.0.1: 9291 + is-obj@^1.0.0, is-obj@^1.0.1: 9273 9292 version "1.0.1" 9274 9293 resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" 9275 9294 integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8=