diff --git a/node-tools/internal/bus/state.go b/node-tools/internal/bus/state.go index 1911720..ed6c196 100644 --- a/node-tools/internal/bus/state.go +++ b/node-tools/internal/bus/state.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-sdk/go/membership" "git.novox.be/novox/mesh-sdk/go/pernode" "github.com/nats-io/nats.go/jetstream" ) @@ -71,21 +72,22 @@ func (c *Conn) issuedState(module, name string) (StateIssued, error) { return StateIssued{}, fmt.Errorf("%s has no membership issued on %s yet, so no state of it is reachable "+ "until the mesh issues one (novox/hq ADR 0201)", module, c.node) } - var names []string + // What a membership issues by a local name is mesh-sdk's one rule (membership.StateIssued, issue 1966): the old + // `state` list, then a key-value entry the module asks for in `requires` (ADR 0595 §1), by its local name. + issuing := membership.Membership{Bindings: m.Bindings} for _, s := range m.State { - if s.Name == name { - return s, nil - } - names = append(names, s.Name) + issuing.State = append(issuing.State, membership.StateIssued{Name: s.Name, Bucket: s.Bucket, Writes: s.Writes, PerNode: s.PerNode}) + } + if s, ok := issuing.StateIssued(name); ok { + return StateIssued{Name: s.Name, Bucket: s.Bucket, Writes: s.Writes, PerNode: s.PerNode}, nil } - sort.Strings(names) issued := "none" - if len(names) > 0 { + if names := issuing.StateNames(); len(names) > 0 { issued = strings.Join(names, ", ") } return StateIssued{}, fmt.Errorf("%s keeps and reads no state called %q: it declares what it keeps under "+ - "`state` and what it reads under `reads` as ., and was issued: %s (novox/hq ADR 0201)", - module, name, issued) + "`state` or as a key-value entry in `requires`, and what it reads under `reads` as ., and was "+ + "issued: %s (novox/hq ADR 0201, ADR 0595 §1)", module, name, issued) } func (c *Conn) bucket(ctx context.Context, s StateIssued) (jetstream.KeyValue, error) { diff --git a/node-tools/internal/runtime/state_test.go b/node-tools/internal/runtime/state_test.go index b88888b..78c2ba5 100644 --- a/node-tools/internal/runtime/state_test.go +++ b/node-tools/internal/runtime/state_test.go @@ -6,6 +6,8 @@ import ( "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" ) @@ -35,16 +37,24 @@ func TestABundleKeepsAndWatchesItsStateThroughTheRuntime(t *testing.T) { 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 := filepath.Join(dir, "keeper.log"), filepath.Join(dir, "peeker.log") + 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) @@ -91,6 +101,16 @@ func TestABundleKeepsAndWatchesItsStateThroughTheRuntime(t *testing.T) { } 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 @@ -100,6 +120,8 @@ func TestABundleKeepsAndWatchesItsStateThroughTheRuntime(t *testing.T) { 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",