#!/usr/bin/env bun import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import pg from "pg"; import { canonicalSnapshot } from "../internal/tasktree/backup-contracts.ts"; import { TaskTreeBackupService } from "../internal/tasktree/backup.ts"; import { TaskTreeStore } from "../internal/tasktree/store.ts"; const databaseUrl = readEnvValue(requiredEnv("TASKTREE_NATIVE_DATABASE_ENV_FILE"), "DATABASE_URL"); const schema = `tasktree_backup_restore_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`; const control = new pg.Pool({ connectionString: databaseUrl, max: 1, connectionTimeoutMillis: 5000, ssl: { rejectUnauthorized: false }, }); const root = mkdtempSync(join(tmpdir(), "tasktree-backup-restore-l1-")); let store; try { await control.query(`CREATE SCHEMA "${schema}" AUTHORIZATION CURRENT_USER`); const isolatedUrl = new URL(databaseUrl); isolatedUrl.searchParams.set("options", `-c search_path=${schema}`); store = new TaskTreeStore(isolatedUrl.toString()); const backup = new TaskTreeBackupService(store, { enabled: true, repoSshUrl: join(root, "remote.git"), branch: "main", basePath: "tasktree/restore-smoke", worktreePath: join(root, "repo"), target: "restore-smoke", intervalMs: 60_000, sshCommand: "", authorName: "TaskTree Restore Smoke", authorEmail: "tasktree-restore@example.invalid", commitMessagePrefix: "tasktree:", }); const init = Bun.spawnSync(["git", "init", "--bare", backup.config.repoSshUrl], { stdout: "pipe", stderr: "pipe", }); if (init.exitCode !== 0) throw new Error("failed to create disposable Git remote"); const group = await store.createGroup("Restore smoke", "Disposable L1 schema"); const task = await store.createTask({ groupId: group.id, title: "Stable identity" }); await store.writeReport({ taskId: task.id, title: "Execution", body: "Original report" }); const original = await store.exportSnapshot(); const published = await backup.create({ reason: "restore smoke baseline" }); await store.updateTask(task.id, { title: "Changed after backup" }); await store.createTask({ groupId: group.id, title: "Created after backup" }); const plan = await backup.restore({ confirm: false }); if (plan.dryRun !== true || plan.diff.tasks.create !== 0 || plan.diff.tasks.update !== 1 || plan.diff.tasks.delete !== 1) { throw new Error("restore dry-run diff did not identify the isolated mutations"); } await assertRejectsCode( () => backup.restore({ confirm: true, expectedSha256: "0".repeat(64) }), "backup_restore_sha_required", ); const restored = await backup.restore({ confirm: true, expectedSha256: published.contentSha256 }); if (restored.restored !== true) throw new Error("confirmed restore did not report success"); const after = await store.exportSnapshot(); if (canonicalSnapshot(after) !== canonicalSnapshot(original)) { throw new Error("confirmed restore did not preserve the canonical snapshot"); } await Bun.write(Bun.stdout, `${JSON.stringify({ ok: true, action: "tasktree-backup-restore-l1-smoke", mutation: true, storage: "native-postgresql-disposable-schema", publishedSha: published.contentSha256, stableGroupId: group.id, stableTaskId: task.id, dryRunDiff: plan.diff, wrongShaRejected: true, restored: true, canonicalMatch: true, valuesPrinted: false, }, null, 2)}\n`); } finally { await store?.close().catch(() => undefined); await control.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`).catch(() => undefined); await control.end().catch(() => undefined); rmSync(root, { recursive: true, force: true }); } async function assertRejectsCode(run, code) { try { await run(); } catch (error) { if (error?.code === code) return; throw error; } throw new Error(`expected ${code}`); } function readEnvValue(path, key) { const line = readFileSync(path, "utf8") .split(/\r?\n/u) .find((candidate) => candidate.startsWith(`${key}=`)); if (!line) throw new Error(`${key} is missing from the configured env source`); const raw = line.slice(key.length + 1).trim(); if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) return raw.slice(1, -1); return raw; } function requiredEnv(name) { const value = process.env[name]; if (!value) throw new Error(`${name} is required`); return value; }