// **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; /** The hooks a part hands the view: everything of mesh-sdk's FollowerOptions but what the view sets itself. */ export type CatalogHooks = Omit; /** A revision's answer as the view takes it: the contract leaves its revision and entries optional, the view needs both. */ export type HeldAnswer = RevisionAnswer & Required>; /** * The revision's answer, or the holder's refusal as an error: never an empty view taken for the trunk. An answer without * its revision or its entries is an error the caller sees (principle 3), never a default. */ export function revisionAnswer(raw: unknown): HeldAnswer { 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 & { 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 HeldAnswer; } 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 = 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 { 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 = {}; 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 { 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 } { 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() }; }