Files

173 lines
6.0 KiB
TypeScript

import {
restoreDiff,
snapshotManifest,
snapshotSha256,
type TaskTreeBackupState,
} from "./backup-contracts.ts";
import { TaskTreeGitBackup, type TaskTreeGitBackupConfig } from "./backup-git.ts";
import { TaskTreeStore } from "./store.ts";
export type TaskTreeBackupConfig = TaskTreeGitBackupConfig & {
enabled: boolean;
intervalMs: number;
};
export class TaskTreeBackupService {
private readonly git: TaskTreeGitBackup;
constructor(
private readonly store: TaskTreeStore,
readonly config: TaskTreeBackupConfig,
) {
this.git = new TaskTreeGitBackup(config);
}
async request(reason: string): Promise<void> {
await this.store.requestBackup(this.config.target, reason);
}
async create(options: { dryRun?: boolean; reason?: string } = {}) {
const snapshot = await this.store.exportSnapshot();
const manifest = snapshotManifest(snapshot, { target: this.config.target });
if (options.dryRun) return { dryRun: true, changed: false, manifest };
this.requireEnabled();
await this.store.beginBackupAttempt(this.config.target);
try {
const published = await this.git.publish(snapshot, options.reason ?? "manual backup");
await this.store.finishBackupSuccess(this.config.target, published.contentSha256, published.gitCommit);
return { dryRun: false, ...published };
} catch (error: any) {
await this.store.finishBackupFailure(
this.config.target,
error?.code ?? "backup_failed",
error?.message ?? String(error),
);
throw error;
}
}
async status(): Promise<{ enabled: boolean; configured: boolean; state: TaskTreeBackupState }> {
return {
enabled: this.config.enabled,
configured: Boolean(this.config.repoSshUrl),
state: await this.store.backupState(this.config.target),
};
}
async verify() {
this.requireEnabled();
const current = await this.store.exportSnapshot();
const stored = this.git.readCurrent();
if (stored === null) throw codedError("backup_snapshot_missing", "TaskTree backup snapshot does not exist");
const databaseSha256 = snapshotSha256(current);
return {
verified: databaseSha256 === stored.manifest.contentSha256,
target: this.config.target,
databaseSha256,
backupSha256: stored.manifest.contentSha256,
gitCommit: stored.gitCommit,
counts: stored.manifest.counts,
};
}
async restore(options: { confirm: boolean; expectedSha256?: string }) {
this.requireEnabled();
const stored = this.git.readCurrent();
if (stored === null) throw codedError("backup_snapshot_missing", "TaskTree backup snapshot does not exist");
const current = await this.store.exportSnapshot();
const diff = restoreDiff(current, stored.snapshot);
if (!options.confirm) {
return {
dryRun: true,
restored: false,
target: this.config.target,
contentSha256: stored.manifest.contentSha256,
gitCommit: stored.gitCommit,
diff,
};
}
if (!options.expectedSha256 || options.expectedSha256 !== stored.manifest.contentSha256) {
throw codedError("backup_restore_sha_required", "confirmed restore requires the exact current backup SHA");
}
await this.store.restoreSnapshot(stored.snapshot);
await this.request("post-restore verification backup");
return {
dryRun: false,
restored: true,
target: this.config.target,
contentSha256: stored.manifest.contentSha256,
gitCommit: stored.gitCommit,
diff,
};
}
private requireEnabled(): void {
if (!this.config.enabled) throw codedError("backup_disabled", "TaskTree GitHub backup is disabled");
if (!this.config.repoSshUrl) throw codedError("backup_repo_missing", "TaskTree backup Git repository is not configured");
}
}
export function taskTreeBackupConfigFromEnv(
env: Record<string, string | undefined> = process.env,
): TaskTreeBackupConfig {
return {
enabled: env.TASKTREE_BACKUP_ENABLED === "true",
repoSshUrl: env.TASKTREE_BACKUP_REPO_SSH_URL ?? "",
branch: env.TASKTREE_BACKUP_BRANCH ?? "main",
basePath: env.TASKTREE_BACKUP_BASE_PATH ?? "tasktree/development",
worktreePath: env.TASKTREE_BACKUP_WORKTREE ?? ".state/tasktree/backup-repo",
target: env.TASKTREE_BACKUP_TARGET ?? "development",
intervalMs: positiveInteger(env.TASKTREE_BACKUP_INTERVAL_MS, 300_000),
sshCommand: env.TASKTREE_BACKUP_GIT_SSH_COMMAND ?? "",
authorName: env.TASKTREE_BACKUP_GIT_AUTHOR_NAME ?? "HWLAB TaskTree",
authorEmail: env.TASKTREE_BACKUP_GIT_AUTHOR_EMAIL ?? "tasktree@hwlab.local",
commitMessagePrefix: env.TASKTREE_BACKUP_GIT_COMMIT_PREFIX ?? "tasktree:",
};
}
export function startTaskTreeBackupScheduler(
service: TaskTreeBackupService,
onResult: (result: Record<string, unknown>) => void = () => {},
): { stop: () => Promise<void>; runNow: () => Promise<void> } {
let current: Promise<void> | null = null;
const runNow = () => {
if (!service.config.enabled) return Promise.resolve();
if (current) return current;
current = (async () => {
try {
const result = await service.create({ reason: "automatic worker snapshot" });
onResult({ ok: true, event: "tasktree_backup_succeeded", ...result });
} catch (error: any) {
onResult({
ok: false,
event: "tasktree_backup_failed",
code: error?.code ?? "backup_failed",
message: error?.message ?? String(error),
});
}
})().finally(() => {
current = null;
});
return current;
};
const timer = setInterval(() => void runNow(), service.config.intervalMs);
timer.unref();
void runNow();
return {
async stop() {
clearInterval(timer);
await current;
},
runNow,
};
}
function positiveInteger(value: string | undefined, fallback: number): number {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function codedError(code: string, message: string): Error {
return Object.assign(new Error(message), { code });
}