From 8a66883df8fa19290e9754c554faf36a4c3090d5 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 5 Sep 2024 14:31:24 -0500 Subject: [PATCH] Add MMKV interface (#5169) --- package.json | 1 + src/storage/README.md | 62 ++++++++++++++++++++++ src/storage/__tests__/index.test.ts | 81 +++++++++++++++++++++++++++++ src/storage/index.ts | 72 +++++++++++++++++++++++++ src/storage/schema.ts | 4 ++ yarn.lock | 5 ++ 6 files changed, 225 insertions(+) create mode 100644 src/storage/README.md create mode 100644 src/storage/__tests__/index.test.ts create mode 100644 src/storage/index.ts create mode 100644 src/storage/schema.ts diff --git a/package.json b/package.json index d3eda07b..22bf8fa4 100644 --- a/package.json +++ b/package.json @@ -180,6 +180,7 @@ "react-native-image-crop-picker": "0.40.3", "react-native-ios-context-menu": "^1.15.3", "react-native-keyboard-controller": "^1.12.1", + "react-native-mmkv": "^3.0.2", "react-native-pager-view": "6.2.3", "react-native-picker-select": "^9.1.3", "react-native-progress": "bluesky-social/react-native-progress", diff --git a/src/storage/README.md b/src/storage/README.md new file mode 100644 index 00000000..b7d8d356 --- /dev/null +++ b/src/storage/README.md @@ -0,0 +1,62 @@ +# `#/storage` + +## Usage + +Import the correctly scoped store from `#/storage`. Each instance of `Storage` +(the base class, not to be used directly), has the following interface: + +- `set([...scope, key], value)` +- `get([...scope, key])` +- `remove([...scope, key])` +- `removeMany([...scope], [...keys])` + +For example, using our `device` store looks like this, since it's scoped to the +device (the most base level scope): + +```typescript +import { device } from '#/storage'; + +device.set(['foobar'], true); +device.get(['foobar']); +device.remove(['foobar']); +device.removeMany([], ['foobar']); +``` + +## TypeScript + +Stores are strongly typed, and when setting a given value, it will need to +conform to the schemas defined in `#/storage/schema`. When getting a value, it +will be returned to you as the type defined in its schema. + +## Scoped Stores + +Some stores are (or might be) scoped to an account or other identifier. In this +case, storage instances are created with type-guards, like this: + +```typescript +type AccountSchema = { + language: `${string}-${string}`; +}; + +type DID = `did:${string}`; + +const account = new Storage< + [DID], + AccountSchema +>({ + id: 'account', +}); + +account.set( + ['did:plc:abc', 'language'], + 'en-US', +); + +const language = account.get([ + 'did:plc:abc', + 'language', +]); +``` + +Here, if `['did:plc:abc']` is not supplied along with the key of +`language`, the `get` will return undefined (and TS will yell at you). diff --git a/src/storage/__tests__/index.test.ts b/src/storage/__tests__/index.test.ts new file mode 100644 index 00000000..e11affa7 --- /dev/null +++ b/src/storage/__tests__/index.test.ts @@ -0,0 +1,81 @@ +import {beforeEach, expect, jest, test} from '@jest/globals' + +import {Storage} from '#/storage' + +jest.mock('react-native-mmkv', () => ({ + MMKV: class MMKVMock { + _store = new Map() + + set(key: string, value: unknown) { + this._store.set(key, value) + } + + getString(key: string) { + return this._store.get(key) + } + + delete(key: string) { + return this._store.delete(key) + } + }, +})) + +type Schema = { + boo: boolean + str: string | null + num: number + obj: Record +} + +const scope = `account` +const store = new Storage<['account'], Schema>({id: 'test'}) + +beforeEach(() => { + store.removeMany([scope], ['boo', 'str', 'num', 'obj']) +}) + +test(`stores and retrieves data`, () => { + store.set([scope, 'boo'], true) + store.set([scope, 'str'], 'string') + store.set([scope, 'num'], 1) + expect(store.get([scope, 'boo'])).toEqual(true) + expect(store.get([scope, 'str'])).toEqual('string') + expect(store.get([scope, 'num'])).toEqual(1) +}) + +test(`removes data`, () => { + store.set([scope, 'boo'], true) + expect(store.get([scope, 'boo'])).toEqual(true) + store.remove([scope, 'boo']) + expect(store.get([scope, 'boo'])).toEqual(undefined) +}) + +test(`removes multiple keys at once`, () => { + store.set([scope, 'boo'], true) + store.set([scope, 'str'], 'string') + store.set([scope, 'num'], 1) + store.removeMany([scope], ['boo', 'str', 'num']) + expect(store.get([scope, 'boo'])).toEqual(undefined) + expect(store.get([scope, 'str'])).toEqual(undefined) + expect(store.get([scope, 'num'])).toEqual(undefined) +}) + +test(`concatenates keys`, () => { + store.remove([scope, 'str']) + store.set([scope, 'str'], 'concat') + // @ts-ignore accessing these properties for testing purposes only + expect(store.store.getString(`${scope}${store.sep}str`)).toBeTruthy() +}) + +test(`can store falsy values`, () => { + store.set([scope, 'str'], null) + store.set([scope, 'num'], 0) + expect(store.get([scope, 'str'])).toEqual(null) + expect(store.get([scope, 'num'])).toEqual(0) +}) + +test(`can store objects`, () => { + const obj = {foo: true} + store.set([scope, 'obj'], obj) + expect(store.get([scope, 'obj'])).toEqual(obj) +}) diff --git a/src/storage/index.ts b/src/storage/index.ts new file mode 100644 index 00000000..819ffab7 --- /dev/null +++ b/src/storage/index.ts @@ -0,0 +1,72 @@ +import {MMKV} from 'react-native-mmkv' + +import {Device} from '#/storage/schema' + +/** + * Generic storage class. DO NOT use this directly. Instead, use the exported + * storage instances below. + */ +export class Storage { + protected sep = ':' + protected store: MMKV + + constructor({id}: {id: string}) { + this.store = new MMKV({id}) + } + + /** + * Store a value in storage based on scopes and/or keys + * + * `set([key], value)` + * `set([scope, key], value)` + */ + set( + scopes: [...Scopes, Key], + data: Schema[Key], + ): void { + // stored as `{ data: }` structure to ease stringification + this.store.set(scopes.join(this.sep), JSON.stringify({data})) + } + + /** + * Get a value from storage based on scopes and/or keys + * + * `get([key])` + * `get([scope, key])` + */ + get( + scopes: [...Scopes, Key], + ): Schema[Key] | undefined { + const res = this.store.getString(scopes.join(this.sep)) + if (!res) return undefined + // parsed from storage structure `{ data: }` + return JSON.parse(res).data + } + + /** + * Remove a value from storage based on scopes and/or keys + * + * `remove([key])` + * `remove([scope, key])` + */ + remove(scopes: [...Scopes, Key]) { + this.store.delete(scopes.join(this.sep)) + } + + /** + * Remove many values from the same storage scope by keys + * + * `removeMany([], [key])` + * `removeMany([scope], [key])` + */ + removeMany(scopes: [...Scopes], keys: Key[]) { + keys.forEach(key => this.remove([...scopes, key])) + } +} + +/** + * Device data that's specific to the device and does not vary based on account + * + * `device.set([key], true)` + */ +export const device = new Storage<[], Device>({id: 'device'}) diff --git a/src/storage/schema.ts b/src/storage/schema.ts new file mode 100644 index 00000000..6522d75a --- /dev/null +++ b/src/storage/schema.ts @@ -0,0 +1,4 @@ +/** + * Device data that's specific to the device and does not vary based account + */ +export type Device = {} diff --git a/yarn.lock b/yarn.lock index 8e39855e..e5518a91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19047,6 +19047,11 @@ react-native-keyboard-controller@^1.12.1: resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.1.tgz#6de22ed4d060528a0dd25621eeaa7f71772ce35f" integrity sha512-2OpQcesiYsMilrTzgcTafSGexd9UryRQRuHudIcOn0YaqvvzNpnhVZMVuJMH93fJv/iaZYp3138rgUKOdHhtSw== +react-native-mmkv@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-3.0.2.tgz#c6376678d33d51a5ace3285c8bbcb5cb613c2741" + integrity sha512-zd+n5qLDy8Ptcj+ZIVa/pkGgL13X/8xFId3o4Ec9TpQVjM3BJaIszNc8jo6UF7dtndtVcvLDoBWBkkYmkpdTYA== + react-native-pager-view@6.2.3: version "6.2.3" resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775"