hsa-app/internal/storage/storage.go
Jean-Michel Tremblay a7566ed166 Verify DB integrity at boot; document crash recovery (spec item 12)
- Open() now runs PRAGMA quick_check after WAL recovery. WAL mode already makes
  opening self-healing (committed writes rolled forward, an interrupted write
  discarded), so a crash mid-write recovers automatically; quick_check fails fast
  only on genuine corruption, pointing the operator at BACKUP_DIR.
- spec item 12 documents the durability/recovery model and the orphan-file caveat.

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

214 lines
7.1 KiB
Go

// Package storage is the SQLite persistence layer for receipts.
package storage
import (
"database/sql"
"fmt"
"strings"
"time"
_ "modernc.org/sqlite"
"maisym.com/hsa/internal/receipt"
)
// Store wraps the SQLite database.
type Store struct {
db *sql.DB
}
// Lookup is an editable label referenced by receipts via foreign key.
type Lookup struct {
ID int64
Label string
}
// seedCategories are inserted on first open. They can be renamed (and more added)
// from the manage portal; receipts reference them by id, so renames propagate.
var seedCategories = []string{"Medical", "Dental", "Vision", "Pharmacy", "Other"}
const schema = `
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS people (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS receipts (
id TEXT PRIMARY KEY,
uploaded_by TEXT NOT NULL,
uploaded_at TEXT NOT NULL,
receipt_date TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
category_id INTEGER NOT NULL REFERENCES categories(id),
person_id INTEGER REFERENCES people(id),
file_path TEXT NOT NULL,
image_data BLOB NOT NULL,
file_size_bytes INTEGER NOT NULL,
original_filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_receipts_active ON receipts(deleted_at);
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
receipt_id TEXT NOT NULL REFERENCES receipts(id),
uploaded_by TEXT NOT NULL,
uploaded_at TEXT NOT NULL,
file_path TEXT NOT NULL,
image_data BLOB NOT NULL,
file_size_bytes INTEGER NOT NULL,
original_filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_attachments_receipt ON attachments(receipt_id, deleted_at);
`
// Open opens (creating if needed) the SQLite database at path, applies the schema,
// and seeds the category list on first creation.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;`); err != nil {
db.Close()
return nil, fmt.Errorf("set pragmas: %w", err)
}
// WAL mode makes opening the file self-healing: SQLite atomically rolls forward
// committed transactions and discards any write interrupted by a crash/kill, so
// the DB comes up at its last committed state. quick_check confirms the result
// is sound — a crash-interrupted write passes; genuine corruption (disk failure)
// fails fast so the operator restores a backup instead of running on a bad DB.
if err := integrityCheck(db); err != nil {
db.Close()
return nil, err
}
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
for _, label := range seedCategories {
if _, err := db.Exec(`INSERT OR IGNORE INTO categories(label) VALUES (?)`, label); err != nil {
db.Close()
return nil, fmt.Errorf("seed categories: %w", err)
}
}
return &Store{db: db}, nil
}
// integrityCheck runs SQLite's quick_check (a fast structural integrity scan) and
// returns an error if the database is not "ok". Run at open, after WAL recovery.
func integrityCheck(db *sql.DB) error {
var result string
if err := db.QueryRow(`PRAGMA quick_check`).Scan(&result); err != nil {
return fmt.Errorf("integrity check: %w", err)
}
if result != "ok" {
return fmt.Errorf("database integrity check failed (%s); restore a backup from BACKUP_DIR or re-import a .db export", result)
}
return nil
}
func (s *Store) Close() error { return s.db.Close() }
// Seed inserts the given category and person labels if they are not already
// present (INSERT OR IGNORE), so renames made in the manage portal are preserved
// across restarts. Called on startup from the config catalog.
func (s *Store) Seed(categories, people []string) error {
insert := func(table string, labels []string) error {
for _, label := range labels {
label = strings.TrimSpace(label)
if label == "" {
continue
}
if _, err := s.db.Exec(`INSERT OR IGNORE INTO `+table+`(label) VALUES (?)`, label); err != nil {
return fmt.Errorf("seed %s: %w", table, err)
}
}
return nil
}
if err := insert("categories", categories); err != nil {
return err
}
return insert("people", people)
}
const rfc3339 = time.RFC3339
// Insert stores a receipt (metadata + image blob) in a single statement.
func (s *Store) Insert(r receipt.Receipt) error {
var personID any
if r.PersonID != nil {
personID = *r.PersonID
}
_, err := s.db.Exec(
`INSERT INTO receipts
(id, uploaded_by, uploaded_at, receipt_date, amount_cents, category_id, person_id,
file_path, image_data, file_size_bytes, original_filename, mime_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.ID, r.UploadedBy, r.UploadedAt.UTC().Format(rfc3339),
r.ReceiptDate.UTC().Format(rfc3339), r.AmountCents, r.CategoryID, personID,
r.FilePath, r.ImageData, r.FileSizeBytes, r.OriginalFilename, r.MimeType,
)
if err != nil {
return fmt.Errorf("insert receipt: %w", err)
}
return nil
}
// Get returns the receipt with the given id (including soft-deleted rows).
func (s *Store) Get(id string) (receipt.Receipt, error) {
row := s.db.QueryRow(
`SELECT id, uploaded_by, uploaded_at, receipt_date, amount_cents, category_id, person_id,
file_path, image_data, file_size_bytes, original_filename, mime_type, deleted_at
FROM receipts WHERE id = ?`, id)
var r receipt.Receipt
var uploadedAt, receiptDate string
var personID sql.NullInt64
var deletedAt sql.NullString
if err := row.Scan(
&r.ID, &r.UploadedBy, &uploadedAt, &receiptDate, &r.AmountCents, &r.CategoryID, &personID,
&r.FilePath, &r.ImageData, &r.FileSizeBytes, &r.OriginalFilename, &r.MimeType, &deletedAt,
); err != nil {
return receipt.Receipt{}, fmt.Errorf("get receipt: %w", err)
}
r.UploadedAt, _ = time.Parse(rfc3339, uploadedAt)
r.ReceiptDate, _ = time.Parse(rfc3339, receiptDate)
if personID.Valid {
r.PersonID = &personID.Int64
}
if deletedAt.Valid {
if t, err := time.Parse(rfc3339, deletedAt.String); err == nil {
r.DeletedAt = &t
}
}
return r, nil
}
// SoftDelete marks a receipt deleted without removing the row or its blob.
func (s *Store) SoftDelete(id string) error {
_, err := s.db.Exec(
`UPDATE receipts SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`,
time.Now().UTC().Format(rfc3339), id)
if err != nil {
return fmt.Errorf("soft delete: %w", err)
}
return nil
}
// CountActive returns the number of non-deleted receipts.
func (s *Store) CountActive() (int, error) {
var n int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM receipts WHERE deleted_at IS NULL`).Scan(&n); err != nil {
return 0, fmt.Errorf("count active: %w", err)
}
return n, nil
}
// DB exposes the underlying handle for the export snapshot.
func (s *Store) DB() *sql.DB { return s.db }