diff --git a/src/main.ts b/src/main.ts index e128825..22671f9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,10 @@ // starts consuming as it is imported, so this also runs consumers. // mesh-tools emit TYPE [JSON] emit one event onto the mesh and exit — an operable primitive, // and what an events test uses to put a message on the wire. +// mesh-tools prepare bring this module's state to the shape this version needs and exit +// — the runtime's answer to the word the mesh asks every module +// (novox/hq ADR 0135). The entrypoints come from MESH_PREPARE, which +// the module's own image names beside MESH_TOOL_MODULES. // mesh-tools run ENTRYPOINT run one compiled module entrypoint to completion and exit — the // runtime side of a run-once step (novox/hq ADR 0052). It imports // the given entrypoint, whose top-level code does its work — seed a @@ -18,6 +22,7 @@ // amqps account scoped to this module. Preferred: a module holds its own. // MESH_BROKER_URL a plain URL, for the bootstrap/admin case before a module has an account. // MESH_TOOL_MODULES /path/a,/path/b,… compiled module entrypoints (serve mode) +// MESH_PREPARE /path/a,/path/b,… compiled entrypoints that prepare this module's state // MESH_MODULE / MESH_NODE the identity stamped onto emitted events (ADR 0042) import { readFileSync } from "node:fs"; @@ -177,12 +182,49 @@ async function runEntry(entrypoint: string): Promise { await import(pathToFileURL(entrypoint).href); } +/** + * Bring this module's state to the shape this version needs, and exit — the runtime's answer to the + * one word the mesh asks every module (novox/hq ADR 0135). + * + * The entrypoints come from `MESH_PREPARE`, which a module's own image sets beside the entrypoints it + * already lists there: the module knows which of its files prepares its state, and nothing else could. + * Each is imported in the order given, to completion, with no broker — preparation runs before the + * version that would use it, so there is nothing yet to talk to. + * + * **An empty list is a failure, not a no-op.** The mesh only asks this of a module whose manifest says + * it prepares something; a module that says so and names nothing has been built wrong, and exiting 0 + * would let that version serve against a state nobody shaped. + */ +async function prepareState(): Promise { + const named = (process.env.MESH_PREPARE ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry !== ""); + if (named.length === 0) { + console.error( + "mesh-tools prepare: this module was asked to prepare its state and its image names nothing " + + "to do it with — set MESH_PREPARE to the compiled entrypoint(s) that prepare it, the way " + + "MESH_TOOL_MODULES names the ones it serves", + ); + process.exit(1); + } + for (const entrypoint of named) { + console.log(`[mesh-tools] preparing with ${entrypoint}`); + await runEntry(entrypoint); + } + console.log(`[mesh-tools] prepared: ${named.length} entrypoint(s) ran to completion`); +} + async function main(): Promise { const [command, ...rest] = process.argv.slice(2); if (command === "run") { await runEntry(rest[0] ?? ""); return; } + if (command === "prepare") { + await prepareState(); + return; + } if (command === "invoke") { const [module, tool] = rest; if (!module || !tool) { diff --git a/test/patient-connect.test.ts b/test/patient-connect.test.ts index a2a687c..5815dad 100644 --- a/test/patient-connect.test.ts +++ b/test/patient-connect.test.ts @@ -1,3 +1,4 @@ +import { spawn } from "node:child_process"; import { test } from "node:test"; import assert from "node:assert/strict"; import { fatalBrokerReason, PinMismatchError } from "../src/broker-nats.ts"; @@ -41,3 +42,21 @@ test("a non-Error value does not crash the classifier", () => { assert.equal(fatalBrokerReason("just a string"), null); assert.equal(fatalBrokerReason(undefined), null); }); + +// **A module asked to prepare its state and naming nothing is a failure, not a no-op** (novox/hq +// ADR 0135). The mesh asks this only of a module whose manifest says it prepares something, so an +// image that names nothing was built wrong, and exiting 0 would let that version serve against a +// state nobody shaped. +test("preparing with nothing named fails rather than passing quietly", async () => { + const runtime = new URL("../dist/main.js", import.meta.url).pathname; + const ran = await new Promise<{ code: number | null; said: string }>((resolve) => { + const child = spawn(process.execPath, [runtime, "prepare"], { + env: { ...process.env, MESH_PREPARE: "" }, + }); + let said = ""; + child.stderr.on("data", (chunk) => (said += String(chunk))); + child.on("close", (code) => resolve({ code, said })); + }); + assert.notEqual(ran.code, 0, "a module that prepares nothing exited 0, so its version would serve"); + assert.match(ran.said, /MESH_PREPARE/); +});