Add local-first refactor foundation

This commit is contained in:
2026-05-25 00:45:46 -04:00
parent 161e871c53
commit 517c6b987b
45 changed files with 11545 additions and 697 deletions

92
internal/store/db.go Normal file
View File

@@ -0,0 +1,92 @@
package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
// Store owns the local domain SQLite database.
type Store struct {
db *sql.DB
}
// Open opens or creates a domain database at path and applies pending migrations.
func Open(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create database directory: %w", err)
}
dsn := path + "?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=ON"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
db.SetMaxOpenConns(1)
s := &Store{db: db}
if err := s.applyMigrations(); err != nil {
_ = db.Close()
return nil, err
}
return s, nil
}
// OpenDefault opens the default user domain database path.
func OpenDefault() (*Store, error) {
return Open(DefaultDBPath())
}
// DefaultDBPath returns the default domain database file path.
func DefaultDBPath() string {
home, err := os.UserHomeDir()
if err == nil {
return filepath.Join(home, ".local", "share", "box-box", "boxbox.db")
}
return filepath.Join(".local", "share", "box-box", "boxbox.db")
}
// DB exposes the underlying connection for advanced callers and tests.
func (s *Store) DB() *sql.DB {
return s.db
}
// Close closes the database connection.
func (s *Store) Close() error {
if s.db == nil {
return nil
}
return s.db.Close()
}
// WithTx runs fn inside a transaction, rolling back on error.
func (s *Store) WithTx(fn func(tx *sql.Tx) error) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
// SchemaVersion returns the highest applied migration version.
func (s *Store) SchemaVersion() (int, error) {
var version sql.NullInt64
err := s.db.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&version)
if err != nil {
return 0, err
}
if !version.Valid {
return 0, nil
}
return int(version.Int64), nil
}

295
internal/store/meetings.go Normal file
View File

@@ -0,0 +1,295 @@
package store
import (
"database/sql"
"fmt"
"time"
)
// UpsertMeeting inserts or updates a meeting by meeting_key.
func (s *Store) UpsertMeeting(m Meeting) error {
if m.UpdatedAt.IsZero() {
m.UpdatedAt = time.Now()
}
_, err := s.db.Exec(`
INSERT INTO meetings (
meeting_key, meeting_name, meeting_official_name, location,
country_code, country_name, circuit_key, circuit_short_name,
gmt_offset, date_start, date_end, year, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(meeting_key) DO UPDATE SET
meeting_name = excluded.meeting_name,
meeting_official_name = excluded.meeting_official_name,
location = excluded.location,
country_code = excluded.country_code,
country_name = excluded.country_name,
circuit_key = excluded.circuit_key,
circuit_short_name = excluded.circuit_short_name,
gmt_offset = excluded.gmt_offset,
date_start = excluded.date_start,
date_end = excluded.date_end,
year = excluded.year,
updated_at = excluded.updated_at
`,
m.MeetingKey,
m.MeetingName,
nullString(m.MeetingOfficialName),
nullString(m.Location),
nullString(m.CountryCode),
nullString(m.CountryName),
nullableZeroInt(m.CircuitKey),
nullString(m.CircuitShortName),
nullString(m.GMTOffset),
nullString(m.DateStart),
nullString(m.DateEnd),
m.Year,
m.UpdatedAt.Unix(),
)
if err != nil {
return fmt.Errorf("upsert meeting: %w", err)
}
return nil
}
// GetMeeting returns a meeting by key.
func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
var m Meeting
var updatedAt int64
var officialName, location, countryCode, countryName sql.NullString
var circuitKey sql.NullInt64
var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString
err := s.db.QueryRow(`
SELECT meeting_key, meeting_name, meeting_official_name, location,
country_code, country_name, circuit_key, circuit_short_name,
gmt_offset, date_start, date_end, year, updated_at
FROM meetings
WHERE meeting_key = ?
`, meetingKey).Scan(
&m.MeetingKey,
&m.MeetingName,
&officialName,
&location,
&countryCode,
&countryName,
&circuitKey,
&circuitShortName,
&gmtOffset,
&dateStart,
&dateEnd,
&m.Year,
&updatedAt,
)
if err != nil {
return Meeting{}, err
}
m.MeetingOfficialName = officialName.String
m.Location = location.String
m.CountryCode = countryCode.String
m.CountryName = countryName.String
if circuitKey.Valid {
m.CircuitKey = int(circuitKey.Int64)
}
m.CircuitShortName = circuitShortName.String
m.GMTOffset = gmtOffset.String
m.DateStart = dateStart.String
m.DateEnd = dateEnd.String
m.UpdatedAt = time.Unix(updatedAt, 0)
return m, nil
}
// ListMeetingsByYear returns meetings for a season ordered by start date.
func (s *Store) ListMeetingsByYear(year int) ([]Meeting, error) {
rows, err := s.db.Query(`
SELECT meeting_key, meeting_name, meeting_official_name, location,
country_code, country_name, circuit_key, circuit_short_name,
gmt_offset, date_start, date_end, year, updated_at
FROM meetings
WHERE year = ?
ORDER BY date_start ASC, meeting_key ASC
`, year)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMeetings(rows)
}
// UpsertSession inserts or updates a session by session_key.
func (s *Store) UpsertSession(sess Session) error {
if sess.UpdatedAt.IsZero() {
sess.UpdatedAt = time.Now()
}
_, err := s.db.Exec(`
INSERT INTO sessions (
session_key, meeting_key, session_name, session_type,
circuit_key, date_start, date_end, gmt_offset, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_key) DO UPDATE SET
meeting_key = excluded.meeting_key,
session_name = excluded.session_name,
session_type = excluded.session_type,
circuit_key = excluded.circuit_key,
date_start = excluded.date_start,
date_end = excluded.date_end,
gmt_offset = excluded.gmt_offset,
updated_at = excluded.updated_at
`,
sess.SessionKey,
sess.MeetingKey,
sess.SessionName,
sess.SessionType,
nullableZeroInt(sess.CircuitKey),
nullString(sess.DateStart),
nullString(sess.DateEnd),
nullString(sess.GMTOffset),
sess.UpdatedAt.Unix(),
)
if err != nil {
return fmt.Errorf("upsert session: %w", err)
}
return nil
}
// GetSession returns a session by key.
func (s *Store) GetSession(sessionKey int) (Session, error) {
var sess Session
var updatedAt int64
var circuitKey sql.NullInt64
var dateStart, dateEnd, gmtOffset sql.NullString
err := s.db.QueryRow(`
SELECT session_key, meeting_key, session_name, session_type,
circuit_key, date_start, date_end, gmt_offset, updated_at
FROM sessions
WHERE session_key = ?
`, sessionKey).Scan(
&sess.SessionKey,
&sess.MeetingKey,
&sess.SessionName,
&sess.SessionType,
&circuitKey,
&dateStart,
&dateEnd,
&gmtOffset,
&updatedAt,
)
if err != nil {
return Session{}, err
}
if circuitKey.Valid {
sess.CircuitKey = int(circuitKey.Int64)
}
sess.DateStart = dateStart.String
sess.DateEnd = dateEnd.String
sess.GMTOffset = gmtOffset.String
sess.UpdatedAt = time.Unix(updatedAt, 0)
return sess, nil
}
// ListSessionsByMeeting returns sessions for a meeting ordered by start time.
func (s *Store) ListSessionsByMeeting(meetingKey int) ([]Session, error) {
rows, err := s.db.Query(`
SELECT session_key, meeting_key, session_name, session_type,
circuit_key, date_start, date_end, gmt_offset, updated_at
FROM sessions
WHERE meeting_key = ?
ORDER BY date_start ASC, session_key ASC
`, meetingKey)
if err != nil {
return nil, err
}
defer rows.Close()
return scanSessions(rows)
}
func scanMeetings(rows *sql.Rows) ([]Meeting, error) {
var out []Meeting
for rows.Next() {
var m Meeting
var updatedAt int64
var officialName, location, countryCode, countryName sql.NullString
var circuitKey sql.NullInt64
var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString
if err := rows.Scan(
&m.MeetingKey,
&m.MeetingName,
&officialName,
&location,
&countryCode,
&countryName,
&circuitKey,
&circuitShortName,
&gmtOffset,
&dateStart,
&dateEnd,
&m.Year,
&updatedAt,
); err != nil {
return nil, err
}
m.MeetingOfficialName = officialName.String
m.Location = location.String
m.CountryCode = countryCode.String
m.CountryName = countryName.String
if circuitKey.Valid {
m.CircuitKey = int(circuitKey.Int64)
}
m.CircuitShortName = circuitShortName.String
m.GMTOffset = gmtOffset.String
m.DateStart = dateStart.String
m.DateEnd = dateEnd.String
m.UpdatedAt = time.Unix(updatedAt, 0)
out = append(out, m)
}
return out, rows.Err()
}
func scanSessions(rows *sql.Rows) ([]Session, error) {
var out []Session
for rows.Next() {
var sess Session
var updatedAt int64
var circuitKey sql.NullInt64
var dateStart, dateEnd, gmtOffset sql.NullString
if err := rows.Scan(
&sess.SessionKey,
&sess.MeetingKey,
&sess.SessionName,
&sess.SessionType,
&circuitKey,
&dateStart,
&dateEnd,
&gmtOffset,
&updatedAt,
); err != nil {
return nil, err
}
if circuitKey.Valid {
sess.CircuitKey = int(circuitKey.Int64)
}
sess.DateStart = dateStart.String
sess.DateEnd = dateEnd.String
sess.GMTOffset = gmtOffset.String
sess.UpdatedAt = time.Unix(updatedAt, 0)
out = append(out, sess)
}
return out, rows.Err()
}
func nullableZeroInt(v int) any {
if v == 0 {
return nil
}
return v
}

View File

@@ -0,0 +1,92 @@
package store
import (
"embed"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
func (s *Store) applyMigrations() error {
if _, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
)
`); err != nil {
return fmt.Errorf("bootstrap schema_migrations: %w", err)
}
entries, err := migrationFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations: %w", err)
}
var files []string
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
files = append(files, entry.Name())
}
sort.Strings(files)
for _, name := range files {
version, err := migrationVersion(name)
if err != nil {
return err
}
applied, err := s.isMigrationApplied(version)
if err != nil {
return err
}
if applied {
continue
}
sqlBytes, err := migrationFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
if _, err := s.db.Exec(string(sqlBytes)); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := s.db.Exec(
`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
version, time.Now().Unix(),
); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
}
}
return nil
}
func migrationVersion(name string) (int, error) {
prefix := strings.SplitN(name, "_", 2)[0]
version, err := strconv.Atoi(prefix)
if err != nil {
return 0, fmt.Errorf("invalid migration filename %q: %w", name, err)
}
return version, nil
}
func (s *Store) isMigrationApplied(version int) (bool, error) {
var count int
err := s.db.QueryRow(
`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`,
version,
).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}

View File

@@ -0,0 +1,118 @@
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS raw_payloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
endpoint TEXT NOT NULL,
request_key TEXT NOT NULL,
meeting_key INTEGER,
session_key INTEGER,
payload TEXT NOT NULL,
payload_hash TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
provenance_json TEXT,
UNIQUE (source, request_key, payload_hash)
);
CREATE INDEX IF NOT EXISTS idx_raw_payloads_meeting ON raw_payloads (meeting_key);
CREATE INDEX IF NOT EXISTS idx_raw_payloads_session ON raw_payloads (session_key);
CREATE TABLE IF NOT EXISTS ingestion_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_type TEXT NOT NULL,
scope_key TEXT NOT NULL,
started_at INTEGER NOT NULL,
finished_at INTEGER,
status TEXT NOT NULL,
refresh INTEGER NOT NULL DEFAULT 0,
summary_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_ingestion_runs_scope ON ingestion_runs (scope_type, scope_key);
CREATE TABLE IF NOT EXISTS meetings (
meeting_key INTEGER PRIMARY KEY,
meeting_name TEXT NOT NULL,
meeting_official_name TEXT,
location TEXT,
country_code TEXT,
country_name TEXT,
circuit_key INTEGER,
circuit_short_name TEXT,
gmt_offset TEXT,
date_start TEXT,
date_end TEXT,
year INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_meetings_year ON meetings (year);
CREATE TABLE IF NOT EXISTS sessions (
session_key INTEGER PRIMARY KEY,
meeting_key INTEGER NOT NULL REFERENCES meetings (meeting_key),
session_name TEXT NOT NULL,
session_type TEXT NOT NULL,
circuit_key INTEGER,
date_start TEXT,
date_end TEXT,
gmt_offset TEXT,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_meeting ON sessions (meeting_key);
CREATE TABLE IF NOT EXISTS drivers (
driver_number INTEGER PRIMARY KEY,
broadcast_name TEXT,
first_name TEXT,
full_name TEXT NOT NULL,
last_name TEXT,
name_acronym TEXT,
headshot_url TEXT,
team_name TEXT,
team_colour TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS session_drivers (
session_key INTEGER NOT NULL,
driver_number INTEGER NOT NULL,
meeting_key INTEGER NOT NULL,
team_name TEXT,
team_colour TEXT,
PRIMARY KEY (session_key, driver_number)
);
CREATE INDEX IF NOT EXISTS idx_session_drivers_meeting ON session_drivers (meeting_key);
CREATE TABLE IF NOT EXISTS session_results (
session_key INTEGER NOT NULL,
driver_number INTEGER NOT NULL,
meeting_key INTEGER NOT NULL,
position INTEGER NOT NULL,
points REAL NOT NULL DEFAULT 0,
number_of_laps INTEGER,
duration_json TEXT,
gap_to_leader_json TEXT,
dnf INTEGER NOT NULL DEFAULT 0,
dns INTEGER NOT NULL DEFAULT 0,
dsq INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (session_key, driver_number)
);
CREATE INDEX IF NOT EXISTS idx_session_results_meeting ON session_results (meeting_key);
CREATE TABLE IF NOT EXISTS starting_grid (
session_key INTEGER NOT NULL,
driver_number INTEGER NOT NULL,
meeting_key INTEGER NOT NULL,
position INTEGER NOT NULL,
lap_duration REAL,
PRIMARY KEY (session_key, driver_number)
);
CREATE INDEX IF NOT EXISTS idx_starting_grid_meeting ON starting_grid (meeting_key);

106
internal/store/models.go Normal file
View File

@@ -0,0 +1,106 @@
package store
import "time"
// RawPayload stores a fetched source payload with provenance metadata.
type RawPayload struct {
ID int64
Source string
Endpoint string
RequestKey string
MeetingKey *int
SessionKey *int
Payload string
PayloadHash string
FetchedAt time.Time
ProvenanceJSON string
}
// IngestionRun tracks a scoped ingestion attempt.
type IngestionRun struct {
ID int64
ScopeType string
ScopeKey string
StartedAt time.Time
FinishedAt *time.Time
Status string
Refresh bool
SummaryJSON string
}
// Meeting is a race weekend record.
type Meeting struct {
MeetingKey int
MeetingName string
MeetingOfficialName string
Location string
CountryCode string
CountryName string
CircuitKey int
CircuitShortName string
GMTOffset string
DateStart string
DateEnd string
Year int
UpdatedAt time.Time
}
// Session is a session within a meeting.
type Session struct {
SessionKey int
MeetingKey int
SessionName string
SessionType string
CircuitKey int
DateStart string
DateEnd string
GMTOffset string
UpdatedAt time.Time
}
// Driver is a driver identity record.
type Driver struct {
DriverNumber int
BroadcastName string
FirstName string
FullName string
LastName string
NameAcronym string
HeadshotURL string
TeamName string
TeamColour string
UpdatedAt time.Time
}
// SessionDriver links a driver to a session with session-specific team info.
type SessionDriver struct {
SessionKey int
DriverNumber int
MeetingKey int
TeamName string
TeamColour string
}
// SessionResult is a final classification row for a session.
type SessionResult struct {
SessionKey int
DriverNumber int
MeetingKey int
Position int
Points float64
NumberOfLaps int
DurationJSON string
GapToLeaderJSON string
DNF bool
DNS bool
DSQ bool
}
// StartingGridEntry is a starting grid position for a session.
type StartingGridEntry struct {
SessionKey int
DriverNumber int
MeetingKey int
Position int
LapDuration float64
}

177
internal/store/raw.go Normal file
View File

@@ -0,0 +1,177 @@
package store
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"time"
)
// InsertRawPayload stores a raw payload if the source/request/hash tuple is new.
// Returns the row ID and true when inserted, or the existing ID and false on duplicate.
func (s *Store) InsertRawPayload(p RawPayload) (int64, bool, error) {
if p.PayloadHash == "" {
p.PayloadHash = hashPayload(p.Payload)
}
if p.FetchedAt.IsZero() {
p.FetchedAt = time.Now()
}
result, err := s.db.Exec(`
INSERT INTO raw_payloads (
source, endpoint, request_key, meeting_key, session_key,
payload, payload_hash, fetched_at, provenance_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source, request_key, payload_hash) DO NOTHING
`,
p.Source,
p.Endpoint,
p.RequestKey,
nullableInt(p.MeetingKey),
nullableInt(p.SessionKey),
p.Payload,
p.PayloadHash,
p.FetchedAt.Unix(),
nullString(p.ProvenanceJSON),
)
if err != nil {
return 0, false, fmt.Errorf("insert raw payload: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return 0, false, err
}
if rows == 0 {
id, err := s.findRawPayloadID(p.Source, p.RequestKey, p.PayloadHash)
return id, false, err
}
id, err := result.LastInsertId()
return id, true, err
}
// GetRawPayload returns a raw payload by ID.
func (s *Store) GetRawPayload(id int64) (RawPayload, error) {
var p RawPayload
var fetchedAt int64
var meetingKey, sessionKey sql.NullInt64
var provenance sql.NullString
err := s.db.QueryRow(`
SELECT id, source, endpoint, request_key, meeting_key, session_key,
payload, payload_hash, fetched_at, provenance_json
FROM raw_payloads
WHERE id = ?
`, id).Scan(
&p.ID,
&p.Source,
&p.Endpoint,
&p.RequestKey,
&meetingKey,
&sessionKey,
&p.Payload,
&p.PayloadHash,
&fetchedAt,
&provenance,
)
if err != nil {
return RawPayload{}, err
}
p.FetchedAt = time.Unix(fetchedAt, 0)
p.MeetingKey = nullIntPtr(meetingKey)
p.SessionKey = nullIntPtr(sessionKey)
if provenance.Valid {
p.ProvenanceJSON = provenance.String
}
return p, nil
}
// ListRawPayloadsBySession returns raw payloads for a session ordered by fetch time.
func (s *Store) ListRawPayloadsBySession(sessionKey int) ([]RawPayload, error) {
rows, err := s.db.Query(`
SELECT id, source, endpoint, request_key, meeting_key, session_key,
payload, payload_hash, fetched_at, provenance_json
FROM raw_payloads
WHERE session_key = ?
ORDER BY fetched_at ASC, id ASC
`, sessionKey)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRawPayloads(rows)
}
func (s *Store) findRawPayloadID(source, requestKey, payloadHash string) (int64, error) {
var id int64
err := s.db.QueryRow(`
SELECT id FROM raw_payloads
WHERE source = ? AND request_key = ? AND payload_hash = ?
`, source, requestKey, payloadHash).Scan(&id)
return id, err
}
func scanRawPayloads(rows *sql.Rows) ([]RawPayload, error) {
var out []RawPayload
for rows.Next() {
var p RawPayload
var fetchedAt int64
var meetingKey, sessionKey sql.NullInt64
var provenance sql.NullString
if err := rows.Scan(
&p.ID,
&p.Source,
&p.Endpoint,
&p.RequestKey,
&meetingKey,
&sessionKey,
&p.Payload,
&p.PayloadHash,
&fetchedAt,
&provenance,
); err != nil {
return nil, err
}
p.FetchedAt = time.Unix(fetchedAt, 0)
p.MeetingKey = nullIntPtr(meetingKey)
p.SessionKey = nullIntPtr(sessionKey)
if provenance.Valid {
p.ProvenanceJSON = provenance.String
}
out = append(out, p)
}
return out, rows.Err()
}
func hashPayload(payload string) string {
sum := sha256.Sum256([]byte(payload))
return hex.EncodeToString(sum[:])
}
func nullableInt(v *int) any {
if v == nil {
return nil
}
return *v
}
func nullIntPtr(v sql.NullInt64) *int {
if !v.Valid {
return nil
}
n := int(v.Int64)
return &n
}
func nullString(v string) any {
if v == "" {
return nil
}
return v
}

302
internal/store/results.go Normal file
View File

@@ -0,0 +1,302 @@
package store
import (
"database/sql"
"fmt"
"time"
)
// UpsertDriver inserts or updates a driver by driver_number.
func (s *Store) UpsertDriver(d Driver) error {
if d.UpdatedAt.IsZero() {
d.UpdatedAt = time.Now()
}
_, err := s.db.Exec(`
INSERT INTO drivers (
driver_number, broadcast_name, first_name, full_name, last_name,
name_acronym, headshot_url, team_name, team_colour, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(driver_number) DO UPDATE SET
broadcast_name = excluded.broadcast_name,
first_name = excluded.first_name,
full_name = excluded.full_name,
last_name = excluded.last_name,
name_acronym = excluded.name_acronym,
headshot_url = excluded.headshot_url,
team_name = excluded.team_name,
team_colour = excluded.team_colour,
updated_at = excluded.updated_at
`,
d.DriverNumber,
nullString(d.BroadcastName),
nullString(d.FirstName),
d.FullName,
nullString(d.LastName),
nullString(d.NameAcronym),
nullString(d.HeadshotURL),
nullString(d.TeamName),
nullString(d.TeamColour),
d.UpdatedAt.Unix(),
)
if err != nil {
return fmt.Errorf("upsert driver: %w", err)
}
return nil
}
// GetDriver returns a driver by number.
func (s *Store) GetDriver(driverNumber int) (Driver, error) {
var d Driver
var updatedAt int64
var broadcastName, firstName, lastName, nameAcronym sql.NullString
var headshotURL, teamName, teamColour sql.NullString
err := s.db.QueryRow(`
SELECT driver_number, broadcast_name, first_name, full_name, last_name,
name_acronym, headshot_url, team_name, team_colour, updated_at
FROM drivers
WHERE driver_number = ?
`, driverNumber).Scan(
&d.DriverNumber,
&broadcastName,
&firstName,
&d.FullName,
&lastName,
&nameAcronym,
&headshotURL,
&teamName,
&teamColour,
&updatedAt,
)
if err != nil {
return Driver{}, err
}
d.BroadcastName = broadcastName.String
d.FirstName = firstName.String
d.LastName = lastName.String
d.NameAcronym = nameAcronym.String
d.HeadshotURL = headshotURL.String
d.TeamName = teamName.String
d.TeamColour = teamColour.String
d.UpdatedAt = time.Unix(updatedAt, 0)
return d, nil
}
// UpsertSessionDriver links a driver to a session.
func (s *Store) UpsertSessionDriver(sd SessionDriver) error {
_, err := s.db.Exec(`
INSERT INTO session_drivers (
session_key, driver_number, meeting_key, team_name, team_colour
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_key, driver_number) DO UPDATE SET
meeting_key = excluded.meeting_key,
team_name = excluded.team_name,
team_colour = excluded.team_colour
`,
sd.SessionKey,
sd.DriverNumber,
sd.MeetingKey,
nullString(sd.TeamName),
nullString(sd.TeamColour),
)
if err != nil {
return fmt.Errorf("upsert session driver: %w", err)
}
return nil
}
// ListSessionDrivers returns drivers entered for a session ordered by number.
func (s *Store) ListSessionDrivers(sessionKey int) ([]SessionDriver, error) {
rows, err := s.db.Query(`
SELECT session_key, driver_number, meeting_key, team_name, team_colour
FROM session_drivers
WHERE session_key = ?
ORDER BY driver_number ASC
`, sessionKey)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SessionDriver
for rows.Next() {
var sd SessionDriver
var teamName, teamColour sql.NullString
if err := rows.Scan(
&sd.SessionKey,
&sd.DriverNumber,
&sd.MeetingKey,
&teamName,
&teamColour,
); err != nil {
return nil, err
}
sd.TeamName = teamName.String
sd.TeamColour = teamColour.String
out = append(out, sd)
}
return out, rows.Err()
}
// UpsertSessionResult inserts or updates a session classification row.
func (s *Store) UpsertSessionResult(r SessionResult) error {
_, err := s.db.Exec(`
INSERT INTO session_results (
session_key, driver_number, meeting_key, position, points,
number_of_laps, duration_json, gap_to_leader_json, dnf, dns, dsq
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_key, driver_number) DO UPDATE SET
meeting_key = excluded.meeting_key,
position = excluded.position,
points = excluded.points,
number_of_laps = excluded.number_of_laps,
duration_json = excluded.duration_json,
gap_to_leader_json = excluded.gap_to_leader_json,
dnf = excluded.dnf,
dns = excluded.dns,
dsq = excluded.dsq
`,
r.SessionKey,
r.DriverNumber,
r.MeetingKey,
r.Position,
r.Points,
nullableZeroInt(r.NumberOfLaps),
nullString(r.DurationJSON),
nullString(r.GapToLeaderJSON),
boolInt(r.DNF),
boolInt(r.DNS),
boolInt(r.DSQ),
)
if err != nil {
return fmt.Errorf("upsert session result: %w", err)
}
return nil
}
// ListSessionResults returns classification rows ordered by finishing position.
func (s *Store) ListSessionResults(sessionKey int) ([]SessionResult, error) {
rows, err := s.db.Query(`
SELECT session_key, driver_number, meeting_key, position, points,
number_of_laps, duration_json, gap_to_leader_json, dnf, dns, dsq
FROM session_results
WHERE session_key = ?
ORDER BY position ASC, driver_number ASC
`, sessionKey)
if err != nil {
return nil, err
}
defer rows.Close()
return scanSessionResults(rows)
}
// UpsertStartingGridEntry inserts or updates a starting grid row.
func (s *Store) UpsertStartingGridEntry(g StartingGridEntry) error {
_, err := s.db.Exec(`
INSERT INTO starting_grid (
session_key, driver_number, meeting_key, position, lap_duration
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_key, driver_number) DO UPDATE SET
meeting_key = excluded.meeting_key,
position = excluded.position,
lap_duration = excluded.lap_duration
`,
g.SessionKey,
g.DriverNumber,
g.MeetingKey,
g.Position,
nullableZeroFloat(g.LapDuration),
)
if err != nil {
return fmt.Errorf("upsert starting grid: %w", err)
}
return nil
}
// ListStartingGrid returns grid rows ordered by position.
func (s *Store) ListStartingGrid(sessionKey int) ([]StartingGridEntry, error) {
rows, err := s.db.Query(`
SELECT session_key, driver_number, meeting_key, position, lap_duration
FROM starting_grid
WHERE session_key = ?
ORDER BY position ASC, driver_number ASC
`, sessionKey)
if err != nil {
return nil, err
}
defer rows.Close()
var out []StartingGridEntry
for rows.Next() {
var g StartingGridEntry
var lapDuration sql.NullFloat64
if err := rows.Scan(
&g.SessionKey,
&g.DriverNumber,
&g.MeetingKey,
&g.Position,
&lapDuration,
); err != nil {
return nil, err
}
if lapDuration.Valid {
g.LapDuration = lapDuration.Float64
}
out = append(out, g)
}
return out, rows.Err()
}
func scanSessionResults(rows *sql.Rows) ([]SessionResult, error) {
var out []SessionResult
for rows.Next() {
var r SessionResult
var numberOfLaps sql.NullInt64
var durationJSON, gapJSON sql.NullString
var dnf, dns, dsq int
if err := rows.Scan(
&r.SessionKey,
&r.DriverNumber,
&r.MeetingKey,
&r.Position,
&r.Points,
&numberOfLaps,
&durationJSON,
&gapJSON,
&dnf,
&dns,
&dsq,
); err != nil {
return nil, err
}
if numberOfLaps.Valid {
r.NumberOfLaps = int(numberOfLaps.Int64)
}
r.DurationJSON = durationJSON.String
r.GapToLeaderJSON = gapJSON.String
r.DNF = dnf != 0
r.DNS = dns != 0
r.DSQ = dsq != 0
out = append(out, r)
}
return out, rows.Err()
}
func boolInt(v bool) int {
if v {
return 1
}
return 0
}
func nullableZeroFloat(v float64) any {
if v == 0 {
return nil
}
return v
}

31
internal/store/runs.go Normal file
View File

@@ -0,0 +1,31 @@
package store
import (
"fmt"
"time"
)
// CreateIngestionRun records the start of an ingestion attempt.
func (s *Store) CreateIngestionRun(scopeType, scopeKey string, refresh bool) (int64, error) {
result, err := s.db.Exec(`
INSERT INTO ingestion_runs (scope_type, scope_key, started_at, status, refresh)
VALUES (?, ?, ?, 'running', ?)
`, scopeType, scopeKey, time.Now().Unix(), boolInt(refresh))
if err != nil {
return 0, fmt.Errorf("create ingestion run: %w", err)
}
return result.LastInsertId()
}
// FinishIngestionRun marks an ingestion run complete with status and summary JSON.
func (s *Store) FinishIngestionRun(id int64, status, summaryJSON string) error {
_, err := s.db.Exec(`
UPDATE ingestion_runs
SET finished_at = ?, status = ?, summary_json = ?
WHERE id = ?
`, time.Now().Unix(), status, nullString(summaryJSON), id)
if err != nil {
return fmt.Errorf("finish ingestion run: %w", err)
}
return nil
}

View File

@@ -0,0 +1,450 @@
package store
import (
"database/sql"
"path/filepath"
"testing"
"time"
)
func openTestStore(t *testing.T) *Store {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
s, err := Open(path)
if err != nil {
t.Fatalf("Open() error = %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestOpenAppliesMigrations(t *testing.T) {
s := openTestStore(t)
version, err := s.SchemaVersion()
if err != nil {
t.Fatalf("SchemaVersion() error = %v", err)
}
if version != 1 {
t.Fatalf("SchemaVersion() = %d, want 1", version)
}
tables := []string{
"schema_migrations",
"raw_payloads",
"ingestion_runs",
"meetings",
"sessions",
"drivers",
"session_drivers",
"session_results",
"starting_grid",
}
for _, table := range tables {
var name string
err := s.db.QueryRow(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,
table,
).Scan(&name)
if err != nil {
t.Fatalf("table %q missing: %v", table, err)
}
}
}
func TestMigrationsAreIdempotent(t *testing.T) {
s := openTestStore(t)
if err := s.applyMigrations(); err != nil {
t.Fatalf("second applyMigrations() error = %v", err)
}
if err := s.applyMigrations(); err != nil {
t.Fatalf("third applyMigrations() error = %v", err)
}
var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = 1`).Scan(&count); err != nil {
t.Fatalf("count schema_migrations: %v", err)
}
if count != 1 {
t.Fatalf("schema_migrations count = %d, want 1", count)
}
}
func TestRawPayloadInsertAndRead(t *testing.T) {
s := openTestStore(t)
meetingKey := 1229
sessionKey := 9472
fetchedAt := time.Unix(1710000000, 0).UTC()
payload := RawPayload{
Source: "openf1",
Endpoint: "session_result",
RequestKey: "session_key=9472",
MeetingKey: &meetingKey,
SessionKey: &sessionKey,
Payload: `[{"position":1,"driver_number":1}]`,
PayloadHash: "abc123",
FetchedAt: fetchedAt,
ProvenanceJSON: `{"status":"ok","http_status":200}`,
}
id, inserted, err := s.InsertRawPayload(payload)
if err != nil {
t.Fatalf("InsertRawPayload() error = %v", err)
}
if !inserted {
t.Fatal("InsertRawPayload() inserted = false, want true")
}
if id <= 0 {
t.Fatalf("InsertRawPayload() id = %d, want > 0", id)
}
got, err := s.GetRawPayload(id)
if err != nil {
t.Fatalf("GetRawPayload() error = %v", err)
}
if got.Source != payload.Source ||
got.Endpoint != payload.Endpoint ||
got.RequestKey != payload.RequestKey ||
got.Payload != payload.Payload ||
got.PayloadHash != payload.PayloadHash ||
got.ProvenanceJSON != payload.ProvenanceJSON {
t.Fatalf("GetRawPayload() = %+v, want provenance preserved", got)
}
if got.MeetingKey == nil || *got.MeetingKey != meetingKey {
t.Fatalf("MeetingKey = %v, want %d", got.MeetingKey, meetingKey)
}
if got.SessionKey == nil || *got.SessionKey != sessionKey {
t.Fatalf("SessionKey = %v, want %d", got.SessionKey, sessionKey)
}
if !got.FetchedAt.Equal(fetchedAt) {
t.Fatalf("FetchedAt = %v, want %v", got.FetchedAt, fetchedAt)
}
rows, err := s.ListRawPayloadsBySession(sessionKey)
if err != nil {
t.Fatalf("ListRawPayloadsBySession() error = %v", err)
}
if len(rows) != 1 || rows[0].ID != id {
t.Fatalf("ListRawPayloadsBySession() = %+v, want one row id=%d", rows, id)
}
}
func TestRawPayloadDuplicateIsIdempotent(t *testing.T) {
s := openTestStore(t)
payload := RawPayload{
Source: "openf1",
Endpoint: "meetings",
RequestKey: "year=2025",
Payload: `[{"meeting_key":1229}]`,
PayloadHash: "dup-hash",
}
firstID, inserted, err := s.InsertRawPayload(payload)
if err != nil {
t.Fatalf("first InsertRawPayload() error = %v", err)
}
if !inserted {
t.Fatal("first insert should succeed")
}
secondID, inserted, err := s.InsertRawPayload(payload)
if err != nil {
t.Fatalf("second InsertRawPayload() error = %v", err)
}
if inserted {
t.Fatal("duplicate insert should not create a new row")
}
if secondID != firstID {
t.Fatalf("duplicate id = %d, want %d", secondID, firstID)
}
var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM raw_payloads`).Scan(&count); err != nil {
t.Fatalf("count raw_payloads: %v", err)
}
if count != 1 {
t.Fatalf("raw_payloads count = %d, want 1", count)
}
}
func TestMeetingSessionDriverUpsertsAreIdempotent(t *testing.T) {
s := openTestStore(t)
meeting := Meeting{
MeetingKey: 1229,
MeetingName: "Monaco",
MeetingOfficialName: "FORMULA 1 GRAND PRIX DE MONACO 2025",
Location: "Monaco",
CountryCode: "MON",
CountryName: "Monaco",
CircuitKey: 10,
CircuitShortName: "Monaco",
Year: 2025,
DateStart: "2025-05-23T00:00:00+00:00",
DateEnd: "2025-05-25T00:00:00+00:00",
}
updatedMeeting := meeting
updatedMeeting.MeetingName = "Monaco GP"
for i := 0; i < 2; i++ {
m := meeting
if i == 1 {
m = updatedMeeting
}
if err := s.UpsertMeeting(m); err != nil {
t.Fatalf("UpsertMeeting(%d) error = %v", i, err)
}
}
gotMeeting, err := s.GetMeeting(meeting.MeetingKey)
if err != nil {
t.Fatalf("GetMeeting() error = %v", err)
}
if gotMeeting.MeetingName != updatedMeeting.MeetingName {
t.Fatalf("MeetingName = %q, want %q", gotMeeting.MeetingName, updatedMeeting.MeetingName)
}
var meetingCount int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM meetings`).Scan(&meetingCount); err != nil {
t.Fatalf("count meetings: %v", err)
}
if meetingCount != 1 {
t.Fatalf("meetings count = %d, want 1", meetingCount)
}
session := Session{
SessionKey: 9472,
MeetingKey: meeting.MeetingKey,
SessionName: "Race",
SessionType: "Race",
CircuitKey: 10,
DateStart: "2025-05-25T13:00:00+00:00",
}
updatedSession := session
updatedSession.DateEnd = "2025-05-25T15:00:00+00:00"
for i := 0; i < 2; i++ {
sess := session
if i == 1 {
sess = updatedSession
}
if err := s.UpsertSession(sess); err != nil {
t.Fatalf("UpsertSession(%d) error = %v", i, err)
}
}
gotSession, err := s.GetSession(session.SessionKey)
if err != nil {
t.Fatalf("GetSession() error = %v", err)
}
if gotSession.DateEnd != updatedSession.DateEnd {
t.Fatalf("DateEnd = %q, want %q", gotSession.DateEnd, updatedSession.DateEnd)
}
driver := Driver{
DriverNumber: 1,
FullName: "Max Verstappen",
NameAcronym: "VER",
TeamName: "Red Bull Racing",
TeamColour: "3671C6",
}
updatedDriver := driver
updatedDriver.TeamName = "Oracle Red Bull Racing"
for i := 0; i < 2; i++ {
d := driver
if i == 1 {
d = updatedDriver
}
if err := s.UpsertDriver(d); err != nil {
t.Fatalf("UpsertDriver(%d) error = %v", i, err)
}
}
gotDriver, err := s.GetDriver(driver.DriverNumber)
if err != nil {
t.Fatalf("GetDriver() error = %v", err)
}
if gotDriver.TeamName != updatedDriver.TeamName {
t.Fatalf("TeamName = %q, want %q", gotDriver.TeamName, updatedDriver.TeamName)
}
sessionDriver := SessionDriver{
SessionKey: session.SessionKey,
DriverNumber: driver.DriverNumber,
MeetingKey: meeting.MeetingKey,
TeamName: "Red Bull Racing",
TeamColour: "3671C6",
}
if err := s.UpsertSessionDriver(sessionDriver); err != nil {
t.Fatalf("UpsertSessionDriver() error = %v", err)
}
if err := s.UpsertSessionDriver(sessionDriver); err != nil {
t.Fatalf("second UpsertSessionDriver() error = %v", err)
}
meetings, err := s.ListMeetingsByYear(2025)
if err != nil {
t.Fatalf("ListMeetingsByYear() error = %v", err)
}
if len(meetings) != 1 {
t.Fatalf("ListMeetingsByYear() len = %d, want 1", len(meetings))
}
sessions, err := s.ListSessionsByMeeting(meeting.MeetingKey)
if err != nil {
t.Fatalf("ListSessionsByMeeting() error = %v", err)
}
if len(sessions) != 1 {
t.Fatalf("ListSessionsByMeeting() len = %d, want 1", len(sessions))
}
sessionDrivers, err := s.ListSessionDrivers(session.SessionKey)
if err != nil {
t.Fatalf("ListSessionDrivers() error = %v", err)
}
if len(sessionDrivers) != 1 {
t.Fatalf("ListSessionDrivers() len = %d, want 1", len(sessionDrivers))
}
}
func TestSessionResultAndStartingGridUpsertRead(t *testing.T) {
s := openTestStore(t)
meetingKey := 1229
sessionKey := 9472
if err := s.UpsertMeeting(Meeting{
MeetingKey: meetingKey,
MeetingName: "Monaco",
Year: 2025,
}); err != nil {
t.Fatalf("UpsertMeeting() error = %v", err)
}
if err := s.UpsertSession(Session{
SessionKey: sessionKey,
MeetingKey: meetingKey,
SessionName: "Race",
SessionType: "Race",
}); err != nil {
t.Fatalf("UpsertSession() error = %v", err)
}
result := SessionResult{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
Position: 1,
Points: 25,
NumberOfLaps: 78,
DurationJSON: "5234.567",
GapToLeaderJSON: "0",
}
updatedResult := result
updatedResult.Points = 26
for i := 0; i < 2; i++ {
r := result
if i == 1 {
r = updatedResult
}
if err := s.UpsertSessionResult(r); err != nil {
t.Fatalf("UpsertSessionResult(%d) error = %v", i, err)
}
}
results, err := s.ListSessionResults(sessionKey)
if err != nil {
t.Fatalf("ListSessionResults() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("ListSessionResults() len = %d, want 1", len(results))
}
if results[0].Points != updatedResult.Points {
t.Fatalf("Points = %v, want %v", results[0].Points, updatedResult.Points)
}
grid := StartingGridEntry{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
Position: 1,
LapDuration: 71.234,
}
updatedGrid := grid
updatedGrid.LapDuration = 71.111
for i := 0; i < 2; i++ {
g := grid
if i == 1 {
g = updatedGrid
}
if err := s.UpsertStartingGridEntry(g); err != nil {
t.Fatalf("UpsertStartingGridEntry(%d) error = %v", i, err)
}
}
grids, err := s.ListStartingGrid(sessionKey)
if err != nil {
t.Fatalf("ListStartingGrid() error = %v", err)
}
if len(grids) != 1 {
t.Fatalf("ListStartingGrid() len = %d, want 1", len(grids))
}
if grids[0].LapDuration != updatedGrid.LapDuration {
t.Fatalf("LapDuration = %v, want %v", grids[0].LapDuration, updatedGrid.LapDuration)
}
var resultCount int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM session_results`).Scan(&resultCount); err != nil {
t.Fatalf("count session_results: %v", err)
}
if resultCount != 1 {
t.Fatalf("session_results count = %d, want 1", resultCount)
}
}
func TestWithTxRollback(t *testing.T) {
s := openTestStore(t)
err := s.WithTx(func(tx *sql.Tx) error {
if _, err := tx.Exec(`
INSERT INTO meetings (meeting_key, meeting_name, year, updated_at)
VALUES (999, 'Rollback Test', 2025, ?)
`, time.Now().Unix()); err != nil {
return err
}
return assertAnError("rollback")
})
if err == nil {
t.Fatal("WithTx() error = nil, want rollback error")
}
var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM meetings WHERE meeting_key = 999`).Scan(&count); err != nil {
t.Fatalf("count meetings: %v", err)
}
if count != 0 {
t.Fatalf("meetings count after rollback = %d, want 0", count)
}
}
func assertAnError(msg string) error {
return &testError{msg: msg}
}
type testError struct {
msg string
}
func (e *testError) Error() string {
return e.msg
}