API index and search · Build metadata
Source snapshot
packages/optimistic/src/client.ts
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