rspace-online/shared/local-first/storage.ts

372 lines
11 KiB
TypeScript

/**
* Layer 3: Storage — Encrypted multi-document IndexedDB store.
*
* Extends the single-document OfflineStore pattern into a multi-document,
* per-document encrypted store. Each document is AES-256-GCM encrypted
* at rest using keys derived from the user's passkey (DocCrypto).
*
* IndexedDB database: "rspace-docs"
* Object store "docs": { docId, data (EncryptedBlob packed), updatedAt }
* Object store "meta": { docId, module, collection, version, createdAt, updatedAt }
* Object store "sync": { key: "{docId}:{peerId}", state: Uint8Array }
* Index on "meta" by [module] for listByModule queries
*/
import type { DocumentId } from './document';
import { DocCrypto, type EncryptedBlob } from './crypto';
// ============================================================================
// TYPES
// ============================================================================
interface StoredDoc {
docId: string;
/** Packed encrypted blob (nonce + ciphertext) — or raw bytes if encryption disabled */
data: Uint8Array;
updatedAt: number;
encrypted: boolean;
}
interface StoredMeta {
docId: string;
module: string;
collection: string;
version: number;
createdAt: number;
updatedAt: number;
}
interface StoredSyncState {
key: string; // "{docId}\0{peerId}"
state: Uint8Array;
}
// ============================================================================
// EncryptedDocStore
// ============================================================================
export class EncryptedDocStore {
#db: IDBDatabase | null = null;
#dbName = 'rspace-docs';
#version = 1;
#crypto: DocCrypto | null = null;
#spaceId: string;
// Debounce infrastructure (same pattern as OfflineStore)
#saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
#pendingSaves = new Map<string, { docId: DocumentId; data: Uint8Array }>();
#saveDebounceMs = 2000;
constructor(spaceId: string, docCrypto?: DocCrypto) {
this.#spaceId = spaceId;
this.#crypto = docCrypto ?? null;
}
/**
* Open the IndexedDB database. Must be called before any other method.
*/
async open(): Promise<void> {
if (this.#db) return;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.#dbName, this.#version);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('docs')) {
db.createObjectStore('docs', { keyPath: 'docId' });
}
if (!db.objectStoreNames.contains('meta')) {
const metaStore = db.createObjectStore('meta', { keyPath: 'docId' });
metaStore.createIndex('by_module', 'module', { unique: false });
metaStore.createIndex('by_module_collection', ['module', 'collection'], { unique: false });
}
if (!db.objectStoreNames.contains('sync')) {
db.createObjectStore('sync', { keyPath: 'key' });
}
};
request.onsuccess = () => {
this.#db = request.result;
resolve();
};
request.onerror = () => {
console.error('[EncryptedDocStore] Failed to open IndexedDB:', request.error);
reject(request.error);
};
});
}
/**
* Save a document (debounced). Encrypts if DocCrypto is configured.
*/
save(docId: DocumentId, plaintext: Uint8Array, meta?: { module: string; collection: string; version: number }): void {
this.#pendingSaves.set(docId, { docId, data: plaintext });
const existing = this.#saveTimers.get(docId);
if (existing) clearTimeout(existing);
this.#saveTimers.set(
docId,
setTimeout(() => {
this.#saveTimers.delete(docId);
this.#pendingSaves.delete(docId);
this.#writeDoc(docId, plaintext, meta).catch((e) => {
console.error('[EncryptedDocStore] Failed to save doc:', e);
});
}, this.#saveDebounceMs)
);
}
/**
* Save a document immediately (bypasses debounce). Use before page unload.
*/
async saveImmediate(docId: DocumentId, plaintext: Uint8Array, meta?: { module: string; collection: string; version: number }): Promise<void> {
const existing = this.#saveTimers.get(docId);
if (existing) {
clearTimeout(existing);
this.#saveTimers.delete(docId);
}
this.#pendingSaves.delete(docId);
await this.#writeDoc(docId, plaintext, meta);
}
/**
* Load a document. Decrypts if encrypted.
*/
async load(docId: DocumentId): Promise<Uint8Array | null> {
if (!this.#db) return null;
const stored = await this.#getDoc(docId);
if (!stored) return null;
if (stored.encrypted && this.#crypto) {
const docKey = await this.#crypto.deriveDocKeyDirect(this.#spaceId, docId);
const blob = DocCrypto.unpack(stored.data);
return this.#crypto.decrypt(docKey, blob);
}
return stored.data;
}
/**
* Delete a document and its metadata.
*/
async delete(docId: DocumentId): Promise<void> {
if (!this.#db) return;
const tx = this.#db.transaction(['docs', 'meta'], 'readwrite');
tx.objectStore('docs').delete(docId);
tx.objectStore('meta').delete(docId);
await this.#txComplete(tx);
}
/**
* List all document IDs for a space and module.
*/
async listByModule(module: string, collection?: string): Promise<DocumentId[]> {
if (!this.#db) return [];
return new Promise((resolve, reject) => {
const tx = this.#db!.transaction('meta', 'readonly');
const store = tx.objectStore('meta');
let request: IDBRequest;
if (collection) {
const index = store.index('by_module_collection');
request = index.getAllKeys(IDBKeyRange.only([module, collection]));
} else {
const index = store.index('by_module');
request = index.getAllKeys(IDBKeyRange.only(module));
}
request.onsuccess = () => {
// Filter to only docs in this space
const all = request.result as string[];
const filtered = all.filter((id) => id.startsWith(`${this.#spaceId}:`));
resolve(filtered as DocumentId[]);
};
request.onerror = () => reject(request.error);
});
}
/**
* List all stored document IDs.
*/
async listAll(): Promise<DocumentId[]> {
if (!this.#db) return [];
return new Promise((resolve, reject) => {
const tx = this.#db!.transaction('meta', 'readonly');
const request = tx.objectStore('meta').getAllKeys();
request.onsuccess = () => {
const all = request.result as string[];
const filtered = all.filter((id) => id.startsWith(`${this.#spaceId}:`));
resolve(filtered as DocumentId[]);
};
request.onerror = () => reject(request.error);
});
}
/**
* Save sync state for a (docId, peerId) pair.
*/
async saveSyncState(docId: DocumentId, peerId: string, state: Uint8Array): Promise<void> {
if (!this.#db) return;
const entry: StoredSyncState = {
key: `${docId}\0${peerId}`,
state,
};
const tx = this.#db.transaction('sync', 'readwrite');
tx.objectStore('sync').put(entry);
await this.#txComplete(tx);
}
/**
* Load sync state for a (docId, peerId) pair.
*/
async loadSyncState(docId: DocumentId, peerId: string): Promise<Uint8Array | null> {
if (!this.#db) return null;
return new Promise((resolve, reject) => {
const tx = this.#db!.transaction('sync', 'readonly');
const request = tx.objectStore('sync').get(`${docId}\0${peerId}`);
request.onsuccess = () => {
const entry = request.result as StoredSyncState | undefined;
resolve(entry?.state ?? null);
};
request.onerror = () => reject(request.error);
});
}
/**
* Clear all sync state for a document.
*/
async clearSyncState(docId: DocumentId): Promise<void> {
if (!this.#db) return;
// Have to iterate since there's no compound index
const tx = this.#db.transaction('sync', 'readwrite');
const store = tx.objectStore('sync');
const request = store.openCursor();
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) { resolve(); return; }
const key = cursor.key as string;
if (key.startsWith(`${docId}\0`)) {
cursor.delete();
}
cursor.continue();
};
request.onerror = () => reject(request.error);
});
}
/**
* Get metadata for a document.
*/
async getMeta(docId: DocumentId): Promise<StoredMeta | null> {
if (!this.#db) return null;
return new Promise((resolve, reject) => {
const tx = this.#db!.transaction('meta', 'readonly');
const request = tx.objectStore('meta').get(docId);
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error);
});
}
/**
* Flush all pending debounced saves immediately.
*/
async flush(): Promise<void> {
const promises: Promise<void>[] = [];
for (const [docId, { data }] of this.#pendingSaves) {
const timer = this.#saveTimers.get(docId);
if (timer) clearTimeout(timer);
this.#saveTimers.delete(docId);
promises.push(this.#writeDoc(docId as DocumentId, data));
}
this.#pendingSaves.clear();
await Promise.all(promises);
}
// ---------- Private helpers ----------
async #writeDoc(
docId: DocumentId,
plaintext: Uint8Array,
meta?: { module: string; collection: string; version: number }
): Promise<void> {
if (!this.#db) return;
const now = Date.now();
let data: Uint8Array;
let encrypted = false;
if (this.#crypto?.isInitialized) {
const docKey = await this.#crypto.deriveDocKeyDirect(this.#spaceId, docId);
const blob = await this.#crypto.encrypt(docKey, plaintext);
data = DocCrypto.pack(blob);
encrypted = true;
} else {
data = plaintext;
}
const tx = this.#db.transaction(['docs', 'meta'], 'readwrite');
const storedDoc: StoredDoc = { docId, data, updatedAt: now, encrypted };
tx.objectStore('docs').put(storedDoc);
if (meta) {
const existingMeta = await this.getMeta(docId);
const storedMeta: StoredMeta = {
docId,
module: meta.module,
collection: meta.collection,
version: meta.version,
createdAt: existingMeta?.createdAt ?? now,
updatedAt: now,
};
tx.objectStore('meta').put(storedMeta);
}
await this.#txComplete(tx);
}
#getDoc(docId: DocumentId): Promise<StoredDoc | null> {
if (!this.#db) return Promise.resolve(null);
return new Promise((resolve, reject) => {
const tx = this.#db!.transaction('docs', 'readonly');
const request = tx.objectStore('docs').get(docId);
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error);
});
}
#txComplete(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
}