Files
jschoubben 7b06a7a408 Event names are local now, in the manifests and in the code
Every module named its events the way the old bus spelled a routing key —
`module.<module>.<verb>`. Design 29 says a module names an event locally and the
mesh works out where it lands, so all 37 were stale against a rule already
decided. On the new bus that derives into a namespace belonging to a module
called "module", so no cross-module subscription in the mesh matched anything:
nothing failed, nothing reacted (novox/hq 04-ISSUES/127).

36 manifests converted, and 43 files of module code with them. The code mattered
as much as the manifests: the runtime builds the subject from what `emit()` is
handed, so a converted manifest with unconverted code would have had the
permission and the subject disagree.

Three things the new check found on the way:

- `photos` emitted an event its manifest never declared, which the new bus refuses
  outright. Declared.
- `showcase` waited for an event nothing emits, so its demo could never be
  triggered — only `showcase` may publish under its own name. It emits both halves
  now.
- `distribution` declared an event named after a different module. It emits
  `image.pushed` under its own name. An event about a *role* belongs on the seat,
  where the name outlives whoever holds it, but the sdk has no way to publish on a
  seat yet, so that stays recorded rather than declared.

The audit logger's "everything" pattern is `**` rather than the old bus's `#`.
2026-09-27 14:42:28 +02:00

39 lines
2.1 KiB
TypeScript

// model-usage's entrypoint — the usage context store's consumer (novox/hq ADR 0054). mesh-controller is
// a CLI and cannot consume events, so the store that keeps the latest usage reading is a MODULE: it
// subscribes to `module.*.usage.*` and upserts each row. Like the audit-logger, the on(...) IS the
// whole handshake — the runtime imports this once the broker is bound, and every usage event any
// producer emits lands here as well as on the audit trail.
//
// The producers (the anthropic adapters) emit ALREADY-NORMALISED rows: the vendor→row normalisation
// lives in the adapter, not here, so this consumer is vendor-neutral (ADR 0054). A body is
// `{ rows: UsageRow[], raw }`; each row is upserted, carrying its own `raw` or the body's as a
// fallback. Delivery is at-least-once, so a duplicate is fine — the upsert keeps the latest.
import { on } from "@novox/mesh-sdk/events";
import { UsageStore, type UsageRow } from "./store.js";
const store = UsageStore.fromEnv();
// Create the store's one table before subscribing. The DDL is idempotent (CREATE TABLE IF NOT
// EXISTS), so a restart re-runs it harmlessly. This is done here, in the long-lived consumer, rather
// than as a gating run-once step: the consumer is a `--restart unless-stopped` service, so if the
// provider is not yet reachable — its overlay address comes up as the same push settles — this exits
// and is restarted until it can connect, without ever halting the apply. A run-once migrate that had
// to reach the provider over the overlay would block the very apply that brings the overlay up.
await store.migrate();
await on("*.usage.*", async (event) => {
const body = event.body as { rows?: UsageRow[]; raw?: unknown };
for (const row of body.rows ?? []) {
try {
await store.upsert({ ...row, raw: row.raw ?? body.raw ?? {} });
} catch (err) {
// A failed upsert is a loud line, never a throw back into the broker that would wedge the
// subscription (the audit-logger's discipline).
console.error(`[model-usage] could not upsert a row from ${event.type}: ${err}`);
}
}
});
console.log("[model-usage] recording model usage to its store");