Files
mesh-delivery/modules/mesh-delivery-sdk/sourcebranches/sourcebranches.go
T
jschoubben 71a35a6376 MV1: move contracts and sdk under modules/, importers follow
The delivery repository's contracts and SDK live under modules/ as
mesh-delivery-contracts and mesh-delivery-sdk, their Go module paths
renamed to match the folders (the TypeScript names already did), and
every importer in mesh-delivery, mesh-catalog, mesh-host, mesh-tools
and mesh-sdk follows in the same group: a design as it should be, one
path per module, checked by the group's check building every importer.

Builds: design 47 G §427, §88

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:09:56 +01:00

137 lines
4.8 KiB
Go

// Package sourcebranches is the follower of the branches the seat mesh-delivery's verb source keeps (design 47 G §614),
// in the Delivery domain's SDK so that a seat's client holds no other seat's types (ADR 0625 §4): the first view of the
// verb given no branch, then each source-moved heard, and the asking again until a change heard is held.
package sourcebranches
import (
"context"
"encoding/json"
"fmt"
"slices"
"time"
delivery "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/views"
)
// Seat is the seat whose verb source and event source-moved are followed.
const Seat = delivery.Seat
// EventSourceMoved is the event the seat says when a branch's source moved.
const EventSourceMoved = delivery.EventSourceMoved
// SourceVerb is the gate's verb a part reads the branches the gate keeps a source revision of by, before the first
// registrations, as its manifest's invokes names it (design 47 G §614).
const SourceVerb = "seat:" + delivery.Seat + "." + delivery.VerbSource
// StateSourceBranches names the view of the branches the gate's source keeps in a part's reader (`mesh-delivery.source`).
const StateSourceBranches = "source"
// Call asks one seat verb over the bus and answers what the holder said, whatever wraps it (process.Conn.Call, or
// stdio.Ask through the tool runner).
type Call func(key string, body any) (json.RawMessage, error)
// Created stands for the picture's Created in a follower: followed by the seat's event, never by an owner's views family,
// so one fixed time, never zero.
var Created = time.Unix(0, 0).UTC()
// ClipBytes is data cut to 200 bytes for a message.
func ClipBytes(b []byte) string {
if len(b) > 200 {
return string(b[:200]) + "…"
}
return string(b)
}
// SourceBranches is a part's follower of the branches the gate's `source` keeps (design 47 G §614): a first view of the
// verb `source` given no branch, then each `source-moved` heard. Its picture is keyed by the escaped branch name.
type SourceBranches struct {
Follower *views.Follower
call Call
}
// New is a SourceBranches asking the gate through call. Nothing is asked before the reader starts it.
func New(call Call) *SourceBranches {
s := &SourceBranches{call: call}
s.Follower = &views.Follower{Owner: Seat, Tool: SourceVerb, State: StateSourceBranches, Ask: s.ask}
return s
}
// Branches is the escaped name of each branch the picture holds.
func (s *SourceBranches) Branches() []string {
out := make([]string, 0)
for b := range s.Follower.Values() {
out = append(out, b)
}
slices.Sort(out)
return out
}
// Hear takes one `source-moved` event: a branch the picture does not hold asks the first view again until it does, on
// its own goroutine, so the consumer is never held. Data that is no such event is an error.
func (s *SourceBranches) Hear(ctx context.Context, data []byte) error {
var ev delivery.SourceMovedEvent
if err := json.Unmarshal(data, &ev); err != nil || ev.Branch == "" {
return fmt.Errorf("sourcebranches: not a %s event: %s", EventSourceMoved, ClipBytes(data))
}
held := func() bool { _, ok := s.Follower.Get(keys.EscapeName(ev.Branch)); return ok }
if held() {
return nil
}
go CatchUp(ctx, s.Follower, held, fmt.Sprintf("source of %s moved", ev.Branch))
return nil
}
func (s *SourceBranches) ask(_ context.Context, _ string) (views.Answer, error) {
raw, err := s.call(SourceVerb, delivery.SourceInput{})
if err != nil {
return views.Answer{}, err
}
answer, said, ok := buscalls.Unwrap(raw)
if !ok {
return views.Answer{}, fmt.Errorf("%s answered: %s", SourceVerb, said)
}
var a delivery.SourceAnswer
if err := json.Unmarshal(answer, &a); err != nil {
return views.Answer{}, fmt.Errorf("%s answered no branches: %s", SourceVerb, ClipBytes(answer))
}
if a.Error != "" {
return views.Answer{}, fmt.Errorf("%s answered: %s", SourceVerb, a.Error)
}
values := make(map[string]json.RawMessage, len(a.Branches))
for _, b := range a.Branches {
values[b] = json.RawMessage(`{}`)
}
return views.Answer{V: 1, Owner: Seat, State: StateSourceBranches, Created: Created, Values: values, At: time.Now()}, nil
}
// CatchUp asks f's first view again until held says it holds what was heard, pausing between asks as the follower's Wait
// says (doubling to a minute), or until ctx ends: an ask already running may have been answered before the change was
// written.
func CatchUp(ctx context.Context, f *views.Follower, held func() bool, why string) {
wait := f.Wait
if wait == 0 {
wait = 5 * time.Second
}
for !held() {
if err := f.Gap(ctx, why); err != nil {
return
}
if held() {
return
}
p, cancel := context.WithTimeout(ctx, wait)
<-p.Done()
cancel()
if ctx.Err() != nil {
return
}
if wait *= 2; wait > time.Minute {
wait = time.Minute
}
}
}