diff --git a/go/catalogue/busstate.go b/go/catalogue/busstate.go index 21dc379..220d5a4 100644 --- a/go/catalogue/busstate.go +++ b/go/catalogue/busstate.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "fmt" + "slices" + "sort" "strings" ) @@ -205,3 +207,21 @@ func setAsideAlso(unknown string, paths []string) string { } return unknown + " (read without " + strings.Join(paths, ", ") + ")" } + +// KeptStates are the local names of the state a manifest keeps, each a key-value bucket of its own: the old `state` +// section's and every key-value entry it asks for in `requires` (ADR 0595 §1, §11), sorted, once each. It is the one +// rule every reader of what a module keeps calls (novox/hq issue 1966): a process's `bus.state` and a seat +// contribution's state name one of these, in either form. +func KeptStates(m Manifest) []string { + var out []string + for _, s := range m.State { + out = append(out, s.Name) + } + for _, e := range m.BusState { + if e.Storage == StorageKeyValue { + out = append(out, e.Name) + } + } + sort.Strings(out) + return slices.Compact(out) +} diff --git a/go/catalogue/optional_requirements_test.go b/go/catalogue/optional_requirements_test.go index 2f6bcb8..a48a0ae 100644 --- a/go/catalogue/optional_requirements_test.go +++ b/go/catalogue/optional_requirements_test.go @@ -78,6 +78,9 @@ func TestARequirementsLongForm(t *testing.T) { // 1966/AC3: a ledger type with each of the four effects round-trips through JSON unchanged. {"a ledger with its type", `[{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024}, "types":[` + string(typeJSON) + `]}]`, "", []BusStateEntry{ledger}, nil, ""}, + // Issue 1966: a key-value entry of `requires` is kept state beside the old section's, by its local name. + {"a key-value entry beside an old state", `[{"name":"holds","storage":"key-value","template":"state"}], "state": ["seen"]`, + "", []BusStateEntry{{Name: "holds", Storage: StorageKeyValue, Template: TemplateState}}, nil, ""}, // A field given twice at any depth is refused, named by its path: a decoder would keep the last without a word. {"a field given twice", `[], "requires": ["postgres-database"]`, "", nil, nil, "the field requires is given twice"}, {"a field given twice deeper", `[{"name":"records","storage":"ledger","name":"other","template":"ledger"}]`, @@ -100,6 +103,14 @@ func TestARequirementsLongForm(t *testing.T) { if !reflect.DeepEqual(got.BusState, c.busState) { t.Errorf("%s: bus state read as %+v, want %+v", c.name, got.BusState, c.busState) } + // Issue 1966: a ledger is no kept state; KeptStates names the old section's and the key-value entries' alone. + var keeps []string + if c.name == "a key-value entry beside an old state" { + keeps = []string{"holds", "seen"} + } + if kept := KeptStates(got); !reflect.DeepEqual(kept, keeps) { + t.Errorf("%s: kept states %v, want %v", c.name, kept, keeps) + } for _, path := range c.aside { if !strings.Contains(got.SetAside(), path) { t.Errorf("%s: %s is not named among what was set aside: %q", c.name, path, got.SetAside()) diff --git a/go/membership/membership.go b/go/membership/membership.go index 38e225c..a38d94d 100644 --- a/go/membership/membership.go +++ b/go/membership/membership.go @@ -6,6 +6,8 @@ package membership import ( "encoding/json" + "slices" + "sort" "git.novox.be/novox/mesh-sdk/go/catalogue" ) @@ -171,3 +173,38 @@ type WaitIssued struct { Event string `json:"event"` Subject string `json:"subject"` } + +// StateIssued is the state this membership issues its module's code by a local name, and whether it does: what every +// runtime that serves a module's state calls reads, so no reader keeps a second copy of the rule (novox/hq issue 1966). +// An entry of the old `state` list wins (ADR 0201); otherwise a binding of a key-value entry the module asks for in +// `requires` is issued by its local name, as its own and written (ADR 0595 §1, §11: a moved module keeps its local +// names), on the bucket the bus module named. +func (m Membership) StateIssued(name string) (StateIssued, bool) { + for _, s := range m.State { + if s.Name == name { + return s, true + } + } + for _, b := range m.Bindings { + if b.Local == name && b.Storage == catalogue.StorageKeyValue && b.Bucket != "" { + return StateIssued{Name: b.Local, Bucket: b.Bucket, Writes: true}, true + } + } + return StateIssued{}, false +} + +// StateNames are the local names StateIssued answers, the old list's and the key-value bindings', sorted, once each: +// what a refusal names as issued. +func (m Membership) StateNames() []string { + var out []string + for _, s := range m.State { + out = append(out, s.Name) + } + for _, b := range m.Bindings { + if b.Storage == catalogue.StorageKeyValue && b.Bucket != "" { + out = append(out, b.Local) + } + } + sort.Strings(out) + return slices.Compact(out) +} diff --git a/go/membership/membership_test.go b/go/membership/membership_test.go index 555f19c..f252687 100644 --- a/go/membership/membership_test.go +++ b/go/membership/membership_test.go @@ -59,6 +59,31 @@ func TestAMembershipRoundTripsAsIssued(t *testing.T) { if string(before) != `{"node":"ace","module":"forge","serves":[{"subject":"s"}],"emits":"e","tools":"t","state":[{"name":"repos","bucket":"forge_repos","writes":true}]}` { t.Fatalf("a membership without bindings encodes otherwise than before: %s", before) } + // Issue 1966 (F1): what a membership issues by a local name is one rule. An old `state` entry wins; a key-value + // binding of `requires` is issued by its local name, own and written; any other binding, and a name of neither, is not. + issuing := Membership{State: []StateIssued{{Name: "repos", Bucket: "forge_repos"}}, + Bindings: []BusBinding{{Local: "holds", Storage: catalogue.StorageKeyValue, Template: catalogue.TemplateState, Bucket: "forge_holds"}, + {Local: "repos", Storage: catalogue.StorageKeyValue, Bucket: "forge_repos2"}, + {Local: "blobs", Storage: catalogue.StorageObjectStore, ObjectStore: "forge_blobs"}, + {Local: "events", Storage: catalogue.StorageStream, Stream: "forge_events"}}} + for _, c := range []struct { + name string + want StateIssued + ok bool + }{ + {"holds", StateIssued{Name: "holds", Bucket: "forge_holds", Writes: true}, true}, + {"repos", StateIssued{Name: "repos", Bucket: "forge_repos"}, true}, + {"blobs", StateIssued{}, false}, + {"events", StateIssued{}, false}, + {"other", StateIssued{}, false}, + } { + if got, ok := issuing.StateIssued(c.name); ok != c.ok || got != c.want { + t.Errorf("StateIssued(%q) = %+v, %v; want %+v, %v", c.name, got, ok, c.want, c.ok) + } + } + if got := issuing.StateNames(); !reflect.DeepEqual(got, []string{"holds", "repos"}) { + t.Errorf("StateNames = %v", got) + } var wire map[string]json.RawMessage _ = json.Unmarshal(raw, &wire) for _, k := range []string{"receives", "mesh", "seat-traffic"} {