import { promises as fs } from 'fs'; import path from 'path'; import Database from 'better-sqlite3'; import { db } from '../db/connection.js'; import { contexts } from '../db/schema.js'; import { buildBackupZip } from './ai-export-service.js'; const BACKUP_DIR = process.env.BACKUP_DIR ?? '/data/backups'; const CONFIG_FILE = path.join(BACKUP_DIR, 'backup-config.json'); const DEFAULT_RETENTION_COUNT = Number(process.env.BACKUP_RETENTION_COUNT ?? 14); const STATE_FILE = path.join(BACKUP_DIR, 'backup-state.json'); interface BackupConfig { retentionCount: number; } interface BackupState { lastRunAt: string | null; } async function readState(): Promise { try { const raw = await fs.readFile(STATE_FILE, 'utf-8'); return JSON.parse(raw) as BackupState; } catch { return { lastRunAt: null }; } } async function writeState(state: BackupState): Promise { await ensureBackupDir(); await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2)); } async function readConfig(): Promise { try { const raw = await fs.readFile(CONFIG_FILE, 'utf-8'); return JSON.parse(raw) as BackupConfig; } catch { return { retentionCount: DEFAULT_RETENTION_COUNT }; } } async function writeConfig(config: BackupConfig): Promise { await ensureBackupDir(); await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2)); } export async function getRetentionCount(): Promise { return (await readConfig()).retentionCount; } export async function setRetentionCount(count: number): Promise { await writeConfig({ retentionCount: count }); } export interface BackupEntry { filename: string; size: number; createdAt: string; } function timestamp(): string { return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); } async function ensureBackupDir(): Promise { await fs.mkdir(BACKUP_DIR, { recursive: true }); } async function pruneOldBackups(): Promise { const retentionCount = await getRetentionCount(); const all = await listBackups(); // listBackups returns newest-first; delete everything beyond retentionCount const toDelete = all.slice(retentionCount); for (const entry of toDelete) { await fs.unlink(path.join(BACKUP_DIR, entry.filename)); } } async function getDistinctUserIds(): Promise { const rows = await db.selectDistinct({ userId: contexts.userId }).from(contexts); return rows.map((r) => r.userId); } export function checkIntegrity(): { ok: boolean; result: string } { const dbPath = process.env.DATABASE_PATH || './ka-note.db'; const sqlite = new Database(dbPath, { readonly: true }); try { const row = sqlite.pragma('integrity_check', { simple: true }) as string; const ok = row === 'ok'; if (!ok) console.error('[db] integrity_check failed:', row); else console.log('[db] integrity_check ok'); return { ok, result: row }; } finally { sqlite.close(); } } export async function runScheduledBackup(): Promise { const { ok, result } = checkIntegrity(); if (!ok) { console.error('[backup] aborting — integrity_check failed:', result); return; } await ensureBackupDir(); const userIds = await getDistinctUserIds(); const ts = timestamp(); for (const userId of userIds) { const zip = await buildBackupZip(userId); const filename = `backup-${userId}-${ts}.zip`; await fs.writeFile(path.join(BACKUP_DIR, filename), zip); } await pruneOldBackups(); await writeState({ lastRunAt: new Date().toISOString() }); } /** Run backup immediately if it has never run or last run was >24h ago. */ export async function runIfMissed(): Promise { const { lastRunAt } = await readState(); const missedThreshold = 24 * 60 * 60 * 1000; if (!lastRunAt || Date.now() - new Date(lastRunAt).getTime() > missedThreshold) { console.log('[backup] missed run detected, executing now'); await runScheduledBackup(); } } function parseCreatedAt(filename: string): string { // filename: backup-{userId}-YYYY-MM-DDTHH-MM-SS.zip const base = filename.slice(0, -4); // strip .zip const ts = base.slice(base.lastIndexOf('-', base.length - 16) + 1); // ts: "2026-02-27T14-30-45" → restore colons in time part const iso = ts.replace(/T(\d{2})-(\d{2})-(\d{2})$/, 'T$1:$2:$3') + 'Z'; const d = new Date(iso); return isNaN(d.getTime()) ? new Date(0).toISOString() : d.toISOString(); } export async function listBackups(): Promise { await ensureBackupDir(); const entries = await fs.readdir(BACKUP_DIR); const result: BackupEntry[] = []; for (const name of entries) { if (!name.endsWith('.zip')) continue; const stat = await fs.stat(path.join(BACKUP_DIR, name)); result.push({ filename: name, size: stat.size, createdAt: parseCreatedAt(name) }); } return result.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } export async function deleteBackup(filename: string): Promise { if (path.basename(filename) !== filename || filename.includes('/') || filename.includes('\\')) { throw new Error('Invalid filename'); } await fs.unlink(path.join(BACKUP_DIR, filename)); } export function backupFilePath(filename: string): string { if (path.basename(filename) !== filename || filename.includes('/') || filename.includes('\\')) { throw new Error('Invalid filename'); } return path.join(BACKUP_DIR, filename); } export async function getBackupStatus(): Promise<{ enabled: boolean; retentionCount: number; backupDir: string; cronHour: number; lastRunAt: string | null }> { const { lastRunAt } = await readState(); return { enabled: process.env.BACKUP_ENABLED === 'true', retentionCount: await getRetentionCount(), backupDir: BACKUP_DIR, cronHour: Number(process.env.BACKUP_CRON_HOUR ?? 3), lastRunAt, }; }