[agent/codex:gpt-5.4-mini] Update the code against the AX (Agent Experience) design pri... #17

Merged
Virgil merged 1 commit from agent/update-the-code-against-the-ax--agent-ex into dev 2026-03-30 13:52:30 +00:00
10 changed files with 153 additions and 122 deletions

View file

@ -63,12 +63,12 @@ sc.Set("config", "key", "val") // stored as "tenant:config" in underlying st
// With quota enforcement
sq, _ := store.NewScopedWithQuota(st, "tenant", store.QuotaConfig{MaxKeys: 100, MaxGroups: 10})
sq.Set("g", "k", "v") // returns ErrQuotaExceeded if limits hit
sq.Set("g", "k", "v") // returns QuotaExceededError if limits hit
// Event hooks
w := st.Watch("group", "*") // wildcard: all keys in group ("*","*" for all)
defer st.Unwatch(w)
e := <-w.Ch // buffered chan, cap 16
e := <-w.Events // buffered chan, cap 16
unreg := st.OnChange(func(e store.Event) { /* synchronous in writer goroutine */ })
defer unreg()

View file

@ -13,23 +13,38 @@ Group-namespaced SQLite key-value store with TTL expiry, namespace isolation, qu
## Quick Start
```go
import "dappco.re/go/core/store"
import (
"fmt"
"time"
st, err := store.New("/path/to/store.db") // or store.New(":memory:")
defer st.Close()
"dappco.re/go/core/store"
)
st.Set("config", "theme", "dark")
st.SetWithTTL("session", "token", "abc123", 24*time.Hour)
val, err := st.Get("config", "theme")
func main() {
st, err := store.New("/path/to/store.db") // or store.New(":memory:")
if err != nil {
panic(err)
}
defer st.Close()
// Watch for mutations
w := st.Watch("config", "*")
defer st.Unwatch(w)
for e := range w.Ch { fmt.Println(e.Type, e.Key) }
st.Set("config", "theme", "dark")
st.SetWithTTL("session", "token", "abc123", 24*time.Hour)
val, err := st.Get("config", "theme")
fmt.Println(val, err)
// Scoped store for tenant isolation
sc, _ := store.NewScoped(st, "tenant-42")
sc.Set("prefs", "locale", "en-GB")
// Watch for mutations
w := st.Watch("config", "*")
defer st.Unwatch(w)
go func() {
for e := range w.Events {
fmt.Println(e.Type, e.Key)
}
}()
// Scoped store for tenant isolation
sc, _ := store.NewScoped(st, "tenant-42")
sc.Set("prefs", "locale", "en-GB")
}
```
## Documentation

View file

@ -75,7 +75,7 @@ Expiry is enforced in three ways:
### 1. Lazy Deletion on Get
If a key is found but its `expires_at` is in the past, it is deleted synchronously before returning `ErrNotFound`. This prevents stale values from being returned even if the background purge has not run yet.
If a key is found but its `expires_at` is in the past, it is deleted synchronously before returning `NotFoundError`. This prevents stale values from being returned even if the background purge has not run yet.
### 2. Query-Time Filtering
@ -94,7 +94,7 @@ Two convenience methods build on `Get` to return iterators over parts of a store
- **`GetSplit(group, key, sep)`** splits the value by a custom separator, returning an `iter.Seq[string]` via `strings.SplitSeq`.
- **`GetFields(group, key)`** splits the value by whitespace, returning an `iter.Seq[string]` via `strings.FieldsSeq`.
Both return `ErrNotFound` if the key does not exist or has expired.
Both return `NotFoundError` if the key does not exist or has expired.
## Template Rendering
@ -137,7 +137,7 @@ Events are emitted synchronously after each successful database write inside the
### Watch/Unwatch
`Watch(group, key)` creates a `Watcher` with a buffered channel (`Ch <-chan Event`, capacity 16).
`Watch(group, key)` creates a `Watcher` with a buffered channel (`Events <-chan Event`, capacity 16). `Ch` remains as a compatibility alias.
| group argument | key argument | Receives |
|---|---|---|
@ -153,7 +153,7 @@ Events are emitted synchronously after each successful database write inside the
w := st.Watch("config", "*")
defer st.Unwatch(w)
for e := range w.Ch {
for e := range w.Events {
fmt.Println(e.Type, e.Group, e.Key, e.Value)
}
```
@ -214,7 +214,7 @@ Zero values mean unlimited. Before each `Set` or `SetWithTTL`, the scoped store:
2. If the key is new, queries `CountAll(namespace + ":")` and compares against `MaxKeys`.
3. If the group is new (current count for that group is zero), queries `GroupsSeq(namespace + ":")` and compares against `MaxGroups`.
Exceeding a limit returns `ErrQuotaExceeded`.
Exceeding a limit returns `QuotaExceededError`.
## Concurrency Model

View file

@ -59,13 +59,13 @@ func main() {
// Quota enforcement
quota := store.QuotaConfig{MaxKeys: 100, MaxGroups: 5}
sq, _ := store.NewScopedWithQuota(st, "tenant-99", quota)
err = sq.Set("g", "k", "v") // returns store.ErrQuotaExceeded if limits are hit
err = sq.Set("g", "k", "v") // returns store.QuotaExceededError if limits are hit
// Watch for mutations via a buffered channel
w := st.Watch("config", "*")
defer st.Unwatch(w)
go func() {
for e := range w.Ch {
for e := range w.Events {
core.Println("event", e.Type, e.Group, e.Key)
}
}()
@ -120,13 +120,13 @@ There are no other direct dependencies. The package uses the Go standard library
- **`ScopedStore`** -- wraps a `*Store` with an auto-prefixed namespace. Provides the same API surface with group names transparently prefixed.
- **`QuotaConfig`** -- configures per-namespace limits on total keys and distinct groups.
- **`Event`** -- describes a single store mutation (type, group, key, value, timestamp).
- **`Watcher`** -- a channel-based subscription to store events, created by `Watch`.
- **`KV`** -- a simple key-value pair struct, used by the `All` iterator.
- **`Watcher`** -- a channel-based subscription to store events, created by `Watch`. `Events` is the primary read-only channel; `Ch` remains as a compatibility alias.
- **`KeyValue`** -- a simple key-value pair struct, used by the `All` iterator. `KV` remains as a compatibility alias.
## Sentinel Errors
- **`ErrNotFound`** -- returned by `Get` when the requested key does not exist or has expired.
- **`ErrQuotaExceeded`** -- returned by `ScopedStore.Set`/`SetWithTTL` when a namespace quota limit is reached.
- **`NotFoundError`** -- returned by `Get` when the requested key does not exist or has expired. `ErrNotFound` remains as a compatibility alias.
- **`QuotaExceededError`** -- returned by `ScopedStore.Set`/`SetWithTTL` when a namespace quota limit is reached. `ErrQuotaExceeded` remains as a compatibility alias.
## Further Reading

View file

@ -50,13 +50,17 @@ 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", "*")`
// create one and Store.Unwatch to stop delivery. Events is the primary
// read-only channel; Ch remains for compatibility.
// Usage example: `watcher := st.Watch("config", "*"); for event := range watcher.Events { _ = event }`
type Watcher struct {
// Ch is the public read-only channel that consumers select on.
// Events is the public read-only channel that consumers select on.
Events <-chan Event
// Ch is a compatibility alias for Events.
Ch <-chan Event
// ch is the internal write channel (same underlying channel as Ch).
// ch is the internal write channel (same underlying channel as Events/Ch).
ch chan Event
group string
@ -70,8 +74,8 @@ type callbackEntry struct {
fn func(Event)
}
// watcherBufSize is the capacity of each watcher's buffered channel.
const watcherBufSize = 16
// watcherBufferSize is the capacity of each watcher's buffered channel.
const watcherBufferSize = 16
// Watch creates a new watcher that receives events matching the given group and
// key. Use "*" as a wildcard: ("mygroup", "*") matches all keys in that group,
@ -79,13 +83,14 @@ const watcherBufSize = 16
// 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)
ch := make(chan Event, watcherBufferSize)
w := &Watcher{
Ch: ch,
ch: ch,
group: group,
key: key,
id: atomic.AddUint64(&s.nextID, 1),
Events: ch,
Ch: ch,
ch: ch,
group: group,
key: key,
id: atomic.AddUint64(&s.nextID, 1),
}
s.mu.Lock()
@ -99,6 +104,10 @@ func (s *Store) Watch(group, key string) *Watcher {
// times; subsequent calls are no-ops.
// Usage example: `st.Unwatch(watcher)`
func (s *Store) Unwatch(w *Watcher) {
if w == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()

View file

@ -25,7 +25,7 @@ func TestEvents_Watch_Good_SpecificKey(t *testing.T) {
require.NoError(t, s.Set("config", "theme", "dark"))
select {
case e := <-w.Ch:
case e := <-w.Events:
assert.Equal(t, EventSet, e.Type)
assert.Equal(t, "config", e.Group)
assert.Equal(t, "theme", e.Key)
@ -39,7 +39,7 @@ func TestEvents_Watch_Good_SpecificKey(t *testing.T) {
require.NoError(t, s.Set("config", "colour", "blue"))
select {
case e := <-w.Ch:
case e := <-w.Events:
t.Fatalf("unexpected event for non-matching key: %+v", e)
case <-time.After(50 * time.Millisecond):
// Expected: no event.
@ -60,7 +60,7 @@ func TestEvents_Watch_Good_WildcardKey(t *testing.T) {
require.NoError(t, s.Set("config", "theme", "dark"))
require.NoError(t, s.Set("config", "colour", "blue"))
received := drainEvents(w.Ch, 2, time.Second)
received := drainEvents(w.Events, 2, time.Second)
require.Len(t, received, 2)
assert.Equal(t, "theme", received[0].Key)
assert.Equal(t, "colour", received[1].Key)
@ -82,7 +82,7 @@ func TestEvents_Watch_Good_WildcardAll(t *testing.T) {
require.NoError(t, s.Delete("g1", "k1"))
require.NoError(t, s.DeleteGroup("g2"))
received := drainEvents(w.Ch, 4, time.Second)
received := drainEvents(w.Events, 4, time.Second)
require.Len(t, received, 4)
assert.Equal(t, EventSet, received[0].Type)
assert.Equal(t, EventSet, received[1].Type)
@ -102,7 +102,7 @@ func TestEvents_Unwatch_Good_StopsDelivery(t *testing.T) {
s.Unwatch(w)
// Channel should be closed.
_, open := <-w.Ch
_, open := <-w.Events
assert.False(t, open, "channel should be closed after Unwatch")
// Set after Unwatch should not panic or block.
@ -133,12 +133,12 @@ func TestEvents_Watch_Good_DeleteEvent(t *testing.T) {
require.NoError(t, s.Set("g", "k", "v"))
// Drain the Set event.
<-w.Ch
<-w.Events
require.NoError(t, s.Delete("g", "k"))
select {
case e := <-w.Ch:
case e := <-w.Events:
assert.Equal(t, EventDelete, e.Type)
assert.Equal(t, "g", e.Group)
assert.Equal(t, "k", e.Key)
@ -163,13 +163,13 @@ func TestEvents_Watch_Good_DeleteGroupEvent(t *testing.T) {
require.NoError(t, s.Set("g", "a", "1"))
require.NoError(t, s.Set("g", "b", "2"))
// Drain Set events.
<-w.Ch
<-w.Ch
<-w.Events
<-w.Events
require.NoError(t, s.DeleteGroup("g"))
select {
case e := <-w.Ch:
case e := <-w.Events:
assert.Equal(t, EventDeleteGroup, e.Type)
assert.Equal(t, "g", e.Group)
assert.Empty(t, e.Key, "DeleteGroup events should have empty Key")
@ -259,16 +259,16 @@ func TestEvents_Watch_Good_BufferFullDoesNotBlock(t *testing.T) {
t.Fatal("writes blocked — buffer-full condition caused deadlock")
}
// Drain what we can — should get exactly watcherBufSize events.
// Drain what we can — should get exactly watcherBufferSize events.
var received int
for range watcherBufSize {
for range watcherBufferSize {
select {
case <-w.Ch:
case <-w.Events:
received++
default:
}
}
assert.Equal(t, watcherBufSize, received, "should receive exactly buffer-size events")
assert.Equal(t, watcherBufferSize, received, "should receive exactly buffer-size events")
}
// ---------------------------------------------------------------------------
@ -288,14 +288,14 @@ func TestEvents_Watch_Good_MultipleWatchersSameKey(t *testing.T) {
// Both watchers should receive the event independently.
select {
case e := <-w1.Ch:
case e := <-w1.Events:
assert.Equal(t, EventSet, e.Type)
case <-time.After(time.Second):
t.Fatal("w1 timed out")
}
select {
case e := <-w2.Ch:
case e := <-w2.Events:
assert.Equal(t, EventSet, e.Type)
case <-time.After(time.Second):
t.Fatal("w2 timed out")
@ -330,7 +330,7 @@ func TestEvents_Watch_Good_ConcurrentWatchUnwatch(t *testing.T) {
// Drain a few events to exercise the channel path.
for range 3 {
select {
case <-w.Ch:
case <-w.Events:
case <-time.After(time.Millisecond):
}
}
@ -361,7 +361,7 @@ func TestEvents_Watch_Good_ScopedStoreEvents(t *testing.T) {
require.NoError(t, sc.Set("config", "theme", "dark"))
select {
case e := <-w.Ch:
case e := <-w.Events:
assert.Equal(t, EventSet, e.Type)
assert.Equal(t, "tenant-a:config", e.Group)
assert.Equal(t, "theme", e.Key)
@ -396,7 +396,7 @@ func TestEvents_Watch_Good_SetWithTTLEmitsEvent(t *testing.T) {
require.NoError(t, s.SetWithTTL("g", "k", "ttl-val", time.Hour))
select {
case e := <-w.Ch:
case e := <-w.Events:
assert.Equal(t, EventSet, e.Type)
assert.Equal(t, "g", e.Group)
assert.Equal(t, "k", e.Key)

View file

@ -52,8 +52,8 @@ func NewScopedWithQuota(store *Store, namespace string, quota QuotaConfig) (*Sco
return s, nil
}
// prefix returns the namespaced group name.
func (s *ScopedStore) prefix(group string) string {
// namespacedGroup returns the group name with the namespace prefix applied.
func (s *ScopedStore) namespacedGroup(group string) string {
return s.namespace + ":" + group
}
@ -66,7 +66,7 @@ func (s *ScopedStore) Namespace() string {
// 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)
return s.store.Get(s.namespacedGroup(group), key)
}
// Set stores a value by group and key within the namespace. If quotas are
@ -76,7 +76,7 @@ func (s *ScopedStore) Set(group, key, value string) error {
if err := s.checkQuota(group, key); err != nil {
return err
}
return s.store.Set(s.prefix(group), key, value)
return s.store.Set(s.namespacedGroup(group), key, value)
}
// SetWithTTL stores a value with a time-to-live within the namespace. Quota
@ -86,46 +86,46 @@ func (s *ScopedStore) SetWithTTL(group, key, value string, ttl time.Duration) er
if err := s.checkQuota(group, key); err != nil {
return err
}
return s.store.SetWithTTL(s.prefix(group), key, value, ttl)
return s.store.SetWithTTL(s.namespacedGroup(group), key, value, ttl)
}
// 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)
return s.store.Delete(s.namespacedGroup(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))
return s.store.DeleteGroup(s.namespacedGroup(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))
return s.store.GetAll(s.namespacedGroup(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))
// Usage example: `for item, err := range sc.All("config") { _ = item; _ = err }`
func (s *ScopedStore) All(group string) iter.Seq2[KeyValue, error] {
return s.store.All(s.namespacedGroup(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))
return s.store.Count(s.namespacedGroup(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))
return s.store.Render(tmplStr, s.namespacedGroup(group))
}
// checkQuota verifies that inserting key into group would not exceed the
@ -136,48 +136,48 @@ func (s *ScopedStore) checkQuota(group, key string) error {
return nil
}
prefixedGroup := s.prefix(group)
nsPrefix := s.namespace + ":"
namespacedGroup := s.namespacedGroup(group)
namespacePrefix := s.namespace + ":"
// Check if this is an upsert (key already exists) — upserts never exceed quota.
_, err := s.store.Get(prefixedGroup, key)
_, err := s.store.Get(namespacedGroup, key)
if err == nil {
// Key exists — this is an upsert, no quota check needed.
return nil
}
if !core.Is(err, ErrNotFound) {
if !core.Is(err, NotFoundError) {
// A database error occurred, not just a "not found" result.
return core.E("store.ScopedStore", "quota check", err)
}
// Check MaxKeys quota.
if s.quota.MaxKeys > 0 {
count, err := s.store.CountAll(nsPrefix)
count, err := s.store.CountAll(namespacePrefix)
if err != nil {
return core.E("store.ScopedStore", "quota check", err)
}
if count >= s.quota.MaxKeys {
return core.E("store.ScopedStore", core.Sprintf("key limit (%d)", s.quota.MaxKeys), ErrQuotaExceeded)
return core.E("store.ScopedStore", core.Sprintf("key limit (%d)", s.quota.MaxKeys), QuotaExceededError)
}
}
// Check MaxGroups quota — only if this would create a new group.
if s.quota.MaxGroups > 0 {
groupCount, err := s.store.Count(prefixedGroup)
groupCount, err := s.store.Count(namespacedGroup)
if err != nil {
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) {
for _, err := range s.store.GroupsSeq(namespacePrefix) {
if err != nil {
return core.E("store.ScopedStore", "quota check", err)
}
count++
}
if count >= s.quota.MaxGroups {
return core.E("store.ScopedStore", core.Sprintf("group limit (%d)", s.quota.MaxGroups), ErrQuotaExceeded)
return core.E("store.ScopedStore", core.Sprintf("group limit (%d)", s.quota.MaxGroups), QuotaExceededError)
}
}
}

View file

@ -85,7 +85,7 @@ func TestScope_ScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) {
// Direct access without prefix should fail.
_, err = s.Get("config", "key")
assert.True(t, core.Is(err, ErrNotFound))
assert.True(t, core.Is(err, NotFoundError))
}
func TestScope_ScopedStore_Good_NamespaceIsolation(t *testing.T) {
@ -116,7 +116,7 @@ func TestScope_ScopedStore_Good_Delete(t *testing.T) {
require.NoError(t, sc.Delete("g", "k"))
_, err := sc.Get("g", "k")
assert.True(t, core.Is(err, ErrNotFound))
assert.True(t, core.Is(err, NotFoundError))
}
func TestScope_ScopedStore_Good_DeleteGroup(t *testing.T) {
@ -187,7 +187,7 @@ func TestScope_ScopedStore_Good_SetWithTTL_Expires(t *testing.T) {
time.Sleep(5 * time.Millisecond)
_, err := sc.Get("g", "k")
assert.True(t, core.Is(err, ErrNotFound))
assert.True(t, core.Is(err, NotFoundError))
}
func TestScope_ScopedStore_Good_Render(t *testing.T) {
@ -221,7 +221,7 @@ func TestScope_Quota_Good_MaxKeys(t *testing.T) {
// 6th key should fail.
err = sc.Set("g", "overflow", "v")
require.Error(t, err)
assert.True(t, core.Is(err, ErrQuotaExceeded), "expected ErrQuotaExceeded, got: %v", err)
assert.True(t, core.Is(err, QuotaExceededError), "expected QuotaExceededError, got: %v", err)
}
func TestScope_Quota_Good_MaxKeys_AcrossGroups(t *testing.T) {
@ -236,7 +236,7 @@ func TestScope_Quota_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, core.Is(err, ErrQuotaExceeded))
assert.True(t, core.Is(err, QuotaExceededError))
}
func TestScope_Quota_Good_UpsertDoesNotCount(t *testing.T) {
@ -303,7 +303,7 @@ func TestScope_Quota_Good_ExpiredKeysExcluded(t *testing.T) {
// Now at 3 — next should fail.
err := sc.Set("g", "new3", "v")
assert.True(t, core.Is(err, ErrQuotaExceeded))
assert.True(t, core.Is(err, QuotaExceededError))
}
func TestScope_Quota_Good_SetWithTTL_Enforced(t *testing.T) {
@ -316,7 +316,7 @@ func TestScope_Quota_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, core.Is(err, ErrQuotaExceeded))
assert.True(t, core.Is(err, QuotaExceededError))
}
// ---------------------------------------------------------------------------
@ -336,7 +336,7 @@ func TestScope_Quota_Good_MaxGroups(t *testing.T) {
// 4th group should fail.
err := sc.Set("g4", "k", "v")
require.Error(t, err)
assert.True(t, core.Is(err, ErrQuotaExceeded))
assert.True(t, core.Is(err, QuotaExceededError))
}
func TestScope_Quota_Good_MaxGroups_ExistingGroupOK(t *testing.T) {
@ -405,7 +405,7 @@ func TestScope_Quota_Good_BothLimits(t *testing.T) {
// Group limit hit.
err := sc.Set("g3", "c", "3")
assert.True(t, core.Is(err, ErrQuotaExceeded))
assert.True(t, core.Is(err, QuotaExceededError))
// But adding to existing groups is fine (within key limit).
require.NoError(t, sc.Set("g1", "d", "4"))
@ -425,11 +425,11 @@ func TestScope_Quota_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, core.Is(err, ErrQuotaExceeded))
assert.True(t, core.Is(err, QuotaExceededError))
// b is also at limit independently.
err = b.Set("g", "b3", "v")
assert.True(t, core.Is(err, ErrQuotaExceeded))
assert.True(t, core.Is(err, QuotaExceededError))
}
// ---------------------------------------------------------------------------

View file

@ -13,13 +13,21 @@ import (
_ "modernc.org/sqlite"
)
// ErrNotFound is returned when a key does not exist in the store.
// Usage example: `if core.Is(err, store.ErrNotFound) { return }`
var ErrNotFound = core.E("store", "not found", nil)
// NotFoundError is returned when a key does not exist in the store.
// Usage example: `if core.Is(err, store.NotFoundError) { return }`
var NotFoundError = core.E("store", "not found", nil)
// ErrQuotaExceeded is returned when a namespace quota limit is reached.
// ErrNotFound is a compatibility alias for NotFoundError.
// Usage example: `if core.Is(err, store.ErrNotFound) { return }`
var ErrNotFound = NotFoundError
// QuotaExceededError is returned when a namespace quota limit is reached.
// Usage example: `if core.Is(err, store.QuotaExceededError) { return }`
var QuotaExceededError = core.E("store", "quota exceeded", nil)
// ErrQuotaExceeded is a compatibility alias for QuotaExceededError.
// Usage example: `if core.Is(err, store.ErrQuotaExceeded) { return }`
var ErrQuotaExceeded = core.E("store", "quota exceeded", nil)
var ErrQuotaExceeded = QuotaExceededError
// Store is a group-namespaced key-value store backed by SQLite.
// Usage example: `st, _ := store.New(":memory:")`
@ -29,7 +37,7 @@ type Store struct {
wg sync.WaitGroup
purgeInterval time.Duration // interval between background purge cycles
// Event hooks (Phase 3).
// Event dispatch state.
watchers []*Watcher
callbacks []callbackEntry
mu sync.RWMutex // protects watchers and callbacks
@ -100,19 +108,14 @@ func (s *Store) Get(group, key string) (string, error) {
group, key,
).Scan(&val, &expiresAt)
if err == sql.ErrNoRows {
return "", core.E("store.Get", core.Concat(group, "/", key), ErrNotFound)
return "", core.E("store.Get", core.Concat(group, "/", key), NotFoundError)
}
if err != nil {
return "", core.E("store.Get", "query", err)
}
if expiresAt.Valid && expiresAt.Int64 <= time.Now().UnixMilli() {
// Lazily delete the expired entry.
if _, err := s.db.Exec("DELETE FROM kv WHERE grp = ? AND key = ?", group, key); err != nil {
// Log error or ignore; we return ErrNotFound regardless.
// For now, we wrap the error to provide context if the delete fails
// for reasons other than "already deleted".
}
return "", core.E("store.Get", core.Concat(group, "/", key), ErrNotFound)
_, _ = s.db.Exec("DELETE FROM kv WHERE grp = ? AND key = ?", group, key)
return "", core.E("store.Get", core.Concat(group, "/", key), NotFoundError)
}
return val, nil
}
@ -187,12 +190,16 @@ func (s *Store) DeleteGroup(group string) error {
return nil
}
// KV represents a key-value pair.
// Usage example: `for kv, err := range st.All("config") { _ = kv }`
type KV struct {
// KeyValue represents a key-value pair.
// Usage example: `for item, err := range st.All("config") { _ = item }`
type KeyValue struct {
Key, Value string
}
// KV is a compatibility alias for KeyValue.
// Usage example: `item := store.KV{Key: "theme", Value: "dark"}`
type KV = KeyValue
// 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) {
@ -207,23 +214,23 @@ 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) {
// Usage example: `for item, err := range st.All("config") { _ = item; _ = err }`
func (s *Store) All(group string) iter.Seq2[KeyValue, error] {
return func(yield func(KeyValue, error) bool) {
rows, err := s.db.Query(
"SELECT key, value FROM kv WHERE grp = ? AND (expires_at IS NULL OR expires_at > ?)",
group, time.Now().UnixMilli(),
)
if err != nil {
yield(KV{}, core.E("store.All", "query", err))
yield(KeyValue{}, core.E("store.All", "query", err))
return
}
defer rows.Close()
for rows.Next() {
var kv KV
var kv KeyValue
if err := rows.Scan(&kv.Key, &kv.Value); err != nil {
if !yield(KV{}, core.E("store.All", "scan", err)) {
if !yield(KeyValue{}, core.E("store.All", "scan", err)) {
return
}
continue
@ -233,7 +240,7 @@ func (s *Store) All(group string) iter.Seq2[KV, error] {
}
}
if err := rows.Err(); err != nil {
yield(KV{}, core.E("store.All", "rows", err))
yield(KeyValue{}, core.E("store.All", "rows", err))
}
}
}

View file

@ -136,7 +136,7 @@ func TestStore_Get_Bad_NotFound(t *testing.T) {
_, err := s.Get("config", "missing")
require.Error(t, err)
assert.True(t, core.Is(err, ErrNotFound), "should wrap ErrNotFound")
assert.True(t, core.Is(err, NotFoundError), "should wrap NotFoundError")
}
func TestStore_Get_Bad_NonExistentGroup(t *testing.T) {
@ -145,7 +145,7 @@ func TestStore_Get_Bad_NonExistentGroup(t *testing.T) {
_, err := s.Get("no-such-group", "key")
require.Error(t, err)
assert.True(t, core.Is(err, ErrNotFound))
assert.True(t, core.Is(err, NotFoundError))
}
func TestStore_Get_Bad_ClosedStore(t *testing.T) {
@ -553,8 +553,8 @@ func TestStore_Concurrent_Good_ReadWrite(t *testing.T) {
for i := range opsPerGoroutine {
key := core.Sprintf("key-%d", i)
_, err := s.Get(group, key)
// ErrNotFound is acceptable — the writer may not have written yet.
if err != nil && !core.Is(err, ErrNotFound) {
// NotFoundError is acceptable — the writer may not have written yet.
if err != nil && !core.Is(err, NotFoundError) {
errs <- core.E("TestStore_Concurrent_Good_ReadWrite", core.Sprintf("reader %d", id), err)
}
}
@ -624,16 +624,16 @@ func TestStore_Concurrent_Good_DeleteGroup(t *testing.T) {
}
// ---------------------------------------------------------------------------
// ErrNotFound wrapping verification
// NotFoundError wrapping verification
// ---------------------------------------------------------------------------
func TestStore_ErrNotFound_Good_Is(t *testing.T) {
func TestStore_NotFoundError_Good_Is(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
_, err := s.Get("g", "k")
require.Error(t, err)
assert.True(t, core.Is(err, ErrNotFound), "error should be ErrNotFound via core.Is")
assert.True(t, core.Is(err, NotFoundError), "error should be NotFoundError via core.Is")
assert.Contains(t, err.Error(), "g/k", "error message should include group/key")
}
@ -737,7 +737,7 @@ func TestStore_SetWithTTL_Good_ExpiresOnGet(t *testing.T) {
_, err := s.Get("g", "ephemeral")
require.Error(t, err)
assert.True(t, core.Is(err, ErrNotFound), "expired key should be ErrNotFound")
assert.True(t, core.Is(err, NotFoundError), "expired key should be NotFoundError")
}
func TestStore_SetWithTTL_Good_ExcludedFromCount(t *testing.T) {