/** * Backup List Command * Lists available backups with metadata. */ import type { Backup } from '../../db/schema'; import { formatSize, getBackup, listBackups, loadBackupHistory, } from '../../services/backup-metadata'; import type { BackupHistory } from '../../services/backup-schedule'; import { getBackupStorage } from '../../services/backup-storage'; import { celiloIntro } from '../prompts'; import type { CommandResult } from '../types'; /** * Format a timestamp as a human-friendly relative string. * Uses relative terms for recent backups and dates for older ones. */ function formatRelativeDate(date: Date, now: number = Date.now()): string { const diffMs = now - date.getTime(); const diffMins = Math.floor(diffMs / 60_000); const diffHours = Math.floor(diffMs / 3_600_000); const diffDays = Math.floor(diffMs / 86_400_000); if (diffMins < 1) return 'just now'; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays === 1) return 'yesterday'; if (diffDays < 7) return `${diffDays} days ago`; if (diffDays < 14) return 'last week'; if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`; if (diffDays < 60) return 'last month'; return `${Math.floor(diffDays / 30)} months ago`; } /** * Show detailed info for a single backup */ function showBackupDetail(backupId: string): CommandResult { const backup = getBackup(backupId); if (!backup) { return { success: false, error: `Backup not found: ${backupId}` }; } const storage = getBackupStorage(backup.storageId); const isSystem = backup.backupType === 'system_state'; console.log(''); console.log(` ID: ${backup.id}`); console.log(` Short ID: ${backup.id.substring(0, 8)}`); if (backup.name) { console.log(` Name: ${backup.name}`); } console.log(` Type: ${isSystem ? 'System State' : 'Module Data'}`); if (!isSystem) { console.log(` Module: ${backup.moduleId ?? 'unknown'}`); } if (backup.moduleVersion) { console.log(` Module Version: ${backup.moduleVersion}`); } if (backup.schemaVersion) { console.log(` Schema Version: ${backup.schemaVersion}`); } console.log(` Status: ${backup.status}`); console.log(` Size: ${formatSize(backup.sizeBytes)}`); console.log( ` Date: ${new Date(backup.startedAt).toISOString().replace('T', ' ').substring(0, 19)} UTC`, ); console.log(` Storage: ${storage?.storageId ?? 'unknown'} (${storage?.name ?? '?'})`); console.log(` Path: ${backup.storagePath}`); if (backup.errorMessage) { console.log(` Error: ${backup.errorMessage}`); } console.log(''); return { success: true, message: `Backup detail: ${backupId}` }; } /** * Say, per module, when its data was last actually captured. * * The listing above is a log of ATTEMPTS, and an attempt log is the one thing * that cannot answer the question an operator is really asking. On celilo-mgr * the forgejo rows read: * * ✗ 17cedba4 forgejo 0 B 1h ago * ✗ 99a9f819 forgejo 0 B 2h ago * ✗ fbb4d355 forgejo 0 B 3h ago * * — a tidy hourly cadence, every row on time, and not one byte of backup * anywhere in it. Regular failure and regular success have the same silhouette * at a glance; the `✗` is one character wide and the rhythm is what the eye * reads. That listing was true for a day while production had no forgejo * backup at all (celilo#685). * * So the recovery question gets answered directly rather than left to be * inferred from a column of marks. `never` and a failure count are the two * facts a column of attempts structurally cannot show. * * System backups are skipped: they are not per-module and have no cadence. */ function printLastSuccessSummary(backupList: Backup[]): void { const moduleIds = backedUpModuleIds(backupList); if (moduleIds.length === 0) return; const lines = lastSuccessSummaryLines( moduleIds.map((moduleId) => ({ moduleId, ...loadBackupHistory(moduleId) })), Date.now(), ); console.log('\nLast successful backup:'); for (const line of lines) console.log(line); } /** The modules named by a listing, deduplicated. System rows have no module. */ export function backedUpModuleIds(backupList: Backup[]): string[] { const ids = backupList .filter((b) => b.backupType === 'module_data' && b.moduleId) .map((b) => b.moduleId as string); return [...new Set(ids)].sort(); } /** One line per module: when it was last captured, and what has failed since. */ export function lastSuccessSummaryLines( entries: Array<{ moduleId: string; lastSuccessAt: Date | null; consecutiveFailures: number }>, now: number, ): string[] { return entries.map(({ moduleId, lastSuccessAt, consecutiveFailures }) => { const when = lastSuccessAt ? formatRelativeDate(lastSuccessAt, now) : 'never'; const since = consecutiveFailures > 0 ? ` (${consecutiveFailures} failed attempt${consecutiveFailures === 1 ? '' : 's'} since)` : ''; return ` ${moduleId.padEnd(20)} ${when}${since}`; }); } /** * The listing as data, for anything that is not a person reading a terminal. * * This exists because the human output cannot be parsed back into facts. Ages * are rendered relative and BUCKETED — everything past six days collapses to * "last week", then "2 weeks ago", then "last month" — so a caller reading the * text can place a backup on a day for six days and not one day further. * * That is fine for a person, who is asking "is this recent". It is not fine for * the web console, which draws one square per module per day: a row it cannot * place is a row it must leave out, and a missing row renders as a day on which * no backup ran. The one thing a coverage grid must never do is invent a gap. * * So the timestamps go out as epoch ms, exactly as stored, and the caller * decides how to say them. Everything else here is what the human path already * shows — the same rows and the same per-module summary — because a second * source of truth for "what backups exist" is worth less than no second source. */ /** * The two database reads this needs, injectable so the shape can be tested * without one (Rule 2.3). The defaults are the real thing. */ export interface BackupListJsonDeps { storageNameOf: (storageId: string) => string; historyOf: (moduleId: string) => BackupHistory; } const LIVE_LOOKUPS: BackupListJsonDeps = { storageNameOf: (storageId) => getBackupStorage(storageId)?.storageId ?? 'unknown', historyOf: loadBackupHistory, }; export function backupListJson( backupList: Backup[], windowDays: number | null, deps: BackupListJsonDeps = LIVE_LOOKUPS, ): CommandResult { // One lookup per distinct destination rather than one per row: a month of a // failing module is hundreds of rows and two or three storages. const storageNames = new Map(); for (const backup of backupList) { if (storageNames.has(backup.storageId)) continue; storageNames.set(backup.storageId, deps.storageNameOf(backup.storageId)); } const payload = { asOf: Date.now(), /** * How far back the rows are COMPLETE for, or null when unbounded. * * The caller needs this to tell "nothing ran that day" from "you did not * ask about that day". They look identical in the data and only one of them * is somebody's missing backup. */ windowDays, backups: backupList.map((backup) => ({ id: backup.id, shortId: backup.id.substring(0, 8), moduleId: backup.moduleId, backupType: backup.backupType, status: backup.status, sizeBytes: backup.sizeBytes, // Epoch ms, both of them. `startedAt` is when the attempt began and // `completedAt` is when it finished; a failed attempt has no second one. startedAt: backup.startedAt.getTime(), completedAt: backup.completedAt ? backup.completedAt.getTime() : null, storage: storageNames.get(backup.storageId) ?? 'unknown', storagePath: backup.storagePath, moduleVersion: backup.moduleVersion, schemaVersion: backup.schemaVersion, name: backup.name, error: backup.errorMessage, })), modules: backedUpModuleIds(backupList).map((moduleId) => { const history = deps.historyOf(moduleId); return { moduleId, lastSuccessAt: history.lastSuccessAt ? history.lastSuccessAt.getTime() : null, lastAttemptAt: history.lastAttemptAt ? history.lastAttemptAt.getTime() : null, consecutiveFailures: history.consecutiveFailures, }; }), }; // `rawOutput` keeps the payload out of the decorating renderer, which would // wrap it and stop it parsing (celilo#698). return { success: true, message: JSON.stringify(payload, null, 2), rawOutput: true }; } export async function handleBackupList( args: string[], flags: Record = {}, ): Promise { try { const moduleIdOrBackupId = args[0]; // A JSON caller is a program with a window in mind, not a person skimming a // screen, so 20 is the wrong ceiling for it. It still takes --limit. const defaultLimit = flags.json ? 5000 : 20; const limit = typeof flags.limit === 'string' ? Number.parseInt(flags.limit, 10) : defaultLimit; const windowDays = typeof flags.since === 'string' ? Number.parseInt(flags.since, 10) : null; if (windowDays !== null && (!Number.isFinite(windowDays) || windowDays <= 0)) { return { success: false, error: `--since takes a positive number of days, got: ${flags.since}`, }; } const since = windowDays === null ? undefined : Date.now() - windowDays * 86_400_000; // If the argument looks like a backup ID (hex chars), show detail view if (moduleIdOrBackupId && /^[0-9a-f]{8,}$/i.test(moduleIdOrBackupId)) { const backup = getBackup(moduleIdOrBackupId); if (backup) { // The same envelope as the listing, holding one row. A caller that can // parse `backup list --json` can parse this without a second shape, and // asking about one backup is the commonest scripted case there is. if (flags.json) return backupListJson([backup], null); celiloIntro('Backup Detail'); return showBackupDetail(moduleIdOrBackupId); } // Fall through to module filter if not a backup ID } const moduleId = moduleIdOrBackupId; const backupList = listBackups({ moduleId, limit, since }); // Before the banner: --json must emit JSON and nothing else, or the first // thing a parser meets is a celilo logo. if (flags.json) return backupListJson(backupList, windowDays); celiloIntro('Available Backups'); if (backupList.length === 0) { console.log('No backups found.\n'); console.log('Create a backup:'); console.log(' celilo module backup '); return { success: true, message: 'No backups found' }; } console.log(''); for (const backup of backupList) { const id = backup.id.substring(0, 8); const module = backup.backupType === 'system_state' ? '[system]' : (backup.moduleId ?? '?'); const when = formatRelativeDate(new Date(backup.startedAt)); const size = formatSize(backup.sizeBytes); const statusIcon = backup.status === 'completed' ? '✓' : backup.status === 'failed' ? '✗' : '…'; const nameTag = backup.name ? ` "${backup.name}"` : ''; console.log( ` ${statusIcon} ${id} ${module.padEnd(12)} ${size.padEnd(10)} ${when}${nameTag}`, ); } console.log(`\n${backupList.length} backup${backupList.length === 1 ? '' : 's'} shown.`); printLastSuccessSummary(backupList); console.log('Run "celilo backup list " for details.\n'); return { success: true, message: `Found ${backupList.length} backup(s)` }; } catch (error) { return { success: false, error: `Failed to list backups: ${error instanceof Error ? error.message : String(error)}`, }; } }