feat(store): add prefix cleanup helpers

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-03 07:14:22 +00:00
parent 2ef3c95fd5
commit 3a8cfcedf9
7 changed files with 96 additions and 7 deletions

View file

@ -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

View file

@ -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.

View file

@ -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 |

View file

@ -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")

View file

@ -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()

View file

@ -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 }`

View file

@ -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()