| 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<void> { |
| 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<BackupResult> { |
| 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 }); |
| } |
| } |
|
|