[agent/codex] AX review #14
19 changed files with 687 additions and 323 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
|
||||
|
|
|
|||
|
|
@ -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
11
LICENCE
Normal 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
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
[](https://pkg.go.dev/dappco.re/go/core/store)
|
||||
[](LICENSE.md)
|
||||
[](LICENCE)
|
||||
[](go.mod)
|
||||
|
||||
# go-store
|
||||
|
|
@ -8,7 +8,7 @@ Group-namespaced SQLite key-value store with TTL expiry, namespace isolation, qu
|
|||
|
||||
**Module**: `dappco.re/go/core/store`
|
||||
**Licence**: EUPL-1.2
|
||||
**Language**: Go 1.25
|
||||
**Language**: Go 1.26
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
|
|||
|
|
@ -2,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 }}`
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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
2
go.mod
|
|
@ -3,7 +3,7 @@ module dappco.re/go/core/store
|
|||
go 1.26.0
|
||||
|
||||
require (
|
||||
dappco.re/go/core/log v0.1.0
|
||||
dappco.re/go/core v0.8.0-alpha.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
modernc.org/sqlite v1.47.0
|
||||
)
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -1,5 +1,5 @@
|
|||
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
|
|
|||
34
scope.go
34
scope.go
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
104
scope_test.go
104
scope_test.go
|
|
@ -1,10 +1,10 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -13,7 +13,7 @@ import (
|
|||
// NewScoped — constructor validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNewScoped_Good(t *testing.T) {
|
||||
func TestScope_NewScoped_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ func TestNewScoped_Good(t *testing.T) {
|
|||
assert.Equal(t, "tenant-1", sc.Namespace())
|
||||
}
|
||||
|
||||
func TestNewScoped_Good_AlphanumericHyphens(t *testing.T) {
|
||||
func TestScope_NewScoped_Good_AlphanumericHyphens(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ func TestNewScoped_Good_AlphanumericHyphens(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNewScoped_Bad_Empty(t *testing.T) {
|
||||
func TestScope_NewScoped_Bad_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ func TestNewScoped_Bad_Empty(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "invalid")
|
||||
}
|
||||
|
||||
func TestNewScoped_Bad_InvalidChars(t *testing.T) {
|
||||
func TestScope_NewScoped_Bad_InvalidChars(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ func TestNewScoped_Bad_InvalidChars(t *testing.T) {
|
|||
// ScopedStore — basic CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestScopedStore_Good_SetGet(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_SetGet(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ func TestScopedStore_Good_SetGet(t *testing.T) {
|
|||
assert.Equal(t, "dark", val)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -85,10 +85,10 @@ func TestScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) {
|
|||
|
||||
// Direct access without prefix should fail.
|
||||
_, err = s.Get("config", "key")
|
||||
assert.True(t, errors.Is(err, ErrNotFound))
|
||||
assert.True(t, core.Is(err, ErrNotFound))
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_NamespaceIsolation(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_NamespaceIsolation(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ func TestScopedStore_Good_NamespaceIsolation(t *testing.T) {
|
|||
assert.Equal(t, "red", vb)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_Delete(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_Delete(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -116,10 +116,10 @@ func TestScopedStore_Good_Delete(t *testing.T) {
|
|||
require.NoError(t, sc.Delete("g", "k"))
|
||||
|
||||
_, err := sc.Get("g", "k")
|
||||
assert.True(t, errors.Is(err, ErrNotFound))
|
||||
assert.True(t, core.Is(err, ErrNotFound))
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_DeleteGroup(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_DeleteGroup(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -133,7 +133,7 @@ func TestScopedStore_Good_DeleteGroup(t *testing.T) {
|
|||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_GetAll(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_GetAll(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ func TestScopedStore_Good_GetAll(t *testing.T) {
|
|||
assert.Equal(t, map[string]string{"z": "3"}, allB)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_Count(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_Count(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -166,7 +166,7 @@ func TestScopedStore_Good_Count(t *testing.T) {
|
|||
assert.Equal(t, 2, n)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_SetWithTTL(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_SetWithTTL(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -178,7 +178,7 @@ func TestScopedStore_Good_SetWithTTL(t *testing.T) {
|
|||
assert.Equal(t, "v", val)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_SetWithTTL_Expires(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_SetWithTTL_Expires(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -187,10 +187,10 @@ func TestScopedStore_Good_SetWithTTL_Expires(t *testing.T) {
|
|||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
_, err := sc.Get("g", "k")
|
||||
assert.True(t, errors.Is(err, ErrNotFound))
|
||||
assert.True(t, core.Is(err, ErrNotFound))
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_Render(t *testing.T) {
|
||||
func TestScope_ScopedStore_Good_Render(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -206,7 +206,7 @@ func TestScopedStore_Good_Render(t *testing.T) {
|
|||
// Quota enforcement — MaxKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestQuota_Good_MaxKeys(t *testing.T) {
|
||||
func TestScope_Quota_Good_MaxKeys(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -221,10 +221,10 @@ func TestQuota_Good_MaxKeys(t *testing.T) {
|
|||
// 6th key should fail.
|
||||
err = sc.Set("g", "overflow", "v")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded), "expected ErrQuotaExceeded, got: %v", err)
|
||||
assert.True(t, core.Is(err, ErrQuotaExceeded), "expected ErrQuotaExceeded, got: %v", err)
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxKeys_AcrossGroups(t *testing.T) {
|
||||
func TestScope_Quota_Good_MaxKeys_AcrossGroups(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -236,10 +236,10 @@ func TestQuota_Good_MaxKeys_AcrossGroups(t *testing.T) {
|
|||
|
||||
// Total is now 3 — any new key should fail regardless of group.
|
||||
err := sc.Set("g4", "d", "4")
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded))
|
||||
assert.True(t, core.Is(err, ErrQuotaExceeded))
|
||||
}
|
||||
|
||||
func TestQuota_Good_UpsertDoesNotCount(t *testing.T) {
|
||||
func TestScope_Quota_Good_UpsertDoesNotCount(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -257,7 +257,7 @@ func TestQuota_Good_UpsertDoesNotCount(t *testing.T) {
|
|||
assert.Equal(t, "updated", val)
|
||||
}
|
||||
|
||||
func TestQuota_Good_DeleteAndReInsert(t *testing.T) {
|
||||
func TestScope_Quota_Good_DeleteAndReInsert(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -272,7 +272,7 @@ func TestQuota_Good_DeleteAndReInsert(t *testing.T) {
|
|||
require.NoError(t, sc.Set("g", "d", "4"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_ZeroMeansUnlimited(t *testing.T) {
|
||||
func TestScope_Quota_Good_ZeroMeansUnlimited(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -284,7 +284,7 @@ func TestQuota_Good_ZeroMeansUnlimited(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestQuota_Good_ExpiredKeysExcluded(t *testing.T) {
|
||||
func TestScope_Quota_Good_ExpiredKeysExcluded(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -303,10 +303,10 @@ func TestQuota_Good_ExpiredKeysExcluded(t *testing.T) {
|
|||
|
||||
// Now at 3 — next should fail.
|
||||
err := sc.Set("g", "new3", "v")
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded))
|
||||
assert.True(t, core.Is(err, ErrQuotaExceeded))
|
||||
}
|
||||
|
||||
func TestQuota_Good_SetWithTTL_Enforced(t *testing.T) {
|
||||
func TestScope_Quota_Good_SetWithTTL_Enforced(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -316,14 +316,14 @@ func TestQuota_Good_SetWithTTL_Enforced(t *testing.T) {
|
|||
require.NoError(t, sc.SetWithTTL("g", "b", "2", time.Hour))
|
||||
|
||||
err := sc.SetWithTTL("g", "c", "3", time.Hour)
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded))
|
||||
assert.True(t, core.Is(err, ErrQuotaExceeded))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quota enforcement — MaxGroups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestQuota_Good_MaxGroups(t *testing.T) {
|
||||
func TestScope_Quota_Good_MaxGroups(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -336,10 +336,10 @@ func TestQuota_Good_MaxGroups(t *testing.T) {
|
|||
// 4th group should fail.
|
||||
err := sc.Set("g4", "k", "v")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded))
|
||||
assert.True(t, core.Is(err, ErrQuotaExceeded))
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxGroups_ExistingGroupOK(t *testing.T) {
|
||||
func TestScope_Quota_Good_MaxGroups_ExistingGroupOK(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -353,7 +353,7 @@ func TestQuota_Good_MaxGroups_ExistingGroupOK(t *testing.T) {
|
|||
require.NoError(t, sc.Set("g2", "d", "4"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxGroups_DeleteAndRecreate(t *testing.T) {
|
||||
func TestScope_Quota_Good_MaxGroups_DeleteAndRecreate(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -367,7 +367,7 @@ func TestQuota_Good_MaxGroups_DeleteAndRecreate(t *testing.T) {
|
|||
require.NoError(t, sc.Set("g3", "k", "v"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxGroups_ZeroUnlimited(t *testing.T) {
|
||||
func TestScope_Quota_Good_MaxGroups_ZeroUnlimited(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -378,7 +378,7 @@ func TestQuota_Good_MaxGroups_ZeroUnlimited(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxGroups_ExpiredGroupExcluded(t *testing.T) {
|
||||
func TestScope_Quota_Good_MaxGroups_ExpiredGroupExcluded(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -394,7 +394,7 @@ func TestQuota_Good_MaxGroups_ExpiredGroupExcluded(t *testing.T) {
|
|||
require.NoError(t, sc.Set("g3", "k", "v"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_BothLimits(t *testing.T) {
|
||||
func TestScope_Quota_Good_BothLimits(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -405,13 +405,13 @@ func TestQuota_Good_BothLimits(t *testing.T) {
|
|||
|
||||
// Group limit hit.
|
||||
err := sc.Set("g3", "c", "3")
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded))
|
||||
assert.True(t, core.Is(err, ErrQuotaExceeded))
|
||||
|
||||
// But adding to existing groups is fine (within key limit).
|
||||
require.NoError(t, sc.Set("g1", "d", "4"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_DoesNotAffectOtherNamespaces(t *testing.T) {
|
||||
func TestScope_Quota_Good_DoesNotAffectOtherNamespaces(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -425,18 +425,18 @@ func TestQuota_Good_DoesNotAffectOtherNamespaces(t *testing.T) {
|
|||
|
||||
// a is at limit — but b's keys don't count against a.
|
||||
err := a.Set("g", "a3", "v")
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded))
|
||||
assert.True(t, core.Is(err, ErrQuotaExceeded))
|
||||
|
||||
// b is also at limit independently.
|
||||
err = b.Set("g", "b3", "v")
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded))
|
||||
assert.True(t, core.Is(err, ErrQuotaExceeded))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CountAll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCountAll_Good_WithPrefix(t *testing.T) {
|
||||
func TestScope_CountAll_Good_WithPrefix(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -454,7 +454,7 @@ func TestCountAll_Good_WithPrefix(t *testing.T) {
|
|||
assert.Equal(t, 1, n)
|
||||
}
|
||||
|
||||
func TestCountAll_Good_WithPrefix_Wildcards(t *testing.T) {
|
||||
func TestScope_CountAll_Good_WithPrefix_Wildcards(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -478,7 +478,7 @@ func TestCountAll_Good_WithPrefix_Wildcards(t *testing.T) {
|
|||
assert.Equal(t, 1, n)
|
||||
}
|
||||
|
||||
func TestCountAll_Good_EmptyPrefix(t *testing.T) {
|
||||
func TestScope_CountAll_Good_EmptyPrefix(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -490,7 +490,7 @@ func TestCountAll_Good_EmptyPrefix(t *testing.T) {
|
|||
assert.Equal(t, 2, n)
|
||||
}
|
||||
|
||||
func TestCountAll_Good_ExcludesExpired(t *testing.T) {
|
||||
func TestScope_CountAll_Good_ExcludesExpired(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -503,7 +503,7 @@ func TestCountAll_Good_ExcludesExpired(t *testing.T) {
|
|||
assert.Equal(t, 1, n, "expired keys should not be counted")
|
||||
}
|
||||
|
||||
func TestCountAll_Good_Empty(t *testing.T) {
|
||||
func TestScope_CountAll_Good_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -512,7 +512,7 @@ func TestCountAll_Good_Empty(t *testing.T) {
|
|||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
func TestCountAll_Bad_ClosedStore(t *testing.T) {
|
||||
func TestScope_CountAll_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -524,7 +524,7 @@ func TestCountAll_Bad_ClosedStore(t *testing.T) {
|
|||
// Groups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGroups_Good_WithPrefix(t *testing.T) {
|
||||
func TestScope_Groups_Good_WithPrefix(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -540,7 +540,7 @@ func TestGroups_Good_WithPrefix(t *testing.T) {
|
|||
assert.Contains(t, groups, "ns-a:g2")
|
||||
}
|
||||
|
||||
func TestGroups_Good_EmptyPrefix(t *testing.T) {
|
||||
func TestScope_Groups_Good_EmptyPrefix(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -553,7 +553,7 @@ func TestGroups_Good_EmptyPrefix(t *testing.T) {
|
|||
assert.Len(t, groups, 3)
|
||||
}
|
||||
|
||||
func TestGroups_Good_Distinct(t *testing.T) {
|
||||
func TestScope_Groups_Good_Distinct(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -568,7 +568,7 @@ func TestGroups_Good_Distinct(t *testing.T) {
|
|||
assert.Equal(t, "g1", groups[0])
|
||||
}
|
||||
|
||||
func TestGroups_Good_ExcludesExpired(t *testing.T) {
|
||||
func TestScope_Groups_Good_ExcludesExpired(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -582,7 +582,7 @@ func TestGroups_Good_ExcludesExpired(t *testing.T) {
|
|||
assert.Equal(t, "ns:g1", groups[0])
|
||||
}
|
||||
|
||||
func TestGroups_Good_Empty(t *testing.T) {
|
||||
func TestScope_Groups_Good_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -591,7 +591,7 @@ func TestGroups_Good_Empty(t *testing.T) {
|
|||
assert.Empty(t, groups)
|
||||
}
|
||||
|
||||
func TestGroups_Bad_ClosedStore(t *testing.T) {
|
||||
func TestScope_Groups_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
|
|||
131
specs/RFC.md
Normal file
131
specs/RFC.md
Normal 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
132
store.go
|
|
@ -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:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
210
store_test.go
210
store_test.go
|
|
@ -3,15 +3,12 @@ package store
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -20,15 +17,15 @@ import (
|
|||
// New
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNew_Good_Memory(t *testing.T) {
|
||||
func TestStore_New_Good_Memory(t *testing.T) {
|
||||
s, err := New(":memory:")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, s)
|
||||
defer s.Close()
|
||||
}
|
||||
|
||||
func TestNew_Good_FileBacked(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
func TestStore_New_Good_FileBacked(t *testing.T) {
|
||||
dbPath := testPath(t, "test.db")
|
||||
s, err := New(dbPath)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, s)
|
||||
|
|
@ -47,7 +44,7 @@ func TestNew_Good_FileBacked(t *testing.T) {
|
|||
assert.Equal(t, "v", val)
|
||||
}
|
||||
|
||||
func TestNew_Bad_InvalidPath(t *testing.T) {
|
||||
func TestStore_New_Bad_InvalidPath(t *testing.T) {
|
||||
// A path under a non-existent directory should fail at the WAL pragma step
|
||||
// because sql.Open is lazy and only validates on first use.
|
||||
_, err := New("/no/such/directory/test.db")
|
||||
|
|
@ -55,21 +52,20 @@ func TestNew_Bad_InvalidPath(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "store.New")
|
||||
}
|
||||
|
||||
func TestNew_Bad_CorruptFile(t *testing.T) {
|
||||
func TestStore_New_Bad_CorruptFile(t *testing.T) {
|
||||
// A file that exists but is not a valid SQLite database should fail.
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "corrupt.db")
|
||||
require.NoError(t, os.WriteFile(dbPath, []byte("not a sqlite database"), 0644))
|
||||
dbPath := testPath(t, "corrupt.db")
|
||||
requireCoreOK(t, testFS().Write(dbPath, "not a sqlite database"))
|
||||
|
||||
_, err := New(dbPath)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "store.New")
|
||||
}
|
||||
|
||||
func TestNew_Bad_ReadOnlyDir(t *testing.T) {
|
||||
func TestStore_New_Bad_ReadOnlyDir(t *testing.T) {
|
||||
// A path in a read-only directory should fail when SQLite tries to create the WAL file.
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "readonly.db")
|
||||
dbPath := core.Path(dir, "readonly.db")
|
||||
|
||||
// Create a valid DB first, then make the directory read-only.
|
||||
s, err := New(dbPath)
|
||||
|
|
@ -77,10 +73,10 @@ func TestNew_Bad_ReadOnlyDir(t *testing.T) {
|
|||
require.NoError(t, s.Close())
|
||||
|
||||
// Remove WAL/SHM files and make directory read-only.
|
||||
os.Remove(dbPath + "-wal")
|
||||
os.Remove(dbPath + "-shm")
|
||||
require.NoError(t, os.Chmod(dir, 0555))
|
||||
defer os.Chmod(dir, 0755) // restore for cleanup
|
||||
_ = testFS().Delete(dbPath + "-wal")
|
||||
_ = testFS().Delete(dbPath + "-shm")
|
||||
require.NoError(t, syscall.Chmod(dir, 0555))
|
||||
defer func() { _ = syscall.Chmod(dir, 0755) }() // restore for cleanup
|
||||
|
||||
_, err = New(dbPath)
|
||||
// May or may not fail depending on OS/filesystem — just exercise the code path.
|
||||
|
|
@ -89,8 +85,8 @@ func TestNew_Bad_ReadOnlyDir(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNew_Good_WALMode(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "wal.db")
|
||||
func TestStore_New_Good_WALMode(t *testing.T) {
|
||||
dbPath := testPath(t, "wal.db")
|
||||
s, err := New(dbPath)
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
|
@ -105,7 +101,7 @@ func TestNew_Good_WALMode(t *testing.T) {
|
|||
// Set / Get — core CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetGet_Good(t *testing.T) {
|
||||
func TestStore_SetGet_Good(t *testing.T) {
|
||||
s, err := New(":memory:")
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
|
@ -118,7 +114,7 @@ func TestSetGet_Good(t *testing.T) {
|
|||
assert.Equal(t, "dark", val)
|
||||
}
|
||||
|
||||
func TestSet_Good_Upsert(t *testing.T) {
|
||||
func TestStore_Set_Good_Upsert(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -134,25 +130,25 @@ func TestSet_Good_Upsert(t *testing.T) {
|
|||
assert.Equal(t, 1, n, "upsert should not duplicate keys")
|
||||
}
|
||||
|
||||
func TestGet_Bad_NotFound(t *testing.T) {
|
||||
func TestStore_Get_Bad_NotFound(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
_, err := s.Get("config", "missing")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrNotFound), "should wrap ErrNotFound")
|
||||
assert.True(t, core.Is(err, ErrNotFound), "should wrap ErrNotFound")
|
||||
}
|
||||
|
||||
func TestGet_Bad_NonExistentGroup(t *testing.T) {
|
||||
func TestStore_Get_Bad_NonExistentGroup(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
_, err := s.Get("no-such-group", "key")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrNotFound))
|
||||
assert.True(t, core.Is(err, ErrNotFound))
|
||||
}
|
||||
|
||||
func TestGet_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_Get_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -160,7 +156,7 @@ func TestGet_Bad_ClosedStore(t *testing.T) {
|
|||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSet_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_Set_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -172,7 +168,7 @@ func TestSet_Bad_ClosedStore(t *testing.T) {
|
|||
// Delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDelete_Good(t *testing.T) {
|
||||
func TestStore_Delete_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -184,7 +180,7 @@ func TestDelete_Good(t *testing.T) {
|
|||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDelete_Good_NonExistent(t *testing.T) {
|
||||
func TestStore_Delete_Good_NonExistent(t *testing.T) {
|
||||
// Deleting a key that does not exist should not error.
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
|
@ -193,7 +189,7 @@ func TestDelete_Good_NonExistent(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDelete_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_Delete_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -205,7 +201,7 @@ func TestDelete_Bad_ClosedStore(t *testing.T) {
|
|||
// Count
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCount_Good(t *testing.T) {
|
||||
func TestStore_Count_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -218,7 +214,7 @@ func TestCount_Good(t *testing.T) {
|
|||
assert.Equal(t, 2, n)
|
||||
}
|
||||
|
||||
func TestCount_Good_Empty(t *testing.T) {
|
||||
func TestStore_Count_Good_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -227,20 +223,20 @@ func TestCount_Good_Empty(t *testing.T) {
|
|||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
func TestCount_Good_BulkInsert(t *testing.T) {
|
||||
func TestStore_Count_Good_BulkInsert(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
const total = 500
|
||||
for i := range total {
|
||||
require.NoError(t, s.Set("bulk", fmt.Sprintf("key-%04d", i), "v"))
|
||||
require.NoError(t, s.Set("bulk", core.Sprintf("key-%04d", i), "v"))
|
||||
}
|
||||
n, err := s.Count("bulk")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, total, n)
|
||||
}
|
||||
|
||||
func TestCount_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_Count_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -252,7 +248,7 @@ func TestCount_Bad_ClosedStore(t *testing.T) {
|
|||
// DeleteGroup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDeleteGroup_Good(t *testing.T) {
|
||||
func TestStore_DeleteGroup_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -265,7 +261,7 @@ func TestDeleteGroup_Good(t *testing.T) {
|
|||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
func TestDeleteGroup_Good_ThenGetAllEmpty(t *testing.T) {
|
||||
func TestStore_DeleteGroup_Good_ThenGetAllEmpty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -278,7 +274,7 @@ func TestDeleteGroup_Good_ThenGetAllEmpty(t *testing.T) {
|
|||
assert.Empty(t, all)
|
||||
}
|
||||
|
||||
func TestDeleteGroup_Good_IsolatesOtherGroups(t *testing.T) {
|
||||
func TestStore_DeleteGroup_Good_IsolatesOtherGroups(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -294,7 +290,7 @@ func TestDeleteGroup_Good_IsolatesOtherGroups(t *testing.T) {
|
|||
assert.Equal(t, "2", val, "other group should be untouched")
|
||||
}
|
||||
|
||||
func TestDeleteGroup_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_DeleteGroup_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -306,7 +302,7 @@ func TestDeleteGroup_Bad_ClosedStore(t *testing.T) {
|
|||
// GetAll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGetAll_Good(t *testing.T) {
|
||||
func TestStore_GetAll_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -319,7 +315,7 @@ func TestGetAll_Good(t *testing.T) {
|
|||
assert.Equal(t, map[string]string{"a": "1", "b": "2"}, all)
|
||||
}
|
||||
|
||||
func TestGetAll_Good_Empty(t *testing.T) {
|
||||
func TestStore_GetAll_Good_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -328,7 +324,7 @@ func TestGetAll_Good_Empty(t *testing.T) {
|
|||
assert.Empty(t, all)
|
||||
}
|
||||
|
||||
func TestGetAll_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_GetAll_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -340,7 +336,7 @@ func TestGetAll_Bad_ClosedStore(t *testing.T) {
|
|||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRender_Good(t *testing.T) {
|
||||
func TestStore_Render_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -354,7 +350,7 @@ func TestRender_Good(t *testing.T) {
|
|||
assert.Contains(t, out, "iz...")
|
||||
}
|
||||
|
||||
func TestRender_Good_EmptyGroup(t *testing.T) {
|
||||
func TestStore_Render_Good_EmptyGroup(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -364,7 +360,7 @@ func TestRender_Good_EmptyGroup(t *testing.T) {
|
|||
assert.Equal(t, "static content", out)
|
||||
}
|
||||
|
||||
func TestRender_Bad_InvalidTemplateSyntax(t *testing.T) {
|
||||
func TestStore_Render_Bad_InvalidTemplateSyntax(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -373,7 +369,7 @@ func TestRender_Bad_InvalidTemplateSyntax(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "store.Render: parse")
|
||||
}
|
||||
|
||||
func TestRender_Bad_MissingTemplateVar(t *testing.T) {
|
||||
func TestStore_Render_Bad_MissingTemplateVar(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -384,7 +380,7 @@ func TestRender_Bad_MissingTemplateVar(t *testing.T) {
|
|||
assert.Contains(t, out, "hello")
|
||||
}
|
||||
|
||||
func TestRender_Bad_ExecError(t *testing.T) {
|
||||
func TestStore_Render_Bad_ExecError(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -396,7 +392,7 @@ func TestRender_Bad_ExecError(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "store.Render: exec")
|
||||
}
|
||||
|
||||
func TestRender_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_Render_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -408,13 +404,13 @@ func TestRender_Bad_ClosedStore(t *testing.T) {
|
|||
// Close
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestClose_Good(t *testing.T) {
|
||||
func TestStore_Close_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
err := s.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestClose_Good_OperationsFailAfterClose(t *testing.T) {
|
||||
func TestStore_Close_Good_OperationsFailAfterClose(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
require.NoError(t, s.Close())
|
||||
|
||||
|
|
@ -445,7 +441,7 @@ func TestClose_Good_OperationsFailAfterClose(t *testing.T) {
|
|||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetGet_Good_EdgeCases(t *testing.T) {
|
||||
func TestStore_SetGet_Good_EdgeCases(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -470,9 +466,9 @@ func TestSetGet_Good_EdgeCases(t *testing.T) {
|
|||
{"special SQL chars", "g", "'; DROP TABLE kv;--", "val"},
|
||||
{"backslash", "g", "back\\slash", "val\\ue"},
|
||||
{"percent", "g", "100%", "50%"},
|
||||
{"long key", "g", strings.Repeat("k", 10000), "val"},
|
||||
{"long value", "g", "longval", strings.Repeat("v", 100000)},
|
||||
{"long group", strings.Repeat("g", 10000), "k", "val"},
|
||||
{"long key", "g", repeatString("k", 10000), "val"},
|
||||
{"long value", "g", "longval", repeatString("v", 100000)},
|
||||
{"long group", repeatString("g", 10000), "k", "val"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
|
|
@ -491,7 +487,7 @@ func TestSetGet_Good_EdgeCases(t *testing.T) {
|
|||
// Group isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestStore_Good_GroupIsolation(t *testing.T) {
|
||||
func TestStore_GroupIsolation_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -520,8 +516,8 @@ func TestStore_Good_GroupIsolation(t *testing.T) {
|
|||
// Concurrent access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestConcurrent_Good_ReadWrite(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "concurrent.db")
|
||||
func TestStore_Concurrent_Good_ReadWrite(t *testing.T) {
|
||||
dbPath := testPath(t, "concurrent.db")
|
||||
s, err := New(dbPath)
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
|
@ -537,12 +533,12 @@ func TestConcurrent_Good_ReadWrite(t *testing.T) {
|
|||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
group := fmt.Sprintf("grp-%d", id)
|
||||
group := core.Sprintf("grp-%d", id)
|
||||
for i := range opsPerGoroutine {
|
||||
key := fmt.Sprintf("key-%d", i)
|
||||
val := fmt.Sprintf("val-%d-%d", id, i)
|
||||
key := core.Sprintf("key-%d", i)
|
||||
val := core.Sprintf("val-%d-%d", id, i)
|
||||
if err := s.Set(group, key, val); err != nil {
|
||||
errs <- fmt.Errorf("writer %d: %w", id, err)
|
||||
errs <- core.E("TestStore_Concurrent_Good_ReadWrite", core.Sprintf("writer %d", id), err)
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
|
|
@ -553,13 +549,13 @@ func TestConcurrent_Good_ReadWrite(t *testing.T) {
|
|||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
group := fmt.Sprintf("grp-%d", id)
|
||||
group := core.Sprintf("grp-%d", id)
|
||||
for i := range opsPerGoroutine {
|
||||
key := fmt.Sprintf("key-%d", i)
|
||||
key := core.Sprintf("key-%d", i)
|
||||
_, err := s.Get(group, key)
|
||||
// ErrNotFound is acceptable — the writer may not have written yet.
|
||||
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||
errs <- fmt.Errorf("reader %d: %w", id, err)
|
||||
if err != nil && !core.Is(err, ErrNotFound) {
|
||||
errs <- core.E("TestStore_Concurrent_Good_ReadWrite", core.Sprintf("reader %d", id), err)
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
|
|
@ -574,21 +570,21 @@ func TestConcurrent_Good_ReadWrite(t *testing.T) {
|
|||
|
||||
// After all writers finish, every key should be present.
|
||||
for g := range goroutines {
|
||||
group := fmt.Sprintf("grp-%d", g)
|
||||
group := core.Sprintf("grp-%d", g)
|
||||
n, err := s.Count(group)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, opsPerGoroutine, n, "group %s should have all keys", group)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrent_Good_GetAll(t *testing.T) {
|
||||
s, err := New(filepath.Join(t.TempDir(), "getall.db"))
|
||||
func TestStore_Concurrent_Good_GetAll(t *testing.T) {
|
||||
s, err := New(testPath(t, "getall.db"))
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
// Seed data.
|
||||
for i := range 50 {
|
||||
require.NoError(t, s.Set("shared", fmt.Sprintf("k%d", i), fmt.Sprintf("v%d", i)))
|
||||
require.NoError(t, s.Set("shared", core.Sprintf("k%d", i), core.Sprintf("v%d", i)))
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
|
@ -607,8 +603,8 @@ func TestConcurrent_Good_GetAll(t *testing.T) {
|
|||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestConcurrent_Good_DeleteGroup(t *testing.T) {
|
||||
s, err := New(filepath.Join(t.TempDir(), "delgrp.db"))
|
||||
func TestStore_Concurrent_Good_DeleteGroup(t *testing.T) {
|
||||
s, err := New(testPath(t, "delgrp.db"))
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -617,9 +613,9 @@ func TestConcurrent_Good_DeleteGroup(t *testing.T) {
|
|||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
grp := fmt.Sprintf("g%d", id)
|
||||
grp := core.Sprintf("g%d", id)
|
||||
for i := range 20 {
|
||||
_ = s.Set(grp, fmt.Sprintf("k%d", i), "v")
|
||||
_ = s.Set(grp, core.Sprintf("k%d", i), "v")
|
||||
}
|
||||
_ = s.DeleteGroup(grp)
|
||||
}(g)
|
||||
|
|
@ -631,13 +627,13 @@ func TestConcurrent_Good_DeleteGroup(t *testing.T) {
|
|||
// ErrNotFound wrapping verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestErrNotFound_Good_Is(t *testing.T) {
|
||||
func TestStore_ErrNotFound_Good_Is(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
_, err := s.Get("g", "k")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrNotFound), "error should be ErrNotFound via errors.Is")
|
||||
assert.True(t, core.Is(err, ErrNotFound), "error should be ErrNotFound via core.Is")
|
||||
assert.Contains(t, err.Error(), "g/k", "error message should include group/key")
|
||||
}
|
||||
|
||||
|
|
@ -651,7 +647,7 @@ func BenchmarkSet(b *testing.B) {
|
|||
|
||||
b.ResetTimer()
|
||||
for i := range b.N {
|
||||
_ = s.Set("bench", fmt.Sprintf("key-%d", i), "value")
|
||||
_ = s.Set("bench", core.Sprintf("key-%d", i), "value")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -662,12 +658,12 @@ func BenchmarkGet(b *testing.B) {
|
|||
// Pre-populate.
|
||||
const keys = 10000
|
||||
for i := range keys {
|
||||
_ = s.Set("bench", fmt.Sprintf("key-%d", i), "value")
|
||||
_ = s.Set("bench", core.Sprintf("key-%d", i), "value")
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := range b.N {
|
||||
_, _ = s.Get("bench", fmt.Sprintf("key-%d", i%keys))
|
||||
_, _ = s.Get("bench", core.Sprintf("key-%d", i%keys))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -677,7 +673,7 @@ func BenchmarkGetAll(b *testing.B) {
|
|||
|
||||
const keys = 10000
|
||||
for i := range keys {
|
||||
_ = s.Set("bench", fmt.Sprintf("key-%d", i), "value")
|
||||
_ = s.Set("bench", core.Sprintf("key-%d", i), "value")
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
|
|
@ -687,13 +683,13 @@ func BenchmarkGetAll(b *testing.B) {
|
|||
}
|
||||
|
||||
func BenchmarkSet_FileBacked(b *testing.B) {
|
||||
dbPath := filepath.Join(b.TempDir(), "bench.db")
|
||||
dbPath := testPath(b, "bench.db")
|
||||
s, _ := New(dbPath)
|
||||
defer s.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := range b.N {
|
||||
_ = s.Set("bench", fmt.Sprintf("key-%d", i), "value")
|
||||
_ = s.Set("bench", core.Sprintf("key-%d", i), "value")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -701,7 +697,7 @@ func BenchmarkSet_FileBacked(b *testing.B) {
|
|||
// TTL support (Phase 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetWithTTL_Good(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -713,7 +709,7 @@ func TestSetWithTTL_Good(t *testing.T) {
|
|||
assert.Equal(t, "v", val)
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Good_Upsert(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good_Upsert(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -729,7 +725,7 @@ func TestSetWithTTL_Good_Upsert(t *testing.T) {
|
|||
assert.Equal(t, 1, n, "upsert should not duplicate keys")
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Good_ExpiresOnGet(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good_ExpiresOnGet(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -741,10 +737,10 @@ func TestSetWithTTL_Good_ExpiresOnGet(t *testing.T) {
|
|||
|
||||
_, err := s.Get("g", "ephemeral")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrNotFound), "expired key should be ErrNotFound")
|
||||
assert.True(t, core.Is(err, ErrNotFound), "expired key should be ErrNotFound")
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Good_ExcludedFromCount(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good_ExcludedFromCount(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -757,7 +753,7 @@ func TestSetWithTTL_Good_ExcludedFromCount(t *testing.T) {
|
|||
assert.Equal(t, 1, n, "expired key should not be counted")
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Good_ExcludedFromGetAll(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good_ExcludedFromGetAll(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -770,7 +766,7 @@ func TestSetWithTTL_Good_ExcludedFromGetAll(t *testing.T) {
|
|||
assert.Equal(t, map[string]string{"a": "1"}, all, "expired key should be excluded")
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Good_ExcludedFromRender(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good_ExcludedFromRender(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -783,7 +779,7 @@ func TestSetWithTTL_Good_ExcludedFromRender(t *testing.T) {
|
|||
assert.Equal(t, "Hello Alice", out)
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Good_SetClearsTTL(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good_SetClearsTTL(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -797,7 +793,7 @@ func TestSetWithTTL_Good_SetClearsTTL(t *testing.T) {
|
|||
assert.Equal(t, "permanent", val, "plain Set should clear TTL")
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Good_FutureTTLAccessible(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Good_FutureTTLAccessible(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -812,7 +808,7 @@ func TestSetWithTTL_Good_FutureTTLAccessible(t *testing.T) {
|
|||
assert.Equal(t, 1, n)
|
||||
}
|
||||
|
||||
func TestSetWithTTL_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_SetWithTTL_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -824,7 +820,7 @@ func TestSetWithTTL_Bad_ClosedStore(t *testing.T) {
|
|||
// PurgeExpired
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPurgeExpired_Good(t *testing.T) {
|
||||
func TestStore_PurgeExpired_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -842,7 +838,7 @@ func TestPurgeExpired_Good(t *testing.T) {
|
|||
assert.Equal(t, 1, n, "only non-expiring key should remain")
|
||||
}
|
||||
|
||||
func TestPurgeExpired_Good_NoneExpired(t *testing.T) {
|
||||
func TestStore_PurgeExpired_Good_NoneExpired(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -854,7 +850,7 @@ func TestPurgeExpired_Good_NoneExpired(t *testing.T) {
|
|||
assert.Equal(t, int64(0), removed)
|
||||
}
|
||||
|
||||
func TestPurgeExpired_Good_Empty(t *testing.T) {
|
||||
func TestStore_PurgeExpired_Good_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -863,7 +859,7 @@ func TestPurgeExpired_Good_Empty(t *testing.T) {
|
|||
assert.Equal(t, int64(0), removed)
|
||||
}
|
||||
|
||||
func TestPurgeExpired_Bad_ClosedStore(t *testing.T) {
|
||||
func TestStore_PurgeExpired_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
|
|
@ -871,7 +867,7 @@ func TestPurgeExpired_Bad_ClosedStore(t *testing.T) {
|
|||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPurgeExpired_Good_BackgroundPurge(t *testing.T) {
|
||||
func TestStore_PurgeExpired_Good_BackgroundPurge(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
// Override purge interval for testing: restart the goroutine with a short interval.
|
||||
s.cancel()
|
||||
|
|
@ -900,8 +896,8 @@ func TestPurgeExpired_Good_BackgroundPurge(t *testing.T) {
|
|||
// Schema migration — reopening an existing database
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchemaUpgrade_Good_ExistingDB(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "upgrade.db")
|
||||
func TestStore_SchemaUpgrade_Good_ExistingDB(t *testing.T) {
|
||||
dbPath := testPath(t, "upgrade.db")
|
||||
|
||||
// Open, write, close.
|
||||
s1, err := New(dbPath)
|
||||
|
|
@ -925,9 +921,9 @@ func TestSchemaUpgrade_Good_ExistingDB(t *testing.T) {
|
|||
assert.Equal(t, "ttl-val", val2)
|
||||
}
|
||||
|
||||
func TestSchemaUpgrade_Good_PreTTLDatabase(t *testing.T) {
|
||||
func TestStore_SchemaUpgrade_Good_PreTTLDatabase(t *testing.T) {
|
||||
// Simulate a database created before TTL support (no expires_at column).
|
||||
dbPath := filepath.Join(t.TempDir(), "pre-ttl.db")
|
||||
dbPath := testPath(t, "pre-ttl.db")
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
require.NoError(t, err)
|
||||
db.SetMaxOpenConns(1)
|
||||
|
|
@ -965,8 +961,8 @@ func TestSchemaUpgrade_Good_PreTTLDatabase(t *testing.T) {
|
|||
// Concurrent TTL access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestConcurrent_Good_TTL(t *testing.T) {
|
||||
s, err := New(filepath.Join(t.TempDir(), "concurrent-ttl.db"))
|
||||
func TestStore_Concurrent_Good_TTL(t *testing.T) {
|
||||
s, err := New(testPath(t, "concurrent-ttl.db"))
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
|
|
@ -978,9 +974,9 @@ func TestConcurrent_Good_TTL(t *testing.T) {
|
|||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
grp := fmt.Sprintf("ttl-%d", id)
|
||||
grp := core.Sprintf("ttl-%d", id)
|
||||
for i := range ops {
|
||||
key := fmt.Sprintf("k%d", i)
|
||||
key := core.Sprintf("k%d", i)
|
||||
if i%2 == 0 {
|
||||
_ = s.SetWithTTL(grp, key, "v", 50*time.Millisecond)
|
||||
} else {
|
||||
|
|
@ -995,7 +991,7 @@ func TestConcurrent_Good_TTL(t *testing.T) {
|
|||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
for g := range goroutines {
|
||||
grp := fmt.Sprintf("ttl-%d", g)
|
||||
grp := core.Sprintf("ttl-%d", g)
|
||||
n, err := s.Count(grp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ops/2, n, "only non-TTL keys should remain in %s", grp)
|
||||
|
|
|
|||
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