114 lines
4.4 KiB
JavaScript
114 lines
4.4 KiB
JavaScript
#!/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;
|
|
}
|