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