Seat definition format: states replace records (templates, writer, key forms with value schemas; ADR 0608 §1, ADR 0634 §6; issue 1966)
Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
+128
-16
@@ -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 (`<asker>.<id>`, 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:<name>.input, verb:<name>.answer, "+
|
||||
"accept:<name>, event:<name>, holder-event:<name>", f.Of)})
|
||||
"accept:<name>, event:<name>, holder-event:<name>, state:<name>/<key form>", 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:<name>/<key form>: 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{}
|
||||
|
||||
@@ -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.<holder>.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":"<asker>.<id>","value":{"type":"object"}}]}`), ""},
|
||||
{"a stream state with an age", withState(whole, `{"name":"placement","template":"events","max-age-days":7,
|
||||
"key-forms":[{"key":"changed.<node>","value":{"type":"object"}}]}`), ""},
|
||||
{"a state of an unknown template", withState(whole, `{"name":"asks","template":"bucket",
|
||||
"key-forms":[{"key":"<id>","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":"<id>"}]}`),
|
||||
`states[0].key-forms[0].value: missing: key form "<id>"`},
|
||||
{"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":"<Asker>.<id>","value":{}}]}`), "part <Asker> is not a lower-case name"},
|
||||
{"a state written by neither", withState(whole, `{"name":"asks","template":"state","writer":"anyone",
|
||||
"key-forms":[{"key":"<id>","value":{}}]}`), "states[0].writer"},
|
||||
{"history of a stream", withState(whole, `{"name":"asks","template":"log","history":2,
|
||||
"key-forms":[{"key":"<id>","value":{}}]}`), "states[0].history"},
|
||||
{"a state twice", withState(withState(whole, `{"name":"asks","template":"state","key-forms":[{"key":"<id>","value":{}}]}`),
|
||||
`{"name":"asks","template":"log","key-forms":[{"key":"<id>","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/<asker>.<id>","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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user