6.2 KiB
6.2 KiB
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.yamlif 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: <backup root>/daily, /weekly, /monthly, /yearly
Configuration
backup:
time: "00:00" # Trigger time in local time (HH:MM format)
Environment variables:
QFS_BACKUP_DIR— backup root directory (default:<db dir>/backups)QFS_BACKUP_DISABLE— disable backups (1/true/yes)
Safety rules:
- Backup root must resolve outside any git worktree.
- If
qfs.dbis placed inside a repository checkout, default backups are rejected untilQFS_BACKUP_DIRpoints 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.jsonmarker file in each period directory - Rotation: excess old archives are deleted automatically
Implementation
Module: internal/appstate/backup.go
Main function:
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error)
Scheduler (in main.go):
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string)
Config struct
type BackupConfig struct {
Time string `yaml:"time"`
}
// Default: "00:00"
Implementation Notes
backup.timeis in local time without timezone offset parsing.period.jsonis 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 (
.gitancestor check) and blocks backup creation inside the repo tree
Full Listing: internal/appstate/backup.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)
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
}