From 380f2b9157ea4ebdd144dbc6e5b811a85feb69e3 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 19:17:11 +0000 Subject: [PATCH 1/2] fix(store): finish ax v0.8.0 polish Co-Authored-By: Virgil --- CLAUDE.md | 6 +- conventions_test.go | 141 ++++++++++++++++++++++++++++++++++---- coverage_test.go | 75 +++++++++----------- docs/development.md | 10 +-- docs/index.md | 14 ++-- events.go | 10 +++ events_test.go | 30 ++++---- scope.go | 29 +++++--- scope_test.go | 80 +++++++++++----------- store.go | 123 +++++++++++++++++++++++---------- store_test.go | 160 +++++++++++++++++++++---------------------- test_helpers_test.go | 45 ++++++++++++ 12 files changed, 474 insertions(+), 249 deletions(-) create mode 100644 test_helpers_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 1c7eb40..3432f3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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__`, 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__` tests 6. Run `go test -race ./...` and `go vet ./...` ## Docs diff --git a/conventions_test.go b/conventions_test.go index d63a94c..fde4323 100644 --- a/conventions_test.go +++ b/conventions_test.go @@ -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__") +} + +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() +} diff --git a/coverage_test.go b/coverage_test.go index 8bd1253..ff0f823 100644 --- a/coverage_test.go +++ b/coverage_test.go @@ -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) diff --git a/docs/development.md b/docs/development.md index e821e0d..9b260ce 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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__` 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 diff --git a/docs/index.md b/docs/index.md index 7365623..5e643df 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/events.go b/events.go index a219ba9..feb6b6b 100644 --- a/events.go +++ b/events.go @@ -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: // diff --git a/events_test.go b/events_test.go index e0d0159..c677e8b 100644 --- a/events_test.go +++ b/events_test.go @@ -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() diff --git a/scope.go b/scope.go index 371e4de..6fd0da7 100644 --- a/scope.go +++ b/scope.go @@ -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) } } } diff --git a/scope_test.go b/scope_test.go index 1a9efc5..c7a574f 100644 --- a/scope_test.go +++ b/scope_test.go @@ -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() diff --git a/store.go b/store.go index 43de238..2a5dc1e 100644 --- a/store.go +++ b/store.go @@ -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:]) + } + } +} diff --git a/store_test.go b/store_test.go index 0a39bcd..f447447 100644 --- a/store_test.go +++ b/store_test.go @@ -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() diff --git a/test_helpers_test.go b/test_helpers_test.go new file mode 100644 index 0000000..986057b --- /dev/null +++ b/test_helpers_test.go @@ -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() +} -- 2.45.3 From 083bc1b2322aa6dab4904e78729fbc9543cde336 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 29 Mar 2026 23:27:16 +0000 Subject: [PATCH 2/2] chore(deps): remove stale core/log dependency Co-Authored-By: Virgil --- go.mod | 1 - go.sum | 2 -- 2 files changed, 3 deletions(-) diff --git a/go.mod b/go.mod index 74cb363..c9e5c0c 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 8b4ec94..adafd38 100644 --- a/go.sum +++ b/go.sum @@ -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= -- 2.45.3