236 lines
6.1 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
}
|