The catalogue holds the module graph, and the modules that make it build themselves #21

Merged
jschoubben merged 21 commits from feat/the-catalogue-module into main 2026-09-13 09:17:11 +00:00
2 changed files with 90 additions and 0 deletions
Showing only changes of commit c80d9d0f7c - Show all commits
+79
View File
@@ -87,6 +87,28 @@ CREATE TABLE IF NOT EXISTS module_artifact (
CREATE INDEX IF NOT EXISTS module_artifact_reference ON module_artifact (reference);
-- What a module-version declares. Separate from the build edges above and never mixed with them:
-- a build edge is a fact about what was compiled, discovered by building it, and these are
-- intentions the module states about itself (ADR 0072). They answer different questions and go
-- stale for different reasons.
CREATE TABLE IF NOT EXISTS module_requires (
module text NOT NULL,
commit_sha text NOT NULL,
requirement text NOT NULL,
PRIMARY KEY (module, commit_sha, requirement)
);
CREATE TABLE IF NOT EXISTS module_provides (
module text NOT NULL,
commit_sha text NOT NULL,
provision text NOT NULL,
scope text NOT NULL DEFAULT '',
PRIMARY KEY (module, commit_sha, provision)
);
CREATE INDEX IF NOT EXISTS module_provides_provision ON module_provides (provision);
CREATE INDEX IF NOT EXISTS module_requires_requirement ON module_requires (requirement);
CREATE TABLE IF NOT EXISTS built_against (
module text NOT NULL,
commit_sha text NOT NULL,
@@ -154,6 +176,34 @@ export class Graph {
// 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.
// Read from the manifest rather than taken as a separate argument: the manifest the builder
// resolved IS the module's declaration, and a second copy passed alongside it could disagree
// with it. Replaced wholesale, because a version declares exactly one set of these.
const declared = (version.manifest ?? {}) as {
requires?: unknown[];
provides?: unknown[];
};
await client.query(`DELETE FROM module_requires WHERE module = $1 AND commit_sha = $2`,
[version.module, version.commit]);
for (const r of declared.requires ?? []) {
const name = typeof r === "string" ? r : String((r as { name?: unknown }).name ?? "");
if (!name) continue;
await client.query(
`INSERT INTO module_requires (module, commit_sha, requirement) VALUES ($1,$2,$3)
ON CONFLICT DO NOTHING`, [version.module, version.commit, name]);
}
await client.query(`DELETE FROM module_provides WHERE module = $1 AND commit_sha = $2`,
[version.module, version.commit]);
for (const pv of declared.provides ?? []) {
const name = typeof pv === "string" ? pv : String((pv as { name?: unknown }).name ?? "");
const scope = typeof pv === "string" ? "" : String((pv as { scope?: unknown }).scope ?? "");
if (!name) continue;
await client.query(
`INSERT INTO module_provides (module, commit_sha, provision, scope) VALUES ($1,$2,$3,$4)
ON CONFLICT (module, commit_sha, provision) DO UPDATE SET scope = excluded.scope`,
[version.module, version.commit, name, scope]);
}
await client.query(`DELETE FROM module_artifact WHERE module = $1 AND commit_sha = $2`,
[version.module, version.commit]);
for (const a of made) {
@@ -285,6 +335,35 @@ export class Graph {
return stale.filter((s) => !s.because.some((b) => waiting.has(b.module)));
}
/**
* What provides a given provision, and what needs it.
*
* **Answered from the graph rather than by scanning manifests**, which is how it is answered
* today — a sweep over every module's declaration, which only whoever holds every declaration
* can do and which is wrong the moment one of them changes.
*/
async whoProvides(provision: string): Promise<{
provides: { module: string; commit: string; scope: string }[];
requires: { module: string; commit: string }[];
}> {
const provides = await this.pool.query(
`SELECT p.module, p.commit_sha, p.scope
FROM module_provides p
JOIN module_current c ON c.module = p.module AND c.commit_sha = p.commit_sha
WHERE p.provision = $1 ORDER BY p.module`, [provision]);
const requires = await this.pool.query(
`SELECT r.module, r.commit_sha
FROM module_requires r
JOIN module_current c ON c.module = r.module AND c.commit_sha = r.commit_sha
WHERE r.requirement = $1 ORDER BY r.module`, [provision]);
return {
provides: provides.rows.map((r) => ({
module: r.module as string, commit: r.commit_sha as string, scope: r.scope as string })),
requires: requires.rows.map((r) => ({
module: r.module as string, commit: r.commit_sha as string })),
};
}
async close(): Promise<void> {
await this.pool.end();
}
+11
View File
@@ -31,6 +31,17 @@ export function getCatalogueTools(graph: Graph): ToolDefinition[] {
return found ? { module: found } : { error: `the catalogue holds no ${module}` };
},
},
{
name: "catalog_provides",
description:
"What provides a provision, and what needs it. Answered from what modules declare about themselves, so it does not require holding every manifest at once.",
input: { provision: { type: "string", description: "the provision, e.g. postgres-database" } },
run: async (args) => {
const provision = String(args.provision ?? "");
if (!provision) return { error: "name a provision" };
return await graph.whoProvides(provision);
},
},
{
name: "catalog_dependents",
description: