node-engine client: the node key of the have, the codecs of the health report and of the data items (ADR 0634 §1, §4; issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 15:16:39 +01:00
parent 82b02ff099
commit 8c5f85e86b
3 changed files with 131 additions and 0 deletions
+43
View File
@@ -105,3 +105,46 @@ func TestHaveNodeCodec(t *testing.T) {
t.Error("a record differs by its version")
}
}
func TestHealthAndDataCodecs(t *testing.T) {
at := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)
h := seat.HealthByNodeValue{Node: "n", At: at, Fallback: &seat.LoginFallback{Node: "n", From: "old", Why: "w", At: at}}
raw, err := EncodeHealth(h)
if err != nil {
t.Fatal(err)
}
back, err := DecodeHealth(raw)
h.V = keys.Version
if err != nil || !SameHealth(back, h) {
t.Fatalf("round trip: %v %+v", err, back)
}
later := h
later.At = at.Add(time.Hour)
if !SameHealth(h, later) {
t.Error("a report differs by its time of writing")
}
for name, bad := range map[string]seat.HealthByNodeValue{
"no node": {},
"another node": {Node: "n", Fallback: &seat.LoginFallback{Node: "m", From: "o", Why: "w", At: at}},
"no time": {Node: "n", Fallback: &seat.LoginFallback{Node: "n", From: "o", Why: "w"}},
"no login from": {Node: "n", Fallback: &seat.LoginFallback{Node: "n", Why: "w", At: at}},
} {
if _, err := EncodeHealth(bad); err == nil {
t.Errorf("%s: encoded", name)
}
}
if _, err := DecodeHealth([]byte(`{"v":2,"node":"n"}`)); !errors.Is(err, keys.ErrUnknownVersion) {
t.Errorf("version 2: %v", err)
}
d := seat.DataByNodeModuleValue{Node: "n", Module: "m", Digest: digestA}
raw, err = EncodeData(d)
if err != nil || !strings.Contains(string(raw), `"items":[]`) {
t.Fatalf("data: %v %s", err, raw)
}
if _, err := EncodeData(seat.DataByNodeModuleValue{Node: "n", Module: "node"}); err == nil {
t.Error("the module name node keys")
}
if k, err := HaveNodeKey("n"); err != nil || k != "n.node" {
t.Errorf("node key %q %v", k, err)
}
}
+80
View File
@@ -0,0 +1,80 @@
package nodeengineclient
import (
"encoding/json"
"fmt"
"time"
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// A node's own health report (the state health, one key per node, written by that node's engine alone) and the data items
// a module declares as the engine placed them (the state data, `<node>.<module>`): their layouts are the seat's contract
// (seat.HealthByNodeValue, seat.DataByNodeModuleValue); this file is their codec, shared by the engine and its readers.
// EncodeHealth is h as JSON at the current version; it refuses a report naming no node, and a fall back to the old login
// that names another node, no login fallen back from, no reason or no time.
func EncodeHealth(h seat.HealthByNodeValue) ([]byte, error) {
h.V = keys.Version
if h.Node == "" {
return nil, fmt.Errorf("node-engine: a node's health report names its node")
}
if f := h.Fallback; f != nil && (f.Node != h.Node || f.From == "" || f.Why == "" || f.At.IsZero()) {
return nil, fmt.Errorf("node-engine: a node's fall back to its old login names its node %s, the login fallen back from, why and when", h.Node)
}
return json.Marshal(h)
}
// DecodeHealth reads a report; it refuses another version (keys.ErrUnknownVersion) and a field the contract does not know.
func DecodeHealth(raw []byte) (seat.HealthByNodeValue, error) {
if err := keys.CheckVersion(raw); err != nil {
return seat.HealthByNodeValue{}, err
}
h, err := seat.DecodeHealthByNodeValue(raw)
if err != nil {
return seat.HealthByNodeValue{}, fmt.Errorf("node-engine: a node's health report is not readable: %w", err)
}
return h, nil
}
// SameHealth says two reports say the same, whenever each was written.
func SameHealth(a, b seat.HealthByNodeValue) bool {
a.At, b.At = time.Time{}, time.Time{}
return sameJSON(a, b)
}
// DataKey is a module's key on a node in the state data, `<node>.<module>`, its module part escaped as a have's is.
func DataKey(node, module string) (string, error) { return HaveKey(node, module) }
// DataNodeKeys is the pattern of every data key of a node, `<node>.*`.
func DataNodeKeys(node string) (string, error) {
if err := keys.CheckName("node", node); err != nil {
return "", err
}
return node + ".*", nil
}
// EncodeData is d as JSON at the current version; it refuses a node or module the key refuses.
func EncodeData(d seat.DataByNodeModuleValue) ([]byte, error) {
d.V = keys.Version
if _, err := DataKey(d.Node, d.Module); err != nil {
return nil, err
}
if d.Items == nil {
d.Items = []seat.PlacedDataItem{}
}
return json.Marshal(d)
}
// DecodeData reads a module's data items; it refuses another version (keys.ErrUnknownVersion).
func DecodeData(raw []byte) (seat.DataByNodeModuleValue, error) {
if err := keys.CheckVersion(raw); err != nil {
return seat.DataByNodeModuleValue{}, err
}
d, err := seat.DecodeDataByNodeModuleValue(raw)
if err != nil {
return seat.DataByNodeModuleValue{}, fmt.Errorf("node-engine: a module's data items are not readable: %w", err)
}
return d, nil
}
+8
View File
@@ -120,3 +120,11 @@ func ParseComposedKey(key string) (node, module, fingerprintPart string, isNode
}
return refused(fmt.Errorf("the key has %d parts", len(parts)))
}
// HaveNodeKey is the key, in the state have, of a node's own facts: `<node>.node`.
func HaveNodeKey(node string) (string, error) {
if err := keys.CheckName("node", node); err != nil {
return "", err
}
return node + "." + NodeWord, nil
}