[agent/codex:gpt-5.4-mini] Update the code against the AX (Agent Experience) design pri... #17
10 changed files with 153 additions and 122 deletions
|
|
@ -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()
|
||||
|
|
|
|||
41
README.md
41
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
33
events.go
33
events.go
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
44
scope.go
44
scope.go
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
55
store.go
55
store.go
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue