package store import ( "context" "database/sql" "errors" "fmt" "math" "time" "gitea.stevedudenhoeffer.com/steve/executus/budget" ) // budgetStore is the SQLite-backed budget.BudgetStorage. type budgetStore struct{ db *sql.DB } // Budget returns a durable budget.BudgetStorage backed by this database. func (d *DB) Budget() budget.BudgetStorage { return &budgetStore{db: d.sql} } var _ budget.BudgetStorage = (*budgetStore)(nil) func (s *budgetStore) Initialize(ctx context.Context) error { _, err := s.db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS skill_budgets ( user_id TEXT PRIMARY KEY, window_start INTEGER NOT NULL, -- unix seconds seconds_used REAL NOT NULL, runs_count INTEGER NOT NULL, updated_at INTEGER NOT NULL )`) if err != nil { return fmt.Errorf("budgetStore.Initialize: %w", err) } return nil } func (s *budgetStore) Get(ctx context.Context, userID string) (*budget.SkillBudget, error) { row := s.db.QueryRowContext(ctx, `SELECT window_start, seconds_used, runs_count, updated_at FROM skill_budgets WHERE user_id = ?`, userID) var ws, ua int64 var used float64 var runs int switch err := row.Scan(&ws, &used, &runs, &ua); { case errors.Is(err, sql.ErrNoRows): return nil, nil // no row yet — documented (nil, nil) case err != nil: return nil, fmt.Errorf("budgetStore.Get: %w", err) } return &budget.SkillBudget{ UserID: userID, WindowStart: time.Unix(ws, 0).UTC(), SecondsUsed: used, RunsCount: runs, UpdatedAt: time.Unix(ua, 0).UTC(), }, nil } // Add increments usage atomically, rolling the 7-day window over inside one // transaction so concurrent Adds can't race the read-modify-write. func (s *budgetStore) Add(ctx context.Context, userID string, secondsUsed float64, now time.Time) error { // A NaN/Inf would poison the seconds_used column irrecoverably (NaN // propagates through every later add), so reject it at the boundary. if math.IsNaN(secondsUsed) || math.IsInf(secondsUsed, 0) { return fmt.Errorf("budgetStore.Add: invalid secondsUsed %v", secondsUsed) } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("budgetStore.Add: begin: %w", err) } defer tx.Rollback() //nolint:errcheck // no-op after Commit var ws int64 var used float64 var runs int err = tx.QueryRowContext(ctx, `SELECT window_start, seconds_used, runs_count FROM skill_budgets WHERE user_id = ?`, userID). Scan(&ws, &used, &runs) switch { case errors.Is(err, sql.ErrNoRows): ws, used, runs = now.Unix(), 0, 0 case err != nil: return fmt.Errorf("budgetStore.Add: select: %w", err) } // Roll the window over if older than 7 days. if now.Sub(time.Unix(ws, 0)) >= 7*24*time.Hour { ws, used, runs = now.Unix(), 0, 0 } used += secondsUsed runs++ if _, err := tx.ExecContext(ctx, ` INSERT INTO skill_budgets (user_id, window_start, seconds_used, runs_count, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET window_start = excluded.window_start, seconds_used = excluded.seconds_used, runs_count = excluded.runs_count, updated_at = excluded.updated_at`, userID, ws, used, runs, now.Unix()); err != nil { return fmt.Errorf("budgetStore.Add: upsert: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("budgetStore.Add: commit: %w", err) } return nil }