diff --git a/modules/mesh-catalog/store.ts b/modules/mesh-catalog/store.ts index 0e6aa58..8e1f87f 100644 --- a/modules/mesh-catalog/store.ts +++ b/modules/mesh-catalog/store.ts @@ -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 { await this.pool.end(); } diff --git a/modules/mesh-catalog/tools/index.ts b/modules/mesh-catalog/tools/index.ts index 09a1cba..0c4efcc 100644 --- a/modules/mesh-catalog/tools/index.ts +++ b/modules/mesh-catalog/tools/index.ts @@ -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: