import { spawn } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, rmSync, renameSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; const root = process.cwd(); const dataDir = join(root, "data"); const dbPath = join(dataDir, "notes.db"); const remoteFolder = sanitizeRemoteFolder(process.env.REMOTE_FOLDER || "huggingface:notes"); const backupFile = "notes.db"; const uploadFile = "notes.db.uploading"; const mode = process.argv.includes("--restore") ? "restore" : "backup"; try { if (mode === "restore") { await restoreBackup(); } else { await uploadBackup(); } } catch (error) { console.error(JSON.stringify({ ok: false, mode, error: error.message })); process.exitCode = 1; } async function restoreBackup() { mkdirSync(dataDir, { recursive: true }); const remote = remotePath(backupFile); const workDir = mkdtempSync(join(tmpdir(), "notes-restore-")); const restorePath = join(workDir, "notes.db"); const copied = await rclone(["copyto", remote, restorePath], { allowFailure: true }); if (!copied.ok) { rmSync(workDir, { recursive: true, force: true }); console.log(JSON.stringify({ ok: true, mode: "restore", restored: false, reason: "remote_backup_unavailable" })); return; } assertUsableSqlite(restorePath); const restoredSummary = inspectDatabase(restorePath); const bytes = statSync(restorePath).size; renameSync(restorePath, dbPath); rmSync(workDir, { recursive: true, force: true }); console.log(JSON.stringify({ ok: true, mode: "restore", restored: true, remote, bytes, ...restoredSummary })); } async function uploadBackup() { if (!existsSync(dbPath) || statSync(dbPath).size === 0) { throw new Error("local_database_missing"); } const localSummary = inspectDatabase(dbPath); if (localSummary.users === 0) { console.log(JSON.stringify({ ok: true, mode: "backup", skipped: true, reason: "database_unconfigured", ...localSummary })); return; } const workDir = mkdtempSync(join(tmpdir(), "notes-backup-")); const snapshotPath = join(workDir, "notes.db"); createSnapshot(snapshotPath); const bytes = statSync(snapshotPath).size; const snapshotSummary = inspectDatabase(snapshotPath); const uploading = remotePath(uploadFile); const target = remotePath(backupFile); await rclone(["copyto", snapshotPath, uploading]); await rclone(["deletefile", target], { allowFailure: true }); await rclone(["moveto", uploading, target]); await rclone(["touch", target], { allowFailure: true }); const remoteSummary = await verifyRemoteBackup(target, snapshotSummary); rmSync(workDir, { recursive: true, force: true }); console.log(JSON.stringify({ ok: true, mode: "backup", remote: target, bytes, ...snapshotSummary, remoteVerified: true, remoteUsers: remoteSummary.users, remoteNotes: remoteSummary.notes, remoteFolders: remoteSummary.folders, updatedAt: Date.now() })); } async function verifyRemoteBackup(target, expected) { const workDir = mkdtempSync(join(tmpdir(), "notes-verify-")); const verifyPath = join(workDir, "notes.db"); try { await rclone(["copyto", target, verifyPath]); assertUsableSqlite(verifyPath); const actual = inspectDatabase(verifyPath); if (actual.users !== expected.users || actual.notes !== expected.notes || actual.folders !== expected.folders) { throw new Error("remote_backup_verify_mismatch"); } return actual; } finally { rmSync(workDir, { recursive: true, force: true }); } } function createSnapshot(snapshotPath) { const db = new DatabaseSync(dbPath); try { db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); db.exec(`VACUUM INTO '${escapeSqlitePath(snapshotPath)}'`); } finally { db.close(); } assertUsableSqlite(snapshotPath); } function assertUsableSqlite(path) { const db = new DatabaseSync(path); try { db.prepare("SELECT name FROM sqlite_master LIMIT 1").all(); } finally { db.close(); } } function inspectDatabase(path) { const db = new DatabaseSync(path); try { return { users: countRows(db, "users"), notes: countRows(db, "notes"), folders: countRows(db, "folders") }; } finally { db.close(); } } function countRows(db, table) { const exists = db.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?").get(table); if (!exists?.count) return 0; return db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count; } function rclone(args, { allowFailure = false } = {}) { return new Promise((resolve, reject) => { const child = spawn("rclone", args, { env: process.env, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr.on("data", () => {}); child.on("error", reject); child.on("close", (code) => { if (code === 0 || allowFailure) { resolve({ ok: code === 0, stdout }); return; } reject(new Error(`rclone_failed_${code}`)); }); }); } function remotePath(file) { return `${remoteFolder.replace(/\/+$/, "")}/${file}`; } function sanitizeRemoteFolder(value) { const trimmed = String(value || "").trim(); if (!trimmed) throw new Error("remote_folder_missing"); if (/[\0\r\n]/.test(trimmed)) throw new Error("remote_folder_invalid"); if (!trimmed.includes(":")) throw new Error("remote_folder_must_include_remote_name"); return trimmed; } function escapeSqlitePath(path) { return path.replaceAll("'", "''"); }