@synapse-sdk/client 0.3.8 → 0.3.9

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/channel.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { SynapseError, encode as encodeBody, type ErrorBody } from "@synapse-sdk/core";
2
2
  import type { Node } from "./node.ts";
3
3
  import type { Session } from "./session.ts";
4
+ import type { Verdict } from "./types.ts";
4
5
  import { AsyncQueue, type Deferred } from "./util.ts";
5
6
  /** A fresh binding id (WP §8.1): 6 hex digits, the identity of one attempt to the runtime. */
6
7
  export declare function newBind(): string;
@@ -12,8 +13,10 @@ export declare const REFUSAL_PACE_MAX_MS = 5000;
12
13
  export declare function refusalPaceMs(n: number): number;
13
14
  /** The runtime is mid-change and says so: asked again shortly, silently. */
14
15
  export declare function isTransient(e: SynapseError): boolean;
15
- /** A refusal that is asked again, never a verdict: every code but the session going. */
16
- export declare function isReasked(e: SynapseError): boolean;
16
+ /** A refusal for a fault of the caller's (PLAN v0.42): not asked again on this session; the verdict is `refused`. */
17
+ export declare function isTerminal(e: SynapseError): boolean;
18
+ /** A refusal that is asked again, paced and warned, never a verdict (contention: `already_registered`, `owner_taken`, `name_taken`, `unauthorized`, and every other code that is neither transient nor terminal); not the session going. */
19
+ export declare function isContention(e: SynapseError): boolean;
17
20
  /** The holder a refusal names (`data.by`), if it does. */
18
21
  export declare function holderOf(e: SynapseError): string | undefined;
19
22
  export type Event = {
@@ -51,15 +54,7 @@ export interface Intent {
51
54
  /** Retracts what `install` installed, if anything is. */
52
55
  retract(): void;
53
56
  /** Where the intent stands on the current session. */
54
- setVerdict(v: {
55
- status: "unbound";
56
- } | {
57
- status: "pending";
58
- epoch: number;
59
- } | {
60
- status: "admitted";
61
- epoch: number;
62
- }): void;
57
+ setVerdict(v: Verdict): void;
63
58
  /** The task is exiting (after the last retract). */
64
59
  exited?(): void;
65
60
  warn(msg: string): void;
@@ -72,7 +67,7 @@ export declare class Handle {
72
67
  readonly mailbox: AsyncQueue<Event>;
73
68
  readonly exited: Deferred<void>;
74
69
  constructor(node: Node, intent: Intent);
75
- /** Resolves once the current attempt is admitted and announced; `not_connected` once the task is gone. */
70
+ /** Resolves once the current attempt is admitted and announced; rejects with a terminal refusal of the current session; `not_connected` once the task is gone. */
76
71
  ready(): Promise<void>;
77
72
  /** The intent is over: the task writes the `unregister` and exits. */
78
73
  unregister(): void;
package/dist/channel.js CHANGED
@@ -9,10 +9,14 @@
9
9
  // - `ended`: the installed binding's reader ended (a provider's `calls` track): a renewal on the same session;
10
10
  // - `ready`: the application waits for the admission ([`Handle.ready`]), answered when it lands;
11
11
  // - `retry`: a paced re-ask is due; `unregister`: the application dropped the handle.
12
- // A transient reply (the runtime mid-change) is asked again on the same bind after `REFOLLOW_PACE`;
13
- // every refusal too (D 2026-10-06, R6, "phoenix style"), paced `refusalPace`, with a warning each
14
- // time. A refusal is no verdict: the verdict stays `pending`. Fast-lane invariant: the task never
15
- // sits between the application and a data track; it only installs and retracts what the fast lane uses.
12
+ // A refusal is one of three kinds (PLAN v0.42): transient (the runtime mid-change), asked again on
13
+ // the same bind after `REFOLLOW_PACE`, silently; contention (`already_registered`, `owner_taken`,
14
+ // `name_taken`, `unauthorized`, and every code not named here: D 2026-10-06, R6, "phoenix style"),
15
+ // asked again paced `refusalPace` with a warning each time, the verdict staying `pending`; terminal
16
+ // (the caller's fault: `isTerminal`): the verdict `refused` with the error, one warning, nothing
17
+ // asked again on the session, `ready()` rejects with it; the next session is a new attempt.
18
+ // Fast-lane invariant: the task never sits between the application and a data track; it only
19
+ // installs and retracts what the fast lane uses.
16
20
  import { SynapseError, encode as encodeBody } from "@synapse-sdk/core";
17
21
  import { startOn } from "./calls.js";
18
22
  import { AsyncQueue, deferred } from "./util.js";
@@ -26,8 +30,10 @@ export const REFUSAL_PACE_MAX_MS = 5000;
26
30
  export function refusalPaceMs(n) { return Math.min(REFOLLOW_PACE_MS * 2 ** Math.min(n - 1, 31), REFUSAL_PACE_MAX_MS); }
27
31
  /** The runtime is mid-change and says so: asked again shortly, silently. */
28
32
  export function isTransient(e) { return ["service_unavailable", "provider_unavailable", "overloaded", "project_settling", "timeout"].includes(e.code); }
29
- /** A refusal that is asked again, never a verdict: every code but the session going. */
30
- export function isReasked(e) { return e.code !== "not_connected" && e.code !== "connection_lost"; }
33
+ /** A refusal for a fault of the caller's (PLAN v0.42): not asked again on this session; the verdict is `refused`. */
34
+ export function isTerminal(e) { return ["schema_mismatch", "content_mismatch", "invalid_input", "mode_mismatch", "project_inactive"].includes(e.code); }
35
+ /** A refusal that is asked again, paced and warned, never a verdict (contention: `already_registered`, `owner_taken`, `name_taken`, `unauthorized`, and every other code that is neither transient nor terminal); not the session going. */
36
+ export function isContention(e) { return !isTerminal(e) && !isTransient(e) && e.code !== "not_connected" && e.code !== "connection_lost"; }
31
37
  /** The holder a refusal names (`data.by`), if it does. */
32
38
  export function holderOf(e) { const d = e.data; return d && typeof d.by === "string" ? d.by : undefined; }
33
39
  /** The application's side of a handle: its mailbox, and `ready()` as a request to the task. */
@@ -41,7 +47,7 @@ export class Handle {
41
47
  this.node = node;
42
48
  this.intent = intent;
43
49
  }
44
- /** Resolves once the current attempt is admitted and announced; `not_connected` once the task is gone. */
50
+ /** Resolves once the current attempt is admitted and announced; rejects with a terminal refusal of the current session; `not_connected` once the task is gone. */
45
51
  ready() {
46
52
  const reply = deferred();
47
53
  if (!this.mailbox.push({ kind: "ready", reply }))
@@ -64,6 +70,8 @@ async function run(h) {
64
70
  let attempt = 0;
65
71
  let current;
66
72
  let admitted = false;
73
+ /** The current attempt's terminal refusal: nothing is asked again on its session. */
74
+ let refusedWith;
67
75
  let refusals = 0;
68
76
  let retryTimer;
69
77
  const ready = [];
@@ -110,6 +118,7 @@ async function run(h) {
110
118
  attempt += 1;
111
119
  clearRetry();
112
120
  admitted = false;
121
+ refusedWith = undefined;
113
122
  const a = current;
114
123
  current = undefined;
115
124
  if (!a)
@@ -141,6 +150,15 @@ async function run(h) {
141
150
  intent.warn(`${intent.registerSvc} ${intent.name}: ${what} (${e.code}${e.message ? `: ${e.message}` : ""}; holder ${holderOf(e) ?? "-"}; attempt ${refusals}; again in ${pace} ms)`);
142
151
  scheduleRetry(pace);
143
152
  };
153
+ /** A terminal refusal (PLAN v0.42): `refused`, one warning, no re-ask; the waiting `ready()`s reject with it. */
154
+ const refusedForGood = (e, epoch) => {
155
+ intent.warn(`${intent.registerSvc} ${intent.name}: refused by the runtime; not asked again on this session (${e.code}${e.message ? `: ${e.message}` : ""}; holder ${holderOf(e) ?? "-"})`);
156
+ clearRetry();
157
+ refusedWith = e;
158
+ intent.setVerdict({ status: "refused", epoch, error: e });
159
+ for (const r of ready.splice(0))
160
+ r.reject(e);
161
+ };
144
162
  const { session: first, unmonitor } = node.monitor(e => mailbox.push(e.kind === "started" ? { kind: "session", session: e.session } : e.kind === "ended" ? { kind: "sessionEnded" } : { kind: "nodeEnded" }));
145
163
  sessionChanged(first);
146
164
  try {
@@ -173,7 +191,9 @@ async function run(h) {
173
191
  }
174
192
  else if (isTransient(ev.result))
175
193
  scheduleRetry(REFOLLOW_PACE_MS);
176
- else if (isReasked(ev.result))
194
+ else if (isTerminal(ev.result))
195
+ refusedForGood(ev.result, a.session.epoch);
196
+ else if (isContention(ev.result))
177
197
  refused(ev.result, "refused by the runtime; asking again");
178
198
  // else: the session went; its end (a message on its way) begins the next attempt
179
199
  break;
@@ -190,6 +210,8 @@ async function run(h) {
190
210
  case "ready":
191
211
  if (admitted)
192
212
  ev.reply.resolve();
213
+ else if (refusedWith)
214
+ ev.reply.reject(refusedWith);
193
215
  else
194
216
  ready.push(ev.reply);
195
217
  break;
package/dist/serve.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type Card, type ErrorBody, type ServiceContract } from "@synapse-sdk/co
2
2
  import { Handle, type Event as HandleEvent, type Intent } from "./channel.ts";
3
3
  import type { Node } from "./node.ts";
4
4
  import { type Session } from "./session.ts";
5
- import type { RegVerdict, Registration, ServeHandler } from "./types.ts";
5
+ import type { RegVerdict, Registration, ServeHandler, Verdict } from "./types.ts";
6
6
  import type { Warn } from "./topics.ts";
7
7
  /** The reason `ctx.signal` carries when our track or session went (WP §8.4). */
8
8
  export declare const TRACK_LOST = "track_lost";
@@ -30,15 +30,7 @@ export declare class ServiceRegistration<I, U, R> implements Intent, Registratio
30
30
  /** Announces the card and `out`, installs the binding, then starts its `calls` reader. */
31
31
  install(s: Session, bind: string, mailbox: (e: HandleEvent) => void): void;
32
32
  retract(): void;
33
- setVerdict(v: {
34
- status: "unbound";
35
- } | {
36
- status: "pending";
37
- epoch: number;
38
- } | {
39
- status: "admitted";
40
- epoch: number;
41
- }): void;
33
+ setVerdict(v: Verdict): void;
42
34
  /** The task is exiting: whatever handler still runs is this registration's to cancel. */
43
35
  exited(): void;
44
36
  }
package/dist/store.d.ts CHANGED
@@ -30,6 +30,8 @@ export declare class State {
30
30
  gap: number | undefined;
31
31
  pending: number;
32
32
  ended: string | undefined;
33
+ /** The open's terminal refusal on the current session (PLAN v0.42). */
34
+ refused: SynapseError | undefined;
33
35
  subscribe(l: () => void): () => void;
34
36
  watch(): AsyncQueue<StoreChange[]>;
35
37
  /** A feed its reader has left: it is closed and gets nothing more. */
@@ -42,6 +44,8 @@ export declare class State {
42
44
  applyDiff(d: StoreDiff, warn: Warn): boolean;
43
45
  /** Nothing of the incarnation is known until the runtime answers the next open. */
44
46
  reset(): void;
47
+ /** The open was refused for good on this session: nothing of the incarnation is known, the error is the status's. */
48
+ refuse(e: SynapseError): void;
45
49
  opened(a: StoreAccess | undefined): void;
46
50
  end(reason: string): void;
47
51
  setPending(n: number): void;
package/dist/store.js CHANGED
@@ -3,7 +3,9 @@
3
3
  // Its intent is the open (`synapse/store/open` with the handle's access mode); admitted, it follows the
4
4
  // incarnation the reply names (`runtime/store/<ns>/@<epoch>`, or `runtime/config/@<epoch>`), track
5
5
  // `data`: a snapshot, then diffs, then a final frame. The mirror is the runtime's: nothing is decided
6
- // here. A refusal is asked again, paced, with a warning (R6); a transient reply paced, silently; an
6
+ // here. A contention refusal is asked again, paced, with a warning (R6); a terminal one (`mode_mismatch`,
7
+ // `invalid_input`, ...: PLAN v0.42) is the status's `refused`, warned once, not asked again until the
8
+ // next session; a transient reply paced, silently; an
7
9
  // incarnation that ends (writer gone, project switch, the owner restarted) replays the intent;
8
10
  // `destroyed` ends the handle. Writes name the epoch the mirror holds; the runtime checks ownership
9
11
  // (`not_owner`). Between incarnations (a session change, a replay) nothing is known: a write then is
@@ -11,7 +13,7 @@
11
13
  import * as Moq from "@moq/net";
12
14
  import { FLAGS, STORE_TRACK, SynapseError, decode as decodeBody, decodeHeader, moqPriority, storeBasePath, storeRobotScoped, } from "@synapse-sdk/core";
13
15
  import { startOn } from "./calls.js";
14
- import { REFOLLOW_PACE_MS, holderOf, isTransient, refusalPaceMs } from "./channel.js";
16
+ import { REFOLLOW_PACE_MS, holderOf, isTerminal, isTransient, refusalPaceMs } from "./channel.js";
15
17
  import { latestSubscription, waitActive } from "./session.js";
16
18
  import { AsyncQueue, StatusCell, TIMEOUT, deferred, withTimeout } from "./util.js";
17
19
  export const ENDED_DESTROYED = "destroyed";
@@ -43,6 +45,8 @@ export class State {
43
45
  gap;
44
46
  pending = 0;
45
47
  ended;
48
+ /** The open's terminal refusal on the current session (PLAN v0.42). */
49
+ refused;
46
50
  #listeners = new Set();
47
51
  #watchers = new Set();
48
52
  subscribe(l) { this.#listeners.add(l); return () => { this.#listeners.delete(l); }; }
@@ -155,6 +159,15 @@ export class State {
155
159
  this.minRole = undefined;
156
160
  this.onDeath = undefined;
157
161
  this.absent = false;
162
+ this.refused = undefined;
163
+ this.#notify();
164
+ }
165
+ /** The open was refused for good on this session: nothing of the incarnation is known, the error is the status's. */
166
+ refuse(e) {
167
+ if (this.ended !== undefined)
168
+ return;
169
+ this.reset();
170
+ this.refused = e;
158
171
  this.#notify();
159
172
  }
160
173
  opened(a) {
@@ -189,7 +202,7 @@ export class State {
189
202
  status(nodeConnected, closed = false) {
190
203
  const ended = closed ? ENDED_CLIENT : this.ended;
191
204
  const connected = ended === undefined && nodeConnected;
192
- return { synced: connected && this.pending === 0 && this.gap === undefined && (this.loaded || this.absent), connected, pending: this.pending, mode: this.mode, owner: this.owner, mine: this.mine, minRole: this.minRole, onDeath: this.onDeath, absent: this.absent, version: this.version, epoch: this.epoch, ended };
205
+ return { synced: connected && this.pending === 0 && this.gap === undefined && (this.loaded || this.absent), connected, pending: this.pending, mode: this.mode, owner: this.owner, mine: this.mine, minRole: this.minRole, onDeath: this.onDeath, absent: this.absent, version: this.version, epoch: this.epoch, ended, refused: this.refused };
193
206
  }
194
207
  }
195
208
  function sameValue(a, b) {
@@ -269,7 +282,7 @@ async function run(ns, registry) {
269
282
  const idle = () => { stopPhase(); phase = { kind: "idle" }; };
270
283
  /** Writes the open on the task's session, from `idle`, when nothing holds it back. */
271
284
  const open = () => {
272
- if (phase.kind !== "idle" || destroying > 0 || state.ended !== undefined || !session)
285
+ if (phase.kind !== "idle" || destroying > 0 || state.ended !== undefined || state.refused !== undefined || !session)
273
286
  return;
274
287
  const s = session;
275
288
  leaveIncarnation();
@@ -395,6 +408,13 @@ async function run(ns, registry) {
395
408
  phase = { kind: "waitingProject", before: known };
396
409
  return;
397
410
  }
411
+ if (isTerminal(e)) {
412
+ warn(`store ${name}: open refused by the runtime; not asked again on this session (${e.code}${e.message ? `: ${e.message}` : ""}; holder ${holderOf(e) ?? "-"})`);
413
+ state.refuse(e);
414
+ for (const j of joining.splice(0))
415
+ j.reject(e);
416
+ return;
417
+ }
398
418
  refusals += 1;
399
419
  const pace = refusalPaceMs(refusals);
400
420
  warn(`store ${name}: open refused by the runtime (${e.code}${e.message ? `: ${e.message}` : ""}; holder ${holderOf(e) ?? "-"}; attempt ${refusals}; again in ${pace} ms)`);
@@ -418,6 +438,10 @@ async function run(ns, registry) {
418
438
  reply.reject(new SynapseError("project_inactive"));
419
439
  return;
420
440
  }
441
+ if (state.refused) {
442
+ reply.reject(state.refused);
443
+ return;
444
+ }
421
445
  if (state.absent || (state.loaded && state.version >= wanted))
422
446
  reply.resolve();
423
447
  else
package/dist/synapse.d.ts CHANGED
@@ -83,7 +83,7 @@ export declare class Synapse implements AsyncDisposable {
83
83
  tUs: number;
84
84
  dom: string;
85
85
  }): Age | undefined;
86
- /** A publisher of `topic`; resolves once the runtime admitted it on the current session (a refusal is asked again, paced, warned). */
86
+ /** A publisher of `topic`; resolves once the runtime admitted it on the current session (a contention refusal is asked again, paced, warned; a terminal one, e.g. `schema_mismatch`, rejects it). */
87
87
  publisher<TIn, TOut>(topic: TopicContract<TIn, TOut>): Promise<Publisher<TIn>>;
88
88
  publisher<TIn = unknown>(topic: string): Promise<Publisher<TIn>>;
89
89
  /**
@@ -91,12 +91,15 @@ export declare class Synapse implements AsyncDisposable {
91
91
  * `for await`. Its follow task starts now. With `cb`, the callback form: the SDK runs the read loop and calls `cb` with
92
92
  * each batch `next()` returns (a throwing `cb` is rethrown later; the loop goes on); the same subscription comes back
93
93
  * (`unsubscribe()` ends it, `stats` counts it).
94
+ *
95
+ * A contract with a schema also tells the runtime the shape this node reads: the catalog lists the topic and this node
96
+ * under `subscribers` while the subscription lives, before anyone publishes. A subscription by bare name is not listed.
94
97
  */
95
98
  subscribe<TIn, TOut>(topic: TopicContract<TIn, TOut>, opts?: SubscribeOptions): Subscription<TOut>;
96
99
  subscribe<TIn, TOut>(topic: TopicContract<TIn, TOut>, cb: (batch: Envelope<TOut>[]) => void, opts?: SubscribeOptions): Subscription<TOut>;
97
100
  subscribe<TOut = unknown>(topic: string, opts?: SubscribeOptions): Subscription<TOut>;
98
101
  subscribe<TOut = unknown>(topic: string, cb: (batch: Envelope<TOut>[]) => void, opts?: SubscribeOptions): Subscription<TOut>;
99
- /** The topic's latest value as a view (SDK §4.3); subscribed while someone listens. */
102
+ /** The topic's latest value as a view (SDK §4.3); subscribed from its creation until `close()`. */
100
103
  view<TIn, TOut, S = TOut>(topic: TopicContract<TIn, TOut>, opts?: ViewOptions<TOut, S>): View<S>;
101
104
  view<TOut = unknown, S = TOut>(topic: string, opts?: ViewOptions<TOut, S>): View<S>;
102
105
  /** Starts a call on the current session (SDK §5.2): `await` it for the result, read its updates (`for await (const u of run)`, `await run.next()`), or pass `onUpdate`. With no session (before the first, between two) the Run is already failed `not_connected`: nothing was sent. */
package/dist/synapse.js CHANGED
@@ -10,7 +10,7 @@ import { ServiceRegistration } from "./serve.js";
10
10
  import { openScene } from "./scene.js";
11
11
  import { Namespace, StoreHandleImpl, attachStore, openStore } from "./store.js";
12
12
  import { TasksFacet } from "./tasks.js";
13
- import { PublisherHandle, TopicPublisher, TopicSubscription, wasmFor } from "./topics.js";
13
+ import { PublisherHandle, SubscriberClaim, TopicPublisher, TopicSubscription, wasmFor } from "./topics.js";
14
14
  import { TopicView } from "./view.js";
15
15
  import { watchState } from "./util.js";
16
16
  export { FIRST_CONNECT_GIVE_UP_MS } from "./node.js";
@@ -25,6 +25,10 @@ function wantedDesc(contract, n) {
25
25
  function descKey(d) {
26
26
  return JSON.stringify({ delivery: d.delivery, content_type: d.content_type, schema_hash: d.schema_hash ?? null, fec: d.fec ?? null });
27
27
  }
28
+ /** The parts of a descriptor two typed subscriptions of one topic on one node must agree on (`fec` is a publisher's own), as text. */
29
+ function shapeKey(d) {
30
+ return JSON.stringify({ delivery: d.delivery, content_type: d.content_type, schema_hash: d.schema_hash ?? null });
31
+ }
28
32
  /**
29
33
  * A fact of the node as a state value (`node.status`, `node.catalog`, `node.project`, `node.discovery`): `read` is the fact
30
34
  * now, the same object while it is unchanged. It changes with the node's link, with the node's own values `kept` names,
@@ -82,6 +86,8 @@ const NO_SERVICES = Object.freeze({});
82
86
  export class Synapse {
83
87
  #node;
84
88
  #publishers = new Map();
89
+ /** The node's subscriber claims by topic name (PLAN v0.41): one per topic, held by its typed subscriptions. */
90
+ #subscriberClaims = new Map();
85
91
  #stores = new Map();
86
92
  warn;
87
93
  /**
@@ -259,15 +265,46 @@ export class Synapse {
259
265
  subscribe(topic, cbOrOpts, opts = {}) {
260
266
  const contract = resolveTopic(topic);
261
267
  if (typeof cbOrOpts !== "function")
262
- return new TopicSubscription(this.node, contract, cbOrOpts ?? {}, this.warn);
263
- const sub = new TopicSubscription(this.node, contract, opts, this.warn);
268
+ return new TopicSubscription(this.node, contract, cbOrOpts ?? {}, this.warn, this.#holdClaim(contract));
269
+ const sub = new TopicSubscription(this.node, contract, opts, this.warn, this.#holdClaim(contract));
264
270
  sub.deliverTo(cbOrOpts);
265
271
  return sub;
266
272
  }
267
273
  view(topic, opts = {}) {
268
274
  const contract = resolveTopic(topic);
269
275
  const { staleTime: _s, select: _sel, ...subOpts } = opts;
270
- return new TopicView(this.node, () => new TopicSubscription(this.node, contract, subOpts, this.warn), opts, () => { });
276
+ return new TopicView(this.node, () => new TopicSubscription(this.node, contract, subOpts, this.warn, this.#holdClaim(contract)), opts, () => { });
277
+ }
278
+ /**
279
+ * The node's subscriber claim of `contract`'s topic, held once more (made with its task when none is live); returns its
280
+ * release, which the last holder's call turns into the `unregister`. A contract without a schema claims nothing; one whose
281
+ * shape differs from the live claim's throws `invalid_input`.
282
+ */
283
+ #holdClaim(contract) {
284
+ if (!contract.schema)
285
+ return undefined;
286
+ const name = contract.name;
287
+ let claim = this.#subscriberClaims.get(name);
288
+ if (!claim) {
289
+ claim = new SubscriberClaim(this.node, contract, this.warn);
290
+ this.#subscriberClaims.set(name, claim);
291
+ }
292
+ // One subscriber claim per topic per node: a typed subscription of another shape is not this node's claim, and its shape never reached the runtime.
293
+ else if (shapeKey(claim.contract.desc(this.node.dom)) !== shapeKey(contract.desc(this.node.dom)))
294
+ throw new SynapseError("invalid_input", `topic ${name}: this node already subscribes with a different descriptor`);
295
+ const c = claim;
296
+ c.holders++;
297
+ let held = true;
298
+ return () => {
299
+ if (!held)
300
+ return;
301
+ held = false;
302
+ if (--c.holders > 0)
303
+ return;
304
+ c.handle.unregister();
305
+ if (this.#subscriberClaims.get(name) === c)
306
+ this.#subscriberClaims.delete(name);
307
+ };
271
308
  }
272
309
  call(svc, args = {}, opts = {}) {
273
310
  const s = this.#session();
package/dist/topics.d.ts CHANGED
@@ -14,8 +14,8 @@ export declare class TopicPublisher<TIn, TOut> implements Intent {
14
14
  private readonly wasm;
15
15
  readonly warn: Warn;
16
16
  readonly name: string;
17
- readonly registerSvc = "synapse/topic/register";
18
- readonly unregisterSvc = "synapse/topic/unregister";
17
+ readonly registerSvc = "synapse/publisher/register";
18
+ readonly unregisterSvc = "synapse/publisher/unregister";
19
19
  readonly desc: TrackDesc;
20
20
  readonly fecParams: FecParams | undefined;
21
21
  readonly fecStats: FecPubStats | undefined;
@@ -71,6 +71,32 @@ export declare class PublisherHandle<TIn, TOut> implements Publisher<TIn> {
71
71
  close(): void;
72
72
  [Symbol.asyncDispose](): Promise<void>;
73
73
  }
74
+ /**
75
+ * A subscriber's claim of a topic (PLAN v0.41): the node's typed subscriptions of the topic tell the runtime the shape
76
+ * they read. One per topic per node (`Synapse` keeps them by name), held by those subscriptions; the last one ended
77
+ * writes the `unregister`. It installs nothing: the subscriptions read as they always do. A refusal (a shape that
78
+ * differs from the topic's established one) is warned once and not asked again on the session (PLAN v0.42).
79
+ */
80
+ export declare class SubscriberClaim implements Intent {
81
+ #private;
82
+ readonly node: Node;
83
+ readonly contract: TopicContract<unknown, unknown>;
84
+ readonly warn: Warn;
85
+ readonly name: string;
86
+ readonly registerSvc = "synapse/subscriber/register";
87
+ readonly unregisterSvc = "synapse/subscriber/unregister";
88
+ /** The subscriptions holding it. */
89
+ holders: number;
90
+ readonly handle: Handle;
91
+ constructor(node: Node, contract: TopicContract<unknown, unknown>, warn: Warn);
92
+ get verdict(): Verdict;
93
+ /** The shape this node reads: the contract's descriptor in our time domain (final before the first session), without `fec` (a publisher's own transport setting). */
94
+ registerArgs(bind: string): unknown;
95
+ unregisterArgs(bind: string): unknown;
96
+ install(_s: Session, _bind: string, _mailbox: (e: HandleEvent) => void): void;
97
+ retract(): void;
98
+ setVerdict(v: Verdict): void;
99
+ }
74
100
  /** Resolves the codec a `fec` topic needs (once per process). */
75
101
  export declare function wasmFor(contract: TopicContract<unknown, unknown>): Promise<FecWasm | undefined>;
76
102
  /** What a source gives when its track ended. */
@@ -114,10 +140,12 @@ export declare class TopicSubscription<T> implements Subscription<T> {
114
140
  readonly contract: TopicContract<unknown, T>;
115
141
  readonly opts: SubscribeOptions;
116
142
  readonly warn: Warn;
143
+ private readonly release?;
117
144
  readonly topic: string;
118
145
  readonly stats: SubStats;
119
146
  readonly latest: boolean;
120
- constructor(node: Node, contract: TopicContract<unknown, T>, opts: SubscribeOptions, warn: Warn);
147
+ /** `release` lets go of what the subscription holds besides its tracks (the node's subscriber claim of a typed topic); called once, at `unsubscribe()`. */
148
+ constructor(node: Node, contract: TopicContract<unknown, T>, opts: SubscribeOptions, warn: Warn, release?: (() => void) | undefined);
121
149
  get signal(): AbortSignal;
122
150
  get ownDom(): string;
123
151
  /** The follow task hands a plain publisher here (a datagram source's reader starts); the waiting `next()` is woken. */
package/dist/topics.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Topics (SDK §4, WP §6–§7), the JS spelling of the Rust client's `topics.rs`.
2
2
  //
3
- // A publisher is an intent (`channel.ts`): its handle task asks the runtime `synapse/topic/register`
3
+ // A publisher is an intent (`channel.ts`): its handle task asks the runtime `synapse/publisher/register`
4
4
  // on every session and, admitted, installs the broadcast `<prefix>/topic/<bind>/<topic>` — `desc`
5
5
  // (one retained frame: the descriptor with our time domain) and `data` — which `publish()` writes to
6
6
  // on the fast lane, synchronously; while nothing is installed frames are dropped and counted.
@@ -11,6 +11,10 @@
11
11
  // (listed = alive): a publisher whose subscribe fails is left out until the catalog next changes,
12
12
  // when every listed publisher not held is tried again; no timer of our own. A datagram publisher
13
13
  // whose own `desc` has `fec` gets a follower (the video path: `FecReader` per publisher).
14
+ //
15
+ // A typed subscription (a contract with a schema) also holds the node's subscriber claim of its topic (PLAN v0.41), an
16
+ // intent of the same shape that installs nothing: the runtime lists the topic and this node under `subscribers` with
17
+ // the shape it reads, before anyone publishes.
14
18
  import * as Moq from "@moq/net";
15
19
  import { FLAGS, MSGPACK, SynapseError, TYPES, auParamSets, auSyncPoints, decode as decodeBody, decodeHeader, encode as encodeBody, fecSymbolSizeFor, frame, isVideo, mediaFlags, moqPriority, nowUs, stampSentUs, hasPendingSentUs, mediaHeader, topicPath, validateSync, } from "@synapse-sdk/core";
16
20
  import { Handle, start as startHandle } from "./channel.js";
@@ -32,8 +36,8 @@ export class TopicPublisher {
32
36
  wasm;
33
37
  warn;
34
38
  name;
35
- registerSvc = "synapse/topic/register";
36
- unregisterSvc = "synapse/topic/unregister";
39
+ registerSvc = "synapse/publisher/register";
40
+ unregisterSvc = "synapse/publisher/unregister";
37
41
  desc;
38
42
  fecParams;
39
43
  fecStats;
@@ -245,6 +249,38 @@ export class PublisherHandle {
245
249
  }
246
250
  async [Symbol.asyncDispose]() { this.close(); }
247
251
  }
252
+ /**
253
+ * A subscriber's claim of a topic (PLAN v0.41): the node's typed subscriptions of the topic tell the runtime the shape
254
+ * they read. One per topic per node (`Synapse` keeps them by name), held by those subscriptions; the last one ended
255
+ * writes the `unregister`. It installs nothing: the subscriptions read as they always do. A refusal (a shape that
256
+ * differs from the topic's established one) is warned once and not asked again on the session (PLAN v0.42).
257
+ */
258
+ export class SubscriberClaim {
259
+ node;
260
+ contract;
261
+ warn;
262
+ name;
263
+ registerSvc = "synapse/subscriber/register";
264
+ unregisterSvc = "synapse/subscriber/unregister";
265
+ #verdict = { status: "unbound" };
266
+ /** The subscriptions holding it. */
267
+ holders = 0;
268
+ handle;
269
+ constructor(node, contract, warn) {
270
+ this.node = node;
271
+ this.contract = contract;
272
+ this.warn = warn;
273
+ this.name = contract.name;
274
+ this.handle = startHandle(node, this);
275
+ }
276
+ get verdict() { return this.#verdict; }
277
+ /** The shape this node reads: the contract's descriptor in our time domain (final before the first session), without `fec` (a publisher's own transport setting). */
278
+ registerArgs(bind) { const { fec: _fec, ...desc } = this.contract.desc(this.node.dom); return { topic: this.name, desc, bind }; }
279
+ unregisterArgs(bind) { return { topic: this.name, bind }; }
280
+ install(_s, _bind, _mailbox) { }
281
+ retract() { }
282
+ setVerdict(v) { this.#verdict = v; }
283
+ }
248
284
  /** Resolves the codec a `fec` topic needs (once per process). */
249
285
  export async function wasmFor(contract) {
250
286
  return contract.delivery === "datagram" && contract.fec ? requireFec(`topic ${contract.name}`) : undefined;
@@ -266,6 +302,7 @@ export class TopicSubscription {
266
302
  contract;
267
303
  opts;
268
304
  warn;
305
+ release;
269
306
  topic;
270
307
  stats = { read: 0, superseded: 0, staleDrops: 0, mismatch: 0, fecGiveup: 0, repaired: 0, coalesced: 0 };
271
308
  #sources = [];
@@ -279,11 +316,13 @@ export class TopicSubscription {
279
316
  #throttleUntil = 0;
280
317
  #throttled;
281
318
  #throttleTimer;
282
- constructor(node, contract, opts, warn) {
319
+ /** `release` lets go of what the subscription holds besides its tracks (the node's subscriber claim of a typed topic); called once, at `unsubscribe()`. */
320
+ constructor(node, contract, opts, warn, release) {
283
321
  this.node = node;
284
322
  this.contract = contract;
285
323
  this.opts = opts;
286
324
  this.warn = warn;
325
+ this.release = release;
287
326
  this.topic = contract.name;
288
327
  this.latest = (opts.queueDepth ?? 1) !== 0;
289
328
  this.#deadlineUs = opts.deadlineMs !== undefined ? opts.deadlineMs * 1000 : undefined;
@@ -616,6 +655,7 @@ export class TopicSubscription {
616
655
  return;
617
656
  this.#ended = true;
618
657
  this.#stop.abort();
658
+ this.release?.();
619
659
  for (const s of this.#sources.splice(0))
620
660
  closeSource(s);
621
661
  this.#fec.length = 0;
package/dist/types.d.ts CHANGED
@@ -163,7 +163,7 @@ export interface PublishOptions {
163
163
  tUs?: number;
164
164
  flags?: number;
165
165
  }
166
- /** Where a publisher stands with the runtime on the current session. */
166
+ /** Where a publisher stands with the runtime on the current session; `refused`: refused for a fault of the caller's (`schema_mismatch`, `content_mismatch`, `invalid_input`, ...), not asked again until the next session. */
167
167
  export type Verdict = {
168
168
  status: "unbound";
169
169
  } | {
@@ -172,6 +172,10 @@ export type Verdict = {
172
172
  } | {
173
173
  status: "admitted";
174
174
  epoch: number;
175
+ } | {
176
+ status: "refused";
177
+ epoch: number;
178
+ error: SynapseError;
175
179
  };
176
180
  export interface PublisherStats {
177
181
  published: number;
@@ -185,7 +189,7 @@ export interface PublisherStats {
185
189
  export interface Publisher<TIn> extends AsyncDisposable {
186
190
  readonly topic: string;
187
191
  readonly verdict: Verdict;
188
- /** Resolves once admitted and announced on the current session; `not_connected` once the node ended. A refusal is asked again, paced and warned about. */
192
+ /** Resolves once admitted and announced on the current session; `not_connected` once the node ended. A contention refusal (`already_registered`, `unauthorized`, ...) is asked again, paced and warned about; a terminal one (`schema_mismatch`, ...) rejects with it. */
189
193
  ready(): Promise<void>;
190
194
  /**
191
195
  * Synchronous: writes to the installed track, or drops and counts while unbound (SDK §12.4). A
@@ -326,6 +330,10 @@ export type RegVerdict = {
326
330
  } | {
327
331
  status: "registered";
328
332
  epoch: number;
333
+ } | {
334
+ status: "refused";
335
+ epoch: number;
336
+ error: SynapseError;
329
337
  };
330
338
  export interface Registration extends AsyncDisposable {
331
339
  readonly name: string;
@@ -355,6 +363,8 @@ export interface StoreStatus {
355
363
  epoch: string | undefined;
356
364
  /** `destroyed`, `connection_lost` or `client`. */
357
365
  ended: string | undefined;
366
+ /** The open refused on this session for a fault of the caller's (`mode_mismatch`, `invalid_input`, ...): not asked again until the next session. */
367
+ refused: SynapseError | undefined;
358
368
  }
359
369
  export type StoreChange = {
360
370
  kind: "put";
@@ -1060,12 +1070,16 @@ export type ProjectEntry = {
1060
1070
  error: string;
1061
1071
  };
1062
1072
  export interface Catalog {
1073
+ /** Every topic with a publisher or a typed subscriber: its established descriptor, its publishers and its typed subscribers, each by session prefix. */
1063
1074
  readonly topics: Readonly<Record<string, {
1064
1075
  desc?: TrackDesc;
1065
1076
  publishers: Record<string, {
1066
1077
  name: string;
1067
1078
  bind: string;
1068
1079
  }>;
1080
+ subscribers: Record<string, {
1081
+ name: string;
1082
+ }>;
1069
1083
  }>>;
1070
1084
  readonly services: Readonly<Record<string, {
1071
1085
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synapse-sdk/client",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Synapse SDK client: connect a node to the runtime; topics, services, calls, store, video.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -14,7 +14,7 @@
14
14
  "dist"
15
15
  ],
16
16
  "dependencies": {
17
- "@synapse-sdk/core": "0.3.8",
17
+ "@synapse-sdk/core": "0.3.9",
18
18
  "@moq/net": "0.4.2",
19
19
  "smol-toml": "1.9.0"
20
20
  },