import { createHash } from 'node:crypto'; import { chmod, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises'; import { basename, join } from 'node:path'; import type { SqliteDatabase } from '../../infrastructure/database/database.js'; export type DataComponentKey = | 'instances' | 'statusSnapshots' | 'jobs' | 'auditEvents' | 'scheduledRuns' | 'notificationQueue' | 'smsMessages' | 'eventJournal' | 'connectionLogs'; export interface RetentionPolicy { readonly enabled: boolean; readonly days: number; readonly maximumCount: number; } export interface MaintenanceOverview { readonly runtime: { readonly version: string; readonly platform: NodeJS.Platform; readonly arch: string; readonly uptimeSeconds: number; }; readonly storage: { readonly databaseBytes: number; /** SQLite file path; empty for in-memory databases. */ readonly databasePath: string; /** Write-ahead log file size; 0 when the database is not in WAL mode. */ readonly walBytes: number; /** Total size of the retained backup files. */ readonly backupBytes: number; /** Free pages that "整理空间" can hand back to the filesystem. */ readonly reclaimableBytes: number; readonly components: readonly { readonly key: DataComponentKey; readonly count: number; readonly bytes: number; }[]; }; readonly retention: Readonly>; } export interface MaintenanceBackup { readonly filename: string; readonly path: string; readonly sizeBytes: number; readonly sha256: string; readonly createdAt: string; } /** Download metadata without the full-file hash so large backups stay cheap to serve. */ export interface MaintenanceBackupFile { readonly filename: string; readonly path: string; readonly sizeBytes: number; readonly createdAt: string; } interface ServiceOptions { readonly version: string; readonly now?: () => Date; readonly backupDirectory?: string; readonly maximumBackups?: number; } const COMPONENT_TABLES: Readonly> = Object.freeze({ instances: 'instances', statusSnapshots: 'status_snapshots', jobs: 'jobs', auditEvents: 'audit_events', scheduledRuns: 'scheduled_runs', notificationQueue: 'notification_queue', smsMessages: 'sms_messages', eventJournal: 'event_journal', connectionLogs: 'connection_logs', }); const RETENTION_SETTING_KEY = 'system.retention'; const DEFAULT_RETENTION: Readonly> = Object.freeze({ instances: Object.freeze({ enabled: false, days: 365, maximumCount: 0 }), statusSnapshots: Object.freeze({ enabled: false, days: 30, maximumCount: 0 }), jobs: Object.freeze({ enabled: true, days: 90, maximumCount: 5_000 }), auditEvents: Object.freeze({ enabled: true, days: 180, maximumCount: 50_000 }), scheduledRuns: Object.freeze({ enabled: true, days: 180, maximumCount: 5_000 }), notificationQueue: Object.freeze({ enabled: true, days: 180, maximumCount: 10_000 }), smsMessages: Object.freeze({ enabled: false, days: 365, maximumCount: 0 }), eventJournal: Object.freeze({ enabled: true, days: 30, maximumCount: 10_000 }), // The probe journal already self-caps, so retention here is opt-in by day window. connectionLogs: Object.freeze({ enabled: true, days: 30, maximumCount: 20_000 }), }); function cloneRetention( value: Readonly>, ): Record { return Object.fromEntries( Object.entries(value).map(([key, policy]) => [key, { ...policy }]), ) as Record; } function validateRetention(value: unknown): Record { const source = value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; const merged = cloneRetention(DEFAULT_RETENTION); for (const key of Object.keys(DEFAULT_RETENTION) as DataComponentKey[]) { const current = source[key]; if (current === undefined) continue; const policy = current as Partial; if ( typeof policy.enabled !== 'boolean' || !Number.isSafeInteger(policy.days) || (policy.days ?? 0) < 1 || (policy.days ?? 0) > 3_650 || !Number.isSafeInteger(policy.maximumCount) || (policy.maximumCount ?? 0) < 0 ) throw new RangeError('Retention days must be between 1 and 3650 and limits must be valid'); merged[key] = { enabled: policy.enabled, days: policy.days!, maximumCount: policy.maximumCount!, }; } if (Object.keys(source).some((key) => !(key in DEFAULT_RETENTION))) throw new TypeError('Unknown data component'); return merged; } function tableExists(db: SqliteDatabase, table: string): boolean { return Boolean( db.prepare('SELECT 1 FROM sqlite_master WHERE type = ? AND name = ?').get('table', table), ); } function countTable(db: SqliteDatabase, table: string): number { if (!tableExists(db, table)) return 0; return Number( (db.prepare(`SELECT COUNT(*) AS count FROM "${table}"`).get() as { count?: number } | undefined) ?.count ?? 0, ); } /** Per-table page usage from the dbstat virtual table; 0 when SQLite was built without it. */ function tableBytes(db: SqliteDatabase, table: string): number { if (!tableExists(db, table)) return 0; try { const row = db .prepare('SELECT COALESCE(SUM(pgsize), 0) AS bytes FROM dbstat WHERE name = ?') .get(table) as { bytes?: number } | undefined; return Number(row?.bytes ?? 0); } catch { return 0; } } export class SystemMaintenanceService { readonly #db: SqliteDatabase; readonly #version: string; readonly #now: () => Date; readonly #backupDirectory: string; readonly #maximumBackups: number; constructor(db: SqliteDatabase, options: ServiceOptions) { this.#db = db; this.#version = options.version; this.#now = options.now ?? (() => new Date()); this.#backupDirectory = options.backupDirectory ?? './data/backups'; this.#maximumBackups = Math.max(1, options.maximumBackups ?? 10); } async overview(): Promise { const pageCount = Number(this.#db.pragma('page_count', { simple: true })); const pageSize = Number(this.#db.pragma('page_size', { simple: true })); const databaseFile = String( (this.#db.prepare('PRAGMA database_list').get() as { file?: string } | undefined)?.file ?? '', ); const walBytes = databaseFile ? await stat(`${databaseFile}-wal`) .then((details) => (details.isFile() ? details.size : 0)) .catch(() => 0) : 0; const freelist = Number(this.#db.pragma('freelist_count', { simple: true })); return { runtime: { version: this.#version, platform: process.platform, arch: process.arch, uptimeSeconds: Math.floor(process.uptime()), }, storage: { databaseBytes: pageCount * pageSize, databasePath: databaseFile, walBytes, backupBytes: await this.#backupBytes(), reclaimableBytes: freelist * pageSize, components: (Object.keys(COMPONENT_TABLES) as DataComponentKey[]).map((key) => ({ key, count: countTable(this.#db, COMPONENT_TABLES[key]), bytes: tableBytes(this.#db, COMPONENT_TABLES[key]), })), }, retention: await this.getRetention(), }; } async #backupBytes(): Promise { const names = await readdir(this.#backupDirectory).catch(() => [] as string[]); let total = 0; for (const name of names.filter((entry) => entry.startsWith('multi-simadmin-'))) { const details = await stat(join(this.#backupDirectory, name)).catch(() => undefined); if (details?.isFile()) total += details.size; } return total; } defaultRetention(): Readonly> { return cloneRetention(DEFAULT_RETENTION); } async getRetention(): Promise>> { const row = this.#db .prepare('SELECT value_json FROM app_settings WHERE key = ?') .get(RETENTION_SETTING_KEY) as { value_json?: string } | undefined; if (!row?.value_json) return this.defaultRetention(); return validateRetention(JSON.parse(row.value_json)); } async updateRetention( value: unknown, ): Promise>> { const policy = validateRetention(value); const now = this.#now().toISOString(); this.#db .prepare( `INSERT INTO app_settings (key,value_json,created_at,updated_at) VALUES (?,?,?,?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`, ) .run(RETENTION_SETTING_KEY, JSON.stringify(policy), now, now); return policy; } async cleanup( components: readonly DataComponentKey[], ): Promise>> { const known = new Set(Object.keys(COMPONENT_TABLES)); if (components.some((component) => !known.has(component))) throw new Error('Unknown data component'); const retention = await this.getRetention(); const result: Partial> = {}; for (const component of components) { const policy = retention[component]; const table = COMPONENT_TABLES[component]; if (!policy.enabled || !tableExists(this.#db, table)) continue; const cutoff = new Date(this.#now().getTime() - policy.days * 86_400_000).toISOString(); if (component === 'auditEvents') result[component] = this.#delete(`DELETE FROM ${table} WHERE created_at < ?`, cutoff); else if (component === 'jobs') result[component] = this.#delete( `DELETE FROM ${table} WHERE created_at < ? AND status NOT IN ('running','pending')`, cutoff, ); else if (component === 'scheduledRuns') result[component] = this.#delete(`DELETE FROM ${table} WHERE due_at < ?`, cutoff); else if (component === 'eventJournal') result[component] = this.#delete(`DELETE FROM ${table}`); else if (component === 'connectionLogs') result[component] = this.#delete(`DELETE FROM ${table} WHERE observed_at < ?`, cutoff); else if (policy.maximumCount > 0) result[component] = this.#delete( `DELETE FROM ${table} WHERE rowid IN ( SELECT rowid FROM ${table} LIMIT ? )`, Math.max(0, countTable(this.#db, table) - policy.maximumCount), ); } return result; } async optimize(): Promise<{ checkpointed: boolean; vacuumed: boolean; analyzed: boolean }> { this.#db.pragma('wal_checkpoint(TRUNCATE)'); this.#db.exec('VACUUM'); this.#db.exec('ANALYZE'); return { checkpointed: true, vacuumed: true, analyzed: true }; } async createBackup(): Promise { await mkdir(this.#backupDirectory, { recursive: true }); const filename = `multi-simadmin-${this.#now().toISOString().replaceAll(/[:.]/gu, '-')}.db`; const path = join(this.#backupDirectory, filename); await this.#db.backup(path); await chmod(path, 0o600); const details = await stat(path); await this.#pruneBackups(); return { filename, path, sizeBytes: details.size, sha256: await readFile(path).then((content) => createHash('sha256').update(content).digest('hex'), ), createdAt: this.#now().toISOString(), }; } async listBackups(): Promise { await mkdir(this.#backupDirectory, { recursive: true }); const names = (await readdir(this.#backupDirectory)) .filter((name) => name.startsWith('multi-simadmin-') && name.endsWith('.db')) .sort() .reverse(); const backups: MaintenanceBackup[] = []; for (const filename of names.slice(0, this.#maximumBackups)) { const path = join(this.#backupDirectory, filename); const details = await stat(path); if (!details.isFile()) continue; backups.push({ filename, path, sizeBytes: details.size, sha256: createHash('sha256') .update(await readFile(path)) .digest('hex'), createdAt: details.mtime.toISOString(), }); } return backups; } /** Resolves a listed backup name to a path that stays inside the backup directory. */ #backupPath(filename: string): string { if ( !/^multi-simadmin-[A-Za-z0-9._-]{1,120}\.db$/u.test(filename) || basename(filename) !== filename ) throw new TypeError('Backup filename is invalid'); return join(this.#backupDirectory, filename); } async backupFile(filename: string): Promise { const path = this.#backupPath(filename); const details = await stat(path).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error; }); if (!details?.isFile()) return undefined; return { filename, path, sizeBytes: details.size, createdAt: details.mtime.toISOString(), }; } async deleteBackup(filename: string): Promise<{ filename: string }> { await rm(this.#backupPath(filename), { force: true }); return { filename }; } /** Names sort newest-first because they embed an ISO timestamp. */ async #pruneBackups(): Promise { const names = (await readdir(this.#backupDirectory)) .filter((name) => name.startsWith('multi-simadmin-') && name.endsWith('.db')) .sort() .reverse(); let removed = 0; for (const filename of names.slice(this.#maximumBackups)) { await rm(join(this.#backupDirectory, filename), { force: true }); removed += 1; } return removed; } #delete(statement: string, ...parameters: readonly unknown[]): number { return Number(this.#db.prepare(statement).run(...parameters).changes); } }