/** * @file backupAutomat.js * @description Manages backup status, archive listings, and VFS file recovery from backup archives (`GET /backup`, `GET /backup/:id/vfs`, `POST /backup/:id/vfs/recover`). * * Upstream Triggers: * - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on * `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`. * - `sseAutomat`: Subscribes to `'backup'` event (if dispatched) to reload silently. * - UI / Settings: Calls to `actions.load()`, `actions.reload()`, `actions.listBackupVfs()`, or `actions.recoverBackupVfs()`. */ import { Automat } from 'react-automat'; import { backup as backupApi } from '../api'; import authAutomat from './authAutomat'; import sseAutomat from './sseAutomat'; /** * @typedef {Object} BackupItem * @property {string} name Archive file or directory name * @property {string} path Full storage path * @property {number} totalSizeBytes Total size in bytes * @property {string|null} timestamp Creation timestamp * @property {object|null} [manifest] Backup manifest details */ /** * @typedef {Object} BackupStatus * @property {boolean} enabled Whether automated backups are enabled * @property {boolean} isRunning Whether a backup is currently executing * @property {string} dbProvider Database provider name (e.g. 'sqlite', 'mssql') * @property {boolean} mssqlWarning Whether an MSSQL warning is present * @property {string} backupDir Root directory where backups are stored * @property {{ hourlyMax: number, dailyMax: number, monthly: string }} retention Retention configuration * @property {{ hourly: number, daily: number, monthly: number }} counts Counts of existing backups * @property {BackupItem|null} [latestHourly] Most recent hourly backup * @property {BackupItem|null} [latestDaily] Most recent daily backup * @property {BackupItem|null} [latestMonthly] Most recent monthly backup */ /** * @typedef {Object} BackupState * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state * @property {string|null} error Last error message * @property {BackupStatus|null} backupStatus Service status and retention settings * @property {{ counts: { hourly: number, daily: number, monthly: number }, hourly: BackupItem[], daily: BackupItem[], monthly: BackupItem[] }} backups Grouped backup archives * @property {Record>} vfsFilesByBackup * @property {Record} vfsLoadingByBackup */ /** @type {BackupState} */ const initialState = { status: 'idle', error: null, backupStatus: null, backups: { counts: { hourly: 0, daily: 0, monthly: 0 }, hourly: [], daily: [], monthly: [], }, vfsFilesByBackup: {}, vfsLoadingByBackup: {}, }; let backupAutomat; let inflightLoad = null; // #region fetch // MARK: calls GET /api/backup from ./api/index.js async function fetchBackupData() { const data = await backupApi.getStatus(); const status = data?.status ?? null; const rawBackups = data?.backups ?? {}; const hourly = Array.isArray(rawBackups.hourly) ? rawBackups.hourly : []; const daily = Array.isArray(rawBackups.daily) ? rawBackups.daily : []; const monthly = Array.isArray(rawBackups.monthly) ? rawBackups.monthly : []; const counts = rawBackups.counts ?? { hourly: hourly.length, daily: daily.length, monthly: monthly.length, }; return { backupStatus: status, backups: { counts, hourly, daily, monthly, }, }; } // #endregion const actions = { // #region load // MARK: calls GET /api/backup from ./api/index.js /** * Loads backup status and archive listing. * Deduplicates concurrent callers. * * @param {boolean} [silent=false] If true, keeps status as 'ready' without flashing 'loading' * @returns {Promise<{ backupStatus: BackupStatus|null, backups: object }>} */ load(silent = false) { if (inflightLoad) return inflightLoad; if (!silent) { backupAutomat.setState({ status: 'loading', error: null }); } inflightLoad = (async () => { try { const { backupStatus, backups } = await fetchBackupData(); backupAutomat.setState({ backupStatus, backups, status: 'ready', error: null, }); return { backupStatus, backups }; } catch (err) { const error = err?.message ?? String(err); backupAutomat.setState(silent ? { error } : { status: 'failed', error }); throw err; } finally { inflightLoad = null; } })(); return inflightLoad; }, // #endregion // #region reload // MARK: calls GET /api/backup from ./api/index.js /** * Silently re-fetches backup status and archives in the background. */ async reload() { return actions.load(true); }, // #endregion // #region listBackupVfs // MARK: calls GET /api/backup/:id/vfs from ./api/index.js /** * Retrieves the list of VFS files stored inside a specific backup. * * @param {string} backupId Identifier of the backup * @param {number} [companyId] Optional company ID override * @returns {Promise>} */ async listBackupVfs(backupId, companyId) { if (!backupId) return []; backupAutomat.setState({ vfsLoadingByBackup: { ...backupAutomat.state.vfsLoadingByBackup, [backupId]: true }, }); try { const res = await backupApi.listVfs(backupId, companyId); const files = Array.isArray(res?.files) ? res.files : []; backupAutomat.setState({ vfsFilesByBackup: { ...backupAutomat.state.vfsFilesByBackup, [backupId]: files }, }); return files; } finally { backupAutomat.setState({ vfsLoadingByBackup: { ...backupAutomat.state.vfsLoadingByBackup, [backupId]: false }, }); } }, // #endregion // #region recoverBackupVfs // MARK: calls POST /api/backup/:id/vfs/recover from ./api/index.js /** * Restores a single VFS file from the specified backup into the active VFS. * * @param {string} backupId Identifier of the backup * @param {string} path Relative file path within the VFS * @param {number} [companyId] Optional company ID override * @returns {Promise} Recovery result */ async recoverBackupVfs(backupId, path, companyId) { return backupApi.recoverVfs(backupId, path, companyId); }, // #endregion /** * Resets the automat state to initial empty values. */ clear() { backupAutomat.setState({ ...initialState }); }, }; backupAutomat = new Automat(initialState, actions, { name: 'backup' }); // #region sseListener if (sseAutomat?.actions?.on) { sseAutomat.actions.on('backup', () => { void backupAutomat.actions.reload().catch(() => {}); }); } // #endregion // #region authSync function authKey(authState) { if (authState?.status !== 'authed' || !authState.user) return null; const companyId = authState.user.selectedCompanyId; return companyId == null ? null : String(companyId); } let lastAuthKey; function syncWithAuth(authState) { const key = authKey(authState); if (key === lastAuthKey) return; lastAuthKey = key; if (key == null) { backupAutomat.actions.clear(); } else { void backupAutomat.actions.load().catch(() => {}); } } authAutomat.subscribe(syncWithAuth); syncWithAuth(authAutomat.state); // #endregion export default backupAutomat;