feat(backups): add manual instance backup endpoint

This commit is contained in:
Dotta committed 2026-04-19 21:46:03 -05:00
1 parent c04289063b
commit 11b0e69356
7 files changed
+512 -95

No files matched your search

+6 -2
View File
@@ -127,6 +127,7 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
backupDir,
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
filenamePrefix: "paperclip-test",
backupEngine: "javascript",
});
expect(result.backupFile).toMatch(/paperclip-test-.*\.sql\.gz$/);
@@ -148,14 +149,17 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
title: string;
payload: string;
state: string;
metadata: { index: number; even: boolean };
metadata: { index: number; even: boolean } | string;
}[]>(`
SELECT "title", "payload", "state"::text AS "state", "metadata"
FROM "public"."backup_test_records"
WHERE "title" IN ('row-0', 'row-159')
ORDER BY "title"
`);
expect(sampleRows).toEqual([
expect(sampleRows.map((row) => ({
...row,
metadata: typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata,
}))).toEqual([
{
title: "row-0",
payload,
+241 -55
View File
@@ -1,6 +1,8 @@
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
import { basename, resolve } from "node:path";
import { createInterface } from "node:readline";
import { spawn } from "node:child_process";
import { open as openFile } from "node:fs/promises";
import { pipeline } from "node:stream/promises";
import { createGunzip, createGzip } from "node:zlib";
import postgres from "postgres";
@@ -20,6 +22,7 @@ export type RunDatabaseBackupOptions = {
includeMigrationJournal?: boolean;
excludeTables?: string[];
nullifyColumns?: Record<string, string[]>;
backupEngine?: "auto" | "pg_dump" | "javascript";
};
export type RunDatabaseBackupResult = {
@@ -61,6 +64,9 @@ type ExtensionDefinition = {
const DRIZZLE_SCHEMA = "drizzle";
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024;
const BACKUP_DATA_CURSOR_ROWS = 100;
const BACKUP_CLI_STDERR_BYTES = 64 * 1024;
const BACKUP_BREAKPOINT_DETECT_BYTES = 64 * 1024;
const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900";
@@ -223,6 +229,134 @@ function tableKey(schemaName: string, tableName: string): string {
return `${schemaName}.${tableName}`;
}
function hasBackupTransforms(opts: RunDatabaseBackupOptions): boolean {
return opts.includeMigrationJournal === true ||
(opts.excludeTables?.length ?? 0) > 0 ||
Object.keys(opts.nullifyColumns ?? {}).length > 0;
}
function formatSqlValue(rawValue: unknown, columnName: string | undefined, nullifiedColumns: Set<string>): string {
const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue;
if (val === null || val === undefined) return "NULL";
if (typeof val === "boolean") return val ? "true" : "false";
if (typeof val === "number") return String(val);
if (val instanceof Date) return formatSqlLiteral(val.toISOString());
if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val));
return formatSqlLiteral(String(val));
}
function appendCapturedStderr(previous: string, chunk: Buffer | string): string {
const next = previous + (Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk);
if (Buffer.byteLength(next, "utf8") <= BACKUP_CLI_STDERR_BYTES) return next;
return Buffer.from(next, "utf8").subarray(-BACKUP_CLI_STDERR_BYTES).toString("utf8");
}
async function waitForChildExit(child: ReturnType<typeof spawn>, label: string): Promise<void> {
let stderr = "";
child.stderr?.on("data", (chunk) => {
stderr = appendCapturedStderr(stderr, chunk);
});
const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code, signal) => resolve({ code, signal }));
});
if (result.signal) {
throw new Error(`${label} exited via ${result.signal}${stderr.trim() ? `: ${stderr.trim()}` : ""}`);
}
if (result.code !== 0) {
throw new Error(`${label} failed with exit code ${result.code ?? "unknown"}${stderr.trim() ? `: ${stderr.trim()}` : ""}`);
}
}
async function runPgDumpBackup(opts: {
connectionString: string;
backupFile: string;
connectTimeout: number;
}): Promise<void> {
const pgDumpBin = process.env.PAPERCLIP_PG_DUMP_PATH || "pg_dump";
const child = spawn(
pgDumpBin,
[
`--dbname=${opts.connectionString}`,
"--format=plain",
"--clean",
"--if-exists",
"--no-owner",
"--no-privileges",
"--schema=public",
],
{
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
PGCONNECT_TIMEOUT: String(opts.connectTimeout),
},
},
);
if (!child.stdout) {
throw new Error("pg_dump did not expose stdout");
}
await Promise.all([
pipeline(child.stdout, createGzip(), createWriteStream(opts.backupFile)),
waitForChildExit(child, pgDumpBin),
]);
}
async function restoreWithPsql(opts: RunDatabaseRestoreOptions, connectTimeout: number): Promise<void> {
const psqlBin = process.env.PAPERCLIP_PSQL_PATH || "psql";
const child = spawn(
psqlBin,
[
`--dbname=${opts.connectionString}`,
"--set=ON_ERROR_STOP=1",
"--quiet",
"--no-psqlrc",
],
{
stdio: ["pipe", "ignore", "pipe"],
env: {
...process.env,
PGCONNECT_TIMEOUT: String(connectTimeout),
},
},
);
if (!child.stdin) {
throw new Error("psql did not expose stdin");
}
const input = opts.backupFile.endsWith(".gz")
? createReadStream(opts.backupFile).pipe(createGunzip())
: createReadStream(opts.backupFile);
await Promise.all([
pipeline(input, child.stdin),
waitForChildExit(child, psqlBin),
]);
}
async function hasStatementBreakpoints(backupFile: string): Promise<boolean> {
const raw = createReadStream(backupFile);
const stream = backupFile.endsWith(".gz") ? raw.pipe(createGunzip()) : raw;
let text = "";
try {
for await (const chunk of stream) {
text += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
if (text.includes(STATEMENT_BREAKPOINT)) return true;
if (Buffer.byteLength(text, "utf8") >= BACKUP_BREAKPOINT_DETECT_BYTES) return false;
}
return text.includes(STATEMENT_BREAKPOINT);
} finally {
stream.destroy();
raw.destroy();
}
}
async function* readRestoreStatements(backupFile: string): AsyncGenerator<string> {
const raw = createReadStream(backupFile);
const stream = backupFile.endsWith(".gz") ? raw.pipe(createGunzip()) : raw;
@@ -263,41 +397,21 @@ async function* readRestoreStatements(backupFile: string): AsyncGenerator<string
}
export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes = DEFAULT_BACKUP_WRITE_BUFFER_BYTES) {
const stream = createWriteStream(filePath, { encoding: "utf8" });
const filePromise = openFile(filePath, "w");
const flushThreshold = Math.max(1, Math.trunc(maxBufferedBytes));
let bufferedLines: string[] = [];
let bufferedBytes = 0;
let firstChunk = true;
let closed = false;
let streamError: Error | null = null;
let pendingWrite = Promise.resolve();
stream.on("error", (error) => {
streamError = error;
});
const writeChunk = async (chunk: string): Promise<void> => {
if (streamError) throw streamError;
const canContinue = stream.write(chunk);
if (!canContinue) {
await new Promise<void>((resolve, reject) => {
const handleDrain = () => {
cleanup();
resolve();
};
const handleError = (error: Error) => {
cleanup();
reject(error);
};
const cleanup = () => {
stream.off("drain", handleDrain);
stream.off("error", handleError);
};
stream.once("drain", handleDrain);
stream.once("error", handleError);
});
const writeChunk = async (chunk: string | Buffer): Promise<void> => {
const file = await filePromise;
if (typeof chunk === "string") {
await file.write(chunk, null, "utf8");
} else {
await file.write(chunk);
}
if (streamError) throw streamError;
};
const flushBufferedLines = () => {
@@ -316,37 +430,43 @@ export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes
if (closed) {
throw new Error(`Cannot write to closed backup file: ${filePath}`);
}
if (streamError) throw streamError;
bufferedLines.push(line);
bufferedBytes += Buffer.byteLength(line, "utf8") + 1;
if (bufferedBytes >= flushThreshold) {
flushBufferedLines();
}
},
async drain() {
if (closed) {
throw new Error(`Cannot drain closed backup file: ${filePath}`);
}
flushBufferedLines();
await pendingWrite;
},
async writeRaw(chunk: string | Buffer) {
if (closed) {
throw new Error(`Cannot write to closed backup file: ${filePath}`);
}
flushBufferedLines();
firstChunk = false;
pendingWrite = pendingWrite.then(() => writeChunk(chunk));
await pendingWrite;
},
async close() {
if (closed) return;
closed = true;
flushBufferedLines();
await pendingWrite;
await new Promise<void>((resolve, reject) => {
if (streamError) {
reject(streamError);
return;
}
stream.end((error?: Error | null) => {
if (error) reject(error);
else resolve();
});
});
if (streamError) throw streamError;
const file = await filePromise;
await file.close();
},
async abort() {
if (closed) return;
closed = true;
bufferedLines = [];
bufferedBytes = 0;
stream.destroy();
await pendingWrite.catch(() => {});
await filePromise.then((file) => file.close()).catch(() => {});
if (existsSync(filePath)) {
try {
unlinkSync(filePath);
@@ -362,16 +482,52 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
const filenamePrefix = opts.filenamePrefix ?? "paperclip";
const retention = opts.retention;
const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5));
const backupEngine = opts.backupEngine ?? "auto";
const canUsePgDump = !hasBackupTransforms(opts);
const includeMigrationJournal = opts.includeMigrationJournal === true;
const excludedTableNames = normalizeTableNameSet(opts.excludeTables);
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
let sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
let sqlClosed = false;
const closeSql = async () => {
if (sqlClosed) return;
sqlClosed = true;
await sql.end();
};
mkdirSync(opts.backupDir, { recursive: true });
const sqlFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
const backupFile = `${sqlFile}.gz`;
const writer = createBufferedTextFileWriter(sqlFile);
try {
if (backupEngine === "pg_dump" || (backupEngine === "auto" && canUsePgDump)) {
await sql`SELECT 1`;
try {
await closeSql();
await runPgDumpBackup({
connectionString: opts.connectionString,
backupFile,
connectTimeout,
});
const sizeBytes = statSync(backupFile).size;
const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix);
return {
backupFile,
sizeBytes,
prunedCount,
};
} catch (error) {
if (existsSync(backupFile)) {
try { unlinkSync(backupFile); } catch { /* ignore */ }
}
if (backupEngine === "pg_dump") {
throw error;
}
sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
sqlClosed = false;
}
}
await sql`SELECT 1`;
const emit = (line: string) => writer.emit(line);
@@ -703,20 +859,39 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
emit(`-- Data for: ${schema_name}.${tablename} (${count[0]!.n} rows)`);
const rows = await sql.unsafe(`SELECT * FROM ${qualifiedTableName}`).values();
const nullifiedColumns = nullifiedColumnsByTable.get(tablename) ?? new Set<string>();
for (const row of rows) {
const values = row.map((rawValue: unknown, index) => {
const columnName = cols[index]?.column_name;
const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue;
if (val === null || val === undefined) return "NULL";
if (typeof val === "boolean") return val ? "true" : "false";
if (typeof val === "number") return String(val);
if (val instanceof Date) return formatSqlLiteral(val.toISOString());
if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val));
return formatSqlLiteral(String(val));
});
emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`);
if (backupEngine !== "javascript" && nullifiedColumns.size === 0) {
emit(`COPY ${qualifiedTableName} (${colNames}) FROM stdin;`);
await writer.writeRaw("\n");
const copySql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
try {
const copyStream = await copySql
.unsafe(`COPY ${qualifiedTableName} (${colNames}) TO STDOUT`)
.readable();
for await (const chunk of copyStream) {
await writer.writeRaw(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
}
} finally {
await copySql.end();
}
await writer.writeRaw("\\.\n");
emitStatementBoundary();
emit("");
continue;
}
const rowCursor = sql
.unsafe(`SELECT * FROM ${qualifiedTableName}`)
.values()
.cursor(BACKUP_DATA_CURSOR_ROWS) as AsyncIterable<unknown[][]>;
for await (const rows of rowCursor) {
for (const row of rows) {
const values = row.map((rawValue, index) =>
formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns),
);
emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`);
}
await writer.drain();
}
emit("");
}
@@ -768,12 +943,23 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
}
throw error;
} finally {
await sql.end();
await closeSql();
}
}
export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise<void> {
const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5));
try {
await restoreWithPsql(opts, connectTimeout);
return;
} catch (error) {
if (!(await hasStatementBreakpoints(opts.backupFile))) {
throw new Error(
`Failed to restore ${basename(opts.backupFile)} with psql: ${sanitizeRestoreErrorMessage(error)}`,
);
}
}
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
try {
@@ -0,0 +1,149 @@
import express from "express";
import request from "supertest";
import { describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import {
instanceDatabaseBackupRoutes,
type InstanceDatabaseBackupService,
} from "../routes/instance-database-backups.js";
import { conflict } from "../errors.js";
function createApp(actor: Record<string, unknown>, service: InstanceDatabaseBackupService) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor as typeof req.actor;
next();
});
app.use("/api", instanceDatabaseBackupRoutes(service));
app.use(errorHandler);
return app;
}
function createBackupService(overrides: Partial<InstanceDatabaseBackupService> = {}): InstanceDatabaseBackupService {
return {
runManualBackup: vi.fn().mockResolvedValue({
trigger: "manual",
backupFile: "/tmp/paperclip-20260416.sql.gz",
sizeBytes: 1234,
prunedCount: 2,
backupDir: "/tmp",
retention: {
dailyDays: 7,
weeklyWeeks: 4,
monthlyMonths: 1,
},
startedAt: "2026-04-16T20:00:00.000Z",
finishedAt: "2026-04-16T20:00:01.000Z",
durationMs: 1000,
}),
...overrides,
};
}
describe("instance database backup routes", () => {
it("runs a manual backup for an instance admin and returns the server result", async () => {
const service = createBackupService();
const app = createApp(
{
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
},
service,
);
const res = await request(app).post("/api/instance/database-backups").send({});
expect(res.status).toBe(201);
expect(service.runManualBackup).toHaveBeenCalledTimes(1);
expect(res.body).toEqual({
trigger: "manual",
backupFile: "/tmp/paperclip-20260416.sql.gz",
sizeBytes: 1234,
prunedCount: 2,
backupDir: "/tmp",
retention: {
dailyDays: 7,
weeklyWeeks: 4,
monthlyMonths: 1,
},
startedAt: "2026-04-16T20:00:00.000Z",
finishedAt: "2026-04-16T20:00:01.000Z",
durationMs: 1000,
});
});
it("allows local implicit board access", async () => {
const service = createBackupService();
const app = createApp(
{
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: false,
},
service,
);
await request(app).post("/api/instance/database-backups").send({}).expect(201);
expect(service.runManualBackup).toHaveBeenCalledTimes(1);
});
it("rejects non-admin board users", async () => {
const service = createBackupService();
const app = createApp(
{
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
},
service,
);
await request(app).post("/api/instance/database-backups").send({}).expect(403);
expect(service.runManualBackup).not.toHaveBeenCalled();
});
it("rejects agent callers", async () => {
const service = createBackupService();
const app = createApp(
{
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
},
service,
);
await request(app).post("/api/instance/database-backups").send({}).expect(403);
expect(service.runManualBackup).not.toHaveBeenCalled();
});
it("returns conflict when another server backup is already running", async () => {
const service = createBackupService({
runManualBackup: vi.fn().mockRejectedValue(conflict("Database backup already in progress")),
});
const app = createApp(
{
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
},
service,
);
const res = await request(app).post("/api/instance/database-backups").send({});
expect(res.status).toBe(409);
expect(res.body).toEqual({ error: "Database backup already in progress" });
});
});
+8
View File
@@ -27,6 +27,10 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import {
instanceDatabaseBackupRoutes,
type InstanceDatabaseBackupService,
} from "./routes/instance-database-backups.js";
import { llmRoutes } from "./routes/llms.js";
import { authRoutes } from "./routes/auth.js";
import { assetRoutes } from "./routes/assets.js";
@@ -112,6 +116,7 @@ export async function createApp(
now?: Date;
}): Promise<unknown>;
};
databaseBackupService?: InstanceDatabaseBackupService;
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
allowedHostnames: string[];
@@ -194,6 +199,9 @@ export async function createApp(
api.use(sidebarPreferenceRoutes(db));
api.use(inboxDismissalRoutes(db));
api.use(instanceSettingsRoutes(db));
if (opts.databaseBackupService) {
api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService));
}
const hostServicesDisposers = new Map<string, () => void>();
const workerManager = createPluginWorkerManager();
const pluginRegistry = pluginRegistryService(db);
+77 -38
View File
@@ -41,6 +41,11 @@ import { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js";
import { initTelemetry, getTelemetryClient } from "./telemetry.js";
import { conflict } from "./errors.js";
import type {
InstanceDatabaseBackupRunResult,
InstanceDatabaseBackupTrigger,
} from "./routes/instance-database-backups.js";
type BetterAuthSessionUser = {
id: string;
@@ -521,11 +526,80 @@ export async function startServer(): Promise<StartedServer> {
const feedback = feedbackService(db as any, {
shareClient: createFeedbackTraceShareClientFromConfig(config),
});
const backupSettingsSvc = instanceSettingsService(db);
let databaseBackupInFlight = false;
const runServerDatabaseBackup = async (
trigger: InstanceDatabaseBackupTrigger,
): Promise<InstanceDatabaseBackupRunResult | null> => {
if (databaseBackupInFlight) {
const message = "Database backup already in progress";
if (trigger === "scheduled") {
logger.warn("Skipping scheduled database backup because a previous backup is still running");
return null;
}
throw conflict(message);
}
databaseBackupInFlight = true;
const startedAt = new Date();
const startedAtMs = Date.now();
const label = trigger === "scheduled" ? "Automatic" : "Manual";
try {
logger.info({ backupDir: config.databaseBackupDir, trigger }, `${label} database backup starting`);
// Read retention from Instance Settings (DB) so changes take effect without restart.
const generalSettings = await backupSettingsSvc.getGeneral();
const retention = generalSettings.backupRetention;
const result = await runDatabaseBackup({
connectionString: activeDatabaseConnectionString,
backupDir: config.databaseBackupDir,
retention,
filenamePrefix: "paperclip",
});
const finishedAt = new Date();
const response: InstanceDatabaseBackupRunResult = {
...result,
trigger,
backupDir: config.databaseBackupDir,
retention,
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: Date.now() - startedAtMs,
};
logger.info(
{
backupFile: result.backupFile,
sizeBytes: result.sizeBytes,
prunedCount: result.prunedCount,
backupDir: config.databaseBackupDir,
retention,
trigger,
durationMs: response.durationMs,
},
`${label} database backup complete: ${formatDatabaseBackupResult(result)}`,
);
return response;
} catch (err) {
logger.error({ err, backupDir: config.databaseBackupDir, trigger }, `${label} database backup failed`);
throw err;
} finally {
databaseBackupInFlight = false;
}
};
const app = await createApp(db as any, {
uiMode,
serverPort: listenPort,
storageService,
feedbackExportService: feedback,
databaseBackupService: {
runManualBackup: async () => {
const result = await runServerDatabaseBackup("manual");
if (!result) {
throw conflict("Database backup already in progress");
}
return result;
},
},
deploymentMode: config.deploymentMode,
deploymentExposure: config.deploymentExposure,
allowedHostnames: config.allowedHostnames,
@@ -644,43 +718,6 @@ export async function startServer(): Promise<StartedServer> {
if (config.databaseBackupEnabled) {
const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000;
const settingsSvc = instanceSettingsService(db);
let backupInFlight = false;
const runScheduledBackup = async () => {
if (backupInFlight) {
logger.warn("Skipping scheduled database backup because a previous backup is still running");
return;
}
backupInFlight = true;
try {
// Read retention from Instance Settings (DB) so changes take effect without restart
const generalSettings = await settingsSvc.getGeneral();
const retention = generalSettings.backupRetention;
const result = await runDatabaseBackup({
connectionString: activeDatabaseConnectionString,
backupDir: config.databaseBackupDir,
retention,
filenamePrefix: "paperclip",
});
logger.info(
{
backupFile: result.backupFile,
sizeBytes: result.sizeBytes,
prunedCount: result.prunedCount,
backupDir: config.databaseBackupDir,
retention,
},
`Automatic database backup complete: ${formatDatabaseBackupResult(result)}`,
);
} catch (err) {
logger.error({ err, backupDir: config.databaseBackupDir }, "Automatic database backup failed");
} finally {
backupInFlight = false;
}
};
logger.info(
{
@@ -691,7 +728,9 @@ export async function startServer(): Promise<StartedServer> {
"Automatic database backups enabled",
);
setInterval(() => {
void runScheduledBackup();
void runServerDatabaseBackup("scheduled").catch(() => {
// runServerDatabaseBackup already logs the failure with context.
});
}, backupIntervalMs);
}
+1
View File
@@ -17,3 +17,4 @@ export { inboxDismissalRoutes } from "./inbox-dismissals.js";
export { llmRoutes } from "./llms.js";
export { accessRoutes } from "./access.js";
export { instanceSettingsRoutes } from "./instance-settings.js";
export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js";
@@ -0,0 +1,30 @@
import { Router } from "express";
import type { BackupRetentionPolicy, RunDatabaseBackupResult } from "@paperclipai/db";
import { assertInstanceAdmin } from "./authz.js";
export type InstanceDatabaseBackupTrigger = "manual" | "scheduled";
export type InstanceDatabaseBackupRunResult = RunDatabaseBackupResult & {
trigger: InstanceDatabaseBackupTrigger;
backupDir: string;
retention: BackupRetentionPolicy;
startedAt: string;
finishedAt: string;
durationMs: number;
};
export type InstanceDatabaseBackupService = {
runManualBackup(): Promise<InstanceDatabaseBackupRunResult>;
};
export function instanceDatabaseBackupRoutes(service: InstanceDatabaseBackupService) {
const router = Router();
router.post("/instance/database-backups", async (req, res) => {
assertInstanceAdmin(req);
const result = await service.runManualBackup();
res.status(201).json(result);
});
return router;
}