feat(scope): add namespace isolation with quota enforcement

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>
This commit is contained in:
Snider 2026-02-20 08:19:11 +00:00
parent 22342f0395
commit 175fd6bf83
5 changed files with 824 additions and 11 deletions

View file

@ -2,7 +2,7 @@
## What This Is
SQLite key-value store wrapper with TTL support. Module: `forge.lthn.ai/core/go-store`
SQLite key-value store wrapper with TTL support and namespace isolation. Module: `forge.lthn.ai/core/go-store`
## Commands
@ -29,6 +29,18 @@ all, _ := st.GetAll("group") // excludes expired
n, _ := st.Count("group") // excludes expired
out, _ := st.Render(tmpl, "group") // excludes expired
removed, _ := st.PurgeExpired() // manual purge
total, _ := st.CountAll("prefix:") // count keys matching prefix (excludes expired)
groups, _ := st.Groups("prefix:") // distinct group names matching prefix
// Namespace isolation (auto-prefixes groups with "tenant:")
sc, _ := store.NewScoped(st, "tenant")
sc.Set("config", "key", "val") // stored as "tenant:config" in underlying store
sc.Get("config", "key") // reads from "tenant:config"
// With quota enforcement
quota := store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}
sq, _ := store.NewScopedWithQuota(st, "tenant", quota)
sq.Set("g", "k", "v") // returns ErrQuotaExceeded if limits hit
```
## Coding Standards

20
TODO.md
View file

@ -29,7 +29,7 @@ Scoped store wrapper that auto-prefixes groups with a namespace to prevent key c
### 2.1 ScopedStore Wrapper
- [ ] **Create `scope.go`** — A lightweight wrapper around `*Store` that auto-prefixes all group names:
- [x] **Create `scope.go`** — A lightweight wrapper around `*Store` that auto-prefixes all group names:
- `type ScopedStore struct { store *Store; namespace string }` — holds reference to underlying store and namespace prefix
- `func NewScoped(store *Store, namespace string) *ScopedStore` — constructor. Validates namespace is non-empty, alphanumeric + hyphens only.
- `func (s *ScopedStore) prefix(group string) string` — returns `namespace + ":" + group`
@ -42,21 +42,21 @@ Scoped store wrapper that auto-prefixes groups with a namespace to prevent key c
### 2.2 Quota Enforcement
- [ ] **Add `QuotaConfig` to ScopedStore** — Optional quota limits per namespace:
- [x] **Add `QuotaConfig` to ScopedStore** — Optional quota limits per namespace:
- `type QuotaConfig struct { MaxKeys int; MaxGroups int }` — zero means unlimited
- `func NewScopedWithQuota(store *Store, namespace string, quota QuotaConfig) *ScopedStore`
- `var ErrQuotaExceeded = errors.New("store: quota exceeded")`
- [ ] **Enforce on Set()** — Before inserting, check `Count()` across all groups with the namespace prefix. If `MaxKeys > 0` and current count >= MaxKeys, return `ErrQuotaExceeded`. Only check on new keys (UPSERT existing keys doesn't increase count).
- [ ] **Enforce on group creation** — Track distinct groups with the namespace prefix. If `MaxGroups > 0` and adding a new group would exceed the limit, return `ErrQuotaExceeded`.
- [ ] **Add `CountAll() (int, error)` to Store** — Returns total key count across ALL groups matching a prefix. SQL: `SELECT COUNT(*) FROM kv WHERE grp LIKE ? AND (expires_at IS NULL OR expires_at > ?)` with `namespace + ":%"`.
- [ ] **Add `Groups() ([]string, error)` to Store** — Returns distinct group names. SQL: `SELECT DISTINCT grp FROM kv WHERE (expires_at IS NULL OR expires_at > ?)`. Useful for quota checks and admin tooling.
- [x] **Enforce on Set()** — Before inserting, check `Count()` across all groups with the namespace prefix. If `MaxKeys > 0` and current count >= MaxKeys, return `ErrQuotaExceeded`. Only check on new keys (UPSERT existing keys doesn't increase count).
- [x] **Enforce on group creation** — Track distinct groups with the namespace prefix. If `MaxGroups > 0` and adding a new group would exceed the limit, return `ErrQuotaExceeded`.
- [x] **Add `CountAll() (int, error)` to Store** — Returns total key count across ALL groups matching a prefix. SQL: `SELECT COUNT(*) FROM kv WHERE grp LIKE ? AND (expires_at IS NULL OR expires_at > ?)` with `namespace + ":%"`.
- [x] **Add `Groups() ([]string, error)` to Store** — Returns distinct group names. SQL: `SELECT DISTINCT grp FROM kv WHERE (expires_at IS NULL OR expires_at > ?)`. Useful for quota checks and admin tooling.
### 2.3 Tests
- [ ] **ScopedStore basic tests** — Set/Get/Delete through ScopedStore, verify underlying store has prefixed groups, two namespaces don't collide, GetAll returns only scoped group's keys
- [ ] **Quota tests** — (a) MaxKeys=5, insert 5 keys → OK, insert 6th → ErrQuotaExceeded, (b) UPSERT existing key doesn't count towards quota, (c) Delete + re-insert stays within quota, (d) MaxGroups=3, create 3 groups → OK, 4th → ErrQuotaExceeded, (e) zero quota = unlimited, (f) TTL-expired keys don't count towards quota
- [ ] **CountAll/Groups tests** — (a) CountAll with mixed namespaces, (b) Groups returns distinct list, (c) expired keys excluded from both
- [ ] **Existing tests still pass** — No changes to Store API, backward compatible
- [x] **ScopedStore basic tests** — Set/Get/Delete through ScopedStore, verify underlying store has prefixed groups, two namespaces don't collide, GetAll returns only scoped group's keys
- [x] **Quota tests** — (a) MaxKeys=5, insert 5 keys → OK, insert 6th → ErrQuotaExceeded, (b) UPSERT existing key doesn't count towards quota, (c) Delete + re-insert stays within quota, (d) MaxGroups=3, create 3 groups → OK, 4th → ErrQuotaExceeded, (e) zero quota = unlimited, (f) TTL-expired keys don't count towards quota
- [x] **CountAll/Groups tests** — (a) CountAll with mixed namespaces, (b) Groups returns distinct list, (c) expired keys excluded from both
- [x] **Existing tests still pass** — No changes to Store API, backward compatible. Coverage: 90.9% → 94.7%.
## Phase 3: Event Hooks

157
scope.go Normal file
View file

@ -0,0 +1,157 @@
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
}

584
scope_test.go Normal file
View file

@ -0,0 +1,584 @@
package store
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// NewScoped — constructor validation
// ---------------------------------------------------------------------------
func TestNewScoped_Good(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, err := NewScoped(s, "tenant-1")
require.NoError(t, err)
require.NotNil(t, sc)
assert.Equal(t, "tenant-1", sc.Namespace())
}
func TestNewScoped_Good_AlphanumericHyphens(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
valid := []string{"abc", "ABC", "123", "a-b-c", "tenant-42", "A1-B2"}
for _, ns := range valid {
sc, err := NewScoped(s, ns)
require.NoError(t, err, "namespace %q should be valid", ns)
require.NotNil(t, sc)
}
}
func TestNewScoped_Bad_Empty(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
_, err := NewScoped(s, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid")
}
func TestNewScoped_Bad_InvalidChars(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
invalid := []string{"foo.bar", "foo:bar", "foo bar", "foo/bar", "foo_bar", "tenant!", "@ns"}
for _, ns := range invalid {
_, err := NewScoped(s, ns)
require.Error(t, err, "namespace %q should be invalid", ns)
}
}
// ---------------------------------------------------------------------------
// ScopedStore — basic CRUD
// ---------------------------------------------------------------------------
func TestScopedStore_Good_SetGet(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScoped(s, "tenant-a")
require.NoError(t, sc.Set("config", "theme", "dark"))
val, err := sc.Get("config", "theme")
require.NoError(t, err)
assert.Equal(t, "dark", val)
}
func TestScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScoped(s, "tenant-a")
require.NoError(t, sc.Set("config", "key", "val"))
// The underlying store should have the prefixed group name.
val, err := s.Get("tenant-a:config", "key")
require.NoError(t, err)
assert.Equal(t, "val", val)
// Direct access without prefix should fail.
_, err = s.Get("config", "key")
assert.True(t, errors.Is(err, ErrNotFound))
}
func TestScopedStore_Good_NamespaceIsolation(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
a, _ := NewScoped(s, "tenant-a")
b, _ := NewScoped(s, "tenant-b")
require.NoError(t, a.Set("config", "colour", "blue"))
require.NoError(t, b.Set("config", "colour", "red"))
va, err := a.Get("config", "colour")
require.NoError(t, err)
assert.Equal(t, "blue", va)
vb, err := b.Get("config", "colour")
require.NoError(t, err)
assert.Equal(t, "red", vb)
}
func TestScopedStore_Good_Delete(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScoped(s, "tenant-a")
require.NoError(t, sc.Set("g", "k", "v"))
require.NoError(t, sc.Delete("g", "k"))
_, err := sc.Get("g", "k")
assert.True(t, errors.Is(err, ErrNotFound))
}
func TestScopedStore_Good_DeleteGroup(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScoped(s, "tenant-a")
require.NoError(t, sc.Set("g", "a", "1"))
require.NoError(t, sc.Set("g", "b", "2"))
require.NoError(t, sc.DeleteGroup("g"))
n, err := sc.Count("g")
require.NoError(t, err)
assert.Equal(t, 0, n)
}
func TestScopedStore_Good_GetAll(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
a, _ := NewScoped(s, "tenant-a")
b, _ := NewScoped(s, "tenant-b")
require.NoError(t, a.Set("items", "x", "1"))
require.NoError(t, a.Set("items", "y", "2"))
require.NoError(t, b.Set("items", "z", "3"))
all, err := a.GetAll("items")
require.NoError(t, err)
assert.Equal(t, map[string]string{"x": "1", "y": "2"}, all)
allB, err := b.GetAll("items")
require.NoError(t, err)
assert.Equal(t, map[string]string{"z": "3"}, allB)
}
func TestScopedStore_Good_Count(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScoped(s, "tenant-a")
require.NoError(t, sc.Set("g", "a", "1"))
require.NoError(t, sc.Set("g", "b", "2"))
n, err := sc.Count("g")
require.NoError(t, err)
assert.Equal(t, 2, n)
}
func TestScopedStore_Good_SetWithTTL(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScoped(s, "tenant-a")
require.NoError(t, sc.SetWithTTL("g", "k", "v", time.Hour))
val, err := sc.Get("g", "k")
require.NoError(t, err)
assert.Equal(t, "v", val)
}
func TestScopedStore_Good_SetWithTTL_Expires(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScoped(s, "tenant-a")
require.NoError(t, sc.SetWithTTL("g", "k", "v", 1*time.Millisecond))
time.Sleep(5 * time.Millisecond)
_, err := sc.Get("g", "k")
assert.True(t, errors.Is(err, ErrNotFound))
}
func TestScopedStore_Good_Render(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScoped(s, "tenant-a")
require.NoError(t, sc.Set("user", "name", "Alice"))
out, err := sc.Render("Hello {{ .name }}", "user")
require.NoError(t, err)
assert.Equal(t, "Hello Alice", out)
}
// ---------------------------------------------------------------------------
// Quota enforcement — MaxKeys
// ---------------------------------------------------------------------------
func TestQuota_Good_MaxKeys(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, err := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 5})
require.NoError(t, err)
// Insert 5 keys across different groups — should be fine.
for i := 0; i < 5; i++ {
require.NoError(t, sc.Set("g", keyName(i), "v"))
}
// 6th key should fail.
err = sc.Set("g", "overflow", "v")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrQuotaExceeded), "expected ErrQuotaExceeded, got: %v", err)
}
func TestQuota_Good_MaxKeys_AcrossGroups(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 3})
require.NoError(t, sc.Set("g1", "a", "1"))
require.NoError(t, sc.Set("g2", "b", "2"))
require.NoError(t, sc.Set("g3", "c", "3"))
// Total is now 3 — any new key should fail regardless of group.
err := sc.Set("g4", "d", "4")
assert.True(t, errors.Is(err, ErrQuotaExceeded))
}
func TestQuota_Good_UpsertDoesNotCount(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 3})
require.NoError(t, sc.Set("g", "a", "1"))
require.NoError(t, sc.Set("g", "b", "2"))
require.NoError(t, sc.Set("g", "c", "3"))
// Upserting existing key should succeed.
require.NoError(t, sc.Set("g", "a", "updated"))
val, err := sc.Get("g", "a")
require.NoError(t, err)
assert.Equal(t, "updated", val)
}
func TestQuota_Good_DeleteAndReInsert(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 3})
require.NoError(t, sc.Set("g", "a", "1"))
require.NoError(t, sc.Set("g", "b", "2"))
require.NoError(t, sc.Set("g", "c", "3"))
// Delete one key, then insert a new one — should work.
require.NoError(t, sc.Delete("g", "c"))
require.NoError(t, sc.Set("g", "d", "4"))
}
func TestQuota_Good_ZeroMeansUnlimited(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 0, MaxGroups: 0})
// Should be able to insert many keys and groups without error.
for i := 0; i < 100; i++ {
require.NoError(t, sc.Set("g", keyName(i), "v"))
}
}
func TestQuota_Good_ExpiredKeysExcluded(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 3})
// Insert 3 keys, 2 with short TTL.
require.NoError(t, sc.SetWithTTL("g", "temp1", "v", 1*time.Millisecond))
require.NoError(t, sc.SetWithTTL("g", "temp2", "v", 1*time.Millisecond))
require.NoError(t, sc.Set("g", "permanent", "v"))
time.Sleep(5 * time.Millisecond)
// After expiry, only 1 key counts — should be able to insert 2 more.
require.NoError(t, sc.Set("g", "new1", "v"))
require.NoError(t, sc.Set("g", "new2", "v"))
// Now at 3 — next should fail.
err := sc.Set("g", "new3", "v")
assert.True(t, errors.Is(err, ErrQuotaExceeded))
}
func TestQuota_Good_SetWithTTL_Enforced(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 2})
require.NoError(t, sc.SetWithTTL("g", "a", "1", time.Hour))
require.NoError(t, sc.SetWithTTL("g", "b", "2", time.Hour))
err := sc.SetWithTTL("g", "c", "3", time.Hour)
assert.True(t, errors.Is(err, ErrQuotaExceeded))
}
// ---------------------------------------------------------------------------
// Quota enforcement — MaxGroups
// ---------------------------------------------------------------------------
func TestQuota_Good_MaxGroups(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxGroups: 3})
require.NoError(t, sc.Set("g1", "k", "v"))
require.NoError(t, sc.Set("g2", "k", "v"))
require.NoError(t, sc.Set("g3", "k", "v"))
// 4th group should fail.
err := sc.Set("g4", "k", "v")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrQuotaExceeded))
}
func TestQuota_Good_MaxGroups_ExistingGroupOK(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxGroups: 2})
require.NoError(t, sc.Set("g1", "a", "1"))
require.NoError(t, sc.Set("g2", "b", "2"))
// Adding more keys to existing groups should be fine.
require.NoError(t, sc.Set("g1", "c", "3"))
require.NoError(t, sc.Set("g2", "d", "4"))
}
func TestQuota_Good_MaxGroups_DeleteAndRecreate(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxGroups: 2})
require.NoError(t, sc.Set("g1", "k", "v"))
require.NoError(t, sc.Set("g2", "k", "v"))
// Delete a group, then create a new one.
require.NoError(t, sc.DeleteGroup("g1"))
require.NoError(t, sc.Set("g3", "k", "v"))
}
func TestQuota_Good_MaxGroups_ZeroUnlimited(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxGroups: 0})
for i := 0; i < 50; i++ {
require.NoError(t, sc.Set(keyName(i), "k", "v"))
}
}
func TestQuota_Good_MaxGroups_ExpiredGroupExcluded(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxGroups: 2})
// Create 2 groups, one with only TTL keys.
require.NoError(t, sc.SetWithTTL("g1", "k", "v", 1*time.Millisecond))
require.NoError(t, sc.Set("g2", "k", "v"))
time.Sleep(5 * time.Millisecond)
// g1's only key has expired, so group count should be 1 — we can create a new one.
require.NoError(t, sc.Set("g3", "k", "v"))
}
func TestQuota_Good_BothLimits(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 10, MaxGroups: 2})
require.NoError(t, sc.Set("g1", "a", "1"))
require.NoError(t, sc.Set("g2", "b", "2"))
// Group limit hit.
err := sc.Set("g3", "c", "3")
assert.True(t, errors.Is(err, ErrQuotaExceeded))
// But adding to existing groups is fine (within key limit).
require.NoError(t, sc.Set("g1", "d", "4"))
}
func TestQuota_Good_DoesNotAffectOtherNamespaces(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
a, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 2})
b, _ := NewScopedWithQuota(s, "tenant-b", QuotaConfig{MaxKeys: 2})
require.NoError(t, a.Set("g", "a1", "v"))
require.NoError(t, a.Set("g", "a2", "v"))
require.NoError(t, b.Set("g", "b1", "v"))
require.NoError(t, b.Set("g", "b2", "v"))
// a is at limit — but b's keys don't count against a.
err := a.Set("g", "a3", "v")
assert.True(t, errors.Is(err, ErrQuotaExceeded))
// b is also at limit independently.
err = b.Set("g", "b3", "v")
assert.True(t, errors.Is(err, ErrQuotaExceeded))
}
// ---------------------------------------------------------------------------
// CountAll
// ---------------------------------------------------------------------------
func TestCountAll_Good_WithPrefix(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
require.NoError(t, s.Set("ns-a:g1", "k1", "v"))
require.NoError(t, s.Set("ns-a:g1", "k2", "v"))
require.NoError(t, s.Set("ns-a:g2", "k1", "v"))
require.NoError(t, s.Set("ns-b:g1", "k1", "v"))
n, err := s.CountAll("ns-a:")
require.NoError(t, err)
assert.Equal(t, 3, n)
n, err = s.CountAll("ns-b:")
require.NoError(t, err)
assert.Equal(t, 1, n)
}
func TestCountAll_Good_EmptyPrefix(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
require.NoError(t, s.Set("g1", "k1", "v"))
require.NoError(t, s.Set("g2", "k2", "v"))
n, err := s.CountAll("")
require.NoError(t, err)
assert.Equal(t, 2, n)
}
func TestCountAll_Good_ExcludesExpired(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
require.NoError(t, s.Set("ns:g", "permanent", "v"))
require.NoError(t, s.SetWithTTL("ns:g", "temp", "v", 1*time.Millisecond))
time.Sleep(5 * time.Millisecond)
n, err := s.CountAll("ns:")
require.NoError(t, err)
assert.Equal(t, 1, n, "expired keys should not be counted")
}
func TestCountAll_Good_Empty(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
n, err := s.CountAll("nonexistent:")
require.NoError(t, err)
assert.Equal(t, 0, n)
}
func TestCountAll_Bad_ClosedStore(t *testing.T) {
s, _ := New(":memory:")
s.Close()
_, err := s.CountAll("")
require.Error(t, err)
}
// ---------------------------------------------------------------------------
// Groups
// ---------------------------------------------------------------------------
func TestGroups_Good_WithPrefix(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
require.NoError(t, s.Set("ns-a:g1", "k", "v"))
require.NoError(t, s.Set("ns-a:g2", "k", "v"))
require.NoError(t, s.Set("ns-a:g2", "k2", "v")) // duplicate group
require.NoError(t, s.Set("ns-b:g1", "k", "v"))
groups, err := s.Groups("ns-a:")
require.NoError(t, err)
assert.Len(t, groups, 2)
assert.Contains(t, groups, "ns-a:g1")
assert.Contains(t, groups, "ns-a:g2")
}
func TestGroups_Good_EmptyPrefix(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
require.NoError(t, s.Set("g1", "k", "v"))
require.NoError(t, s.Set("g2", "k", "v"))
require.NoError(t, s.Set("g3", "k", "v"))
groups, err := s.Groups("")
require.NoError(t, err)
assert.Len(t, groups, 3)
}
func TestGroups_Good_Distinct(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
// Multiple keys in the same group should produce one entry.
require.NoError(t, s.Set("g1", "a", "v"))
require.NoError(t, s.Set("g1", "b", "v"))
require.NoError(t, s.Set("g1", "c", "v"))
groups, err := s.Groups("")
require.NoError(t, err)
assert.Len(t, groups, 1)
assert.Equal(t, "g1", groups[0])
}
func TestGroups_Good_ExcludesExpired(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
require.NoError(t, s.Set("ns:g1", "permanent", "v"))
require.NoError(t, s.SetWithTTL("ns:g2", "temp", "v", 1*time.Millisecond))
time.Sleep(5 * time.Millisecond)
groups, err := s.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 TestGroups_Good_Empty(t *testing.T) {
s, _ := New(":memory:")
defer s.Close()
groups, err := s.Groups("nonexistent:")
require.NoError(t, err)
assert.Empty(t, groups)
}
func TestGroups_Bad_ClosedStore(t *testing.T) {
s, _ := New(":memory:")
s.Close()
_, err := s.Groups("")
require.Error(t, err)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func keyName(i int) string {
return "key-" + string(rune('a'+i%26))
}

View file

@ -16,6 +16,9 @@ import (
// ErrNotFound is returned when a key does not exist in the store.
var ErrNotFound = errors.New("store: not found")
// ErrQuotaExceeded is returned when a namespace quota limit is reached.
var ErrQuotaExceeded = errors.New("store: quota exceeded")
// Store is a group-namespaced key-value store backed by SQLite.
type Store struct {
db *sql.DB
@ -215,6 +218,63 @@ func (s *Store) Render(tmplStr, group string) (string, error) {
return b.String(), nil
}
// CountAll returns the total number of non-expired keys across all groups whose
// name starts with the given prefix. Pass an empty string to count everything.
func (s *Store) CountAll(prefix string) (int, error) {
var n int
var err error
if prefix == "" {
err = s.db.QueryRow(
"SELECT COUNT(*) FROM kv WHERE (expires_at IS NULL OR expires_at > ?)",
time.Now().UnixMilli(),
).Scan(&n)
} else {
err = s.db.QueryRow(
"SELECT COUNT(*) FROM kv WHERE grp LIKE ? AND (expires_at IS NULL OR expires_at > ?)",
prefix+"%", time.Now().UnixMilli(),
).Scan(&n)
}
if err != nil {
return 0, fmt.Errorf("store.CountAll: %w", err)
}
return n, nil
}
// Groups returns the distinct group names of all non-expired keys. If prefix is
// non-empty, only groups starting with that prefix are returned.
func (s *Store) Groups(prefix string) ([]string, error) {
var rows *sql.Rows
var err error
if prefix == "" {
rows, err = s.db.Query(
"SELECT DISTINCT grp FROM kv WHERE (expires_at IS NULL OR expires_at > ?)",
time.Now().UnixMilli(),
)
} else {
rows, err = s.db.Query(
"SELECT DISTINCT grp FROM kv WHERE grp LIKE ? AND (expires_at IS NULL OR expires_at > ?)",
prefix+"%", time.Now().UnixMilli(),
)
}
if err != nil {
return nil, fmt.Errorf("store.Groups: %w", err)
}
defer rows.Close()
var groups []string
for rows.Next() {
var g string
if err := rows.Scan(&g); err != nil {
return nil, fmt.Errorf("store.Groups: scan: %w", err)
}
groups = append(groups, g)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store.Groups: rows: %w", err)
}
return groups, nil
}
// PurgeExpired deletes all expired keys across all groups. Returns the number
// of rows removed.
func (s *Store) PurgeExpired() (int64, error) {