Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-03 14:20:55 -08:00
commit 69bd4a476b
14 changed files with 121 additions and 53 deletions

View File

@ -38,10 +38,11 @@ export class BooksLocalFirstClient {
if (this.#initialized) return; if (this.#initialized) return;
await this.#store.open(); await this.#store.open();
const cachedIds = await this.#store.listByModule('books', 'catalog'); const cachedIds = await this.#store.listByModule('books', 'catalog');
for (const docId of cachedIds) { const cached = await this.#store.loadMany(cachedIds);
const binary = await this.#store.load(docId); for (const [docId, binary] of cached) {
if (binary) this.#documents.open<BooksCatalogDoc>(docId, booksCatalogSchema, binary); this.#documents.open<BooksCatalogDoc>(docId, booksCatalogSchema, binary);
} }
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[BooksClient] Working offline'); } 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; if (this.#initialized) return;
await this.#store.open(); await this.#store.open();
const cachedIds = await this.#store.listByModule('cal', 'events'); const cachedIds = await this.#store.listByModule('cal', 'events');
for (const docId of cachedIds) { const cached = await this.#store.loadMany(cachedIds);
const binary = await this.#store.load(docId); for (const [docId, binary] of cached) {
if (binary) this.#documents.open<CalendarDoc>(docId, calendarSchema, binary); this.#documents.open<CalendarDoc>(docId, calendarSchema, binary);
} }
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[CalClient] Working offline'); } 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> { async init(): Promise<void> {
if (this.#initialized) return; if (this.#initialized) return;
await this.#store.open(); await this.#store.open();
const catalogIds = await this.#store.listByModule('cart', 'catalog'); const [catalogIds, orderIds] = await Promise.all([
for (const docId of catalogIds) { this.#store.listByModule('cart', 'catalog'),
const binary = await this.#store.load(docId); this.#store.listByModule('cart', 'orders'),
if (binary) this.#documents.open<CatalogDoc>(docId, catalogSchema, binary); ]);
} const allIds = [...catalogIds, ...orderIds];
const orderIds = await this.#store.listByModule('cart', 'orders'); const cached = await this.#store.loadMany(allIds);
for (const docId of orderIds) { for (const [docId, binary] of cached) {
const binary = await this.#store.load(docId); if (catalogIds.includes(docId)) {
if (binary) this.#documents.open<OrderDoc>(docId, orderSchema, binary); 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 proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[CartClient] Working offline'); } 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; if (this.#initialized) return;
await this.#store.open(); await this.#store.open();
const cachedIds = await this.#store.listByModule('files', 'cards'); const cachedIds = await this.#store.listByModule('files', 'cards');
for (const docId of cachedIds) { const cached = await this.#store.loadMany(cachedIds);
const binary = await this.#store.load(docId); for (const [docId, binary] of cached) {
if (binary) this.#documents.open<FilesDoc>(docId, filesSchema, binary); this.#documents.open<FilesDoc>(docId, filesSchema, binary);
} }
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[FilesClient] Working offline'); } 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; if (this.#initialized) return;
await this.#store.open(); await this.#store.open();
const cachedIds = await this.#store.listByModule('funds', 'flows'); const cachedIds = await this.#store.listByModule('funds', 'flows');
for (const docId of cachedIds) { const cached = await this.#store.loadMany(cachedIds);
const binary = await this.#store.load(docId); for (const [docId, binary] of cached) {
if (binary) this.#documents.open<FundsDoc>(docId, fundsSchema, binary); this.#documents.open<FundsDoc>(docId, fundsSchema, binary);
} }
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[FundsClient] Working offline'); } 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; if (this.#initialized) return;
await this.#store.open(); await this.#store.open();
const cachedIds = await this.#store.listByModule('inbox', 'mailboxes'); const cachedIds = await this.#store.listByModule('inbox', 'mailboxes');
for (const docId of cachedIds) { const cached = await this.#store.loadMany(cachedIds);
const binary = await this.#store.load(docId); for (const [docId, binary] of cached) {
if (binary) this.#documents.open<MailboxDoc>(docId, mailboxSchema, binary); this.#documents.open<MailboxDoc>(docId, mailboxSchema, binary);
} }
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[InboxClient] Working offline'); } 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 // Open IndexedDB store
await this.#store.open(); 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'); const cachedIds = await this.#store.listByModule('notes', 'notebooks');
for (const docId of cachedIds) { const cached = await this.#store.loadMany(cachedIds);
const binary = await this.#store.load(docId); for (const [docId, binary] of cached) {
if (binary) { this.#documents.open<NotebookDoc>(docId, notebookSchema, binary);
this.#documents.open<NotebookDoc>(docId, notebookSchema, binary);
}
} }
// Preload sync states in parallel before connecting
await this.#sync.preloadSyncStates(cachedIds);
// Connect to sync server // Connect to sync server
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;

View File

@ -38,10 +38,11 @@ export class SplatLocalFirstClient {
if (this.#initialized) return; if (this.#initialized) return;
await this.#store.open(); await this.#store.open();
const cachedIds = await this.#store.listByModule('splat', 'scenes'); const cachedIds = await this.#store.listByModule('splat', 'scenes');
for (const docId of cachedIds) { const cached = await this.#store.loadMany(cachedIds);
const binary = await this.#store.load(docId); for (const [docId, binary] of cached) {
if (binary) this.#documents.open<SplatScenesDoc>(docId, splatScenesSchema, binary); this.#documents.open<SplatScenesDoc>(docId, splatScenesSchema, binary);
} }
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[SplatClient] Working offline'); } 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; if (this.#initialized) return;
await this.#store.open(); await this.#store.open();
const cachedIds = await this.#store.listByModule('trips', 'trips'); const cachedIds = await this.#store.listByModule('trips', 'trips');
for (const docId of cachedIds) { const cached = await this.#store.loadMany(cachedIds);
const binary = await this.#store.load(docId); for (const [docId, binary] of cached) {
if (binary) this.#documents.open<TripDoc>(docId, tripSchema, binary); this.#documents.open<TripDoc>(docId, tripSchema, binary);
} }
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[TripsClient] Working offline'); } 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; if (this.#initialized) return;
await this.#store.open(); await this.#store.open();
const cachedIds = await this.#store.listByModule('vote', 'proposals'); const cachedIds = await this.#store.listByModule('vote', 'proposals');
for (const docId of cachedIds) { const cached = await this.#store.loadMany(cachedIds);
const binary = await this.#store.load(docId); for (const [docId, binary] of cached) {
if (binary) this.#documents.open<ProposalDoc>(docId, proposalSchema, binary); this.#documents.open<ProposalDoc>(docId, proposalSchema, binary);
} }
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[VoteClient] Working offline'); } 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; if (this.#initialized) return;
await this.#store.open(); await this.#store.open();
const cachedIds = await this.#store.listByModule('work', 'boards'); const cachedIds = await this.#store.listByModule('work', 'boards');
for (const docId of cachedIds) { const cached = await this.#store.loadMany(cachedIds);
const binary = await this.#store.load(docId); for (const [docId, binary] of cached) {
if (binary) this.#documents.open<BoardDoc>(docId, boardSchema, binary); this.#documents.open<BoardDoc>(docId, boardSchema, binary);
} }
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[WorkClient] Working offline'); } 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 { export class DocCrypto {
#masterKeyMaterial: CryptoKey | null = null; #masterKeyMaterial: CryptoKey | null = null;
#spaceKeyCache = new Map<string, CryptoKey>();
#docKeyCache = new Map<string, CryptoKey>();
/** /**
* Initialize from a master key. Accepts either: * Initialize from a master key. Accepts either:
@ -94,6 +96,9 @@ export class DocCrypto {
async deriveSpaceKey(spaceId: string): Promise<CryptoKey> { async deriveSpaceKey(spaceId: string): Promise<CryptoKey> {
this.#assertInit(); this.#assertInit();
const cached = this.#spaceKeyCache.get(spaceId);
if (cached) return cached;
// Derive 256 bits of key material for the space // Derive 256 bits of key material for the space
const bits = await crypto.subtle.deriveBits( const bits = await crypto.subtle.deriveBits(
{ {
@ -107,13 +112,15 @@ export class DocCrypto {
); );
// Re-import as HKDF for further derivation (space → doc) // Re-import as HKDF for further derivation (space → doc)
return crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', 'raw',
bits, bits,
{ name: 'HKDF' }, { name: 'HKDF' },
false, false,
['deriveKey', 'deriveBits'] ['deriveKey', 'deriveBits']
); );
this.#spaceKeyCache.set(spaceId, key);
return key;
} }
/** /**
@ -121,7 +128,10 @@ export class DocCrypto {
* info = "rspace:{spaceId}:{docId}" * info = "rspace:{spaceId}:{docId}"
*/ */
async deriveDocKey(spaceKey: CryptoKey, docId: string): Promise<CryptoKey> { 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', name: 'HKDF',
hash: 'SHA-256', hash: 'SHA-256',
@ -133,6 +143,8 @@ export class DocCrypto {
false, // non-extractable false, // non-extractable
['encrypt', 'decrypt'] ['encrypt', 'decrypt']
); );
this.#docKeyCache.set(docId, key);
return key;
} }
/** /**
@ -200,6 +212,8 @@ export class DocCrypto {
*/ */
clear(): void { clear(): void {
this.#masterKeyMaterial = null; this.#masterKeyMaterial = null;
this.#spaceKeyCache.clear();
this.#docKeyCache.clear();
} }
#assertInit(): void { #assertInit(): void {

View File

@ -154,6 +154,20 @@ export class EncryptedDocStore {
return stored.data; 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). * Load raw stored bytes for a document (without decrypting).
* Used by the backup manager to upload already-encrypted blobs. * 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. * Subscribe to sync for one or more documents.
*/ */
@ -213,16 +238,17 @@ export class DocSyncManager {
this.#subscribedDocs.add(id); this.#subscribedDocs.add(id);
newIds.push(id); newIds.push(id);
// Initialize sync state from store if available // Initialize sync state from store if available (skip if preloaded)
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));
}
}
if (!this.#syncStates.has(id)) { 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());
}
} }
} }
} }