Files
QuoteForge/bible-local/06-backup.md
2026-03-07 23:18:07 +03:00

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.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: <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.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:

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.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

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
}