hsa-app/internal/storage/snapshot.go
Jean-Michel Tremblay fbfdc6877d Add scheduled metadata-only DB backups (spec item 11)
- internal/backup: weekly (configurable) snapshots via the existing VACUUM INTO
  + blob-strip path; restart-safe (only backs up if newest is older than the
  interval), retains BACKUP_KEEP most recent, prunes the rest. Runs in a
  background goroutine; failures are logged, never fatal.
- Config: BACKUP_DIR (./data/backups), BACKUP_INTERVAL (168h; 0 disables),
  BACKUP_KEEP (8).
- Fix: blob-strip now clears attachments.image_data too, not just receipts.
- Fix: STORAGE_DIR is the storage ROOT (default ./data) — receipts/ and
  attachments/ live under it; corrects the doubled-nesting from item 10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:12:50 -04:00

44 lines
1.4 KiB
Go

package storage
import (
"database/sql"
"fmt"
"strings"
)
// SnapshotTo writes a consistent point-in-time copy of the database to destPath
// using SQLite's VACUUM INTO (never touching/locking the live file). When
// includeBlobs is false, the image_data blobs are stripped from the copy,
// leaving only metadata — which is ~99% smaller.
//
// destPath must not already exist (VACUUM INTO requires this).
func (s *Store) SnapshotTo(destPath string, includeBlobs bool) error {
if _, err := s.db.Exec(`VACUUM INTO '` + escapeSQLiteString(destPath) + `'`); err != nil {
return fmt.Errorf("vacuum into snapshot: %w", err)
}
if includeBlobs {
return nil
}
// Strip blobs from the copy only. image_data is NOT NULL, so use an empty blob.
// Both receipts and their attachments carry blobs.
snap, err := sql.Open("sqlite", destPath)
if err != nil {
return fmt.Errorf("open snapshot: %w", err)
}
defer snap.Close()
for _, table := range []string{"receipts", "attachments"} {
if _, err := snap.Exec(`UPDATE ` + table + ` SET image_data = X''`); err != nil {
return fmt.Errorf("strip %s blobs: %w", table, err)
}
}
if _, err := snap.Exec(`VACUUM`); err != nil {
return fmt.Errorf("vacuum stripped snapshot: %w", err)
}
return nil
}
// escapeSQLiteString escapes a string for use inside single quotes in SQL.
func escapeSQLiteString(s string) string {
return strings.ReplaceAll(s, "'", "''")
}