perf: parallelize local-first init — batch IDB loads, cache crypto keys, preload sync states

Sequential document loading during init() was the main bottleneck: each cached
doc required a serial IDB read + key derivation + decryption + Automerge.load.
With N documents this meant N× single-doc latency instead of 1×.

Changes:
- Add loadMany() to EncryptedDocStore for parallel Promise.all() batch loading
- Cache derived space/doc CryptoKeys in DocCrypto (one derivation per session)
- Add preloadSyncStates() to DocSyncManager for parallel sync state loading
- Update all 11 module local-first-client init() methods to use batch APIs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-03 14:18:55 -08:00
parent 7a34239071
commit 63ac2a268e
14 changed files with 121 additions and 53 deletions

View File

@ -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<BooksCatalogDoc>(docId, booksCatalogSchema, binary);
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<BooksCatalogDoc>(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'); }

View File

@ -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<CalendarDoc>(docId, calendarSchema, binary);
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<CalendarDoc>(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'); }

View File

@ -38,16 +38,20 @@ export class CartLocalFirstClient {
async init(): Promise<void> {
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<CatalogDoc>(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<OrderDoc>(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<CatalogDoc>(docId, catalogSchema, binary);
} else {
this.#documents.open<OrderDoc>(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'); }

View File

@ -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<FilesDoc>(docId, filesSchema, binary);
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<FilesDoc>(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'); }

View File

@ -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<FundsDoc>(docId, fundsSchema, binary);
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<FundsDoc>(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'); }

View File

@ -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<MailboxDoc>(docId, mailboxSchema, binary);
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<MailboxDoc>(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'); }

View File

@ -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<NotebookDoc>(docId, notebookSchema, binary);
}
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<NotebookDoc>(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}`;

View File

@ -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<SplatScenesDoc>(docId, splatScenesSchema, binary);
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<SplatScenesDoc>(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'); }

View File

@ -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<TripDoc>(docId, tripSchema, binary);
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<TripDoc>(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'); }

View File

@ -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<ProposalDoc>(docId, proposalSchema, binary);
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<ProposalDoc>(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'); }

View File

@ -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<BoardDoc>(docId, boardSchema, binary);
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<BoardDoc>(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'); }

View File

@ -41,6 +41,8 @@ const encoder = new TextEncoder();
*/
export class DocCrypto {
#masterKeyMaterial: CryptoKey | null = null;
#spaceKeyCache = new Map<string, CryptoKey>();
#docKeyCache = new Map<string, CryptoKey>();
/**
* Initialize from a master key. Accepts either:
@ -94,6 +96,9 @@ export class DocCrypto {
async deriveSpaceKey(spaceId: string): Promise<CryptoKey> {
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<CryptoKey> {
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 {

View File

@ -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<Map<DocumentId, Uint8Array>> {
const results = new Map<DocumentId, Uint8Array>();
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.

View File

@ -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<void> {
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());
}
}
}
}