my fork of the bluesky client

Add MMKV interface (#5169)

authored by

Eric Bailey and committed by
GitHub
8a66883d 2265fedd

+225
+1
package.json
··· 180 180 "react-native-image-crop-picker": "0.40.3", 181 181 "react-native-ios-context-menu": "^1.15.3", 182 182 "react-native-keyboard-controller": "^1.12.1", 183 + "react-native-mmkv": "^3.0.2", 183 184 "react-native-pager-view": "6.2.3", 184 185 "react-native-picker-select": "^9.1.3", 185 186 "react-native-progress": "bluesky-social/react-native-progress",
+62
src/storage/README.md
··· 1 + # `#/storage` 2 + 3 + ## Usage 4 + 5 + Import the correctly scoped store from `#/storage`. Each instance of `Storage` 6 + (the base class, not to be used directly), has the following interface: 7 + 8 + - `set([...scope, key], value)` 9 + - `get([...scope, key])` 10 + - `remove([...scope, key])` 11 + - `removeMany([...scope], [...keys])` 12 + 13 + For example, using our `device` store looks like this, since it's scoped to the 14 + device (the most base level scope): 15 + 16 + ```typescript 17 + import { device } from '#/storage'; 18 + 19 + device.set(['foobar'], true); 20 + device.get(['foobar']); 21 + device.remove(['foobar']); 22 + device.removeMany([], ['foobar']); 23 + ``` 24 + 25 + ## TypeScript 26 + 27 + Stores are strongly typed, and when setting a given value, it will need to 28 + conform to the schemas defined in `#/storage/schema`. When getting a value, it 29 + will be returned to you as the type defined in its schema. 30 + 31 + ## Scoped Stores 32 + 33 + Some stores are (or might be) scoped to an account or other identifier. In this 34 + case, storage instances are created with type-guards, like this: 35 + 36 + ```typescript 37 + type AccountSchema = { 38 + language: `${string}-${string}`; 39 + }; 40 + 41 + type DID = `did:${string}`; 42 + 43 + const account = new Storage< 44 + [DID], 45 + AccountSchema 46 + >({ 47 + id: 'account', 48 + }); 49 + 50 + account.set( 51 + ['did:plc:abc', 'language'], 52 + 'en-US', 53 + ); 54 + 55 + const language = account.get([ 56 + 'did:plc:abc', 57 + 'language', 58 + ]); 59 + ``` 60 + 61 + Here, if `['did:plc:abc']` is not supplied along with the key of 62 + `language`, the `get` will return undefined (and TS will yell at you).
+81
src/storage/__tests__/index.test.ts
··· 1 + import {beforeEach, expect, jest, test} from '@jest/globals' 2 + 3 + import {Storage} from '#/storage' 4 + 5 + jest.mock('react-native-mmkv', () => ({ 6 + MMKV: class MMKVMock { 7 + _store = new Map() 8 + 9 + set(key: string, value: unknown) { 10 + this._store.set(key, value) 11 + } 12 + 13 + getString(key: string) { 14 + return this._store.get(key) 15 + } 16 + 17 + delete(key: string) { 18 + return this._store.delete(key) 19 + } 20 + }, 21 + })) 22 + 23 + type Schema = { 24 + boo: boolean 25 + str: string | null 26 + num: number 27 + obj: Record<string, unknown> 28 + } 29 + 30 + const scope = `account` 31 + const store = new Storage<['account'], Schema>({id: 'test'}) 32 + 33 + beforeEach(() => { 34 + store.removeMany([scope], ['boo', 'str', 'num', 'obj']) 35 + }) 36 + 37 + test(`stores and retrieves data`, () => { 38 + store.set([scope, 'boo'], true) 39 + store.set([scope, 'str'], 'string') 40 + store.set([scope, 'num'], 1) 41 + expect(store.get([scope, 'boo'])).toEqual(true) 42 + expect(store.get([scope, 'str'])).toEqual('string') 43 + expect(store.get([scope, 'num'])).toEqual(1) 44 + }) 45 + 46 + test(`removes data`, () => { 47 + store.set([scope, 'boo'], true) 48 + expect(store.get([scope, 'boo'])).toEqual(true) 49 + store.remove([scope, 'boo']) 50 + expect(store.get([scope, 'boo'])).toEqual(undefined) 51 + }) 52 + 53 + test(`removes multiple keys at once`, () => { 54 + store.set([scope, 'boo'], true) 55 + store.set([scope, 'str'], 'string') 56 + store.set([scope, 'num'], 1) 57 + store.removeMany([scope], ['boo', 'str', 'num']) 58 + expect(store.get([scope, 'boo'])).toEqual(undefined) 59 + expect(store.get([scope, 'str'])).toEqual(undefined) 60 + expect(store.get([scope, 'num'])).toEqual(undefined) 61 + }) 62 + 63 + test(`concatenates keys`, () => { 64 + store.remove([scope, 'str']) 65 + store.set([scope, 'str'], 'concat') 66 + // @ts-ignore accessing these properties for testing purposes only 67 + expect(store.store.getString(`${scope}${store.sep}str`)).toBeTruthy() 68 + }) 69 + 70 + test(`can store falsy values`, () => { 71 + store.set([scope, 'str'], null) 72 + store.set([scope, 'num'], 0) 73 + expect(store.get([scope, 'str'])).toEqual(null) 74 + expect(store.get([scope, 'num'])).toEqual(0) 75 + }) 76 + 77 + test(`can store objects`, () => { 78 + const obj = {foo: true} 79 + store.set([scope, 'obj'], obj) 80 + expect(store.get([scope, 'obj'])).toEqual(obj) 81 + })
+72
src/storage/index.ts
··· 1 + import {MMKV} from 'react-native-mmkv' 2 + 3 + import {Device} from '#/storage/schema' 4 + 5 + /** 6 + * Generic storage class. DO NOT use this directly. Instead, use the exported 7 + * storage instances below. 8 + */ 9 + export class Storage<Scopes extends unknown[], Schema> { 10 + protected sep = ':' 11 + protected store: MMKV 12 + 13 + constructor({id}: {id: string}) { 14 + this.store = new MMKV({id}) 15 + } 16 + 17 + /** 18 + * Store a value in storage based on scopes and/or keys 19 + * 20 + * `set([key], value)` 21 + * `set([scope, key], value)` 22 + */ 23 + set<Key extends keyof Schema>( 24 + scopes: [...Scopes, Key], 25 + data: Schema[Key], 26 + ): void { 27 + // stored as `{ data: <value> }` structure to ease stringification 28 + this.store.set(scopes.join(this.sep), JSON.stringify({data})) 29 + } 30 + 31 + /** 32 + * Get a value from storage based on scopes and/or keys 33 + * 34 + * `get([key])` 35 + * `get([scope, key])` 36 + */ 37 + get<Key extends keyof Schema>( 38 + scopes: [...Scopes, Key], 39 + ): Schema[Key] | undefined { 40 + const res = this.store.getString(scopes.join(this.sep)) 41 + if (!res) return undefined 42 + // parsed from storage structure `{ data: <value> }` 43 + return JSON.parse(res).data 44 + } 45 + 46 + /** 47 + * Remove a value from storage based on scopes and/or keys 48 + * 49 + * `remove([key])` 50 + * `remove([scope, key])` 51 + */ 52 + remove<Key extends keyof Schema>(scopes: [...Scopes, Key]) { 53 + this.store.delete(scopes.join(this.sep)) 54 + } 55 + 56 + /** 57 + * Remove many values from the same storage scope by keys 58 + * 59 + * `removeMany([], [key])` 60 + * `removeMany([scope], [key])` 61 + */ 62 + removeMany<Key extends keyof Schema>(scopes: [...Scopes], keys: Key[]) { 63 + keys.forEach(key => this.remove([...scopes, key])) 64 + } 65 + } 66 + 67 + /** 68 + * Device data that's specific to the device and does not vary based on account 69 + * 70 + * `device.set([key], true)` 71 + */ 72 + export const device = new Storage<[], Device>({id: 'device'})
+4
src/storage/schema.ts
··· 1 + /** 2 + * Device data that's specific to the device and does not vary based account 3 + */ 4 + export type Device = {}
+5
yarn.lock
··· 19047 19047 resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.1.tgz#6de22ed4d060528a0dd25621eeaa7f71772ce35f" 19048 19048 integrity sha512-2OpQcesiYsMilrTzgcTafSGexd9UryRQRuHudIcOn0YaqvvzNpnhVZMVuJMH93fJv/iaZYp3138rgUKOdHhtSw== 19049 19049 19050 + react-native-mmkv@^3.0.2: 19051 + version "3.0.2" 19052 + resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-3.0.2.tgz#c6376678d33d51a5ace3285c8bbcb5cb613c2741" 19053 + integrity sha512-zd+n5qLDy8Ptcj+ZIVa/pkGgL13X/8xFId3o4Ec9TpQVjM3BJaIszNc8jo6UF7dtndtVcvLDoBWBkkYmkpdTYA== 19054 + 19050 19055 react-native-pager-view@6.2.3: 19051 19056 version "6.2.3" 19052 19057 resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775"