@rindle/api-server 0.4.4 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { driveMutationAsync, insertCell, insertPlan, isoTx, toCell } from "@rindle/client";
1
+ import { driveMutationAsync, insertCell, insertPlan, isGeneratorMutator, isoTx, toCell } from "@rindle/client";
2
2
  import type {
3
3
  Ast,
4
4
  ColType,
@@ -21,6 +21,8 @@ import type { Catalog, ColumnType as QueryColumnType, TableSchema } from "@rindl
21
21
  import type {
22
22
  ClaimRoomEpochInput,
23
23
  ClaimRoomEpochOutput,
24
+ CoverQueryInput,
25
+ CoverQueryOutput,
24
26
  DematerializeInput,
25
27
  DematerializeOutput,
26
28
  MaterializationPolicy,
@@ -51,12 +53,52 @@ import type {
51
53
  WireValue,
52
54
  } from "@rindle/daemon-client";
53
55
 
56
+ import {
57
+ assertLabeledProfilesExist,
58
+ assertUnwindowedFootprint,
59
+ attachRealtimeLabel,
60
+ compileRoomProfiles,
61
+ compileRoomScopeSpecs,
62
+ compileRoomTableSpecs,
63
+ mintRoomDoc,
64
+ queryRealtimeLabel,
65
+ queryResultToAst,
66
+ splitRoomDoc,
67
+ } from "./rooms.ts";
68
+ import type { RoomProfile, RoomScopeSpec, RoomTableSpec } from "./rooms.ts";
69
+ // The room lease token (RINDLE-REALTIME §10.1): minted here, verified by the room SHELL against
70
+ // its `downstream.tokenKeys` ring — the `/token` subpath is pure WebCrypto (no wasm, no shell).
71
+ // Loaded LAZILY at the first mint: `@rindle/room` is an OPTIONAL dependency (see package.json), so
72
+ // it is installed transitively — a consumer bundling api-server (Vite/Rollup/esbuild) can resolve
73
+ // this dynamic import even when it never uses rooms — yet the mint only runs when
74
+ // `realtime.roomTokenKey` is configured. Should the module be genuinely absent (an install that
75
+ // skipped the optional dep), the serve decision fail-opens: daemon-served leases plus a one-time
76
+ // warning naming the missing module. It is NOT a hard `dependency` because the entire code path is
77
+ // optional; optionalDependencies keeps a failed install of it non-fatal.
78
+ import type { mintRoomToken as MintRoomToken, scopeSpecsHash as ScopeSpecsHash } from "@rindle/room/token";
79
+ let roomTokenModule: { mintRoomToken: typeof MintRoomToken; scopeSpecsHash: typeof ScopeSpecsHash } | undefined;
80
+ async function loadRoomTokenModule(): Promise<{ mintRoomToken: typeof MintRoomToken; scopeSpecsHash: typeof ScopeSpecsHash }> {
81
+ if (roomTokenModule === undefined) {
82
+ const m = await import("@rindle/room/token");
83
+ roomTokenModule = { mintRoomToken: m.mintRoomToken, scopeSpecsHash: m.scopeSpecsHash };
84
+ }
85
+ return roomTokenModule;
86
+ }
87
+
54
88
  // Re-export the shared (generator) mutator seam so an app builds its server mutators from ONE import:
55
89
  // co-locate each body with its arg schema (`shared`), bulk-drive the registry ({@link sharedApiMutators}),
56
90
  // keeping only server-only authority as explicit overrides (see MUTATORS-ISOMORPHIC).
57
91
  export { isoTx, shared } from "@rindle/client";
58
92
  export type { ArgSchema, IsoTx, MutationGen, MutatorCtx, SharedMutator, SharedMutatorWithArgs } from "@rindle/client";
59
93
 
94
+ // The room-profile declaration layer (RINDLE-REALTIME-QUERY-ENABLEMENT §2, slice G-iv-a). The
95
+ // compiled-profile shapes stay internal to `./rooms.ts` — G-iv-b consumes them in-package.
96
+ // `RoomTableSpec` (G-iv-b) is public: it rides the lease wire (`QueryLeaseResponse.realtime`).
97
+ // `RoomScopeSpec` (H-iv-b) is public: it rides the boot wire (`RoomBootResponse.scopes`).
98
+ export { queryRealtimeLabel, queryResultToAst } from "./rooms.ts";
99
+ export type { RoomProfile, RoomScopeSpec, RoomTableSpec } from "./rooms.ts";
100
+ export type { RealtimeQueryLabel } from "@rindle/client";
101
+
60
102
  export const DEFAULT_RINDLE_API_ROUTES = {
61
103
  query: "/api/rindle/query",
62
104
  read: "/api/rindle/read",
@@ -149,6 +191,103 @@ export type ApiMutator<User, Args> = (
149
191
  args: Args,
150
192
  ctx: MutationContext<User>,
151
193
  ) => MaybePromise<ApiMutatorResult>;
194
+
195
+ // --------------------------------------------------------------------------- scoped (outside-tx) mutators
196
+ //
197
+ // The tx-form {@link ApiMutator} above runs ENTIRELY inside the transaction. A SCOPED mutator
198
+ // (WORK-OUTSIDE-TX) instead controls the boundary itself: it receives a {@link MutationScope}, runs
199
+ // server-only code BEFORE opening the one atomic transaction (`scope.transact`), and MAY run code
200
+ // AFTER it commits. The outside-tx code is server-only by nature (the client's optimistic prediction
201
+ // can't call Stripe), so it lives HERE, never in the isomorphic body — the shared generator stays
202
+ // pure and identical on both tiers; server-computed values flow into it through `ctx`, exactly like
203
+ // `ctx.user` (undefined/predicted on the client, authoritative here).
204
+
205
+ /** Thrown by {@link MutationScope.transact} when the transacted body BUSINESS-rejects: the data
206
+ * rolled back and `lmid` advanced alone (§2.4). Catch it to COMPENSATE an outside-tx side effect
207
+ * (refund the charge), then rethrow or return — the mutation's protocol outcome is already sealed
208
+ * as rejected, so a post-reject throw can't change it. A DB/infra failure is NOT this — it
209
+ * propagates as the raw driver error (the client retries; `lmid` did not advance). */
210
+ export class MutationRejected extends Error {
211
+ readonly reason: string;
212
+ constructor(reason: string) {
213
+ super(reason);
214
+ this.name = "MutationRejected";
215
+ this.reason = reason;
216
+ }
217
+ }
218
+
219
+ /** The per-mutation server handle a {@link ScopedMutator} runs against. Code before {@link transact}
220
+ * runs OUTSIDE the transaction; code after a clean `transact` runs AFTER the commit. The
221
+ * `lmid`-always-advances invariant is the HARNESS's, not the author's: {@link RindleApiServer.pushMutation}
222
+ * seals the response from this handle's recorded outcome, so an early return, a never-called
223
+ * `transact`, or a swallowed {@link MutationRejected} still advances `lmid` and never wedges the
224
+ * client's pending queue. */
225
+ export interface MutationScope {
226
+ /** Open the ONE atomic write transaction and drive `body` inside it, committing (stamping `lmid`
227
+ * co-transactionally) on a clean return. MAY be called at most once — a second call throws.
228
+ *
229
+ * Two forms:
230
+ * - `transact(sharedMutator, args, ctx)` — drive a SHARED (generator) mutator (the same body the
231
+ * client predicts); pass the already-parsed `args` and the server `ctx` (fold server-only
232
+ * values like a charge id into `ctx` here).
233
+ * - `transact(run)` — a raw callback receiving the live {@link ServerMutationTx} (the escape
234
+ * hatch: `tx.exec`, logical writes, read-your-writes reads).
235
+ *
236
+ * A THROW from the body that is not a {@link BackendError} is a BUSINESS rejection: the data rolls
237
+ * back, `lmid` advances alone, and this method throws {@link MutationRejected} (so surrounding
238
+ * code can compensate). A {@link BackendError} is INFRA: it propagates (the client retries). */
239
+ transact(run: (tx: ServerMutationTx) => void | Promise<void>): Promise<void>;
240
+ transact<A, C extends MutatorCtx>(mutator: SharedMutator<A, C>, args: A, ctx: C): Promise<void>;
241
+ }
242
+
243
+ /** A SCOPED server mutator (WORK-OUTSIDE-TX): server-only code, ONE `scope.transact`, optional
244
+ * post-commit code. Register it by wrapping in {@link scoped} — the tag the api-server routes on to
245
+ * hand it a {@link MutationScope} instead of running its whole body inside the transaction. */
246
+ export type ScopedMutator<User, Args> = (
247
+ scope: MutationScope,
248
+ args: Args,
249
+ ctx: MutationContext<User>,
250
+ ) => void | Promise<void>;
251
+
252
+ /** A {@link ScopedMutator} tagged by {@link scoped} so the harness invokes it with a
253
+ * {@link MutationScope}. Typed as a BRANDED tx-form {@link ApiMutator} purely so it registers in the
254
+ * `mutators` record without widening it to a union (which would break contextual inference for every
255
+ * plain tx-form entry). Its true runtime shape is `(scope, args, ctx)`; the tag — not the type —
256
+ * routes it, and it is never actually called as a tx-form mutator. */
257
+ export type ScopedApiMutator<User, Args> = ApiMutator<User, Args> & { readonly __rindleScoped: true };
258
+
259
+ /** Mark a mutator as SCOPED so the api-server gives it a {@link MutationScope} (author-controlled tx
260
+ * boundary via `scope.transact`) rather than running its whole body inside the transaction. Register
261
+ * it alongside the tx-form mutators — it wins by key like any override:
262
+ *
263
+ * ```ts
264
+ * mutators: defineApiMutators({
265
+ * ...sharedApiMutators(sharedMutators, sharedCtx), // tx-form (common case)
266
+ * createOrder: scoped(async (scope, raw, ctx) => { // needs outside-tx work
267
+ * const args = createOrder.args.parse(raw);
268
+ * const chargeId = await stripe.charge(args.amount, { idempotencyKey: ctx.envelope.mid }); // outside tx
269
+ * try {
270
+ * await scope.transact(createOrder, args, { ...sharedCtx(ctx), chargeId }); // inside tx
271
+ * } catch (e) {
272
+ * await stripe.refund(chargeId); // compensate — the write rejected
273
+ * throw e;
274
+ * }
275
+ * await sendReceipt(ctx.user); // after commit
276
+ * }),
277
+ * }),
278
+ * ```
279
+ */
280
+ export function scoped<User, Args>(fn: ScopedMutator<User, Args>): ScopedApiMutator<User, Args> {
281
+ // The brand carries the scoped runtime shape; typing the RETURN as the (branded) tx-form keeps it
282
+ // assignable into `mutators` WITHOUT unioning that record — the harness routes on the brand and
283
+ // never calls it as a tx-form mutator, so the cast is sound.
284
+ return Object.assign(fn, { __rindleScoped: true as const }) as unknown as ScopedApiMutator<User, Args>;
285
+ }
286
+
287
+ function isScoped<User>(m: ApiMutator<User, any>): m is ScopedApiMutator<User, any> {
288
+ return (m as { __rindleScoped?: boolean }).__rindleScoped === true;
289
+ }
290
+
152
291
  export type ApiMutators<User> = Record<string, ApiMutator<User, any>>;
153
292
 
154
293
  export interface QueryLeaseRequest<User> {
@@ -160,6 +299,96 @@ export interface QueryLeaseRequest<User> {
160
299
  * when there is no authenticated subject and no session cookie (READ-ROUTER-DESIGN.md §1.5/§2.2).
161
300
  * A routing HINT only, never authorization. */
162
301
  clientId?: string;
302
+ /** The browser's opaque follower-affinity ticket (FOLLOWER-AFFINITY-DESIGN.md §3), read off the
303
+ * query POST and forwarded OPAQUELY on `materialize` so the fleet `fly-replay`s to the follower
304
+ * the browser's ws is pinned to (§2, §4) — both legs co-locate. The api-server does NOT verify
305
+ * it (the fleet does); it holds no signing key. Absent ⇒ single daemon / affinity off. */
306
+ affinity?: string;
307
+ }
308
+
309
+ /**
310
+ * The room-serve block on a query lease (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1 step 5 / §2.4,
311
+ * slice G-iv-b): present only when the named query carries a realtime label AND its resolved AST
312
+ * was PROVABLY covered by its room profile's footprint (the daemon's `/cover-check`). The G-v
313
+ * client uses it to open the room transport for THIS query beside — never instead of — its daemon
314
+ * session.
315
+ *
316
+ * It is a dedicated block on purpose: the ROOM ws is a SEPARATE connection this query opens beside
317
+ * its daemon session (never a migration of the daemon session — the daemon ws host is fixed and
318
+ * placed by the affinity ticket). A room-served lease's top-level fields are byte-identical to the
319
+ * daemon-served ones.
320
+ */
321
+ export interface QueryLeaseRealtime {
322
+ /** The client store's gate/domain key for this room source (`connectSource`) AND the string the
323
+ * wasm engine's `parse_source_key` accepts: any string other than the reserved `"daemon"`
324
+ * parses as a room source, and the established convention is `"room:" + doc`
325
+ * (e.g. `room:document/doc:d1`). */
326
+ sourceKey: string;
327
+ /** Where the client opens the ROOM ws for this query (from `realtime.locateRoom`) — its OWN
328
+ * connection, distinct from the daemon session's fixed ws host. */
329
+ wsEndpoint: string;
330
+ /** The room's self-authorizing signed lease (`@rindle/room/token`): the APPROVED query AST +
331
+ * doc + subject, HMAC-signed with `realtime.roomTokenKey` so the room shell's
332
+ * `downstream.tokenKeys` ring verifies it. The room materializes on first presentation. */
333
+ roomToken: string;
334
+ /** Token expiry (ms epoch) — the client's renewal clock (renewal = a fresh lease). */
335
+ exp: number;
336
+ /** The wire room doc (`"<profile>/<key>"`, minted server-side — never client-derived). */
337
+ doc: string;
338
+ /** Per-footprint-table specs: the §2.2 owned/followed split + §3.2 routing metadata. Since
339
+ * H-iii each spec also carries `footprintWhere` — the same exact membership predicate the boot
340
+ * wire ships the room gate (one compiler, `compileRoomScopeSpecs`) — feeding the client's §3
341
+ * prove-or-slow-path router. Advisory routing metadata, never a credential (§3.2). */
342
+ tables: RoomTableSpec[];
343
+ }
344
+
345
+ /**
346
+ * One minted SYSTEM-STREAM lease on a query lease's `lifecycle` block (RINDLE-REALTIME-QUERY-
347
+ * ENABLEMENT §4, Slice I-iii): an ordinary daemon materialization over one of the four
348
+ * `_rindle_*` lifecycle system tables (registered by the daemon's `enable_realtime_lifecycle` —
349
+ * `rust/rindle-replica/src/mutations.rs`), attachable by the EXISTING client subscribe path
350
+ * (present `leaseToken` on a `subscribe` frame, exactly like the primary lease). The identity
351
+ * fields (`scope`/`doc`/`clientId`) document the minted AST's predicate — the client keys its
352
+ * retains and its release-time row filters on them.
353
+ */
354
+ export interface QueryLeaseLifecycleLease {
355
+ /** Which system table this lease's subscription serves. */
356
+ table: string;
357
+ leaseToken: string;
358
+ /** DOORBELL only: the §4.1 occupancy scope — the wire room doc (`"<profile>/<key>"`). */
359
+ scope?: string;
360
+ /** FENCE entries only: the room doc the predicate is scoped to. */
361
+ doc?: string;
362
+ /** FENCE ledger/outcome entries: present iff the predicate was ALSO client-scoped (the lease
363
+ * request carried `clientId`). Absent ⇒ doc-only predicate — the client filters to its own
364
+ * rows regardless (defense in depth). */
365
+ clientId?: string;
366
+ }
367
+
368
+ /** The §4 lifecycle block on a query lease (Slice I-iii): present only when BOTH the realtime
369
+ * `lifecycle` config is on AND the query is realtime-labeled. `doorbell` rides EVERY labeled
370
+ * lease (occupancy is counted whether or not the query is room-served — the 1→2 upgrade trigger
371
+ * needs solo watchers subscribed BEFORE any room exists, §4.1); `fence` rides only a ROOM-SERVED
372
+ * lease (the §4.2/§7.1/§3.3 downgrade surfaces are meaningful only where a room domain exists).
373
+ * The §4.2 fence VALUE (`finalFlushSeq`) is deliberately NOT here — it arrives with the I-v
374
+ * downgrade response; I-iii only stands up the streams. */
375
+ export interface QueryLeaseLifecycle {
376
+ doorbell: QueryLeaseLifecycleLease;
377
+ fence?: QueryLeaseLifecycleLease[];
378
+ }
379
+
380
+ /** The §4.2 downgrade fence on a query lease (Slice I-v): rides a labeled reply whose §4.1
381
+ * occupancy gate CLOSED (so there is NO `realtime` block) when the server could drain the room.
382
+ * A SIBLING of `realtime`, never nested inside it — block-ABSENCE is the downgrade signal, and
383
+ * the fence rides alongside that absence. Its `finalFlushSeq` is the room's last COMMITTED flush
384
+ * seq; the client's frozen room ghost holds visible until the daemon plane has provably absorbed
385
+ * it (`_rindle_room_watermark(doc) ≥ finalFlushSeq`). Absent from every non-downgrade reply. */
386
+ export interface QueryLeaseRealtimeFence {
387
+ /** The retiring room source's gate/domain key — `"room:" + doc`, matching what the room-served
388
+ * lease's {@link QueryLeaseRealtime.sourceKey} carried. */
389
+ sourceKey: string;
390
+ doc: string;
391
+ finalFlushSeq: number;
163
392
  }
164
393
 
165
394
  export interface QueryLeaseResponse {
@@ -167,9 +396,17 @@ export interface QueryLeaseResponse {
167
396
  materializationId: string;
168
397
  queryKey?: string;
169
398
  reused?: boolean;
170
- /** The follower this lease lives on (READ-ROUTER-DESIGN.md §2.3) — the browser opens its
171
- * subscription ws here. Absent ⇒ single-daemon (the client uses its static `wsUrl`). */
172
- wsEndpoint?: string;
399
+ /** The room-serve block (G-iv-b) — see {@link QueryLeaseRealtime}. Absent ⇒ the lease is
400
+ * byte-identical to the legacy daemon-served shape. */
401
+ realtime?: QueryLeaseRealtime;
402
+ /** The §4.2 downgrade fence (Slice I-v) — see {@link QueryLeaseRealtimeFence}. Present only on a
403
+ * labeled reply whose occupancy gate closed AND `realtime.lifecycle.drainRoom` could drain the
404
+ * room; absent otherwise (including on every room-served reply). */
405
+ realtimeFence?: QueryLeaseRealtimeFence;
406
+ /** The §4 lifecycle system-stream block (Slice I-iii) — see {@link QueryLeaseLifecycle}.
407
+ * Minted ONLY under the opt-in `realtime.lifecycle` config; absent ⇒ byte-identical to the
408
+ * pre-lifecycle response. */
409
+ lifecycle?: QueryLeaseLifecycle;
173
410
  }
174
411
 
175
412
  /** A one-shot SSR read of a named query (SSR-DESIGN.md §6): same `(name, args)` surface as a lease,
@@ -183,6 +420,9 @@ export interface QueryReadRequest<User> {
183
420
  * {@link QueryLeaseRequest.clientId}). Lets the SSR read co-locate on the follower the booting
184
421
  * client's first subscribe will hit (READ-ROUTER-DESIGN.md §2.4). */
185
422
  clientId?: string;
423
+ /** The browser's opaque follower-affinity ticket — see {@link QueryLeaseRequest.affinity}.
424
+ * Forwarded on the one-shot `query` so an SSR read lands on the same pinned follower. */
425
+ affinity?: string;
186
426
  }
187
427
 
188
428
  /** The assembled (nested-by-name) first-paint snapshot the server-side Store seeds + dehydrates
@@ -191,9 +431,6 @@ export interface QueryReadResponse {
191
431
  rows: Array<{ cols: Record<string, unknown>; [rel: string]: unknown }>;
192
432
  cvMin?: number;
193
433
  queryKey?: string;
194
- /** The follower this read warmed (READ-ROUTER-DESIGN.md §2.4) — inject it into the SSR bootstrap
195
- * so the booting client opens its ws to the same warm follower. Absent ⇒ single-daemon. */
196
- wsEndpoint?: string;
197
434
  }
198
435
 
199
436
  /** The context a {@link MutationBackend} needs to run one mutation inside its transaction. */
@@ -311,6 +548,16 @@ export interface RoomBootResponse {
311
548
  /** Where the room opens its upstream subscription (a routed deploy's follower). Absent ⇒ the
312
549
  * shell's statically configured rindled ws endpoint. */
313
550
  upstreamWsEndpoint?: string;
551
+ /** Fresh opaque follower-placement ticket minted alongside `upstreamLeaseToken`. The DO offers
552
+ * it with `rindle.v1` on the separate upstream ws so a static fleet endpoint replays to the
553
+ * exact follower holding that local lease. Absent when daemon affinity is off. */
554
+ upstreamAffinity?: string;
555
+ /** Per-footprint-table scope specs (H-iv-b), compiled from the resolved footprint AST + the
556
+ * profile's context set (the legacy anonymous profile compiles with an empty context set):
557
+ * what the shell hands the wasm room's `enableWritesV2` — the §3.3 commit gate. Optional
558
+ * only for wire compatibility with pre-H-iv-b servers; a shell that doesn't receive them
559
+ * enables the v1 table-granular write plane exactly as before. */
560
+ scopes?: RoomScopeSpec[];
314
561
  flush: RoomBootFlush;
315
562
  }
316
563
 
@@ -319,11 +566,22 @@ export interface RindleRealtimeOptions<User> {
319
566
  * room's `Authorization: Bearer` on `/room-boot` (the default {@link authorizeBoot}) and keys
320
567
  * the DEFAULT epoch-bound flush credential. */
321
568
  shellSecret: string;
569
+ /** NAMED room profiles (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §2.1): profile name → key
570
+ * derivation + canonical unwindowed footprint + read-only context tables. The wire room key
571
+ * for a named profile is `"<profile>/<key>"`; `/room-boot` splits it and resolves the
572
+ * profile's footprint with the bare key. Compiled + validated LOUDLY at construction (§2.3):
573
+ * a windowed footprint, a context table missing from the schema/footprint, or a registered
574
+ * query whose realtime label names a missing profile all throw from `createRindleApiServer`. */
575
+ rooms?: Record<string, RoomProfile<User>>;
322
576
  /** doc → the room's approved upstream footprint (§3.1) — an `Ast` or fluent `Query`. MAY
323
577
  * delegate to the named-query registry internally; throw `RindleApiError("not-found", …, 404)`
324
578
  * for a doc that shouldn't exist. The §9 footprint budget belongs here — it runs once per
325
- * placement, at lease mint. */
326
- resolveFootprint: (doc: string, ctx: ApiContext<User>) => MaybePromise<ApiQueryResult>;
579
+ * placement, at lease mint.
580
+ * @deprecated Prefer named {@link rooms} profiles (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1).
581
+ * This bare form remains as the single-profile LEGACY alias — the anonymous/default profile:
582
+ * a doc with no known `"<profile>/"` prefix resolves here, byte-identically to before named
583
+ * profiles existed (and with none of their construction/boot-time validation). */
584
+ resolveFootprint?: (doc: string, ctx: ApiContext<User>) => MaybePromise<ApiQueryResult>;
327
585
  /** Gates the flush trio — `authorizeRoom`, relocated. Default: verify the default flush
328
586
  * credential from the {@link ROOM_FLUSH_CREDENTIAL_HEADER} request header — which requires the
329
587
  * transport to pass its incoming request as `context.request` (Fetch `Request` and node
@@ -339,9 +597,77 @@ export interface RindleRealtimeOptions<User> {
339
597
  /** Lease TTL for the room's upstream footprint materialization (defaults to the server-wide
340
598
  * `leaseTtlMs`, else the daemon's default). */
341
599
  upstreamLeaseTtlMs?: number;
342
- /** Static override for where rooms open their upstream subscription; default is the
343
- * `wsEndpoint` the materialize returns (set in routed deploys), else absent. */
600
+ /** Static endpoint where rooms open their upstream subscription. In a follower fleet this is the
601
+ * fleet ws URL; `/room-boot` pairs it with the materialization's fresh placement ticket so the
602
+ * room lands on the exact follower holding its lease. Absent ⇒ no explicit upstream (the Node
603
+ * room shell may use its own default; the shipped DO shell requires this endpoint). */
344
604
  upstreamWsEndpoint?: string;
605
+ /** Locate (or place) the room serving `doc` and return the ROOM ws endpoint a room-served
606
+ * lease's client should open (G-iv-b; on the DO shell this is the Worker's room URL). The
607
+ * endpoint rides the lease's dedicated `realtime.wsEndpoint` — its OWN connection, distinct from
608
+ * the daemon session's fixed ws host. Absent ⇒ room-serving is OFF: labeled queries serve from
609
+ * the daemon exactly as today (fail-open). */
610
+ locateRoom?: (doc: string) => MaybePromise<{ wsEndpoint: string }>;
611
+ /** The room lease token signing key (`@rindle/room/token`): `kid` + secret, matching an entry
612
+ * in the room shell's `downstream.tokenKeys` ring. Required for room-serving (without it a
613
+ * labeled query fail-opens to the daemon with a one-time warning). A separate secret from
614
+ * `shellSecret` on purpose — the shell's ring is the client-token trust domain, the shell
615
+ * secret is the boot/flush trust domain. */
616
+ roomTokenKey?: { kid: string; secret: string };
617
+ /** Room lease token TTL, ms (default 5 minutes — the §4.1 short-TTL backstop; renewal is a
618
+ * fresh lease through this server, never an extension). */
619
+ roomTokenTtlMs?: number;
620
+ /** Loud-diagnostics sink for the realtime layer (profile compilation warnings + the one-time
621
+ * per-(query, profile) "not room-served" serve-decision warnings). Defaults to
622
+ * `console.warn`; injectable for tests. */
623
+ warn?: (message: string) => void;
624
+ /** The §4 upgrade/downgrade lifecycle plane (RINDLE-REALTIME-QUERY-ENABLEMENT §4, Slice
625
+ * I-iii): PRESENCE of this block is the opt-in — every realtime-labeled lease then
626
+ * additionally mints the doorbell system lease (occupancy, §4.1) and every ROOM-SERVED lease
627
+ * the fence bundle (watermark + ledger + outcomes, §4.2/§7.1/§3.3) — see
628
+ * {@link QueryLeaseLifecycle}. Requires the daemon to have run `enable_realtime_lifecycle`
629
+ * (the four `_rindle_*` system tables must be registered or the minted materializations fail
630
+ * — which fail-opens with a one-time warning, never blocking the lease). Absent ⇒ the lease
631
+ * response is byte-identical to pre-lifecycle. */
632
+ lifecycle?: RindleRealtimeLifecycleOptions;
633
+ }
634
+
635
+ /** {@link RindleRealtimeOptions.lifecycle}. PRESENCE of the block is the opt-in switch (I-iii);
636
+ * the fields below are the Slice I-iv occupancy knobs (§4.1, decisions D4/D6/D7). All optional —
637
+ * `lifecycle: {}` gets the designed defaults. */
638
+ export interface RindleRealtimeLifecycleOptions {
639
+ /** D6 (§4.1): the occupancy threshold for room-serving. A labeled lease whose scope counts
640
+ * FEWER than this many distinct unexpired sessions (the caller's own included) ships WITHOUT
641
+ * the realtime block — served from the daemon, indistinguishable from an uncovered query —
642
+ * but WITH the doorbell, so the 1→2 transition wakes it (that is the point: solo docs never
643
+ * cost room infrastructure). Default **2** (the design's 1→2 trigger). Set `1` to room-serve
644
+ * solo viewers (the pre-I-iv behavior under lifecycle config). */
645
+ minSessions?: number;
646
+ /** The §9.1 hysteresis window, ms (default **120_000**). Two consumers: (a) the lazy sweep
647
+ * (D4) keeps expired session rows lingering at least this long past expiry — Slice I-v's
648
+ * downgrade decision ("no other unexpired row AND the newest other row expired > graceMs
649
+ * ago") is read FROM those rows, so they must survive to be read; (b) this slice's gate
650
+ * applies the same hysteresis upward: a scope with an other-session row expired ≤ graceMs
651
+ * ago keeps room-serving through the window (see `lifecycleOccupancy` — no flap on one
652
+ * client's brief lapse). §9.1-tunable: raise it for docs where collaborators churn slowly. */
653
+ graceMs?: number;
654
+ /** TTL of an occupancy session row, ms — `expires_at = now + sessionTtlMs` on every labeled
655
+ * lease mint/renewal (D7: session identity = the request's `clientId`; two tabs are two
656
+ * sessions iff their clientIds differ). Default = the server's `leaseTtlMs`, else 5 minutes —
657
+ * matching the room-token renewal cadence (`roomTokenTtlMs`, renewed 30s early), so a
658
+ * room-attached client's renewals keep its row unexpired; a daemon-attached solo client's row
659
+ * MAY lapse (it has no renewal timer) and is refreshed by its next doorbell-triggered
660
+ * re-lease — occupancy converges through the doorbell itself. */
661
+ sessionTtlMs?: number;
662
+ /** The §4.2 downgrade drain hook (Slice I-v). When the occupancy gate CLOSES for a labeled
663
+ * lease whose scope PLAUSIBLY hosted a room (an other-session row still lingers — never a
664
+ * never-shared solo doc), the api-server calls this to drain the room's pending write-behind
665
+ * and learn its last COMMITTED `flush_seq`, then rides the value back on the lease as the
666
+ * {@link QueryLeaseRealtimeFence}. The deployment wires it to the room shell's / DO's `/drain`
667
+ * control. Absent ⇒ no fence is attached (the client hits its loud legacy downgrade path);
668
+ * a throw fails OPEN to the same (a downgrade never blocks the lease). Concurrent drains across
669
+ * api-server instances are fine — `/drain` is idempotent. */
670
+ drainRoom?: (doc: string) => Promise<{ finalFlushSeq: number }>;
345
671
  }
346
672
 
347
673
  // The DEFAULT flush credential: `rfc1.<b64url payload>.<b64url hmac-sha256>`, payload
@@ -496,12 +822,11 @@ export interface RindleApiServerOptions<User> {
496
822
  * HINT only — never authorization. Ignored by a single (unrouted) daemon, which has nothing to
497
823
  * route. */
498
824
  routingKey?: string | ((input: QueryLeaseRequest<User>) => MaybePromise<string | undefined>);
499
- /** The EXPLICIT fleet pin fan-out (READ-ROUTER-DESIGN.md §4.2 "push") — when set,
500
- * {@link RindleApiServer.assertPins} fans each resolved pin across ALL live followers through it
501
- * (e.g. `createRouterPinClient` from `@rindle/router`) instead of materializing each pin once on
502
- * the (single) daemon. A per-viewer `materialize` always routes ONE; a pin-assert always fans
503
- * ALL — never inferred from `policy.kind`. Absent ⇒ single-daemon behavior (one materialize per
504
- * pin). */
825
+ /** The EXPLICIT fleet pin fan-out — when set, {@link RindleApiServer.assertPins} fans each
826
+ * resolved pin across ALL live followers through it (a fleet control action over the machine
827
+ * list FOLLOWER-AFFINITY-DESIGN.md §11) instead of materializing each pin once on the (single)
828
+ * daemon. A per-viewer `materialize` always routes ONE; a pin-assert always fans ALL — never
829
+ * inferred from `policy.kind`. Absent ⇒ single-daemon behavior (one materialize per pin). */
505
830
  pinFanout?: PinFanout;
506
831
  /** Named queries to keep permanently materialized via {@link RindleApiServer.assertPins}.
507
832
  * Each is materialized with a `pinned` policy (survives zero subscribers) so late joiners
@@ -510,6 +835,12 @@ export interface RindleApiServerOptions<User> {
510
835
  /** The user context pins resolve under (pins are shared, so they should not depend on a
511
836
  * per-viewer identity). Defaults to `undefined`. */
512
837
  pinUser?: User;
838
+ /** Surfaced when a SCOPED mutator ({@link scoped}) throws from code that runs AFTER `scope.transact`
839
+ * has already sealed the protocol outcome — a post-commit effect, or a compensation handler running
840
+ * after a business rejection. The outcome is fixed (this callback CANNOT change the client's
841
+ * response or the `lmid` advance), but the throw must not vanish: a failed refund is real money.
842
+ * Absent ⇒ the error is logged to `console.error`. */
843
+ onScopeError?: (err: unknown, info: { phase: "committed" | "rejected"; envelope: MutationEnvelope }) => void;
513
844
  }
514
845
 
515
846
  export interface RindleApiServer<User> {
@@ -520,6 +851,18 @@ export interface RindleApiServer<User> {
520
851
  * startup and whenever the daemon restarts (e.g. from the daemon-client `onBootId` hook), since
521
852
  * the daemon holds no durable materialization state. No-op when `pinnedQueries` is empty. */
522
853
  assertPins(): Promise<void>;
854
+ /** The room-serving coverage DIAGNOSTIC (G-iv-b; the `assertPins`-style explicit check): for
855
+ * every realtime-labeled query, resolve it (under `pinUser`, per exemplar args), resolve its
856
+ * profile's footprint, and run the REAL daemon coverage check — the same verdict the lease
857
+ * path serves by. Returns every verdict; `strict` throws when any labeled query is not
858
+ * provably covered (deploy-gate mode). Deliberately ignores `locateRoom`/`roomTokenKey` — it
859
+ * answers "would this query be coverable", not "is serving fully wired". */
860
+ validateRealtime(opts?: {
861
+ /** Per-query exemplar args (a query is checked once per exemplar; default: one `null`). */
862
+ exemplars?: Partial<Record<string, readonly unknown[]>>;
863
+ /** Throw when any labeled query is uncovered (instead of just reporting). */
864
+ strict?: boolean;
865
+ }): Promise<ValidateRealtimeReport>;
523
866
  pushMutation(input: PushMutationRequest<User>): Promise<PushMutationResponse>;
524
867
  /** Apply an in-order batch (the client mutation queue's flush). Envelopes run strictly
525
868
  * sequentially; a rejection still advances the daemon's lmid, so later envelopes in the
@@ -558,6 +901,24 @@ export interface RindleApiServer<User> {
558
901
  handleRoomBootJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
559
902
  }
560
903
 
904
+ /** One {@link RindleApiServer.validateRealtime} verdict: a labeled query × exemplar args. */
905
+ export interface ValidateRealtimeVerdict {
906
+ query: string;
907
+ profile: string;
908
+ args: unknown;
909
+ covered: boolean;
910
+ /** Why it is not covered (uncovered verdicts only) — the daemon's reason strings, the
911
+ * aggregate refusal, or a resolve/footprint error message. */
912
+ reasons?: string[];
913
+ }
914
+
915
+ /** The {@link RindleApiServer.validateRealtime} report. */
916
+ export interface ValidateRealtimeReport {
917
+ verdicts: ValidateRealtimeVerdict[];
918
+ /** The uncovered subset of `verdicts` (what `strict` throws on). */
919
+ uncovered: ValidateRealtimeVerdict[];
920
+ }
921
+
561
922
  export type RindleApiErrorCode = "bad-request" | "forbidden" | "not-found" | "rejected";
562
923
 
563
924
  export class RindleApiError extends Error {
@@ -652,11 +1013,18 @@ export class SplitDaemonClient implements RindleDaemonClient {
652
1013
  if (!lmids) return Promise.reject(new Error("the write master lacks roomLmids"));
653
1014
  return lmids(input);
654
1015
  }
1016
+ // Pure computation hosted by rindled: keep it on the read/follower leg. The write master owns
1017
+ // room durability, not query-cover analysis.
1018
+ coverQuery(input: CoverQueryInput): Promise<CoverQueryOutput> {
1019
+ const cover = this.reads.coverQuery?.bind(this.reads);
1020
+ if (!cover) return Promise.reject(new Error("the read follower lacks coverQuery"));
1021
+ return cover(input);
1022
+ }
655
1023
  migrate(input: MigrateInput): Promise<MigrateOutput> {
656
1024
  return this.writes.migrate(input);
657
1025
  }
658
1026
 
659
- // reads → the router (it stamps `wsEndpoint` onto the outputs)
1027
+ // reads → the fleet (one FLEET_URL follower; the affinity ticket + Fly edge place the machine)
660
1028
  materialize(input: MaterializeInput): Promise<MaterializeOutput> {
661
1029
  return this.reads.materialize(input);
662
1030
  }
@@ -885,6 +1253,24 @@ class AbsorbedReplay extends Error {
885
1253
  }
886
1254
  }
887
1255
 
1256
+ const MUTATOR_CONFLICT_MAX_ATTEMPTS = 5;
1257
+
1258
+ function isRetryableCommitConflict(error: unknown): boolean {
1259
+ if (!(error instanceof DaemonHttpError) || error.status !== 409) return false;
1260
+ try {
1261
+ const body = JSON.parse(error.body) as { code?: unknown; retryable?: unknown };
1262
+ return body.code === "retryable-conflict" && body.retryable === true;
1263
+ } catch {
1264
+ return false;
1265
+ }
1266
+ }
1267
+
1268
+ async function mutatorConflictBackoff(attempt: number): Promise<void> {
1269
+ const ceiling = Math.min(32, 2 ** attempt);
1270
+ const millis = ceiling + Math.floor(Math.random() * 4);
1271
+ await new Promise<void>((resolve) => setTimeout(resolve, millis));
1272
+ }
1273
+
888
1274
  /**
889
1275
  * The daemon server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
890
1276
  * execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
@@ -1156,37 +1542,48 @@ class PgLiveTx implements ServerMutationTx {
1156
1542
  export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
1157
1543
  return {
1158
1544
  dialect: sqliteDialect,
1159
- async runMutation({ envelope, render, run }) {
1160
- const tx = new DaemonLazyTx(render, daemon, envelope);
1161
- try {
1162
- await run(tx);
1163
- } catch (err) {
1164
- // A begin-absorbed replay: the authoritative outcome already committed — answer it,
1165
- // whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
1166
- if (tx.absorbed) return { accepted: true, output: tx.absorbed };
1167
- if (err instanceof BackendError) {
1168
- await tx.rollbackSessionQuietly();
1169
- throw err.driverError; // infra — never a user rejection
1170
- }
1171
- const reason = errMessage(err);
1172
- // Data first, watermark second: the rollback releases the single writer that the
1173
- // `/reject-mutation` lmid-only commit needs (§2.4 on the session path).
1174
- await tx.rollbackSessionQuietly();
1175
- const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1176
- return { accepted: false, reason, output };
1177
- }
1178
- if (tx.absorbed) return { accepted: true, output: tx.absorbed };
1179
- if (tx.session) {
1545
+ async runMutation(input) {
1546
+ for (let attempt = 0; attempt < MUTATOR_CONFLICT_MAX_ATTEMPTS; attempt++) {
1180
1547
  try {
1181
- return { accepted: true, output: await tx.commitSession() };
1182
- } catch (err) {
1183
- if (err instanceof BackendError) throw err.driverError; // infra (client retries; dedup absorbs)
1184
- throw err;
1548
+ const { envelope, render, run } = input;
1549
+ const tx = new DaemonLazyTx(render, daemon, envelope);
1550
+ try {
1551
+ await run(tx);
1552
+ } catch (err) {
1553
+ // A begin-absorbed replay: the authoritative outcome already committed — answer it,
1554
+ // whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
1555
+ if (tx.absorbed) return { accepted: true, output: tx.absorbed };
1556
+ if (err instanceof BackendError) {
1557
+ await tx.rollbackSessionQuietly();
1558
+ throw err.driverError; // infra — never a user rejection
1559
+ }
1560
+ const reason = errMessage(err);
1561
+ // Data first, watermark second: rollback releases this session's connection before
1562
+ // the lmid-only rejection commit.
1563
+ await tx.rollbackSessionQuietly();
1564
+ const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1565
+ return { accepted: false, reason, output };
1566
+ }
1567
+ if (tx.absorbed) return { accepted: true, output: tx.absorbed };
1568
+ if (tx.session) {
1569
+ try {
1570
+ return { accepted: true, output: await tx.commitSession() };
1571
+ } catch (err) {
1572
+ if (err instanceof BackendError) throw err.driverError;
1573
+ throw err;
1574
+ }
1575
+ }
1576
+ const txn: SqlTxn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
1577
+ if (tx.idempotencyKey !== undefined) txn.idempotencyKey = tx.idempotencyKey;
1578
+ return { accepted: true, output: await daemon.executeSqlTxn(txn) };
1579
+ } catch (error) {
1580
+ if (!isRetryableCommitConflict(error) || attempt + 1 === MUTATOR_CONFLICT_MAX_ATTEMPTS) {
1581
+ throw error;
1582
+ }
1583
+ await mutatorConflictBackoff(attempt);
1185
1584
  }
1186
1585
  }
1187
- const txn: SqlTxn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
1188
- if (tx.idempotencyKey !== undefined) txn.idempotencyKey = tx.idempotencyKey;
1189
- return { accepted: true, output: await daemon.executeSqlTxn(txn) };
1586
+ throw new Error("unreachable mutator conflict retry loop");
1190
1587
  },
1191
1588
  reject({ envelope, reason }) {
1192
1589
  return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
@@ -1390,7 +1787,11 @@ export function registerQueries<User>(queries: readonly NamedQuery<any, any, any
1390
1787
  if (Object.prototype.hasOwnProperty.call(out, query.queryName)) {
1391
1788
  throw new Error(`registerQueries: duplicate query name "${query.queryName}"`);
1392
1789
  }
1393
- out[query.queryName] = (ctx, args) => query.resolve(args, ctx);
1790
+ const wrapped: ApiQuery<User, any> = (ctx, args) => query.resolve(args, ctx);
1791
+ // The §2.1 realtime label survives this seam (read it back with {@link queryRealtimeLabel}) —
1792
+ // the lease path looks up (room profile, args mapping) by query name. Unlabeled queries get
1793
+ // the exact bare wrapper they always did.
1794
+ out[query.queryName] = query.realtime === undefined ? wrapped : attachRealtimeLabel(wrapped, query.realtime);
1394
1795
  }
1395
1796
  return out;
1396
1797
  }
@@ -1431,11 +1832,47 @@ export function sharedApiMutators<User>(
1431
1832
  return out;
1432
1833
  }
1433
1834
 
1434
- export function queryResultToAst(result: ApiQueryResult): Ast {
1435
- if (result && typeof result === "object" && "ast" in result && typeof result.ast === "function") {
1436
- return result.ast();
1437
- }
1438
- return result as Ast;
1835
+ /**
1836
+ * Wrap a SHARED (generator) mutator with a row-level ACCESS GUARD the multi-tenant authz twin of
1837
+ * {@link sharedApiMutators}. It parses the untrusted wire args, derives the {@link MutatorCtx}
1838
+ * principal (the SAME mapping you pass to `sharedApiMutators`), evaluates `predicate` against the OPEN
1839
+ * mutation txn (so it can READ the rows the write depends on), and throws `forbidden` (403 — the
1840
+ * client's optimistic write snaps back) when access is denied; otherwise it drives the SAME body the
1841
+ * client predicts ({@link runSharedMutation}). Use it for the entries that need server-only authority
1842
+ * the client cannot predict, OVERRIDING the auto-wrapped default (spread `sharedApiMutators(...)`
1843
+ * first, then the guarded overrides win by key):
1844
+ *
1845
+ * ```ts
1846
+ * const principal = (ctx) => ({ user: requireUser(ctx.user) });
1847
+ * mutators: defineApiMutators({
1848
+ * ...sharedApiMutators(sharedMutators, principal),
1849
+ * updateSlide: guardMutator(sharedMutators.updateSlide, principal,
1850
+ * async (tx, a, { user }) =>
1851
+ * (await tx.query(q.slide.where.id(a.slideId).where(editableBy(user)).one())) != null,
1852
+ * { message: "not permitted to edit this slide" }),
1853
+ * }),
1854
+ * ```
1855
+ *
1856
+ * The predicate keeps the shared body READ-FREE, so the client's `.folded` hot paths (drag/keystroke)
1857
+ * still fold — the read is server-side only. Return `false` to deny (→ the default or `opts.message`
1858
+ * forbidden); return `true`/nothing to allow. To reject with a different status/message (a business
1859
+ * rejection, a not-found), throw a {@link RindleApiError} from inside the predicate instead. `principal`
1860
+ * runs before the predicate, so it too may throw `forbidden` for an anonymous caller.
1861
+ */
1862
+ export function guardMutator<User, Args>(
1863
+ gen: SharedMutatorWithArgs<Args>,
1864
+ principal: (ctx: MutationContext<User>) => MutatorCtx,
1865
+ predicate: (tx: ServerMutationTx, args: Args, ctx: MutatorCtx) => boolean | void | Promise<boolean | void>,
1866
+ opts?: { message?: string },
1867
+ ): ApiMutator<User, unknown> {
1868
+ return async (tx, raw, ctx) => {
1869
+ const args = gen.args.parse(raw);
1870
+ const pctx = principal(ctx);
1871
+ if ((await predicate(tx, args, pctx)) === false) {
1872
+ throw new RindleApiError("forbidden", opts?.message ?? "not permitted", 403);
1873
+ }
1874
+ return runSharedMutation(gen, args, pctx, tx);
1875
+ };
1439
1876
  }
1440
1877
 
1441
1878
  /** One exemplar invocation for {@link dumpQueryShapes} — the `args`/`user` a query is built with.
@@ -1536,6 +1973,88 @@ function normalizeCondition(c: Condition): unknown {
1536
1973
  }
1537
1974
  }
1538
1975
 
1976
+ /**
1977
+ * The runtime {@link MutationScope} handed to a {@link ScopedMutator}. It owns the single atomic
1978
+ * transaction (delegating to {@link MutationBackend.runMutation} — the exact machinery a tx-form
1979
+ * mutator uses), but lets the AUTHOR decide when it opens, so server-only work can run outside it.
1980
+ *
1981
+ * It records its outcome so the harness — not the author — enforces the `lmid`-always-advances
1982
+ * invariant: `phase` reports whether the tx committed, business-rejected, or never ran, and `infra`
1983
+ * latches a backend (DB) failure. Because the backend's `runMutation` RETURNS `{accepted:false}` for
1984
+ * a business rejection (having already advanced `lmid` alone) and THROWS only for infra, `transact`
1985
+ * can cleanly re-throw {@link MutationRejected} on the former (for author compensation) and propagate
1986
+ * the raw driver error on the latter.
1987
+ */
1988
+ class MutationScopeImpl implements MutationScope {
1989
+ private attempted = false;
1990
+ private readonly backend: MutationBackend;
1991
+ private readonly envelope: MutationEnvelope;
1992
+ private readonly render: RenderIndex;
1993
+ /** Set once `transact` resolved through the backend (accepted OR business-rejected). */
1994
+ outcome?: MutationOutcome;
1995
+ /** The value the backend threw on INFRA (the DB failed) — always propagated, never an `lmid`
1996
+ * advance. Its presence is tracked by {@link infraLatched}, NOT by testing this for `undefined`:
1997
+ * a driver may legitimately reject with a falsy value, and misreading that as "no infra" would
1998
+ * reclassify a lost-connection failure as a business rejection and wrongly advance `lmid`. */
1999
+ infra?: unknown;
2000
+ /** True once an INFRA failure latched, regardless of its (possibly falsy) value. */
2001
+ infraLatched = false;
2002
+ /** The in-flight `transact` promise. `settle` awaits it before sealing, so a transact the author
2003
+ * FORGOT to await (a floating promise — nothing here lints against it) is still resolved to its
2004
+ * real outcome first; otherwise the seal would read `untouched`, reply with a phantom no-op, and
2005
+ * let the real write commit out-of-band after the response was already sent. */
2006
+ pending?: Promise<void>;
2007
+
2008
+ constructor(backend: MutationBackend, envelope: MutationEnvelope, render: RenderIndex) {
2009
+ this.backend = backend;
2010
+ this.envelope = envelope;
2011
+ this.render = render;
2012
+ }
2013
+
2014
+ transact(
2015
+ first: SharedMutator<any, any> | ((tx: ServerMutationTx) => void | Promise<void>),
2016
+ args?: unknown,
2017
+ ctx?: MutatorCtx,
2018
+ ): Promise<void> {
2019
+ if (this.attempted) throw new Error("scope.transact may be called at most once per mutation");
2020
+ this.attempted = true;
2021
+ const promise = this.drive(first, args, ctx);
2022
+ // Record the in-flight promise so `settle` can await it even when the author didn't. Errors are
2023
+ // latched onto `this` (outcome / infra), so this tracking copy swallows them — the author's
2024
+ // returned `promise` still rejects for them to await/catch.
2025
+ this.pending = promise.then(
2026
+ () => undefined,
2027
+ () => undefined,
2028
+ );
2029
+ return promise;
2030
+ }
2031
+
2032
+ private async drive(
2033
+ first: SharedMutator<any, any> | ((tx: ServerMutationTx) => void | Promise<void>),
2034
+ args?: unknown,
2035
+ ctx?: MutatorCtx,
2036
+ ): Promise<void> {
2037
+ // A shared (generator) mutator is driven via the isomorphic seam; a plain callback gets the raw tx.
2038
+ const run = isGeneratorMutator(first)
2039
+ ? async (tx: ServerMutationTx) => {
2040
+ await runSharedMutation(first as SharedMutator<unknown, MutatorCtx>, args, ctx as MutatorCtx, tx);
2041
+ }
2042
+ : async (tx: ServerMutationTx) => {
2043
+ await (first as (tx: ServerMutationTx) => void | Promise<void>)(tx);
2044
+ };
2045
+ let outcome: MutationOutcome;
2046
+ try {
2047
+ outcome = await this.backend.runMutation({ envelope: this.envelope, render: this.render, run });
2048
+ } catch (err) {
2049
+ this.infra = err; // the backend throws ONLY for infra; a business rejection returns {accepted:false}
2050
+ this.infraLatched = true;
2051
+ throw err;
2052
+ }
2053
+ this.outcome = outcome;
2054
+ if (!outcome.accepted) throw new MutationRejected(outcome.reason);
2055
+ }
2056
+ }
2057
+
1539
2058
  export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptions<User>): RindleApiServer<User> {
1540
2059
  const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
1541
2060
  const mode = opts.mode ?? "normalized";
@@ -1549,6 +2068,25 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1549
2068
  // floor, §4.1) so the first viewer to route to a follower warms it for late joiners.
1550
2069
  const pinnedNames = new Set((opts.pinnedQueries ?? []).map((p) => p.name));
1551
2070
 
2071
+ // Rindle Realtime declaration layer (RINDLE-REALTIME-QUERY-ENABLEMENT §2, slice G-iv-a):
2072
+ // compile the named room profiles and run every "loud at registration" (§2.3) check NOW —
2073
+ // construction is the moment a misconfigured profile or label can still fail the deploy,
2074
+ // not a 3am room boot. The legacy flat `resolveFootprint` stays the anonymous profile and
2075
+ // is deliberately NOT probed or validated (byte-identical legacy behavior).
2076
+ const realtime = opts.realtime;
2077
+ const roomProfiles = compileRoomProfiles<User>({
2078
+ rooms: realtime?.rooms,
2079
+ schema: opts.schema,
2080
+ warn: realtime?.warn,
2081
+ });
2082
+ assertLabeledProfilesExist(opts.queries, roomProfiles);
2083
+ if (realtime !== undefined && roomProfiles.size === 0 && realtime.resolveFootprint === undefined) {
2084
+ throw new Error(
2085
+ "realtime: configure at least one room profile (realtime.rooms) or the legacy resolveFootprint — " +
2086
+ "a realtime host with neither can never boot a room.",
2087
+ );
2088
+ }
2089
+
1552
2090
  // Resolve a named query (+ args) to its AST under a given context — the shared path for both
1553
2091
  // a per-viewer lease and a system-level pin (which skips per-user authorization).
1554
2092
  const resolveAst = async (name: string, args: unknown, context: ApiContext<User>): Promise<Ast> => {
@@ -1560,6 +2098,375 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1560
2098
  return queryResultToAst(result);
1561
2099
  };
1562
2100
 
2101
+ // ---------------------------------------------------------------- the room-serve decision
2102
+ //
2103
+ // RINDLE-REALTIME-QUERY-ENABLEMENT §2.1 lease-flow steps 2–5, slice G-iv-b. Everything here is
2104
+ // FAIL-OPEN: any missing wiring, refused proof, or thrown error means the lease is served from
2105
+ // the daemon EXACTLY as today (no `realtime` block, top-level fields untouched) plus a one-time
2106
+ // diagnostic — a coverage/config problem must never block a lease.
2107
+
2108
+ const realtimeWarn = realtime?.warn ?? ((message: string) => console.warn(message));
2109
+ // One-time per (queryName, profile): the serve decision runs on EVERY lease, so an uncovered
2110
+ // labeled query would otherwise warn once per viewer per mount.
2111
+ const warnedRoomServe = new Set<string>();
2112
+ const warnRoomServeOnce = (queryName: string, profile: string, reasons: readonly string[]): void => {
2113
+ const key = `${queryName}\u0000${profile}`;
2114
+ if (warnedRoomServe.has(key)) return;
2115
+ warnedRoomServe.add(key);
2116
+ realtimeWarn(
2117
+ `query "${queryName}" is labeled realtime (room profile "${profile}") but is NOT room-served — ` +
2118
+ `${reasons.join("; ")}. It serves from the daemon (correct, just not room-accelerated). ` +
2119
+ `This warning fires once per (query, profile).`,
2120
+ );
2121
+ };
2122
+
2123
+ // The §2.3 aggregate refusal: the client's aggregate overlay is daemon-gated until post-G, so
2124
+ // an aggregate/reduce-shaped query is refused room-serving REGARDLESS of coverage.
2125
+ const AGGREGATE_REFUSAL =
2126
+ "the query AST contains an aggregate/reduce shape — aggregate overlays are daemon-gated until post-G";
2127
+
2128
+ // Verdict cache. The verdict is a pure function of exactly two inputs — the resolved footprint
2129
+ // AST and the resolved query AST — so the tightest SOUND key is those two ASTs themselves
2130
+ // (stable-stringified), scoped by (queryName, profile) for legibility. Args/user/ctx need no
2131
+ // separate slot precisely because anything that changes the verdict must change one of the two
2132
+ // ASTs (predicate literals embed the args; ctx-scoped queries embed the principal); keying on
2133
+ // `(name, args)` alone would ALIAS two users' different ASTs under one verdict — unsound.
2134
+ // Bounded FIFO (Map iterates in insertion order) so per-user literals can't grow it forever.
2135
+ const coverVerdicts = new Map<string, CoverQueryOutput>();
2136
+ const COVER_VERDICT_CACHE_MAX = 1024;
2137
+
2138
+ const maybeRoomServe = async (
2139
+ input: QueryLeaseRequest<User>,
2140
+ queryAst: Ast,
2141
+ context: ApiContext<User>,
2142
+ subject: string | undefined,
2143
+ ): Promise<QueryLeaseRealtime | undefined> => {
2144
+ // (a) the label + (b) its profile — the fast bail keeps unlabeled leases byte-identical.
2145
+ const label = queryRealtimeLabel(opts.queries?.[input.name]);
2146
+ if (label === undefined) return undefined;
2147
+ const profile = roomProfiles.get(label.room);
2148
+ if (profile === undefined) return undefined; // unreachable: construction asserted it exists
2149
+ try {
2150
+ // (c) the wiring gates — each absence fail-opens with a one-time, named reason.
2151
+ if (realtime?.locateRoom === undefined) {
2152
+ warnRoomServeOnce(input.name, profile.name, ["realtime.locateRoom is not configured"]);
2153
+ return undefined;
2154
+ }
2155
+ const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
2156
+ if (coverQuery === undefined) {
2157
+ warnRoomServeOnce(input.name, profile.name, [
2158
+ "the configured daemon client does not implement coverQuery (/cover-check)",
2159
+ ]);
2160
+ return undefined;
2161
+ }
2162
+ const tokenKey = realtime.roomTokenKey;
2163
+ if (tokenKey === undefined) {
2164
+ warnRoomServeOnce(input.name, profile.name, [
2165
+ "realtime.roomTokenKey is not configured — the room lease token cannot be signed",
2166
+ ]);
2167
+ return undefined;
2168
+ }
2169
+ // The token's subject: the same resolved subject the daemon lease carries, else the
2170
+ // browser's clientId. The shell refuses a subject-less token, so with neither we fail open.
2171
+ const sub = subject ?? input.clientId;
2172
+ if (sub === undefined) {
2173
+ warnRoomServeOnce(input.name, profile.name, [
2174
+ "no token subject — configure `subject` (or have the client send clientId)",
2175
+ ]);
2176
+ return undefined;
2177
+ }
2178
+
2179
+ // §2.1: (roomProfile, roomArgs) via the label's args mapping; key + doc minted SERVER-side
2180
+ // (input.args just passed the query's own validation inside resolveAst).
2181
+ const roomArgs = label.args !== undefined ? label.args(input.args) : input.args;
2182
+ const key = profile.key(roomArgs);
2183
+ const doc = mintRoomDoc(profile.name, key);
2184
+
2185
+ // The profile footprint for THIS key under the request ctx (works for non-static
2186
+ // profiles), with the §2.3 unwindowed backstop `/room-boot` also applies.
2187
+ const footprintAst = queryResultToAst(await profile.footprint(key, context));
2188
+ assertUnwindowedFootprint(footprintAst, profile.name);
2189
+
2190
+ const verdictKey = `${input.name}\u0000${profile.name}\u0000${stableStringify(footprintAst)}\u0000${stableStringify(queryAst)}`;
2191
+ let verdict = coverVerdicts.get(verdictKey);
2192
+ if (verdict === undefined) {
2193
+ verdict = astHasAggregate(queryAst)
2194
+ ? { covered: false, reasons: [AGGREGATE_REFUSAL] }
2195
+ : await coverQuery({ footprint: footprintAst, query: queryAst });
2196
+ // (A coverQuery THROW never lands here — the outer catch fail-opens without caching, so
2197
+ // a transient daemon failure doesn't pin an uncovered verdict.)
2198
+ if (coverVerdicts.size >= COVER_VERDICT_CACHE_MAX) {
2199
+ coverVerdicts.delete(coverVerdicts.keys().next().value as string);
2200
+ }
2201
+ coverVerdicts.set(verdictKey, verdict);
2202
+ }
2203
+ if (!verdict.covered) {
2204
+ warnRoomServeOnce(input.name, profile.name, verdict.reasons ?? ["not provably covered"]);
2205
+ return undefined;
2206
+ }
2207
+
2208
+ // Covered ⇒ assemble the realtime block. The room endpoint rides ITS OWN field
2209
+ // (`realtime.wsEndpoint`) — a separate connection from the daemon session's fixed ws host.
2210
+ const { wsEndpoint } = await realtime.locateRoom(doc);
2211
+ const now = Date.now();
2212
+ const ttlMs = realtime.roomTokenTtlMs ?? DEFAULT_ROOM_TOKEN_TTL_MS;
2213
+ const { mintRoomToken, scopeSpecsHash } = await loadRoomTokenModule();
2214
+ // The lease-wire specs, hashed ONCE: the same value is stamped on the token (so the
2215
+ // shell can flag scope skew — a profile edited under a live room, whose gate armed
2216
+ // with the OLD specs at boot) and returned as the client's `tables`.
2217
+ const tables = compileRoomTableSpecs(footprintAst, profile.context);
2218
+ const roomToken = await mintRoomToken({
2219
+ doc,
2220
+ ast: queryAst, // the APPROVED resolved AST — the client carries it, it can't mint/alter it
2221
+ sub,
2222
+ kid: tokenKey.kid,
2223
+ key: tokenKey.secret,
2224
+ ttlMs,
2225
+ now,
2226
+ scopesHash: scopeSpecsHash(tables),
2227
+ });
2228
+ return {
2229
+ // `parse_source_key` (rust/src/wasm/db.rs): anything but the reserved "daemon" is a room
2230
+ // source; the client-store convention is `room:` + the wire doc.
2231
+ sourceKey: `room:${doc}`,
2232
+ wsEndpoint,
2233
+ roomToken,
2234
+ exp: now + ttlMs,
2235
+ doc,
2236
+ tables,
2237
+ };
2238
+ } catch (e) {
2239
+ // Fail open — a lease is never blocked on the proof. Not cached (may be transient).
2240
+ warnRoomServeOnce(input.name, profile.name, [`room-serve failed: ${errMessage(e)}`]);
2241
+ return undefined;
2242
+ }
2243
+ };
2244
+
2245
+ // ---------------------------------------------------------------- the §4 lifecycle mint (I-iii)
2246
+ //
2247
+ // Gated on the OPT-IN `realtime.lifecycle` block: absent, this whole section is dead code and
2248
+ // every lease response is byte-identical to pre-lifecycle. Present, a labeled lease gains the
2249
+ // doorbell system lease and a ROOM-SERVED one the fence bundle (see {@link QueryLeaseLifecycle}).
2250
+ // FAIL-OPEN like the room-serve decision: a mint failure (e.g. a daemon that never ran
2251
+ // `enable_realtime_lifecycle`) warns once per query and the lease ships without the block.
2252
+
2253
+ const warnedLifecycle = new Set<string>();
2254
+ const warnLifecycleOnce = (queryName: string, reason: string): void => {
2255
+ if (warnedLifecycle.has(queryName)) return;
2256
+ warnedLifecycle.add(queryName);
2257
+ realtimeWarn(
2258
+ `query "${queryName}" is realtime-labeled with lifecycle configured, but its lifecycle ` +
2259
+ `system leases were not minted — ${reason}. The lease serves without the lifecycle block ` +
2260
+ `(correct, just no §4 upgrade/downgrade plane). This warning fires once per query.`,
2261
+ );
2262
+ };
2263
+
2264
+ /** THE SCOPE-KEY DECISION (§4.1): the doorbell scope IS the wire room doc — `"<profile>/<key>"`
2265
+ * via {@link mintRoomDoc}, the same computation `maybeRoomServe` runs (label args mapping →
2266
+ * `profile.key`) and the same key `locateRoom`/`/room-boot` address the room by. Occupancy
2267
+ * (I-iv writes the `_rindle_scope_sessions` rows) must be counted on EXACTLY the key the 1→2
2268
+ * transition provisions, and this is that key. Computed independently of the room-serve
2269
+ * decision on purpose: the doorbell rides every LABELED lease — an uncovered/unwired labeled
2270
+ * query still counts toward occupancy (its collaborators still want the upgrade). */
2271
+ const lifecycleScopeDoc = (input: QueryLeaseRequest<User>): string | undefined => {
2272
+ const label = queryRealtimeLabel(opts.queries?.[input.name]);
2273
+ if (label === undefined) return undefined; // unlabeled — no scope to count on
2274
+ const profile = roomProfiles.get(label.room);
2275
+ if (profile === undefined) return undefined; // unreachable: construction asserted it exists
2276
+ const roomArgs = label.args !== undefined ? label.args(input.args) : input.args;
2277
+ return mintRoomDoc(profile.name, profile.key(roomArgs));
2278
+ };
2279
+
2280
+ const maybeLifecycle = async (
2281
+ input: QueryLeaseRequest<User>,
2282
+ roomServed: boolean,
2283
+ subject: string | undefined,
2284
+ routingKey: string | undefined,
2285
+ ): Promise<QueryLeaseLifecycle | undefined> => {
2286
+ if (realtime?.lifecycle === undefined) return undefined; // the opt-in gate — mint NOTHING
2287
+ try {
2288
+ const doc = lifecycleScopeDoc(input);
2289
+ if (doc === undefined) return undefined;
2290
+ // Each system lease is an ordinary daemon materialization (the room-boot direct pattern),
2291
+ // carrying the SAME subject/routingKey as the primary lease so a routed deploy co-locates
2292
+ // the system streams on the follower the client's daemon session already lives on. The
2293
+ // daemon dedups by canonical query, so N clients' doorbells over one scope share ONE
2294
+ // materialization (each still minting its own leaseToken); the client-scoped fence ASTs
2295
+ // are per-client by construction.
2296
+ const mint = (ast: Ast) =>
2297
+ opts.daemon.materialize({
2298
+ ast,
2299
+ mode,
2300
+ subject,
2301
+ leaseTtlMs: opts.leaseTtlMs,
2302
+ metadata: routingKey !== undefined ? { routingKey } : undefined,
2303
+ // Lifecycle leases are follower-local exactly like the primary lease. Forward the SAME
2304
+ // opaque placement ticket so every doorbell/fence materialization is minted on the
2305
+ // browser socket's follower instead of independently anycasting across the fleet.
2306
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
2307
+ });
2308
+ const lease = (table: string, out: MaterializeOutput, id: { scope?: string; doc?: string; clientId?: string }): QueryLeaseLifecycleLease => ({
2309
+ table,
2310
+ leaseToken: out.leaseToken,
2311
+ ...(id.scope !== undefined ? { scope: id.scope } : {}),
2312
+ ...(id.doc !== undefined ? { doc: id.doc } : {}),
2313
+ ...(id.clientId !== undefined ? { clientId: id.clientId } : {}),
2314
+ });
2315
+ const lifecycle: QueryLeaseLifecycle = {
2316
+ doorbell: lease(SCOPE_SESSIONS_TABLE, await mint(scopeSessionsAst(doc)), { scope: doc }),
2317
+ };
2318
+ // The fence bundle only where a room domain exists to fence (room-served leases): the
2319
+ // §4.2 watermark, the §7.1 daemon-carried ledger, and the §3.3 outcome rows.
2320
+ if (roomServed) {
2321
+ const clientId = input.clientId;
2322
+ lifecycle.fence = [
2323
+ lease(ROOM_WATERMARK_TABLE, await mint(roomWatermarkAst(doc)), { doc }),
2324
+ lease(ROOM_CLIENT_MUTATIONS_TABLE, await mint(docClientAst(ROOM_CLIENT_MUTATIONS_TABLE, doc, clientId)), { doc, clientId }),
2325
+ lease(ROOM_MUTATION_OUTCOMES_TABLE, await mint(docClientAst(ROOM_MUTATION_OUTCOMES_TABLE, doc, clientId)), { doc, clientId }),
2326
+ ];
2327
+ }
2328
+ return lifecycle;
2329
+ } catch (e) {
2330
+ warnLifecycleOnce(input.name, errMessage(e)); // fail open — a lease is never blocked
2331
+ return undefined;
2332
+ }
2333
+ };
2334
+
2335
+ // ------------------------------------------------------------ the §4.1 occupancy gate (I-iv)
2336
+ //
2337
+ // Runs on EVERY labeled lease under the opt-in `realtime.lifecycle` config (mint AND renewal —
2338
+ // both land on this same route), BEFORE the room-serve decision: (1) sweep + upsert the
2339
+ // caller's session row through the normal write path (the write is the doorbell — I-i's CDC
2340
+ // capture fans the row delta to every solo watcher's doorbell subscription), then (2) read the
2341
+ // occupancy count and return the D6 gate verdict `maybeRoomServe` is conditioned on. The upsert
2342
+ // deliberately precedes the count so the caller's own row is on disk when the verdict is
2343
+ // computed (its own presence rides the `+ 1`, and — more importantly — a concurrent second
2344
+ // client's read sees it). Ordering within the pair is otherwise value-neutral: the count
2345
+ // EXCLUDES the caller's clientId and adds the `+ 1` analytically.
2346
+ //
2347
+ // THE RENEWAL-vs-FRESH DECISION (grounded here because the task forces it): this server is
2348
+ // stateless and the lease request carries no "I am currently room-attached" field, so the gate
2349
+ // CANNOT distinguish a fresh mint from a live room's renewal. Instead of gating on the raw
2350
+ // count (which would suppress a momentarily-solo room's renewal and force the loud client-side
2351
+ // downgrade anomaly), the gate applies the §9.1 hysteresis DIRECTLY FROM THE LINGERING ROWS the
2352
+ // D4 sweep preserves: room-serve iff `liveOthers + self ≥ minSessions` OR some other session
2353
+ // expired within `graceMs`. A renewal is therefore never suppressed until the scope has been
2354
+ // solo SUSTAINED past the grace window — which is exactly Slice I-v's downgrade condition, read
2355
+ // from the same rows; I-v replaces that post-grace loud suppression with the fenced downgrade
2356
+ // dance, refining (not re-deciding) this verdict. A truly fresh solo scope (no other row, live
2357
+ // or lingering) is suppressed immediately — the D6 point.
2358
+ //
2359
+ // Timestamps are `Date.now()` server-side throughout (mint, sweep, count): occupancy tolerates
2360
+ // clock skew between api-server instances up to ~grace — a skewed `now` moves a session between
2361
+ // "live" and "in-grace", both of which hold the gate open; only skew past the grace+slack band
2362
+ // could mis-sweep, and the slack exists to keep that band clear.
2363
+ //
2364
+ // A request with NO `clientId` (a non-shipped client — the shipped one always sends it, see
2365
+ // `postLease`) upserts NO row and contributes NOTHING to occupancy, including to its own gate:
2366
+ // it room-serves only if the OTHER sessions alone reach `minSessions` (there is no session
2367
+ // identity to count it under, D7). It still gets its doorbell (`maybeLifecycle` is independent).
2368
+ //
2369
+ // FAIL-OPEN, like every lifecycle surface: an occupancy failure (e.g. a daemon that never ran
2370
+ // `enable_realtime_lifecycle`) warns once per query and returns `true` — the gate falls away
2371
+ // and the lease serves exactly as pre-I-iv. Suppressing on infrastructure failure would turn
2372
+ // realtime off fleet-wide from one missing table; never block, never suppress, on an error.
2373
+
2374
+ const warnedOccupancy = new Set<string>();
2375
+ interface LifecycleOccupancy {
2376
+ /** The D6 room-serve gate verdict — `false` ⇒ suppress the room block (solo/uncovered). */
2377
+ gateOpen: boolean;
2378
+ /** The I-v downgrade guard: a room plausibly hosts this scope (some other-session row lives
2379
+ * or lingers). Only a `!gateOpen && roomPlausible` reply drains — never a never-shared doc. */
2380
+ roomPlausible: boolean;
2381
+ /** The wire doc the scope maps to (`"<profile>/<key>"`); `undefined` when unlabeled / off. */
2382
+ doc: string | undefined;
2383
+ }
2384
+ const lifecycleOccupancy = async (input: QueryLeaseRequest<User>): Promise<LifecycleOccupancy> => {
2385
+ const lc = realtime?.lifecycle;
2386
+ if (lc === undefined) return { gateOpen: true, roomPlausible: false, doc: undefined }; // lifecycle off — the gate does not exist (inert-until-fed)
2387
+ const doc = lifecycleScopeDoc(input);
2388
+ if (doc === undefined) return { gateOpen: true, roomPlausible: false, doc: undefined }; // unlabeled — no scope to count on, nothing to gate
2389
+ try {
2390
+ const now = Date.now();
2391
+ const minSessions = lc.minSessions ?? DEFAULT_LIFECYCLE_MIN_SESSIONS;
2392
+ const graceMs = lc.graceMs ?? DEFAULT_LIFECYCLE_GRACE_MS;
2393
+ const sessionTtlMs = lc.sessionTtlMs ?? opts.leaseTtlMs ?? DEFAULT_SESSION_TTL_MS;
2394
+ const clientId = input.clientId;
2395
+ // (1) sweep + upsert, ONE write txn (D4: the sweep shares the upsert's transaction — no
2396
+ // separate maintenance pass, and the linger bound holds atomically with the refresh).
2397
+ const statements: SqlStatement[] = [
2398
+ { sql: SESSION_SWEEP_SQL, params: [doc, now - (graceMs + SESSION_SWEEP_SLACK_MS)] },
2399
+ ];
2400
+ if (clientId !== undefined) {
2401
+ statements.push({ sql: SESSION_UPSERT_SQL, params: [doc, clientId, now + sessionTtlMs] });
2402
+ }
2403
+ await opts.daemon.executeSqlTxn({ statements });
2404
+ // (2) the count — read-your-writes via `consistency: "strong"` (see the section note above).
2405
+ const read = await opts.daemon.executeSqlRead({
2406
+ sql: clientId !== undefined ? SESSION_COUNT_OTHERS_SQL : SESSION_COUNT_SQL,
2407
+ params:
2408
+ clientId !== undefined
2409
+ ? [now, now, now - graceMs, doc, clientId]
2410
+ : [now, now, now - graceMs, doc],
2411
+ consistency: "strong",
2412
+ });
2413
+ const cells = read.rows[0] ?? [];
2414
+ const liveOthers = Number(cells[0] ?? 0); // SUM over zero rows is NULL — coerce
2415
+ const graceOthers = Number(cells[1] ?? 0);
2416
+ const totalOthers = Number(cells[2] ?? 0); // ALL other rows (any expiry, pre-sweep)
2417
+ const self = clientId !== undefined ? 1 : 0;
2418
+ return {
2419
+ gateOpen: liveOthers + self >= minSessions || graceOthers > 0,
2420
+ // Room plausibly exists ⇒ this scope was shared (a room was provisioned on the 1→2). A
2421
+ // never-shared solo doc has NO other row and must never drain (no wasted room boot).
2422
+ roomPlausible: totalOthers > 0,
2423
+ doc,
2424
+ };
2425
+ } catch (e) {
2426
+ if (!warnedOccupancy.has(input.name)) {
2427
+ warnedOccupancy.add(input.name);
2428
+ realtimeWarn(
2429
+ `query "${input.name}" is realtime-labeled with lifecycle configured, but the §4.1 ` +
2430
+ `occupancy step failed — ${errMessage(e)}. The occupancy gate fail-opens (the lease ` +
2431
+ `serves exactly as pre-I-iv; no session row was counted). This warning fires once per query.`,
2432
+ );
2433
+ }
2434
+ return { gateOpen: true, roomPlausible: false, doc };
2435
+ }
2436
+ };
2437
+
2438
+ // ------------------------------------------------------------ the §4.2 downgrade drain (I-v)
2439
+ //
2440
+ // When the occupancy gate closes for a scope a room plausibly hosted, drain that room to a
2441
+ // COMMITTED flush seq and hand it back as the fence. `drainRoom` (deployment-wired to the room
2442
+ // shell / DO `/drain`) is idempotent (concurrent api-server instances may both call it) and
2443
+ // fails OPEN — a downgrade must never block a lease, so an unconfigured or throwing hook simply
2444
+ // omits the fence (warn-once) and the client falls to its loud legacy downgrade path.
2445
+ const warnedDrain = new Set<string>();
2446
+ const warnDrainOnce = (queryName: string, reason: string): void => {
2447
+ if (warnedDrain.has(queryName)) return;
2448
+ warnedDrain.add(queryName);
2449
+ realtimeWarn(
2450
+ `query "${queryName}" downgraded (occupancy gate closed) but no §4.2 fence was attached — ` +
2451
+ `${reason}. The lease ships without the fence; a room-attached client falls back to its ` +
2452
+ `loud legacy downgrade (correct, just not graceful). This warning fires once per query.`,
2453
+ );
2454
+ };
2455
+ const maybeDrainRoom = async (queryName: string, doc: string): Promise<QueryLeaseRealtimeFence | undefined> => {
2456
+ const drainRoom = realtime?.lifecycle?.drainRoom;
2457
+ if (drainRoom === undefined) {
2458
+ warnDrainOnce(queryName, "realtime.lifecycle.drainRoom is not configured");
2459
+ return undefined;
2460
+ }
2461
+ try {
2462
+ const { finalFlushSeq } = await drainRoom(doc);
2463
+ return { sourceKey: `room:${doc}`, doc, finalFlushSeq };
2464
+ } catch (e) {
2465
+ warnDrainOnce(queryName, `drainRoom threw — ${errMessage(e)}`);
2466
+ return undefined;
2467
+ }
2468
+ };
2469
+
1563
2470
  const createQueryLease = async (input: QueryLeaseRequest<User>): Promise<QueryLeaseResponse> => {
1564
2471
  const context: ApiContext<User> = { user: input.user, request: input.request };
1565
2472
  await assertAuthorized(opts.authorizeQuery, {
@@ -1586,8 +2493,38 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1586
2493
  // The anonymous routing key rides `metadata.routingKey`; the router keys on
1587
2494
  // `subject ?? metadata.routingKey` (§2.2). Omitted when there is none.
1588
2495
  metadata: routingKey !== undefined ? { routingKey } : undefined,
2496
+ // Forward the browser's opaque affinity ticket (if any) so the fleet `fly-replay`s this
2497
+ // materialize to the follower the ws is pinned to (FOLLOWER-AFFINITY-DESIGN.md §4). Opaque —
2498
+ // never verified here. Inert when the reads client is a single daemon (no fleet edge).
2499
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
1589
2500
  });
1590
- return queryLeaseResponse(out);
2501
+ const res = queryLeaseResponse(out);
2502
+ // I-iv (§4.1): the occupancy step FIRST — session sweep+upsert, then the D6 gate verdict. A
2503
+ // closed gate suppresses the room-serve ONLY (the lease ships without the realtime block,
2504
+ // indistinguishable from an uncovered query — the fail-open daemon path) while the doorbell
2505
+ // below still rides; lifecycle-off ⇒ `gateOpen: true` unconditionally and this line is inert.
2506
+ const occ = await lifecycleOccupancy(input);
2507
+ // G-iv-b: a covered labeled query ADDITIONALLY gains the realtime block. The daemon lease
2508
+ // above is unconditional (and its fields untouched) — room-serving only ever adds a field,
2509
+ // so an uncovered/unwired/legacy lease stays byte-identical and nothing here can block one.
2510
+ const rt = occ.gateOpen ? await maybeRoomServe(input, ast, context, subject) : undefined;
2511
+ if (rt !== undefined) res.realtime = rt;
2512
+ // I-v (§4.2): the gate CLOSED and a room plausibly hosted this scope — drain it and ride the
2513
+ // fence back so a room-attached client runs the GRACEFUL downgrade instead of the loud legacy
2514
+ // anomaly. A never-shared solo doc (`!roomPlausible`) never drains (no wasted room boot); a
2515
+ // daemon-attached client that receives a stray fence ignores it (its resolver reads only the
2516
+ // daemon fields). `drainRoom` absent/throwing ⇒ no fence (fail-open, warn-once).
2517
+ if (!occ.gateOpen && occ.roomPlausible && occ.doc !== undefined) {
2518
+ const fence = await maybeDrainRoom(input.name, occ.doc);
2519
+ if (fence !== undefined) res.realtimeFence = fence;
2520
+ }
2521
+ // I-iii: under the opt-in `realtime.lifecycle` config a LABELED lease additionally gains the
2522
+ // §4 system-stream block (doorbell always; the fence bundle iff room-served OR downgrade-fenced
2523
+ // — a downgrading client needs the watermark/ledger/outcome streams to run the ghost drop).
2524
+ // Same additive discipline as the realtime block: absent config ⇒ byte-identical response.
2525
+ const lc = await maybeLifecycle(input, rt !== undefined || res.realtimeFence !== undefined, subject, routingKey);
2526
+ if (lc !== undefined) res.lifecycle = lc;
2527
+ return res;
1591
2528
  };
1592
2529
 
1593
2530
  const readQuery = async (input: QueryReadRequest<User>): Promise<QueryReadResponse> => {
@@ -1609,10 +2546,13 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1609
2546
  const subject = await resolveSubject(opts.subject, input);
1610
2547
  const routingKey = await resolveRoutingKey(opts.routingKey, input);
1611
2548
  const visibilityKey = subject ?? routingKey;
1612
- const out = await opts.daemon.query({ ast, visibilityKey, ttlMs: opts.readIdleTtlMs });
1613
- const res: QueryReadResponse = { rows: out.rows, cvMin: out.cvMin, queryKey: out.queryKey };
1614
- if (out.wsEndpoint !== undefined) res.wsEndpoint = out.wsEndpoint;
1615
- return res;
2549
+ const out = await opts.daemon.query({
2550
+ ast,
2551
+ visibilityKey,
2552
+ ttlMs: opts.readIdleTtlMs,
2553
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
2554
+ });
2555
+ return { rows: out.rows, cvMin: out.cvMin, queryKey: out.queryKey };
1616
2556
  };
1617
2557
 
1618
2558
  const pushMutation = async (input: PushMutationRequest<User>): Promise<PushMutationResponse> => {
@@ -1629,23 +2569,80 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1629
2569
  } catch (err) {
1630
2570
  return reject(backend, input.envelope, errMessage(err));
1631
2571
  }
1632
- // Run the mutator INSIDE the backend's transaction. A throw from the mutator body is a business
1633
- // rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
2572
+ const mctx: MutationContext<User> = {
2573
+ user: input.user,
2574
+ envelope: input.envelope,
2575
+ daemon: opts.daemon,
2576
+ request: input.request,
2577
+ };
2578
+
2579
+ // SCOPED mutator (WORK-OUTSIDE-TX): the author controls the tx boundary via `scope.transact`,
2580
+ // running server-only code before/after it. The `lmid`-always-advances invariant is OURS, not
2581
+ // the author's — we seal the response from the scope's recorded state, so an early return, a
2582
+ // never-called transact, or a swallowed rejection can't wedge the client's pending queue.
2583
+ if (isScoped<User>(mutator)) {
2584
+ const scope = new MutationScopeImpl(backend, input.envelope, renderIndex);
2585
+ // A throw that reaches `settle` AFTER the outcome is already sealed (post-commit effect, or a
2586
+ // compensation handler after a business rejection) can't change the response — but it must not
2587
+ // vanish. Route it to the app's hook, else log so a failed refund is never fully silent.
2588
+ const reportSealed = (err: unknown, phase: "committed" | "rejected"): void => {
2589
+ if (opts.onScopeError) opts.onScopeError(err, { phase, envelope: input.envelope });
2590
+ else console.error(`[rindle api-server] scoped mutator ${input.envelope.name}: post-${phase} code threw (outcome already sealed):`, err);
2591
+ };
2592
+ // Derive the response from the scope's OUTCOME (not the body's return), so control flow in the
2593
+ // author's function can't skip the lmid advance. `caught` distinguishes "the body threw"
2594
+ // (present, even if the thrown value was `undefined`) from "it returned cleanly".
2595
+ const settle = async (caught?: { err: unknown }): Promise<PushMutationResponse> => {
2596
+ // Seal from the REAL outcome even if the author forgot to `await` transact: draining its
2597
+ // in-flight promise here records the outcome/infra before we read it (else a phantom no-op
2598
+ // ships while the real write commits out-of-band). Already-resolved when it WAS awaited.
2599
+ if (scope.pending) await scope.pending;
2600
+ // Infra always wins: the backend threw, the commit state is unknown — never advance lmid.
2601
+ // Keyed on the latched BOOLEAN, so a driver that rejects with a falsy value is still infra.
2602
+ if (scope.infraLatched) throw scope.infra;
2603
+ // transact resolved (committed OR business-rejected): seal from its recorded outcome. A
2604
+ // post-commit / post-reject-compensation throw can't change the sealed outcome (its effects
2605
+ // can't roll the tx back, and lmid already advanced §2.4). Rethrowing the MutationRejected is
2606
+ // the sanctioned "compensated, stay rejected" signal — expected, not surfaced. Any OTHER throw
2607
+ // (a FAILED refund, a post-commit effect) must not vanish — surface it.
2608
+ if (scope.outcome) {
2609
+ if (caught && !(caught.err instanceof MutationRejected)) {
2610
+ reportSealed(caught.err, scope.outcome.accepted ? "committed" : "rejected");
2611
+ }
2612
+ return outcomeToResponse(scope.outcome);
2613
+ }
2614
+ // Never transacted:
2615
+ if (caught) {
2616
+ // A throw before/around transact. A BackendError is the author signaling INFRA (retry);
2617
+ // any other throw is a BUSINESS rejection — advance lmid alone so the prediction snaps back.
2618
+ if (caught.err instanceof BackendError) throw caught.err.driverError;
2619
+ return reject(backend, input.envelope, errMessage(caught.err));
2620
+ }
2621
+ // Clean return with no transact — an accepted no-op that STILL advances lmid (the client
2622
+ // predicted a write; its pending entry must resolve).
2623
+ return outcomeToResponse(
2624
+ await backend.runMutation({ envelope: input.envelope, render: renderIndex, run: async () => {} }),
2625
+ );
2626
+ };
2627
+ try {
2628
+ await (mutator as unknown as ScopedMutator<User, unknown>)(scope, input.envelope.args as never, mctx);
2629
+ } catch (err) {
2630
+ return settle({ err });
2631
+ }
2632
+ return settle();
2633
+ }
2634
+
2635
+ // Run the (tx-form) mutator INSIDE the backend's transaction. A throw from the mutator body is a
2636
+ // business rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
1634
2637
  const outcome = await backend.runMutation({
1635
2638
  envelope: input.envelope,
1636
2639
  render: renderIndex,
1637
2640
  run: async (tx) => {
1638
- const result = await mutator(tx, input.envelope.args as never, {
1639
- user: input.user,
1640
- envelope: input.envelope,
1641
- daemon: opts.daemon,
1642
- request: input.request,
1643
- });
2641
+ const result = await mutator(tx, input.envelope.args as never, mctx);
1644
2642
  applyResultToTx(result, tx);
1645
2643
  },
1646
2644
  });
1647
- if (outcome.accepted) return { accepted: true, rejected: false, output: outcome.output };
1648
- return { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
2645
+ return outcomeToResponse(outcome);
1649
2646
  };
1650
2647
 
1651
2648
  const pushMutations = async (input: PushMutationsRequest<User>): Promise<PushMutationResponse[]> => {
@@ -1703,10 +2700,60 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1703
2700
  }
1704
2701
  };
1705
2702
 
2703
+ // The explicit coverage diagnostic (the assertPins pattern: system-level, resolved under
2704
+ // `pinUser`, per-query failures collected — never strand the rest). It runs the REAL check —
2705
+ // the daemon's /cover-check on the actually-resolved ASTs — so its verdicts are exactly the
2706
+ // lease path's, minus the serving wiring (locateRoom/roomTokenKey), which it deliberately
2707
+ // ignores: it answers "is this labeled query coverable", the deployable-config question.
2708
+ const validateRealtime = async (vopts?: {
2709
+ exemplars?: Partial<Record<string, readonly unknown[]>>;
2710
+ strict?: boolean;
2711
+ }): Promise<ValidateRealtimeReport> => {
2712
+ const context: ApiContext<User> = { user: opts.pinUser as User, request: undefined };
2713
+ const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
2714
+ const verdicts: ValidateRealtimeVerdict[] = [];
2715
+ for (const [name, q] of Object.entries(opts.queries ?? {})) {
2716
+ const label = queryRealtimeLabel(q);
2717
+ if (label === undefined) continue;
2718
+ const profile = roomProfiles.get(label.room);
2719
+ if (profile === undefined) continue; // unreachable: construction asserted it exists
2720
+ for (const args of vopts?.exemplars?.[name] ?? [null]) {
2721
+ const verdict: ValidateRealtimeVerdict = { query: name, profile: profile.name, args, covered: false };
2722
+ try {
2723
+ const ast = await resolveAst(name, args, context);
2724
+ const roomArgs = label.args !== undefined ? label.args(args) : args;
2725
+ const footprintAst = queryResultToAst(await profile.footprint(profile.key(roomArgs), context));
2726
+ assertUnwindowedFootprint(footprintAst, profile.name);
2727
+ if (astHasAggregate(ast)) {
2728
+ verdict.reasons = [AGGREGATE_REFUSAL];
2729
+ } else if (coverQuery === undefined) {
2730
+ verdict.reasons = ["the configured daemon client does not implement coverQuery (/cover-check)"];
2731
+ } else {
2732
+ const out = await coverQuery({ footprint: footprintAst, query: ast });
2733
+ verdict.covered = out.covered;
2734
+ if (!out.covered) verdict.reasons = out.reasons ?? ["not provably covered"];
2735
+ }
2736
+ } catch (e) {
2737
+ verdict.reasons = [errMessage(e)];
2738
+ }
2739
+ verdicts.push(verdict);
2740
+ }
2741
+ }
2742
+ const uncovered = verdicts.filter((v) => !v.covered);
2743
+ if (vopts?.strict && uncovered.length > 0) {
2744
+ throw new Error(
2745
+ `validateRealtime: ${uncovered.length} labeled query verdict(s) not provably covered — ` +
2746
+ uncovered
2747
+ .map((v) => `${v.query} (profile "${v.profile}"): ${(v.reasons ?? []).join("; ")}`)
2748
+ .join(" | "),
2749
+ );
2750
+ }
2751
+ return { verdicts, uncovered };
2752
+ };
2753
+
1706
2754
  // The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
1707
2755
  // the `realtime` block (which also activates `/room-boot`) or the deprecated bare
1708
2756
  // `authorizeRoom` (trio only). Hosting an authority is never a default.
1709
- const realtime = opts.realtime;
1710
2757
  const roomAuthorizer: Authorizer<ApiContext<User>> | undefined = realtime
1711
2758
  ? (realtime.authorize ?? defaultFlushGate(realtime.shellSecret))
1712
2759
  : opts.authorizeRoom;
@@ -1717,6 +2764,40 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1717
2764
  await assertAuthorized(roomAuthorizer, context);
1718
2765
  };
1719
2766
 
2767
+ // §2.1 room-key routing: a "<profile>/<key>" doc resolves through its NAMED profile — with the
2768
+ // boot-time unwindowed backstop (§2.3), which covers footprints that weren't statically
2769
+ // resolvable at construction AND key-dependent branches that window only some docs. Anything
2770
+ // else falls through to the legacy single-profile alias BYTE-IDENTICALLY (the anonymous
2771
+ // profile, bare-key form). A named profile wins over a legacy doc that merely contains "/".
2772
+ // Returns the profile's context set beside the AST (H-iv-b: the scope-spec compilation needs
2773
+ // the §2.2 owned/followed split; the legacy anonymous profile has no declaration — empty set).
2774
+ const resolveRoomFootprint = async (
2775
+ rt: RindleRealtimeOptions<User>,
2776
+ doc: string,
2777
+ context: ApiContext<User>,
2778
+ ): Promise<{ ast: Ast; contextTables: ReadonlySet<string> }> => {
2779
+ const split = splitRoomDoc(doc);
2780
+ if (split !== undefined) {
2781
+ const profile = roomProfiles.get(split.profile);
2782
+ if (profile !== undefined) {
2783
+ const ast = queryResultToAst(await profile.footprint(split.key, context));
2784
+ assertUnwindowedFootprint(ast, profile.name);
2785
+ return { ast, contextTables: profile.context };
2786
+ }
2787
+ }
2788
+ if (rt.resolveFootprint) {
2789
+ return {
2790
+ ast: queryResultToAst(await rt.resolveFootprint(doc, context)),
2791
+ contextTables: new Set<string>(),
2792
+ };
2793
+ }
2794
+ throw new RindleApiError(
2795
+ "not-found",
2796
+ `no room profile matches doc "${doc}" — named profiles are addressed as "<profile>/<key>"`,
2797
+ 404,
2798
+ );
2799
+ };
2800
+
1720
2801
  // The store's verdict rides specific statuses + body shapes (fence / conflict /
1721
2802
  // identity) the room decodes — pass a daemon HTTP error through VERBATIM.
1722
2803
  const daemonVerdict = (e: unknown): RoomHostResponse => {
@@ -1737,6 +2818,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1737
2818
  createQueryLease,
1738
2819
  readQuery,
1739
2820
  assertPins,
2821
+ validateRealtime,
1740
2822
  pushMutation,
1741
2823
  pushMutations,
1742
2824
  handleApplyRowChangeTxnJson: async (body, context) => {
@@ -1766,6 +2848,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1766
2848
  handleRoomLmidsJson: async (body, context) => {
1767
2849
  await roomGate(context);
1768
2850
  const msg = parseObject(body, "room-lmids request");
2851
+ const doc = parseString(msg.doc, "doc");
1769
2852
  if (!Array.isArray(msg.clients) || msg.clients.some((c) => typeof c !== "string")) {
1770
2853
  throw new RindleApiError("bad-request", "clients must be an array of strings", 400);
1771
2854
  }
@@ -1774,7 +2857,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1774
2857
  throw new Error("the configured daemon client does not implement roomLmids");
1775
2858
  }
1776
2859
  try {
1777
- return { status: 200, body: await lmids({ clients: msg.clients as string[] }) };
2860
+ return { status: 200, body: await lmids({ doc, clients: msg.clients as string[] }) };
1778
2861
  } catch (e) {
1779
2862
  return daemonVerdict(e);
1780
2863
  }
@@ -1787,7 +2870,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1787
2870
  const msg = parseObject(body, "room-boot request");
1788
2871
  const doc = parseString(msg.doc, "doc");
1789
2872
  if (msg.instance !== undefined) parseString(msg.instance, "instance"); // diagnostic identity only
1790
- const ast = queryResultToAst(await realtime.resolveFootprint(doc, context));
2873
+ const { ast, contextTables } = await resolveRoomFootprint(realtime, doc, context);
1791
2874
  const claim = opts.daemon.claimRoomEpoch?.bind(opts.daemon);
1792
2875
  if (!claim) {
1793
2876
  throw new Error("the configured daemon client does not implement claimRoomEpoch");
@@ -1815,6 +2898,9 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1815
2898
  const res: RoomBootResponse = {
1816
2899
  epoch,
1817
2900
  upstreamLeaseToken: lease.leaseToken,
2901
+ // H-iv-b: the §3.3 commit-gate scope specs, for named-profile AND legacy docs alike
2902
+ // (the footprint AST is resolved either way; legacy has an empty context set).
2903
+ scopes: compileRoomScopeSpecs(ast, contextTables),
1818
2904
  flush: {
1819
2905
  urls: {
1820
2906
  apply: routes.applyRowChangeTxn,
@@ -1824,7 +2910,8 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1824
2910
  headers,
1825
2911
  },
1826
2912
  };
1827
- const upstreamWsEndpoint = realtime.upstreamWsEndpoint ?? lease.wsEndpoint;
2913
+ if (lease.affinity !== undefined) res.upstreamAffinity = lease.affinity;
2914
+ const upstreamWsEndpoint = realtime.upstreamWsEndpoint;
1828
2915
  if (upstreamWsEndpoint !== undefined) res.upstreamWsEndpoint = upstreamWsEndpoint;
1829
2916
  return { status: 200, body: res };
1830
2917
  } catch (e) {
@@ -1839,6 +2926,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1839
2926
  args: msg.args ?? null,
1840
2927
  request: context.request,
1841
2928
  clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
2929
+ affinity: typeof msg.affinity === "string" ? msg.affinity : undefined,
1842
2930
  });
1843
2931
  },
1844
2932
  handleReadJson: (body, context) => {
@@ -1849,6 +2937,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1849
2937
  args: msg.args ?? null,
1850
2938
  request: context.request,
1851
2939
  clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
2940
+ affinity: typeof msg.affinity === "string" ? msg.affinity : undefined,
1852
2941
  });
1853
2942
  },
1854
2943
  handleMutateJson: (body, context) => {
@@ -2020,21 +3109,157 @@ async function resolveRoutingKey<User>(
2020
3109
  }
2021
3110
 
2022
3111
  function queryLeaseResponse(out: MaterializeOutput): QueryLeaseResponse {
2023
- const res: QueryLeaseResponse = {
3112
+ return {
2024
3113
  leaseToken: out.leaseToken,
2025
3114
  materializationId: out.materializationId,
2026
3115
  queryKey: out.queryKey,
2027
3116
  reused: out.reused,
2028
3117
  };
2029
- // Only present in a routed deploy — absent reproduces today's single-daemon response exactly.
2030
- if (out.wsEndpoint !== undefined) res.wsEndpoint = out.wsEndpoint;
2031
- return res;
2032
3118
  }
2033
3119
 
2034
3120
  function errMessage(reason: unknown): string {
2035
3121
  return String((reason as Error)?.message ?? reason);
2036
3122
  }
2037
3123
 
3124
+ // --------------------------------------------------------- lifecycle system leases (Slice I-iii)
3125
+
3126
+ // The four §4 lifecycle system tables, mirrored VERBATIM from the daemon DDL — the source of
3127
+ // truth is `rust/rindle-replica/src/mutations.rs` (`realtime_lifecycle_ddl()` + the room-ledger
3128
+ // DDL in `enable_client_mutations`); duplicated here like `DEFAULT_ROUTES` is client-side so this
3129
+ // package needs no engine import. `Db::enable_realtime_lifecycle` REGISTERS all four, so a
3130
+ // hand-built AST over them materializes and resolves `hello` like any base table (the room-boot
3131
+ // direct-materialize pattern).
3132
+ const SCOPE_SESSIONS_TABLE = "_rindle_scope_sessions";
3133
+ const ROOM_WATERMARK_TABLE = "_rindle_room_watermark";
3134
+ const ROOM_CLIENT_MUTATIONS_TABLE = "_rindle_room_client_mutations";
3135
+ const ROOM_MUTATION_OUTCOMES_TABLE = "_rindle_room_mutation_outcomes";
3136
+
3137
+ // --------------------------------------------------------- occupancy counting (Slice I-iv, §4.1)
3138
+ //
3139
+ // The occupancy step rides the NORMAL surfaces end to end: the session upsert + lazy sweep are one
3140
+ // `executeSqlTxn` (a plain write txn — CDC-captured since I-i, so the row landing IS the doorbell
3141
+ // delta fanning to every subscribed solo client; no clientID/mid — a system write must never
3142
+ // advance an lmid — and no idempotencyKey — a renewal's re-upsert must re-run, that is the
3143
+ // refresh), and the count is one `executeSqlRead` with `consistency: "strong"` — the read surface
3144
+ // the api-server already has against the daemon (the `DaemonLazyTx` fallback precedent above).
3145
+ // "strong" routes the read to the WRITE MASTER in a split deploy, which just serialized our
3146
+ // upsert: read-your-writes without a mutation session (the interactive-txn machinery is optional
3147
+ // on the daemon interface and far heavier than this two-round-trip pair needs).
3148
+
3149
+ /** Default {@link RindleRealtimeLifecycleOptions.minSessions} — the §4.1 1→2 trigger. */
3150
+ const DEFAULT_LIFECYCLE_MIN_SESSIONS = 2;
3151
+ /** Default {@link RindleRealtimeLifecycleOptions.graceMs} — the §9.1 hysteresis window. */
3152
+ const DEFAULT_LIFECYCLE_GRACE_MS = 120_000;
3153
+ /** Default {@link RindleRealtimeLifecycleOptions.sessionTtlMs} fallback when no `leaseTtlMs` is
3154
+ * configured either — 5 minutes, the {@link DEFAULT_ROOM_TOKEN_TTL_MS} cadence (see the field doc). */
3155
+ const DEFAULT_SESSION_TTL_MS = 5 * 60_000;
3156
+ /** Sweep slack past the grace window (D4): rows are deleted only once expired for MORE than
3157
+ * `graceMs + this` — the linger I-v's downgrade decision reads must comfortably outlive the
3158
+ * grace comparison itself under clock skew between api-server instances (occupancy tolerates
3159
+ * skew ≤ grace; the slack keeps the boundary case out of the deletable band). */
3160
+ const SESSION_SWEEP_SLACK_MS = 60_000;
3161
+
3162
+ /** D7 upsert: one row per (scope, clientId) — `(scope, client_id)` is the table's PRIMARY KEY
3163
+ * (`realtime_lifecycle_ddl()`), so a renewal refreshes `expires_at` in place. */
3164
+ const SESSION_UPSERT_SQL =
3165
+ `INSERT INTO ${SCOPE_SESSIONS_TABLE} (scope, client_id, expires_at) VALUES (?, ?, ?) ` +
3166
+ `ON CONFLICT(scope, client_id) DO UPDATE SET expires_at = excluded.expires_at`;
3167
+ /** The D4 lazy sweep, in the SAME txn as the upsert: age out THIS scope's long-expired rows.
3168
+ * Param 2 is `now − (graceMs + SESSION_SWEEP_SLACK_MS)` — never tighter (the linger contract). */
3169
+ const SESSION_SWEEP_SQL = `DELETE FROM ${SCOPE_SESSIONS_TABLE} WHERE scope = ? AND expires_at < ?`;
3170
+ /** The occupancy read, one SELECT: cell 0 = DISTINCT unexpired sessions (`expires_at > now`;
3171
+ * distinct by construction — `(scope, client_id)` is the PK), cell 1 = sessions expired WITHIN
3172
+ * the grace window (`now − graceMs < expires_at ≤ now`) — the upward hysteresis input, cell 2 =
3173
+ * ALL matching rows regardless of expiry (the I-v "room plausibly exists" signal: a scope with
3174
+ * ANY other-session row — live OR still lingering pre-sweep — was shared, so a room was
3175
+ * provisioned; a never-shared solo doc has none and must never drain). Params:
3176
+ * `[now, now, now − graceMs, scope]`. */
3177
+ const SESSION_COUNT_SQL =
3178
+ `SELECT SUM(CASE WHEN expires_at > ? THEN 1 ELSE 0 END), ` +
3179
+ `SUM(CASE WHEN expires_at <= ? AND expires_at > ? THEN 1 ELSE 0 END), ` +
3180
+ `COUNT(*) ` +
3181
+ `FROM ${SCOPE_SESSIONS_TABLE} WHERE scope = ?`;
3182
+ /** {@link SESSION_COUNT_SQL} excluding the CALLER's own row (D6 counts *other* sessions; the
3183
+ * caller contributes itself as the `+ 1`). One extra trailing param: the caller's clientId. */
3184
+ const SESSION_COUNT_OTHERS_SQL = `${SESSION_COUNT_SQL} AND client_id <> ?`;
3185
+
3186
+ /** `col = <string literal>` — the only predicate shape the lifecycle ASTs need. */
3187
+ function colEq(name: string, value: string): Condition {
3188
+ return { type: "simple", op: "=", left: { type: "column", name }, right: { type: "literal", value } };
3189
+ }
3190
+
3191
+ /** The doorbell AST (§4.1): every unexpired row under the scope is one live session; the row
3192
+ * delta arriving through a solo client's daemon subscription IS the 1→2 upgrade signal. The
3193
+ * expiry filter is deliberately NOT in the predicate — `expires_at > now()` would freeze `now`
3194
+ * at mint time; liveness is the READER's judgment (I-iv), the stream just carries the rows. */
3195
+ function scopeSessionsAst(scope: string): Ast {
3196
+ return { table: SCOPE_SESSIONS_TABLE, where: colEq("scope", scope) };
3197
+ }
3198
+
3199
+ /** The §4.2 fence AST: the doc's monotone `flush_seq` row. */
3200
+ function roomWatermarkAst(doc: string): Ast {
3201
+ return { table: ROOM_WATERMARK_TABLE, where: colEq("doc", doc) };
3202
+ }
3203
+
3204
+ /** The §7.1 ledger / §3.3 outcome ASTs share one shape: doc-scoped, and ADDITIONALLY
3205
+ * client-scoped when the lease request carried the browser's stable `clientId` (the same id the
3206
+ * mutation envelopes stamp, so it is exactly the ledger/outcome `client_id`). Without it the
3207
+ * predicate stays doc-only and the client filters to its own rows (defense in depth either
3208
+ * way — the client always filters). */
3209
+ function docClientAst(table: string, doc: string, clientId: string | undefined): Ast {
3210
+ const docCond = colEq("doc", doc);
3211
+ return {
3212
+ table,
3213
+ where: clientId === undefined ? docCond : { type: "and", conditions: [docCond, colEq("client_id", clientId)] },
3214
+ };
3215
+ }
3216
+
3217
+ // --------------------------------------------------------- room-serve helpers (G-iv-b)
3218
+
3219
+ /** Default room lease token TTL: short (minutes) per RINDLE-REALTIME §4.1 — renewal is a fresh
3220
+ * lease through the api-server, never an extension of this token. */
3221
+ const DEFAULT_ROOM_TOKEN_TTL_MS = 5 * 60_000;
3222
+
3223
+ /** Deterministic JSON: object keys sorted recursively, so two structurally identical ASTs from
3224
+ * independent resolves stringify identically (the verdict-cache key). */
3225
+ function stableStringify(v: unknown): string {
3226
+ return JSON.stringify(v, (_key, value: unknown) => {
3227
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
3228
+ const rec = value as Record<string, unknown>;
3229
+ const sorted: Record<string, unknown> = {};
3230
+ for (const k of Object.keys(rec).sort()) sorted[k] = rec[k];
3231
+ return sorted;
3232
+ }
3233
+ return value;
3234
+ });
3235
+ }
3236
+
3237
+ /** Does the AST contain an aggregate/reduce shape ANYWHERE (root, a `related` subquery, or an
3238
+ * `EXISTS` child)? Room-serving refuses these regardless of coverage: the client's aggregate
3239
+ * overlay (AGGREGATE-SYNC) is computed against the DAEMON's normalized stream and stays
3240
+ * daemon-gated until post-G. (`groupBy`/`having` only occur alongside `aggregate`, so testing
3241
+ * `aggregate` covers them; `having` is still walked for nested EXISTS aggregates.) */
3242
+ function astHasAggregate(ast: Ast): boolean {
3243
+ if (ast.aggregate !== undefined) return true;
3244
+ for (const rel of ast.related ?? []) {
3245
+ if (astHasAggregate(rel.subquery)) return true;
3246
+ }
3247
+ return conditionHasAggregate(ast.where) || conditionHasAggregate(ast.having);
3248
+ }
3249
+
3250
+ function conditionHasAggregate(cond: Condition | undefined): boolean {
3251
+ if (cond === undefined) return false;
3252
+ switch (cond.type) {
3253
+ case "simple":
3254
+ return false;
3255
+ case "and":
3256
+ case "or":
3257
+ return cond.conditions.some(conditionHasAggregate);
3258
+ case "correlatedSubquery":
3259
+ return astHasAggregate(cond.related.subquery);
3260
+ }
3261
+ }
3262
+
2038
3263
  async function reject(
2039
3264
  backend: MutationBackend,
2040
3265
  envelope: MutationEnvelope,
@@ -2044,6 +3269,15 @@ async function reject(
2044
3269
  return { accepted: false, rejected: true, reason, output };
2045
3270
  }
2046
3271
 
3272
+ /** The single place a {@link MutationOutcome} becomes the wire {@link PushMutationResponse} — shared
3273
+ * by the tx-form path and every scoped-mutator seal branch so the accepted/rejected shape can never
3274
+ * drift between them. */
3275
+ function outcomeToResponse(outcome: MutationOutcome): PushMutationResponse {
3276
+ return outcome.accepted
3277
+ ? { accepted: true, rejected: false, output: outcome.output }
3278
+ : { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
3279
+ }
3280
+
2047
3281
  function parseObject(value: unknown, label: string): Record<string, unknown> {
2048
3282
  if (!value || typeof value !== "object" || Array.isArray(value)) {
2049
3283
  throw new RindleApiError("bad-request", `invalid ${label}`, 400);