From ca4aa885838a6b1d9f1a20a7a73cf139ad4dc77e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:02:58 +0100 Subject: [PATCH] IX8: the indexer writes each entry's edges of kinds 1, 2, 3, 4 and 6 into the revision it keeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entry's edges run between artifact references, from the artifact that comes first: kind 1 from the publisher of a package it builds against, 2 from an artifact its `needs` names, 3 from its build module and its bases, 4 from the provider of a provision a want of it waits for, 6 from the server of a verb it invokes (none on a cycle). Kind 5 waits on a design line (A-212). Builds: design 47 G §47, §48, §49, §50, §121, §122, §378, §432 (A-208, A-212) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../internal/index/catalog_edges.go | 168 ++++++++++++++++++ .../internal/index/catalog_edges_test.go | 66 +++++++ .../internal/index/catalog_revisions.go | 1 + 3 files changed, 235 insertions(+) create mode 100644 modules/mesh-delivery-indexer/internal/index/catalog_edges.go create mode 100644 modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_edges.go b/modules/mesh-delivery-indexer/internal/index/catalog_edges.go new file mode 100644 index 00000000..1ede7055 --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/catalog_edges.go @@ -0,0 +1,168 @@ +package index + +import ( + "cmp" + "maps" + "slices" + "strings" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" +) + +// The edges of a catalog graph entry (design 47 G §47 to §50, §121; A-208): the indexer writes, in each entry of a revision, +// the edges between artifact references of kinds 1, 2, 3, 4 and 6 that the entry's own words and the other entries of the same +// revision say (the builder and the assigner read them from the entry, never derive their own). Kind 5 waits on a design line (A-212). An edge runs from B, the artifact that comes first, to A, the artifact that waits for it. + +// WithEdges gives each entry of a revision its edges. The entries given are not changed. +func WithEdges(entries map[string]meshcatalog.CatalogEntry) map[string]meshcatalog.CatalogEntry { + out := make(map[string]meshcatalog.CatalogEntry, len(entries)) + for module, e := range entries { + e.Edges = edgesOf(e, entries) + out[module] = e + } + return out +} + +// primaryOf is the artifact an entry delivers: the one its first process resource runs, else its first artifact; empty for a +// module with no artifact. +func primaryOf(e meshcatalog.CatalogEntry) meshcatalog.ArtifactRef { + if resources, ok := e.Manifest["resources"].([]any); ok { + for _, r := range resources { + res, _ := r.(map[string]any) + if res["type"] != "process" { + continue + } + if name, _ := res["artifact"].(string); name != "" && slices.ContainsFunc(e.Artifacts, func(a meshcatalog.Artifact) bool { return a.Ref == ArtifactRefOf(e.Module, name) }) { + return ArtifactRefOf(e.Module, name) + } + } + } + if len(e.Artifacts) > 0 { + return e.Artifacts[0].Ref + } + return "" +} + +// ofLanguage are the artifacts of e a build of language (go or another) produces: its packages' and programs' of that language. +func ofLanguage(e meshcatalog.CatalogEntry, language string) []meshcatalog.ArtifactRef { + var out []meshcatalog.ArtifactRef + for _, a := range e.Artifacts { + if a.Language == language { + out = append(out, a.Ref) + } + } + return out +} + +func edgesOf(a meshcatalog.CatalogEntry, all map[string]meshcatalog.CatalogEntry) []meshcatalog.Edge { + var out []meshcatalog.Edge + add := func(e meshcatalog.Edge) { + if e.From != "" && e.To != "" && e.From != e.To && !slices.Contains(out, e) { + out = append(out, e) + } + } + modules := slices.Sorted(maps.Keys(all)) + delivers := primaryOf(a) + // Kind 1: a package its build imports, from the artifact that publishes it, to each artifact built from that ecosystem (§47). + for _, name := range slices.Sorted(maps.Keys(a.Packages)) { + for _, m := range modules { + b := all[m] + if m == a.Module { + continue + } + for _, art := range b.Artifacts { + if art.Package == nil || art.Package.Name != name { + continue + } + for _, to := range importersOf(a, art.Package.Ecosystem) { + add(meshcatalog.Edge{Kind: 1, From: art.Ref, To: to, Stale: true, Word: "packages", Why: name}) + } + } + } + } + // Kind 2: an artifact its manifest's `needs` references in another module, published before the artifact that names it + // delivers (A-212, §432). + for _, n := range needsOf(a) { + if owner := all[strings.SplitN(n.ref, ":", 2)[0]]; owner.Module != "" && owner.Module != a.Module && + slices.ContainsFunc(owner.Artifacts, func(x meshcatalog.Artifact) bool { return x.Ref == n.ref }) { + add(meshcatalog.Edge{Kind: 2, From: n.ref, To: n.by, Declared: true, Word: "needs", Why: n.ref}) + } + } + // Kind 3: the build module its language names and the modules it is built on (§47, §378). + for _, language := range slices.Sorted(maps.Keys(a.Toolchains)) { + builder, ok := all[a.Toolchains[language].Module] + if !ok || builder.Module == a.Module { + continue + } + for _, to := range ofLanguage(a, language) { + add(meshcatalog.Edge{Kind: 3, From: primaryOf(builder), To: to, Stale: true, Word: "toolchains", Why: language}) + } + } + for _, base := range a.Bases { + b, ok := all[base] + if !ok || b.Module == a.Module { + continue + } + for _, art := range a.Artifacts { + add(meshcatalog.Edge{Kind: 3, From: primaryOf(b), To: art.Ref, Stale: true, Word: "build.on", Why: base}) + } + } + // Kind 4: a provision it requires and a provider offers (§47): the provisions a want of it waits for. + for _, provision := range a.Awaits { + for _, m := range modules { + if b := all[m]; m != a.Module && (slices.Contains(b.Provides, provision) || m == provision) { + add(meshcatalog.Edge{Kind: 4, From: primaryOf(b), To: delivers, Word: "requires", Why: provision}) + } + } + } + // Kind 6: a verb or a tool it invokes and another entry serves (§47), unless its edge lies on a cycle and is left out (ADR 0614 §3). + for _, verb := range a.Invokes { + if slices.Contains(a.LeftOut, verb) { + continue + } + for _, m := range modules { + if b := all[m]; m != a.Module && slices.Contains(b.Serves, verb) { + add(meshcatalog.Edge{Kind: 6, From: primaryOf(b), To: delivers, Word: verb, Why: verb}) + } + } + } + slices.SortFunc(out, func(x, y meshcatalog.Edge) int { + return cmp.Or(cmp.Compare(x.Kind, y.Kind), strings.Compare(x.From, y.From), strings.Compare(x.To, y.To), strings.Compare(x.Word, y.Word), strings.Compare(x.Why, y.Why)) + }) + return out +} + +type need struct{ by, ref string } + +// needsOf are the artifact references each artifact of the entry's manifest names in its `needs`, with the artifact that names them. +func needsOf(e meshcatalog.CatalogEntry) []need { + build, _ := e.Manifest["build"].(map[string]any) + arts, _ := build["artifacts"].([]any) + var out []need + for _, x := range arts { + art, _ := x.(map[string]any) + name, _ := art["name"].(string) + list, _ := art["needs"].([]any) + for _, r := range list { + if ref, _ := r.(string); ref != "" && name != "" { + out = append(out, need{by: ArtifactRefOf(e.Module, name), ref: ref}) + } + } + } + return out +} + +// importersOf are the artifacts of a built from a package of ecosystem: a Go package by the Go artifacts, an npm package by +// the others that are no image. +func importersOf(a meshcatalog.CatalogEntry, ecosystem string) []meshcatalog.ArtifactRef { + var out []meshcatalog.ArtifactRef + for _, art := range a.Artifacts { + if art.Kind == "image" { + continue + } + if (ecosystem == EcosystemGo) == (art.Language == "go") && ecosystem != EcosystemImage { + out = append(out, art.Ref) + } + } + return out +} diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go b/modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go new file mode 100644 index 00000000..99116dd1 --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/catalog_edges_test.go @@ -0,0 +1,66 @@ +package index + +import ( + "context" + "slices" + "testing" + "time" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" +) + +// Design 47 G §47, §121, A-208, A-212: an entry's edges between artifact references are written from its own words and +// the other entries of the revision, kinds 1 (an imported package), 2 (a `needs`), 3 (build module and bases), 4 (an +// awaited provision) and 6 (an invoked verb, none that lies on a cycle), each from B to A. +func TestAnEntryCarriesItsEdgesOfKindsOneToFourAndSix(t *testing.T) { + entries := map[string]meshcatalog.CatalogEntry{ + "lib": {Module: "lib", Artifacts: []meshcatalog.Artifact{{Ref: "lib:go", Language: "go", Kind: "package", Package: &meshcatalog.ArtifactPackage{Ecosystem: EcosystemGo, Name: "git.example/lib/go"}}}}, + "build-go": {Module: "build-go", Artifacts: []meshcatalog.Artifact{{Ref: "build-go:program", Language: "go"}}, + Provides: []string{"builder"}, Serves: []string{"seat:mesh-builds.build"}}, + "db": {Module: "db", Artifacts: []meshcatalog.Artifact{{Ref: "db:image", Kind: "image"}}, Provides: []string{"sql"}}, + "cfg": {Module: "cfg", Artifacts: []meshcatalog.Artifact{{Ref: "cfg:archive", Kind: "archive"}}}, + "app": {Module: "app", Packages: map[string]string{"git.example/lib/go": "1.2.3"}, Bases: []string{"db"}, + Toolchains: map[string]meshcatalog.BuildEdge{"go": {Module: "build-go"}}, + Awaits: []string{"sql"}, Invokes: []string{"seat:mesh-builds.build", "seat:mesh-builds.cycle"}, LeftOut: []string{"seat:mesh-builds.cycle"}, + Manifest: map[string]any{"build": map[string]any{"artifacts": []any{map[string]any{"name": "program", "needs": []any{"cfg:archive", "nothing:here"}}}}}, + Artifacts: []meshcatalog.Artifact{{Ref: "app:program", Language: "go"}}}, + } + before := entries["app"] + got := WithEdges(entries)["app"].Edges + want := []meshcatalog.Edge{ + {Kind: 1, From: "lib:go", To: "app:program", Stale: true, Word: "packages", Why: "git.example/lib/go"}, + {Kind: 2, From: "cfg:archive", To: "app:program", Declared: true, Word: "needs", Why: "cfg:archive"}, + {Kind: 3, From: "build-go:program", To: "app:program", Stale: true, Word: "toolchains", Why: "go"}, + {Kind: 3, From: "db:image", To: "app:program", Stale: true, Word: "build.on", Why: "db"}, + {Kind: 4, From: "db:image", To: "app:program", Word: "requires", Why: "sql"}, + {Kind: 6, From: "build-go:program", To: "app:program", Word: "seat:mesh-builds.build", Why: "seat:mesh-builds.build"}, + } + if !slices.Equal(got, want) { + t.Errorf("edges\n got %+v\nwant %+v", got, want) + } + if len(before.Edges) != 0 || len(entries["app"].Edges) != 0 { + t.Errorf("the entries given were changed") + } +} + +// Design 47 G A-208: a revision kept holds each entry with its edges, so the builder and the assigner read them from the entry. +func TestAKeptRevisionHoldsTheEdgesOfItsEntries(t *testing.T) { + ctx := context.Background() + ix := &Indexer{Revisions: newMemObjects(1 << 20), Now: func() time.Time { return time.Date(2026, 11, 1, 3, 0, 0, 0, time.UTC) }} + err := ix.KeepBranchEntries(ctx, "main", 1, map[string]meshcatalog.CatalogEntry{ + "b": {Module: "b", Manifest: map[string]any{}, Artifacts: []meshcatalog.Artifact{{Ref: "b:p"}}, Serves: []string{"seat:s.v"}}, + "a": {Module: "a", Manifest: map[string]any{}, Artifacts: []meshcatalog.Artifact{{Ref: "a:p"}}, Invokes: []string{"seat:s.v"}}, + }) + if err != nil { + t.Fatal(err) + } + _, entries, err := ReadRevision(ctx, ix.Revisions, "trunk.1") + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.Module == "a" && (len(e.Edges) != 1 || e.Edges[0].Kind != 6 || e.Edges[0].From != "b:p" || e.Edges[0].To != "a:p") { + t.Errorf("the kept entry of a holds %+v, want one edge of kind 6 from b:p", e.Edges) + } + } +} diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_revisions.go b/modules/mesh-delivery-indexer/internal/index/catalog_revisions.go index 4134c2be..89460c43 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog_revisions.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog_revisions.go @@ -322,6 +322,7 @@ func (ix *Indexer) keepBranchEntries(ctx context.Context, branch string, src int ix.logf("catalog graph: no revision is kept: the newest is not read: %v", err) return fmt.Errorf("the newest catalog graph revision: %w", err) } + entries = WithEdges(entries) // the edges between artifact references are written here, once, for every reader (A-208) at := ix.now().UTC() rev, objects, err := CatalogRevisionOf(meshcatalog.CatalogRevision{Revision: newest.Revision + 1, Kind: "trunk", Branch: branch, Source: src, At: &at}, entries) if err != nil {