about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-09-05 14:31:24 -0500
committerGitHub <noreply@github.com>2024-09-05 12:31:24 -0700
commit8a66883df8fa19290e9754c554faf36a4c3090d5 (patch)
tree4fca7de046372f0fe825ede0b5803d950ec6186d /src
parent2265fedd2ac4d006e3c55dbb81ee387b93be9830 (diff)
downloadvoidsky-8a66883df8fa19290e9754c554faf36a4c3090d5.tar.zst
Add MMKV interface (#5169)
Diffstat (limited to 'src')
-rw-r--r--src/storage/README.md62
-rw-r--r--src/storage/__tests__/index.test.ts81
-rw-r--r--src/storage/index.ts72
-rw-r--r--src/storage/schema.ts4
4 files changed, 219 insertions, 0 deletions
diff --git a/src/storage/README.md b/src/storage/README.md
new file mode 100644
index 000000000..b7d8d3561
--- /dev/null
+++ b/src/storage/README.md
@@ -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).
diff --git a/src/storage/__tests__/index.test.ts b/src/storage/__tests__/index.test.ts
new file mode 100644
index 000000000..e11affa7a
--- /dev/null
+++ b/src/storage/__tests__/index.test.ts
@@ -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)
+})
diff --git a/src/storage/index.ts b/src/storage/index.ts
new file mode 100644
index 000000000..819ffab7e
--- /dev/null
+++ b/src/storage/index.ts
@@ -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'})
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
new file mode 100644
index 000000000..6522d75a3
--- /dev/null
+++ b/src/storage/schema.ts
@@ -0,0 +1,4 @@
+/**
+ * Device data that's specific to the device and does not vary based account
+ */
+export type Device = {}