Files
QuoteForge/bible/06-backup.md
Mikhail Chusavitin c295b60dd8 docs: introduce bible/ as single source of architectural truth
- Add bible/ with 7 hierarchical English-only files covering overview,
  architecture, database schemas, API endpoints, config/env, backup, and dev guides
- Consolidate all docs from README.md, CLAUDE.md, man/backup.md into bible/
- Simplify CLAUDE.md to a single rule: read and respect the bible
- Simplify README.md to a brief intro with links to bible/
- Remove man/backup.md and pricelists_window.md (content migrated or obsolete)
- Fix API docs: add missing endpoints (preview-article, sync/repair),
  correct DELETE /api/projects/:uuid semantics (variant soft-delete only)
- Add Soft Deletes section to architecture doc (is_active pattern)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 14:15:52 +03:00

5.7 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)

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

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
}