A build edge names an artifact, because that is what the builder can see

The catalogue expected each edge to name a module and a commit. The builder
sends a pinned image reference — it cannot know which module produced it, that
is a fact about the graph. So every build that had been built on top of anything
was rejected, and only the modules built against nothing ever registered.

Versions now record what they published, and an edge resolves through that. An
edge to an artifact no module here produced is kept: it resolves by itself when
that module is registered, which is the ordinary case while a mesh fills in.
This commit is contained in:
2026-09-13 01:37:51 +02:00
parent 9387f8b040
commit f15c814145
2 changed files with 90 additions and 38 deletions
+11 -11
View File
@@ -10,7 +10,7 @@
// diamond need no special handling, and nothing holds a plan.
import { on, emit } from "@novox/mesh-sdk/events";
import { Graph, type BuiltAgainst } from "./store.js";
import { Graph, type Made } from "./store.js";
const graph = Graph.fromEnv();
@@ -27,8 +27,15 @@ interface Built {
path?: string;
ref?: string;
manifest?: unknown;
/** Every artifact this was built against, so the edge is derived rather than declared. */
against?: { module: string; commit: string }[];
/** What this build published, each pinned as anything else would name it. */
made?: Made[];
/**
* Every artifact this was built on top of, so the edge is derived rather than declared.
*
* References, not module names: the builder sees a pinned image and cannot see which module
* produced it. Turning that into an edge between module-versions is this module's job.
*/
against?: string[];
}
await on("module.builder.built", async (event) => {
@@ -40,13 +47,6 @@ await on("module.builder.built", async (event) => {
return;
}
const against: BuiltAgainst[] = (body.against ?? []).map((a) => ({
module: body.module as string,
commit: body.commit as string,
againstModule: a.module,
againstCommit: a.commit,
}));
const { upgraded, previous } = await graph.register({
module: body.module,
commit: body.commit,
@@ -54,7 +54,7 @@ await on("module.builder.built", async (event) => {
path: body.path ?? "",
ref: body.ref ?? "",
manifest: body.manifest ?? {},
}, against);
}, body.made ?? [], body.against ?? []);
await emit("module.mesh-catalog.registered", {
module: body.module, commit: body.commit, upgraded,
+79 -27
View File
@@ -27,15 +27,25 @@ export interface ModuleVersion {
manifest: unknown;
}
/** An edge: this module-version was built against that one. Derived, never declared (ADR 0009). */
export interface BuiltAgainst {
module: string;
commit: string;
againstModule: string;
againstCommit: string;
/** An artifact a module-version produced, as the builder published it. */
export interface Made {
name: string;
kind: string;
/** How anything else names it — for an image, a registry reference pinned to a digest. */
reference: string;
}
/** A module whose artifacts were built against something that is no longer current. */
/**
* A module whose artifacts were built against something that is no longer current.
*
* **An edge is an artifact reference, not a module name.** The builder can see exactly what it
* built on top of — a pinned image — and cannot see which module produced it: that is a fact about
* the graph, and the graph is here. So the reference is what is stored, and resolving it to a
* module-version is a join against what each version says it made. An edge to an artifact no
* module here produced is kept rather than dropped; it resolves by itself the day that module is
* registered, which is the ordinary case while a mesh is still being filled in.
*/
export interface Stale {
module: string;
commit: string;
@@ -64,15 +74,39 @@ CREATE TABLE IF NOT EXISTS module_current (
moved timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS built_against (
module text NOT NULL,
commit_sha text NOT NULL,
against_module text NOT NULL,
against_commit text NOT NULL,
PRIMARY KEY (module, commit_sha, against_module, against_commit)
-- What a module-version published. This is what makes a build edge resolvable: an edge names an
-- artifact, and this says which version put that artifact there.
CREATE TABLE IF NOT EXISTS module_artifact (
module text NOT NULL,
commit_sha text NOT NULL,
name text NOT NULL,
kind text NOT NULL DEFAULT '',
reference text NOT NULL,
PRIMARY KEY (module, commit_sha, name)
);
CREATE INDEX IF NOT EXISTS built_against_target ON built_against (against_module);
CREATE INDEX IF NOT EXISTS module_artifact_reference ON module_artifact (reference);
CREATE TABLE IF NOT EXISTS built_against (
module text NOT NULL,
commit_sha text NOT NULL,
against_reference text NOT NULL,
PRIMARY KEY (module, commit_sha, against_reference)
);
CREATE INDEX IF NOT EXISTS built_against_target ON built_against (against_reference);
`;
// The graph first keyed its edges on a module and a commit, which the builder does not know and
// never sent — so every build that had been built against anything was rejected, and the only
// versions that registered were the ones built against nothing. No edge was ever stored, so there
// is nothing to carry across: the old columns are dropped and the new one added.
const MIGRATE = `
ALTER TABLE built_against ADD COLUMN IF NOT EXISTS against_reference text;
DELETE FROM built_against WHERE against_reference IS NULL;
ALTER TABLE built_against DROP COLUMN IF EXISTS against_module;
ALTER TABLE built_against DROP COLUMN IF EXISTS against_commit;
ALTER TABLE built_against ALTER COLUMN against_reference SET NOT NULL;
`;
export class Graph {
@@ -91,6 +125,7 @@ export class Graph {
async migrate(): Promise<void> {
await this.pool.query(DDL);
await this.pool.query(MIGRATE);
}
/**
@@ -100,7 +135,7 @@ export class Graph {
* rebuild producing the commit already current changes nothing, and announcing it as an upgrade
* would ripple outward forever through modules that did not change (ADR 0072).
*/
async register(version: ModuleVersion, against: BuiltAgainst[]): Promise<{ upgraded: boolean; previous: string | null }> {
async register(version: ModuleVersion, made: Made[], against: string[]): Promise<{ upgraded: boolean; previous: string | null }> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
@@ -114,15 +149,25 @@ export class Graph {
JSON.stringify(version.manifest ?? {})],
);
// The edges are replaced rather than added to: they describe this build, and a previous
// build of the same commit that saw different dependencies was wrong about one of them.
// Both are replaced rather than added to: they describe this build, and a previous build of
// the same commit that saw different artifacts was wrong about one of them.
await client.query(`DELETE FROM module_artifact WHERE module = $1 AND commit_sha = $2`,
[version.module, version.commit]);
for (const a of made) {
await client.query(
`INSERT INTO module_artifact (module, commit_sha, name, kind, reference)
VALUES ($1,$2,$3,$4,$5) ON CONFLICT (module, commit_sha, name) DO UPDATE SET
kind = excluded.kind, reference = excluded.reference`,
[version.module, version.commit, a.name, a.kind, a.reference]);
}
await client.query(`DELETE FROM built_against WHERE module = $1 AND commit_sha = $2`,
[version.module, version.commit]);
for (const e of against) {
for (const reference of against) {
await client.query(
`INSERT INTO built_against (module, commit_sha, against_module, against_commit)
VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`,
[version.module, version.commit, e.againstModule, e.againstCommit]);
`INSERT INTO built_against (module, commit_sha, against_reference)
VALUES ($1,$2,$3) ON CONFLICT DO NOTHING`,
[version.module, version.commit, reference]);
}
const was = await client.query(
@@ -175,13 +220,18 @@ export class Graph {
};
}
/** What was built against this module — the modules a change to it reaches. */
/**
* What was built against this module — the modules a change to it reaches.
*
* Resolved through the artifacts each version published, because that is what an edge names.
*/
async dependents(module: string): Promise<{ module: string; commit: string; againstCommit: string }[]> {
const { rows } = await this.pool.query(
`SELECT b.module, b.commit_sha, b.against_commit
`SELECT b.module, b.commit_sha, a.commit_sha AS against_commit
FROM built_against b
JOIN module_current c ON c.module = b.module AND c.commit_sha = b.commit_sha
WHERE b.against_module = $1
JOIN module_artifact a ON a.reference = b.against_reference
WHERE a.module = $1
ORDER BY b.module`, [module]);
return rows.map((r) => ({
module: r.module as string, commit: r.commit_sha as string,
@@ -199,11 +249,13 @@ export class Graph {
*/
async stale(): Promise<Stale[]> {
const { rows } = await this.pool.query(
`SELECT b.module, b.commit_sha, b.against_module, b.against_commit, c2.commit_sha AS now_at
`SELECT b.module, b.commit_sha, a.module AS against_module, a.commit_sha AS against_commit,
c2.commit_sha AS now_at
FROM built_against b
JOIN module_current c1 ON c1.module = b.module AND c1.commit_sha = b.commit_sha
JOIN module_current c2 ON c2.module = b.against_module
WHERE c2.commit_sha <> b.against_commit
JOIN module_artifact a ON a.reference = b.against_reference
JOIN module_current c2 ON c2.module = a.module
WHERE c2.commit_sha <> a.commit_sha
ORDER BY b.module`);
const by = new Map<string, Stale>();
for (const r of rows) {