diff --git a/go/catalogue/delivery_seat_test.go b/go/catalogue/delivery_seat_test.go index 046b93d..033ce24 100644 --- a/go/catalogue/delivery_seat_test.go +++ b/go/catalogue/delivery_seat_test.go @@ -4,6 +4,8 @@ import ( "reflect" "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/seatdef" ) // The delivery seat answers "what did the mesh's checks say of this pull request?" as a verb of its own, @@ -58,3 +60,18 @@ func TestTheDeliverySeatPromisesChecks(t *testing.T) { t.Fatalf("a holder without %s: %v", before[0], err) } } + +// An event whose subject carries parts is said by its subject with `*` for each part, so a consumer naming one +// instance or every instance of it is heard as the seat's (design 47 *What each step says*). +func TestAnEventWithPartsIsTheSeatsByItsSubject(t *testing.T) { + s := SeatFromDefinition(seatdef.Definition{Seat: "mesh-delivery", Scope: "mesh", Events: []seatdef.Message{ + {Name: "owed", Subject: "owed.."}, {Name: "pull.updated"}}}) + for _, c := range []struct { + event string + says bool + }{{"owed.*.*", true}, {"owed.novox-mesh-sdk.12", true}, {"owed", false}, {"pull.updated", true}, {"pull.merged", false}} { + if got := SeatSays(s.Emits, c.event); got != c.says { + t.Errorf("%s: says %v, want %v (emits %v)", c.event, got, c.says, s.Emits) + } + } +} diff --git a/go/catalogue/seat_from_definition.go b/go/catalogue/seat_from_definition.go index 317b35e..1acc52c 100644 --- a/go/catalogue/seat_from_definition.go +++ b/go/catalogue/seat_from_definition.go @@ -12,7 +12,7 @@ import ( // contribution kind given as data gets its checks (withShapeCode), which are code and never a definition's. func SeatFromDefinition(d seatdef.Definition) Seat { s := Seat{Name: d.Seat, Scope: d.Scope, Replicated: d.Replicated, Delivers: d.Delivers, - Accepts: seatdef.MessageNames(d.Accepts), Emits: seatdef.MessageNames(d.Events), PulledByKind: d.PulledByKind, + Accepts: seatdef.MessageNames(d.Accepts), Emits: localEvents(d.Events), PulledByKind: d.PulledByKind, Yields: d.Yields, HeldByTheEngine: d.HeldByTheEngine, Decision: d.Decision} if len(s.Accepts) == 0 { s.Accepts = nil @@ -62,6 +62,16 @@ func SeatsFromDefinitions(defs []seatdef.Definition) []Seat { return out } +// localEvents are the events as a manifest names them: an event whose subject carries parts by its subject with `*` +// for each part (owed.*.*), so SeatSays matches a consumer's name against it. +func localEvents(es []seatdef.Message) []string { + out := make([]string, 0, len(es)) + for _, e := range es { + out = append(out, e.Local()) + } + return out +} + func examples(in []map[string]any) []map[string]any { if in == nil { return nil diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index cfa8e13..ddaa7c2 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -110,10 +110,81 @@ type Verb struct { // Message is one accepted message or one event of the seat, its body a JSON Schema. type Message struct { + // Name is the message's name; an event's may be lower-case words joined by dots (pull.updated), as a module's + // event keys are. Name string `json:"name"` Body map[string]any `json:"body,omitempty"` // ByCaller says the subject ends in the name of who sent it (or, for an event, of who it is for). ByCaller bool `json:"by-caller,omitempty"` + // Subject is an event's subject after `mesh.seat..event.` when it carries parts (design 47 *What each step + // says*: `owed..`, `running..`, `milestone..asked`): its tokens are + // the name's words in order with parts between angle brackets among them, so a reader that knows only the name + // still knows the event. Empty, the subject is the name. A part `` is the node that publishes it, which + // the grants part grants a node's account alone (ADR 0363 ยง2). + Subject string `json:"subject,omitempty"` +} + +// SubjectPattern is the event's subject after the seat's event prefix with its parts between angle brackets: the +// name itself when the definition gives no subject. +func (m Message) SubjectPattern() string { + if m.Subject == "" { + return m.Name + } + return m.Subject +} + +// Parts are the names of the subject's parts, in order; none for a subject that is the name. +func (m Message) Parts() []string { + var out []string + for _, tok := range strings.Split(m.SubjectPattern(), ".") { + if keyPartOK.MatchString(tok) { + out = append(out, tok[1:len(tok)-1]) + } + } + return out +} + +// Local is the subject after the seat's event prefix with `*` for each part: what a manifest's `emits` and +// `consumes` name of the event, and the filter that hears every instance of it. +func (m Message) Local() string { + toks := strings.Split(m.SubjectPattern(), ".") + for i, tok := range toks { + if keyPartOK.MatchString(tok) { + toks[i] = "*" + } + } + return strings.Join(toks, ".") +} + +// checkSubject says what is wrong with an event's subject, or "". +func checkSubject(m Message) string { + if m.Subject == "" { + return "" + } + if m.ByCaller { + return "an event named by its caller ends in the caller's name; it carries no subject of its own" + } + var words, parts []string + for _, tok := range strings.Split(m.Subject, ".") { + switch { + case keyPartOK.MatchString(tok): + if slices.Contains(parts, tok) { + return fmt.Sprintf("part %s twice", tok) + } + parts = append(parts, tok) + case wordName.MatchString(tok): + words = append(words, tok) + default: + return fmt.Sprintf("%q is neither a lower-case word nor a part between < and >", tok) + } + } + if len(parts) == 0 { + return "no part: an event whose subject is its name gives no subject" + } + if strings.Join(words, ".") != m.Name { + return fmt.Sprintf("its words are %q, not the event's name %q", strings.Join(words, "."), m.Name) + } + return "" } // HolderEvent is one of the holder's own events listed beside the seat. @@ -582,12 +653,21 @@ func checkMessages(field string, ms []Message, whole bool) Problems { seen := map[string]bool{} for i, m := range ms { where := fmt.Sprintf("%s[%d]", field, i) - if !wordName.MatchString(m.Name) { + named := wordName + if field == "events" { + named = eventName + } + if !named.MatchString(m.Name) { ps = append(ps, Problem{where + ".name", "missing, or not a lower-case name"}) } else if seen[m.Name] { ps = append(ps, Problem{where + ".name", fmt.Sprintf("%q twice", m.Name)}) } seen[m.Name] = true + if m.Subject != "" && field != "events" { + ps = append(ps, Problem{where + ".subject", "only an event carries a subject of its own"}) + } else if why := checkSubject(m); why != "" { + ps = append(ps, Problem{where + ".subject", why}) + } if whole && m.Body == nil { ps = append(ps, Problem{where + ".body", "missing: a whole definition gives every message's body"}) } diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index d679037..ded6e2b 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -52,6 +52,19 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { `"subject":"mesh.mod..event.verdict","family":"checker.asks"}],"family":`, 1), "a family names the holder's own stream"}, {"a holder's event on a subject of no module", strings.Replace(whole, `"family":`, `"holder-events":[{"name":"verdict",`+ `"subject":"mesh.event.verdict"}],"family":`, 1), "holder-events[0].subject: not mesh.mod..event.verdict"}, + {"an event with dotted words", strings.Replace(whole, `"name":"created"`, `"name":"pull.updated"`, 1), ""}, + {"an event whose subject carries parts", strings.Replace(whole, `"name":"created"`, + `"name":"milestone.asked","subject":"milestone..asked"`, 1), ""}, + {"a subject whose words are not the name", strings.Replace(whole, `"name":"created"`, + `"name":"owed","subject":"owing."`, 1), `its words are "owing", not the event's name "owed"`}, + {"a subject with no part", strings.Replace(whole, `"name":"created"`, `"name":"owed","subject":"owed"`, 1), + "events[0].subject: no part"}, + {"a subject's part twice", strings.Replace(whole, `"name":"created"`, + `"name":"running","subject":"running.."`, 1), "part twice"}, + {"a subject on an event named by its caller", strings.Replace(whole, `"name":"created"`, + `"name":"owed","by-caller":true,"subject":"owed."`, 1), "carries no subject of its own"}, + {"an accept with dotted words", strings.Replace(whole, `"events":`, + `"accepts":[{"name":"run.now","body":{}}],"events":`, 1), "accepts[0].name"}, {"a verb twice", strings.Replace(listGrain, `]}`, `,{"name":"add"}]}`, 1), `"add" twice`}, {"states at the seat list's grain", strings.Replace(listGrain, `"domain":"Core","decision":"ADR 1",`, `"domain":"Core","decision":"ADR 1","states":[{"name":"asks"}],`, 1), "grain: seat-list holds"}, @@ -150,3 +163,27 @@ func withState(def, state string) string { } return strings.Replace(def, `"domain":"Core","decision":"ADR 1",`, `"domain":"Core","decision":"ADR 1","states":[`+state+`],`, 1) } + +// TestAnEventsSubjectNamesItsParts holds the three readings of an event's subject: its pattern, its parts in order and +// the local name a manifest's emits and consumes give it. +func TestAnEventsSubjectNamesItsParts(t *testing.T) { + for _, c := range []struct { + m Message + pattern, parts, local string + }{ + {Message{Name: "created"}, "created", "", "created"}, + {Message{Name: "pull.updated"}, "pull.updated", "", "pull.updated"}, + {Message{Name: "running", Subject: "running.."}, "running..", "node,module", "running.*.*"}, + {Message{Name: "milestone.asked", Subject: "milestone..asked"}, "milestone..asked", "milestone", "milestone.*.asked"}, + } { + if got := c.m.SubjectPattern(); got != c.pattern { + t.Errorf("%s: pattern %q, want %q", c.m.Name, got, c.pattern) + } + if got := strings.Join(c.m.Parts(), ","); got != c.parts { + t.Errorf("%s: parts %q, want %q", c.m.Name, got, c.parts) + } + if got := c.m.Local(); got != c.local { + t.Errorf("%s: local %q, want %q", c.m.Name, got, c.local) + } + } +}