diff --git a/modules/rbooks/local-first-client.ts b/modules/rbooks/local-first-client.ts index 23b9249..7af78ac 100644 --- a/modules/rbooks/local-first-client.ts +++ b/modules/rbooks/local-first-client.ts @@ -38,10 +38,11 @@ export class BooksLocalFirstClient { if (this.#initialized) return; await this.#store.open(); const cachedIds = await this.#store.listByModule('books', 'catalog'); - for (const docId of cachedIds) { - const binary = await this.#store.load(docId); - if (binary) this.#documents.open(docId, booksCatalogSchema, binary); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, booksCatalogSchema, binary); } + await this.#sync.preloadSyncStates(cachedIds); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[BooksClient] Working offline'); } diff --git a/modules/rcal/local-first-client.ts b/modules/rcal/local-first-client.ts index 12e0bec..d45f74b 100644 --- a/modules/rcal/local-first-client.ts +++ b/modules/rcal/local-first-client.ts @@ -38,10 +38,11 @@ export class CalLocalFirstClient { if (this.#initialized) return; await this.#store.open(); const cachedIds = await this.#store.listByModule('cal', 'events'); - for (const docId of cachedIds) { - const binary = await this.#store.load(docId); - if (binary) this.#documents.open(docId, calendarSchema, binary); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, calendarSchema, binary); } + await this.#sync.preloadSyncStates(cachedIds); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[CalClient] Working offline'); } diff --git a/modules/rcart/local-first-client.ts b/modules/rcart/local-first-client.ts index d0ea566..e80d17e 100644 --- a/modules/rcart/local-first-client.ts +++ b/modules/rcart/local-first-client.ts @@ -38,16 +38,20 @@ export class CartLocalFirstClient { async init(): Promise { if (this.#initialized) return; await this.#store.open(); - const catalogIds = await this.#store.listByModule('cart', 'catalog'); - for (const docId of catalogIds) { - const binary = await this.#store.load(docId); - if (binary) this.#documents.open(docId, catalogSchema, binary); - } - const orderIds = await this.#store.listByModule('cart', 'orders'); - for (const docId of orderIds) { - const binary = await this.#store.load(docId); - if (binary) this.#documents.open(docId, orderSchema, binary); + const [catalogIds, orderIds] = await Promise.all([ + this.#store.listByModule('cart', 'catalog'), + this.#store.listByModule('cart', 'orders'), + ]); + const allIds = [...catalogIds, ...orderIds]; + const cached = await this.#store.loadMany(allIds); + for (const [docId, binary] of cached) { + if (catalogIds.includes(docId)) { + this.#documents.open(docId, catalogSchema, binary); + } else { + this.#documents.open(docId, orderSchema, binary); + } } + await this.#sync.preloadSyncStates(allIds); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[CartClient] Working offline'); } diff --git a/modules/rfiles/local-first-client.ts b/modules/rfiles/local-first-client.ts index 785dece..370a62c 100644 --- a/modules/rfiles/local-first-client.ts +++ b/modules/rfiles/local-first-client.ts @@ -38,10 +38,11 @@ export class FilesLocalFirstClient { if (this.#initialized) return; await this.#store.open(); const cachedIds = await this.#store.listByModule('files', 'cards'); - for (const docId of cachedIds) { - const binary = await this.#store.load(docId); - if (binary) this.#documents.open(docId, filesSchema, binary); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, filesSchema, binary); } + await this.#sync.preloadSyncStates(cachedIds); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[FilesClient] Working offline'); } diff --git a/modules/rfunds/local-first-client.ts b/modules/rfunds/local-first-client.ts index d17384c..ed1ece3 100644 --- a/modules/rfunds/local-first-client.ts +++ b/modules/rfunds/local-first-client.ts @@ -38,10 +38,11 @@ export class FundsLocalFirstClient { if (this.#initialized) return; await this.#store.open(); const cachedIds = await this.#store.listByModule('funds', 'flows'); - for (const docId of cachedIds) { - const binary = await this.#store.load(docId); - if (binary) this.#documents.open(docId, fundsSchema, binary); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, fundsSchema, binary); } + await this.#sync.preloadSyncStates(cachedIds); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[FundsClient] Working offline'); } diff --git a/modules/rinbox/local-first-client.ts b/modules/rinbox/local-first-client.ts index 3bffed4..28247d8 100644 --- a/modules/rinbox/local-first-client.ts +++ b/modules/rinbox/local-first-client.ts @@ -38,10 +38,11 @@ export class InboxLocalFirstClient { if (this.#initialized) return; await this.#store.open(); const cachedIds = await this.#store.listByModule('inbox', 'mailboxes'); - for (const docId of cachedIds) { - const binary = await this.#store.load(docId); - if (binary) this.#documents.open(docId, mailboxSchema, binary); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, mailboxSchema, binary); } + await this.#sync.preloadSyncStates(cachedIds); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[InboxClient] Working offline'); } diff --git a/modules/rnotes/local-first-client.ts b/modules/rnotes/local-first-client.ts index 412e76a..e016f55 100644 --- a/modules/rnotes/local-first-client.ts +++ b/modules/rnotes/local-first-client.ts @@ -54,15 +54,16 @@ export class NotesLocalFirstClient { // Open IndexedDB store await this.#store.open(); - // Load any cached notebook docs from IndexedDB + // Load all cached notebook docs from IndexedDB in parallel const cachedIds = await this.#store.listByModule('notes', 'notebooks'); - for (const docId of cachedIds) { - const binary = await this.#store.load(docId); - if (binary) { - this.#documents.open(docId, notebookSchema, binary); - } + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, notebookSchema, binary); } + // Preload sync states in parallel before connecting + await this.#sync.preloadSyncStates(cachedIds); + // Connect to sync server const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; diff --git a/modules/rsplat/local-first-client.ts b/modules/rsplat/local-first-client.ts index b3ae570..6d6ecfd 100644 --- a/modules/rsplat/local-first-client.ts +++ b/modules/rsplat/local-first-client.ts @@ -38,10 +38,11 @@ export class SplatLocalFirstClient { if (this.#initialized) return; await this.#store.open(); const cachedIds = await this.#store.listByModule('splat', 'scenes'); - for (const docId of cachedIds) { - const binary = await this.#store.load(docId); - if (binary) this.#documents.open(docId, splatScenesSchema, binary); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, splatScenesSchema, binary); } + await this.#sync.preloadSyncStates(cachedIds); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[SplatClient] Working offline'); } diff --git a/modules/rtrips/local-first-client.ts b/modules/rtrips/local-first-client.ts index e89efa4..fb0eb29 100644 --- a/modules/rtrips/local-first-client.ts +++ b/modules/rtrips/local-first-client.ts @@ -37,10 +37,11 @@ export class TripsLocalFirstClient { if (this.#initialized) return; await this.#store.open(); const cachedIds = await this.#store.listByModule('trips', 'trips'); - for (const docId of cachedIds) { - const binary = await this.#store.load(docId); - if (binary) this.#documents.open(docId, tripSchema, binary); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, tripSchema, binary); } + await this.#sync.preloadSyncStates(cachedIds); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[TripsClient] Working offline'); } diff --git a/modules/rvote/local-first-client.ts b/modules/rvote/local-first-client.ts index 22950b6..9b75696 100644 --- a/modules/rvote/local-first-client.ts +++ b/modules/rvote/local-first-client.ts @@ -38,10 +38,11 @@ export class VoteLocalFirstClient { if (this.#initialized) return; await this.#store.open(); const cachedIds = await this.#store.listByModule('vote', 'proposals'); - for (const docId of cachedIds) { - const binary = await this.#store.load(docId); - if (binary) this.#documents.open(docId, proposalSchema, binary); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, proposalSchema, binary); } + await this.#sync.preloadSyncStates(cachedIds); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[VoteClient] Working offline'); } diff --git a/modules/rwork/local-first-client.ts b/modules/rwork/local-first-client.ts index a7eefa8..6f26777 100644 --- a/modules/rwork/local-first-client.ts +++ b/modules/rwork/local-first-client.ts @@ -38,10 +38,11 @@ export class WorkLocalFirstClient { if (this.#initialized) return; await this.#store.open(); const cachedIds = await this.#store.listByModule('work', 'boards'); - for (const docId of cachedIds) { - const binary = await this.#store.load(docId); - if (binary) this.#documents.open(docId, boardSchema, binary); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, boardSchema, binary); } + await this.#sync.preloadSyncStates(cachedIds); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[WorkClient] Working offline'); } diff --git a/shared/local-first/crypto.ts b/shared/local-first/crypto.ts index e5625b7..210ea26 100644 --- a/shared/local-first/crypto.ts +++ b/shared/local-first/crypto.ts @@ -41,6 +41,8 @@ const encoder = new TextEncoder(); */ export class DocCrypto { #masterKeyMaterial: CryptoKey | null = null; + #spaceKeyCache = new Map(); + #docKeyCache = new Map(); /** * Initialize from a master key. Accepts either: @@ -94,6 +96,9 @@ export class DocCrypto { async deriveSpaceKey(spaceId: string): Promise { this.#assertInit(); + const cached = this.#spaceKeyCache.get(spaceId); + if (cached) return cached; + // Derive 256 bits of key material for the space const bits = await crypto.subtle.deriveBits( { @@ -107,13 +112,15 @@ export class DocCrypto { ); // Re-import as HKDF for further derivation (space → doc) - return crypto.subtle.importKey( + const key = await crypto.subtle.importKey( 'raw', bits, { name: 'HKDF' }, false, ['deriveKey', 'deriveBits'] ); + this.#spaceKeyCache.set(spaceId, key); + return key; } /** @@ -121,7 +128,10 @@ export class DocCrypto { * info = "rspace:{spaceId}:{docId}" */ async deriveDocKey(spaceKey: CryptoKey, docId: string): Promise { - return crypto.subtle.deriveKey( + const cached = this.#docKeyCache.get(docId); + if (cached) return cached; + + const key = await crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', @@ -133,6 +143,8 @@ export class DocCrypto { false, // non-extractable ['encrypt', 'decrypt'] ); + this.#docKeyCache.set(docId, key); + return key; } /** @@ -200,6 +212,8 @@ export class DocCrypto { */ clear(): void { this.#masterKeyMaterial = null; + this.#spaceKeyCache.clear(); + this.#docKeyCache.clear(); } #assertInit(): void { diff --git a/shared/local-first/storage.ts b/shared/local-first/storage.ts index 3bc2c54..70d8cae 100644 --- a/shared/local-first/storage.ts +++ b/shared/local-first/storage.ts @@ -154,6 +154,20 @@ export class EncryptedDocStore { return stored.data; } + /** + * Load multiple documents in parallel. Returns a Map of docId → decrypted bytes. + */ + async loadMany(docIds: DocumentId[]): Promise> { + const results = new Map(); + if (!this.#db || docIds.length === 0) return results; + + const entries = await Promise.all(docIds.map(id => this.load(id).then(data => [id, data] as const))); + for (const [id, data] of entries) { + if (data) results.set(id, data); + } + return results; + } + /** * Load raw stored bytes for a document (without decrypting). * Used by the backup manager to upload already-encrypted blobs. diff --git a/shared/local-first/sync.ts b/shared/local-first/sync.ts index 8a305a2..8a04abc 100644 --- a/shared/local-first/sync.ts +++ b/shared/local-first/sync.ts @@ -202,6 +202,31 @@ export class DocSyncManager { }); } + /** + * Preload sync states for multiple documents in parallel. + * Call before subscribe() to avoid sequential IDB reads during subscription. + */ + async preloadSyncStates(docIds: DocumentId[]): Promise { + if (!this.#store) return; + + const toLoad = docIds.filter(id => !this.#syncStates.has(id)); + if (toLoad.length === 0) return; + + const entries = await Promise.all( + toLoad.map(id => + this.#store!.loadSyncState(id, this.#peerId).then(saved => [id, saved] as const) + ) + ); + + for (const [id, saved] of entries) { + if (saved) { + this.#syncStates.set(id, Automerge.decodeSyncState(saved)); + } else { + this.#syncStates.set(id, Automerge.initSyncState()); + } + } + } + /** * Subscribe to sync for one or more documents. */ @@ -213,16 +238,17 @@ export class DocSyncManager { this.#subscribedDocs.add(id); newIds.push(id); - // Initialize sync state from store if available - if (this.#store && !this.#syncStates.has(id)) { - const saved = await this.#store.loadSyncState(id, this.#peerId); - if (saved) { - this.#syncStates.set(id, Automerge.decodeSyncState(saved)); - } - } - + // Initialize sync state from store if available (skip if preloaded) if (!this.#syncStates.has(id)) { - this.#syncStates.set(id, Automerge.initSyncState()); + if (this.#store) { + const saved = await this.#store.loadSyncState(id, this.#peerId); + if (saved) { + this.#syncStates.set(id, Automerge.decodeSyncState(saved)); + } + } + if (!this.#syncStates.has(id)) { + this.#syncStates.set(id, Automerge.initSyncState()); + } } } }