[agent/codex:gpt-5.3-codex-spark] Update the code against the AX (Agent Experience) design pri... #16

Merged
Virgil merged 2 commits from agent/update-the-code-against-the-ax--agent-ex into dev 2026-03-29 23:27:26 +00:00
14 changed files with 474 additions and 252 deletions

View file

@ -18,7 +18,7 @@ go test ./... -count=1
```bash
go test ./... # Run all tests
go test -v -run TestWatch_Good ./... # Run single test
go test -v -run TestEvents_Watch_Good_SpecificKey ./... # Run single test
go test -race ./... # Race detector (must pass before commit)
go test -cover ./... # Coverage (target: 95%+)
go test -bench=. -benchmem ./... # Benchmarks
@ -85,7 +85,7 @@ defer unreg()
## Test Conventions
- Suffix convention: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge)
- Test names follow `Test<File>_<Function>_<Good|Bad|Ugly>`, for example `TestEvents_Watch_Good_SpecificKey`
- Use `New(":memory:")` unless testing persistence; use `t.TempDir()` for file-backed
- TTL tests: 1ms TTL + 5ms sleep; use `sync.WaitGroup` not sleeps for goroutine sync
- `require` for preconditions, `assert` for verifications (`testify`)
@ -96,7 +96,7 @@ defer unreg()
2. If mutating, call `s.notify(Event{...})` after successful DB write
3. Add delegation method on `ScopedStore` in `scope.go` (prefix the group)
4. Update `checkQuota` in `scope.go` if it affects key/group counts
5. Write `_Good`/`_Bad` tests
5. Write `Test<File>_<Function>_<Good|Bad|Ugly>` tests
6. Run `go test -race ./...` and `go vet ./...`
## Docs

View file

@ -4,42 +4,55 @@ import (
"go/ast"
"go/parser"
"go/token"
"os"
"io/fs"
"slices"
"testing"
"unicode"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoConventions_Good_BannedImports(t *testing.T) {
func TestConventions_Imports_Good_Banned(t *testing.T) {
files := repoGoFiles(t, func(name string) bool {
return core.HasSuffix(name, ".go")
})
bannedImports := []string{
"encoding/json",
"errors",
"fmt",
"os",
"os/exec",
"path/filepath",
"strings",
}
var banned []string
for _, path := range files {
file := parseGoFile(t, path)
for _, spec := range file.Imports {
importPath := core.TrimPrefix(core.TrimSuffix(spec.Path.Value, `"`), `"`)
if core.HasPrefix(importPath, "forge.lthn.ai/") {
banned = append(banned, path+": "+importPath)
importPath := trimImportPath(spec.Path.Value)
if core.HasPrefix(importPath, "forge.lthn.ai/") || slices.Contains(bannedImports, importPath) {
banned = append(banned, core.Concat(path, ": ", importPath))
}
}
}
slices.Sort(banned)
assert.Empty(t, banned, "legacy forge.lthn.ai imports are banned")
assert.Empty(t, banned, "banned imports should not appear in repository Go files")
}
func TestRepoConventions_Good_TestNaming(t *testing.T) {
func TestConventions_TestNaming_Good_StrictPattern(t *testing.T) {
files := repoGoFiles(t, func(name string) bool {
return core.HasSuffix(name, "_test.go")
})
allowedClasses := []string{"Good", "Bad", "Ugly"}
var invalid []string
for _, path := range files {
expectedPrefix := testNamePrefix(path)
file := parseGoFile(t, path)
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
@ -50,29 +63,82 @@ func TestRepoConventions_Good_TestNaming(t *testing.T) {
if !core.HasPrefix(name, "Test") || name == "TestMain" {
continue
}
if core.Contains(name, "_Good") || core.Contains(name, "_Bad") || core.Contains(name, "_Ugly") {
if !core.HasPrefix(name, expectedPrefix) {
invalid = append(invalid, core.Concat(path, ": ", name))
continue
}
invalid = append(invalid, path+": "+name)
parts := core.Split(core.TrimPrefix(name, expectedPrefix), "_")
if len(parts) < 2 || parts[0] == "" || !slices.Contains(allowedClasses, parts[1]) {
invalid = append(invalid, core.Concat(path, ": ", name))
}
}
}
slices.Sort(invalid)
assert.Empty(t, invalid, "top-level tests must include _Good, _Bad, or _Ugly in the name")
assert.Empty(t, invalid, "top-level tests must follow Test<File>_<Function>_<Good|Bad|Ugly>")
}
func TestConventions_Exports_Good_UsageExamples(t *testing.T) {
files := repoGoFiles(t, func(name string) bool {
return core.HasSuffix(name, ".go") && !core.HasSuffix(name, "_test.go")
})
var missing []string
for _, path := range files {
file := parseGoFile(t, path)
for _, decl := range file.Decls {
switch node := decl.(type) {
case *ast.FuncDecl:
if !node.Name.IsExported() {
continue
}
if !core.Contains(commentText(node.Doc), "Usage example:") {
missing = append(missing, core.Concat(path, ": ", node.Name.Name))
}
case *ast.GenDecl:
for _, spec := range node.Specs {
switch item := spec.(type) {
case *ast.TypeSpec:
if !item.Name.IsExported() {
continue
}
if !core.Contains(commentText(item.Doc, node.Doc), "Usage example:") {
missing = append(missing, core.Concat(path, ": ", item.Name.Name))
}
case *ast.ValueSpec:
for _, name := range item.Names {
if !name.IsExported() {
continue
}
if !core.Contains(commentText(item.Doc, node.Doc), "Usage example:") {
missing = append(missing, core.Concat(path, ": ", name.Name))
}
}
}
}
}
}
}
slices.Sort(missing)
assert.Empty(t, missing, "exported declarations must include a usage example in their doc comment")
}
func repoGoFiles(t *testing.T, keep func(name string) bool) []string {
t.Helper()
entries, err := os.ReadDir(".")
require.NoError(t, err)
result := testFS().List(".")
requireCoreOK(t, result)
entries, ok := result.Value.([]fs.DirEntry)
require.True(t, ok, "unexpected directory entry type: %T", result.Value)
var files []string
for _, entry := range entries {
if entry.IsDir() || !keep(entry.Name()) {
continue
}
files = append(files, core.CleanPath(entry.Name(), "/"))
files = append(files, entry.Name())
}
slices.Sort(files)
@ -82,7 +148,54 @@ func repoGoFiles(t *testing.T, keep func(name string) bool) []string {
func parseGoFile(t *testing.T, path string) *ast.File {
t.Helper()
file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ParseComments)
require.NoError(t, err)
return file
}
func trimImportPath(value string) string {
return core.TrimSuffix(core.TrimPrefix(value, `"`), `"`)
}
func testNamePrefix(path string) string {
return core.Concat("Test", camelCase(core.TrimSuffix(path, "_test.go")), "_")
}
func camelCase(value string) string {
parts := core.Split(value, "_")
builder := core.NewBuilder()
for _, part := range parts {
if part == "" {
continue
}
builder.WriteString(upperFirst(part))
}
return builder.String()
}
func upperFirst(value string) string {
runes := []rune(value)
if len(runes) == 0 {
return ""
}
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
func commentText(groups ...*ast.CommentGroup) string {
builder := core.NewBuilder()
for _, group := range groups {
if group == nil {
continue
}
text := core.Trim(group.Text())
if text == "" {
continue
}
if builder.Len() > 0 {
builder.WriteString("\n")
}
builder.WriteString(text)
}
return builder.String()
}

View file

@ -2,7 +2,6 @@ package store
import (
"database/sql"
"os"
"testing"
core "dappco.re/go/core"
@ -14,12 +13,11 @@ import (
// New — schema error path
// ---------------------------------------------------------------------------
func TestNew_Bad_SchemaConflict(t *testing.T) {
func TestCoverage_New_Bad_SchemaConflict(t *testing.T) {
// Pre-create a database with an INDEX named "kv". When New() runs
// CREATE TABLE IF NOT EXISTS kv, SQLite returns an error because the
// name "kv" is already taken by the index.
dir := t.TempDir()
dbPath := core.JoinPath(dir, "conflict.db")
dbPath := testPath(t, "conflict.db")
db, err := sql.Open("sqlite", dbPath)
require.NoError(t, err)
@ -41,7 +39,7 @@ func TestNew_Bad_SchemaConflict(t *testing.T) {
// GetAll — scan error path
// ---------------------------------------------------------------------------
func TestGetAll_Bad_ScanError(t *testing.T) {
func TestCoverage_GetAll_Bad_ScanError(t *testing.T) {
// Trigger a scan error by inserting a row with a NULL key. The production
// code scans into plain strings, which cannot represent NULL.
s, err := New(":memory:")
@ -77,11 +75,10 @@ func TestGetAll_Bad_ScanError(t *testing.T) {
// GetAll — rows iteration error path
// ---------------------------------------------------------------------------
func TestGetAll_Bad_RowsError(t *testing.T) {
func TestCoverage_GetAll_Bad_RowsError(t *testing.T) {
// Trigger rows.Err() by corrupting the database file so that iteration
// starts successfully but encounters a malformed page mid-scan.
dir := t.TempDir()
dbPath := core.JoinPath(dir, "corrupt-getall.db")
dbPath := testPath(t, "corrupt-getall.db")
s, err := New(dbPath)
require.NoError(t, err)
@ -105,26 +102,24 @@ func TestGetAll_Bad_RowsError(t *testing.T) {
// Corrupt data pages in the latter portion of the file (skip the first
// pages which hold the schema).
info, err := os.Stat(dbPath)
require.NoError(t, err)
require.Greater(t, info.Size(), int64(16384), "DB should be large enough to corrupt")
f, err := os.OpenFile(dbPath, os.O_RDWR, 0644)
require.NoError(t, err)
data := requireCoreReadBytes(t, dbPath)
garbage := make([]byte, 4096)
for i := range garbage {
garbage[i] = 0xFF
}
offset := info.Size() * 3 / 4
_, err = f.WriteAt(garbage, offset)
require.NoError(t, err)
_, err = f.WriteAt(garbage, offset+4096)
require.NoError(t, err)
require.NoError(t, f.Close())
require.Greater(t, len(data), len(garbage)*2, "DB should be large enough to corrupt")
offset := len(data) * 3 / 4
maxOffset := len(data) - (len(garbage) * 2)
if offset > maxOffset {
offset = maxOffset
}
copy(data[offset:offset+len(garbage)], garbage)
copy(data[offset+len(garbage):offset+(len(garbage)*2)], garbage)
requireCoreWriteBytes(t, dbPath, data)
// Remove WAL/SHM so the reopened connection reads from the main file.
os.Remove(dbPath + "-wal")
os.Remove(dbPath + "-shm")
_ = testFS().Delete(dbPath + "-wal")
_ = testFS().Delete(dbPath + "-shm")
s2, err := New(dbPath)
require.NoError(t, err)
@ -139,8 +134,8 @@ func TestGetAll_Bad_RowsError(t *testing.T) {
// Render — scan error path
// ---------------------------------------------------------------------------
func TestRender_Bad_ScanError(t *testing.T) {
// Same NULL-key technique as TestGetAll_Bad_ScanError.
func TestCoverage_Render_Bad_ScanError(t *testing.T) {
// Same NULL-key technique as TestCoverage_GetAll_Bad_ScanError.
s, err := New(":memory:")
require.NoError(t, err)
defer s.Close()
@ -172,10 +167,9 @@ func TestRender_Bad_ScanError(t *testing.T) {
// Render — rows iteration error path
// ---------------------------------------------------------------------------
func TestRender_Bad_RowsError(t *testing.T) {
// Same corruption technique as TestGetAll_Bad_RowsError.
dir := t.TempDir()
dbPath := core.JoinPath(dir, "corrupt-render.db")
func TestCoverage_Render_Bad_RowsError(t *testing.T) {
// Same corruption technique as TestCoverage_GetAll_Bad_RowsError.
dbPath := testPath(t, "corrupt-render.db")
s, err := New(dbPath)
require.NoError(t, err)
@ -195,24 +189,23 @@ func TestRender_Bad_RowsError(t *testing.T) {
require.NoError(t, err)
require.NoError(t, raw.Close())
info, err := os.Stat(dbPath)
require.NoError(t, err)
f, err := os.OpenFile(dbPath, os.O_RDWR, 0644)
require.NoError(t, err)
data := requireCoreReadBytes(t, dbPath)
garbage := make([]byte, 4096)
for i := range garbage {
garbage[i] = 0xFF
}
offset := info.Size() * 3 / 4
_, err = f.WriteAt(garbage, offset)
require.NoError(t, err)
_, err = f.WriteAt(garbage, offset+4096)
require.NoError(t, err)
require.NoError(t, f.Close())
require.Greater(t, len(data), len(garbage)*2, "DB should be large enough to corrupt")
offset := len(data) * 3 / 4
maxOffset := len(data) - (len(garbage) * 2)
if offset > maxOffset {
offset = maxOffset
}
copy(data[offset:offset+len(garbage)], garbage)
copy(data[offset+len(garbage):offset+(len(garbage)*2)], garbage)
requireCoreWriteBytes(t, dbPath, data)
os.Remove(dbPath + "-wal")
os.Remove(dbPath + "-shm")
_ = testFS().Delete(dbPath + "-wal")
_ = testFS().Delete(dbPath + "-shm")
s2, err := New(dbPath)
require.NoError(t, err)

View file

@ -23,7 +23,7 @@ go test ./...
go test -race ./...
# Run a single test by name
go test -v -run TestWatch_Good_SpecificKey ./...
go test -v -run TestEvents_Watch_Good_SpecificKey ./...
# Run tests with coverage
go test -cover ./...
@ -51,7 +51,7 @@ core go qa # fmt + vet + lint + test
## Test Patterns
Tests follow the `_Good`, `_Bad`, `_Ugly` suffix convention used across the Core Go ecosystem:
Tests follow the `Test<File>_<Function>_<Good|Bad|Ugly>` convention used across the Core Go ecosystem:
- `_Good` -- happy-path behaviour, including edge cases that should succeed
- `_Bad` -- expected error conditions (closed store, invalid input, quota exceeded)
@ -64,15 +64,15 @@ Tests are grouped into sections by the method under test, marked with comment ba
// Watch -- specific key
// ---------------------------------------------------------------------------
func TestWatch_Good_SpecificKey(t *testing.T) { ... }
func TestWatch_Good_WildcardKey(t *testing.T) { ... }
func TestEvents_Watch_Good_SpecificKey(t *testing.T) { ... }
func TestEvents_Watch_Good_WildcardKey(t *testing.T) { ... }
```
### In-Memory vs File-Backed Stores
Use `New(":memory:")` for all tests that do not require persistence. In-memory stores are faster and leave no filesystem artefacts.
Use `filepath.Join(t.TempDir(), "name.db")` for tests that verify WAL mode, persistence across open/close cycles, or concurrent writes. `t.TempDir()` is cleaned up automatically at the end of the test.
Use `core.Path(t.TempDir(), "name.db")` for tests that verify WAL mode, persistence across open/close cycles, or concurrent writes. `t.TempDir()` is cleaned up automatically at the end of the test.
### TTL Tests

View file

@ -19,9 +19,9 @@ The package has a single runtime dependency -- a pure-Go SQLite driver (`modernc
package main
import (
"fmt"
"time"
"dappco.re/go/core"
"dappco.re/go/core/store"
)
@ -36,20 +36,20 @@ func main() {
// Basic CRUD
st.Set("config", "theme", "dark")
val, _ := st.Get("config", "theme")
fmt.Println(val) // "dark"
core.Println(val) // "dark"
// TTL expiry -- key disappears after the duration elapses
st.SetWithTTL("session", "token", "abc123", 24*time.Hour)
// Fetch all keys in a group
all, _ := st.GetAll("config")
fmt.Println(all) // map[theme:dark]
core.Println(all) // map[theme:dark]
// Template rendering from stored values
st.Set("mail", "host", "smtp.example.com")
st.Set("mail", "port", "587")
out, _ := st.Render(`{{ .host }}:{{ .port }}`, "mail")
fmt.Println(out) // "smtp.example.com:587"
core.Println(out) // "smtp.example.com:587"
// Namespace isolation for multi-tenant use
sc, _ := store.NewScoped(st, "tenant-42")
@ -66,13 +66,13 @@ func main() {
defer st.Unwatch(w)
go func() {
for e := range w.Ch {
fmt.Printf("event: %s %s/%s\n", e.Type, e.Group, e.Key)
core.Println("event", e.Type, e.Group, e.Key)
}
}()
// Or register a synchronous callback
unreg := st.OnChange(func(e store.Event) {
fmt.Printf("changed: %s\n", e.Key)
core.Println("changed", e.Key)
})
defer unreg()
}
@ -112,7 +112,7 @@ Tests are organised in corresponding files:
|--------|---------|
| `github.com/stretchr/testify` | Assertion helpers (`assert`, `require`) for tests. |
There are no other direct dependencies. The package uses only the Go standard library (`database/sql`, `context`, `sync`, `time`, `text/template`, `iter`, `errors`, `fmt`, `strings`, `regexp`, `slices`, `sync/atomic`) beyond the SQLite driver.
There are no other direct dependencies. The package uses the Go standard library plus `dappco.re/go/core` helper primitives for error wrapping, string handling, and filesystem-safe path composition.
## Key Types

View file

@ -8,18 +8,23 @@ import (
)
// EventType describes the kind of store mutation that occurred.
// Usage example: `if event.Type == store.EventSet { return }`
type EventType int
const (
// EventSet indicates a key was created or updated.
// Usage example: `if event.Type == store.EventSet { return }`
EventSet EventType = iota
// EventDelete indicates a single key was removed.
// Usage example: `if event.Type == store.EventDelete { return }`
EventDelete
// EventDeleteGroup indicates all keys in a group were removed.
// Usage example: `if event.Type == store.EventDeleteGroup { return }`
EventDeleteGroup
)
// String returns a human-readable label for the event type.
// Usage example: `label := store.EventSet.String()`
func (t EventType) String() string {
switch t {
case EventSet:
@ -35,6 +40,7 @@ func (t EventType) String() string {
// Event describes a single store mutation. Key is empty for EventDeleteGroup.
// Value is only populated for EventSet.
// Usage example: `func handle(e store.Event) { _ = e.Group }`
type Event struct {
Type EventType
Group string
@ -45,6 +51,7 @@ type Event struct {
// Watcher receives events matching a group/key filter. Use Store.Watch to
// create one and Store.Unwatch to stop delivery.
// Usage example: `watcher := st.Watch("config", "*")`
type Watcher struct {
// Ch is the public read-only channel that consumers select on.
Ch <-chan Event
@ -70,6 +77,7 @@ const watcherBufSize = 16
// key. Use "*" as a wildcard: ("mygroup", "*") matches all keys in that group,
// ("*", "*") matches every mutation. The returned Watcher has a buffered
// channel (cap 16); events are dropped if the consumer falls behind.
// Usage example: `watcher := st.Watch("config", "*")`
func (s *Store) Watch(group, key string) *Watcher {
ch := make(chan Event, watcherBufSize)
w := &Watcher{
@ -89,6 +97,7 @@ func (s *Store) Watch(group, key string) *Watcher {
// Unwatch removes a watcher and closes its channel. Safe to call multiple
// times; subsequent calls are no-ops.
// Usage example: `st.Unwatch(watcher)`
func (s *Store) Unwatch(w *Watcher) {
s.mu.Lock()
defer s.mu.Unlock()
@ -106,6 +115,7 @@ func (s *Store) Unwatch(w *Watcher) {
// are called synchronously in the goroutine that performed the write, so the
// caller controls concurrency. Returns an unregister function; calling it stops
// future invocations.
// Usage example: `unreg := st.OnChange(func(e store.Event) {})`
//
// This is the integration point for go-ws and similar consumers:
//

View file

@ -15,7 +15,7 @@ import (
// Watch — specific key
// ---------------------------------------------------------------------------
func TestWatch_Good_SpecificKey(t *testing.T) {
func TestEvents_Watch_Good_SpecificKey(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -50,7 +50,7 @@ func TestWatch_Good_SpecificKey(t *testing.T) {
// Watch — wildcard key "*"
// ---------------------------------------------------------------------------
func TestWatch_Good_WildcardKey(t *testing.T) {
func TestEvents_Watch_Good_WildcardKey(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -70,7 +70,7 @@ func TestWatch_Good_WildcardKey(t *testing.T) {
// Watch — wildcard ("*", "*") matches everything
// ---------------------------------------------------------------------------
func TestWatch_Good_WildcardAll(t *testing.T) {
func TestEvents_Watch_Good_WildcardAll(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -94,7 +94,7 @@ func TestWatch_Good_WildcardAll(t *testing.T) {
// Unwatch — stops delivery, channel closed
// ---------------------------------------------------------------------------
func TestUnwatch_Good_StopsDelivery(t *testing.T) {
func TestEvents_Unwatch_Good_StopsDelivery(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -109,7 +109,7 @@ func TestUnwatch_Good_StopsDelivery(t *testing.T) {
require.NoError(t, s.Set("g", "k", "v"))
}
func TestUnwatch_Good_Idempotent(t *testing.T) {
func TestEvents_Unwatch_Good_Idempotent(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -124,7 +124,7 @@ func TestUnwatch_Good_Idempotent(t *testing.T) {
// Delete triggers event
// ---------------------------------------------------------------------------
func TestWatch_Good_DeleteEvent(t *testing.T) {
func TestEvents_Watch_Good_DeleteEvent(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -152,7 +152,7 @@ func TestWatch_Good_DeleteEvent(t *testing.T) {
// DeleteGroup triggers event
// ---------------------------------------------------------------------------
func TestWatch_Good_DeleteGroupEvent(t *testing.T) {
func TestEvents_Watch_Good_DeleteGroupEvent(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -182,7 +182,7 @@ func TestWatch_Good_DeleteGroupEvent(t *testing.T) {
// OnChange — callback fires on mutations
// ---------------------------------------------------------------------------
func TestOnChange_Good_Fires(t *testing.T) {
func TestEvents_OnChange_Good_Fires(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -210,7 +210,7 @@ func TestOnChange_Good_Fires(t *testing.T) {
// OnChange — unregister stops callback
// ---------------------------------------------------------------------------
func TestOnChange_Good_Unregister(t *testing.T) {
func TestEvents_OnChange_Good_Unregister(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -236,7 +236,7 @@ func TestOnChange_Good_Unregister(t *testing.T) {
// Buffer-full doesn't block the writer
// ---------------------------------------------------------------------------
func TestWatch_Good_BufferFullDoesNotBlock(t *testing.T) {
func TestEvents_Watch_Good_BufferFullDoesNotBlock(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -275,7 +275,7 @@ func TestWatch_Good_BufferFullDoesNotBlock(t *testing.T) {
// Multiple watchers on same key
// ---------------------------------------------------------------------------
func TestWatch_Good_MultipleWatchersSameKey(t *testing.T) {
func TestEvents_Watch_Good_MultipleWatchersSameKey(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -306,7 +306,7 @@ func TestWatch_Good_MultipleWatchersSameKey(t *testing.T) {
// Concurrent Watch/Unwatch during writes (race test)
// ---------------------------------------------------------------------------
func TestWatch_Good_ConcurrentWatchUnwatch(t *testing.T) {
func TestEvents_Watch_Good_ConcurrentWatchUnwatch(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -347,7 +347,7 @@ func TestWatch_Good_ConcurrentWatchUnwatch(t *testing.T) {
// ScopedStore events — prefixed group name
// ---------------------------------------------------------------------------
func TestWatch_Good_ScopedStoreEvents(t *testing.T) {
func TestEvents_Watch_Good_ScopedStoreEvents(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -375,7 +375,7 @@ func TestWatch_Good_ScopedStoreEvents(t *testing.T) {
// EventType.String()
// ---------------------------------------------------------------------------
func TestEventType_Good_String(t *testing.T) {
func TestEvents_EventType_Good_String(t *testing.T) {
assert.Equal(t, "set", EventSet.String())
assert.Equal(t, "delete", EventDelete.String())
assert.Equal(t, "delete_group", EventDeleteGroup.String())
@ -386,7 +386,7 @@ func TestEventType_Good_String(t *testing.T) {
// SetWithTTL emits events
// ---------------------------------------------------------------------------
func TestWatch_Good_SetWithTTLEmitsEvent(t *testing.T) {
func TestEvents_Watch_Good_SetWithTTLEmitsEvent(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()

1
go.mod
View file

@ -4,7 +4,6 @@ go 1.26.0
require (
dappco.re/go/core v0.8.0-alpha.1
dappco.re/go/core/log v0.1.0
github.com/stretchr/testify v1.11.1
modernc.org/sqlite v1.47.0
)

2
go.sum
View file

@ -1,7 +1,5 @@
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View file

@ -6,7 +6,6 @@ import (
"time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
// validNamespace matches alphanumeric characters and hyphens (non-empty).
@ -14,6 +13,7 @@ var validNamespace = regexp.MustCompile(`^[a-zA-Z0-9-]+$`)
// QuotaConfig defines optional limits for a ScopedStore namespace.
// Zero values mean unlimited.
// Usage example: `quota := store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}`
type QuotaConfig struct {
MaxKeys int // maximum total keys across all groups in the namespace
MaxGroups int // maximum distinct groups in the namespace
@ -21,6 +21,7 @@ type QuotaConfig struct {
// ScopedStore wraps a *Store and auto-prefixes all group names with a
// namespace to prevent key collisions across tenants.
// Usage example: `sc, _ := store.NewScoped(st, "tenant-a")`
type ScopedStore struct {
store *Store
namespace string
@ -30,9 +31,10 @@ type ScopedStore struct {
// NewScoped creates a ScopedStore that prefixes all groups with the given
// namespace. The namespace must be non-empty and contain only alphanumeric
// characters and hyphens.
// Usage example: `sc, _ := store.NewScoped(st, "tenant-a")`
func NewScoped(store *Store, namespace string) (*ScopedStore, error) {
if !validNamespace.MatchString(namespace) {
return nil, coreerr.E("store.NewScoped", core.Sprintf("namespace %q is invalid (must be non-empty, alphanumeric + hyphens)", namespace), nil)
return nil, core.E("store.NewScoped", core.Sprintf("namespace %q is invalid (must be non-empty, alphanumeric + hyphens)", namespace), nil)
}
return &ScopedStore{store: store, namespace: namespace}, nil
}
@ -40,6 +42,7 @@ func NewScoped(store *Store, namespace string) (*ScopedStore, error) {
// NewScopedWithQuota creates a ScopedStore with quota enforcement. Quotas are
// checked on Set and SetWithTTL before inserting new keys or creating new
// groups.
// Usage example: `sc, _ := store.NewScopedWithQuota(st, "tenant-a", quota)`
func NewScopedWithQuota(store *Store, namespace string, quota QuotaConfig) (*ScopedStore, error) {
s, err := NewScoped(store, namespace)
if err != nil {
@ -55,17 +58,20 @@ func (s *ScopedStore) prefix(group string) string {
}
// Namespace returns the namespace string for this scoped store.
// Usage example: `name := sc.Namespace()`
func (s *ScopedStore) Namespace() string {
return s.namespace
}
// Get retrieves a value by group and key within the namespace.
// Usage example: `value, err := sc.Get("config", "theme")`
func (s *ScopedStore) Get(group, key string) (string, error) {
return s.store.Get(s.prefix(group), key)
}
// Set stores a value by group and key within the namespace. If quotas are
// configured, they are checked before inserting new keys or groups.
// Usage example: `err := sc.Set("config", "theme", "dark")`
func (s *ScopedStore) Set(group, key, value string) error {
if err := s.checkQuota(group, key); err != nil {
return err
@ -75,6 +81,7 @@ func (s *ScopedStore) Set(group, key, value string) error {
// SetWithTTL stores a value with a time-to-live within the namespace. Quota
// checks are applied for new keys and groups.
// Usage example: `err := sc.SetWithTTL("sessions", "token", "abc", time.Hour)`
func (s *ScopedStore) SetWithTTL(group, key, value string, ttl time.Duration) error {
if err := s.checkQuota(group, key); err != nil {
return err
@ -83,34 +90,40 @@ func (s *ScopedStore) SetWithTTL(group, key, value string, ttl time.Duration) er
}
// Delete removes a single key from a group within the namespace.
// Usage example: `err := sc.Delete("config", "theme")`
func (s *ScopedStore) Delete(group, key string) error {
return s.store.Delete(s.prefix(group), key)
}
// DeleteGroup removes all keys in a group within the namespace.
// Usage example: `err := sc.DeleteGroup("cache")`
func (s *ScopedStore) DeleteGroup(group string) error {
return s.store.DeleteGroup(s.prefix(group))
}
// GetAll returns all non-expired key-value pairs in a group within the
// namespace.
// Usage example: `all, err := sc.GetAll("config")`
func (s *ScopedStore) GetAll(group string) (map[string]string, error) {
return s.store.GetAll(s.prefix(group))
}
// All returns an iterator over all non-expired key-value pairs in a group
// within the namespace.
// Usage example: `for kv, err := range sc.All("config") { _ = kv; _ = err }`
func (s *ScopedStore) All(group string) iter.Seq2[KV, error] {
return s.store.All(s.prefix(group))
}
// Count returns the number of non-expired keys in a group within the namespace.
// Usage example: `n, err := sc.Count("config")`
func (s *ScopedStore) Count(group string) (int, error) {
return s.store.Count(s.prefix(group))
}
// Render loads all non-expired key-value pairs from a namespaced group and
// renders a Go template.
// Usage example: `out, err := sc.Render("Hello {{ .name }}", "user")`
func (s *ScopedStore) Render(tmplStr, group string) (string, error) {
return s.store.Render(tmplStr, s.prefix(group))
}
@ -134,17 +147,17 @@ func (s *ScopedStore) checkQuota(group, key string) error {
}
if !core.Is(err, ErrNotFound) {
// A database error occurred, not just a "not found" result.
return coreerr.E("store.ScopedStore", "quota check", err)
return core.E("store.ScopedStore", "quota check", err)
}
// Check MaxKeys quota.
if s.quota.MaxKeys > 0 {
count, err := s.store.CountAll(nsPrefix)
if err != nil {
return coreerr.E("store.ScopedStore", "quota check", err)
return core.E("store.ScopedStore", "quota check", err)
}
if count >= s.quota.MaxKeys {
return coreerr.E("store.ScopedStore", core.Sprintf("key limit (%d)", s.quota.MaxKeys), ErrQuotaExceeded)
return core.E("store.ScopedStore", core.Sprintf("key limit (%d)", s.quota.MaxKeys), ErrQuotaExceeded)
}
}
@ -152,19 +165,19 @@ func (s *ScopedStore) checkQuota(group, key string) error {
if s.quota.MaxGroups > 0 {
groupCount, err := s.store.Count(prefixedGroup)
if err != nil {
return coreerr.E("store.ScopedStore", "quota check", err)
return core.E("store.ScopedStore", "quota check", err)
}
if groupCount == 0 {
// This group is new — check if adding it would exceed the group limit.
count := 0
for _, err := range s.store.GroupsSeq(nsPrefix) {
if err != nil {
return coreerr.E("store.ScopedStore", "quota check", err)
return core.E("store.ScopedStore", "quota check", err)
}
count++
}
if count >= s.quota.MaxGroups {
return coreerr.E("store.ScopedStore", core.Sprintf("group limit (%d)", s.quota.MaxGroups), ErrQuotaExceeded)
return core.E("store.ScopedStore", core.Sprintf("group limit (%d)", s.quota.MaxGroups), ErrQuotaExceeded)
}
}
}

View file

@ -13,7 +13,7 @@ import (
// NewScoped — constructor validation
// ---------------------------------------------------------------------------
func TestNewScoped_Good(t *testing.T) {
func TestScope_NewScoped_Good(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -23,7 +23,7 @@ func TestNewScoped_Good(t *testing.T) {
assert.Equal(t, "tenant-1", sc.Namespace())
}
func TestNewScoped_Good_AlphanumericHyphens(t *testing.T) {
func TestScope_NewScoped_Good_AlphanumericHyphens(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -35,7 +35,7 @@ func TestNewScoped_Good_AlphanumericHyphens(t *testing.T) {
}
}
func TestNewScoped_Bad_Empty(t *testing.T) {
func TestScope_NewScoped_Bad_Empty(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -44,7 +44,7 @@ func TestNewScoped_Bad_Empty(t *testing.T) {
assert.Contains(t, err.Error(), "invalid")
}
func TestNewScoped_Bad_InvalidChars(t *testing.T) {
func TestScope_NewScoped_Bad_InvalidChars(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -59,7 +59,7 @@ func TestNewScoped_Bad_InvalidChars(t *testing.T) {
// ScopedStore — basic CRUD
// ---------------------------------------------------------------------------
func TestScopedStore_Good_SetGet(t *testing.T) {
func TestScope_ScopedStore_Good_SetGet(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -71,7 +71,7 @@ func TestScopedStore_Good_SetGet(t *testing.T) {
assert.Equal(t, "dark", val)
}
func TestScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) {
func TestScope_ScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -88,7 +88,7 @@ func TestScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) {
assert.True(t, core.Is(err, ErrNotFound))
}
func TestScopedStore_Good_NamespaceIsolation(t *testing.T) {
func TestScope_ScopedStore_Good_NamespaceIsolation(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -107,7 +107,7 @@ func TestScopedStore_Good_NamespaceIsolation(t *testing.T) {
assert.Equal(t, "red", vb)
}
func TestScopedStore_Good_Delete(t *testing.T) {
func TestScope_ScopedStore_Good_Delete(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -119,7 +119,7 @@ func TestScopedStore_Good_Delete(t *testing.T) {
assert.True(t, core.Is(err, ErrNotFound))
}
func TestScopedStore_Good_DeleteGroup(t *testing.T) {
func TestScope_ScopedStore_Good_DeleteGroup(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -133,7 +133,7 @@ func TestScopedStore_Good_DeleteGroup(t *testing.T) {
assert.Equal(t, 0, n)
}
func TestScopedStore_Good_GetAll(t *testing.T) {
func TestScope_ScopedStore_Good_GetAll(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -153,7 +153,7 @@ func TestScopedStore_Good_GetAll(t *testing.T) {
assert.Equal(t, map[string]string{"z": "3"}, allB)
}
func TestScopedStore_Good_Count(t *testing.T) {
func TestScope_ScopedStore_Good_Count(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -166,7 +166,7 @@ func TestScopedStore_Good_Count(t *testing.T) {
assert.Equal(t, 2, n)
}
func TestScopedStore_Good_SetWithTTL(t *testing.T) {
func TestScope_ScopedStore_Good_SetWithTTL(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -178,7 +178,7 @@ func TestScopedStore_Good_SetWithTTL(t *testing.T) {
assert.Equal(t, "v", val)
}
func TestScopedStore_Good_SetWithTTL_Expires(t *testing.T) {
func TestScope_ScopedStore_Good_SetWithTTL_Expires(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -190,7 +190,7 @@ func TestScopedStore_Good_SetWithTTL_Expires(t *testing.T) {
assert.True(t, core.Is(err, ErrNotFound))
}
func TestScopedStore_Good_Render(t *testing.T) {
func TestScope_ScopedStore_Good_Render(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -206,7 +206,7 @@ func TestScopedStore_Good_Render(t *testing.T) {
// Quota enforcement — MaxKeys
// ---------------------------------------------------------------------------
func TestQuota_Good_MaxKeys(t *testing.T) {
func TestScope_Quota_Good_MaxKeys(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -224,7 +224,7 @@ func TestQuota_Good_MaxKeys(t *testing.T) {
assert.True(t, core.Is(err, ErrQuotaExceeded), "expected ErrQuotaExceeded, got: %v", err)
}
func TestQuota_Good_MaxKeys_AcrossGroups(t *testing.T) {
func TestScope_Quota_Good_MaxKeys_AcrossGroups(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -239,7 +239,7 @@ func TestQuota_Good_MaxKeys_AcrossGroups(t *testing.T) {
assert.True(t, core.Is(err, ErrQuotaExceeded))
}
func TestQuota_Good_UpsertDoesNotCount(t *testing.T) {
func TestScope_Quota_Good_UpsertDoesNotCount(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -257,7 +257,7 @@ func TestQuota_Good_UpsertDoesNotCount(t *testing.T) {
assert.Equal(t, "updated", val)
}
func TestQuota_Good_DeleteAndReInsert(t *testing.T) {
func TestScope_Quota_Good_DeleteAndReInsert(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -272,7 +272,7 @@ func TestQuota_Good_DeleteAndReInsert(t *testing.T) {
require.NoError(t, sc.Set("g", "d", "4"))
}
func TestQuota_Good_ZeroMeansUnlimited(t *testing.T) {
func TestScope_Quota_Good_ZeroMeansUnlimited(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -284,7 +284,7 @@ func TestQuota_Good_ZeroMeansUnlimited(t *testing.T) {
}
}
func TestQuota_Good_ExpiredKeysExcluded(t *testing.T) {
func TestScope_Quota_Good_ExpiredKeysExcluded(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -306,7 +306,7 @@ func TestQuota_Good_ExpiredKeysExcluded(t *testing.T) {
assert.True(t, core.Is(err, ErrQuotaExceeded))
}
func TestQuota_Good_SetWithTTL_Enforced(t *testing.T) {
func TestScope_Quota_Good_SetWithTTL_Enforced(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -323,7 +323,7 @@ func TestQuota_Good_SetWithTTL_Enforced(t *testing.T) {
// Quota enforcement — MaxGroups
// ---------------------------------------------------------------------------
func TestQuota_Good_MaxGroups(t *testing.T) {
func TestScope_Quota_Good_MaxGroups(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -339,7 +339,7 @@ func TestQuota_Good_MaxGroups(t *testing.T) {
assert.True(t, core.Is(err, ErrQuotaExceeded))
}
func TestQuota_Good_MaxGroups_ExistingGroupOK(t *testing.T) {
func TestScope_Quota_Good_MaxGroups_ExistingGroupOK(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -353,7 +353,7 @@ func TestQuota_Good_MaxGroups_ExistingGroupOK(t *testing.T) {
require.NoError(t, sc.Set("g2", "d", "4"))
}
func TestQuota_Good_MaxGroups_DeleteAndRecreate(t *testing.T) {
func TestScope_Quota_Good_MaxGroups_DeleteAndRecreate(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -367,7 +367,7 @@ func TestQuota_Good_MaxGroups_DeleteAndRecreate(t *testing.T) {
require.NoError(t, sc.Set("g3", "k", "v"))
}
func TestQuota_Good_MaxGroups_ZeroUnlimited(t *testing.T) {
func TestScope_Quota_Good_MaxGroups_ZeroUnlimited(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -378,7 +378,7 @@ func TestQuota_Good_MaxGroups_ZeroUnlimited(t *testing.T) {
}
}
func TestQuota_Good_MaxGroups_ExpiredGroupExcluded(t *testing.T) {
func TestScope_Quota_Good_MaxGroups_ExpiredGroupExcluded(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -394,7 +394,7 @@ func TestQuota_Good_MaxGroups_ExpiredGroupExcluded(t *testing.T) {
require.NoError(t, sc.Set("g3", "k", "v"))
}
func TestQuota_Good_BothLimits(t *testing.T) {
func TestScope_Quota_Good_BothLimits(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -411,7 +411,7 @@ func TestQuota_Good_BothLimits(t *testing.T) {
require.NoError(t, sc.Set("g1", "d", "4"))
}
func TestQuota_Good_DoesNotAffectOtherNamespaces(t *testing.T) {
func TestScope_Quota_Good_DoesNotAffectOtherNamespaces(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -436,7 +436,7 @@ func TestQuota_Good_DoesNotAffectOtherNamespaces(t *testing.T) {
// CountAll
// ---------------------------------------------------------------------------
func TestCountAll_Good_WithPrefix(t *testing.T) {
func TestScope_CountAll_Good_WithPrefix(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -454,7 +454,7 @@ func TestCountAll_Good_WithPrefix(t *testing.T) {
assert.Equal(t, 1, n)
}
func TestCountAll_Good_WithPrefix_Wildcards(t *testing.T) {
func TestScope_CountAll_Good_WithPrefix_Wildcards(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -478,7 +478,7 @@ func TestCountAll_Good_WithPrefix_Wildcards(t *testing.T) {
assert.Equal(t, 1, n)
}
func TestCountAll_Good_EmptyPrefix(t *testing.T) {
func TestScope_CountAll_Good_EmptyPrefix(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -490,7 +490,7 @@ func TestCountAll_Good_EmptyPrefix(t *testing.T) {
assert.Equal(t, 2, n)
}
func TestCountAll_Good_ExcludesExpired(t *testing.T) {
func TestScope_CountAll_Good_ExcludesExpired(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -503,7 +503,7 @@ func TestCountAll_Good_ExcludesExpired(t *testing.T) {
assert.Equal(t, 1, n, "expired keys should not be counted")
}
func TestCountAll_Good_Empty(t *testing.T) {
func TestScope_CountAll_Good_Empty(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -512,7 +512,7 @@ func TestCountAll_Good_Empty(t *testing.T) {
assert.Equal(t, 0, n)
}
func TestCountAll_Bad_ClosedStore(t *testing.T) {
func TestScope_CountAll_Bad_ClosedStore(t *testing.T) {
s, _ := New(":memory:")
s.Close()
@ -524,7 +524,7 @@ func TestCountAll_Bad_ClosedStore(t *testing.T) {
// Groups
// ---------------------------------------------------------------------------
func TestGroups_Good_WithPrefix(t *testing.T) {
func TestScope_Groups_Good_WithPrefix(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -540,7 +540,7 @@ func TestGroups_Good_WithPrefix(t *testing.T) {
assert.Contains(t, groups, "ns-a:g2")
}
func TestGroups_Good_EmptyPrefix(t *testing.T) {
func TestScope_Groups_Good_EmptyPrefix(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -553,7 +553,7 @@ func TestGroups_Good_EmptyPrefix(t *testing.T) {
assert.Len(t, groups, 3)
}
func TestGroups_Good_Distinct(t *testing.T) {
func TestScope_Groups_Good_Distinct(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -568,7 +568,7 @@ func TestGroups_Good_Distinct(t *testing.T) {
assert.Equal(t, "g1", groups[0])
}
func TestGroups_Good_ExcludesExpired(t *testing.T) {
func TestScope_Groups_Good_ExcludesExpired(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -582,7 +582,7 @@ func TestGroups_Good_ExcludesExpired(t *testing.T) {
assert.Equal(t, "ns:g1", groups[0])
}
func TestGroups_Good_Empty(t *testing.T) {
func TestScope_Groups_Good_Empty(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -591,7 +591,7 @@ func TestGroups_Good_Empty(t *testing.T) {
assert.Empty(t, groups)
}
func TestGroups_Bad_ClosedStore(t *testing.T) {
func TestScope_Groups_Bad_ClosedStore(t *testing.T) {
s, _ := New(":memory:")
s.Close()

123
store.go
View file

@ -4,25 +4,25 @@ import (
"context"
"database/sql"
"iter"
"strings"
"sync"
"text/template"
"time"
"unicode"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
_ "modernc.org/sqlite"
)
// ErrNotFound is returned when a key does not exist in the store.
// Use errors.Is(err, ErrNotFound) to test for it.
var ErrNotFound = coreerr.E("store", "not found", nil)
// Usage example: `if core.Is(err, store.ErrNotFound) { return }`
var ErrNotFound = core.E("store", "not found", nil)
// ErrQuotaExceeded is returned when a namespace quota limit is reached.
// Use errors.Is(err, ErrQuotaExceeded) to test for it.
var ErrQuotaExceeded = coreerr.E("store", "quota exceeded", nil)
// Usage example: `if core.Is(err, store.ErrQuotaExceeded) { return }`
var ErrQuotaExceeded = core.E("store", "quota exceeded", nil)
// Store is a group-namespaced key-value store backed by SQLite.
// Usage example: `st, _ := store.New(":memory:")`
type Store struct {
db *sql.DB
cancel context.CancelFunc
@ -37,10 +37,11 @@ type Store struct {
}
// New creates a Store at the given SQLite path. Use ":memory:" for tests.
// Usage example: `st, _ := store.New("/tmp/config.db")`
func New(dbPath string) (*Store, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, coreerr.E("store.New", "open", err)
return nil, core.E("store.New", "open", err)
}
// Serialise all access through a single connection. SQLite only supports
// one writer at a time; using a pool causes SQLITE_BUSY under contention
@ -49,11 +50,11 @@ func New(dbPath string) (*Store, error) {
db.SetMaxOpenConns(1)
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, coreerr.E("store.New", "WAL", err)
return nil, core.E("store.New", "WAL", err)
}
if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
db.Close()
return nil, coreerr.E("store.New", "busy_timeout", err)
return nil, core.E("store.New", "busy_timeout", err)
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS kv (
grp TEXT NOT NULL,
@ -63,14 +64,14 @@ func New(dbPath string) (*Store, error) {
PRIMARY KEY (grp, key)
)`); err != nil {
db.Close()
return nil, coreerr.E("store.New", "schema", err)
return nil, core.E("store.New", "schema", err)
}
// Ensure the expires_at column exists for databases created before TTL support.
if _, err := db.Exec("ALTER TABLE kv ADD COLUMN expires_at INTEGER"); err != nil {
// SQLite returns "duplicate column name" if it already exists.
if !core.Contains(err.Error(), "duplicate column name") {
db.Close()
return nil, coreerr.E("store.New", "migration", err)
return nil, core.E("store.New", "migration", err)
}
}
@ -81,6 +82,7 @@ func New(dbPath string) (*Store, error) {
}
// Close stops the background purge goroutine and closes the underlying database.
// Usage example: `defer st.Close()`
func (s *Store) Close() error {
s.cancel()
s.wg.Wait()
@ -89,6 +91,7 @@ func (s *Store) Close() error {
// Get retrieves a value by group and key. Expired keys are lazily deleted and
// treated as not found.
// Usage example: `value, err := st.Get("config", "theme")`
func (s *Store) Get(group, key string) (string, error) {
var val string
var expiresAt sql.NullInt64
@ -97,10 +100,10 @@ func (s *Store) Get(group, key string) (string, error) {
group, key,
).Scan(&val, &expiresAt)
if err == sql.ErrNoRows {
return "", coreerr.E("store.Get", group+"/"+key, ErrNotFound)
return "", core.E("store.Get", core.Concat(group, "/", key), ErrNotFound)
}
if err != nil {
return "", coreerr.E("store.Get", "query", err)
return "", core.E("store.Get", "query", err)
}
if expiresAt.Valid && expiresAt.Int64 <= time.Now().UnixMilli() {
// Lazily delete the expired entry.
@ -109,13 +112,14 @@ func (s *Store) Get(group, key string) (string, error) {
// For now, we wrap the error to provide context if the delete fails
// for reasons other than "already deleted".
}
return "", coreerr.E("store.Get", group+"/"+key, ErrNotFound)
return "", core.E("store.Get", core.Concat(group, "/", key), ErrNotFound)
}
return val, nil
}
// Set stores a value by group and key, overwriting if exists. The key has no
// expiry (it persists until explicitly deleted).
// Usage example: `err := st.Set("config", "theme", "dark")`
func (s *Store) Set(group, key, value string) error {
_, err := s.db.Exec(
`INSERT INTO kv (grp, key, value, expires_at) VALUES (?, ?, ?, NULL)
@ -123,7 +127,7 @@ func (s *Store) Set(group, key, value string) error {
group, key, value,
)
if err != nil {
return coreerr.E("store.Set", "exec", err)
return core.E("store.Set", "exec", err)
}
s.notify(Event{Type: EventSet, Group: group, Key: key, Value: value, Timestamp: time.Now()})
return nil
@ -132,6 +136,7 @@ func (s *Store) Set(group, key, value string) error {
// SetWithTTL stores a value that expires after the given duration. After expiry
// the key is lazily removed on the next Get and periodically by a background
// purge goroutine.
// Usage example: `err := st.SetWithTTL("session", "token", "abc", time.Hour)`
func (s *Store) SetWithTTL(group, key, value string, ttl time.Duration) error {
expiresAt := time.Now().Add(ttl).UnixMilli()
_, err := s.db.Exec(
@ -140,23 +145,25 @@ func (s *Store) SetWithTTL(group, key, value string, ttl time.Duration) error {
group, key, value, expiresAt,
)
if err != nil {
return coreerr.E("store.SetWithTTL", "exec", err)
return core.E("store.SetWithTTL", "exec", err)
}
s.notify(Event{Type: EventSet, Group: group, Key: key, Value: value, Timestamp: time.Now()})
return nil
}
// Delete removes a single key from a group.
// Usage example: `err := st.Delete("config", "theme")`
func (s *Store) Delete(group, key string) error {
_, err := s.db.Exec("DELETE FROM kv WHERE grp = ? AND key = ?", group, key)
if err != nil {
return coreerr.E("store.Delete", "exec", err)
return core.E("store.Delete", "exec", err)
}
s.notify(Event{Type: EventDelete, Group: group, Key: key, Timestamp: time.Now()})
return nil
}
// Count returns the number of non-expired keys in a group.
// Usage example: `n, err := st.Count("config")`
func (s *Store) Count(group string) (int, error) {
var n int
err := s.db.QueryRow(
@ -164,32 +171,35 @@ func (s *Store) Count(group string) (int, error) {
group, time.Now().UnixMilli(),
).Scan(&n)
if err != nil {
return 0, coreerr.E("store.Count", "query", err)
return 0, core.E("store.Count", "query", err)
}
return n, nil
}
// DeleteGroup removes all keys in a group.
// Usage example: `err := st.DeleteGroup("cache")`
func (s *Store) DeleteGroup(group string) error {
_, err := s.db.Exec("DELETE FROM kv WHERE grp = ?", group)
if err != nil {
return coreerr.E("store.DeleteGroup", "exec", err)
return core.E("store.DeleteGroup", "exec", err)
}
s.notify(Event{Type: EventDeleteGroup, Group: group, Timestamp: time.Now()})
return nil
}
// KV represents a key-value pair.
// Usage example: `for kv, err := range st.All("config") { _ = kv }`
type KV struct {
Key, Value string
}
// GetAll returns all non-expired key-value pairs in a group.
// Usage example: `all, err := st.GetAll("config")`
func (s *Store) GetAll(group string) (map[string]string, error) {
result := make(map[string]string)
for kv, err := range s.All(group) {
if err != nil {
return nil, coreerr.E("store.GetAll", "iterate", err)
return nil, core.E("store.GetAll", "iterate", err)
}
result[kv.Key] = kv.Value
}
@ -197,6 +207,7 @@ func (s *Store) GetAll(group string) (map[string]string, error) {
}
// All returns an iterator over all non-expired key-value pairs in a group.
// Usage example: `for kv, err := range st.All("config") { _ = kv; _ = err }`
func (s *Store) All(group string) iter.Seq2[KV, error] {
return func(yield func(KV, error) bool) {
rows, err := s.db.Query(
@ -204,7 +215,7 @@ func (s *Store) All(group string) iter.Seq2[KV, error] {
group, time.Now().UnixMilli(),
)
if err != nil {
yield(KV{}, err)
yield(KV{}, core.E("store.All", "query", err))
return
}
defer rows.Close()
@ -212,7 +223,7 @@ func (s *Store) All(group string) iter.Seq2[KV, error] {
for rows.Next() {
var kv KV
if err := rows.Scan(&kv.Key, &kv.Value); err != nil {
if !yield(KV{}, coreerr.E("store.All", "scan", err)) {
if !yield(KV{}, core.E("store.All", "scan", err)) {
return
}
continue
@ -222,55 +233,59 @@ func (s *Store) All(group string) iter.Seq2[KV, error] {
}
}
if err := rows.Err(); err != nil {
yield(KV{}, coreerr.E("store.All", "rows", err))
yield(KV{}, core.E("store.All", "rows", err))
}
}
}
// GetSplit retrieves a value and returns an iterator over its parts, split by
// sep.
// Usage example: `parts, _ := st.GetSplit("config", "hosts", ",")`
func (s *Store) GetSplit(group, key, sep string) (iter.Seq[string], error) {
val, err := s.Get(group, key)
if err != nil {
return nil, err
}
return strings.SplitSeq(val, sep), nil
return splitSeq(val, sep), nil
}
// GetFields retrieves a value and returns an iterator over its parts, split by
// whitespace.
// Usage example: `fields, _ := st.GetFields("config", "flags")`
func (s *Store) GetFields(group, key string) (iter.Seq[string], error) {
val, err := s.Get(group, key)
if err != nil {
return nil, err
}
return strings.FieldsSeq(val), nil
return fieldsSeq(val), nil
}
// Render loads all non-expired key-value pairs from a group and renders a Go
// template.
// Usage example: `out, err := st.Render("Hello {{ .name }}", "user")`
func (s *Store) Render(tmplStr, group string) (string, error) {
vars := make(map[string]string)
for kv, err := range s.All(group) {
if err != nil {
return "", coreerr.E("store.Render", "iterate", err)
return "", core.E("store.Render", "iterate", err)
}
vars[kv.Key] = kv.Value
}
tmpl, err := template.New("render").Parse(tmplStr)
if err != nil {
return "", coreerr.E("store.Render", "parse", err)
return "", core.E("store.Render", "parse", err)
}
var b strings.Builder
if err := tmpl.Execute(&b, vars); err != nil {
return "", coreerr.E("store.Render", "exec", err)
b := core.NewBuilder()
if err := tmpl.Execute(b, vars); err != nil {
return "", core.E("store.Render", "exec", err)
}
return b.String(), nil
}
// CountAll returns the total number of non-expired keys across all groups whose
// name starts with the given prefix. Pass an empty string to count everything.
// Usage example: `n, err := st.CountAll("tenant-a:")`
func (s *Store) CountAll(prefix string) (int, error) {
var n int
var err error
@ -286,13 +301,14 @@ func (s *Store) CountAll(prefix string) (int, error) {
).Scan(&n)
}
if err != nil {
return 0, coreerr.E("store.CountAll", "query", err)
return 0, core.E("store.CountAll", "query", err)
}
return n, nil
}
// Groups returns the distinct group names of all non-expired keys. If prefix is
// non-empty, only groups starting with that prefix are returned.
// Usage example: `groups, err := st.Groups("tenant-a:")`
func (s *Store) Groups(prefix string) ([]string, error) {
var groups []string
for g, err := range s.GroupsSeq(prefix) {
@ -306,6 +322,7 @@ func (s *Store) Groups(prefix string) ([]string, error) {
// GroupsSeq returns an iterator over the distinct group names of all
// non-expired keys.
// Usage example: `for group, err := range st.GroupsSeq("tenant-a:") { _ = group }`
func (s *Store) GroupsSeq(prefix string) iter.Seq2[string, error] {
return func(yield func(string, error) bool) {
var rows *sql.Rows
@ -323,7 +340,7 @@ func (s *Store) GroupsSeq(prefix string) iter.Seq2[string, error] {
)
}
if err != nil {
yield("", coreerr.E("store.Groups", "query", err))
yield("", core.E("store.Groups", "query", err))
return
}
defer rows.Close()
@ -331,7 +348,7 @@ func (s *Store) GroupsSeq(prefix string) iter.Seq2[string, error] {
for rows.Next() {
var g string
if err := rows.Scan(&g); err != nil {
if !yield("", coreerr.E("store.Groups", "scan", err)) {
if !yield("", core.E("store.Groups", "scan", err)) {
return
}
continue
@ -341,7 +358,7 @@ func (s *Store) GroupsSeq(prefix string) iter.Seq2[string, error] {
}
}
if err := rows.Err(); err != nil {
yield("", coreerr.E("store.Groups", "rows", err))
yield("", core.E("store.Groups", "rows", err))
}
}
}
@ -356,11 +373,12 @@ func escapeLike(s string) string {
// PurgeExpired deletes all expired keys across all groups. Returns the number
// of rows removed.
// Usage example: `removed, err := st.PurgeExpired()`
func (s *Store) PurgeExpired() (int64, error) {
res, err := s.db.Exec("DELETE FROM kv WHERE expires_at IS NOT NULL AND expires_at <= ?",
time.Now().UnixMilli())
if err != nil {
return 0, coreerr.E("store.PurgeExpired", "exec", err)
return 0, core.E("store.PurgeExpired", "exec", err)
}
return res.RowsAffected()
}
@ -386,3 +404,38 @@ func (s *Store) startPurge(ctx context.Context) {
}
})
}
// splitSeq preserves the iter.Seq API without importing strings directly.
func splitSeq(value, sep string) iter.Seq[string] {
return func(yield func(string) bool) {
for _, part := range core.Split(value, sep) {
if !yield(part) {
return
}
}
}
}
// fieldsSeq yields whitespace-delimited fields without importing strings.
func fieldsSeq(value string) iter.Seq[string] {
return func(yield func(string) bool) {
start := -1
for i, r := range value {
if unicode.IsSpace(r) {
if start >= 0 {
if !yield(value[start:i]) {
return
}
start = -1
}
continue
}
if start < 0 {
start = i
}
}
if start >= 0 {
yield(value[start:])
}
}
}

View file

@ -3,9 +3,8 @@ package store
import (
"context"
"database/sql"
"os"
"strings"
"sync"
"syscall"
"testing"
"time"
@ -18,15 +17,15 @@ import (
// New
// ---------------------------------------------------------------------------
func TestNew_Good_Memory(t *testing.T) {
func TestStore_New_Good_Memory(t *testing.T) {
s, err := New(":memory:")
require.NoError(t, err)
require.NotNil(t, s)
defer s.Close()
}
func TestNew_Good_FileBacked(t *testing.T) {
dbPath := core.JoinPath(t.TempDir(), "test.db")
func TestStore_New_Good_FileBacked(t *testing.T) {
dbPath := testPath(t, "test.db")
s, err := New(dbPath)
require.NoError(t, err)
require.NotNil(t, s)
@ -45,7 +44,7 @@ func TestNew_Good_FileBacked(t *testing.T) {
assert.Equal(t, "v", val)
}
func TestNew_Bad_InvalidPath(t *testing.T) {
func TestStore_New_Bad_InvalidPath(t *testing.T) {
// A path under a non-existent directory should fail at the WAL pragma step
// because sql.Open is lazy and only validates on first use.
_, err := New("/no/such/directory/test.db")
@ -53,21 +52,20 @@ func TestNew_Bad_InvalidPath(t *testing.T) {
assert.Contains(t, err.Error(), "store.New")
}
func TestNew_Bad_CorruptFile(t *testing.T) {
func TestStore_New_Bad_CorruptFile(t *testing.T) {
// A file that exists but is not a valid SQLite database should fail.
dir := t.TempDir()
dbPath := core.JoinPath(dir, "corrupt.db")
require.NoError(t, os.WriteFile(dbPath, []byte("not a sqlite database"), 0644))
dbPath := testPath(t, "corrupt.db")
requireCoreOK(t, testFS().Write(dbPath, "not a sqlite database"))
_, err := New(dbPath)
require.Error(t, err)
assert.Contains(t, err.Error(), "store.New")
}
func TestNew_Bad_ReadOnlyDir(t *testing.T) {
func TestStore_New_Bad_ReadOnlyDir(t *testing.T) {
// A path in a read-only directory should fail when SQLite tries to create the WAL file.
dir := t.TempDir()
dbPath := core.JoinPath(dir, "readonly.db")
dbPath := core.Path(dir, "readonly.db")
// Create a valid DB first, then make the directory read-only.
s, err := New(dbPath)
@ -75,10 +73,10 @@ func TestNew_Bad_ReadOnlyDir(t *testing.T) {
require.NoError(t, s.Close())
// Remove WAL/SHM files and make directory read-only.
os.Remove(dbPath + "-wal")
os.Remove(dbPath + "-shm")
require.NoError(t, os.Chmod(dir, 0555))
defer os.Chmod(dir, 0755) // restore for cleanup
_ = testFS().Delete(dbPath + "-wal")
_ = testFS().Delete(dbPath + "-shm")
require.NoError(t, syscall.Chmod(dir, 0555))
defer func() { _ = syscall.Chmod(dir, 0755) }() // restore for cleanup
_, err = New(dbPath)
// May or may not fail depending on OS/filesystem — just exercise the code path.
@ -87,8 +85,8 @@ func TestNew_Bad_ReadOnlyDir(t *testing.T) {
}
}
func TestNew_Good_WALMode(t *testing.T) {
dbPath := core.JoinPath(t.TempDir(), "wal.db")
func TestStore_New_Good_WALMode(t *testing.T) {
dbPath := testPath(t, "wal.db")
s, err := New(dbPath)
require.NoError(t, err)
defer s.Close()
@ -103,7 +101,7 @@ func TestNew_Good_WALMode(t *testing.T) {
// Set / Get — core CRUD
// ---------------------------------------------------------------------------
func TestSetGet_Good(t *testing.T) {
func TestStore_SetGet_Good(t *testing.T) {
s, err := New(":memory:")
require.NoError(t, err)
defer s.Close()
@ -116,7 +114,7 @@ func TestSetGet_Good(t *testing.T) {
assert.Equal(t, "dark", val)
}
func TestSet_Good_Upsert(t *testing.T) {
func TestStore_Set_Good_Upsert(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -132,7 +130,7 @@ func TestSet_Good_Upsert(t *testing.T) {
assert.Equal(t, 1, n, "upsert should not duplicate keys")
}
func TestGet_Bad_NotFound(t *testing.T) {
func TestStore_Get_Bad_NotFound(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -141,7 +139,7 @@ func TestGet_Bad_NotFound(t *testing.T) {
assert.True(t, core.Is(err, ErrNotFound), "should wrap ErrNotFound")
}
func TestGet_Bad_NonExistentGroup(t *testing.T) {
func TestStore_Get_Bad_NonExistentGroup(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -150,7 +148,7 @@ func TestGet_Bad_NonExistentGroup(t *testing.T) {
assert.True(t, core.Is(err, ErrNotFound))
}
func TestGet_Bad_ClosedStore(t *testing.T) {
func TestStore_Get_Bad_ClosedStore(t *testing.T) {
s, _ := New(":memory:")
s.Close()
@ -158,7 +156,7 @@ func TestGet_Bad_ClosedStore(t *testing.T) {
require.Error(t, err)
}
func TestSet_Bad_ClosedStore(t *testing.T) {
func TestStore_Set_Bad_ClosedStore(t *testing.T) {
s, _ := New(":memory:")
s.Close()
@ -170,7 +168,7 @@ func TestSet_Bad_ClosedStore(t *testing.T) {
// Delete
// ---------------------------------------------------------------------------
func TestDelete_Good(t *testing.T) {
func TestStore_Delete_Good(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -182,7 +180,7 @@ func TestDelete_Good(t *testing.T) {
assert.Error(t, err)
}
func TestDelete_Good_NonExistent(t *testing.T) {
func TestStore_Delete_Good_NonExistent(t *testing.T) {
// Deleting a key that does not exist should not error.
s, _ := New(":memory:")
defer s.Close()
@ -191,7 +189,7 @@ func TestDelete_Good_NonExistent(t *testing.T) {
assert.NoError(t, err)
}
func TestDelete_Bad_ClosedStore(t *testing.T) {
func TestStore_Delete_Bad_ClosedStore(t *testing.T) {
s, _ := New(":memory:")
s.Close()
@ -203,7 +201,7 @@ func TestDelete_Bad_ClosedStore(t *testing.T) {
// Count
// ---------------------------------------------------------------------------
func TestCount_Good(t *testing.T) {
func TestStore_Count_Good(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -216,7 +214,7 @@ func TestCount_Good(t *testing.T) {
assert.Equal(t, 2, n)
}
func TestCount_Good_Empty(t *testing.T) {
func TestStore_Count_Good_Empty(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -225,7 +223,7 @@ func TestCount_Good_Empty(t *testing.T) {
assert.Equal(t, 0, n)
}
func TestCount_Good_BulkInsert(t *testing.T) {
func TestStore_Count_Good_BulkInsert(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -238,7 +236,7 @@ func TestCount_Good_BulkInsert(t *testing.T) {
assert.Equal(t, total, n)
}
func TestCount_Bad_ClosedStore(t *testing.T) {
func TestStore_Count_Bad_ClosedStore(t *testing.T) {
s, _ := New(":memory:")
s.Close()
@ -250,7 +248,7 @@ func TestCount_Bad_ClosedStore(t *testing.T) {
// DeleteGroup
// ---------------------------------------------------------------------------
func TestDeleteGroup_Good(t *testing.T) {
func TestStore_DeleteGroup_Good(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -263,7 +261,7 @@ func TestDeleteGroup_Good(t *testing.T) {
assert.Equal(t, 0, n)
}
func TestDeleteGroup_Good_ThenGetAllEmpty(t *testing.T) {
func TestStore_DeleteGroup_Good_ThenGetAllEmpty(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -276,7 +274,7 @@ func TestDeleteGroup_Good_ThenGetAllEmpty(t *testing.T) {
assert.Empty(t, all)
}
func TestDeleteGroup_Good_IsolatesOtherGroups(t *testing.T) {
func TestStore_DeleteGroup_Good_IsolatesOtherGroups(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -292,7 +290,7 @@ func TestDeleteGroup_Good_IsolatesOtherGroups(t *testing.T) {
assert.Equal(t, "2", val, "other group should be untouched")
}
func TestDeleteGroup_Bad_ClosedStore(t *testing.T) {
func TestStore_DeleteGroup_Bad_ClosedStore(t *testing.T) {
s, _ := New(":memory:")
s.Close()
@ -304,7 +302,7 @@ func TestDeleteGroup_Bad_ClosedStore(t *testing.T) {
// GetAll
// ---------------------------------------------------------------------------
func TestGetAll_Good(t *testing.T) {
func TestStore_GetAll_Good(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -317,7 +315,7 @@ func TestGetAll_Good(t *testing.T) {
assert.Equal(t, map[string]string{"a": "1", "b": "2"}, all)
}
func TestGetAll_Good_Empty(t *testing.T) {
func TestStore_GetAll_Good_Empty(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -326,7 +324,7 @@ func TestGetAll_Good_Empty(t *testing.T) {
assert.Empty(t, all)
}
func TestGetAll_Bad_ClosedStore(t *testing.T) {
func TestStore_GetAll_Bad_ClosedStore(t *testing.T) {
s, _ := New(":memory:")
s.Close()
@ -338,7 +336,7 @@ func TestGetAll_Bad_ClosedStore(t *testing.T) {
// Render
// ---------------------------------------------------------------------------
func TestRender_Good(t *testing.T) {
func TestStore_Render_Good(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -352,7 +350,7 @@ func TestRender_Good(t *testing.T) {
assert.Contains(t, out, "iz...")
}
func TestRender_Good_EmptyGroup(t *testing.T) {
func TestStore_Render_Good_EmptyGroup(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -362,7 +360,7 @@ func TestRender_Good_EmptyGroup(t *testing.T) {
assert.Equal(t, "static content", out)
}
func TestRender_Bad_InvalidTemplateSyntax(t *testing.T) {
func TestStore_Render_Bad_InvalidTemplateSyntax(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -371,7 +369,7 @@ func TestRender_Bad_InvalidTemplateSyntax(t *testing.T) {
assert.Contains(t, err.Error(), "store.Render: parse")
}
func TestRender_Bad_MissingTemplateVar(t *testing.T) {
func TestStore_Render_Bad_MissingTemplateVar(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -382,7 +380,7 @@ func TestRender_Bad_MissingTemplateVar(t *testing.T) {
assert.Contains(t, out, "hello")
}
func TestRender_Bad_ExecError(t *testing.T) {
func TestStore_Render_Bad_ExecError(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -394,7 +392,7 @@ func TestRender_Bad_ExecError(t *testing.T) {
assert.Contains(t, err.Error(), "store.Render: exec")
}
func TestRender_Bad_ClosedStore(t *testing.T) {
func TestStore_Render_Bad_ClosedStore(t *testing.T) {
s, _ := New(":memory:")
s.Close()
@ -406,13 +404,13 @@ func TestRender_Bad_ClosedStore(t *testing.T) {
// Close
// ---------------------------------------------------------------------------
func TestClose_Good(t *testing.T) {
func TestStore_Close_Good(t *testing.T) {
s, _ := New(":memory:")
err := s.Close()
require.NoError(t, err)
}
func TestClose_Good_OperationsFailAfterClose(t *testing.T) {
func TestStore_Close_Good_OperationsFailAfterClose(t *testing.T) {
s, _ := New(":memory:")
require.NoError(t, s.Close())
@ -443,7 +441,7 @@ func TestClose_Good_OperationsFailAfterClose(t *testing.T) {
// Edge cases
// ---------------------------------------------------------------------------
func TestSetGet_Good_EdgeCases(t *testing.T) {
func TestStore_SetGet_Good_EdgeCases(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -468,9 +466,9 @@ func TestSetGet_Good_EdgeCases(t *testing.T) {
{"special SQL chars", "g", "'; DROP TABLE kv;--", "val"},
{"backslash", "g", "back\\slash", "val\\ue"},
{"percent", "g", "100%", "50%"},
{"long key", "g", strings.Repeat("k", 10000), "val"},
{"long value", "g", "longval", strings.Repeat("v", 100000)},
{"long group", strings.Repeat("g", 10000), "k", "val"},
{"long key", "g", repeatString("k", 10000), "val"},
{"long value", "g", "longval", repeatString("v", 100000)},
{"long group", repeatString("g", 10000), "k", "val"},
}
for _, tc := range tests {
@ -489,7 +487,7 @@ func TestSetGet_Good_EdgeCases(t *testing.T) {
// Group isolation
// ---------------------------------------------------------------------------
func TestStore_Good_GroupIsolation(t *testing.T) {
func TestStore_GroupIsolation_Good(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -518,8 +516,8 @@ func TestStore_Good_GroupIsolation(t *testing.T) {
// Concurrent access
// ---------------------------------------------------------------------------
func TestConcurrent_Good_ReadWrite(t *testing.T) {
dbPath := core.JoinPath(t.TempDir(), "concurrent.db")
func TestStore_Concurrent_Good_ReadWrite(t *testing.T) {
dbPath := testPath(t, "concurrent.db")
s, err := New(dbPath)
require.NoError(t, err)
defer s.Close()
@ -540,7 +538,7 @@ func TestConcurrent_Good_ReadWrite(t *testing.T) {
key := core.Sprintf("key-%d", i)
val := core.Sprintf("val-%d-%d", id, i)
if err := s.Set(group, key, val); err != nil {
errs <- core.Wrap(err, "writer", core.Sprintf("%d", id))
errs <- core.E("TestStore_Concurrent_Good_ReadWrite", core.Sprintf("writer %d", id), err)
}
}
}(g)
@ -557,7 +555,7 @@ func TestConcurrent_Good_ReadWrite(t *testing.T) {
_, err := s.Get(group, key)
// ErrNotFound is acceptable — the writer may not have written yet.
if err != nil && !core.Is(err, ErrNotFound) {
errs <- core.Wrap(err, "reader", core.Sprintf("%d", id))
errs <- core.E("TestStore_Concurrent_Good_ReadWrite", core.Sprintf("reader %d", id), err)
}
}
}(g)
@ -579,8 +577,8 @@ func TestConcurrent_Good_ReadWrite(t *testing.T) {
}
}
func TestConcurrent_Good_GetAll(t *testing.T) {
s, err := New(core.JoinPath(t.TempDir(), "getall.db"))
func TestStore_Concurrent_Good_GetAll(t *testing.T) {
s, err := New(testPath(t, "getall.db"))
require.NoError(t, err)
defer s.Close()
@ -605,8 +603,8 @@ func TestConcurrent_Good_GetAll(t *testing.T) {
wg.Wait()
}
func TestConcurrent_Good_DeleteGroup(t *testing.T) {
s, err := New(core.JoinPath(t.TempDir(), "delgrp.db"))
func TestStore_Concurrent_Good_DeleteGroup(t *testing.T) {
s, err := New(testPath(t, "delgrp.db"))
require.NoError(t, err)
defer s.Close()
@ -629,7 +627,7 @@ func TestConcurrent_Good_DeleteGroup(t *testing.T) {
// ErrNotFound wrapping verification
// ---------------------------------------------------------------------------
func TestErrNotFound_Good_Is(t *testing.T) {
func TestStore_ErrNotFound_Good_Is(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -685,7 +683,7 @@ func BenchmarkGetAll(b *testing.B) {
}
func BenchmarkSet_FileBacked(b *testing.B) {
dbPath := core.JoinPath(b.TempDir(), "bench.db")
dbPath := testPath(b, "bench.db")
s, _ := New(dbPath)
defer s.Close()
@ -699,7 +697,7 @@ func BenchmarkSet_FileBacked(b *testing.B) {
// TTL support (Phase 1)
// ---------------------------------------------------------------------------
func TestSetWithTTL_Good(t *testing.T) {
func TestStore_SetWithTTL_Good(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -711,7 +709,7 @@ func TestSetWithTTL_Good(t *testing.T) {
assert.Equal(t, "v", val)
}
func TestSetWithTTL_Good_Upsert(t *testing.T) {
func TestStore_SetWithTTL_Good_Upsert(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -727,7 +725,7 @@ func TestSetWithTTL_Good_Upsert(t *testing.T) {
assert.Equal(t, 1, n, "upsert should not duplicate keys")
}
func TestSetWithTTL_Good_ExpiresOnGet(t *testing.T) {
func TestStore_SetWithTTL_Good_ExpiresOnGet(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -742,7 +740,7 @@ func TestSetWithTTL_Good_ExpiresOnGet(t *testing.T) {
assert.True(t, core.Is(err, ErrNotFound), "expired key should be ErrNotFound")
}
func TestSetWithTTL_Good_ExcludedFromCount(t *testing.T) {
func TestStore_SetWithTTL_Good_ExcludedFromCount(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -755,7 +753,7 @@ func TestSetWithTTL_Good_ExcludedFromCount(t *testing.T) {
assert.Equal(t, 1, n, "expired key should not be counted")
}
func TestSetWithTTL_Good_ExcludedFromGetAll(t *testing.T) {
func TestStore_SetWithTTL_Good_ExcludedFromGetAll(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -768,7 +766,7 @@ func TestSetWithTTL_Good_ExcludedFromGetAll(t *testing.T) {
assert.Equal(t, map[string]string{"a": "1"}, all, "expired key should be excluded")
}
func TestSetWithTTL_Good_ExcludedFromRender(t *testing.T) {
func TestStore_SetWithTTL_Good_ExcludedFromRender(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -781,7 +779,7 @@ func TestSetWithTTL_Good_ExcludedFromRender(t *testing.T) {
assert.Equal(t, "Hello Alice", out)
}
func TestSetWithTTL_Good_SetClearsTTL(t *testing.T) {
func TestStore_SetWithTTL_Good_SetClearsTTL(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -795,7 +793,7 @@ func TestSetWithTTL_Good_SetClearsTTL(t *testing.T) {
assert.Equal(t, "permanent", val, "plain Set should clear TTL")
}
func TestSetWithTTL_Good_FutureTTLAccessible(t *testing.T) {
func TestStore_SetWithTTL_Good_FutureTTLAccessible(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -810,7 +808,7 @@ func TestSetWithTTL_Good_FutureTTLAccessible(t *testing.T) {
assert.Equal(t, 1, n)
}
func TestSetWithTTL_Bad_ClosedStore(t *testing.T) {
func TestStore_SetWithTTL_Bad_ClosedStore(t *testing.T) {
s, _ := New(":memory:")
s.Close()
@ -822,7 +820,7 @@ func TestSetWithTTL_Bad_ClosedStore(t *testing.T) {
// PurgeExpired
// ---------------------------------------------------------------------------
func TestPurgeExpired_Good(t *testing.T) {
func TestStore_PurgeExpired_Good(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -840,7 +838,7 @@ func TestPurgeExpired_Good(t *testing.T) {
assert.Equal(t, 1, n, "only non-expiring key should remain")
}
func TestPurgeExpired_Good_NoneExpired(t *testing.T) {
func TestStore_PurgeExpired_Good_NoneExpired(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -852,7 +850,7 @@ func TestPurgeExpired_Good_NoneExpired(t *testing.T) {
assert.Equal(t, int64(0), removed)
}
func TestPurgeExpired_Good_Empty(t *testing.T) {
func TestStore_PurgeExpired_Good_Empty(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
@ -861,7 +859,7 @@ func TestPurgeExpired_Good_Empty(t *testing.T) {
assert.Equal(t, int64(0), removed)
}
func TestPurgeExpired_Bad_ClosedStore(t *testing.T) {
func TestStore_PurgeExpired_Bad_ClosedStore(t *testing.T) {
s, _ := New(":memory:")
s.Close()
@ -869,7 +867,7 @@ func TestPurgeExpired_Bad_ClosedStore(t *testing.T) {
require.Error(t, err)
}
func TestPurgeExpired_Good_BackgroundPurge(t *testing.T) {
func TestStore_PurgeExpired_Good_BackgroundPurge(t *testing.T) {
s, _ := New(":memory:")
// Override purge interval for testing: restart the goroutine with a short interval.
s.cancel()
@ -898,8 +896,8 @@ func TestPurgeExpired_Good_BackgroundPurge(t *testing.T) {
// Schema migration — reopening an existing database
// ---------------------------------------------------------------------------
func TestSchemaUpgrade_Good_ExistingDB(t *testing.T) {
dbPath := core.JoinPath(t.TempDir(), "upgrade.db")
func TestStore_SchemaUpgrade_Good_ExistingDB(t *testing.T) {
dbPath := testPath(t, "upgrade.db")
// Open, write, close.
s1, err := New(dbPath)
@ -923,9 +921,9 @@ func TestSchemaUpgrade_Good_ExistingDB(t *testing.T) {
assert.Equal(t, "ttl-val", val2)
}
func TestSchemaUpgrade_Good_PreTTLDatabase(t *testing.T) {
func TestStore_SchemaUpgrade_Good_PreTTLDatabase(t *testing.T) {
// Simulate a database created before TTL support (no expires_at column).
dbPath := core.JoinPath(t.TempDir(), "pre-ttl.db")
dbPath := testPath(t, "pre-ttl.db")
db, err := sql.Open("sqlite", dbPath)
require.NoError(t, err)
db.SetMaxOpenConns(1)
@ -963,8 +961,8 @@ func TestSchemaUpgrade_Good_PreTTLDatabase(t *testing.T) {
// Concurrent TTL access
// ---------------------------------------------------------------------------
func TestConcurrent_Good_TTL(t *testing.T) {
s, err := New(core.JoinPath(t.TempDir(), "concurrent-ttl.db"))
func TestStore_Concurrent_Good_TTL(t *testing.T) {
s, err := New(testPath(t, "concurrent-ttl.db"))
require.NoError(t, err)
defer s.Close()

45
test_helpers_test.go Normal file
View file

@ -0,0 +1,45 @@
package store
import (
"testing"
core "dappco.re/go/core"
"github.com/stretchr/testify/require"
)
func testFS() *core.Fs {
return (&core.Fs{}).NewUnrestricted()
}
func testPath(tb testing.TB, name string) string {
tb.Helper()
return core.Path(tb.TempDir(), name)
}
func requireCoreOK(tb testing.TB, result core.Result) {
tb.Helper()
require.True(tb, result.OK, "core result failed: %v", result.Value)
}
func requireCoreReadBytes(tb testing.TB, path string) []byte {
tb.Helper()
result := testFS().Read(path)
requireCoreOK(tb, result)
return []byte(result.Value.(string))
}
func requireCoreWriteBytes(tb testing.TB, path string, data []byte) {
tb.Helper()
requireCoreOK(tb, testFS().Write(path, string(data)))
}
func repeatString(value string, count int) string {
if count <= 0 {
return ""
}
builder := core.NewBuilder()
for range count {
builder.WriteString(value)
}
return builder.String()
}