From 3c23236273d3f1d4f62b20c452df9822debfdc93 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Thu, 22 May 2025 02:44:49 +0700 Subject: [PATCH] feat(lib): initial impl of `storage` --- lib/storage/package.json | 6 +- lib/storage/src/index.ts | 167 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 2 deletions(-) diff --git a/lib/storage/package.json b/lib/storage/package.json index 84fbaef..f7f6427 100644 --- a/lib/storage/package.json +++ b/lib/storage/package.json @@ -2,5 +2,9 @@ "name": "@revenge-mod/storage", "private": true, "type": "module", - "exports": {} + "exports": { + ".": { + "default": "./src/index.ts" + } + } } diff --git a/lib/storage/src/index.ts b/lib/storage/src/index.ts index cdbdc6d..562960b 100644 --- a/lib/storage/src/index.ts +++ b/lib/storage/src/index.ts @@ -1 +1,166 @@ -// TODO(storage): Implement from NativeModules interop instead +// TODO(storage): Implement using NativeModules interop instead + +import { React } from '@revenge-mod/react' +import { FileModule } from '@revenge-mod/discord/native' +import { mergeDeep } from '@revenge-mod/utils/objects' +import { useReRender } from '@revenge-mod/utils/react' + +import type { AnyObject, DeepPartial } from '@revenge-mod/utils/types' + +/** + * Get a storage object for a given path and directory. + * + * @param path Path relative to the directory. + * @param directory Directory to use. Can be either 'cache' or 'documents'. + */ +export function getStorage(path: string, options?: StorageOptions): Storage { + const { CacheDirPath, DocumentsDirPath } = FileModule.getConstants() + + const directory = options?.directory ?? 'documents' + const dirPath = directory === 'cache' ? CacheDirPath : DocumentsDirPath + const fullPath = `${dirPath}/${path}` + + type Subscription = (v: DeepPartial) => void + const subs = new Set() + + const storage: Storage = { + loaded: false, + cache: options?.default, + use(filter) { + if (!this.cache) this.get() + + const reRender = useReRender() + + React.useEffect(() => { + const sub = ( + filter + ? v => { + if (filter(v)) reRender() + } + : reRender + ) as Subscription + + subs.add(sub) + + return () => { + subs.delete(sub) + } + }, []) + + return this.cache + }, + exists() { + return FileModule.fileExists(fullPath) + }, + async get() { + if (!(await this.exists())) { + this.cache = options?.default ?? {} + await this.set({}) + this.loaded = true + return this.cache + } + + const contents = await FileModule.readFile(fullPath, 'utf8') + if (contents) { + this.loaded = true + try { + const cache = (this.cache = JSON.parse(contents)) + for (const sub of subs) sub(cache) + return cache + } catch (e) { + console.error('Failed to parse storage file (most likely corrupted)', directory, path, e) + } + } + }, + async set(value) { + if (!this.cache) await this.get() + mergeDeep(this.cache!, value) + + try { + const contents = JSON.stringify(this.cache) + await FileModule.writeFile(directory, path, contents, 'utf8') + + for (const sub of subs) sub(value) + } catch (e) { + console.error('Failed to write storage file', directory, path, e) + } + }, + } + + if (options?.load) storage.get() + + return storage +} + +export interface StorageOptions { + /** + * The directory of the storage file. + */ + directory?: StorageDirectory + /** + * The default value to use for the storage. This will also be used for cache. + */ + default?: T + /** + * Automatically load the storage after creating the instance. + */ + load?: boolean +} + +export type UseStorageFilter = (newValue: DeepPartial) => any + +export interface Storage { + /** + * Whether the storage has been loaded. If the storage is not loaded, `storage.cache` may be `undefined`. + * If you have `options.default` set, you can use this property to check if `storage.cache` is the default value or not. + */ + loaded: boolean + /** + * The cached storage object. Set once `get()` is called, or `options.default` is set, and updated on `set()`. + * You should not modify this directly. + */ + cache?: AnyObject + /** + * Use the storage in a React component. The component will re-render when the storage is updated. + * + * @example + * ```tsx + * type Settings = { key: boolean, nested: { key: boolean } } + * + * const SettingsStorage = getStorage('settings.json') + * + * const MyComponent = () => { + * // Re-renders every time any of the keys in the settings object change + * const settings = SettingsStorage.use() + * // const settings: Settings | undefined + * + * // ... + * } + * + * const MyComponent2 = () => { + * // Re-renders every time the new value matches the filter + * const settings = SettingsStorage.use(val => val.key !== undefined) + * // const settings: Settings | undefined + * + * // ... + * } + */ + + use(filter?: UseStorageFilter): T | undefined + /** + * Get the storage. + */ + get(): Promise + /** + * Set the storage. + * + * @param value The value to merge into the storage. + */ + set(value: DeepPartial): Promise + /** + * Whether the storage is exists. + */ + exists(): Promise +} + +export type StorageDirectory = 'cache' | 'documents'