diff --git a/apps/extension/src/storage/base.ts b/apps/extension/src/storage/base.ts index 646cc521..9f6231d9 100644 --- a/apps/extension/src/storage/base.ts +++ b/apps/extension/src/storage/base.ts @@ -15,16 +15,33 @@ export interface IStorage { }; } +/** + * To be imported in order to define a migration from the previous schema to the new one + * Note: If one adds an optional field (newField: string | undefined), a migration is not necessary. + * If one adds a new required field (newField: string[]), a migration is necessary + * to have the default value in the database. + */ export type MigrationFn = (prev: OldState) => NewState | Promise; +// All properties except for dbVersion. While this is an available key to query, +// it is defined in the version field. export type ExtensionStorageDefaults = Required< Omit >; +/** + * get(), set(), and remove() operations kick off storage migration process if it is necessary + * + * A migration happens for the entire storage object. For dev: + * - Define RequiredMigrations within version field in ExtensionStorageProps + * - The key is the version that will need migrating to that version + 1 (the next version) + * - The value is a function that takes that versions state and migrates it to the new state (strongly type both) + * - Note: It is quite difficult writing a generic that covers all migration function kinds. + * Given the use of any's, the writer of the migration should ensure it is typesafe when they define it. + */ export interface RequiredMigrations { - // Explicitly require key '0' representing... - // It is quite difficult writing a generic that covers all migration function kinds. - // Therefore, the writer of the migration should ensure it is typesafe when they define it. + // Explicitly require key 0 representing a special key for migrating from + // a state prior to the current implementation. // eslint-disable-next-line @typescript-eslint/no-explicit-any 0: MigrationFn; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -32,7 +49,7 @@ export interface RequiredMigrations { } export interface Version { - current: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; // and so on, just not zero + current: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; // and so on, just not zero as it's reserved migrations: RequiredMigrations; } @@ -46,6 +63,10 @@ export class ExtensionStorage { private readonly storage: IStorage; private readonly defaults: ExtensionStorageDefaults; private readonly version: Version; + + /** + * Locks are important on get/set/removes as migrations need to complete before those actions take place + */ private dbLock: Promise | undefined = undefined; constructor({ storage, defaults, version }: ExtensionStorageProps) { @@ -54,42 +75,76 @@ export class ExtensionStorage { this.version = version; } + /** + * Retrieves a value by key (waits on ongoing migration) + */ async get(key: K): Promise { return this.withDbLock(() => { return this._get(key) as Promise; }); } - // TODO: Retrieving will return either... document + /** + * Retrieving from chrome storage will return an object with the key and value: + * { fullSyncHeight: 923582341 } + * This function will return its value. If there isn't a value in the db for this key, + * chrome storage will return an empty object. For this case, we'll return undefined. + */ private async _get(key: K): Promise { const result = (await this.storage.get(String(key))) as Record | EmptyObject; return isEmptyObj(result) ? undefined : result[key]; } + /** + * Sets value for key (waits on ongoing migration). + * Not allowed to manually update dbversion. + */ async set>(key: K, value: T[K]): Promise { await this.withDbLock(async () => { await this._set({ [key]: value } as Record); }); } + /** + * Private set method that circumvents need to wait on migration lock (use normal set() for that) + */ private async _set(keys: Record): Promise { await this.storage.set(keys); } + /** + * Removes key/value from db (waits on ongoing migration) + */ async remove(key: K): Promise { await this.withDbLock(async () => { await this.storage.remove(String(key)); }); } + /** + * Adds a listener to detect changes in the storage. + */ addListener(listener: Listener) { this.storage.onChanged.addListener(listener); } + /** + * Removes a listener from the storage change listeners. + */ removeListener(listener: Listener) { this.storage.onChanged.removeListener(listener); } + /** + * A migration happens for the entire storage object. Process: + * During runtime: + * - get, set, or remove is called + * - methods internally calls withDbLock, checking if the lock is already acquired + * - if the lock is not acquired (ie. undefined), acquire the lock by assigning dbLock to the promise returned by migrateOrInitializeIfNeeded + * - wait for the lock to resolve, ensuring initialization or migration is complete + * - execute the storage get, set, or remove operation + * - finally, release the lock. + */ private async withDbLock(fn: () => Promise): Promise { if (this.dbLock) { await this.dbLock; @@ -105,6 +160,9 @@ export class ExtensionStorage { } } + /** + * Migrates all fields from a given version to the next. + */ private async migrateAllFields(storedVersion: number): Promise { const migrationFn = this.version.migrations[storedVersion]; @@ -122,6 +180,9 @@ export class ExtensionStorage { return storedVersion + 1; } + /** + * Initializes the database with defaults or performs migrations (multiple possible if a sequence is needed). + */ private async migrateOrInitializeIfNeeded(): Promise { // If db is empty, initialize it with defaults. const bytesInUse = await this.storage.getBytesInUse();