notes / scripts /backup-sqlite.mjs
ghuser1's picture
Upload backup-sqlite.mjs
5e8ee96 verified
Raw
History Blame Contribute Delete
5.69 kB
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("'", "''");
}