Files
mesh-tools/node-tools/internal/runtime/state_test.go
T
jschoubben 8052fd14f4
mesh/delivery merged: source.main moved to novox/mesh-tools@2ea4f019e594
mesh/delivery-group group fix/1966-requires-state merged: every member is merged
mesh/check checking 8052fd14f4fd on 2ea4f019e594 | novox/mesh-tools#66 in group fix/1966-requires-state
Issue a key-value entry asked in requires as a state, by its local name
A bundle's state call on a key-value entry its module asks for in requires
(ADR 0595 §1) was refused "keeps and reads no state called", because the
tool runner read the old state list alone (issue 1966). It now reads mesh-sdk's
one rule, membership.StateIssued.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 12:39:04 +01:00

202 lines
8.3 KiB
Go

package runtime
import (
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
"github.com/novox/mesh-tools/node-tools/internal/bus"
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
)
// novox/hq ADR 0201: a module keeps its current state in buckets it declares, and its code reaches
// them through the runtime. The owner's instances write and read; a reader only reads; a watch hands
// the current values — none that is deleted — and then every change; the runtime refuses, with the
// reason, what the module was not issued, a write to state it only reads, and a value naming a
// credential.
func TestABundleKeepsAndWatchesItsStateThroughTheRuntime(t *testing.T) {
mesh := mt.New(t)
kv := mesh.Bucket(t, "keeper_servers")
// What was there before anything started: one value, and one key since deleted.
if _, err := kv.Put("all.one", []byte(`{"url":"http://one"}`)); err != nil {
t.Fatal(err)
}
if _, err := kv.Put("all.gone", []byte(`{}`)); err != nil {
t.Fatal(err)
}
if err := kv.Delete("all.gone"); err != nil {
t.Fatal(err)
}
keeper := mt.MembershipOf("keeper", "anchor", false, nil)
keeper.State = []bus.StateIssued{{Name: "servers", Bucket: "keeper_servers", Writes: true}}
peeker := mt.MembershipOf("peeker", "anchor", false, nil)
peeker.State = []bus.StateIssued{{Name: "keeper.servers", Bucket: "keeper_servers"}}
mesh.Issue(t, keeper)
mesh.Issue(t, peeker)
// Issue 1966 (F1): a module that asks for its state as a key-value entry of `requires` (ADR 0595 §1) is issued it by
// its binding alone, under its local name, as its own; no other module is.
mesh.Bucket(t, "binder_holds")
binder := mt.MembershipOf("binder", "anchor", false, nil)
binder.Bindings = []membership.BusBinding{{Local: "holds", Storage: catalogue.StorageKeyValue, Template: catalogue.TemplateState, Bucket: "binder_holds"}}
mesh.Issue(t, binder)
nodeTools := connect(t, "node-tools", "anchor")
asker := connect(t, "console", "workstation")
dir := t.TempDir()
keeperLog, peekerLog, binderLog := filepath.Join(dir, "keeper.log"), filepath.Join(dir, "peeker.log"), filepath.Join(dir, "binder.log")
said := &lines{}
stop, err := Run(nodeTools, []Served{
{Module: "keeper", Entrypoints: []string{mt.Fixture("state-keeper.mjs")}},
{Module: "peeker", Entrypoints: []string{mt.Fixture("state-keeper.mjs")}},
{Module: "binder", Entrypoints: []string{mt.Fixture("state-keeper.mjs")}},
}, map[string]map[string]string{
"binder": {"STATE_NAME": "holds", "STATE_LOG": binderLog},
"keeper": {"STATE_NAME": "servers", "STATE_LOG": keeperLog},
"peeker": {"STATE_NAME": "keeper.servers", "STATE_LOG": peekerLog},
}, said.logf)
if err != nil {
t.Fatal(err)
}
t.Cleanup(stop)
// Both read the whole current state at start: the value, not the deleted key, then the end of it.
for _, log := range []string{keeperLog, peekerLog} {
waitFor(t, log, "current")
lines := read(t, log)
if lines[0] != `was put all.one {"url":"http://one"}` || lines[1] != "current" || strings.Contains(strings.Join(lines, "\n"), "all.gone") {
t.Fatalf("the current state was not handed over as it is:\n%s\nruntime:\n%s", strings.Join(lines, "\n"), said.all())
}
}
// The owner writes; every watch sees the change.
got, err := call(t, asker, "keeper.put@anchor", map[string]any{"state": "servers", "key": "anchor.two", "value": map[string]any{"url": "http://two"}})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, `"revision"`) {
t.Fatalf("a put answered %s", got)
}
waitFor(t, keeperLog, `now put anchor.two {"url":"http://two"}`)
waitFor(t, peekerLog, `now put anchor.two {"url":"http://two"}`)
// Get and keys, by the owner and by the reader.
got, err = call(t, asker, "peeker.get@anchor", map[string]any{"state": "keeper.servers", "key": "anchor.two"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, `"value":{"url":"http://two"}`) {
t.Fatalf("the reader's get answered %s", got)
}
got, err = call(t, asker, "peeker.get@anchor", map[string]any{"state": "keeper.servers", "key": "nothing.here"})
if err != nil || got != "null" {
t.Fatalf("a key with no value answered %s, %v", got, err)
}
got, err = call(t, asker, "keeper.keys@anchor", map[string]any{"state": "servers"})
if err != nil {
t.Fatal(err)
}
same(t, got, `["all.one","anchor.two"]`)
// The bound module puts, gets and watches its key-value entry by its local name.
waitFor(t, binderLog, "current")
if _, err := call(t, asker, "binder.put@anchor", map[string]any{"state": "holds", "key": "h1", "value": 1}); err != nil {
t.Fatalf("a put on a bound key-value entry: %v", err)
}
waitFor(t, binderLog, "now put h1 1")
if got, err := call(t, asker, "binder.get@anchor", map[string]any{"state": "holds", "key": "h1"}); err != nil || !strings.Contains(got, `"value":1`) {
t.Fatalf("a get on a bound key-value entry answered %s, %v", got, err)
}
// Refused, with the reason, before anything is sent.
for _, c := range []struct {
key, why string
args map[string]any
}{
{"peeker.put@anchor", "peeker reads keeper.servers and does not keep it",
map[string]any{"state": "keeper.servers", "key": "x", "value": 1}},
{"keeper.get@anchor", `keeper keeps and reads no state called "other"`,
map[string]any{"state": "other", "key": "x"}},
{"keeper.get@anchor", `keeper keeps and reads no state called "holds"`,
map[string]any{"state": "holds", "key": "h1"}},
{"keeper.put@anchor", `carries a field "accessToken", which names a credential`,
map[string]any{"state": "servers", "key": "x", "value": map[string]any{"headers": map[string]any{"accessToken": "s"}}}},
{"keeper.put@anchor", "is not a key the bus can hold",
map[string]any{"state": "servers", "key": "a..b", "value": 1}},
} {
if _, err := call(t, asker, c.key, c.args); err == nil || !strings.Contains(err.Error(), c.why) {
t.Errorf("%s %v: want refused for %q, got %v", c.key, c.args, c.why, err)
}
}
// A delete reaches every watch.
if _, err := call(t, asker, "keeper.del@anchor", map[string]any{"state": "servers", "key": "all.one"}); err != nil {
t.Fatal(err)
}
waitFor(t, peekerLog, "now delete all.one")
waitFor(t, keeperLog, "now delete all.one")
}
func read(t *testing.T, path string) []string {
t.Helper()
b, _ := os.ReadFile(path)
return strings.Split(strings.TrimSpace(string(b)), "\n")
}
func waitFor(t *testing.T, path, line string) {
t.Helper()
mt.Until(t, func() error {
for _, l := range read(t, path) {
if l == line {
return nil
}
}
return errorf("%s has no %q yet: %s", filepath.Base(path), line, strings.Join(read(t, path), " | "))
})
}
// The TypeScript SDK against the runtime (ADR 0201): two watches of one state, each handed only its
// own changes, the current values handled before the module's import goes on.
func TestTheSDKsStateReachesTheRuntime(t *testing.T) {
mt.NeedBundles(t)
mesh := mt.New(t)
kv := mesh.Bucket(t, "sdkkeeper_servers")
if _, err := kv.Put("all.one", []byte(`{"url":"http://one"}`)); err != nil {
t.Fatal(err)
}
m := mt.MembershipOf("sdkkeeper", "anchor", false, nil)
m.State = []bus.StateIssued{{Name: "servers", Bucket: "sdkkeeper_servers", Writes: true}}
mesh.Issue(t, m)
nodeTools := connect(t, "node-tools", "anchor")
asker := connect(t, "console", "workstation")
log := filepath.Join(t.TempDir(), "sdk.log")
said := &lines{}
stop, err := Run(nodeTools, []Served{{Module: "sdkkeeper", Entrypoints: []string{mt.Fixture("state-sdk.serve.mjs")}}},
map[string]map[string]string{"sdkkeeper": {"STATE_LOG": log}}, said.logf)
if err != nil {
t.Fatalf("%v\n%s", err, said.all())
}
t.Cleanup(stop)
waitFor(t, log, "current")
if got := strings.Join(read(t, log), " | "); got != "was put all.one | current" {
t.Fatalf("the import went on with %q", got)
}
if _, err := call(t, asker, "sdkkeeper.register@anchor", map[string]any{"key": "anchor.two", "url": "http://two"}); err != nil {
t.Fatal(err)
}
waitFor(t, log, "now put anchor.two")
waitFor(t, log, "narrow put anchor.two")
if _, err := call(t, asker, "sdkkeeper.register@anchor", map[string]any{"key": "all.three", "url": "http://three"}); err != nil {
t.Fatal(err)
}
waitFor(t, log, "now put all.three")
for _, l := range read(t, log) {
if l == "narrow put all.three" {
t.Fatalf("a narrowed watch was handed a key outside it: %s", strings.Join(read(t, log), " | "))
}
}
}