/** * 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>(); /** Debounced save — writes Automerge binary to disk after SAVE_DEBOUNCE_MS. */ export function saveDoc(docId: string, doc: Automerge.Doc): 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 { 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 { 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; }