diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index 6017b36..37626d4 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -28,7 +28,7 @@ const MaxSize = 256 << 10 const Format = 1 // The two grains a definition is written at (ADR 0613 §1, §2; ADR 0620 §1): whole, with every verb's messages, its -// events and their bodies, its records and its provisions' forms; or the seat list's, holding what mesh-sdk's +// events and their bodies, its states and its provisions' forms; or the seat list's, holding what mesh-sdk's // compiled seat list held, until its area's contract next changes. const ( Whole = "whole" @@ -74,8 +74,9 @@ type Definition struct { // HolderEvents are the holder's own events that belong with the seat, listed beside it and never moved onto // its subject (ADR 0620 §2): they keep the holder's subjects. HolderEvents []HolderEvent `json:"holder-events,omitempty"` - // Records are the states and records the holder keeps for the seat. - Records []Record `json:"records,omitempty"` + // States are the layouts of the state the seat's holder keeps on the bus, and the callers' writes into it (ADR + // 0608 §1, ADR 0634 §6). + States []State `json:"states,omitempty"` // Provisions are the forms of the provisions the seat's holder answers. Provisions []Provision `json:"provisions,omitempty"` // Receives and Paths are the seat list's contributions and owned paths. @@ -129,11 +130,48 @@ type Family struct { // EventsStream, whose test holds the two equal). const SharedEventsMaxAgeDays = 7 -// Record is one state the holder keeps for the seat: its key's form and its value's schema. -type Record struct { - Name string `json:"name"` - Key string `json:"key,omitempty"` - Value map[string]any `json:"value,omitempty"` +// The templates a state is kept in (ADR 0595 §2): the same six names mesh-sdk's catalogue gives a module's `requires` +// entry; a unit test holds the two lists equal. This package imports no catalogue. +const ( + TemplateState = "state" + TemplateEvents = "events" + TemplateWorkQueue = "work-queue" + TemplateLog = "log" + TemplateObjects = "objects" + TemplateLedger = "ledger" +) + +// Templates are the six, in the order ADR 0595 gives them. +var Templates = []string{TemplateState, TemplateEvents, TemplateWorkQueue, TemplateLog, TemplateObjects, TemplateLedger} + +// The writers of a state: the seat's holder (the default), or its callers, each writing its own key. +const ( + WriterHolder = "holder" + WriterCaller = "caller" +) + +// State is one state of the seat, by its local name: the holder's name is never in it, since a contract names no +// holder (ADR 0625 §4, §7); each reader takes the holder from its own manifest's `reads` entry. A state is kept in +// one of the templates, and its keys have one form each, with the schema of the value under it. +type State struct { + Name string `json:"name"` + Template string `json:"template"` + // Writer is WriterHolder when empty. + Writer string `json:"writer,omitempty"` + KeyForms []KeyForm `json:"key-forms"` + // History is how many past values a TemplateState's key keeps; MaxAgeDays is how long a stream template + // (events, work queue, log) keeps a message. Neither is given where the template has no such setting. + History int `json:"history,omitempty"` + MaxAgeDays int `json:"max-age-days,omitempty"` + Description string `json:"description,omitempty"` +} + +// KeyForm is one form a state's key takes: a pattern of named parts (`.`, a part between angle brackets, +// the rest literal) and the JSON Schema of the value kept under keys of that form. +type KeyForm struct { + Key string `json:"key"` + Value map[string]any `json:"value"` + Description string `json:"description,omitempty"` } // Provision is the form of a provision the seat's holder answers. @@ -271,7 +309,7 @@ func ParseFixtures(raw []byte) ([]Fixture, error) { } if _, _, ok := f.Instance(); !ok { ps = append(ps, Problem{where + ".of", fmt.Sprintf("%q is none of verb:.input, verb:.answer, "+ - "accept:, event:, holder-event:", f.Of)}) + "accept:, event:, holder-event:, state:/", f.Of)}) } if f.Given == nil { ps = append(ps, Problem{where + ".given", "missing"}) @@ -292,6 +330,11 @@ func (f Fixture) Instance() (kind, name string, ok bool) { switch k { case "accept", "event", "holder-event": return k, rest, true + case "state": + // state:/: one value of the state under that key form. + if n, form, ok := strings.Cut(rest, "/"); ok && n != "" && form != "" { + return "state", rest, true + } case "verb": if n, ok := strings.CutSuffix(rest, ".input"); ok && n != "" { return "verb-input", n, true @@ -356,10 +399,10 @@ func (d Definition) check() Problems { ps = append(ps, Problem{"decision", "missing"}) } whole := d.Grain == Whole - if !whole && (len(d.Includes) > 0 || d.Family != nil || len(d.HolderEvents) > 0 || len(d.Records) > 0 || + if !whole && (len(d.Includes) > 0 || d.Family != nil || len(d.HolderEvents) > 0 || len(d.States) > 0 || len(d.Provisions) > 0 || len(d.Defs) > 0) { ps = append(ps, Problem{"grain", "seat-list holds the seat list's fields only; includes, family, " + - "holder-events, records, provisions and defs are a whole definition's"}) + "holder-events, states, provisions and defs are a whole definition's"}) } if d.HolderDeclares && !whole { ps = append(ps, Problem{"holder-declares", "only a whole definition describes a seat its holder declares"}) @@ -403,11 +446,7 @@ func (d Definition) check() Problems { ps = append(ps, Problem{where + ".subject", fmt.Sprintf("not %s, nor %s for an event kept in the holder's own stream", own, stream)}) } } - for i, r := range d.Records { - if !wordName.MatchString(r.Name) { - ps = append(ps, Problem{fmt.Sprintf("records[%d].name", i), "missing, or not a lower-case name"}) - } - } + ps = append(ps, checkStates(d.States)...) for i, p := range d.Provisions { where := fmt.Sprintf("provisions[%d]", i) if p.Name == "" { @@ -430,6 +469,79 @@ func (d Definition) check() Problems { return ps } +var ( + keyPart = regexp.MustCompile(`<[^<>]*>`) + keyPartOK = regexp.MustCompile(`^<[a-z][a-z0-9-]*>$`) + keyLiteral = regexp.MustCompile(`^[A-Za-z0-9_.\-/=:]*$`) +) + +func checkStates(ss []State) Problems { + var ps Problems + seen := map[string]bool{} + for i, s := range ss { + where := fmt.Sprintf("states[%d]", i) + if !wordName.MatchString(s.Name) { + ps = append(ps, Problem{where + ".name", "missing, or not a lower-case name"}) + } else if seen[s.Name] { + ps = append(ps, Problem{where + ".name", fmt.Sprintf("%q twice", s.Name)}) + } + seen[s.Name] = true + known := false + for _, t := range Templates { + known = known || s.Template == t + } + switch { + case s.Template == "": + ps = append(ps, Problem{where + ".template", "missing"}) + case !known: + ps = append(ps, Problem{where + ".template", fmt.Sprintf("%q is none of %s", s.Template, strings.Join(Templates, ", "))}) + } + if s.Writer != "" && s.Writer != WriterHolder && s.Writer != WriterCaller { + ps = append(ps, Problem{where + ".writer", fmt.Sprintf("%q is neither holder nor caller", s.Writer)}) + } + if s.History < 0 || (s.History > 0 && s.Template != TemplateState) { + ps = append(ps, Problem{where + ".history", "a count of past values, of a state template only"}) + } + stream := s.Template == TemplateEvents || s.Template == TemplateWorkQueue || s.Template == TemplateLog + if s.MaxAgeDays < 0 || (s.MaxAgeDays > 0 && !stream) { + ps = append(ps, Problem{where + ".max-age-days", "days, of an events, work-queue or log template only"}) + } + if len(s.KeyForms) == 0 { + ps = append(ps, Problem{where + ".key-forms", "missing: a state gives at least one key form"}) + } + forms := map[string]bool{} + for j, f := range s.KeyForms { + at := fmt.Sprintf("%s.key-forms[%d]", where, j) + if f.Key == "" { + ps = append(ps, Problem{at + ".key", "missing"}) + } else if bad := badKey(f.Key); bad != "" { + ps = append(ps, Problem{at + ".key", bad}) + } else if forms[f.Key] { + ps = append(ps, Problem{at + ".key", fmt.Sprintf("%q twice", f.Key)}) + } + forms[f.Key] = true + if f.Value == nil { + ps = append(ps, Problem{at + ".value", fmt.Sprintf("missing: key form %q gives its value's schema", f.Key)}) + } + } + } + return ps +} + +// badKey says what is wrong with a key pattern: a part between angle brackets that is not a lower-case name, or a +// literal outside what a bus key may hold. +func badKey(key string) string { + for _, part := range keyPart.FindAllString(key, -1) { + if !keyPartOK.MatchString(part) { + return fmt.Sprintf("part %s is not a lower-case name between < and >", part) + } + } + if lit := keyPart.ReplaceAllString(key, ""); !keyLiteral.MatchString(lit) || strings.ContainsAny(lit, "<>") { + return fmt.Sprintf("%q holds a character a key may not", key) + } + return "" +} + func checkVerbs(vs []Verb, whole bool) Problems { var ps Problems seen := map[string]bool{} diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index e385e38..fccd43b 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -4,6 +4,8 @@ import ( "errors" "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // TestADefinitionIsReadStrictlyNamingWhatIsWrong is the format's own table (design 26: a valid definition parses; @@ -50,8 +52,27 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { {"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"}, {"a verb twice", strings.Replace(listGrain, `]}`, `,{"name":"add"}]}`, 1), `"add" twice`}, - {"records at the seat list's grain", strings.Replace(listGrain, `"decision":"ADR 1",`, - `"decision":"ADR 1","records":[{"name":"asks"}],`, 1), "grain: seat-list holds"}, + {"states at the seat list's grain", strings.Replace(listGrain, `"decision":"ADR 1",`, + `"decision":"ADR 1","states":[{"name":"asks"}],`, 1), "grain: seat-list holds"}, + {"the old records field is gone", strings.Replace(whole, `"decision":"ADR 1",`, + `"decision":"ADR 1","records":[{"name":"asks"}],`, 1), "records: a field the format does not know"}, + {"a state with a key form and a value", withState(whole, `{"name":"asks","template":"state","writer":"caller", + "history":3,"key-forms":[{"key":".","value":{"type":"object"}}]}`), ""}, + {"a stream state with an age", withState(whole, `{"name":"placement","template":"events","max-age-days":7, + "key-forms":[{"key":"changed.","value":{"type":"object"}}]}`), ""}, + {"a state of an unknown template", withState(whole, `{"name":"asks","template":"bucket", + "key-forms":[{"key":"","value":{"type":"object"}}]}`), `states[0].template: "bucket" is none of state, events`}, + {"a key form with no value", withState(whole, `{"name":"asks","template":"state","key-forms":[{"key":""}]}`), + `states[0].key-forms[0].value: missing: key form ""`}, + {"a state with no key form", withState(whole, `{"name":"asks","template":"state"}`), "states[0].key-forms: missing"}, + {"a key part that is no name", withState(whole, `{"name":"asks","template":"state", + "key-forms":[{"key":".","value":{}}]}`), "part is not a lower-case name"}, + {"a state written by neither", withState(whole, `{"name":"asks","template":"state","writer":"anyone", + "key-forms":[{"key":"","value":{}}]}`), "states[0].writer"}, + {"history of a stream", withState(whole, `{"name":"asks","template":"log","history":2, + "key-forms":[{"key":"","value":{}}]}`), "states[0].history"}, + {"a state twice", withState(withState(whole, `{"name":"asks","template":"state","key-forms":[{"key":"","value":{}}]}`), + `{"name":"asks","template":"log","key-forms":[{"key":"","value":{}}]}`), `"asks" twice`}, {"over 256 KiB", listGrain[:len(listGrain)-1] + `,"description":"` + strings.Repeat("x", MaxSize) + `"}`, ErrTooLarge.Error()}, } @@ -106,6 +127,12 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { "subject":"mesh.seat.mesh-broker.event.created","given":{}}]`)); err != nil { t.Errorf("fixtures: %v", err) } + if _, err := ParseFixtures([]byte(`[{"name":"a","of":"state:asks/.","given":{}}]`)); err != nil { + t.Errorf("a state's fixture: %v", err) + } + if _, err := ParseFixtures([]byte(`[{"name":"a","of":"state:asks","given":{}}]`)); err == nil { + t.Error("a fixture of a state with no key form parsed") + } if _, err := ParseFixtures([]byte(`[{"name":"a","of":"verb:list","given":{}}]`)); err == nil || !strings.Contains(err.Error(), "fixtures[0].of") { t.Errorf("a fixture of nothing known: %v", err) @@ -114,3 +141,21 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { t.Error("an index naming a seat twice parsed") } } + +// withState adds one state to a whole definition's JSON. +func withState(def, state string) string { + if strings.Contains(def, `"states":[`) { + return strings.Replace(def, `"states":[`, `"states":[`+state+`,`, 1) + } + return strings.Replace(def, `"decision":"ADR 1",`, `"decision":"ADR 1","states":[`+state+`],`, 1) +} + +// TestTheTemplatesAreTheCataloguesSix holds this reader's list of templates equal to the one a module's `requires` +// entry takes (ADR 0595 §2), so a seventh template cannot be added to one and not the other. +func TestTheTemplatesAreTheCataloguesSix(t *testing.T) { + want := []string{catalogue.TemplateState, catalogue.TemplateEvents, catalogue.TemplateWorkQueue, + catalogue.TemplateLog, catalogue.TemplateObjects, catalogue.TemplateLedger} + if strings.Join(Templates, ",") != strings.Join(want, ",") { + t.Errorf("templates %v, the catalogue's %v", Templates, want) + } +}