@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 +7 -12
- package/dist/channel.js +30 -8
- package/dist/serve.d.ts +2 -10
- package/dist/store.d.ts +4 -0
- package/dist/store.js +28 -4
- package/dist/synapse.d.ts +5 -2
- package/dist/synapse.js +41 -4
- package/dist/topics.d.ts +31 -3
- package/dist/topics.js +44 -4
- package/dist/types.d.ts +16 -2
- package/package.json +2 -2
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
|
|
16
|
-
export declare function
|
|
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
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
|
30
|
-
export function
|
|
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 (
|
|
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
|
|
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
|
|
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/
|
|
18
|
-
readonly unregisterSvc = "synapse/
|
|
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
|
-
|
|
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/
|
|
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/
|
|
36
|
-
unregisterSvc = "synapse/
|
|
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
|
-
|
|
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.
|
|
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.
|
|
17
|
+
"@synapse-sdk/core": "0.3.9",
|
|
18
18
|
"@moq/net": "0.4.2",
|
|
19
19
|
"smol-toml": "1.9.0"
|
|
20
20
|
},
|