// 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-backup-" suffix = ".db" stamp = "2006-01-02-150405" ) // Schedule runs metadata-only backups into dir every `every`, keeping the most // recent `keep`. It blocks until ctx is cancelled, so run it in a goroutine. // // On start it backs up immediately unless a backup younger than `every` already // exists — so frequent restarts don't spam the directory, and a gap longer than // the interval is covered right away. `now` is injected for testability (pass // time.Now). 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 { timer := time.NewTimer(untilNext(dir, every, now())) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: } if err := writeBackup(s, dir, now()); err != nil { log.Printf("backup: %v", err) continue } if err := prune(dir, keep); err != nil { log.Printf("backup prune: %v", err) } } } // untilNext is how long to wait before the next backup: zero if none exists or the // newest is already older than `every`, otherwise the remaining time. func untilNext(dir string, every time.Duration, now time.Time) time.Duration { newest := newestBackup(dir) if newest.IsZero() { return 0 } if elapsed := now.Sub(newest); elapsed < every { return every - elapsed } return 0 } func writeBackup(s Snapshotter, dir string, now time.Time) error { name := prefix + now.UTC().Format(stamp) + suffix if err := s.SnapshotTo(filepath.Join(dir, name), 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 } func newestBackup(dir string) time.Time { names := backupFiles(dir) if len(names) == 0 { return time.Time{} } last := strings.TrimSuffix(strings.TrimPrefix(names[len(names)-1], prefix), suffix) t, err := time.Parse(stamp, last) // layout has no zone → parsed as UTC if err != nil { return time.Time{} } return t } // 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 }