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:
parent
7a34239071
commit
63ac2a268e
|
|
@ -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'); }
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue