[agent/codex] AX review #14

Closed
Virgil wants to merge 5 commits from agent/ax-review--banned-imports--test-naming into dev
19 changed files with 687 additions and 323 deletions

View file

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

View file

@ -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)**.

11
LICENCE Normal file
View file

@ -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

View file

@ -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

View file

@ -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 }}`

View file

@ -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<File>_<Function>_<Good|Bad|Ugly>")
}
func TestConventions_Exports_Good_UsageExamples(t *testing.T) {
files := repoGoFiles(t, func(name string) bool {
return core.HasSuffix(name, ".go") && !core.HasSuffix(name, "_test.go")
})
var missing []string
for _, path := range files {
file := parseGoFile(t, path)
for _, decl := range file.Decls {
switch node := decl.(type) {
case *ast.FuncDecl:
if !node.Name.IsExported() {
continue
}
if !core.Contains(commentText(node.Doc), "Usage example:") {
missing = append(missing, core.Concat(path, ": ", node.Name.Name))
}
case *ast.GenDecl:
for _, spec := range node.Specs {
switch item := spec.(type) {
case *ast.TypeSpec:
if !item.Name.IsExported() {
continue
}
if !core.Contains(commentText(item.Doc, node.Doc), "Usage example:") {
missing = append(missing, core.Concat(path, ": ", item.Name.Name))
}
case *ast.ValueSpec:
for _, name := range item.Names {
if !name.IsExported() {
continue
}
if !core.Contains(commentText(item.Doc, node.Doc), "Usage example:") {
missing = append(missing, core.Concat(path, ": ", name.Name))
}
}
}
}
}
}
}
slices.Sort(missing)
assert.Empty(t, missing, "exported declarations must include a usage example in their doc comment")
}
func repoGoFiles(t *testing.T, keep func(name string) bool) []string {
t.Helper()
entries, err := os.ReadDir(".")
require.NoError(t, err)
result := testFS().List(".")
requireCoreOK(t, result)
entries, ok := result.Value.([]fs.DirEntry)
require.True(t, ok, "unexpected directory entry type: %T", result.Value)
var files []string
for _, entry := range entries {
if entry.IsDir() || !keep(entry.Name()) {
continue
}
files = append(files, 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()
}

View file

@ -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)

View file

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

View file

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

View file

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

View file

@ -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()

2
go.mod
View file

@ -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
)

4
go.sum
View file

@ -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=

View file

@ -1,13 +1,11 @@
package store
import (
"errors"
"fmt"
"iter"
"regexp"
"time"
coreerr "dappco.re/go/core/log"
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)
}
}
}

View file

@ -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()

131
specs/RFC.md Normal file
View file

@ -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`. |

132
store.go
View file

@ -4,24 +4,25 @@ import (
"context"
"database/sql"
"iter"
"strings"
"sync"
"text/template"
"time"
"unicode"
coreerr "dappco.re/go/core/log"
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:])
}
}
}

View file

@ -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)

45
test_helpers_test.go Normal file
View file

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