@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/dist/client.js CHANGED
@@ -8,14 +8,34 @@
8
8
  // the ws transport to the daemon, lease resolution through the API server's query route,
9
9
  // and the mutation queue flushing confirmed in-order batches through the mutate route
10
10
  // (rejection reasons surface via `onRejected`).
11
- import { RemoteOptimisticSource, WsTransport, createQueuedMutationSender, } from "@rindle/remote";
11
+ import { localTableNames } from "@rindle/client";
12
+ import { RemoteOptimisticSource, WsTransport, createAffinityTicketStore, createQueuedMutationSender, offerSubprotocols, } from "@rindle/remote";
12
13
  import { initWasm } from "@rindle/wasm";
13
- import { resetStableClientID, stableClientID } from "./client-id.js";
14
+ import { resetStableClientID, sessionTicketPersistence, stableClientID } from "./client-id.js";
15
+ import { LIFECYCLE_QUERY_NAME, ROOM_CLIENT_MUTATIONS_TABLE, ROOM_MUTATION_OUTCOMES_TABLE, ROOM_WATERMARK_TABLE, SCOPE_SESSIONS_TABLE, } from "./system-streams.js";
14
16
  import { createOptimisticStore } from "./index.js";
15
17
  import { attachLocalPersistence } from "./local-persist.js";
16
18
  /** Must mirror `DEFAULT_RINDLE_API_ROUTES` in `@rindle/api-server` (the app-wire contract;
17
19
  * duplicated so the browser bundle doesn't import the server package). */
18
20
  const DEFAULT_ROUTES = { query: "/api/rindle/query", mutate: "/api/rindle/mutate" };
21
+ /** Default {@link RealtimeClientOptions.renewMarginMs}. */
22
+ const DEFAULT_RENEW_MARGIN_MS = 30_000;
23
+ /** Renewal-delay floor: a nearly-expired lease still renews soon, but never in a hot loop. */
24
+ const MIN_RENEW_DELAY_MS = 1_000;
25
+ /** Retry delay after a failed renewal POST (only while the current token is still live). */
26
+ const RENEW_RETRY_MS = 5_000;
27
+ /** A one-shot token handoff not consumed within this window is stale — the resolver falls through
28
+ * to a fresh lease POST instead of presenting a token the server may already refuse. */
29
+ const HANDOFF_MAX_AGE_MS = 15_000;
30
+ /** How long a fresh (ticketless) connect waits for its affinity mint frame before leasing
31
+ * TICKETLESS + warning (FOLLOWER-AFFINITY-DESIGN.md §4.1). Generous — the frame normally arrives in
32
+ * well under one RTT; this bound only trips on an affinity-off daemon (misconfig / rolling upgrade),
33
+ * so it degrades loudly instead of hanging. */
34
+ const AFFINITY_TICKET_TIMEOUT_MS = 4_000;
35
+ /** Client-minted remote-retain qids live in their own high band so they can never collide with the
36
+ * Store's own 1, 2, 3, … view qids or the reserved per-channel lmid qid 0. Exact in f64 (the wire
37
+ * number type), far below 2^53. */
38
+ const REALTIME_RETAIN_QID_BASE = 2 ** 30;
19
39
  export async function createRindleClient(opts) {
20
40
  await initWasm();
21
41
  const clientID = opts.clientID ?? stableClientID();
@@ -34,16 +54,108 @@ export async function createRindleClient(opts) {
34
54
  throw new RindleApiHttpError(path, res.status, text);
35
55
  return text ? JSON.parse(text) : undefined;
36
56
  };
57
+ // FOLLOWER-AFFINITY mode (design §2): opt-in, and only for a fleet `wsUrl` connection (a pre-built
58
+ // transport has no fleet edge to route through). The store is shared by the ws transport (offers
59
+ // the ticket as a subprotocol), the source (records the follower's minted ticket / clears it on a
60
+ // dead follower), and the lease POST below (forwards it). Persisted per-TAB (sessionStorage) so two
61
+ // tabs can pin two regions (design §13) yet a reload lands back on the same follower.
62
+ const affinityOn = !("transport" in opts.daemon) && opts.daemon.affinity === true;
63
+ const affinityStore = affinityOn
64
+ ? createAffinityTicketStore(sessionTicketPersistence())
65
+ : undefined;
66
+ // The ONE app-lease POST both legs share. Sends the stable `clientId` so the api-server/router
67
+ // can use it as the anonymous routing key (READ-ROUTER-DESIGN.md §2.2); the reply's top-level
68
+ // fields are the daemon lease, and a room-served labeled query ADDITIONALLY carries `realtime`.
69
+ // In affinity mode it ALSO forwards the placement `affinity` ticket — awaited first so a fresh
70
+ // (ticketless) connect leases only AFTER its mint frame arrives, co-locating both legs (§4.1). On
71
+ // reconnect, the WebSocket may offer the persisted ticket on its handshake, but the lease waits
72
+ // for that connection's fresh mint frame. The wait is BOUNDED: if no mint frame arrives (the daemon is
73
+ // affinity-off — a misconfig, or mid rolling-upgrade), we lease
74
+ // TICKETLESS and warn rather than hang. That first timeout LATCHES ticketless mode in the store:
75
+ // later leases return immediately (and do not accumulate abandoned waiters) until an affinity
76
+ // frame actually arrives. A persisted ticket remains useful for the ws handshake but is never
77
+ // forwarded on a lease until the CURRENT connection refreshes it, preventing a restored stale
78
+ // ticket from independently re-pinning the HTTP and ws legs.
79
+ let affinityFallbackWarned = false;
80
+ const postLease = async (remote) => {
81
+ let affinity;
82
+ if (affinityStore) {
83
+ const ticket = await affinityStore.leaseTicket(AFFINITY_TICKET_TIMEOUT_MS);
84
+ affinity = ticket.ticket;
85
+ if (affinity !== undefined)
86
+ affinityFallbackWarned = false;
87
+ if (ticket.timedOut && !affinityFallbackWarned) {
88
+ affinityFallbackWarned = true;
89
+ console.warn(`[rindle] no affinity ticket after ${AFFINITY_TICKET_TIMEOUT_MS}ms — leasing ticketless ` +
90
+ "(is the daemon affinity-enabled / RINDLE_AFFINITY_KEY set?)");
91
+ }
92
+ }
93
+ return post(routes.query, {
94
+ name: remote.name,
95
+ args: remote.args,
96
+ clientId: clientID,
97
+ ...(affinity !== undefined ? { affinity } : {}),
98
+ });
99
+ };
100
+ // One-shot fresh-token handoffs, by remote key: G-v's resolve-then-register (and the proactive
101
+ // renewal) has ALREADY leased when the subscribe fires, so the resolver consumes the handed
102
+ // token instead of POSTing a second time — one lease per subscribe, exactly the unlabeled
103
+ // cadence. Age-capped: an entry no subscribe consumed (e.g. a refcount-only retain) must not
104
+ // serve a stale token to a much-later re-subscribe (which re-leases fresh instead).
105
+ const tokenHandoffs = new Map();
106
+ const takeHandoff = (key) => {
107
+ const handed = tokenHandoffs.get(key);
108
+ if (!handed)
109
+ return undefined;
110
+ tokenHandoffs.delete(key);
111
+ return Date.now() - handed.at <= HANDOFF_MAX_AGE_MS ? handed.target : undefined;
112
+ };
113
+ /** RE-resolve a SYSTEM (lifecycle) subscription (Slice I-iii — a reconnect / gap repair /
114
+ * overflow re-subscribe whose mint-time handoff is long consumed). `_rindle/lifecycle` is a
115
+ * reserved CLIENT-side name (like the lmid query's): the api-server cannot lease it by name,
116
+ * so the re-resolution re-leases the PARENT labeled query — renewal-as-reauthorization, the
117
+ * room-token precedent — and picks the matching entry out of the fresh `lifecycle` block. A
118
+ * reply without the entry throws: the server no longer minting this stream (config off, label
119
+ * dropped) must not silently re-attach — the source logs the failed subscribe, and I-iv/I-v
120
+ * own any reaction. */
121
+ const resolveLifecycleTarget = async (args) => {
122
+ const out = (await postLease({ name: args.parent.name, args: args.parent.args }));
123
+ const want = systemEntryKey(args);
124
+ const entry = out.lifecycle === undefined
125
+ ? undefined
126
+ : [out.lifecycle.doorbell, ...(out.lifecycle.fence ?? [])].find((e) => systemEntryKey(e) === want);
127
+ if (entry === undefined) {
128
+ throw new Error(`lifecycle re-lease of "${args.parent.name}" no longer carries the ${args.table} system lease`);
129
+ }
130
+ return { leaseToken: entry.leaseToken, ...(entry.wsEndpoint !== undefined ? { wsEndpoint: entry.wsEndpoint } : {}) };
131
+ };
37
132
  // Reads-leg connection: a fixed transport (tests/in-process) or a replaceable connection built
38
133
  // from `wsUrl` (eager when present, lazy when omitted). A routed lease's `wsEndpoint` migrates it.
134
+ // In affinity mode each transport offers the current ticket as a subprotocol (evaluated per
135
+ // connect, so a reconnect presents the freshest — or freshly cleared — ticket).
39
136
  const connection = "transport" in opts.daemon
40
137
  ? { transport: opts.daemon.transport }
41
- : { factory: (endpoint) => new WsTransport(endpoint), endpoint: opts.daemon.wsUrl };
138
+ : {
139
+ factory: (endpoint) => new WsTransport(endpoint, affinityStore ? { subprotocols: () => offerSubprotocols(affinityStore) } : {}),
140
+ endpoint: opts.daemon.wsUrl,
141
+ };
42
142
  const source = new RemoteOptimisticSource(connection, clientID, {
143
+ ...(affinityStore ? { affinity: affinityStore } : {}),
43
144
  resolveSubscribe: async ({ remote }) => {
44
- // Send the stable `clientId` so the api-server/router can use it as the anonymous routing key
45
- // (READ-ROUTER-DESIGN.md §2.2); read back the follower's `wsEndpoint` for placement.
46
- const out = (await post(routes.query, { name: remote.name, args: remote.args, clientId: clientID }));
145
+ // A fail-open labeled register already leased present exactly that token (see
146
+ // `tokenHandoffs`); otherwise lease now. Read back the follower's `wsEndpoint` for placement.
147
+ // A `realtime` block on a RE-resolution is deliberately IGNORED here even now that the
148
+ // upgrade dance exists (I-iv): retargeting from inside a reconnect's resolve would race the
149
+ // very re-subscribe it resolves. The §4.1 DOORBELL path owns upgrades (`runUpgrade` below)
150
+ // — the occupancy row that made this lease carry a block will (re)ring it.
151
+ const handed = takeHandoff(remoteKey(remote));
152
+ if (handed)
153
+ return handed;
154
+ // A SYSTEM (lifecycle) sub re-resolves through its PARENT labeled query (I-iii) — the
155
+ // reserved name is never leaseable by itself (see resolveLifecycleTarget).
156
+ if (remote.name === LIFECYCLE_QUERY_NAME)
157
+ return resolveLifecycleTarget(remote.args);
158
+ const out = await postLease(remote);
47
159
  return { leaseToken: out.leaseToken, wsEndpoint: out.wsEndpoint };
48
160
  },
49
161
  pushMutation: createQueuedMutationSender({
@@ -67,6 +179,669 @@ export async function createRindleClient(opts) {
67
179
  const { store, backend, mutate } = createOptimisticStore(opts.schema, source, opts.mutators, {
68
180
  clientID,
69
181
  user: opts.user,
182
+ // The DECLARED router (302 §5): an explicit `domainPolicy` wins; otherwise the app's declared
183
+ // realtime mutators route to the one attached room (`rooms` is read lazily at invoke time —
184
+ // it is declared below, after this construction).
185
+ ...(opts.domainPolicy
186
+ ? { domainPolicy: opts.domainPolicy }
187
+ : opts.realtime?.mutators !== undefined
188
+ ? { domainPolicy: declaredMutatorPolicy(new Set(opts.realtime.mutators), () => rooms) }
189
+ : {}),
190
+ // Room-plane rejection parity (H-v): a room's `mutationOutcome {kind:"rejected"}` frame
191
+ // surfaces through the SAME callback the HTTP mutate path uses below — one app-level
192
+ // rejection surface, whichever authority said no.
193
+ ...(opts.onRejected ? { onRejected: opts.onRejected } : {}),
194
+ });
195
+ // ---- Rindle Realtime (G-v): resolve-then-register for LABELED queries -------------------------
196
+ //
197
+ // `store.materialize` is wrapped: an UNLABELED query takes the original path byte-identically; a
198
+ // query stamped with a `realtime` label materializes its local view synchronously (the Store's
199
+ // ordinary seed/`unknown` pre-marking runs untouched) while the remote register is SPLIT — the
200
+ // shadowed `backend.registerQuery` below registers the LOCAL half only, and the remote retain
201
+ // attaches when the lease answers: on `realtime.sourceKey`'s room channel when the lease carries
202
+ // a realtime block, on the daemon (fail-open, indistinguishable from unlabeled) when it doesn't.
203
+ const realtimeOpts = opts.realtime ?? {};
204
+ const roomTransportFactory = realtimeOpts.transport ?? ((endpoint) => new WsTransport(endpoint));
205
+ const renewMarginMs = realtimeOpts.renewMarginMs ?? DEFAULT_RENEW_MARGIN_MS;
206
+ const localTables = localTableNames(opts.schema);
207
+ let realtimeClosed = false;
208
+ const anomaly = (kind, remote, message) => {
209
+ // LOUD by contract: every anomaly hits the console even with a handler installed.
210
+ console.error(`[rindle] realtime ${kind} for query "${remote.name}": ${message}`);
211
+ try {
212
+ realtimeOpts.onAnomaly?.({ kind, name: remote.name, args: remote.args, message });
213
+ }
214
+ catch (err) {
215
+ console.error("[rindle] realtime onAnomaly handler threw:", err);
216
+ }
217
+ };
218
+ const rooms = new Map();
219
+ const roomQueries = new Map();
220
+ let nextRetainQid = REALTIME_RETAIN_QID_BASE;
221
+ const systemSubs = new Map();
222
+ /** Claim every entry of `block` for one holder (`claims` — the holder's own claim set; a key
223
+ * already claimed by THIS holder is skipped, so renewal re-presentations are idempotent).
224
+ * Unknown tables are skipped (forward-compat: a newer server minting a fifth stream must not
225
+ * break this client). */
226
+ const claimLifecycle = (claims, block, parent) => {
227
+ if (block === undefined || realtimeClosed)
228
+ return;
229
+ for (const entry of [block.doorbell, ...(block.fence ?? [])]) {
230
+ if (!isSystemTable(entry.table))
231
+ continue;
232
+ const key = systemEntryKey(entry);
233
+ if (claims.has(key))
234
+ continue;
235
+ let live = systemSubs.get(key);
236
+ if (!live) {
237
+ // The sub's wire identity embeds the PARENT labeled query so a RE-resolution can
238
+ // re-lease it (resolveLifecycleTarget); the minted token is handed to the resolver so
239
+ // the first subscribe presents exactly it — one lease per subscribe, the G-v cadence.
240
+ const remote = {
241
+ name: LIFECYCLE_QUERY_NAME,
242
+ args: {
243
+ table: entry.table,
244
+ ...(entry.scope !== undefined ? { scope: entry.scope } : {}),
245
+ ...(entry.doc !== undefined ? { doc: entry.doc } : {}),
246
+ ...(entry.clientId !== undefined ? { clientId: entry.clientId } : {}),
247
+ parent: { name: parent.name, args: parent.args },
248
+ },
249
+ };
250
+ tokenHandoffs.set(remoteKey(remote), {
251
+ target: { leaseToken: entry.leaseToken, ...(entry.wsEndpoint !== undefined ? { wsEndpoint: entry.wsEndpoint } : {}) },
252
+ at: Date.now(),
253
+ });
254
+ const retainQid = nextRetainQid++;
255
+ backend.retainSystemQuery(retainQid, remote, {
256
+ table: entry.table,
257
+ ...(entry.scope !== undefined ? { scope: entry.scope } : {}),
258
+ ...(entry.doc !== undefined ? { doc: entry.doc } : {}),
259
+ });
260
+ live = { retainQid, refCount: 0 };
261
+ systemSubs.set(key, live);
262
+ }
263
+ live.refCount++;
264
+ claims.add(key);
265
+ }
266
+ };
267
+ /** Release one holder's claims; the LAST holder of a key releases the backend retain (the wire
268
+ * sub unsubscribes; the backend's folded fence/occupancy STATE deliberately survives). */
269
+ const releaseLifecycle = (claims) => {
270
+ for (const key of claims) {
271
+ const live = systemSubs.get(key);
272
+ if (!live)
273
+ continue;
274
+ if (--live.refCount <= 0) {
275
+ systemSubs.delete(key);
276
+ backend.releaseSystemQuery(live.retainQid);
277
+ }
278
+ }
279
+ claims.clear();
280
+ };
281
+ /** Register the room's OWNED tables (302 §2 — one source per table): every lease table spec
282
+ * whose `writable` kind is not `"none"` names a table the room owns; the backend registers a
283
+ * namespaced engine twin the room channel feeds and the room-homed views swap onto. Context
284
+ * tables (`kind: "none"`) are deliberately NOT registered — the daemon is their sole
285
+ * authority, and the gate DROPS the room's relayed copies (302 §6). Idempotent per
286
+ * (sourceKey, table) — the backend's record is the one source of truth; a footprint table
287
+ * absent from the client schema has nothing to hold rows for and is skipped backend-side. */
288
+ const promoteRoomTables = (room, specs) => {
289
+ const owned = specs.filter((s) => s.writable.kind !== "none").map((s) => s.table);
290
+ // ALWAYS register — even an all-context lease's `owned = []`: the installed (empty) map is
291
+ // what makes the room gate DROP every relayed delta (302 §6). Skipping the call would leave
292
+ // `gate.tableMap` undefined — the DAEMON identity path — and fold the room's relayed copies
293
+ // of daemon-authoritative rows verbatim into the plain tables, two syncs fighting over one
294
+ // baseline (stale overwrites + dueling GC removes).
295
+ backend.registerRoomTables(room.sourceKey, owned);
296
+ };
297
+ /** (Re)arm a room query's proactive renewal from its current `exp`. Timers are unref'd (Node)
298
+ * so an idle renewal never holds the process open; cleared on release/close. */
299
+ const scheduleRenewal = (key, state, delayMs) => {
300
+ if (state.renewTimer !== undefined)
301
+ clearTimeout(state.renewTimer);
302
+ if (realtimeClosed)
303
+ return;
304
+ const delay = delayMs ?? Math.max(state.exp - Date.now() - renewMarginMs, MIN_RENEW_DELAY_MS);
305
+ state.renewTimer = setTimeout(() => {
306
+ state.renewTimer = undefined;
307
+ void renewRoomQuery(key, state);
308
+ }, delay);
309
+ state.renewTimer.unref?.();
310
+ };
311
+ /** Proactive token renewal (renewal-as-reauthorization): re-lease through the SAME app query
312
+ * route; only a reply WITH a realtime block (and the SAME sourceKey) re-authorizes — the fresh
313
+ * token is handed to the resolver and the live sub re-subscribes with it BEFORE the room
314
+ * shell's TTL backstop can drop it. A reply without the block is the DOWNGRADE signal: loud;
315
+ * the sub is left to die at `exp` (the graceful downgrade dance is Slice I). */
316
+ const renewRoomQuery = async (key, state) => {
317
+ if (realtimeClosed || roomQueries.get(key) !== state)
318
+ return;
319
+ let lease;
320
+ try {
321
+ lease = await postLease(state.remote);
322
+ }
323
+ catch (err) {
324
+ anomaly("lease-failed", state.remote, `token renewal failed: ${String(err?.message ?? err)}`);
325
+ // Retry while the current token is still live; past `exp` the shell has dropped the sub
326
+ // anyway and the next reconnect re-resolution owns recovery.
327
+ if (Date.now() < state.exp && roomQueries.get(key) === state)
328
+ scheduleRenewal(key, state, RENEW_RETRY_MS);
329
+ return;
330
+ }
331
+ if (realtimeClosed || roomQueries.get(key) !== state)
332
+ return;
333
+ // I-iii: a renewal re-presents the lifecycle block — re-claim idempotently (a key this query
334
+ // already holds is skipped; a NEW entry, e.g. the fence appearing when the query became
335
+ // room-served mid-life, is retained now). Claimed BEFORE the realtime check on purpose: a
336
+ // downgraded renewal (no realtime block) still carries the doorbell, and the occupancy
337
+ // stream must survive the downgrade (it is what re-upgrades, §4.1).
338
+ claimLifecycle(state.lifecycleClaims, lease.lifecycle, state.remote);
339
+ const rt = lease.realtime;
340
+ if (rt === undefined) {
341
+ // No realtime block: the occupancy gate closed server-side. WITH a §4.2 fence, run the
342
+ // graceful I-v downgrade dance (retarget → demote behind the watermark → re-arm the
343
+ // doorbell); WITHOUT one, stay loud (a pre-I-v server, or `lifecycle.drainRoom`
344
+ // unconfigured — nothing to ghost behind soundly).
345
+ if (lease.realtimeFence !== undefined) {
346
+ // Hand the fresh daemon token so the driving query's daemon re-subscribe presents it (no
347
+ // extra POST); co-tenant queries sharing the room re-lease on their own daemon re-subscribe.
348
+ tokenHandoffs.set(key, {
349
+ target: { leaseToken: lease.leaseToken, ...(lease.wsEndpoint !== undefined ? { wsEndpoint: lease.wsEndpoint } : {}) },
350
+ at: Date.now(),
351
+ });
352
+ downgradeRoom(lease.realtimeFence);
353
+ }
354
+ else {
355
+ anomaly("downgrade", state.remote, "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");
356
+ }
357
+ return;
358
+ }
359
+ if (rt.sourceKey !== state.sourceKey) {
360
+ anomaly("source-key-changed", state.remote, `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)`);
361
+ return;
362
+ }
363
+ const room = rooms.get(state.sourceKey);
364
+ if (!room)
365
+ return;
366
+ try {
367
+ // Promotion is per-(sourceKey, table) idempotent, so a renewal compiles only tables that
368
+ // are NEW to the lease (a profile edit mid-life). A compile throw (schema skew) must not
369
+ // kill the renewal — this is a timer-driven void promise, so an escape would be an
370
+ // unhandled rejection — and the live sub keeps its already-promoted tables + the fresh
371
+ // token below; the new table's routing simply never arms (its writes route slow).
372
+ promoteRoomTables(room, rt.tables);
373
+ }
374
+ catch (err) {
375
+ anomaly("lease-failed", state.remote, `renewal promotion failed: ${String(err?.message ?? err)} (the room keeps its already-promoted tables)`);
376
+ }
377
+ state.exp = rt.exp;
378
+ if (lease.lifecycle?.doorbell.scope !== undefined)
379
+ state.doorbellScope = lease.lifecycle.doorbell.scope;
380
+ // Re-present NOW with the fresh token: hand it to the resolver and re-subscribe the live sub
381
+ // (an ordinary epoch bump server-side; the fresh snapshot re-hydrates through the room gate as
382
+ // a net-zero footprint diff).
383
+ tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });
384
+ room.source.registerQuery(state.sourceQid, state.remote);
385
+ scheduleRenewal(key, state);
386
+ };
387
+ /** The room channel's subscribe resolver: first subscribe consumes the handed fresh token; every
388
+ * RE-resolution (reconnect, gap repair, endpoint recovery) is a full re-lease through the app
389
+ * route — renewal-as-reauthorization, so a revoked/downgraded query cannot silently re-attach. */
390
+ const roomResolver = (room) => async ({ remote }) => {
391
+ const key = remoteKey(remote);
392
+ const handed = takeHandoff(key);
393
+ if (handed)
394
+ return handed;
395
+ const lease = await postLease(remote);
396
+ const rt = lease.realtime;
397
+ if (rt === undefined) {
398
+ anomaly("downgrade", remote, "the re-lease carries no realtime block — the query is no longer room-served (room subscribe aborted; graceful downgrade is Slice I)");
399
+ throw new Error(`realtime downgrade: query "${remote.name}" is no longer room-served`);
400
+ }
401
+ if (rt.sourceKey !== room.sourceKey) {
402
+ anomaly("source-key-changed", remote, `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)`);
403
+ throw new Error(`realtime sourceKey changed for query "${remote.name}"`);
404
+ }
405
+ // A re-lease may widen the footprint (new tables) and always refreshes the renewal clock.
406
+ promoteRoomTables(room, rt.tables);
407
+ const state = roomQueries.get(key);
408
+ if (state) {
409
+ state.exp = rt.exp;
410
+ scheduleRenewal(key, state);
411
+ // I-iii: a re-resolution's lifecycle block re-claims like a renewal's (idempotent).
412
+ claimLifecycle(state.lifecycleClaims, lease.lifecycle, state.remote);
413
+ }
414
+ return { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint };
415
+ };
416
+ /** Connect (once) the room source for a lease's `sourceKey` — multiple labeled queries on the
417
+ * same room share the one source/gate. The endpoint is the lease's DEDICATED
418
+ * `realtime.wsEndpoint`; the TOP-LEVEL `wsEndpoint` (whole-daemon-session migration) never
419
+ * reaches a room transport. NO `pushMutation` override: a room-domain mutation ships over the
420
+ * ROOM socket itself (§7.5 sent-pins-domain — the backend's `channelFor` picks this source). */
421
+ const ensureRoom = (rt) => {
422
+ const existing = rooms.get(rt.sourceKey);
423
+ if (existing)
424
+ return existing;
425
+ const room = {
426
+ sourceKey: rt.sourceKey,
427
+ wsEndpoint: rt.wsEndpoint,
428
+ source: undefined,
429
+ };
430
+ room.source = new RemoteOptimisticSource({ factory: roomTransportFactory, endpoint: rt.wsEndpoint }, clientID, { resolveSubscribe: roomResolver(room) });
431
+ rooms.set(rt.sourceKey, room);
432
+ // connectSource BEFORE any retain on this channel (the backend throws otherwise); it also
433
+ // auto-registers the reserved lmid system query, so the room's confirms fold into
434
+ // `watermark[sourceKey]` from the first frame.
435
+ backend.connectSource(rt.sourceKey, room.source);
436
+ return room;
437
+ };
438
+ /** Candidates by remote key — ONE re-lease upgrades every view of the (name, args) at once
439
+ * (the backend moves the sub wholesale). */
440
+ const upgradeCandidates = new Map();
441
+ /** EVERY live labeled view's hook, by remote key (I-v): the downgrade dance runs from the
442
+ * renewal loop — no view reference in scope — yet must re-register each surviving view as an
443
+ * upgrade candidate (the doorbell re-arms the next upgrade) and clear its room bookkeeping.
444
+ * Registered at materialize, dropped at destroy. */
445
+ const labeledViewHooks = new Map();
446
+ /** Last observed other-session count per scope — the 0→≥1 transition tracker. A first
447
+ * observation at ≥1 counts as a transition (there was none before we could see). */
448
+ const lastOthers = new Map();
449
+ const runUpgrade = async (cand) => {
450
+ let lease;
451
+ try {
452
+ lease = await postLease(cand.remote);
453
+ }
454
+ catch (err) {
455
+ anomaly("lease-failed", cand.remote, `doorbell re-lease failed: ${String(err?.message ?? err)} (staying daemon-attached; the next doorbell/renewal is the retry)`);
456
+ return;
457
+ }
458
+ if (realtimeClosed || cand.views.size === 0)
459
+ return; // torn down while the lease was in flight
460
+ const rt = lease.realtime;
461
+ if (rt === undefined)
462
+ return; // still gated server-side (e.g. its minSessions is higher) — stay daemon-attached, silently
463
+ const key = remoteKey(cand.remote);
464
+ if (roomQueries.has(key))
465
+ return; // already room-attached (a racing fresh view won) — nothing to move
466
+ try {
467
+ // The G-v attach order, verbatim, up to the sub move: room source/gate first, engine
468
+ // promotion second (both idempotent — `ensureRoom` per sourceKey, `promoteRoomTables` per
469
+ // (sourceKey, table) via the backend's routing record), THEN the wire cutover with the
470
+ // fresh roomToken handed to the room resolver. `retargetRemoteQuery` is itself idempotent
471
+ // per (query, sourceKey), so a duplicate doorbell that slipped the `inFlight` guard cannot
472
+ // double-attach.
473
+ const room = ensureRoom(rt);
474
+ promoteRoomTables(room, rt.tables);
475
+ tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });
476
+ const sourceQid = backend.retargetRemoteQuery(cand.remote, rt.sourceKey);
477
+ const state = {
478
+ remote: cand.remote,
479
+ sourceKey: rt.sourceKey,
480
+ sourceQid,
481
+ refCount: 0,
482
+ exp: rt.exp,
483
+ // ADOPT the candidate's claims (I-v): a re-upgrade after a downgrade inherits the fence
484
+ // streams the ghost still needs, already subscribed — so they are NOT re-subscribed; a
485
+ // fresh candidate's set is empty. `upgradeCandidates.delete(key)` below leaves the set
486
+ // owned by this state.
487
+ lifecycleClaims: cand.claims,
488
+ doorbellScope: cand.scope,
489
+ };
490
+ // The re-lease's lifecycle block now carries the fence bundle (the query is room-served):
491
+ // claim it on the query's renewal-loop set (idempotent — an adopted key is skipped), exactly
492
+ // as a renewal that turned room-served mid-life would (I-iii) — released when the last view
493
+ // drops the query.
494
+ claimLifecycle(state.lifecycleClaims, lease.lifecycle, cand.remote);
495
+ // No awaits since the `views.size` check above — destroys cannot have interleaved, so at
496
+ // least one view adopts (a released one declines via its own flag, defensively).
497
+ for (const view of [...cand.views])
498
+ view.adoptRoom(key, state);
499
+ upgradeCandidates.delete(key);
500
+ roomQueries.set(key, state);
501
+ scheduleRenewal(key, state);
502
+ }
503
+ catch (err) {
504
+ // Fail open: the retarget primitive validates before mutating, so the daemon retain is
505
+ // intact — the query keeps serving from the daemon exactly as before the doorbell.
506
+ tokenHandoffs.delete(key); // never leave a room token where the DAEMON resolver could eat it
507
+ anomaly("lease-failed", cand.remote, `upgrade retarget failed: ${String(err?.message ?? err)} (staying daemon-attached; the next doorbell/renewal is the retry)`);
508
+ }
509
+ };
510
+ /** Kick every idle candidate on `scope` — the doorbell reaction proper. */
511
+ const maybeUpgrade = (scope) => {
512
+ if (realtimeClosed)
513
+ return;
514
+ for (const cand of upgradeCandidates.values()) {
515
+ if (cand.scope !== scope || cand.inFlight || cand.views.size === 0)
516
+ continue;
517
+ cand.inFlight = true;
518
+ void runUpgrade(cand).finally(() => {
519
+ cand.inFlight = false;
520
+ });
521
+ }
522
+ };
523
+ const registerUpgradeCandidate = (remote, scope, hook) => {
524
+ const key = remoteKey(remote);
525
+ let cand = upgradeCandidates.get(key);
526
+ if (!cand)
527
+ upgradeCandidates.set(key, (cand = { remote, scope, views: new Set(), inFlight: false, claims: new Set() }));
528
+ cand.views.add(hook);
529
+ // Registration-time check: a doorbell that FOLDED before this candidate existed (the lease
530
+ // resolve raced the occupancy delta) must still trigger — same count rule as the events.
531
+ if (backend.otherScopeSessions(scope) >= 1)
532
+ maybeUpgrade(scope);
533
+ };
534
+ const dropUpgradeCandidate = (remote, hook) => {
535
+ const cand = upgradeCandidates.get(remoteKey(remote));
536
+ if (!cand)
537
+ return;
538
+ cand.views.delete(hook);
539
+ if (cand.views.size === 0) {
540
+ upgradeCandidates.delete(remoteKey(remote));
541
+ // A candidate carrying a downgrade's fence-stream claims (I-v) releases them with its last
542
+ // view — the LAST holder unsubscribes the wire sub (empty set ⇒ no-op for a fresh candidate).
543
+ releaseLifecycle(cand.claims);
544
+ }
545
+ };
546
+ /** The §4.2 graceful downgrade dance (Slice I-v): a renewal came back with NO realtime block
547
+ * but WITH a fence. Handle the WHOLE room at once — retarget every live sub sharing the source
548
+ * onto the daemon (the I-iv retarget in REVERSE), demote the room source behind the watermark
549
+ * fence (its rows persist as a FROZEN ghost until the daemon plane absorbs the final flush),
550
+ * close the room transport, and re-register each surviving view as an upgrade candidate so the
551
+ * next doorbell re-upgrades the same doc. `demoteRoomSource` refuses to demote while any sub is
552
+ * still on the channel, so all subs must retarget first; a co-tenant query's own later renewal
553
+ * then finds the room gone and no-ops (retarget-to-daemon + demote are both idempotent).
554
+ *
555
+ * Ordering with disconnect: `demoteRoomSource` → `disconnectSource` drops the room gate, which
556
+ * makes the retarget's deferred phase-2 GC (`flushRetargetGc`, run at the daemon's first
557
+ * release) a no-op — it deletes the pending-GC record then finds no old gate to rewind, so the
558
+ * room slice's rows leave ONLY through the ghost's `removeRoomSource` under the fence (never via
559
+ * a GC rewind that would surface a lagging follower's pre-flush images). */
560
+ const downgradeRoom = (fence) => {
561
+ if (realtimeClosed)
562
+ return;
563
+ const sourceKey = fence.sourceKey;
564
+ const onRoom = [...roomQueries].filter(([, s]) => s.sourceKey === sourceKey);
565
+ for (const [key, state] of onRoom) {
566
+ backend.retargetRemoteQuery(state.remote, "daemon"); // room → daemon; the no-block reply IS a daemon lease
567
+ if (state.renewTimer !== undefined)
568
+ clearTimeout(state.renewTimer);
569
+ roomQueries.delete(key);
570
+ const hooks = labeledViewHooks.get(key);
571
+ if (state.doorbellScope !== undefined && hooks !== undefined && hooks.size > 0) {
572
+ let cand = upgradeCandidates.get(key);
573
+ if (!cand) {
574
+ upgradeCandidates.set(key, (cand = { remote: state.remote, scope: state.doorbellScope, views: new Set(), inFlight: false, claims: new Set() }));
575
+ }
576
+ // Carry the renewal-loop's lifecycle claims (the fence streams — the ghost's watermark
577
+ // input, delivered on the DAEMON channel) onto the candidate so they outlive the room
578
+ // state and are adopted by the next upgrade (`runUpgrade`).
579
+ for (const c of state.lifecycleClaims)
580
+ cand.claims.add(c);
581
+ state.lifecycleClaims.clear();
582
+ for (const h of hooks) {
583
+ h.clearRoom(); // forget the dead room bookkeeping (destroy must not decrement a gone state)
584
+ cand.views.add(h);
585
+ }
586
+ // Self-heal: a collaborator still present at downgrade re-rings immediately. Normally the
587
+ // scope is solo here (that IS why the server downgraded), so this is inert.
588
+ if (backend.otherScopeSessions(state.doorbellScope) >= 1)
589
+ maybeUpgrade(state.doorbellScope);
590
+ }
591
+ else {
592
+ // No re-upgrade possible (no doorbell scope, or no surviving view): release the claims.
593
+ if (hooks !== undefined)
594
+ for (const h of hooks)
595
+ h.clearRoom();
596
+ releaseLifecycle(state.lifecycleClaims);
597
+ }
598
+ }
599
+ backend.demoteRoomSource(sourceKey, fence.doc, fence.finalFlushSeq); // frozen ghost behind the fence
600
+ const room = rooms.get(sourceKey);
601
+ if (room !== undefined) {
602
+ rooms.delete(sourceKey);
603
+ room.source.close(); // every sub retargeted off it
604
+ }
605
+ };
606
+ // The trigger: the backend reports (scope, other-session count) after each release that folded
607
+ // occupancy rows; the 0→≥1 transition rings. `others` never counts our own clientID or expired
608
+ // rows (the backend's one rule), so a solo tab's own row cannot ring its own bell, and a stale
609
+ // collaborator aging out then re-appearing rings again (0→1 anew) — which is idempotent here
610
+ // (an already-room-attached query has no candidate left to kick).
611
+ backend.onScopeSessions(({ scope, others }) => {
612
+ const prev = lastOthers.get(scope) ?? 0;
613
+ lastOthers.set(scope, others);
614
+ if (prev === 0 && others >= 1)
615
+ maybeUpgrade(scope);
616
+ });
617
+ // The I-v stuck-downgrade surface (§7.5): a ghost whose watermark fence cleared but whose sent
618
+ // room-domain mids never resolved through the daemon-carried folds. The ghost HOLDS (no
619
+ // timeout-retire is invented) — surface it loudly, naming the mids.
620
+ backend.onDowngradeStuck(({ sourceKey, doc, mids }) => {
621
+ anomaly("downgrade-stuck", { name: sourceKey, args: { doc, mids } }, `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)`);
622
+ });
623
+ // The 302 §6.1 context-coverage surface: a room-homed view's non-owned refs stay daemon-served
624
+ // after the swap (the gate drops the room's relayed copies by design) — whether a daemon
625
+ // subscription covers those rows is unknowable here, so name the condition loudly once per
626
+ // view instead of letting the join render silently empty.
627
+ backend.onRoomContextJoin(({ sourceKey, name, args, tables }) => {
628
+ anomaly("context-coverage", { name, args }, `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)`);
629
+ });
630
+ // The split-register ticket: set (synchronously) by the wrapped `materialize` just before it
631
+ // delegates, consumed by the shadowed `backend.registerQuery` below — which registers the LOCAL
632
+ // half only (the Store's SSR-seed + `unknown` pre-marking has already run) and defers the remote
633
+ // retain to the lease resolution. Everything else (unlabeled queries, React retains, re-registers)
634
+ // flows through untouched.
635
+ let labeledTicket = null;
636
+ const origRegisterQuery = backend.registerQuery.bind(backend);
637
+ backend.registerQuery = (qid, ast, remote, channel) => {
638
+ if (labeledTicket === null || remote === undefined) {
639
+ origRegisterQuery(qid, ast, remote, channel);
640
+ return;
641
+ }
642
+ const ticket = labeledTicket;
643
+ labeledTicket = null;
644
+ ticket.consumed = true;
645
+ // The LOCAL half of the split retain (the backend's documented split-retain shape): the remote
646
+ // attaches via `retainRemoteQuery` on the channel the lease names, once it answers.
647
+ origRegisterQuery(qid, ast, undefined);
648
+ };
649
+ const origMaterialize = store.materialize.bind(store);
650
+ /** The G-v labeled-materialize: synchronous local view now, remote retain when the lease answers. */
651
+ const materializeLabeled = (query, mOpts) => {
652
+ const remote = { name: query.name, args: query.args };
653
+ const ast = query.ast();
654
+ // E3 parity (201-LOCAL-ONLY-TABLES-DESIGN.md): the unlabeled remote path rejects a remote query
655
+ // naming a local-only table synchronously inside materialize; the labeled path defers the
656
+ // remote register past the lease, so run the SAME guard here — identical throw, identical
657
+ // timing, no view leaked.
658
+ for (const t of collectAstTables(ast)) {
659
+ if (localTables.has(t)) {
660
+ throw new Error(`remote query "${remote.name}" references local-only table "${t}" — local tables never cross the wire (201-LOCAL-ONLY-TABLES-DESIGN.md E3).`);
661
+ }
662
+ }
663
+ const ticket = { consumed: false };
664
+ labeledTicket = ticket;
665
+ let view;
666
+ try {
667
+ view = origMaterialize(query, mOpts);
668
+ }
669
+ finally {
670
+ labeledTicket = null;
671
+ }
672
+ const localQid = view.qid;
673
+ // The Store pre-marked the view `unknown` (a remote-identity register under a lifecycle
674
+ // backend), but the local-half register flipped it back to `complete` (a local-only
675
+ // registration is synchronously authoritative). Re-flip for the lease window so the view never
676
+ // reads server-authoritative before ANY authority answered — the retain below recomputes it
677
+ // against real hydration. (`readOnce` on a labeled query correctly waits because of this.)
678
+ if (ticket.consumed)
679
+ flipResultTypeUnknown(view);
680
+ let released = false;
681
+ let retainQid;
682
+ let roomKey;
683
+ // I-iii: the lifecycle system-stream claims THIS VIEW holds (claimed once per key when its
684
+ // lease resolves; released with the view — the LAST holder of a scope/doc drops the sub).
685
+ const viewLifecycleClaims = new Set();
686
+ // I-iv/I-v: this view's hook — `adoptRoom` (an upgrade joins the view to the new room state)
687
+ // and `clearRoom` (the downgrade dismantled the room state wholesale — forget it so destroy
688
+ // never decrements a dead record). Registered in `labeledViewHooks` for EVERY labeled view so
689
+ // the downgrade dance (which runs from the renewal loop, no view in scope) can find and
690
+ // re-candidate each surviving view; used as the candidate hook for the daemon-attached shape.
691
+ const viewHook = {
692
+ adoptRoom: (key, state) => {
693
+ if (released)
694
+ return; // a released view never joins (its retain is already gone)
695
+ roomKey = key;
696
+ state.refCount++;
697
+ },
698
+ clearRoom: () => {
699
+ roomKey = undefined;
700
+ },
701
+ };
702
+ const hookKey = remoteKey(remote);
703
+ let viewHooks = labeledViewHooks.get(hookKey);
704
+ if (viewHooks === undefined)
705
+ labeledViewHooks.set(hookKey, (viewHooks = new Set()));
706
+ viewHooks.add(viewHook);
707
+ /** Fail-open: retain on the daemon, indistinguishable from an unlabeled query. The ONE lease
708
+ * already resolved (when it succeeded) is handed to the daemon resolver so the subscribe
709
+ * presents exactly that token — one POST per subscribe, the unlabeled cadence. */
710
+ const attachDaemon = (target) => {
711
+ if (target)
712
+ tokenHandoffs.set(remoteKey(remote), { target, at: Date.now() });
713
+ retainQid = nextRetainQid++;
714
+ backend.retainRemoteQuery(retainQid, remote, localQid, ast);
715
+ };
716
+ /** Room-served: ensure the shared room source/gate, promote the engine per the lease's table
717
+ * specs BEFORE retaining, then retain the sub on the room channel with the roomToken handed
718
+ * to the resolver. `doorbellScope` (from the lease's lifecycle block) is pinned on the room
719
+ * state so a later I-v downgrade can re-candidate this query. */
720
+ const attachRoom = (rt, doorbellScope) => {
721
+ const key = remoteKey(remote);
722
+ const existing = roomQueries.get(key);
723
+ if (existing && existing.sourceKey !== rt.sourceKey) {
724
+ anomaly("source-key-changed", remote, `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)`);
725
+ return;
726
+ }
727
+ const room = ensureRoom(rt);
728
+ promoteRoomTables(room, rt.tables);
729
+ // Only the retain that CREATES the wire sub consumes a token at subscribe time — hand one
730
+ // exactly then (a refcount-only retain issues no wire subscribe; the age cap covers races).
731
+ if (!existing) {
732
+ tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });
733
+ }
734
+ retainQid = nextRetainQid++;
735
+ backend.retainRemoteQuery(retainQid, remote, localQid, ast, rt.sourceKey);
736
+ let state = existing;
737
+ if (!state) {
738
+ state = { remote, sourceKey: rt.sourceKey, sourceQid: retainQid, refCount: 0, exp: rt.exp, lifecycleClaims: new Set() };
739
+ roomQueries.set(key, state);
740
+ }
741
+ else {
742
+ state.exp = Math.max(state.exp, rt.exp);
743
+ }
744
+ if (doorbellScope !== undefined)
745
+ state.doorbellScope = doorbellScope;
746
+ state.refCount++;
747
+ roomKey = key;
748
+ scheduleRenewal(key, state);
749
+ };
750
+ // Resolve-then-register: the lease FIRST; the register follows its verdict.
751
+ void (async () => {
752
+ let lease;
753
+ try {
754
+ lease = await postLease(remote);
755
+ }
756
+ catch (err) {
757
+ // The lease POST itself failed: fail OPEN to the daemon with no handoff — the daemon
758
+ // retain's own resolver re-leases (and the transport's resync retries), exactly an
759
+ // unlabeled query's recovery story.
760
+ anomaly("lease-failed", remote, `query lease failed: ${String(err?.message ?? err)}`);
761
+ if (!released && !realtimeClosed)
762
+ attachDaemon();
763
+ return;
764
+ }
765
+ if (released || realtimeClosed)
766
+ return;
767
+ try {
768
+ if (lease.realtime === undefined) {
769
+ attachDaemon({ leaseToken: lease.leaseToken, wsEndpoint: lease.wsEndpoint });
770
+ // I-iv: a daemon-attached labeled view under a doorbell scope is an UPGRADE CANDIDATE —
771
+ // the occupancy stream's 0→≥1 transition re-leases it and (block permitting) retargets
772
+ // the whole (name, args) sub onto the room. A blockless lease (pre-lifecycle server)
773
+ // registers nothing: the plane stays inert-until-fed.
774
+ const doorbellScope = lease.lifecycle?.doorbell.scope;
775
+ if (doorbellScope !== undefined)
776
+ registerUpgradeCandidate(remote, doorbellScope, viewHook);
777
+ }
778
+ else {
779
+ attachRoom(lease.realtime, lease.lifecycle?.doorbell.scope);
780
+ }
781
+ // I-iii: retain the lease's lifecycle system streams on the DAEMON channel — for the
782
+ // room-served AND the daemon-served (labeled, not covered) shapes alike (the doorbell
783
+ // rides both; the fence only where a room block exists). Absent block ⇒ no-op — a
784
+ // pre-lifecycle server leaves this client byte-identical.
785
+ claimLifecycle(viewLifecycleClaims, lease.lifecycle, remote);
786
+ }
787
+ catch (err) {
788
+ // Fail OPEN, exactly like a lease without a block: a room-attach throw (most plausibly a
789
+ // lease `where` this bundle's schema cannot compile — version skew) must not strand the
790
+ // view local-only. `retainQid === undefined` ⇒ no retain was established (room OR daemon),
791
+ // so the daemon fallback cannot double-attach; a throw AFTER a successful retain (a
792
+ // lifecycle claim, say) leaves the live sub alone. Partial promotion is harmless (it is
793
+ // idempotent, and the room gate re-proves any routed write) — but never leave the room
794
+ // token where the daemon resolver could eat it.
795
+ anomaly("lease-failed", remote, `realtime attach failed: ${String(err?.message ?? err)} (falling back to the daemon lease)`);
796
+ if (!released && !realtimeClosed && retainQid === undefined) {
797
+ tokenHandoffs.delete(remoteKey(remote));
798
+ try {
799
+ attachDaemon({ leaseToken: lease.leaseToken, wsEndpoint: lease.wsEndpoint });
800
+ }
801
+ catch (fallbackErr) {
802
+ anomaly("lease-failed", remote, `daemon fallback failed: ${String(fallbackErr?.message ?? fallbackErr)}`);
803
+ }
804
+ }
805
+ }
806
+ })();
807
+ // Teardown rides the view: release the remote retain (room or daemon) with the local view, and
808
+ // drop the room query's refcount/renewal when the last view goes.
809
+ const origDestroy = view.destroy.bind(view);
810
+ view.destroy = () => {
811
+ if (!released) {
812
+ released = true;
813
+ if (retainQid !== undefined)
814
+ backend.releaseRemoteQuery(retainQid);
815
+ // I-iv/I-v: drop this view's hook — from the per-key hook registry and the candidate set
816
+ // (both no-ops when it was never a candidate; the candidate's own last-view release frees
817
+ // any fence-stream claims a downgrade parked on it).
818
+ viewHooks.delete(viewHook);
819
+ if (viewHooks.size === 0)
820
+ labeledViewHooks.delete(hookKey);
821
+ dropUpgradeCandidate(remote, viewHook);
822
+ // I-iii: this view's lifecycle claims drop with it; the LAST holder of a key releases
823
+ // the system sub (the backend's folded fence/occupancy state deliberately survives).
824
+ releaseLifecycle(viewLifecycleClaims);
825
+ if (roomKey !== undefined) {
826
+ const state = roomQueries.get(roomKey);
827
+ if (state && --state.refCount <= 0) {
828
+ if (state.renewTimer !== undefined)
829
+ clearTimeout(state.renewTimer);
830
+ roomQueries.delete(roomKey);
831
+ // …including any claims the renewal loop made on this query's behalf.
832
+ releaseLifecycle(state.lifecycleClaims);
833
+ }
834
+ }
835
+ }
836
+ origDestroy();
837
+ };
838
+ return view;
839
+ };
840
+ store.materialize = ((query, mOpts) => {
841
+ const label = query.realtime;
842
+ if (label === undefined || typeof query.name !== "string")
843
+ return origMaterialize(query, mOpts);
844
+ return materializeLabeled(query, mOpts);
70
845
  });
71
846
  // Local-table persistence (207 §5.2): attach immediately after the store exists — before any
72
847
  // app write can reach `writeLocal` — and AWAIT the initial restore (one `getAll` over small
@@ -103,10 +878,116 @@ export async function createRindleClient(opts) {
103
878
  target.removeEventListener?.("pagehide", onPageHide);
104
879
  target.removeEventListener?.("beforeunload", onPageHide);
105
880
  persistence?.close(); // releases leadership + the channel + the IDB handle (207 P10)
881
+ // Realtime teardown: renewal timers first (no renewal may fire into a closing client), then
882
+ // every room socket; in-flight lease resolutions are made inert via the flag.
883
+ realtimeClosed = true;
884
+ upgradeCandidates.clear(); // no doorbell may retarget into a closing client
885
+ for (const state of roomQueries.values()) {
886
+ if (state.renewTimer !== undefined)
887
+ clearTimeout(state.renewTimer);
888
+ }
889
+ roomQueries.clear();
890
+ for (const room of rooms.values())
891
+ room.source.close();
892
+ rooms.clear();
106
893
  source.close();
107
894
  },
895
+ __realtimeInspect: () => ({
896
+ rooms: Object.fromEntries([...rooms].map(([sourceKey, room]) => [
897
+ sourceKey,
898
+ {
899
+ wsEndpoint: room.wsEndpoint,
900
+ // Read back from the backend's room-table registry (302 §2): wire → engine table.
901
+ promoted: Object.fromEntries(backend.roomTablesFor(sourceKey)),
902
+ queries: Object.fromEntries([...roomQueries]
903
+ .filter(([, s]) => s.sourceKey === sourceKey)
904
+ .map(([key, s]) => [
905
+ key,
906
+ { name: s.remote.name, sourceQid: s.sourceQid, exp: s.exp, refCount: s.refCount },
907
+ ])),
908
+ },
909
+ ])),
910
+ }),
108
911
  };
109
912
  }
913
+ /** The default DECLARED router (302 §5) when the app names `realtime.mutators` and passes no
914
+ * explicit `domainPolicy`: a declared mutator routes to the ONE attached room; solo or
915
+ * multi-room it abstains (⇒ daemon). `getRooms` is read lazily per invoke so the policy tracks
916
+ * attach/downgrade live. */
917
+ function declaredMutatorPolicy(declared, getRooms) {
918
+ return (name) => {
919
+ if (!declared.has(name))
920
+ return undefined;
921
+ const rooms = getRooms();
922
+ if (rooms.size !== 1)
923
+ return undefined; // solo or ambiguous — the daemon path (302 §5)
924
+ return rooms.keys().next().value;
925
+ };
926
+ }
927
+ /** Flip a just-materialized labeled view back to `unknown` for the lease-resolve window. The
928
+ * plural `FlatArrayView` exposes `setResultType`; a `.one()` query's `SingularView` wrapper hides
929
+ * it behind its (runtime-visible) `inner` — reach through. Best-effort by design: the deferred
930
+ * retain recomputes the lifecycle authoritatively the moment it attaches, and the Store keeps
931
+ * routing backend transitions to the SAME underlying view either way. */
932
+ function flipResultTypeUnknown(view) {
933
+ const v = view;
934
+ if (typeof v.setResultType === "function")
935
+ v.setResultType("unknown");
936
+ else if (typeof v.inner?.setResultType === "function")
937
+ v.inner.setResultType("unknown");
938
+ }
939
+ /** Every base table an AST tree can draw from (root, related subtrees, EXISTS children) — the E3
940
+ * guard's input. Mirrors the backend's own `collectTables`. */
941
+ function collectAstTables(ast, out = new Set()) {
942
+ out.add(ast.table);
943
+ for (const rel of ast.related ?? [])
944
+ collectAstTables(rel.subquery, out);
945
+ collectConditionTables(ast.where, out);
946
+ collectConditionTables(ast.having, out);
947
+ return out;
948
+ }
949
+ function collectConditionTables(cond, out) {
950
+ if (cond === undefined)
951
+ return;
952
+ if (cond.type === "and" || cond.type === "or") {
953
+ for (const c of cond.conditions)
954
+ collectConditionTables(c, out);
955
+ }
956
+ else if (cond.type === "correlatedSubquery") {
957
+ collectAstTables(cond.related.subquery, out);
958
+ }
959
+ }
960
+ /** The (name, args) sub identity — key-order-stable, mirroring the backend's own `remoteKey` so
961
+ * the client-side room bookkeeping groups retains exactly as the backend dedups subs. */
962
+ function remoteKey(remote) {
963
+ return stableJson([remote.name, remote.args]);
964
+ }
965
+ const SYSTEM_TABLES = new Set([
966
+ SCOPE_SESSIONS_TABLE,
967
+ ROOM_WATERMARK_TABLE,
968
+ ROOM_CLIENT_MUTATIONS_TABLE,
969
+ ROOM_MUTATION_OUTCOMES_TABLE,
970
+ ]);
971
+ function isSystemTable(table) {
972
+ return SYSTEM_TABLES.has(table);
973
+ }
974
+ /** The idempotence key a lifecycle retain is claimed under: the minted predicate's full identity
975
+ * (table + scope/doc/clientId) — two labeled queries on one scope share ONE doorbell sub; two
976
+ * docs' fences never alias. */
977
+ function systemEntryKey(entry) {
978
+ return stableJson([entry.table, entry.scope ?? null, entry.doc ?? null, entry.clientId ?? null]);
979
+ }
980
+ function stableJson(value) {
981
+ if (value === null || typeof value !== "object")
982
+ return JSON.stringify(value);
983
+ if (Array.isArray(value))
984
+ return `[${value.map(stableJson).join(",")}]`;
985
+ const obj = value;
986
+ return `{${Object.keys(obj)
987
+ .sort()
988
+ .map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`)
989
+ .join(",")}}`;
990
+ }
110
991
  class RindleApiHttpError extends Error {
111
992
  path;
112
993
  status;