@synapse-sdk/client 0.3.3 → 0.3.4

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.
@@ -1,7 +1,7 @@
1
1
  import type { SceneEntity, SceneTxn, Xform } from "./types.ts";
2
2
  /** The ops the materializers queue. */
3
3
  type FrameTxn = Pick<SceneTxn, "spawn" | "patch" | "rename" | "despawn">;
4
- /** The entities to reconcile against: `scene.entities` (id → entity) or a list of entities. */
4
+ /** The entities to reconcile against: `scene.get()` (id → entity) or a list of entities. */
5
5
  type Entities = Iterable<SceneEntity> | Readonly<Record<string, SceneEntity>>;
6
6
  /** The presentation name of a materialized derived frame: `<parent name>_<frame>` (e.g. `ur5e_tool0`). */
7
7
  export declare function derivedFrameName(parentName: string, frame: string): string;
@@ -28,7 +28,7 @@ export interface MaterializeReport {
28
28
  /**
29
29
  * Reconciles `robot`'s `robot.frames` with its derived frames (those with `props.from` equal to
30
30
  * `provenance`, default `"urdf"`; a frame without `from` counts as `"urdf"`), queueing the ops on
31
- * `txn`. `entities` is the scene now (`scene.entities`). `restPoses` maps declared names to poses
31
+ * `txn`. `entities` is the scene now (`scene.get()`). `restPoses` maps declared names to poses
32
32
  * (FK at the authored joints, the caller's work); a name with no frame yet and no pose is an error,
33
33
  * never a spawn, so a call with no poses only renames, backfills `from` and retracts.
34
34
  *
@@ -29,7 +29,7 @@ export function validateDerivedFrame(frame, parentId) {
29
29
  /**
30
30
  * Reconciles `robot`'s `robot.frames` with its derived frames (those with `props.from` equal to
31
31
  * `provenance`, default `"urdf"`; a frame without `from` counts as `"urdf"`), queueing the ops on
32
- * `txn`. `entities` is the scene now (`scene.entities`). `restPoses` maps declared names to poses
32
+ * `txn`. `entities` is the scene now (`scene.get()`). `restPoses` maps declared names to poses
33
33
  * (FK at the authored joints, the caller's work); a name with no frame yet and no pose is an error,
34
34
  * never a spawn, so a call with no poses only renames, backfills `from` and retracts.
35
35
  *
package/dist/session.d.ts CHANGED
@@ -83,7 +83,7 @@ export declare function ensureWebTransport(): Promise<void>;
83
83
  export declare function describeError(e: unknown): string;
84
84
  /**
85
85
  * The error of a session's close (`classify_close`): `admission_refused` travels as the application
86
- * close code (retryable: a live session may hold this name, the project may be settling), another
86
+ * close code (retryable: a live session holds this name; a crashed one is released after the idle timeout), another
87
87
  * application code is a broker-side failure, `unauthorized` is the relay's own refusal, everything
88
88
  * else is the transport (`connection_lost`); a clean close is a drop too (in one-shot mode a broker
89
89
  * GOAWAY looks like one).
@@ -164,6 +164,8 @@ export declare class Session {
164
164
  readonly log: MoqLogScope;
165
165
  /** Counts *established* sessions of the node (SDK §2); set by the node when attached. */
166
166
  epoch: number;
167
+ /** The target generation this session was opened for (`Target.generation`); set by the node's loop when attached. */
168
+ generation: number;
167
169
  readonly startedMs: number;
168
170
  readonly transport: "webtransport" | "websocket";
169
171
  /** Datagrams travel only over WebTransport; the WebSocket fallback is stream-only. */
package/dist/session.js CHANGED
@@ -313,7 +313,7 @@ const ADMISSION_REFUSED_SESSION_CODE = 64 + ADMISSION_REFUSED_CLOSE_CODE;
313
313
  const ADMISSION_REFUSED_REASON = new RegExp(`app code=${ADMISSION_REFUSED_CLOSE_CODE}(?!\\d)`);
314
314
  /**
315
315
  * The error of a session's close (`classify_close`): `admission_refused` travels as the application
316
- * close code (retryable: a live session may hold this name, the project may be settling), another
316
+ * close code (retryable: a live session holds this name; a crashed one is released after the idle timeout), another
317
317
  * application code is a broker-side failure, `unauthorized` is the relay's own refusal, everything
318
318
  * else is the transport (`connection_lost`); a clean close is a drop too (in one-shot mode a broker
319
319
  * GOAWAY looks like one).
@@ -323,7 +323,7 @@ export function closeError(err) {
323
323
  return new SynapseError("connection_lost", { message: "session closed by the broker", source: "transport" });
324
324
  const text = `${err.message} ${err.reason ?? ""}`;
325
325
  if ((err instanceof Moq.Error.Session && err.code === ADMISSION_REFUSED_SESSION_CODE) || ADMISSION_REFUSED_REASON.test(text)) {
326
- return new SynapseError("admission_refused", { message: "the runtime refused this connection, retryable: a live session may hold this name, the project may be settling, or the runtime is overloaded; retry with backoff", source: "broker", cause: err });
326
+ return new SynapseError("admission_refused", { message: "the runtime refused this connection; retryable: a live session holds this name (a crashed one is released after the idle timeout); the SDK keeps retrying with backoff", source: "broker", cause: err });
327
327
  }
328
328
  if (err instanceof Moq.Error.Session && err.code === Moq.SessionCode.Unauthorized)
329
329
  return new SynapseError("unauthorized", { message: text.trim(), source: "broker", cause: err });
@@ -446,6 +446,8 @@ export class Session {
446
446
  log;
447
447
  /** Counts *established* sessions of the node (SDK §2); set by the node when attached. */
448
448
  epoch = 0;
449
+ /** The target generation this session was opened for (`Target.generation`); set by the node's loop when attached. */
450
+ generation = 0;
449
451
  startedMs = performance.now();
450
452
  transport;
451
453
  /** Datagrams travel only over WebTransport; the WebSocket fallback is stream-only. */
package/dist/store.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { Node } from "./node.ts";
3
3
  import { type Session } from "./session.ts";
4
4
  import type { Warn } from "./topics.ts";
5
5
  import type { StoreChange, StoreHandle, StoreOptions, StoreStatus } from "./types.ts";
6
- import { AsyncQueue, type Deferred } from "./util.ts";
6
+ import { AsyncQueue, type Deferred, type StateValue } from "./util.ts";
7
7
  export declare const ENDED_DESTROYED = "destroyed";
8
8
  export declare const ENDED_CONNECTION_LOST = "connection_lost";
9
9
  export declare const ENDED_CLIENT = "client";
@@ -78,11 +78,19 @@ type Event = {
78
78
  epoch: string;
79
79
  version: number;
80
80
  reply: Deferred<void>;
81
- } | {
81
+ }
82
+ /** A write of handle `handle` (its parked writes are refused when it leaves). */
83
+ | {
82
84
  kind: "write";
85
+ handle: number;
83
86
  op: string;
84
87
  args: WriteOp;
85
88
  reply: Deferred<unknown>;
89
+ }
90
+ /** Handle `handle` was closed, others remain: its parked writes are refused. */
91
+ | {
92
+ kind: "leave";
93
+ handle: number;
86
94
  } | {
87
95
  kind: "writeDone";
88
96
  op: string;
@@ -107,6 +115,7 @@ export declare class Namespace {
107
115
  readonly mailbox: AsyncQueue<Event>;
108
116
  readonly exited: Deferred<void>;
109
117
  handles: number;
118
+ nextHandle: number;
110
119
  constructor(name: string, opts: StoreOptions, node: Node, warn: Warn);
111
120
  post(e: Event): boolean;
112
121
  request<T>(make: (reply: Deferred<T>) => Event): Promise<T>;
@@ -116,32 +125,34 @@ export declare function startNamespace(ns: Namespace, registry: Map<string, Name
116
125
  export declare class StoreHandleImpl implements StoreHandle {
117
126
  #private;
118
127
  private readonly ns;
128
+ /** The handle's own state, a state value: `get()` (the same object while unchanged), `onChange`, `changed()`, `watch()`. */
129
+ readonly status: StateValue<StoreStatus>;
119
130
  constructor(ns: Namespace);
120
131
  get name(): string;
121
- /** The whole value, what the runtime has; the same object while nothing changed (reference-stable for React). */
122
- get value(): Record<string, unknown>;
132
+ /** `get()`: the whole value, what the runtime has; the same object while nothing changed (reference-stable for React). `get(key)`: that key's value, `undefined` when absent. */
133
+ get(): Record<string, unknown>;
123
134
  get(key: string): unknown;
124
135
  get synced(): boolean;
125
- get status(): StoreStatus;
126
- /** `set(key, value)` or `set({ ...several keys })` (one atomic write); resolves once `value` shows it. */
136
+ /** `set(key, value)` or `set({ ...several keys })` (one atomic write); resolves once `get()` shows it. */
127
137
  set(keyOrValues: string | Record<string, unknown>, value?: unknown): Promise<void>;
128
138
  delete(key: string): Promise<void>;
129
139
  clear(): Promise<void>;
130
140
  destroy(): Promise<void>;
131
141
  /** `for await (const value of store.watch())`: the whole value after each change; `store.watch(key)`: that key's value each time it changes. Ends with the handle; leaving the loop removes the feed. */
132
142
  watch(key?: string): AsyncGenerator<unknown, void, undefined>;
133
- /** The framework bridge (React's `useStore`): called on every change of the value or the status. */
134
- subscribe(listener: () => void): () => void;
143
+ /** The whole value after its next change (`store_gone` once this handle is closed). */
144
+ changed(): Promise<Record<string, unknown>>;
145
+ /** `cb` with the whole value on every change of the value or the status (the framework bridge, React's `useStore`); returns the remover. */
146
+ onChange(cb: (value: Record<string, unknown>) => void): () => void;
135
147
  /** Waits for the namespace to be in (the first snapshot applied, or absent). */
136
- join(): Promise<void>;
137
- /** `join()` under the SDK's word (Publisher and Registration have `ready()` too). */
138
148
  ready(): Promise<void>;
139
149
  close(): void;
140
150
  [Symbol.asyncDispose](): Promise<void>;
141
151
  }
142
152
  /**
143
153
  * `store(name, opts)`: one task per namespace per node; a second `store` with the same options joins
144
- * it (no open written), with other options `invalid_input`; one exiting is waited out.
154
+ * it (no open written), with other options `invalid_input`; one exiting is waited out. Without a session
155
+ * (before the first, between two) the task waits for the next one, like after a drop; it resolves once in.
145
156
  */
146
157
  export declare function openStore(node: Node, registry: Map<string, Namespace>, name: string, opts: StoreOptions, warn: Warn): Promise<StoreHandleImpl>;
147
158
  /**
package/dist/store.js CHANGED
@@ -6,13 +6,14 @@
6
6
  // here. A refusal is asked again, paced, with a warning (R6); a transient reply paced, silently; an
7
7
  // incarnation that ends (writer gone, project switch, the owner restarted) replays the intent;
8
8
  // `destroyed` ends the handle. Writes name the epoch the mirror holds; the runtime checks ownership
9
- // (`not_owner`). Between incarnations (a session change, a replay) nothing is known: `store_gone`.
9
+ // (`not_owner`). Between incarnations (a session change, a replay) nothing is known: a write then is
10
+ // parked by the task and sent once the handle binds again (`store_gone` if it ends first).
10
11
  import * as Moq from "@moq/net";
11
12
  import { FLAGS, STORE_TRACK, SynapseError, decode as decodeBody, decodeHeader, moqPriority, storeBasePath, storeRobotScoped, } from "@synapse-sdk/core";
12
13
  import { startOn } from "./calls.js";
13
14
  import { REFOLLOW_PACE_MS, holderOf, isTransient, refusalPaceMs } from "./channel.js";
14
15
  import { latestSubscription, waitActive } from "./session.js";
15
- import { AsyncQueue, TIMEOUT, deferred, withTimeout } from "./util.js";
16
+ import { AsyncQueue, StatusCell, TIMEOUT, deferred, withTimeout } from "./util.js";
16
17
  export const ENDED_DESTROYED = "destroyed";
17
18
  export const ENDED_CONNECTION_LOST = "connection_lost";
18
19
  export const ENDED_CLIENT = "client";
@@ -206,6 +207,7 @@ export class Namespace {
206
207
  mailbox = new AsyncQueue(Infinity);
207
208
  exited = deferred();
208
209
  handles = 0;
210
+ nextHandle = 0;
209
211
  constructor(name, opts, node, warn) {
210
212
  this.name = name;
211
213
  this.opts = opts;
@@ -239,6 +241,9 @@ async function run(ns, registry) {
239
241
  let unwatchProject;
240
242
  const joining = [];
241
243
  let versionWaiters = [];
244
+ /** Writes that came while no incarnation was bound (a replay, or no session yet), in arrival order: sent once the handle binds again, refused `store_gone` if it ends first. Each counts in `pending`. */
245
+ let parked = [];
246
+ const ABSENT = "the namespace is absent: open it with an access mode to create it";
242
247
  const leaveIncarnation = () => { for (const w of versionWaiters.splice(0))
243
248
  w.reply.reject(gone(name, `the handle left the incarnation before version ${w.version}`)); };
244
249
  const stopPhase = () => {
@@ -358,9 +363,12 @@ async function run(ns, registry) {
358
363
  const done = follow(ns, s, announced, access?.epoch, stop.signal);
359
364
  phase = { kind: "following", attempt, stop, done };
360
365
  void done.then(reason => post({ kind: "followEnded", attempt, reason }));
361
- if (!access)
366
+ // Absent: honestly in (nothing to load), and nothing to write to (the parked writes are refused).
367
+ if (!access) {
362
368
  for (const j of joining.splice(0))
363
369
  j.resolve();
370
+ refuseParked(ABSENT);
371
+ }
364
372
  return;
365
373
  }
366
374
  announced.close();
@@ -389,6 +397,9 @@ async function run(ns, registry) {
389
397
  if (state.loaded && state.epoch === epoch && state.version >= wanted)
390
398
  for (const j of joining.splice(0))
391
399
  j.resolve();
400
+ // The handle is bound again (the reader applied the snapshot that names the epoch): the writes parked meanwhile go out, in order, naming it.
401
+ if (state.ended === undefined && state.epoch !== undefined)
402
+ sendParked();
392
403
  };
393
404
  const join = (reply) => {
394
405
  if (phase.kind === "waitingProject") {
@@ -426,14 +437,53 @@ async function run(ns, registry) {
426
437
  again(REFOLLOW_PACE_MS);
427
438
  }
428
439
  };
429
- const write = (op, args, reply) => {
440
+ /**
441
+ * A write. Ended or absent: `store_gone`. Between incarnations (a replay, or no session yet) it is parked: a write is
442
+ * intent, sent once the handle binds again (the runtime decides it then), refused `store_gone` if the handle ends first;
443
+ * it has no deadline of its own. Bound: sent after any parked before it, naming the mirror's epoch (and claim).
444
+ */
445
+ const write = (handle, op, args, reply) => {
430
446
  if (state.ended !== undefined) {
431
447
  reply.reject(gone(name, `the handle ended (${state.ended})`));
432
448
  return;
433
449
  }
450
+ if (state.epoch === undefined) {
451
+ if (state.absent) {
452
+ reply.reject(gone(name, ABSENT));
453
+ return;
454
+ }
455
+ parked.push({ handle, op, args, reply });
456
+ state.setPending(state.pending + 1);
457
+ return;
458
+ }
459
+ sendParked();
460
+ send(op, args, reply);
461
+ };
462
+ /** The parked writes go out, in arrival order (the handle is bound). */
463
+ const sendParked = () => {
464
+ if (!parked.length)
465
+ return;
466
+ const ws = parked;
467
+ parked = [];
468
+ state.setPending(state.pending - ws.length);
469
+ for (const w of ws)
470
+ send(w.op, w.args, w.reply);
471
+ };
472
+ /** The parked writes are refused `store_gone` (`why`): all of them (the handle ended, or the namespace is absent), or those of handle `of` (that handle was closed, others stay open). */
473
+ const refuseParked = (why, of) => {
474
+ const ws = parked.filter(w => of === undefined || w.handle === of);
475
+ if (!ws.length)
476
+ return;
477
+ parked = parked.filter(w => !ws.includes(w));
478
+ state.setPending(state.pending - ws.length);
479
+ for (const w of ws)
480
+ w.reply.reject(gone(name, why));
481
+ };
482
+ /** One write on the wire, naming the bound epoch (and claim); answered by the broker (`writeDone`). */
483
+ const send = (op, args, reply) => {
434
484
  const epoch = state.epoch;
435
485
  if (epoch === undefined) {
436
- reply.reject(state.absent ? gone(name, "the namespace is absent: open it with an access mode to create it") : gone(name, "no incarnation is bound: the intent is being replayed"));
486
+ reply.reject(gone(name, "no incarnation is bound"));
437
487
  return;
438
488
  }
439
489
  if (!session) {
@@ -534,7 +584,10 @@ async function run(ns, registry) {
534
584
  waitVersion(ev.epoch, ev.version, ev.reply);
535
585
  break;
536
586
  case "write":
537
- write(ev.op, ev.args, ev.reply);
587
+ write(ev.handle, ev.op, ev.args, ev.reply);
588
+ break;
589
+ case "leave":
590
+ refuseParked(`the handle ended (${ENDED_CLIENT})`, ev.handle);
538
591
  break;
539
592
  case "writeDone":
540
593
  if (writeDone(ev.op, ev.result, ev.reply)) {
@@ -567,6 +620,7 @@ async function run(ns, registry) {
567
620
  for (const j of joining.splice(0))
568
621
  j.reject(e);
569
622
  leaveIncarnation();
623
+ refuseParked(`the handle ended (${state.ended ?? reason ?? ENDED_CLIENT})`);
570
624
  {
571
625
  const p = ph();
572
626
  if (p.kind === "following" || p.kind === "paced")
@@ -580,7 +634,7 @@ async function run(ns, registry) {
580
634
  if (ev.kind === "writeDone")
581
635
  writeDone(ev.op, ev.result, ev.reply);
582
636
  else if (ev.kind === "write")
583
- write(ev.op, ev.args, ev.reply);
637
+ write(ev.handle, ev.op, ev.args, ev.reply);
584
638
  else if (ev.kind === "opened") {
585
639
  const p = ph();
586
640
  if (p.kind === "opening" && p.o.attempt === ev.attempt) {
@@ -771,31 +825,39 @@ async function follow(ns, session, announced, want, signal) {
771
825
  export class StoreHandleImpl {
772
826
  ns;
773
827
  #closed = false;
828
+ /** This handle's id in its namespace: its parked writes are refused when it closes. */
829
+ #id;
774
830
  /** Resolved when this handle closes: its version waits end. */
775
831
  #left = deferred();
776
832
  /** This handle's change feeds: closed with it. */
777
833
  #feeds = new Set();
778
834
  #valueObj;
779
835
  #valueOf;
836
+ /** Told when this handle closes (its status says `ended: client` from then on, whatever the namespace does). */
837
+ #onClose = new Set();
838
+ /** The handle's own state, a state value: `get()` (the same object while unchanged), `onChange`, `changed()`, `watch()`. */
839
+ status;
840
+ #status;
780
841
  constructor(ns) {
781
842
  this.ns = ns;
782
843
  ns.handles++;
844
+ this.#id = ns.nextHandle++;
845
+ this.status = this.#status = new StatusCell(() => this.ns.state.status(this.ns.node.isConnected, this.#closed), l => { const un = this.ns.state.subscribe(l); this.#onClose.add(l); return () => { un(); this.#onClose.delete(l); }; }, () => this.#closed || this.ns.state.ended !== undefined);
783
846
  }
784
847
  #ended() { return gone(this.ns.name, `the handle ended (${ENDED_CLIENT})`); }
785
848
  get name() { return this.ns.name; }
786
- /** The whole value, what the runtime has; the same object while nothing changed (reference-stable for React). */
787
- get value() {
849
+ get(key) {
788
850
  const flat = this.ns.state.flat;
851
+ if (key !== undefined)
852
+ return flat.get(key);
789
853
  if (this.#valueOf !== flat) {
790
854
  this.#valueOf = flat;
791
855
  this.#valueObj = Object.fromEntries(flat);
792
856
  }
793
857
  return this.#valueObj;
794
858
  }
795
- get(key) { return this.ns.state.flat.get(key); }
796
- get synced() { return this.status.synced; }
797
- get status() { return this.ns.state.status(this.ns.node.isConnected, this.#closed); }
798
- /** `set(key, value)` or `set({ ...several keys })` (one atomic write); resolves once `value` shows it. */
859
+ get synced() { return this.status.get().synced; }
860
+ /** `set(key, value)` or `set({ ...several keys })` (one atomic write); resolves once `get()` shows it. */
799
861
  async set(keyOrValues, value) {
800
862
  const v = typeof keyOrValues === "string" ? await this.#write("put", { key: keyOrValues, value }) : await this.#write("put_many", { values: keyOrValues });
801
863
  await this.#reached(v);
@@ -803,11 +865,11 @@ export class StoreHandleImpl {
803
865
  async delete(key) { await this.#reached(await this.#write("delete", { key })); }
804
866
  async clear() { await this.#reached(await this.#write("clear", {})); }
805
867
  async destroy() { await this.#write("destroy", {}); }
806
- /** The wire half of a write: the runtime's answer, the version it assigned. */
868
+ /** The wire half of a write: the runtime's answer, the version it assigned (between incarnations the task holds it until the handle binds again). */
807
869
  async #write(op, args) {
808
870
  if (this.#closed)
809
871
  throw this.#ended();
810
- const r = await this.ns.request(reply => ({ kind: "write", op, args, reply }));
872
+ const r = await this.ns.request(reply => ({ kind: "write", handle: this.#id, op, args, reply }));
811
873
  const v = r?.version;
812
874
  return typeof v === "number" ? v : typeof v === "bigint" ? Number(v) : 0;
813
875
  }
@@ -830,7 +892,7 @@ export class StoreHandleImpl {
830
892
  try {
831
893
  for await (const batch of q) {
832
894
  if (key === undefined)
833
- yield this.value;
895
+ yield this.get();
834
896
  else if (batch.some(c => c.key === key))
835
897
  yield this.ns.state.flat.get(key);
836
898
  }
@@ -840,12 +902,25 @@ export class StoreHandleImpl {
840
902
  this.ns.state.unwatch(q);
841
903
  }
842
904
  }
843
- /** The framework bridge (React's `useStore`): called on every change of the value or the status. */
844
- subscribe(listener) { return this.ns.state.subscribe(listener); }
905
+ /** The whole value after its next change (`store_gone` once this handle is closed). */
906
+ changed() {
907
+ if (this.#closed)
908
+ return Promise.reject(this.#ended());
909
+ const from = this.get();
910
+ const d = deferred();
911
+ const un = this.ns.state.subscribe(() => { const v = this.get(); if (v !== from) {
912
+ un();
913
+ this.#onClose.delete(gone);
914
+ d.resolve(v);
915
+ } });
916
+ const gone = () => { un(); this.#onClose.delete(gone); d.reject(this.#ended()); };
917
+ this.#onClose.add(gone);
918
+ return d.promise;
919
+ }
920
+ /** `cb` with the whole value on every change of the value or the status (the framework bridge, React's `useStore`); returns the remover. */
921
+ onChange(cb) { return this.ns.state.subscribe(() => cb(this.get())); }
845
922
  /** Waits for the namespace to be in (the first snapshot applied, or absent). */
846
- join() { return this.ns.request(reply => ({ kind: "join", reply })); }
847
- /** `join()` under the SDK's word (Publisher and Registration have `ready()` too). */
848
- ready() { return this.join(); }
923
+ ready() { return this.ns.request(reply => ({ kind: "join", reply })); }
849
924
  close() {
850
925
  if (this.#closed)
851
926
  return;
@@ -854,18 +929,29 @@ export class StoreHandleImpl {
854
929
  for (const q of this.#feeds)
855
930
  this.ns.state.unwatch(q);
856
931
  this.#feeds.clear();
932
+ for (const l of [...this.#onClose]) {
933
+ try {
934
+ l();
935
+ }
936
+ catch (e) {
937
+ queueMicrotask(() => { throw e; });
938
+ }
939
+ }
940
+ this.#onClose.clear();
941
+ this.#status.dispose();
857
942
  if (--this.ns.handles === 0)
858
943
  this.ns.post({ kind: "close" });
944
+ else
945
+ this.ns.post({ kind: "leave", handle: this.#id });
859
946
  }
860
947
  async [Symbol.asyncDispose]() { this.close(); }
861
948
  }
862
949
  /**
863
950
  * `store(name, opts)`: one task per namespace per node; a second `store` with the same options joins
864
- * it (no open written), with other options `invalid_input`; one exiting is waited out.
951
+ * it (no open written), with other options `invalid_input`; one exiting is waited out. Without a session
952
+ * (before the first, between two) the task waits for the next one, like after a drop; it resolves once in.
865
953
  */
866
954
  export async function openStore(node, registry, name, opts, warn) {
867
- if (!node.currentSession())
868
- throw new SynapseError("not_connected");
869
955
  for (;;) {
870
956
  const live = registry.get(name);
871
957
  if (live) {
@@ -877,7 +963,7 @@ export async function openStore(node, registry, name, opts, warn) {
877
963
  throw new SynapseError("invalid_input", `store ${JSON.stringify(name)}: this node already has it open with other options`);
878
964
  const h = new StoreHandleImpl(live);
879
965
  try {
880
- await h.join();
966
+ await h.ready();
881
967
  }
882
968
  catch (e) {
883
969
  h.close();
@@ -890,7 +976,7 @@ export async function openStore(node, registry, name, opts, warn) {
890
976
  const h = new StoreHandleImpl(ns);
891
977
  startNamespace(ns, registry);
892
978
  try {
893
- await h.join();
979
+ await h.ready();
894
980
  }
895
981
  catch (e) {
896
982
  h.close();
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 { CallEvent, 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,13 +61,20 @@ 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, or read `updates()` first. 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
80
  /** The call's updates and result as one ordered stream; `break` cancels. */
@@ -64,26 +85,32 @@ export declare class Synapse implements AsyncDisposable {
64
85
  serve<I = unknown, U = unknown, R = unknown>(svc: string, handler: ServeHandler<I, U, R>, opts?: ServeOptions): Promise<Registration>;
65
86
  /** Opens namespace `name` (SDK §9); resolves once the mirror is in (or the namespace is known absent). */
66
87
  store(name: string, opts?: StoreOptions): Promise<StoreHandle>;
67
- /** The runtime's `config` namespace (operators write it). */
68
- config(opts?: StoreOptions): Promise<StoreHandle>;
69
88
  /** The active project's scene (PLAN v0.24 §5); resolves attached, or at once `detached` when no project is active. */
70
89
  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. */
90
+ /** 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. */
91
+ readonly catalog: StateValue<Catalog>;
92
+ /** The project state the current session last read (WP §3.5), a state value; `get()` is `undefined` between sessions or before the first snapshot. */
93
+ readonly project: StateValue<ProjectSnapshot | undefined>;
94
+ /**
95
+ * `synapse/project/activate {id}`: activates `id` when no project is active, or joins it when it is the active one;
96
+ * `project_busy` while another project is active (no takeover: deactivate it first). A web node is promoted to
97
+ * operator and resolves once the session with the new role is connected; an hmi stays `hmi` and resolves on the answer.
98
+ */
79
99
  activateProject(id: string): Promise<ProjectSnapshot>;
80
- /** `synapse/project/deactivate {}`: every operator is demoted. */
100
+ /**
101
+ * `synapse/project/deactivate {}`: any operator or hmi may deactivate, except a project an hmi activated
102
+ * (`active.activated_by_hmi`), which only an hmi may (`not_operator`). Every web operator is demoted; when that
103
+ * demotes this node, resolves once the demoted session is connected. An hmi is never demoted.
104
+ */
81
105
  deactivateProject(): Promise<ProjectSnapshot>;
82
- /** `synapse/project/join {as: "operator"}`: promoted on the active project, or `project_inactive`. */
106
+ /** `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
107
  joinProject(as?: "operator"): Promise<ProjectSnapshot>;
84
- /** `synapse/project/leave {}`: drops our standing (every session of our `user_id`). */
108
+ /**
109
+ * `synapse/project/leave {}`: drops our standing. For an hmi membership is per session: this session only (it stays
110
+ * `hmi`, never closed). For a web node it removes our `user_id`'s web sessions only; when that demotes this node,
111
+ * resolves once the demoted session is connected.
112
+ */
85
113
  leaveProject(): Promise<ProjectSnapshot>;
86
- getProject(): Promise<ProjectSnapshot>;
87
114
  /**
88
115
  * The robot's task declarations, what runs (`status`), what the runtime can run (`catalog`), and the verbs; the same
89
116
  * object for the node's life. Each handle opens on its first touch and is the node's own (`node.close()` ends it).