rspace-online/lib/offline-store.ts

236 lines
6.1 KiB
TypeScript

/**
* OfflineStore — IndexedDB persistence for Automerge documents and sync state.
*
* Stores per-community:
* - docBinary: Automerge.save(doc) output (Uint8Array)
* - syncStateBinary: Automerge.encodeSyncState() output (Uint8Array)
* - lastSynced / lastModified timestamps
*/
export interface OfflineCacheEntry {
slug: string;
docBinary: Uint8Array;
syncStateBinary: Uint8Array | null;
lastSynced: number;
lastModified: number;
}
export class OfflineStore {
#db: IDBDatabase | null = null;
#dbName = "rspace-offline";
#storeName = "communities";
#version = 1;
#saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
#pendingSaves = new Map<string, Uint8Array>();
#saveDebounceMs = 2000;
/**
* Open the IndexedDB database. Must be called before any other method.
* Safe to call multiple times (idempotent).
*/
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(this.#storeName)) {
db.createObjectStore(this.#storeName, { keyPath: "slug" });
}
};
request.onsuccess = () => {
this.#db = request.result;
resolve();
};
request.onerror = () => {
console.error("[OfflineStore] Failed to open IndexedDB:", request.error);
reject(request.error);
};
});
}
/**
* Save Automerge document binary, debounced to avoid thrashing.
*/
saveDoc(slug: string, docBinary: Uint8Array): void {
this.#pendingSaves.set(slug, docBinary);
const existing = this.#saveTimers.get(slug);
if (existing) clearTimeout(existing);
this.#saveTimers.set(
slug,
setTimeout(() => {
this.#saveTimers.delete(slug);
this.#pendingSaves.delete(slug);
this.#writeDoc(slug, docBinary).catch((e) => {
console.error("[OfflineStore] Failed to save doc:", e);
});
}, this.#saveDebounceMs)
);
}
/**
* Immediately save document binary (bypasses debounce).
* Used before page unload.
*/
async saveDocImmediate(slug: string, docBinary: Uint8Array): Promise<void> {
// Cancel any pending debounced save for this slug
const existing = this.#saveTimers.get(slug);
if (existing) {
clearTimeout(existing);
this.#saveTimers.delete(slug);
}
this.#pendingSaves.delete(slug);
await this.#writeDoc(slug, docBinary);
}
/**
* Load cached Automerge document binary.
*/
async loadDoc(slug: string): Promise<Uint8Array | null> {
const entry = await this.#getEntry(slug);
return entry?.docBinary ?? null;
}
/**
* Save Automerge SyncState binary for incremental reconnection.
*/
async saveSyncState(slug: string, syncStateBinary: Uint8Array): Promise<void> {
if (!this.#db) return;
try {
const entry = await this.#getEntry(slug);
if (!entry) return; // No doc saved yet, skip sync state
entry.syncStateBinary = syncStateBinary;
await this.#putEntry(entry);
} catch (e) {
console.error("[OfflineStore] Failed to save sync state:", e);
}
}
/**
* Load cached SyncState binary.
*/
async loadSyncState(slug: string): Promise<Uint8Array | null> {
const entry = await this.#getEntry(slug);
return entry?.syncStateBinary ?? null;
}
/**
* Update the lastSynced timestamp (called when server confirms sync).
*/
async markSynced(slug: string): Promise<void> {
if (!this.#db) return;
try {
const entry = await this.#getEntry(slug);
if (!entry) return;
entry.lastSynced = Date.now();
await this.#putEntry(entry);
} catch (e) {
console.error("[OfflineStore] Failed to mark synced:", e);
}
}
/**
* Clear all cached data for a community.
*/
async clear(slug: string): Promise<void> {
if (!this.#db) return;
return new Promise((resolve, reject) => {
const tx = this.#db!.transaction(this.#storeName, "readwrite");
const store = tx.objectStore(this.#storeName);
const request = store.delete(slug);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* List all cached community slugs.
*/
async listCommunities(): Promise<string[]> {
if (!this.#db) return [];
return new Promise((resolve, reject) => {
const tx = this.#db!.transaction(this.#storeName, "readonly");
const store = tx.objectStore(this.#storeName);
const request = store.getAllKeys();
request.onsuccess = () => resolve(request.result as string[]);
request.onerror = () => reject(request.error);
});
}
/**
* Flush all pending debounced saves immediately.
* Call from beforeunload handler.
*/
async flush(): Promise<void> {
const promises: Promise<void>[] = [];
for (const [slug, binary] of this.#pendingSaves) {
const timer = this.#saveTimers.get(slug);
if (timer) clearTimeout(timer);
this.#saveTimers.delete(slug);
promises.push(this.#writeDoc(slug, binary));
}
this.#pendingSaves.clear();
await Promise.all(promises);
}
// --- Private helpers ---
async #writeDoc(slug: string, docBinary: Uint8Array): Promise<void> {
if (!this.#db) return;
const existing = await this.#getEntry(slug);
const entry: OfflineCacheEntry = {
slug,
docBinary,
syncStateBinary: existing?.syncStateBinary ?? null,
lastSynced: existing?.lastSynced ?? 0,
lastModified: Date.now(),
};
await this.#putEntry(entry);
}
#getEntry(slug: string): Promise<OfflineCacheEntry | null> {
if (!this.#db) return Promise.resolve(null);
return new Promise((resolve, reject) => {
const tx = this.#db!.transaction(this.#storeName, "readonly");
const store = tx.objectStore(this.#storeName);
const request = store.get(slug);
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error);
});
}
#putEntry(entry: OfflineCacheEntry): Promise<void> {
if (!this.#db) return Promise.resolve();
return new Promise((resolve, reject) => {
const tx = this.#db!.transaction(this.#storeName, "readwrite");
const store = tx.objectStore(this.#storeName);
const request = store.put(entry);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}