From 05410a9498fa4b51c30eaef52b48167d029c602a Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 19:06:00 +0000 Subject: [PATCH] refactor(store): clarify public AX surface Add descriptive public type comments and rename watcher pattern fields so the package reads more directly for agent consumers. Co-Authored-By: Virgil --- events.go | 28 +++++++++++++++++----------- scope.go | 5 +++++ store.go | 5 +++++ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/events.go b/events.go index dee9bb4..166e7da 100644 --- a/events.go +++ b/events.go @@ -7,6 +7,7 @@ import ( "time" ) +// EventType identifies the kind of mutation emitted by Store. // Usage example: `if event.Type == store.EventSet { return }` type EventType int @@ -33,6 +34,7 @@ func (t EventType) String() string { } } +// Event describes one mutation delivered to watchers and callbacks. // Usage example: `event := store.Event{Type: store.EventSet, Group: "config", Key: "colour", Value: "blue"}` // Usage example: `event := store.Event{Type: store.EventDeleteGroup, Group: "config"}` type Event struct { @@ -48,16 +50,17 @@ type Event struct { Timestamp time.Time } +// Watcher exposes the read-only event stream returned by Watch. // Usage example: `watcher := storeInstance.Watch("config", "*"); defer storeInstance.Unwatch(watcher); for event := range watcher.Events { if event.Type == EventDeleteGroup { return } }` type Watcher struct { // Usage example: `for event := range watcher.Events { if event.Key == "colour" { return } }` Events <-chan Event - // eventChannel is the internal write channel (same underlying channel as Events). - eventChannel chan Event + // eventsChannel is the internal write channel (same underlying channel as Events). + eventsChannel chan Event - group string - key string + groupPattern string + keyPattern string registrationID uint64 } @@ -72,14 +75,15 @@ type changeCallbackRegistration struct { // start dropping new ones. const watcherEventBufferCapacity = 16 +// Watch registers a buffered subscription for matching mutations. // Usage example: `watcher := storeInstance.Watch("*", "*")` func (storeInstance *Store) Watch(group, key string) *Watcher { eventChannel := make(chan Event, watcherEventBufferCapacity) watcher := &Watcher{ Events: eventChannel, - eventChannel: eventChannel, - group: group, - key: key, + eventsChannel: eventChannel, + groupPattern: group, + keyPattern: key, registrationID: atomic.AddUint64(&storeInstance.nextWatcherRegistrationID, 1), } @@ -90,6 +94,7 @@ func (storeInstance *Store) Watch(group, key string) *Watcher { return watcher } +// Unwatch removes a watcher and closes its event stream. // Usage example: `storeInstance.Unwatch(watcher)` func (storeInstance *Store) Unwatch(watcher *Watcher) { if watcher == nil { @@ -101,13 +106,14 @@ func (storeInstance *Store) Unwatch(watcher *Watcher) { storeInstance.watchers = slices.DeleteFunc(storeInstance.watchers, func(existing *Watcher) bool { if existing.registrationID == watcher.registrationID { - close(watcher.eventChannel) + close(watcher.eventsChannel) return true } return false }) } +// OnChange registers a synchronous mutation callback. // Usage example: `events := make(chan store.Event, 1); unregister := storeInstance.OnChange(func(event store.Event) { events <- event }); defer unregister()` func (storeInstance *Store) OnChange(callback func(Event)) func() { registrationID := atomic.AddUint64(&storeInstance.nextCallbackRegistrationID, 1) @@ -144,7 +150,7 @@ func (storeInstance *Store) notify(event Event) { } // Non-blocking send: drop the event rather than block the writer. select { - case watcher.eventChannel <- event: + case watcher.eventsChannel <- event: default: } } @@ -162,10 +168,10 @@ func (storeInstance *Store) notify(event Event) { // watcherMatches reports whether Watch("config", "*") should receive // Event{Group: "config", Key: "colour"}. func watcherMatches(watcher *Watcher, event Event) bool { - if watcher.group != "*" && watcher.group != event.Group { + if watcher.groupPattern != "*" && watcher.groupPattern != event.Group { return false } - if watcher.key != "*" && watcher.key != event.Key { + if watcher.keyPattern != "*" && watcher.keyPattern != event.Key { // EventDeleteGroup has an empty Key — only wildcard watchers or // group-level watchers (key="*") should receive it. return false diff --git a/scope.go b/scope.go index 51d0847..158d80d 100644 --- a/scope.go +++ b/scope.go @@ -11,6 +11,7 @@ import ( // validNamespace.MatchString("tenant-a") is true; validNamespace.MatchString("tenant_a") is false. var validNamespace = regexp.MustCompile(`^[a-zA-Z0-9-]+$`) +// QuotaConfig sets per-namespace key and group limits. // Usage example: `quota := store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}` type QuotaConfig struct { // Usage example: `store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}` limits a namespace to 100 keys. @@ -19,6 +20,7 @@ type QuotaConfig struct { MaxGroups int } +// ScopedStore prefixes group names with namespace + ":" before delegating to Store. // Usage example: `scopedStore, err := store.NewScoped(storeInstance, "tenant-a"); if err != nil { return }; if err := scopedStore.Set("config", "colour", "blue"); err != nil { return }` type ScopedStore struct { storeInstance *Store @@ -26,6 +28,7 @@ type ScopedStore struct { quota QuotaConfig } +// NewScoped validates a namespace and prefixes groups with namespace + ":". // Usage example: `scopedStore, err := store.NewScoped(storeInstance, "tenant-a"); if err != nil { return }` func NewScoped(storeInstance *Store, namespace string) (*ScopedStore, error) { if !validNamespace.MatchString(namespace) { @@ -35,6 +38,7 @@ func NewScoped(storeInstance *Store, namespace string) (*ScopedStore, error) { return scopedStore, nil } +// NewScopedWithQuota adds per-namespace key and group limits. // Usage example: `scopedStore, err := store.NewScopedWithQuota(storeInstance, "tenant-a", store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}); if err != nil { return }` func NewScopedWithQuota(storeInstance *Store, namespace string, quota QuotaConfig) (*ScopedStore, error) { scopedStore, err := NewScoped(storeInstance, namespace) @@ -49,6 +53,7 @@ func (scopedStore *ScopedStore) namespacedGroup(group string) string { return scopedStore.namespace + ":" + group } +// Namespace returns the namespace string. // Usage example: `scopedStore, err := store.NewScoped(storeInstance, "tenant-a"); if err != nil { return }; namespace := scopedStore.Namespace(); fmt.Println(namespace)` func (scopedStore *ScopedStore) Namespace() string { return scopedStore.namespace diff --git a/store.go b/store.go index 74425ba..0d3d5ff 100644 --- a/store.go +++ b/store.go @@ -13,9 +13,11 @@ import ( _ "modernc.org/sqlite" ) +// NotFoundError is returned when Get cannot find a live key. // Usage example: `if core.Is(err, store.NotFoundError) { return }` var NotFoundError = core.E("store", "not found", nil) +// QuotaExceededError is returned when a scoped write would exceed quota. // Usage example: `if core.Is(err, store.QuotaExceededError) { return }` var QuotaExceededError = core.E("store", "quota exceeded", nil) @@ -27,6 +29,8 @@ const ( entryValueColumn = "entry_value" ) +// Store provides SQLite-backed grouped entries with TTL expiry, namespace +// isolation, and reactive change notifications. // Usage example: `storeInstance, err := store.New(":memory:"); if err != nil { return }; if err := storeInstance.Set("config", "colour", "blue"); err != nil { return }` type Store struct { database *sql.DB @@ -168,6 +172,7 @@ func (storeInstance *Store) DeleteGroup(group string) error { return nil } +// KeyValue is one item returned by All. // Usage example: `for entry, err := range storeInstance.All("config") { if err != nil { break }; fmt.Println(entry.Key, entry.Value) }` type KeyValue struct { // Usage example: `if entry.Key == "colour" { return }` -- 2.45.3