diff --git a/packages/common/infra/src/storage/async-memento.ts b/packages/common/infra/src/storage/async-memento.ts new file mode 100644 index 0000000000..0c801d59a4 --- /dev/null +++ b/packages/common/infra/src/storage/async-memento.ts @@ -0,0 +1,10 @@ +import { type Observable } from 'rxjs'; + +export interface AsyncMemento { + watch(key: string): Observable; + get(key: string): Promise; + set(key: string, value: T | undefined): Promise; + del(key: string): Promise; + clear(): Promise; + keys(): Promise; +} diff --git a/packages/common/infra/src/storage/index.ts b/packages/common/infra/src/storage/index.ts index caffa7a147..8ecc696e6f 100644 --- a/packages/common/infra/src/storage/index.ts +++ b/packages/common/infra/src/storage/index.ts @@ -1,2 +1,3 @@ +export * from './async-memento'; export * from './kv'; export * from './memento'; diff --git a/packages/frontend/core/src/modules/storage/impls/storage.ts b/packages/frontend/core/src/modules/storage/impls/storage.ts index 9ce7814c92..a98abccc0a 100644 --- a/packages/frontend/core/src/modules/storage/impls/storage.ts +++ b/packages/frontend/core/src/modules/storage/impls/storage.ts @@ -1,8 +1,10 @@ -import type { Memento } from '@toeverything/infra'; +import type { AsyncMemento, Memento } from '@toeverything/infra'; import EventEmitter2 from 'eventemitter2'; +import { type IDBPDatabase, openDB } from 'idb'; import { Observable } from 'rxjs'; import type { + CacheStorage, GlobalCache, GlobalSessionState, GlobalState, @@ -99,3 +101,131 @@ export class SessionStorageGlobalSessionState super(sessionStorage, 'global-session-state:'); } } + +export class AsyncStorageMemento implements AsyncMemento { + // eventEmitter is used for same tab event + private readonly eventEmitter = new EventEmitter2(); + // channel is used for cross-tab event + private readonly channel = new BroadcastChannel(this.dbName); + constructor( + private readonly dbName: string, + private readonly table: string + ) {} + + private _db: IDBPDatabase | null = null; + + private async getDB() { + const { dbName, table } = this; + if (!this._db) { + this._db = await openDB(dbName, 1, { + upgrade(db) { + if (!db.objectStoreNames.contains(table)) { + db.createObjectStore(table, { keyPath: 'key' }); + } + }, + }); + } + return this._db; + } + + async get(key: string): Promise { + const db = await this.getDB(); + const tx = db.transaction(this.table, 'readonly'); + const store = tx.objectStore(this.table); + const result = await store.get(key); + return result?.value; + } + + watch(key: string): Observable { + return new Observable(subscriber => { + // Get initial value + this.get(key).then( + value => { + subscriber.next(value); + }, + error => { + console.error('Error getting initial value:', error); + subscriber.next(undefined); + } + ); + + // Listen for same tab events + const eventEmitterCb = (value: T) => { + subscriber.next(value); + }; + this.eventEmitter.on(key, eventEmitterCb); + + // Listen for cross-tab events + // eslint-disable-next-line sonarjs/no-identical-functions + const channelCb = (event: MessageEvent) => { + if (event.data.key === key) { + subscriber.next(event.data.value); + } + }; + this.channel.addEventListener('message', channelCb); + + return () => { + this.eventEmitter.off(key, eventEmitterCb); + this.channel.removeEventListener('message', channelCb); + }; + }); + } + + async set(key: string, value: T | undefined): Promise { + const db = await this.getDB(); + const tx = db.transaction(this.table, 'readwrite'); + const store = tx.objectStore(this.table); + + if (value === undefined) { + await store.delete(key); + } else { + await store.put({ key, value }); + } + + // Emit events + this.eventEmitter.emit(key, value); + this.channel.postMessage({ key, value }); + } + + async del(key: string): Promise { + const db = await this.getDB(); + const tx = db.transaction(this.table, 'readwrite'); + const store = tx.objectStore(this.table); + await store.delete(key); + + // Emit events + this.eventEmitter.emit(key, undefined); + this.channel.postMessage({ key, value: undefined }); + } + + async clear(): Promise { + const keys = await this.keys(); + const db = await this.getDB(); + const tx = db.transaction(this.table, 'readwrite'); + const store = tx.objectStore(this.table); + await store.clear(); + + // Notify observers about each deleted key + for (const key of keys) { + this.eventEmitter.emit(key, undefined); + this.channel.postMessage({ key, value: undefined }); + } + } + + async keys(): Promise { + const db = await this.getDB(); + const tx = db.transaction(this.table, 'readonly'); + const store = tx.objectStore(this.table); + const allObjects = await store.getAll(); + return allObjects.map(obj => obj.key); + } +} + +export class IDBGlobalState + extends AsyncStorageMemento + implements CacheStorage +{ + constructor() { + super('global-storage', 'global-state'); + } +} diff --git a/packages/frontend/core/src/modules/storage/index.ts b/packages/frontend/core/src/modules/storage/index.ts index 5996a8f3eb..1e70f32304 100644 --- a/packages/frontend/core/src/modules/storage/index.ts +++ b/packages/frontend/core/src/modules/storage/index.ts @@ -16,11 +16,13 @@ import { type Framework } from '@toeverything/infra'; import { DesktopApiService } from '../desktop-api'; import { ElectronGlobalCache, ElectronGlobalState } from './impls/electron'; import { + IDBGlobalState, LocalStorageGlobalCache, LocalStorageGlobalState, SessionStorageGlobalSessionState, } from './impls/storage'; import { + CacheStorage, GlobalCache, GlobalSessionState, GlobalState, @@ -43,11 +45,13 @@ export const configureStorageModule = (framework: Framework) => { export function configureLocalStorageStateStorageImpls(framework: Framework) { framework.impl(GlobalCache, LocalStorageGlobalCache); framework.impl(GlobalState, LocalStorageGlobalState); + framework.impl(CacheStorage, IDBGlobalState); } export function configureElectronStateStorageImpls(framework: Framework) { framework.impl(GlobalCache, ElectronGlobalCache, [DesktopApiService]); framework.impl(GlobalState, ElectronGlobalState, [DesktopApiService]); + framework.impl(CacheStorage, IDBGlobalState); } export function configureCommonGlobalStorageImpls(framework: Framework) { diff --git a/packages/frontend/core/src/modules/storage/providers/global.ts b/packages/frontend/core/src/modules/storage/providers/global.ts index 36f643c25d..d0c473930e 100644 --- a/packages/frontend/core/src/modules/storage/providers/global.ts +++ b/packages/frontend/core/src/modules/storage/providers/global.ts @@ -1,4 +1,8 @@ -import { createIdentifier, type Memento } from '@toeverything/infra'; +import { + type AsyncMemento, + createIdentifier, + type Memento, +} from '@toeverything/infra'; /** * A memento object that stores the entire application state. @@ -25,3 +29,7 @@ export const GlobalCache = createIdentifier('GlobalCache'); export interface GlobalSessionState extends Memento {} export const GlobalSessionState = createIdentifier('GlobalSessionState'); + +export interface CacheStorage extends AsyncMemento {} + +export const CacheStorage = createIdentifier('CacheStorage');