feat(store): add prefix cleanup helpers
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
2ef3c95fd5
commit
3a8cfcedf9
7 changed files with 96 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
9
scope.go
9
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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
42
store.go
42
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 }`
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue