import { spawn } from 'node:child_process'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { sqlite } from './db'; type BackupDisabled = { enabled: false; reason: string; }; type BackupSuccess = { enabled: true; ok: true; file: string; remote: string; }; type BackupFailure = { enabled: true; ok: false; error: string; }; export type BackupResult = BackupDisabled | BackupSuccess | BackupFailure; const REMOTE = process.env.RCLONE_BACKUP_REMOTE || ''; const TIMEOUT_MS = Number(process.env.RCLONE_BACKUP_TIMEOUT_MS || 120_000); const BACKUP_FILE = 'promptlib-latest.sqlite'; function joinRcloneTarget(remote: string, fileName: string): string { if (remote.endsWith(':') || remote.endsWith('/')) return `${remote}${fileName}`; return `${remote}/${fileName}`; } function truncateOutput(value: string): string { return value.length > 4000 ? `${value.slice(0, 4000)}...` : value; } function runRclone(args: string[], timeoutMs: number): Promise { return new Promise((resolve, reject) => { const child = spawn('rclone', args, { env: process.env, stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error(`rclone timed out after ${timeoutMs}ms`)); }, timeoutMs); child.stdout.on('data', (chunk) => { stdout += String(chunk); }); child.stderr.on('data', (chunk) => { stderr += String(chunk); }); child.on('error', (err) => { clearTimeout(timer); reject(err); }); child.on('close', (code) => { clearTimeout(timer); if (code === 0) { resolve(); return; } const output = truncateOutput([stdout.trim(), stderr.trim()].filter(Boolean).join('\n')); reject(new Error(`rclone exited with code ${code}${output ? `: ${output}` : ''}`)); }); }); } export async function backupDatabaseToRclone(): Promise { if (!REMOTE) { return { enabled: false, reason: 'RCLONE_BACKUP_REMOTE is not set' }; } const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'promptlib-backup-')); const fileName = BACKUP_FILE; const snapshot = path.join(tmpDir, fileName); const target = joinRcloneTarget(REMOTE, fileName); try { await sqlite.backup(snapshot); await runRclone(['copyto', snapshot, target], TIMEOUT_MS); console.log('[backup] uploaded', target); return { enabled: true, ok: true, file: fileName, remote: target }; } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error('[backup] failed', message); return { enabled: true, ok: false, error: message }; } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }