refactor(store): tighten AX public comments
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-30 16:20:35 +00:00
parent cdc4d5a11d
commit 0bda91f0bd
4 changed files with 13 additions and 41 deletions

8
doc.go
View file

@ -1,18 +1,14 @@
// Package store provides a SQLite-backed key-value store with group namespaces,
// Package store provides SQLite-backed key-value storage with group namespaces,
// TTL expiry, quota-enforced scoped views, and reactive change notifications.
//
// Usage example:
//
// storeInstance, _ := store.New(":memory:")
// defer storeInstance.Close()
// storeInstance.Set("config", "theme", "dark")
// _ = storeInstance.Set("config", "theme", "dark")
// themeValue, _ := storeInstance.Get("config", "theme")
// scopedStore, _ := store.NewScoped(storeInstance, "tenant-a")
// _ = scopedStore.Set("config", "theme", "dark")
// quotaScopedStore, _ := store.NewScopedWithQuota(storeInstance, "tenant-b", store.QuotaConfig{MaxKeys: 100, MaxGroups: 10})
// _ = quotaScopedStore.Set("prefs", "locale", "en-GB")
//
// Use New to open a store, then Set/Get for CRUD operations. Use
// NewScoped/NewScopedWithQuota when group names need tenant isolation or
// per-namespace quotas.
package store

View file

@ -11,13 +11,10 @@ import (
type EventType int
const (
// EventSet indicates a key was created or updated.
// Usage example: `if event.Type == store.EventSet { return }`
EventSet EventType = iota
// EventDelete indicates a single key was removed.
// Usage example: `if event.Type == store.EventDelete { return }`
EventDelete
// EventDeleteGroup indicates all keys in a group were removed.
// Usage example: `if event.Type == store.EventDeleteGroup { return }`
EventDeleteGroup
)
@ -38,7 +35,6 @@ func (t EventType) String() string {
// Usage example: `event := store.Event{Type: store.EventSet, Group: "config", Key: "theme", Value: "dark"}`
// Usage example: `event := store.Event{Type: store.EventDeleteGroup, Group: "config"}`
// EventDeleteGroup leaves Key and Value empty.
type Event struct {
Type EventType
Group string
@ -69,8 +65,7 @@ type changeCallbackRegistration struct {
// watcherEventBufferCapacity is the capacity of each watcher's buffered channel.
const watcherEventBufferCapacity = 16
// Usage example: `watcher := storeInstance.Watch("config", "*")`
// `("*", "*")` matches every mutation and the watcher buffer holds 16 events.
// Usage example: `watcher := storeInstance.Watch("*", "*")`
func (storeInstance *Store) Watch(group, key string) *Watcher {
eventChannel := make(chan Event, watcherEventBufferCapacity)
watcher := &Watcher{
@ -89,7 +84,6 @@ func (storeInstance *Store) Watch(group, key string) *Watcher {
}
// Usage example: `storeInstance.Unwatch(watcher)`
// Safe to call multiple times; subsequent calls are no-ops.
func (storeInstance *Store) Unwatch(watcher *Watcher) {
if watcher == nil {
return
@ -107,8 +101,7 @@ func (storeInstance *Store) Unwatch(watcher *Watcher) {
})
}
// Usage example: `unregister := storeInstance.OnChange(func(event store.Event) { hub.SendToChannel("store-events", event) }); defer unregister()`
// Callbacks run synchronously in the writer goroutine, so keep heavy work out of the handler.
// 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.nextRegistrationID, 1)
callbackRegistration := changeCallbackRegistration{id: registrationID, callback: callback}

View file

@ -11,14 +11,12 @@ import (
// validNamespace matches alphanumeric characters and hyphens (non-empty).
var validNamespace = regexp.MustCompile(`^[a-zA-Z0-9-]+$`)
// Zero values mean unlimited.
// Usage example: `quota := store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}`
type QuotaConfig struct {
MaxKeys int // maximum total keys across all groups in the namespace
MaxGroups int // maximum distinct groups in the namespace
}
// Group names are prefixed with namespace + ":" before reaching the underlying store.
// Usage example: `scopedStore, _ := store.NewScoped(storeInstance, "tenant-a"); _ = scopedStore.Set("config", "theme", "dark")`
type ScopedStore struct {
storeInstance *Store
@ -26,7 +24,6 @@ type ScopedStore struct {
quota QuotaConfig
}
// Namespaces must be non-empty and contain only alphanumeric characters and hyphens.
// Usage example: `scopedStore, _ := store.NewScoped(storeInstance, "tenant-a")`
func NewScoped(storeInstance *Store, namespace string) (*ScopedStore, error) {
if !validNamespace.MatchString(namespace) {
@ -36,7 +33,6 @@ func NewScoped(storeInstance *Store, namespace string) (*ScopedStore, error) {
return scopedStore, nil
}
// Quotas are checked before new keys or groups are created.
// Usage example: `scopedStore, _ := store.NewScopedWithQuota(storeInstance, "tenant-a", store.QuotaConfig{MaxKeys: 100, MaxGroups: 10})`
func NewScopedWithQuota(storeInstance *Store, namespace string, quota QuotaConfig) (*ScopedStore, error) {
scopedStore, err := NewScoped(storeInstance, namespace)
@ -47,7 +43,6 @@ func NewScopedWithQuota(storeInstance *Store, namespace string, quota QuotaConfi
return scopedStore, nil
}
// namespacedGroup returns the group name with the namespace prefix applied.
func (scopedStore *ScopedStore) namespacedGroup(group string) string {
return scopedStore.namespace + ":" + group
}
@ -62,8 +57,7 @@ func (scopedStore *ScopedStore) Get(group, key string) (string, error) {
return scopedStore.storeInstance.Get(scopedStore.namespacedGroup(group), key)
}
// Quota checks happen before inserting new keys or groups.
// Usage example: `err := scopedStore.Set("config", "theme", "dark")`
// Usage example: `_ = scopedStore.Set("config", "theme", "dark")`
func (scopedStore *ScopedStore) Set(group, key, value string) error {
if err := scopedStore.checkQuota(group, key); err != nil {
return err
@ -71,8 +65,7 @@ func (scopedStore *ScopedStore) Set(group, key, value string) error {
return scopedStore.storeInstance.Set(scopedStore.namespacedGroup(group), key, value)
}
// Quota checks happen before inserting new keys or groups, even when the value expires later.
// Usage example: `err := scopedStore.SetWithTTL("sessions", "token", "abc", time.Hour)`
// Usage example: `_ = scopedStore.SetWithTTL("sessions", "token", "abc123", time.Hour)`
func (scopedStore *ScopedStore) SetWithTTL(group, key, value string, ttl time.Duration) error {
if err := scopedStore.checkQuota(group, key); err != nil {
return err
@ -80,12 +73,12 @@ func (scopedStore *ScopedStore) SetWithTTL(group, key, value string, ttl time.Du
return scopedStore.storeInstance.SetWithTTL(scopedStore.namespacedGroup(group), key, value, ttl)
}
// Usage example: `err := scopedStore.Delete("config", "theme")`
// Usage example: `_ = scopedStore.Delete("config", "theme")`
func (scopedStore *ScopedStore) Delete(group, key string) error {
return scopedStore.storeInstance.Delete(scopedStore.namespacedGroup(group), key)
}
// Usage example: `err := scopedStore.DeleteGroup("cache")`
// Usage example: `_ = scopedStore.DeleteGroup("cache")`
func (scopedStore *ScopedStore) DeleteGroup(group string) error {
return scopedStore.storeInstance.DeleteGroup(scopedStore.namespacedGroup(group))
}

View file

@ -13,11 +13,9 @@ import (
_ "modernc.org/sqlite"
)
// 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)
// 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)
@ -29,7 +27,6 @@ const (
entryValueColumn = "entry_value"
)
// Store is a group-namespaced key-value store backed by SQLite.
// Usage example: `storeInstance, _ := store.New(":memory:"); _ = storeInstance.Set("config", "theme", "dark")`
type Store struct {
database *sql.DB
@ -45,8 +42,7 @@ type Store struct {
nextRegistrationID uint64 // monotonic ID for watchers and callbacks
}
// Use ":memory:" for ephemeral data or a file path for persistence.
// Usage example: `storeInstance, _ := store.New(":memory:"); defer storeInstance.Close()`
// Usage example: `storeInstance, _ := store.New(":memory:")`
func New(databasePath string) (*Store, error) {
sqliteDatabase, err := sql.Open("sqlite", databasePath)
if err != nil {
@ -76,7 +72,6 @@ func New(databasePath string) (*Store, error) {
return storeInstance, nil
}
// Stops the background purge goroutine and closes the database.
// Usage example: `storeInstance, _ := store.New(":memory:"); defer storeInstance.Close()`
func (storeInstance *Store) Close() error {
storeInstance.cancelPurge()
@ -84,7 +79,6 @@ func (storeInstance *Store) Close() error {
return storeInstance.database.Close()
}
// Expired keys are lazily removed and treated as not found.
// Usage example: `themeValue, err := storeInstance.Get("config", "theme")`
func (storeInstance *Store) Get(group, key string) (string, error) {
var value string
@ -106,7 +100,7 @@ func (storeInstance *Store) Get(group, key string) (string, error) {
return value, nil
}
// Usage example: `err := storeInstance.Set("config", "theme", "dark")`
// Usage example: `_ = storeInstance.Set("config", "theme", "dark")`
func (storeInstance *Store) Set(group, key, value string) error {
_, err := storeInstance.database.Exec(
"INSERT INTO "+entriesTableName+" ("+entryGroupColumn+", "+entryKeyColumn+", "+entryValueColumn+", expires_at) VALUES (?, ?, ?, NULL) "+
@ -120,8 +114,7 @@ func (storeInstance *Store) Set(group, key, value string) error {
return nil
}
// The key expires after ttl and is removed on the next Get or by purge.
// Usage example: `err := storeInstance.SetWithTTL("session", "token", "abc", time.Hour)`
// Usage example: `_ = storeInstance.SetWithTTL("session", "token", "abc123", time.Minute)`
func (storeInstance *Store) SetWithTTL(group, key, value string, ttl time.Duration) error {
expiresAt := time.Now().Add(ttl).UnixMilli()
_, err := storeInstance.database.Exec(
@ -136,7 +129,7 @@ func (storeInstance *Store) SetWithTTL(group, key, value string, ttl time.Durati
return nil
}
// Usage example: `err := storeInstance.Delete("config", "theme")`
// Usage example: `_ = storeInstance.Delete("config", "theme")`
func (storeInstance *Store) Delete(group, key string) error {
_, err := storeInstance.database.Exec("DELETE FROM "+entriesTableName+" WHERE "+entryGroupColumn+" = ? AND "+entryKeyColumn+" = ?", group, key)
if err != nil {
@ -159,7 +152,7 @@ func (storeInstance *Store) Count(group string) (int, error) {
return count, nil
}
// Usage example: `err := storeInstance.DeleteGroup("cache")`
// Usage example: `_ = storeInstance.DeleteGroup("cache")`
func (storeInstance *Store) DeleteGroup(group string) error {
_, err := storeInstance.database.Exec("DELETE FROM "+entriesTableName+" WHERE "+entryGroupColumn+" = ?", group)
if err != nil {
@ -217,7 +210,6 @@ func (storeInstance *Store) All(group string) iter.Seq2[KeyValue, error] {
}
}
// Splits the stored value by a custom separator.
// Usage example: `parts, _ := storeInstance.GetSplit("config", "hosts", ",")`
func (storeInstance *Store) GetSplit(group, key, separator string) (iter.Seq[string], error) {
value, err := storeInstance.Get(group, key)
@ -227,7 +219,6 @@ func (storeInstance *Store) GetSplit(group, key, separator string) (iter.Seq[str
return splitSeq(value, separator), nil
}
// Splits the stored value on whitespace.
// Usage example: `fields, _ := storeInstance.GetFields("config", "flags")`
func (storeInstance *Store) GetFields(group, key string) (iter.Seq[string], error) {
value, err := storeInstance.Get(group, key)
@ -340,7 +331,6 @@ func escapeLike(text string) string {
return text
}
// Deletes expired keys across all groups.
// Usage example: `removed, err := storeInstance.PurgeExpired()`
func (storeInstance *Store) PurgeExpired() (int64, error) {
deleteResult, err := storeInstance.database.Exec("DELETE FROM "+entriesTableName+" WHERE expires_at IS NOT NULL AND expires_at <= ?",