From a6001b92ef4408842a941c8a67d287b3776d6b41 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 13:51:58 +0000 Subject: [PATCH] refactor(store): adopt AX-friendly public names Co-Authored-By: Virgil --- CLAUDE.md | 4 ++-- README.md | 41 ++++++++++++++++++++++----------- docs/architecture.md | 10 ++++---- docs/index.md | 12 +++++----- events.go | 33 ++++++++++++++++---------- events_test.go | 38 +++++++++++++++--------------- scope.go | 44 +++++++++++++++++------------------ scope_test.go | 22 +++++++++--------- store.go | 55 +++++++++++++++++++++++++------------------- store_test.go | 16 ++++++------- 10 files changed, 153 insertions(+), 122 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3432f3d..4adb0bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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() diff --git a/README.md b/README.md index 05399b2..d99207c 100644 --- a/README.md +++ b/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 diff --git a/docs/architecture.md b/docs/architecture.md index 0a7770f..6a639d6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/index.md b/docs/index.md index 5e643df..c3996f6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/events.go b/events.go index feb6b6b..205216c 100644 --- a/events.go +++ b/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() diff --git a/events_test.go b/events_test.go index c677e8b..05a40e8 100644 --- a/events_test.go +++ b/events_test.go @@ -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) diff --git a/scope.go b/scope.go index 6fd0da7..fc259f9 100644 --- a/scope.go +++ b/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) } } } diff --git a/scope_test.go b/scope_test.go index c7a574f..d5a4512 100644 --- a/scope_test.go +++ b/scope_test.go @@ -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)) } // --------------------------------------------------------------------------- diff --git a/store.go b/store.go index 2a5dc1e..ba04785 100644 --- a/store.go +++ b/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)) } } } diff --git a/store_test.go b/store_test.go index f447447..03450b6 100644 --- a/store_test.go +++ b/store_test.go @@ -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) { -- 2.45.3