package backup import ( "fmt" "log/slog" "os" "path/filepath" "sync" "time" "github.com/alexei/tinyforge/internal/store" ) // Engine manages database backup operations. type Engine struct { mu sync.Mutex store *store.Store dbPath string backupDir string } // New creates a new backup engine. It ensures the backup directory exists. func New(st *store.Store, dbPath, dataDir string) (*Engine, error) { backupDir := filepath.Join(dataDir, "backups") if err := os.MkdirAll(backupDir, 0o755); err != nil { return nil, fmt.Errorf("create backup directory: %w", err) } return &Engine{ store: st, dbPath: dbPath, backupDir: backupDir, }, nil } // BackupDir returns the path to the backup directory. func (e *Engine) BackupDir() string { return e.backupDir } // CreateBackup creates a new database backup using VACUUM INTO. // Returns the backup metadata record. func (e *Engine) CreateBackup(backupType string) (store.Backup, error) { // Validate backup type to prevent path traversal via filename. switch backupType { case "manual", "auto", "pre-restore", "pre-deploy": // valid default: return store.Backup{}, fmt.Errorf("invalid backup type: %q", backupType) } e.mu.Lock() defer e.mu.Unlock() timestamp := time.Now().UTC().Format("20060102-150405") filename := fmt.Sprintf("tinyforge-%s-%s.db", backupType, timestamp) destPath := filepath.Join(e.backupDir, filename) // VACUUM INTO creates a clean, standalone copy of the database. // It is safe to use while the database is open and in WAL mode. _, err := e.store.DB().Exec(`VACUUM INTO ?`, destPath) if err != nil { return store.Backup{}, fmt.Errorf("vacuum into %s: %w", destPath, err) } // Get file size. info, err := os.Stat(destPath) if err != nil { return store.Backup{}, fmt.Errorf("stat backup file: %w", err) } // Store metadata. backup, err := e.store.CreateBackup(store.Backup{ Filename: filename, SizeBytes: info.Size(), BackupType: backupType, }) if err != nil { // Best effort: remove the file if metadata insert fails. os.Remove(destPath) return store.Backup{}, fmt.Errorf("store backup metadata: %w", err) } slog.Info("backup created", "id", backup.ID, "filename", filename, "size", info.Size(), "type", backupType) return backup, nil } // ListBackups returns all backup records. func (e *Engine) ListBackups() ([]store.Backup, error) { return e.store.ListBackups() } // GetBackup returns a single backup record. func (e *Engine) GetBackup(id string) (store.Backup, error) { return e.store.GetBackup(id) } // FilePath returns the full filesystem path for a backup. func (e *Engine) FilePath(backup store.Backup) string { return filepath.Join(e.backupDir, backup.Filename) } // DeleteBackup removes a backup file and its metadata record. func (e *Engine) DeleteBackup(id string) error { backup, err := e.store.GetBackup(id) if err != nil { return fmt.Errorf("get backup: %w", err) } // Remove file. filePath := filepath.Join(e.backupDir, backup.Filename) if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove backup file: %w", err) } // Remove metadata. if err := e.store.DeleteBackup(id); err != nil { return fmt.Errorf("delete backup metadata: %w", err) } slog.Info("backup deleted", "id", id, "filename", backup.Filename) return nil } // RestorePath returns the path of a backup file for restore operations. // The caller is responsible for actually replacing the database. func (e *Engine) RestorePath(id string) (string, error) { backup, err := e.store.GetBackup(id) if err != nil { return "", fmt.Errorf("get backup: %w", err) } filePath := filepath.Join(e.backupDir, backup.Filename) if _, err := os.Stat(filePath); err != nil { return "", fmt.Errorf("backup file not found: %w", err) } return filePath, nil } // Prune removes old backups exceeding the retention count. // Returns the number of backups pruned. func (e *Engine) Prune(retentionCount int) (int, error) { if retentionCount <= 0 { return 0, nil } count, err := e.store.CountBackups() if err != nil { return 0, fmt.Errorf("count backups: %w", err) } excess := count - retentionCount if excess <= 0 { return 0, nil } oldest, err := e.store.GetOldestBackups(excess) if err != nil { return 0, fmt.Errorf("get oldest backups: %w", err) } pruned := 0 for _, b := range oldest { if err := e.DeleteBackup(b.ID); err != nil { slog.Warn("prune: failed to delete backup", "id", b.ID, "error", err) continue } pruned++ } if pruned > 0 { slog.Info("backups pruned", "pruned", pruned, "retention", retentionCount) } return pruned, nil } // CleanOrphans removes backup files in the backup directory that have no metadata record. func (e *Engine) CleanOrphans() (int, error) { entries, err := os.ReadDir(e.backupDir) if err != nil { return 0, fmt.Errorf("read backup directory: %w", err) } backups, err := e.store.ListBackups() if err != nil { return 0, fmt.Errorf("list backups: %w", err) } tracked := make(map[string]bool, len(backups)) for _, b := range backups { tracked[b.Filename] = true } cleaned := 0 for _, entry := range entries { if entry.IsDir() { continue } if !tracked[entry.Name()] { filePath := filepath.Join(e.backupDir, entry.Name()) if err := os.Remove(filePath); err != nil { slog.Warn("clean orphan: failed to remove file", "file", entry.Name(), "error", err) continue } slog.Info("removed orphaned backup file", "file", entry.Name()) cleaned++ } } return cleaned, nil }