@macula-io/ts 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/pool.js CHANGED
@@ -1,616 +1,235 @@
1
- // Resilient multi-station client -- ports macula/src/client/macula_client.erl's
2
- // pool design (N live links, monitor + respawn-with-backoff, subscription
3
- // replay on reconnect) into this SDK. `Session` (session.ts) and
4
- // `connectWithFallback`/`withSession` (this SDK's consumers, e.g.
5
- // macula-mcp's macula_ts_client.ts) are both dial-one-then-use primitives --
6
- // correct for a one-shot operation, wrong for anything that needs to stay
7
- // reachable across a station's own outage. A Pool holds live connections to
8
- // every configured seed concurrently, not one-then-fallback-on-first-failure;
9
- // a seed that never connects and a link whose connection later dies are the
10
- // same condition (backoff, retry forever) rather than two different code
11
- // paths.
12
- //
13
- // == Why this isn't "one Session per seed" ==
14
- //
15
- // Erlang's macula_client can put N tracked (Realm, Topic) subscriptions
16
- // (plus Call, plus Advertise) on ONE gen_server-owned link, because a BEAM
17
- // mailbox demuxes every message type for that one process regardless of how
18
- // many concerns it's juggling. This SDK's Session has no equivalent: its
19
- // underlying control stream supports exactly one concurrent reader --
20
- // `subscribe()` throws outright if called twice on the same Session
21
- // ("a second concurrent subscribe() on the same Session races; open a
22
- // second Session instead"), and `call()`/`serve()` are mutually exclusive
23
- // with an active subscribe on the same Session for the identical reason
24
- // (macula-go's FrameStream does a raw sequential read into a shared buffer,
25
- // no per-caller demux). Confirmed live 2026-09-04 while porting the Go side
26
- // of this same design: this is a wire-level constraint, not a TS-specific
27
- // gap -- macula-mcp already works around it today by giving lobby_observer.ts
28
- // a dedicated identity+Session PER ROOM TOPIC rather than multiplexing one
29
- // connection.
30
- //
31
- // So a "link" to one station here is a small SET of role-scoped sessions,
32
- // not one session:
33
- // - one "control" session (this pool's own caller-supplied identity),
34
- // used for publish() and call() and NEVER subscribed on -- publish() is
35
- // write-only (explicitly unguarded by the exclusivity rule) and call()'s
36
- // own internal queue already serialises concurrent calls safely, so one
37
- // control session per station is enough regardless of call volume.
38
- // - one additional session PER CURRENTLY-TRACKED TOPIC, under an identity
39
- // derived from that topic (shared across every station's copy of that
40
- // topic's session -- reused across stations is safe, since a station's
41
- // own per-identity dedupe only kicks a SECOND connection to THAT SAME
42
- // station, never across different stations). Each does exactly one
43
- // subscribe() and nothing else.
44
- // Every one of these sessions is independently monitored and respawned with
45
- // backoff; a topic session dying just re-subscribes its own one topic on
46
- // respawn (no "replay N subscriptions onto a survivor" step needed, since
47
- // each session only ever carried one). A control link can't also carry a
48
- // liveness-only subscribe without reintroducing the exclusivity problem it
49
- // exists to avoid, and a failed call() genuinely does surface a dead
50
- // connection -- but publish() does not: found live 2026-09-04 that a
51
- // publish() attempt on a session the station had already kicked can still
52
- // locally "succeed" (the frame is handed off before the QUIC stack notices
53
- // its own connection is gone), so a publish-only workload could go
54
- // arbitrarily long without ever discovering a dead control link. Every
55
- // live control link is therefore also health-checked on a timer
56
- // (#armHealthCheck's own doc has the mechanism) -- a periodic call() to a
57
- // procedure name guaranteed never to be advertised, whose failure is
58
- // inspected to tell "a real BOLT#4 unknown_next_peer came back" (alive)
59
- // apart from "this never reached anyone at all" (dead, reconnect).
60
- //
61
- // == Deliberate deviation from the Erlang reference ==
62
- //
63
- // macula_client's own inbound-event dedup keys on (Realm, Publisher, Seq)
64
- // alone, with no Topic. That is the exact shape of a real bug found and
65
- // fixed on the station side 2026-09-04 (macula-station's event_dedup): an
66
- // identity's own auto-published facts and its application-level publishes
67
- // can share one seq-counter space, and two different topics publishing the
68
- // same seq collide under a topic-blind key. This pool's own dedup includes
69
- // Topic from the start.
70
- import { Identity } from "./identity.js";
71
- import { bytesModeFor, MaculaCallError } from "./rpc.js";
72
- import { realmBytesFromHex, Session } from "./session.js";
73
- /** Thrown by publish()/call() when the pool has zero live control links
74
- * to try -- a distinct, identifiable condition from any one link's own
75
- * transient error, matching macula_client:publish/5's own `{error,
76
- * {transient, no_healthy_station}}`. */
77
- export class NoHealthyStationError extends Error {
78
- constructor() {
79
- super("macula-ts: pool has no currently-live links");
80
- this.name = "NoHealthyStationError";
1
+ // A node's pool of station links on the macula 12 mesh, as macula-go's pool
2
+ // keeps it: every seed pinned by its node_id, the realms whose keys the node
3
+ // trusts, and one identity for every link. Calls and streams reach a provider
4
+ // by direct dial: its advertisements from the DHT, trusted only when the
5
+ // realm's key authorizes them (or, in a node's own namespace `~<node_id>/`,
6
+ // only when that node signed them), and the station it serves from dialed
7
+ // pinned.
8
+ import { native } from "./binding.js";
9
+ import { Stream } from "./stream.js";
10
+ import { DEFAULT_CALL_TIMEOUT_MS, bytesModeFor, callError, hex, id32, } from "./wire.js";
11
+ /** macula 12's record types. */
12
+ export var RecordType;
13
+ (function (RecordType) {
14
+ RecordType[RecordType["NodeRecord"] = 1] = "NodeRecord";
15
+ RecordType[RecordType["ProcedureAdvertisement"] = 6] = "ProcedureAdvertisement";
16
+ RecordType[RecordType["Tombstone"] = 12] = "Tombstone";
17
+ RecordType[RecordType["ContentAnnouncement"] = 17] = "ContentAnnouncement";
18
+ RecordType[RecordType["StationEndpoint"] = 18] = "StationEndpoint";
19
+ RecordType[RecordType["OrgDirectory"] = 21] = "OrgDirectory";
20
+ RecordType[RecordType["ProcedureDelegation"] = 22] = "ProcedureDelegation";
21
+ })(RecordType || (RecordType = {}));
22
+ /** A subscription, until stop() or the pool closes. */
23
+ export class Subscription {
24
+ handle;
25
+ closed;
26
+ /** @internal */
27
+ constructor(handle, closed) {
28
+ this.handle = handle;
29
+ this.closed = closed;
30
+ }
31
+ /** Ends the subscription on every link. */
32
+ async stop() {
33
+ await native.subscriptionStop(this.handle);
81
34
  }
82
35
  }
83
- const RECONNECT_BASE_MS = 1_000;
84
- const RECONNECT_MAX_MS = 30_000;
85
- /** How often a LIVE control link is health-checked (see #armHealthCheck's
86
- * own doc for why publish() alone can't be trusted to ever notice a dead
87
- * connection). Topic-role links need no equivalent -- their own
88
- * subscribe() already gives them a real, immediate onClosed signal. */
89
- const HEALTH_CHECK_INTERVAL_MS = 10_000;
90
- function subKey(realm, topic) {
91
- return `${realm ?? ""}\0${topic}`;
92
- }
93
- function dedupKey(realm, publisher, seq, topic) {
94
- return `${realm ?? ""}\0${Buffer.from(publisher).toString("hex")}\0${seq}\0${topic}`;
36
+ /** A served procedure, until stop(). */
37
+ export class Served {
38
+ handle;
39
+ stopped = false;
40
+ /** @internal */
41
+ constructor(handle) {
42
+ this.handle = handle;
43
+ }
44
+ /** Withdraws the procedure on every link. */
45
+ async stop() {
46
+ if (this.stopped)
47
+ return;
48
+ this.stopped = true;
49
+ await native.servedStop(this.handle);
50
+ }
95
51
  }
96
- /**
97
- * A resilient multi-station client: live connections to every configured
98
- * seed held concurrently, each independently monitored and respawned
99
- * with backoff on disconnect, every tracked subscription re-established
100
- * automatically when its own link reconnects. See this module's own
101
- * header doc for the full design, why a "link" is a small role-scoped
102
- * session set rather than one Session, and its one deliberate deviation
103
- * from the Erlang reference (topic-scoped dedup).
104
- */
105
52
  export class Pool {
106
- #controlIdentity;
107
- #controlLinks;
108
- #seeds;
109
- #subscriptions = new Map();
110
- #dedup = new Map();
111
- #sweepTimer;
112
- #replicationFactor;
113
- #dedupWindowMs;
114
- #healthCheckIntervalMs;
115
- #closed = false;
116
- constructor(controlIdentity, seeds, opts) {
117
- this.#controlIdentity = controlIdentity;
118
- this.#seeds = seeds;
119
- const requestedReplication = opts.replicationFactor ?? 1;
120
- if (requestedReplication !== 1) {
121
- console.error(`macula-ts pool: replicationFactor ${requestedReplication} is not yet supported (each replica would publish ` +
122
- "a distinct seq, so a receiver's dedup can't collapse them into one event) -- clamping to 1.");
53
+ handle;
54
+ closed = false;
55
+ constructor(handle) {
56
+ this.handle = handle;
57
+ }
58
+ /** Links the key's node to every seed, and resolves once one link is up. */
59
+ static async connect(key, seeds, options = {}) {
60
+ const seedJson = seeds.map((s) => ({ host: s.host, port: s.port, node_id: hex(id32(s.nodeId, "a seed's nodeId")) }));
61
+ const realmTrust = {};
62
+ for (const t of options.realmTrust ?? []) {
63
+ realmTrust[hex(id32(t.realm, "a realm id"))] = typeof t.key === "string" ? t.key : hex(t.key);
123
64
  }
124
- this.#replicationFactor = 1;
125
- this.#dedupWindowMs = opts.dedupWindowMs ?? 60_000;
126
- this.#healthCheckIntervalMs = opts.healthCheckIntervalMs ?? HEALTH_CHECK_INTERVAL_MS;
127
- this.#controlLinks = seeds.map((seed) => this.#newRoleLink(seed, controlIdentity, undefined));
128
- const sweepMs = opts.dedupSweepMs ?? 30_000;
129
- this.#sweepTimer = setInterval(() => this.#sweepDedup(), sweepMs);
130
- this.#sweepTimer.unref?.();
131
- }
132
- #newRoleLink(seed, identity, onConnected) {
133
- return {
134
- seed,
135
- identity,
136
- session: undefined,
137
- status: "connecting",
138
- reconnectAttempt: 0,
139
- retryTimer: undefined,
140
- healthCheckTimer: undefined,
141
- closing: false,
142
- inFlight: undefined,
143
- pendingClose: undefined,
144
- onConnected,
145
- closedDuringConnect: false,
65
+ const opts = {
66
+ realm_trust: realmTrust,
67
+ replication_factor: options.replicationFactor ?? 0,
68
+ max_direct_links: options.maxDirectLinks ?? 0,
69
+ respawn_delay_ms: options.respawnDelayMs ?? 0,
70
+ timeout_ms: options.timeoutMs ?? 0,
146
71
  };
72
+ return new Pool(await native.poolConnect(key.live(), JSON.stringify(seedJson), JSON.stringify(opts)));
147
73
  }
148
- /** Dials the control role against every seed concurrently under
149
- * `controlIdentity` (the caller's own identity -- publish()/call()
150
- * are attributed to it on the wire) and resolves once every seed's
151
- * FIRST connect attempt has settled (success or a logged failure
152
- * headed into backoff) -- matches tapRoom()'s own "await the first
153
- * attempt, not the eventual outcome" reasoning (macula-mcp's
154
- * lobby_observer.ts): a caller that publishes/calls immediately
155
- * after connect() must not race links still mid-handshake. */
156
- static async connect(seeds, controlIdentity, opts = {}) {
157
- if (seeds.length === 0)
158
- throw new Error("macula-ts: Pool.connect() needs at least one seed");
159
- const seen = new Set();
160
- for (const seed of seeds) {
161
- const seedKey = `${seed.host}:${seed.port}`;
162
- if (seen.has(seedKey))
163
- throw new Error(`macula-ts: Pool.connect() given duplicate seed ${seedKey} -- each seed must be a distinct station`);
164
- seen.add(seedKey);
165
- }
166
- const pool = new Pool(controlIdentity, seeds, opts);
167
- await Promise.all(pool.#controlLinks.map((link) => pool.#attach(link)));
168
- return pool;
74
+ /** The node_id the pool links as. */
75
+ nodeId() {
76
+ return hex(native.poolNodeId(this.live()));
169
77
  }
170
- async #attach(link) {
171
- const attempt = this.#attachOnce(link);
172
- link.inFlight = attempt.finally(() => {
173
- if (link.inFlight === attempt)
174
- link.inFlight = undefined;
175
- });
176
- await link.inFlight;
78
+ /** name in this node's own namespace, `~<node_id>/<name>`: a procedure it
79
+ * serves with no org and no realm key, authorized by its advertisement's
80
+ * signature alone, and that any node calls with no realm key pinned. */
81
+ ownProcedure(name) {
82
+ return `~${this.nodeId()}/${name}`;
177
83
  }
178
- /** (Re)connects one role link and, for a topic role, re-subscribes it.
179
- * Never throws -- a failure logs and schedules a backoff retry, the
180
- * same self-healing shape every persistent connection in this
181
- * ecosystem already uses. */
182
- async #attachOnce(link) {
183
- if (link.closing)
184
- return;
185
- // Defensive: a redundant call on an already-live link must never
186
- // overwrite its session with a second connection under the same
187
- // identity to the same station (exactly the collision this whole
188
- // design exists to avoid). #scheduleReconnect's own idempotency
189
- // guard is what should prevent this from ever being reachable in
190
- // practice; this is the belt-and-suspenders backstop.
191
- if (link.session)
192
- return;
193
- link.status = "connecting";
194
- let session;
84
+ /** Every link the pool holds. */
85
+ status() {
86
+ return JSON.parse(native.poolStatus(this.live())) ?? [];
87
+ }
88
+ /** Calls procedure in realm at a provider (any trusted one unless
89
+ * `provider` names one) by direct dial. A provider's ERROR is thrown as a
90
+ * ProviderError, a station's relay error as a RelayError. */
91
+ async call(realm, procedure, payload = {}, options = {}) {
195
92
  try {
196
- session = await Session.connect(link.seed.host, link.seed.port, link.identity);
197
- }
198
- catch (err) {
199
- console.error(`macula-ts pool: connect to ${link.seed.host}:${link.seed.port} failed:`, err);
200
- this.#scheduleReconnect(link);
201
- return;
93
+ const result = await native.poolCall(this.live(), id32(realm, "realm"), procedure, JSON.stringify(payload), options.provider === undefined ? null : id32(options.provider, "provider"), options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS, bytesModeFor(options.bytes));
94
+ return JSON.parse(result);
202
95
  }
203
- if (link.closing) {
204
- await session.close(link.identity).catch(() => { });
205
- return;
96
+ catch (e) {
97
+ throw callError(e);
206
98
  }
207
- if (link.onConnected) {
208
- link.closedDuringConnect = false;
209
- try {
210
- await link.onConnected(link, session);
211
- }
212
- catch (err) {
213
- console.error(`macula-ts pool: subscribe on ${link.seed.host}:${link.seed.port} failed:`, err);
214
- await session.close(link.identity).catch(() => { });
215
- this.#scheduleReconnect(link);
99
+ }
100
+ /** The procedure's trusted providers, freshest first. */
101
+ async providers(realm, procedure, options = {}) {
102
+ return JSON.parse(await native.poolProviders(this.live(), id32(realm, "realm"), procedure, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS)) ?? [];
103
+ }
104
+ /** Publishes payload on topic in realm. Topics name a kind of fact; ids go
105
+ * in the payload. */
106
+ async publish(realm, topic, payload, options = {}) {
107
+ await native.poolPublish(this.live(), id32(realm, "realm"), topic, JSON.stringify(payload), options.ttlMs ?? 0);
108
+ }
109
+ /** Subscribes to topic in realm: onEvent hears each verified event once,
110
+ * however many links deliver it. `closed` resolves when the subscription
111
+ * ends, with why or null. */
112
+ async subscribe(realm, topic, onEvent, options = {}) {
113
+ let settle = () => { };
114
+ const closed = new Promise((resolve) => (settle = resolve));
115
+ const handle = await native.poolSubscribe(this.live(), id32(realm, "realm"), topic, bytesModeFor(options.bytes), (d) => {
116
+ if (d.kind === "closed") {
117
+ settle(d.json === "" ? null : d.json);
216
118
  return;
217
119
  }
218
- if (link.closedDuringConnect) {
219
- console.error(`macula-ts pool: ${link.seed.host}:${link.seed.port}'s subscription closed before it could be marked live -- retrying`);
220
- await session.close(link.identity).catch(() => { });
221
- this.#scheduleReconnect(link);
120
+ const e = JSON.parse(d.json);
121
+ onEvent({ publisher: e.publisher, realm: e.realm, topic: e.topic, seq: e.seq, publishedAt: e.published_at,
122
+ payload: e.payload, deliveredVia: e.delivered_via });
123
+ });
124
+ return new Subscription(handle, closed);
125
+ }
126
+ /** Serves procedure in realm: handler answers each call, and its thrown
127
+ * error goes back as a handler_error with its message. An org procedure
128
+ * needs the realm's key pinned and the org's delegation to this node in the
129
+ * DHT; a procedure in this node's own namespace (ownProcedure) needs
130
+ * neither, and another node's namespace is refused. */
131
+ async serve(realm, procedure, handler, options = {}) {
132
+ const handle = await native.poolServe(this.live(), id32(realm, "realm"), procedure, bytesModeFor(options.bytes), (d) => {
133
+ if (d.kind !== "request")
222
134
  return;
223
- }
224
- }
225
- link.session = session;
226
- link.status = "live";
227
- link.reconnectAttempt = 0;
228
- if (!link.onConnected)
229
- this.#armHealthCheck(link);
230
- }
231
- /** Control-role links only (topic roles have their own subscribe()
232
- * onClosed and never reach here -- `!link.onConnected` is what
233
- * distinguishes them). publish() is fire-and-forget: found live
234
- * 2026-09-04 that a publish() attempt on a session the station had
235
- * already kicked can still locally "succeed" (the frame is handed off
236
- * before the QUIC stack notices its own connection is gone), so a
237
- * publish-only caller could go arbitrarily long without this pool
238
- * ever discovering a dead control link. Every HEALTH_CHECK_INTERVAL_MS
239
- * while the link is live, this calls a procedure name that is
240
- * guaranteed never to be advertised (a fresh UUID-shaped string) and
241
- * inspects the failure: a clean MaculaCallError means a real BOLT#4
242
- * response came back over the wire (unknown_next_peer, as expected) --
243
- * the connection is genuinely alive, nothing to do. Any OTHER
244
- * thrown error means the call never got a wire-level answer at all --
245
- * the same signal call()'s own doc uses to distinguish a real BOLT#4
246
- * answer from "this never reached anyone" -- so it's treated exactly
247
- * like a real operation failure and schedules a reconnect. */
248
- #armHealthCheck(link) {
249
- if (link.healthCheckTimer)
250
- clearInterval(link.healthCheckTimer);
251
- const timer = setInterval(() => {
252
- const session = link.session;
253
- if (!session)
135
+ void answer(d, handler);
136
+ });
137
+ return new Served(handle);
138
+ }
139
+ /** Serves procedure in realm as a stream of mode: handler drives each
140
+ * session. The stream is closed when the handler returns without ending
141
+ * it, aborted with code error when it throws, and released either way. */
142
+ async serveStream(realm, procedure, mode, handler, options = {}) {
143
+ const handle = await native.poolServeStream(this.live(), id32(realm, "realm"), procedure, mode, bytesModeFor(options.bytes), (d) => {
144
+ if (d.kind !== "request")
254
145
  return;
255
- this.#probeLiveness(session).then((alive) => {
256
- if (alive)
257
- return;
258
- if (link.session !== session)
259
- return; // already superseded by a reconnect
260
- console.error(`macula-ts pool: health check on ${link.seed.host}:${link.seed.port} failed`);
261
- this.#scheduleReconnect(link);
262
- });
263
- }, this.#healthCheckIntervalMs);
264
- timer.unref?.();
265
- link.healthCheckTimer = timer;
146
+ void runStream(new Stream(d.handle, options.bytes), handler);
147
+ });
148
+ return new Served(handle);
266
149
  }
267
- /** Calls a procedure name guaranteed never to be advertised and
268
- * classifies the outcome: true if a real BOLT#4 answer came back
269
- * (the connection is alive, whatever else provoked this probe), false
270
- * if the call never got a wire-level answer at all (the connection is
271
- * genuinely dead). Shared by #armHealthCheck's own timer and call()'s
272
- * handling of an ambiguous failure (see call()'s own doc) -- a plain
273
- * Error from session.call() means "no wire answer", but that is also
274
- * exactly what a timed-out call against an otherwise-healthy but
275
- * momentarily slow provider looks like. Found live 2026-09-05: without
276
- * this second opinion, call() tore down every live control link in
277
- * turn on nothing more than one slow provider response. */
278
- async #probeLiveness(session) {
279
- const probe = `_pool.healthcheck.${Math.random().toString(36).slice(2)}`;
150
+ /** Opens a stream of mode on procedure in realm at a provider, by direct
151
+ * dial. A refusal arrives on its first recv(). */
152
+ async openStream(realm, procedure, mode, payload = {}, options = {}) {
280
153
  try {
281
- await session.call(probe, null, { deadlineMs: this.#healthCheckIntervalMs });
282
- return true; // a real provider somehow answering a random UUID-shaped name is astronomically unlikely either way
154
+ const handle = await native.poolOpenStream(this.live(), id32(realm, "realm"), procedure, mode, JSON.stringify(payload), options.provider === undefined ? null : id32(options.provider, "provider"), options.deadlineMs ?? 0, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS);
155
+ return new Stream(handle, options.bytes);
283
156
  }
284
- catch (err) {
285
- return err instanceof MaculaCallError; // alive iff a real wire answer came back
157
+ catch (e) {
158
+ throw callError(e);
286
159
  }
287
160
  }
288
- /** Marks `link` for backoff and schedules its next reconnect attempt.
289
- * Closes whatever session it currently holds first (fire-and-forget,
290
- * not awaited -- the retry timer must not wait on a graceful close
291
- * round trip) so a still-alive session from an OPERATION failure
292
- * (publish()/call() throwing for a reason that doesn't mean the
293
- * connection itself already died, unlike a subscribe onClosed) is
294
- * never simply orphaned. Found live 2026-09-04: without this, a
295
- * publish/call failure left the old session's native handle both
296
- * leaked AND, worse, still open under this link's identity -- the
297
- * NEXT connect attempt for this SAME station under that SAME
298
- * identity then races the still-live old one for the station's own
299
- * per-identity dedupe kick, with no guarantee which one loses. The
300
- * close is fired here, before scheduling, so it has the full backoff
301
- * delay (at least RECONNECT_BASE_MS) to land before a new connect
302
- * attempt begins.
303
- *
304
- * Idempotent per backoff episode: a no-op once `link.status` is
305
- * already "backoff". Found live in review 2026-09-05: concurrent
306
- * operations queued on one session (e.g. several call()s serialised
307
- * by Session's own internal queue) can ALL fail once that session
308
- * dies, and each one's catch handler calls this -- without this
309
- * guard, every failure past the first would re-close an
310
- * already-`undefined` `link.session` (harmless) but ALSO arm a
311
- * second, third, ... retryTimer, each bumping reconnectAttempt
312
- * independently, leaving multiple redundant reconnects racing each
313
- * other. Deliberately NOT guarded against "connecting" -- this is
314
- * also called from `#attachOnce`'s own failure paths, while status
315
- * is still "connecting" from the top of that same function, and
316
- * that path must proceed normally. */
317
- #scheduleReconnect(link) {
318
- if (link.closing)
319
- return;
320
- if (link.status === "backoff")
321
- return;
322
- if (link.healthCheckTimer) {
323
- clearInterval(link.healthCheckTimer);
324
- link.healthCheckTimer = undefined;
161
+ /** The verified record under key, or null when there is none. */
162
+ async findRecord(key, options = {}) {
163
+ try {
164
+ return toRecord(JSON.parse(await native.poolFindRecord(this.live(), id32(key, "key"), options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS, bytesModeFor(options.bytes))));
325
165
  }
326
- const stale = link.session;
327
- link.status = "backoff";
328
- link.session = undefined;
329
- if (stale) {
330
- const closing = stale.close(link.identity).catch(() => { });
331
- link.pendingClose = closing;
332
- void closing.finally(() => {
333
- if (link.pendingClose === closing)
334
- link.pendingClose = undefined;
335
- });
166
+ catch (e) {
167
+ if (e instanceof Error && e.message === "not_found")
168
+ return null;
169
+ throw e;
336
170
  }
337
- const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** link.reconnectAttempt);
338
- link.reconnectAttempt += 1;
339
- const timer = setTimeout(() => {
340
- const attempt = this.#attachOnce(link);
341
- link.inFlight = attempt.finally(() => {
342
- if (link.inFlight === attempt)
343
- link.inFlight = undefined;
344
- });
345
- }, delay);
346
- // Deliberately NOT unref()'d, unlike healthCheckTimer/sweepTimer --
347
- // this pool's own documented contract is "retry forever" for a
348
- // link in backoff. Found live in review 2026-09-05: a pure
349
- // subscribe()-only process (no other keep-alive handle: no HTTP
350
- // listener, no stdio transport) would otherwise exit cleanly the
351
- // moment its last live connection drops, silently breaking that
352
- // promise instead of actually retrying through the outage.
353
- link.retryTimer = timer;
354
171
  }
355
- #sweepDedup() {
356
- const cutoff = Date.now() - this.#dedupWindowMs;
357
- for (const [key, at] of this.#dedup) {
358
- if (at < cutoff)
359
- this.#dedup.delete(key);
360
- }
172
+ /** Every verified record under key, and how many did not verify. */
173
+ async findRecords(key, options = {}) {
174
+ return toRecords(JSON.parse(await native.poolFindRecords(this.live(), id32(key, "key"), options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS, bytesModeFor(options.bytes))));
361
175
  }
362
- #liveControlLinks() {
363
- return this.#controlLinks.filter((l) => l.status === "live" && l.session);
176
+ /** Every verified record of type the station holds, and how many did not
177
+ * verify. */
178
+ async findRecordsByType(type, options = {}) {
179
+ return toRecords(JSON.parse(await native.poolFindRecordsByType(this.live(), type, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS, bytesModeFor(options.bytes))));
364
180
  }
365
- /** Refuses an `identity` that would double-connect to the same
366
- * station as the control role or another already-tracked
367
- * subscription -- the station kicks the OLDER of the two, this pool
368
- * reconnects it, which gets IT kicked in turn: a perpetual ping-pong
369
- * with no error naming the cause. Compares raw node-id bytes, not
370
- * object identity, so two separately-loaded Identity instances over
371
- * the SAME underlying keypair are still caught. */
372
- #assertIdentityNotAlreadyUsed(identity) {
373
- const nodeIdHex = Buffer.from(identity.nodeId).toString("hex");
374
- if (nodeIdHex === Buffer.from(this.#controlIdentity.nodeId).toString("hex")) {
375
- throw new Error("macula-ts pool: this identity is already the pool's own control identity -- reusing it for a subscription would double-connect one identity to one station");
376
- }
377
- for (const sub of this.#subscriptions.values()) {
378
- if (Buffer.from(sub.identity.nodeId).toString("hex") === nodeIdHex) {
379
- throw new Error(`macula-ts pool: this identity is already used by the "${sub.topic}" subscription -- reusing it here would double-connect one identity to one station`);
380
- }
381
- }
181
+ /** Puts a signed record's wire bytes in the DHT. */
182
+ async putRecord(wire, options = {}) {
183
+ await native.poolPutRecord(this.live(), wire, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS);
382
184
  }
383
- /** Publishes to `replicationFactor` currently-live control links
384
- * (default 1); partial success counts as success. A link whose
385
- * publish attempt fails is marked for respawn immediately -- this is
386
- * this v1's only liveness signal for the control role, since it
387
- * cannot also carry a liveness-only subscribe (see this module's own
388
- * header doc). Throws NoHealthyStationError if zero links are live.
389
- *
390
- * `realm`/`payload` are validated before any link is touched. Found
391
- * live 2026-09-05: a malformed realm or an unserializable payload
392
- * throws inside session.publish() itself, well before any wire I/O --
393
- * treating that throw as evidence of a dead connection (the pre-fix
394
- * behavior) tore down a perfectly healthy link over a caller-side
395
- * argument bug. */
396
- async publish(realm, topic, payload, opts = {}) {
397
- if (this.#closed)
398
- throw new Error("macula-ts pool: used after close()");
399
- realmBytesFromHex(realm);
400
- JSON.stringify(payload ?? null);
401
- const targets = this.#liveControlLinks().slice(0, this.#replicationFactor);
402
- if (targets.length === 0)
403
- throw new NoHealthyStationError();
404
- const results = await Promise.allSettled(targets.map(async (link) => {
405
- const session = link.session;
406
- if (link.status !== "live" || !session)
407
- throw new NoHealthyStationError(); // superseded since targets was captured
408
- try {
409
- await session.publish(topic, payload, { realm, ttlMs: opts.ttlMs });
410
- }
411
- catch (err) {
412
- if (link.session === session)
413
- this.#scheduleReconnect(link);
414
- throw err;
415
- }
416
- }));
417
- if (!results.some((r) => r.status === "fulfilled")) {
418
- const first = results.find((r) => r.status === "rejected");
419
- throw first ? first.reason : new Error("macula-ts pool: publish failed on every targeted link");
420
- }
185
+ /** Closes every link and subscription. */
186
+ async close() {
187
+ if (this.closed)
188
+ return;
189
+ this.closed = true;
190
+ await native.poolClose(this.handle);
421
191
  }
422
- /** Calls `procedure` against the pool's live control links in order
423
- * until one succeeds or all have been tried. Throws
424
- * NoHealthyStationError if zero links are live.
425
- *
426
- * `realm`/`payload`/`opts.bytes` are validated before any link is
427
- * touched, for the
428
- * same reason as publish() -- a malformed realm is a caller bug, not
429
- * evidence of a dead connection, and must never be attributed to one.
430
- *
431
- * A `MaculaCallError` (a real BOLT#4 response -- e.g.
432
- * unknown_next_peer, unauthorized, a procedure nobody serves, a gated
433
- * call this identity isn't authorized for) does NOT mark its link for
434
- * respawn: the connection plainly worked, it answered. call() still
435
- * falls through to the next link either way, matching the Erlang
436
- * reference's own keep_or_next (macula_client.erl) -- a non-idempotent
437
- * provider handler genuinely can be re-invoked on each live link this
438
- * reaches; that is parity with the reference, not a bug this fixes.
439
- *
440
- * Any OTHER thrown error (never a wire-level answer at all) is
441
- * ambiguous, not automatically a dead link: session.call()'s own
442
- * deadlineMs elapsing looks identical to a genuinely severed
443
- * connection, but means the far end is merely slow, not gone. Found
444
- * live 2026-09-05: treating every such error as a dead link meant one
445
- * slow provider response tore down and reconnected EVERY live control
446
- * link in turn as call() moved through them re-trying the same call.
447
- * #probeLiveness's own dedicated liveness call is the tiebreaker --
448
- * only a link that ALSO fails to get a wire-level answer on that
449
- * fresh probe is scheduled for reconnect. Each link's own `session`
450
- * reference is re-checked both before probing and before scheduling a
451
- * reconnect, in case a concurrent operation already superseded it. */
452
- async call(realm, procedure, payload, opts = {}) {
453
- if (this.#closed)
454
- throw new Error("macula-ts pool: used after close()");
455
- realmBytesFromHex(realm);
456
- JSON.stringify(payload ?? null);
457
- bytesModeFor(opts.bytes);
458
- const targets = this.#liveControlLinks();
459
- if (targets.length === 0)
460
- throw new NoHealthyStationError();
461
- let lastErr;
462
- for (const link of targets) {
463
- const session = link.session;
464
- if (link.status !== "live" || !session)
465
- continue; // superseded since `targets` was captured
466
- try {
467
- return await session.call(procedure, payload, { realm, deadlineMs: opts.deadlineMs, bytes: opts.bytes });
468
- }
469
- catch (err) {
470
- lastErr = err;
471
- if (!(err instanceof MaculaCallError) && link.session === session && !(await this.#probeLiveness(session))) {
472
- if (link.session === session)
473
- this.#scheduleReconnect(link);
474
- }
475
- }
192
+ live() {
193
+ if (this.closed)
194
+ throw new Error("macula-ts: this Pool is closed");
195
+ return this.handle;
196
+ }
197
+ }
198
+ /** Answers one served call with the handler's result or its error, once. */
199
+ async function answer(d, handler) {
200
+ const r = JSON.parse(d.json);
201
+ const request = { caller: r.caller, realm: r.realm, procedure: r.procedure, payload: r.payload,
202
+ deadlineMs: r.deadline_ms };
203
+ try {
204
+ native.pendingReply(d.handle, JSON.stringify(await handler(request)));
205
+ }
206
+ catch (e) {
207
+ try {
208
+ native.pendingError(d.handle, e instanceof Error ? e.message : String(e));
209
+ }
210
+ catch {
211
+ // Answered already, or its deadline passed: nothing is waiting.
476
212
  }
477
- throw lastErr;
478
213
  }
479
- /** Subscribes `handler` to `(realm, topic)`: opens one subscribe-only
480
- * session against every configured seed, replayed automatically on
481
- * every future respawn. Mints a dedicated identity for this topic by
482
- * default (disposed on unsubscribe); pass `identity` to supply the
483
- * pool's own instead (e.g. for a stable, caller-controlled identity
484
- * across restarts, matching macula-mcp's own observeRoomIdentityPath
485
- * pattern) -- the pool never disposes an identity it didn't mint.
486
- * `opts.bytes` picks how bytes in each event's payload reach `handler`
487
- * (rpc.ts's BytesOutput), on every seed and after every respawn.
488
- * Returns an unsubscribe function. */
489
- async subscribe(realm, topic, handler, identity, opts = {}) {
490
- if (this.#closed)
491
- throw new Error("macula-ts pool: used after close()");
492
- bytesModeFor(opts.bytes);
493
- const key = subKey(realm, topic);
494
- if (this.#subscriptions.has(key))
495
- throw new Error(`macula-ts pool: already subscribed to ${topic}${realm ? ` (realm ${realm})` : ""}`);
496
- if (identity)
497
- this.#assertIdentityNotAlreadyUsed(identity);
498
- const ownsIdentity = identity === undefined;
499
- const subIdentity = identity ?? Identity.generate();
500
- const sub = { realm, topic, handler, identity: subIdentity, ownsIdentity, links: [] };
501
- const onConnected = async (link, session) => {
502
- await session.subscribe(topic, (evt) => {
503
- const dkey = dedupKey(realm, evt.publisher, evt.seq, topic);
504
- if (this.#dedup.has(dkey))
505
- return;
506
- this.#dedup.set(dkey, Date.now());
507
- // A caller's handler throwing must not take down the native
508
- // callback that invoked it -- found live 2026-09-05: an
509
- // uncaught exception here crosses back into the N-API
510
- // callback with no pending-exception handling on the addon
511
- // side, which Node only warns about today (DEP0168) but is
512
- // documented to become a fatal, unrecoverable crash under
513
- // --force-node-api-uncaught-exceptions-policy once that
514
- // policy's default flips.
515
- try {
516
- sub.handler(evt);
517
- }
518
- catch (err) {
519
- console.error(`macula-ts pool: subscription handler for ${topic} threw:`, err);
520
- }
521
- }, {
522
- realm,
523
- bytes: opts.bytes,
524
- onClosed: (err) => {
525
- if (link.closing)
526
- return; // already tearing down -- #scheduleReconnect would bail anyway, don't log a misleading "reconnecting"
527
- if (link.session === session) {
528
- console.error(`macula-ts pool: subscription to ${topic} on ${link.seed.host}:${link.seed.port} dropped (${err.message}) -- reconnecting`);
529
- this.#scheduleReconnect(link);
530
- }
531
- else {
532
- // subscribe-start hasn't resolved on this side yet (or
533
- // this link has already moved on) -- flag it so
534
- // #attachOnce notices once `onConnected` itself returns,
535
- // rather than marking a link live with a subscription
536
- // that already silently died. See closedDuringConnect's
537
- // own doc.
538
- link.closedDuringConnect = true;
539
- }
540
- },
541
- });
542
- };
543
- sub.links = this.#seeds.map((seed) => this.#newRoleLink(seed, subIdentity, onConnected));
544
- this.#subscriptions.set(key, sub);
545
- await Promise.all(sub.links.map((link) => this.#attach(link)));
546
- return async () => {
547
- // Guards against a stale unsubscribe() firing (possibly a second
548
- // time, which this SDK's own convention elsewhere treats as safe)
549
- // after a newer subscription has since taken this same (realm,
550
- // topic) key -- found live 2026-09-05: without this check, an
551
- // old unsubscribe() tore down a DIFFERENT, newer subscription's
552
- // links and deleted it from #subscriptions, silently stopping its
553
- // handler with no error anywhere.
554
- if (this.#subscriptions.get(key) !== sub)
555
- return;
556
- const tracked = sub;
557
- this.#subscriptions.delete(key);
558
- await Promise.all(tracked.links.map(async (link) => {
559
- link.closing = true;
560
- if (link.retryTimer)
561
- clearTimeout(link.retryTimer);
562
- if (link.inFlight)
563
- await link.inFlight.catch(() => { });
564
- if (link.pendingClose)
565
- await link.pendingClose;
566
- if (link.session)
567
- await link.session.close(link.identity).catch(() => { });
568
- }));
569
- if (tracked.ownsIdentity)
570
- tracked.identity.dispose();
571
- };
214
+ }
215
+ /** Runs a stream handler, ends the stream as it leaves it, and releases it. */
216
+ async function runStream(stream, handler) {
217
+ try {
218
+ await handler(stream, stream.request());
219
+ await stream.close().catch(() => { });
572
220
  }
573
- /** Live/backing-off CONTROL link counts (publish/call reachability).
574
- * Every configured seed is exactly one or the other. Per-topic
575
- * subscription link health is not reflected here -- inspect a
576
- * specific subscription's own behavior (events arriving or not)
577
- * instead; exposing N independent per-topic health vectors here
578
- * would not simplify what a caller actually needs to know. */
579
- status() {
580
- const healthy = this.#liveControlLinks().length;
581
- return { healthyLinks: healthy, failedLinks: this.#controlLinks.length - healthy };
221
+ catch (e) {
222
+ await stream.abort("error", e instanceof Error ? e.message : String(e)).catch(() => { });
582
223
  }
583
- /** Closes every control and subscription link and disposes every
584
- * identity this pool owns (the caller-supplied control identity
585
- * included). Awaits each link's in-flight connect/reconnect first,
586
- * so a still-connecting link never has its identity yanked out from
587
- * under it. */
588
- async close() {
589
- if (this.#closed)
590
- return;
591
- this.#closed = true;
592
- clearInterval(this.#sweepTimer);
593
- const allLinks = [...this.#controlLinks, ...[...this.#subscriptions.values()].flatMap((s) => s.links)];
594
- await Promise.all(allLinks.map(async (link) => {
595
- link.closing = true;
596
- if (link.retryTimer)
597
- clearTimeout(link.retryTimer);
598
- if (link.healthCheckTimer)
599
- clearInterval(link.healthCheckTimer);
600
- if (link.inFlight)
601
- await link.inFlight.catch(() => { });
602
- if (link.pendingClose)
603
- await link.pendingClose;
604
- if (link.session)
605
- await link.session.close(link.identity).catch(() => { });
606
- link.session = undefined;
607
- link.status = "backoff"; // reflects reality for status()/#liveControlLinks() if either is called after close()
608
- }));
609
- for (const sub of this.#subscriptions.values())
610
- if (sub.ownsIdentity)
611
- sub.identity.dispose();
612
- this.#subscriptions.clear();
613
- this.#controlIdentity.dispose();
224
+ finally {
225
+ await stream.free();
614
226
  }
615
227
  }
228
+ function toRecord(r) {
229
+ return { type: r.type, keyId: r.key_id, createdAt: r.created_at, expiresAt: r.expires_at, payload: r.payload,
230
+ wire: r.wire };
231
+ }
232
+ function toRecords(out) {
233
+ return { records: (out.records ?? []).map(toRecord), dropped: out.dropped ?? 0 };
234
+ }
616
235
  //# sourceMappingURL=pool.js.map