diff --git a/internal/store/host_repo_stats_history.go b/internal/store/host_repo_stats_history.go new file mode 100644 index 0000000..79a1433 --- /dev/null +++ b/internal/store/host_repo_stats_history.go @@ -0,0 +1,98 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// RepoStatsHistoryPoint is one (day, host) point for the trend chart. +// Both metric pointers may be nil — a row exists as soon as either +// metric was reported on that day. +type RepoStatsHistoryPoint struct { + Day time.Time // 00:00:00 UTC + TotalSizeBytes *int64 + SnapshotCount *int64 +} + +// UpsertHostRepoStatsHistory records the metrics carried by a +// repo.stats patch into the daily history table. Only the non-nil +// fields of patch.TotalSizeBytes / patch.SnapshotCount are written; +// existing values in the row are preserved via COALESCE so a +// prune-only or check-only patch does not null out a backup-time +// size we already captured earlier the same day. +func (s *Store) UpsertHostRepoStatsHistory( + ctx context.Context, hostID, day string, patch HostRepoStats, recordedAt time.Time, +) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO host_repo_stats_history + (host_id, day, total_size_bytes, snapshot_count, recorded_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(host_id, day) DO UPDATE SET + total_size_bytes = COALESCE(excluded.total_size_bytes, host_repo_stats_history.total_size_bytes), + snapshot_count = COALESCE(excluded.snapshot_count, host_repo_stats_history.snapshot_count), + recorded_at = excluded.recorded_at`, + hostID, day, + nullableInt64(patch.TotalSizeBytes), + nullableInt64(patch.SnapshotCount), + recordedAt.UTC().Format(time.RFC3339Nano), + ) + if err != nil { + return fmt.Errorf("store: upsert host_repo_stats_history: %w", err) + } + return nil +} + +// ListHostRepoStatsHistory returns all points for hostID with day +// >= since (UTC), ordered ascending. Pass time.Time{} to fetch the +// full history. +func (s *Store) ListHostRepoStatsHistory( + ctx context.Context, hostID string, since time.Time, +) ([]RepoStatsHistoryPoint, error) { + sinceStr := "" + if !since.IsZero() { + sinceStr = since.UTC().Format("2006-01-02") + } + rows, err := s.db.QueryContext(ctx, ` + SELECT day, total_size_bytes, snapshot_count + FROM host_repo_stats_history + WHERE host_id = ? AND day >= ? + ORDER BY day ASC`, + hostID, sinceStr) + if err != nil { + return nil, fmt.Errorf("store: list host_repo_stats_history: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []RepoStatsHistoryPoint + for rows.Next() { + var ( + dayStr string + total sql.NullInt64 + snapCnt sql.NullInt64 + ) + if err := rows.Scan(&dayStr, &total, &snapCnt); err != nil { + return nil, fmt.Errorf("store: scan history row: %w", err) + } + d, perr := time.Parse("2006-01-02", dayStr) + if perr != nil { + return nil, fmt.Errorf("store: parse history day %q: %w", dayStr, perr) + } + p := RepoStatsHistoryPoint{Day: d} + if total.Valid { + v := total.Int64 + p.TotalSizeBytes = &v + } + if snapCnt.Valid { + v := snapCnt.Int64 + p.SnapshotCount = &v + } + out = append(out, p) + } + if err := rows.Err(); err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("store: iterate history rows: %w", err) + } + return out, nil +} diff --git a/internal/store/host_repo_stats_history_test.go b/internal/store/host_repo_stats_history_test.go new file mode 100644 index 0000000..cd70b13 --- /dev/null +++ b/internal/store/host_repo_stats_history_test.go @@ -0,0 +1,120 @@ +package store + +import ( + "context" + "testing" + "time" +) + +func TestHostRepoStatsHistory_PartialUpsertPreservesPriorValues(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + + const hostID = "h-history-1" + seedHost(t, s, hostID) + + day := "2026-05-07" + now := time.Now().UTC() + + // 1. First write of the day: total_size only. + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day, + HostRepoStats{TotalSizeBytes: int64ptr(100)}, now); err != nil { + t.Fatalf("upsert 1: %v", err) + } + + // 2. Second write: snapshot_count only — total_size MUST survive. + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day, + HostRepoStats{SnapshotCount: int64ptr(7)}, now.Add(time.Minute)); err != nil { + t.Fatalf("upsert 2: %v", err) + } + + // 3. Third write: a prune-only patch (no size, no count). Both + // prior values must survive. + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day, + HostRepoStats{}, now.Add(2*time.Minute)); err != nil { + t.Fatalf("upsert 3: %v", err) + } + + pts, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(pts) != 1 { + t.Fatalf("want 1 point, got %d", len(pts)) + } + p := pts[0] + if p.TotalSizeBytes == nil || *p.TotalSizeBytes != 100 { + t.Errorf("TotalSizeBytes: want 100, got %v", p.TotalSizeBytes) + } + if p.SnapshotCount == nil || *p.SnapshotCount != 7 { + t.Errorf("SnapshotCount: want 7, got %v", p.SnapshotCount) + } +} + +func TestHostRepoStatsHistory_OrderingAndSinceFilter(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + + const hostID = "h-history-2" + seedHost(t, s, hostID) + + now := time.Now().UTC() + for i, day := range []string{"2026-05-01", "2026-05-02", "2026-05-04", "2026-05-07"} { + v := int64(100 + i*10) + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, day, + HostRepoStats{TotalSizeBytes: &v}, now); err != nil { + t.Fatalf("upsert %s: %v", day, err) + } + } + + all, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{}) + if err != nil { + t.Fatalf("list all: %v", err) + } + if len(all) != 4 { + t.Fatalf("want 4 points, got %d", len(all)) + } + wantDays := []string{"2026-05-01", "2026-05-02", "2026-05-04", "2026-05-07"} + for i, p := range all { + got := p.Day.Format("2006-01-02") + if got != wantDays[i] { + t.Errorf("point %d: want day %s, got %s", i, wantDays[i], got) + } + } + + since, _ := time.Parse("2006-01-02", "2026-05-03") + recent, err := s.ListHostRepoStatsHistory(ctx, hostID, since) + if err != nil { + t.Fatalf("list since: %v", err) + } + if len(recent) != 2 { + t.Fatalf("since 2026-05-03: want 2 points, got %d", len(recent)) + } +} + +func TestHostRepoStatsHistory_CascadeOnHostDelete(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + + const hostID = "h-history-3" + seedHost(t, s, hostID) + if err := s.UpsertHostRepoStatsHistory(ctx, hostID, "2026-05-07", + HostRepoStats{TotalSizeBytes: int64ptr(42)}, time.Now().UTC()); err != nil { + t.Fatalf("upsert: %v", err) + } + + if err := s.DeleteHost(ctx, hostID); err != nil { + t.Fatalf("delete host: %v", err) + } + + pts, err := s.ListHostRepoStatsHistory(ctx, hostID, time.Time{}) + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if len(pts) != 0 { + t.Fatalf("want 0 points after host delete, got %d", len(pts)) + } +}