From d777ac4e1478198f55d81e62639935f48bbb3131 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 15:45:23 +0000 Subject: [PATCH 1/5] chore(deps): align core with v0.8.0-alpha.1 Co-Authored-By: Virgil --- go.mod | 2 +- go.sum | 4 ++-- scope.go | 2 +- store.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 853eac3..c9e5c0c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module dappco.re/go/core/store go 1.26.0 require ( - dappco.re/go/core/log v0.1.0 + dappco.re/go/core v0.8.0-alpha.1 github.com/stretchr/testify v1.11.1 modernc.org/sqlite v1.47.0 ) diff --git a/go.sum b/go.sum index 0dc6285..adafd38 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= -dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= +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= 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= diff --git a/scope.go b/scope.go index 3c529a9..a809a66 100644 --- a/scope.go +++ b/scope.go @@ -7,7 +7,7 @@ import ( "regexp" "time" - coreerr "dappco.re/go/core/log" + coreerr "dappco.re/go/core" ) // validNamespace matches alphanumeric characters and hyphens (non-empty). diff --git a/store.go b/store.go index 576b66b..82b3c8d 100644 --- a/store.go +++ b/store.go @@ -9,7 +9,7 @@ import ( "text/template" "time" - coreerr "dappco.re/go/core/log" + coreerr "dappco.re/go/core" _ "modernc.org/sqlite" ) -- 2.45.3 From 1e87003c155a747c6ef0d54a4dc9bf888940f433 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 19:17:11 +0000 Subject: [PATCH 2/5] fix(store): finish ax v0.8.0 polish Co-Authored-By: Virgil --- CLAUDE.md | 6 +- bench_test.go | 19 ++-- conventions_test.go | 150 +++++++++++++++++++++++++++---- coverage_test.go | 86 ++++++++---------- docs/development.md | 10 +-- docs/index.md | 14 +-- events.go | 10 +++ events_test.go | 36 ++++---- scope.go | 34 ++++--- scope_test.go | 104 ++++++++++----------- store.go | 132 +++++++++++++++++++-------- store_test.go | 210 +++++++++++++++++++++---------------------- test_helpers_test.go | 45 ++++++++++ 13 files changed, 539 insertions(+), 317 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/bench_test.go b/bench_test.go index 1b5f7f7..4f8896c 100644 --- a/bench_test.go +++ b/bench_test.go @@ -2,8 +2,9 @@ package store import ( - "fmt" "testing" + + core "dappco.re/go/core" ) // Supplemental benchmarks beyond the core Set/Get/GetAll/FileBacked benchmarks @@ -14,7 +15,7 @@ func BenchmarkGetAll_VaryingSize(b *testing.B) { sizes := []int{10, 100, 1_000, 10_000} for _, size := range sizes { - b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) { + b.Run(core.Sprintf("size=%d", size), func(b *testing.B) { s, err := New(":memory:") if err != nil { b.Fatal(err) @@ -22,7 +23,7 @@ func BenchmarkGetAll_VaryingSize(b *testing.B) { defer s.Close() for i := range size { - _ = s.Set("bench", fmt.Sprintf("key-%d", i), "value") + _ = s.Set("bench", core.Sprintf("key-%d", i), "value") } b.ReportAllocs() @@ -48,7 +49,7 @@ func BenchmarkSetGet_Parallel(b *testing.B) { b.RunParallel(func(pb *testing.PB) { i := 0 for pb.Next() { - key := fmt.Sprintf("key-%d", i) + key := core.Sprintf("key-%d", i) _ = s.Set("parallel", key, "value") _, _ = s.Get("parallel", key) i++ @@ -64,7 +65,7 @@ func BenchmarkCount_10K(b *testing.B) { defer s.Close() for i := range 10_000 { - _ = s.Set("bench", fmt.Sprintf("key-%d", i), "value") + _ = s.Set("bench", core.Sprintf("key-%d", i), "value") } b.ReportAllocs() @@ -84,14 +85,14 @@ func BenchmarkDelete(b *testing.B) { // Pre-populate keys that will be deleted. for i := range b.N { - _ = s.Set("bench", fmt.Sprintf("key-%d", i), "value") + _ = s.Set("bench", core.Sprintf("key-%d", i), "value") } b.ReportAllocs() b.ResetTimer() for i := range b.N { - _ = s.Delete("bench", fmt.Sprintf("key-%d", i)) + _ = s.Delete("bench", core.Sprintf("key-%d", i)) } } @@ -106,7 +107,7 @@ func BenchmarkSetWithTTL(b *testing.B) { b.ResetTimer() for i := range b.N { - _ = s.SetWithTTL("bench", fmt.Sprintf("key-%d", i), "value", 60_000_000_000) // 60s + _ = s.SetWithTTL("bench", core.Sprintf("key-%d", i), "value", 60_000_000_000) // 60s } } @@ -118,7 +119,7 @@ func BenchmarkRender(b *testing.B) { defer s.Close() for i := range 50 { - _ = s.Set("bench", fmt.Sprintf("key%d", i), fmt.Sprintf("val%d", i)) + _ = s.Set("bench", core.Sprintf("key%d", i), core.Sprintf("val%d", i)) } tmpl := `{{ .key0 }} {{ .key25 }} {{ .key49 }}` diff --git a/conventions_test.go b/conventions_test.go index 94ff9f8..fde4323 100644 --- a/conventions_test.go +++ b/conventions_test.go @@ -4,43 +4,55 @@ import ( "go/ast" "go/parser" "go/token" - "os" - "path/filepath" + "io/fs" "slices" - "strings" "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 strings.HasSuffix(name, ".go") + 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 := strings.Trim(spec.Path.Value, `"`) - if strings.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 strings.HasSuffix(name, "_test.go") + 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) @@ -48,32 +60,85 @@ func TestRepoConventions_Good_TestNaming(t *testing.T) { continue } name := fn.Name.Name - if !strings.HasPrefix(name, "Test") || name == "TestMain" { + if !core.HasPrefix(name, "Test") || name == "TestMain" { continue } - if strings.Contains(name, "_Good") || strings.Contains(name, "_Bad") || strings.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, filepath.Clean(entry.Name())) + files = append(files, entry.Name()) } slices.Sort(files) @@ -83,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 da903ae..ff0f823 100644 --- a/coverage_test.go +++ b/coverage_test.go @@ -2,11 +2,9 @@ package store import ( "database/sql" - "fmt" - "os" - "path/filepath" "testing" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -15,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 := filepath.Join(dir, "conflict.db") + dbPath := testPath(t, "conflict.db") db, err := sql.Open("sqlite", dbPath) require.NoError(t, err) @@ -42,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:") @@ -78,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 := filepath.Join(dir, "corrupt-getall.db") + dbPath := testPath(t, "corrupt-getall.db") s, err := New(dbPath) require.NoError(t, err) @@ -91,8 +87,8 @@ func TestGetAll_Bad_RowsError(t *testing.T) { const rows = 5000 for i := range rows { require.NoError(t, s.Set("g", - fmt.Sprintf("key-%06d", i), - fmt.Sprintf("value-with-padding-%06d-xxxxxxxxxxxxxxxxxxxxxxxx", i))) + core.Sprintf("key-%06d", i), + core.Sprintf("value-with-padding-%06d-xxxxxxxxxxxxxxxxxxxxxxxx", i))) } s.Close() @@ -106,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) @@ -140,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() @@ -173,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 := filepath.Join(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) @@ -184,8 +177,8 @@ func TestRender_Bad_RowsError(t *testing.T) { const rows = 5000 for i := range rows { require.NoError(t, s.Set("g", - fmt.Sprintf("key-%06d", i), - fmt.Sprintf("value-with-padding-%06d-xxxxxxxxxxxxxxxxxxxxxxxx", i))) + core.Sprintf("key-%06d", i), + core.Sprintf("value-with-padding-%06d-xxxxxxxxxxxxxxxxxxxxxxxx", i))) } s.Close() @@ -196,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 995e83a..c677e8b 100644 --- a/events_test.go +++ b/events_test.go @@ -1,12 +1,12 @@ package store import ( - "fmt" "sync" "sync/atomic" "testing" "time" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -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() @@ -248,7 +248,7 @@ func TestWatch_Good_BufferFullDoesNotBlock(t *testing.T) { go func() { defer close(done) for i := range 32 { - require.NoError(t, s.Set("g", fmt.Sprintf("k%d", i), "v")) + require.NoError(t, s.Set("g", core.Sprintf("k%d", i), "v")) } }() @@ -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() @@ -318,7 +318,7 @@ func TestWatch_Good_ConcurrentWatchUnwatch(t *testing.T) { // Writers — continuously mutate the store. wg.Go(func() { for i := range goroutines * ops { - _ = s.Set("g", fmt.Sprintf("k%d", i), "v") + _ = s.Set("g", core.Sprintf("k%d", i), "v") } }) @@ -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 a809a66..6fd0da7 100644 --- a/scope.go +++ b/scope.go @@ -1,13 +1,11 @@ package store import ( - "errors" - "fmt" "iter" "regexp" "time" - coreerr "dappco.re/go/core" + core "dappco.re/go/core" ) // validNamespace matches alphanumeric characters and hyphens (non-empty). @@ -15,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 @@ -22,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 @@ -31,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", fmt.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 } @@ -41,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 { @@ -56,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 @@ -76,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 @@ -84,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)) } @@ -133,19 +145,19 @@ func (s *ScopedStore) checkQuota(group, key string) error { // Key exists — this is an upsert, no quota check needed. return nil } - if !errors.Is(err, ErrNotFound) { + 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", fmt.Sprintf("key limit (%d)", s.quota.MaxKeys), ErrQuotaExceeded) + return core.E("store.ScopedStore", core.Sprintf("key limit (%d)", s.quota.MaxKeys), ErrQuotaExceeded) } } @@ -153,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", fmt.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 07cbca9..c7a574f 100644 --- a/scope_test.go +++ b/scope_test.go @@ -1,10 +1,10 @@ package store import ( - "errors" "testing" "time" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -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() @@ -85,10 +85,10 @@ func TestScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) { // Direct access without prefix should fail. _, err = s.Get("config", "key") - assert.True(t, errors.Is(err, ErrNotFound)) + 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() @@ -116,10 +116,10 @@ func TestScopedStore_Good_Delete(t *testing.T) { require.NoError(t, sc.Delete("g", "k")) _, err := sc.Get("g", "k") - assert.True(t, errors.Is(err, ErrNotFound)) + 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() @@ -187,10 +187,10 @@ func TestScopedStore_Good_SetWithTTL_Expires(t *testing.T) { time.Sleep(5 * time.Millisecond) _, err := sc.Get("g", "k") - assert.True(t, errors.Is(err, ErrNotFound)) + 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() @@ -221,10 +221,10 @@ func TestQuota_Good_MaxKeys(t *testing.T) { // 6th key should fail. err = sc.Set("g", "overflow", "v") require.Error(t, err) - assert.True(t, errors.Is(err, ErrQuotaExceeded), "expected ErrQuotaExceeded, got: %v", err) + 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() @@ -236,10 +236,10 @@ func TestQuota_Good_MaxKeys_AcrossGroups(t *testing.T) { // Total is now 3 — any new key should fail regardless of group. err := sc.Set("g4", "d", "4") - assert.True(t, errors.Is(err, ErrQuotaExceeded)) + 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() @@ -303,10 +303,10 @@ func TestQuota_Good_ExpiredKeysExcluded(t *testing.T) { // Now at 3 — next should fail. err := sc.Set("g", "new3", "v") - assert.True(t, errors.Is(err, ErrQuotaExceeded)) + 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() @@ -316,14 +316,14 @@ func TestQuota_Good_SetWithTTL_Enforced(t *testing.T) { require.NoError(t, sc.SetWithTTL("g", "b", "2", time.Hour)) err := sc.SetWithTTL("g", "c", "3", time.Hour) - assert.True(t, errors.Is(err, ErrQuotaExceeded)) + assert.True(t, core.Is(err, ErrQuotaExceeded)) } // --------------------------------------------------------------------------- // Quota enforcement — MaxGroups // --------------------------------------------------------------------------- -func TestQuota_Good_MaxGroups(t *testing.T) { +func TestScope_Quota_Good_MaxGroups(t *testing.T) { s, _ := New(":memory:") defer s.Close() @@ -336,10 +336,10 @@ func TestQuota_Good_MaxGroups(t *testing.T) { // 4th group should fail. err := sc.Set("g4", "k", "v") require.Error(t, err) - assert.True(t, errors.Is(err, ErrQuotaExceeded)) + 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() @@ -405,13 +405,13 @@ func TestQuota_Good_BothLimits(t *testing.T) { // Group limit hit. err := sc.Set("g3", "c", "3") - assert.True(t, errors.Is(err, ErrQuotaExceeded)) + assert.True(t, core.Is(err, ErrQuotaExceeded)) // But adding to existing groups is fine (within key limit). 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() @@ -425,18 +425,18 @@ func TestQuota_Good_DoesNotAffectOtherNamespaces(t *testing.T) { // a is at limit — but b's keys don't count against a. err := a.Set("g", "a3", "v") - assert.True(t, errors.Is(err, ErrQuotaExceeded)) + assert.True(t, core.Is(err, ErrQuotaExceeded)) // b is also at limit independently. err = b.Set("g", "b3", "v") - assert.True(t, errors.Is(err, ErrQuotaExceeded)) + assert.True(t, core.Is(err, ErrQuotaExceeded)) } // --------------------------------------------------------------------------- // 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 82b3c8d..2a5dc1e 100644 --- a/store.go +++ b/store.go @@ -4,24 +4,25 @@ import ( "context" "database/sql" "iter" - "strings" "sync" "text/template" "time" + "unicode" - coreerr "dappco.re/go/core" + core "dappco.re/go/core" _ "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 @@ -36,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 @@ -48,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, @@ -62,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 !strings.Contains(err.Error(), "duplicate column name") { + 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) } } @@ -80,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() @@ -88,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 @@ -96,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. @@ -108,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) @@ -122,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 @@ -131,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( @@ -139,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( @@ -163,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 } @@ -196,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( @@ -203,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() @@ -211,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 @@ -221,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 @@ -285,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) { @@ -305,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 @@ -322,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() @@ -330,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 @@ -340,26 +358,27 @@ 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)) } } } // escapeLike escapes SQLite LIKE wildcards and the escape character itself. func escapeLike(s string) string { - s = strings.ReplaceAll(s, "^", "^^") - s = strings.ReplaceAll(s, "%", "^%") - s = strings.ReplaceAll(s, "_", "^_") + s = core.Replace(s, "^", "^^") + s = core.Replace(s, "%", "^%") + s = core.Replace(s, "_", "^_") return s } // 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() } @@ -385,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 c0452e0..f447447 100644 --- a/store_test.go +++ b/store_test.go @@ -3,15 +3,12 @@ package store import ( "context" "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "strings" "sync" + "syscall" "testing" "time" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -20,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 := filepath.Join(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) @@ -47,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") @@ -55,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 := filepath.Join(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 := filepath.Join(dir, "readonly.db") + dbPath := core.Path(dir, "readonly.db") // Create a valid DB first, then make the directory read-only. s, err := New(dbPath) @@ -77,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. @@ -89,8 +85,8 @@ func TestNew_Bad_ReadOnlyDir(t *testing.T) { } } -func TestNew_Good_WALMode(t *testing.T) { - dbPath := filepath.Join(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() @@ -105,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() @@ -118,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() @@ -134,25 +130,25 @@ 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() _, err := s.Get("config", "missing") require.Error(t, err) - assert.True(t, errors.Is(err, ErrNotFound), "should wrap ErrNotFound") + 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() _, err := s.Get("no-such-group", "key") require.Error(t, err) - assert.True(t, errors.Is(err, ErrNotFound)) + 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() @@ -160,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() @@ -172,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() @@ -184,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() @@ -193,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() @@ -205,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() @@ -218,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() @@ -227,20 +223,20 @@ 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() const total = 500 for i := range total { - require.NoError(t, s.Set("bulk", fmt.Sprintf("key-%04d", i), "v")) + require.NoError(t, s.Set("bulk", core.Sprintf("key-%04d", i), "v")) } n, err := s.Count("bulk") require.NoError(t, err) 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() @@ -252,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() @@ -265,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() @@ -278,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() @@ -294,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() @@ -306,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() @@ -319,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() @@ -328,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() @@ -340,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() @@ -354,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() @@ -364,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() @@ -373,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() @@ -384,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() @@ -396,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() @@ -408,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()) @@ -445,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() @@ -470,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 { @@ -491,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() @@ -520,8 +516,8 @@ func TestStore_Good_GroupIsolation(t *testing.T) { // Concurrent access // --------------------------------------------------------------------------- -func TestConcurrent_Good_ReadWrite(t *testing.T) { - dbPath := filepath.Join(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() @@ -537,12 +533,12 @@ func TestConcurrent_Good_ReadWrite(t *testing.T) { wg.Add(1) go func(id int) { defer wg.Done() - group := fmt.Sprintf("grp-%d", id) + group := core.Sprintf("grp-%d", id) for i := range opsPerGoroutine { - key := fmt.Sprintf("key-%d", i) - val := fmt.Sprintf("val-%d-%d", id, i) + key := core.Sprintf("key-%d", i) + val := core.Sprintf("val-%d-%d", id, i) if err := s.Set(group, key, val); err != nil { - errs <- fmt.Errorf("writer %d: %w", id, err) + errs <- core.E("TestStore_Concurrent_Good_ReadWrite", core.Sprintf("writer %d", id), err) } } }(g) @@ -553,13 +549,13 @@ func TestConcurrent_Good_ReadWrite(t *testing.T) { wg.Add(1) go func(id int) { defer wg.Done() - group := fmt.Sprintf("grp-%d", id) + group := core.Sprintf("grp-%d", id) for i := range opsPerGoroutine { - key := fmt.Sprintf("key-%d", i) + key := core.Sprintf("key-%d", i) _, err := s.Get(group, key) // ErrNotFound is acceptable — the writer may not have written yet. - if err != nil && !errors.Is(err, ErrNotFound) { - errs <- fmt.Errorf("reader %d: %w", id, err) + if err != nil && !core.Is(err, ErrNotFound) { + errs <- core.E("TestStore_Concurrent_Good_ReadWrite", core.Sprintf("reader %d", id), err) } } }(g) @@ -574,21 +570,21 @@ func TestConcurrent_Good_ReadWrite(t *testing.T) { // After all writers finish, every key should be present. for g := range goroutines { - group := fmt.Sprintf("grp-%d", g) + group := core.Sprintf("grp-%d", g) n, err := s.Count(group) require.NoError(t, err) assert.Equal(t, opsPerGoroutine, n, "group %s should have all keys", group) } } -func TestConcurrent_Good_GetAll(t *testing.T) { - s, err := New(filepath.Join(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() // Seed data. for i := range 50 { - require.NoError(t, s.Set("shared", fmt.Sprintf("k%d", i), fmt.Sprintf("v%d", i))) + require.NoError(t, s.Set("shared", core.Sprintf("k%d", i), core.Sprintf("v%d", i))) } var wg sync.WaitGroup @@ -607,8 +603,8 @@ func TestConcurrent_Good_GetAll(t *testing.T) { wg.Wait() } -func TestConcurrent_Good_DeleteGroup(t *testing.T) { - s, err := New(filepath.Join(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() @@ -617,9 +613,9 @@ func TestConcurrent_Good_DeleteGroup(t *testing.T) { wg.Add(1) go func(id int) { defer wg.Done() - grp := fmt.Sprintf("g%d", id) + grp := core.Sprintf("g%d", id) for i := range 20 { - _ = s.Set(grp, fmt.Sprintf("k%d", i), "v") + _ = s.Set(grp, core.Sprintf("k%d", i), "v") } _ = s.DeleteGroup(grp) }(g) @@ -631,13 +627,13 @@ 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() _, err := s.Get("g", "k") require.Error(t, err) - assert.True(t, errors.Is(err, ErrNotFound), "error should be ErrNotFound via errors.Is") + assert.True(t, core.Is(err, ErrNotFound), "error should be ErrNotFound via core.Is") assert.Contains(t, err.Error(), "g/k", "error message should include group/key") } @@ -651,7 +647,7 @@ func BenchmarkSet(b *testing.B) { b.ResetTimer() for i := range b.N { - _ = s.Set("bench", fmt.Sprintf("key-%d", i), "value") + _ = s.Set("bench", core.Sprintf("key-%d", i), "value") } } @@ -662,12 +658,12 @@ func BenchmarkGet(b *testing.B) { // Pre-populate. const keys = 10000 for i := range keys { - _ = s.Set("bench", fmt.Sprintf("key-%d", i), "value") + _ = s.Set("bench", core.Sprintf("key-%d", i), "value") } b.ResetTimer() for i := range b.N { - _, _ = s.Get("bench", fmt.Sprintf("key-%d", i%keys)) + _, _ = s.Get("bench", core.Sprintf("key-%d", i%keys)) } } @@ -677,7 +673,7 @@ func BenchmarkGetAll(b *testing.B) { const keys = 10000 for i := range keys { - _ = s.Set("bench", fmt.Sprintf("key-%d", i), "value") + _ = s.Set("bench", core.Sprintf("key-%d", i), "value") } b.ResetTimer() @@ -687,13 +683,13 @@ func BenchmarkGetAll(b *testing.B) { } func BenchmarkSet_FileBacked(b *testing.B) { - dbPath := filepath.Join(b.TempDir(), "bench.db") + dbPath := testPath(b, "bench.db") s, _ := New(dbPath) defer s.Close() b.ResetTimer() for i := range b.N { - _ = s.Set("bench", fmt.Sprintf("key-%d", i), "value") + _ = s.Set("bench", core.Sprintf("key-%d", i), "value") } } @@ -701,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() @@ -713,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() @@ -729,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() @@ -741,10 +737,10 @@ func TestSetWithTTL_Good_ExpiresOnGet(t *testing.T) { _, err := s.Get("g", "ephemeral") require.Error(t, err) - assert.True(t, errors.Is(err, ErrNotFound), "expired key should be ErrNotFound") + 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() @@ -757,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() @@ -770,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() @@ -783,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() @@ -797,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() @@ -812,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() @@ -824,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() @@ -842,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() @@ -854,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() @@ -863,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() @@ -871,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() @@ -900,8 +896,8 @@ func TestPurgeExpired_Good_BackgroundPurge(t *testing.T) { // Schema migration — reopening an existing database // --------------------------------------------------------------------------- -func TestSchemaUpgrade_Good_ExistingDB(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "upgrade.db") +func TestStore_SchemaUpgrade_Good_ExistingDB(t *testing.T) { + dbPath := testPath(t, "upgrade.db") // Open, write, close. s1, err := New(dbPath) @@ -925,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 := filepath.Join(t.TempDir(), "pre-ttl.db") + dbPath := testPath(t, "pre-ttl.db") db, err := sql.Open("sqlite", dbPath) require.NoError(t, err) db.SetMaxOpenConns(1) @@ -965,8 +961,8 @@ func TestSchemaUpgrade_Good_PreTTLDatabase(t *testing.T) { // Concurrent TTL access // --------------------------------------------------------------------------- -func TestConcurrent_Good_TTL(t *testing.T) { - s, err := New(filepath.Join(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() @@ -978,9 +974,9 @@ func TestConcurrent_Good_TTL(t *testing.T) { wg.Add(1) go func(id int) { defer wg.Done() - grp := fmt.Sprintf("ttl-%d", id) + grp := core.Sprintf("ttl-%d", id) for i := range ops { - key := fmt.Sprintf("k%d", i) + key := core.Sprintf("k%d", i) if i%2 == 0 { _ = s.SetWithTTL(grp, key, "v", 50*time.Millisecond) } else { @@ -995,7 +991,7 @@ func TestConcurrent_Good_TTL(t *testing.T) { time.Sleep(60 * time.Millisecond) for g := range goroutines { - grp := fmt.Sprintf("ttl-%d", g) + grp := core.Sprintf("ttl-%d", g) n, err := s.Count(grp) require.NoError(t, err) assert.Equal(t, ops/2, n, "only non-TTL keys should remain in %s", grp) 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 43ae4ebe6a067f9c356d1fffa2373d5c34ab6958 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 27 Mar 2026 03:36:52 +0000 Subject: [PATCH 3/5] chore(verification): record clean verification pass Co-Authored-By: Virgil -- 2.45.3 From 44bb8b1a8eab6737931d270b8586df85c8e80fe0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 27 Mar 2026 05:20:45 +0000 Subject: [PATCH 4/5] docs(repo): align licence metadata Co-Authored-By: Virgil --- CONTRIBUTING.md | 2 +- LICENCE | 11 +++++++++++ README.md | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 LICENCE diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b96297..2d8e57d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,5 +31,5 @@ We follow the [Conventional Commits](https://www.conventionalcommits.org/) speci Example: `feat: add new endpoint for health check` -## License +## Licence By contributing to this project, you agree that your contributions will be licensed under the **European Union Public Licence (EUPL-1.2)**. diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..3904922 --- /dev/null +++ b/LICENCE @@ -0,0 +1,11 @@ +European Union Public Licence v1.2 (EUPL-1.2) + +Copyright (c) the contributors to this repository. + +This repository is made available under the terms of the European Union Public +Licence v1.2. The authoritative licence text is published by the European +Commission: + +https://interoperable-europe.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + +SPDX-License-Identifier: EUPL-1.2 diff --git a/README.md b/README.md index 05399b2..b3cfe5b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![Go Reference](https://pkg.go.dev/badge/dappco.re/go/core/store.svg)](https://pkg.go.dev/dappco.re/go/core/store) -[![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE.md) +[![Licence: EUPL-1.2](https://img.shields.io/badge/Licence-EUPL--1.2-blue.svg)](LICENCE) [![Go Version](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go)](go.mod) # go-store @@ -8,7 +8,7 @@ Group-namespaced SQLite key-value store with TTL expiry, namespace isolation, qu **Module**: `dappco.re/go/core/store` **Licence**: EUPL-1.2 -**Language**: Go 1.25 +**Language**: Go 1.26 ## Quick Start -- 2.45.3 From 0609e4a7b68eb2b8a15d404878fa620c4a452963 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 27 Mar 2026 20:43:54 +0000 Subject: [PATCH 5/5] docs(specs): document exported api --- specs/RFC.md | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 specs/RFC.md diff --git a/specs/RFC.md b/specs/RFC.md new file mode 100644 index 0000000..09983ed --- /dev/null +++ b/specs/RFC.md @@ -0,0 +1,131 @@ +# store + +**Import:** `dappco.re/go/core/store` + +**Files:** 4 + +`store` provides a SQLite-backed key-value store with group namespaces, TTL expiry, quota-enforced scoped views, and reactive change notifications. The package also exports the sentinel errors `ErrNotFound` and `ErrQuotaExceeded`. + +## Types + +This package exports structs and one defined integer type. It exports no interfaces or type aliases. + +### `EventType` + +`type EventType int` + +Describes the kind of store mutation that occurred. + +Exported constants: +- `EventSet`: a key was created or updated. +- `EventDelete`: a single key was removed. +- `EventDeleteGroup`: all keys in a group were removed. + +### `Event` + +`type Event struct` + +Describes a single store mutation. `Key` is empty for `EventDeleteGroup`. `Value` is only populated for `EventSet`. + +Fields: +- `Type EventType`: the mutation kind. +- `Group string`: the group that changed. +- `Key string`: the key that changed, or `""` for group deletion. +- `Value string`: the new value for set events. +- `Timestamp time.Time`: when the event was emitted. + +### `Watcher` + +`type Watcher struct` + +Receives events matching a group/key filter. Create one with `(*Store).Watch` and stop delivery with `(*Store).Unwatch`. + +Fields: +- `Ch <-chan Event`: the public read-only event channel consumers select on. + +### `KV` + +`type KV struct` + +Represents a key-value pair yielded by store iterators. + +Fields: +- `Key string`: the stored key. +- `Value string`: the stored value. + +### `QuotaConfig` + +`type QuotaConfig struct` + +Defines optional limits for a `ScopedStore` namespace. Zero values mean unlimited. + +Fields: +- `MaxKeys int`: maximum total keys across all groups in the namespace. +- `MaxGroups int`: maximum distinct groups in the namespace. + +### `ScopedStore` + +`type ScopedStore struct` + +Wraps a `*Store` and prefixes all group names with a namespace to prevent collisions across tenants. Quotas, when configured, are enforced on new keys and groups. + +### `Store` + +`type Store struct` + +Group-namespaced key-value store backed by SQLite. It owns the SQLite connection, starts a background purge loop for expired entries, and fans out mutation notifications to watchers and change callbacks. + +## Functions + +### Package functions + +| Signature | Description | +| --- | --- | +| `func New(dbPath string) (*Store, error)` | Creates a `Store` at the given SQLite path. `":memory:"` is valid for tests. The implementation opens SQLite, forces a single open connection, enables WAL mode, sets `busy_timeout=5000`, ensures the `kv` table exists, applies the `expires_at` migration if needed, and starts the background expiry purge loop. | +| `func NewScoped(store *Store, namespace string) (*ScopedStore, error)` | Creates a `ScopedStore` that prefixes every group with `namespace:`. The namespace must be non-empty and match `^[a-zA-Z0-9-]+$`. | +| `func NewScopedWithQuota(store *Store, namespace string, quota QuotaConfig) (*ScopedStore, error)` | Creates a `ScopedStore` with the same namespace rules as `NewScoped`, then attaches quota enforcement used by `Set` and `SetWithTTL` for new keys and new groups. | + +### `EventType` methods + +| Signature | Description | +| --- | --- | +| `func (t EventType) String() string` | Returns a human-readable label for the event type: `set`, `delete`, `delete_group`, or `unknown`. | + +### `Store` methods + +| Signature | Description | +| --- | --- | +| `func (s *Store) All(group string) iter.Seq2[KV, error]` | Returns an iterator over all non-expired key-value pairs in `group`. Query, scan, and row errors are yielded through the second iterator value. | +| `func (s *Store) Close() error` | Stops the background purge goroutine and closes the underlying database connection. | +| `func (s *Store) Count(group string) (int, error)` | Returns the number of non-expired keys in `group`. | +| `func (s *Store) CountAll(prefix string) (int, error)` | Returns the total number of non-expired keys across all groups whose names start with `prefix`. Passing `""` counts all non-expired keys. Prefix matching is implemented with escaped SQLite `LIKE` patterns. | +| `func (s *Store) Delete(group, key string) error` | Removes a single key from a group and emits an `EventDelete` notification after a successful write. | +| `func (s *Store) DeleteGroup(group string) error` | Removes all keys in a group and emits an `EventDeleteGroup` notification after a successful write. | +| `func (s *Store) Get(group, key string) (string, error)` | Retrieves a value by group and key. Expired entries are treated as missing, are lazily deleted on read, and return `ErrNotFound` wrapped with `store.Get` context. | +| `func (s *Store) GetAll(group string) (map[string]string, error)` | Collects all non-expired key-value pairs in `group` into a `map[string]string` by consuming `All`. | +| `func (s *Store) GetFields(group, key string) (iter.Seq[string], error)` | Retrieves a value and returns an iterator over whitespace-delimited fields. | +| `func (s *Store) GetSplit(group, key, sep string) (iter.Seq[string], error)` | Retrieves a value and returns an iterator over substrings split by `sep`. | +| `func (s *Store) Groups(prefix string) ([]string, error)` | Returns the distinct names of groups containing non-expired keys. If `prefix` is non-empty, only matching group names are returned. | +| `func (s *Store) GroupsSeq(prefix string) iter.Seq2[string, error]` | Returns an iterator over the distinct names of groups containing non-expired keys, optionally filtered by `prefix`. Query, scan, and row errors are yielded through the second iterator value. | +| `func (s *Store) OnChange(fn func(Event)) func()` | Registers a callback invoked on every store mutation. Callbacks run synchronously in the goroutine that performed the write. The returned function unregisters the callback and is idempotent. | +| `func (s *Store) PurgeExpired() (int64, error)` | Deletes all expired keys across all groups and returns the number of removed rows. | +| `func (s *Store) Render(tmplStr, group string) (string, error)` | Loads all non-expired key-value pairs from `group`, parses `tmplStr` with Go's `text/template`, and executes the template with the group's key-value map as data. | +| `func (s *Store) Set(group, key, value string) error` | Inserts or updates a key with no expiry. Existing rows are overwritten and any previous TTL is cleared. A successful write emits an `EventSet`. | +| `func (s *Store) SetWithTTL(group, key, value string, ttl time.Duration) error` | Inserts or updates a key with an expiry time of `time.Now().Add(ttl)`. A successful write emits an `EventSet`. | +| `func (s *Store) Unwatch(w *Watcher)` | Removes a watcher and closes its channel. Calling `Unwatch` more than once for the same watcher is a no-op. | +| `func (s *Store) Watch(group, key string) *Watcher` | Creates a watcher that receives events matching `group` and `key`. `*` acts as a wildcard, the returned channel is buffered to 16 events, and sends are non-blocking, so events are dropped if the consumer falls behind. | + +### `ScopedStore` methods + +| Signature | Description | +| --- | --- | +| `func (s *ScopedStore) All(group string) iter.Seq2[KV, error]` | Returns the same iterator as `Store.All`, but against the namespace-prefixed group. | +| `func (s *ScopedStore) Count(group string) (int, error)` | Returns the number of non-expired keys in the namespace-prefixed group. | +| `func (s *ScopedStore) Delete(group, key string) error` | Removes a single key from the namespace-prefixed group. | +| `func (s *ScopedStore) DeleteGroup(group string) error` | Removes all keys from the namespace-prefixed group. | +| `func (s *ScopedStore) Get(group, key string) (string, error)` | Retrieves a value from the namespace-prefixed group. | +| `func (s *ScopedStore) GetAll(group string) (map[string]string, error)` | Returns all non-expired key-value pairs from the namespace-prefixed group. | +| `func (s *ScopedStore) Namespace() string` | Returns the namespace string used to prefix groups. | +| `func (s *ScopedStore) Render(tmplStr, group string) (string, error)` | Renders a Go template with the key-value map loaded from the namespace-prefixed group. | +| `func (s *ScopedStore) Set(group, key, value string) error` | Stores a value in the namespace-prefixed group. When quotas are configured, new keys and new groups are checked before writing; upserts bypass the quota limit checks. | +| `func (s *ScopedStore) SetWithTTL(group, key, value string, ttl time.Duration) error` | Stores a TTL-bound value in the namespace-prefixed group with the same quota enforcement rules as `Set`. | -- 2.45.3