EXTENSION for TreeOS
backup
Two backup modes. Full backup serializes every document in the database to a single JSON file: nodes, users, notes, contributions, chats, LLM connections, canopy peers, extension models. Snapshot backup captures only structure and metadata, omitting note content and chat history, producing a smaller file suitable for frequent automated runs. Restore reads a backup file, validates its format, drops existing collections, and rebuilds the database. After restore the extension triggers index rebuilds, tree integrity checks, ancestor cache invalidation, and fires afterRestore so other extensions can reinitialize. The restore flag is cleared automatically after post-restore boot completes. Automatic snapshots run on a configurable interval when backupInterval is set in land config (milliseconds). Output directory is configurable via backupPath (defaults to ./backups). All operations are admin-only.
v1.0.1 by TreeOS Site 0 downloads 3 files 391 lines 14.5 KB published 38d ago
treeos ext install backup
View changelog

Manifest

Provides

  • 4 CLI commands
  • 1 custom hooks

Requires

  • models: Node, User, Note, Contribution
SHA256: da8e4a7fa19837b8a0ac62e8809873134586e600ea8e50336bf3f3858b20a38f

CLI Commands

CommandMethodDescription
backupPOSTFull backup (all data)
backup-snapshotPOSTSnapshot (structure + metadata only)
backup-restorePOSTRestore from backup file
backup-listGETList available backups

Hooks

Listens To

  • afterBoot

Fires

HookDataDescription
afterRestore

Source Code

1/**
2 * Backup and Restore
3 *
4 * Reads kernel models directly (allowed for extensions). Knows Node, User, Note,
5 * Contribution, AIChat. That's enough to export and import everything.
6 *
7 * Three modes:
8 *   exportLand()    - Full backup. Every node, user, note, contribution. JSON file.
9 *   snapshotLand()  - Lightweight. Tree structure + metadata only. No content, no history.
10 *   importLand()    - Restore from export. Validates checksum, preserves all metadata.
11 */
12
13import crypto from "crypto";
14import fs from "fs";
15import path from "path";
16import Node from "../../seed/models/node.js";
17import User from "../../seed/models/user.js";
18import Note from "../../seed/models/note.js";
19import Contribution from "../../seed/models/contribution.js";
20import log from "../../seed/log.js";
21
22let Chat = null;
23try { Chat = (await import("../../seed/models/chat.js")).default; } catch (err) { log.debug("Backup", "Chat model not available:", err.message); }
24
25let seedVersion = "unknown";
26try { seedVersion = (await import("../../seed/version.js")).SEED_VERSION; } catch (err) { log.debug("Backup", "Could not load seed version:", err.message); }
27
28let getLandConfigValue = () => null;
29try {
30  const mod = await import("../../seed/landConfig.js");
31  getLandConfigValue = mod.getLandConfigValue;
32} catch (err) { log.debug("Backup", "Could not load landConfig:", err.message); }
33
34function normalizeDoc(doc) {
35  if (doc.metadata instanceof Map) {
36    doc.metadata = Object.fromEntries(doc.metadata);
37  }
38  return doc;
39}
40
41/**
42 * Full land export.
43 */
44export async function exportLand(opts = {}) {
45  const timestamp = new Date().toISOString();
46  log.info("Backup", `Starting full export at ${timestamp}`);
47
48  const nodes = await Node.find({}).lean();
49  const users = await User.find({}).select("-password").lean();
50  const notes = await Note.find({}).lean();
51
52  const retentionDays = parseInt(getLandConfigValue("contributionRetentionDays") || "365", 10);
53  const cutoff = retentionDays > 0
54    ? new Date(Date.now() - retentionDays * 86400000)
55    : new Date(0);
56  const contributions = await Contribution.find({ date: { $gte: cutoff } }).lean();
57
58  const data = {
59    _backup: {
60      version: 1,
61      seedVersion,
62      timestamp,
63      landName: getLandConfigValue("LAND_NAME") || "Unknown",
64    },
65    nodes: nodes.map(normalizeDoc),
66    users: users.map(normalizeDoc),
67    notes,
68    contributions,
69  };
70
71  const serialized = JSON.stringify(data);
72  data._backup.checksum = crypto.createHash("sha256").update(serialized).digest("hex");
73  data._backup.sizeBytes = Buffer.byteLength(serialized, "utf8");
74
75  if (opts.outputPath) {
76    const dir = path.dirname(opts.outputPath);
77    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
78    fs.writeFileSync(opts.outputPath, JSON.stringify(data, null, 2));
79    log.info("Backup", `Full export: ${opts.outputPath} (${nodes.length} nodes, ${users.length} users, ${notes.length} notes)`);
80    return { outputPath: opts.outputPath, nodes: nodes.length, users: users.length, notes: notes.length, contributions: contributions.length };
81  }
82
83  log.info("Backup", `Full export complete (${nodes.length} nodes, ${users.length} users, ${notes.length} notes)`);
84  return data;
85}
86
87/**
88 * Lightweight snapshot. Structure + metadata only.
89 */
90export async function snapshotLand(opts = {}) {
91  const timestamp = new Date().toISOString();
92
93  const nodes = await Node.find({}).select("_id name type status parent children rootOwner contributors systemRole metadata visibility dateCreated").lean();
94  const users = await User.find({}).select("-password").lean();
95
96  const data = {
97    _backup: {
98      version: 1,
99      seedVersion,
100      timestamp,
101      type: "snapshot",
102      landName: getLandConfigValue("LAND_NAME") || "Unknown",
103    },
104    nodes: nodes.map(normalizeDoc),
105    users: users.map(normalizeDoc),
106  };
107
108  const serialized = JSON.stringify(data);
109  data._backup.checksum = crypto.createHash("sha256").update(serialized).digest("hex");
110
111  if (opts.outputPath) {
112    const dir = path.dirname(opts.outputPath);
113    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
114    fs.writeFileSync(opts.outputPath, JSON.stringify(data, null, 2));
115    log.info("Backup", `Snapshot: ${opts.outputPath} (${nodes.length} nodes)`);
116    return { outputPath: opts.outputPath, nodes: nodes.length, users: users.length };
117  }
118
119  return data;
120}
121
122/**
123 * Restore a land from an export.
124 */
125export async function importLand(input) {
126  let data;
127  if (typeof input === "string") {
128    // Path traversal guard: resolve to absolute and verify it's within the backup directory
129    const backupPath = path.resolve(getLandConfigValue("backupPath") || "./backups");
130    const resolved = path.resolve(backupPath, path.basename(input));
131    if (!resolved.startsWith(backupPath)) {
132      throw new Error("Invalid backup path: must be within the backup directory");
133    }
134    if (!fs.existsSync(resolved)) throw new Error(`Backup file not found: ${path.basename(input)}`);
135    const raw = fs.readFileSync(resolved, "utf8");
136    data = JSON.parse(raw);
137  } else {
138    data = input;
139  }
140
141  if (!data?._backup?.version) {
142    throw new Error("Invalid backup: missing _backup header");
143  }
144
145  // Validate checksum
146  const savedChecksum = data._backup.checksum;
147  if (savedChecksum) {
148    const copy = JSON.parse(JSON.stringify(data));
149    delete copy._backup.checksum;
150    delete copy._backup.sizeBytes;
151    const computed = crypto.createHash("sha256").update(JSON.stringify(copy)).digest("hex");
152    if (computed !== savedChecksum) {
153      throw new Error(`Checksum mismatch: backup may be corrupted`);
154    }
155  }
156
157  log.info("Backup", `Restoring from ${data._backup.type || "full"} backup (seed ${data._backup.seedVersion}, ${data._backup.timestamp})`);
158
159  // Drop existing data
160  const mongoose = (await import("mongoose")).default;
161  const db = mongoose.connection.db;
162  for (const col of ["nodes", "users", "notes", "contributions"]) {
163    try { await db.collection(col).deleteMany({}); } catch (err) { log.debug("Backup", `Failed to clear collection ${col}:`, err.message); }
164  }
165
166  const report = { nodes: 0, users: 0, notes: 0, contributions: 0 };
167
168  if (data.nodes?.length > 0) {
169    const docs = data.nodes.map(n => {
170      if (n.metadata && typeof n.metadata === "object" && !(n.metadata instanceof Map)) {
171        n.metadata = new Map(Object.entries(n.metadata));
172      }
173      return n;
174    });
175    await Node.insertMany(docs, { ordered: false });
176    report.nodes = docs.length;
177  }
178
179  if (data.users?.length > 0) {
180    const docs = data.users.map(u => {
181      if (u.metadata && typeof u.metadata === "object" && !(u.metadata instanceof Map)) {
182        u.metadata = new Map(Object.entries(u.metadata));
183      }
184      return u;
185    });
186    await User.insertMany(docs, { ordered: false });
187    report.users = docs.length;
188  }
189
190  if (data.notes?.length > 0) {
191    await Note.insertMany(data.notes, { ordered: false });
192    report.notes = data.notes.length;
193  }
194
195  if (data.contributions?.length > 0) {
196    await Contribution.insertMany(data.contributions, { ordered: false });
197    report.contributions = data.contributions.length;
198  }
199
200  log.info("Backup",
201    `Restore complete: ${report.nodes} nodes, ${report.users} users, ${report.notes} notes, ${report.contributions} contributions`
202  );
203
204  return report;
205}
206
207/**
208 * List available backups in the backup directory.
209 */
210export function listBackups(backupPath = "./backups") {
211  if (!fs.existsSync(backupPath)) return [];
212  return fs.readdirSync(backupPath)
213    .filter(f => f.endsWith(".json"))
214    .map(f => {
215      const full = path.join(backupPath, f);
216      const stat = fs.statSync(full);
217      return { file: f, path: full, size: stat.size, modified: stat.mtime.toISOString() };
218    })
219    .sort((a, b) => b.modified.localeCompare(a.modified));
220}
221
1import express from "express";
2import { exportLand, snapshotLand, importLand, listBackups } from "./core.js";
3import { getLandConfigValue } from "../../seed/landConfig.js";
4import { sendOk, sendError, ERR } from "../../seed/protocol.js";
5import log from "../../seed/log.js";
6import path from "path";
7
8const router = express.Router();
9
10// POST /backup/full - trigger full backup
11router.post("/backup/full", async (req, res) => {
12  try {
13    const user = await (await import("../../seed/models/user.js")).default.findById(req.userId).select("isAdmin").lean();
14    if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
15
16    const backupPath = getLandConfigValue("backupPath") || "./backups";
17    const filename = `full-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
18    const outputPath = path.join(backupPath, filename);
19
20    const result = await exportLand({ outputPath });
21    sendOk(res, result);
22  } catch (err) {
23    sendError(res, 500, ERR.INTERNAL, err.message);
24  }
25});
26
27// POST /backup/snapshot - trigger snapshot
28router.post("/backup/snapshot", async (req, res) => {
29  try {
30    const user = await (await import("../../seed/models/user.js")).default.findById(req.userId).select("isAdmin").lean();
31    if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
32
33    const backupPath = getLandConfigValue("backupPath") || "./backups";
34    const filename = `snapshot-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
35    const outputPath = path.join(backupPath, filename);
36
37    const result = await snapshotLand({ outputPath });
38    sendOk(res, result);
39  } catch (err) {
40    sendError(res, 500, ERR.INTERNAL, err.message);
41  }
42});
43
44// POST /backup/restore - restore from backup
45router.post("/backup/restore", async (req, res) => {
46  try {
47    const user = await (await import("../../seed/models/user.js")).default.findById(req.userId).select("isAdmin").lean();
48    if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
49
50    const { file } = req.body;
51    if (!file) return sendError(res, 400, ERR.INVALID_INPUT, "file is required");
52
53    const result = await importLand(file);
54    sendOk(res, result);
55  } catch (err) {
56    sendError(res, 500, ERR.INTERNAL, err.message);
57  }
58});
59
60// GET /backup/list - list available backups
61router.get("/backup/list", async (req, res) => {
62  try {
63    const user = await (await import("../../seed/models/user.js")).default.findById(req.userId).select("isAdmin").lean();
64    if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
65
66    const backupPath = getLandConfigValue("backupPath") || "./backups";
67    const backups = listBackups(backupPath);
68    sendOk(res, { backups });
69  } catch (err) {
70    sendError(res, 500, ERR.INTERNAL, err.message);
71  }
72});
73
74export async function init(core) {
75  // Start automatic snapshot job if configured
76  const interval = parseInt(getLandConfigValue("backupInterval") || "0", 10);
77  if (interval > 0) {
78    const backupPath = getLandConfigValue("backupPath") || "./backups";
79    const timer = setInterval(async () => {
80      try {
81        const filename = `snapshot-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
82        const outputPath = path.join(backupPath, filename);
83        await snapshotLand({ outputPath });
84      } catch (err) {
85        log.error("Backup", "Automatic snapshot failed:", err.message);
86      }
87    }, interval);
88    if (timer.unref) timer.unref();
89    log.verbose("Backup", `Automatic snapshots every ${Math.round(interval / 3600000)}h to ${backupPath}`);
90  }
91
92  // Listen for afterRestore to reinitialize after a restore
93  core.hooks.register("afterBoot", async () => {
94    const restoreInfo = getLandConfigValue("_restoredFrom");
95    if (!restoreInfo) return;
96
97    log.info("Backup", `Post-restore boot detected (restored at ${restoreInfo.restoredAt})`);
98
99    // Rebuild indexes
100    try {
101      const { ensureIndexes } = await import("../../seed/tree/indexes.js");
102      await ensureIndexes();
103    } catch (err) { log.debug("Backup", "Post-restore index rebuild failed:", err.message); }
104
105    // Integrity check
106    try {
107      const { checkIntegrity } = await import("../../seed/tree/integrityCheck.js");
108      await checkIntegrity({ repair: true });
109    } catch (err) { log.debug("Backup", "Post-restore integrity check failed:", err.message); }
110
111    // Invalidate ancestor cache
112    try {
113      const { invalidateAll } = await import("../../seed/tree/ancestorCache.js");
114      invalidateAll();
115    } catch (err) { log.debug("Backup", "Post-restore ancestor cache invalidation failed:", err.message); }
116
117    // Fire afterRestore for other extensions
118    await core.hooks.run("afterRestore", { restoreInfo });
119
120    // Clear the flag
121    const { setLandConfigValue } = await import("../../seed/landConfig.js");
122    await setLandConfigValue("_restoredFrom", null);
123  }, "backup");
124
125  return {
126    router,
127    exports: { exportLand, snapshotLand, importLand, listBackups },
128  };
129}
130
1export default {
2  name: "backup",
3  version: "1.0.1",
4  builtFor: "TreeOS",
5  description:
6    "Two backup modes. Full backup serializes every document in the database to a " +
7    "single JSON file: nodes, users, notes, contributions, chats, LLM connections, " +
8    "canopy peers, extension models. Snapshot backup captures only structure and " +
9    "metadata, omitting note content and chat history, producing a smaller file " +
10    "suitable for frequent automated runs." +
11    "\n\n" +
12    "Restore reads a backup file, validates its format, drops existing collections, " +
13    "and rebuilds the database. After restore the extension triggers index rebuilds, " +
14    "tree integrity checks, ancestor cache invalidation, and fires afterRestore so " +
15    "other extensions can reinitialize. The restore flag is cleared automatically " +
16    "after post-restore boot completes." +
17    "\n\n" +
18    "Automatic snapshots run on a configurable interval when backupInterval is set " +
19    "in land config (milliseconds). Output directory is configurable via backupPath " +
20    "(defaults to ./backups). All operations are admin-only.",
21
22  needs: {
23    models: ["Node", "User", "Note", "Contribution"],
24  },
25  optional: {
26    models: ["Chat"],
27  },
28  provides: {
29    routes: false,
30    hooks: { fires: ["afterRestore"], listens: ["afterBoot"] },
31    cli: [
32      { command: "backup", scope: ["home"], description: "Full backup (all data)", method: "POST", endpoint: "/backup/full", bodyMap: { output: 0 } },
33      { command: "backup-snapshot", scope: ["home"], description: "Snapshot (structure + metadata only)", method: "POST", endpoint: "/backup/snapshot", bodyMap: { output: 0 } },
34      { command: "backup-restore", scope: ["home"], description: "Restore from backup file", method: "POST", endpoint: "/backup/restore", bodyMap: { file: 0 } },
35      { command: "backup-list", scope: ["home"], description: "List available backups", method: "GET", endpoint: "/backup/list" },
36    ],
37    env: [],
38  },
39};
40

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star backup

Comments

Loading comments...

Post comments from the CLI: treeos ext comment backup "your comment"
Max 3 comments per extension. One star and one flag per user.