File size: 2,843 Bytes
cfea436
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b8a1d79
cfea436
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b8a1d79
cfea436
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
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 });
  }
}