// Package backup handles SQLite backups: on-demand, scheduled, and // restore. It uses SQLite's own online backup (via VACUUM INTO), which is // safe to run against a live WAL-mode database without blocking writers // for more than the duration of the copy. package backup import ( "context" "database/sql" "fmt" "log/slog" "os" "path/filepath" "sort" "time" ) // Run performs a single backup of the database at db into dir, named with // a timestamp, then prunes old backups beyond keep. func Run(db *sql.DB, dir string, keep int) (string, error) { if err := os.MkdirAll(dir, 0o700); err != nil { return "", err } name := fmt.Sprintf("vibeproxy-%s.db", time.Now().UTC().Format("20060102-150405")) path := filepath.Join(dir, name) if _, err := db.Exec(`VACUUM INTO ?`, path); err != nil { return "", fmt.Errorf("backup failed: %w", err) } if err := os.Chmod(path, 0o600); err != nil { return "", err } if keep > 0 { if err := prune(dir, keep); err != nil { return path, err } } return path, nil } func prune(dir string, keep int) error { entries, err := os.ReadDir(dir) if err != nil { return err } var names []string for _, e := range entries { if !e.IsDir() { names = append(names, e.Name()) } } sort.Strings(names) // timestamped names sort chronologically if len(names) <= keep { return nil } for _, n := range names[:len(names)-keep] { os.Remove(filepath.Join(dir, n)) } return nil } // StartScheduledBackups runs Run on the given interval until ctx is done. func StartScheduledBackups(ctx context.Context, db *sql.DB, log *slog.Logger, getDir func() string, getIntervalHours func() int, getKeep func() int, getEnabled func() bool) { ticker := time.NewTicker(1 * time.Hour) lastRun := time.Time{} go func() { defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: if !getEnabled() { continue } interval := time.Duration(getIntervalHours()) * time.Hour if interval <= 0 { interval = 24 * time.Hour } if time.Since(lastRun) < interval { continue } lastRun = time.Now() path, err := Run(db, getDir(), getKeep()) if err != nil { log.Error("scheduled backup failed", "err", err) } else { log.Info("scheduled backup complete", "path", path) } } } }() } // Restore replaces the live database file at dbPath with backupPath. The // caller must have closed all connections to dbPath before calling this // and reopen afterward. func Restore(backupPath, dbPath string) error { data, err := os.ReadFile(backupPath) if err != nil { return err } tmp := dbPath + ".restoring" if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } // Remove any stale WAL/SHM files so SQLite doesn't try to replay a // journal that belongs to the old database. os.Remove(dbPath + "-wal") os.Remove(dbPath + "-shm") return os.Rename(tmp, dbPath) }