ScopedStore wraps Store and auto-prefixes groups with a namespace to prevent key collisions across tenants. QuotaConfig enforces per-namespace MaxKeys and MaxGroups limits (zero = unlimited). Upserts and expired keys are excluded from quota counts. New Store methods: CountAll(prefix) and Groups(prefix) for cross-group queries. All 93 tests pass with race detector, coverage 94.7%. Co-Authored-By: Virgil <virgil@lethean.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
157 lines
5 KiB
Go
157 lines
5 KiB
Go
package store
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"time"
|
|
)
|
|
|
|
// validNamespace matches alphanumeric characters and hyphens (non-empty).
|
|
var validNamespace = regexp.MustCompile(`^[a-zA-Z0-9-]+$`)
|
|
|
|
// QuotaConfig defines optional limits for a ScopedStore namespace.
|
|
// Zero values mean unlimited.
|
|
type QuotaConfig struct {
|
|
MaxKeys int // maximum total keys across all groups in the namespace
|
|
MaxGroups int // maximum distinct groups in the namespace
|
|
}
|
|
|
|
// ScopedStore wraps a *Store and auto-prefixes all group names with a
|
|
// namespace to prevent key collisions across tenants.
|
|
type ScopedStore struct {
|
|
store *Store
|
|
namespace string
|
|
quota QuotaConfig
|
|
}
|
|
|
|
// NewScoped creates a ScopedStore that prefixes all groups with the given
|
|
// namespace. The namespace must be non-empty and contain only alphanumeric
|
|
// characters and hyphens.
|
|
func NewScoped(store *Store, namespace string) (*ScopedStore, error) {
|
|
if !validNamespace.MatchString(namespace) {
|
|
return nil, fmt.Errorf("store.NewScoped: namespace %q is invalid (must be non-empty, alphanumeric + hyphens)", namespace)
|
|
}
|
|
return &ScopedStore{store: store, namespace: namespace}, nil
|
|
}
|
|
|
|
// NewScopedWithQuota creates a ScopedStore with quota enforcement. Quotas are
|
|
// checked on Set and SetWithTTL before inserting new keys or creating new
|
|
// groups.
|
|
func NewScopedWithQuota(store *Store, namespace string, quota QuotaConfig) (*ScopedStore, error) {
|
|
s, err := NewScoped(store, namespace)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.quota = quota
|
|
return s, nil
|
|
}
|
|
|
|
// prefix returns the namespaced group name.
|
|
func (s *ScopedStore) prefix(group string) string {
|
|
return s.namespace + ":" + group
|
|
}
|
|
|
|
// Namespace returns the namespace string for this scoped store.
|
|
func (s *ScopedStore) Namespace() string {
|
|
return s.namespace
|
|
}
|
|
|
|
// Get retrieves a value by group and key within the namespace.
|
|
func (s *ScopedStore) Get(group, key string) (string, error) {
|
|
return s.store.Get(s.prefix(group), key)
|
|
}
|
|
|
|
// Set stores a value by group and key within the namespace. If quotas are
|
|
// configured, they are checked before inserting new keys or groups.
|
|
func (s *ScopedStore) Set(group, key, value string) error {
|
|
if err := s.checkQuota(group, key); err != nil {
|
|
return err
|
|
}
|
|
return s.store.Set(s.prefix(group), key, value)
|
|
}
|
|
|
|
// SetWithTTL stores a value with a time-to-live within the namespace. Quota
|
|
// checks are applied for new keys and groups.
|
|
func (s *ScopedStore) SetWithTTL(group, key, value string, ttl time.Duration) error {
|
|
if err := s.checkQuota(group, key); err != nil {
|
|
return err
|
|
}
|
|
return s.store.SetWithTTL(s.prefix(group), key, value, ttl)
|
|
}
|
|
|
|
// Delete removes a single key from a group within the namespace.
|
|
func (s *ScopedStore) Delete(group, key string) error {
|
|
return s.store.Delete(s.prefix(group), key)
|
|
}
|
|
|
|
// DeleteGroup removes all keys in a group within the namespace.
|
|
func (s *ScopedStore) DeleteGroup(group string) error {
|
|
return s.store.DeleteGroup(s.prefix(group))
|
|
}
|
|
|
|
// GetAll returns all non-expired key-value pairs in a group within the
|
|
// namespace.
|
|
func (s *ScopedStore) GetAll(group string) (map[string]string, error) {
|
|
return s.store.GetAll(s.prefix(group))
|
|
}
|
|
|
|
// Count returns the number of non-expired keys in a group within the namespace.
|
|
func (s *ScopedStore) Count(group string) (int, error) {
|
|
return s.store.Count(s.prefix(group))
|
|
}
|
|
|
|
// Render loads all non-expired key-value pairs from a namespaced group and
|
|
// renders a Go template.
|
|
func (s *ScopedStore) Render(tmplStr, group string) (string, error) {
|
|
return s.store.Render(tmplStr, s.prefix(group))
|
|
}
|
|
|
|
// checkQuota verifies that inserting key into group would not exceed the
|
|
// namespace's quota limits. It returns nil if no quota is set or the operation
|
|
// is within bounds. Existing keys (upserts) are not counted as new.
|
|
func (s *ScopedStore) checkQuota(group, key string) error {
|
|
if s.quota.MaxKeys == 0 && s.quota.MaxGroups == 0 {
|
|
return nil
|
|
}
|
|
|
|
prefixedGroup := s.prefix(group)
|
|
nsPrefix := s.namespace + ":"
|
|
|
|
// Check if this is an upsert (key already exists) — upserts never exceed quota.
|
|
_, err := s.store.Get(prefixedGroup, key)
|
|
if err == nil {
|
|
// Key exists — this is an upsert, no quota check needed.
|
|
return nil
|
|
}
|
|
|
|
// Check MaxKeys quota.
|
|
if s.quota.MaxKeys > 0 {
|
|
count, err := s.store.CountAll(nsPrefix)
|
|
if err != nil {
|
|
return fmt.Errorf("store.ScopedStore: quota check: %w", err)
|
|
}
|
|
if count >= s.quota.MaxKeys {
|
|
return fmt.Errorf("store.ScopedStore: key limit (%d): %w", s.quota.MaxKeys, ErrQuotaExceeded)
|
|
}
|
|
}
|
|
|
|
// Check MaxGroups quota — only if this would create a new group.
|
|
if s.quota.MaxGroups > 0 {
|
|
groupCount, err := s.store.Count(prefixedGroup)
|
|
if err != nil {
|
|
return fmt.Errorf("store.ScopedStore: quota check: %w", err)
|
|
}
|
|
if groupCount == 0 {
|
|
// This group is new — check if adding it would exceed the group limit.
|
|
groups, err := s.store.Groups(nsPrefix)
|
|
if err != nil {
|
|
return fmt.Errorf("store.ScopedStore: quota check: %w", err)
|
|
}
|
|
if len(groups) >= s.quota.MaxGroups {
|
|
return fmt.Errorf("store.ScopedStore: group limit (%d): %w", s.quota.MaxGroups, ErrQuotaExceeded)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|