@synapse-sdk/client 0.3.3 → 0.3.6

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/synapse.d.ts CHANGED
@@ -1,44 +1,58 @@
1
- import { type Age, type ClockInfo, type ErrorBody, type LocalTime, type ProjectSnapshot, type ServiceContract, type TimeOffsets, type TopicContract } from "@synapse-sdk/core";
1
+ import { type Age, type ErrorBody, type ProjectSnapshot, type ServiceContract, type TopicContract } from "@synapse-sdk/core";
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 { CallEvent, CallOptions, Catalog, ConnectOptions, ConnectTarget, ConnectionState, Envelope, Info, Metrics, Notice, Publisher, Registration, Run, ServeHandler, ServeOptions, SceneHandle, StoreHandle, StoreOptions, SubscribeOptions, Subscription, Tasks, View, ViewOptions } from "./types.ts";
6
- /** How long the first `connect()` keeps trying a retryable failure before `failed` (SDK §2: the first connect is bounded). */
7
- export declare const FIRST_CONNECT_GIVE_UP_MS = 30000;
5
+ import type { CallOptions, Catalog, ConnectOptions, ConnectTarget, ConnectionState, Envelope, Info, Metrics, Publisher, Registration, Run, ServeHandler, ServeOptions, SceneHandle, StoreHandle, StoreOptions, SubscribeOptions, Subscription, Tasks, View, ViewOptions } from "./types.ts";
6
+ import { type StateValue } from "./util.ts";
7
+ export { FIRST_CONNECT_GIVE_UP_MS } from "./node.ts";
8
8
  export declare class Synapse implements AsyncDisposable {
9
9
  #private;
10
10
  warn: Warn;
11
+ /**
12
+ * The node, usable at once and `idle` (no target) until `connect()`: constructing opens no connection. Where to connect
13
+ * (the url, its token, the certificate pin) is the target's, given to `connect()`. Throws `invalid_input` for options
14
+ * that can never work (a bad `name`).
15
+ */
11
16
  constructor(opts: ConnectOptions);
12
- /** `await Synapse.connect(opts)`: a node with a session, or the reason there is none. */
13
- static connect(opts: ConnectOptions): Promise<Synapse>;
14
- /** Connects (once; later calls return the same promise). Resolves once a session is established. */
15
- connect(): Promise<this>;
17
+ /**
18
+ * Connects the node to `target` (a string is the url), the one connect verb: the first connect, and how an HMI moves
19
+ * from one robot's runtime to another. The current session, if any, is torn down before `target` is dialed; every
20
+ * handle re-binds to the new session as across a reconnect. Identity fields `target` leaves out keep the node's.
21
+ *
22
+ * Operator intent is never lost under quick clicking: the newest call wins. The node works toward its latest target; a
23
+ * call superseded by a newer `connect()` or `disconnect()` resolves quietly. The target already declared declares
24
+ * nothing and settles with it, except from its own `failed`, where it is dialed again.
25
+ *
26
+ * Bounded (SDK §2): a retryable failure is tried again, paced by the reconnect policy, until `FIRST_CONNECT_GIVE_UP_MS`
27
+ * after the first attempt, a hard bound (an attempt still outstanding then is cut); `unauthorized` gives up at once.
28
+ * Resolves once a session to *this* target is connected (or a newer call superseded it). Rejects with this target's
29
+ * error when the node rests `failed` (still usable: `connect()` dials again), `invalid_input` at once for a malformed
30
+ * target, `not_connected` once the node has ended (`close()`).
31
+ *
32
+ * With no target: the declared target, if there is one (settles with it, as above), else `SYNAPSE_ENDPOINT` (Bun/Node;
33
+ * the runtime sets it for every task it starts); neither (or a browser) is `invalid_input` and the node stays `idle`.
34
+ *
35
+ * The outcome is handled: `node.connect(url);` without a handler is safe (`useConnection()` shows `failed`); `await` still rejects.
36
+ */
37
+ connect(target?: ConnectTarget | string): Promise<this>;
38
+ /**
39
+ * Disconnects: the session, if any, is torn down and the node rests `idle` (no target), not ended. Every handle is kept,
40
+ * exactly as between two sessions, and a later `connect()` dials again. A `connect()` still pursuing is superseded (it
41
+ * resolves quietly). Resolves once the session is gone; already `idle`: at once. `not_connected` once the node has ended.
42
+ */
43
+ disconnect(): Promise<void>;
44
+ /** The node's engine (the SDK's own modules and tests use it). */
16
45
  get node(): Node;
17
- get status(): ConnectionState;
18
- get connected(): boolean;
19
- /** Called on every change of the connection state; returns the unsubscribe. */
20
- watchStatus(cb: (s: ConnectionState) => void): () => void;
21
- /** Resolves on the next connected session (at once if connected); rejects once the node ended. */
22
- whenConnected(): Promise<void>;
23
- /** Declares a new target (broker, identity); resolves once connected to it (or rejects with why not). `null` disconnects for good. */
24
- setTarget(target: ConnectTarget | null): Promise<void>;
25
- /** Tells the runtime we are leaving, ends every handle, and resolves once the node has ended. */
26
- /** During the first `connect()` it ends that attempt: the node ends `closed` and `connect()` rejects `not_connected`. */
46
+ /** Where the node stands (`ConnectionState`), a state value: `get()` now, `onChange` for each change, `changed()` for the next one, `watch()` for each one in a loop. */
47
+ readonly status: StateValue<ConnectionState>;
48
+ /** Tells the runtime we are leaving, ends every handle, and resolves once the node has ended (`closed`, for good). A `connect()` in flight rejects `not_connected`. */
27
49
  close(): Promise<void>;
28
50
  [Symbol.asyncDispose](): Promise<void>;
29
- /** Resolves once the node has ended (closed, failed, or the policy gave up). */
30
- get ended(): Promise<void>;
31
51
  info(): Info;
32
52
  metrics(): Metrics;
33
- /** Broker notices addressed to this node (an error body on `res` without a call). */
34
- onNotice(cb: (n: ErrorBody) => void): () => void;
35
- /** `promoted`/`demoted` notices (WP §3.5). */
36
- onProjectNotice(cb: (n: Notice) => void): () => void;
53
+ /** Notices addressed to this node: `cb` per notice (an error body on `res` without a call, and a refused live override claim of this node's scene); returns the unsubscribe. */
54
+ notices(cb: (n: ErrorBody) => void): () => void;
37
55
  nowUs(): number;
38
- clock(): ClockInfo;
39
- timeOffsets(): TimeOffsets;
40
- /** A publisher stamp in our clock (WP §7.4), when the offsets allow it. */
41
- toLocalUs(tUs: number, dom: string): LocalTime | undefined;
42
56
  /** How old a message is now, in our clock. */
43
57
  ageUs(env: {
44
58
  tUs: number;
@@ -47,43 +61,53 @@ export declare class Synapse implements AsyncDisposable {
47
61
  /** A publisher of `topic`; resolves once the runtime admitted it on the current session (a refusal is asked again, paced, warned). */
48
62
  publisher<TIn, TOut>(topic: TopicContract<TIn, TOut>): Promise<Publisher<TIn>>;
49
63
  publisher<TIn = unknown>(topic: string): Promise<Publisher<TIn>>;
50
- /** A subscription to `topic` (every publisher the catalog lists but ourselves); its follow task starts now. */
64
+ /**
65
+ * A subscription to `topic` (every publisher the catalog lists but ourselves), the stream: read it with `next()` or
66
+ * `for await`. Its follow task starts now. With `cb`, the callback form: the SDK runs the read loop and calls `cb` with
67
+ * each batch `next()` returns (a throwing `cb` is rethrown later; the loop goes on); the same subscription comes back
68
+ * (`unsubscribe()` ends it, `stats` counts it).
69
+ */
51
70
  subscribe<TIn, TOut>(topic: TopicContract<TIn, TOut>, opts?: SubscribeOptions): Subscription<TOut>;
71
+ subscribe<TIn, TOut>(topic: TopicContract<TIn, TOut>, cb: (batch: Envelope<TOut>[]) => void, opts?: SubscribeOptions): Subscription<TOut>;
52
72
  subscribe<TOut = unknown>(topic: string, opts?: SubscribeOptions): Subscription<TOut>;
53
- /** The topic's latest value as a cell (SDK §4.3); subscribed while someone listens. */
73
+ subscribe<TOut = unknown>(topic: string, cb: (batch: Envelope<TOut>[]) => void, opts?: SubscribeOptions): Subscription<TOut>;
74
+ /** The topic's latest value as a view (SDK §4.3); subscribed while someone listens. */
54
75
  view<TIn, TOut, S = TOut>(topic: TopicContract<TIn, TOut>, opts?: ViewOptions<TOut, S>): View<S>;
55
76
  view<TOut = unknown, S = TOut>(topic: string, opts?: ViewOptions<TOut, S>): View<S>;
56
- /** Starts a call on the current session (SDK §5.2): `await` it for the result, or read `updates()` first. `not_connected` between sessions. */
77
+ /** 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. */
57
78
  call<TArgs, TInput, U, R>(svc: ServiceContract<TArgs, TInput, U, R>, args: TArgs, opts?: CallOptions<U>): Run<U, R>;
58
79
  call<U = unknown, R = unknown>(svc: string, args?: unknown, opts?: CallOptions<U>): Run<U, R>;
59
- /** The call's updates and result as one ordered stream; `break` cancels. */
60
- callStream<TArgs, TInput, U, R>(svc: ServiceContract<TArgs, TInput, U, R>, args: TArgs, opts?: CallOptions<U>): AsyncGenerator<CallEvent<U, R>, void, undefined>;
61
- callStream<U = unknown, R = unknown>(svc: string, args?: unknown, opts?: CallOptions<U>): AsyncGenerator<CallEvent<U, R>, void, undefined>;
62
80
  /** Registers `handler` as the provider of `svc`; resolves once the runtime lists us on the current session. One provider per service path per node. */
63
81
  serve<TArgs, TInput, U, R>(svc: ServiceContract<TArgs, TInput, U, R>, handler: ServeHandler<TInput, U, R>, opts?: ServeOptions): Promise<Registration>;
64
82
  serve<I = unknown, U = unknown, R = unknown>(svc: string, handler: ServeHandler<I, U, R>, opts?: ServeOptions): Promise<Registration>;
65
83
  /** Opens namespace `name` (SDK §9); resolves once the mirror is in (or the namespace is known absent). */
66
84
  store(name: string, opts?: StoreOptions): Promise<StoreHandle>;
67
- /** The runtime's `config` namespace (operators write it). */
68
- config(opts?: StoreOptions): Promise<StoreHandle>;
69
- /** The active project's scene (PLAN v0.24 §5); resolves attached, or at once `detached` when no project is active. */
85
+ /** The active project's scene (PLAN v0.24 §5); resolves attached (current as of the attach: at the version the runtime answered), or at once `detached` when no project is active. */
70
86
  scene(): Promise<SceneHandle>;
71
- /** The runtime's catalogs as the current session last read them; empty between sessions. */
72
- get catalog(): Catalog;
73
- /** Called when either catalog changes on the current session. */
74
- watchCatalog(cb: (c: Catalog) => void): () => void;
75
- /** The project state the current session last read (WP §3.5); `undefined` between sessions or before the first snapshot. */
76
- project(): ProjectSnapshot | undefined;
77
- watchProject(cb: (p: ProjectSnapshot | undefined) => void): () => void;
78
- /** `synapse/project/activate {id}`: activates `id` (or joins it when active); promoted to operator. */
87
+ /** The runtime's catalogs as the current session last read them (empty between sessions), a state value: `get()` now, `onChange` for each change, `changed()` for the next one, `watch()` for each one in a loop. */
88
+ readonly catalog: StateValue<Catalog>;
89
+ /** The project state the current session last read (WP §3.5), a state value; `get()` is `undefined` between sessions or before the first snapshot. */
90
+ readonly project: StateValue<ProjectSnapshot | undefined>;
91
+ /**
92
+ * `synapse/project/activate {id}`: activates `id` when no project is active, or joins it when it is the active one;
93
+ * `project_busy` while another project is active (no takeover: deactivate it first). A web node is promoted to
94
+ * operator and resolves once the session with the new role is connected; an hmi stays `hmi` and resolves on the answer.
95
+ */
79
96
  activateProject(id: string): Promise<ProjectSnapshot>;
80
- /** `synapse/project/deactivate {}`: every operator is demoted. */
97
+ /**
98
+ * `synapse/project/deactivate {}`: any operator or hmi may deactivate, except a project an hmi activated
99
+ * (`active.activated_by_hmi`), which only an hmi may (`not_operator`). Every web operator is demoted; when that
100
+ * demotes this node, resolves once the demoted session is connected. An hmi is never demoted.
101
+ */
81
102
  deactivateProject(): Promise<ProjectSnapshot>;
82
- /** `synapse/project/join {as: "operator"}`: promoted on the active project, or `project_inactive`. */
103
+ /** `synapse/project/join {as: "operator"}`: an operator of the active project, or `project_inactive`. A web node resolves once the promoted session is connected; an hmi on the answer. */
83
104
  joinProject(as?: "operator"): Promise<ProjectSnapshot>;
84
- /** `synapse/project/leave {}`: drops our standing (every session of our `user_id`). */
105
+ /**
106
+ * `synapse/project/leave {}`: drops our standing. For an hmi membership is per session: this session only (it stays
107
+ * `hmi`, never closed). For a web node it removes our `user_id`'s web sessions only; when that demotes this node,
108
+ * resolves once the demoted session is connected.
109
+ */
85
110
  leaveProject(): Promise<ProjectSnapshot>;
86
- getProject(): Promise<ProjectSnapshot>;
87
111
  /**
88
112
  * The robot's task declarations, what runs (`status`), what the runtime can run (`catalog`), and the verbs; the same
89
113
  * 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
@@ -2,17 +2,17 @@
2
2
  // loop (`node.ts`) drives sessions; publishers, registrations, subscriptions and namespaces are their
3
3
  // own tasks, told about sessions by the loop; every fact about the world is read from the current
4
4
  // `Session` (the runtime's statement) at the moment it is asked for, never copied here.
5
- import { ClockSync, SynapseError, ageOf, defaultDomId, fecSymbolSizeFor, isServiceContract, nowUs, resolveTopic, serviceName, toLocalUs, } from "@synapse-sdk/core";
6
- import { callStream, startOn } from "./calls.js";
7
- import { Node, resolveTarget, startNode } from "./node.js";
5
+ import { SynapseError, ageOf, fecSymbolSizeFor, isServiceContract, nowUs, resolveTopic, serviceName, } from "@synapse-sdk/core";
6
+ import { failedRun, startOn } from "./calls.js";
7
+ import { Node, createNode, endpointDial, resolveTarget } from "./node.js";
8
8
  import { ServiceRegistration } from "./serve.js";
9
9
  import { openScene } from "./scene.js";
10
10
  import { Namespace, StoreHandleImpl, attachStore, openStore } from "./store.js";
11
11
  import { TasksFacet } from "./tasks.js";
12
12
  import { PublisherHandle, TopicPublisher, TopicSubscription, wasmFor } from "./topics.js";
13
13
  import { TopicView } from "./view.js";
14
- /** How long the first `connect()` keeps trying a retryable failure before `failed` (SDK §2: the first connect is bounded). */
15
- export const FIRST_CONNECT_GIVE_UP_MS = 30_000;
14
+ import { watchState } from "./util.js";
15
+ export { FIRST_CONNECT_GIVE_UP_MS } from "./node.js";
16
16
  /** The descriptor a publisher of `contract` on node `n` declares (as `TopicPublisher` builds it: a `fec` symbol size of 0 is the node's). */
17
17
  function wantedDesc(contract, n) {
18
18
  const d = contract.desc(n.dom);
@@ -24,73 +24,140 @@ function wantedDesc(contract, n) {
24
24
  function descKey(d) {
25
25
  return JSON.stringify({ delivery: d.delivery, content_type: d.content_type, schema_hash: d.schema_hash ?? null, fec: d.fec ?? null });
26
26
  }
27
+ /**
28
+ * A fact of the node as a state value (`node.status`, `node.catalog`, `node.project`): `read` is the fact now, the same
29
+ * object while it is unchanged. It changes with the node's link and, while connected, with the current session's values
30
+ * that `watched` names; a callback is rebound to the new session's values on every link change.
31
+ */
32
+ class NodeCell {
33
+ node;
34
+ read;
35
+ watched;
36
+ constructor(node, read, watched) {
37
+ this.node = node;
38
+ this.read = read;
39
+ this.watched = watched;
40
+ }
41
+ get() { return this.read(); }
42
+ /** `cb` with the value on each change of what it is read from; returns the remover. */
43
+ onChange(cb) {
44
+ const listener = () => cb(this.read());
45
+ let un = [];
46
+ const bind = () => {
47
+ for (const u of un)
48
+ u();
49
+ const s = this.node.currentSession();
50
+ un = s ? this.watched(s).map(c => c.subscribe(listener)) : [];
51
+ };
52
+ bind();
53
+ const unLink = this.node.link.subscribe(() => { bind(); listener(); });
54
+ return () => { unLink(); for (const u of un)
55
+ u(); };
56
+ }
57
+ /** The value after its next change; once the node ends, the value then, whether or not it changed (nothing will change any more). */
58
+ changed() {
59
+ const from = this.read();
60
+ if (this.node.isEnded)
61
+ return Promise.resolve(from);
62
+ return new Promise(resolve => {
63
+ let done = false;
64
+ const finish = (v) => { if (done)
65
+ return; done = true; un(); resolve(v); };
66
+ const un = this.onChange(v => { if (v !== from)
67
+ finish(v); });
68
+ void this.node.ended.promise.then(() => finish(this.read()));
69
+ });
70
+ }
71
+ /** The value after each change; ends when the node ends. */
72
+ watch() { return watchState(cb => this.onChange(cb), this.read, () => this.node.isEnded); }
73
+ }
74
+ const NO_TOPICS = Object.freeze({});
75
+ const NO_SERVICES = Object.freeze({});
27
76
  export class Synapse {
28
77
  #node;
29
- #connecting;
30
- #starting;
31
78
  #publishers = new Map();
32
79
  #stores = new Map();
33
80
  warn;
34
- // Private: the options hold the token and the credential, which nothing prints.
35
- #opts;
81
+ /**
82
+ * The node, usable at once and `idle` (no target) until `connect()`: constructing opens no connection. Where to connect
83
+ * (the url, its token, the certificate pin) is the target's, given to `connect()`. Throws `invalid_input` for options
84
+ * that can never work (a bad `name`).
85
+ */
36
86
  constructor(opts) {
37
- this.#opts = opts;
38
87
  this.warn = msg => console.warn(`[synapse] ${msg}`);
88
+ const n = this.#node = createNode(opts);
89
+ let lastLink = n.link.get(), lastState = n.state();
90
+ this.status = new NodeCell(n, () => {
91
+ const l = n.link.get();
92
+ if (l !== lastLink) {
93
+ lastLink = l;
94
+ lastState = n.state();
95
+ }
96
+ return lastState;
97
+ }, () => []);
98
+ let lastCatalog;
99
+ this.catalog = new NodeCell(n, () => {
100
+ const s = n.currentSession();
101
+ const topics = s?.topics.get().topics ?? NO_TOPICS, services = s?.services.get().services ?? NO_SERVICES;
102
+ if (lastCatalog?.topics !== topics || lastCatalog.services !== services)
103
+ lastCatalog = { topics, services, describe: name => services[name]?.card };
104
+ return lastCatalog;
105
+ }, s => [s.topics, s.services]);
106
+ this.project = new NodeCell(n, () => n.currentSession()?.project.get(), s => [s.project]);
39
107
  }
40
- /** `await Synapse.connect(opts)`: a node with a session, or the reason there is none. */
41
- static connect(opts) { return new Synapse(opts).connect(); }
42
- /** Connects (once; later calls return the same promise). Resolves once a session is established. */
43
- connect() {
44
- this.#connecting ??= (async () => {
45
- const started = startNode(this.#opts, FIRST_CONNECT_GIVE_UP_MS);
46
- // The node as soon as its loop runs (or none: the options were refused), for a `close()` during the first dial.
47
- this.#starting = started.then(s => s.node, () => undefined);
48
- const { node, connected } = await started;
49
- await connected;
50
- this.#node = node;
51
- return this;
52
- })();
53
- return this.#connecting;
54
- }
55
- get node() {
56
- if (!this.#node)
57
- throw new SynapseError("not_connected", "connect() has not completed");
58
- return this.#node;
59
- }
60
- #session() { return this.node.currentSession(); }
61
- // ------------------------------------------------------------------------------ lifecycle
62
- get status() { return this.#node?.state() ?? { status: "connecting", attempt: 1 }; }
63
- get connected() { return this.#node?.isConnected ?? false; }
64
- /** Called on every change of the connection state; returns the unsubscribe. */
65
- watchStatus(cb) { return this.node.link.subscribe(() => cb(this.node.state())); }
66
- /** Resolves on the next connected session (at once if connected); rejects once the node ended. */
67
- whenConnected() {
68
- const n = this.node;
69
- if (n.isConnected)
70
- return Promise.resolve();
108
+ /**
109
+ * Connects the node to `target` (a string is the url), the one connect verb: the first connect, and how an HMI moves
110
+ * from one robot's runtime to another. The current session, if any, is torn down before `target` is dialed; every
111
+ * handle re-binds to the new session as across a reconnect. Identity fields `target` leaves out keep the node's.
112
+ *
113
+ * Operator intent is never lost under quick clicking: the newest call wins. The node works toward its latest target; a
114
+ * call superseded by a newer `connect()` or `disconnect()` resolves quietly. The target already declared declares
115
+ * nothing and settles with it, except from its own `failed`, where it is dialed again.
116
+ *
117
+ * Bounded (SDK §2): a retryable failure is tried again, paced by the reconnect policy, until `FIRST_CONNECT_GIVE_UP_MS`
118
+ * after the first attempt, a hard bound (an attempt still outstanding then is cut); `unauthorized` gives up at once.
119
+ * Resolves once a session to *this* target is connected (or a newer call superseded it). Rejects with this target's
120
+ * error when the node rests `failed` (still usable: `connect()` dials again), `invalid_input` at once for a malformed
121
+ * target, `not_connected` once the node has ended (`close()`).
122
+ *
123
+ * With no target: the declared target, if there is one (settles with it, as above), else `SYNAPSE_ENDPOINT` (Bun/Node;
124
+ * the runtime sets it for every task it starts); neither (or a browser) is `invalid_input` and the node stays `idle`.
125
+ *
126
+ * The outcome is handled: `node.connect(url);` without a handler is safe (`useConnection()` shows `failed`); `await` still rejects.
127
+ */
128
+ connect(target) {
129
+ const n = this.#node;
130
+ let p;
71
131
  if (n.isEnded)
72
- return Promise.reject(new SynapseError("not_connected", "node closed"));
73
- return new Promise((resolve, reject) => {
74
- const un = n.link.subscribe(l => { if (l.status === "connected") {
75
- un();
76
- resolve();
77
- } });
78
- // The node's end settles it (a resolved promise ignores the later reject).
79
- void n.ended.promise.then(() => { un(); reject(new SynapseError("not_connected", "node closed")); });
80
- });
81
- }
82
- /** Declares a new target (broker, identity); resolves once connected to it (or rejects with why not). `null` disconnects for good. */
83
- setTarget(target) { return this.node.setTarget(target ? resolveTarget(this.node, target) : undefined); }
84
- /** Tells the runtime we are leaving, ends every handle, and resolves once the node has ended. */
85
- /** During the first `connect()` it ends that attempt: the node ends `closed` and `connect()` rejects `not_connected`. */
86
- async close() {
87
- const n = this.#node ?? (await this.#starting);
88
- if (n)
89
- await n.close();
132
+ p = Promise.reject(new SynapseError("not_connected", "node closed"));
133
+ else {
134
+ let dial;
135
+ try {
136
+ dial = target === undefined ? (n.target.get().dial ?? endpointDial(n)) : resolveTarget(n, typeof target === "string" ? { url: target } : target);
137
+ }
138
+ catch (e) {
139
+ return Promise.reject(e);
140
+ }
141
+ p = n.declare(dial).then(() => this);
142
+ }
143
+ p.catch(() => { });
144
+ return p;
90
145
  }
146
+ /**
147
+ * Disconnects: the session, if any, is torn down and the node rests `idle` (no target), not ended. Every handle is kept,
148
+ * exactly as between two sessions, and a later `connect()` dials again. A `connect()` still pursuing is superseded (it
149
+ * resolves quietly). Resolves once the session is gone; already `idle`: at once. `not_connected` once the node has ended.
150
+ */
151
+ disconnect() { return this.#node.declare(undefined); }
152
+ /** The node's engine (the SDK's own modules and tests use it). */
153
+ get node() { return this.#node; }
154
+ #session() { return this.#node.currentSession(); }
155
+ // ------------------------------------------------------------------------------ lifecycle
156
+ /** Where the node stands (`ConnectionState`), a state value: `get()` now, `onChange` for each change, `changed()` for the next one, `watch()` for each one in a loop. */
157
+ status;
158
+ /** Tells the runtime we are leaving, ends every handle, and resolves once the node has ended (`closed`, for good). A `connect()` in flight rejects `not_connected`. */
159
+ async close() { await this.#node.close(); }
91
160
  async [Symbol.asyncDispose]() { await this.close(); }
92
- /** Resolves once the node has ended (closed, failed, or the policy gave up). */
93
- get ended() { return this.node.ended.promise; }
94
161
  info() {
95
162
  const n = this.node;
96
163
  const id = n.target.get().identity;
@@ -98,21 +165,17 @@ export class Synapse {
98
165
  return { name: id.name ?? "", type: id.type, userId: id.user_id, prefix: s?.prefix ?? "", role: s?.role ?? "observer", dom: n.dom, brokerDom: s?.hello.dom ?? "", brokerVersion: s?.hello.broker_version ?? "", robot: s?.hello.robot ?? "", runtimeName: s?.hello.runtime_name ?? "", runtimeId: s?.hello.runtime_id ?? "", url: n.target.get().dial?.url ?? "" };
99
166
  }
100
167
  metrics() { const n = this.node; return { sessions: n.epochs, reconnects: n.reconnects, callsSent: n.callsSent, callsAnswered: n.callsAnswered }; }
101
- /** Broker notices addressed to this node (an error body on `res` without a call). */
102
- onNotice(cb) { this.node.notices.add(cb); return () => { this.node.notices.delete(cb); }; }
103
- /** `promoted`/`demoted` notices (WP §3.5). */
104
- onProjectNotice(cb) { this.node.projectNotices.add(cb); return () => { this.node.projectNotices.delete(cb); }; }
168
+ /** Notices addressed to this node: `cb` per notice (an error body on `res` without a call, and a refused live override claim of this node's scene); returns the unsubscribe. */
169
+ notices(cb) { this.node.notices.add(cb); return () => { this.node.notices.delete(cb); }; }
105
170
  // ---------------------------------------------------------------------------------- clock
106
171
  nowUs() { return nowUs(); }
107
- clock() { const s = this.#session(); return s ? s.clock.get() : new ClockSync(this.node.dom.id, "", 0).info(); }
108
- timeOffsets() { return this.#session()?.timeOffsets.get() ?? {}; }
109
- /** A publisher stamp in our clock (WP §7.4), when the offsets allow it. */
110
- toLocalUs(tUs, dom) { const s = this.#session(); return s ? toLocalUs(s.clock.get(), s.timeOffsets.get(), tUs, dom) : undefined; }
111
172
  /** How old a message is now, in our clock. */
112
173
  ageUs(env) { const s = this.#session(); return s ? ageOf(s.clock.get(), s.timeOffsets.get(), env.tUs, env.dom, nowUs()) : undefined; }
113
174
  async publisher(topic) {
114
175
  const contract = resolveTopic(topic);
115
176
  const n = this.node;
177
+ // Its descriptor names the node's time domain: final before any publisher is made.
178
+ await n.prepared;
116
179
  const name = contract.name;
117
180
  let p;
118
181
  let held;
@@ -157,9 +220,13 @@ export class Synapse {
157
220
  }
158
221
  return h;
159
222
  }
160
- subscribe(topic, opts = {}) {
223
+ subscribe(topic, cbOrOpts, opts = {}) {
161
224
  const contract = resolveTopic(topic);
162
- return new TopicSubscription(this.node, contract, opts, this.warn);
225
+ if (typeof cbOrOpts !== "function")
226
+ return new TopicSubscription(this.node, contract, cbOrOpts ?? {}, this.warn);
227
+ const sub = new TopicSubscription(this.node, contract, opts, this.warn);
228
+ sub.deliverTo(cbOrOpts);
229
+ return sub;
163
230
  }
164
231
  view(topic, opts = {}) {
165
232
  const contract = resolveTopic(topic);
@@ -169,13 +236,10 @@ export class Synapse {
169
236
  call(svc, args = {}, opts = {}) {
170
237
  const s = this.#session();
171
238
  if (!s)
172
- throw new SynapseError("not_connected", "no session: the call was not sent");
239
+ return failedRun(new SynapseError("not_connected", "no session: the call was not sent"));
173
240
  const contract = isServiceContract(svc) ? svc : undefined;
174
241
  return startOn(this.node, s, serviceName(svc), args, opts, contract);
175
242
  }
176
- callStream(svc, args = {}, opts = {}) {
177
- return callStream(this.call(svc, args, opts));
178
- }
179
243
  async serve(svc, handler, opts = {}) {
180
244
  const n = this.node;
181
245
  const name = serviceName(svc);
@@ -204,57 +268,57 @@ export class Synapse {
204
268
  // ---------------------------------------------------------------------------------- store
205
269
  /** Opens namespace `name` (SDK §9); resolves once the mirror is in (or the namespace is known absent). */
206
270
  store(name, opts = {}) { return openStore(this.node, this.#stores, name, opts, this.warn); }
207
- /** The runtime's `config` namespace (operators write it). */
208
- config(opts = {}) { return this.store("config", opts); }
209
271
  // ---------------------------------------------------------------------------------- scene
210
272
  #scenes = { current: undefined };
211
- /** The active project's scene (PLAN v0.24 §5); resolves attached, or at once `detached` when no project is active. */
273
+ /** The active project's scene (PLAN v0.24 §5); resolves attached (current as of the attach: at the version the runtime answered), or at once `detached` when no project is active. */
212
274
  scene() { return openScene(this.node, this.#scenes, this.warn); }
213
275
  // -------------------------------------------------------------------------------- catalog
214
- /** The runtime's catalogs as the current session last read them; empty between sessions. */
215
- get catalog() {
216
- const s = this.#session();
217
- const topics = s?.topics.get().topics ?? {};
218
- const services = s?.services.get().services ?? {};
219
- return { topics, services, describe: name => services[name]?.card };
220
- }
221
- /** Called when either catalog changes on the current session. */
222
- watchCatalog(cb) {
223
- let un = [];
224
- const bind = () => {
225
- for (const u of un)
226
- u();
227
- un = [];
228
- const s = this.#session();
229
- if (!s)
230
- return;
231
- un.push(s.topics.subscribe(() => cb(this.catalog)), s.services.subscribe(() => cb(this.catalog)));
232
- };
233
- bind();
234
- const unLink = this.node.link.subscribe(() => { bind(); cb(this.catalog); });
235
- return () => { unLink(); for (const u of un)
236
- u(); };
237
- }
276
+ /** The runtime's catalogs as the current session last read them (empty between sessions), a state value: `get()` now, `onChange` for each change, `changed()` for the next one, `watch()` for each one in a loop. */
277
+ catalog;
238
278
  // -------------------------------------------------------------------------------- project
239
- /** The project state the current session last read (WP §3.5); `undefined` between sessions or before the first snapshot. */
240
- project() { return this.#session()?.project.get(); }
241
- watchProject(cb) {
242
- let un;
243
- const bind = () => { un?.(); un = this.#session()?.project.subscribe(p => cb(p)); };
244
- bind();
245
- const unLink = this.node.link.subscribe(() => { bind(); cb(this.project()); });
246
- return () => { unLink(); un?.(); };
279
+ /** The project state the current session last read (WP §3.5), a state value; `get()` is `undefined` between sessions or before the first snapshot. */
280
+ project;
281
+ /**
282
+ * A project call on the current session. The runtime tells a web session whose role it changed before it answers on
283
+ * it (the notice, then the answer, on the same `res` track, read in order) and closes it `role_changed` after: a role
284
+ * that differs from the session's hello grant means the switch is coming, and the call resolves once a session other
285
+ * than the one it went out on is connected (`not_connected` if the node ends or is disconnected meanwhile, the error
286
+ * if it rests `failed`). An hmi's role is `hmi` from admission and never changes, so its calls resolve on the answer.
287
+ */
288
+ async #projectCall(op, args) {
289
+ const n = this.node;
290
+ const asked = n.currentSession();
291
+ if (!asked)
292
+ throw new SynapseError("not_connected", "no session: the call was not sent");
293
+ const snapshot = await startOn(n, asked, `synapse/project/${op}`, args, {}).result;
294
+ if (asked.role !== asked.hello.role) {
295
+ // The runtime replaces this session (the role changed): its close is expected, as one we make ourselves, so
296
+ // @moq/net's track and discovery lines about it are routine (moq_log.ts).
297
+ asked.log?.closing();
298
+ await n.switchedFrom(asked);
299
+ }
300
+ return snapshot;
247
301
  }
248
- #projectCall(op, args) { return this.call(`synapse/project/${op}`, args).result; }
249
- /** `synapse/project/activate {id}`: activates `id` (or joins it when active); promoted to operator. */
302
+ /**
303
+ * `synapse/project/activate {id}`: activates `id` when no project is active, or joins it when it is the active one;
304
+ * `project_busy` while another project is active (no takeover: deactivate it first). A web node is promoted to
305
+ * operator and resolves once the session with the new role is connected; an hmi stays `hmi` and resolves on the answer.
306
+ */
250
307
  activateProject(id) { return this.#projectCall("activate", { id }); }
251
- /** `synapse/project/deactivate {}`: every operator is demoted. */
308
+ /**
309
+ * `synapse/project/deactivate {}`: any operator or hmi may deactivate, except a project an hmi activated
310
+ * (`active.activated_by_hmi`), which only an hmi may (`not_operator`). Every web operator is demoted; when that
311
+ * demotes this node, resolves once the demoted session is connected. An hmi is never demoted.
312
+ */
252
313
  deactivateProject() { return this.#projectCall("deactivate", {}); }
253
- /** `synapse/project/join {as: "operator"}`: promoted on the active project, or `project_inactive`. */
314
+ /** `synapse/project/join {as: "operator"}`: an operator of the active project, or `project_inactive`. A web node resolves once the promoted session is connected; an hmi on the answer. */
254
315
  joinProject(as = "operator") { return this.#projectCall("join", { as }); }
255
- /** `synapse/project/leave {}`: drops our standing (every session of our `user_id`). */
316
+ /**
317
+ * `synapse/project/leave {}`: drops our standing. For an hmi membership is per session: this session only (it stays
318
+ * `hmi`, never closed). For a web node it removes our `user_id`'s web sessions only; when that demotes this node,
319
+ * resolves once the demoted session is connected.
320
+ */
256
321
  leaveProject() { return this.#projectCall("leave", {}); }
257
- getProject() { return this.#projectCall("get", {}); }
258
322
  // -------------------------------------------------------------------------------- tasks
259
323
  #tasks;
260
324
  /**
package/dist/tasks.d.ts CHANGED
@@ -8,7 +8,7 @@ export declare const TASKS_NAMESPACES: {
8
8
  readonly status: "task_status";
9
9
  readonly catalog: "task_catalog";
10
10
  };
11
- /** What `status.value` is before the runtime said anything: neither scope's state known, no tasks. */
11
+ /** What `status.get()` is before the runtime said anything: neither scope's state known, no tasks. */
12
12
  export declare const EMPTY_TASKS_STATUS: TasksStatus;
13
13
  /**
14
14
  * `task_status`'s rows as a `TasksStatus`. A run row is the one keyed `robot` or `project`; an instance