rspace-online/server/local-first/doc-persistence.ts

98 lines
3.1 KiB
TypeScript

/**
* Doc Persistence — Maps docIds to filesystem paths and provides debounced save/load.
*
* Storage layout: {DOCS_STORAGE_DIR}/{space}/{module}/{collection}[/{itemId}].automerge
* Example: /data/docs/demo/notes/notebooks/abc.automerge
*/
import { resolve, dirname } from "node:path";
import { mkdir, readdir, readFile, writeFile, stat } from "node:fs/promises";
import * as Automerge from "@automerge/automerge";
import type { SyncServer } from "./sync-server";
const DOCS_DIR = process.env.DOCS_STORAGE_DIR || "/data/docs";
const SAVE_DEBOUNCE_MS = 2000;
/** Convert a docId like "demo:notes:notebooks:abc" to a filesystem path. */
export function docIdToPath(docId: string): string {
const parts = docId.split(":");
if (parts.length < 3) throw new Error(`Invalid docId: ${docId}`);
// Last part becomes the filename, rest become directories
const filename = parts.pop()! + ".automerge";
return resolve(DOCS_DIR, ...parts, filename);
}
/** Convert a filesystem path back to a docId. */
function pathToDocId(filePath: string): string {
const rel = filePath.slice(DOCS_DIR.length + 1); // strip leading dir + /
const withoutExt = rel.replace(/\.automerge$/, "");
return withoutExt.split("/").join(":");
}
// Debounce timers per docId
const saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** Debounced save — writes Automerge binary to disk after SAVE_DEBOUNCE_MS. */
export function saveDoc(docId: string, doc: Automerge.Doc<any>): void {
const existing = saveTimers.get(docId);
if (existing) clearTimeout(existing);
saveTimers.set(
docId,
setTimeout(async () => {
saveTimers.delete(docId);
try {
const filePath = docIdToPath(docId);
await mkdir(dirname(filePath), { recursive: true });
const binary = Automerge.save(doc);
await writeFile(filePath, binary);
console.log(`[DocStore] Saved ${docId} (${binary.byteLength} bytes)`);
} catch (e) {
console.error(`[DocStore] Failed to save ${docId}:`, e);
}
}, SAVE_DEBOUNCE_MS)
);
}
/** Recursively scan DOCS_DIR and load all .automerge files into the SyncServer. */
export async function loadAllDocs(syncServer: SyncServer): Promise<number> {
let count = 0;
try {
await mkdir(DOCS_DIR, { recursive: true });
count = await scanDir(DOCS_DIR, syncServer);
} catch (e) {
console.error("[DocStore] Failed to scan docs directory:", e);
}
console.log(`[DocStore] Loaded ${count} documents from ${DOCS_DIR}`);
return count;
}
async function scanDir(dir: string, syncServer: SyncServer): Promise<number> {
let count = 0;
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return 0;
}
for (const entry of entries) {
const fullPath = resolve(dir, entry.name);
if (entry.isDirectory()) {
count += await scanDir(fullPath, syncServer);
} else if (entry.name.endsWith(".automerge")) {
try {
const binary = await readFile(fullPath);
const doc = Automerge.load(new Uint8Array(binary));
const docId = pathToDocId(fullPath);
syncServer.setDoc(docId, doc);
count++;
} catch (e) {
console.error(`[DocStore] Failed to load ${fullPath}:`, e);
}
}
}
return count;
}