diff --git a/docs/architecture.md b/docs/architecture.md index 51c9d81..92c7d4c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -209,6 +209,8 @@ Namespace strings must match `^[a-zA-Z0-9-]+$`. Invalid namespaces are rejected `ScopedStore` exposes the same read helpers as `Store` for `Get`, `Set`, `SetWithTTL`, `Delete`, `DeleteGroup`, `DeletePrefix`, `GetAll`, `All`, `Count`, `CountAll`, `Groups`, `GroupsSeq`, `GetSplit`, `GetFields`, `Render`, and `PurgeExpired`. Methods that return group names strip the namespace prefix before returning results. The `Namespace()` method returns the namespace string. +`ScopedStore.Transaction` exposes the same transaction helpers through `ScopedStoreTransaction`, so callers can work inside a namespace without manually prefixing group names during a multi-step write. + ### Quota Enforcement `NewScopedWithQuota(store, namespace, QuotaConfig)` adds per-namespace limits. For example, `store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}` caps a namespace at 100 keys and 10 groups: diff --git a/scope.go b/scope.go index b029fe6..36e75b7 100644 --- a/scope.go +++ b/scope.go @@ -35,6 +35,12 @@ type ScopedStore struct { scopedWatchers map[uintptr]*scopedWatcherBinding } +// Usage example: `err := scopedStore.Transaction(func(transaction *store.ScopedStoreTransaction) error { return transaction.Set("colour", "blue") })` +type ScopedStoreTransaction struct { + scopedStore *ScopedStore + storeTransaction *StoreTransaction +} + // 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"}` @@ -427,6 +433,242 @@ func (scopedStore *ScopedStore) PurgeExpired() (int64, error) { 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 + } + 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 + } + 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) +} + func (scopedStore *ScopedStore) forgetScopedWatcher(events <-chan Event) { if scopedStore == nil || events == nil { return diff --git a/scope_test.go b/scope_test.go index 49ff84b..c0a6fc3 100644 --- a/scope_test.go +++ b/scope_test.go @@ -587,6 +587,56 @@ func TestScope_ScopedStore_Good_OnChange(t *testing.T) { 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) +} + // --------------------------------------------------------------------------- // Quota enforcement — MaxKeys // ---------------------------------------------------------------------------