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>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user