372 lines
11 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
}
|