[agent/codex:gpt-5.3-codex-spark] Update the code against the AX (Agent Experience) design pri... #16
14 changed files with 474 additions and 252 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,42 +4,55 @@ import (
|
|||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"io/fs"
|
||||
"slices"
|
||||
"testing"
|
||||
"unicode"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoConventions_Good_BannedImports(t *testing.T) {
|
||||
func TestConventions_Imports_Good_Banned(t *testing.T) {
|
||||
files := repoGoFiles(t, func(name string) bool {
|
||||
return core.HasSuffix(name, ".go")
|
||||
})
|
||||
|
||||
bannedImports := []string{
|
||||
"encoding/json",
|
||||
"errors",
|
||||
"fmt",
|
||||
"os",
|
||||
"os/exec",
|
||||
"path/filepath",
|
||||
"strings",
|
||||
}
|
||||
|
||||
var banned []string
|
||||
for _, path := range files {
|
||||
file := parseGoFile(t, path)
|
||||
for _, spec := range file.Imports {
|
||||
importPath := core.TrimPrefix(core.TrimSuffix(spec.Path.Value, `"`), `"`)
|
||||
if core.HasPrefix(importPath, "forge.lthn.ai/") {
|
||||
banned = append(banned, path+": "+importPath)
|
||||
importPath := trimImportPath(spec.Path.Value)
|
||||
if core.HasPrefix(importPath, "forge.lthn.ai/") || slices.Contains(bannedImports, importPath) {
|
||||
banned = append(banned, core.Concat(path, ": ", importPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(banned)
|
||||
assert.Empty(t, banned, "legacy forge.lthn.ai imports are banned")
|
||||
assert.Empty(t, banned, "banned imports should not appear in repository Go files")
|
||||
}
|
||||
|
||||
func TestRepoConventions_Good_TestNaming(t *testing.T) {
|
||||
func TestConventions_TestNaming_Good_StrictPattern(t *testing.T) {
|
||||
files := repoGoFiles(t, func(name string) bool {
|
||||
return core.HasSuffix(name, "_test.go")
|
||||
})
|
||||
|
||||
allowedClasses := []string{"Good", "Bad", "Ugly"}
|
||||
var invalid []string
|
||||
for _, path := range files {
|
||||
expectedPrefix := testNamePrefix(path)
|
||||
file := parseGoFile(t, path)
|
||||
for _, decl := range file.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
|
|
@ -50,29 +63,82 @@ func TestRepoConventions_Good_TestNaming(t *testing.T) {
|
|||
if !core.HasPrefix(name, "Test") || name == "TestMain" {
|
||||
continue
|
||||
}
|
||||
if core.Contains(name, "_Good") || core.Contains(name, "_Bad") || core.Contains(name, "_Ugly") {
|
||||
if !core.HasPrefix(name, expectedPrefix) {
|
||||
invalid = append(invalid, core.Concat(path, ": ", name))
|
||||
continue
|
||||
}
|
||||
invalid = append(invalid, path+": "+name)
|
||||
parts := core.Split(core.TrimPrefix(name, expectedPrefix), "_")
|
||||
if len(parts) < 2 || parts[0] == "" || !slices.Contains(allowedClasses, parts[1]) {
|
||||
invalid = append(invalid, core.Concat(path, ": ", name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(invalid)
|
||||
assert.Empty(t, invalid, "top-level tests must include _Good, _Bad, or _Ugly in the name")
|
||||
assert.Empty(t, invalid, "top-level tests must follow Test<File>_<Function>_<Good|Bad|Ugly>")
|
||||
}
|
||||
|
||||
func TestConventions_Exports_Good_UsageExamples(t *testing.T) {
|
||||
files := repoGoFiles(t, func(name string) bool {
|
||||
return core.HasSuffix(name, ".go") && !core.HasSuffix(name, "_test.go")
|
||||
})
|
||||
|
||||
var missing []string
|
||||
for _, path := range files {
|
||||
file := parseGoFile(t, path)
|
||||
for _, decl := range file.Decls {
|
||||
switch node := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
if !node.Name.IsExported() {
|
||||
continue
|
||||
}
|
||||
if !core.Contains(commentText(node.Doc), "Usage example:") {
|
||||
missing = append(missing, core.Concat(path, ": ", node.Name.Name))
|
||||
}
|
||||
case *ast.GenDecl:
|
||||
for _, spec := range node.Specs {
|
||||
switch item := spec.(type) {
|
||||
case *ast.TypeSpec:
|
||||
if !item.Name.IsExported() {
|
||||
continue
|
||||
}
|
||||
if !core.Contains(commentText(item.Doc, node.Doc), "Usage example:") {
|
||||
missing = append(missing, core.Concat(path, ": ", item.Name.Name))
|
||||
}
|
||||
case *ast.ValueSpec:
|
||||
for _, name := range item.Names {
|
||||
if !name.IsExported() {
|
||||
continue
|
||||
}
|
||||
if !core.Contains(commentText(item.Doc, node.Doc), "Usage example:") {
|
||||
missing = append(missing, core.Concat(path, ": ", name.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(missing)
|
||||
assert.Empty(t, missing, "exported declarations must include a usage example in their doc comment")
|
||||
}
|
||||
|
||||
func repoGoFiles(t *testing.T, keep func(name string) bool) []string {
|
||||
t.Helper()
|
||||
|
||||
entries, err := os.ReadDir(".")
|
||||
require.NoError(t, err)
|
||||
result := testFS().List(".")
|
||||
requireCoreOK(t, result)
|
||||
|
||||
entries, ok := result.Value.([]fs.DirEntry)
|
||||
require.True(t, ok, "unexpected directory entry type: %T", result.Value)
|
||||
|
||||
var files []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !keep(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
files = append(files, core.CleanPath(entry.Name(), "/"))
|
||||
files = append(files, entry.Name())
|
||||
}
|
||||
|
||||
slices.Sort(files)
|
||||
|
|
@ -82,7 +148,54 @@ func repoGoFiles(t *testing.T, keep func(name string) bool) []string {
|
|||
func parseGoFile(t *testing.T, path string) *ast.File {
|
||||
t.Helper()
|
||||
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ParseComments)
|
||||
require.NoError(t, err)
|
||||
return file
|
||||
}
|
||||
|
||||
func trimImportPath(value string) string {
|
||||
return core.TrimSuffix(core.TrimPrefix(value, `"`), `"`)
|
||||
}
|
||||
|
||||
func testNamePrefix(path string) string {
|
||||
return core.Concat("Test", camelCase(core.TrimSuffix(path, "_test.go")), "_")
|
||||
}
|
||||
|
||||
func camelCase(value string) string {
|
||||
parts := core.Split(value, "_")
|
||||
builder := core.NewBuilder()
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
builder.WriteString(upperFirst(part))
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func upperFirst(value string) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) == 0 {
|
||||
return ""
|
||||
}
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func commentText(groups ...*ast.CommentGroup) string {
|
||||
builder := core.NewBuilder()
|
||||
for _, group := range groups {
|
||||
if group == nil {
|
||||
continue
|
||||
}
|
||||
text := core.Trim(group.Text())
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString(text)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package store
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
|
|
@ -14,12 +13,11 @@ import (
|
|||
// New — schema error path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNew_Bad_SchemaConflict(t *testing.T) {
|
||||
func TestCoverage_New_Bad_SchemaConflict(t *testing.T) {
|
||||
// Pre-create a database with an INDEX named "kv". When New() runs
|
||||
// CREATE TABLE IF NOT EXISTS kv, SQLite returns an error because the
|
||||
// name "kv" is already taken by the index.
|
||||
dir := t.TempDir()
|
||||
dbPath := core.JoinPath(dir, "conflict.db")
|
||||
dbPath := testPath(t, "conflict.db")
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
require.NoError(t, err)
|
||||
|
|
@ -41,7 +39,7 @@ func TestNew_Bad_SchemaConflict(t *testing.T) {
|
|||
// GetAll — scan error path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetAll_Bad_ScanError(t *testing.T) {
|
||||
func TestCoverage_GetAll_Bad_ScanError(t *testing.T) {
|
||||
// Trigger a scan error by inserting a row with a NULL key. The production
|
||||
// code scans into plain strings, which cannot represent NULL.
|
||||
s, err := New(":memory:")
|
||||
|
|
@ -77,11 +75,10 @@ func TestGetAll_Bad_ScanError(t *testing.T) {
|
|||
// GetAll — rows iteration error path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetAll_Bad_RowsError(t *testing.T) {
|
||||
func TestCoverage_GetAll_Bad_RowsError(t *testing.T) {
|
||||
// Trigger rows.Err() by corrupting the database file so that iteration
|
||||
// starts successfully but encounters a malformed page mid-scan.
|
||||
dir := t.TempDir()
|
||||
dbPath := core.JoinPath(dir, "corrupt-getall.db")
|
||||
dbPath := testPath(t, "corrupt-getall.db")
|
||||
|
||||
s, err := New(dbPath)
|
||||
require.NoError(t, err)
|
||||
|
|
@ -105,26 +102,24 @@ func TestGetAll_Bad_RowsError(t *testing.T) {
|
|||
|
||||
// Corrupt data pages in the latter portion of the file (skip the first
|
||||
// pages which hold the schema).
|
||||
info, err := os.Stat(dbPath)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, info.Size(), int64(16384), "DB should be large enough to corrupt")
|
||||
|
||||
f, err := os.OpenFile(dbPath, os.O_RDWR, 0644)
|
||||
require.NoError(t, err)
|
||||
data := requireCoreReadBytes(t, dbPath)
|
||||
garbage := make([]byte, 4096)
|
||||
for i := range garbage {
|
||||
garbage[i] = 0xFF
|
||||
}
|
||||
offset := info.Size() * 3 / 4
|
||||
_, err = f.WriteAt(garbage, offset)
|
||||
require.NoError(t, err)
|
||||
_, err = f.WriteAt(garbage, offset+4096)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, f.Close())
|
||||
require.Greater(t, len(data), len(garbage)*2, "DB should be large enough to corrupt")
|
||||
offset := len(data) * 3 / 4
|
||||
maxOffset := len(data) - (len(garbage) * 2)
|
||||
if offset > maxOffset {
|
||||
offset = maxOffset
|
||||
}
|
||||
copy(data[offset:offset+len(garbage)], garbage)
|
||||
copy(data[offset+len(garbage):offset+(len(garbage)*2)], garbage)
|
||||
requireCoreWriteBytes(t, dbPath, data)
|
||||
|
||||
// Remove WAL/SHM so the reopened connection reads from the main file.
|
||||
os.Remove(dbPath + "-wal")
|
||||
os.Remove(dbPath + "-shm")
|
||||
_ = testFS().Delete(dbPath + "-wal")
|
||||
_ = testFS().Delete(dbPath + "-shm")
|
||||
|
||||
s2, err := New(dbPath)
|
||||
require.NoError(t, err)
|
||||
|
|
@ -139,8 +134,8 @@ func TestGetAll_Bad_RowsError(t *testing.T) {
|
|||
// Render — scan error path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRender_Bad_ScanError(t *testing.T) {
|
||||
// Same NULL-key technique as TestGetAll_Bad_ScanError.
|
||||
func TestCoverage_Render_Bad_ScanError(t *testing.T) {
|
||||
// Same NULL-key technique as TestCoverage_GetAll_Bad_ScanError.
|
||||
s, err := New(":memory:")
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
|
@ -172,10 +167,9 @@ func TestRender_Bad_ScanError(t *testing.T) {
|
|||
// Render — rows iteration error path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRender_Bad_RowsError(t *testing.T) {
|
||||
// Same corruption technique as TestGetAll_Bad_RowsError.
|
||||
dir := t.TempDir()
|
||||
dbPath := core.JoinPath(dir, "corrupt-render.db")
|
||||
func TestCoverage_Render_Bad_RowsError(t *testing.T) {
|
||||
// Same corruption technique as TestCoverage_GetAll_Bad_RowsError.
|
||||
dbPath := testPath(t, "corrupt-render.db")
|
||||
|
||||
s, err := New(dbPath)
|
||||
require.NoError(t, err)
|
||||
|
|
@ -195,24 +189,23 @@ func TestRender_Bad_RowsError(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.NoError(t, raw.Close())
|
||||
|
||||
info, err := os.Stat(dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
f, err := os.OpenFile(dbPath, os.O_RDWR, 0644)
|
||||
require.NoError(t, err)
|
||||
data := requireCoreReadBytes(t, dbPath)
|
||||
garbage := make([]byte, 4096)
|
||||
for i := range garbage {
|
||||
garbage[i] = 0xFF
|
||||
}
|
||||
offset := info.Size() * 3 / 4
|
||||
_, err = f.WriteAt(garbage, offset)
|
||||
require.NoError(t, err)
|
||||
_, err = f.WriteAt(garbage, offset+4096)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, f.Close())
|
||||
require.Greater(t, len(data), len(garbage)*2, "DB should be large enough to corrupt")
|
||||
offset := len(data) * 3 / 4
|
||||
maxOffset := len(data) - (len(garbage) * 2)
|
||||
if offset > maxOffset {
|
||||
offset = maxOffset
|
||||
}
|
||||
copy(data[offset:offset+len(garbage)], garbage)
|
||||
copy(data[offset+len(garbage):offset+(len(garbage)*2)], garbage)
|
||||
requireCoreWriteBytes(t, dbPath, data)
|
||||
|
||||
os.Remove(dbPath + "-wal")
|
||||
os.Remove(dbPath + "-shm")
|
||||
_ = testFS().Delete(dbPath + "-wal")
|
||||
_ = testFS().Delete(dbPath + "-shm")
|
||||
|
||||
s2, err := New(dbPath)
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
10
events.go
10
events.go
|
|
@ -8,18 +8,23 @@ import (
|
|||
)
|
||||
|
||||
// EventType describes the kind of store mutation that occurred.
|
||||
// Usage example: `if event.Type == store.EventSet { return }`
|
||||
type EventType int
|
||||
|
||||
const (
|
||||
// EventSet indicates a key was created or updated.
|
||||
// Usage example: `if event.Type == store.EventSet { return }`
|
||||
EventSet EventType = iota
|
||||
// EventDelete indicates a single key was removed.
|
||||
// Usage example: `if event.Type == store.EventDelete { return }`
|
||||
EventDelete
|
||||
// EventDeleteGroup indicates all keys in a group were removed.
|
||||
// Usage example: `if event.Type == store.EventDeleteGroup { return }`
|
||||
EventDeleteGroup
|
||||
)
|
||||
|
||||
// String returns a human-readable label for the event type.
|
||||
// Usage example: `label := store.EventSet.String()`
|
||||
func (t EventType) String() string {
|
||||
switch t {
|
||||
case EventSet:
|
||||
|
|
@ -35,6 +40,7 @@ func (t EventType) String() string {
|
|||
|
||||
// Event describes a single store mutation. Key is empty for EventDeleteGroup.
|
||||
// Value is only populated for EventSet.
|
||||
// Usage example: `func handle(e store.Event) { _ = e.Group }`
|
||||
type Event struct {
|
||||
Type EventType
|
||||
Group string
|
||||
|
|
@ -45,6 +51,7 @@ type Event struct {
|
|||
|
||||
// Watcher receives events matching a group/key filter. Use Store.Watch to
|
||||
// create one and Store.Unwatch to stop delivery.
|
||||
// Usage example: `watcher := st.Watch("config", "*")`
|
||||
type Watcher struct {
|
||||
// Ch is the public read-only channel that consumers select on.
|
||||
Ch <-chan Event
|
||||
|
|
@ -70,6 +77,7 @@ const watcherBufSize = 16
|
|||
// key. Use "*" as a wildcard: ("mygroup", "*") matches all keys in that group,
|
||||
// ("*", "*") matches every mutation. The returned Watcher has a buffered
|
||||
// channel (cap 16); events are dropped if the consumer falls behind.
|
||||
// Usage example: `watcher := st.Watch("config", "*")`
|
||||
func (s *Store) Watch(group, key string) *Watcher {
|
||||
ch := make(chan Event, watcherBufSize)
|
||||
w := &Watcher{
|
||||
|
|
@ -89,6 +97,7 @@ func (s *Store) Watch(group, key string) *Watcher {
|
|||
|
||||
// Unwatch removes a watcher and closes its channel. Safe to call multiple
|
||||
// times; subsequent calls are no-ops.
|
||||
// Usage example: `st.Unwatch(watcher)`
|
||||
func (s *Store) Unwatch(w *Watcher) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -106,6 +115,7 @@ func (s *Store) Unwatch(w *Watcher) {
|
|||
// are called synchronously in the goroutine that performed the write, so the
|
||||
// caller controls concurrency. Returns an unregister function; calling it stops
|
||||
// future invocations.
|
||||
// Usage example: `unreg := st.OnChange(func(e store.Event) {})`
|
||||
//
|
||||
// This is the integration point for go-ws and similar consumers:
|
||||
//
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import (
|
|||
// Watch — specific key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWatch_Good_SpecificKey(t *testing.T) {
|
||||
func TestEvents_Watch_Good_SpecificKey(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ func TestWatch_Good_SpecificKey(t *testing.T) {
|
|||
// Watch — wildcard key "*"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWatch_Good_WildcardKey(t *testing.T) {
|
||||
func TestEvents_Watch_Good_WildcardKey(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ func TestWatch_Good_WildcardKey(t *testing.T) {
|
|||
// Watch — wildcard ("*", "*") matches everything
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWatch_Good_WildcardAll(t *testing.T) {
|
||||
func TestEvents_Watch_Good_WildcardAll(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ func TestWatch_Good_WildcardAll(t *testing.T) {
|
|||
// Unwatch — stops delivery, channel closed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUnwatch_Good_StopsDelivery(t *testing.T) {
|
||||
func TestEvents_Unwatch_Good_StopsDelivery(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ func TestUnwatch_Good_StopsDelivery(t *testing.T) {
|
|||
require.NoError(t, s.Set("g", "k", "v"))
|
||||
}
|
||||
|
||||
func TestUnwatch_Good_Idempotent(t *testing.T) {
|
||||
func TestEvents_Unwatch_Good_Idempotent(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ func TestUnwatch_Good_Idempotent(t *testing.T) {
|
|||
// Delete triggers event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWatch_Good_DeleteEvent(t *testing.T) {
|
||||
func TestEvents_Watch_Good_DeleteEvent(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ func TestWatch_Good_DeleteEvent(t *testing.T) {
|
|||
// DeleteGroup triggers event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWatch_Good_DeleteGroupEvent(t *testing.T) {
|
||||
func TestEvents_Watch_Good_DeleteGroupEvent(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -182,7 +182,7 @@ func TestWatch_Good_DeleteGroupEvent(t *testing.T) {
|
|||
// OnChange — callback fires on mutations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOnChange_Good_Fires(t *testing.T) {
|
||||
func TestEvents_OnChange_Good_Fires(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -210,7 +210,7 @@ func TestOnChange_Good_Fires(t *testing.T) {
|
|||
// OnChange — unregister stops callback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOnChange_Good_Unregister(t *testing.T) {
|
||||
func TestEvents_OnChange_Good_Unregister(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -236,7 +236,7 @@ func TestOnChange_Good_Unregister(t *testing.T) {
|
|||
// Buffer-full doesn't block the writer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWatch_Good_BufferFullDoesNotBlock(t *testing.T) {
|
||||
func TestEvents_Watch_Good_BufferFullDoesNotBlock(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -275,7 +275,7 @@ func TestWatch_Good_BufferFullDoesNotBlock(t *testing.T) {
|
|||
// Multiple watchers on same key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWatch_Good_MultipleWatchersSameKey(t *testing.T) {
|
||||
func TestEvents_Watch_Good_MultipleWatchersSameKey(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -306,7 +306,7 @@ func TestWatch_Good_MultipleWatchersSameKey(t *testing.T) {
|
|||
// Concurrent Watch/Unwatch during writes (race test)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWatch_Good_ConcurrentWatchUnwatch(t *testing.T) {
|
||||
func TestEvents_Watch_Good_ConcurrentWatchUnwatch(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -347,7 +347,7 @@ func TestWatch_Good_ConcurrentWatchUnwatch(t *testing.T) {
|
|||
// ScopedStore events — prefixed group name
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWatch_Good_ScopedStoreEvents(t *testing.T) {
|
||||
func TestEvents_Watch_Good_ScopedStoreEvents(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -375,7 +375,7 @@ func TestWatch_Good_ScopedStoreEvents(t *testing.T) {
|
|||
// EventType.String()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestEventType_Good_String(t *testing.T) {
|
||||
func TestEvents_EventType_Good_String(t *testing.T) {
|
||||
assert.Equal(t, "set", EventSet.String())
|
||||
assert.Equal(t, "delete", EventDelete.String())
|
||||
assert.Equal(t, "delete_group", EventDeleteGroup.String())
|
||||
|
|
@ -386,7 +386,7 @@ func TestEventType_Good_String(t *testing.T) {
|
|||
// SetWithTTL emits events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWatch_Good_SetWithTTLEmitsEvent(t *testing.T) {
|
||||
func TestEvents_Watch_Good_SetWithTTLEmitsEvent(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -4,7 +4,6 @@ go 1.26.0
|
|||
|
||||
require (
|
||||
dappco.re/go/core v0.8.0-alpha.1
|
||||
dappco.re/go/core/log v0.1.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
modernc.org/sqlite v1.47.0
|
||||
)
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -1,7 +1,5 @@
|
|||
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
|
|
|||
29
scope.go
29
scope.go
|
|
@ -6,7 +6,6 @@ import (
|
|||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// validNamespace matches alphanumeric characters and hyphens (non-empty).
|
||||
|
|
@ -14,6 +13,7 @@ var validNamespace = regexp.MustCompile(`^[a-zA-Z0-9-]+$`)
|
|||
|
||||
// QuotaConfig defines optional limits for a ScopedStore namespace.
|
||||
// Zero values mean unlimited.
|
||||
// Usage example: `quota := store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}`
|
||||
type QuotaConfig struct {
|
||||
MaxKeys int // maximum total keys across all groups in the namespace
|
||||
MaxGroups int // maximum distinct groups in the namespace
|
||||
|
|
@ -21,6 +21,7 @@ type QuotaConfig struct {
|
|||
|
||||
// ScopedStore wraps a *Store and auto-prefixes all group names with a
|
||||
// namespace to prevent key collisions across tenants.
|
||||
// Usage example: `sc, _ := store.NewScoped(st, "tenant-a")`
|
||||
type ScopedStore struct {
|
||||
store *Store
|
||||
namespace string
|
||||
|
|
@ -30,9 +31,10 @@ type ScopedStore struct {
|
|||
// NewScoped creates a ScopedStore that prefixes all groups with the given
|
||||
// namespace. The namespace must be non-empty and contain only alphanumeric
|
||||
// characters and hyphens.
|
||||
// Usage example: `sc, _ := store.NewScoped(st, "tenant-a")`
|
||||
func NewScoped(store *Store, namespace string) (*ScopedStore, error) {
|
||||
if !validNamespace.MatchString(namespace) {
|
||||
return nil, coreerr.E("store.NewScoped", core.Sprintf("namespace %q is invalid (must be non-empty, alphanumeric + hyphens)", namespace), nil)
|
||||
return nil, core.E("store.NewScoped", core.Sprintf("namespace %q is invalid (must be non-empty, alphanumeric + hyphens)", namespace), nil)
|
||||
}
|
||||
return &ScopedStore{store: store, namespace: namespace}, nil
|
||||
}
|
||||
|
|
@ -40,6 +42,7 @@ func NewScoped(store *Store, namespace string) (*ScopedStore, error) {
|
|||
// NewScopedWithQuota creates a ScopedStore with quota enforcement. Quotas are
|
||||
// checked on Set and SetWithTTL before inserting new keys or creating new
|
||||
// groups.
|
||||
// Usage example: `sc, _ := store.NewScopedWithQuota(st, "tenant-a", quota)`
|
||||
func NewScopedWithQuota(store *Store, namespace string, quota QuotaConfig) (*ScopedStore, error) {
|
||||
s, err := NewScoped(store, namespace)
|
||||
if err != nil {
|
||||
|
|
@ -55,17 +58,20 @@ func (s *ScopedStore) prefix(group string) string {
|
|||
}
|
||||
|
||||
// Namespace returns the namespace string for this scoped store.
|
||||
// Usage example: `name := sc.Namespace()`
|
||||
func (s *ScopedStore) Namespace() string {
|
||||
return s.namespace
|
||||
}
|
||||
|
||||
// Get retrieves a value by group and key within the namespace.
|
||||
// Usage example: `value, err := sc.Get("config", "theme")`
|
||||
func (s *ScopedStore) Get(group, key string) (string, error) {
|
||||
return s.store.Get(s.prefix(group), key)
|
||||
}
|
||||
|
||||
// Set stores a value by group and key within the namespace. If quotas are
|
||||
// configured, they are checked before inserting new keys or groups.
|
||||
// Usage example: `err := sc.Set("config", "theme", "dark")`
|
||||
func (s *ScopedStore) Set(group, key, value string) error {
|
||||
if err := s.checkQuota(group, key); err != nil {
|
||||
return err
|
||||
|
|
@ -75,6 +81,7 @@ func (s *ScopedStore) Set(group, key, value string) error {
|
|||
|
||||
// SetWithTTL stores a value with a time-to-live within the namespace. Quota
|
||||
// checks are applied for new keys and groups.
|
||||
// Usage example: `err := sc.SetWithTTL("sessions", "token", "abc", time.Hour)`
|
||||
func (s *ScopedStore) SetWithTTL(group, key, value string, ttl time.Duration) error {
|
||||
if err := s.checkQuota(group, key); err != nil {
|
||||
return err
|
||||
|
|
@ -83,34 +90,40 @@ func (s *ScopedStore) SetWithTTL(group, key, value string, ttl time.Duration) er
|
|||
}
|
||||
|
||||
// Delete removes a single key from a group within the namespace.
|
||||
// Usage example: `err := sc.Delete("config", "theme")`
|
||||
func (s *ScopedStore) Delete(group, key string) error {
|
||||
return s.store.Delete(s.prefix(group), key)
|
||||
}
|
||||
|
||||
// DeleteGroup removes all keys in a group within the namespace.
|
||||
// Usage example: `err := sc.DeleteGroup("cache")`
|
||||
func (s *ScopedStore) DeleteGroup(group string) error {
|
||||
return s.store.DeleteGroup(s.prefix(group))
|
||||
}
|
||||
|
||||
// GetAll returns all non-expired key-value pairs in a group within the
|
||||
// namespace.
|
||||
// Usage example: `all, err := sc.GetAll("config")`
|
||||
func (s *ScopedStore) GetAll(group string) (map[string]string, error) {
|
||||
return s.store.GetAll(s.prefix(group))
|
||||
}
|
||||
|
||||
// All returns an iterator over all non-expired key-value pairs in a group
|
||||
// within the namespace.
|
||||
// Usage example: `for kv, err := range sc.All("config") { _ = kv; _ = err }`
|
||||
func (s *ScopedStore) All(group string) iter.Seq2[KV, error] {
|
||||
return s.store.All(s.prefix(group))
|
||||
}
|
||||
|
||||
// Count returns the number of non-expired keys in a group within the namespace.
|
||||
// Usage example: `n, err := sc.Count("config")`
|
||||
func (s *ScopedStore) Count(group string) (int, error) {
|
||||
return s.store.Count(s.prefix(group))
|
||||
}
|
||||
|
||||
// Render loads all non-expired key-value pairs from a namespaced group and
|
||||
// renders a Go template.
|
||||
// Usage example: `out, err := sc.Render("Hello {{ .name }}", "user")`
|
||||
func (s *ScopedStore) Render(tmplStr, group string) (string, error) {
|
||||
return s.store.Render(tmplStr, s.prefix(group))
|
||||
}
|
||||
|
|
@ -134,17 +147,17 @@ func (s *ScopedStore) checkQuota(group, key string) error {
|
|||
}
|
||||
if !core.Is(err, ErrNotFound) {
|
||||
// A database error occurred, not just a "not found" result.
|
||||
return coreerr.E("store.ScopedStore", "quota check", err)
|
||||
return core.E("store.ScopedStore", "quota check", err)
|
||||
}
|
||||
|
||||
// Check MaxKeys quota.
|
||||
if s.quota.MaxKeys > 0 {
|
||||
count, err := s.store.CountAll(nsPrefix)
|
||||
if err != nil {
|
||||
return coreerr.E("store.ScopedStore", "quota check", err)
|
||||
return core.E("store.ScopedStore", "quota check", err)
|
||||
}
|
||||
if count >= s.quota.MaxKeys {
|
||||
return coreerr.E("store.ScopedStore", core.Sprintf("key limit (%d)", s.quota.MaxKeys), ErrQuotaExceeded)
|
||||
return core.E("store.ScopedStore", core.Sprintf("key limit (%d)", s.quota.MaxKeys), ErrQuotaExceeded)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,19 +165,19 @@ func (s *ScopedStore) checkQuota(group, key string) error {
|
|||
if s.quota.MaxGroups > 0 {
|
||||
groupCount, err := s.store.Count(prefixedGroup)
|
||||
if err != nil {
|
||||
return coreerr.E("store.ScopedStore", "quota check", err)
|
||||
return core.E("store.ScopedStore", "quota check", err)
|
||||
}
|
||||
if groupCount == 0 {
|
||||
// This group is new — check if adding it would exceed the group limit.
|
||||
count := 0
|
||||
for _, err := range s.store.GroupsSeq(nsPrefix) {
|
||||
if err != nil {
|
||||
return coreerr.E("store.ScopedStore", "quota check", err)
|
||||
return core.E("store.ScopedStore", "quota check", err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
if count >= s.quota.MaxGroups {
|
||||
return coreerr.E("store.ScopedStore", core.Sprintf("group limit (%d)", s.quota.MaxGroups), ErrQuotaExceeded)
|
||||
return core.E("store.ScopedStore", core.Sprintf("group limit (%d)", s.quota.MaxGroups), ErrQuotaExceeded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
// NewScoped — constructor validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNewScoped_Good(t *testing.T) {
|
||||
func TestScope_NewScoped_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ func TestNewScoped_Good(t *testing.T) {
|
|||
assert.Equal(t, "tenant-1", sc.Namespace())
|
||||
}
|
||||
|
||||
func TestNewScoped_Good_AlphanumericHyphens(t *testing.T) {
|
||||
func TestScope_NewScoped_Good_AlphanumericHyphens(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ func TestNewScoped_Good_AlphanumericHyphens(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNewScoped_Bad_Empty(t *testing.T) {
|
||||
func TestScope_NewScoped_Bad_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ func TestNewScoped_Bad_Empty(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "invalid")
|
||||
}
|
||||
|
||||
func TestNewScoped_Bad_InvalidChars(t *testing.T) {
|
||||
func TestScope_NewScoped_Bad_InvalidChars(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ func TestNewScoped_Bad_InvalidChars(t *testing.T) {
|
|||
// ScopedStore — basic CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestScopedStore_Good_SetGet(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_SetGet(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ func TestScopedStore_Good_SetGet(t *testing.T) {
|
|||
assert.Equal(t, "dark", val)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ func TestScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) {
|
|||
assert.True(t, core.Is(err, ErrNotFound))
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_NamespaceIsolation(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_NamespaceIsolation(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ func TestScopedStore_Good_NamespaceIsolation(t *testing.T) {
|
|||
assert.Equal(t, "red", vb)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_Delete(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_Delete(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ func TestScopedStore_Good_Delete(t *testing.T) {
|
|||
assert.True(t, core.Is(err, ErrNotFound))
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_DeleteGroup(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_DeleteGroup(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -133,7 +133,7 @@ func TestScopedStore_Good_DeleteGroup(t *testing.T) {
|
|||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_GetAll(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_GetAll(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ func TestScopedStore_Good_GetAll(t *testing.T) {
|
|||
assert.Equal(t, map[string]string{"z": "3"}, allB)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_Count(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_Count(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -166,7 +166,7 @@ func TestScopedStore_Good_Count(t *testing.T) {
|
|||
assert.Equal(t, 2, n)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_SetWithTTL(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_SetWithTTL(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -178,7 +178,7 @@ func TestScopedStore_Good_SetWithTTL(t *testing.T) {
|
|||
assert.Equal(t, "v", val)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_SetWithTTL_Expires(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_SetWithTTL_Expires(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -190,7 +190,7 @@ func TestScopedStore_Good_SetWithTTL_Expires(t *testing.T) {
|
|||
assert.True(t, core.Is(err, ErrNotFound))
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_Render(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_Render(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -206,7 +206,7 @@ func TestScopedStore_Good_Render(t *testing.T) {
|
|||
// Quota enforcement — MaxKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestQuota_Good_MaxKeys(t *testing.T) {
|
||||
func TestScope_Quota_Good_MaxKeys(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -224,7 +224,7 @@ func TestQuota_Good_MaxKeys(t *testing.T) {
|
|||
assert.True(t, core.Is(err, ErrQuotaExceeded), "expected ErrQuotaExceeded, got: %v", err)
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxKeys_AcrossGroups(t *testing.T) {
|
||||
func TestScope_Quota_Good_MaxKeys_AcrossGroups(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -239,7 +239,7 @@ func TestQuota_Good_MaxKeys_AcrossGroups(t *testing.T) {
|
|||
assert.True(t, core.Is(err, ErrQuotaExceeded))
|
||||
}
|
||||
|
||||
func TestQuota_Good_UpsertDoesNotCount(t *testing.T) {
|
||||
func TestScope_Quota_Good_UpsertDoesNotCount(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -257,7 +257,7 @@ func TestQuota_Good_UpsertDoesNotCount(t *testing.T) {
|
|||
assert.Equal(t, "updated", val)
|
||||
}
|
||||
|
||||
func TestQuota_Good_DeleteAndReInsert(t *testing.T) {
|
||||
func TestScope_Quota_Good_DeleteAndReInsert(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -272,7 +272,7 @@ func TestQuota_Good_DeleteAndReInsert(t *testing.T) {
|
|||
require.NoError(t, sc.Set("g", "d", "4"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_ZeroMeansUnlimited(t *testing.T) {
|
||||
func TestScope_Quota_Good_ZeroMeansUnlimited(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -284,7 +284,7 @@ func TestQuota_Good_ZeroMeansUnlimited(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestQuota_Good_ExpiredKeysExcluded(t *testing.T) {
|
||||
func TestScope_Quota_Good_ExpiredKeysExcluded(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -306,7 +306,7 @@ func TestQuota_Good_ExpiredKeysExcluded(t *testing.T) {
|
|||
assert.True(t, core.Is(err, ErrQuotaExceeded))
|
||||
}
|
||||
|
||||
func TestQuota_Good_SetWithTTL_Enforced(t *testing.T) {
|
||||
func TestScope_Quota_Good_SetWithTTL_Enforced(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -323,7 +323,7 @@ func TestQuota_Good_SetWithTTL_Enforced(t *testing.T) {
|
|||
// Quota enforcement — MaxGroups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestQuota_Good_MaxGroups(t *testing.T) {
|
||||
func TestScope_Quota_Good_MaxGroups(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -339,7 +339,7 @@ func TestQuota_Good_MaxGroups(t *testing.T) {
|
|||
assert.True(t, core.Is(err, ErrQuotaExceeded))
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxGroups_ExistingGroupOK(t *testing.T) {
|
||||
func TestScope_Quota_Good_MaxGroups_ExistingGroupOK(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -353,7 +353,7 @@ func TestQuota_Good_MaxGroups_ExistingGroupOK(t *testing.T) {
|
|||
require.NoError(t, sc.Set("g2", "d", "4"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxGroups_DeleteAndRecreate(t *testing.T) {
|
||||
func TestScope_Quota_Good_MaxGroups_DeleteAndRecreate(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -367,7 +367,7 @@ func TestQuota_Good_MaxGroups_DeleteAndRecreate(t *testing.T) {
|
|||
require.NoError(t, sc.Set("g3", "k", "v"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxGroups_ZeroUnlimited(t *testing.T) {
|
||||
func TestScope_Quota_Good_MaxGroups_ZeroUnlimited(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -378,7 +378,7 @@ func TestQuota_Good_MaxGroups_ZeroUnlimited(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxGroups_ExpiredGroupExcluded(t *testing.T) {
|
||||
func TestScope_Quota_Good_MaxGroups_ExpiredGroupExcluded(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -394,7 +394,7 @@ func TestQuota_Good_MaxGroups_ExpiredGroupExcluded(t *testing.T) {
|
|||
require.NoError(t, sc.Set("g3", "k", "v"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_BothLimits(t *testing.T) {
|
||||
func TestScope_Quota_Good_BothLimits(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -411,7 +411,7 @@ func TestQuota_Good_BothLimits(t *testing.T) {
|
|||
require.NoError(t, sc.Set("g1", "d", "4"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_DoesNotAffectOtherNamespaces(t *testing.T) {
|
||||
func TestScope_Quota_Good_DoesNotAffectOtherNamespaces(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -436,7 +436,7 @@ func TestQuota_Good_DoesNotAffectOtherNamespaces(t *testing.T) {
|
|||
// CountAll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCountAll_Good_WithPrefix(t *testing.T) {
|
||||
func TestScope_CountAll_Good_WithPrefix(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -454,7 +454,7 @@ func TestCountAll_Good_WithPrefix(t *testing.T) {
|
|||
assert.Equal(t, 1, n)
|
||||
}
|
||||
|
||||
func TestCountAll_Good_WithPrefix_Wildcards(t *testing.T) {
|
||||
func TestScope_CountAll_Good_WithPrefix_Wildcards(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -478,7 +478,7 @@ func TestCountAll_Good_WithPrefix_Wildcards(t *testing.T) {
|
|||
assert.Equal(t, 1, n)
|
||||
}
|
||||
|
||||
func TestCountAll_Good_EmptyPrefix(t *testing.T) {
|
||||
func TestScope_CountAll_Good_EmptyPrefix(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -490,7 +490,7 @@ func TestCountAll_Good_EmptyPrefix(t *testing.T) {
|
|||
assert.Equal(t, 2, n)
|
||||
}
|
||||
|
||||
func TestCountAll_Good_ExcludesExpired(t *testing.T) {
|
||||
func TestScope_CountAll_Good_ExcludesExpired(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -503,7 +503,7 @@ func TestCountAll_Good_ExcludesExpired(t *testing.T) {
|
|||
assert.Equal(t, 1, n, "expired keys should not be counted")
|
||||
}
|
||||
|
||||
func TestCountAll_Good_Empty(t *testing.T) {
|
||||
func TestScope_CountAll_Good_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -512,7 +512,7 @@ func TestCountAll_Good_Empty(t *testing.T) {
|
|||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
func TestCountAll_Bad_ClosedStore(t *testing.T) {
|
||||
func TestScope_CountAll_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -524,7 +524,7 @@ func TestCountAll_Bad_ClosedStore(t *testing.T) {
|
|||
// Groups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGroups_Good_WithPrefix(t *testing.T) {
|
||||
func TestScope_Groups_Good_WithPrefix(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -540,7 +540,7 @@ func TestGroups_Good_WithPrefix(t *testing.T) {
|
|||
assert.Contains(t, groups, "ns-a:g2")
|
||||
}
|
||||
|
||||
func TestGroups_Good_EmptyPrefix(t *testing.T) {
|
||||
func TestScope_Groups_Good_EmptyPrefix(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -553,7 +553,7 @@ func TestGroups_Good_EmptyPrefix(t *testing.T) {
|
|||
assert.Len(t, groups, 3)
|
||||
}
|
||||
|
||||
func TestGroups_Good_Distinct(t *testing.T) {
|
||||
func TestScope_Groups_Good_Distinct(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -568,7 +568,7 @@ func TestGroups_Good_Distinct(t *testing.T) {
|
|||
assert.Equal(t, "g1", groups[0])
|
||||
}
|
||||
|
||||
func TestGroups_Good_ExcludesExpired(t *testing.T) {
|
||||
func TestScope_Groups_Good_ExcludesExpired(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -582,7 +582,7 @@ func TestGroups_Good_ExcludesExpired(t *testing.T) {
|
|||
assert.Equal(t, "ns:g1", groups[0])
|
||||
}
|
||||
|
||||
func TestGroups_Good_Empty(t *testing.T) {
|
||||
func TestScope_Groups_Good_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -591,7 +591,7 @@ func TestGroups_Good_Empty(t *testing.T) {
|
|||
assert.Empty(t, groups)
|
||||
}
|
||||
|
||||
func TestGroups_Bad_ClosedStore(t *testing.T) {
|
||||
func TestScope_Groups_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
|
|||
123
store.go
123
store.go
|
|
@ -4,25 +4,25 @@ import (
|
|||
"context"
|
||||
"database/sql"
|
||||
"iter"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a key does not exist in the store.
|
||||
// Use errors.Is(err, ErrNotFound) to test for it.
|
||||
var ErrNotFound = coreerr.E("store", "not found", nil)
|
||||
// Usage example: `if core.Is(err, store.ErrNotFound) { return }`
|
||||
var ErrNotFound = core.E("store", "not found", nil)
|
||||
|
||||
// ErrQuotaExceeded is returned when a namespace quota limit is reached.
|
||||
// Use errors.Is(err, ErrQuotaExceeded) to test for it.
|
||||
var ErrQuotaExceeded = coreerr.E("store", "quota exceeded", nil)
|
||||
// Usage example: `if core.Is(err, store.ErrQuotaExceeded) { return }`
|
||||
var ErrQuotaExceeded = core.E("store", "quota exceeded", nil)
|
||||
|
||||
// Store is a group-namespaced key-value store backed by SQLite.
|
||||
// Usage example: `st, _ := store.New(":memory:")`
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
cancel context.CancelFunc
|
||||
|
|
@ -37,10 +37,11 @@ type Store struct {
|
|||
}
|
||||
|
||||
// New creates a Store at the given SQLite path. Use ":memory:" for tests.
|
||||
// Usage example: `st, _ := store.New("/tmp/config.db")`
|
||||
func New(dbPath string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, coreerr.E("store.New", "open", err)
|
||||
return nil, core.E("store.New", "open", err)
|
||||
}
|
||||
// Serialise all access through a single connection. SQLite only supports
|
||||
// one writer at a time; using a pool causes SQLITE_BUSY under contention
|
||||
|
|
@ -49,11 +50,11 @@ func New(dbPath string) (*Store, error) {
|
|||
db.SetMaxOpenConns(1)
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, coreerr.E("store.New", "WAL", err)
|
||||
return nil, core.E("store.New", "WAL", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
|
||||
db.Close()
|
||||
return nil, coreerr.E("store.New", "busy_timeout", err)
|
||||
return nil, core.E("store.New", "busy_timeout", err)
|
||||
}
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS kv (
|
||||
grp TEXT NOT NULL,
|
||||
|
|
@ -63,14 +64,14 @@ func New(dbPath string) (*Store, error) {
|
|||
PRIMARY KEY (grp, key)
|
||||
)`); err != nil {
|
||||
db.Close()
|
||||
return nil, coreerr.E("store.New", "schema", err)
|
||||
return nil, core.E("store.New", "schema", err)
|
||||
}
|
||||
// Ensure the expires_at column exists for databases created before TTL support.
|
||||
if _, err := db.Exec("ALTER TABLE kv ADD COLUMN expires_at INTEGER"); err != nil {
|
||||
// SQLite returns "duplicate column name" if it already exists.
|
||||
if !core.Contains(err.Error(), "duplicate column name") {
|
||||
db.Close()
|
||||
return nil, coreerr.E("store.New", "migration", err)
|
||||
return nil, core.E("store.New", "migration", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,6 +82,7 @@ func New(dbPath string) (*Store, error) {
|
|||
}
|
||||
|
||||
// Close stops the background purge goroutine and closes the underlying database.
|
||||
// Usage example: `defer st.Close()`
|
||||
func (s *Store) Close() error {
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
|
|
@ -89,6 +91,7 @@ func (s *Store) Close() error {
|
|||
|
||||
// Get retrieves a value by group and key. Expired keys are lazily deleted and
|
||||
// treated as not found.
|
||||
// Usage example: `value, err := st.Get("config", "theme")`
|
||||
func (s *Store) Get(group, key string) (string, error) {
|
||||
var val string
|
||||
var expiresAt sql.NullInt64
|
||||
|
|
@ -97,10 +100,10 @@ func (s *Store) Get(group, key string) (string, error) {
|
|||
group, key,
|
||||
).Scan(&val, &expiresAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", coreerr.E("store.Get", group+"/"+key, ErrNotFound)
|
||||
return "", core.E("store.Get", core.Concat(group, "/", key), ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return "", coreerr.E("store.Get", "query", err)
|
||||
return "", core.E("store.Get", "query", err)
|
||||
}
|
||||
if expiresAt.Valid && expiresAt.Int64 <= time.Now().UnixMilli() {
|
||||
// Lazily delete the expired entry.
|
||||
|
|
@ -109,13 +112,14 @@ func (s *Store) Get(group, key string) (string, error) {
|
|||
// For now, we wrap the error to provide context if the delete fails
|
||||
// for reasons other than "already deleted".
|
||||
}
|
||||
return "", coreerr.E("store.Get", group+"/"+key, ErrNotFound)
|
||||
return "", core.E("store.Get", core.Concat(group, "/", key), ErrNotFound)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// Set stores a value by group and key, overwriting if exists. The key has no
|
||||
// expiry (it persists until explicitly deleted).
|
||||
// Usage example: `err := st.Set("config", "theme", "dark")`
|
||||
func (s *Store) Set(group, key, value string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO kv (grp, key, value, expires_at) VALUES (?, ?, ?, NULL)
|
||||
|
|
@ -123,7 +127,7 @@ func (s *Store) Set(group, key, value string) error {
|
|||
group, key, value,
|
||||
)
|
||||
if err != nil {
|
||||
return coreerr.E("store.Set", "exec", err)
|
||||
return core.E("store.Set", "exec", err)
|
||||
}
|
||||
s.notify(Event{Type: EventSet, Group: group, Key: key, Value: value, Timestamp: time.Now()})
|
||||
return nil
|
||||
|
|
@ -132,6 +136,7 @@ func (s *Store) Set(group, key, value string) error {
|
|||
// SetWithTTL stores a value that expires after the given duration. After expiry
|
||||
// the key is lazily removed on the next Get and periodically by a background
|
||||
// purge goroutine.
|
||||
// Usage example: `err := st.SetWithTTL("session", "token", "abc", time.Hour)`
|
||||
func (s *Store) SetWithTTL(group, key, value string, ttl time.Duration) error {
|
||||
expiresAt := time.Now().Add(ttl).UnixMilli()
|
||||
_, err := s.db.Exec(
|
||||
|
|
@ -140,23 +145,25 @@ func (s *Store) SetWithTTL(group, key, value string, ttl time.Duration) error {
|
|||
group, key, value, expiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return coreerr.E("store.SetWithTTL", "exec", err)
|
||||
return core.E("store.SetWithTTL", "exec", err)
|
||||
}
|
||||
s.notify(Event{Type: EventSet, Group: group, Key: key, Value: value, Timestamp: time.Now()})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a single key from a group.
|
||||
// Usage example: `err := st.Delete("config", "theme")`
|
||||
func (s *Store) Delete(group, key string) error {
|
||||
_, err := s.db.Exec("DELETE FROM kv WHERE grp = ? AND key = ?", group, key)
|
||||
if err != nil {
|
||||
return coreerr.E("store.Delete", "exec", err)
|
||||
return core.E("store.Delete", "exec", err)
|
||||
}
|
||||
s.notify(Event{Type: EventDelete, Group: group, Key: key, Timestamp: time.Now()})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count returns the number of non-expired keys in a group.
|
||||
// Usage example: `n, err := st.Count("config")`
|
||||
func (s *Store) Count(group string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(
|
||||
|
|
@ -164,32 +171,35 @@ func (s *Store) Count(group string) (int, error) {
|
|||
group, time.Now().UnixMilli(),
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, coreerr.E("store.Count", "query", err)
|
||||
return 0, core.E("store.Count", "query", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// DeleteGroup removes all keys in a group.
|
||||
// Usage example: `err := st.DeleteGroup("cache")`
|
||||
func (s *Store) DeleteGroup(group string) error {
|
||||
_, err := s.db.Exec("DELETE FROM kv WHERE grp = ?", group)
|
||||
if err != nil {
|
||||
return coreerr.E("store.DeleteGroup", "exec", err)
|
||||
return core.E("store.DeleteGroup", "exec", err)
|
||||
}
|
||||
s.notify(Event{Type: EventDeleteGroup, Group: group, Timestamp: time.Now()})
|
||||
return nil
|
||||
}
|
||||
|
||||
// KV represents a key-value pair.
|
||||
// Usage example: `for kv, err := range st.All("config") { _ = kv }`
|
||||
type KV struct {
|
||||
Key, Value string
|
||||
}
|
||||
|
||||
// GetAll returns all non-expired key-value pairs in a group.
|
||||
// Usage example: `all, err := st.GetAll("config")`
|
||||
func (s *Store) GetAll(group string) (map[string]string, error) {
|
||||
result := make(map[string]string)
|
||||
for kv, err := range s.All(group) {
|
||||
if err != nil {
|
||||
return nil, coreerr.E("store.GetAll", "iterate", err)
|
||||
return nil, core.E("store.GetAll", "iterate", err)
|
||||
}
|
||||
result[kv.Key] = kv.Value
|
||||
}
|
||||
|
|
@ -197,6 +207,7 @@ func (s *Store) GetAll(group string) (map[string]string, error) {
|
|||
}
|
||||
|
||||
// All returns an iterator over all non-expired key-value pairs in a group.
|
||||
// Usage example: `for kv, err := range st.All("config") { _ = kv; _ = err }`
|
||||
func (s *Store) All(group string) iter.Seq2[KV, error] {
|
||||
return func(yield func(KV, error) bool) {
|
||||
rows, err := s.db.Query(
|
||||
|
|
@ -204,7 +215,7 @@ func (s *Store) All(group string) iter.Seq2[KV, error] {
|
|||
group, time.Now().UnixMilli(),
|
||||
)
|
||||
if err != nil {
|
||||
yield(KV{}, err)
|
||||
yield(KV{}, core.E("store.All", "query", err))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
|
@ -212,7 +223,7 @@ func (s *Store) All(group string) iter.Seq2[KV, error] {
|
|||
for rows.Next() {
|
||||
var kv KV
|
||||
if err := rows.Scan(&kv.Key, &kv.Value); err != nil {
|
||||
if !yield(KV{}, coreerr.E("store.All", "scan", err)) {
|
||||
if !yield(KV{}, core.E("store.All", "scan", err)) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
|
|
@ -222,55 +233,59 @@ func (s *Store) All(group string) iter.Seq2[KV, error] {
|
|||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
yield(KV{}, coreerr.E("store.All", "rows", err))
|
||||
yield(KV{}, core.E("store.All", "rows", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetSplit retrieves a value and returns an iterator over its parts, split by
|
||||
// sep.
|
||||
// Usage example: `parts, _ := st.GetSplit("config", "hosts", ",")`
|
||||
func (s *Store) GetSplit(group, key, sep string) (iter.Seq[string], error) {
|
||||
val, err := s.Get(group, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return strings.SplitSeq(val, sep), nil
|
||||
return splitSeq(val, sep), nil
|
||||
}
|
||||
|
||||
// GetFields retrieves a value and returns an iterator over its parts, split by
|
||||
// whitespace.
|
||||
// Usage example: `fields, _ := st.GetFields("config", "flags")`
|
||||
func (s *Store) GetFields(group, key string) (iter.Seq[string], error) {
|
||||
val, err := s.Get(group, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return strings.FieldsSeq(val), nil
|
||||
return fieldsSeq(val), nil
|
||||
}
|
||||
|
||||
// Render loads all non-expired key-value pairs from a group and renders a Go
|
||||
// template.
|
||||
// Usage example: `out, err := st.Render("Hello {{ .name }}", "user")`
|
||||
func (s *Store) Render(tmplStr, group string) (string, error) {
|
||||
vars := make(map[string]string)
|
||||
for kv, err := range s.All(group) {
|
||||
if err != nil {
|
||||
return "", coreerr.E("store.Render", "iterate", err)
|
||||
return "", core.E("store.Render", "iterate", err)
|
||||
}
|
||||
vars[kv.Key] = kv.Value
|
||||
}
|
||||
|
||||
tmpl, err := template.New("render").Parse(tmplStr)
|
||||
if err != nil {
|
||||
return "", coreerr.E("store.Render", "parse", err)
|
||||
return "", core.E("store.Render", "parse", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
if err := tmpl.Execute(&b, vars); err != nil {
|
||||
return "", coreerr.E("store.Render", "exec", err)
|
||||
b := core.NewBuilder()
|
||||
if err := tmpl.Execute(b, vars); err != nil {
|
||||
return "", core.E("store.Render", "exec", err)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// CountAll returns the total number of non-expired keys across all groups whose
|
||||
// name starts with the given prefix. Pass an empty string to count everything.
|
||||
// Usage example: `n, err := st.CountAll("tenant-a:")`
|
||||
func (s *Store) CountAll(prefix string) (int, error) {
|
||||
var n int
|
||||
var err error
|
||||
|
|
@ -286,13 +301,14 @@ func (s *Store) CountAll(prefix string) (int, error) {
|
|||
).Scan(&n)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, coreerr.E("store.CountAll", "query", err)
|
||||
return 0, core.E("store.CountAll", "query", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Groups returns the distinct group names of all non-expired keys. If prefix is
|
||||
// non-empty, only groups starting with that prefix are returned.
|
||||
// Usage example: `groups, err := st.Groups("tenant-a:")`
|
||||
func (s *Store) Groups(prefix string) ([]string, error) {
|
||||
var groups []string
|
||||
for g, err := range s.GroupsSeq(prefix) {
|
||||
|
|
@ -306,6 +322,7 @@ func (s *Store) Groups(prefix string) ([]string, error) {
|
|||
|
||||
// GroupsSeq returns an iterator over the distinct group names of all
|
||||
// non-expired keys.
|
||||
// Usage example: `for group, err := range st.GroupsSeq("tenant-a:") { _ = group }`
|
||||
func (s *Store) GroupsSeq(prefix string) iter.Seq2[string, error] {
|
||||
return func(yield func(string, error) bool) {
|
||||
var rows *sql.Rows
|
||||
|
|
@ -323,7 +340,7 @@ func (s *Store) GroupsSeq(prefix string) iter.Seq2[string, error] {
|
|||
)
|
||||
}
|
||||
if err != nil {
|
||||
yield("", coreerr.E("store.Groups", "query", err))
|
||||
yield("", core.E("store.Groups", "query", err))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
|
@ -331,7 +348,7 @@ func (s *Store) GroupsSeq(prefix string) iter.Seq2[string, error] {
|
|||
for rows.Next() {
|
||||
var g string
|
||||
if err := rows.Scan(&g); err != nil {
|
||||
if !yield("", coreerr.E("store.Groups", "scan", err)) {
|
||||
if !yield("", core.E("store.Groups", "scan", err)) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
|
|
@ -341,7 +358,7 @@ func (s *Store) GroupsSeq(prefix string) iter.Seq2[string, error] {
|
|||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
yield("", coreerr.E("store.Groups", "rows", err))
|
||||
yield("", core.E("store.Groups", "rows", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -356,11 +373,12 @@ func escapeLike(s string) string {
|
|||
|
||||
// PurgeExpired deletes all expired keys across all groups. Returns the number
|
||||
// of rows removed.
|
||||
// Usage example: `removed, err := st.PurgeExpired()`
|
||||
func (s *Store) PurgeExpired() (int64, error) {
|
||||
res, err := s.db.Exec("DELETE FROM kv WHERE expires_at IS NOT NULL AND expires_at <= ?",
|
||||
time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
return 0, coreerr.E("store.PurgeExpired", "exec", err)
|
||||
return 0, core.E("store.PurgeExpired", "exec", err)
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
|
@ -386,3 +404,38 @@ func (s *Store) startPurge(ctx context.Context) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
// splitSeq preserves the iter.Seq API without importing strings directly.
|
||||
func splitSeq(value, sep string) iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
for _, part := range core.Split(value, sep) {
|
||||
if !yield(part) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fieldsSeq yields whitespace-delimited fields without importing strings.
|
||||
func fieldsSeq(value string) iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
start := -1
|
||||
for i, r := range value {
|
||||
if unicode.IsSpace(r) {
|
||||
if start >= 0 {
|
||||
if !yield(value[start:i]) {
|
||||
return
|
||||
}
|
||||
start = -1
|
||||
}
|
||||
continue
|
||||
}
|
||||
if start < 0 {
|
||||
start = i
|
||||
}
|
||||
}
|
||||
if start >= 0 {
|
||||
yield(value[start:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
160
store_test.go
160
store_test.go
|
|
@ -3,9 +3,8 @@ package store
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -18,15 +17,15 @@ import (
|
|||
// New
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNew_Good_Memory(t *testing.T) {
|
||||
func TestStore_New_Good_Memory(t *testing.T) {
|
||||
s, err := New(":memory:")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, s)
|
||||
defer s.Close()
|
||||
}
|
||||
|
||||
func TestNew_Good_FileBacked(t *testing.T) {
|
||||
dbPath := core.JoinPath(t.TempDir(), "test.db")
|
||||
func TestStore_New_Good_FileBacked(t *testing.T) {
|
||||
dbPath := testPath(t, "test.db")
|
||||
s, err := New(dbPath)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, s)
|
||||
|
|
@ -45,7 +44,7 @@ func TestNew_Good_FileBacked(t *testing.T) {
|
|||
assert.Equal(t, "v", val)
|
||||
}
|
||||
|
||||
func TestNew_Bad_InvalidPath(t *testing.T) {
|
||||
func TestStore_New_Bad_InvalidPath(t *testing.T) {
|
||||
// A path under a non-existent directory should fail at the WAL pragma step
|
||||
// because sql.Open is lazy and only validates on first use.
|
||||
_, err := New("/no/such/directory/test.db")
|
||||
|
|
@ -53,21 +52,20 @@ func TestNew_Bad_InvalidPath(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "store.New")
|
||||
}
|
||||
|
||||
func TestNew_Bad_CorruptFile(t *testing.T) {
|
||||
func TestStore_New_Bad_CorruptFile(t *testing.T) {
|
||||
// A file that exists but is not a valid SQLite database should fail.
|
||||
dir := t.TempDir()
|
||||
dbPath := core.JoinPath(dir, "corrupt.db")
|
||||
require.NoError(t, os.WriteFile(dbPath, []byte("not a sqlite database"), 0644))
|
||||
dbPath := testPath(t, "corrupt.db")
|
||||
requireCoreOK(t, testFS().Write(dbPath, "not a sqlite database"))
|
||||
|
||||
_, err := New(dbPath)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "store.New")
|
||||
}
|
||||
|
||||
func TestNew_Bad_ReadOnlyDir(t *testing.T) {
|
||||
func TestStore_New_Bad_ReadOnlyDir(t *testing.T) {
|
||||
// A path in a read-only directory should fail when SQLite tries to create the WAL file.
|
||||
dir := t.TempDir()
|
||||
dbPath := core.JoinPath(dir, "readonly.db")
|
||||
dbPath := core.Path(dir, "readonly.db")
|
||||
|
||||
// Create a valid DB first, then make the directory read-only.
|
||||
s, err := New(dbPath)
|
||||
|
|
@ -75,10 +73,10 @@ func TestNew_Bad_ReadOnlyDir(t *testing.T) {
|
|||
require.NoError(t, s.Close())
|
||||
|
||||
// Remove WAL/SHM files and make directory read-only.
|
||||
os.Remove(dbPath + "-wal")
|
||||
os.Remove(dbPath + "-shm")
|
||||
require.NoError(t, os.Chmod(dir, 0555))
|
||||
defer os.Chmod(dir, 0755) // restore for cleanup
|
||||
_ = testFS().Delete(dbPath + "-wal")
|
||||
_ = testFS().Delete(dbPath + "-shm")
|
||||
require.NoError(t, syscall.Chmod(dir, 0555))
|
||||
defer func() { _ = syscall.Chmod(dir, 0755) }() // restore for cleanup
|
||||
|
||||
_, err = New(dbPath)
|
||||
// May or may not fail depending on OS/filesystem — just exercise the code path.
|
||||
|
|
@ -87,8 +85,8 @@ func TestNew_Bad_ReadOnlyDir(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNew_Good_WALMode(t *testing.T) {
|
||||
dbPath := core.JoinPath(t.TempDir(), "wal.db")
|
||||
func TestStore_New_Good_WALMode(t *testing.T) {
|
||||
dbPath := testPath(t, "wal.db")
|
||||
s, err := New(dbPath)
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
|
@ -103,7 +101,7 @@ func TestNew_Good_WALMode(t *testing.T) {
|
|||
// Set / Get — core CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetGet_Good(t *testing.T) {
|
||||
func TestStore_SetGet_Good(t *testing.T) {
|
||||
s, err := New(":memory:")
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
|
@ -116,7 +114,7 @@ func TestSetGet_Good(t *testing.T) {
|
|||
assert.Equal(t, "dark", val)
|
||||
}
|
||||
|
||||
func TestSet_Good_Upsert(t *testing.T) {
|
||||
func TestStore_Set_Good_Upsert(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -132,7 +130,7 @@ func TestSet_Good_Upsert(t *testing.T) {
|
|||
assert.Equal(t, 1, n, "upsert should not duplicate keys")
|
||||
}
|
||||
|
||||
func TestGet_Bad_NotFound(t *testing.T) {
|
||||
func TestStore_Get_Bad_NotFound(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -141,7 +139,7 @@ func TestGet_Bad_NotFound(t *testing.T) {
|
|||
assert.True(t, core.Is(err, ErrNotFound), "should wrap ErrNotFound")
|
||||
}
|
||||
|
||||
func TestGet_Bad_NonExistentGroup(t *testing.T) {
|
||||
func TestStore_Get_Bad_NonExistentGroup(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -150,7 +148,7 @@ func TestGet_Bad_NonExistentGroup(t *testing.T) {
|
|||
assert.True(t, core.Is(err, ErrNotFound))
|
||||
}
|
||||
|
||||
func TestGet_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_Get_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -158,7 +156,7 @@ func TestGet_Bad_ClosedStore(t *testing.T) {
|
|||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSet_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_Set_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -170,7 +168,7 @@ func TestSet_Bad_ClosedStore(t *testing.T) {
|
|||
// Delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDelete_Good(t *testing.T) {
|
||||
func TestStore_Delete_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -182,7 +180,7 @@ func TestDelete_Good(t *testing.T) {
|
|||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDelete_Good_NonExistent(t *testing.T) {
|
||||
func TestStore_Delete_Good_NonExistent(t *testing.T) {
|
||||
// Deleting a key that does not exist should not error.
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
|
@ -191,7 +189,7 @@ func TestDelete_Good_NonExistent(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDelete_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_Delete_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -203,7 +201,7 @@ func TestDelete_Bad_ClosedStore(t *testing.T) {
|
|||
// Count
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCount_Good(t *testing.T) {
|
||||
func TestStore_Count_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -216,7 +214,7 @@ func TestCount_Good(t *testing.T) {
|
|||
assert.Equal(t, 2, n)
|
||||
}
|
||||
|
||||
func TestCount_Good_Empty(t *testing.T) {
|
||||
func TestStore_Count_Good_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -225,7 +223,7 @@ func TestCount_Good_Empty(t *testing.T) {
|
|||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
func TestCount_Good_BulkInsert(t *testing.T) {
|
||||
func TestStore_Count_Good_BulkInsert(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -238,7 +236,7 @@ func TestCount_Good_BulkInsert(t *testing.T) {
|
|||
assert.Equal(t, total, n)
|
||||
}
|
||||
|
||||
func TestCount_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_Count_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -250,7 +248,7 @@ func TestCount_Bad_ClosedStore(t *testing.T) {
|
|||
// DeleteGroup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDeleteGroup_Good(t *testing.T) {
|
||||
func TestStore_DeleteGroup_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -263,7 +261,7 @@ func TestDeleteGroup_Good(t *testing.T) {
|
|||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
func TestDeleteGroup_Good_ThenGetAllEmpty(t *testing.T) {
|
||||
func TestStore_DeleteGroup_Good_ThenGetAllEmpty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -276,7 +274,7 @@ func TestDeleteGroup_Good_ThenGetAllEmpty(t *testing.T) {
|
|||
assert.Empty(t, all)
|
||||
}
|
||||
|
||||
func TestDeleteGroup_Good_IsolatesOtherGroups(t *testing.T) {
|
||||
func TestStore_DeleteGroup_Good_IsolatesOtherGroups(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -292,7 +290,7 @@ func TestDeleteGroup_Good_IsolatesOtherGroups(t *testing.T) {
|
|||
assert.Equal(t, "2", val, "other group should be untouched")
|
||||
}
|
||||
|
||||
func TestDeleteGroup_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_DeleteGroup_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -304,7 +302,7 @@ func TestDeleteGroup_Bad_ClosedStore(t *testing.T) {
|
|||
// GetAll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetAll_Good(t *testing.T) {
|
||||
func TestStore_GetAll_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -317,7 +315,7 @@ func TestGetAll_Good(t *testing.T) {
|
|||
assert.Equal(t, map[string]string{"a": "1", "b": "2"}, all)
|
||||
}
|
||||
|
||||
func TestGetAll_Good_Empty(t *testing.T) {
|
||||
func TestStore_GetAll_Good_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -326,7 +324,7 @@ func TestGetAll_Good_Empty(t *testing.T) {
|
|||
assert.Empty(t, all)
|
||||
}
|
||||
|
||||
func TestGetAll_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_GetAll_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -338,7 +336,7 @@ func TestGetAll_Bad_ClosedStore(t *testing.T) {
|
|||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRender_Good(t *testing.T) {
|
||||
func TestStore_Render_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -352,7 +350,7 @@ func TestRender_Good(t *testing.T) {
|
|||
assert.Contains(t, out, "iz...")
|
||||
}
|
||||
|
||||
func TestRender_Good_EmptyGroup(t *testing.T) {
|
||||
func TestStore_Render_Good_EmptyGroup(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -362,7 +360,7 @@ func TestRender_Good_EmptyGroup(t *testing.T) {
|
|||
assert.Equal(t, "static content", out)
|
||||
}
|
||||
|
||||
func TestRender_Bad_InvalidTemplateSyntax(t *testing.T) {
|
||||
func TestStore_Render_Bad_InvalidTemplateSyntax(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -371,7 +369,7 @@ func TestRender_Bad_InvalidTemplateSyntax(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "store.Render: parse")
|
||||
}
|
||||
|
||||
func TestRender_Bad_MissingTemplateVar(t *testing.T) {
|
||||
func TestStore_Render_Bad_MissingTemplateVar(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -382,7 +380,7 @@ func TestRender_Bad_MissingTemplateVar(t *testing.T) {
|
|||
assert.Contains(t, out, "hello")
|
||||
}
|
||||
|
||||
func TestRender_Bad_ExecError(t *testing.T) {
|
||||
func TestStore_Render_Bad_ExecError(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -394,7 +392,7 @@ func TestRender_Bad_ExecError(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "store.Render: exec")
|
||||
}
|
||||
|
||||
func TestRender_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_Render_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -406,13 +404,13 @@ func TestRender_Bad_ClosedStore(t *testing.T) {
|
|||
// Close
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestClose_Good(t *testing.T) {
|
||||
func TestStore_Close_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
err := s.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestClose_Good_OperationsFailAfterClose(t *testing.T) {
|
||||
func TestStore_Close_Good_OperationsFailAfterClose(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
require.NoError(t, s.Close())
|
||||
|
||||
|
|
@ -443,7 +441,7 @@ func TestClose_Good_OperationsFailAfterClose(t *testing.T) {
|
|||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetGet_Good_EdgeCases(t *testing.T) {
|
||||
func TestStore_SetGet_Good_EdgeCases(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -468,9 +466,9 @@ func TestSetGet_Good_EdgeCases(t *testing.T) {
|
|||
{"special SQL chars", "g", "'; DROP TABLE kv;--", "val"},
|
||||
{"backslash", "g", "back\\slash", "val\\ue"},
|
||||
{"percent", "g", "100%", "50%"},
|
||||
{"long key", "g", strings.Repeat("k", 10000), "val"},
|
||||
{"long value", "g", "longval", strings.Repeat("v", 100000)},
|
||||
{"long group", strings.Repeat("g", 10000), "k", "val"},
|
||||
{"long key", "g", repeatString("k", 10000), "val"},
|
||||
{"long value", "g", "longval", repeatString("v", 100000)},
|
||||
{"long group", repeatString("g", 10000), "k", "val"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
|
|
@ -489,7 +487,7 @@ func TestSetGet_Good_EdgeCases(t *testing.T) {
|
|||
// Group isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestStore_Good_GroupIsolation(t *testing.T) {
|
||||
func TestStore_GroupIsolation_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -518,8 +516,8 @@ func TestStore_Good_GroupIsolation(t *testing.T) {
|
|||
// Concurrent access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestConcurrent_Good_ReadWrite(t *testing.T) {
|
||||
dbPath := core.JoinPath(t.TempDir(), "concurrent.db")
|
||||
func TestStore_Concurrent_Good_ReadWrite(t *testing.T) {
|
||||
dbPath := testPath(t, "concurrent.db")
|
||||
s, err := New(dbPath)
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
|
@ -540,7 +538,7 @@ func TestConcurrent_Good_ReadWrite(t *testing.T) {
|
|||
key := core.Sprintf("key-%d", i)
|
||||
val := core.Sprintf("val-%d-%d", id, i)
|
||||
if err := s.Set(group, key, val); err != nil {
|
||||
errs <- core.Wrap(err, "writer", core.Sprintf("%d", id))
|
||||
errs <- core.E("TestStore_Concurrent_Good_ReadWrite", core.Sprintf("writer %d", id), err)
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
|
|
@ -557,7 +555,7 @@ func TestConcurrent_Good_ReadWrite(t *testing.T) {
|
|||
_, err := s.Get(group, key)
|
||||
// ErrNotFound is acceptable — the writer may not have written yet.
|
||||
if err != nil && !core.Is(err, ErrNotFound) {
|
||||
errs <- core.Wrap(err, "reader", core.Sprintf("%d", id))
|
||||
errs <- core.E("TestStore_Concurrent_Good_ReadWrite", core.Sprintf("reader %d", id), err)
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
|
|
@ -579,8 +577,8 @@ func TestConcurrent_Good_ReadWrite(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestConcurrent_Good_GetAll(t *testing.T) {
|
||||
s, err := New(core.JoinPath(t.TempDir(), "getall.db"))
|
||||
func TestStore_Concurrent_Good_GetAll(t *testing.T) {
|
||||
s, err := New(testPath(t, "getall.db"))
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -605,8 +603,8 @@ func TestConcurrent_Good_GetAll(t *testing.T) {
|
|||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestConcurrent_Good_DeleteGroup(t *testing.T) {
|
||||
s, err := New(core.JoinPath(t.TempDir(), "delgrp.db"))
|
||||
func TestStore_Concurrent_Good_DeleteGroup(t *testing.T) {
|
||||
s, err := New(testPath(t, "delgrp.db"))
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -629,7 +627,7 @@ func TestConcurrent_Good_DeleteGroup(t *testing.T) {
|
|||
// ErrNotFound wrapping verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestErrNotFound_Good_Is(t *testing.T) {
|
||||
func TestStore_ErrNotFound_Good_Is(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -685,7 +683,7 @@ func BenchmarkGetAll(b *testing.B) {
|
|||
}
|
||||
|
||||
func BenchmarkSet_FileBacked(b *testing.B) {
|
||||
dbPath := core.JoinPath(b.TempDir(), "bench.db")
|
||||
dbPath := testPath(b, "bench.db")
|
||||
s, _ := New(dbPath)
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -699,7 +697,7 @@ func BenchmarkSet_FileBacked(b *testing.B) {
|
|||
// TTL support (Phase 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetWithTTL_Good(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -711,7 +709,7 @@ func TestSetWithTTL_Good(t *testing.T) {
|
|||
assert.Equal(t, "v", val)
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Good_Upsert(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good_Upsert(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -727,7 +725,7 @@ func TestSetWithTTL_Good_Upsert(t *testing.T) {
|
|||
assert.Equal(t, 1, n, "upsert should not duplicate keys")
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Good_ExpiresOnGet(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good_ExpiresOnGet(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -742,7 +740,7 @@ func TestSetWithTTL_Good_ExpiresOnGet(t *testing.T) {
|
|||
assert.True(t, core.Is(err, ErrNotFound), "expired key should be ErrNotFound")
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Good_ExcludedFromCount(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good_ExcludedFromCount(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -755,7 +753,7 @@ func TestSetWithTTL_Good_ExcludedFromCount(t *testing.T) {
|
|||
assert.Equal(t, 1, n, "expired key should not be counted")
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Good_ExcludedFromGetAll(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good_ExcludedFromGetAll(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -768,7 +766,7 @@ func TestSetWithTTL_Good_ExcludedFromGetAll(t *testing.T) {
|
|||
assert.Equal(t, map[string]string{"a": "1"}, all, "expired key should be excluded")
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Good_ExcludedFromRender(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good_ExcludedFromRender(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -781,7 +779,7 @@ func TestSetWithTTL_Good_ExcludedFromRender(t *testing.T) {
|
|||
assert.Equal(t, "Hello Alice", out)
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Good_SetClearsTTL(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good_SetClearsTTL(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -795,7 +793,7 @@ func TestSetWithTTL_Good_SetClearsTTL(t *testing.T) {
|
|||
assert.Equal(t, "permanent", val, "plain Set should clear TTL")
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Good_FutureTTLAccessible(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good_FutureTTLAccessible(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -810,7 +808,7 @@ func TestSetWithTTL_Good_FutureTTLAccessible(t *testing.T) {
|
|||
assert.Equal(t, 1, n)
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -822,7 +820,7 @@ func TestSetWithTTL_Bad_ClosedStore(t *testing.T) {
|
|||
// PurgeExpired
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPurgeExpired_Good(t *testing.T) {
|
||||
func TestStore_PurgeExpired_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -840,7 +838,7 @@ func TestPurgeExpired_Good(t *testing.T) {
|
|||
assert.Equal(t, 1, n, "only non-expiring key should remain")
|
||||
}
|
||||
|
||||
func TestPurgeExpired_Good_NoneExpired(t *testing.T) {
|
||||
func TestStore_PurgeExpired_Good_NoneExpired(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -852,7 +850,7 @@ func TestPurgeExpired_Good_NoneExpired(t *testing.T) {
|
|||
assert.Equal(t, int64(0), removed)
|
||||
}
|
||||
|
||||
func TestPurgeExpired_Good_Empty(t *testing.T) {
|
||||
func TestStore_PurgeExpired_Good_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -861,7 +859,7 @@ func TestPurgeExpired_Good_Empty(t *testing.T) {
|
|||
assert.Equal(t, int64(0), removed)
|
||||
}
|
||||
|
||||
func TestPurgeExpired_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_PurgeExpired_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -869,7 +867,7 @@ func TestPurgeExpired_Bad_ClosedStore(t *testing.T) {
|
|||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPurgeExpired_Good_BackgroundPurge(t *testing.T) {
|
||||
func TestStore_PurgeExpired_Good_BackgroundPurge(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
// Override purge interval for testing: restart the goroutine with a short interval.
|
||||
s.cancel()
|
||||
|
|
@ -898,8 +896,8 @@ func TestPurgeExpired_Good_BackgroundPurge(t *testing.T) {
|
|||
// Schema migration — reopening an existing database
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchemaUpgrade_Good_ExistingDB(t *testing.T) {
|
||||
dbPath := core.JoinPath(t.TempDir(), "upgrade.db")
|
||||
func TestStore_SchemaUpgrade_Good_ExistingDB(t *testing.T) {
|
||||
dbPath := testPath(t, "upgrade.db")
|
||||
|
||||
// Open, write, close.
|
||||
s1, err := New(dbPath)
|
||||
|
|
@ -923,9 +921,9 @@ func TestSchemaUpgrade_Good_ExistingDB(t *testing.T) {
|
|||
assert.Equal(t, "ttl-val", val2)
|
||||
}
|
||||
|
||||
func TestSchemaUpgrade_Good_PreTTLDatabase(t *testing.T) {
|
||||
func TestStore_SchemaUpgrade_Good_PreTTLDatabase(t *testing.T) {
|
||||
// Simulate a database created before TTL support (no expires_at column).
|
||||
dbPath := core.JoinPath(t.TempDir(), "pre-ttl.db")
|
||||
dbPath := testPath(t, "pre-ttl.db")
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
require.NoError(t, err)
|
||||
db.SetMaxOpenConns(1)
|
||||
|
|
@ -963,8 +961,8 @@ func TestSchemaUpgrade_Good_PreTTLDatabase(t *testing.T) {
|
|||
// Concurrent TTL access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestConcurrent_Good_TTL(t *testing.T) {
|
||||
s, err := New(core.JoinPath(t.TempDir(), "concurrent-ttl.db"))
|
||||
func TestStore_Concurrent_Good_TTL(t *testing.T) {
|
||||
s, err := New(testPath(t, "concurrent-ttl.db"))
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
|
|
|
|||
45
test_helpers_test.go
Normal file
45
test_helpers_test.go
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue