From 1fb8295713b743ebf1c85f7e1d1ff59edf5b2b46 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 16:06:43 +0000 Subject: [PATCH] feat(store): add scoped store config constructor Co-Authored-By: Virgil --- coverage_test.go | 8 +- doc.go | 80 +----- events_test.go | 3 +- scope.go | 717 ++++++++--------------------------------------- scope_test.go | 661 ++++++++++++++++++------------------------- 5 files changed, 419 insertions(+), 1050 deletions(-) diff --git a/coverage_test.go b/coverage_test.go index 434a4bb..f01302a 100644 --- a/coverage_test.go +++ b/coverage_test.go @@ -284,13 +284,13 @@ func TestCoverage_ScopedStore_Bad_GroupsClosedStore(t *testing.T) { storeInstance, _ := New(":memory:") require.NoError(t, storeInstance.Close()) - scopedStore := NewScoped(storeInstance, "tenant-a") + scopedStore, err := NewScoped(storeInstance, "tenant-a") + require.NoError(t, err) require.NotNil(t, scopedStore) - var err error _, err = scopedStore.Groups("") require.Error(t, err) - assert.Contains(t, err.Error(), "store.ScopedStore.Groups") + assert.Contains(t, err.Error(), "store.Groups") } func TestCoverage_ScopedStore_Bad_GroupsSeqRowsError(t *testing.T) { @@ -304,7 +304,7 @@ func TestCoverage_ScopedStore_Bad_GroupsSeqRowsError(t *testing.T) { defer database.Close() scopedStore := &ScopedStore{ - store: &Store{ + storeInstance: &Store{ sqliteDatabase: database, cancelPurge: func() {}, }, diff --git a/doc.go b/doc.go index 4280e57..b349a4d 100644 --- a/doc.go +++ b/doc.go @@ -1,68 +1,50 @@ -// Package store provides SQLite-backed key-value storage for grouped entries, -// TTL expiry, namespace isolation, quota enforcement, reactive change -// notifications, SQLite journal writes, workspace journalling, and orphan -// recovery. -// -// Workspace files live under `.core/state/` and can be recovered with -// `RecoverOrphans(".core/state/")`. -// -// Use `store.NewConfigured(store.StoreConfig{...})` when the database path, -// journal, and purge interval are already known. Prefer the struct literal -// over `store.New(..., store.WithJournal(...))` when the full configuration is -// already available, because it reads as data rather than a chain of steps. +// Package store provides SQLite-backed storage for grouped entries, TTL expiry, +// namespace isolation, quota enforcement, and reactive change notifications. // // Usage example: // // func main() { -// configuredStore, err := store.NewConfigured(store.StoreConfig{ -// DatabasePath: ":memory:", -// Journal: store.JournalConfiguration{ -// EndpointURL: "http://127.0.0.1:8086", -// Organisation: "core", -// BucketName: "events", -// }, -// PurgeInterval: 20 * time.Millisecond, -// }) +// storeInstance, err := store.New(":memory:") // if err != nil { // return // } -// defer configuredStore.Close() +// defer storeInstance.Close() // -// if err := configuredStore.Set("config", "colour", "blue"); err != nil { +// if err := storeInstance.Set("config", "colour", "blue"); err != nil { // return // } -// if err := configuredStore.SetWithTTL("session", "token", "abc123", 5*time.Minute); err != nil { +// if err := storeInstance.SetWithTTL("session", "token", "abc123", 5*time.Minute); err != nil { // return // } // -// colourValue, err := configuredStore.Get("config", "colour") +// colourValue, err := storeInstance.Get("config", "colour") // if err != nil { // return // } // fmt.Println(colourValue) // -// for entry, err := range configuredStore.All("config") { +// for entry, err := range storeInstance.All("config") { // if err != nil { // return // } // fmt.Println(entry.Key, entry.Value) // } // -// events := configuredStore.Watch("config") -// defer configuredStore.Unwatch("config", events) +// events := storeInstance.Watch("config") +// defer storeInstance.Unwatch("config", events) // go func() { // for event := range events { // fmt.Println(event.Type, event.Group, event.Key, event.Value) // } // }() // -// unregister := configuredStore.OnChange(func(event store.Event) { +// unregister := storeInstance.OnChange(func(event store.Event) { // fmt.Println("changed", event.Group, event.Key, event.Value) // }) // defer unregister() // // scopedStore, err := store.NewScopedConfigured( -// configuredStore, +// storeInstance, // store.ScopedStoreConfig{ // Namespace: "tenant-a", // Quota: store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}, @@ -75,47 +57,11 @@ // return // } // -// for groupName, err := range configuredStore.GroupsSeq("tenant-a:") { +// for groupName, err := range storeInstance.GroupsSeq("tenant-a:") { // if err != nil { // return // } // fmt.Println(groupName) // } -// -// workspace, err := configuredStore.NewWorkspace("scroll-session") -// if err != nil { -// return -// } -// defer workspace.Discard() -// -// if err := workspace.Put("like", map[string]any{"user": "@alice"}); err != nil { -// return -// } -// if err := workspace.Put("profile_match", map[string]any{"user": "@charlie"}); err != nil { -// return -// } -// if result := workspace.Commit(); !result.OK { -// return -// } -// -// orphans := configuredStore.RecoverOrphans(".core/state") -// for _, orphanWorkspace := range orphans { -// fmt.Println(orphanWorkspace.Name(), orphanWorkspace.Aggregate()) -// orphanWorkspace.Discard() -// } -// -// journalResult := configuredStore.QueryJournal(`from(bucket: "events") |> range(start: -24h)`) -// if !journalResult.OK { -// return -// } -// -// archiveResult := configuredStore.Compact(store.CompactOptions{ -// Before: time.Now().Add(-30 * 24 * time.Hour), -// Output: "/tmp/archive", -// Format: "gzip", -// }) -// if !archiveResult.OK { -// return -// } // } package store diff --git a/events_test.go b/events_test.go index 82305d2..1a02a39 100644 --- a/events_test.go +++ b/events_test.go @@ -293,7 +293,8 @@ func TestEvents_Watch_Good_ScopedStoreEventGroup(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := NewScoped(storeInstance, "tenant-a") + scopedStore, err := NewScoped(storeInstance, "tenant-a") + require.NoError(t, err) require.NotNil(t, scopedStore) events := storeInstance.Watch("tenant-a:config") diff --git a/scope.go b/scope.go index 593cd3b..7a2b866 100644 --- a/scope.go +++ b/scope.go @@ -3,7 +3,6 @@ package store import ( "iter" "regexp" - "sync" "time" core "dappco.re/go/core" @@ -14,6 +13,7 @@ var validNamespace = regexp.MustCompile(`^[a-zA-Z0-9-]+$`) const defaultScopedGroupName = "default" +// 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. @@ -34,28 +34,7 @@ func (quotaConfig QuotaConfig) Validate() error { return nil } -// Usage example: `scopedStore, err := store.NewScopedConfigured(storeInstance, store.ScopedStoreConfig{Namespace: "tenant-a", Quota: store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}}); if err != nil { return }; _ = scopedStore.Set("colour", "blue")` -// ScopedStore keeps one namespace isolated behind helpers such as Set and -// GetFrom so callers do not repeat the `tenant-a:` prefix manually. -type ScopedStore struct { - store *Store - namespace string - // Usage example: `scopedStore.MaxKeys = 100` - MaxKeys int - // Usage example: `scopedStore.MaxGroups = 10` - MaxGroups int - - scopedWatchersLock sync.Mutex - scopedWatchers map[uintptr]*scopedWatcherBinding -} - -// Usage example: `err := scopedStore.Transaction(func(transaction *store.ScopedStoreTransaction) error { return transaction.Set("colour", "blue") })` -// Usage example: `if err := transaction.Delete("config", "colour"); err != nil { return err }` -type ScopedStoreTransaction struct { - scopedStore *ScopedStore - storeTransaction *StoreTransaction -} - +// ScopedStoreConfig combines namespace selection with optional quota limits. // Usage example: `config := store.ScopedStoreConfig{Namespace: "tenant-a", Quota: store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}}` type ScopedStoreConfig struct { // Usage example: `config := store.ScopedStoreConfig{Namespace: "tenant-a"}` @@ -79,39 +58,31 @@ func (scopedConfig ScopedStoreConfig) Validate() error { return nil } -type scopedWatcherBinding struct { - store *Store - underlyingEvents <-chan Event - done chan struct{} - stop chan struct{} - stopOnce sync.Once +// 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 + namespace string + // Usage example: `scopedStore.MaxKeys = 100` + MaxKeys int + // Usage example: `scopedStore.MaxGroups = 10` + MaxGroups int } -func (scopedStore *ScopedStore) resolvedStore(operation string) (*Store, error) { - if scopedStore == nil { - return nil, core.E(operation, "scoped store is nil", nil) - } - if scopedStore.store == nil { - return nil, core.E(operation, "underlying store is nil", nil) - } - if err := scopedStore.store.ensureReady(operation); err != nil { - return nil, err - } - return scopedStore.store, nil -} - -// Usage example: `scopedStore := store.NewScoped(storeInstance, "tenant-a"); if scopedStore == nil { return }` -func NewScoped(storeInstance *Store, namespace string) *ScopedStore { +// 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 storeInstance == nil { - return nil + return nil, core.E("store.NewScoped", "store instance is nil", nil) } if !validNamespace.MatchString(namespace) { - return nil + return nil, core.E("store.NewScoped", core.Sprintf("namespace %q is invalid; use names like %q or %q", namespace, "tenant-a", "tenant-42"), nil) } - scopedStore := &ScopedStore{store: storeInstance, namespace: namespace} - return scopedStore + scopedStore := &ScopedStore{storeInstance: storeInstance, namespace: namespace} + return scopedStore, nil } +// NewScopedConfigured validates the namespace and optional quota settings before constructing a ScopedStore. // Usage example: `scopedStore, err := store.NewScopedConfigured(storeInstance, store.ScopedStoreConfig{Namespace: "tenant-a", Quota: store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}}); if err != nil { return }` func NewScopedConfigured(storeInstance *Store, scopedConfig ScopedStoreConfig) (*ScopedStore, error) { if storeInstance == nil { @@ -120,12 +91,16 @@ func NewScopedConfigured(storeInstance *Store, scopedConfig ScopedStoreConfig) ( if err := scopedConfig.Validate(); err != nil { return nil, core.E("store.NewScopedConfigured", "validate config", err) } - scopedStore := NewScoped(storeInstance, scopedConfig.Namespace) + scopedStore, err := NewScoped(storeInstance, scopedConfig.Namespace) + if err != nil { + return nil, err + } scopedStore.MaxKeys = scopedConfig.Quota.MaxKeys scopedStore.MaxGroups = scopedConfig.Quota.MaxGroups 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) { return NewScopedConfigured(storeInstance, ScopedStoreConfig{ @@ -142,160 +117,103 @@ func (scopedStore *ScopedStore) namespacePrefix() string { return scopedStore.namespace + ":" } +func (scopedStore *ScopedStore) defaultGroup() string { + return defaultScopedGroupName +} + func (scopedStore *ScopedStore) trimNamespacePrefix(groupName string) string { return core.TrimPrefix(groupName, scopedStore.namespacePrefix()) } -// Usage example: `scopedStore := store.NewScoped(storeInstance, "tenant-a"); if scopedStore == nil { return }; fmt.Println(scopedStore.Namespace())` +// 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 { - if scopedStore == nil { - return "" - } return scopedStore.namespace } // Usage example: `colourValue, err := scopedStore.Get("colour")` -func (scopedStore *ScopedStore) Get(key string) (string, error) { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.Get") +// Usage example: `colourValue, err := scopedStore.Get("config", "colour")` +func (scopedStore *ScopedStore) Get(arguments ...string) (string, error) { + group, key, err := scopedStore.getArguments(arguments) if err != nil { return "", err } - return backingStore.Get(scopedStore.namespacedGroup(defaultScopedGroupName), key) + return scopedStore.storeInstance.Get(scopedStore.namespacedGroup(group), key) } +// GetFrom reads a key from an explicit namespaced group. // Usage example: `colourValue, err := scopedStore.GetFrom("config", "colour")` func (scopedStore *ScopedStore) GetFrom(group, key string) (string, error) { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.GetFrom") - if err != nil { - return "", err - } - return backingStore.Get(scopedStore.namespacedGroup(group), key) + return scopedStore.Get(group, key) } // Usage example: `if err := scopedStore.Set("colour", "blue"); err != nil { return }` -func (scopedStore *ScopedStore) Set(key, value string) error { - return scopedStore.SetIn(defaultScopedGroupName, key, value) -} - -// Usage example: `if err := scopedStore.SetIn("config", "colour", "blue"); err != nil { return }` -func (scopedStore *ScopedStore) SetIn(group, key, value string) error { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.SetIn") +// Usage example: `if err := scopedStore.Set("config", "colour", "blue"); err != nil { return }` +func (scopedStore *ScopedStore) Set(arguments ...string) error { + group, key, value, err := scopedStore.setArguments(arguments) if err != nil { return err } - if err := scopedStore.checkQuota("store.ScopedStore.SetIn", group, key); err != nil { + if err := scopedStore.checkQuota("store.ScopedStore.Set", group, key); err != nil { return err } - return backingStore.Set(scopedStore.namespacedGroup(group), key, value) + return scopedStore.storeInstance.Set(scopedStore.namespacedGroup(group), key, value) +} + +// SetIn writes a key to an explicit namespaced group. +// Usage example: `if err := scopedStore.SetIn("config", "colour", "blue"); err != nil { return }` +func (scopedStore *ScopedStore) SetIn(group, key, value string) error { + return scopedStore.Set(group, key, value) } // Usage example: `if err := scopedStore.SetWithTTL("sessions", "token", "abc123", time.Hour); err != nil { return }` func (scopedStore *ScopedStore) SetWithTTL(group, key, value string, timeToLive time.Duration) error { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.SetWithTTL") - if err != nil { - return err - } if err := scopedStore.checkQuota("store.ScopedStore.SetWithTTL", group, key); err != nil { return err } - return backingStore.SetWithTTL(scopedStore.namespacedGroup(group), key, value, timeToLive) + return scopedStore.storeInstance.SetWithTTL(scopedStore.namespacedGroup(group), key, value, timeToLive) } // Usage example: `if err := scopedStore.Delete("config", "colour"); err != nil { return }` func (scopedStore *ScopedStore) Delete(group, key string) error { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.Delete") - if err != nil { - return err - } - return backingStore.Delete(scopedStore.namespacedGroup(group), key) + return scopedStore.storeInstance.Delete(scopedStore.namespacedGroup(group), key) } // Usage example: `if err := scopedStore.DeleteGroup("cache"); err != nil { return }` func (scopedStore *ScopedStore) DeleteGroup(group string) error { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.DeleteGroup") - if err != nil { - return err - } - return backingStore.DeleteGroup(scopedStore.namespacedGroup(group)) -} - -// Usage example: `if err := scopedStore.DeletePrefix("config"); err != nil { return }` -func (scopedStore *ScopedStore) DeletePrefix(groupPrefix string) error { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.DeletePrefix") - if err != nil { - return err - } - return backingStore.DeletePrefix(scopedStore.namespacedGroup(groupPrefix)) + return scopedStore.storeInstance.DeleteGroup(scopedStore.namespacedGroup(group)) } // Usage example: `colourEntries, err := scopedStore.GetAll("config")` func (scopedStore *ScopedStore) GetAll(group string) (map[string]string, error) { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.GetAll") - if err != nil { - return nil, err - } - return backingStore.GetAll(scopedStore.namespacedGroup(group)) -} - -// Usage example: `page, err := scopedStore.GetPage("config", 0, 25); if err != nil { return }; for _, entry := range page { fmt.Println(entry.Key, entry.Value) }` -func (scopedStore *ScopedStore) GetPage(group string, offset, limit int) ([]KeyValue, error) { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.GetPage") - if err != nil { - return nil, err - } - return backingStore.GetPage(scopedStore.namespacedGroup(group), offset, limit) + return scopedStore.storeInstance.GetAll(scopedStore.namespacedGroup(group)) } // Usage example: `for entry, err := range scopedStore.All("config") { if err != nil { break }; fmt.Println(entry.Key, entry.Value) }` func (scopedStore *ScopedStore) All(group string) iter.Seq2[KeyValue, error] { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.All") - if err != nil { - return func(yield func(KeyValue, error) bool) { - yield(KeyValue{}, err) - } - } - return backingStore.All(scopedStore.namespacedGroup(group)) + return scopedStore.storeInstance.All(scopedStore.namespacedGroup(group)) } // Usage example: `for entry, err := range scopedStore.AllSeq("config") { if err != nil { break }; fmt.Println(entry.Key, entry.Value) }` func (scopedStore *ScopedStore) AllSeq(group string) iter.Seq2[KeyValue, error] { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.AllSeq") - if err != nil { - return func(yield func(KeyValue, error) bool) { - yield(KeyValue{}, err) - } - } - return backingStore.AllSeq(scopedStore.namespacedGroup(group)) + return scopedStore.All(group) } // Usage example: `keyCount, err := scopedStore.Count("config")` func (scopedStore *ScopedStore) Count(group string) (int, error) { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.Count") - if err != nil { - return 0, err - } - return backingStore.Count(scopedStore.namespacedGroup(group)) + return scopedStore.storeInstance.Count(scopedStore.namespacedGroup(group)) } // Usage example: `keyCount, err := scopedStore.CountAll("config")` // Usage example: `keyCount, err := scopedStore.CountAll()` func (scopedStore *ScopedStore) CountAll(groupPrefix ...string) (int, error) { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.CountAll") - if err != nil { - return 0, err - } - return backingStore.CountAll(scopedStore.namespacedGroup(firstOrEmptyString(groupPrefix))) + return scopedStore.storeInstance.CountAll(scopedStore.namespacedGroup(firstString(groupPrefix))) } // Usage example: `groupNames, err := scopedStore.Groups("config")` // Usage example: `groupNames, err := scopedStore.Groups()` func (scopedStore *ScopedStore) Groups(groupPrefix ...string) ([]string, error) { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.Groups") - if err != nil { - return nil, err - } - - groupNames, err := backingStore.Groups(scopedStore.namespacedGroup(firstOrEmptyString(groupPrefix))) + groupNames, err := scopedStore.storeInstance.Groups(scopedStore.namespacedGroup(firstString(groupPrefix))) if err != nil { return nil, err } @@ -309,13 +227,8 @@ func (scopedStore *ScopedStore) Groups(groupPrefix ...string) ([]string, error) // Usage example: `for groupName, err := range scopedStore.GroupsSeq() { if err != nil { break }; fmt.Println(groupName) }` func (scopedStore *ScopedStore) GroupsSeq(groupPrefix ...string) iter.Seq2[string, error] { return func(yield func(string, error) bool) { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.GroupsSeq") - if err != nil { - yield("", err) - return - } namespacePrefix := scopedStore.namespacePrefix() - for groupName, err := range backingStore.GroupsSeq(scopedStore.namespacedGroup(firstOrEmptyString(groupPrefix))) { + for groupName, err := range scopedStore.storeInstance.GroupsSeq(scopedStore.namespacedGroup(firstString(groupPrefix))) { if err != nil { if !yield("", err) { return @@ -331,503 +244,119 @@ func (scopedStore *ScopedStore) GroupsSeq(groupPrefix ...string) iter.Seq2[strin // Usage example: `renderedTemplate, err := scopedStore.Render("Hello {{ .name }}", "user")` func (scopedStore *ScopedStore) Render(templateSource, group string) (string, error) { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.Render") - if err != nil { - return "", err - } - return backingStore.Render(templateSource, scopedStore.namespacedGroup(group)) + return scopedStore.storeInstance.Render(templateSource, scopedStore.namespacedGroup(group)) } // Usage example: `parts, err := scopedStore.GetSplit("config", "hosts", ","); if err != nil { return }; for part := range parts { fmt.Println(part) }` func (scopedStore *ScopedStore) GetSplit(group, key, separator string) (iter.Seq[string], error) { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.GetSplit") - if err != nil { - return nil, err - } - return backingStore.GetSplit(scopedStore.namespacedGroup(group), key, separator) + return scopedStore.storeInstance.GetSplit(scopedStore.namespacedGroup(group), key, separator) } // Usage example: `fields, err := scopedStore.GetFields("config", "flags"); if err != nil { return }; for field := range fields { fmt.Println(field) }` func (scopedStore *ScopedStore) GetFields(group, key string) (iter.Seq[string], error) { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.GetFields") - if err != nil { - return nil, err - } - return backingStore.GetFields(scopedStore.namespacedGroup(group), key) -} - -// Usage example: `events := scopedStore.Watch("config")` -func (scopedStore *ScopedStore) Watch(group string) <-chan Event { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.Watch") - if err != nil { - return closedEventChannel() - } - if group != "*" { - return backingStore.Watch(scopedStore.namespacedGroup(group)) - } - - forwardedEvents := make(chan Event, watcherEventBufferCapacity) - binding := &scopedWatcherBinding{ - store: backingStore, - underlyingEvents: backingStore.Watch("*"), - done: make(chan struct{}), - stop: make(chan struct{}), - } - - scopedStore.scopedWatchersLock.Lock() - if scopedStore.scopedWatchers == nil { - scopedStore.scopedWatchers = make(map[uintptr]*scopedWatcherBinding) - } - scopedStore.scopedWatchers[channelPointer(forwardedEvents)] = binding - scopedStore.scopedWatchersLock.Unlock() - - namespacePrefix := scopedStore.namespacePrefix() - go func() { - defer close(forwardedEvents) - defer close(binding.done) - defer scopedStore.forgetScopedWatcher(forwardedEvents) - - for { - select { - case event, ok := <-binding.underlyingEvents: - if !ok { - return - } - if !core.HasPrefix(event.Group, namespacePrefix) { - continue - } - select { - case forwardedEvents <- event: - default: - } - case <-binding.stop: - return - case <-backingStore.purgeContext.Done(): - return - } - } - }() - - return forwardedEvents -} - -// Usage example: `scopedStore.Unwatch("config", events)` -func (scopedStore *ScopedStore) Unwatch(group string, events <-chan Event) { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.Unwatch") - if err != nil { - return - } - if group == "*" { - scopedStore.forgetAndStopScopedWatcher(events) - return - } - backingStore.Unwatch(scopedStore.namespacedGroup(group), events) -} - -// Usage example: `unregister := scopedStore.OnChange(func(event store.Event) { fmt.Println(event.Group, event.Key) })` -func (scopedStore *ScopedStore) OnChange(callback func(Event)) func() { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.OnChange") - if err != nil { - return func() {} - } - if callback == nil { - return func() {} - } - - namespacePrefix := scopedStore.namespacePrefix() - return backingStore.OnChange(func(event Event) { - if !core.HasPrefix(event.Group, namespacePrefix) { - return - } - callback(event) - }) + return scopedStore.storeInstance.GetFields(scopedStore.namespacedGroup(group), key) } // Usage example: `removedRows, err := scopedStore.PurgeExpired(); if err != nil { return }; fmt.Println(removedRows)` func (scopedStore *ScopedStore) PurgeExpired() (int64, error) { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.PurgeExpired") - if err != nil { - return 0, err - } - removedRows, err := backingStore.purgeExpiredMatchingGroupPrefix(scopedStore.namespacePrefix()) + removedRows, err := scopedStore.storeInstance.purgeExpiredMatchingGroupPrefix(scopedStore.namespacePrefix()) if err != nil { return 0, core.E("store.ScopedStore.PurgeExpired", "delete expired rows", err) } return removedRows, nil } -// Usage example: `err := scopedStore.Transaction(func(transaction *store.ScopedStoreTransaction) error { return transaction.SetIn("config", "colour", "blue") })` -func (scopedStore *ScopedStore) Transaction(operation func(*ScopedStoreTransaction) error) error { - backingStore, err := scopedStore.resolvedStore("store.ScopedStore.Transaction") - if err != nil { - return err - } - if operation == nil { - return core.E("store.ScopedStore.Transaction", "operation is nil", nil) - } - - return backingStore.Transaction(func(transaction *StoreTransaction) error { - scopedTransaction := &ScopedStoreTransaction{ - scopedStore: scopedStore, - storeTransaction: transaction, - } - return operation(scopedTransaction) - }) -} - -func (scopedTransaction *ScopedStoreTransaction) resolvedTransaction(operation string) (*StoreTransaction, error) { - if scopedTransaction == nil { - return nil, core.E(operation, "scoped transaction is nil", nil) - } - if scopedTransaction.scopedStore == nil { - return nil, core.E(operation, "scoped store is nil", nil) - } - if scopedTransaction.storeTransaction == nil { - return nil, core.E(operation, "transaction is nil", nil) - } - if _, err := scopedTransaction.scopedStore.resolvedStore(operation); err != nil { - return nil, err - } - return scopedTransaction.storeTransaction, nil -} - -// Usage example: `value, err := transaction.Get("colour")` -func (scopedTransaction *ScopedStoreTransaction) Get(key string) (string, error) { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.Get") - if err != nil { - return "", err - } - return storeTransaction.Get(scopedTransaction.scopedStore.namespacedGroup(defaultScopedGroupName), key) -} - -// Usage example: `value, err := transaction.GetFrom("config", "colour")` -func (scopedTransaction *ScopedStoreTransaction) GetFrom(group, key string) (string, error) { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.GetFrom") - if err != nil { - return "", err - } - return storeTransaction.Get(scopedTransaction.scopedStore.namespacedGroup(group), key) -} - -// Usage example: `if err := transaction.Set("colour", "blue"); err != nil { return err }` -func (scopedTransaction *ScopedStoreTransaction) Set(key, value string) error { - return scopedTransaction.SetIn(defaultScopedGroupName, key, value) -} - -// Usage example: `if err := transaction.SetIn("config", "colour", "blue"); err != nil { return err }` -func (scopedTransaction *ScopedStoreTransaction) SetIn(group, key, value string) error { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.SetIn") - if err != nil { - return err - } - if err := scopedTransaction.checkQuota("store.ScopedStoreTransaction.SetIn", group, key); err != nil { - return err - } - return storeTransaction.Set(scopedTransaction.scopedStore.namespacedGroup(group), key, value) -} - -// Usage example: `if err := transaction.SetWithTTL("sessions", "token", "abc123", time.Hour); err != nil { return err }` -func (scopedTransaction *ScopedStoreTransaction) SetWithTTL(group, key, value string, timeToLive time.Duration) error { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.SetWithTTL") - if err != nil { - return err - } - if err := scopedTransaction.checkQuota("store.ScopedStoreTransaction.SetWithTTL", group, key); err != nil { - return err - } - return storeTransaction.SetWithTTL(scopedTransaction.scopedStore.namespacedGroup(group), key, value, timeToLive) -} - -// Usage example: `if err := transaction.Delete("config", "colour"); err != nil { return err }` -func (scopedTransaction *ScopedStoreTransaction) Delete(group, key string) error { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.Delete") - if err != nil { - return err - } - return storeTransaction.Delete(scopedTransaction.scopedStore.namespacedGroup(group), key) -} - -// Usage example: `if err := transaction.DeleteGroup("cache"); err != nil { return err }` -func (scopedTransaction *ScopedStoreTransaction) DeleteGroup(group string) error { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.DeleteGroup") - if err != nil { - return err - } - return storeTransaction.DeleteGroup(scopedTransaction.scopedStore.namespacedGroup(group)) -} - -// Usage example: `if err := transaction.DeletePrefix("config"); err != nil { return err }` -func (scopedTransaction *ScopedStoreTransaction) DeletePrefix(groupPrefix string) error { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.DeletePrefix") - if err != nil { - return err - } - return storeTransaction.DeletePrefix(scopedTransaction.scopedStore.namespacedGroup(groupPrefix)) -} - -// Usage example: `entries, err := transaction.GetAll("config")` -func (scopedTransaction *ScopedStoreTransaction) GetAll(group string) (map[string]string, error) { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.GetAll") - if err != nil { - return nil, err - } - return storeTransaction.GetAll(scopedTransaction.scopedStore.namespacedGroup(group)) -} - -// Usage example: `page, err := transaction.GetPage("config", 0, 25)` -func (scopedTransaction *ScopedStoreTransaction) GetPage(group string, offset, limit int) ([]KeyValue, error) { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.GetPage") - if err != nil { - return nil, err - } - return storeTransaction.GetPage(scopedTransaction.scopedStore.namespacedGroup(group), offset, limit) -} - -// Usage example: `for entry, err := range transaction.All("config") { if err != nil { return }; fmt.Println(entry.Key, entry.Value) }` -func (scopedTransaction *ScopedStoreTransaction) All(group string) iter.Seq2[KeyValue, error] { - return scopedTransaction.AllSeq(group) -} - -// Usage example: `for entry, err := range transaction.AllSeq("config") { if err != nil { return }; fmt.Println(entry.Key, entry.Value) }` -func (scopedTransaction *ScopedStoreTransaction) AllSeq(group string) iter.Seq2[KeyValue, error] { - return func(yield func(KeyValue, error) bool) { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.AllSeq") - if err != nil { - yield(KeyValue{}, err) - return - } - for entry, iterationErr := range storeTransaction.AllSeq(scopedTransaction.scopedStore.namespacedGroup(group)) { - if iterationErr != nil { - if !yield(KeyValue{}, iterationErr) { - return - } - continue - } - if !yield(entry, nil) { - return - } - } - } -} - -// Usage example: `count, err := transaction.Count("config")` -func (scopedTransaction *ScopedStoreTransaction) Count(group string) (int, error) { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.Count") - if err != nil { - return 0, err - } - return storeTransaction.Count(scopedTransaction.scopedStore.namespacedGroup(group)) -} - -// Usage example: `count, err := transaction.CountAll("config")` -// Usage example: `count, err := transaction.CountAll()` -func (scopedTransaction *ScopedStoreTransaction) CountAll(groupPrefix ...string) (int, error) { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.CountAll") - if err != nil { - return 0, err - } - return storeTransaction.CountAll(scopedTransaction.scopedStore.namespacedGroup(firstOrEmptyString(groupPrefix))) -} - -// Usage example: `groups, err := transaction.Groups("config")` -// Usage example: `groups, err := transaction.Groups()` -func (scopedTransaction *ScopedStoreTransaction) Groups(groupPrefix ...string) ([]string, error) { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.Groups") - if err != nil { - return nil, err - } - - groupNames, err := storeTransaction.Groups(scopedTransaction.scopedStore.namespacedGroup(firstOrEmptyString(groupPrefix))) - if err != nil { - return nil, err - } - for index, groupName := range groupNames { - groupNames[index] = scopedTransaction.scopedStore.trimNamespacePrefix(groupName) - } - return groupNames, nil -} - -// Usage example: `for groupName, err := range transaction.GroupsSeq("config") { if err != nil { return }; fmt.Println(groupName) }` -// Usage example: `for groupName, err := range transaction.GroupsSeq() { if err != nil { return }; fmt.Println(groupName) }` -func (scopedTransaction *ScopedStoreTransaction) GroupsSeq(groupPrefix ...string) iter.Seq2[string, error] { - return func(yield func(string, error) bool) { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.GroupsSeq") - if err != nil { - yield("", err) - return - } - namespacePrefix := scopedTransaction.scopedStore.namespacePrefix() - for groupName, iterationErr := range storeTransaction.GroupsSeq(scopedTransaction.scopedStore.namespacedGroup(firstOrEmptyString(groupPrefix))) { - if iterationErr != nil { - if !yield("", iterationErr) { - return - } - continue - } - if !yield(core.TrimPrefix(groupName, namespacePrefix), nil) { - return - } - } - } -} - -// Usage example: `renderedTemplate, err := transaction.Render("Hello {{ .name }}", "user")` -func (scopedTransaction *ScopedStoreTransaction) Render(templateSource, group string) (string, error) { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.Render") - if err != nil { - return "", err - } - return storeTransaction.Render(templateSource, scopedTransaction.scopedStore.namespacedGroup(group)) -} - -// Usage example: `parts, err := transaction.GetSplit("config", "hosts", ",")` -func (scopedTransaction *ScopedStoreTransaction) GetSplit(group, key, separator string) (iter.Seq[string], error) { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.GetSplit") - if err != nil { - return nil, err - } - return storeTransaction.GetSplit(scopedTransaction.scopedStore.namespacedGroup(group), key, separator) -} - -// Usage example: `fields, err := transaction.GetFields("config", "flags")` -func (scopedTransaction *ScopedStoreTransaction) GetFields(group, key string) (iter.Seq[string], error) { - storeTransaction, err := scopedTransaction.resolvedTransaction("store.ScopedStoreTransaction.GetFields") - if err != nil { - return nil, err - } - return storeTransaction.GetFields(scopedTransaction.scopedStore.namespacedGroup(group), key) -} - -// checkQuota("store.ScopedStoreTransaction.SetIn", "config", "colour") uses -// the transaction's own read state so staged writes inside the same -// transaction count towards the namespace limits. -func (scopedTransaction *ScopedStoreTransaction) checkQuota(operation, group, key string) error { - if scopedTransaction == nil { - return core.E(operation, "scoped transaction is nil", nil) - } - if scopedTransaction.scopedStore == nil { - return core.E(operation, "scoped store is nil", nil) - } - storeTransaction, err := scopedTransaction.resolvedTransaction(operation) - if err != nil { - return err - } - return checkNamespaceQuota( - operation, - group, - key, - scopedTransaction.scopedStore.namespacedGroup(group), - scopedTransaction.scopedStore.namespacePrefix(), - scopedTransaction.scopedStore.MaxKeys, - scopedTransaction.scopedStore.MaxGroups, - storeTransaction, - ) -} - -func (scopedStore *ScopedStore) forgetScopedWatcher(events <-chan Event) { - if scopedStore == nil || events == nil { - return - } - - scopedStore.scopedWatchersLock.Lock() - defer scopedStore.scopedWatchersLock.Unlock() - if scopedStore.scopedWatchers == nil { - return - } - delete(scopedStore.scopedWatchers, channelPointer(events)) -} - -func (scopedStore *ScopedStore) forgetAndStopScopedWatcher(events <-chan Event) { - if scopedStore == nil || events == nil { - return - } - - scopedStore.scopedWatchersLock.Lock() - binding := scopedStore.scopedWatchers[channelPointer(events)] - if binding != nil { - delete(scopedStore.scopedWatchers, channelPointer(events)) - } - scopedStore.scopedWatchersLock.Unlock() - - if binding == nil { - return - } - - binding.stopOnce.Do(func() { - close(binding.stop) - }) - if binding.store != nil { - binding.store.Unwatch("*", binding.underlyingEvents) - } - <-binding.done -} - // checkQuota("store.ScopedStore.Set", "config", "colour") returns nil when the // namespace still has quota available and QuotaExceededError when a new key or // group would exceed the configured limit. Existing keys are treated as // upserts and do not consume quota. func (scopedStore *ScopedStore) checkQuota(operation, group, key string) error { - if scopedStore == nil { - return core.E(operation, "scoped store is nil", nil) - } - return checkNamespaceQuota( - operation, - group, - key, - scopedStore.namespacedGroup(group), - scopedStore.namespacePrefix(), - scopedStore.MaxKeys, - scopedStore.MaxGroups, - scopedStore.store, - ) -} - -type namespaceQuotaReader interface { - Get(group, key string) (string, error) - Count(group string) (int, error) - CountAll(groupPrefix string) (int, error) - Groups(groupPrefix ...string) ([]string, error) -} - -func checkNamespaceQuota(operation, group, key, namespacedGroup, namespacePrefix string, maxKeys, maxGroups int, reader namespaceQuotaReader) error { - if maxKeys == 0 && maxGroups == 0 { + if scopedStore.MaxKeys == 0 && scopedStore.MaxGroups == 0 { return nil } - // Upserts never consume quota. - _, err := reader.Get(namespacedGroup, key) + namespacedGroup := scopedStore.namespacedGroup(group) + namespacePrefix := scopedStore.namespacePrefix() + + // Check if this is an upsert (key already exists) — upserts never exceed quota. + _, err := scopedStore.storeInstance.Get(namespacedGroup, key) if err == nil { + // Key exists — this is an upsert, no quota check needed. return nil } if !core.Is(err, NotFoundError) { + // A database error occurred, not just a "not found" result. return core.E(operation, "quota check", err) } - if maxKeys > 0 { - keyCount, err := reader.CountAll(namespacePrefix) + // Check MaxKeys quota. + if scopedStore.MaxKeys > 0 { + keyCount, err := scopedStore.storeInstance.CountAll(namespacePrefix) if err != nil { return core.E(operation, "quota check", err) } - if keyCount >= maxKeys { - return core.E(operation, core.Sprintf("key limit (%d)", maxKeys), QuotaExceededError) + if keyCount >= scopedStore.MaxKeys { + return core.E(operation, core.Sprintf("key limit (%d)", scopedStore.MaxKeys), QuotaExceededError) } } - if maxGroups > 0 { - existingGroupCount, err := reader.Count(namespacedGroup) + // Check MaxGroups quota — only if this would create a new group. + if scopedStore.MaxGroups > 0 { + existingGroupCount, err := scopedStore.storeInstance.Count(namespacedGroup) if err != nil { return core.E(operation, "quota check", err) } if existingGroupCount == 0 { - groupNames, err := reader.Groups(namespacePrefix) - if err != nil { - return core.E(operation, "quota check", err) + // This group is new — check if adding it would exceed the group limit. + knownGroupCount := 0 + for _, iterationErr := range scopedStore.storeInstance.GroupsSeq(namespacePrefix) { + if iterationErr != nil { + return core.E(operation, "quota check", iterationErr) + } + knownGroupCount++ } - if len(groupNames) >= maxGroups { - return core.E(operation, core.Sprintf("group limit (%d)", maxGroups), QuotaExceededError) + if knownGroupCount >= scopedStore.MaxGroups { + return core.E(operation, core.Sprintf("group limit (%d)", scopedStore.MaxGroups), QuotaExceededError) } } } return nil } + +func (scopedStore *ScopedStore) getArguments(arguments []string) (string, string, error) { + switch len(arguments) { + case 1: + return scopedStore.defaultGroup(), arguments[0], nil + case 2: + return arguments[0], arguments[1], nil + default: + return "", "", core.E( + "store.ScopedStore.Get", + core.Sprintf("expected 1 or 2 arguments; got %d", len(arguments)), + nil, + ) + } +} + +func (scopedStore *ScopedStore) setArguments(arguments []string) (string, string, string, error) { + switch len(arguments) { + case 2: + return scopedStore.defaultGroup(), arguments[0], arguments[1], nil + case 3: + return arguments[0], arguments[1], arguments[2], nil + default: + return "", "", "", core.E( + "store.ScopedStore.Set", + core.Sprintf("expected 2 or 3 arguments; got %d", len(arguments)), + nil, + ) + } +} + +func firstString(values []string) string { + if len(values) == 0 { + return "" + } + return values[0] +} diff --git a/scope_test.go b/scope_test.go index c54688e..7ef2f27 100644 --- a/scope_test.go +++ b/scope_test.go @@ -9,14 +9,6 @@ import ( "github.com/stretchr/testify/require" ) -func mustScoped(t *testing.T, storeInstance *Store, namespace string) *ScopedStore { - t.Helper() - - scopedStore := NewScoped(storeInstance, namespace) - require.NotNil(t, scopedStore) - return scopedStore -} - // --------------------------------------------------------------------------- // NewScoped — constructor validation // --------------------------------------------------------------------------- @@ -25,7 +17,9 @@ func TestScope_NewScoped_Good(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-1") + scopedStore, err := NewScoped(storeInstance, "tenant-1") + require.NoError(t, err) + require.NotNil(t, scopedStore) assert.Equal(t, "tenant-1", scopedStore.Namespace()) } @@ -33,8 +27,11 @@ func TestScope_NewScoped_Good_AlphanumericHyphens(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - for _, namespace := range []string{"abc", "ABC", "123", "a-b-c", "tenant-42", "A1-B2"} { - require.NotNil(t, NewScoped(storeInstance, namespace), "namespace %q should be valid", namespace) + valid := []string{"abc", "ABC", "123", "a-b-c", "tenant-42", "A1-B2"} + for _, namespace := range valid { + scopedStore, err := NewScoped(storeInstance, namespace) + require.NoError(t, err, "namespace %q should be valid", namespace) + require.NotNil(t, scopedStore) } } @@ -42,19 +39,25 @@ func TestScope_NewScoped_Bad_Empty(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - assert.Nil(t, NewScoped(storeInstance, "")) + _, err := NewScoped(storeInstance, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid") } func TestScope_NewScoped_Bad_NilStore(t *testing.T) { - assert.Nil(t, NewScoped(nil, "tenant-a")) + _, err := NewScoped(nil, "tenant-a") + require.Error(t, err) + assert.Contains(t, err.Error(), "store instance is nil") } func TestScope_NewScoped_Bad_InvalidChars(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - for _, namespace := range []string{"foo.bar", "foo:bar", "foo bar", "foo/bar", "foo_bar", "tenant!", "@ns"} { - assert.Nil(t, NewScoped(storeInstance, namespace), "namespace %q should be invalid", namespace) + invalid := []string{"foo.bar", "foo:bar", "foo bar", "foo/bar", "foo_bar", "tenant!", "@ns"} + for _, namespace := range invalid { + _, err := NewScoped(storeInstance, namespace) + require.Error(t, err, "namespace %q should be invalid", namespace) } } @@ -64,7 +67,7 @@ func TestScope_NewScopedWithQuota_Bad_InvalidNamespace(t *testing.T) { _, err := NewScopedWithQuota(storeInstance, "tenant_a", QuotaConfig{MaxKeys: 1}) require.Error(t, err) - assert.Contains(t, err.Error(), "namespace") + assert.Contains(t, err.Error(), "store.NewScoped") } func TestScope_NewScopedWithQuota_Bad_NilStore(t *testing.T) { @@ -102,6 +105,14 @@ func TestScope_NewScopedWithQuota_Good_InlineQuotaFields(t *testing.T) { assert.Equal(t, 2, scopedStore.MaxGroups) } +func TestScope_ScopedStoreConfig_Good_Validate(t *testing.T) { + err := (ScopedStoreConfig{ + Namespace: "tenant-a", + Quota: QuotaConfig{MaxKeys: 4, MaxGroups: 2}, + }).Validate() + require.NoError(t, err) +} + func TestScope_NewScopedConfigured_Good(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() @@ -111,8 +122,7 @@ func TestScope_NewScopedConfigured_Good(t *testing.T) { Quota: QuotaConfig{MaxKeys: 4, MaxGroups: 2}, }) require.NoError(t, err) - - assert.Equal(t, "tenant-a", scopedStore.Namespace()) + require.NotNil(t, scopedStore) assert.Equal(t, 4, scopedStore.MaxKeys) assert.Equal(t, 2, scopedStore.MaxGroups) } @@ -121,45 +131,14 @@ func TestScope_NewScopedConfigured_Bad_InvalidNamespace(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - _, err := NewScopedConfigured(storeInstance, ScopedStoreConfig{Namespace: "tenant_a"}) + _, err := NewScopedConfigured(storeInstance, ScopedStoreConfig{ + Namespace: "tenant_a", + Quota: QuotaConfig{MaxKeys: 1}, + }) require.Error(t, err) assert.Contains(t, err.Error(), "namespace") } -func TestScope_QuotaConfig_Good_Validate(t *testing.T) { - err := (QuotaConfig{MaxKeys: 4, MaxGroups: 2}).Validate() - require.NoError(t, err) -} - -func TestScope_QuotaConfig_Bad_ValidateNegativeValue(t *testing.T) { - err := (QuotaConfig{MaxKeys: -1, MaxGroups: 2}).Validate() - require.Error(t, err) - assert.Contains(t, err.Error(), "quota values must be zero or positive") -} - -func TestScope_ScopedStoreConfig_Good_Validate(t *testing.T) { - err := (ScopedStoreConfig{ - Namespace: "tenant-a", - Quota: QuotaConfig{MaxKeys: 4, MaxGroups: 2}, - }).Validate() - require.NoError(t, err) -} - -func TestScope_ScopedStoreConfig_Bad_InvalidNamespace(t *testing.T) { - err := (ScopedStoreConfig{Namespace: "tenant_a"}).Validate() - require.Error(t, err) - assert.Contains(t, err.Error(), "namespace") -} - -func TestScope_ScopedStoreConfig_Bad_NegativeQuota(t *testing.T) { - err := (ScopedStoreConfig{ - Namespace: "tenant-a", - Quota: QuotaConfig{MaxKeys: -1}, - }).Validate() - require.Error(t, err) - assert.Contains(t, err.Error(), "quota values must be zero or positive") -} - // --------------------------------------------------------------------------- // ScopedStore — basic CRUD // --------------------------------------------------------------------------- @@ -168,10 +147,10 @@ func TestScope_ScopedStore_Good_SetGet(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("config", "theme", "dark")) + scopedStore, _ := NewScoped(storeInstance, "tenant-a") + require.NoError(t, scopedStore.Set("config", "theme", "dark")) - value, err := scopedStore.GetFrom("config", "theme") + value, err := scopedStore.Get("config", "theme") require.NoError(t, err) assert.Equal(t, "dark", value) } @@ -180,7 +159,7 @@ func TestScope_ScopedStore_Good_DefaultGroupHelpers(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") + scopedStore, _ := NewScoped(storeInstance, "tenant-a") require.NoError(t, scopedStore.Set("theme", "dark")) value, err := scopedStore.Get("theme") @@ -192,61 +171,31 @@ func TestScope_ScopedStore_Good_DefaultGroupHelpers(t *testing.T) { assert.Equal(t, "dark", rawValue) } -func TestScope_ScopedStore_Good_SetInAndGetFrom(t *testing.T) { +func TestScope_ScopedStore_Good_SetInGetFrom(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("config", "colour", "blue")) + scopedStore, _ := NewScoped(storeInstance, "tenant-a") + require.NoError(t, scopedStore.SetIn("config", "theme", "dark")) - value, err := scopedStore.GetFrom("config", "colour") + value, err := scopedStore.GetFrom("config", "theme") require.NoError(t, err) - assert.Equal(t, "blue", value) -} - -func TestScope_ScopedStore_Good_AllSeq(t *testing.T) { - storeInstance, _ := New(":memory:") - defer storeInstance.Close() - - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("items", "first", "1")) - require.NoError(t, scopedStore.SetIn("items", "second", "2")) - - var keys []string - for entry, err := range scopedStore.AllSeq("items") { - require.NoError(t, err) - keys = append(keys, entry.Key) - } - - assert.ElementsMatch(t, []string{"first", "second"}, keys) -} - -func TestScope_ScopedStore_Good_GetPage(t *testing.T) { - storeInstance, _ := New(":memory:") - defer storeInstance.Close() - - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("items", "charlie", "3")) - require.NoError(t, scopedStore.SetIn("items", "alpha", "1")) - require.NoError(t, scopedStore.SetIn("items", "bravo", "2")) - - page, err := scopedStore.GetPage("items", 0, 2) - require.NoError(t, err) - require.Len(t, page, 2) - assert.Equal(t, []KeyValue{{Key: "alpha", Value: "1"}, {Key: "bravo", Value: "2"}}, page) + assert.Equal(t, "dark", value) } func TestScope_ScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("config", "key", "val")) + scopedStore, _ := NewScoped(storeInstance, "tenant-a") + require.NoError(t, scopedStore.Set("config", "key", "val")) + // The underlying store should have the prefixed group name. value, err := storeInstance.Get("tenant-a:config", "key") require.NoError(t, err) assert.Equal(t, "val", value) + // Direct access without prefix should fail. _, err = storeInstance.Get("config", "key") assert.True(t, core.Is(err, NotFoundError)) } @@ -255,17 +204,17 @@ func TestScope_ScopedStore_Good_NamespaceIsolation(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - alphaStore := mustScoped(t, storeInstance, "tenant-a") - betaStore := mustScoped(t, storeInstance, "tenant-b") + alphaStore, _ := NewScoped(storeInstance, "tenant-a") + betaStore, _ := NewScoped(storeInstance, "tenant-b") - require.NoError(t, alphaStore.SetIn("config", "colour", "blue")) - require.NoError(t, betaStore.SetIn("config", "colour", "red")) + require.NoError(t, alphaStore.Set("config", "colour", "blue")) + require.NoError(t, betaStore.Set("config", "colour", "red")) - alphaValue, err := alphaStore.GetFrom("config", "colour") + alphaValue, err := alphaStore.Get("config", "colour") require.NoError(t, err) assert.Equal(t, "blue", alphaValue) - betaValue, err := betaStore.GetFrom("config", "colour") + betaValue, err := betaStore.Get("config", "colour") require.NoError(t, err) assert.Equal(t, "red", betaValue) } @@ -274,11 +223,11 @@ func TestScope_ScopedStore_Good_Delete(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("g", "k", "v")) + scopedStore, _ := NewScoped(storeInstance, "tenant-a") + require.NoError(t, scopedStore.Set("g", "k", "v")) require.NoError(t, scopedStore.Delete("g", "k")) - _, err := scopedStore.GetFrom("g", "k") + _, err := scopedStore.Get("g", "k") assert.True(t, core.Is(err, NotFoundError)) } @@ -286,9 +235,9 @@ func TestScope_ScopedStore_Good_DeleteGroup(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("g", "a", "1")) - require.NoError(t, scopedStore.SetIn("g", "b", "2")) + scopedStore, _ := NewScoped(storeInstance, "tenant-a") + require.NoError(t, scopedStore.Set("g", "a", "1")) + require.NoError(t, scopedStore.Set("g", "b", "2")) require.NoError(t, scopedStore.DeleteGroup("g")) count, err := scopedStore.Count("g") @@ -296,37 +245,16 @@ func TestScope_ScopedStore_Good_DeleteGroup(t *testing.T) { assert.Equal(t, 0, count) } -func TestScope_ScopedStore_Good_DeletePrefix(t *testing.T) { - storeInstance, _ := New(":memory:") - defer storeInstance.Close() - - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("config", "colour", "blue")) - require.NoError(t, scopedStore.SetIn("sessions", "token", "abc123")) - require.NoError(t, storeInstance.Set("tenant-b:config", "colour", "green")) - - require.NoError(t, scopedStore.DeletePrefix("")) - - _, err := scopedStore.GetFrom("config", "colour") - assert.Error(t, err) - _, err = scopedStore.GetFrom("sessions", "token") - assert.Error(t, err) - - value, err := storeInstance.Get("tenant-b:config", "colour") - require.NoError(t, err) - assert.Equal(t, "green", value) -} - func TestScope_ScopedStore_Good_GetAll(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - alphaStore := mustScoped(t, storeInstance, "tenant-a") - betaStore := mustScoped(t, storeInstance, "tenant-b") + alphaStore, _ := NewScoped(storeInstance, "tenant-a") + betaStore, _ := NewScoped(storeInstance, "tenant-b") - require.NoError(t, alphaStore.SetIn("items", "x", "1")) - require.NoError(t, alphaStore.SetIn("items", "y", "2")) - require.NoError(t, betaStore.SetIn("items", "z", "3")) + require.NoError(t, alphaStore.Set("items", "x", "1")) + require.NoError(t, alphaStore.Set("items", "y", "2")) + require.NoError(t, betaStore.Set("items", "z", "3")) all, err := alphaStore.GetAll("items") require.NoError(t, err) @@ -341,9 +269,9 @@ func TestScope_ScopedStore_Good_All(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("items", "first", "1")) - require.NoError(t, scopedStore.SetIn("items", "second", "2")) + scopedStore, _ := NewScoped(storeInstance, "tenant-a") + require.NoError(t, scopedStore.Set("items", "first", "1")) + require.NoError(t, scopedStore.Set("items", "second", "2")) var keys []string for entry, err := range scopedStore.All("items") { @@ -358,10 +286,10 @@ func TestScope_ScopedStore_Good_All_SortedByKey(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("items", "charlie", "3")) - require.NoError(t, scopedStore.SetIn("items", "alpha", "1")) - require.NoError(t, scopedStore.SetIn("items", "bravo", "2")) + scopedStore, _ := NewScoped(storeInstance, "tenant-a") + require.NoError(t, scopedStore.Set("items", "charlie", "3")) + require.NoError(t, scopedStore.Set("items", "alpha", "1")) + require.NoError(t, scopedStore.Set("items", "bravo", "2")) var keys []string for entry, err := range scopedStore.All("items") { @@ -376,9 +304,9 @@ func TestScope_ScopedStore_Good_Count(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("g", "a", "1")) - require.NoError(t, scopedStore.SetIn("g", "b", "2")) + scopedStore, _ := NewScoped(storeInstance, "tenant-a") + require.NoError(t, scopedStore.Set("g", "a", "1")) + require.NoError(t, scopedStore.Set("g", "b", "2")) count, err := scopedStore.Count("g") require.NoError(t, err) @@ -389,10 +317,10 @@ func TestScope_ScopedStore_Good_SetWithTTL(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") + scopedStore, _ := NewScoped(storeInstance, "tenant-a") require.NoError(t, scopedStore.SetWithTTL("g", "k", "v", time.Hour)) - value, err := scopedStore.GetFrom("g", "k") + value, err := scopedStore.Get("g", "k") require.NoError(t, err) assert.Equal(t, "v", value) } @@ -401,11 +329,11 @@ func TestScope_ScopedStore_Good_SetWithTTL_Expires(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") + scopedStore, _ := NewScoped(storeInstance, "tenant-a") require.NoError(t, scopedStore.SetWithTTL("g", "k", "v", 1*time.Millisecond)) time.Sleep(5 * time.Millisecond) - _, err := scopedStore.GetFrom("g", "k") + _, err := scopedStore.Get("g", "k") assert.True(t, core.Is(err, NotFoundError)) } @@ -413,8 +341,8 @@ func TestScope_ScopedStore_Good_Render(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("user", "name", "Alice")) + scopedStore, _ := NewScoped(storeInstance, "tenant-a") + require.NoError(t, scopedStore.Set("user", "name", "Alice")) renderedTemplate, err := scopedStore.Render("Hello {{ .name }}", "user") require.NoError(t, err) @@ -425,12 +353,12 @@ func TestScope_ScopedStore_Good_BulkHelpers(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - alphaStore := mustScoped(t, storeInstance, "tenant-a") - betaStore := mustScoped(t, storeInstance, "tenant-b") + alphaStore, _ := NewScoped(storeInstance, "tenant-a") + betaStore, _ := NewScoped(storeInstance, "tenant-b") - require.NoError(t, alphaStore.SetIn("config", "colour", "blue")) - require.NoError(t, alphaStore.SetIn("sessions", "token", "abc123")) - require.NoError(t, betaStore.SetIn("config", "colour", "red")) + require.NoError(t, alphaStore.Set("config", "colour", "blue")) + require.NoError(t, alphaStore.Set("sessions", "token", "abc123")) + require.NoError(t, betaStore.Set("config", "colour", "red")) count, err := alphaStore.CountAll("") require.NoError(t, err) @@ -467,9 +395,9 @@ func TestScope_ScopedStore_Good_GroupsSeqStopsEarly(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("alpha", "a", "1")) - require.NoError(t, scopedStore.SetIn("beta", "b", "2")) + scopedStore, _ := NewScoped(storeInstance, "tenant-a") + require.NoError(t, scopedStore.Set("alpha", "a", "1")) + require.NoError(t, scopedStore.Set("beta", "b", "2")) groups := scopedStore.GroupsSeq("") var seen []string @@ -486,10 +414,10 @@ func TestScope_ScopedStore_Good_GroupsSeqSorted(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("charlie", "c", "3")) - require.NoError(t, scopedStore.SetIn("alpha", "a", "1")) - require.NoError(t, scopedStore.SetIn("bravo", "b", "2")) + scopedStore, _ := NewScoped(storeInstance, "tenant-a") + require.NoError(t, scopedStore.Set("charlie", "c", "3")) + require.NoError(t, scopedStore.Set("alpha", "a", "1")) + require.NoError(t, scopedStore.Set("bravo", "b", "2")) var groupNames []string for groupName, iterationErr := range scopedStore.GroupsSeq("") { @@ -504,9 +432,9 @@ func TestScope_ScopedStore_Good_GetSplitAndGetFields(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") - require.NoError(t, scopedStore.SetIn("config", "hosts", "alpha,beta,gamma")) - require.NoError(t, scopedStore.SetIn("config", "flags", "one two\tthree\n")) + scopedStore, _ := NewScoped(storeInstance, "tenant-a") + require.NoError(t, scopedStore.Set("config", "hosts", "alpha,beta,gamma")) + require.NoError(t, scopedStore.Set("config", "flags", "one two\tthree\n")) parts, err := scopedStore.GetSplit("config", "hosts", ",") require.NoError(t, err) @@ -531,7 +459,7 @@ func TestScope_ScopedStore_Good_PurgeExpired(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - scopedStore := mustScoped(t, storeInstance, "tenant-a") + scopedStore, _ := NewScoped(storeInstance, "tenant-a") require.NoError(t, scopedStore.SetWithTTL("session", "token", "abc123", 1*time.Millisecond)) time.Sleep(5 * time.Millisecond) @@ -539,7 +467,7 @@ func TestScope_ScopedStore_Good_PurgeExpired(t *testing.T) { require.NoError(t, err) assert.Equal(t, int64(1), removedRows) - _, err = scopedStore.GetFrom("session", "token") + _, err = scopedStore.Get("session", "token") assert.True(t, core.Is(err, NotFoundError)) } @@ -547,8 +475,8 @@ func TestScope_ScopedStore_Good_PurgeExpired_NamespaceLocal(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - alphaStore := mustScoped(t, storeInstance, "tenant-a") - betaStore := mustScoped(t, storeInstance, "tenant-b") + alphaStore, _ := NewScoped(storeInstance, "tenant-a") + betaStore, _ := NewScoped(storeInstance, "tenant-b") require.NoError(t, alphaStore.SetWithTTL("session", "alpha-token", "alpha", 1*time.Millisecond)) require.NoError(t, betaStore.SetWithTTL("session", "beta-token", "beta", 1*time.Millisecond)) @@ -565,148 +493,6 @@ func TestScope_ScopedStore_Good_PurgeExpired_NamespaceLocal(t *testing.T) { assert.Equal(t, 1, rawEntryCount(t, storeInstance, "tenant-b:session")) } -func TestScope_ScopedStore_Good_WatchAndUnwatch(t *testing.T) { - storeInstance, _ := New(":memory:") - defer storeInstance.Close() - - scopedStore := mustScoped(t, storeInstance, "tenant-a") - events := scopedStore.Watch("config") - scopedStore.Unwatch("config", events) - - _, open := <-events - assert.False(t, open, "channel should be closed after Unwatch") - - require.NoError(t, scopedStore.SetIn("config", "theme", "dark")) -} - -func TestScope_ScopedStore_Good_WatchWildcardGroup(t *testing.T) { - storeInstance, _ := New(":memory:") - defer storeInstance.Close() - - scopedStore := mustScoped(t, storeInstance, "tenant-a") - events := scopedStore.Watch("*") - - require.NoError(t, scopedStore.SetIn("config", "theme", "dark")) - require.NoError(t, storeInstance.Set("other", "theme", "light")) - - received := drainEvents(events, 1, time.Second) - require.Len(t, received, 1) - assert.Equal(t, "tenant-a:config", received[0].Group) - assert.Equal(t, "theme", received[0].Key) - assert.Equal(t, "dark", received[0].Value) - - scopedStore.Unwatch("*", events) - _, open := <-events - assert.False(t, open, "channel should be closed after wildcard Unwatch") -} - -func TestScope_ScopedStore_Good_OnChange(t *testing.T) { - storeInstance, _ := New(":memory:") - defer storeInstance.Close() - - scopedStore := mustScoped(t, storeInstance, "tenant-a") - - var seen []Event - unregister := scopedStore.OnChange(func(event Event) { - seen = append(seen, event) - }) - defer unregister() - - require.NoError(t, scopedStore.SetIn("config", "theme", "dark")) - require.NoError(t, storeInstance.Set("other", "key", "value")) - - require.Len(t, seen, 1) - assert.Equal(t, "tenant-a:config", seen[0].Group) - assert.Equal(t, "theme", seen[0].Key) - assert.Equal(t, "dark", seen[0].Value) -} - -func TestScope_ScopedStoreTransaction_Good_PrefixesAndReadsPendingWrites(t *testing.T) { - storeInstance, _ := New(":memory:") - defer storeInstance.Close() - - scopedStore := mustScoped(t, storeInstance, "tenant-a") - events := storeInstance.Watch("*") - defer storeInstance.Unwatch("*", events) - - err := scopedStore.Transaction(func(transaction *ScopedStoreTransaction) error { - require.NoError(t, transaction.Set("theme", "dark")) - require.NoError(t, transaction.SetIn("config", "colour", "blue")) - - value, err := transaction.Get("theme") - require.NoError(t, err) - assert.Equal(t, "dark", value) - - entriesByKey, err := transaction.GetAll("config") - require.NoError(t, err) - assert.Equal(t, map[string]string{"colour": "blue"}, entriesByKey) - - count, err := transaction.CountAll("") - require.NoError(t, err) - assert.Equal(t, 2, count) - - groupNames, err := transaction.Groups() - require.NoError(t, err) - assert.Equal(t, []string{"config", "default"}, groupNames) - - renderedTemplate, err := transaction.Render("{{ .theme }} / {{ .colour }}", "default") - require.NoError(t, err) - assert.Equal(t, "dark / ", renderedTemplate) - - return nil - }) - require.NoError(t, err) - - value, err := storeInstance.Get("tenant-a:default", "theme") - require.NoError(t, err) - assert.Equal(t, "dark", value) - - value, err = storeInstance.Get("tenant-a:config", "colour") - require.NoError(t, err) - assert.Equal(t, "blue", value) - - received := drainEvents(events, 2, time.Second) - require.Len(t, received, 2) - assert.Equal(t, "tenant-a:default", received[0].Group) - assert.Equal(t, "tenant-a:config", received[1].Group) -} - -func TestScope_Quota_Good_TransactionEnforcesMaxKeys(t *testing.T) { - storeInstance, _ := New(":memory:") - defer storeInstance.Close() - - scopedStore, err := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxKeys: 1}) - require.NoError(t, err) - - err = scopedStore.Transaction(func(transaction *ScopedStoreTransaction) error { - require.NoError(t, transaction.SetIn("config", "colour", "blue")) - return transaction.SetIn("config", "language", "en-GB") - }) - require.Error(t, err) - assert.True(t, core.Is(err, QuotaExceededError)) - - _, err = scopedStore.GetFrom("config", "colour") - assert.ErrorIs(t, err, NotFoundError) -} - -func TestScope_Quota_Good_TransactionEnforcesMaxGroups(t *testing.T) { - storeInstance, _ := New(":memory:") - defer storeInstance.Close() - - scopedStore, err := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxGroups: 1}) - require.NoError(t, err) - - err = scopedStore.Transaction(func(transaction *ScopedStoreTransaction) error { - require.NoError(t, transaction.SetIn("config", "colour", "blue")) - return transaction.SetWithTTL("preferences", "language", "en-GB", time.Hour) - }) - require.Error(t, err) - assert.True(t, core.Is(err, QuotaExceededError)) - - _, err = scopedStore.GetFrom("config", "colour") - assert.ErrorIs(t, err, NotFoundError) -} - // --------------------------------------------------------------------------- // Quota enforcement — MaxKeys // --------------------------------------------------------------------------- @@ -718,13 +504,15 @@ func TestScope_Quota_Good_MaxKeys(t *testing.T) { scopedStore, err := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxKeys: 5}) require.NoError(t, err) + // Insert 5 keys across different groups — should be fine. for i := range 5 { - require.NoError(t, scopedStore.SetIn("g", keyName(i), "v")) + require.NoError(t, scopedStore.Set("g", keyName(i), "v")) } - err = scopedStore.SetIn("g", "overflow", "v") + // 6th key should fail. + err = scopedStore.Set("g", "overflow", "v") require.Error(t, err) - assert.True(t, core.Is(err, QuotaExceededError)) + assert.True(t, core.Is(err, QuotaExceededError), "expected QuotaExceededError, got: %v", err) } func TestScope_Quota_Bad_QuotaCheckQueryError(t *testing.T) { @@ -739,7 +527,7 @@ func TestScope_Quota_Bad_QuotaCheckQueryError(t *testing.T) { scopedStore, err := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxKeys: 1}) require.NoError(t, err) - err = scopedStore.SetIn("config", "theme", "dark") + err = scopedStore.Set("config", "theme", "dark") require.Error(t, err) assert.Contains(t, err.Error(), "quota check") } @@ -750,11 +538,12 @@ func TestScope_Quota_Good_MaxKeys_AcrossGroups(t *testing.T) { scopedStore, _ := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxKeys: 3}) - require.NoError(t, scopedStore.SetIn("g1", "a", "1")) - require.NoError(t, scopedStore.SetIn("g2", "b", "2")) - require.NoError(t, scopedStore.SetIn("g3", "c", "3")) + require.NoError(t, scopedStore.Set("g1", "a", "1")) + require.NoError(t, scopedStore.Set("g2", "b", "2")) + require.NoError(t, scopedStore.Set("g3", "c", "3")) - err := scopedStore.SetIn("g4", "d", "4") + // Total is now 3 — any new key should fail regardless of group. + err := scopedStore.Set("g4", "d", "4") assert.True(t, core.Is(err, QuotaExceededError)) } @@ -764,12 +553,14 @@ func TestScope_Quota_Good_UpsertDoesNotCount(t *testing.T) { scopedStore, _ := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxKeys: 3}) - require.NoError(t, scopedStore.SetIn("g", "a", "1")) - require.NoError(t, scopedStore.SetIn("g", "b", "2")) - require.NoError(t, scopedStore.SetIn("g", "c", "3")) - require.NoError(t, scopedStore.SetIn("g", "a", "updated")) + require.NoError(t, scopedStore.Set("g", "a", "1")) + require.NoError(t, scopedStore.Set("g", "b", "2")) + require.NoError(t, scopedStore.Set("g", "c", "3")) - value, err := scopedStore.GetFrom("g", "a") + // Upserting existing key should succeed. + require.NoError(t, scopedStore.Set("g", "a", "updated")) + + value, err := scopedStore.Get("g", "a") require.NoError(t, err) assert.Equal(t, "updated", value) } @@ -780,11 +571,13 @@ func TestScope_Quota_Good_DeleteAndReInsert(t *testing.T) { scopedStore, _ := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxKeys: 3}) - require.NoError(t, scopedStore.SetIn("g", "a", "1")) - require.NoError(t, scopedStore.SetIn("g", "b", "2")) - require.NoError(t, scopedStore.SetIn("g", "c", "3")) + require.NoError(t, scopedStore.Set("g", "a", "1")) + require.NoError(t, scopedStore.Set("g", "b", "2")) + require.NoError(t, scopedStore.Set("g", "c", "3")) + + // Delete one key, then insert a new one — should work. require.NoError(t, scopedStore.Delete("g", "c")) - require.NoError(t, scopedStore.SetIn("g", "d", "4")) + require.NoError(t, scopedStore.Set("g", "d", "4")) } func TestScope_Quota_Good_ZeroMeansUnlimited(t *testing.T) { @@ -793,8 +586,9 @@ func TestScope_Quota_Good_ZeroMeansUnlimited(t *testing.T) { scopedStore, _ := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxKeys: 0, MaxGroups: 0}) + // Should be able to insert many keys and groups without error. for i := range 100 { - require.NoError(t, scopedStore.SetIn("g", keyName(i), "v")) + require.NoError(t, scopedStore.Set("g", keyName(i), "v")) } } @@ -804,16 +598,19 @@ func TestScope_Quota_Good_ExpiredKeysExcluded(t *testing.T) { scopedStore, _ := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxKeys: 3}) + // Insert 3 keys, 2 with short TTL. require.NoError(t, scopedStore.SetWithTTL("g", "temp1", "v", 1*time.Millisecond)) require.NoError(t, scopedStore.SetWithTTL("g", "temp2", "v", 1*time.Millisecond)) - require.NoError(t, scopedStore.SetIn("g", "permanent", "v")) + require.NoError(t, scopedStore.Set("g", "permanent", "v")) time.Sleep(5 * time.Millisecond) - require.NoError(t, scopedStore.SetIn("g", "new1", "v")) - require.NoError(t, scopedStore.SetIn("g", "new2", "v")) + // After expiry, only 1 key counts — should be able to insert 2 more. + require.NoError(t, scopedStore.Set("g", "new1", "v")) + require.NoError(t, scopedStore.Set("g", "new2", "v")) - err := scopedStore.SetIn("g", "new3", "v") + // Now at 3 — next should fail. + err := scopedStore.Set("g", "new3", "v") assert.True(t, core.Is(err, QuotaExceededError)) } @@ -840,11 +637,12 @@ func TestScope_Quota_Good_MaxGroups(t *testing.T) { scopedStore, _ := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxGroups: 3}) - require.NoError(t, scopedStore.SetIn("g1", "k", "v")) - require.NoError(t, scopedStore.SetIn("g2", "k", "v")) - require.NoError(t, scopedStore.SetIn("g3", "k", "v")) + require.NoError(t, scopedStore.Set("g1", "k", "v")) + require.NoError(t, scopedStore.Set("g2", "k", "v")) + require.NoError(t, scopedStore.Set("g3", "k", "v")) - err := scopedStore.SetIn("g4", "k", "v") + // 4th group should fail. + err := scopedStore.Set("g4", "k", "v") require.Error(t, err) assert.True(t, core.Is(err, QuotaExceededError)) } @@ -855,10 +653,12 @@ func TestScope_Quota_Good_MaxGroups_ExistingGroupOK(t *testing.T) { scopedStore, _ := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxGroups: 2}) - require.NoError(t, scopedStore.SetIn("g1", "a", "1")) - require.NoError(t, scopedStore.SetIn("g2", "b", "2")) - require.NoError(t, scopedStore.SetIn("g1", "c", "3")) - require.NoError(t, scopedStore.SetIn("g2", "d", "4")) + require.NoError(t, scopedStore.Set("g1", "a", "1")) + require.NoError(t, scopedStore.Set("g2", "b", "2")) + + // Adding more keys to existing groups should be fine. + require.NoError(t, scopedStore.Set("g1", "c", "3")) + require.NoError(t, scopedStore.Set("g2", "d", "4")) } func TestScope_Quota_Good_MaxGroups_DeleteAndRecreate(t *testing.T) { @@ -867,10 +667,12 @@ func TestScope_Quota_Good_MaxGroups_DeleteAndRecreate(t *testing.T) { scopedStore, _ := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxGroups: 2}) - require.NoError(t, scopedStore.SetIn("g1", "k", "v")) - require.NoError(t, scopedStore.SetIn("g2", "k", "v")) + require.NoError(t, scopedStore.Set("g1", "k", "v")) + require.NoError(t, scopedStore.Set("g2", "k", "v")) + + // Delete a group, then create a new one. require.NoError(t, scopedStore.DeleteGroup("g1")) - require.NoError(t, scopedStore.SetIn("g3", "k", "v")) + require.NoError(t, scopedStore.Set("g3", "k", "v")) } func TestScope_Quota_Good_MaxGroups_ZeroUnlimited(t *testing.T) { @@ -880,7 +682,7 @@ func TestScope_Quota_Good_MaxGroups_ZeroUnlimited(t *testing.T) { scopedStore, _ := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxGroups: 0}) for i := range 50 { - require.NoError(t, scopedStore.SetIn(keyName(i), "k", "v")) + require.NoError(t, scopedStore.Set(keyName(i), "k", "v")) } } @@ -890,12 +692,14 @@ func TestScope_Quota_Good_MaxGroups_ExpiredGroupExcluded(t *testing.T) { scopedStore, _ := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxGroups: 2}) + // Create 2 groups, one with only TTL keys. require.NoError(t, scopedStore.SetWithTTL("g1", "k", "v", 1*time.Millisecond)) - require.NoError(t, scopedStore.SetIn("g2", "k", "v")) + require.NoError(t, scopedStore.Set("g2", "k", "v")) time.Sleep(5 * time.Millisecond) - require.NoError(t, scopedStore.SetIn("g3", "k", "v")) + // g1's only key has expired, so group count should be 1 — we can create a new one. + require.NoError(t, scopedStore.Set("g3", "k", "v")) } func TestScope_Quota_Good_BothLimits(t *testing.T) { @@ -904,13 +708,15 @@ func TestScope_Quota_Good_BothLimits(t *testing.T) { scopedStore, _ := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxKeys: 10, MaxGroups: 2}) - require.NoError(t, scopedStore.SetIn("g1", "a", "1")) - require.NoError(t, scopedStore.SetIn("g2", "b", "2")) + require.NoError(t, scopedStore.Set("g1", "a", "1")) + require.NoError(t, scopedStore.Set("g2", "b", "2")) - err := scopedStore.SetIn("g3", "c", "3") + // Group limit hit. + err := scopedStore.Set("g3", "c", "3") assert.True(t, core.Is(err, QuotaExceededError)) - require.NoError(t, scopedStore.SetIn("g1", "d", "4")) + // But adding to existing groups is fine (within key limit). + require.NoError(t, scopedStore.Set("g1", "d", "4")) } func TestScope_Quota_Good_DoesNotAffectOtherNamespaces(t *testing.T) { @@ -920,15 +726,17 @@ func TestScope_Quota_Good_DoesNotAffectOtherNamespaces(t *testing.T) { alphaStore, _ := NewScopedWithQuota(storeInstance, "tenant-a", QuotaConfig{MaxKeys: 2}) betaStore, _ := NewScopedWithQuota(storeInstance, "tenant-b", QuotaConfig{MaxKeys: 2}) - require.NoError(t, alphaStore.SetIn("g", "a1", "v")) - require.NoError(t, alphaStore.SetIn("g", "a2", "v")) - require.NoError(t, betaStore.SetIn("g", "b1", "v")) - require.NoError(t, betaStore.SetIn("g", "b2", "v")) + require.NoError(t, alphaStore.Set("g", "a1", "v")) + require.NoError(t, alphaStore.Set("g", "a2", "v")) + require.NoError(t, betaStore.Set("g", "b1", "v")) + require.NoError(t, betaStore.Set("g", "b2", "v")) - err := alphaStore.SetIn("g", "a3", "v") + // alphaStore is at limit — but betaStore's keys don't count against alphaStore. + err := alphaStore.Set("g", "a3", "v") assert.True(t, core.Is(err, QuotaExceededError)) - err = betaStore.SetIn("g", "b3", "v") + // betaStore is also at limit independently. + err = betaStore.Set("g", "b3", "v") assert.True(t, core.Is(err, QuotaExceededError)) } @@ -958,15 +766,21 @@ func TestScope_CountAll_Good_WithPrefix_Wildcards(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() + // Add keys in groups that look like wildcards. require.NoError(t, storeInstance.Set("user_1", "k", "v")) require.NoError(t, storeInstance.Set("user_2", "k", "v")) require.NoError(t, storeInstance.Set("user%test", "k", "v")) require.NoError(t, storeInstance.Set("user_test", "k", "v")) + // Prefix "user_" should ONLY match groups starting with "user_". + // Since we escape "_", it matches literal "_". + // Groups: "user_1", "user_2", "user_test" (3 total). + // "user%test" is NOT matched because "_" is literal. count, err := storeInstance.CountAll("user_") require.NoError(t, err) assert.Equal(t, 3, count) + // Prefix "user%" should ONLY match "user%test". count, err = storeInstance.CountAll("user%") require.NoError(t, err) assert.Equal(t, 1, count) @@ -984,6 +798,36 @@ func TestScope_CountAll_Good_EmptyPrefix(t *testing.T) { assert.Equal(t, 2, count) } +func TestScope_CountAll_Good_ExcludesExpired(t *testing.T) { + storeInstance, _ := New(":memory:") + defer storeInstance.Close() + + require.NoError(t, storeInstance.Set("ns:g", "permanent", "v")) + require.NoError(t, storeInstance.SetWithTTL("ns:g", "temp", "v", 1*time.Millisecond)) + time.Sleep(5 * time.Millisecond) + + count, err := storeInstance.CountAll("ns:") + require.NoError(t, err) + assert.Equal(t, 1, count, "expired keys should not be counted") +} + +func TestScope_CountAll_Good_Empty(t *testing.T) { + storeInstance, _ := New(":memory:") + defer storeInstance.Close() + + count, err := storeInstance.CountAll("nonexistent:") + require.NoError(t, err) + assert.Equal(t, 0, count) +} + +func TestScope_CountAll_Bad_ClosedStore(t *testing.T) { + storeInstance, _ := New(":memory:") + storeInstance.Close() + + _, err := storeInstance.CountAll("") + require.Error(t, err) +} + // --------------------------------------------------------------------------- // Groups // --------------------------------------------------------------------------- @@ -992,57 +836,106 @@ func TestScope_Groups_Good_WithPrefix(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - require.NoError(t, storeInstance.Set("ns-a:group-1", "k", "v")) - require.NoError(t, storeInstance.Set("ns-a:group-2", "k", "v")) - require.NoError(t, storeInstance.Set("ns-b:group-1", "k", "v")) + require.NoError(t, storeInstance.Set("ns-a:g1", "k", "v")) + require.NoError(t, storeInstance.Set("ns-a:g2", "k", "v")) + require.NoError(t, storeInstance.Set("ns-a:g2", "k2", "v")) // duplicate group + require.NoError(t, storeInstance.Set("ns-b:g1", "k", "v")) groups, err := storeInstance.Groups("ns-a:") require.NoError(t, err) - assert.Equal(t, []string{"ns-a:group-1", "ns-a:group-2"}, groups) + assert.Len(t, groups, 2) + assert.Contains(t, groups, "ns-a:g1") + assert.Contains(t, groups, "ns-a:g2") } -func TestScope_GroupsSeq_Good_EmptyPrefix(t *testing.T) { +func TestScope_Groups_Good_EmptyPrefix(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - require.NoError(t, storeInstance.Set("g1", "k1", "v")) - require.NoError(t, storeInstance.Set("g2", "k2", "v")) + require.NoError(t, storeInstance.Set("g1", "k", "v")) + require.NoError(t, storeInstance.Set("g2", "k", "v")) + require.NoError(t, storeInstance.Set("g3", "k", "v")) - var groups []string - for groupName, err := range storeInstance.GroupsSeq("") { - require.NoError(t, err) - groups = append(groups, groupName) - } - assert.Equal(t, []string{"g1", "g2"}, groups) + groups, err := storeInstance.Groups("") + require.NoError(t, err) + assert.Len(t, groups, 3) } -func TestScope_GroupsSeq_Good_StopsEarly(t *testing.T) { +func TestScope_Groups_Good_Distinct(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() - require.NoError(t, storeInstance.Set("g1", "k1", "v")) - require.NoError(t, storeInstance.Set("g2", "k2", "v")) + // Multiple keys in the same group should produce one entry. + require.NoError(t, storeInstance.Set("g1", "a", "v")) + require.NoError(t, storeInstance.Set("g1", "b", "v")) + require.NoError(t, storeInstance.Set("g1", "c", "v")) - count := 0 - for range storeInstance.GroupsSeq("") { - count++ - break - } - assert.Equal(t, 1, count) + groups, err := storeInstance.Groups("") + require.NoError(t, err) + assert.Len(t, groups, 1) + assert.Equal(t, "g1", groups[0]) } -func keyName(index int) string { - return core.Sprintf("key-%02d", index) +func TestScope_Groups_Good_ExcludesExpired(t *testing.T) { + storeInstance, _ := New(":memory:") + defer storeInstance.Close() + + require.NoError(t, storeInstance.Set("ns:g1", "permanent", "v")) + require.NoError(t, storeInstance.SetWithTTL("ns:g2", "temp", "v", 1*time.Millisecond)) + time.Sleep(5 * time.Millisecond) + + groups, err := storeInstance.Groups("ns:") + require.NoError(t, err) + assert.Len(t, groups, 1, "group with only expired keys should be excluded") + assert.Equal(t, "ns:g1", groups[0]) } -func rawEntryCount(tb testing.TB, storeInstance *Store, group string) int { - tb.Helper() +func TestScope_Groups_Good_SortedByGroupName(t *testing.T) { + storeInstance, _ := New(":memory:") + defer storeInstance.Close() + + require.NoError(t, storeInstance.Set("charlie", "c", "3")) + require.NoError(t, storeInstance.Set("alpha", "a", "1")) + require.NoError(t, storeInstance.Set("bravo", "b", "2")) + + groups, err := storeInstance.Groups("") + require.NoError(t, err) + assert.Equal(t, []string{"alpha", "bravo", "charlie"}, groups) +} + +func TestScope_Groups_Good_Empty(t *testing.T) { + storeInstance, _ := New(":memory:") + defer storeInstance.Close() + + groups, err := storeInstance.Groups("nonexistent:") + require.NoError(t, err) + assert.Empty(t, groups) +} + +func TestScope_Groups_Bad_ClosedStore(t *testing.T) { + storeInstance, _ := New(":memory:") + storeInstance.Close() + + _, err := storeInstance.Groups("") + require.Error(t, err) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func keyName(i int) string { + return "key-" + string(rune('a'+i%26)) +} + +func rawEntryCount(t *testing.T, storeInstance *Store, group string) int { + t.Helper() var count int err := storeInstance.sqliteDatabase.QueryRow( "SELECT COUNT(*) FROM "+entriesTableName+" WHERE "+entryGroupColumn+" = ?", group, ).Scan(&count) - require.NoError(tb, err) + require.NoError(t, err) return count }