@rindle/api-server 0.5.0 → 0.6.3

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/src/index.ts CHANGED
@@ -68,11 +68,13 @@ import {
68
68
  import type { RoomProfile, RoomScopeSpec, RoomTableSpec } from "./rooms.ts";
69
69
  // The room lease token (RINDLE-REALTIME §10.1): minted here, verified by the room SHELL against
70
70
  // its `downstream.tokenKeys` ring — the `/token` subpath is pure WebCrypto (no wasm, no shell).
71
- // Loaded LAZILY at the first mint: `@rindle/room` is a dev-only workspace dep (it is not published
72
- // to npm yetinfra/tests/publish-sync.mjs would flag a runtime dep on it), and the mint only
73
- // runs when `realtime.roomTokenKey` is configured. In-repo (workspace linking) the import always
74
- // resolves; an npm consumer without the package gets the serve decision's ordinary fail-open —
75
- // daemon-served leases plus a one-time warning naming the missing module.
71
+ // Loaded LAZILY at the first mint: `@rindle/room` is an OPTIONAL dependency (see package.json), so
72
+ // it is installed transitively — a consumer bundling api-server (Vite/Rollup/esbuild) can resolve
73
+ // this dynamic import even when it never uses rooms yet the mint only runs when
74
+ // `realtime.roomTokenKey` is configured. Should the module be genuinely absent (an install that
75
+ // skipped the optional dep), the serve decision fail-opens: daemon-served leases plus a one-time
76
+ // warning naming the missing module. It is NOT a hard `dependency` because the entire code path is
77
+ // optional; optionalDependencies keeps a failed install of it non-fatal.
76
78
  import type { mintRoomToken as MintRoomToken, scopeSpecsHash as ScopeSpecsHash } from "@rindle/room/token";
77
79
  let roomTokenModule: { mintRoomToken: typeof MintRoomToken; scopeSpecsHash: typeof ScopeSpecsHash } | undefined;
78
80
  async function loadRoomTokenModule(): Promise<{ mintRoomToken: typeof MintRoomToken; scopeSpecsHash: typeof ScopeSpecsHash }> {
@@ -297,6 +299,11 @@ export interface QueryLeaseRequest<User> {
297
299
  * when there is no authenticated subject and no session cookie (READ-ROUTER-DESIGN.md §1.5/§2.2).
298
300
  * A routing HINT only, never authorization. */
299
301
  clientId?: string;
302
+ /** The browser's opaque follower-affinity ticket (FOLLOWER-AFFINITY-DESIGN.md §3), read off the
303
+ * query POST and forwarded OPAQUELY on `materialize` so the fleet `fly-replay`s to the follower
304
+ * the browser's ws is pinned to (§2, §4) — both legs co-locate. The api-server does NOT verify
305
+ * it (the fleet does); it holds no signing key. Absent ⇒ single daemon / affinity off. */
306
+ affinity?: string;
300
307
  }
301
308
 
302
309
  /**
@@ -306,11 +313,10 @@ export interface QueryLeaseRequest<User> {
306
313
  * client uses it to open the room transport for THIS query beside — never instead of — its daemon
307
314
  * session.
308
315
  *
309
- * It is a NEW dedicated block on purpose: the TOP-LEVEL `wsEndpoint` is the read-router's
310
- * whole-session migration signal (`packages/remote/src/optimistic-source.ts`a lease whose
311
- * `wsEndpoint` differs migrates the client's ENTIRE ws session), so the room endpoint must never
312
- * ride that field. A room-served lease's top-level fields are byte-identical to the daemon-served
313
- * ones.
316
+ * It is a dedicated block on purpose: the ROOM ws is a SEPARATE connection this query opens beside
317
+ * its daemon session (never a migration of the daemon session the daemon ws host is fixed and
318
+ * placed by the affinity ticket). A room-served lease's top-level fields are byte-identical to the
319
+ * daemon-served ones.
314
320
  */
315
321
  export interface QueryLeaseRealtime {
316
322
  /** The client store's gate/domain key for this room source (`connectSource`) AND the string the
@@ -318,8 +324,8 @@ export interface QueryLeaseRealtime {
318
324
  * parses as a room source, and the established convention is `"room:" + doc`
319
325
  * (e.g. `room:document/doc:d1`). */
320
326
  sourceKey: string;
321
- /** Where the client opens the ROOM ws for this query (from `realtime.locateRoom`) — a separate
322
- * field from the top-level `wsEndpoint` (see above: no whole-session migration). */
327
+ /** Where the client opens the ROOM ws for this query (from `realtime.locateRoom`) — its OWN
328
+ * connection, distinct from the daemon session's fixed ws host. */
323
329
  wsEndpoint: string;
324
330
  /** The room's self-authorizing signed lease (`@rindle/room/token`): the APPROVED query AST +
325
331
  * doc + subject, HMAC-signed with `realtime.roomTokenKey` so the room shell's
@@ -349,9 +355,6 @@ export interface QueryLeaseLifecycleLease {
349
355
  /** Which system table this lease's subscription serves. */
350
356
  table: string;
351
357
  leaseToken: string;
352
- /** The follower this system materialization lives on (routed deploys; mirrors the top-level
353
- * `wsEndpoint` semantics). Absent ⇒ single-daemon. */
354
- wsEndpoint?: string;
355
358
  /** DOORBELL only: the §4.1 occupancy scope — the wire room doc (`"<profile>/<key>"`). */
356
359
  scope?: string;
357
360
  /** FENCE entries only: the room doc the predicate is scoped to. */
@@ -393,9 +396,6 @@ export interface QueryLeaseResponse {
393
396
  materializationId: string;
394
397
  queryKey?: string;
395
398
  reused?: boolean;
396
- /** The follower this lease lives on (READ-ROUTER-DESIGN.md §2.3) — the browser opens its
397
- * subscription ws here. Absent ⇒ single-daemon (the client uses its static `wsUrl`). */
398
- wsEndpoint?: string;
399
399
  /** The room-serve block (G-iv-b) — see {@link QueryLeaseRealtime}. Absent ⇒ the lease is
400
400
  * byte-identical to the legacy daemon-served shape. */
401
401
  realtime?: QueryLeaseRealtime;
@@ -420,6 +420,9 @@ export interface QueryReadRequest<User> {
420
420
  * {@link QueryLeaseRequest.clientId}). Lets the SSR read co-locate on the follower the booting
421
421
  * client's first subscribe will hit (READ-ROUTER-DESIGN.md §2.4). */
422
422
  clientId?: string;
423
+ /** The browser's opaque follower-affinity ticket — see {@link QueryLeaseRequest.affinity}.
424
+ * Forwarded on the one-shot `query` so an SSR read lands on the same pinned follower. */
425
+ affinity?: string;
423
426
  }
424
427
 
425
428
  /** The assembled (nested-by-name) first-paint snapshot the server-side Store seeds + dehydrates
@@ -428,9 +431,6 @@ export interface QueryReadResponse {
428
431
  rows: Array<{ cols: Record<string, unknown>; [rel: string]: unknown }>;
429
432
  cvMin?: number;
430
433
  queryKey?: string;
431
- /** The follower this read warmed (READ-ROUTER-DESIGN.md §2.4) — inject it into the SSR bootstrap
432
- * so the booting client opens its ws to the same warm follower. Absent ⇒ single-daemon. */
433
- wsEndpoint?: string;
434
434
  }
435
435
 
436
436
  /** The context a {@link MutationBackend} needs to run one mutation inside its transaction. */
@@ -548,6 +548,10 @@ export interface RoomBootResponse {
548
548
  /** Where the room opens its upstream subscription (a routed deploy's follower). Absent ⇒ the
549
549
  * shell's statically configured rindled ws endpoint. */
550
550
  upstreamWsEndpoint?: string;
551
+ /** Fresh opaque follower-placement ticket minted alongside `upstreamLeaseToken`. The DO offers
552
+ * it with `rindle.v1` on the separate upstream ws so a static fleet endpoint replays to the
553
+ * exact follower holding that local lease. Absent when daemon affinity is off. */
554
+ upstreamAffinity?: string;
551
555
  /** Per-footprint-table scope specs (H-iv-b), compiled from the resolved footprint AST + the
552
556
  * profile's context set (the legacy anonymous profile compiles with an empty context set):
553
557
  * what the shell hands the wasm room's `enableWritesV2` — the §3.3 commit gate. Optional
@@ -593,14 +597,16 @@ export interface RindleRealtimeOptions<User> {
593
597
  /** Lease TTL for the room's upstream footprint materialization (defaults to the server-wide
594
598
  * `leaseTtlMs`, else the daemon's default). */
595
599
  upstreamLeaseTtlMs?: number;
596
- /** Static override for where rooms open their upstream subscription; default is the
597
- * `wsEndpoint` the materialize returns (set in routed deploys), else absent. */
600
+ /** Static endpoint where rooms open their upstream subscription. In a follower fleet this is the
601
+ * fleet ws URL; `/room-boot` pairs it with the materialization's fresh placement ticket so the
602
+ * room lands on the exact follower holding its lease. Absent ⇒ no explicit upstream (the Node
603
+ * room shell may use its own default; the shipped DO shell requires this endpoint). */
598
604
  upstreamWsEndpoint?: string;
599
605
  /** Locate (or place) the room serving `doc` and return the ROOM ws endpoint a room-served
600
606
  * lease's client should open (G-iv-b; on the DO shell this is the Worker's room URL). The
601
- * endpoint rides the lease's dedicated `realtime.wsEndpoint` — NEVER the top-level
602
- * `wsEndpoint` (that field migrates the client's whole daemon session). Absent ⇒ room-serving
603
- * is OFF: labeled queries serve from the daemon exactly as today (fail-open). */
607
+ * endpoint rides the lease's dedicated `realtime.wsEndpoint` — its OWN connection, distinct from
608
+ * the daemon session's fixed ws host. Absent ⇒ room-serving is OFF: labeled queries serve from
609
+ * the daemon exactly as today (fail-open). */
604
610
  locateRoom?: (doc: string) => MaybePromise<{ wsEndpoint: string }>;
605
611
  /** The room lease token signing key (`@rindle/room/token`): `kid` + secret, matching an entry
606
612
  * in the room shell's `downstream.tokenKeys` ring. Required for room-serving (without it a
@@ -816,12 +822,11 @@ export interface RindleApiServerOptions<User> {
816
822
  * HINT only — never authorization. Ignored by a single (unrouted) daemon, which has nothing to
817
823
  * route. */
818
824
  routingKey?: string | ((input: QueryLeaseRequest<User>) => MaybePromise<string | undefined>);
819
- /** The EXPLICIT fleet pin fan-out (READ-ROUTER-DESIGN.md §4.2 "push") — when set,
820
- * {@link RindleApiServer.assertPins} fans each resolved pin across ALL live followers through it
821
- * (e.g. `createRouterPinClient` from `@rindle/router`) instead of materializing each pin once on
822
- * the (single) daemon. A per-viewer `materialize` always routes ONE; a pin-assert always fans
823
- * ALL — never inferred from `policy.kind`. Absent ⇒ single-daemon behavior (one materialize per
824
- * pin). */
825
+ /** The EXPLICIT fleet pin fan-out — when set, {@link RindleApiServer.assertPins} fans each
826
+ * resolved pin across ALL live followers through it (a fleet control action over the machine
827
+ * list FOLLOWER-AFFINITY-DESIGN.md §11) instead of materializing each pin once on the (single)
828
+ * daemon. A per-viewer `materialize` always routes ONE; a pin-assert always fans ALL — never
829
+ * inferred from `policy.kind`. Absent ⇒ single-daemon behavior (one materialize per pin). */
825
830
  pinFanout?: PinFanout;
826
831
  /** Named queries to keep permanently materialized via {@link RindleApiServer.assertPins}.
827
832
  * Each is materialized with a `pinned` policy (survives zero subscribers) so late joiners
@@ -1008,18 +1013,18 @@ export class SplitDaemonClient implements RindleDaemonClient {
1008
1013
  if (!lmids) return Promise.reject(new Error("the write master lacks roomLmids"));
1009
1014
  return lmids(input);
1010
1015
  }
1011
- // Pure computation, but routed to the MASTER like the rest of the room ops: the master is a
1012
- // real rindled that hosts `/cover-check`; the read router may not proxy it.
1016
+ // Pure computation hosted by rindled: keep it on the read/follower leg. The write master owns
1017
+ // room durability, not query-cover analysis.
1013
1018
  coverQuery(input: CoverQueryInput): Promise<CoverQueryOutput> {
1014
- const cover = this.writes.coverQuery?.bind(this.writes);
1015
- if (!cover) return Promise.reject(new Error("the write master lacks coverQuery"));
1019
+ const cover = this.reads.coverQuery?.bind(this.reads);
1020
+ if (!cover) return Promise.reject(new Error("the read follower lacks coverQuery"));
1016
1021
  return cover(input);
1017
1022
  }
1018
1023
  migrate(input: MigrateInput): Promise<MigrateOutput> {
1019
1024
  return this.writes.migrate(input);
1020
1025
  }
1021
1026
 
1022
- // reads → the router (it stamps `wsEndpoint` onto the outputs)
1027
+ // reads → the fleet (one FLEET_URL follower; the affinity ticket + Fly edge place the machine)
1023
1028
  materialize(input: MaterializeInput): Promise<MaterializeOutput> {
1024
1029
  return this.reads.materialize(input);
1025
1030
  }
@@ -1248,6 +1253,24 @@ class AbsorbedReplay extends Error {
1248
1253
  }
1249
1254
  }
1250
1255
 
1256
+ const MUTATOR_CONFLICT_MAX_ATTEMPTS = 5;
1257
+
1258
+ function isRetryableCommitConflict(error: unknown): boolean {
1259
+ if (!(error instanceof DaemonHttpError) || error.status !== 409) return false;
1260
+ try {
1261
+ const body = JSON.parse(error.body) as { code?: unknown; retryable?: unknown };
1262
+ return body.code === "retryable-conflict" && body.retryable === true;
1263
+ } catch {
1264
+ return false;
1265
+ }
1266
+ }
1267
+
1268
+ async function mutatorConflictBackoff(attempt: number): Promise<void> {
1269
+ const ceiling = Math.min(32, 2 ** attempt);
1270
+ const millis = ceiling + Math.floor(Math.random() * 4);
1271
+ await new Promise<void>((resolve) => setTimeout(resolve, millis));
1272
+ }
1273
+
1251
1274
  /**
1252
1275
  * The daemon server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
1253
1276
  * execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
@@ -1519,37 +1542,48 @@ class PgLiveTx implements ServerMutationTx {
1519
1542
  export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
1520
1543
  return {
1521
1544
  dialect: sqliteDialect,
1522
- async runMutation({ envelope, render, run }) {
1523
- const tx = new DaemonLazyTx(render, daemon, envelope);
1524
- try {
1525
- await run(tx);
1526
- } catch (err) {
1527
- // A begin-absorbed replay: the authoritative outcome already committed — answer it,
1528
- // whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
1529
- if (tx.absorbed) return { accepted: true, output: tx.absorbed };
1530
- if (err instanceof BackendError) {
1531
- await tx.rollbackSessionQuietly();
1532
- throw err.driverError; // infra — never a user rejection
1533
- }
1534
- const reason = errMessage(err);
1535
- // Data first, watermark second: the rollback releases the single writer that the
1536
- // `/reject-mutation` lmid-only commit needs (§2.4 on the session path).
1537
- await tx.rollbackSessionQuietly();
1538
- const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1539
- return { accepted: false, reason, output };
1540
- }
1541
- if (tx.absorbed) return { accepted: true, output: tx.absorbed };
1542
- if (tx.session) {
1545
+ async runMutation(input) {
1546
+ for (let attempt = 0; attempt < MUTATOR_CONFLICT_MAX_ATTEMPTS; attempt++) {
1543
1547
  try {
1544
- return { accepted: true, output: await tx.commitSession() };
1545
- } catch (err) {
1546
- if (err instanceof BackendError) throw err.driverError; // infra (client retries; dedup absorbs)
1547
- throw err;
1548
+ const { envelope, render, run } = input;
1549
+ const tx = new DaemonLazyTx(render, daemon, envelope);
1550
+ try {
1551
+ await run(tx);
1552
+ } catch (err) {
1553
+ // A begin-absorbed replay: the authoritative outcome already committed — answer it,
1554
+ // whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
1555
+ if (tx.absorbed) return { accepted: true, output: tx.absorbed };
1556
+ if (err instanceof BackendError) {
1557
+ await tx.rollbackSessionQuietly();
1558
+ throw err.driverError; // infra — never a user rejection
1559
+ }
1560
+ const reason = errMessage(err);
1561
+ // Data first, watermark second: rollback releases this session's connection before
1562
+ // the lmid-only rejection commit.
1563
+ await tx.rollbackSessionQuietly();
1564
+ const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1565
+ return { accepted: false, reason, output };
1566
+ }
1567
+ if (tx.absorbed) return { accepted: true, output: tx.absorbed };
1568
+ if (tx.session) {
1569
+ try {
1570
+ return { accepted: true, output: await tx.commitSession() };
1571
+ } catch (err) {
1572
+ if (err instanceof BackendError) throw err.driverError;
1573
+ throw err;
1574
+ }
1575
+ }
1576
+ const txn: SqlTxn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
1577
+ if (tx.idempotencyKey !== undefined) txn.idempotencyKey = tx.idempotencyKey;
1578
+ return { accepted: true, output: await daemon.executeSqlTxn(txn) };
1579
+ } catch (error) {
1580
+ if (!isRetryableCommitConflict(error) || attempt + 1 === MUTATOR_CONFLICT_MAX_ATTEMPTS) {
1581
+ throw error;
1582
+ }
1583
+ await mutatorConflictBackoff(attempt);
1548
1584
  }
1549
1585
  }
1550
- const txn: SqlTxn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
1551
- if (tx.idempotencyKey !== undefined) txn.idempotencyKey = tx.idempotencyKey;
1552
- return { accepted: true, output: await daemon.executeSqlTxn(txn) };
1586
+ throw new Error("unreachable mutator conflict retry loop");
1553
1587
  },
1554
1588
  reject({ envelope, reason }) {
1555
1589
  return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
@@ -2171,8 +2205,8 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2171
2205
  return undefined;
2172
2206
  }
2173
2207
 
2174
- // Covered ⇒ assemble the realtime block. The room endpoint rides ITS OWN field — the
2175
- // top-level `wsEndpoint` stays exactly the daemon lease's (whole-session migration signal).
2208
+ // Covered ⇒ assemble the realtime block. The room endpoint rides ITS OWN field
2209
+ // (`realtime.wsEndpoint`) a separate connection from the daemon session's fixed ws host.
2176
2210
  const { wsEndpoint } = await realtime.locateRoom(doc);
2177
2211
  const now = Date.now();
2178
2212
  const ttlMs = realtime.roomTokenTtlMs ?? DEFAULT_ROOM_TOKEN_TTL_MS;
@@ -2266,11 +2300,14 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2266
2300
  subject,
2267
2301
  leaseTtlMs: opts.leaseTtlMs,
2268
2302
  metadata: routingKey !== undefined ? { routingKey } : undefined,
2303
+ // Lifecycle leases are follower-local exactly like the primary lease. Forward the SAME
2304
+ // opaque placement ticket so every doorbell/fence materialization is minted on the
2305
+ // browser socket's follower instead of independently anycasting across the fleet.
2306
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
2269
2307
  });
2270
2308
  const lease = (table: string, out: MaterializeOutput, id: { scope?: string; doc?: string; clientId?: string }): QueryLeaseLifecycleLease => ({
2271
2309
  table,
2272
2310
  leaseToken: out.leaseToken,
2273
- ...(out.wsEndpoint !== undefined ? { wsEndpoint: out.wsEndpoint } : {}),
2274
2311
  ...(id.scope !== undefined ? { scope: id.scope } : {}),
2275
2312
  ...(id.doc !== undefined ? { doc: id.doc } : {}),
2276
2313
  ...(id.clientId !== undefined ? { clientId: id.clientId } : {}),
@@ -2456,6 +2493,10 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2456
2493
  // The anonymous routing key rides `metadata.routingKey`; the router keys on
2457
2494
  // `subject ?? metadata.routingKey` (§2.2). Omitted when there is none.
2458
2495
  metadata: routingKey !== undefined ? { routingKey } : undefined,
2496
+ // Forward the browser's opaque affinity ticket (if any) so the fleet `fly-replay`s this
2497
+ // materialize to the follower the ws is pinned to (FOLLOWER-AFFINITY-DESIGN.md §4). Opaque —
2498
+ // never verified here. Inert when the reads client is a single daemon (no fleet edge).
2499
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
2459
2500
  });
2460
2501
  const res = queryLeaseResponse(out);
2461
2502
  // I-iv (§4.1): the occupancy step FIRST — session sweep+upsert, then the D6 gate verdict. A
@@ -2505,10 +2546,13 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2505
2546
  const subject = await resolveSubject(opts.subject, input);
2506
2547
  const routingKey = await resolveRoutingKey(opts.routingKey, input);
2507
2548
  const visibilityKey = subject ?? routingKey;
2508
- const out = await opts.daemon.query({ ast, visibilityKey, ttlMs: opts.readIdleTtlMs });
2509
- const res: QueryReadResponse = { rows: out.rows, cvMin: out.cvMin, queryKey: out.queryKey };
2510
- if (out.wsEndpoint !== undefined) res.wsEndpoint = out.wsEndpoint;
2511
- return res;
2549
+ const out = await opts.daemon.query({
2550
+ ast,
2551
+ visibilityKey,
2552
+ ttlMs: opts.readIdleTtlMs,
2553
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
2554
+ });
2555
+ return { rows: out.rows, cvMin: out.cvMin, queryKey: out.queryKey };
2512
2556
  };
2513
2557
 
2514
2558
  const pushMutation = async (input: PushMutationRequest<User>): Promise<PushMutationResponse> => {
@@ -2866,7 +2910,8 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2866
2910
  headers,
2867
2911
  },
2868
2912
  };
2869
- const upstreamWsEndpoint = realtime.upstreamWsEndpoint ?? lease.wsEndpoint;
2913
+ if (lease.affinity !== undefined) res.upstreamAffinity = lease.affinity;
2914
+ const upstreamWsEndpoint = realtime.upstreamWsEndpoint;
2870
2915
  if (upstreamWsEndpoint !== undefined) res.upstreamWsEndpoint = upstreamWsEndpoint;
2871
2916
  return { status: 200, body: res };
2872
2917
  } catch (e) {
@@ -2881,6 +2926,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2881
2926
  args: msg.args ?? null,
2882
2927
  request: context.request,
2883
2928
  clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
2929
+ affinity: typeof msg.affinity === "string" ? msg.affinity : undefined,
2884
2930
  });
2885
2931
  },
2886
2932
  handleReadJson: (body, context) => {
@@ -2891,6 +2937,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2891
2937
  args: msg.args ?? null,
2892
2938
  request: context.request,
2893
2939
  clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
2940
+ affinity: typeof msg.affinity === "string" ? msg.affinity : undefined,
2894
2941
  });
2895
2942
  },
2896
2943
  handleMutateJson: (body, context) => {
@@ -3062,15 +3109,12 @@ async function resolveRoutingKey<User>(
3062
3109
  }
3063
3110
 
3064
3111
  function queryLeaseResponse(out: MaterializeOutput): QueryLeaseResponse {
3065
- const res: QueryLeaseResponse = {
3112
+ return {
3066
3113
  leaseToken: out.leaseToken,
3067
3114
  materializationId: out.materializationId,
3068
3115
  queryKey: out.queryKey,
3069
3116
  reused: out.reused,
3070
3117
  };
3071
- // Only present in a routed deploy — absent reproduces today's single-daemon response exactly.
3072
- if (out.wsEndpoint !== undefined) res.wsEndpoint = out.wsEndpoint;
3073
- return res;
3074
3118
  }
3075
3119
 
3076
3120
  function errMessage(reason: unknown): string {