@rindle/optimistic 0.5.0 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/backend.d.ts +136 -323
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +445 -829
- package/dist/backend.js.map +1 -1
- package/dist/client-id.d.ts +5 -0
- package/dist/client-id.d.ts.map +1 -1
- package/dist/client-id.js +45 -0
- package/dist/client-id.js.map +1 -1
- package/dist/client.d.ts +21 -6
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +91 -40
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/backend.ts +481 -1031
- package/src/client-id.ts +44 -0
- package/src/client.ts +124 -51
- package/src/index.ts +5 -5
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,
|
|
@@ -170,6 +171,15 @@ export interface RealtimeAnomaly {
|
|
|
170
171
|
/** Rindle Realtime client knobs (Slice G-v). All optional — an app with no labeled queries never
|
|
171
172
|
* touches any of this. */
|
|
172
173
|
export interface RealtimeClientOptions {
|
|
174
|
+
/** The DECLARED room mutators (302 §5: declared, not derived). A mutator named here routes to
|
|
175
|
+
* the attached room — it stages onto the room's own tables and ships on the room socket —
|
|
176
|
+
* whenever exactly ONE room is attached; solo (no room) it takes the ordinary daemon path,
|
|
177
|
+
* and with several rooms attached it routes daemon too (explicit multi-room binding is a
|
|
178
|
+
* later slice). Every mutator NOT named here is a daemon mutator. A misdeclaration fails
|
|
179
|
+
* SOFT (302 §5.1): the write lands on the other authority's tables, so the view just stops
|
|
180
|
+
* feeling instant until the echo relays it — never a divergence. An explicit top-level
|
|
181
|
+
* `domainPolicy` overrides this entirely. */
|
|
182
|
+
mutators?: readonly string[];
|
|
173
183
|
/** Build the ROOM ws transport for a lease's `realtime.wsEndpoint`. Default
|
|
174
184
|
* `(endpoint) => new WsTransport(endpoint)`. Injectable for tests / custom ws impls. */
|
|
175
185
|
transport?: (endpoint: string) => Transport;
|
|
@@ -189,11 +199,10 @@ export interface RealtimeInspect {
|
|
|
189
199
|
string,
|
|
190
200
|
{
|
|
191
201
|
wsEndpoint: string;
|
|
192
|
-
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
|
|
196
|
-
promoted: Record<string, string[]>;
|
|
202
|
+
/** The room's OWNED tables (302 §2): wire table → its namespaced engine table — read back
|
|
203
|
+
* from the BACKEND's registry (`backend.roomTablesFor`, the one source of truth; the
|
|
204
|
+
* client keeps no shadow copy). */
|
|
205
|
+
promoted: Record<string, string>;
|
|
197
206
|
/** Live room-retained queries on this room, by remote key. */
|
|
198
207
|
queries: Record<string, { name: string; sourceQid: QueryId; exp: number; refCount: number }>;
|
|
199
208
|
}
|
|
@@ -209,6 +218,11 @@ const RENEW_RETRY_MS = 5_000;
|
|
|
209
218
|
/** A one-shot token handoff not consumed within this window is stale — the resolver falls through
|
|
210
219
|
* to a fresh lease POST instead of presenting a token the server may already refuse. */
|
|
211
220
|
const HANDOFF_MAX_AGE_MS = 15_000;
|
|
221
|
+
/** How long a fresh (ticketless) connect waits for its affinity mint frame before leasing
|
|
222
|
+
* TICKETLESS + warning (FOLLOWER-AFFINITY-DESIGN.md §4.1). Generous — the frame normally arrives in
|
|
223
|
+
* well under one RTT; this bound only trips on an affinity-off daemon (misconfig / rolling upgrade),
|
|
224
|
+
* so it degrades loudly instead of hanging. */
|
|
225
|
+
const AFFINITY_TICKET_TIMEOUT_MS = 4_000;
|
|
212
226
|
/** Client-minted remote-retain qids live in their own high band so they can never collide with the
|
|
213
227
|
* Store's own 1, 2, 3, … view qids or the reserved per-channel lmid qid 0. Exact in f64 (the wire
|
|
214
228
|
* number type), far below 2^53. */
|
|
@@ -236,8 +250,14 @@ export interface RindleClientOptions<S extends ColsMap, R extends ClientRegistry
|
|
|
236
250
|
* different follower migrates the connection there.
|
|
237
251
|
* - `{ wsUrl }` omitted — pure-lazy: the first lease's `wsEndpoint` opens the connection (a
|
|
238
252
|
* routed SPA with no SSR bootstrap).
|
|
239
|
-
* - `{ transport }` — a pre-built transport (tests / in-process); fixed, no migration.
|
|
240
|
-
|
|
253
|
+
* - `{ transport }` — a pre-built transport (tests / in-process); fixed, no migration.
|
|
254
|
+
*
|
|
255
|
+
* Set `affinity: true` (with `wsUrl` pointing at the fleet host, `app-<id>.rindle.cloud`) to run
|
|
256
|
+
* FOLLOWER-AFFINITY mode (design §2): the ws offers a placement ticket as a subprotocol, the
|
|
257
|
+
* client persists the follower's minted ticket (per-tab) and forwards it on the lease POST so both
|
|
258
|
+
* legs pin the same nearby follower — sticky, regional, monotone reads. Off (default) is today's
|
|
259
|
+
* single-daemon behavior, byte-identical. Ignored for a pre-built `{ transport }`. */
|
|
260
|
+
daemon: { wsUrl?: string; affinity?: boolean } | { transport: Transport };
|
|
241
261
|
/** Stable client identity. Default: a per-origin base (localStorage) plus per-tab and per-instance
|
|
242
262
|
* suffixes, so each tab — and each client instance within a tab — gets its own mid sequence yet a
|
|
243
263
|
* reload keeps it; falls back to a fresh random id when web storage is unavailable. Pass a value
|
|
@@ -316,11 +336,51 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
|
|
|
316
336
|
return text ? JSON.parse(text) : undefined;
|
|
317
337
|
};
|
|
318
338
|
|
|
339
|
+
// FOLLOWER-AFFINITY mode (design §2): opt-in, and only for a fleet `wsUrl` connection (a pre-built
|
|
340
|
+
// transport has no fleet edge to route through). The store is shared by the ws transport (offers
|
|
341
|
+
// the ticket as a subprotocol), the source (records the follower's minted ticket / clears it on a
|
|
342
|
+
// dead follower), and the lease POST below (forwards it). Persisted per-TAB (sessionStorage) so two
|
|
343
|
+
// tabs can pin two regions (design §13) yet a reload lands back on the same follower.
|
|
344
|
+
const affinityOn = !("transport" in opts.daemon) && opts.daemon.affinity === true;
|
|
345
|
+
const affinityStore: AffinityTicketStore | undefined = affinityOn
|
|
346
|
+
? createAffinityTicketStore(sessionTicketPersistence())
|
|
347
|
+
: undefined;
|
|
348
|
+
|
|
319
349
|
// The ONE app-lease POST both legs share. Sends the stable `clientId` so the api-server/router
|
|
320
350
|
// can use it as the anonymous routing key (READ-ROUTER-DESIGN.md §2.2); the reply's top-level
|
|
321
351
|
// fields are the daemon lease, and a room-served labeled query ADDITIONALLY carries `realtime`.
|
|
322
|
-
|
|
323
|
-
|
|
352
|
+
// In affinity mode it ALSO forwards the placement `affinity` ticket — awaited first so a fresh
|
|
353
|
+
// (ticketless) connect leases only AFTER its mint frame arrives, co-locating both legs (§4.1). On
|
|
354
|
+
// reconnect, the WebSocket may offer the persisted ticket on its handshake, but the lease waits
|
|
355
|
+
// for that connection's fresh mint frame. The wait is BOUNDED: if no mint frame arrives (the daemon is
|
|
356
|
+
// affinity-off — a misconfig, or mid rolling-upgrade), we lease
|
|
357
|
+
// TICKETLESS and warn rather than hang. That first timeout LATCHES ticketless mode in the store:
|
|
358
|
+
// later leases return immediately (and do not accumulate abandoned waiters) until an affinity
|
|
359
|
+
// frame actually arrives. A persisted ticket remains useful for the ws handshake but is never
|
|
360
|
+
// forwarded on a lease until the CURRENT connection refreshes it, preventing a restored stale
|
|
361
|
+
// ticket from independently re-pinning the HTTP and ws legs.
|
|
362
|
+
let affinityFallbackWarned = false;
|
|
363
|
+
const postLease = async (remote: RemoteQuery): Promise<QueryLeaseWire> => {
|
|
364
|
+
let affinity: string | undefined;
|
|
365
|
+
if (affinityStore) {
|
|
366
|
+
const ticket = await affinityStore.leaseTicket(AFFINITY_TICKET_TIMEOUT_MS);
|
|
367
|
+
affinity = ticket.ticket;
|
|
368
|
+
if (affinity !== undefined) affinityFallbackWarned = false;
|
|
369
|
+
if (ticket.timedOut && !affinityFallbackWarned) {
|
|
370
|
+
affinityFallbackWarned = true;
|
|
371
|
+
console.warn(
|
|
372
|
+
`[rindle] no affinity ticket after ${AFFINITY_TICKET_TIMEOUT_MS}ms — leasing ticketless ` +
|
|
373
|
+
"(is the daemon affinity-enabled / RINDLE_AFFINITY_KEY set?)",
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return post(routes.query, {
|
|
378
|
+
name: remote.name,
|
|
379
|
+
args: remote.args,
|
|
380
|
+
clientId: clientID,
|
|
381
|
+
...(affinity !== undefined ? { affinity } : {}),
|
|
382
|
+
}) as Promise<QueryLeaseWire>;
|
|
383
|
+
};
|
|
324
384
|
|
|
325
385
|
// One-shot fresh-token handoffs, by remote key: G-v's resolve-then-register (and the proactive
|
|
326
386
|
// renewal) has ALREADY leased when the subscribe fires, so the resolver consumes the handed
|
|
@@ -359,12 +419,22 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
|
|
|
359
419
|
|
|
360
420
|
// Reads-leg connection: a fixed transport (tests/in-process) or a replaceable connection built
|
|
361
421
|
// from `wsUrl` (eager when present, lazy when omitted). A routed lease's `wsEndpoint` migrates it.
|
|
422
|
+
// In affinity mode each transport offers the current ticket as a subprotocol (evaluated per
|
|
423
|
+
// connect, so a reconnect presents the freshest — or freshly cleared — ticket).
|
|
362
424
|
const connection: RemoteOptimisticConnection =
|
|
363
425
|
"transport" in opts.daemon
|
|
364
426
|
? { transport: opts.daemon.transport }
|
|
365
|
-
: {
|
|
427
|
+
: {
|
|
428
|
+
factory: (endpoint) =>
|
|
429
|
+
new WsTransport(
|
|
430
|
+
endpoint,
|
|
431
|
+
affinityStore ? { subprotocols: () => offerSubprotocols(affinityStore) } : {},
|
|
432
|
+
),
|
|
433
|
+
endpoint: opts.daemon.wsUrl,
|
|
434
|
+
};
|
|
366
435
|
|
|
367
436
|
const source = new RemoteOptimisticSource(connection, clientID, {
|
|
437
|
+
...(affinityStore ? { affinity: affinityStore } : {}),
|
|
368
438
|
resolveSubscribe: async ({ remote }) => {
|
|
369
439
|
// A fail-open labeled register already leased — present exactly that token (see
|
|
370
440
|
// `tokenHandoffs`); otherwise lease now. Read back the follower's `wsEndpoint` for placement.
|
|
@@ -401,7 +471,14 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
|
|
|
401
471
|
const { store, backend, mutate } = createOptimisticStore(opts.schema, source, opts.mutators, {
|
|
402
472
|
clientID,
|
|
403
473
|
user: opts.user,
|
|
404
|
-
|
|
474
|
+
// The DECLARED router (302 §5): an explicit `domainPolicy` wins; otherwise the app's declared
|
|
475
|
+
// realtime mutators route to the one attached room (`rooms` is read lazily at invoke time —
|
|
476
|
+
// it is declared below, after this construction).
|
|
477
|
+
...(opts.domainPolicy
|
|
478
|
+
? { domainPolicy: opts.domainPolicy }
|
|
479
|
+
: opts.realtime?.mutators !== undefined
|
|
480
|
+
? { domainPolicy: declaredMutatorPolicy(new Set(opts.realtime.mutators), () => rooms) }
|
|
481
|
+
: {}),
|
|
405
482
|
// Room-plane rejection parity (H-v): a room's `mutationOutcome {kind:"rejected"}` frame
|
|
406
483
|
// surfaces through the SAME callback the HTTP mutate path uses below — one app-level
|
|
407
484
|
// rejection surface, whichever authority said no.
|
|
@@ -434,9 +511,9 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
|
|
|
434
511
|
};
|
|
435
512
|
|
|
436
513
|
/** One connected room: its gate key + its `RemoteOptimisticSource`. The promoted-table
|
|
437
|
-
* bookkeeping
|
|
438
|
-
*
|
|
439
|
-
*
|
|
514
|
+
* bookkeeping lives in the backend's 302 roomTables record (`backend.roomTablesFor(sourceKey)`
|
|
515
|
+
* — the wire→namespaced-twin rename map; one source of truth for the gate's rename/DROP, the
|
|
516
|
+
* idempotence check, and `__realtimeInspect`). */
|
|
440
517
|
interface RoomConnection {
|
|
441
518
|
sourceKey: string;
|
|
442
519
|
wsEndpoint: string;
|
|
@@ -541,26 +618,21 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
|
|
|
541
618
|
claims.clear();
|
|
542
619
|
};
|
|
543
620
|
|
|
544
|
-
/**
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
* none of it crosses the wasm `WritableDescriptor` ABI. */
|
|
621
|
+
/** Register the room's OWNED tables (302 §2 — one source per table): every lease table spec
|
|
622
|
+
* whose `writable` kind is not `"none"` names a table the room owns; the backend registers a
|
|
623
|
+
* namespaced engine twin the room channel feeds and the room-homed views swap onto. Context
|
|
624
|
+
* tables (`kind: "none"`) are deliberately NOT registered — the daemon is their sole
|
|
625
|
+
* authority, and the gate DROPS the room's relayed copies (302 §6). Idempotent per
|
|
626
|
+
* (sourceKey, table) — the backend's record is the one source of truth; a footprint table
|
|
627
|
+
* absent from the client schema has nothing to hold rows for and is skipped backend-side. */
|
|
552
628
|
const promoteRoomTables = (room: RoomConnection, specs: RealtimeLeaseTableSpec[]): void => {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
: {}),
|
|
561
|
-
...(spec.footprintWhere !== undefined ? { footprintWhere: spec.footprintWhere } : {}),
|
|
562
|
-
});
|
|
563
|
-
}
|
|
629
|
+
const owned = specs.filter((s) => s.writable.kind !== "none").map((s) => s.table);
|
|
630
|
+
// ALWAYS register — even an all-context lease's `owned = []`: the installed (empty) map is
|
|
631
|
+
// what makes the room gate DROP every relayed delta (302 §6). Skipping the call would leave
|
|
632
|
+
// `gate.tableMap` undefined — the DAEMON identity path — and fold the room's relayed copies
|
|
633
|
+
// of daemon-authoritative rows verbatim into the plain tables, two syncs fighting over one
|
|
634
|
+
// baseline (stale overwrites + dueling GC removes).
|
|
635
|
+
backend.registerRoomTables(room.sourceKey, owned);
|
|
564
636
|
};
|
|
565
637
|
|
|
566
638
|
/** (Re)arm a room query's proactive renewal from its current `exp`. Timers are unref'd (Node)
|
|
@@ -1214,11 +1286,8 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
|
|
|
1214
1286
|
sourceKey,
|
|
1215
1287
|
{
|
|
1216
1288
|
wsEndpoint: room.wsEndpoint,
|
|
1217
|
-
// Read back from the backend's
|
|
1218
|
-
|
|
1219
|
-
promoted: Object.fromEntries(
|
|
1220
|
-
[...backend.roomTablesFor(sourceKey)].map(([t, spec]) => [t, [...spec.joinKeyCols]]),
|
|
1221
|
-
),
|
|
1289
|
+
// Read back from the backend's room-table registry (302 §2): wire → engine table.
|
|
1290
|
+
promoted: Object.fromEntries(backend.roomTablesFor(sourceKey)),
|
|
1222
1291
|
queries: Object.fromEntries(
|
|
1223
1292
|
[...roomQueries]
|
|
1224
1293
|
.filter(([, s]) => s.sourceKey === sourceKey)
|
|
@@ -1243,16 +1312,20 @@ interface MaterializedViewLike {
|
|
|
1243
1312
|
destroy(): void;
|
|
1244
1313
|
}
|
|
1245
1314
|
|
|
1246
|
-
/**
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1249
|
-
*
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1315
|
+
/** The default DECLARED router (302 §5) when the app names `realtime.mutators` and passes no
|
|
1316
|
+
* explicit `domainPolicy`: a declared mutator routes to the ONE attached room; solo or
|
|
1317
|
+
* multi-room it abstains (⇒ daemon). `getRooms` is read lazily per invoke so the policy tracks
|
|
1318
|
+
* attach/downgrade live. */
|
|
1319
|
+
function declaredMutatorPolicy(
|
|
1320
|
+
declared: ReadonlySet<string>,
|
|
1321
|
+
getRooms: () => ReadonlyMap<string, unknown>,
|
|
1322
|
+
): (name: string, args: unknown) => string | undefined {
|
|
1323
|
+
return (name) => {
|
|
1324
|
+
if (!declared.has(name)) return undefined;
|
|
1325
|
+
const rooms = getRooms();
|
|
1326
|
+
if (rooms.size !== 1) return undefined; // solo or ambiguous — the daemon path (302 §5)
|
|
1327
|
+
return rooms.keys().next().value as string;
|
|
1328
|
+
};
|
|
1256
1329
|
}
|
|
1257
1330
|
|
|
1258
1331
|
/** Flip a just-materialized labeled view back to `unknown` for the lease-resolve window. The
|
package/src/index.ts
CHANGED
|
@@ -41,10 +41,6 @@ export type {
|
|
|
41
41
|
ReadOutcome,
|
|
42
42
|
ReadRecord,
|
|
43
43
|
ResultType,
|
|
44
|
-
RoomTableRouting,
|
|
45
|
-
RoomTableRoutingSpec,
|
|
46
|
-
RoutingFailureReason,
|
|
47
|
-
RoutingInspect,
|
|
48
44
|
ScopeSessionsEvent,
|
|
49
45
|
SystemStreamSpec,
|
|
50
46
|
SystemStreamTable,
|
|
@@ -62,7 +58,11 @@ export {
|
|
|
62
58
|
roomDomainKey,
|
|
63
59
|
SCOPE_SESSIONS_TABLE,
|
|
64
60
|
} from "./system-streams.ts";
|
|
65
|
-
export type {
|
|
61
|
+
export type {
|
|
62
|
+
MutationEnvelope,
|
|
63
|
+
OptimisticSource,
|
|
64
|
+
ProgressFrame,
|
|
65
|
+
} from "@rindle/client";
|
|
66
66
|
// The shared (generator) mutator seam — a registry may hold plain client mutators OR these isomorphic
|
|
67
67
|
// generators (the SAME body the API server runs); re-exported here so an app registers from one import.
|
|
68
68
|
export { isoTx } from "@rindle/client";
|