# 06 — Backup ## Overview Automatic rotating ZIP backup system for local data. **What is included in each archive:** - SQLite DB (`qfs.db`) - SQLite sidecars (`qfs.db-wal`, `qfs.db-shm`) if present - `config.yaml` if present **Archive name format:** `qfs-backp-YYYY-MM-DD.zip` **Retention policy:** | Period | Keep | |--------|------| | Daily | 7 archives | | Weekly | 4 archives | | Monthly | 12 archives | | Yearly | 10 archives | **Directories:** `/daily`, `/weekly`, `/monthly`, `/yearly` --- ## Configuration ```yaml backup: time: "00:00" # Trigger time in local time (HH:MM format) ``` **Environment variables:** - `QFS_BACKUP_DIR` — backup root directory (default: `/backups`) - `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`) **Safety rules:** - Backup root must resolve outside any git worktree. - If `qfs.db` is placed inside a repository checkout, default backups are rejected until `QFS_BACKUP_DIR` points outside the repo. - Backup archives intentionally do **not** include `local_encryption.key`; restored installations on another machine must re-enter DB credentials. --- ## Behavior - **At startup:** if no backup exists for the current period, one is created immediately - **Daily:** at the configured time, a new backup is created - **Deduplication:** prevented via a `.period.json` marker file in each period directory - **Rotation:** excess old archives are deleted automatically --- ## Implementation Module: `internal/appstate/backup.go` Main function: ```go func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) ``` Scheduler (in `main.go`): ```go func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) ``` ### Config struct ```go type BackupConfig struct { Time string `yaml:"time"` } // Default: "00:00" ``` --- ## Implementation Notes - `backup.time` is in **local time** without timezone offset parsing - `.period.json` is the marker that prevents duplicate backups within the same period - Archive filenames contain only the date; uniqueness is ensured by per-period directories + the period marker - When changing naming or retention: update both the filename logic and the prune logic together - Git worktree detection is path-based (`.git` ancestor check) and blocks backup creation inside the repo tree --- ## Full Listing: `internal/appstate/backup.go` ```go package appstate import ( "archive/zip" "encoding/json" "fmt" "io" "os" "path/filepath" "sort" "strings" "time" ) type backupPeriod struct { name string retention int key func(time.Time) string date func(time.Time) string } var backupPeriods = []backupPeriod{ { name: "daily", retention: 7, key: func(t time.Time) string { return t.Format("2006-01-02") }, date: func(t time.Time) string { return t.Format("2006-01-02") }, }, { name: "weekly", retention: 4, key: func(t time.Time) string { y, w := t.ISOWeek() return fmt.Sprintf("%04d-W%02d", y, w) }, date: func(t time.Time) string { return t.Format("2006-01-02") }, }, { name: "monthly", retention: 12, key: func(t time.Time) string { return t.Format("2006-01") }, date: func(t time.Time) string { return t.Format("2006-01-02") }, }, { name: "yearly", retention: 10, key: func(t time.Time) string { return t.Format("2006") }, date: func(t time.Time) string { return t.Format("2006-01-02") }, }, } func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) { if isBackupDisabled() || dbPath == "" { return nil, nil } if _, err := os.Stat(dbPath); os.IsNotExist(err) { return nil, nil } root := resolveBackupRoot(dbPath) now := time.Now() created := make([]string, 0) for _, period := range backupPeriods { newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath) if err != nil { return created, err } created = append(created, newFiles...) } return created, nil } ``` --- ## Full Listing: Scheduler Hook (`main.go`) ```go func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) { if cfg == nil { return } hour, minute, err := parseBackupTime(cfg.Backup.Time) if err != nil { slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err) hour, minute = 0, 0 } // Startup check: create backup immediately if none exists for current periods if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil { slog.Error("local backup failed", "error", backupErr) } else { for _, path := range created { slog.Info("local backup completed", "archive", path) } } for { next := nextBackupTime(time.Now(), hour, minute) timer := time.NewTimer(time.Until(next)) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: start := time.Now() created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath) duration := time.Since(start) if backupErr != nil { slog.Error("local backup failed", "error", backupErr, "duration", duration) } else { for _, path := range created { slog.Info("local backup completed", "archive", path, "duration", duration) } } } } } func parseBackupTime(value string) (int, int, error) { if strings.TrimSpace(value) == "" { return 0, 0, fmt.Errorf("empty backup time") } parsed, err := time.Parse("15:04", value) if err != nil { return 0, 0, err } return parsed.Hour(), parsed.Minute(), nil } func nextBackupTime(now time.Time, hour, minute int) time.Time { target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location()) if !now.Before(target) { target = target.Add(24 * time.Hour) } return target } ```