// Package backup writes periodic metadata-only snapshots of the database to a // local directory, on a schedule, so a fresh copy of the receipt metadata always // survives even if the live DB is lost. Blobs (receipt + attachment images) are // excluded to keep the backups small — the on-disk files and the full-blob export // remain the source for the images themselves. package backup import ( "context" "fmt" "log" "os" "path/filepath" "sort" "strings" "time" ) // Snapshotter writes a point-in-time DB copy. *storage.Store satisfies this. type Snapshotter interface { SnapshotTo(destPath string, includeBlobs bool) error } const ( prefix = "hsa_sqlite_backup_" suffix = ".db" stamp = "2006_01_02" // yyyy_mm_dd — one backup per day ) // Schedule runs metadata-only backups into dir, attempting one on start and then // every `every`, keeping the most recent `keep`. It blocks until ctx is cancelled, // so run it in a goroutine. `now` is injected for testability (pass time.Now). // // Backups are idempotent per day (date-only filenames): a write whose dated file // already exists is a no-op, so frequent restarts — or intervals shorter than a // day — never duplicate or spam, they just settle at one backup per day. func Schedule(ctx context.Context, s Snapshotter, dir string, every time.Duration, keep int, now func() time.Time) { if err := os.MkdirAll(dir, 0o700); err != nil { log.Printf("backup: cannot create %s: %v (backups disabled)", dir, err) return } for { if err := writeBackup(s, dir, now()); err != nil { log.Printf("backup: %v", err) } else if err := prune(dir, keep); err != nil { log.Printf("backup prune: %v", err) } timer := time.NewTimer(every) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: } } } func writeBackup(s Snapshotter, dir string, now time.Time) error { name := prefix + now.UTC().Format(stamp) + suffix dest := filepath.Join(dir, name) if _, err := os.Stat(dest); err == nil { return nil // today's backup already exists (date-only name) — nothing to do } if err := s.SnapshotTo(dest, false); err != nil { return fmt.Errorf("write %s: %w", name, err) } log.Printf("backup: wrote %s", name) return nil } // backupFiles returns the backup filenames in chronological order (the timestamped // names sort lexically the same as by time). func backupFiles(dir string) []string { ents, err := os.ReadDir(dir) if err != nil { return nil } var names []string for _, e := range ents { if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), suffix) { names = append(names, e.Name()) } } sort.Strings(names) return names } // prune deletes the oldest backups beyond the most recent `keep`. func prune(dir string, keep int) error { names := backupFiles(dir) if keep <= 0 || len(names) <= keep { return nil } for _, name := range names[:len(names)-keep] { if err := os.Remove(filepath.Join(dir, name)); err != nil { return err } } return nil }