Files
mesh-sdk/go/views/viewstest/owner.go
T
jschoubben fe83d66b62 J4 (A2j finding 1): an owner's read tool answers a key's kept history, so a reader sees what changed before its first view
The view's module history, heard messages and a want's since asked the owners' read tools for a key's history and
were refused: views.ReadTool takes key with history and answers {history: [{revision, at, op, value}]} from the
state's kept values (KeptSource through mesh/state.history; viewstest.Source and Serve from the bucket).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 14:48:21 +01:00

141 lines
5.3 KiB
Go

// Package viewstest is an owner of a state as a reader's whole-module test needs it (novox/hq ADR 0615): the
// owner's read tool answering the first view from its bucket at its mark, and its views family said by the real
// views emitter onto its views stream, which the reader's durable consumer reads.
package viewstest
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views"
)
// Serve is owner's state on the bus with the owner's side of it: the bucket `<owner>_<state>` (made when absent, history
// 1), the owner's marks, its views stream with a durable consumer per reader (`<node>_<module>`), the emitter saying
// each change, and the read tool answering on nc: tool is the owner's read tool as a reader's invokes names it
// (`<owner>.<tool>`), stream its views stream as its manifest's `streams` names it, both given as the reader's
// ProcessReader.Follow takes them. The tool answers a key's history too (key with history), from what the bucket keeps:
// a test that reads history makes the bucket first with the history it needs. It returns the bucket the test writes.
func Serve(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.Conn, owner, state, tool, stream string, readers ...string) jetstream.KeyValue {
t.Helper()
bucket := owner + "_" + state
kv, err := js.KeyValue(ctx, bucket)
if err != nil {
if kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucket, History: 1}); err != nil {
t.Fatal(err)
}
}
marks, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: owner + "_" + views.MarksState, History: 1})
if err != nil {
t.Fatal(err)
}
stream = catalogue.StreamBusName(stream)
if _, err := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{Name: stream, Subjects: []string{process.ModuleStreamSubject(owner, views.EventPrefix+">")}}); err != nil {
t.Fatal(err)
}
for _, r := range readers {
if _, err := js.CreateOrUpdateConsumer(ctx, stream, jetstream.ConsumerConfig{Durable: r, AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
}
em := views.Emitter{Owner: owner, Subject: func(e string) string { return process.ModuleStreamSubject(owner, e) },
Publish: views.JetStreamPublish(js), Marks: marks}
if err := em.Start(ctx, state, kv, nil); err != nil {
t.Fatal(err)
}
tool = strings.TrimPrefix(tool, owner+".")
sub, err := nc.Subscribe("mesh.mod."+owner+".call."+tool+".>", func(m *nats.Msg) {
var in struct {
After string `json:"after"`
Key string `json:"key"`
History bool `json:"history"`
}
_ = json.Unmarshal(m.Data, &in)
var a any
var err error
if in.History {
a, err = views.ReadHistory(owner, state, Source{ctx, kv}, in.Key)
} else {
a, err = views.FirstView(owner, state, Source{ctx, kv}, Source{ctx, marks}, in.After, time.Now())
}
out := map[string]any{"result": a, "node": "n0"}
if err != nil {
out = map[string]any{"error": err.Error()}
}
raw, _ := json.Marshal(out)
_ = m.Respond(raw)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
return kv
}
// Grants are the subjects a reader's account publishes to follow owner's state (its `invokes` and `consumes`): the
// read tool's call subjects, its durable consumer's info, next and acknowledgements on the owner's views stream.
func Grants(owner, tool, stream, durable string) []string {
tool = strings.TrimPrefix(tool, owner+".")
stream = catalogue.StreamBusName(stream)
return []string{"mesh.mod." + owner + ".call." + tool + ".>", "mesh.mod." + owner + ".call." + tool,
"$JS.API.CONSUMER.INFO." + stream + "." + durable, "$JS.API.CONSUMER.MSG.NEXT." + stream + "." + durable,
"$JS.ACK." + stream + "." + durable + ".>"}
}
// Source is a bucket as a views.Source.
type Source struct {
Ctx context.Context
KV jetstream.KeyValue
}
// Keys is every key; none is no error.
func (s Source) Keys() ([]string, error) {
keys, err := s.KV.Keys(s.Ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return keys, err
}
// Get is one key's value, revision and the time the bus wrote it.
func (s Source) Get(key string) (*views.Entry, bool, error) {
e, err := s.KV.Get(s.Ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return &views.Entry{Value: e.Value(), Revision: e.Revision(), Written: e.Created()}, true, nil
}
// History is every value the bucket keeps for key, oldest first, as the tool runner's mesh/state.history says it
// (a put, or a delete with a null value); none kept is an empty history.
func (s Source) History(key string) ([]views.Revision, error) {
kept, err := s.KV.History(s.Ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return []views.Revision{}, nil
}
if err != nil {
return nil, err
}
out := make([]views.Revision, 0, len(kept))
for _, e := range kept {
r := views.Revision{Revision: e.Revision(), At: e.Created().UTC(), Op: "delete", Value: json.RawMessage("null")}
if e.Operation() == jetstream.KeyValuePut {
r.Op, r.Value = "put", json.RawMessage(e.Value())
}
out = append(out, r)
}
return out, nil
}