173 lines
6.0 KiB
TypeScript
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 });
|
|
}
|