Files
executus/contrib/store/budget_store.go
T
steve 95f564ac4e P4: contrib/store — second module (pure-Go SQLite), budget store
Establish the nested persistence module — the architectural reason the core
stays lean: a SEPARATE go.mod carrying modernc.org/sqlite (pure Go, no cgo), so
the SQLite driver NEVER enters the executus core go.sum. A static-binary host
(gadfly) importing only the core stays static; a host wanting turnkey
persistence imports contrib/store.

- sqlite.go: store.Open(dsn) -> *DB (one SQLite file), accessor-per-seam.
- budget_store.go: db.Budget() satisfies budget.BudgetStorage; Add() does the
  7-day window rollover atomically inside a transaction (concurrent Adds can't
  race the read-modify-write — the in-memory store's one weak spot).
- Conformance test: budget.NewDBBudget over the SQLite store passes the SAME
  rolling-window contract as the in-memory store.
- CI: a new step builds + tests contrib/store on its own AND asserts it carries
  the sqlite driver the core forbids (proof the split works). Verified: core
  go.sum has 0 sqlite refs; contrib/store go.sum has it.

persona/skill/audit SQLite stores follow next (same JSON-blob + indexed-columns
pattern, sidestepping the three-layer field-loss footgun).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:44:44 -04:00

100 lines
3.0 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"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 {
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
}