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,65 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/budget"
|
||||
)
|
||||
|
||||
// TestSQLiteBudgetConformance runs the budget battery over the SQLite store and
|
||||
// asserts the same rolling-window contract the in-memory store must satisfy.
|
||||
func TestSQLiteBudgetConformance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
st := db.Budget()
|
||||
if err := st.Initialize(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
b := budget.NewDBBudget(st, func() float64 { return 100 }, nil, func() time.Time { return now })
|
||||
|
||||
if err := b.Check(ctx, "u"); err != nil {
|
||||
t.Fatalf("fresh caller should pass: %v", err)
|
||||
}
|
||||
b.Commit(ctx, "u", 60)
|
||||
if err := b.Check(ctx, "u"); err != nil {
|
||||
t.Fatalf("60/100 should pass: %v", err)
|
||||
}
|
||||
b.Commit(ctx, "u", 50) // 110 total
|
||||
if err := b.Check(ctx, "u"); !errors.Is(err, budget.ErrBudgetExceeded) {
|
||||
t.Fatalf("110/100 should be ErrBudgetExceeded, got %v", err)
|
||||
}
|
||||
|
||||
// Direct Get reflects the persisted row.
|
||||
row, err := st.Get(ctx, "u")
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("Get: %v %+v", err, row)
|
||||
}
|
||||
if row.SecondsUsed != 110 || row.RunsCount != 2 {
|
||||
t.Errorf("row = %+v, want seconds=110 runs=2", row)
|
||||
}
|
||||
|
||||
// Window rolls over after 7 days.
|
||||
now = now.Add(8 * 24 * time.Hour)
|
||||
b.Commit(ctx, "u", 1)
|
||||
if err := b.Check(ctx, "u"); err != nil {
|
||||
t.Fatalf("after rollover should pass: %v", err)
|
||||
}
|
||||
row, _ = st.Get(ctx, "u")
|
||||
if row.SecondsUsed != 1 || row.RunsCount != 1 {
|
||||
t.Errorf("post-rollover row = %+v, want seconds=1 runs=1", row)
|
||||
}
|
||||
|
||||
// Unknown user -> (nil, nil).
|
||||
if r, err := st.Get(ctx, "nobody"); err != nil || r != nil {
|
||||
t.Errorf("Get(unknown) = %+v %v, want nil,nil", r, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user