// Package store owns the SQLite database (AGENTS.md §7). It is the single // source of truth for stored items, scans, notifications and session state. package store import ( "context" "database/sql" "embed" "fmt" "io/fs" "sort" "time" _ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo) ) //go:embed migrations/*.sql var migrationFS embed.FS // Store wraps the database handle. type Store struct { db *sql.DB } // Open opens (creating if needed) the SQLite database at path. func Open(path string) (*Store, error) { dsn := fmt.Sprintf( "file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)", path, ) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } // modernc/sqlite serialises writes; one connection avoids lock churn. db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) return &Store{db: db}, nil } // DB exposes the handle for read-only queries. func (s *Store) DB() *sql.DB { return s.db } // Ping verifies the database is reachable. func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } // Close closes the database. func (s *Store) Close() error { return s.db.Close() } // Migrate applies every embedded migration that has not run yet. It tracks the // applied version with PRAGMA user_version, so reruns are safe. func (s *Store) Migrate(ctx context.Context) error { files, err := migrationFiles() if err != nil { return err } var version int if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { return fmt.Errorf("read user_version: %w", err) } for i := version; i < len(files); i++ { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin migration %d: %w", i+1, err) } if _, err := tx.ExecContext(ctx, files[i].sql); err != nil { _ = tx.Rollback() return fmt.Errorf("migration %s: %w", files[i].name, err) } if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil { _ = tx.Rollback() return fmt.Errorf("set user_version %d: %w", i+1, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit migration %s: %w", files[i].name, err) } } return nil } type migration struct { name string sql string } func migrationFiles() ([]migration, error) { entries, err := fs.ReadDir(migrationFS, "migrations") if err != nil { return nil, fmt.Errorf("read migrations: %w", err) } names := make([]string, 0, len(entries)) for _, e := range entries { if !e.IsDir() { names = append(names, e.Name()) } } sort.Strings(names) // 001_, 002_, ... apply in order out := make([]migration, 0, len(names)) for _, n := range names { b, err := fs.ReadFile(migrationFS, "migrations/"+n) if err != nil { return nil, fmt.Errorf("read migration %s: %w", n, err) } out = append(out, migration{name: n, sql: string(b)}) } return out, nil } // Child is one school child (AGENTS.md §7). type Child struct { ID int64 Name string Year string CompassID string CreatedAt time.Time } // SeedChildren inserts the given names if they are not present yet. It is // idempotent, so it is safe to call on every start. It never deletes a child. func (s *Store) SeedChildren(ctx context.Context, names ...string) error { for _, name := range names { if _, err := s.db.ExecContext(ctx, `INSERT INTO children (name) VALUES (?) ON CONFLICT(name) DO NOTHING`, name); err != nil { return fmt.Errorf("seed child %q: %w", name, err) } } return nil } // ListChildren returns every child, oldest first. func (s *Store) ListChildren(ctx context.Context) ([]Child, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, name, COALESCE(year, ''), COALESCE(compass_id, ''), created_at FROM children ORDER BY id`) if err != nil { return nil, fmt.Errorf("list children: %w", err) } defer rows.Close() var out []Child for rows.Next() { var c Child var created string if err := rows.Scan(&c.ID, &c.Name, &c.Year, &c.CompassID, &created); err != nil { return nil, fmt.Errorf("scan child: %w", err) } c.CreatedAt = parseTime(created) out = append(out, c) } return out, rows.Err() } // Scan is one row of the append-only scan log (FR-13). type Scan struct { ID int64 `json:"id"` ChildID *int64 `json:"child_id,omitempty"` ChildName string `json:"child,omitempty"` StartedAt time.Time `json:"started_at"` FinishedAt *time.Time `json:"finished_at,omitempty"` PagesFetched int `json:"pages_fetched"` ItemsFound int `json:"items_found"` OK bool `json:"ok"` Error string `json:"error,omitempty"` } // ScanStatus summarises scan health for the dashboard. It exists so that a // failed scan is shown as an explicit failure and never as an empty day // (FR-12). type ScanStatus struct { // EverRun is false until the first scan is recorded. EverRun bool `json:"ever_run"` // Total is the number of scan rows. Total int `json:"total"` // Failed is the number of rows with ok = 0. Failed int `json:"failed"` // Last is the most recent scan, when one exists. Last *Scan `json:"last,omitempty"` } // LastScan returns the most recent scan row, or nil when none exists. func (s *Store) LastScan(ctx context.Context) (*Scan, error) { row := s.db.QueryRowContext(ctx, `SELECT sc.id, sc.child_id, COALESCE(ch.name, ''), sc.started_at, sc.finished_at, sc.pages_fetched, sc.items_found, sc.ok, COALESCE(sc.error, '') FROM scans sc LEFT JOIN children ch ON ch.id = sc.child_id ORDER BY sc.started_at DESC, sc.id DESC LIMIT 1`) sc, err := scanRow(row) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &sc, nil } // ScanSummary builds the scan health status shown on the dashboard. func (s *Store) ScanSummary(ctx context.Context) (ScanStatus, error) { var st ScanStatus if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END), 0) FROM scans`). Scan(&st.Total, &st.Failed); err != nil { return st, fmt.Errorf("scan summary: %w", err) } st.EverRun = st.Total > 0 last, err := s.LastScan(ctx) if err != nil { return st, err } st.Last = last return st, nil } type rowScanner interface { Scan(dest ...any) error } func scanRow(r rowScanner) (Scan, error) { var sc Scan var childID sql.NullInt64 var started string var finished sql.NullString var ok int if err := r.Scan(&sc.ID, &childID, &sc.ChildName, &started, &finished, &sc.PagesFetched, &sc.ItemsFound, &ok, &sc.Error); err != nil { return sc, err } if childID.Valid { sc.ChildID = &childID.Int64 } sc.StartedAt = parseTime(started) if finished.Valid { t := parseTime(finished.String) sc.FinishedAt = &t } sc.OK = ok == 1 return sc, nil } // parseTime accepts SQLite's default datetime format and RFC3339. func parseTime(s string) time.Time { for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} { if t, err := time.ParseInLocation(layout, s, time.UTC); err == nil { return t } } return time.Time{} } // RecordScan appends one scan row. It exists so later phases and the tests can // prove the fail-loudly path. A non-empty errMsg records a failure even when // nothing was fetched. func (s *Store) RecordScan(ctx context.Context, childID *int64, pages, items int, ok bool, errMsg string) (int64, error) { okInt := 0 if ok { okInt = 1 } res, err := s.db.ExecContext(ctx, `INSERT INTO scans (child_id, finished_at, pages_fetched, items_found, ok, error) VALUES (?, datetime('now'), ?, ?, ?, NULLIF(?, ''))`, childID, pages, items, okInt, errMsg) if err != nil { return 0, fmt.Errorf("record scan: %w", err) } return res.LastInsertId() }