diff --git a/CLAUDE.md b/CLAUDE.md index 49dcadd..d1633ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/TODO.md b/TODO.md index 960e96b..08e60d6 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/scope.go b/scope.go new file mode 100644 index 0000000..fb2e2fb --- /dev/null +++ b/scope.go @@ -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 +} diff --git a/scope_test.go b/scope_test.go new file mode 100644 index 0000000..6e59878 --- /dev/null +++ b/scope_test.go @@ -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)) +} diff --git a/store.go b/store.go index 7471b39..7dfceec 100644 --- a/store.go +++ b/store.go @@ -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) {