The old bus's wire is pinned unchanged, because this has to merge to a running mesh

Every module's event names were converted from the old bus's routing keys to local
names, and this client maps them back. If that mapping is wrong anywhere a live mesh's
events stop being delivered — silently, because a binding that matches nothing is not an
error.

So the mapping is pinned against the literal routing keys the mesh published before,
taken from the manifests as they were: what each module now emits, what each now binds,
and that a handler still matches what the bus delivers. Including the audit logger's
"everything", which must stay `#` on this bus.

And a key already in the old form is left alone, so a module built from an older manifest
keeps working beside one built from a current manifest — which is the state the mesh will
actually be in between deployments.
This commit is contained in:
2026-09-27 17:37:19 +02:00
parent 9acc40145a
commit 3d55aeb1d8
2 changed files with 72 additions and 4 deletions
+4 -4
View File
@@ -373,7 +373,7 @@ function toEnvelope<T>(msg: amqp.ConsumeMessage): Envelope<T> {
/** AMQP topic matching: `*` matches one word, `#` zero or more. Used to fan a shared queue's
* deliveries out to the handlers whose pattern actually matches the routing key. */
function topicMatches(pattern: string, key: string): boolean {
export function topicMatches(pattern: string, key: string): boolean {
return matchFrom(pattern.split("."), 0, key.split("."), 0);
}
@@ -404,19 +404,19 @@ function matchFrom(p: string[], pi: number, k: string[], ki: number): boolean {
* **Why it exists at all.** Until 04-ISSUES/127 every module passed the routing key itself, which
* worked on this bus and derived into a namespace nobody owns on the one being built. Converting the
* modules to local names without this would have broken the mesh that is actually running. */
function routingKeyFor(key: string, self: string): string {
export function routingKeyFor(key: string, self: string): string {
return key.startsWith("module.") ? key : `module.${self}.${key}`;
}
/** A local pattern as this bus's binding. `**` is the mesh's wildcard for the rest of a name; here
* that is `#`, and on the bus being built it is `>`. Neither spelling appears in a manifest. */
function bindingFor(pattern: string): string {
export function bindingFor(pattern: string): string {
const here = pattern.split(".").map((part) => (part === "**" ? "#" : part)).join(".");
if (here === "#") return "#";
return here.startsWith("module.") ? here : `module.${here}`;
}
/** A routing key as the local name a handler and a manifest both use: the emitter and the event. */
function localKeyFor(routingKey: string): string {
export function localKeyFor(routingKey: string): string {
return routingKey.startsWith("module.") ? routingKey.slice("module.".length) : routingKey;
}
+68
View File
@@ -0,0 +1,68 @@
/**
* **The old bus's wire is byte-identical after the rename, and this is the test that lets the change
* be merged to a running mesh.**
*
* Every module's event names were converted from the old bus's routing keys to local names
* (novox/hq 04-ISSUES/127), and the old bus's client maps them back. If that mapping is wrong
* anywhere, a live mesh's events stop being delivered — silently, because a binding that matches
* nothing is not an error.
*
* So this pins the mapping against the literal routing keys the mesh used before, taken from the
* manifests as they were. It needs no bus: it is about a string.
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import { routingKeyFor, bindingFor, localKeyFor, topicMatches } from "../dist/broker-amqp.js";
test("a converted emit produces the routing key the mesh published before", () => {
// left: what the module's code says now. right: what went on the wire before, unchanged.
const same: [string, string, string][] = [
["plex", "playback.started", "module.plex.playback.started"],
["sonarr", "download.completed", "module.sonarr.download.completed"],
["builder", "built", "module.builder.built"],
["mesh-catalog", "upgraded", "module.mesh-catalog.upgraded"],
["keycloak", "user.created", "module.keycloak.user.created"],
["mesh-vault", "secret.rotated", "module.mesh-vault.secret.rotated"],
];
for (const [self, local, before] of same) {
assert.equal(routingKeyFor(local, self), before, `${self} emitting ${local}`);
}
});
test("a converted subscription binds what it bound before", () => {
const same: [string, string][] = [
["builder.built", "module.builder.built"],
["*.download.completed", "module.*.download.completed"],
["*.usage.*", "module.*.usage.*"],
// The audit logger's "everything": `#` on this bus, and it must stay `#`.
["**", "#"],
];
for (const [declared, before] of same) {
assert.equal(bindingFor(declared), before, `consuming ${declared}`);
}
});
test("a handler still matches what the bus delivers", () => {
// The key a handler is given is the local one now, and the pattern it compares against is local
// too — so the pair must still meet for every case the mesh actually has.
const pairs: [string, string][] = [
["builder.built", "module.builder.built"],
["*.download.completed", "module.sonarr.download.completed"],
["*.usage.*", "module.anthropic-consumer.usage.session"],
["**", "module.anything.at.all"],
];
for (const [pattern, delivered] of pairs) {
assert.ok(
topicMatches(pattern, localKeyFor(delivered)),
`${pattern} no longer matches ${delivered}, so a running module would stop reacting`,
);
}
});
test("a routing key already in the old form is left alone", () => {
// Belt for the transition: anything not yet converted still goes out as it did, so a module built
// from an older manifest keeps working beside one built from a current manifest.
assert.equal(routingKeyFor("module.plex.playback.started", "plex"), "module.plex.playback.started");
assert.equal(bindingFor("module.builder.built"), "module.builder.built");
});