gitea imports @novox/mesh-delivery-contracts-mesh-pipeline-runner, so its package.json names it and the Node fetch places the group's (design 47 G A-285); the catalog view's revision answer is typed with its revision and entries, which revisionAnswer already refuses loudly when the holder leaves them out. Builds: design 47 G A-285 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
152 lines
8.2 KiB
TypeScript
152 lines
8.2 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">;
|
|
|
|
/** 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<Pick<RevisionAnswer, "revision" | "entries">>;
|
|
|
|
/**
|
|
* 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<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 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<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() };
|
|
}
|