A module's state through the runtime, in TypeScript and Go (novox/hq ADR 0201)
state(name) / stdio.State(name): get, put, delete, keys and watch over the runtime's mesh/state verbs. A watch's handler is registered under the id it is asked with, so its current values — which arrive before the watch is answered — and its changes reach it and no other watch.
This commit is contained in:
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
// A launched bundle that keeps state through its runtime (novox/hq ADR 0201): it watches its servers as
|
||||
// it is imported, before it says what it serves, and its tools write, read and list them.
|
||||
import { state } from "../../dist/state/index.js";
|
||||
import { registerModuleTools } from "../../dist/tools/index.js";
|
||||
import { serveRegisteredOverStdio } from "../../dist/stdio/index.js";
|
||||
|
||||
const servers = state("servers");
|
||||
await servers.watch((c) => {
|
||||
process.stderr.write(`${c.current ? "was" : "now"} ${c.op} ${c.key} ${JSON.stringify(c.value ?? null)}\n`);
|
||||
if (c.value && c.value.fail) throw new Error("not this one");
|
||||
});
|
||||
process.stderr.write("watching\n");
|
||||
registerModuleTools("keeper", () => [
|
||||
{ name: "remember", description: "put", input: {}, run: async (a) => servers.put(a.key, a.value) },
|
||||
{ name: "recall", description: "get", input: {}, run: async (a) => servers.get(a.key) },
|
||||
{ name: "list", description: "keys", input: {}, run: async () => state("licence-manager.bindings").keys() },
|
||||
]);
|
||||
await serveRegisteredOverStdio();
|
||||
@@ -160,6 +160,76 @@ test("a launched bundle subscribes as it is imported, handles events the runtime
|
||||
}
|
||||
});
|
||||
|
||||
test("a launched bundle watches its state as it is imported, has the current values before it goes on, and keeps state through the runtime (ADR 0201)", async () => {
|
||||
const child = spawn(process.execPath, [fileURLToPath(new URL("./fixtures/stdio-state.mjs", import.meta.url))],
|
||||
{ stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, MESH_SERVED_MODULE: "keeper" } });
|
||||
const seen: any[] = [];
|
||||
let buffered = "";
|
||||
let said = "";
|
||||
child.stderr.on("data", (d) => (said += d.toString()));
|
||||
child.stdout.on("data", (d) => {
|
||||
buffered += d.toString();
|
||||
let at: number;
|
||||
while ((at = buffered.indexOf("\n")) >= 0) {
|
||||
seen.push(JSON.parse(buffered.slice(0, at)));
|
||||
buffered = buffered.slice(at + 1);
|
||||
}
|
||||
});
|
||||
const until = async (match: (m: any) => boolean) => {
|
||||
for (let i = 0; i < 150 && !seen.some(match); i++) await new Promise((r) => setTimeout(r, 20));
|
||||
return seen.find(match);
|
||||
};
|
||||
const send = (m: unknown) => child.stdin.write(JSON.stringify(m) + "\n");
|
||||
const change = (id: number, watch: string, c: Record<string, unknown>) =>
|
||||
send({ jsonrpc: "2.0", id, method: "mesh/state", params: { watch, change: { state: "servers", revision: id, ...c } } });
|
||||
try {
|
||||
// The watch comes first, from the import; the current values arrive before it is answered.
|
||||
const watch = await until((m) => m.method === "mesh/state.watch");
|
||||
assert.deepEqual(watch.params, { state: "servers", key: "" });
|
||||
change(100, watch.id, { key: "all.one", op: "put", value: { url: "http://one" }, current: true });
|
||||
assert.deepEqual((await until((m) => m.id === 100)).result, {});
|
||||
assert.match(said, /was put all\.one \{"url":"http:\/\/one"\}/);
|
||||
assert.doesNotMatch(said, /watching/, "the import went on before the watch was answered");
|
||||
send({ jsonrpc: "2.0", id: watch.id, result: {} });
|
||||
await until(() => said.includes("watching"));
|
||||
assert.match(said, /watching/);
|
||||
|
||||
// A change for a watch this bundle never asked for is refused, not handed to somebody else.
|
||||
send({ jsonrpc: "2.0", id: 101, method: "mesh/state", params: { watch: "bundle-999", change: { key: "x", op: "put" } } });
|
||||
assert.match((await until((m) => m.id === 101)).error.message, /names no watch/);
|
||||
// A change the handler fails is answered as an error.
|
||||
change(102, watch.id, { key: "all.two", op: "put", value: { fail: true }, current: false });
|
||||
assert.match((await until((m) => m.id === 102)).error.message, /not this one/);
|
||||
change(103, watch.id, { key: "all.one", op: "delete", current: false });
|
||||
assert.deepEqual((await until((m) => m.id === 103)).result, {});
|
||||
assert.match(said, /now delete all\.one null/);
|
||||
|
||||
send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {} } });
|
||||
assert.equal((await until((m) => m.id === 1)).result.serverInfo.name, "keeper");
|
||||
|
||||
// A tool writes: the runtime is asked, and the revision it answers is the tool's.
|
||||
send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "remember", arguments: { key: "all.three", value: { url: "http://three" } } } });
|
||||
const put = await until((m) => m.method === "mesh/state.put");
|
||||
assert.deepEqual(put.params, { state: "servers", key: "all.three", value: { url: "http://three" } });
|
||||
send({ jsonrpc: "2.0", id: put.id, result: { revision: 7 } });
|
||||
assert.equal(JSON.parse((await until((m) => m.id === 2)).result.content[0].text), 7);
|
||||
// A read of a key with no value is null; a refusal is the tool's error, in the runtime's words.
|
||||
send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "recall", arguments: { key: "nothing" } } });
|
||||
const get = await until((m) => m.method === "mesh/state.get");
|
||||
send({ jsonrpc: "2.0", id: get.id, result: null });
|
||||
assert.equal(JSON.parse((await until((m) => m.id === 3)).result.content[0].text), null);
|
||||
send({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "list", arguments: {} } });
|
||||
const keys = await until((m) => m.method === "mesh/state.keys");
|
||||
assert.deepEqual(keys.params, { state: "licence-manager.bindings" });
|
||||
send({ jsonrpc: "2.0", id: keys.id, error: { code: -32000, message: "keeper keeps and reads no state called" } });
|
||||
const refused = await until((m) => m.id === 4);
|
||||
assert.equal(refused.result.isError, true);
|
||||
assert.match(refused.result.content[0].text, /keeps and reads no state called/);
|
||||
} finally {
|
||||
child.kill();
|
||||
}
|
||||
});
|
||||
|
||||
test("topics match as the runtime's do: * one segment, # and ** any number", async () => {
|
||||
const { topicMatches } = await import("../dist/stdio/index.js");
|
||||
assert.ok(topicMatches("alpha.happened", "alpha.happened"));
|
||||
|
||||
Reference in New Issue
Block a user