98 lines
3.1 KiB
TypeScript
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;
|
|
}
|