@rindle/optimistic 0.4.4 → 0.5.0

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