Rindle

API index and search · Build metadata

Source snapshot

packages/optimistic/src/client.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
1// createRindleClient — the one-call client for the daemon/serverless topology:2//3//   const app = await createRindleClient({ schema, mutators, api: { url } });4//   const view = app.store.materialize(queries.allIssues());5//   app.mutate.createIssue({ id: 1, title: "hi", score: 0 });6//7// It wires what the pieces otherwise wire by hand: the wasm engine init, a stable per-tab clientID,8// the ws transport to the daemon, lease resolution through the API server's query route,9// and the mutation queue flushing confirmed in-order batches through the mutate route10// (a policy rejection's reason surfaces via `onRejected`; a failed FLUSH — the batch the queue11// keeps retrying — via `onMutationError`, and always the console).1213import { localTableNames, QueryEnsureCache } from "@rindle/client";14import type {15  AnyQuery,16  Ast,17  ColsMap,18  Condition,19  EnsureQueryOptions,20  MutationEnvelope,21  Query,22  QueryId,23  RealtimeQueryLabel,24  RemoteQuery,25  Schema,26} from "@rindle/client";27import {28  RemoteOptimisticSource,29  WsTransport,30  createAffinityTicketStore,31  createQueuedMutationSender,32  offerSubprotocols,33} from "@rindle/remote";34import type { AffinityTicketStore, PushOutcome, RemoteOptimisticConnection, Transport } from "@rindle/remote";35import { initWasm } from "@rindle/wasm";3637import type { OptimisticBackend } from "./backend.ts";38import type { ClientRegistry, MutationTx } from "./backend.ts";39import { resetStableClientID, sessionTicketPersistence, stableClientID } from "./client-id.ts";40import {41  LIFECYCLE_QUERY_NAME,42  ROOM_CLIENT_MUTATIONS_TABLE,43  ROOM_MUTATION_OUTCOMES_TABLE,44  ROOM_WATERMARK_TABLE,45  SCOPE_SESSIONS_TABLE,46  type SystemStreamTable,47} from "./system-streams.ts";48import { createOptimisticStore, type MutateFn } from "./index.ts";49import { attachLocalPersistence, type LocalPersistence, type PersistLocalOptions } from "./local-persist.ts";50import type { Store } from "@rindle/client";5152/** Must mirror `DEFAULT_RINDLE_API_ROUTES` in `@rindle/api-server` (the app-wire contract;53 *  duplicated so the browser bundle doesn't import the server package). */54const DEFAULT_ROUTES = { query: "/api/rindle/query", mutate: "/api/rindle/mutate" } as const;5556export type HeadersInit = Record<string, string>;5758// --------------------------------------------------------------------------- Rindle Realtime (G-v)59//60// A named query stamped with a `realtime` label (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1) resolves61// its lease FIRST (resolve-then-register): the local AST view still materializes synchronously,62// but the remote retain attaches only when the lease answers — on the ROOM channel when the lease63// carries a `realtime` block, byte-identically on the daemon when it doesn't (fail-open). The64// types below mirror the api-server's `QueryLeaseRealtime`/`RoomTableSpec` wire shapes (duplicated65// like DEFAULT_ROUTES so the browser bundle never imports the server package).6667/** One footprint table's spec on the lease wire (mirror of the api-server's `RoomTableSpec`).68 *  `footprintWhere` (H-iii lease-wire flip) is the EXACT footprint-membership predicate from the69 *  ONE unified compiler (`compileRoomScopeSpecs` — the same output the boot wire ships the room70 *  gate): present only for an exact footprint ROOT (lossless row-local extraction; the vacuous-true71 *  empty AND for an unconstrained one), ABSENT for child/correlated tables. It feeds the §372 *  router's pk-membership read proof (`OptimisticBackend`'s routing table) — never authorization. */73export interface RealtimeLeaseTableSpec {74  table: string;75  footprintWhere?: Condition;76  writable:77    | { kind: "none" }78    | { kind: "predicate"; where?: Condition; joinKeyCols: string[] };79}8081/** The room-serve block on a query lease (mirror of the api-server's `QueryLeaseRealtime`). */82export interface RealtimeLeaseBlock {83  /** The store's gate/domain key for this room source (`connectSource`) — `"room:<profile>/<key>"`. */84  sourceKey: string;85  /** Where the ROOM ws opens — the lease's DEDICATED field. Never confuse it with the TOP-LEVEL86   *  `wsEndpoint` (the read-router's whole-DAEMON-session migration signal). */87  wsEndpoint: string;88  /** The room shell's self-authorizing signed lease (seals the APPROVED query AST) — presented as89   *  the room subscribe's `leaseToken`. */90  roomToken: string;91  /** Token expiry (ms epoch) — the renewal clock (renewal = a fresh lease through the app route). */92  exp: number;93  doc: string;94  tables: RealtimeLeaseTableSpec[];95}9697/** One minted SYSTEM-STREAM lease on the `lifecycle` block (mirror of the api-server's98 *  `QueryLeaseLifecycleLease`; Slice I-iii): an ordinary daemon materialization over one of the99 *  four `_rindle_*` lifecycle tables, presented on the wire exactly like the primary lease100 *  (subscribe-with-`leaseToken`). The identity fields document the minted predicate — this client101 *  keys its retains (idempotence per (table, scope/doc/clientId)) and the backend keys its102 *  release-time row filters on them. */103export interface LifecycleLeaseEntry {104  table: string;105  leaseToken: string;106  wsEndpoint?: string;107  /** DOORBELL only: the §4.1 occupancy scope (= the wire room doc, `"<profile>/<key>"`). */108  scope?: string;109  /** FENCE entries only: the room doc. */110  doc?: string;111  /** FENCE ledger/outcomes when the server could client-scope the predicate. */112  clientId?: string;113}114115/** The §4 lifecycle block on a query lease (mirror of the api-server's `QueryLeaseLifecycle`):116 *  `doorbell` on every labeled lease under the opt-in server config, `fence` (watermark + ledger117 *  + outcomes) only when the lease is ALSO room-served. Absent ⇒ this client behaves exactly as118 *  today — the whole plane is inert-until-fed. */119export interface LifecycleLeaseBlock {120  doorbell: LifecycleLeaseEntry;121  fence?: LifecycleLeaseEntry[];122}123124/** The §4.2 downgrade fence block on a query lease (mirror of the api-server's125 *  `QueryLeaseRealtimeFence`, Slice I-v): rides a labeled reply whose occupancy gate CLOSED126 *  (no `realtime` block) when the server could drain the room — `finalFlushSeq` is the room's127 *  last COMMITTED flush seq, the value the client's ghost holds against128 *  (`_rindle_room_watermark(doc) ≥ finalFlushSeq` through the daemon plane). A room-attached129 *  query receiving it runs the GRACEFUL downgrade dance instead of the loud legacy anomaly. */130export interface RealtimeFenceBlock {131  /** The retiring room source's gate/domain key (`"room:" + doc`). */132  sourceKey: string;133  doc: string;134  finalFlushSeq: number;135}136137/** The query-lease reply as this client reads it (top-level daemon lease + optional room block138 *  + optional §4.2 downgrade fence + optional §4 lifecycle system-stream block). */139interface QueryLeaseWire {140  leaseToken: string;141  wsEndpoint?: string;142  /** Fresh placement ticket for the follower that minted this lease. When present on the first143   *  pure-lazy lease, it enables affinity before the returned endpoint opens. */144  affinity?: string;145  realtime?: RealtimeLeaseBlock;146  realtimeFence?: RealtimeFenceBlock;147  lifecycle?: LifecycleLeaseBlock;148}149150export type RealtimeAnomalyKind =151  /** A re-lease (renewal / reconnect re-resolution) came back WITHOUT a realtime block AND152   *  without a §4.2 fence — the query is no longer room-served but the server gave nothing to153   *  downgrade behind (a legacy/pre-I-v server, or `lifecycle.drainRoom` unconfigured). Surfaced154   *  loudly; a reply WITH a `realtimeFence` takes the graceful I-v dance instead. */155  | "downgrade"156  /** The I-v ghost is STUCK (§7.5): its watermark fence cleared but sent room-domain mids never157   *  resolved (sent-but-undelivered when the socket died — undecidable in general). The ghost158   *  holds — no timeout-retire is invented — and the mids are named once, actionably. */159  | "downgrade-stuck"160  /** A lease named a DIFFERENT `sourceKey` than the query's live room sub — surfaced loudly, no161   *  re-attach. Deliberately NOT composed from demote+upgrade (deferred to §7.6's rare-case162   *  follow-up): a sourceKey-change reply carries a realtime block for the NEW room but NO163   *  fence for the OLD one, and without `finalFlushSeq` the old slice cannot be ghosted soundly. */164  | "source-key-changed"165  /** The lease POST failed or the room attach threw. The initial-materialize case fails OPEN to166   *  the daemon path (indistinguishable from an unlabeled query's recovery). */167  | "lease-failed";168169/** A loud realtime lease anomaly (always ALSO `console.error`'d). */170export interface RealtimeAnomaly {171  kind: RealtimeAnomalyKind;172  name: string;173  args: unknown;174  message: string;175}176177/** Rindle Realtime client knobs (Slice G-v). All optional — an app with no labeled queries never178 *  touches any of this. */179export interface RealtimeClientOptions {180  /** The DECLARED room mutators (302 §5: declared, not derived). A mutator named here routes to181   *  the attached room — it stages onto the room's own tables and ships on the room socket —182   *  whenever exactly ONE room is attached; solo (no room) it takes the ordinary daemon path,183   *  and with several rooms attached it routes daemon too (explicit multi-room binding is a184   *  later slice). Every mutator NOT named here is a daemon mutator. A misdeclaration fails185   *  SOFT (302 §5.1): the write lands on the other authority's tables, so the view just stops186   *  feeling instant until the echo relays it — never a divergence. An explicit top-level187   *  `domainPolicy` overrides this entirely. */188  mutators?: readonly string[];189  /** Build the ROOM ws transport for a lease's `realtime.wsEndpoint`. Default190   *  `(endpoint) => new WsTransport(endpoint)`. Injectable for tests / custom ws impls. */191  transport?: (endpoint: string) => Transport;192  /** Loud anomaly surface — see {@link RealtimeAnomaly}. Every anomaly is also `console.error`'d. */193  onAnomaly?: (anomaly: RealtimeAnomaly) => void;194  /** How long before a room lease's `exp` the proactive token renewal fires (default 30s). The195   *  renewal is a FRESH lease through the app query route (renewal-as-reauthorization), and the196   *  live room sub proactively re-subscribes with the fresh token so the shell's TTL backstop197   *  never fires on a healthy session. */198  renewMarginMs?: number;199}200201/** Read-only realtime bookkeeping snapshot ({@link RindleClient.__realtimeInspect}) — test/devtools202 *  introspection, mirroring the backend's `__inspect` convention. */203export interface RealtimeInspect {204  rooms: Record<205    string,206    {207      wsEndpoint: string;208      /** The room's OWNED tables (302 §2): wire table → its namespaced engine table — read back209       *  from the BACKEND's registry (`backend.roomTablesFor`, the one source of truth; the210       *  client keeps no shadow copy). */211      promoted: Record<string, string>;212      /** Live room-retained queries on this room, by remote key. */213      queries: Record<string, { name: string; sourceQid: QueryId; exp: number; refCount: number }>;214    }215  >;216}217218/** Default {@link RealtimeClientOptions.renewMarginMs}. */219const DEFAULT_RENEW_MARGIN_MS = 30_000;220/** Renewal-delay floor: a nearly-expired lease still renews soon, but never in a hot loop. */221const MIN_RENEW_DELAY_MS = 1_000;222/** Retry delay after a failed renewal POST (only while the current token is still live). */223const RENEW_RETRY_MS = 5_000;224/** A one-shot token handoff not consumed within this window is stale — the resolver falls through225 *  to a fresh lease POST instead of presenting a token the server may already refuse. */226const HANDOFF_MAX_AGE_MS = 15_000;227/** How long a fresh (ticketless) connect waits for its affinity mint frame before leasing228 *  TICKETLESS + warning (FOLLOWER-AFFINITY-DESIGN.md §4.1). Generous — the frame normally arrives in229 *  well under one RTT; this bound only trips on an affinity-off daemon (misconfig / rolling upgrade),230 *  so it degrades loudly instead of hanging. */231const AFFINITY_TICKET_TIMEOUT_MS = 4_000;232/** Client-minted remote-retain qids live in their own high band so they can never collide with the233 *  Store's own 1, 2, 3, … view qids or the reserved per-channel lmid qid 0. Exact in f64 (the wire234 *  number type), far below 2^53. */235const REALTIME_RETAIN_QID_BASE = 2 ** 30;236237export interface RindleClientOptions<S extends ColsMap, R extends ClientRegistry> {238  schema: Schema<S>;239  /** The PREDICTED mutators (the API server holds the authoritative twins by name). */240  mutators: R;241  /** The acting principal for a shared (generator) mutator's `ctx.user` — the local identity the242   *  optimistic prediction writes under (the server injects its OWN authenticated user for the243   *  authoritative run). Re-read on each run, including replay. Keep this identity stable for244   *  the client's lifetime and recreate the client on account changes. */245  user?: () => string;246  /** The app API server: named queries resolve to leases here, mutations push here. */247  api: {248    url: string;249    routes?: { query?: string; mutate?: string };250    /** Extra headers per request (auth). A function is re-evaluated per call. */251    headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);252    fetch?: typeof fetch;253  };254  /** Optional subscription transport override. Omit it in the normal unified setup: the first255   *  query lease carries `wsEndpoint` + a fresh affinity ticket and opens the transport lazily.256   *  - `{ wsUrl }` — a static endpoint (single daemon), opened eagerly; in a routed deploy this is257   *    the SSR-injected bootstrap endpoint (READ-ROUTER-DESIGN.md §2.4). A routed lease naming a258   *    different follower migrates the connection there.259   *  - `{ wsUrl }` omitted (or this whole option omitted) — pure-lazy: the first lease's260   *    `wsEndpoint` opens the connection (a261   *    routed SPA with no SSR bootstrap).262   *  - `{ transport }` — a pre-built transport (tests / in-process); fixed, no migration.263   *264   *  With a fixed `wsUrl`, set `affinity: true` to opt into FOLLOWER-AFFINITY mode (design §2).265   *  With no daemon option, a lease that returns an affinity ticket enables the same mode266   *  automatically before opening its socket. The ticket is persisted per-tab and forwarded on267   *  later leases so both legs pin the same nearby follower. Ignored for `{ transport }`. */268  daemon?: { wsUrl?: string; affinity?: boolean } | { transport: Transport };269  /** Stable client identity. Default: a per-origin base (localStorage) plus per-tab and per-instance270   *  suffixes, so each tab — and each client instance within a tab — gets its own mid sequence yet a271   *  reload keeps it; falls back to a fresh random id when web storage is unavailable. Pass a value272   *  to override. */273  clientID?: string;274  /** A policy rejection's reason (the prediction's snap-back rides the lmid release). Fires for275   *  BOTH planes since H-v: the HTTP mutate route's per-envelope rejections AND a room's276   *  `mutationOutcome {kind:"rejected"}` frames — one surface, whichever authority said no. */277  onRejected?: (envelope: MutationEnvelope, reason: string) => void;278  /** A failed mutate FLUSH — the transport/authority leg, not a policy verdict: the batch is279   *  retried with backoff and nothing has been confirmed yet, so the pending mutations stay280   *  predicted and the queue is head-of-line blocked until it succeeds. Fires once per attempt.281   *  This is the twin of {@link onRejected}: `onRejected` is "the authority said no" (final,282   *  lmid already advanced), this is "the authority never answered" (retrying).283   *284   *  LOUD by contract — every attempt reaching this hook is ALSO `console.error`'d (backed off285   *  to attempts 1, 2, 4, 8, … so a long outage doesn't flood the console), because an286   *  indefinitely retried flush is indistinguishable from a hung app if it stays silent. */287  onMutationError?: (err: unknown, attempt: number) => void;288  /** Persist `local: true` tables across reloads and keep them live-coherent across tabs289   *  (`207-LOCAL-TABLE-PERSISTENCE-DESIGN.md`). `user` is the storage identity (§3.2) — one IDB290   *  database per (origin, user); pass a sentinel like `"anon"` for a signed-out mode. When set,291   *  `createRindleClient` awaits the initial restore attempt before returning. Storage failures292   *  can leave tables empty while local writes remain usable. Close the old client before calling293   *  `deleteLocalPersistence(user)` at logout; deletion is never implicit.294   *  Per-table opt-out: declare `table(name, { local: "session" })` for local state that must stay295   *  ephemeral and per-tab (e.g. selection) even with persistence on (§5.4). */296  persistLocal?: PersistLocalOptions;297  queue?: { maxBatch?: number; retryDelayMs?: (attempt: number) => number };298  /** Explicitly selects a mutation's confirming stream. Its domain supplies the mutation ID,299   *  transport, and confirmation watermark. Returning `undefined` selects `"daemon"`.300   *  If this policy is omitted, `realtime.mutators` supplies the declared room routing policy;301   *  without either declaration, mutations route to the daemon. Routes are not inferred from302   *  reads or writes. A room deopt can re-enqueue the mutation on the daemon stream. */303  domainPolicy?: (name: string, args: unknown) => string | undefined;304  /** Rindle Realtime client knobs (G-v resolve-then-register) — see {@link RealtimeClientOptions}. */305  realtime?: RealtimeClientOptions;306  /** Development-only recovery knobs. Keep off in production: a mutation gap means state loss307   *  or two writers sharing a clientID, and should be investigated. */308  dev?: {309    /** On a mutation-gap response, clear the persisted clientID and hard reload the page. This310     *  recovers from dev DB wipes while making the reset visible to the developer. */311    resetOnMutationGap?: boolean;312  };313}314315export interface RindleClient<S extends ColsMap, R extends ClientRegistry> {316  store: Store<S>;317  backend: OptimisticBackend<S>;318  /** Retain a named query for navigation/prefetch. By default waits for server authority; pass319   *  `{ until: "present" }` to continue as soon as the local view has a result. */320  ensure<Q extends AnyQuery>(query: Q, options?: EnsureQueryOptions): Promise<void>;321  /** Call `mutate.foo(args)` for a normal optimistic write, or `mutate.foo.folded(opts, args)` for a322   *  debounced, last-value-wins folded write (FOLDED-MUTATIONS-DESIGN §3). */323  mutate: { [K in keyof R]: MutateFn<Parameters<R[K]>[1]> };324  /** Assign IDs and enqueue every outstanding fold immediately. Also called on325   *  `beforeunload`/`pagehide` as a best-effort flush; neither path confirms delivery. */326  flushFolds(): void;327  clientID: string;328  /** Release retained prefetch queries, transports, and lifecycle listeners; close local persistence.329   *  Flushes folded arguments into the queue first; does not await server confirmation. */330  close(): void;331  /** Read-only realtime bookkeeping snapshot (rooms, promoted tables + their client-held332   *  `joinKeyCols`, live room queries) — the `__inspect`-convention test/devtools hook. */333  __realtimeInspect(): RealtimeInspect;334}335336/** Create a synced application client over the local WASM engine. Connects named query leases,337 *  WebSocket subscriptions, and the optimistic mutation queue to the application's API routes.338 *  Await construction before using the store; when configured, it awaits the local-table restore339 *  attempt as well. Construction does not mean remote queries are synchronized: retain queries340 *  or use `ensure` for the required readiness boundary.341 *342 *  Keep one client per application session. Call `close()` when the session ends, and create a343 *  new client when its authenticated principal changes. The application server owns authorization.344 */345export async function createRindleClient<S extends ColsMap, R extends ClientRegistry>(346  opts: RindleClientOptions<S, R>,347): Promise<RindleClient<S, R>> {348  await initWasm();349350  const clientID = opts.clientID ?? stableClientID();351  const routes = { ...DEFAULT_ROUTES, ...opts.api.routes };352  const fetchImpl = opts.api.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args));353  const base = opts.api.url.replace(/\/$/, "");354355  const post = async (path: string, payload: unknown): Promise<unknown> => {356    const extra = typeof opts.api.headers === "function" ? await opts.api.headers() : (opts.api.headers ?? {});357    const res = await fetchImpl(`${base}${path}`, {358      method: "POST",359      headers: { "content-type": "application/json", ...extra },360      body: JSON.stringify(payload),361    });362    const text = await res.text();363    if (!res.ok) throw new RindleApiHttpError(path, res.status, text);364    return text ? JSON.parse(text) : undefined;365  };366367  // FOLLOWER-AFFINITY mode (design §2): explicit for an eager fleet endpoint, or activated by the368  // first pure-lazy lease's placement ticket. A pre-built transport has no fleet edge to route369  // through. The store is shared by the ws transport, source, and lease POST, and persisted per-tab.370  const daemon = opts.daemon ?? {};371  const leaseDiscoversAffinity = !("transport" in daemon) && daemon.wsUrl === undefined;372  let affinityOn = !("transport" in daemon) && daemon.affinity === true;373  // A replaceable connection always has a dormant store. The first lease can activate it before374  // that same lease's `wsEndpoint` constructs the pure-lazy socket.375  const affinityStore: AffinityTicketStore | undefined =376    "transport" in daemon ? undefined : createAffinityTicketStore(sessionTicketPersistence());377378  // The ONE app-lease POST both legs share. Sends the stable `clientId` so the api-server/router379  // can use it as the anonymous routing key (READ-ROUTER-DESIGN.md §2.2); the reply's top-level380  // fields are the daemon lease, and a room-served labeled query ADDITIONALLY carries `realtime`.381  // In affinity mode it ALSO forwards the placement `affinity` ticket — awaited first so a fresh382  // (ticketless) connect leases only AFTER its mint frame arrives, co-locating both legs (§4.1). On383  // reconnect, the WebSocket may offer the persisted ticket on its handshake, but the lease waits384  // for that connection's fresh mint frame. The wait is BOUNDED: if no mint frame arrives (the daemon is385  // affinity-off — a misconfig, or mid rolling-upgrade), we lease386  // TICKETLESS and warn rather than hang. That first timeout LATCHES ticketless mode in the store:387  // later leases return immediately (and do not accumulate abandoned waiters) until an affinity388  // frame actually arrives. A persisted ticket remains useful for the ws handshake but is never389  // forwarded on a lease until the CURRENT connection refreshes it, preventing a restored stale390  // ticket from independently re-pinning the HTTP and ws legs.391  let affinityFallbackWarned = false;392  const performPostLease = async (remote: RemoteQuery): Promise<QueryLeaseWire> => {393    let affinity: string | undefined;394    if (affinityOn && affinityStore) {395      const ticket = await affinityStore.leaseTicket(AFFINITY_TICKET_TIMEOUT_MS);396      affinity = ticket.ticket;397      if (affinity !== undefined) affinityFallbackWarned = false;398      if (ticket.timedOut && !affinityFallbackWarned) {399        affinityFallbackWarned = true;400        console.warn(401          `[rindle] no affinity ticket after ${AFFINITY_TICKET_TIMEOUT_MS}ms — leasing ticketless ` +402            "(is the daemon affinity-enabled / RINDLE_AFFINITY_KEY set?)",403        );404      }405    }406    const out = (await post(routes.query, {407      name: remote.name,408      args: remote.args,409      clientId: clientID,410      ...(affinity !== undefined ? { affinity } : {}),411    })) as QueryLeaseWire;412    // A lease ticket ACTIVATES affinity (recorded before the pure-lazy socket opens), but never413    // refreshes an already-active store: once a connection exists, only ITS `{t:"affinity"}` frames414    // may mark the ticket connection-fresh (`connectionPending` discipline) — an HTTP response415    // landing mid-reconnect must not re-pin the legs onto a follower the socket already left.416    if (affinityStore && out.affinity !== undefined && !affinityOn && leaseDiscoversAffinity) {417      affinityStore.set(out.affinity);418      affinityOn = true;419    }420    // A successful ticketless reply settles discovery either way: the backend does not do421    // placement, so stop serializing leases. (A thrown POST leaves discovery pending — the next422    // queued lease becomes the discoverer.)423    discoveryPending = false;424    return out;425  };426427  // Pure-lazy clients do not know they are affinity-enabled until the first lease replies. Queue428  // that discovery window so only one ticketless POST can be in flight: once it establishes a429  // placement, every queued lease re-checks `affinityOn` in `performPostLease` and forwards the430  // same ticket. Without this gate, concurrent first materializations could land on different431  // followers and mint leases that the one stable WebSocket can never subscribe to correctly.432  // The window closes on the first completed lease — with a ticket (affinity on) or without (the433  // backend mints none; leases run unserialized from then on).434  let discoveryPending = leaseDiscoversAffinity;435  let affinityDiscoveryTail = Promise.resolve();436  const postLease = async (remote: RemoteQuery): Promise<QueryLeaseWire> => {437    if (!discoveryPending || affinityOn) return performPostLease(remote);438439    const previous = affinityDiscoveryTail;440    let release!: () => void;441    affinityDiscoveryTail = new Promise<void>((resolve) => {442      release = resolve;443    });444    await previous;445    try {446      return await performPostLease(remote);447    } finally {448      release();449    }450  };451452  // One-shot fresh-token handoffs, by remote key: G-v's resolve-then-register (and the proactive453  // renewal) has ALREADY leased when the subscribe fires, so the resolver consumes the handed454  // token instead of POSTing a second time — one lease per subscribe, exactly the unlabeled455  // cadence. Age-capped: an entry no subscribe consumed (e.g. a refcount-only retain) must not456  // serve a stale token to a much-later re-subscribe (which re-leases fresh instead).457  const tokenHandoffs = new Map<string, { target: { leaseToken: string; wsEndpoint?: string }; at: number }>();458  const takeHandoff = (key: string): { leaseToken: string; wsEndpoint?: string } | undefined => {459    const handed = tokenHandoffs.get(key);460    if (!handed) return undefined;461    tokenHandoffs.delete(key);462    return Date.now() - handed.at <= HANDOFF_MAX_AGE_MS ? handed.target : undefined;463  };464465  /** RE-resolve a SYSTEM (lifecycle) subscription (Slice I-iii — a reconnect / gap repair /466   *  overflow re-subscribe whose mint-time handoff is long consumed). `_rindle/lifecycle` is a467   *  reserved CLIENT-side name (like the lmid query's): the api-server cannot lease it by name,468   *  so the re-resolution re-leases the PARENT labeled query — renewal-as-reauthorization, the469   *  room-token precedent — and picks the matching entry out of the fresh `lifecycle` block. A470   *  reply without the entry throws: the server no longer minting this stream (config off, label471   *  dropped) must not silently re-attach — the source logs the failed subscribe, and I-iv/I-v472   *  own any reaction. */473  const resolveLifecycleTarget = async (args: LifecycleRemoteArgs): Promise<{ leaseToken: string; wsEndpoint?: string }> => {474    const out = (await postLease({ name: args.parent.name, args: args.parent.args })) as QueryLeaseWire;475    const want = systemEntryKey(args);476    const entry = out.lifecycle === undefined477      ? undefined478      : [out.lifecycle.doorbell, ...(out.lifecycle.fence ?? [])].find((e) => systemEntryKey(e) === want);479    if (entry === undefined) {480      throw new Error(481        `lifecycle re-lease of "${args.parent.name}" no longer carries the ${args.table} system lease`,482      );483    }484    return { leaseToken: entry.leaseToken, ...(entry.wsEndpoint !== undefined ? { wsEndpoint: entry.wsEndpoint } : {}) };485  };486487  // Reads-leg connection: a fixed transport (tests/in-process) or a replaceable connection built488  // from `wsUrl` (eager when present, lazy when omitted). A routed lease's `wsEndpoint` migrates it.489  // In affinity mode each transport offers the current ticket as a subprotocol (evaluated per490  // connect, so a reconnect presents the freshest — or freshly cleared — ticket).491  const connection: RemoteOptimisticConnection =492    "transport" in daemon493      ? { transport: daemon.transport }494      : {495          factory: (endpoint) =>496            new WsTransport(497              endpoint,498              affinityOn && affinityStore ? { subprotocols: () => offerSubprotocols(affinityStore) } : {},499            ),500          endpoint: daemon.wsUrl,501        };502503  const source = new RemoteOptimisticSource(connection, clientID, {504    ...(affinityStore ? { affinity: () => (affinityOn ? affinityStore : undefined) } : {}),505    resolveSubscribe: async ({ remote }) => {506      // A fail-open labeled register already leased — present exactly that token (see507      // `tokenHandoffs`); otherwise lease now. Read back the follower's `wsEndpoint` for placement.508      // A `realtime` block on a RE-resolution is deliberately IGNORED here even now that the509      // upgrade dance exists (I-iv): retargeting from inside a reconnect's resolve would race the510      // very re-subscribe it resolves. The §4.1 DOORBELL path owns upgrades (`runUpgrade` below)511      // — the occupancy row that made this lease carry a block will (re)ring it.512      const handed = takeHandoff(remoteKey(remote));513      if (handed) return handed;514      // A SYSTEM (lifecycle) sub re-resolves through its PARENT labeled query (I-iii) — the515      // reserved name is never leaseable by itself (see resolveLifecycleTarget).516      if (remote.name === LIFECYCLE_QUERY_NAME) return resolveLifecycleTarget(remote.args as LifecycleRemoteArgs);517      const out = await postLease(remote);518      return { leaseToken: out.leaseToken, wsEndpoint: out.wsEndpoint };519    },520    pushMutation: createQueuedMutationSender({521      maxBatch: opts.queue?.maxBatch,522      retryDelayMs: opts.queue?.retryDelayMs,523      onRejected: opts.onRejected,524      // A failed flush is retried FOREVER (a network blip must not drop a write), so without a525      // surface here an authority-side failure — a constraint violation the mutator let reach the526      // DB, a 500, a bad deploy — is perfectly silent: the response body carries the reason, the527      // console shows nothing, and every later mutation sits behind it. Log it (LOUD by contract,528      // like `anomaly` below), backing the logging off with the retry so a long outage doesn't529      // flood the console while the FIRST failure is always visible.530      onError: (err, attempt) => {531        if ((attempt & (attempt - 1)) === 0) {532          console.error(533            `[rindle] mutate flush failed (attempt ${attempt}) — retrying; mutations stay pending behind it:`,534            err,535          );536        }537        try {538          opts.onMutationError?.(err, attempt);539        } catch (hookErr) {540          console.error("[rindle] onMutationError handler threw:", hookErr);541        }542      },543      send: async (envelopes) => {544        try {545          const outcomes = (await post(routes.mutate, { envelopes })) as Array<{ accepted: boolean; reason?: string }>;546          return outcomes.map((o): PushOutcome => ({ accepted: o.accepted, reason: o.reason }));547        } catch (err) {548          if (opts.dev?.resetOnMutationGap && reloadAfterMutationGap(err)) {549            return new Promise<never>(() => {});550          }551          throw err;552        }553      },554    }),555  });556557  const { store, backend, mutate } = createOptimisticStore(opts.schema, source, opts.mutators, {558    clientID,559    user: opts.user,560    // The DECLARED router (302 §5): an explicit `domainPolicy` wins; otherwise the app's declared561    // realtime mutators route to the one attached room (`rooms` is read lazily at invoke time —562    // it is declared below, after this construction).563    ...(opts.domainPolicy564      ? { domainPolicy: opts.domainPolicy }565      : opts.realtime?.mutators !== undefined566        ? { domainPolicy: declaredMutatorPolicy(new Set(opts.realtime.mutators), () => rooms) }567        : {}),568    // Room-plane rejection parity (H-v): a room's `mutationOutcome {kind:"rejected"}` frame569    // surfaces through the SAME callback the HTTP mutate path uses below — one app-level570    // rejection surface, whichever authority said no.571    ...(opts.onRejected ? { onRejected: opts.onRejected } : {}),572  });573574  // ---- Rindle Realtime (G-v): resolve-then-register for LABELED queries -------------------------575  //576  // `store.materialize` is wrapped: an UNLABELED query takes the original path byte-identically; a577  // query stamped with a `realtime` label materializes its local view synchronously (the Store's578  // ordinary seed/`unknown` pre-marking runs untouched) while the remote register is SPLIT — the579  // shadowed `backend.registerQuery` below registers the LOCAL half only, and the remote retain580  // attaches when the lease answers: on `realtime.sourceKey`'s room channel when the lease carries581  // a realtime block, on the daemon (fail-open, indistinguishable from unlabeled) when it doesn't.582583  const realtimeOpts = opts.realtime ?? {};584  const roomTransportFactory = realtimeOpts.transport ?? ((endpoint: string) => new WsTransport(endpoint));585  const renewMarginMs = realtimeOpts.renewMarginMs ?? DEFAULT_RENEW_MARGIN_MS;586  const localTables = localTableNames(opts.schema);587  let realtimeClosed = false;588589  const anomaly = (kind: RealtimeAnomalyKind, remote: RemoteQuery, message: string): void => {590    // LOUD by contract: every anomaly hits the console even with a handler installed.591    console.error(`[rindle] realtime ${kind} for query "${remote.name}": ${message}`);592    try {593      realtimeOpts.onAnomaly?.({ kind, name: remote.name, args: remote.args, message });594    } catch (err) {595      console.error("[rindle] realtime onAnomaly handler threw:", err);596    }597  };598599  /** One connected room: its gate key + its `RemoteOptimisticSource`. The promoted-table600   *  bookkeeping lives in the backend's 302 roomTables record (`backend.roomTablesFor(sourceKey)`601   *  — the wire→namespaced-twin rename map; one source of truth for the gate's rename/DROP, the602   *  idempotence check, and `__realtimeInspect`). */603  interface RoomConnection {604    sourceKey: string;605    wsEndpoint: string;606    source: RemoteOptimisticSource;607  }608  /** One room-retained (name, args): its ONE wire sub (`sourceQid` = the creating retain's qid),609   *  how many live views hold it, and the renewal clock. `lifecycleClaims` (I-iii) holds the610   *  system-stream claims RENEWAL-path re-leases made on this query's behalf (a renewal may mint611   *  entries the original attach never saw, e.g. the query became room-served); released when the612   *  last view drops the query. */613  interface RoomQueryState {614    remote: RemoteQuery;615    sourceKey: string;616    sourceQid: QueryId;617    refCount: number;618    exp: number;619    renewTimer?: ReturnType<typeof setTimeout>;620    lifecycleClaims: Set<string>;621    /** The §4.1 doorbell scope this room-served query counts on (`lease.lifecycle.doorbell.scope`622     *  = the wire doc). Captured on attach/upgrade/renewal so the I-v downgrade dance — which runs623     *  from the renewal loop with no lease-block in scope for co-tenant queries — can re-register624     *  each surviving view as an upgrade candidate under the scope its next doorbell will ring. */625    doorbellScope?: string;626  }627  const rooms = new Map<string, RoomConnection>();628  const roomQueries = new Map<string, RoomQueryState>();629  let nextRetainQid: QueryId = REALTIME_RETAIN_QID_BASE;630631  // ---- the §4 lifecycle SYSTEM-STREAM plane, client wiring (Slice I-iii) -----------------------632  //633  // A lease's `lifecycle` block names minted daemon subscriptions over the four `_rindle_*`634  // system tables. They are retained on the DAEMON channel (that is the point of the plane: the635  // outcome/ledger/watermark rows must reach the client with no room socket alive) through636  // `backend.retainSystemQuery` — no store view, no user-visible table; the backend folds their637  // rows at release time. Retains are IDEMPOTENT per (table, scope/doc/clientId): every live638  // holder (a labeled view; a room query's renewal loop) claims a key at most once, one wire sub639  // exists per key, and the LAST holder's release drops it. NO reactions are wired here — the640  // doorbell-triggered re-lease is I-iv, the ghost-drop fence consumer is I-v. Absent block ⇒641  // this whole section never runs.642643  /** One live system sub: the backend retain + how many holders claim it. */644  interface SystemSubState {645    retainQid: QueryId;646    refCount: number;647  }648  const systemSubs = new Map<string, SystemSubState>();649650  /** Claim every entry of `block` for one holder (`claims` — the holder's own claim set; a key651   *  already claimed by THIS holder is skipped, so renewal re-presentations are idempotent).652   *  Unknown tables are skipped (forward-compat: a newer server minting a fifth stream must not653   *  break this client). */654  const claimLifecycle = (claims: Set<string>, block: LifecycleLeaseBlock | undefined, parent: RemoteQuery): void => {655    if (block === undefined || realtimeClosed) return;656    for (const entry of [block.doorbell, ...(block.fence ?? [])]) {657      if (!isSystemTable(entry.table)) continue;658      const key = systemEntryKey(entry);659      if (claims.has(key)) continue;660      let live = systemSubs.get(key);661      if (!live) {662        // The sub's wire identity embeds the PARENT labeled query so a RE-resolution can663        // re-lease it (resolveLifecycleTarget); the minted token is handed to the resolver so664        // the first subscribe presents exactly it — one lease per subscribe, the G-v cadence.665        const remote: RemoteQuery = {666          name: LIFECYCLE_QUERY_NAME,667          args: {668            table: entry.table,669            ...(entry.scope !== undefined ? { scope: entry.scope } : {}),670            ...(entry.doc !== undefined ? { doc: entry.doc } : {}),671            ...(entry.clientId !== undefined ? { clientId: entry.clientId } : {}),672            parent: { name: parent.name, args: parent.args },673          } satisfies LifecycleRemoteArgs,674        };675        tokenHandoffs.set(remoteKey(remote), {676          target: { leaseToken: entry.leaseToken, ...(entry.wsEndpoint !== undefined ? { wsEndpoint: entry.wsEndpoint } : {}) },677          at: Date.now(),678        });679        const retainQid = nextRetainQid++;680        backend.retainSystemQuery(retainQid, remote, {681          table: entry.table,682          ...(entry.scope !== undefined ? { scope: entry.scope } : {}),683          ...(entry.doc !== undefined ? { doc: entry.doc } : {}),684        });685        live = { retainQid, refCount: 0 };686        systemSubs.set(key, live);687      }688      live.refCount++;689      claims.add(key);690    }691  };692693  /** Release one holder's claims; the LAST holder of a key releases the backend retain (the wire694   *  sub unsubscribes; the backend's folded fence/occupancy STATE deliberately survives). */695  const releaseLifecycle = (claims: Set<string>): void => {696    for (const key of claims) {697      const live = systemSubs.get(key);698      if (!live) continue;699      if (--live.refCount <= 0) {700        systemSubs.delete(key);701        backend.releaseSystemQuery(live.retainQid);702      }703    }704    claims.clear();705  };706707  /** Register the room's OWNED tables (302 §2 — one source per table): every lease table spec708   *  whose `writable` kind is not `"none"` names a table the room owns; the backend registers a709   *  namespaced engine twin the room channel feeds and the room-homed views swap onto. Context710   *  tables (`kind: "none"`) are deliberately NOT registered — the daemon is their sole711   *  authority, and the gate DROPS the room's relayed copies (302 §6). Idempotent per712   *  (sourceKey, table) — the backend's record is the one source of truth; a footprint table713   *  absent from the client schema has nothing to hold rows for and is skipped backend-side. */714  const promoteRoomTables = (room: RoomConnection, specs: RealtimeLeaseTableSpec[]): void => {715    const owned = specs.filter((s) => s.writable.kind !== "none").map((s) => s.table);716    // ALWAYS register — even an all-context lease's `owned = []`: the installed (empty) map is717    // what makes the room gate DROP every relayed delta (302 §6). Skipping the call would leave718    // `gate.tableMap` undefined — the DAEMON identity path — and fold the room's relayed copies719    // of daemon-authoritative rows verbatim into the plain tables, two syncs fighting over one720    // baseline (stale overwrites + dueling GC removes).721    backend.registerRoomTables(room.sourceKey, owned);722  };723724  /** (Re)arm a room query's proactive renewal from its current `exp`. Timers are unref'd (Node)725   *  so an idle renewal never holds the process open; cleared on release/close. */726  const scheduleRenewal = (key: string, state: RoomQueryState, delayMs?: number): void => {727    if (state.renewTimer !== undefined) clearTimeout(state.renewTimer);728    if (realtimeClosed) return;729    const delay = delayMs ?? Math.max(state.exp - Date.now() - renewMarginMs, MIN_RENEW_DELAY_MS);730    state.renewTimer = setTimeout(() => {731      state.renewTimer = undefined;732      void renewRoomQuery(key, state);733    }, delay);734    (state.renewTimer as unknown as { unref?: () => void }).unref?.();735  };736737  /** Proactive token renewal (renewal-as-reauthorization): re-lease through the SAME app query738   *  route; only a reply WITH a realtime block (and the SAME sourceKey) re-authorizes — the fresh739   *  token is handed to the resolver and the live sub re-subscribes with it BEFORE the room740   *  shell's TTL backstop can drop it. A reply without the block is the DOWNGRADE signal: loud;741   *  the sub is left to die at `exp` (the graceful downgrade dance is Slice I). */742  const renewRoomQuery = async (key: string, state: RoomQueryState): Promise<void> => {743    if (realtimeClosed || roomQueries.get(key) !== state) return;744    let lease: QueryLeaseWire;745    try {746      lease = await postLease(state.remote);747    } catch (err) {748      anomaly("lease-failed", state.remote, `token renewal failed: ${String((err as Error)?.message ?? err)}`);749      // Retry while the current token is still live; past `exp` the shell has dropped the sub750      // anyway and the next reconnect re-resolution owns recovery.751      if (Date.now() < state.exp && roomQueries.get(key) === state) scheduleRenewal(key, state, RENEW_RETRY_MS);752      return;753    }754    if (realtimeClosed || roomQueries.get(key) !== state) return;755    // I-iii: a renewal re-presents the lifecycle block — re-claim idempotently (a key this query756    // already holds is skipped; a NEW entry, e.g. the fence appearing when the query became757    // room-served mid-life, is retained now). Claimed BEFORE the realtime check on purpose: a758    // downgraded renewal (no realtime block) still carries the doorbell, and the occupancy759    // stream must survive the downgrade (it is what re-upgrades, §4.1).760    claimLifecycle(state.lifecycleClaims, lease.lifecycle, state.remote);761    const rt = lease.realtime;762    if (rt === undefined) {763      // No realtime block: the occupancy gate closed server-side. WITH a §4.2 fence, run the764      // graceful I-v downgrade dance (retarget → demote behind the watermark → re-arm the765      // doorbell); WITHOUT one, stay loud (a pre-I-v server, or `lifecycle.drainRoom`766      // unconfigured — nothing to ghost behind soundly).767      if (lease.realtimeFence !== undefined) {768        // Hand the fresh daemon token so the driving query's daemon re-subscribe presents it (no769        // extra POST); co-tenant queries sharing the room re-lease on their own daemon re-subscribe.770        tokenHandoffs.set(key, {771          target: { leaseToken: lease.leaseToken, ...(lease.wsEndpoint !== undefined ? { wsEndpoint: lease.wsEndpoint } : {}) },772          at: Date.now(),773        });774        downgradeRoom(lease.realtimeFence);775      } else {776        anomaly(777          "downgrade",778          state.remote,779          "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",780        );781      }782      return;783    }784    if (rt.sourceKey !== state.sourceKey) {785      anomaly(786        "source-key-changed",787        state.remote,788        `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)`,789      );790      return;791    }792    const room = rooms.get(state.sourceKey);793    if (!room) return;794    try {795      // Promotion is per-(sourceKey, table) idempotent, so a renewal compiles only tables that796      // are NEW to the lease (a profile edit mid-life). A compile throw (schema skew) must not797      // kill the renewal — this is a timer-driven void promise, so an escape would be an798      // unhandled rejection — and the live sub keeps its already-promoted tables + the fresh799      // token below; the new table's routing simply never arms (its writes route slow).800      promoteRoomTables(room, rt.tables);801    } catch (err) {802      anomaly("lease-failed", state.remote, `renewal promotion failed: ${String((err as Error)?.message ?? err)} (the room keeps its already-promoted tables)`);803    }804    state.exp = rt.exp;805    if (lease.lifecycle?.doorbell.scope !== undefined) state.doorbellScope = lease.lifecycle.doorbell.scope;806    // Re-present NOW with the fresh token: hand it to the resolver and re-subscribe the live sub807    // (an ordinary epoch bump server-side; the fresh snapshot re-hydrates through the room gate as808    // a net-zero footprint diff).809    tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });810    room.source.registerQuery(state.sourceQid, state.remote);811    scheduleRenewal(key, state);812  };813814  /** The room channel's subscribe resolver: first subscribe consumes the handed fresh token; every815   *  RE-resolution (reconnect, gap repair, endpoint recovery) is a full re-lease through the app816   *  route — renewal-as-reauthorization, so a revoked/downgraded query cannot silently re-attach. */817  const roomResolver =818    (room: RoomConnection) =>819    async ({ remote }: { queryId: QueryId; remote: RemoteQuery }) => {820      const key = remoteKey(remote);821      const handed = takeHandoff(key);822      if (handed) return handed;823      const lease = await postLease(remote);824      const rt = lease.realtime;825      if (rt === undefined) {826        anomaly(827          "downgrade",828          remote,829          "the re-lease carries no realtime block — the query is no longer room-served (room subscribe aborted; graceful downgrade is Slice I)",830        );831        throw new Error(`realtime downgrade: query "${remote.name}" is no longer room-served`);832      }833      if (rt.sourceKey !== room.sourceKey) {834        anomaly(835          "source-key-changed",836          remote,837          `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)`,838        );839        throw new Error(`realtime sourceKey changed for query "${remote.name}"`);840      }841      // A re-lease may widen the footprint (new tables) and always refreshes the renewal clock.842      promoteRoomTables(room, rt.tables);843      const state = roomQueries.get(key);844      if (state) {845        state.exp = rt.exp;846        scheduleRenewal(key, state);847        // I-iii: a re-resolution's lifecycle block re-claims like a renewal's (idempotent).848        claimLifecycle(state.lifecycleClaims, lease.lifecycle, state.remote);849      }850      return { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint };851    };852853  /** Connect (once) the room source for a lease's `sourceKey` — multiple labeled queries on the854   *  same room share the one source/gate. The endpoint is the lease's DEDICATED855   *  `realtime.wsEndpoint`; the TOP-LEVEL `wsEndpoint` (whole-daemon-session migration) never856   *  reaches a room transport. NO `pushMutation` override: a room-domain mutation ships over the857   *  ROOM socket itself (§7.5 sent-pins-domain — the backend's `channelFor` picks this source). */858  const ensureRoom = (rt: RealtimeLeaseBlock): RoomConnection => {859    const existing = rooms.get(rt.sourceKey);860    if (existing) return existing;861    const room: RoomConnection = {862      sourceKey: rt.sourceKey,863      wsEndpoint: rt.wsEndpoint,864      source: undefined as unknown as RemoteOptimisticSource,865    };866    room.source = new RemoteOptimisticSource(867      { factory: roomTransportFactory, endpoint: rt.wsEndpoint },868      clientID,869      { resolveSubscribe: roomResolver(room) },870    );871    rooms.set(rt.sourceKey, room);872    // connectSource BEFORE any retain on this channel (the backend throws otherwise); it also873    // auto-registers the reserved lmid system query, so the room's confirms fold into874    // `watermark[sourceKey]` from the first frame.875    backend.connectSource(rt.sourceKey, room.source);876    return room;877  };878879  // ---- the §4.1 doorbell reaction + upgrade retarget (Slice I-iv) ------------------------------880  //881  // A labeled query the lease left DAEMON-attached (the api-server's occupancy gate suppressed882  // its room-serve — or the server simply couldn't serve it yet) registers as an UPGRADE883  // CANDIDATE under its doorbell scope. The backend's scope-session fold then reports occupancy884  // per release (`onScopeSessions`); on the 0→≥1 transition of ANOTHER clientID's unexpired885  // session the candidate re-leases ONCE — debounced per (name, args): one in-flight re-lease,886  // repeat doorbells coalesce into it — and a reply that NOW carries a realtime block runs the887  // retarget: ensureRoom → promoteRoomTables → hand the roomToken → `backend.retargetRemoteQuery`888  // (the two-phase no-flicker cutover; see its doc) — mirroring `attachRoom`'s exact order889  // (connect + promote BEFORE any wire sub moves), with the retarget primitive replacing the890  // fresh retain. Failures fail OPEN and LOUD: the daemon retain is untouched (the primitive891  // validates before mutating), the anomaly surfaces, and the NEXT doorbell/renewal is the retry892  // — no retry loop of our own. A reply still without a block is SILENT: suppression is the893  // occupancy gate's designed state, not an anomaly.894895  interface UpgradeViewHook {896    /** Flip this view's client-side bookkeeping onto the room query state (sets `roomKey`,897     *  joins the refcount) — a released view declines. */898    adoptRoom(key: string, state: RoomQueryState): void;899    /** The I-v inverse: detach this view's bookkeeping from a dismantled room query state (the900     *  downgrade deleted it wholesale — the view must not decrement a dead record on destroy). */901    clearRoom(): void;902  }903  interface UpgradeCandidate {904    remote: RemoteQuery;905    scope: string;906    views: Set<UpgradeViewHook>;907    inFlight: boolean;908    /** Lifecycle system-stream claims this candidate carries between a DOWNGRADE and the next909     *  upgrade (I-v): the dismantled room query's renewal-loop claims move here so the fence910     *  streams (the ghost drop's watermark input) outlive the room state. Adopted by the next911     *  upgrade's fresh {@link RoomQueryState}; released when the candidate dies with its last912     *  view. Empty for a fresh (never-downgraded) candidate. */913    claims: Set<string>;914  }915  /** Candidates by remote key — ONE re-lease upgrades every view of the (name, args) at once916   *  (the backend moves the sub wholesale). */917  const upgradeCandidates = new Map<string, UpgradeCandidate>();918  /** EVERY live labeled view's hook, by remote key (I-v): the downgrade dance runs from the919   *  renewal loop — no view reference in scope — yet must re-register each surviving view as an920   *  upgrade candidate (the doorbell re-arms the next upgrade) and clear its room bookkeeping.921   *  Registered at materialize, dropped at destroy. */922  const labeledViewHooks = new Map<string, Set<UpgradeViewHook>>();923  /** Last observed other-session count per scope — the 0→≥1 transition tracker. A first924   *  observation at ≥1 counts as a transition (there was none before we could see). */925  const lastOthers = new Map<string, number>();926927  const runUpgrade = async (cand: UpgradeCandidate): Promise<void> => {928    let lease: QueryLeaseWire;929    try {930      lease = await postLease(cand.remote);931    } catch (err) {932      anomaly(933        "lease-failed",934        cand.remote,935        `doorbell re-lease failed: ${String((err as Error)?.message ?? err)} (staying daemon-attached; the next doorbell/renewal is the retry)`,936      );937      return;938    }939    if (realtimeClosed || cand.views.size === 0) return; // torn down while the lease was in flight940    const rt = lease.realtime;941    if (rt === undefined) return; // still gated server-side (e.g. its minSessions is higher) — stay daemon-attached, silently942    const key = remoteKey(cand.remote);943    if (roomQueries.has(key)) return; // already room-attached (a racing fresh view won) — nothing to move944    try {945      // The G-v attach order, verbatim, up to the sub move: room source/gate first, engine946      // promotion second (both idempotent — `ensureRoom` per sourceKey, `promoteRoomTables` per947      // (sourceKey, table) via the backend's routing record), THEN the wire cutover with the948      // fresh roomToken handed to the room resolver. `retargetRemoteQuery` is itself idempotent949      // per (query, sourceKey), so a duplicate doorbell that slipped the `inFlight` guard cannot950      // double-attach.951      const room = ensureRoom(rt);952      promoteRoomTables(room, rt.tables);953      tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });954      const sourceQid = backend.retargetRemoteQuery(cand.remote, rt.sourceKey);955      const state: RoomQueryState = {956        remote: cand.remote,957        sourceKey: rt.sourceKey,958        sourceQid,959        refCount: 0,960        exp: rt.exp,961        // ADOPT the candidate's claims (I-v): a re-upgrade after a downgrade inherits the fence962        // streams the ghost still needs, already subscribed — so they are NOT re-subscribed; a963        // fresh candidate's set is empty. `upgradeCandidates.delete(key)` below leaves the set964        // owned by this state.965        lifecycleClaims: cand.claims,966        doorbellScope: cand.scope,967      };968      // The re-lease's lifecycle block now carries the fence bundle (the query is room-served):969      // claim it on the query's renewal-loop set (idempotent — an adopted key is skipped), exactly970      // as a renewal that turned room-served mid-life would (I-iii) — released when the last view971      // drops the query.972      claimLifecycle(state.lifecycleClaims, lease.lifecycle, cand.remote);973      // No awaits since the `views.size` check above — destroys cannot have interleaved, so at974      // least one view adopts (a released one declines via its own flag, defensively).975      for (const view of [...cand.views]) view.adoptRoom(key, state);976      upgradeCandidates.delete(key);977      roomQueries.set(key, state);978      scheduleRenewal(key, state);979    } catch (err) {980      // Fail open: the retarget primitive validates before mutating, so the daemon retain is981      // intact — the query keeps serving from the daemon exactly as before the doorbell.982      tokenHandoffs.delete(key); // never leave a room token where the DAEMON resolver could eat it983      anomaly(984        "lease-failed",985        cand.remote,986        `upgrade retarget failed: ${String((err as Error)?.message ?? err)} (staying daemon-attached; the next doorbell/renewal is the retry)`,987      );988    }989  };990991  /** Kick every idle candidate on `scope` — the doorbell reaction proper. */992  const maybeUpgrade = (scope: string): void => {993    if (realtimeClosed) return;994    for (const cand of upgradeCandidates.values()) {995      if (cand.scope !== scope || cand.inFlight || cand.views.size === 0) continue;996      cand.inFlight = true;997      void runUpgrade(cand).finally(() => {998        cand.inFlight = false;999      });1000    }1001  };10021003  const registerUpgradeCandidate = (remote: RemoteQuery, scope: string, hook: UpgradeViewHook): void => {1004    const key = remoteKey(remote);1005    let cand = upgradeCandidates.get(key);1006    if (!cand) upgradeCandidates.set(key, (cand = { remote, scope, views: new Set(), inFlight: false, claims: new Set() }));1007    cand.views.add(hook);1008    // Registration-time check: a doorbell that FOLDED before this candidate existed (the lease1009    // resolve raced the occupancy delta) must still trigger — same count rule as the events.1010    if (backend.otherScopeSessions(scope) >= 1) maybeUpgrade(scope);1011  };10121013  const dropUpgradeCandidate = (remote: RemoteQuery, hook: UpgradeViewHook): void => {1014    const cand = upgradeCandidates.get(remoteKey(remote));1015    if (!cand) return;1016    cand.views.delete(hook);1017    if (cand.views.size === 0) {1018      upgradeCandidates.delete(remoteKey(remote));1019      // A candidate carrying a downgrade's fence-stream claims (I-v) releases them with its last1020      // view — the LAST holder unsubscribes the wire sub (empty set ⇒ no-op for a fresh candidate).1021      releaseLifecycle(cand.claims);1022    }1023  };10241025  /** The §4.2 graceful downgrade dance (Slice I-v): a renewal came back with NO realtime block1026   *  but WITH a fence. Handle the WHOLE room at once — retarget every live sub sharing the source1027   *  onto the daemon (the I-iv retarget in REVERSE), demote the room source behind the watermark1028   *  fence (its rows persist as a FROZEN ghost until the daemon plane absorbs the final flush),1029   *  close the room transport, and re-register each surviving view as an upgrade candidate so the1030   *  next doorbell re-upgrades the same doc. `demoteRoomSource` refuses to demote while any sub is1031   *  still on the channel, so all subs must retarget first; a co-tenant query's own later renewal1032   *  then finds the room gone and no-ops (retarget-to-daemon + demote are both idempotent).1033   *1034   *  Ordering with disconnect: `demoteRoomSource` → `disconnectSource` drops the room gate, which1035   *  makes the retarget's deferred phase-2 GC (`flushRetargetGc`, run at the daemon's first1036   *  release) a no-op — it deletes the pending-GC record then finds no old gate to rewind, so the1037   *  room slice's rows leave ONLY through the ghost's `removeRoomSource` under the fence (never via1038   *  a GC rewind that would surface a lagging follower's pre-flush images). */1039  const downgradeRoom = (fence: RealtimeFenceBlock): void => {1040    if (realtimeClosed) return;1041    const sourceKey = fence.sourceKey;1042    const onRoom = [...roomQueries].filter(([, s]) => s.sourceKey === sourceKey);1043    for (const [key, state] of onRoom) {1044      backend.retargetRemoteQuery(state.remote, "daemon"); // room → daemon; the no-block reply IS a daemon lease1045      if (state.renewTimer !== undefined) clearTimeout(state.renewTimer);1046      roomQueries.delete(key);1047      const hooks = labeledViewHooks.get(key);1048      if (state.doorbellScope !== undefined && hooks !== undefined && hooks.size > 0) {1049        let cand = upgradeCandidates.get(key);1050        if (!cand) {1051          upgradeCandidates.set(key, (cand = { remote: state.remote, scope: state.doorbellScope, views: new Set(), inFlight: false, claims: new Set() }));1052        }1053        // Carry the renewal-loop's lifecycle claims (the fence streams — the ghost's watermark1054        // input, delivered on the DAEMON channel) onto the candidate so they outlive the room1055        // state and are adopted by the next upgrade (`runUpgrade`).1056        for (const c of state.lifecycleClaims) cand.claims.add(c);1057        state.lifecycleClaims.clear();1058        for (const h of hooks) {1059          h.clearRoom(); // forget the dead room bookkeeping (destroy must not decrement a gone state)1060          cand.views.add(h);1061        }1062        // Self-heal: a collaborator still present at downgrade re-rings immediately. Normally the1063        // scope is solo here (that IS why the server downgraded), so this is inert.1064        if (backend.otherScopeSessions(state.doorbellScope) >= 1) maybeUpgrade(state.doorbellScope);1065      } else {1066        // No re-upgrade possible (no doorbell scope, or no surviving view): release the claims.1067        if (hooks !== undefined) for (const h of hooks) h.clearRoom();1068        releaseLifecycle(state.lifecycleClaims);1069      }1070    }1071    backend.demoteRoomSource(sourceKey, fence.doc, fence.finalFlushSeq); // frozen ghost behind the fence1072    const room = rooms.get(sourceKey);1073    if (room !== undefined) {1074      rooms.delete(sourceKey);1075      room.source.close(); // every sub retargeted off it1076    }1077  };10781079  // The trigger: the backend reports (scope, other-session count) after each release that folded1080  // occupancy rows; the 0→≥1 transition rings. `others` never counts our own clientID or expired1081  // rows (the backend's one rule), so a solo tab's own row cannot ring its own bell, and a stale1082  // collaborator aging out then re-appearing rings again (0→1 anew) — which is idempotent here1083  // (an already-room-attached query has no candidate left to kick).1084  backend.onScopeSessions(({ scope, others }) => {1085    const prev = lastOthers.get(scope) ?? 0;1086    lastOthers.set(scope, others);1087    if (prev === 0 && others >= 1) maybeUpgrade(scope);1088  });10891090  // The I-v stuck-downgrade surface (§7.5): a ghost whose watermark fence cleared but whose sent1091  // room-domain mids never resolved through the daemon-carried folds. The ghost HOLDS (no1092  // timeout-retire is invented) — surface it loudly, naming the mids.1093  backend.onDowngradeStuck(({ sourceKey, doc, mids }) => {1094    anomaly(1095      "downgrade-stuck",1096      { name: sourceKey, args: { doc, mids } },1097      `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)`,1098    );1099  });11001101  // The split-register ticket: set (synchronously) by the wrapped `materialize` just before it1102  // delegates, consumed by the shadowed `backend.registerQuery` below — which registers the LOCAL1103  // half only (the Store's SSR-seed + `unknown` pre-marking has already run) and defers the remote1104  // retain to the lease resolution. Everything else (unlabeled queries, React retains, re-registers)1105  // flows through untouched.1106  let labeledTicket: { consumed: boolean } | null = null;1107  const origRegisterQuery = backend.registerQuery.bind(backend);1108  backend.registerQuery = (qid: QueryId, ast: Ast, remote?: RemoteQuery, channel?: string): void => {1109    if (labeledTicket === null || remote === undefined) {1110      origRegisterQuery(qid, ast, remote, channel);1111      return;1112    }1113    const ticket = labeledTicket;1114    labeledTicket = null;1115    ticket.consumed = true;1116    // The LOCAL half of the split retain (the backend's documented split-retain shape): the remote1117    // attaches via `retainRemoteQuery` on the channel the lease names, once it answers.1118    origRegisterQuery(qid, ast, undefined);1119  };11201121  const origMaterialize = store.materialize.bind(store) as (1122    query: Query<any, any, any>,1123    mOpts?: unknown,1124  ) => MaterializedViewLike;11251126  /** The G-v labeled-materialize: synchronous local view now, remote retain when the lease answers. */1127  const materializeLabeled = (query: Query<any, any, any> & { name: string }, mOpts?: unknown): MaterializedViewLike => {1128    const remote: RemoteQuery = { name: query.name, args: query.args };1129    const ast = query.ast() as Ast;1130    // E3 parity (201-LOCAL-ONLY-TABLES-DESIGN.md): the unlabeled remote path rejects a remote query1131    // naming a local-only table synchronously inside materialize; the labeled path defers the1132    // remote register past the lease, so run the SAME guard here — identical throw, identical1133    // timing, no view leaked.1134    for (const t of collectAstTables(ast)) {1135      if (localTables.has(t)) {1136        throw new Error(1137          `remote query "${remote.name}" references local-only table "${t}" — local tables never cross the wire (201-LOCAL-ONLY-TABLES-DESIGN.md E3).`,1138        );1139      }1140    }1141    const ticket = { consumed: false };1142    labeledTicket = ticket;1143    let view: MaterializedViewLike;1144    try {1145      view = origMaterialize(query, mOpts);1146    } finally {1147      labeledTicket = null;1148    }1149    const localQid = view.qid;1150    // The Store pre-marked the view `unknown` (a remote-identity register under a lifecycle1151    // backend), but the local-half register flipped it back to `complete` (a local-only1152    // registration is synchronously authoritative). Re-flip for the lease window so the view never1153    // reads server-authoritative before ANY authority answered — the retain below recomputes it1154    // against real hydration. (`readOnce` on a labeled query correctly waits because of this.)1155    if (ticket.consumed) flipResultTypeUnknown(view);11561157    let released = false;1158    let retainQid: QueryId | undefined;1159    let roomKey: string | undefined;1160    // I-iii: the lifecycle system-stream claims THIS VIEW holds (claimed once per key when its1161    // lease resolves; released with the view — the LAST holder of a scope/doc drops the sub).1162    const viewLifecycleClaims = new Set<string>();1163    // I-iv/I-v: this view's hook — `adoptRoom` (an upgrade joins the view to the new room state)1164    // and `clearRoom` (the downgrade dismantled the room state wholesale — forget it so destroy1165    // never decrements a dead record). Registered in `labeledViewHooks` for EVERY labeled view so1166    // the downgrade dance (which runs from the renewal loop, no view in scope) can find and1167    // re-candidate each surviving view; used as the candidate hook for the daemon-attached shape.1168    const viewHook: UpgradeViewHook = {1169      adoptRoom: (key: string, state: RoomQueryState): void => {1170        if (released) return; // a released view never joins (its retain is already gone)1171        roomKey = key;1172        state.refCount++;1173      },1174      clearRoom: (): void => {1175        roomKey = undefined;1176      },1177    };1178    const hookKey = remoteKey(remote);1179    let viewHooks = labeledViewHooks.get(hookKey);1180    if (viewHooks === undefined) labeledViewHooks.set(hookKey, (viewHooks = new Set()));1181    viewHooks.add(viewHook);11821183    /** Fail-open: retain on the daemon, indistinguishable from an unlabeled query. The ONE lease1184     *  already resolved (when it succeeded) is handed to the daemon resolver so the subscribe1185     *  presents exactly that token — one POST per subscribe, the unlabeled cadence. */1186    const attachDaemon = (target?: { leaseToken: string; wsEndpoint?: string }): void => {1187      if (target) tokenHandoffs.set(remoteKey(remote), { target, at: Date.now() });1188      retainQid = nextRetainQid++;1189      backend.retainRemoteQuery(retainQid, remote, localQid, ast);1190    };11911192    /** Room-served: ensure the shared room source/gate, promote the engine per the lease's table1193     *  specs BEFORE retaining, then retain the sub on the room channel with the roomToken handed1194     *  to the resolver. `doorbellScope` (from the lease's lifecycle block) is pinned on the room1195     *  state so a later I-v downgrade can re-candidate this query. */1196    const attachRoom = (rt: RealtimeLeaseBlock, doorbellScope?: string): void => {1197      const key = remoteKey(remote);1198      const existing = roomQueries.get(key);1199      if (existing && existing.sourceKey !== rt.sourceKey) {1200        anomaly(1201          "source-key-changed",1202          remote,1203          `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)`,1204        );1205        return;1206      }1207      const room = ensureRoom(rt);1208      promoteRoomTables(room, rt.tables);1209      // Only the retain that CREATES the wire sub consumes a token at subscribe time — hand one1210      // exactly then (a refcount-only retain issues no wire subscribe; the age cap covers races).1211      if (!existing) {1212        tokenHandoffs.set(key, { target: { leaseToken: rt.roomToken, wsEndpoint: rt.wsEndpoint }, at: Date.now() });1213      }1214      retainQid = nextRetainQid++;1215      backend.retainRemoteQuery(retainQid, remote, localQid, ast, rt.sourceKey);1216      let state = existing;1217      if (!state) {1218        state = { remote, sourceKey: rt.sourceKey, sourceQid: retainQid, refCount: 0, exp: rt.exp, lifecycleClaims: new Set() };1219        roomQueries.set(key, state);1220      } else {1221        state.exp = Math.max(state.exp, rt.exp);1222      }1223      if (doorbellScope !== undefined) state.doorbellScope = doorbellScope;1224      state.refCount++;1225      roomKey = key;1226      scheduleRenewal(key, state);1227    };12281229    // Resolve-then-register: the lease FIRST; the register follows its verdict.1230    void (async () => {1231      let lease: QueryLeaseWire;1232      try {1233        lease = await postLease(remote);1234      } catch (err) {1235        // The lease POST itself failed: fail OPEN to the daemon with no handoff — the daemon1236        // retain's own resolver re-leases (and the transport's resync retries), exactly an1237        // unlabeled query's recovery story.1238        anomaly("lease-failed", remote, `query lease failed: ${String((err as Error)?.message ?? err)}`);1239        if (!released && !realtimeClosed) attachDaemon();1240        return;1241      }1242      if (released || realtimeClosed) return;1243      try {1244        if (lease.realtime === undefined) {1245          attachDaemon({ leaseToken: lease.leaseToken, wsEndpoint: lease.wsEndpoint });1246          // I-iv: a daemon-attached labeled view under a doorbell scope is an UPGRADE CANDIDATE —1247          // the occupancy stream's 0→≥1 transition re-leases it and (block permitting) retargets1248          // the whole (name, args) sub onto the room. A blockless lease (pre-lifecycle server)1249          // registers nothing: the plane stays inert-until-fed.1250          const doorbellScope = lease.lifecycle?.doorbell.scope;1251          if (doorbellScope !== undefined) registerUpgradeCandidate(remote, doorbellScope, viewHook);1252        } else {1253          attachRoom(lease.realtime, lease.lifecycle?.doorbell.scope);1254        }1255        // I-iii: retain the lease's lifecycle system streams on the DAEMON channel — for the1256        // room-served AND the daemon-served (labeled, not covered) shapes alike (the doorbell1257        // rides both; the fence only where a room block exists). Absent block ⇒ no-op — a1258        // pre-lifecycle server leaves this client byte-identical.1259        claimLifecycle(viewLifecycleClaims, lease.lifecycle, remote);1260      } catch (err) {1261        // Fail OPEN, exactly like a lease without a block: a room-attach throw (most plausibly a1262        // lease `where` this bundle's schema cannot compile — version skew) must not strand the1263        // view local-only. `retainQid === undefined` ⇒ no retain was established (room OR daemon),1264        // so the daemon fallback cannot double-attach; a throw AFTER a successful retain (a1265        // lifecycle claim, say) leaves the live sub alone. Partial promotion is harmless (it is1266        // idempotent, and the room gate re-proves any routed write) — but never leave the room1267        // token where the daemon resolver could eat it.1268        anomaly("lease-failed", remote, `realtime attach failed: ${String((err as Error)?.message ?? err)} (falling back to the daemon lease)`);1269        if (!released && !realtimeClosed && retainQid === undefined) {1270          tokenHandoffs.delete(remoteKey(remote));1271          try {1272            attachDaemon({ leaseToken: lease.leaseToken, wsEndpoint: lease.wsEndpoint });1273          } catch (fallbackErr) {1274            anomaly("lease-failed", remote, `daemon fallback failed: ${String((fallbackErr as Error)?.message ?? fallbackErr)}`);1275          }1276        }1277      }1278    })();12791280    // Teardown rides the view: release the remote retain (room or daemon) with the local view, and1281    // drop the room query's refcount/renewal when the last view goes.1282    const origDestroy = view.destroy.bind(view);1283    view.destroy = () => {1284      if (!released) {1285        released = true;1286        if (retainQid !== undefined) backend.releaseRemoteQuery(retainQid);1287        // I-iv/I-v: drop this view's hook — from the per-key hook registry and the candidate set1288        // (both no-ops when it was never a candidate; the candidate's own last-view release frees1289        // any fence-stream claims a downgrade parked on it).1290        viewHooks.delete(viewHook);1291        if (viewHooks.size === 0) labeledViewHooks.delete(hookKey);1292        dropUpgradeCandidate(remote, viewHook);1293        // I-iii: this view's lifecycle claims drop with it; the LAST holder of a key releases1294        // the system sub (the backend's folded fence/occupancy state deliberately survives).1295        releaseLifecycle(viewLifecycleClaims);1296        if (roomKey !== undefined) {1297          const state = roomQueries.get(roomKey);1298          if (state && --state.refCount <= 0) {1299            if (state.renewTimer !== undefined) clearTimeout(state.renewTimer);1300            roomQueries.delete(roomKey);1301            // …including any claims the renewal loop made on this query's behalf.1302            releaseLifecycle(state.lifecycleClaims);1303          }1304        }1305      }1306      origDestroy();1307    };1308    return view;1309  };13101311  store.materialize = ((query: Query<any, any, any>, mOpts?: unknown) => {1312    const label = (query as { realtime?: RealtimeQueryLabel }).realtime;1313    if (label === undefined || typeof query.name !== "string") return origMaterialize(query, mOpts);1314    return materializeLabeled(query as Query<any, any, any> & { name: string }, mOpts);1315  }) as Store<S>["materialize"];13161317  // Local-table persistence (207 §5.2): attach immediately after the store exists — before any1318  // app write can reach `writeLocal` — and AWAIT the initial restore (one `getAll` over small1319  // tables) so the first render never flashes empty local state. Restored rows arrive as ordinary1320  // deltas, so correctness never needed the gate — only UX does.1321  let persistence: LocalPersistence | undefined;1322  if (opts.persistLocal) {1323    persistence = attachLocalPersistence(backend, opts.schema, opts.persistLocal);1324    await persistence.ready;1325  }13261327  const queryEnsures = new QueryEnsureCache(store);13281329  // Drain folds before the tab goes away (FOLDED-MUTATIONS-DESIGN §0.1/§3): a fold deliberately1330  // holds its server write through the debounce window, so an unclean navigation would otherwise1331  // lose the applied-locally tail. `pagehide`/`beforeunload` is the last-chance flush; `close`1332  // also drains. The same hook is the persistence layer's best-effort final forward/persist1333  // (207 §9 — bounds the unacked-tail loss). (Best-effort: an unclean crash still loses the1334  // tail — an accepted cost, §0.1.)1335  const flushFolds = () => backend.flushFolds();1336  const onPageHide = () => {1337    flushFolds();1338    void persistence?.flush();1339  };1340  // Structural typing — this package has no DOM lib, but the browser global carries these.1341  const target = globalThis as unknown as {1342    addEventListener?: (type: string, listener: () => void) => void;1343    removeEventListener?: (type: string, listener: () => void) => void;1344  };1345  target.addEventListener?.("pagehide", onPageHide);1346  target.addEventListener?.("beforeunload", onPageHide);13471348  return {1349    store,1350    backend,1351    mutate,1352    ensure: (query, options) => queryEnsures.ensure(query, options),1353    flushFolds,1354    clientID,1355    close: () => {1356      flushFolds();1357      target.removeEventListener?.("pagehide", onPageHide);1358      target.removeEventListener?.("beforeunload", onPageHide);1359      persistence?.close(); // releases leadership + the channel + the IDB handle (207 P10)1360      queryEnsures.close();1361      // Realtime teardown: renewal timers first (no renewal may fire into a closing client), then1362      // every room socket; in-flight lease resolutions are made inert via the flag.1363      realtimeClosed = true;1364      upgradeCandidates.clear(); // no doorbell may retarget into a closing client1365      for (const state of roomQueries.values()) {1366        if (state.renewTimer !== undefined) clearTimeout(state.renewTimer);1367      }1368      roomQueries.clear();1369      for (const room of rooms.values()) room.source.close();1370      rooms.clear();1371      source.close();1372    },1373    __realtimeInspect: (): RealtimeInspect => ({1374      rooms: Object.fromEntries(1375        [...rooms].map(([sourceKey, room]) => [1376          sourceKey,1377          {1378            wsEndpoint: room.wsEndpoint,1379            // Read back from the backend's room-table registry (302 §2): wire → engine table.1380            promoted: Object.fromEntries(backend.roomTablesFor(sourceKey)),1381            queries: Object.fromEntries(1382              [...roomQueries]1383                .filter(([, s]) => s.sourceKey === sourceKey)1384                .map(([key, s]) => [1385                  key,1386                  { name: s.remote.name, sourceQid: s.sourceQid, exp: s.exp, refCount: s.refCount },1387                ]),1388            ),1389          },1390        ]),1391      ),1392    }),1393  };1394}13951396// --------------------------------------------------------------------------- realtime helpers13971398/** The minimal view surface the labeled-materialize wrapper needs (structural — the real return1399 *  type flows through unchanged). */1400interface MaterializedViewLike {1401  readonly qid: QueryId;1402  destroy(): void;1403}14041405/** The default DECLARED router (302 §5) when the app names `realtime.mutators` and passes no1406 *  explicit `domainPolicy`: a declared mutator routes to the ONE attached room; solo or1407 *  multi-room it abstains (⇒ daemon). `getRooms` is read lazily per invoke so the policy tracks1408 *  attach/downgrade live. */1409function declaredMutatorPolicy(1410  declared: ReadonlySet<string>,1411  getRooms: () => ReadonlyMap<string, unknown>,1412): (name: string, args: unknown) => string | undefined {1413  return (name) => {1414    if (!declared.has(name)) return undefined;1415    const rooms = getRooms();1416    if (rooms.size !== 1) return undefined; // solo or ambiguous — the daemon path (302 §5)1417    return rooms.keys().next().value as string;1418  };1419}14201421/** Flip a just-materialized labeled view back to `unknown` for the lease-resolve window. The1422 *  plural `FlatArrayView` exposes `setResultType`; a `.one()` query's `SingularView` wrapper hides1423 *  it behind its (runtime-visible) `inner` — reach through. Best-effort by design: the deferred1424 *  retain recomputes the lifecycle authoritatively the moment it attaches, and the Store keeps1425 *  routing backend transitions to the SAME underlying view either way. */1426function flipResultTypeUnknown(view: unknown): void {1427  const v = view as {1428    setResultType?: (rt: "unknown") => void;1429    inner?: { setResultType?: (rt: "unknown") => void };1430  };1431  if (typeof v.setResultType === "function") v.setResultType("unknown");1432  else if (typeof v.inner?.setResultType === "function") v.inner.setResultType("unknown");1433}14341435/** Every base table an AST tree can draw from (root, related subtrees, EXISTS children) — the E31436 *  guard's input. Mirrors the backend's own `collectTables`. */1437function collectAstTables(ast: Ast, out = new Set<string>()): Set<string> {1438  out.add(ast.table);1439  for (const rel of ast.related ?? []) collectAstTables(rel.subquery, out);1440  collectConditionTables(ast.where, out);1441  collectConditionTables(ast.having, out);1442  return out;1443}14441445function collectConditionTables(cond: Condition | undefined, out: Set<string>): void {1446  if (cond === undefined) return;1447  if (cond.type === "and" || cond.type === "or") {1448    for (const c of cond.conditions) collectConditionTables(c, out);1449  } else if (cond.type === "correlatedSubquery") {1450    collectAstTables(cond.related.subquery, out);1451  }1452}14531454/** The (name, args) sub identity — key-order-stable, mirroring the backend's own `remoteKey` so1455 *  the client-side room bookkeeping groups retains exactly as the backend dedups subs. */1456function remoteKey(remote: RemoteQuery): string {1457  return stableJson([remote.name, remote.args]);1458}14591460// ---- lifecycle system-sub helpers (Slice I-iii) ----14611462/** A system sub's wire `args` (under the reserved `_rindle/lifecycle` name): the lease entry's1463 *  identity fields plus the PARENT labeled query, so a re-resolution can re-lease the parent and1464 *  re-find the entry (`resolveLifecycleTarget`). */1465interface LifecycleRemoteArgs {1466  table: SystemStreamTable;1467  scope?: string;1468  doc?: string;1469  clientId?: string;1470  parent: { name: string; args: unknown };1471}14721473const SYSTEM_TABLES: ReadonlySet<string> = new Set([1474  SCOPE_SESSIONS_TABLE,1475  ROOM_WATERMARK_TABLE,1476  ROOM_CLIENT_MUTATIONS_TABLE,1477  ROOM_MUTATION_OUTCOMES_TABLE,1478]);14791480function isSystemTable(table: string): table is SystemStreamTable {1481  return SYSTEM_TABLES.has(table);1482}14831484/** The idempotence key a lifecycle retain is claimed under: the minted predicate's full identity1485 *  (table + scope/doc/clientId) — two labeled queries on one scope share ONE doorbell sub; two1486 *  docs' fences never alias. */1487function systemEntryKey(entry: { table: string; scope?: string; doc?: string; clientId?: string }): string {1488  return stableJson([entry.table, entry.scope ?? null, entry.doc ?? null, entry.clientId ?? null]);1489}14901491function stableJson(value: unknown): string {1492  if (value === null || typeof value !== "object") return JSON.stringify(value);1493  if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;1494  const obj = value as Record<string, unknown>;1495  return `{${Object.keys(obj)1496    .sort()1497    .map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`)1498    .join(",")}}`;1499}15001501export type { ClientRegistry, MutationTx };1502export type { MutateFn } from "./index.ts";15031504class RindleApiHttpError extends Error {1505  readonly path: string;1506  readonly status: number;1507  readonly body: string;15081509  constructor(path: string, status: number, body: string) {1510    super(`rindle api ${path} failed: ${status}${body ? ` ${body}` : ""}`);1511    this.name = "RindleApiHttpError";1512    this.path = path;1513    this.status = status;1514    this.body = body;1515  }1516}15171518const MUTATION_GAP_RE = /\bmutation(?: id)? gap\b/i;15191520function reloadAfterMutationGap(err: unknown): boolean {1521  const text =1522    err instanceof RindleApiHttpError1523      ? `${err.status} ${err.body} ${err.message}`1524      : String((err as Error)?.message ?? err);1525  if (!MUTATION_GAP_RE.test(text)) return false;15261527  resetStableClientID();1528  console.error(1529    "[rindle] mutation gap detected; cleared the dev client id and reloading so this tab starts a fresh mutation stream.",1530    err,1531  );1532  const loc = (globalThis as unknown as { location?: { reload?: () => void } }).location;1533  if (typeof loc?.reload !== "function") return false;1534  try {1535    loc.reload();1536    return true;1537  } catch {1538    return false;1539  }1540}1541