// 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" "strings" "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() } // Item is one row of the items table (AGENTS.md §7). It mirrors parse.Item // without importing the parse package, so the store stays independent of the // scraper. type Item struct { ID int64 ChildID int64 Child string Source string Type string Subject string Teacher string Title string Body string DueAt *time.Time URL string SourcePage string Importance string ExternalRef string } // UpsertItems writes every item for one child, de-duplicating on // (child_id, source, type, external_ref). It returns how many rows were // inserted and how many were refreshed, so a re-parse never duplicates an item. func (s *Store) UpsertItems(ctx context.Context, childID int64, items []Item) (inserted, updated int, err error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return 0, 0, fmt.Errorf("upsert items: begin: %w", err) } defer func() { _ = tx.Rollback() }() for _, it := range items { if it.ExternalRef == "" { return 0, 0, fmt.Errorf("upsert items: %q has no external reference", it.Title) } var existing int64 err := tx.QueryRowContext(ctx, `SELECT id FROM items WHERE child_id = ? AND source = ? AND type = ? AND external_ref = ?`, childID, it.Source, it.Type, it.ExternalRef).Scan(&existing) switch { case err == sql.ErrNoRows: if _, err := tx.ExecContext(ctx, `INSERT INTO items (child_id, source, type, subject, teacher, title, body, due_at, url, source_page, importance, external_ref) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, childID, it.Source, it.Type, it.Subject, it.Teacher, it.Title, it.Body, formatDue(it.DueAt), it.URL, it.SourcePage, it.Importance, it.ExternalRef); err != nil { return 0, 0, fmt.Errorf("insert item %q: %w", it.Title, err) } inserted++ case err != nil: return 0, 0, fmt.Errorf("look up item %q: %w", it.Title, err) default: if _, err := tx.ExecContext(ctx, `UPDATE items SET subject = ?, teacher = ?, title = ?, body = ?, due_at = ?, url = ?, source_page = ?, importance = ?, updated_at = datetime('now') WHERE id = ?`, it.Subject, it.Teacher, it.Title, it.Body, formatDue(it.DueAt), it.URL, it.SourcePage, it.Importance, existing); err != nil { return 0, 0, fmt.Errorf("update item %q: %w", it.Title, err) } updated++ } } if err := tx.Commit(); err != nil { return 0, 0, fmt.Errorf("upsert items: commit: %w", err) } return inserted, updated, nil } // formatDue stores a due time in UTC RFC3339 so ordering is DST-safe, or NULL // when there is no due date. func formatDue(t *time.Time) any { if t == nil { return nil } return t.UTC().Format(time.RFC3339) } // DeleteChildCompassTasksFromPages removes a child's Compass task rows that came // from the given source pages. It is used when an authoritative full task list // replaces a partial grid: the legacy grid is capped // (LearningTask_Default_Records_Count) and a stale row must not survive as if it // were the whole set (AUD-114, FR-12). Rows from any other source page are left // untouched. func (s *Store) DeleteChildCompassTasksFromPages(ctx context.Context, childID int64, sourcePages ...string) (int64, error) { if len(sourcePages) == 0 { return 0, nil } placeholders := strings.Repeat("?,", len(sourcePages)-1) + "?" args := make([]any, 0, len(sourcePages)+1) args = append(args, childID) for _, p := range sourcePages { args = append(args, p) } res, err := s.db.ExecContext(ctx, `DELETE FROM items WHERE child_id = ? AND source = 'compass' AND type = 'task' AND source_page IN (`+placeholders+`)`, args...) if err != nil { return 0, fmt.Errorf("delete partial task rows: %w", err) } return res.RowsAffected() } // TypeCount is the number of items of one type for one child. type TypeCount struct { Child string Type string Count int } // CountItemsByType returns per-child, per-type row counts, ordered by child then // type. It is the evidence query for Phase 3. func (s *Store) CountItemsByType(ctx context.Context) ([]TypeCount, error) { rows, err := s.db.QueryContext(ctx, `SELECT COALESCE(ch.name, '(unknown)'), i.type, COUNT(*) FROM items i LEFT JOIN children ch ON ch.id = i.child_id GROUP BY ch.name, i.type ORDER BY ch.name, i.type`) if err != nil { return nil, fmt.Errorf("count items: %w", err) } defer rows.Close() var out []TypeCount for rows.Next() { var tc TypeCount if err := rows.Scan(&tc.Child, &tc.Type, &tc.Count); err != nil { return nil, fmt.Errorf("scan item count: %w", err) } out = append(out, tc) } return out, rows.Err() } // SampleItems returns up to limit items for a child, newest due date first. func (s *Store) SampleItems(ctx context.Context, childID int64, limit int) ([]Item, error) { rows, err := s.db.QueryContext(ctx, `SELECT i.id, i.child_id, COALESCE(ch.name, ''), i.source, i.type, COALESCE(i.subject, ''), COALESCE(i.teacher, ''), i.title, COALESCE(i.body, ''), i.due_at, COALESCE(i.url, ''), COALESCE(i.source_page, ''), COALESCE(i.importance, ''), COALESCE(i.external_ref, '') FROM items i LEFT JOIN children ch ON ch.id = i.child_id WHERE i.child_id = ? ORDER BY i.type, i.due_at DESC, i.id LIMIT ?`, childID, limit) if err != nil { return nil, fmt.Errorf("sample items: %w", err) } defer rows.Close() var out []Item for rows.Next() { var it Item var due sql.NullString if err := rows.Scan(&it.ID, &it.ChildID, &it.Child, &it.Source, &it.Type, &it.Subject, &it.Teacher, &it.Title, &it.Body, &due, &it.URL, &it.SourcePage, &it.Importance, &it.ExternalRef); err != nil { return nil, fmt.Errorf("scan item: %w", err) } if due.Valid { t := parseTime(due.String) it.DueAt = &t } out = append(out, it) } return out, rows.Err() } // ChildByName returns one child by name. func (s *Store) ChildByName(ctx context.Context, name string) (*Child, error) { row := s.db.QueryRowContext(ctx, `SELECT id, name, COALESCE(year, ''), COALESCE(compass_id, ''), created_at FROM children WHERE name = ?`, name) var c Child var created string if err := row.Scan(&c.ID, &c.Name, &c.Year, &c.CompassID, &created); err != nil { if err == sql.ErrNoRows { return nil, nil } return nil, fmt.Errorf("child %q: %w", name, err) } c.CreatedAt = parseTime(created) return &c, nil } // SetChildCompassID records the child's Compass userId, discovered by the // fetcher from the parent landing page (AGENTS.md §7). func (s *Store) SetChildCompassID(ctx context.Context, childID int64, compassID string) error { if compassID == "" { return nil } if _, err := s.db.ExecContext(ctx, `UPDATE children SET compass_id = ? WHERE id = ?`, compassID, childID); err != nil { return fmt.Errorf("set compass id for child %d: %w", childID, err) } return nil } // DeleteItems removes items for one child, optionally limited to a source and a // type. It exists so a scan can drop rows that were stored against the wrong // child (Phase 4: nothing may leak between children) and so a later Delete // action has a home. It returns how many rows were removed. func (s *Store) DeleteItems(ctx context.Context, childID int64, source, itemType string) (int64, error) { q := `DELETE FROM items WHERE child_id = ?` args := []any{childID} if source != "" { q += ` AND source = ?` args = append(args, source) } if itemType != "" { q += ` AND type = ?` args = append(args, itemType) } res, err := s.db.ExecContext(ctx, q, args...) if err != nil { return 0, fmt.Errorf("delete items for child %d: %w", childID, err) } n, err := res.RowsAffected() if err != nil { return 0, fmt.Errorf("delete items for child %d: %w", childID, err) } return n, nil }