feat(memory): Store port with nop and fake adapters
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
// Package memory is Keel's durable, cross-run memory port. Modes Record derived
|
||||||
|
// events and read them back with Recent; the AW-backed adapter persists them to
|
||||||
|
// an ActivityWatch bucket. Event-kind names are owned by the modes that write
|
||||||
|
// them — this package stays a generic event store.
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is one derived event Keel chooses to remember. Kind is the event type
|
||||||
|
// (e.g. "proposal_made"); Data carries pointers and small values, not copies of
|
||||||
|
// other tools' source of truth.
|
||||||
|
type Event struct {
|
||||||
|
Kind string
|
||||||
|
At time.Time
|
||||||
|
Data map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store records derived events and reads recent ones back by kind.
|
||||||
|
type Store interface {
|
||||||
|
Record(ctx context.Context, e Event) error
|
||||||
|
Recent(ctx context.Context, kind string, n int) ([]Event, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nopStore is the no-memory fallback used when AW is unreachable or disabled.
|
||||||
|
type nopStore struct{}
|
||||||
|
|
||||||
|
// NewNop returns a Store that drops writes and returns no history.
|
||||||
|
func NewNop() Store { return nopStore{} }
|
||||||
|
|
||||||
|
func (nopStore) Record(context.Context, Event) error { return nil }
|
||||||
|
func (nopStore) Recent(context.Context, string, int) ([]Event, error) { return nil, nil }
|
||||||
|
|
||||||
|
// Fake is an in-memory Store for tests.
|
||||||
|
type Fake struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFake returns an empty in-memory Store.
|
||||||
|
func NewFake() *Fake { return &Fake{} }
|
||||||
|
|
||||||
|
func (f *Fake) Record(_ context.Context, e Event) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.events = append(f.events, e)
|
||||||
|
f.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) Recent(_ context.Context, kind string, n int) ([]Event, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []Event
|
||||||
|
for i := len(f.events) - 1; i >= 0 && len(out) < n; i-- {
|
||||||
|
if f.events[i].Kind == kind {
|
||||||
|
out = append(out, f.events[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events returns a snapshot of everything recorded, for test assertions.
|
||||||
|
func (f *Fake) Events() []Event {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]Event(nil), f.events...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFakeRecordThenRecentNewestFirstByKind(t *testing.T) {
|
||||||
|
f := NewFake()
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(1, 0), Data: map[string]any{"n": "a"}})
|
||||||
|
_ = f.Record(ctx, Event{Kind: "action_taken", At: time.Unix(2, 0)})
|
||||||
|
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(3, 0), Data: map[string]any{"n": "b"}})
|
||||||
|
|
||||||
|
got, err := f.Recent(ctx, "proposal_made", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recent: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].Data["n"] != "b" {
|
||||||
|
t.Fatalf("want newest-first [b,a], got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeRecentRespectsLimit(t *testing.T) {
|
||||||
|
f := NewFake()
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(int64(i), 0)})
|
||||||
|
}
|
||||||
|
got, _ := f.Recent(ctx, "proposal_made", 2)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("limit not honored: %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNopStoreIsInert(t *testing.T) {
|
||||||
|
s := NewNop()
|
||||||
|
if err := s.Record(context.Background(), Event{Kind: "x"}); err != nil {
|
||||||
|
t.Fatalf("nop Record: %v", err)
|
||||||
|
}
|
||||||
|
got, err := s.Recent(context.Background(), "x", 5)
|
||||||
|
if err != nil || len(got) != 0 {
|
||||||
|
t.Fatalf("nop Recent = %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user