refactor(store): tighten AX examples and error handling
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 1m26s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-30 16:41:56 +00:00
parent c15862a81d
commit 5df38516cc
7 changed files with 134 additions and 57 deletions

View file

@ -8,7 +8,7 @@ Group-namespaced SQLite key-value store with TTL expiry, namespace isolation, qu
**Module**: `dappco.re/go/core/store`
**Licence**: EUPL-1.2
**Language**: Go 1.25
**Language**: Go 1.26
## Quick Start
@ -25,14 +25,21 @@ import (
func main() {
storeInstance, err := store.New("/path/to/store.db") // or store.New(":memory:")
if err != nil {
panic(err)
return
}
defer storeInstance.Close()
_ = storeInstance.Set("config", "theme", "dark")
_ = storeInstance.SetWithTTL("session", "token", "abc123", 24*time.Hour)
if err := storeInstance.Set("config", "theme", "dark"); err != nil {
return
}
if err := storeInstance.SetWithTTL("session", "token", "abc123", 24*time.Hour); err != nil {
return
}
themeValue, err := storeInstance.Get("config", "theme")
fmt.Println(themeValue, err)
if err != nil {
return
}
fmt.Println(themeValue)
// Watch for mutations
watcher := storeInstance.Watch("config", "*")
@ -44,8 +51,13 @@ func main() {
}()
// Scoped store for tenant isolation
scopedStore, _ := store.NewScoped(storeInstance, "tenant-42")
_ = scopedStore.Set("prefs", "locale", "en-GB")
scopedStore, err := store.NewScoped(storeInstance, "tenant-42")
if err != nil {
return
}
if err := scopedStore.Set("prefs", "locale", "en-GB"); err != nil {
return
}
}
```

37
doc.go
View file

@ -3,15 +3,36 @@
//
// Usage example:
//
// storeInstance, _ := store.New(":memory:")
// defer storeInstance.Close()
// func main() {
// storeInstance, err := store.New(":memory:")
// if err != nil {
// return
// }
// defer storeInstance.Close()
//
// _ = storeInstance.Set("config", "theme", "dark")
// themeValue, _ := storeInstance.Get("config", "theme")
// if err := storeInstance.Set("config", "theme", "dark"); err != nil {
// return
// }
// themeValue, err := storeInstance.Get("config", "theme")
// if err != nil {
// return
// }
// core.Println(themeValue)
//
// scopedStore, _ := store.NewScoped(storeInstance, "tenant-a")
// _ = scopedStore.Set("config", "theme", "dark")
// scopedStore, err := store.NewScoped(storeInstance, "tenant-a")
// if err != nil {
// return
// }
// if err := scopedStore.Set("config", "theme", "dark"); err != nil {
// return
// }
//
// quotaScopedStore, _ := store.NewScopedWithQuota(storeInstance, "tenant-b", store.QuotaConfig{MaxKeys: 100, MaxGroups: 10})
// _ = quotaScopedStore.Set("prefs", "locale", "en-GB")
// quotaScopedStore, err := store.NewScopedWithQuota(storeInstance, "tenant-b", store.QuotaConfig{MaxKeys: 100, MaxGroups: 10})
// if err != nil {
// return
// }
// if err := quotaScopedStore.Set("prefs", "locale", "en-GB"); err != nil {
// return
// }
// }
package store

View file

@ -103,9 +103,16 @@ Both return `NotFoundError` if the key does not exist or has expired.
`Render(templateSource, group)` is a convenience method that fetches all non-expired key-value pairs from a group and renders a Go `text/template` against them. The template data is a `map[string]string` keyed by the field name.
```go
storeInstance.Set("miner", "pool", "pool.lthn.io:3333")
storeInstance.Set("miner", "wallet", "iz...")
renderedTemplate, _ := storeInstance.Render(`{"pool":"{{ .pool }}","wallet":"{{ .wallet }}"}`, "miner")
if err := storeInstance.Set("miner", "pool", "pool.lthn.io:3333"); err != nil {
return
}
if err := storeInstance.Set("miner", "wallet", "iz..."); err != nil {
return
}
renderedTemplate, err := storeInstance.Render(`{"pool":"{{ .pool }}","wallet":"{{ .wallet }}"}`, "miner")
if err != nil {
return
}
// renderedTemplate: {"pool":"pool.lthn.io:3333","wallet":"iz..."}
```
@ -187,8 +194,13 @@ Watcher matching is handled by the `watcherMatches` helper, which checks the gro
`ScopedStore` wraps a `*Store` and automatically prefixes all group names with `namespace + ":"`. This prevents key collisions when multiple tenants share a single underlying database.
```go
scopedStore, _ := store.NewScoped(storeInstance, "tenant-42")
scopedStore.Set("config", "theme", "dark")
scopedStore, err := store.NewScoped(storeInstance, "tenant-42")
if err != nil {
return
}
if err := scopedStore.Set("config", "theme", "dark"); err != nil {
return
}
// Stored in underlying store as group="tenant-42:config", key="theme"
```

View file

@ -29,37 +29,65 @@ func main() {
// Open a store. Use ":memory:" for ephemeral data or a file path for persistence.
storeInstance, err := store.New("/tmp/app.db")
if err != nil {
panic(err)
return
}
defer storeInstance.Close()
// Basic CRUD
_ = storeInstance.Set("config", "theme", "dark")
themeValue, _ := storeInstance.Get("config", "theme")
if err := storeInstance.Set("config", "theme", "dark"); err != nil {
return
}
themeValue, err := storeInstance.Get("config", "theme")
if err != nil {
return
}
core.Println(themeValue) // "dark"
// TTL expiry -- key disappears after the duration elapses
_ = storeInstance.SetWithTTL("session", "token", "abc123", 24*time.Hour)
if err := storeInstance.SetWithTTL("session", "token", "abc123", 24*time.Hour); err != nil {
return
}
// Fetch all keys in a group
configEntries, _ := storeInstance.GetAll("config")
configEntries, err := storeInstance.GetAll("config")
if err != nil {
return
}
core.Println(configEntries) // map[theme:dark]
// Template rendering from stored values
storeInstance.Set("mail", "host", "smtp.example.com")
storeInstance.Set("mail", "port", "587")
renderedTemplate, _ := storeInstance.Render(`{{ .host }}:{{ .port }}`, "mail")
if err := storeInstance.Set("mail", "host", "smtp.example.com"); err != nil {
return
}
if err := storeInstance.Set("mail", "port", "587"); err != nil {
return
}
renderedTemplate, err := storeInstance.Render(`{{ .host }}:{{ .port }}`, "mail")
if err != nil {
return
}
core.Println(renderedTemplate) // "smtp.example.com:587"
// Namespace isolation for multi-tenant use
scopedStore, _ := store.NewScoped(storeInstance, "tenant-42")
_ = scopedStore.Set("prefs", "locale", "en-GB")
scopedStore, err := store.NewScoped(storeInstance, "tenant-42")
if err != nil {
return
}
if err := scopedStore.Set("prefs", "locale", "en-GB"); err != nil {
return
}
// Stored internally as group "tenant-42:prefs", key "locale"
// Quota enforcement
quota := store.QuotaConfig{MaxKeys: 100, MaxGroups: 5}
quotaScopedStore, _ := store.NewScopedWithQuota(storeInstance, "tenant-99", quota)
err = quotaScopedStore.Set("g", "k", "v") // returns store.QuotaExceededError if limits are hit
quotaScopedStore, err := store.NewScopedWithQuota(storeInstance, "tenant-99", quota)
if err != nil {
return
}
// returns store.QuotaExceededError if limits are hit
if err := quotaScopedStore.Set("g", "k", "v"); err != nil {
return
}
// Watch for mutations via a buffered channel
watcher := storeInstance.Watch("config", "*")

View file

@ -43,9 +43,9 @@ type Event struct {
Timestamp time.Time
}
// Usage example: `watcher := storeInstance.Watch("config", "*"); defer storeInstance.Unwatch(watcher); for event := range watcher.Events { _ = event }`
// 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 { _ = event }`
// Usage example: `for event := range watcher.Events { if event.Key == "theme" { return } }`
Events <-chan Event
// eventChannel is the internal write channel (same underlying channel as Events).

View file

@ -17,14 +17,14 @@ type QuotaConfig struct {
MaxGroups int // maximum distinct groups in the namespace
}
// Usage example: `scopedStore, _ := store.NewScoped(storeInstance, "tenant-a"); _ = scopedStore.Set("config", "theme", "dark")`
// Usage example: `scopedStore, err := store.NewScoped(storeInstance, "tenant-a"); if err != nil { return }; if err := scopedStore.Set("config", "theme", "dark"); err != nil { return }`
type ScopedStore struct {
storeInstance *Store
namespace string
quota QuotaConfig
}
// Usage example: `scopedStore, _ := store.NewScoped(storeInstance, "tenant-a")`
// 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) {
return nil, core.E("store.NewScoped", core.Sprintf("namespace %q is invalid (must be non-empty, alphanumeric + hyphens)", namespace), nil)
@ -33,7 +33,7 @@ func NewScoped(storeInstance *Store, namespace string) (*ScopedStore, error) {
return scopedStore, nil
}
// Usage example: `scopedStore, _ := store.NewScopedWithQuota(storeInstance, "tenant-a", store.QuotaConfig{MaxKeys: 100, MaxGroups: 10})`
// 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)
if err != nil {
@ -47,7 +47,7 @@ func (scopedStore *ScopedStore) namespacedGroup(group string) string {
return scopedStore.namespace + ":" + group
}
// Usage example: `scopedStore, _ := store.NewScoped(storeInstance, "tenant-a"); namespace := scopedStore.Namespace()`
// Usage example: `scopedStore, err := store.NewScoped(storeInstance, "tenant-a"); if err != nil { return }; namespace := scopedStore.Namespace(); core.Println(namespace)`
func (scopedStore *ScopedStore) Namespace() string {
return scopedStore.namespace
}
@ -57,7 +57,7 @@ func (scopedStore *ScopedStore) Get(group, key string) (string, error) {
return scopedStore.storeInstance.Get(scopedStore.namespacedGroup(group), key)
}
// Usage example: `_ = scopedStore.Set("config", "theme", "dark")`
// Usage example: `if err := scopedStore.Set("config", "theme", "dark"); err != nil { return }`
func (scopedStore *ScopedStore) Set(group, key, value string) error {
if err := scopedStore.checkQuota("store.ScopedStore.Set", group, key); err != nil {
return err
@ -65,7 +65,7 @@ func (scopedStore *ScopedStore) Set(group, key, value string) error {
return scopedStore.storeInstance.Set(scopedStore.namespacedGroup(group), key, value)
}
// Usage example: `_ = scopedStore.SetWithTTL("sessions", "token", "abc123", time.Hour)`
// Usage example: `if err := scopedStore.SetWithTTL("sessions", "token", "abc123", time.Hour); err != nil { return }`
func (scopedStore *ScopedStore) SetWithTTL(group, key, value string, ttl time.Duration) error {
if err := scopedStore.checkQuota("store.ScopedStore.SetWithTTL", group, key); err != nil {
return err
@ -73,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: `_ = scopedStore.Delete("config", "theme")`
// Usage example: `if err := scopedStore.Delete("config", "theme"); err != nil { return }`
func (scopedStore *ScopedStore) Delete(group, key string) error {
return scopedStore.storeInstance.Delete(scopedStore.namespacedGroup(group), key)
}
// Usage example: `_ = scopedStore.DeleteGroup("cache")`
// Usage example: `if err := scopedStore.DeleteGroup("cache"); err != nil { return }`
func (scopedStore *ScopedStore) DeleteGroup(group string) error {
return scopedStore.storeInstance.DeleteGroup(scopedStore.namespacedGroup(group))
}
@ -88,7 +88,7 @@ func (scopedStore *ScopedStore) GetAll(group string) (map[string]string, error)
return scopedStore.storeInstance.GetAll(scopedStore.namespacedGroup(group))
}
// Usage example: `for entry, err := range scopedStore.All("config") { if err != nil { break }; _ = entry }`
// Usage example: `for entry, err := range scopedStore.All("config") { if err != nil { break }; core.Println(entry.Key, entry.Value) }`
func (scopedStore *ScopedStore) All(group string) iter.Seq2[KeyValue, error] {
return scopedStore.storeInstance.All(scopedStore.namespacedGroup(group))
}

View file

@ -27,7 +27,7 @@ const (
entryValueColumn = "entry_value"
)
// Usage example: `storeInstance, _ := store.New(":memory:"); _ = storeInstance.Set("config", "theme", "dark")`
// Usage example: `storeInstance, err := store.New(":memory:"); if err != nil { return }; if err := storeInstance.Set("config", "theme", "dark"); err != nil { return }`
type Store struct {
database *sql.DB
cancelPurge context.CancelFunc
@ -42,7 +42,7 @@ type Store struct {
nextRegistrationID uint64 // monotonic ID for watchers and callbacks
}
// Usage example: `storeInstance, _ := store.New(":memory:")`
// Usage example: `storeInstance, err := store.New(":memory:"); if err != nil { return }`
func New(databasePath string) (*Store, error) {
sqliteDatabase, err := sql.Open("sqlite", databasePath)
if err != nil {
@ -72,7 +72,7 @@ func New(databasePath string) (*Store, error) {
return storeInstance, nil
}
// Usage example: `storeInstance, _ := store.New(":memory:"); defer storeInstance.Close()`
// Usage example: `storeInstance, err := store.New(":memory:"); if err != nil { return }; defer storeInstance.Close()`
func (storeInstance *Store) Close() error {
storeInstance.cancelPurge()
storeInstance.purgeWaitGroup.Wait()
@ -97,13 +97,15 @@ func (storeInstance *Store) Get(group, key string) (string, error) {
return "", core.E("store.Get", "query", err)
}
if expiresAt.Valid && expiresAt.Int64 <= time.Now().UnixMilli() {
_, _ = storeInstance.database.Exec("DELETE FROM "+entriesTableName+" WHERE "+entryGroupColumn+" = ? AND "+entryKeyColumn+" = ?", group, key)
if _, err := storeInstance.database.Exec("DELETE FROM "+entriesTableName+" WHERE "+entryGroupColumn+" = ? AND "+entryKeyColumn+" = ?", group, key); err != nil {
return "", core.E("store.Get", "lazy delete", err)
}
return "", core.E("store.Get", core.Concat(group, "/", key), NotFoundError)
}
return value, nil
}
// Usage example: `_ = storeInstance.Set("config", "theme", "dark")`
// Usage example: `if err := storeInstance.Set("config", "theme", "dark"); err != nil { return }`
func (storeInstance *Store) Set(group, key, value string) error {
_, err := storeInstance.database.Exec(
"INSERT INTO "+entriesTableName+" ("+entryGroupColumn+", "+entryKeyColumn+", "+entryValueColumn+", expires_at) VALUES (?, ?, ?, NULL) "+
@ -117,7 +119,7 @@ func (storeInstance *Store) Set(group, key, value string) error {
return nil
}
// Usage example: `_ = storeInstance.SetWithTTL("session", "token", "abc123", time.Minute)`
// Usage example: `if err := storeInstance.SetWithTTL("session", "token", "abc123", time.Minute); err != nil { return }`
func (storeInstance *Store) SetWithTTL(group, key, value string, ttl time.Duration) error {
expiresAt := time.Now().Add(ttl).UnixMilli()
_, err := storeInstance.database.Exec(
@ -132,7 +134,7 @@ func (storeInstance *Store) SetWithTTL(group, key, value string, ttl time.Durati
return nil
}
// Usage example: `_ = storeInstance.Delete("config", "theme")`
// Usage example: `if err := storeInstance.Delete("config", "theme"); err != nil { return }`
func (storeInstance *Store) Delete(group, key string) error {
_, err := storeInstance.database.Exec("DELETE FROM "+entriesTableName+" WHERE "+entryGroupColumn+" = ? AND "+entryKeyColumn+" = ?", group, key)
if err != nil {
@ -155,7 +157,7 @@ func (storeInstance *Store) Count(group string) (int, error) {
return count, nil
}
// Usage example: `_ = storeInstance.DeleteGroup("cache")`
// Usage example: `if err := storeInstance.DeleteGroup("cache"); err != nil { return }`
func (storeInstance *Store) DeleteGroup(group string) error {
_, err := storeInstance.database.Exec("DELETE FROM "+entriesTableName+" WHERE "+entryGroupColumn+" = ?", group)
if err != nil {
@ -165,7 +167,7 @@ func (storeInstance *Store) DeleteGroup(group string) error {
return nil
}
// Usage example: `for entry, err := range storeInstance.All("config") { if err != nil { break }; _ = entry }`
// Usage example: `for entry, err := range storeInstance.All("config") { if err != nil { break }; core.Println(entry.Key, entry.Value) }`
type KeyValue struct {
Key, Value string
}
@ -182,7 +184,7 @@ func (storeInstance *Store) GetAll(group string) (map[string]string, error) {
return entriesByKey, nil
}
// Usage example: `for entry, err := range storeInstance.All("config") { if err != nil { break }; _ = entry }`
// Usage example: `for entry, err := range storeInstance.All("config") { if err != nil { break }; core.Println(entry.Key, entry.Value) }`
func (storeInstance *Store) All(group string) iter.Seq2[KeyValue, error] {
return func(yield func(KeyValue, error) bool) {
rows, err := storeInstance.database.Query(
@ -213,7 +215,7 @@ func (storeInstance *Store) All(group string) iter.Seq2[KeyValue, error] {
}
}
// Usage example: `parts, _ := storeInstance.GetSplit("config", "hosts", ",")`
// Usage example: `parts, err := storeInstance.GetSplit("config", "hosts", ","); if err != nil { return }; for part := range parts { core.Println(part) }`
func (storeInstance *Store) GetSplit(group, key, separator string) (iter.Seq[string], error) {
value, err := storeInstance.Get(group, key)
if err != nil {
@ -222,7 +224,7 @@ func (storeInstance *Store) GetSplit(group, key, separator string) (iter.Seq[str
return splitSeq(value, separator), nil
}
// Usage example: `fields, _ := storeInstance.GetFields("config", "flags")`
// Usage example: `fields, err := storeInstance.GetFields("config", "flags"); if err != nil { return }; for field := range fields { core.Println(field) }`
func (storeInstance *Store) GetFields(group, key string) (iter.Seq[string], error) {
value, err := storeInstance.Get(group, key)
if err != nil {
@ -285,7 +287,7 @@ func (storeInstance *Store) Groups(groupPrefix string) ([]string, error) {
return groupNames, nil
}
// Usage example: `for tenantGroupName, err := range storeInstance.GroupsSeq("tenant-a:") { if err != nil { break }; _ = tenantGroupName }`
// Usage example: `for tenantGroupName, err := range storeInstance.GroupsSeq("tenant-a:") { if err != nil { break }; core.Println(tenantGroupName) }`
func (storeInstance *Store) GroupsSeq(groupPrefix string) iter.Seq2[string, error] {
return func(yield func(string, error) bool) {
var rows *sql.Rows
@ -489,7 +491,9 @@ func migrateLegacyEntriesTable(database *sql.DB) error {
committed := false
defer func() {
if !committed {
_ = transaction.Rollback()
if rollbackErr := transaction.Rollback(); rollbackErr != nil {
// Ignore rollback failures; the original error is already being returned.
}
}
}()