Files
mesh-catalog/modules/gitea/catalog.ts
T
jschoubben 32655b95f6 GIT1, GIT2, GIT3, GIT10: the forge module serves the seat git's verbs and says its events, retires its acting tools, follows the catalog half
The four share files (index.ts, module.json, guard.test.ts), so they land as one commit.
GIT1: seat.ts serves pull, open-pulls, tags, trees, file, contains, statuses, status, comment, view, note, merge-pull
as tools on the seat, each answering the contract's shape and an error of kind refused, not-found or failed; a comment,
view or note whose act id is held is answered held (note matched by id). merge-pull merges the head the caller names
(its from-G judgements are GIT6, GIT7, GIT9).
GIT2: seatevents.ts says pull-updated, pull-merged, pull-closed, forge-pull, forge-status, forge-delete and repo-writable on
mesh.seat.git.event.<event>; the module's emits drop them.
GIT3: merge.ts, check.ts, the acting tools, the owed watch, the gate's source read and check-gates-merge are gone.
GIT10: catalog.ts follows the seat mesh-catalog (revision, catalog-revision); no write is granted before the first view.

Builds: design 47 G §310, §311, §312, §313, §314, §163, §317, §278, §321, §196

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:31:24 +01:00

146 lines
7.8 KiB
TypeScript

// **The forge module's view of the catalog half** (novox/hq design 47 *Every part takes a first view, then follows the
// events*, ADR 0656 §6): which repositories build a registered module is the catalog half's word, asked of the seat
// mesh-catalog's verb `revision` through the seat's generated TypeScript contract and then kept from its event
// `catalog-revision`, never the holder's state nor a module named. The picture is mesh-sdk's views Follower, so the
// module's first-view tools (statusTools) read and steer it as every part's.
//
// The readers' rule: a candidate's revision moves nothing; a trunk revision heard that the view does not hold yet is
// waited for (said in the status) and asked, and past mesh-sdk's WAITS_BOUND_MS the module writes view-waits; until the
// first view is held the module acts on nothing (writes.ts grants no write).
import { Follower, type Answer, type Finding, type FollowerOptions, type Status } from "@novox/mesh-sdk/views";
import {
seat as CATALOG, verbs as CATALOG_VERBS, events as CATALOG_EVENTS, states as CATALOG_STATES,
type CatalogEntry, type CatalogRevisionEvent, type RevisionAnswer, type RevisionInput,
} from "@novox/mesh-delivery-contracts-mesh-catalog";
/** The seat verb the view asks, as the manifest's `invokes` names it. */
export const REVISION_VERB = `seat:${CATALOG}.${CATALOG_VERBS.revision}`;
/** The seat event the view hears, as the manifest's `consumes` names it. */
export const CATALOG_REVISION_EVENT = `${CATALOG}.${CATALOG_EVENTS.catalogRevision}`;
/** A trunk revision; a candidate's moves no running part (ADR 0656 §6). */
export const TRUNK = "trunk";
/** A revision view is never made again: one fixed creation time lets the follower tell a revision heard from one held. */
const CREATED = new Date(0).toISOString();
/** Asks one seat verb over the bus and answers what the holder said. */
export type Ask = (verb: string, input: RevisionInput) => Promise<unknown>;
/** The hooks a part hands the view: everything of mesh-sdk's FollowerOptions but what the view sets itself. */
export type CatalogHooks = Omit<FollowerOptions, "owner" | "tool" | "state" | "ask">;
/** The revision's answer, or the holder's refusal as an error: never an empty view taken for the trunk. */
export function revisionAnswer(raw: unknown): RevisionAnswer {
let a: unknown = raw;
if (typeof raw === "string") {
try {
a = JSON.parse(raw);
} catch {
throw new Error(`${REVISION_VERB} answered: ${raw.slice(0, 200)}`);
}
}
const r = a as Partial<RevisionAnswer> & { error?: unknown };
if (!r || typeof r !== "object" || typeof r.revision?.revision !== "number" || r.revision.revision <= 0 || !Array.isArray(r.entries)) {
const said = r && typeof r === "object" && typeof r.error === "string" ? r.error : JSON.stringify(a ?? null).slice(0, 200);
throw new Error(`${REVISION_VERB} answered no revision: ${said}`);
}
return r as RevisionAnswer;
}
export class CatalogView {
readonly follower: Follower;
/** The trunk revision whose pages are being read; 0 between asks. */
private paging = 0;
/** The trunk revision the view holds whole; 0 before the first view. */
private heldRevision = 0;
private readonly ask: Ask;
constructor(ask: Ask, hooks: CatalogHooks) {
this.ask = ask;
this.follower = new Follower({ ...hooks, owner: CATALOG, tool: REVISION_VERB, state: CATALOG_STATES.catalogRevisions,
ask: (after) => this.page(after) });
}
/** The ask the last event began, not waited on by hear: a test awaits it. */
asked: Promise<void> = Promise.resolve();
/** The trunk revision the view holds, 0 before the first view. */
get held(): number {
return this.heldRevision;
}
/** One module's entry in the view, as the catalog half says it. */
entry(module: string): CatalogEntry | undefined {
return this.follower.get(module) as CatalogEntry | undefined;
}
/** The Follower's ask: the trunk's newest revision, then the next pages of the same one. */
private async page(after: string): Promise<Answer> {
const input: RevisionInput = after && this.paging ? { after, revision: this.paging } : {};
const a = revisionAnswer(await this.ask(REVISION_VERB, input));
const rev = a.revision.revision;
if (a.revision.kind !== TRUNK) throw new Error(`${REVISION_VERB} answered a ${a.revision.kind} revision for the trunk`);
if (after && this.paging && rev !== this.paging) {
const was = this.paging;
this.paging = 0;
throw new Error(`${REVISION_VERB} answered revision ${rev} for a page of revision ${was}`);
}
this.paging = rev;
const values: Record<string, unknown> = {};
for (const e of a.entries) values[e.module] = e;
if (!a.next) {
this.paging = 0;
this.heldRevision = rev;
const waits = Number(this.follower.status()["waits-for"] ?? 0);
if (waits <= rev) await this.follower.holds(String(rev));
}
return { v: 1, owner: CATALOG, state: CATALOG_STATES.catalogRevisions, revision: rev, created: CREATED, values,
...(a.next ? { next: a.next } : {}), at: new Date().toISOString() };
}
/**
* One `catalog-revision` event. A candidate's moves nothing; a trunk revision the view holds, or an older one, is passed
* over; a newer one is waited for and asked, not waited on here. Data that is no such event is an error.
*/
async hear(ev: CatalogRevisionEvent | undefined): Promise<void> {
if (!ev || typeof ev.revision !== "number" || ev.revision <= 0) {
throw new Error(`not a ${CATALOG_REVISION_EVENT} event: ${JSON.stringify(ev ?? null).slice(0, 200)}`);
}
if (ev.kind !== TRUNK || ev.revision <= this.heldRevision) return;
const held = this.heldRevision;
// The follower keeps the newest revision heard, so an ask running when it was heard is asked once more.
await this.follower.apply({ v: 1, owner: CATALOG, state: CATALOG_STATES.catalogRevisions, key: CATALOG_REVISION_EVENT,
revision: ev.revision, created: CREATED, gap: true });
await this.follower.waitFor(String(ev.revision));
this.asked = this.follower.gap(`catalog revision ${ev.revision} said, the view holds ${held}`);
}
}
/** The kind of a finding of a first view unanswered past its bound (ADR 0615 §6), as mesh-sdk's Go views writes it. */
export const UNANSWERED_KIND = "first-view-unanswered";
/** The key of a finding about a module: scope, the id's dotted parts, and the kind, each part made safe (mesh-sdk health.Key). */
export function findingKey(scope: string, id: string, kind: string): string {
return [scope, ...id.split("."), kind].map((p) => p.trim().replace(/[^A-Za-z0-9_=/-]/g, "_") || "_").join(".");
}
/** A finding as the condition keeper reads it from the module's state `findings`: its key and its value. */
export function findingValue(f: Finding): { key: string; value: Record<string, unknown> } {
const key = findingKey("module", f.id, f.kind);
return { key, value: {
v: 1, key, kind: f.kind, subject: { scope: "module", id: f.id }, severity: "warning", summary: f.summary, headline: f.headline,
explanation: f.explanation, resolved: "", evidence: [{ at: f.at, said: f.summary }], source: `${f.observer}.${f.kind}`,
observer: f.observer, at: f.at,
} };
}
/** A part's finding of a first view unanswered past its bound, from the follower's status. */
export function unansweredFinding(part: string, s: Status, at: Date): Finding {
const summary = `${part} has no first view of ${s.owner}'s ${s.state}: ${s.tool} has not answered since ${s.asked ?? "its start"} (${(s.error ?? "").trim()})`;
return { observer: part, kind: UNANSWERED_KIND, id: `${part}.${s.owner}.${s.state}`, summary, headline: `${part} waits for ${s.owner}`,
explanation: `${part} reads ${s.owner}'s ${s.state} only through ${s.tool}, and keeps asking it; it never reads the state itself. The answer ` +
`clears this. Look at ${s.owner}'s holder: is it running and connected?`, at: at.toISOString() };
}