Add async storage interface
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
# `#/storage`
|
||||||
|
|
||||||
|
Strongly typed storage interface over `AsyncStorage`.
|
||||||
|
|
||||||
|
## 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 `storage` global store looks like this, since it's scoped to the
|
||||||
|
device and not an account:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as storage from '#/storage';
|
||||||
|
|
||||||
|
storage.device.set(['colorScheme'], 'light');
|
||||||
|
storage.device.get(['colorScheme']);
|
||||||
|
storage.device.remove(['colorScheme']);
|
||||||
|
storage.device.removeMany([], ['colorScheme']);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storing Objects
|
||||||
|
|
||||||
|
Because of the way `Storage` serializes data for storage, you don't need to
|
||||||
|
pre-serialize objects or any other type of data.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
storage.device.set(['foo'], true)
|
||||||
|
storage.device.get(['foo']) // => boolean
|
||||||
|
|
||||||
|
storage.device.set(['bar'], 1)
|
||||||
|
storage.device.get(['bar']) // => number
|
||||||
|
|
||||||
|
storage.device.set(['baz'], { yes: true })
|
||||||
|
storage.device.get(['baz']) // => object { yes: true }
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript
|
||||||
|
|
||||||
|
Stores are strongly typed, and when setting a given value, it will need to
|
||||||
|
conform to the schemas defined in `#/storage/schemas`. When getting a value, it
|
||||||
|
will be returned to you as the type defined in its schema.
|
||||||
|
|
||||||
|
## Scoped Stores
|
||||||
|
|
||||||
|
Some stores are (will be) scoped to account. In this case, storage instances are
|
||||||
|
created with type-guards, like this:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Account = {
|
||||||
|
initialView: string
|
||||||
|
};
|
||||||
|
|
||||||
|
type DID = `did:plc:${string}`;
|
||||||
|
|
||||||
|
const account = new Storage<
|
||||||
|
[DID],
|
||||||
|
Account
|
||||||
|
>({
|
||||||
|
initialView: 'following',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as storage from '#/storage'
|
||||||
|
|
||||||
|
storage.account.set(['did:plc:123abc', 'initialView'], 'following')
|
||||||
|
storage.account.get(['did:plc:123abc', 'initialView'])
|
||||||
|
```
|
||||||
|
|
||||||
|
Here, if `['did:plc:123abc']` is not supplied along with the storage key of
|
||||||
|
`initialView`, type checking will fail, and the value will return undefined at
|
||||||
|
runtime.
|
||||||
|
|
||||||
|
## Extensibility
|
||||||
|
|
||||||
|
For storage instances that require scopes like `account`, it may be useful in
|
||||||
|
the future to define wrappers around `Storage` that can cache references to the
|
||||||
|
currently active account. That way, we don't have to pass in the `DID` every
|
||||||
|
time.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {beforeEach, expect, test} from '@jest/globals'
|
||||||
|
|
||||||
|
import {Storage} from '#/storage'
|
||||||
|
|
||||||
|
type Schema = {
|
||||||
|
boo: boolean
|
||||||
|
str: string | null
|
||||||
|
num: number
|
||||||
|
obj: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Storage<[], Schema>()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store.removeMany([], ['boo', 'str', 'num', 'obj'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`stores and retrieves data`, async () => {
|
||||||
|
await store.set(['boo'], true)
|
||||||
|
await store.set(['str'], 'string')
|
||||||
|
await store.set(['num'], 1)
|
||||||
|
expect(await store.get(['boo'])).toEqual(true)
|
||||||
|
expect(await store.get(['str'])).toEqual('string')
|
||||||
|
expect(await store.get(['num'])).toEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`removes data`, async () => {
|
||||||
|
await store.set(['boo'], true)
|
||||||
|
expect(await store.get(['boo'])).toEqual(true)
|
||||||
|
await store.remove(['boo'])
|
||||||
|
expect(await store.get(['boo'])).toEqual(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`removes multiple keys at once`, async () => {
|
||||||
|
await store.set(['boo'], true)
|
||||||
|
await store.set(['str'], 'string')
|
||||||
|
await store.set(['num'], 1)
|
||||||
|
await store.removeMany([], ['boo', 'str', 'num'])
|
||||||
|
expect(await store.get(['boo'])).toEqual(undefined)
|
||||||
|
expect(await store.get(['str'])).toEqual(undefined)
|
||||||
|
expect(await store.get(['num'])).toEqual(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`concatenates keys`, async () => {
|
||||||
|
await store.remove(['str'])
|
||||||
|
await store.set(['str'], 'concat')
|
||||||
|
// @ts-ignore accessing these properties for testing purposes only
|
||||||
|
expect(await store.store.getItem(`str`)).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`can store falsy values`, async () => {
|
||||||
|
await store.set(['str'], null)
|
||||||
|
await store.set(['num'], 0)
|
||||||
|
expect(await store.get(['str'])).toEqual(null)
|
||||||
|
expect(await store.get(['num'])).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`can store objects`, async () => {
|
||||||
|
const obj = {foo: true}
|
||||||
|
await store.set(['obj'], obj)
|
||||||
|
expect(await store.get(['obj'])).toEqual(obj)
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
|
||||||
|
import {Device} from '#/storage/schemas'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: typeof AsyncStorage
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.store = AsyncStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a value in storage based on scopes and/or keys
|
||||||
|
*
|
||||||
|
* `set([key], value)`
|
||||||
|
* `set([scope, key], value)`
|
||||||
|
*/
|
||||||
|
async set<Key extends keyof Schema>(
|
||||||
|
scopes: [...Scopes, Key],
|
||||||
|
data: Schema[Key],
|
||||||
|
): Promise<void> {
|
||||||
|
// stored as `{ data: <value> }` structure to ease stringification
|
||||||
|
await this.store.setItem(scopes.join(this.sep), JSON.stringify({data}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value from storage based on scopes and/or keys
|
||||||
|
*
|
||||||
|
* `get([key])`
|
||||||
|
* `get([scope, key])`
|
||||||
|
*/
|
||||||
|
async get<Key extends keyof Schema>(
|
||||||
|
scopes: [...Scopes, Key],
|
||||||
|
): Promise<Schema[Key] | undefined> {
|
||||||
|
const res = await this.store.getItem(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])`
|
||||||
|
*/
|
||||||
|
async remove<Key extends keyof Schema>(scopes: [...Scopes, Key]) {
|
||||||
|
await this.store.removeItem(scopes.join(this.sep))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove many values from the same storage scope by keys
|
||||||
|
*
|
||||||
|
* `removeMany([], [key])`
|
||||||
|
* `removeMany([scope], [key])`
|
||||||
|
*/
|
||||||
|
async removeMany<Key extends keyof Schema>(scopes: [...Scopes], keys: Key[]) {
|
||||||
|
await Promise.all(keys.map(key => this.remove([...scopes, key])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data that's specific to the device
|
||||||
|
*
|
||||||
|
* `device.set(['colorScheme'], 'light')`
|
||||||
|
*/
|
||||||
|
export const device = new Storage<[], Device>()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Data that's specific to the device
|
||||||
|
*/
|
||||||
|
export type Device = {
|
||||||
|
colorScheme: 'light' | 'dark' | 'system'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user