From 3a8cfcedf97a45649613b563eaeca300620fd438 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 07:14:22 +0000 Subject: [PATCH] feat(store): add prefix cleanup helpers Co-Authored-By: Virgil --- docs/architecture.md | 6 +++--- docs/history.md | 3 --- docs/index.md | 2 +- scope.go | 9 +++++++++ scope_test.go | 21 +++++++++++++++++++++ store.go | 42 ++++++++++++++++++++++++++++++++++++++++++ store_test.go | 20 ++++++++++++++++++++ 7 files changed, 96 insertions(+), 7 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 712e26e..f495f3c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,7 +54,7 @@ group: "user:42:config" key: "language" group: "session:abc" key: "token" ``` -All read operations (`Get`, `GetAll`, `Count`, `Render`) are scoped to a single group. `DeleteGroup` atomically removes all keys in a group. `CountAll` and `Groups` operate across groups by prefix match. +All read operations (`Get`, `GetAll`, `Count`, `Render`) are scoped to a single group. `DeleteGroup` atomically removes all keys in a group. `DeletePrefix` removes every group whose name starts with a supplied prefix. `CountAll` and `Groups` operate across groups by prefix match. ## UPSERT Semantics @@ -207,7 +207,7 @@ Namespace strings must match `^[a-zA-Z0-9-]+$`. Invalid namespaces are rejected `ScopedStore` delegates all operations to the underlying `Store` after prefixing. Events emitted by scoped operations carry the full prefixed group name in `Event.Group`, enabling watchers on the underlying store to observe scoped mutations. -`ScopedStore` exposes the same read helpers as `Store` for `Get`, `Set`, `SetWithTTL`, `Delete`, `DeleteGroup`, `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` 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. ### Quota Enforcement @@ -243,7 +243,7 @@ All operations are safe to call from multiple goroutines concurrently. The race ``` doc.go Package comment with concrete usage examples -store.go Core Store type, CRUD, TTL, background purge, iterators, rendering +store.go Core Store type, CRUD, prefix cleanup, TTL, background purge, iterators, rendering events.go EventType, Event, Watch, Unwatch, OnChange, notify scope.go ScopedStore, QuotaConfig, namespace-local helper delegation, quota enforcement journal.go Journal persistence, Flux-like querying, JSON row inflation diff --git a/docs/history.md b/docs/history.md index 7b7a11a..d390041 100644 --- a/docs/history.md +++ b/docs/history.md @@ -206,8 +206,6 @@ These tests exercise correct defensive code. They must continue to pass but are **No cross-group transactions.** There is no API for atomic multi-group operations. Each method is individually atomic at the SQLite level, but there is no `Begin`/`Commit` exposed to callers. -**No wildcard deletes.** There is no `DeletePrefix` or pattern-based delete. To delete all groups under a namespace, callers must retrieve the group list via `Groups()` and delete each individually. - **No persistence of watcher registrations.** Watchers and callbacks are in-memory only. They are not persisted across `Close`/`New` cycles. --- @@ -220,4 +218,3 @@ These are design notes, not committed work: - **Indexed prefix keys.** An additional index on `(group_name, entry_key)` prefix would accelerate prefix scans without a full-table scan. - **TTL background purge interval as constructor configuration.** Currently only settable by mutating `storeInstance.purgeInterval` directly in tests. A constructor-level `PurgeInterval` field or config entry would make this part of the public API. - **Cross-group atomic operations.** Exposing a `Transaction(func(tx *StoreTx) error)` API would allow callers to compose atomic multi-group operations. -- **`DeletePrefix(prefix string)` method.** Would enable efficient cleanup of an entire namespace without first listing groups. diff --git a/docs/index.md b/docs/index.md index 4ded9e5..c05b5c5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -114,7 +114,7 @@ The entire package lives in a single Go package (`package store`) with the follo | File | Purpose | |------|---------| | `doc.go` | Package comment with concrete usage examples | -| `store.go` | Core `Store` type, CRUD operations (`Get`, `Set`, `SetWithTTL`, `Delete`, `DeleteGroup`), bulk queries (`GetAll`, `All`, `Count`, `CountAll`, `Groups`, `GroupsSeq`), string splitting helpers (`GetSplit`, `GetFields`), template rendering (`Render`), TTL expiry, background purge goroutine | +| `store.go` | Core `Store` type, CRUD operations (`Get`, `Set`, `SetWithTTL`, `Delete`, `DeleteGroup`, `DeletePrefix`), bulk queries (`GetAll`, `All`, `Count`, `CountAll`, `Groups`, `GroupsSeq`), string splitting helpers (`GetSplit`, `GetFields`), template rendering (`Render`), TTL expiry, background purge goroutine | | `events.go` | `EventType` constants, `Event` struct, `Watch`/`Unwatch` channel subscriptions, `OnChange` callback registration, internal `notify` dispatch | | `scope.go` | `ScopedStore` wrapper for namespace isolation, `QuotaConfig` struct, `NewScoped`/`NewScopedWithQuota` constructors, namespace-local helper delegation, quota enforcement logic | | `journal.go` | Journal persistence, Flux-like querying, JSON row inflation, journal schema helpers | diff --git a/scope.go b/scope.go index 6f02298..f141795 100644 --- a/scope.go +++ b/scope.go @@ -172,6 +172,15 @@ func (scopedStore *ScopedStore) DeleteGroup(group string) error { return storeInstance.DeleteGroup(scopedStore.namespacedGroup(group)) } +// Usage example: `if err := scopedStore.DeletePrefix("config"); err != nil { return }` +func (scopedStore *ScopedStore) DeletePrefix(groupPrefix string) error { + storeInstance, err := scopedStore.storeInstance("store.DeletePrefix") + if err != nil { + return err + } + return storeInstance.DeletePrefix(scopedStore.namespacedGroup(groupPrefix)) +} + // Usage example: `colourEntries, err := scopedStore.GetAll("config")` func (scopedStore *ScopedStore) GetAll(group string) (map[string]string, error) { storeInstance, err := scopedStore.storeInstance("store.GetAll") diff --git a/scope_test.go b/scope_test.go index 0aa567f..df4a449 100644 --- a/scope_test.go +++ b/scope_test.go @@ -223,6 +223,27 @@ 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() diff --git a/store.go b/store.go index c18aab8..80b87de 100644 --- a/store.go +++ b/store.go @@ -345,6 +345,48 @@ func (storeInstance *Store) DeleteGroup(group string) error { return nil } +// Usage example: `if err := storeInstance.DeletePrefix("tenant-a:"); err != nil { return }` +func (storeInstance *Store) DeletePrefix(groupPrefix string) error { + if err := storeInstance.ensureReady("store.DeletePrefix"); err != nil { + return err + } + + var rows *sql.Rows + var err error + if groupPrefix == "" { + rows, err = storeInstance.database.Query( + "SELECT DISTINCT " + entryGroupColumn + " FROM " + entriesTableName + " ORDER BY " + entryGroupColumn, + ) + } else { + rows, err = storeInstance.database.Query( + "SELECT DISTINCT "+entryGroupColumn+" FROM "+entriesTableName+" WHERE "+entryGroupColumn+" LIKE ? ESCAPE '^' ORDER BY "+entryGroupColumn, + escapeLike(groupPrefix)+"%", + ) + } + if err != nil { + return core.E("store.DeletePrefix", "list groups", err) + } + defer rows.Close() + + var groupNames []string + for rows.Next() { + var groupName string + if err := rows.Scan(&groupName); err != nil { + return core.E("store.DeletePrefix", "scan group name", err) + } + groupNames = append(groupNames, groupName) + } + if err := rows.Err(); err != nil { + return core.E("store.DeletePrefix", "iterate groups", err) + } + for _, groupName := range groupNames { + if err := storeInstance.DeleteGroup(groupName); err != nil { + return core.E("store.DeletePrefix", "delete group", err) + } + } + return nil +} + // Usage example: `for entry, err := range storeInstance.All("config") { if err != nil { break }; fmt.Println(entry.Key, entry.Value) }` type KeyValue struct { // Usage example: `if entry.Key == "colour" { return }` diff --git a/store_test.go b/store_test.go index a4e1aa5..5132321 100644 --- a/store_test.go +++ b/store_test.go @@ -340,6 +340,26 @@ func TestStore_DeleteGroup_Good_IsolatesOtherGroups(t *testing.T) { assert.Equal(t, "2", value, "other group should be untouched") } +func TestStore_DeletePrefix_Good(t *testing.T) { + storeInstance, _ := New(":memory:") + defer storeInstance.Close() + + _ = storeInstance.Set("tenant-a:config", "colour", "blue") + _ = storeInstance.Set("tenant-a:sessions", "token", "abc123") + _ = storeInstance.Set("tenant-b:config", "colour", "green") + + require.NoError(t, storeInstance.DeletePrefix("tenant-a:")) + + _, err := storeInstance.Get("tenant-a:config", "colour") + assert.Error(t, err) + _, err = storeInstance.Get("tenant-a:sessions", "token") + assert.Error(t, err) + + value, err := storeInstance.Get("tenant-b:config", "colour") + require.NoError(t, err) + assert.Equal(t, "green", value) +} + func TestStore_DeleteGroup_Bad_ClosedStore(t *testing.T) { storeInstance, _ := New(":memory:") storeInstance.Close()