Add MMKV interface (#5169)

zio/stable
Eric Bailey 2024-09-05 14:31:24 -05:00 committed by GitHub
parent 2265fedd2a
commit 8a66883df8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 225 additions and 0 deletions

View File

@ -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",

View File

@ -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).

View File

@ -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<string, unknown>
}
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)
})

View File

@ -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<Scopes extends unknown[], Schema> {
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<Key extends keyof Schema>(
scopes: [...Scopes, Key],
data: Schema[Key],
): void {
// stored as `{ data: <value> }` 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<Key extends keyof Schema>(
scopes: [...Scopes, Key],
): Schema[Key] | undefined {
const res = this.store.getString(scopes.join(this.sep))
if (!res) return undefined
// parsed from storage structure `{ data: <value> }`
return JSON.parse(res).data
}
/**
* Remove a value from storage based on scopes and/or keys
*
* `remove([key])`
* `remove([scope, key])`
*/
remove<Key extends keyof Schema>(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<Key extends keyof Schema>(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'})

View File

@ -0,0 +1,4 @@
/**
* Device data that's specific to the device and does not vary based account
*/
export type Device = {}

View File

@ -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"