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