mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Add local-first refactor foundation
This commit is contained in:
92
internal/store/db.go
Normal file
92
internal/store/db.go
Normal 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
295
internal/store/meetings.go
Normal 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
|
||||
}
|
||||
92
internal/store/migrations.go
Normal file
92
internal/store/migrations.go
Normal 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
|
||||
}
|
||||
118
internal/store/migrations/001_initial.sql
Normal file
118
internal/store/migrations/001_initial.sql
Normal 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
106
internal/store/models.go
Normal 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
177
internal/store/raw.go
Normal 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
302
internal/store/results.go
Normal 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
31
internal/store/runs.go
Normal 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
|
||||
}
|
||||
450
internal/store/store_test.go
Normal file
450
internal/store/store_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user