- 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>
214 lines
7.1 KiB
Go
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 }
|