@rindle/optimistic 0.4.4 → 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.ts CHANGED
@@ -9,18 +9,39 @@
9
9
  // and the mutation queue flushing confirmed in-order batches through the mutate route
10
10
  // (rejection reasons surface via `onRejected`).
11
11
 
12
- import type { ColsMap, MutationEnvelope, Schema } from "@rindle/client";
12
+ import { localTableNames } from "@rindle/client";
13
+ import type {
14
+ Ast,
15
+ ColsMap,
16
+ Condition,
17
+ MutationEnvelope,
18
+ Query,
19
+ QueryId,
20
+ RealtimeQueryLabel,
21
+ RemoteQuery,
22
+ Schema,
23
+ } from "@rindle/client";
13
24
  import {
14
25
  RemoteOptimisticSource,
15
26
  WsTransport,
27
+ createAffinityTicketStore,
16
28
  createQueuedMutationSender,
29
+ offerSubprotocols,
17
30
  } from "@rindle/remote";
18
- import type { PushOutcome, RemoteOptimisticConnection, Transport } from "@rindle/remote";
31
+ import type { AffinityTicketStore, PushOutcome, RemoteOptimisticConnection, Transport } from "@rindle/remote";
19
32
  import { initWasm } from "@rindle/wasm";
20
33
 
21
34
  import type { OptimisticBackend } from "./backend.ts";
22
35
  import type { ClientRegistry, MutationTx } from "./backend.ts";
23
- import { resetStableClientID, stableClientID } from "./client-id.ts";
36
+ import { resetStableClientID, sessionTicketPersistence, stableClientID } from "./client-id.ts";
37
+ import {
38
+ LIFECYCLE_QUERY_NAME,
39
+ ROOM_CLIENT_MUTATIONS_TABLE,
40
+ ROOM_MUTATION_OUTCOMES_TABLE,
41
+ ROOM_WATERMARK_TABLE,
42
+ SCOPE_SESSIONS_TABLE,
43
+ type SystemStreamTable,
44
+ } from "./system-streams.ts";
24
45
  import { createOptimisticStore, type MutateFn } from "./index.ts";
25
46
  import { attachLocalPersistence, type LocalPersistence, type PersistLocalOptions } from "./local-persist.ts";
26
47
  import type { Store } from "@rindle/client";
@@ -31,6 +52,188 @@ const DEFAULT_ROUTES = { query: "/api/rindle/query", mutate: "/api/rindle/mutate
31
52
 
32
53
  export type HeadersInit = Record<string, string>;
33
54
 
55
+ // --------------------------------------------------------------------------- Rindle Realtime (G-v)
56
+ //
57
+ // A named query stamped with a `realtime` label (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1) resolves
58
+ // its lease FIRST (resolve-then-register): the local AST view still materializes synchronously,
59
+ // but the remote retain attaches only when the lease answers — on the ROOM channel when the lease
60
+ // carries a `realtime` block, byte-identically on the daemon when it doesn't (fail-open). The
61
+ // types below mirror the api-server's `QueryLeaseRealtime`/`RoomTableSpec` wire shapes (duplicated
62
+ // like DEFAULT_ROUTES so the browser bundle never imports the server package).
63
+
64
+ /** One footprint table's spec on the lease wire (mirror of the api-server's `RoomTableSpec`).
65
+ * `footprintWhere` (H-iii lease-wire flip) is the EXACT footprint-membership predicate from the
66
+ * ONE unified compiler (`compileRoomScopeSpecs` — the same output the boot wire ships the room
67
+ * gate): present only for an exact footprint ROOT (lossless row-local extraction; the vacuous-true
68
+ * empty AND for an unconstrained one), ABSENT for child/correlated tables. It feeds the §3
69
+ * router's pk-membership read proof (`OptimisticBackend`'s routing table) — never authorization. */
70
+ export interface RealtimeLeaseTableSpec {
71
+ table: string;
72
+ footprintWhere?: Condition;
73
+ writable:
74
+ | { kind: "none" }
75
+ | { kind: "predicate"; where?: Condition; joinKeyCols: string[] };
76
+ }
77
+
78
+ /** The room-serve block on a query lease (mirror of the api-server's `QueryLeaseRealtime`). */
79
+ export interface RealtimeLeaseBlock {
80
+ /** The store's gate/domain key for this room source (`connectSource`) — `"room:<profile>/<key>"`. */
81
+ sourceKey: string;
82
+ /** Where the ROOM ws opens — the lease's DEDICATED field. Never confuse it with the TOP-LEVEL
83
+ * `wsEndpoint` (the read-router's whole-DAEMON-session migration signal). */
84
+ wsEndpoint: string;
85
+ /** The room shell's self-authorizing signed lease (seals the APPROVED query AST) — presented as
86
+ * the room subscribe's `leaseToken`. */
87
+ roomToken: string;
88
+ /** Token expiry (ms epoch) — the renewal clock (renewal = a fresh lease through the app route). */
89
+ exp: number;
90
+ doc: string;
91
+ tables: RealtimeLeaseTableSpec[];
92
+ }
93
+
94
+ /** One minted SYSTEM-STREAM lease on the `lifecycle` block (mirror of the api-server's
95
+ * `QueryLeaseLifecycleLease`; Slice I-iii): an ordinary daemon materialization over one of the
96
+ * four `_rindle_*` lifecycle tables, presented on the wire exactly like the primary lease
97
+ * (subscribe-with-`leaseToken`). The identity fields document the minted predicate — this client
98
+ * keys its retains (idempotence per (table, scope/doc/clientId)) and the backend keys its
99
+ * release-time row filters on them. */
100
+ export interface LifecycleLeaseEntry {
101
+ table: string;
102
+ leaseToken: string;
103
+ wsEndpoint?: string;
104
+ /** DOORBELL only: the §4.1 occupancy scope (= the wire room doc, `"<profile>/<key>"`). */
105
+ scope?: string;
106
+ /** FENCE entries only: the room doc. */
107
+ doc?: string;
108
+ /** FENCE ledger/outcomes when the server could client-scope the predicate. */
109
+ clientId?: string;
110
+ }
111
+
112
+ /** The §4 lifecycle block on a query lease (mirror of the api-server's `QueryLeaseLifecycle`):
113
+ * `doorbell` on every labeled lease under the opt-in server config, `fence` (watermark + ledger
114
+ * + outcomes) only when the lease is ALSO room-served. Absent ⇒ this client behaves exactly as
115
+ * today — the whole plane is inert-until-fed. */
116
+ export interface LifecycleLeaseBlock {
117
+ doorbell: LifecycleLeaseEntry;
118
+ fence?: LifecycleLeaseEntry[];
119
+ }
120
+
121
+ /** The §4.2 downgrade fence block on a query lease (mirror of the api-server's
122
+ * `QueryLeaseRealtimeFence`, Slice I-v): rides a labeled reply whose occupancy gate CLOSED
123
+ * (no `realtime` block) when the server could drain the room — `finalFlushSeq` is the room's
124
+ * last COMMITTED flush seq, the value the client's ghost holds against
125
+ * (`_rindle_room_watermark(doc) ≥ finalFlushSeq` through the daemon plane). A room-attached
126
+ * query receiving it runs the GRACEFUL downgrade dance instead of the loud legacy anomaly. */
127
+ export interface RealtimeFenceBlock {
128
+ /** The retiring room source's gate/domain key (`"room:" + doc`). */
129
+ sourceKey: string;
130
+ doc: string;
131
+ finalFlushSeq: number;
132
+ }
133
+
134
+ /** The query-lease reply as this client reads it (top-level daemon lease + optional room block
135
+ * + optional §4.2 downgrade fence + optional §4 lifecycle system-stream block). */
136
+ interface QueryLeaseWire {
137
+ leaseToken: string;
138
+ wsEndpoint?: string;
139
+ realtime?: RealtimeLeaseBlock;
140
+ realtimeFence?: RealtimeFenceBlock;
141
+ lifecycle?: LifecycleLeaseBlock;
142
+ }
143
+
144
+ export type RealtimeAnomalyKind =
145
+ /** A re-lease (renewal / reconnect re-resolution) came back WITHOUT a realtime block AND
146
+ * without a §4.2 fence — the query is no longer room-served but the server gave nothing to
147
+ * downgrade behind (a legacy/pre-I-v server, or `lifecycle.drainRoom` unconfigured). Surfaced
148
+ * loudly; a reply WITH a `realtimeFence` takes the graceful I-v dance instead. */
149
+ | "downgrade"
150
+ /** The I-v ghost is STUCK (§7.5): its watermark fence cleared but sent room-domain mids never
151
+ * resolved (sent-but-undelivered when the socket died — undecidable in general). The ghost
152
+ * holds — no timeout-retire is invented — and the mids are named once, actionably. */
153
+ | "downgrade-stuck"
154
+ /** A lease named a DIFFERENT `sourceKey` than the query's live room sub — surfaced loudly, no
155
+ * re-attach. Deliberately NOT composed from demote+upgrade (deferred to §7.6's rare-case
156
+ * follow-up): a sourceKey-change reply carries a realtime block for the NEW room but NO
157
+ * fence for the OLD one, and without `finalFlushSeq` the old slice cannot be ghosted soundly. */
158
+ | "source-key-changed"
159
+ /** The lease POST failed or the room attach threw. The initial-materialize case fails OPEN to
160
+ * the daemon path (indistinguishable from an unlabeled query's recovery). */
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";
168
+
169
+ /** A loud realtime lease anomaly (always ALSO `console.error`'d). */
170
+ export interface RealtimeAnomaly {
171
+ kind: RealtimeAnomalyKind;
172
+ name: string;
173
+ args: unknown;
174
+ message: string;
175
+ }
176
+
177
+ /** Rindle Realtime client knobs (Slice G-v). All optional — an app with no labeled queries never
178
+ * touches any of this. */
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[];
189
+ /** Build the ROOM ws transport for a lease's `realtime.wsEndpoint`. Default
190
+ * `(endpoint) => new WsTransport(endpoint)`. Injectable for tests / custom ws impls. */
191
+ transport?: (endpoint: string) => Transport;
192
+ /** Loud anomaly surface — see {@link RealtimeAnomaly}. Every anomaly is also `console.error`'d. */
193
+ onAnomaly?: (anomaly: RealtimeAnomaly) => void;
194
+ /** How long before a room lease's `exp` the proactive token renewal fires (default 30s). The
195
+ * renewal is a FRESH lease through the app query route (renewal-as-reauthorization), and the
196
+ * live room sub proactively re-subscribes with the fresh token so the shell's TTL backstop
197
+ * never fires on a healthy session. */
198
+ renewMarginMs?: number;
199
+ }
200
+
201
+ /** Read-only realtime bookkeeping snapshot ({@link RindleClient.__realtimeInspect}) — test/devtools
202
+ * introspection, mirroring the backend's `__inspect` convention. */
203
+ export interface RealtimeInspect {
204
+ rooms: Record<
205
+ string,
206
+ {
207
+ wsEndpoint: 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>;
212
+ /** Live room-retained queries on this room, by remote key. */
213
+ queries: Record<string, { name: string; sourceQid: QueryId; exp: number; refCount: number }>;
214
+ }
215
+ >;
216
+ }
217
+
218
+ /** Default {@link RealtimeClientOptions.renewMarginMs}. */
219
+ const DEFAULT_RENEW_MARGIN_MS = 30_000;
220
+ /** Renewal-delay floor: a nearly-expired lease still renews soon, but never in a hot loop. */
221
+ const MIN_RENEW_DELAY_MS = 1_000;
222
+ /** Retry delay after a failed renewal POST (only while the current token is still live). */
223
+ const RENEW_RETRY_MS = 5_000;
224
+ /** A one-shot token handoff not consumed within this window is stale — the resolver falls through
225
+ * to a fresh lease POST instead of presenting a token the server may already refuse. */
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;
232
+ /** Client-minted remote-retain qids live in their own high band so they can never collide with the
233
+ * Store's own 1, 2, 3, … view qids or the reserved per-channel lmid qid 0. Exact in f64 (the wire
234
+ * number type), far below 2^53. */
235
+ const REALTIME_RETAIN_QID_BASE = 2 ** 30;
236
+
34
237
  export interface RindleClientOptions<S extends ColsMap, R extends ClientRegistry> {
35
238
  schema: Schema<S>;
36
239
  /** The PREDICTED mutators (the API server holds the authoritative twins by name). */
@@ -53,14 +256,22 @@ export interface RindleClientOptions<S extends ColsMap, R extends ClientRegistry
53
256
  * different follower migrates the connection there.
54
257
  * - `{ wsUrl }` omitted — pure-lazy: the first lease's `wsEndpoint` opens the connection (a
55
258
  * routed SPA with no SSR bootstrap).
56
- * - `{ transport }` — a pre-built transport (tests / in-process); fixed, no migration. */
57
- 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 };
58
267
  /** Stable client identity. Default: a per-origin base (localStorage) plus per-tab and per-instance
59
268
  * suffixes, so each tab — and each client instance within a tab — gets its own mid sequence yet a
60
269
  * reload keeps it; falls back to a fresh random id when web storage is unavailable. Pass a value
61
270
  * to override. */
62
271
  clientID?: string;
63
- /** A policy rejection's reason (the prediction's snap-back rides the lmid release). */
272
+ /** A policy rejection's reason (the prediction's snap-back rides the lmid release). Fires for
273
+ * BOTH planes since H-v: the HTTP mutate route's per-envelope rejections AND a room's
274
+ * `mutationOutcome {kind:"rejected"}` frames — one surface, whichever authority said no. */
64
275
  onRejected?: (envelope: MutationEnvelope, reason: string) => void;
65
276
  /** Persist `local: true` tables across reloads and keep them live-coherent across tabs
66
277
  * (`207-LOCAL-TABLE-PERSISTENCE-DESIGN.md`). `user` is the storage identity (§3.2) — one IDB
@@ -71,6 +282,19 @@ export interface RindleClientOptions<S extends ColsMap, R extends ClientRegistry
71
282
  * ephemeral and per-tab (e.g. selection) even with persistence on (§5.4). */
72
283
  persistLocal?: PersistLocalOptions;
73
284
  queue?: { maxBatch?: number; retryDelayMs?: (attempt: number) => number };
285
+ /** The explicit confirming-stream OVERRIDE (RINDLE-REALTIME-QUERY-ENABLEMENT §7.1/§3): which
286
+ * domain's ledger a mutation is dealt from — its mid comes from that domain's counter, it ships
287
+ * on that domain's channel (§7.5), and only that domain's confirm watermark retires it. Since
288
+ * Slice H-iii a returned string PINS that domain verbatim (no proof runs); `undefined` — or no
289
+ * policy at all, the default — DERIVES the route per §3 from the prediction run's write/read
290
+ * capture (prove-or-slow-path; any unproven condition routes to the daemon). With no room gate
291
+ * connected the derivation is `"daemon"`, byte-for-byte as before. A room route the gate
292
+ * DEOPTS at commit (H-iv-b) is recovered automatically since H-v: the client re-enqueues the
293
+ * same logical mutation onto the daemon stream (prediction applied throughout, one burnt room
294
+ * mid), so deriving is safe for realtime apps. */
295
+ domainPolicy?: (name: string, args: unknown) => string | undefined;
296
+ /** Rindle Realtime client knobs (G-v resolve-then-register) — see {@link RealtimeClientOptions}. */
297
+ realtime?: RealtimeClientOptions;
74
298
  /** Development-only recovery knobs. Keep off in production: a mutation gap means state loss
75
299
  * or two writers sharing a clientID, and should be investigated. */
76
300
  dev?: {
@@ -91,6 +315,9 @@ export interface RindleClient<S extends ColsMap, R extends ClientRegistry> {
91
315
  flushFolds(): void;
92
316
  clientID: string;
93
317
  close(): void;
318
+ /** Read-only realtime bookkeeping snapshot (rooms, promoted tables + their client-held
319
+ * `joinKeyCols`, live room queries) — the `__inspect`-convention test/devtools hook. */
320
+ __realtimeInspect(): RealtimeInspect;
94
321
  }
95
322
 
96
323
  export async function createRindleClient<S extends ColsMap, R extends ClientRegistry>(
@@ -115,21 +342,118 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
115
342
  return text ? JSON.parse(text) : undefined;
116
343
  };
117
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
+
355
+ // The ONE app-lease POST both legs share. Sends the stable `clientId` so the api-server/router
356
+ // can use it as the anonymous routing key (READ-ROUTER-DESIGN.md §2.2); the reply's top-level
357
+ // fields are the daemon lease, and a room-served labeled query ADDITIONALLY carries `realtime`.
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
+ };
390
+
391
+ // One-shot fresh-token handoffs, by remote key: G-v's resolve-then-register (and the proactive
392
+ // renewal) has ALREADY leased when the subscribe fires, so the resolver consumes the handed
393
+ // token instead of POSTing a second time — one lease per subscribe, exactly the unlabeled
394
+ // cadence. Age-capped: an entry no subscribe consumed (e.g. a refcount-only retain) must not
395
+ // serve a stale token to a much-later re-subscribe (which re-leases fresh instead).
396
+ const tokenHandoffs = new Map<string, { target: { leaseToken: string; wsEndpoint?: string }; at: number }>();
397
+ const takeHandoff = (key: string): { leaseToken: string; wsEndpoint?: string } | undefined => {
398
+ const handed = tokenHandoffs.get(key);
399
+ if (!handed) return undefined;
400
+ tokenHandoffs.delete(key);
401
+ return Date.now() - handed.at <= HANDOFF_MAX_AGE_MS ? handed.target : undefined;
402
+ };
403
+
404
+ /** RE-resolve a SYSTEM (lifecycle) subscription (Slice I-iii — a reconnect / gap repair /
405
+ * overflow re-subscribe whose mint-time handoff is long consumed). `_rindle/lifecycle` is a
406
+ * reserved CLIENT-side name (like the lmid query's): the api-server cannot lease it by name,
407
+ * so the re-resolution re-leases the PARENT labeled query — renewal-as-reauthorization, the
408
+ * room-token precedent — and picks the matching entry out of the fresh `lifecycle` block. A
409
+ * reply without the entry throws: the server no longer minting this stream (config off, label
410
+ * dropped) must not silently re-attach — the source logs the failed subscribe, and I-iv/I-v
411
+ * own any reaction. */
412
+ const resolveLifecycleTarget = async (args: LifecycleRemoteArgs): Promise<{ leaseToken: string; wsEndpoint?: string }> => {
413
+ const out = (await postLease({ name: args.parent.name, args: args.parent.args })) as QueryLeaseWire;
414
+ const want = systemEntryKey(args);
415
+ const entry = out.lifecycle === undefined
416
+ ? undefined
417
+ : [out.lifecycle.doorbell, ...(out.lifecycle.fence ?? [])].find((e) => systemEntryKey(e) === want);
418
+ if (entry === undefined) {
419
+ throw new Error(
420
+ `lifecycle re-lease of "${args.parent.name}" no longer carries the ${args.table} system lease`,
421
+ );
422
+ }
423
+ return { leaseToken: entry.leaseToken, ...(entry.wsEndpoint !== undefined ? { wsEndpoint: entry.wsEndpoint } : {}) };
424
+ };
425
+
118
426
  // Reads-leg connection: a fixed transport (tests/in-process) or a replaceable connection built
119
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).
120
430
  const connection: RemoteOptimisticConnection =
121
431
  "transport" in opts.daemon
122
432
  ? { transport: opts.daemon.transport }
123
- : { 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
+ };
124
441
 
125
442
  const source = new RemoteOptimisticSource(connection, clientID, {
443
+ ...(affinityStore ? { affinity: affinityStore } : {}),
126
444
  resolveSubscribe: async ({ remote }) => {
127
- // Send the stable `clientId` so the api-server/router can use it as the anonymous routing key
128
- // (READ-ROUTER-DESIGN.md §2.2); read back the follower's `wsEndpoint` for placement.
129
- const out = (await post(routes.query, { name: remote.name, args: remote.args, clientId: clientID })) as {
130
- leaseToken: string;
131
- wsEndpoint?: string;
132
- };
445
+ // A fail-open labeled register already leased present exactly that token (see
446
+ // `tokenHandoffs`); otherwise lease now. Read back the follower's `wsEndpoint` for placement.
447
+ // A `realtime` block on a RE-resolution is deliberately IGNORED here even now that the
448
+ // upgrade dance exists (I-iv): retargeting from inside a reconnect's resolve would race the
449
+ // very re-subscribe it resolves. The §4.1 DOORBELL path owns upgrades (`runUpgrade` below)
450
+ // — the occupancy row that made this lease carry a block will (re)ring it.
451
+ const handed = takeHandoff(remoteKey(remote));
452
+ if (handed) return handed;
453
+ // A SYSTEM (lifecycle) sub re-resolves through its PARENT labeled query (I-iii) — the
454
+ // reserved name is never leaseable by itself (see resolveLifecycleTarget).
455
+ if (remote.name === LIFECYCLE_QUERY_NAME) return resolveLifecycleTarget(remote.args as LifecycleRemoteArgs);
456
+ const out = await postLease(remote);
133
457
  return { leaseToken: out.leaseToken, wsEndpoint: out.wsEndpoint };
134
458
  },
135
459
  pushMutation: createQueuedMutationSender({
@@ -153,8 +477,775 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
153
477
  const { store, backend, mutate } = createOptimisticStore(opts.schema, source, opts.mutators, {
154
478
  clientID,
155
479
  user: opts.user,
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
+ : {}),
488
+ // Room-plane rejection parity (H-v): a room's `mutationOutcome {kind:"rejected"}` frame
489
+ // surfaces through the SAME callback the HTTP mutate path uses below — one app-level
490
+ // rejection surface, whichever authority said no.
491
+ ...(opts.onRejected ? { onRejected: opts.onRejected } : {}),
492
+ });
493
+
494
+ // ---- Rindle Realtime (G-v): resolve-then-register for LABELED queries -------------------------
495
+ //
496
+ // `store.materialize` is wrapped: an UNLABELED query takes the original path byte-identically; a
497
+ // query stamped with a `realtime` label materializes its local view synchronously (the Store's
498
+ // ordinary seed/`unknown` pre-marking runs untouched) while the remote register is SPLIT — the
499
+ // shadowed `backend.registerQuery` below registers the LOCAL half only, and the remote retain
500
+ // attaches when the lease answers: on `realtime.sourceKey`'s room channel when the lease carries
501
+ // a realtime block, on the daemon (fail-open, indistinguishable from unlabeled) when it doesn't.
502
+
503
+ const realtimeOpts = opts.realtime ?? {};
504
+ const roomTransportFactory = realtimeOpts.transport ?? ((endpoint: string) => new WsTransport(endpoint));
505
+ const renewMarginMs = realtimeOpts.renewMarginMs ?? DEFAULT_RENEW_MARGIN_MS;
506
+ const localTables = localTableNames(opts.schema);
507
+ let realtimeClosed = false;
508
+
509
+ const anomaly = (kind: RealtimeAnomalyKind, remote: RemoteQuery, message: string): void => {
510
+ // LOUD by contract: every anomaly hits the console even with a handler installed.
511
+ console.error(`[rindle] realtime ${kind} for query "${remote.name}": ${message}`);
512
+ try {
513
+ realtimeOpts.onAnomaly?.({ kind, name: remote.name, args: remote.args, message });
514
+ } catch (err) {
515
+ console.error("[rindle] realtime onAnomaly handler threw:", err);
516
+ }
517
+ };
518
+
519
+ /** One connected room: its gate key + its `RemoteOptimisticSource`. The promoted-table
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`). */
523
+ interface RoomConnection {
524
+ sourceKey: string;
525
+ wsEndpoint: string;
526
+ source: RemoteOptimisticSource;
527
+ }
528
+ /** One room-retained (name, args): its ONE wire sub (`sourceQid` = the creating retain's qid),
529
+ * how many live views hold it, and the renewal clock. `lifecycleClaims` (I-iii) holds the
530
+ * system-stream claims RENEWAL-path re-leases made on this query's behalf (a renewal may mint
531
+ * entries the original attach never saw, e.g. the query became room-served); released when the
532
+ * last view drops the query. */
533
+ interface RoomQueryState {
534
+ remote: RemoteQuery;
535
+ sourceKey: string;
536
+ sourceQid: QueryId;
537
+ refCount: number;
538
+ exp: number;
539
+ renewTimer?: ReturnType<typeof setTimeout>;
540
+ lifecycleClaims: Set<string>;
541
+ /** The §4.1 doorbell scope this room-served query counts on (`lease.lifecycle.doorbell.scope`
542
+ * = the wire doc). Captured on attach/upgrade/renewal so the I-v downgrade dance — which runs
543
+ * from the renewal loop with no lease-block in scope for co-tenant queries — can re-register
544
+ * each surviving view as an upgrade candidate under the scope its next doorbell will ring. */
545
+ doorbellScope?: string;
546
+ }
547
+ const rooms = new Map<string, RoomConnection>();
548
+ const roomQueries = new Map<string, RoomQueryState>();
549
+ let nextRetainQid: QueryId = REALTIME_RETAIN_QID_BASE;
550
+
551
+ // ---- the §4 lifecycle SYSTEM-STREAM plane, client wiring (Slice I-iii) -----------------------
552
+ //
553
+ // A lease's `lifecycle` block names minted daemon subscriptions over the four `_rindle_*`
554
+ // system tables. They are retained on the DAEMON channel (that is the point of the plane: the
555
+ // outcome/ledger/watermark rows must reach the client with no room socket alive) through
556
+ // `backend.retainSystemQuery` — no store view, no user-visible table; the backend folds their
557
+ // rows at release time. Retains are IDEMPOTENT per (table, scope/doc/clientId): every live
558
+ // holder (a labeled view; a room query's renewal loop) claims a key at most once, one wire sub
559
+ // exists per key, and the LAST holder's release drops it. NO reactions are wired here — the
560
+ // doorbell-triggered re-lease is I-iv, the ghost-drop fence consumer is I-v. Absent block ⇒
561
+ // this whole section never runs.
562
+
563
+ /** One live system sub: the backend retain + how many holders claim it. */
564
+ interface SystemSubState {
565
+ retainQid: QueryId;
566
+ refCount: number;
567
+ }
568
+ const systemSubs = new Map<string, SystemSubState>();
569
+
570
+ /** Claim every entry of `block` for one holder (`claims` — the holder's own claim set; a key
571
+ * already claimed by THIS holder is skipped, so renewal re-presentations are idempotent).
572
+ * Unknown tables are skipped (forward-compat: a newer server minting a fifth stream must not
573
+ * break this client). */
574
+ const claimLifecycle = (claims: Set<string>, block: LifecycleLeaseBlock | undefined, parent: RemoteQuery): void => {
575
+ if (block === undefined || realtimeClosed) return;
576
+ for (const entry of [block.doorbell, ...(block.fence ?? [])]) {
577
+ if (!isSystemTable(entry.table)) continue;
578
+ const key = systemEntryKey(entry);
579
+ if (claims.has(key)) continue;
580
+ let live = systemSubs.get(key);
581
+ if (!live) {
582
+ // The sub's wire identity embeds the PARENT labeled query so a RE-resolution can
583
+ // re-lease it (resolveLifecycleTarget); the minted token is handed to the resolver so
584
+ // the first subscribe presents exactly it — one lease per subscribe, the G-v cadence.
585
+ const remote: RemoteQuery = {
586
+ name: LIFECYCLE_QUERY_NAME,
587
+ args: {
588
+ table: entry.table,
589
+ ...(entry.scope !== undefined ? { scope: entry.scope } : {}),
590
+ ...(entry.doc !== undefined ? { doc: entry.doc } : {}),
591
+ ...(entry.clientId !== undefined ? { clientId: entry.clientId } : {}),
592
+ parent: { name: parent.name, args: parent.args },
593
+ } satisfies LifecycleRemoteArgs,
594
+ };
595
+ tokenHandoffs.set(remoteKey(remote), {
596
+ target: { leaseToken: entry.leaseToken, ...(entry.wsEndpoint !== undefined ? { wsEndpoint: entry.wsEndpoint } : {}) },
597
+ at: Date.now(),
598
+ });
599
+ const retainQid = nextRetainQid++;
600
+ backend.retainSystemQuery(retainQid, remote, {
601
+ table: entry.table,
602
+ ...(entry.scope !== undefined ? { scope: entry.scope } : {}),
603
+ ...(entry.doc !== undefined ? { doc: entry.doc } : {}),
604
+ });
605
+ live = { retainQid, refCount: 0 };
606
+ systemSubs.set(key, live);
607
+ }
608
+ live.refCount++;
609
+ claims.add(key);
610
+ }
611
+ };
612
+
613
+ /** Release one holder's claims; the LAST holder of a key releases the backend retain (the wire
614
+ * sub unsubscribes; the backend's folded fence/occupancy STATE deliberately survives). */
615
+ const releaseLifecycle = (claims: Set<string>): void => {
616
+ for (const key of claims) {
617
+ const live = systemSubs.get(key);
618
+ if (!live) continue;
619
+ if (--live.refCount <= 0) {
620
+ systemSubs.delete(key);
621
+ backend.releaseSystemQuery(live.retainQid);
622
+ }
623
+ }
624
+ claims.clear();
625
+ };
626
+
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. */
634
+ const promoteRoomTables = (room: RoomConnection, specs: RealtimeLeaseTableSpec[]): void => {
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);
642
+ };
643
+
644
+ /** (Re)arm a room query's proactive renewal from its current `exp`. Timers are unref'd (Node)
645
+ * so an idle renewal never holds the process open; cleared on release/close. */
646
+ const scheduleRenewal = (key: string, state: RoomQueryState, delayMs?: number): void => {
647
+ if (state.renewTimer !== undefined) clearTimeout(state.renewTimer);
648
+ if (realtimeClosed) return;
649
+ const delay = delayMs ?? Math.max(state.exp - Date.now() - renewMarginMs, MIN_RENEW_DELAY_MS);
650
+ state.renewTimer = setTimeout(() => {
651
+ state.renewTimer = undefined;
652
+ void renewRoomQuery(key, state);
653
+ }, delay);
654
+ (state.renewTimer as unknown as { unref?: () => void }).unref?.();
655
+ };
656
+
657
+ /** Proactive token renewal (renewal-as-reauthorization): re-lease through the SAME app query
658
+ * route; only a reply WITH a realtime block (and the SAME sourceKey) re-authorizes — the fresh
659
+ * token is handed to the resolver and the live sub re-subscribes with it BEFORE the room
660
+ * shell's TTL backstop can drop it. A reply without the block is the DOWNGRADE signal: loud;
661
+ * the sub is left to die at `exp` (the graceful downgrade dance is Slice I). */
662
+ const renewRoomQuery = async (key: string, state: RoomQueryState): Promise<void> => {
663
+ if (realtimeClosed || roomQueries.get(key) !== state) return;
664
+ let lease: QueryLeaseWire;
665
+ try {
666
+ lease = await postLease(state.remote);
667
+ } catch (err) {
668
+ anomaly("lease-failed", state.remote, `token renewal failed: ${String((err as Error)?.message ?? err)}`);
669
+ // Retry while the current token is still live; past `exp` the shell has dropped the sub
670
+ // anyway and the next reconnect re-resolution owns recovery.
671
+ if (Date.now() < state.exp && roomQueries.get(key) === state) scheduleRenewal(key, state, RENEW_RETRY_MS);
672
+ return;
673
+ }
674
+ if (realtimeClosed || roomQueries.get(key) !== state) return;
675
+ // I-iii: a renewal re-presents the lifecycle block — re-claim idempotently (a key this query
676
+ // already holds is skipped; a NEW entry, e.g. the fence appearing when the query became
677
+ // room-served mid-life, is retained now). Claimed BEFORE the realtime check on purpose: a
678
+ // downgraded renewal (no realtime block) still carries the doorbell, and the occupancy
679
+ // stream must survive the downgrade (it is what re-upgrades, §4.1).
680
+ claimLifecycle(state.lifecycleClaims, lease.lifecycle, state.remote);
681
+ const rt = lease.realtime;
682
+ if (rt === undefined) {
683
+ // No realtime block: the occupancy gate closed server-side. WITH a §4.2 fence, run the
684
+ // graceful I-v downgrade dance (retarget → demote behind the watermark → re-arm the
685
+ // doorbell); WITHOUT one, stay loud (a pre-I-v server, or `lifecycle.drainRoom`
686
+ // unconfigured — nothing to ghost behind soundly).
687
+ if (lease.realtimeFence !== undefined) {
688
+ // Hand the fresh daemon token so the driving query's daemon re-subscribe presents it (no
689
+ // extra POST); co-tenant queries sharing the room re-lease on their own daemon re-subscribe.
690
+ tokenHandoffs.set(key, {
691
+ target: { leaseToken: lease.leaseToken, ...(lease.wsEndpoint !== undefined ? { wsEndpoint: lease.wsEndpoint } : {}) },
692
+ at: Date.now(),
693
+ });
694
+ downgradeRoom(lease.realtimeFence);
695
+ } else {
696
+ anomaly(
697
+ "downgrade",
698
+ state.remote,
699
+ "the renewal lease carries no realtime block AND no §4.2 downgrade fence — the query is no longer room-served and the server offered nothing to fall back behind (a pre-I-v server, or lifecycle.drainRoom unconfigured); its room sub will lapse at exp",
700
+ );
701
+ }
702
+ return;
703
+ }
704
+ if (rt.sourceKey !== state.sourceKey) {
705
+ anomaly(
706
+ "source-key-changed",
707
+ state.remote,
708
+ `the renewal lease names sourceKey ${JSON.stringify(rt.sourceKey)} but the live sub is on ${JSON.stringify(state.sourceKey)} (teardown + re-register is the §7.6 rare-case follow-up — deferred, no fence for the old room)`,
709
+ );
710
+ return;
711
+ }
712
+ const room = rooms.get(state.sourceKey);
713
+ if (!room) return;
714
+ try {
715
+ // Promotion is per-(sourceKey, table) idempotent, so a renewal compiles only tables that
716
+ // are NEW to the lease (a profile edit mid-life). A compile throw (schema skew) must not
717
+ // kill the renewal — this is a timer-driven void promise, so an escape would be an
718
+ // unhandled rejection — and the live sub keeps its already-promoted tables + the fresh
719
+ // token below; the new table's routing simply never arms (its writes route slow).
720
+ promoteRoomTables(room, rt.tables);
721
+ } catch (err) {
722
+ anomaly("lease-failed", state.remote, `renewal promotion failed: ${String((err as Error)?.message ?? err)} (the room keeps its already-promoted tables)`);
723
+ }
724
+ state.exp = rt.exp;
725
+ if (lease.lifecycle?.doorbell.scope !== undefined) state.doorbellScope = lease.lifecycle.doorbell.scope;
726
+ // Re-present NOW with the fresh token: hand it to the resolver and re-subscribe the live sub
727
+ // (an ordinary epoch bump server-side; the fresh snapshot re-hydrates through the room gate as
728
+ // a net-zero footprint diff).
729
+ tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });
730
+ room.source.registerQuery(state.sourceQid, state.remote);
731
+ scheduleRenewal(key, state);
732
+ };
733
+
734
+ /** The room channel's subscribe resolver: first subscribe consumes the handed fresh token; every
735
+ * RE-resolution (reconnect, gap repair, endpoint recovery) is a full re-lease through the app
736
+ * route — renewal-as-reauthorization, so a revoked/downgraded query cannot silently re-attach. */
737
+ const roomResolver =
738
+ (room: RoomConnection) =>
739
+ async ({ remote }: { queryId: QueryId; remote: RemoteQuery }) => {
740
+ const key = remoteKey(remote);
741
+ const handed = takeHandoff(key);
742
+ if (handed) return handed;
743
+ const lease = await postLease(remote);
744
+ const rt = lease.realtime;
745
+ if (rt === undefined) {
746
+ anomaly(
747
+ "downgrade",
748
+ remote,
749
+ "the re-lease carries no realtime block — the query is no longer room-served (room subscribe aborted; graceful downgrade is Slice I)",
750
+ );
751
+ throw new Error(`realtime downgrade: query "${remote.name}" is no longer room-served`);
752
+ }
753
+ if (rt.sourceKey !== room.sourceKey) {
754
+ anomaly(
755
+ "source-key-changed",
756
+ remote,
757
+ `the re-lease names sourceKey ${JSON.stringify(rt.sourceKey)} but the live sub is on ${JSON.stringify(room.sourceKey)} (teardown + re-register is the §7.6 rare-case follow-up — deferred, no fence for the old room)`,
758
+ );
759
+ throw new Error(`realtime sourceKey changed for query "${remote.name}"`);
760
+ }
761
+ // A re-lease may widen the footprint (new tables) and always refreshes the renewal clock.
762
+ promoteRoomTables(room, rt.tables);
763
+ const state = roomQueries.get(key);
764
+ if (state) {
765
+ state.exp = rt.exp;
766
+ scheduleRenewal(key, state);
767
+ // I-iii: a re-resolution's lifecycle block re-claims like a renewal's (idempotent).
768
+ claimLifecycle(state.lifecycleClaims, lease.lifecycle, state.remote);
769
+ }
770
+ return { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint };
771
+ };
772
+
773
+ /** Connect (once) the room source for a lease's `sourceKey` — multiple labeled queries on the
774
+ * same room share the one source/gate. The endpoint is the lease's DEDICATED
775
+ * `realtime.wsEndpoint`; the TOP-LEVEL `wsEndpoint` (whole-daemon-session migration) never
776
+ * reaches a room transport. NO `pushMutation` override: a room-domain mutation ships over the
777
+ * ROOM socket itself (§7.5 sent-pins-domain — the backend's `channelFor` picks this source). */
778
+ const ensureRoom = (rt: RealtimeLeaseBlock): RoomConnection => {
779
+ const existing = rooms.get(rt.sourceKey);
780
+ if (existing) return existing;
781
+ const room: RoomConnection = {
782
+ sourceKey: rt.sourceKey,
783
+ wsEndpoint: rt.wsEndpoint,
784
+ source: undefined as unknown as RemoteOptimisticSource,
785
+ };
786
+ room.source = new RemoteOptimisticSource(
787
+ { factory: roomTransportFactory, endpoint: rt.wsEndpoint },
788
+ clientID,
789
+ { resolveSubscribe: roomResolver(room) },
790
+ );
791
+ rooms.set(rt.sourceKey, room);
792
+ // connectSource BEFORE any retain on this channel (the backend throws otherwise); it also
793
+ // auto-registers the reserved lmid system query, so the room's confirms fold into
794
+ // `watermark[sourceKey]` from the first frame.
795
+ backend.connectSource(rt.sourceKey, room.source);
796
+ return room;
797
+ };
798
+
799
+ // ---- the §4.1 doorbell reaction + upgrade retarget (Slice I-iv) ------------------------------
800
+ //
801
+ // A labeled query the lease left DAEMON-attached (the api-server's occupancy gate suppressed
802
+ // its room-serve — or the server simply couldn't serve it yet) registers as an UPGRADE
803
+ // CANDIDATE under its doorbell scope. The backend's scope-session fold then reports occupancy
804
+ // per release (`onScopeSessions`); on the 0→≥1 transition of ANOTHER clientID's unexpired
805
+ // session the candidate re-leases ONCE — debounced per (name, args): one in-flight re-lease,
806
+ // repeat doorbells coalesce into it — and a reply that NOW carries a realtime block runs the
807
+ // retarget: ensureRoom → promoteRoomTables → hand the roomToken → `backend.retargetRemoteQuery`
808
+ // (the two-phase no-flicker cutover; see its doc) — mirroring `attachRoom`'s exact order
809
+ // (connect + promote BEFORE any wire sub moves), with the retarget primitive replacing the
810
+ // fresh retain. Failures fail OPEN and LOUD: the daemon retain is untouched (the primitive
811
+ // validates before mutating), the anomaly surfaces, and the NEXT doorbell/renewal is the retry
812
+ // — no retry loop of our own. A reply still without a block is SILENT: suppression is the
813
+ // occupancy gate's designed state, not an anomaly.
814
+
815
+ interface UpgradeViewHook {
816
+ /** Flip this view's client-side bookkeeping onto the room query state (sets `roomKey`,
817
+ * joins the refcount) — a released view declines. */
818
+ adoptRoom(key: string, state: RoomQueryState): void;
819
+ /** The I-v inverse: detach this view's bookkeeping from a dismantled room query state (the
820
+ * downgrade deleted it wholesale — the view must not decrement a dead record on destroy). */
821
+ clearRoom(): void;
822
+ }
823
+ interface UpgradeCandidate {
824
+ remote: RemoteQuery;
825
+ scope: string;
826
+ views: Set<UpgradeViewHook>;
827
+ inFlight: boolean;
828
+ /** Lifecycle system-stream claims this candidate carries between a DOWNGRADE and the next
829
+ * upgrade (I-v): the dismantled room query's renewal-loop claims move here so the fence
830
+ * streams (the ghost drop's watermark input) outlive the room state. Adopted by the next
831
+ * upgrade's fresh {@link RoomQueryState}; released when the candidate dies with its last
832
+ * view. Empty for a fresh (never-downgraded) candidate. */
833
+ claims: Set<string>;
834
+ }
835
+ /** Candidates by remote key — ONE re-lease upgrades every view of the (name, args) at once
836
+ * (the backend moves the sub wholesale). */
837
+ const upgradeCandidates = new Map<string, UpgradeCandidate>();
838
+ /** EVERY live labeled view's hook, by remote key (I-v): the downgrade dance runs from the
839
+ * renewal loop — no view reference in scope — yet must re-register each surviving view as an
840
+ * upgrade candidate (the doorbell re-arms the next upgrade) and clear its room bookkeeping.
841
+ * Registered at materialize, dropped at destroy. */
842
+ const labeledViewHooks = new Map<string, Set<UpgradeViewHook>>();
843
+ /** Last observed other-session count per scope — the 0→≥1 transition tracker. A first
844
+ * observation at ≥1 counts as a transition (there was none before we could see). */
845
+ const lastOthers = new Map<string, number>();
846
+
847
+ const runUpgrade = async (cand: UpgradeCandidate): Promise<void> => {
848
+ let lease: QueryLeaseWire;
849
+ try {
850
+ lease = await postLease(cand.remote);
851
+ } catch (err) {
852
+ anomaly(
853
+ "lease-failed",
854
+ cand.remote,
855
+ `doorbell re-lease failed: ${String((err as Error)?.message ?? err)} (staying daemon-attached; the next doorbell/renewal is the retry)`,
856
+ );
857
+ return;
858
+ }
859
+ if (realtimeClosed || cand.views.size === 0) return; // torn down while the lease was in flight
860
+ const rt = lease.realtime;
861
+ if (rt === undefined) return; // still gated server-side (e.g. its minSessions is higher) — stay daemon-attached, silently
862
+ const key = remoteKey(cand.remote);
863
+ if (roomQueries.has(key)) return; // already room-attached (a racing fresh view won) — nothing to move
864
+ try {
865
+ // The G-v attach order, verbatim, up to the sub move: room source/gate first, engine
866
+ // promotion second (both idempotent — `ensureRoom` per sourceKey, `promoteRoomTables` per
867
+ // (sourceKey, table) via the backend's routing record), THEN the wire cutover with the
868
+ // fresh roomToken handed to the room resolver. `retargetRemoteQuery` is itself idempotent
869
+ // per (query, sourceKey), so a duplicate doorbell that slipped the `inFlight` guard cannot
870
+ // double-attach.
871
+ const room = ensureRoom(rt);
872
+ promoteRoomTables(room, rt.tables);
873
+ tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });
874
+ const sourceQid = backend.retargetRemoteQuery(cand.remote, rt.sourceKey);
875
+ const state: RoomQueryState = {
876
+ remote: cand.remote,
877
+ sourceKey: rt.sourceKey,
878
+ sourceQid,
879
+ refCount: 0,
880
+ exp: rt.exp,
881
+ // ADOPT the candidate's claims (I-v): a re-upgrade after a downgrade inherits the fence
882
+ // streams the ghost still needs, already subscribed — so they are NOT re-subscribed; a
883
+ // fresh candidate's set is empty. `upgradeCandidates.delete(key)` below leaves the set
884
+ // owned by this state.
885
+ lifecycleClaims: cand.claims,
886
+ doorbellScope: cand.scope,
887
+ };
888
+ // The re-lease's lifecycle block now carries the fence bundle (the query is room-served):
889
+ // claim it on the query's renewal-loop set (idempotent — an adopted key is skipped), exactly
890
+ // as a renewal that turned room-served mid-life would (I-iii) — released when the last view
891
+ // drops the query.
892
+ claimLifecycle(state.lifecycleClaims, lease.lifecycle, cand.remote);
893
+ // No awaits since the `views.size` check above — destroys cannot have interleaved, so at
894
+ // least one view adopts (a released one declines via its own flag, defensively).
895
+ for (const view of [...cand.views]) view.adoptRoom(key, state);
896
+ upgradeCandidates.delete(key);
897
+ roomQueries.set(key, state);
898
+ scheduleRenewal(key, state);
899
+ } catch (err) {
900
+ // Fail open: the retarget primitive validates before mutating, so the daemon retain is
901
+ // intact — the query keeps serving from the daemon exactly as before the doorbell.
902
+ tokenHandoffs.delete(key); // never leave a room token where the DAEMON resolver could eat it
903
+ anomaly(
904
+ "lease-failed",
905
+ cand.remote,
906
+ `upgrade retarget failed: ${String((err as Error)?.message ?? err)} (staying daemon-attached; the next doorbell/renewal is the retry)`,
907
+ );
908
+ }
909
+ };
910
+
911
+ /** Kick every idle candidate on `scope` — the doorbell reaction proper. */
912
+ const maybeUpgrade = (scope: string): void => {
913
+ if (realtimeClosed) return;
914
+ for (const cand of upgradeCandidates.values()) {
915
+ if (cand.scope !== scope || cand.inFlight || cand.views.size === 0) continue;
916
+ cand.inFlight = true;
917
+ void runUpgrade(cand).finally(() => {
918
+ cand.inFlight = false;
919
+ });
920
+ }
921
+ };
922
+
923
+ const registerUpgradeCandidate = (remote: RemoteQuery, scope: string, hook: UpgradeViewHook): void => {
924
+ const key = remoteKey(remote);
925
+ let cand = upgradeCandidates.get(key);
926
+ if (!cand) upgradeCandidates.set(key, (cand = { remote, scope, views: new Set(), inFlight: false, claims: new Set() }));
927
+ cand.views.add(hook);
928
+ // Registration-time check: a doorbell that FOLDED before this candidate existed (the lease
929
+ // resolve raced the occupancy delta) must still trigger — same count rule as the events.
930
+ if (backend.otherScopeSessions(scope) >= 1) maybeUpgrade(scope);
931
+ };
932
+
933
+ const dropUpgradeCandidate = (remote: RemoteQuery, hook: UpgradeViewHook): void => {
934
+ const cand = upgradeCandidates.get(remoteKey(remote));
935
+ if (!cand) return;
936
+ cand.views.delete(hook);
937
+ if (cand.views.size === 0) {
938
+ upgradeCandidates.delete(remoteKey(remote));
939
+ // A candidate carrying a downgrade's fence-stream claims (I-v) releases them with its last
940
+ // view — the LAST holder unsubscribes the wire sub (empty set ⇒ no-op for a fresh candidate).
941
+ releaseLifecycle(cand.claims);
942
+ }
943
+ };
944
+
945
+ /** The §4.2 graceful downgrade dance (Slice I-v): a renewal came back with NO realtime block
946
+ * but WITH a fence. Handle the WHOLE room at once — retarget every live sub sharing the source
947
+ * onto the daemon (the I-iv retarget in REVERSE), demote the room source behind the watermark
948
+ * fence (its rows persist as a FROZEN ghost until the daemon plane absorbs the final flush),
949
+ * close the room transport, and re-register each surviving view as an upgrade candidate so the
950
+ * next doorbell re-upgrades the same doc. `demoteRoomSource` refuses to demote while any sub is
951
+ * still on the channel, so all subs must retarget first; a co-tenant query's own later renewal
952
+ * then finds the room gone and no-ops (retarget-to-daemon + demote are both idempotent).
953
+ *
954
+ * Ordering with disconnect: `demoteRoomSource` → `disconnectSource` drops the room gate, which
955
+ * makes the retarget's deferred phase-2 GC (`flushRetargetGc`, run at the daemon's first
956
+ * release) a no-op — it deletes the pending-GC record then finds no old gate to rewind, so the
957
+ * room slice's rows leave ONLY through the ghost's `removeRoomSource` under the fence (never via
958
+ * a GC rewind that would surface a lagging follower's pre-flush images). */
959
+ const downgradeRoom = (fence: RealtimeFenceBlock): void => {
960
+ if (realtimeClosed) return;
961
+ const sourceKey = fence.sourceKey;
962
+ const onRoom = [...roomQueries].filter(([, s]) => s.sourceKey === sourceKey);
963
+ for (const [key, state] of onRoom) {
964
+ backend.retargetRemoteQuery(state.remote, "daemon"); // room → daemon; the no-block reply IS a daemon lease
965
+ if (state.renewTimer !== undefined) clearTimeout(state.renewTimer);
966
+ roomQueries.delete(key);
967
+ const hooks = labeledViewHooks.get(key);
968
+ if (state.doorbellScope !== undefined && hooks !== undefined && hooks.size > 0) {
969
+ let cand = upgradeCandidates.get(key);
970
+ if (!cand) {
971
+ upgradeCandidates.set(key, (cand = { remote: state.remote, scope: state.doorbellScope, views: new Set(), inFlight: false, claims: new Set() }));
972
+ }
973
+ // Carry the renewal-loop's lifecycle claims (the fence streams — the ghost's watermark
974
+ // input, delivered on the DAEMON channel) onto the candidate so they outlive the room
975
+ // state and are adopted by the next upgrade (`runUpgrade`).
976
+ for (const c of state.lifecycleClaims) cand.claims.add(c);
977
+ state.lifecycleClaims.clear();
978
+ for (const h of hooks) {
979
+ h.clearRoom(); // forget the dead room bookkeeping (destroy must not decrement a gone state)
980
+ cand.views.add(h);
981
+ }
982
+ // Self-heal: a collaborator still present at downgrade re-rings immediately. Normally the
983
+ // scope is solo here (that IS why the server downgraded), so this is inert.
984
+ if (backend.otherScopeSessions(state.doorbellScope) >= 1) maybeUpgrade(state.doorbellScope);
985
+ } else {
986
+ // No re-upgrade possible (no doorbell scope, or no surviving view): release the claims.
987
+ if (hooks !== undefined) for (const h of hooks) h.clearRoom();
988
+ releaseLifecycle(state.lifecycleClaims);
989
+ }
990
+ }
991
+ backend.demoteRoomSource(sourceKey, fence.doc, fence.finalFlushSeq); // frozen ghost behind the fence
992
+ const room = rooms.get(sourceKey);
993
+ if (room !== undefined) {
994
+ rooms.delete(sourceKey);
995
+ room.source.close(); // every sub retargeted off it
996
+ }
997
+ };
998
+
999
+ // The trigger: the backend reports (scope, other-session count) after each release that folded
1000
+ // occupancy rows; the 0→≥1 transition rings. `others` never counts our own clientID or expired
1001
+ // rows (the backend's one rule), so a solo tab's own row cannot ring its own bell, and a stale
1002
+ // collaborator aging out then re-appearing rings again (0→1 anew) — which is idempotent here
1003
+ // (an already-room-attached query has no candidate left to kick).
1004
+ backend.onScopeSessions(({ scope, others }) => {
1005
+ const prev = lastOthers.get(scope) ?? 0;
1006
+ lastOthers.set(scope, others);
1007
+ if (prev === 0 && others >= 1) maybeUpgrade(scope);
1008
+ });
1009
+
1010
+ // The I-v stuck-downgrade surface (§7.5): a ghost whose watermark fence cleared but whose sent
1011
+ // room-domain mids never resolved through the daemon-carried folds. The ghost HOLDS (no
1012
+ // timeout-retire is invented) — surface it loudly, naming the mids.
1013
+ backend.onDowngradeStuck(({ sourceKey, doc, mids }) => {
1014
+ anomaly(
1015
+ "downgrade-stuck",
1016
+ { name: sourceKey, args: { doc, mids } },
1017
+ `the downgrade ghost for doc ${JSON.stringify(doc)} is stuck: sent room mids [${mids.join(", ")}] never resolved through the daemon-carried outcome/ledger folds (§7.5 sent-pins-domain — undecidable in general; the ghost holds, investigate the lost outcome frames)`,
1018
+ );
1019
+ });
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
+ );
156
1031
  });
157
1032
 
1033
+ // The split-register ticket: set (synchronously) by the wrapped `materialize` just before it
1034
+ // delegates, consumed by the shadowed `backend.registerQuery` below — which registers the LOCAL
1035
+ // half only (the Store's SSR-seed + `unknown` pre-marking has already run) and defers the remote
1036
+ // retain to the lease resolution. Everything else (unlabeled queries, React retains, re-registers)
1037
+ // flows through untouched.
1038
+ let labeledTicket: { consumed: boolean } | null = null;
1039
+ const origRegisterQuery = backend.registerQuery.bind(backend);
1040
+ backend.registerQuery = (qid: QueryId, ast: Ast, remote?: RemoteQuery, channel?: string): void => {
1041
+ if (labeledTicket === null || remote === undefined) {
1042
+ origRegisterQuery(qid, ast, remote, channel);
1043
+ return;
1044
+ }
1045
+ const ticket = labeledTicket;
1046
+ labeledTicket = null;
1047
+ ticket.consumed = true;
1048
+ // The LOCAL half of the split retain (the backend's documented split-retain shape): the remote
1049
+ // attaches via `retainRemoteQuery` on the channel the lease names, once it answers.
1050
+ origRegisterQuery(qid, ast, undefined);
1051
+ };
1052
+
1053
+ const origMaterialize = store.materialize.bind(store) as (
1054
+ query: Query<any, any, any>,
1055
+ mOpts?: unknown,
1056
+ ) => MaterializedViewLike;
1057
+
1058
+ /** The G-v labeled-materialize: synchronous local view now, remote retain when the lease answers. */
1059
+ const materializeLabeled = (query: Query<any, any, any> & { name: string }, mOpts?: unknown): MaterializedViewLike => {
1060
+ const remote: RemoteQuery = { name: query.name, args: query.args };
1061
+ const ast = query.ast() as Ast;
1062
+ // E3 parity (201-LOCAL-ONLY-TABLES-DESIGN.md): the unlabeled remote path rejects a remote query
1063
+ // naming a local-only table synchronously inside materialize; the labeled path defers the
1064
+ // remote register past the lease, so run the SAME guard here — identical throw, identical
1065
+ // timing, no view leaked.
1066
+ for (const t of collectAstTables(ast)) {
1067
+ if (localTables.has(t)) {
1068
+ throw new Error(
1069
+ `remote query "${remote.name}" references local-only table "${t}" — local tables never cross the wire (201-LOCAL-ONLY-TABLES-DESIGN.md E3).`,
1070
+ );
1071
+ }
1072
+ }
1073
+ const ticket = { consumed: false };
1074
+ labeledTicket = ticket;
1075
+ let view: MaterializedViewLike;
1076
+ try {
1077
+ view = origMaterialize(query, mOpts);
1078
+ } finally {
1079
+ labeledTicket = null;
1080
+ }
1081
+ const localQid = view.qid;
1082
+ // The Store pre-marked the view `unknown` (a remote-identity register under a lifecycle
1083
+ // backend), but the local-half register flipped it back to `complete` (a local-only
1084
+ // registration is synchronously authoritative). Re-flip for the lease window so the view never
1085
+ // reads server-authoritative before ANY authority answered — the retain below recomputes it
1086
+ // against real hydration. (`readOnce` on a labeled query correctly waits because of this.)
1087
+ if (ticket.consumed) flipResultTypeUnknown(view);
1088
+
1089
+ let released = false;
1090
+ let retainQid: QueryId | undefined;
1091
+ let roomKey: string | undefined;
1092
+ // I-iii: the lifecycle system-stream claims THIS VIEW holds (claimed once per key when its
1093
+ // lease resolves; released with the view — the LAST holder of a scope/doc drops the sub).
1094
+ const viewLifecycleClaims = new Set<string>();
1095
+ // I-iv/I-v: this view's hook — `adoptRoom` (an upgrade joins the view to the new room state)
1096
+ // and `clearRoom` (the downgrade dismantled the room state wholesale — forget it so destroy
1097
+ // never decrements a dead record). Registered in `labeledViewHooks` for EVERY labeled view so
1098
+ // the downgrade dance (which runs from the renewal loop, no view in scope) can find and
1099
+ // re-candidate each surviving view; used as the candidate hook for the daemon-attached shape.
1100
+ const viewHook: UpgradeViewHook = {
1101
+ adoptRoom: (key: string, state: RoomQueryState): void => {
1102
+ if (released) return; // a released view never joins (its retain is already gone)
1103
+ roomKey = key;
1104
+ state.refCount++;
1105
+ },
1106
+ clearRoom: (): void => {
1107
+ roomKey = undefined;
1108
+ },
1109
+ };
1110
+ const hookKey = remoteKey(remote);
1111
+ let viewHooks = labeledViewHooks.get(hookKey);
1112
+ if (viewHooks === undefined) labeledViewHooks.set(hookKey, (viewHooks = new Set()));
1113
+ viewHooks.add(viewHook);
1114
+
1115
+ /** Fail-open: retain on the daemon, indistinguishable from an unlabeled query. The ONE lease
1116
+ * already resolved (when it succeeded) is handed to the daemon resolver so the subscribe
1117
+ * presents exactly that token — one POST per subscribe, the unlabeled cadence. */
1118
+ const attachDaemon = (target?: { leaseToken: string; wsEndpoint?: string }): void => {
1119
+ if (target) tokenHandoffs.set(remoteKey(remote), { target, at: Date.now() });
1120
+ retainQid = nextRetainQid++;
1121
+ backend.retainRemoteQuery(retainQid, remote, localQid, ast);
1122
+ };
1123
+
1124
+ /** Room-served: ensure the shared room source/gate, promote the engine per the lease's table
1125
+ * specs BEFORE retaining, then retain the sub on the room channel with the roomToken handed
1126
+ * to the resolver. `doorbellScope` (from the lease's lifecycle block) is pinned on the room
1127
+ * state so a later I-v downgrade can re-candidate this query. */
1128
+ const attachRoom = (rt: RealtimeLeaseBlock, doorbellScope?: string): void => {
1129
+ const key = remoteKey(remote);
1130
+ const existing = roomQueries.get(key);
1131
+ if (existing && existing.sourceKey !== rt.sourceKey) {
1132
+ anomaly(
1133
+ "source-key-changed",
1134
+ remote,
1135
+ `this lease names sourceKey ${JSON.stringify(rt.sourceKey)} but the live sub is on ${JSON.stringify(existing.sourceKey)} (teardown + re-register is the §7.6 rare-case follow-up, deferred; this view stays local-only)`,
1136
+ );
1137
+ return;
1138
+ }
1139
+ const room = ensureRoom(rt);
1140
+ promoteRoomTables(room, rt.tables);
1141
+ // Only the retain that CREATES the wire sub consumes a token at subscribe time — hand one
1142
+ // exactly then (a refcount-only retain issues no wire subscribe; the age cap covers races).
1143
+ if (!existing) {
1144
+ tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });
1145
+ }
1146
+ retainQid = nextRetainQid++;
1147
+ backend.retainRemoteQuery(retainQid, remote, localQid, ast, rt.sourceKey);
1148
+ let state = existing;
1149
+ if (!state) {
1150
+ state = { remote, sourceKey: rt.sourceKey, sourceQid: retainQid, refCount: 0, exp: rt.exp, lifecycleClaims: new Set() };
1151
+ roomQueries.set(key, state);
1152
+ } else {
1153
+ state.exp = Math.max(state.exp, rt.exp);
1154
+ }
1155
+ if (doorbellScope !== undefined) state.doorbellScope = doorbellScope;
1156
+ state.refCount++;
1157
+ roomKey = key;
1158
+ scheduleRenewal(key, state);
1159
+ };
1160
+
1161
+ // Resolve-then-register: the lease FIRST; the register follows its verdict.
1162
+ void (async () => {
1163
+ let lease: QueryLeaseWire;
1164
+ try {
1165
+ lease = await postLease(remote);
1166
+ } catch (err) {
1167
+ // The lease POST itself failed: fail OPEN to the daemon with no handoff — the daemon
1168
+ // retain's own resolver re-leases (and the transport's resync retries), exactly an
1169
+ // unlabeled query's recovery story.
1170
+ anomaly("lease-failed", remote, `query lease failed: ${String((err as Error)?.message ?? err)}`);
1171
+ if (!released && !realtimeClosed) attachDaemon();
1172
+ return;
1173
+ }
1174
+ if (released || realtimeClosed) return;
1175
+ try {
1176
+ if (lease.realtime === undefined) {
1177
+ attachDaemon({ leaseToken: lease.leaseToken, wsEndpoint: lease.wsEndpoint });
1178
+ // I-iv: a daemon-attached labeled view under a doorbell scope is an UPGRADE CANDIDATE —
1179
+ // the occupancy stream's 0→≥1 transition re-leases it and (block permitting) retargets
1180
+ // the whole (name, args) sub onto the room. A blockless lease (pre-lifecycle server)
1181
+ // registers nothing: the plane stays inert-until-fed.
1182
+ const doorbellScope = lease.lifecycle?.doorbell.scope;
1183
+ if (doorbellScope !== undefined) registerUpgradeCandidate(remote, doorbellScope, viewHook);
1184
+ } else {
1185
+ attachRoom(lease.realtime, lease.lifecycle?.doorbell.scope);
1186
+ }
1187
+ // I-iii: retain the lease's lifecycle system streams on the DAEMON channel — for the
1188
+ // room-served AND the daemon-served (labeled, not covered) shapes alike (the doorbell
1189
+ // rides both; the fence only where a room block exists). Absent block ⇒ no-op — a
1190
+ // pre-lifecycle server leaves this client byte-identical.
1191
+ claimLifecycle(viewLifecycleClaims, lease.lifecycle, remote);
1192
+ } catch (err) {
1193
+ // Fail OPEN, exactly like a lease without a block: a room-attach throw (most plausibly a
1194
+ // lease `where` this bundle's schema cannot compile — version skew) must not strand the
1195
+ // view local-only. `retainQid === undefined` ⇒ no retain was established (room OR daemon),
1196
+ // so the daemon fallback cannot double-attach; a throw AFTER a successful retain (a
1197
+ // lifecycle claim, say) leaves the live sub alone. Partial promotion is harmless (it is
1198
+ // idempotent, and the room gate re-proves any routed write) — but never leave the room
1199
+ // token where the daemon resolver could eat it.
1200
+ anomaly("lease-failed", remote, `realtime attach failed: ${String((err as Error)?.message ?? err)} (falling back to the daemon lease)`);
1201
+ if (!released && !realtimeClosed && retainQid === undefined) {
1202
+ tokenHandoffs.delete(remoteKey(remote));
1203
+ try {
1204
+ attachDaemon({ leaseToken: lease.leaseToken, wsEndpoint: lease.wsEndpoint });
1205
+ } catch (fallbackErr) {
1206
+ anomaly("lease-failed", remote, `daemon fallback failed: ${String((fallbackErr as Error)?.message ?? fallbackErr)}`);
1207
+ }
1208
+ }
1209
+ }
1210
+ })();
1211
+
1212
+ // Teardown rides the view: release the remote retain (room or daemon) with the local view, and
1213
+ // drop the room query's refcount/renewal when the last view goes.
1214
+ const origDestroy = view.destroy.bind(view);
1215
+ view.destroy = () => {
1216
+ if (!released) {
1217
+ released = true;
1218
+ if (retainQid !== undefined) backend.releaseRemoteQuery(retainQid);
1219
+ // I-iv/I-v: drop this view's hook — from the per-key hook registry and the candidate set
1220
+ // (both no-ops when it was never a candidate; the candidate's own last-view release frees
1221
+ // any fence-stream claims a downgrade parked on it).
1222
+ viewHooks.delete(viewHook);
1223
+ if (viewHooks.size === 0) labeledViewHooks.delete(hookKey);
1224
+ dropUpgradeCandidate(remote, viewHook);
1225
+ // I-iii: this view's lifecycle claims drop with it; the LAST holder of a key releases
1226
+ // the system sub (the backend's folded fence/occupancy state deliberately survives).
1227
+ releaseLifecycle(viewLifecycleClaims);
1228
+ if (roomKey !== undefined) {
1229
+ const state = roomQueries.get(roomKey);
1230
+ if (state && --state.refCount <= 0) {
1231
+ if (state.renewTimer !== undefined) clearTimeout(state.renewTimer);
1232
+ roomQueries.delete(roomKey);
1233
+ // …including any claims the renewal loop made on this query's behalf.
1234
+ releaseLifecycle(state.lifecycleClaims);
1235
+ }
1236
+ }
1237
+ }
1238
+ origDestroy();
1239
+ };
1240
+ return view;
1241
+ };
1242
+
1243
+ store.materialize = ((query: Query<any, any, any>, mOpts?: unknown) => {
1244
+ const label = (query as { realtime?: RealtimeQueryLabel }).realtime;
1245
+ if (label === undefined || typeof query.name !== "string") return origMaterialize(query, mOpts);
1246
+ return materializeLabeled(query as Query<any, any, any> & { name: string }, mOpts);
1247
+ }) as Store<S>["materialize"];
1248
+
158
1249
  // Local-table persistence (207 §5.2): attach immediately after the store exists — before any
159
1250
  // app write can reach `writeLocal` — and AWAIT the initial restore (one `getAll` over small
160
1251
  // tables) so the first render never flashes empty local state. Restored rows arrive as ordinary
@@ -195,11 +1286,146 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
195
1286
  target.removeEventListener?.("pagehide", onPageHide);
196
1287
  target.removeEventListener?.("beforeunload", onPageHide);
197
1288
  persistence?.close(); // releases leadership + the channel + the IDB handle (207 P10)
1289
+ // Realtime teardown: renewal timers first (no renewal may fire into a closing client), then
1290
+ // every room socket; in-flight lease resolutions are made inert via the flag.
1291
+ realtimeClosed = true;
1292
+ upgradeCandidates.clear(); // no doorbell may retarget into a closing client
1293
+ for (const state of roomQueries.values()) {
1294
+ if (state.renewTimer !== undefined) clearTimeout(state.renewTimer);
1295
+ }
1296
+ roomQueries.clear();
1297
+ for (const room of rooms.values()) room.source.close();
1298
+ rooms.clear();
198
1299
  source.close();
199
1300
  },
1301
+ __realtimeInspect: (): RealtimeInspect => ({
1302
+ rooms: Object.fromEntries(
1303
+ [...rooms].map(([sourceKey, room]) => [
1304
+ sourceKey,
1305
+ {
1306
+ wsEndpoint: room.wsEndpoint,
1307
+ // Read back from the backend's room-table registry (302 §2): wire → engine table.
1308
+ promoted: Object.fromEntries(backend.roomTablesFor(sourceKey)),
1309
+ queries: Object.fromEntries(
1310
+ [...roomQueries]
1311
+ .filter(([, s]) => s.sourceKey === sourceKey)
1312
+ .map(([key, s]) => [
1313
+ key,
1314
+ { name: s.remote.name, sourceQid: s.sourceQid, exp: s.exp, refCount: s.refCount },
1315
+ ]),
1316
+ ),
1317
+ },
1318
+ ]),
1319
+ ),
1320
+ }),
200
1321
  };
201
1322
  }
202
1323
 
1324
+ // --------------------------------------------------------------------------- realtime helpers
1325
+
1326
+ /** The minimal view surface the labeled-materialize wrapper needs (structural — the real return
1327
+ * type flows through unchanged). */
1328
+ interface MaterializedViewLike {
1329
+ readonly qid: QueryId;
1330
+ destroy(): void;
1331
+ }
1332
+
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
+ };
1347
+ }
1348
+
1349
+ /** Flip a just-materialized labeled view back to `unknown` for the lease-resolve window. The
1350
+ * plural `FlatArrayView` exposes `setResultType`; a `.one()` query's `SingularView` wrapper hides
1351
+ * it behind its (runtime-visible) `inner` — reach through. Best-effort by design: the deferred
1352
+ * retain recomputes the lifecycle authoritatively the moment it attaches, and the Store keeps
1353
+ * routing backend transitions to the SAME underlying view either way. */
1354
+ function flipResultTypeUnknown(view: unknown): void {
1355
+ const v = view as {
1356
+ setResultType?: (rt: "unknown") => void;
1357
+ inner?: { setResultType?: (rt: "unknown") => void };
1358
+ };
1359
+ if (typeof v.setResultType === "function") v.setResultType("unknown");
1360
+ else if (typeof v.inner?.setResultType === "function") v.inner.setResultType("unknown");
1361
+ }
1362
+
1363
+ /** Every base table an AST tree can draw from (root, related subtrees, EXISTS children) — the E3
1364
+ * guard's input. Mirrors the backend's own `collectTables`. */
1365
+ function collectAstTables(ast: Ast, out = new Set<string>()): Set<string> {
1366
+ out.add(ast.table);
1367
+ for (const rel of ast.related ?? []) collectAstTables(rel.subquery, out);
1368
+ collectConditionTables(ast.where, out);
1369
+ collectConditionTables(ast.having, out);
1370
+ return out;
1371
+ }
1372
+
1373
+ function collectConditionTables(cond: Condition | undefined, out: Set<string>): void {
1374
+ if (cond === undefined) return;
1375
+ if (cond.type === "and" || cond.type === "or") {
1376
+ for (const c of cond.conditions) collectConditionTables(c, out);
1377
+ } else if (cond.type === "correlatedSubquery") {
1378
+ collectAstTables(cond.related.subquery, out);
1379
+ }
1380
+ }
1381
+
1382
+ /** The (name, args) sub identity — key-order-stable, mirroring the backend's own `remoteKey` so
1383
+ * the client-side room bookkeeping groups retains exactly as the backend dedups subs. */
1384
+ function remoteKey(remote: RemoteQuery): string {
1385
+ return stableJson([remote.name, remote.args]);
1386
+ }
1387
+
1388
+ // ---- lifecycle system-sub helpers (Slice I-iii) ----
1389
+
1390
+ /** A system sub's wire `args` (under the reserved `_rindle/lifecycle` name): the lease entry's
1391
+ * identity fields plus the PARENT labeled query, so a re-resolution can re-lease the parent and
1392
+ * re-find the entry (`resolveLifecycleTarget`). */
1393
+ interface LifecycleRemoteArgs {
1394
+ table: SystemStreamTable;
1395
+ scope?: string;
1396
+ doc?: string;
1397
+ clientId?: string;
1398
+ parent: { name: string; args: unknown };
1399
+ }
1400
+
1401
+ const SYSTEM_TABLES: ReadonlySet<string> = new Set([
1402
+ SCOPE_SESSIONS_TABLE,
1403
+ ROOM_WATERMARK_TABLE,
1404
+ ROOM_CLIENT_MUTATIONS_TABLE,
1405
+ ROOM_MUTATION_OUTCOMES_TABLE,
1406
+ ]);
1407
+
1408
+ function isSystemTable(table: string): table is SystemStreamTable {
1409
+ return SYSTEM_TABLES.has(table);
1410
+ }
1411
+
1412
+ /** The idempotence key a lifecycle retain is claimed under: the minted predicate's full identity
1413
+ * (table + scope/doc/clientId) — two labeled queries on one scope share ONE doorbell sub; two
1414
+ * docs' fences never alias. */
1415
+ function systemEntryKey(entry: { table: string; scope?: string; doc?: string; clientId?: string }): string {
1416
+ return stableJson([entry.table, entry.scope ?? null, entry.doc ?? null, entry.clientId ?? null]);
1417
+ }
1418
+
1419
+ function stableJson(value: unknown): string {
1420
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
1421
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
1422
+ const obj = value as Record<string, unknown>;
1423
+ return `{${Object.keys(obj)
1424
+ .sort()
1425
+ .map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`)
1426
+ .join(",")}}`;
1427
+ }
1428
+
203
1429
  export type { ClientRegistry, MutationTx };
204
1430
  export type { MutateFn } from "./index.ts";
205
1431