Revise according to discussion
This commit is contained in:
@@ -73,6 +73,7 @@
|
||||
"@tiptap/pm": "^2.0.0-beta.220",
|
||||
"@tiptap/react": "^2.0.0-beta.220",
|
||||
"@tiptap/suggestion": "^2.0.0-beta.220",
|
||||
"@types/lodash.get": "^4.4.8",
|
||||
"@types/node": "^18.16.2",
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"array.prototype.findlast": "^1.2.3",
|
||||
@@ -108,6 +109,7 @@
|
||||
"lande": "^1.0.10",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.omit": "^4.5.0",
|
||||
"lodash.once": "^4.1.1",
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# `#/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.
|
||||
@@ -1,62 +1,39 @@
|
||||
import {beforeEach, expect, test} from '@jest/globals'
|
||||
import {jest, beforeAll, afterEach, expect, test} from '@jest/globals'
|
||||
// has a built-in mock
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {Storage} from '#/storage'
|
||||
import * as 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'])
|
||||
beforeAll(async () => {
|
||||
await storage.init()
|
||||
jest.mocked(AsyncStorage.getItem).mockClear()
|
||||
})
|
||||
|
||||
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)
|
||||
afterEach(() => {
|
||||
jest.mocked(AsyncStorage.getItem).mockClear()
|
||||
})
|
||||
|
||||
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(`gets and sets data synchronously`, async () => {
|
||||
storage.set('shell.colorMode', 'light')
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
storage.STORAGE_ROOT_KEY,
|
||||
JSON.stringify({shell: {colorMode: 'light'}}),
|
||||
)
|
||||
expect(storage.get('shell.colorMode')).toBe('light')
|
||||
expect(AsyncStorage.getItem).not.toHaveBeenCalled()
|
||||
|
||||
storage.set('shell.colorMode', 'dark')
|
||||
expect(storage.get('shell.colorMode')).toBe('dark')
|
||||
})
|
||||
|
||||
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(`set rolls back on error`, async () => {
|
||||
storage.set('shell.colorMode', 'light')
|
||||
|
||||
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()
|
||||
})
|
||||
jest
|
||||
.mocked(AsyncStorage.setItem)
|
||||
.mockImplementationOnce(() => Promise.reject(new Error('test error')))
|
||||
|
||||
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)
|
||||
})
|
||||
await storage.set('shell.colorMode', 'system')
|
||||
|
||||
test(`can store objects`, async () => {
|
||||
const obj = {foo: true}
|
||||
await store.set(['obj'], obj)
|
||||
expect(await store.get(['obj'])).toEqual(obj)
|
||||
expect(storage.get('shell.colorMode')).toBe('light')
|
||||
})
|
||||
|
||||
+40
-64
@@ -1,72 +1,48 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import getObj from 'lodash.get'
|
||||
import setObj from 'lodash.set'
|
||||
|
||||
import {Device} from '#/storage/schemas'
|
||||
import {logger} from '#/logger'
|
||||
import {Schema, PropertyMap} from '#/storage/schema'
|
||||
|
||||
export const STORAGE_ROOT_KEY = 'root'
|
||||
|
||||
/**
|
||||
* Generic storage class. DO NOT use this directly. Instead, use the exported
|
||||
* storage instances below.
|
||||
* In memory cache of local storage data. Never export or reference this
|
||||
* directly.
|
||||
*/
|
||||
export class Storage<Scopes extends unknown[], Schema> {
|
||||
protected sep = ':'
|
||||
protected store: typeof AsyncStorage
|
||||
let data: Schema | undefined
|
||||
|
||||
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])))
|
||||
}
|
||||
export async function init() {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_ROOT_KEY)
|
||||
logger.debug(`storage initialized`)
|
||||
data = (raw ? JSON.parse(raw) : {}) as Schema
|
||||
}
|
||||
|
||||
/**
|
||||
* Data that's specific to the device
|
||||
*
|
||||
* `device.set(['colorScheme'], 'light')`
|
||||
*/
|
||||
export const device = new Storage<[], Device>()
|
||||
export function get<T extends keyof PropertyMap>(keypath: T): PropertyMap[T] {
|
||||
logger.debug(`storage get(${keypath})`)
|
||||
if (!data) {
|
||||
throw new Error(`Data is not initialized. Did you forget to call init()`)
|
||||
}
|
||||
return getObj(data, keypath)
|
||||
}
|
||||
|
||||
export async function set<T extends keyof PropertyMap>(
|
||||
keypath: T,
|
||||
value: PropertyMap[T],
|
||||
) {
|
||||
logger.debug(`storage set(${keypath}, value)`)
|
||||
if (!data) {
|
||||
throw new Error(`Data is not initialized. Did you forget to call init()`)
|
||||
}
|
||||
|
||||
const prev = getObj(data, keypath)
|
||||
setObj(data, keypath, value)
|
||||
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_ROOT_KEY, JSON.stringify(data))
|
||||
} catch (err) {
|
||||
logger.error(`storage set(${keypath}, value) failed`)
|
||||
setObj(data, keypath, prev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {LabelPreference} from '@atproto/api'
|
||||
|
||||
/**
|
||||
* The shape of the object we store in local storage
|
||||
*/
|
||||
export type Schema = {
|
||||
shell: {
|
||||
colorMode: 'system' | 'light' | 'dark'
|
||||
}
|
||||
session: {
|
||||
data: {
|
||||
service: string
|
||||
did: `did:plc:${string}`
|
||||
}
|
||||
accounts: {
|
||||
service: string
|
||||
did: `did:plc:${string}`
|
||||
refreshJwt: string
|
||||
accessJwt: string
|
||||
handle: string
|
||||
email: string
|
||||
displayName: string
|
||||
aviUrl: string
|
||||
emailConfirmed: boolean
|
||||
}[]
|
||||
}
|
||||
me: {
|
||||
did: `did:plc:${string}`
|
||||
handle: string
|
||||
displayName: string
|
||||
description: string
|
||||
avatar: string
|
||||
}
|
||||
onboarding: {
|
||||
step: string
|
||||
}
|
||||
preferences: {
|
||||
primaryLanguage: string
|
||||
contentLanguages: string[]
|
||||
postLanguage: string
|
||||
postLanguageHistory: string[]
|
||||
contentLabels: {
|
||||
nsfw: LabelPreference
|
||||
nudity: LabelPreference
|
||||
suggestive: LabelPreference
|
||||
gore: LabelPreference
|
||||
hate: LabelPreference
|
||||
spam: LabelPreference
|
||||
impersonation: LabelPreference
|
||||
}
|
||||
savedFeeds: string[]
|
||||
pinnedFeeds: string[]
|
||||
requireAltTextEnabled: boolean
|
||||
}
|
||||
invitedUsers: {
|
||||
seenDids: string[]
|
||||
copiedInvites: string[]
|
||||
}
|
||||
mutedThreads: {uris: string[]}
|
||||
reminders: {lastEmailConfirm: string}
|
||||
}
|
||||
|
||||
/**
|
||||
* A map of all the properties that can be stored in the storage, and their
|
||||
* values. This mapping must be updated in order to get/set values by their
|
||||
* keypaths.
|
||||
*/
|
||||
export type PropertyMap = {
|
||||
'shell.colorMode': Schema['shell']['colorMode']
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Data that's specific to the device
|
||||
*/
|
||||
export type Device = {
|
||||
colorScheme: 'light' | 'dark' | 'system'
|
||||
}
|
||||
@@ -5425,6 +5425,13 @@
|
||||
dependencies:
|
||||
"@types/lodash" "*"
|
||||
|
||||
"@types/lodash.get@^4.4.8":
|
||||
version "4.4.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash.get/-/lodash.get-4.4.8.tgz#205004a76a7b821999563342e63ffb1940a9498a"
|
||||
integrity sha512-XK+co6sBkJxh1vaVP8al6cAA17dX//RNCknGG8JhpHFJfxq/GXKAYB9NKheG22pu2xpWpxfFd65W08EhH2IFlg==
|
||||
dependencies:
|
||||
"@types/lodash" "*"
|
||||
|
||||
"@types/lodash.isequal@^4.5.6":
|
||||
version "4.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.6.tgz#ff42a1b8e20caa59a97e446a77dc57db923bc02b"
|
||||
@@ -12803,6 +12810,11 @@ lodash.defaults@^4.2.0:
|
||||
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
|
||||
integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==
|
||||
|
||||
lodash.get@^4.4.2:
|
||||
version "4.4.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
|
||||
integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==
|
||||
|
||||
lodash.includes@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
|
||||
|
||||
Reference in New Issue
Block a user