using Microsoft.Data.Sqlite; namespace JobTrackerApi.Services { public interface IDatabaseBackupRunner { string BackupsRoot { get; } bool IsSupported { get; } /// Creates one backup file and prunes old ones. Returns the backup path, or null when unsupported. Task RunOnceAsync(CancellationToken ct); DateTime? GetLatestBackupUtc(); } public sealed class SqliteDatabaseBackupRunner : IDatabaseBackupRunner { public const string BackupFilePrefix = "jobtracker_backup_"; private readonly ILogger _logger; private readonly string _connectionString; private readonly int _retainCount; public string BackupsRoot { get; } public bool IsSupported { get; } public SqliteDatabaseBackupRunner(IConfiguration cfg, AppPaths paths, ILogger logger) { _logger = logger; var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant(); var cs = cfg.GetConnectionString("JobTracker"); if (string.IsNullOrWhiteSpace(cs)) { cs = $"Data Source={paths.GetDbPath()}"; provider = "sqlite"; } _connectionString = cs; IsSupported = provider == "sqlite"; BackupsRoot = Path.Combine(paths.DataRoot, "backups"); _retainCount = Math.Clamp(cfg.GetValue("Backups:RetainCount", 14), 1, 365); } // Test-friendly constructor. public SqliteDatabaseBackupRunner(string connectionString, string backupsRoot, int retainCount, ILogger logger) { _logger = logger; _connectionString = connectionString; IsSupported = true; BackupsRoot = backupsRoot; _retainCount = Math.Clamp(retainCount, 1, 365); } public async Task RunOnceAsync(CancellationToken ct) { if (!IsSupported) { _logger.LogWarning("Automated backups only support the SQLite provider. Configure external backups for MySQL/MariaDB."); return null; } Directory.CreateDirectory(BackupsRoot); var target = Path.Combine(BackupsRoot, $"{BackupFilePrefix}{DateTime.UtcNow:yyyyMMdd_HHmmss}.db"); if (File.Exists(target)) File.Delete(target); await using (var connection = new SqliteConnection(_connectionString)) { await connection.OpenAsync(ct); await using var command = connection.CreateCommand(); // VACUUM INTO produces a consistent, compacted snapshot without blocking writers (WAL). command.CommandText = $"VACUUM INTO '{target.Replace("'", "''")}'"; await command.ExecuteNonQueryAsync(ct); } _logger.LogInformation("Database backup written: {File}.", target); PruneOldBackups(); return target; } public DateTime? GetLatestBackupUtc() { if (!Directory.Exists(BackupsRoot)) return null; var latest = ListBackups().FirstOrDefault(); return latest?.LastWriteTimeUtc; } private void PruneOldBackups() { foreach (var stale in ListBackups().Skip(_retainCount)) { try { stale.Delete(); _logger.LogInformation("Pruned old database backup: {File}.", stale.Name); } catch (IOException ex) { _logger.LogWarning(ex, "Could not prune old database backup {File}.", stale.Name); } } } private IOrderedEnumerable ListBackups() => new DirectoryInfo(BackupsRoot) .EnumerateFiles($"{BackupFilePrefix}*.db") .OrderByDescending(f => f.LastWriteTimeUtc); } }