import { BaseStore } from './base-store.js'; import { StoreInMemory } from './store-inmemory.js'; /** * A store that accepts multiple stores and syncs them by key. * * @remarks * * Syncing is done by key - if values differ, then this will remain undetected. * This is by design - local stores will sometimes prune irrelevant values as an optimization (while retaining the key). * However, this cannot be done on the encrypted stores as the encrypted stores cannot inspect the data to know whether relevant/irrelevant. * UUIDv7 keys are used to ensure uniqueness of keys. */ export class StoreComposite extends BaseStore { static async from( stores: Array = [], primaryStore: BaseStore = StoreInMemory.from(), ) { const compositeStore = new this(stores, primaryStore); await compositeStore.sync(); return compositeStore; } constructor( private stores: Array = [], private primaryStore: BaseStore, ) { super(); } async all() { return await this.primaryStore.all(); } async keys() { return await this.primaryStore.keys(); } async get(key: string) { return await this.primaryStore.get(key); } async set(key: string, value: T) { await this.primaryStore.set(key, value); this.emit('set', { key, value }); } async delete(key: string) { await this.primaryStore.delete(key); this.emit('delete', { key }); } async clear() { await this.primaryStore.clear(); this.emit('clear', undefined); } async has(key: string) { return await this.primaryStore.has(key); } async close() { await Promise.all([ this.primaryStore.close(), ...this.stores.map((store) => store.close()), ]); } /** * Add a new remote store and set up synchronization */ async addStore(remoteStore: BaseStore): Promise { this.stores.push(remoteStore); // Propagate existing local events to the new remote this.primaryStore.on('set', async (payload) => { if (await remoteStore.has(payload.key)) return; await remoteStore.set(payload.key, payload.value); }); this.primaryStore.on('delete', async (payload) => { if (await remoteStore.has(payload.key)) { await remoteStore.delete(payload.key); } }); this.primaryStore.on('clear', async () => { await remoteStore.clear(); }); // Set up remote to local propagation remoteStore.on('set', async (payload) => { if (await this.primaryStore.has(payload.key)) return; await this.primaryStore.set(payload.key, payload.value); }); remoteStore.on('delete', async (payload) => { if (await this.primaryStore.has(payload.key)) { await this.primaryStore.delete(payload.key); } }); remoteStore.on('clear', async () => { await this.primaryStore.clear(); }); // Sync with the new remote await this.syncStore(remoteStore); } async sync(): Promise { // Sync all remote stores in parallel await Promise.all( this.stores.map(async (remoteStore) => this.syncStore(remoteStore)), ); } private async syncStore(store: BaseStore): Promise { const storeKeys = await store.keys(); const primaryStoreKeys = await this.primaryStore.keys(); const missingLocalKeys = storeKeys.filter( (key) => !primaryStoreKeys.includes(key), ); const missingRemoteKeys = primaryStoreKeys.filter( (key) => !storeKeys.includes(key), ); await Promise.all([ ...missingLocalKeys.map(async (key) => { const value = await store.get(key); await this.set(key, value); }), ...missingRemoteKeys.map(async (key) => { const value = await this.primaryStore.get(key); await store.set(key, value); }), ]); } }