@synapse-sdk/client 0.3.7 → 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/scene.d.ts +20 -5
- package/dist/scene.js +48 -11
- 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 +11 -3
- package/dist/synapse.js +46 -4
- package/dist/topics.d.ts +31 -3
- package/dist/topics.js +44 -4
- package/dist/types.d.ts +66 -17
- 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/scene.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { SynapseError, type ErrorBody, type ProjectSnapshot } from "@synapse-sdk
|
|
|
3
3
|
import type { Node } from "./node.ts";
|
|
4
4
|
import { StreamWriter, type Session } from "./session.ts";
|
|
5
5
|
import type { Warn } from "./topics.ts";
|
|
6
|
-
import type { LiveJoints, LivePose, LiveRemoved, LiveState, Quat, SceneAuthored, SceneDiffOp, SceneEntity, SceneEntityLive, SceneEntityRef, SceneEntityView, SceneHandle, SceneKind, SceneLive, SceneOp,
|
|
6
|
+
import type { LiveJoints, LivePose, LiveRemoved, LiveState, Quat, SceneAuthored, SceneDiffOp, SceneEntity, SceneEntityLive, SceneEntityRef, SceneEntityView, SceneHandle, SceneKind, SceneLive, SceneOp, SceneSpawnOptions, SceneState, SceneStatus, SceneTree, SceneTxn, SceneView, Vec3 } from "./types.ts";
|
|
7
7
|
import { AsyncQueue, type Deferred, type StateValue } from "./util.ts";
|
|
8
8
|
/** How many batches a feed may fall behind before it is ended (the store's `STORE_FEED_CAP`). */
|
|
9
9
|
export declare const SCENE_FEED_CAP = 256;
|
|
@@ -12,6 +12,9 @@ export declare const PATCH_PROTECTED: readonly string[];
|
|
|
12
12
|
type Entities = Map<string, SceneEntity>;
|
|
13
13
|
/** An entity named by its id or by a view of it (the id the view names now). */
|
|
14
14
|
type EntityArg = string | SceneEntityView;
|
|
15
|
+
/** A patch's `set` and `reparent`'s options, as the public interfaces take them. */
|
|
16
|
+
type PatchSet = Parameters<SceneTxn["patch"]>[1];
|
|
17
|
+
type ReparentOptions = NonNullable<Parameters<SceneTxn["reparent"]>[2]>;
|
|
15
18
|
/**
|
|
16
19
|
* A fresh entity id: a ULID (26 Crockford base-32 characters) made from a UUIDv7 (48-bit ms, version,
|
|
17
20
|
* 74 random bits, variant), so ids sort by the time they were minted; within one millisecond the
|
|
@@ -30,6 +33,14 @@ export declare function orderBetween(left: string | null | undefined, right: str
|
|
|
30
33
|
* prototype's rule). A cycle cannot come from the runtime; were one there, it is cut where it closes.
|
|
31
34
|
*/
|
|
32
35
|
export declare function treeOf(entities: Readonly<Record<string, SceneEntity>>): SceneTree;
|
|
36
|
+
/**
|
|
37
|
+
* The runtime's patch merge (`merge_set`): merges one level deep; a null deletes the key it names. A top-level null
|
|
38
|
+
* removes the component; an object merges into an object component key by key, a null sub-key removing that sub-key;
|
|
39
|
+
* an object given for a component that is absent or not an object replaces it without its null sub-keys, and nulls
|
|
40
|
+
* alone leave no component (an explicitly given `{}` is kept); any other value replaces. `undefined` is not given (the
|
|
41
|
+
* encoder skips it).
|
|
42
|
+
*/
|
|
43
|
+
export declare function mergeSet(into: Record<string, unknown>, set: Record<string, unknown>): Record<string, unknown>;
|
|
33
44
|
/**
|
|
34
45
|
* One authoring batch being built (`txn`): the ops it sends as one atomic `apply`. Ops on an entity
|
|
35
46
|
* spawned in the same batch fold into its spawn: a patch (of keys a patch may set) or a rename merges
|
|
@@ -41,11 +52,15 @@ export declare class Txn implements SceneTxn {
|
|
|
41
52
|
get ops(): readonly SceneOp[];
|
|
42
53
|
/** Spawns an entity of `kind` under `opts.parent` (default the world root) with the components given (`layer` `authored` when not given, else `calibration`; a live entity is not spawned here, it comes from `scene.live.upsert`); its id is minted here (or `opts.id`, a known one) and returned. No `order` is sent: the runtime appends it after its siblings. */
|
|
43
54
|
spawn(kind: SceneKind, opts: SceneSpawnOptions): string;
|
|
44
|
-
/** Sets components of the entity
|
|
45
|
-
patch(entity: EntityArg, set:
|
|
55
|
+
/** Sets components of the entity: merges one level deep; a null deletes the key it names. */
|
|
56
|
+
patch(entity: EntityArg, set: PatchSet): void;
|
|
46
57
|
rename(entity: EntityArg, name: string): void;
|
|
47
|
-
/**
|
|
48
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Moves the entity under `parent` (`null`: the world root), `before` or `after` a sibling (default: after the last).
|
|
60
|
+
* By default it keeps its world pose (the runtime's default: nothing sent); `preserveWorld: false` sends
|
|
61
|
+
* `preserve_world: false`, and it keeps its local xform.
|
|
62
|
+
*/
|
|
63
|
+
reparent(entity: EntityArg, parent: EntityArg | null, opts?: ReparentOptions): void;
|
|
49
64
|
/** Despawns the entity (`cascade`: with its subtree). One spawned in this batch is cancelled instead, unless it has children spawned in the batch and `cascade` is off (then the runtime decides). */
|
|
50
65
|
despawn(entity: EntityArg, opts?: {
|
|
51
66
|
cascade?: boolean;
|
package/dist/scene.js
CHANGED
|
@@ -231,14 +231,45 @@ export function treeOf(entities) {
|
|
|
231
231
|
return build(null);
|
|
232
232
|
}
|
|
233
233
|
// -------------------------------------------------------------------------------------------- txn
|
|
234
|
-
/**
|
|
235
|
-
|
|
234
|
+
/**
|
|
235
|
+
* The runtime's patch merge (`merge_set`): merges one level deep; a null deletes the key it names. A top-level null
|
|
236
|
+
* removes the component; an object merges into an object component key by key, a null sub-key removing that sub-key;
|
|
237
|
+
* an object given for a component that is absent or not an object replaces it without its null sub-keys, and nulls
|
|
238
|
+
* alone leave no component (an explicitly given `{}` is kept); any other value replaces. `undefined` is not given (the
|
|
239
|
+
* encoder skips it).
|
|
240
|
+
*/
|
|
241
|
+
export function mergeSet(into, set) {
|
|
236
242
|
const out = { ...into };
|
|
237
243
|
for (const [k, v] of Object.entries(set)) {
|
|
238
244
|
if (v === undefined)
|
|
239
245
|
continue;
|
|
246
|
+
if (v === null) {
|
|
247
|
+
delete out[k];
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (!isObject(v)) {
|
|
251
|
+
out[k] = v;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const given = Object.entries(v).filter(([, s]) => s !== undefined);
|
|
240
255
|
const old = out[k];
|
|
241
|
-
|
|
256
|
+
if (isObject(old)) {
|
|
257
|
+
const next = { ...old };
|
|
258
|
+
for (const [s, sv] of given) {
|
|
259
|
+
if (sv === null)
|
|
260
|
+
delete next[s];
|
|
261
|
+
else
|
|
262
|
+
next[s] = sv;
|
|
263
|
+
}
|
|
264
|
+
out[k] = next;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
const kept = given.filter(([, s]) => s !== null);
|
|
268
|
+
if (kept.length === 0 && given.length > 0)
|
|
269
|
+
delete out[k];
|
|
270
|
+
else
|
|
271
|
+
out[k] = Object.fromEntries(kept);
|
|
272
|
+
}
|
|
242
273
|
}
|
|
243
274
|
return out;
|
|
244
275
|
}
|
|
@@ -264,7 +295,7 @@ export class Txn {
|
|
|
264
295
|
this.#ops.push({ op: "spawn", entity });
|
|
265
296
|
return id;
|
|
266
297
|
}
|
|
267
|
-
/** Sets components of the entity
|
|
298
|
+
/** Sets components of the entity: merges one level deep; a null deletes the key it names. */
|
|
268
299
|
patch(entity, set) {
|
|
269
300
|
const id = idOf(entity);
|
|
270
301
|
// A protected key is the runtime's to refuse: such a patch is sent as it is.
|
|
@@ -287,13 +318,19 @@ export class Txn {
|
|
|
287
318
|
}
|
|
288
319
|
this.#ops.push({ op: "rename", id, name });
|
|
289
320
|
}
|
|
290
|
-
/**
|
|
291
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Moves the entity under `parent` (`null`: the world root), `before` or `after` a sibling (default: after the last).
|
|
323
|
+
* By default it keeps its world pose (the runtime's default: nothing sent); `preserveWorld: false` sends
|
|
324
|
+
* `preserve_world: false`, and it keeps its local xform.
|
|
325
|
+
*/
|
|
326
|
+
reparent(entity, parent, opts = {}) {
|
|
292
327
|
const op = { op: "reparent", id: idOf(entity), parent: parent === null ? null : idOf(parent, "parent") };
|
|
293
|
-
if (
|
|
294
|
-
op.before = idOf(
|
|
295
|
-
else if (
|
|
296
|
-
op.after = idOf(
|
|
328
|
+
if (opts.before !== undefined)
|
|
329
|
+
op.before = idOf(opts.before, "before");
|
|
330
|
+
else if (opts.after !== undefined)
|
|
331
|
+
op.after = idOf(opts.after, "after");
|
|
332
|
+
if (opts.preserveWorld === false)
|
|
333
|
+
op.preserve_world = false;
|
|
297
334
|
this.#ops.push(op);
|
|
298
335
|
}
|
|
299
336
|
/** Despawns the entity (`cascade`: with its subtree). One spawned in this batch is cancelled instead, unless it has children spawned in the batch and `cascade` is off (then the runtime decides). */
|
|
@@ -1972,7 +2009,7 @@ export class SceneHandleImpl {
|
|
|
1972
2009
|
patch: (entity, set) => this.#txn(t => t.patch(entity, set)),
|
|
1973
2010
|
upsert: (kind, opts) => this.#upsert(kind, opts),
|
|
1974
2011
|
rename: (entity, name) => this.#txn(t => t.rename(entity, name)),
|
|
1975
|
-
reparent: (entity, parent,
|
|
2012
|
+
reparent: (entity, parent, opts) => this.#txn(t => t.reparent(entity, parent, opts)),
|
|
1976
2013
|
despawn: (entity, opts) => this.#txn(t => t.despawn(entity, opts)),
|
|
1977
2014
|
setLocked: (entity, locked) => this.#txn(t => t.setLocked(entity, locked)),
|
|
1978
2015
|
});
|
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
|
@@ -2,7 +2,7 @@ import { type Age, type ErrorBody, type ProjectSnapshot, type ServiceContract, t
|
|
|
2
2
|
import { Node } from "./node.ts";
|
|
3
3
|
import { StoreHandleImpl } from "./store.ts";
|
|
4
4
|
import { type Warn } from "./topics.ts";
|
|
5
|
-
import type { CallOptions, Catalog, ConnectOptions, ConnectTarget, ConnectionState, Discovery, DiscoveryState, Envelope, FetchToken, Info, Metrics, Publisher, Registration, Run, ServeHandler, ServeOptions, SceneHandle, StoreHandle, StoreOptions, SubscribeOptions, Subscription, Tasks, View, ViewOptions } from "./types.ts";
|
|
5
|
+
import type { CallOptions, Catalog, ConnectOptions, ConnectTarget, ConnectionState, Discovery, DiscoveryState, Envelope, FetchToken, Info, Metrics, ProjectListing, Publisher, Registration, Run, ServeHandler, ServeOptions, SceneHandle, StoreHandle, StoreOptions, SubscribeOptions, Subscription, Tasks, View, ViewOptions } from "./types.ts";
|
|
6
6
|
import { type StateValue } from "./util.ts";
|
|
7
7
|
export { FIRST_CONNECT_GIVE_UP_MS } from "./node.ts";
|
|
8
8
|
export declare class Synapse implements AsyncDisposable {
|
|
@@ -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. */
|
|
@@ -133,6 +136,11 @@ export declare class Synapse implements AsyncDisposable {
|
|
|
133
136
|
* resolves once the demoted session is connected.
|
|
134
137
|
*/
|
|
135
138
|
leaveProject(): Promise<ProjectSnapshot>;
|
|
139
|
+
/**
|
|
140
|
+
* `synapse/project/list {}`: every project the runtime has, sorted by id: what each names (`name` when it has one,
|
|
141
|
+
* `scene_id`, `store_id`, whether it is the active one), or why it cannot be read (`error`). Changes nothing.
|
|
142
|
+
*/
|
|
143
|
+
listProjects(): Promise<ProjectListing>;
|
|
136
144
|
/**
|
|
137
145
|
* The robot's task declarations, what runs (`status`), what the runtime can run (`catalog`), and the verbs; the same
|
|
138
146
|
* object for the node's life. Each handle opens on its first touch and is the node's own (`node.close()` ends it).
|
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();
|
|
@@ -355,6 +392,11 @@ export class Synapse {
|
|
|
355
392
|
* resolves once the demoted session is connected.
|
|
356
393
|
*/
|
|
357
394
|
leaveProject() { return this.#projectCall("leave", {}); }
|
|
395
|
+
/**
|
|
396
|
+
* `synapse/project/list {}`: every project the runtime has, sorted by id: what each names (`name` when it has one,
|
|
397
|
+
* `scene_id`, `store_id`, whether it is the active one), or why it cannot be read (`error`). Changes nothing.
|
|
398
|
+
*/
|
|
399
|
+
listProjects() { return this.call("synapse/project/list", {}).result; }
|
|
358
400
|
// -------------------------------------------------------------------------------- tasks
|
|
359
401
|
#tasks;
|
|
360
402
|
/**
|
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";
|
|
@@ -488,6 +498,8 @@ export interface Hardpoint {
|
|
|
488
498
|
/**
|
|
489
499
|
* An entity of the scene. The fields below are the ones the runtime and the early users read;
|
|
490
500
|
* every other key passes through untouched.
|
|
501
|
+
* A component is absent (none), `{}` (there, nothing specified yet: every field at its default) or
|
|
502
|
+
* specified; a null component or sub-key is never stored (a patch deletes the key it names, a spawn drops it).
|
|
491
503
|
*/
|
|
492
504
|
export interface SceneEntity {
|
|
493
505
|
/** A ULID; never changes. */
|
|
@@ -620,8 +632,8 @@ export interface EntityAuthored {
|
|
|
620
632
|
* not have: its `not_found`). A view by name whose name no entity holds has no id to send to: `not_found`
|
|
621
633
|
* (`data: { name }`), nothing sent. Detached: `project_inactive`.
|
|
622
634
|
*/
|
|
623
|
-
set<K extends keyof SceneEntity & string>(component: K, value:
|
|
624
|
-
set(components:
|
|
635
|
+
set<K extends keyof SceneEntity & string>(component: K, value: PatchSet[K]): Promise<void>;
|
|
636
|
+
set(components: PatchSet): Promise<void>;
|
|
625
637
|
}
|
|
626
638
|
/** An entity view's live plane (`view.live`). Reads as the view does: nothing once the handle is detached or closed. */
|
|
627
639
|
export interface EntityLive {
|
|
@@ -666,6 +678,12 @@ export interface SceneStatus {
|
|
|
666
678
|
/** Lane rows dropped because their id's claim was refused (rows parked while it was asked, and rows written while refused). */
|
|
667
679
|
unclaimedDrops: number;
|
|
668
680
|
}
|
|
681
|
+
type NullSubKeys<T> = T extends readonly unknown[] ? T : T extends object ? {
|
|
682
|
+
[S in keyof T]?: T[S] | null;
|
|
683
|
+
} : T;
|
|
684
|
+
type PatchSet = {
|
|
685
|
+
[K in keyof SceneEntity]?: unknown extends SceneEntity[K] ? unknown : NullSubKeys<NonNullable<SceneEntity[K]>> | null;
|
|
686
|
+
};
|
|
669
687
|
/** An authored change as the runtime applied it (a diff op of the scene's `authored` track). */
|
|
670
688
|
export type SceneDiffOp = {
|
|
671
689
|
op: "spawn";
|
|
@@ -679,7 +697,7 @@ export type SceneDiffOp = {
|
|
|
679
697
|
} | {
|
|
680
698
|
op: "patch";
|
|
681
699
|
id: string;
|
|
682
|
-
set:
|
|
700
|
+
set: PatchSet;
|
|
683
701
|
rev: number;
|
|
684
702
|
} | {
|
|
685
703
|
op: "reparent";
|
|
@@ -710,7 +728,7 @@ export type SceneOp = {
|
|
|
710
728
|
} | {
|
|
711
729
|
op: "patch";
|
|
712
730
|
id: string;
|
|
713
|
-
set:
|
|
731
|
+
set: PatchSet;
|
|
714
732
|
} | {
|
|
715
733
|
op: "reparent";
|
|
716
734
|
id: string;
|
|
@@ -733,20 +751,22 @@ export type SceneSpawnOptions = {
|
|
|
733
751
|
parent?: string | SceneEntityView | null;
|
|
734
752
|
id?: string;
|
|
735
753
|
} & Partial<Omit<SceneEntity, "id" | "kind" | "name" | "parent" | "order" | "meta">>;
|
|
736
|
-
/** Where `reparent` puts the entity among its new siblings (default: after the last): a sibling by its id or its view. */
|
|
737
|
-
export type ScenePlace = {
|
|
738
|
-
before: string | SceneEntityView;
|
|
739
|
-
} | {
|
|
740
|
-
after: string | SceneEntityView;
|
|
741
|
-
};
|
|
742
754
|
/** One authoring batch being built (`txn`): sent as one atomic `apply`. An entity is named by its id or by a view of it (`upsert`'s result). */
|
|
743
755
|
export interface SceneTxn {
|
|
744
756
|
/** Spawns an entity of `kind`; returns its id. */
|
|
745
757
|
spawn(kind: SceneKind, opts: SceneSpawnOptions): string;
|
|
746
|
-
/** Sets components of the entity
|
|
747
|
-
patch(entity: string | SceneEntityView, set:
|
|
758
|
+
/** Sets components of the entity: merges one level deep; a null deletes the key it names. */
|
|
759
|
+
patch(entity: string | SceneEntityView, set: PatchSet): void;
|
|
748
760
|
rename(entity: string | SceneEntityView, name: string): void;
|
|
749
|
-
|
|
761
|
+
/**
|
|
762
|
+
* Moves the entity under `parent` (`null`: the world root), `before` or `after` a sibling (default: after the last).
|
|
763
|
+
* By default it keeps its world pose (its local xform is recomputed); `preserveWorld: false` keeps its local xform.
|
|
764
|
+
*/
|
|
765
|
+
reparent(entity: string | SceneEntityView, parent: string | SceneEntityView | null, opts?: {
|
|
766
|
+
before?: string | SceneEntityView;
|
|
767
|
+
after?: string | SceneEntityView;
|
|
768
|
+
preserveWorld?: boolean;
|
|
769
|
+
}): void;
|
|
750
770
|
despawn(entity: string | SceneEntityView, opts?: {
|
|
751
771
|
cascade?: boolean;
|
|
752
772
|
}): void;
|
|
@@ -797,7 +817,8 @@ export interface SceneAuthored {
|
|
|
797
817
|
txn(build: (t: SceneTxn) => void): Promise<void>;
|
|
798
818
|
/** Spawns an entity; its id, once the entities show it. */
|
|
799
819
|
spawn(kind: SceneKind, opts: SceneSpawnOptions): Promise<string>;
|
|
800
|
-
|
|
820
|
+
/** Sets components of the entity: merges one level deep; a null deletes the key it names. */
|
|
821
|
+
patch(entity: string | SceneEntityView, set: PatchSet): Promise<void>;
|
|
801
822
|
/**
|
|
802
823
|
* A builder's one verb, keyed by `name`; resolves with the entity's view (by id: that entity) once the entities show
|
|
803
824
|
* it. The id is the runtime's answer, never the copy's: `spawn(kind, { name, parent, ...components })` (a fresh id) is
|
|
@@ -813,7 +834,16 @@ export interface SceneAuthored {
|
|
|
813
834
|
id?: never;
|
|
814
835
|
}): Promise<SceneEntityView>;
|
|
815
836
|
rename(entity: string | SceneEntityView, name: string): Promise<void>;
|
|
816
|
-
|
|
837
|
+
/**
|
|
838
|
+
* Moves the entity under `parent` (`null`: the world root), `before` or `after` a sibling (default: after the last).
|
|
839
|
+
* By default the entity keeps its world pose (its local xform is recomputed). With `preserveWorld: false` it keeps its
|
|
840
|
+
* local xform instead, so a tool moved to another flange snaps to it.
|
|
841
|
+
*/
|
|
842
|
+
reparent(entity: string | SceneEntityView, parent: string | SceneEntityView | null, opts?: {
|
|
843
|
+
before?: string | SceneEntityView;
|
|
844
|
+
after?: string | SceneEntityView;
|
|
845
|
+
preserveWorld?: boolean;
|
|
846
|
+
}): Promise<void>;
|
|
817
847
|
despawn(entity: string | SceneEntityView, opts?: {
|
|
818
848
|
cascade?: boolean;
|
|
819
849
|
}): Promise<void>;
|
|
@@ -1024,13 +1054,32 @@ export interface Tasks {
|
|
|
1024
1054
|
/** Stops every project task (also when none ran). Robot tasks are not touched. */
|
|
1025
1055
|
stopProjectTasks(): Promise<void>;
|
|
1026
1056
|
}
|
|
1057
|
+
/** The answer of `node.listProjects()` (`synapse/project/list`): every project the runtime has, sorted by id. */
|
|
1058
|
+
export interface ProjectListing {
|
|
1059
|
+
projects: ProjectEntry[];
|
|
1060
|
+
}
|
|
1061
|
+
/** One project of a {@link ProjectListing}: what its document names (`name` when it has one), or why it cannot be read (`error`). */
|
|
1062
|
+
export type ProjectEntry = {
|
|
1063
|
+
id: string;
|
|
1064
|
+
name?: string;
|
|
1065
|
+
scene_id: string;
|
|
1066
|
+
store_id: string;
|
|
1067
|
+
active: boolean;
|
|
1068
|
+
} | {
|
|
1069
|
+
id: string;
|
|
1070
|
+
error: string;
|
|
1071
|
+
};
|
|
1027
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. */
|
|
1028
1074
|
readonly topics: Readonly<Record<string, {
|
|
1029
1075
|
desc?: TrackDesc;
|
|
1030
1076
|
publishers: Record<string, {
|
|
1031
1077
|
name: string;
|
|
1032
1078
|
bind: string;
|
|
1033
1079
|
}>;
|
|
1080
|
+
subscribers: Record<string, {
|
|
1081
|
+
name: string;
|
|
1082
|
+
}>;
|
|
1034
1083
|
}>>;
|
|
1035
1084
|
readonly services: Readonly<Record<string, {
|
|
1036
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
|
},
|