From bf7b616fe166125b492d11ad716ab8677bc9b05e Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 08:32:35 +0000 Subject: [PATCH] feat(store): add paginated group reads Co-Authored-By: Virgil --- docs/history.md | 1 - docs/index.md | 2 +- scope.go | 9 +++++++++ scope_test.go | 15 +++++++++++++++ store.go | 35 +++++++++++++++++++++++++++++++++++ store_test.go | 29 +++++++++++++++++++++++++++++ 6 files changed, 89 insertions(+), 2 deletions(-) diff --git a/docs/history.md b/docs/history.md index d390041..f64cb18 100644 --- a/docs/history.md +++ b/docs/history.md @@ -214,7 +214,6 @@ These tests exercise correct defensive code. They must continue to pass but are These are design notes, not committed work: -- **Pagination for `GetAll`.** A `GetPage(group string, offset, limit int)` method would support large groups without full in-memory materialisation. - **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. diff --git a/docs/index.md b/docs/index.md index a5d8495..6dbc6b3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -117,7 +117,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`, `DeletePrefix`), 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`, `GetPage`, `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 f141795..1d6f240 100644 --- a/scope.go +++ b/scope.go @@ -190,6 +190,15 @@ func (scopedStore *ScopedStore) GetAll(group string) (map[string]string, error) return storeInstance.GetAll(scopedStore.namespacedGroup(group)) } +// Usage example: `page, err := scopedStore.GetPage("config", 0, 25); if err != nil { return }; for _, entry := range page { fmt.Println(entry.Key, entry.Value) }` +func (scopedStore *ScopedStore) GetPage(group string, offset, limit int) ([]KeyValue, error) { + storeInstance, err := scopedStore.storeInstance("store.GetPage") + if err != nil { + return nil, err + } + return storeInstance.GetPage(scopedStore.namespacedGroup(group), offset, limit) +} + // Usage example: `for entry, err := range scopedStore.All("config") { if err != nil { break }; fmt.Println(entry.Key, entry.Value) }` func (scopedStore *ScopedStore) All(group string) iter.Seq2[KeyValue, error] { storeInstance, err := scopedStore.storeInstance("store.All") diff --git a/scope_test.go b/scope_test.go index df4a449..f76c084 100644 --- a/scope_test.go +++ b/scope_test.go @@ -163,6 +163,21 @@ func TestScope_ScopedStore_Good_AllSeq(t *testing.T) { assert.ElementsMatch(t, []string{"first", "second"}, keys) } +func TestScope_ScopedStore_Good_GetPage(t *testing.T) { + storeInstance, _ := New(":memory:") + defer storeInstance.Close() + + scopedStore := mustScoped(t, storeInstance, "tenant-a") + require.NoError(t, scopedStore.SetIn("items", "charlie", "3")) + require.NoError(t, scopedStore.SetIn("items", "alpha", "1")) + require.NoError(t, scopedStore.SetIn("items", "bravo", "2")) + + page, err := scopedStore.GetPage("items", 0, 2) + require.NoError(t, err) + require.Len(t, page, 2) + assert.Equal(t, []KeyValue{{Key: "alpha", Value: "1"}, {Key: "bravo", Value: "2"}}, page) +} + func TestScope_ScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) { storeInstance, _ := New(":memory:") defer storeInstance.Close() diff --git a/store.go b/store.go index 6bc06c3..c74189c 100644 --- a/store.go +++ b/store.go @@ -434,6 +434,41 @@ func (storeInstance *Store) GetAll(group string) (map[string]string, error) { return entriesByKey, nil } +// Usage example: `page, err := storeInstance.GetPage("config", 0, 25); if err != nil { return }; for _, entry := range page { fmt.Println(entry.Key, entry.Value) }` +func (storeInstance *Store) GetPage(group string, offset, limit int) ([]KeyValue, error) { + if err := storeInstance.ensureReady("store.GetPage"); err != nil { + return nil, err + } + if offset < 0 { + return nil, core.E("store.GetPage", "offset must be zero or positive", nil) + } + if limit < 0 { + return nil, core.E("store.GetPage", "limit must be zero or positive", nil) + } + + rows, err := storeInstance.database.Query( + "SELECT "+entryKeyColumn+", "+entryValueColumn+" FROM "+entriesTableName+" WHERE "+entryGroupColumn+" = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY "+entryKeyColumn+" LIMIT ? OFFSET ?", + group, time.Now().UnixMilli(), limit, offset, + ) + if err != nil { + return nil, core.E("store.GetPage", "query rows", err) + } + defer rows.Close() + + page := make([]KeyValue, 0, limit) + for rows.Next() { + var entry KeyValue + if err := rows.Scan(&entry.Key, &entry.Value); err != nil { + return nil, core.E("store.GetPage", "scan row", err) + } + page = append(page, entry) + } + if err := rows.Err(); err != nil { + return nil, core.E("store.GetPage", "rows iteration", err) + } + return page, nil +} + // Usage example: `for entry, err := range storeInstance.AllSeq("config") { if err != nil { break }; fmt.Println(entry.Key, entry.Value) }` func (storeInstance *Store) AllSeq(group string) iter.Seq2[KeyValue, error] { return func(yield func(KeyValue, error) bool) { diff --git a/store_test.go b/store_test.go index becbdc9..18f0e4d 100644 --- a/store_test.go +++ b/store_test.go @@ -394,6 +394,35 @@ func TestStore_GetAll_Good_Empty(t *testing.T) { assert.Empty(t, all) } +func TestStore_GetPage_Good(t *testing.T) { + storeInstance, _ := New(":memory:") + defer storeInstance.Close() + + require.NoError(t, storeInstance.Set("grp", "charlie", "3")) + require.NoError(t, storeInstance.Set("grp", "alpha", "1")) + require.NoError(t, storeInstance.Set("grp", "bravo", "2")) + + page, err := storeInstance.GetPage("grp", 1, 2) + require.NoError(t, err) + require.Len(t, page, 2) + assert.Equal(t, []KeyValue{{Key: "bravo", Value: "2"}, {Key: "charlie", Value: "3"}}, page) +} + +func TestStore_GetPage_Good_EmptyAndBounds(t *testing.T) { + storeInstance, _ := New(":memory:") + defer storeInstance.Close() + + page, err := storeInstance.GetPage("grp", 0, 0) + require.NoError(t, err) + assert.Empty(t, page) + + _, err = storeInstance.GetPage("grp", -1, 1) + require.Error(t, err) + + _, err = storeInstance.GetPage("grp", 0, -1) + require.Error(t, err) +} + func TestStore_GetAll_Bad_ClosedStore(t *testing.T) { storeInstance, _ := New(":memory:") storeInstance.Close()