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:
jochen
2026-10-04 02:47:31 +02:00
parent 0b018562e3
commit 096c5562a4
12 changed files with 488 additions and 9 deletions
+1
View File
@@ -10,3 +10,4 @@ export * as provisioner from "./provisioner/index.js";
export * as messaging from "./messaging/index.js";
export * as events from "./events/index.js";
export * as primitives from "./primitives/index.js";
export * as state from "./state/index.js";
+89
View File
@@ -0,0 +1,89 @@
// State — the current value of something a module keeps on the bus, which every machine sees,
// including one that joins later (novox/hq ADR 0201). Not history: that is an event. Never a secret,
// sealed or not: a bucket is kept, and a machine joining a year later reads it whole.
//
// A module declares the state it keeps (`state` in its manifest) and the state of others it reads
// (`reads`, as `<module>.<name>`), and names each here the way it named it there. Every instance of
// the owner may write; a reader reads and watches. The runtime that launched the bundle does the work
// and refuses, with the reason, what the module did not declare.
/** One key's current value. */
export interface StateEntry<T = unknown> {
readonly key: string;
readonly value: T;
/** Increases with every write to the bucket; a later value has a larger one. */
readonly revision: number;
}
/** One change a watch hands over. */
export interface StateChange<T = unknown> {
/** The state, as the module named it. */
readonly state: string;
readonly key: string;
readonly op: "put" | "delete";
/** The new value; absent for a delete. */
readonly value?: T;
readonly revision: number;
/** True for a value that was there when the watch began, false for a change since. */
readonly current: boolean;
}
/** One state a module keeps or reads. */
export interface State<T = unknown> {
/** A key's current value, or null when it has none. */
get(key: string): Promise<StateEntry<T> | null>;
/** Write a key — the owner's instances only. Resolves to the revision written. */
put(key: string, value: T): Promise<number>;
/** Remove a key — the owner's instances only. Removing one that is not there is not an error. */
delete(key: string): Promise<void>;
/** Every key with a current value, sorted. */
keys(): Promise<string[]>;
/**
* Hand `handler` every current value — none that is deleted — and then every change, in order.
* Resolves once the current values have all been handled, so code after it has the whole state.
* `key` narrows it: a key, with `*` for one name and `**` for the rest. The watch lasts as long as
* the process; a bundle that starts again watches again as its code runs again.
*/
watch(handler: (change: StateChange<T>) => Promise<void> | void, opts?: { key?: string }): Promise<void>;
}
/** What the state surface needs of the channel to the runtime; the stdio session provides it. */
export interface StateChannel {
ask(method: string, params: unknown): Promise<unknown>;
/** Ask for a watch, with `deliver` registered for its changes before the request is sent. */
watch(params: unknown, deliver: (change: StateChange<unknown>) => Promise<void>): Promise<void>;
}
let channel: StateChannel | undefined;
/** Bind the channel. The stdio session calls this when the runtime launched the process. */
export function useStateChannel(c: StateChannel): void {
channel = c;
}
function bound(): StateChannel {
if (!channel) {
throw new Error(
"no runtime to keep state through — a module's state is reached through the runtime that launched " +
"its bundle (novox/hq ADR 0201)",
);
}
return channel;
}
/** A state this module keeps (by its local name) or reads (`<module>.<name>`). */
export function state<T = unknown>(name: string): State<T> {
return {
get: async (key) => (await bound().ask("mesh/state.get", { state: name, key })) as StateEntry<T> | null,
put: async (key, value) =>
((await bound().ask("mesh/state.put", { state: name, key, value })) as { revision: number }).revision,
delete: async (key) => {
await bound().ask("mesh/state.delete", { state: name, key });
},
keys: async () => (await bound().ask("mesh/state.keys", { state: name })) as string[],
watch: async (handler, opts = {}) =>
bound().watch({ state: name, key: opts.key ?? "" }, async (change) => {
await handler(change as StateChange<T>);
}),
};
}
+38 -2
View File
@@ -15,6 +15,7 @@
import type { ToolDefinition } from "../contracts/index.js";
import { collectTools } from "../tools/index.js";
import { useBroker, type Broker, type Envelope } from "../messaging/index.js";
import { useStateChannel, type StateChange } from "../state/index.js";
/** The protocol version this speaks; the runtime says the same. */
export const PROTOCOL = "2025-03-26";
@@ -63,12 +64,30 @@ function startSession(): Session {
};
let nextOut = 1;
const awaiting = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
const ask = (method: string, params: unknown): Promise<unknown> =>
const askAs = (id: string, method: string, params: unknown): Promise<unknown> =>
new Promise((resolve, reject) => {
const id = `bundle-${nextOut++}`;
awaiting.set(id, { resolve, reject });
say({ jsonrpc: "2.0", id, method, params });
});
const ask = (method: string, params: unknown): Promise<unknown> => askAs(`bundle-${nextOut++}`, method, params);
// The module's state (ADR 0201): each watch's changes arrive as `mesh/state` requests naming the
// watch by the id it was asked with, so its handler is registered under that id before it is asked
// — the current values arrive before the watch itself is answered.
const watches = new Map<string, (change: StateChange<unknown>) => Promise<void>>();
useStateChannel({
ask,
watch: async (params, deliver) => {
const id = `bundle-${nextOut++}`;
watches.set(id, deliver);
try {
await askAs(id, "mesh/state.watch", params);
} catch (err) {
watches.delete(id);
throw err;
}
},
});
// What the module's code reacts to: every handler, matched here against each event the runtime
// delivers. The runtime binds the module's one durable consumer and hands every message it reads to
@@ -156,6 +175,23 @@ function startSession(): Session {
}
return;
}
case "mesh/state": {
// One change to a state this bundle watches (ADR 0201), answered once its handler has taken
// it. The runtime delivers a watch's changes one at a time, in order.
const p = request.params as { watch?: string; change?: StateChange<unknown> } | undefined;
const deliver = p?.watch !== undefined ? watches.get(String(p.watch)) : undefined;
if (!deliver || !p?.change) {
say({ jsonrpc: "2.0", id: request.id, error: { code: -32602, message: "mesh/state names no watch of this bundle" } });
return;
}
try {
await deliver(p.change);
say({ jsonrpc: "2.0", id: request.id, result: {} });
} catch (err) {
say({ jsonrpc: "2.0", id: request.id, error: { code: -32000, message: err instanceof Error ? err.message : String(err) } });
}
return;
}
default:
if (!notification) say({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: `no ${request.method}` } });
}