From b56415356cf7d54abe06f9bda92b9becb3f0e091 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Sun, 5 Nov 2023 13:03:08 -0600 Subject: [PATCH] Add async storage interface --- src/storage/README.md | 85 +++++++++++++++++++++++++++ src/storage/__tests__/storage.test.ts | 62 +++++++++++++++++++ src/storage/index.ts | 72 +++++++++++++++++++++++ src/storage/schemas.ts | 6 ++ 4 files changed, 225 insertions(+) create mode 100644 src/storage/README.md create mode 100644 src/storage/__tests__/storage.test.ts create mode 100644 src/storage/index.ts create mode 100644 src/storage/schemas.ts diff --git a/src/storage/README.md b/src/storage/README.md new file mode 100644 index 0000000000..58d2a5d5a9 --- /dev/null +++ b/src/storage/README.md @@ -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. diff --git a/src/storage/__tests__/storage.test.ts b/src/storage/__tests__/storage.test.ts new file mode 100644 index 0000000000..41cb40821f --- /dev/null +++ b/src/storage/__tests__/storage.test.ts @@ -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 +} + +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) +}) diff --git a/src/storage/index.ts b/src/storage/index.ts new file mode 100644 index 0000000000..bea4a80275 --- /dev/null +++ b/src/storage/index.ts @@ -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 { + 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( + scopes: [...Scopes, Key], + data: Schema[Key], + ): Promise { + // stored as `{ data: }` 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( + scopes: [...Scopes, Key], + ): Promise { + const res = await this.store.getItem(scopes.join(this.sep)) + if (!res) return undefined + // parsed from storage structure `{ data: }` + return JSON.parse(res).data + } + + /** + * Remove a value from storage based on scopes and/or keys + * + * `remove([key])` + * `remove([scope, key])` + */ + async remove(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(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>() diff --git a/src/storage/schemas.ts b/src/storage/schemas.ts new file mode 100644 index 0000000000..2a7e9b26b7 --- /dev/null +++ b/src/storage/schemas.ts @@ -0,0 +1,6 @@ +/** + * Data that's specific to the device + */ +export type Device = { + colorScheme: 'light' | 'dark' | 'system' +}