@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/README.md +1 -1
- package/dist/backend.d.ts +159 -323
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +471 -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 +28 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +98 -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 +519 -1028
- package/src/client-id.ts +44 -0
- package/src/client.ts +143 -52
- package/src/index.ts +6 -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,
|
|
@@ -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
|
-
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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
|
-
: {
|
|
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
|
-
|
|
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
|
|
438
|
-
*
|
|
439
|
-
*
|
|
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
|
-
/**
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
1218
|
-
|
|
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
|
-
/**
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1249
|
-
*
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
-
|
|
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 {
|
|
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";
|