File size: 5,686 Bytes
163c686
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29ea797
 
 
 
 
 
 
163c686
 
 
 
c70e382
4afcd55
163c686
 
4afcd55
163c686
 
 
 
 
 
c70e382
 
 
 
 
163c686
 
 
 
 
c70e382
163c686
 
 
 
5e8ee96
163c686
c70e382
4afcd55
163c686
 
 
 
 
 
c70e382
4afcd55
 
 
 
163c686
 
 
 
4afcd55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163c686
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c70e382
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163c686
 
 
 
 
 
 
 
 
 
 
 
 
 
29ea797
163c686
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
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("'", "''");
}