storage: add speedtests table + Save/List/Delete + retention

Adds a Store-method API for the RecordSpeedTest RPC (Task 8) and the
retention sweep (Task 14):
  - SaveSpeedTest / ListRecentSpeedTests / DeleteSpeedTestsBefore
  - SpeedTest struct (no json tags; that decision deferred to Task 10)

Schema lives in migrations/002_speedtests.sql to match the existing
embed.FS pattern (rather than inline in Go). The migrate() loop now
iterates every *.sql in lexical order, so future migrations drop in
without code changes. Both files use CREATE ... IF NOT EXISTS so the
existing 001 migration remains idempotent on already-initialised DBs.

CHECK constraints enforce node IN ('fl','pl1') and non-negative metric
values at the DB level. country has no DB CHECK — validation belongs
to the gRPC handler in Task 8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
BergaBruh
2026-05-09 18:30:17 +05:00
parent ff85f21f5d
commit d1692842b3
4 changed files with 181 additions and 3 deletions
+23 -3
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"embed"
"fmt"
"sort"
"time"
"github.com/bergabruh/stats-gateway/internal/probes"
@@ -42,13 +43,32 @@ func Open(path string) (*Store, error) {
// Close closes the underlying database.
func (s *Store) Close() error { return s.db.Close() }
// migrate applies every SQL file under migrations/ in lexical filename order.
// Each file is executed wholesale (statements are CREATE ... IF NOT EXISTS,
// so re-applying on an existing DB is a no-op).
func (s *Store) migrate() error {
body, err := migrations.ReadFile("migrations/001_init.sql")
entries, err := migrations.ReadDir("migrations")
if err != nil {
return err
}
_, err = s.db.Exec(string(body))
return err
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
names = append(names, e.Name())
}
sort.Strings(names)
for _, name := range names {
body, err := migrations.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
if _, err := s.db.Exec(string(body)); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
}
return nil
}
// InsertSample stores a probe result for the given node.