Add MMKV interface (#5169)
parent
2265fedd2a
commit
8a66883df8
|
@ -180,6 +180,7 @@
|
||||||
"react-native-image-crop-picker": "0.40.3",
|
"react-native-image-crop-picker": "0.40.3",
|
||||||
"react-native-ios-context-menu": "^1.15.3",
|
"react-native-ios-context-menu": "^1.15.3",
|
||||||
"react-native-keyboard-controller": "^1.12.1",
|
"react-native-keyboard-controller": "^1.12.1",
|
||||||
|
"react-native-mmkv": "^3.0.2",
|
||||||
"react-native-pager-view": "6.2.3",
|
"react-native-pager-view": "6.2.3",
|
||||||
"react-native-picker-select": "^9.1.3",
|
"react-native-picker-select": "^9.1.3",
|
||||||
"react-native-progress": "bluesky-social/react-native-progress",
|
"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"
|
resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.1.tgz#6de22ed4d060528a0dd25621eeaa7f71772ce35f"
|
||||||
integrity sha512-2OpQcesiYsMilrTzgcTafSGexd9UryRQRuHudIcOn0YaqvvzNpnhVZMVuJMH93fJv/iaZYp3138rgUKOdHhtSw==
|
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:
|
react-native-pager-view@6.2.3:
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775"
|
||||||
|
|
Loading…
Reference in New Issue