@rindle/optimistic 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/client-id.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { TicketPersistence } from "@rindle/remote";
2
+
1
3
  // The connection's stable clientID — kept in its own (wasm-free) module so it can be unit-tested
2
4
  // without pulling the engine in. A per-ORIGIN base (localStorage) shared by every tab and kept
3
5
  // across reloads, a per-TAB suffix (sessionStorage) that survives a reload within a tab but is
@@ -10,6 +12,12 @@
10
12
  const CLIENT_ID_KEY = "rindle-client-id";
11
13
  /** The per-TAB suffix (sessionStorage): survives a reload within a tab but is distinct per tab. */
12
14
  const TAB_ID_KEY = "rindle-tab-id";
15
+ /** The per-TAB follower-affinity ticket (sessionStorage): kept across a reload WITHIN a tab (so a
16
+ * reload lands back on the same follower — monotone reads survive it, FOLLOWER-AFFINITY-DESIGN.md
17
+ * §8), but DISTINCT per tab so two tabs can pin two regions (design §13; a shared localStorage
18
+ * ticket would force both onto one follower). The ticket is opaque and short-lived — the follower
19
+ * re-mints one on every connect — so losing it (no sessionStorage) merely re-anycasts. */
20
+ const AFFINITY_TICKET_KEY = "rindle-affinity-ticket";
13
21
 
14
22
  /** Read-or-mint a value in a web Storage area, tolerating its absence (SSR, privacy mode). Returns
15
23
  * `undefined` when the area is unavailable so the caller can pick a fallback. */
@@ -55,6 +63,42 @@ export function resetStableClientID(): void {
55
63
  tabInstance = 0;
56
64
  }
57
65
 
66
+ /** A sessionStorage-backed {@link TicketPersistence} for the affinity ticket — per-tab, survives a
67
+ * reload (see {@link AFFINITY_TICKET_KEY}). Every access tolerates web storage being absent (SSR,
68
+ * private mode): the store then behaves as pure in-memory, re-anycasting on each fresh load. */
69
+ export function sessionTicketPersistence(): TicketPersistence {
70
+ const area = () => {
71
+ try {
72
+ return (globalThis as unknown as Record<string, Storage | undefined>).sessionStorage;
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ };
77
+ return {
78
+ load: () => {
79
+ try {
80
+ return area()?.getItem(AFFINITY_TICKET_KEY) ?? undefined;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ },
85
+ save: (ticket) => {
86
+ try {
87
+ area()?.setItem(AFFINITY_TICKET_KEY, ticket);
88
+ } catch {
89
+ // Best-effort persistence; a full/blocked store just means we re-anycast on the next load.
90
+ }
91
+ },
92
+ clear: () => {
93
+ try {
94
+ area()?.removeItem(AFFINITY_TICKET_KEY);
95
+ } catch {
96
+ // Best-effort.
97
+ }
98
+ },
99
+ };
100
+ }
101
+
58
102
  /** The connection's stable clientID. A per-origin base (localStorage) keeps one logical identity
59
103
  * across reloads; a per-tab suffix (sessionStorage) gives each tab its OWN mid/lmid stream; a
60
104
  * per-load instance suffix keeps two clients constructed in ONE tab from sharing that stream. Falls
package/src/client.ts CHANGED
@@ -24,15 +24,16 @@ import type {
24
24
  import {
25
25
  RemoteOptimisticSource,
26
26
  WsTransport,
27
+ createAffinityTicketStore,
27
28
  createQueuedMutationSender,
29
+ offerSubprotocols,
28
30
  } from "@rindle/remote";
29
- import type { PushOutcome, RemoteOptimisticConnection, Transport } from "@rindle/remote";
31
+ import type { AffinityTicketStore, PushOutcome, RemoteOptimisticConnection, Transport } from "@rindle/remote";
30
32
  import { initWasm } from "@rindle/wasm";
31
- import type { WritableDescriptor } from "@rindle/wasm";
32
33
 
33
34
  import type { OptimisticBackend } from "./backend.ts";
34
35
  import type { ClientRegistry, MutationTx } from "./backend.ts";
35
- import { resetStableClientID, stableClientID } from "./client-id.ts";
36
+ import { resetStableClientID, sessionTicketPersistence, stableClientID } from "./client-id.ts";
36
37
  import {
37
38
  LIFECYCLE_QUERY_NAME,
38
39
  ROOM_CLIENT_MUTATIONS_TABLE,
@@ -157,7 +158,13 @@ export type RealtimeAnomalyKind =
157
158
  | "source-key-changed"
158
159
  /** The lease POST failed or the room attach threw. The initial-materialize case fails OPEN to
159
160
  * the daemon path (indistinguishable from an unlabeled query's recovery). */
160
- | "lease-failed";
161
+ | "lease-failed"
162
+ /** 302 §6.1: a view swapping onto a room still references tables the room does NOT own — those
163
+ * refs keep reading the plain DAEMON tables (the client-side join across kinds), and the
164
+ * room's relayed copies are dropped by design (§6), so the joined rows render only if a
165
+ * daemon subscription covers them. Unverifiable client-side — surfaced once per view so a
166
+ * silently-empty join is a named condition, not a mystery. */
167
+ | "context-coverage";
161
168
 
162
169
  /** A loud realtime lease anomaly (always ALSO `console.error`'d). */
163
170
  export interface RealtimeAnomaly {
@@ -170,6 +177,15 @@ export interface RealtimeAnomaly {
170
177
  /** Rindle Realtime client knobs (Slice G-v). All optional — an app with no labeled queries never
171
178
  * touches any of this. */
172
179
  export interface RealtimeClientOptions {
180
+ /** The DECLARED room mutators (302 §5: declared, not derived). A mutator named here routes to
181
+ * the attached room — it stages onto the room's own tables and ships on the room socket —
182
+ * whenever exactly ONE room is attached; solo (no room) it takes the ordinary daemon path,
183
+ * and with several rooms attached it routes daemon too (explicit multi-room binding is a
184
+ * later slice). Every mutator NOT named here is a daemon mutator. A misdeclaration fails
185
+ * SOFT (302 §5.1): the write lands on the other authority's tables, so the view just stops
186
+ * feeling instant until the echo relays it — never a divergence. An explicit top-level
187
+ * `domainPolicy` overrides this entirely. */
188
+ mutators?: readonly string[];
173
189
  /** Build the ROOM ws transport for a lease's `realtime.wsEndpoint`. Default
174
190
  * `(endpoint) => new WsTransport(endpoint)`. Injectable for tests / custom ws impls. */
175
191
  transport?: (endpoint: string) => Transport;
@@ -189,11 +205,10 @@ export interface RealtimeInspect {
189
205
  string,
190
206
  {
191
207
  wsEndpoint: string;
192
- /** Promoted table → the lease's `joinKeyCols` ("room mutators never change join keys", the
193
- * §3 write proof's rule #3) read back from the BACKEND's routing table (H-iii:
194
- * `backend.roomTablesFor`, the one source of truth; the client keeps no shadow copy), and
195
- * deliberately never crossing the wasm `WritableDescriptor` ABI. Shape unchanged since G-v. */
196
- promoted: Record<string, string[]>;
208
+ /** The room's OWNED tables (302 §2): wire table its namespaced engine table — read back
209
+ * from the BACKEND's registry (`backend.roomTablesFor`, the one source of truth; the
210
+ * client keeps no shadow copy). */
211
+ promoted: Record<string, string>;
197
212
  /** Live room-retained queries on this room, by remote key. */
198
213
  queries: Record<string, { name: string; sourceQid: QueryId; exp: number; refCount: number }>;
199
214
  }
@@ -209,6 +224,11 @@ const RENEW_RETRY_MS = 5_000;
209
224
  /** A one-shot token handoff not consumed within this window is stale — the resolver falls through
210
225
  * to a fresh lease POST instead of presenting a token the server may already refuse. */
211
226
  const HANDOFF_MAX_AGE_MS = 15_000;
227
+ /** How long a fresh (ticketless) connect waits for its affinity mint frame before leasing
228
+ * TICKETLESS + warning (FOLLOWER-AFFINITY-DESIGN.md §4.1). Generous — the frame normally arrives in
229
+ * well under one RTT; this bound only trips on an affinity-off daemon (misconfig / rolling upgrade),
230
+ * so it degrades loudly instead of hanging. */
231
+ const AFFINITY_TICKET_TIMEOUT_MS = 4_000;
212
232
  /** Client-minted remote-retain qids live in their own high band so they can never collide with the
213
233
  * Store's own 1, 2, 3, … view qids or the reserved per-channel lmid qid 0. Exact in f64 (the wire
214
234
  * number type), far below 2^53. */
@@ -236,8 +256,14 @@ export interface RindleClientOptions<S extends ColsMap, R extends ClientRegistry
236
256
  * different follower migrates the connection there.
237
257
  * - `{ wsUrl }` omitted — pure-lazy: the first lease's `wsEndpoint` opens the connection (a
238
258
  * routed SPA with no SSR bootstrap).
239
- * - `{ transport }` — a pre-built transport (tests / in-process); fixed, no migration. */
240
- daemon: { wsUrl?: string } | { transport: Transport };
259
+ * - `{ transport }` — a pre-built transport (tests / in-process); fixed, no migration.
260
+ *
261
+ * Set `affinity: true` (with `wsUrl` pointing at the fleet host, `app-<id>.rindle.cloud`) to run
262
+ * FOLLOWER-AFFINITY mode (design §2): the ws offers a placement ticket as a subprotocol, the
263
+ * client persists the follower's minted ticket (per-tab) and forwards it on the lease POST so both
264
+ * legs pin the same nearby follower — sticky, regional, monotone reads. Off (default) is today's
265
+ * single-daemon behavior, byte-identical. Ignored for a pre-built `{ transport }`. */
266
+ daemon: { wsUrl?: string; affinity?: boolean } | { transport: Transport };
241
267
  /** Stable client identity. Default: a per-origin base (localStorage) plus per-tab and per-instance
242
268
  * suffixes, so each tab — and each client instance within a tab — gets its own mid sequence yet a
243
269
  * reload keeps it; falls back to a fresh random id when web storage is unavailable. Pass a value
@@ -316,11 +342,51 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
316
342
  return text ? JSON.parse(text) : undefined;
317
343
  };
318
344
 
345
+ // FOLLOWER-AFFINITY mode (design §2): opt-in, and only for a fleet `wsUrl` connection (a pre-built
346
+ // transport has no fleet edge to route through). The store is shared by the ws transport (offers
347
+ // the ticket as a subprotocol), the source (records the follower's minted ticket / clears it on a
348
+ // dead follower), and the lease POST below (forwards it). Persisted per-TAB (sessionStorage) so two
349
+ // tabs can pin two regions (design §13) yet a reload lands back on the same follower.
350
+ const affinityOn = !("transport" in opts.daemon) && opts.daemon.affinity === true;
351
+ const affinityStore: AffinityTicketStore | undefined = affinityOn
352
+ ? createAffinityTicketStore(sessionTicketPersistence())
353
+ : undefined;
354
+
319
355
  // The ONE app-lease POST both legs share. Sends the stable `clientId` so the api-server/router
320
356
  // can use it as the anonymous routing key (READ-ROUTER-DESIGN.md §2.2); the reply's top-level
321
357
  // fields are the daemon lease, and a room-served labeled query ADDITIONALLY carries `realtime`.
322
- const postLease = (remote: RemoteQuery): Promise<QueryLeaseWire> =>
323
- post(routes.query, { name: remote.name, args: remote.args, clientId: clientID }) as Promise<QueryLeaseWire>;
358
+ // In affinity mode it ALSO forwards the placement `affinity` ticket — awaited first so a fresh
359
+ // (ticketless) connect leases only AFTER its mint frame arrives, co-locating both legs (§4.1). On
360
+ // reconnect, the WebSocket may offer the persisted ticket on its handshake, but the lease waits
361
+ // for that connection's fresh mint frame. The wait is BOUNDED: if no mint frame arrives (the daemon is
362
+ // affinity-off — a misconfig, or mid rolling-upgrade), we lease
363
+ // TICKETLESS and warn rather than hang. That first timeout LATCHES ticketless mode in the store:
364
+ // later leases return immediately (and do not accumulate abandoned waiters) until an affinity
365
+ // frame actually arrives. A persisted ticket remains useful for the ws handshake but is never
366
+ // forwarded on a lease until the CURRENT connection refreshes it, preventing a restored stale
367
+ // ticket from independently re-pinning the HTTP and ws legs.
368
+ let affinityFallbackWarned = false;
369
+ const postLease = async (remote: RemoteQuery): Promise<QueryLeaseWire> => {
370
+ let affinity: string | undefined;
371
+ if (affinityStore) {
372
+ const ticket = await affinityStore.leaseTicket(AFFINITY_TICKET_TIMEOUT_MS);
373
+ affinity = ticket.ticket;
374
+ if (affinity !== undefined) affinityFallbackWarned = false;
375
+ if (ticket.timedOut && !affinityFallbackWarned) {
376
+ affinityFallbackWarned = true;
377
+ console.warn(
378
+ `[rindle] no affinity ticket after ${AFFINITY_TICKET_TIMEOUT_MS}ms — leasing ticketless ` +
379
+ "(is the daemon affinity-enabled / RINDLE_AFFINITY_KEY set?)",
380
+ );
381
+ }
382
+ }
383
+ return post(routes.query, {
384
+ name: remote.name,
385
+ args: remote.args,
386
+ clientId: clientID,
387
+ ...(affinity !== undefined ? { affinity } : {}),
388
+ }) as Promise<QueryLeaseWire>;
389
+ };
324
390
 
325
391
  // One-shot fresh-token handoffs, by remote key: G-v's resolve-then-register (and the proactive
326
392
  // renewal) has ALREADY leased when the subscribe fires, so the resolver consumes the handed
@@ -359,12 +425,22 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
359
425
 
360
426
  // Reads-leg connection: a fixed transport (tests/in-process) or a replaceable connection built
361
427
  // from `wsUrl` (eager when present, lazy when omitted). A routed lease's `wsEndpoint` migrates it.
428
+ // In affinity mode each transport offers the current ticket as a subprotocol (evaluated per
429
+ // connect, so a reconnect presents the freshest — or freshly cleared — ticket).
362
430
  const connection: RemoteOptimisticConnection =
363
431
  "transport" in opts.daemon
364
432
  ? { transport: opts.daemon.transport }
365
- : { factory: (endpoint) => new WsTransport(endpoint), endpoint: opts.daemon.wsUrl };
433
+ : {
434
+ factory: (endpoint) =>
435
+ new WsTransport(
436
+ endpoint,
437
+ affinityStore ? { subprotocols: () => offerSubprotocols(affinityStore) } : {},
438
+ ),
439
+ endpoint: opts.daemon.wsUrl,
440
+ };
366
441
 
367
442
  const source = new RemoteOptimisticSource(connection, clientID, {
443
+ ...(affinityStore ? { affinity: affinityStore } : {}),
368
444
  resolveSubscribe: async ({ remote }) => {
369
445
  // A fail-open labeled register already leased — present exactly that token (see
370
446
  // `tokenHandoffs`); otherwise lease now. Read back the follower's `wsEndpoint` for placement.
@@ -401,7 +477,14 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
401
477
  const { store, backend, mutate } = createOptimisticStore(opts.schema, source, opts.mutators, {
402
478
  clientID,
403
479
  user: opts.user,
404
- ...(opts.domainPolicy ? { domainPolicy: opts.domainPolicy } : {}),
480
+ // The DECLARED router (302 §5): an explicit `domainPolicy` wins; otherwise the app's declared
481
+ // realtime mutators route to the one attached room (`rooms` is read lazily at invoke time —
482
+ // it is declared below, after this construction).
483
+ ...(opts.domainPolicy
484
+ ? { domainPolicy: opts.domainPolicy }
485
+ : opts.realtime?.mutators !== undefined
486
+ ? { domainPolicy: declaredMutatorPolicy(new Set(opts.realtime.mutators), () => rooms) }
487
+ : {}),
405
488
  // Room-plane rejection parity (H-v): a room's `mutationOutcome {kind:"rejected"}` frame
406
489
  // surfaces through the SAME callback the HTTP mutate path uses below — one app-level
407
490
  // rejection surface, whichever authority said no.
@@ -434,9 +517,9 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
434
517
  };
435
518
 
436
519
  /** One connected room: its gate key + its `RemoteOptimisticSource`. The promoted-table
437
- * bookkeeping that used to live here (G-v's `promoted` map) is DELEGATED to the backend's
438
- * routing table since H-iii (`backend.roomTablesFor(sourceKey)` one source of truth for the
439
- * §3 router, the idempotence check, and `__realtimeInspect`). */
520
+ * bookkeeping lives in the backend's 302 roomTables record (`backend.roomTablesFor(sourceKey)`
521
+ * the wire→namespaced-twin rename map; one source of truth for the gate's rename/DROP, the
522
+ * idempotence check, and `__realtimeInspect`). */
440
523
  interface RoomConnection {
441
524
  sourceKey: string;
442
525
  wsEndpoint: string;
@@ -541,26 +624,21 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
541
624
  claims.clear();
542
625
  };
543
626
 
544
- /** Promote every lease table spec this client can hold, exactly once per (sourceKey, table)
545
- * re-leases (renewal / reconnect) must never re-promote (the engine refuses a duplicate room);
546
- * the idempotence check reads the BACKEND's routing record (the one source of truth since
547
- * H-iii). A footprint table absent from the client schema has nothing to promote (the client
548
- * never materializes rows for it) and is skipped. The lease spec's routing half —
549
- * `joinKeyCols`, the row-local writable `where`, and `footprintWhere` (H-iii) threads through
550
- * as the backend's per-(sourceKey, table) `RoomTableRouting` record, the §3 router's input;
551
- * none of it crosses the wasm `WritableDescriptor` ABI. */
627
+ /** Register the room's OWNED tables (302 §2 one source per table): every lease table spec
628
+ * whose `writable` kind is not `"none"` names a table the room owns; the backend registers a
629
+ * namespaced engine twin the room channel feeds and the room-homed views swap onto. Context
630
+ * tables (`kind: "none"`) are deliberately NOT registered the daemon is their sole
631
+ * authority, and the gate DROPS the room's relayed copies (302 §6). Idempotent per
632
+ * (sourceKey, table) — the backend's record is the one source of truth; a footprint table
633
+ * absent from the client schema has nothing to hold rows for and is skipped backend-side. */
552
634
  const promoteRoomTables = (room: RoomConnection, specs: RealtimeLeaseTableSpec[]): void => {
553
- for (const spec of specs) {
554
- if (backend.roomTablesFor(room.sourceKey).has(spec.table)) continue;
555
- if (!(spec.table in opts.schema.tables)) continue;
556
- backend.promoteRoomTable(spec.table, room.sourceKey, toWritableDescriptor(spec), {
557
- joinKeyCols: spec.writable.kind === "predicate" ? spec.writable.joinKeyCols : [],
558
- ...(spec.writable.kind === "predicate" && spec.writable.where !== undefined
559
- ? { where: spec.writable.where }
560
- : {}),
561
- ...(spec.footprintWhere !== undefined ? { footprintWhere: spec.footprintWhere } : {}),
562
- });
563
- }
635
+ const owned = specs.filter((s) => s.writable.kind !== "none").map((s) => s.table);
636
+ // ALWAYS register — even an all-context lease's `owned = []`: the installed (empty) map is
637
+ // what makes the room gate DROP every relayed delta (302 §6). Skipping the call would leave
638
+ // `gate.tableMap` undefined — the DAEMON identity path — and fold the room's relayed copies
639
+ // of daemon-authoritative rows verbatim into the plain tables, two syncs fighting over one
640
+ // baseline (stale overwrites + dueling GC removes).
641
+ backend.registerRoomTables(room.sourceKey, owned);
564
642
  };
565
643
 
566
644
  /** (Re)arm a room query's proactive renewal from its current `exp`. Timers are unref'd (Node)
@@ -940,6 +1018,18 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
940
1018
  );
941
1019
  });
942
1020
 
1021
+ // The 302 §6.1 context-coverage surface: a room-homed view's non-owned refs stay daemon-served
1022
+ // after the swap (the gate drops the room's relayed copies by design) — whether a daemon
1023
+ // subscription covers those rows is unknowable here, so name the condition loudly once per
1024
+ // view instead of letting the join render silently empty.
1025
+ backend.onRoomContextJoin(({ sourceKey, name, args, tables }) => {
1026
+ anomaly(
1027
+ "context-coverage",
1028
+ { name, args },
1029
+ `after swapping onto room ${JSON.stringify(sourceKey)}, table(s) ${tables.join(", ")} stay daemon-served (the room does not own them) — ensure a daemon subscription covers the joined rows, or the join renders empty for the room session (302 §6.1)`,
1030
+ );
1031
+ });
1032
+
943
1033
  // The split-register ticket: set (synchronously) by the wrapped `materialize` just before it
944
1034
  // delegates, consumed by the shadowed `backend.registerQuery` below — which registers the LOCAL
945
1035
  // half only (the Store's SSR-seed + `unknown` pre-marking has already run) and defers the remote
@@ -1214,11 +1304,8 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
1214
1304
  sourceKey,
1215
1305
  {
1216
1306
  wsEndpoint: room.wsEndpoint,
1217
- // Read back from the backend's routing table (H-iii) shape-compatible with G-v's
1218
- // client-held map (table → joinKeyCols).
1219
- promoted: Object.fromEntries(
1220
- [...backend.roomTablesFor(sourceKey)].map(([t, spec]) => [t, [...spec.joinKeyCols]]),
1221
- ),
1307
+ // Read back from the backend's room-table registry (302 §2): wire engine table.
1308
+ promoted: Object.fromEntries(backend.roomTablesFor(sourceKey)),
1222
1309
  queries: Object.fromEntries(
1223
1310
  [...roomQueries]
1224
1311
  .filter(([, s]) => s.sourceKey === sourceKey)
@@ -1243,16 +1330,20 @@ interface MaterializedViewLike {
1243
1330
  destroy(): void;
1244
1331
  }
1245
1332
 
1246
- /** Map a lease `RoomTableSpec.writable` onto the wasm engine's {@link WritableDescriptor}. An
1247
- * absent `where` on the predicate arm means NO row-local constraint every row the room holds
1248
- * for the table is in the writable scope (the spec contract) i.e. the engine's `all` arm. The
1249
- * spec's `joinKeyCols`/`footprintWhere` deliberately do NOT cross this boundary — they live in
1250
- * the backend's per-(sourceKey, table) routing record (H-iii, `backend.roomTablesFor`). */
1251
- function toWritableDescriptor(spec: RealtimeLeaseTableSpec): WritableDescriptor {
1252
- if (spec.writable.kind === "none") return { kind: "none" };
1253
- return spec.writable.where === undefined
1254
- ? { kind: "all" }
1255
- : { kind: "predicate", where: spec.writable.where };
1333
+ /** The default DECLARED router (302 §5) when the app names `realtime.mutators` and passes no
1334
+ * explicit `domainPolicy`: a declared mutator routes to the ONE attached room; solo or
1335
+ * multi-room it abstains (⇒ daemon). `getRooms` is read lazily per invoke so the policy tracks
1336
+ * attach/downgrade live. */
1337
+ function declaredMutatorPolicy(
1338
+ declared: ReadonlySet<string>,
1339
+ getRooms: () => ReadonlyMap<string, unknown>,
1340
+ ): (name: string, args: unknown) => string | undefined {
1341
+ return (name) => {
1342
+ if (!declared.has(name)) return undefined;
1343
+ const rooms = getRooms();
1344
+ if (rooms.size !== 1) return undefined; // solo or ambiguous — the daemon path (302 §5)
1345
+ return rooms.keys().next().value as string;
1346
+ };
1256
1347
  }
1257
1348
 
1258
1349
  /** Flip a just-materialized labeled view back to `unknown` for the lease-resolve window. The
package/src/index.ts CHANGED
@@ -41,10 +41,7 @@ export type {
41
41
  ReadOutcome,
42
42
  ReadRecord,
43
43
  ResultType,
44
- RoomTableRouting,
45
- RoomTableRoutingSpec,
46
- RoutingFailureReason,
47
- RoutingInspect,
44
+ RoomContextJoinEvent,
48
45
  ScopeSessionsEvent,
49
46
  SystemStreamSpec,
50
47
  SystemStreamTable,
@@ -62,7 +59,11 @@ export {
62
59
  roomDomainKey,
63
60
  SCOPE_SESSIONS_TABLE,
64
61
  } from "./system-streams.ts";
65
- export type { MutationEnvelope, OptimisticSource, ProgressFrame } from "@rindle/client";
62
+ export type {
63
+ MutationEnvelope,
64
+ OptimisticSource,
65
+ ProgressFrame,
66
+ } from "@rindle/client";
66
67
  // The shared (generator) mutator seam — a registry may hold plain client mutators OR these isomorphic
67
68
  // generators (the SAME body the API server runs); re-exported here so an app registers from one import.
68
69
  export { isoTx } from "@rindle/client";