import { BaseStore } from './base-store.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(localStore: BaseStore, remoteStores: Array) { const compositeStore = new this(localStore, remoteStores); await compositeStore.sync(); return compositeStore; } constructor( private localStore: BaseStore, private remoteStores: Array, ) { super(); // Local store events propagate to remote stores this.localStore.on('set', async (payload) => { // Propagate to all remote stores in parallel await Promise.all( this.remoteStores.map((remoteStore) => remoteStore.set(payload.key, payload.value), ), ); }); // Local store events propagate to remote stores this.localStore.on('delete', async (payload) => { // Propagate to all remote stores in parallel await Promise.all( this.remoteStores.map((remoteStore) => remoteStore.delete(payload.key)), ); }); // Local store events propagate to remote stores this.localStore.on('clear', async () => { // Propagate to all remote stores in parallel await Promise.all( this.remoteStores.map((remoteStore) => remoteStore.clear()), ); }); // Remote store events propagate to local store for (const remoteStore of this.remoteStores) { remoteStore.on('set', async (payload) => { // Skip if the local store already has the key // NOTE: It's important we DO NOT repropagate events that have already occurred. // If we do, we can end up in infinite loops. if (await this.localStore.has(payload.key)) return; await this.localStore.set(payload.key, payload.value); }); remoteStore.on('delete', async (payload) => { // NOTE: It's important we DO NOT repropagate events that have already occurred. // If we do, we can end up in infinite loops. if (await this.localStore.has(payload.key)) { await this.localStore.delete(payload.key); } }); } } async all() { return await this.localStore.all(); } async keys() { return await this.localStore.keys(); } async get(key: string) { return await this.localStore.get(key); } async set(key: string, value: T) { await this.localStore.set(key, value); this.emit('set', { key, value }); } async delete(key: string) { await this.localStore.delete(key); this.emit('delete', { key }); } async clear() { await this.localStore.clear(); this.emit('clear', undefined); } async has(key: string) { return await this.localStore.has(key); } async close() { await Promise.all([ this.localStore.close(), ...this.remoteStores.map((store) => store.close()), ]); } async sync(): Promise { // Sync all remote stores in parallel await Promise.all( this.remoteStores.map(async (remoteStore) => { // TODO: We want to negotiate using an Inverse Bloom Filter first to optimize the sync. // Currently, this is doing a naive set comparison. // Get all keys from the remote store const remoteKeys = await remoteStore.keys(); const localKeys = await this.localStore.keys(); // Find keys missing in local store const missingLocalKeys = remoteKeys.filter( (key) => !localKeys.includes(key), ); // Find keys missing in remote store const missingRemoteKeys = localKeys.filter( (key) => !remoteKeys.includes(key), ); // Sync missing keys from remote to local (in parallel) await Promise.all( missingLocalKeys.map(async (key) => { const value = await remoteStore.get(key); await this.localStore.set(key, value); }), ); // Sync missing keys from local to remote (in parallel) await Promise.all( missingRemoteKeys.map(async (key) => { const value = await this.localStore.get(key); await remoteStore.set(key, value); }), ); }), ); } }