diff --git a/go/views/contract_fake_test.go b/go/views/contract_fake_test.go index 85fcae11..64ad5cea 100644 --- a/go/views/contract_fake_test.go +++ b/go/views/contract_fake_test.go @@ -6,8 +6,8 @@ import ( "git.novox.be/novox/mesh-sdk/go/views" ) -// The test's own stand-in for the types the seat generator emits for the seat `mesh-conditions` (design 47 G §686, -// §640): mesh-sdk keeps no such layout, so the writer's tests bring one, with the accessors the generator emits. +// Plain structs shaped as the types the seat generator emits for the seat `mesh-conditions` (design 47 G A-3): the writer +// is generic over them and works on their wire form, so the test brings the same shape, field for field. type testSubject struct { Also []string `json:"also,omitempty"` @@ -17,52 +17,23 @@ type testSubject struct { } type testFinding struct { - AtVal time.Time `json:"at"` + At time.Time `json:"at"` Explanation string `json:"explanation,omitempty"` - HeadlineVal string `json:"headline,omitempty"` - IDField string `json:"id"` - KindField string `json:"kind"` - ObserverVal string `json:"observer"` + Headline string `json:"headline,omitempty"` + ID string `json:"id"` + Kind string `json:"kind"` + Observer string `json:"observer"` Owner string `json:"owner,omitempty"` Run string `json:"run,omitempty"` - ScopeField string `json:"scope"` - SeverityVal string `json:"severity"` - SinceVal time.Time `json:"since"` - SourceVal string `json:"source"` - SubjectVal testSubject `json:"subject"` - SummaryVal string `json:"summary"` + Scope string `json:"scope"` + Severity string `json:"severity"` + Since time.Time `json:"since"` + Source string `json:"source"` + Subject testSubject `json:"subject"` + Summary string `json:"summary"` WaitingOn string `json:"waiting-on,omitempty"` } -var _ views.FindingBody = (*testFinding)(nil) - -func (f *testFinding) Kind() string { return f.KindField } -func (f *testFinding) SetKind(v string) { f.KindField = v } -func (f *testFinding) Scope() string { return f.ScopeField } -func (f *testFinding) SetScope(v string) { f.ScopeField = v } -func (f *testFinding) ID() string { return f.IDField } -func (f *testFinding) SetID(v string) { f.IDField = v } -func (f *testFinding) At() time.Time { return f.AtVal } -func (f *testFinding) Since() time.Time { return f.SinceVal } -func (f *testFinding) SetSince(v time.Time) { f.SinceVal = v } -func (f *testFinding) Severity() string { return f.SeverityVal } -func (f *testFinding) SetSeverity(v string) { f.SeverityVal = v } -func (f *testFinding) Summary() string { return f.SummaryVal } -func (f *testFinding) SetSummary(v string) { f.SummaryVal = v } -func (f *testFinding) Headline() string { return f.HeadlineVal } -func (f *testFinding) SetHeadline(v string) { f.HeadlineVal = v } -func (f *testFinding) Source() string { return f.SourceVal } -func (f *testFinding) SetSource(v string) { f.SourceVal = v } -func (f *testFinding) Observer() string { return f.ObserverVal } -func (f *testFinding) SetObserver(v string) { f.ObserverVal = v } -func (f *testFinding) SetAt(v time.Time) { f.AtVal = v } -func (f *testFinding) SetSubject(a []string, id, m, s string) { - f.SubjectVal = testSubject{Also: a, ID: id, Machine: m, Scope: s} -} -func (f *testFinding) Subject() ([]string, string, string, string) { - return f.SubjectVal.Also, f.SubjectVal.ID, f.SubjectVal.Machine, f.SubjectVal.Scope -} - type testCleared struct { At time.Time `json:"at"` ID string `json:"id"` @@ -71,12 +42,6 @@ type testCleared struct { Why string `json:"why"` } -func (c *testCleared) SetKind(v string) { c.Kind = v } -func (c *testCleared) SetScope(v string) { c.Scope = v } -func (c *testCleared) SetID(v string) { c.ID = v } -func (c *testCleared) SetAt(v time.Time) { c.At = v } -func (c *testCleared) SetWhy(v string) { c.Why = v } - type testPending struct { Bound string `json:"bound"` Key string `json:"key"` @@ -91,36 +56,11 @@ type testStated struct { Pending []testPending `json:"pending"` } -func newTestStated() *testStated { - return &testStated{Findings: []testFinding{}, Pending: []testPending{}} -} - -func (s *testStated) SetAt(v time.Time) { s.At = v } -func (s *testStated) SetAsked(v string) { s.Asked = v } -func (s *testStated) SetKinds(v []string) { s.Kinds = v } -func (s *testStated) AddFinding(b views.FindingBody) { - s.Findings = append(s.Findings, *b.(*testFinding)) -} -func (s *testStated) AddPending(k string, since time.Time, bound string) { - s.Pending = append(s.Pending, testPending{Bound: bound, Key: k, Since: since}) -} - type testAsked struct { - AskID string `json:"ask"` - Mod string `json:"module,omitempty"` + Ask string `json:"ask"` + Module string `json:"module,omitempty"` } -func (a *testAsked) Ask() string { return a.AskID } -func (a *testAsked) Module() string { return a.Mod } - -var bodies = struct { - NewFinding func() views.FindingBody - NewCleared func() views.ClearedBody - NewStated func() views.StatedBody - NewAsked func() views.AskedBody -}{ - func() views.FindingBody { return &testFinding{} }, - func() views.ClearedBody { return &testCleared{} }, - func() views.StatedBody { return newTestStated() }, - func() views.AskedBody { return &testAsked{} }, -} +type testWriter = views.Writer[testFinding, testCleared, testStated, testAsked] +type testConfig = views.WriterConfig[testFinding, testCleared, testStated, testAsked] +type testObserved = views.Observed[testFinding] diff --git a/go/views/reader.go b/go/views/reader.go index 6b06f410..e4616485 100644 --- a/go/views/reader.go +++ b/go/views/reader.go @@ -619,43 +619,51 @@ func (r *Reader) Tools(prefix string) []stdio.Tool { // FindingScope is the scope of a views reader's findings: they are findings of the part's module (design 47 G §687). const FindingScope = "module" -// WriterFindings writes a views reader's findings through the part's mesh-sdk writer, as kinds of the reader's own -// (design 47 G §639, §580, §687): the Findings of a reader, never a writer of its own. mesh-sdk lays each finding out -// through the body's setters, and the part supplies only New, the generated contract's constructor of a finding body: -// `kind` the finding's, `scope` module, `id` the finding's id escaped as a key's name is, `severity` warning but urgent -// for view-too-large, `source` views, `subject` the part's module on its node, `summary` and `headline` the finding's -// texts. The writer's statement covers the reader's open findings beside its observe's. -type WriterFindings struct { - Writer *Writer - New func() FindingBody +// wireWriter is the part of a Writer[F, C, S, A] a views reader's findings use, whatever the contract's body types. +type wireWriter interface { + WriteWire(ctx context.Context, finding map[string]any) error + Clear(ctx context.Context, scope, id, kind, why string) error + ModuleNode() (module, node string) } -func (o WriterFindings) body(f Finding) FindingBody { - b := o.New() - b.SetKind(f.Kind) - b.SetScope(FindingScope) - b.SetID(keys.EscapeName(f.ID)) - b.SetSeverity("warning") +// WriterFindings writes a views reader's findings through the part's mesh-sdk writer, as kinds of the reader's own +// (design 47 G §639, §580, §687): the Findings of a reader, never a writer of its own. mesh-sdk lays each finding out on +// its wire form: `kind` the finding's, `scope` module, `id` the finding's id escaped as a key's name is, `severity` +// warning but urgent for view-too-large, `source` views, `subject` the part's module on its node, `summary` and +// `headline` the finding's texts. The writer's statement covers the reader's open findings beside its observe's. Writer +// is the part's *Writer[F, C, S, A]. +type WriterFindings struct { + Writer wireWriter +} + +func (o WriterFindings) body(f Finding) map[string]any { + module, node := o.Writer.ModuleNode() + sev := "warning" if f.Kind == TooLargeKind { - b.SetSeverity("urgent") + sev = "urgent" + } + subject := map[string]any{"scope": FindingScope, "id": module} + if node != "" { + subject["machine"] = node + } + b := map[string]any{"kind": f.Kind, "scope": FindingScope, "id": keys.EscapeName(f.ID), "severity": sev, "source": "views", + "subject": subject, "summary": f.Summary} + if f.Headline != "" { + b["headline"] = f.Headline } - b.SetSource("views") - b.SetSubject(nil, o.Writer.cfg.Module, o.Writer.cfg.Node, FindingScope) - b.SetSummary(f.Summary) - b.SetHeadline(f.Headline) if !f.At.IsZero() { - b.SetAt(f.At) + b["at"] = f.At } return b } // Put writes one finding. func (o WriterFindings) Put(f Finding) error { - return o.Writer.Write(context.Background(), o.body(f)) + return o.Writer.WriteWire(context.Background(), o.body(f)) } // Delete clears one finding. func (o WriterFindings) Delete(f Finding) error { b := o.body(f) - return o.Writer.Clear(context.Background(), b.Scope(), b.ID(), b.Kind(), "the part no longer observes it") + return o.Writer.Clear(context.Background(), FindingScope, b["id"].(string), f.Kind, "the part no longer observes it") } diff --git a/go/views/writer.go b/go/views/writer.go index 7c01cb43..0acd499c 100644 --- a/go/views/writer.go +++ b/go/views/writer.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "os" "regexp" "slices" @@ -31,67 +32,45 @@ const ( ObserveRetryEvery = time.Minute ) -// FindingBody is one finding as the seat `mesh-conditions` lays it out (design 47 G §686, §640): the writer keeps no copy -// of that layout, it takes the type the seat generator emits through these accessors. A getter and a setter for each field -// the writer reads or fills; the subject is one pair, its fields as plain values in the generated struct's order (design 47 -// G A-1). A body also marshals as JSON to its layout, which is how it is kept and said. -type FindingBody interface { - Kind() string - SetKind(string) - Scope() string - SetScope(string) - ID() string - SetID(string) - At() time.Time - SetAt(time.Time) - Since() time.Time - SetSince(time.Time) - Severity() string - SetSeverity(string) - Summary() string - SetSummary(string) - Headline() string - SetHeadline(string) - Source() string - SetSource(string) - Subject() (also []string, id, machine, scope string) - SetSubject(also []string, id, machine, scope string) - Observer() string - SetObserver(string) +// A body is handled in its wire form (design 47 G A-3): the writer is generic over the contract's body types, which stay plain +// structs, and marshals a body, reads and sets `kind`, `scope`, `id`, `subject`, `at`, `since` and `observer` by their +// contract names. wire is that form. +type wire = map[string]any + +// wireOf is v in its wire form. +func wireOf(v any) (wire, error) { + raw, err := json.Marshal(v) + if err != nil { + return nil, err + } + var m wire + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("a body is not an object: %w", err) + } + return m, nil } -// ClearedBody is the body of `finding-cleared`, as the generated contract's type takes its fields (design 47 G A-1). -type ClearedBody interface { - SetKind(string) - SetScope(string) - SetID(string) - SetAt(time.Time) - SetWhy(string) -} - -// StatedBody is the body of `findings-stated`, as the generated contract's type takes its fields (design 47 G A-1): -// every finding the observer observes now, and each key it cannot judge yet, with its since and its bound (a duration -// such as 5m). -type StatedBody interface { - SetAt(time.Time) - SetAsked(string) - // SetKinds are the kinds the statement covers (design 47 G §580); none means every kind. - SetKinds([]string) - AddFinding(FindingBody) - AddPending(key string, since time.Time, bound string) -} - -// AskedBody is the body of `findings-asked` the writer reads: the ask's id and the module asked, none asking every -// observer (design 47 G A-1). -type AskedBody interface { - Ask() string - Module() string -} +func str(m wire, field string) string { s, _ := m[field].(string); return s } // FindingKey is the key of the finding of scope, id and kind: `..` (novox/hq ADR 0564 §2). func FindingKey(scope, id, kind string) string { return scope + "." + id + "." + kind } -func keyOf(b FindingBody) string { return FindingKey(b.Scope(), b.ID(), b.Kind()) } +func keyOf(m wire) string { return FindingKey(str(m, "scope"), str(m, "id"), str(m, "kind")) } + +// requireFields refuses a contract body whose wire form lacks a field the writer reads or sets (design 47 G A-3). +func requireFields[T any](what string, fields ...string) error { + var zero T + m, err := wireOf(&zero) + if err != nil { + return fmt.Errorf("the %s body of the contract cannot be read: %w", what, err) + } + for _, f := range fields { + if _, ok := m[f]; !ok { + return fmt.Errorf("the %s body of the contract has no field %q on its wire form", what, f) + } + } + return nil +} // ViewKinds are the finding kinds a views reader writes through the part's writer (design 47 G §639). var ViewKinds = []string{ViewWaitsKind, UnansweredKind, UnsaidKind, TooLargeKind} @@ -119,8 +98,8 @@ type PendingKey struct { } // Observed is what an observer's own truth says now: the findings it observes and the keys it cannot judge yet. -type Observed struct { - Findings []FindingBody +type Observed[F any] struct { + Findings []F Pending []PendingKey } @@ -139,7 +118,7 @@ type WriterSubjects struct { } // WriterConfig makes a Writer. -type WriterConfig struct { +type WriterConfig[F, C, S, A any] struct { // Module is the observer's module name: it fills the observer of a finding and tells an ask for another module. Module string // State is the observer's own state `findings`. @@ -153,18 +132,12 @@ type WriterConfig struct { Conn *nats.Conn // Subjects are the four events' subjects. Subjects WriterSubjects - // NewFinding, NewCleared, NewStated and NewAsked make an empty body of the generated contract's types (design 47 G - // §686, §640, A-1): the writer holds no layout of its own. All four are needed. - NewFinding func() FindingBody - NewCleared func() ClearedBody - NewStated func() StatedBody - NewAsked func() AskedBody // Node is the machine the module runs on, which a views reader's findings name in their subject (design 47 G §687); // none given, MESH_NODE. Node string // Observe answers the findings the observer observes now and its pending keys, or an error. It is called under the // writer's one lock, so it must not call Write or Clear. - Observe func(ctx context.Context) (Observed, error) + Observe func(ctx context.Context) (Observed[F], error) // Send puts a set larger than one bus message in the observer's sent store and answers what to publish instead // (stateobjects.Sender.Send); nil sends the body as it is. Send func(label string, body []byte) ([]byte, error) @@ -181,8 +154,8 @@ type WriterConfig struct { // Writer keeps an observer's findings and says them (design 47 G §461, §485). Its acts, Write, Clear and a statement, // run one at a time in the order they are asked. -type Writer struct { - cfg WriterConfig +type Writer[F, C, S, A any] struct { + cfg WriterConfig[F, C, S, A] mu sync.Mutex // the one lock of the acts seq uint64 ctl sync.Mutex // guards the consumer, the asks and the loop's context @@ -195,7 +168,7 @@ type Writer struct { } // NewWriter is the writer for cfg. -func NewWriter(cfg WriterConfig) (*Writer, error) { +func NewWriter[F, C, S, A any](cfg WriterConfig[F, C, S, A]) (*Writer[F, C, S, A], error) { switch { case cfg.Module == "": return nil, errors.New("a finding writer needs its module's name") @@ -205,11 +178,21 @@ func NewWriter(cfg WriterConfig) (*Writer, error) { return nil, errors.New("a finding writer needs the observe function") case cfg.Subjects.Written == "" || cfg.Subjects.Cleared == "" || cfg.Subjects.Stated == "" || cfg.Subjects.Asked == "": return nil, errors.New("a finding writer needs the subjects of finding-written, finding-cleared, findings-stated and findings-asked") - case cfg.NewFinding == nil || cfg.NewCleared == nil || cfg.NewStated == nil || cfg.NewAsked == nil: - return nil, errors.New("a finding writer needs the generated contract's constructors of the finding, finding-cleared, findings-stated and findings-asked bodies") case cfg.JS == nil && (cfg.Publish == nil || cfg.Listen == nil): return nil, errors.New("a finding writer needs the bus connection's JetStream") } + if err := requireFields[F]("finding", "kind", "scope", "id", "subject", "at", "since", "observer"); err != nil { + return nil, err + } + if err := requireFields[C]("finding-cleared", "kind", "scope", "id", "at", "why"); err != nil { + return nil, err + } + if err := requireFields[S]("findings-stated", "at", "findings", "pending"); err != nil { + return nil, err + } + if err := requireFields[A]("findings-asked", "ask"); err != nil { + return nil, err + } if cfg.Node == "" { cfg.Node = os.Getenv("MESH_NODE") } @@ -237,7 +220,7 @@ func NewWriter(cfg WriterConfig) (*Writer, error) { if cfg.Listen == nil { cfg.Listen = jetStreamListen(cfg.JS) } - return &Writer{cfg: cfg, wake: make(chan struct{}, 1)}, nil + return &Writer[F, C, S, A]{cfg: cfg, wake: make(chan struct{}, 1)}, nil } func jetStreamPublish(js jetstream.JetStream) func(context.Context, string, []byte, string) error { @@ -274,7 +257,7 @@ func jetStreamListen(js jetstream.JetStream) func(context.Context, string, func( // Start makes the consumer of `findings-asked` and then states the findings now, and again on each ask and each time the // connection is made again (design 47 G §384, §485). It returns once the consumer exists; the statements run in the // writer's own goroutine until ctx ends or Close. -func (w *Writer) Start(ctx context.Context) error { +func (w *Writer[F, C, S, A]) Start(ctx context.Context) error { w.ctl.Lock() defer w.ctl.Unlock() if w.run != nil { @@ -302,7 +285,7 @@ func (w *Writer) Start(ctx context.Context) error { } // Close ends the writer's consumer and its statements. -func (w *Writer) Close() { +func (w *Writer[F, C, S, A]) Close() { w.ctl.Lock() defer w.ctl.Unlock() if w.stop != nil { @@ -314,7 +297,7 @@ func (w *Writer) Close() { } } -func (w *Writer) listen() error { +func (w *Writer[F, C, S, A]) listen() error { stop, err := w.cfg.Listen(w.run, w.cfg.Subjects.Asked, w.heard) if err != nil { return err @@ -328,7 +311,7 @@ func (w *Writer) listen() error { // Reconnected is told that the bus connection was made again: the writer makes its consumer again, from the newest ask, // and states its findings again (design 47 G §485). -func (w *Writer) Reconnected() { +func (w *Writer[F, C, S, A]) Reconnected() { w.ctl.Lock() defer w.ctl.Unlock() if w.run == nil || w.run.Err() != nil { @@ -341,7 +324,7 @@ func (w *Writer) Reconnected() { w.signal() } -func (w *Writer) signal() { +func (w *Writer[F, C, S, A]) signal() { select { case w.wake <- struct{}{}: default: @@ -349,22 +332,27 @@ func (w *Writer) signal() { } // heard is an ask: answered when it names no module or this one. -func (w *Writer) heard(data []byte) { - a := w.cfg.NewAsked() - if err := json.Unmarshal(data, a); err != nil { +func (w *Writer[F, C, S, A]) heard(data []byte) { + var body A + if err := json.Unmarshal(data, &body); err != nil { w.cfg.Logf("an ask for findings could not be read: %v", err) return } - if a.Module() != "" && a.Module() != w.cfg.Module { + a, err := wireOf(&body) + if err != nil { + w.cfg.Logf("an ask for findings could not be read: %v", err) + return + } + if str(a, "module") != "" && str(a, "module") != w.cfg.Module { return } w.ctl.Lock() - w.asks, w.want = append(w.asks, a.Ask()), true + w.asks, w.want = append(w.asks, str(a, "ask")), true w.ctl.Unlock() w.signal() } -func (w *Writer) loop(ctx context.Context) { +func (w *Writer[F, C, S, A]) loop(ctx context.Context) { for { select { case <-ctx.Done(): @@ -397,12 +385,12 @@ func (w *Writer) loop(ctx context.Context) { // owns says whether the writer's statement covers kind: every kind when none were given (design 47 G §580), else its // own and the views reader's. -func (w *Writer) owns(kind string) bool { +func (w *Writer[F, C, S, A]) owns(kind string) bool { return len(w.cfg.Kinds) == 0 || slices.Contains(w.cfg.Kinds, kind) || slices.Contains(ViewKinds, kind) } // statedKinds are the kinds a statement names: none when the writer owns every kind. -func (w *Writer) statedKinds() []string { +func (w *Writer[F, C, S, A]) statedKinds() []string { if len(w.cfg.Kinds) == 0 { return nil } @@ -412,7 +400,7 @@ func (w *Writer) statedKinds() []string { } // ownsKey says whether key is one of a kind the writer owns: its last part is the kind (`..`). -func (w *Writer) ownsKey(key string) bool { +func (w *Writer[F, C, S, A]) ownsKey(key string) bool { if len(w.cfg.Kinds) == 0 { return true } @@ -420,20 +408,18 @@ func (w *Writer) ownsKey(key string) bool { return i >= 0 && w.owns(key[i+1:]) } -func (w *Writer) now() time.Time { return w.cfg.Now().UTC() } +func (w *Writer[F, C, S, A]) now() time.Time { return w.cfg.Now().UTC() } // unsaid is a finding as compared to say whether what it says changed: not the time it was last seen, nor what saw it. -func unsaid(f FindingBody) string { - raw, _ := json.Marshal(f) - var m map[string]any - _ = json.Unmarshal(raw, &m) +func unsaid(f wire) string { + m := maps.Clone(f) delete(m, "at") delete(m, "run") - raw, _ = json.Marshal(m) + raw, _ := json.Marshal(m) return string(raw) } -func (w *Writer) read(key string) (FindingBody, error) { +func (w *Writer[F, C, S, A]) read(key string) (wire, error) { e, err := w.cfg.State.Get(key) if err != nil { return nil, err @@ -441,8 +427,8 @@ func (w *Writer) read(key string) (FindingBody, error) { if e == nil { return nil, nil } - old := w.cfg.NewFinding() - if err := json.Unmarshal(e.Value, old); err != nil { + var old wire + if err := json.Unmarshal(e.Value, &old); err != nil { return nil, fmt.Errorf("the finding %s in the state findings: %w", key, err) } return old, nil @@ -450,73 +436,88 @@ func (w *Writer) read(key string) (FindingBody, error) { // CheckFinding refuses a finding the keeper could not make a condition of, naming the field of the first rule it breaks: // the key's three parts (`scope`, `id` and `kind`, each given, the kind with no dot, since the kind is the key's last -// part), and the forms of `owner` and `waiting-on` (design 47 G §678, §689). The writer refuses such a finding before it -// writes it, and the keeper judges the findings others write with it. -func CheckFinding(b FindingBody) error { +// part), and the forms of `owner` and `waiting-on` (design 47 G §678, §689). It judges the finding's wire form (design 47 +// G A-3): any value that marshals as a finding, the contract's generated type among them. The writer refuses such a +// finding before it writes it, and the keeper judges the findings others write with it. +func CheckFinding(finding any) error { + m, ok := finding.(wire) + if !ok { + var err error + if m, err = wireOf(finding); err != nil { + return fmt.Errorf("the finding could not be read: %w", err) + } + } switch { - case strings.TrimSpace(b.Scope()) == "": + case strings.TrimSpace(str(m, "scope")) == "": return errors.New("the finding's scope is not given") - case strings.TrimSpace(b.ID()) == "": + case strings.TrimSpace(str(m, "id")) == "": return errors.New("the finding's id is not given") - case strings.TrimSpace(b.Kind()) == "": + case strings.TrimSpace(str(m, "kind")) == "": return errors.New("the finding's kind is not given") - case strings.Contains(b.Kind(), "."): - return fmt.Errorf("the finding's kind %q holds a dot, and the kind is the last part of its key", b.Kind()) + case strings.Contains(str(m, "kind"), "."): + return fmt.Errorf("the finding's kind %q holds a dot, and the kind is the last part of its key", str(m, "kind")) } - raw, err := json.Marshal(b) - if err != nil { - return fmt.Errorf("the finding could not be read: %w", err) - } - var who struct { - Owner string `json:"owner"` - WaitingOn string `json:"waiting-on"` - } - if err := json.Unmarshal(raw, &who); err != nil { - return fmt.Errorf("the finding could not be read: %w", err) - } - if err := formOfWho("owner", who.Owner); err != nil { + if err := formOfWho("owner", str(m, "owner")); err != nil { return err } - return formOfWho("waiting-on", who.WaitingOn) + return formOfWho("waiting-on", str(m, "waiting-on")) } // complete fills what the observer leaves to the writer and keeps the key's `since` where the key exists (design 47 G §384). -func (w *Writer) complete(f FindingBody) (FindingBody, FindingBody, error) { +func (w *Writer[F, C, S, A]) complete(finding F) (wire, wire, error) { + f, err := wireOf(&finding) + if err != nil { + return nil, nil, fmt.Errorf("the finding could not be read: %w", err) + } if err := CheckFinding(f); err != nil { return f, nil, err } - if !w.owns(f.Kind()) { - return f, nil, fmt.Errorf("the finding kind %q is not one this writer owns (%v)", f.Kind(), w.cfg.Kinds) + if !w.owns(str(f, "kind")) { + return f, nil, fmt.Errorf("the finding kind %q is not one this writer owns (%v)", str(f, "kind"), w.cfg.Kinds) } old, err := w.read(keyOf(f)) if err != nil { return f, nil, err } - if f.Observer() == "" { - f.SetObserver(w.cfg.Module) + if str(f, "observer") == "" { + f["observer"] = w.cfg.Module } - at := f.At() + at, _ := time.Parse(time.RFC3339Nano, str(f, "at")) if at.IsZero() { at = w.now() } - f.SetAt(at.UTC()) - since := f.Since() - switch { - case old != nil && !old.Since().IsZero(): - since = old.Since() - case since.IsZero(): - since = f.At() + f["at"] = at.UTC() + since, _ := time.Parse(time.RFC3339Nano, str(f, "since")) + if old != nil { + if os, _ := time.Parse(time.RFC3339Nano, str(old, "since")); !os.IsZero() { + since = os + } } - f.SetSince(since.UTC()) + if since.IsZero() { + since = at + } + f["since"] = since.UTC() return f, old, nil } +// WriteWire is Write of a finding given in its wire form (a views reader's findings, design 47 G §687). +func (w *Writer[F, C, S, A]) WriteWire(ctx context.Context, finding map[string]any) error { + var f F + if err := fillFrom(&f, finding); err != nil { + return err + } + return w.Write(ctx, f) +} + +// ModuleNode is the module and the node the writer's findings are about. +func (w *Writer[F, C, S, A]) ModuleNode() (string, string) { return w.cfg.Module, w.cfg.Node } + // Write keeps the finding in its key and says `finding-written` only when what the finding says changed (design 47 G // §383, §461). The key's `since` stays where the key exists. -func (w *Writer) Write(ctx context.Context, f FindingBody) error { +func (w *Writer[F, C, S, A]) Write(ctx context.Context, finding F) error { w.mu.Lock() defer w.mu.Unlock() - f, old, err := w.complete(f) + f, old, err := w.complete(finding) if err != nil { return err } @@ -533,7 +534,7 @@ func (w *Writer) Write(ctx context.Context, f FindingBody) error { // Clear deletes the finding's key and says `finding-cleared`; a key that does not exist is deleted of nothing and is said // all the same, so a keeper holding the finding clears it (design 47 G §485). -func (w *Writer) Clear(ctx context.Context, scope, id, kind, why string) error { +func (w *Writer[F, C, S, A]) Clear(ctx context.Context, scope, id, kind, why string) error { w.mu.Lock() defer w.mu.Unlock() key := FindingKey(scope, id, kind) @@ -543,13 +544,11 @@ func (w *Writer) Clear(ctx context.Context, scope, id, kind, why string) error { if err := w.cfg.State.Delete(key); err != nil { return fmt.Errorf("clearing the finding %s: %w", key, err) } - cleared := w.cfg.NewCleared() - cleared.SetKind(kind) - cleared.SetScope(scope) - cleared.SetID(id) - cleared.SetAt(w.now()) - cleared.SetWhy(why) - raw, _ := json.Marshal(cleared) + var cleared C + if err := fillFrom(&cleared, wire{"kind": kind, "scope": scope, "id": id, "at": w.now(), "why": why}); err != nil { + return err + } + raw, _ := json.Marshal(&cleared) w.say(ctx, w.cfg.Subjects.Cleared, "finding-cleared", key, raw) return nil } @@ -557,7 +556,7 @@ func (w *Writer) Clear(ctx context.Context, scope, id, kind, why string) error { // State judges every finding anew with observe, writes each stated finding's key, deletes each key neither stated nor // pending, and says `findings-stated` (design 47 G §384, §461); asked is the ask it answers, none at a start. An error of // observe says no set. -func (w *Writer) State(ctx context.Context, asked string) error { +func (w *Writer[F, C, S, A]) State(ctx context.Context, asked string) error { w.mu.Lock() defer w.mu.Unlock() obs, err := w.cfg.Observe(ctx) @@ -565,12 +564,9 @@ func (w *Writer) State(ctx context.Context, asked string) error { return fmt.Errorf("observe: %w", err) } keep := map[string]bool{} - body := w.cfg.NewStated() - body.SetAt(w.now()) - body.SetAsked(asked) - body.SetKinds(w.statedKinds()) - for _, f := range obs.Findings { - f, _, err := w.complete(f) + findings, pending := []wire{}, []wire{} + for _, finding := range obs.Findings { + f, _, err := w.complete(finding) if err != nil { return err } @@ -578,11 +574,11 @@ func (w *Writer) State(ctx context.Context, asked string) error { return fmt.Errorf("writing the finding %s: %w", keyOf(f), err) } keep[keyOf(f)] = true - body.AddFinding(f) + findings = append(findings, f) } for _, p := range obs.Pending { keep[p.Key] = true - body.AddPending(p.Key, p.Since, p.Bound) + pending = append(pending, wire{"key": p.Key, "since": p.Since, "bound": p.Bound}) } keys, err := w.cfg.State.Keys() if err != nil { @@ -594,7 +590,7 @@ func (w *Writer) State(ctx context.Context, asked string) error { if i := strings.LastIndex(k, "."); !keep[k] && i >= 0 && slices.Contains(ViewKinds, k[i+1:]) { if old, err := w.read(k); err == nil && old != nil { keep[k] = true - body.AddFinding(old) + findings = append(findings, old) } } if !keep[k] && w.ownsKey(k) { @@ -603,7 +599,18 @@ func (w *Writer) State(ctx context.Context, asked string) error { } } } - raw, _ := json.Marshal(body) + stated := wire{"at": w.now(), "findings": findings, "pending": pending} + if asked != "" { + stated["asked"] = asked + } + if k := w.statedKinds(); k != nil { + stated["kinds"] = k + } + var body S + if err := fillFrom(&body, stated); err != nil { + return err + } + raw, _ := json.Marshal(&body) if w.cfg.Send != nil { if raw, err = w.cfg.Send("findings-stated."+w.cfg.Module, raw); err != nil { w.cfg.Logf("the findings-stated of %s could not be sent, the next statement covers it: %v", w.cfg.Module, err) @@ -616,7 +623,7 @@ func (w *Writer) State(ctx context.Context, asked string) error { // say publishes one event awaiting the bus's acknowledgement, sent again for PublishRetryFor when unacknowledged, then // written in the journal as an error naming what it said (design 47 G §383); the next statement covers it. -func (w *Writer) say(ctx context.Context, subject, event, what string, data []byte) { +func (w *Writer[F, C, S, A]) say(ctx context.Context, subject, event, what string, data []byte) { w.seq++ id := fmt.Sprintf("%s-%s-%d-%d", w.cfg.Module, event, w.now().UnixNano(), w.seq) start := w.now() @@ -631,3 +638,12 @@ func (w *Writer) say(ctx context.Context, subject, event, what string, data []by } } } + +// fillFrom sets the contract body v from its wire form. +func fillFrom(v any, m wire) error { + raw, err := json.Marshal(m) + if err != nil { + return err + } + return json.Unmarshal(raw, v) +} diff --git a/go/views/writer_integration_test.go b/go/views/writer_integration_test.go index c1d54e5d..e89ce052 100644 --- a/go/views/writer_integration_test.go +++ b/go/views/writer_integration_test.go @@ -43,11 +43,10 @@ func TestWriterSaysItsEventsAndHearsAsksOnTheBus(t *testing.T) { t.Fatal(err) } state := &fakeState{m: map[string][]byte{}} - w, err := views.NewWriter(views.WriterConfig{Module: "m", State: state, JS: js, Conn: nc, Node: "n1", - NewFinding: bodies.NewFinding, NewCleared: bodies.NewCleared, NewStated: bodies.NewStated, NewAsked: bodies.NewAsked, + w, err := views.NewWriter(testConfig{Module: "m", State: state, JS: js, Conn: nc, Node: "n1", Subjects: views.WriterSubjects{Written: "obs.event.written", Cleared: "obs.event.cleared", Stated: "obs.event.stated", Asked: "obs.ask"}, - Observe: func(context.Context) (views.Observed, error) { - return views.Observed{Findings: []views.FindingBody{finding("a", "now")}}, nil + Observe: func(context.Context) (testObserved, error) { + return testObserved{Findings: []testFinding{finding("a", "now")}}, nil }, Logf: t.Logf}) if err != nil { t.Fatal(err) diff --git a/go/views/writer_test.go b/go/views/writer_test.go index 1815f1fa..36ce4d50 100644 --- a/go/views/writer_test.go +++ b/go/views/writer_test.go @@ -67,11 +67,11 @@ type rig struct { fail int // publishes refused before one is taken waits []time.Duration logs []string - observe func(context.Context) (views.Observed, error) + observe func(context.Context) (testObserved, error) handle func([]byte) listens int stopped int - w *views.Writer + w *testWriter ch chan struct{} } @@ -84,11 +84,10 @@ func newRig(t *testing.T) *rig { return newRigKinds(t) } func newRigKinds(t *testing.T, kinds ...string) *rig { r := &rig{t: t, state: &fakeState{m: map[string][]byte{}}, now: time.Date(2026, 11, 11, 12, 0, 0, 0, time.UTC), ch: make(chan struct{}, 100)} - r.observe = func(context.Context) (views.Observed, error) { return views.Observed{}, nil } - w, err := views.NewWriter(views.WriterConfig{ + r.observe = func(context.Context) (testObserved, error) { return testObserved{}, nil } + w, err := views.NewWriter(testConfig{ Module: "m", State: r.state, Subjects: subjects, Kinds: kinds, Node: "n1", - NewFinding: bodies.NewFinding, NewCleared: bodies.NewCleared, NewStated: bodies.NewStated, NewAsked: bodies.NewAsked, - Observe: func(ctx context.Context) (views.Observed, error) { return r.observe(ctx) }, + Observe: func(ctx context.Context) (testObserved, error) { return r.observe(ctx) }, Logf: func(f string, a ...any) { r.mu.Lock() r.logs = append(r.logs, fmt.Sprintf(f, a...)) @@ -137,9 +136,9 @@ func (r *rig) events() []said { return slices.Clone(r.sent) } -func finding(id, summary string) *testFinding { - return &testFinding{KindField: "k", ScopeField: "module", IDField: id, SeverityVal: "warning", SummaryVal: summary, SourceVal: "probe", - SubjectVal: testSubject{Scope: "module", ID: id}} +func finding(id, summary string) testFinding { + return testFinding{Kind: "k", Scope: "module", ID: id, Severity: "warning", Summary: summary, Source: "probe", + Subject: testSubject{Scope: "module", ID: id}} } // A finding written says its event with the layout whole after the key, keeps the key's since, and says again only when @@ -168,7 +167,7 @@ func TestWriterWritesAndClearsFindingsAsEvents(t *testing.T) { if err := json.Unmarshal(ev[1].data, &got); err != nil { t.Fatal(err) } - if got.SummaryVal != "two" || !got.SinceVal.Equal(first) || got.ObserverVal != "m" || views.FindingKey(got.ScopeField, got.IDField, got.KindField) != "module.a.k" || !got.AtVal.Equal(r.now) { + if got.Summary != "two" || !got.Since.Equal(first) || got.Observer != "m" || views.FindingKey(got.Scope, got.ID, got.Kind) != "module.a.k" || !got.At.Equal(r.now) { t.Fatalf("body: %+v", got) } if e, _ := r.state.Get("module.a.k"); e == nil { @@ -222,14 +221,14 @@ func TestWriterStatesAtStartAndOnAsksAndRecovers(t *testing.T) { r.state.Put("module.old.k", finding("old", "x")) r.state.Put("module.wait.k", finding("wait", "x")) first := r.now - r.state.Put("module.a.k", func() *testFinding { f := finding("a", "before"); f.SinceVal = first.Add(-time.Hour); return f }()) + r.state.Put("module.a.k", func() testFinding { f := finding("a", "before"); f.Since = first.Add(-time.Hour); return f }()) calls := 0 - r.observe = func(context.Context) (views.Observed, error) { + r.observe = func(context.Context) (testObserved, error) { calls++ if calls <= 2 { - return views.Observed{}, errors.New("not yet") + return testObserved{}, errors.New("not yet") } - return views.Observed{Findings: []views.FindingBody{finding("a", "now")}, + return testObserved{Findings: []testFinding{finding("a", "now")}, Pending: []views.PendingKey{{Key: "module.wait.k", Since: first, Bound: "5m"}}}, nil } if err := r.w.Start(ctx); err != nil { @@ -244,8 +243,8 @@ func TestWriterStatesAtStartAndOnAsksAndRecovers(t *testing.T) { if err := json.Unmarshal(stated.data, &st); err != nil { t.Fatal(err) } - if stated.subject != subjects.Stated || st.Asked != "" || len(st.Findings) != 1 || st.Findings[0].SummaryVal != "now" || - !st.Findings[0].SinceVal.Equal(first.Add(-time.Hour)) || len(st.Pending) != 1 || st.Pending[0].Key != "module.wait.k" { + if stated.subject != subjects.Stated || st.Asked != "" || len(st.Findings) != 1 || st.Findings[0].Summary != "now" || + !st.Findings[0].Since.Equal(first.Add(-time.Hour)) || len(st.Pending) != 1 || st.Pending[0].Key != "module.wait.k" { t.Fatalf("statement: %s", stated.data) } keys, _ := r.state.Keys() @@ -291,10 +290,10 @@ func waitEvent(t *testing.T, r *rig, n int) said { func TestWriterRunsOneActAtATime(t *testing.T) { r := newRig(t) inside, release := make(chan struct{}), make(chan struct{}) - r.observe = func(context.Context) (views.Observed, error) { + r.observe = func(context.Context) (testObserved, error) { close(inside) <-release - return views.Observed{}, nil + return testObserved{}, nil } done := make(chan error, 1) go func() { done <- r.w.State(context.Background(), "") }() @@ -323,8 +322,8 @@ func TestWriterKindsAndReaderFindings(t *testing.T) { ctx := context.Background() r := newRigKinds(t, "k") other := finding("x", "another bundle's") - other.KindField = "other" - otherKey := views.FindingKey(other.ScopeField, other.IDField, other.KindField) + other.Kind = "other" + otherKey := views.FindingKey(other.Scope, other.ID, other.Kind) r.state.m[otherKey] = []byte(`{}`) if err := r.w.Write(ctx, other); err == nil || !strings.Contains(err.Error(), `"other"`) { t.Errorf("a write of another kind: %v", err) @@ -347,12 +346,12 @@ func TestWriterKindsAndReaderFindings(t *testing.T) { } } // a views reader writes through the part's writer, and the statement keeps its open findings - rf := views.WriterFindings{Writer: r.w, New: bodies.NewFinding} + rf := views.WriterFindings{Writer: r.w} if err := rf.Put(views.Finding{Kind: views.ViewWaitsKind, ID: "m.o.s", Summary: "waits"}); err != nil { t.Fatal(err) } - r.observe = func(context.Context) (views.Observed, error) { - return views.Observed{Findings: []views.FindingBody{finding("a", "observed")}}, nil + r.observe = func(context.Context) (testObserved, error) { + return testObserved{Findings: []testFinding{finding("a", "observed")}}, nil } if err := r.w.State(ctx, ""); err != nil { t.Fatal(err) @@ -389,7 +388,7 @@ func TestWriterKindsAndReaderFindings(t *testing.T) { // (§689). func TestReaderFindingsAreLaidOutAndCheckFindingRefuses(t *testing.T) { r := newRig(t) - rf := views.WriterFindings{Writer: r.w, New: bodies.NewFinding} + rf := views.WriterFindings{Writer: r.w} for _, kind := range views.ViewKinds { if err := rf.Put(views.Finding{Kind: kind, ID: "o.s.main", Summary: "sum " + kind, Headline: "head"}); err != nil { t.Fatal(err) @@ -403,21 +402,21 @@ func TestReaderFindingsAreLaidOutAndCheckFindingRefuses(t *testing.T) { if kind == views.TooLargeKind { sev = "urgent" } - if got.KindField != kind || got.ScopeField != "module" || got.IDField != "o=2es=2emain" || got.SeverityVal != sev || got.SourceVal != "views" || - got.SubjectVal.Scope != "module" || got.SubjectVal.ID != "m" || got.SubjectVal.Machine != "n1" || got.SummaryVal != "sum "+kind || got.HeadlineVal != "head" || got.ObserverVal != "m" { + if got.Kind != kind || got.Scope != "module" || got.ID != "o=2es=2emain" || got.Severity != sev || got.Source != "views" || + got.Subject.Scope != "module" || got.Subject.ID != "m" || got.Subject.Machine != "n1" || got.Summary != "sum "+kind || got.Headline != "head" || got.Observer != "m" { t.Errorf("%s laid out as %+v", kind, got) } } for name, c := range map[string]struct { - f *testFinding + f testFinding want string }{ - "scope": {&testFinding{IDField: "a", KindField: "k"}, "scope"}, - "id": {&testFinding{ScopeField: "module", KindField: "k"}, "id"}, - "kind": {&testFinding{ScopeField: "module", IDField: "a"}, "kind"}, - "dotted": {&testFinding{ScopeField: "module", IDField: "a", KindField: "k.x"}, "kind"}, - "owner": {&testFinding{ScopeField: "module", IDField: "a", KindField: "k", Owner: "Not A Form"}, "owner"}, - "waiting-on": {&testFinding{ScopeField: "module", IDField: "a", KindField: "k", WaitingOn: "x y"}, "waiting-on"}, + "scope": {testFinding{ID: "a", Kind: "k"}, "scope"}, + "id": {testFinding{Scope: "module", Kind: "k"}, "id"}, + "kind": {testFinding{Scope: "module", ID: "a"}, "kind"}, + "dotted": {testFinding{Scope: "module", ID: "a", Kind: "k.x"}, "kind"}, + "owner": {testFinding{Scope: "module", ID: "a", Kind: "k", Owner: "Not A Form"}, "owner"}, + "waiting-on": {testFinding{Scope: "module", ID: "a", Kind: "k", WaitingOn: "x y"}, "waiting-on"}, } { if err := views.CheckFinding(c.f); err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s: %v", name, err) @@ -430,3 +429,20 @@ func TestReaderFindingsAreLaidOutAndCheckFindingRefuses(t *testing.T) { t.Error(err) } } + +// A finding body whose wire form lacks a field the contract names is refused when the writer is made, naming the field +// (design 47 G A-3). +func TestWriterRefusesABodyWithoutAContractField(t *testing.T) { + type noSince struct { + Kind, Scope, ID, Observer string + At time.Time `json:"at"` + Subject testSubject + } + _, err := views.NewWriter(views.WriterConfig[noSince, testCleared, testStated, testAsked]{Module: "m", State: &fakeState{m: map[string][]byte{}}, + Subjects: subjects, Observe: func(context.Context) (views.Observed[noSince], error) { return views.Observed[noSince]{}, nil }, + Publish: func(context.Context, string, []byte, string) error { return nil }, + Listen: func(context.Context, string, func([]byte)) (func(), error) { return func() {}, nil }}) + if err == nil || !strings.Contains(err.Error(), `"kind"`) { + t.Fatalf("a body of other wire names: %v", err) + } +}