zerobyte/packages/core/src/restic/commands/dump.ts
Nico d4436b0cdc
refactor(restic): all commands return effects (#924)
* refactor(restic): all commands are effects

* fix(restic): preserve effect failure errors

* chore: pr feedbacks
2026-05-30 10:10:54 +02:00

133 lines
3.2 KiB
TypeScript

import { normalizeAbsolutePath } from "../../utils/path";
import type { Readable } from "node:stream";
import type { ResticDeps } from "../types";
import { addCommonArgs } from "../helpers/add-common-args";
import { buildEnv } from "../helpers/build-env";
import { buildRepoUrl } from "../helpers/build-repo-url";
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
import type { RepositoryConfig } from "../schemas";
import { logger, safeSpawn } from "../../node";
import { ResticError } from "../error";
import { Data, Effect } from "effect";
import { toMessage } from "../../utils";
class ResticDumpCommandError extends Data.TaggedError("ResticDumpCommandError")<{
cause: unknown;
message: string;
}> {}
const normalizeDumpPath = (pathToDump?: string): string => {
const trimmedPath = pathToDump?.trim();
if (!trimmedPath) {
return "/";
}
return normalizeAbsolutePath(trimmedPath);
};
export const dump = (
config: RepositoryConfig,
snapshotRef: string,
options: {
organizationId: string;
path?: string;
archive?: false;
},
deps: ResticDeps,
) => {
return Effect.tryPromise({
try: async () => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, options.organizationId, deps);
const pathToDump = normalizeDumpPath(options.path);
const args: string[] = ["--repo", repoUrl, "dump"];
if (options.archive !== false) {
args.push("--archive", "tar");
}
addCommonArgs(args, env, config, { includeJson: false });
args.push("--", snapshotRef, pathToDump);
logger.debug(`Executing: restic ${args.join(" ")}`);
let didCleanup = false;
const cleanup = async () => {
if (didCleanup) {
return;
}
didCleanup = true;
await cleanupTemporaryKeys(env, deps);
};
let stream: Readable | null = null;
let abortController: AbortController | null = new AbortController();
const maxStderrChars = 64 * 1024;
let stderrTail = "";
const completion = safeSpawn({
command: "restic",
args,
env,
signal: abortController.signal,
stdoutMode: "raw",
onSpawn: (child) => {
stream = child.stdout;
},
onStderr: (line) => {
const chunk = line.trim();
if (chunk) {
stderrTail += `${line}\n`;
if (stderrTail.length > maxStderrChars) {
stderrTail = stderrTail.slice(-maxStderrChars);
}
}
},
})
.then((result) => {
if (result.exitCode === 0) {
return;
}
const stderr = stderrTail.trim() || result.error;
logger.error(`Restic dump failed: ${stderr}`);
throw new ResticError(result.exitCode, stderr);
})
.finally(async () => {
abortController = null;
await cleanup();
});
completion.catch(() => {});
const completionPromise = new Promise<void>((resolve, reject) => completion.then(resolve, reject));
if (!stream) {
await cleanup();
throw new Error("Failed to initialize restic dump stream");
}
return {
stream,
completion: completionPromise,
abort: () => {
if (abortController) {
abortController.abort();
}
},
};
},
catch: (error) => {
if (error instanceof ResticError) {
return error;
}
return new ResticDumpCommandError({
cause: error,
message: toMessage(error),
});
},
});
};