Add MMKV interface (#5169)
parent
2265fedd2a
commit
8a66883df8
|
@ -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",
|
||||
|
|
|
@ -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).
|
|
@ -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)
|
||||
})
|
|
@ -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'})
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Device data that's specific to the device and does not vary based account
|
||||
*/
|
||||
export type Device = {}
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue