@rindle/api-server 0.5.0 → 0.6.4

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
@@ -16,13 +16,21 @@ import type {
16
16
  ServerWriteTx,
17
17
  } from "@rindle/client";
18
18
  import { DaemonHttpError } from "@rindle/daemon-client";
19
+ import { createSqlClient, encodeSqlValue, RindleSqlError } from "@rindle/sql-client";
20
+ import type {
21
+ ClientOptions as SqlClientOptions,
22
+ MutationReceipt as SqlMutationReceipt,
23
+ MutationRows as SqlMutationRows,
24
+ SqlClient,
25
+ SqlMutationTransaction,
26
+ SqlSession,
27
+ Statement as PublicSqlStatement,
28
+ } from "@rindle/sql-client";
19
29
  import { compile as compileQueryAst } from "@rindle/query-compiler";
20
30
  import type { Catalog, ColumnType as QueryColumnType, TableSchema } from "@rindle/query-compiler";
21
31
  import type {
22
32
  ClaimRoomEpochInput,
23
33
  ClaimRoomEpochOutput,
24
- CoverQueryInput,
25
- CoverQueryOutput,
26
34
  DematerializeInput,
27
35
  DematerializeOutput,
28
36
  MaterializationPolicy,
@@ -68,11 +76,13 @@ import {
68
76
  import type { RoomProfile, RoomScopeSpec, RoomTableSpec } from "./rooms.ts";
69
77
  // The room lease token (RINDLE-REALTIME §10.1): minted here, verified by the room SHELL against
70
78
  // its `downstream.tokenKeys` ring — the `/token` subpath is pure WebCrypto (no wasm, no shell).
71
- // Loaded LAZILY at the first mint: `@rindle/room` is a dev-only workspace dep (it is not published
72
- // to npm yetinfra/tests/publish-sync.mjs would flag a runtime dep on it), and the mint only
73
- // runs when `realtime.roomTokenKey` is configured. In-repo (workspace linking) the import always
74
- // resolves; an npm consumer without the package gets the serve decision's ordinary fail-open —
75
- // daemon-served leases plus a one-time warning naming the missing module.
79
+ // Loaded LAZILY at the first mint: `@rindle/room` is an OPTIONAL dependency (see package.json), so
80
+ // it is installed transitively — a consumer bundling api-server (Vite/Rollup/esbuild) can resolve
81
+ // this dynamic import even when it never uses rooms yet the mint only runs when
82
+ // `realtime.roomTokenKey` is configured. Should the module be genuinely absent (an install that
83
+ // skipped the optional dep), the serve decision fail-opens: daemon-served leases plus a one-time
84
+ // warning naming the missing module. It is NOT a hard `dependency` because the entire code path is
85
+ // optional; optionalDependencies keeps a failed install of it non-fatal.
76
86
  import type { mintRoomToken as MintRoomToken, scopeSpecsHash as ScopeSpecsHash } from "@rindle/room/token";
77
87
  let roomTokenModule: { mintRoomToken: typeof MintRoomToken; scopeSpecsHash: typeof ScopeSpecsHash } | undefined;
78
88
  async function loadRoomTokenModule(): Promise<{ mintRoomToken: typeof MintRoomToken; scopeSpecsHash: typeof ScopeSpecsHash }> {
@@ -153,10 +163,24 @@ export interface MutationContext<User> {
153
163
  request?: unknown;
154
164
  }
155
165
 
156
- /** The legacy raw-SQL escape hatch (still the surface for relational/authority statements a keyed op
157
- * can't express an owner-gated cascade, a `NOT EXISTS` dedup). `exec` accumulates on the daemon
158
- * backend and executes live on the Postgres backend; `statements` is the collected raw list. */
166
+ /** A deliberately narrow raw-SQL facade exposed by the API server. On {@link ServerMutationTx}
167
+ * it is bound to the open mutation transaction; on {@link MutationScope} each call runs in its
168
+ * own transaction outside the mutation boundary. Column aliases should be unique: positional
169
+ * driver rows are keyed by column name, so a duplicate alias is represented by its last value. */
170
+ export interface ServerSql {
171
+ /** Queue/execute one statement. A transaction-bound call commits with the surrounding mutation. */
172
+ execute(sql: string, params?: readonly WireValue[]): Promise<void>;
173
+ /** Queue/execute an ordered statement batch. An empty batch is a no-op. */
174
+ batch(statements: readonly SqlStatement[]): Promise<void>;
175
+ /** Run a read and return rows keyed by their column names. */
176
+ query<Row = Record<string, unknown>>(sql: string, params?: readonly WireValue[]): Promise<Row[]>;
177
+ }
178
+
179
+ /** The raw-SQL escape hatch for relational/authority statements a keyed op can't express — an
180
+ * owner-gated cascade, a `NOT EXISTS` dedup. Prefer `tx.sql`; `exec` remains the synchronous
181
+ * compatibility shorthand for a queued `tx.sql.execute`, and `statements` is the raw write list. */
159
182
  export interface SqlMutationTx {
183
+ readonly sql: ServerSql;
160
184
  exec(sql: string, params?: WireValue[]): void;
161
185
  readonly statements: readonly SqlStatement[];
162
186
  }
@@ -164,17 +188,17 @@ export interface SqlMutationTx {
164
188
  /** The write handle a server mutator runs against — the ASYNC twin of the client's `MutationTx`. It
165
189
  * is both the isomorphic {@link ServerWriteTx} logical surface (insert/update/upsert/insertIgnore/
166
190
  * delete/row, rendered to dialect SQL) AND the legacy {@link SqlMutationTx} raw escape hatch. Both
167
- * backends run reads through the OPEN transaction (read-your-writes): the Postgres backend executes
168
- * everything live; the daemon backend accumulates writes and lazily upgrades to an interactive
169
- * mutation session at the first read (DAEMON-INTERACTIVE-TXN-DESIGN.md §5). */
191
+ * implementations run reads through the OPEN transaction (read-your-writes): Postgres executes
192
+ * everything live; the SQL-client and daemon adapters accumulate writes and lazily upgrade to an
193
+ * interactive mutation session at the first read (DAEMON-INTERACTIVE-TXN-DESIGN.md §5). */
170
194
  export interface ServerMutationTx extends ServerWriteTx, SqlMutationTx {
171
195
  /** Run a full query (a fluent `Query` or its wire `Ast`) INSIDE the open transaction —
172
196
  * read-your-writes, like {@link ServerWriteTx.row} but for arbitrary shapes. Returns the
173
197
  * parsed nested result tree: an array for a plural root, an object or `null` for a `.one()`
174
198
  * root, with cells in their raw SQLite storage-class representations (the same vocabulary
175
- * `row` speaks). Daemon backend: compiled by `@rindle/query-compiler`'s sqlite dialect (bind
176
- * params, NO casts — §5.4) and executed through the mutation session. Postgres backend: lands
177
- * with the read-compiler catalog integration (POSTGRES-READ-COMPILER-DESIGN.md Phase B). */
199
+ * `row` speaks). Remote SQLite backends: compiled by `@rindle/query-compiler`'s sqlite dialect
200
+ * (bind params, NO casts — §5.4) and executed through the mutation session. Postgres: lands with
201
+ * the read-compiler catalog integration (POSTGRES-READ-COMPILER-DESIGN.md Phase B). */
178
202
  query(q: Ast | Query<any, any, any>): Promise<unknown>;
179
203
  }
180
204
 
@@ -221,6 +245,10 @@ export class MutationRejected extends Error {
221
245
  * `transact`, or a swallowed {@link MutationRejected} still advances `lmid` and never wedges the
222
246
  * client's pending queue. */
223
247
  export interface MutationScope {
248
+ /** Raw SQL OUTSIDE the mutation transaction. Every call commits independently and therefore may
249
+ * be observed even if {@link transact} later rejects or fails. Calls may also repeat when an
250
+ * envelope is retried, so outside writes need their own idempotency key/unique constraint. */
251
+ readonly sql: ServerSql;
224
252
  /** Open the ONE atomic write transaction and drive `body` inside it, committing (stamping `lmid`
225
253
  * co-transactionally) on a clean return. MAY be called at most once — a second call throws.
226
254
  *
@@ -297,20 +325,23 @@ export interface QueryLeaseRequest<User> {
297
325
  * when there is no authenticated subject and no session cookie (READ-ROUTER-DESIGN.md §1.5/§2.2).
298
326
  * A routing HINT only, never authorization. */
299
327
  clientId?: string;
328
+ /** The browser's opaque follower-affinity ticket (FOLLOWER-AFFINITY-DESIGN.md §3), read off the
329
+ * query POST and forwarded OPAQUELY on `materialize` so the fleet `fly-replay`s to the follower
330
+ * the browser's ws is pinned to (§2, §4) — both legs co-locate. The api-server does NOT verify
331
+ * it (the fleet does); it holds no signing key. Absent ⇒ single daemon / affinity off. */
332
+ affinity?: string;
300
333
  }
301
334
 
302
335
  /**
303
336
  * The room-serve block on a query lease (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1 step 5 / §2.4,
304
- * slice G-iv-b): present only when the named query carries a realtime label AND its resolved AST
305
- * was PROVABLY covered by its room profile's footprint (the daemon's `/cover-check`). The G-v
306
- * client uses it to open the room transport for THIS query beside — never instead of — its daemon
307
- * session.
337
+ * slice G-iv-b): present when the named query carries a realtime label naming a configured room
338
+ * profile (302 §5 declared, not derived; no coverage proof). The G-v client uses it to open the
339
+ * room transport for THIS query beside — never instead of — its daemon session.
308
340
  *
309
- * It is a NEW dedicated block on purpose: the TOP-LEVEL `wsEndpoint` is the read-router's
310
- * whole-session migration signal (`packages/remote/src/optimistic-source.ts`a lease whose
311
- * `wsEndpoint` differs migrates the client's ENTIRE ws session), so the room endpoint must never
312
- * ride that field. A room-served lease's top-level fields are byte-identical to the daemon-served
313
- * ones.
341
+ * It is a dedicated block on purpose: the ROOM ws is a SEPARATE connection this query opens beside
342
+ * its daemon session (never a migration of the daemon session the daemon ws host is fixed and
343
+ * placed by the affinity ticket). A room-served lease's top-level fields are byte-identical to the
344
+ * daemon-served ones.
314
345
  */
315
346
  export interface QueryLeaseRealtime {
316
347
  /** The client store's gate/domain key for this room source (`connectSource`) AND the string the
@@ -318,8 +349,8 @@ export interface QueryLeaseRealtime {
318
349
  * parses as a room source, and the established convention is `"room:" + doc`
319
350
  * (e.g. `room:document/doc:d1`). */
320
351
  sourceKey: string;
321
- /** Where the client opens the ROOM ws for this query (from `realtime.locateRoom`) — a separate
322
- * field from the top-level `wsEndpoint` (see above: no whole-session migration). */
352
+ /** Where the client opens the ROOM ws for this query (from `realtime.locateRoom`) — its OWN
353
+ * connection, distinct from the daemon session's fixed ws host. */
323
354
  wsEndpoint: string;
324
355
  /** The room's self-authorizing signed lease (`@rindle/room/token`): the APPROVED query AST +
325
356
  * doc + subject, HMAC-signed with `realtime.roomTokenKey` so the room shell's
@@ -349,9 +380,6 @@ export interface QueryLeaseLifecycleLease {
349
380
  /** Which system table this lease's subscription serves. */
350
381
  table: string;
351
382
  leaseToken: string;
352
- /** The follower this system materialization lives on (routed deploys; mirrors the top-level
353
- * `wsEndpoint` semantics). Absent ⇒ single-daemon. */
354
- wsEndpoint?: string;
355
383
  /** DOORBELL only: the §4.1 occupancy scope — the wire room doc (`"<profile>/<key>"`). */
356
384
  scope?: string;
357
385
  /** FENCE entries only: the room doc the predicate is scoped to. */
@@ -393,9 +421,6 @@ export interface QueryLeaseResponse {
393
421
  materializationId: string;
394
422
  queryKey?: string;
395
423
  reused?: boolean;
396
- /** The follower this lease lives on (READ-ROUTER-DESIGN.md §2.3) — the browser opens its
397
- * subscription ws here. Absent ⇒ single-daemon (the client uses its static `wsUrl`). */
398
- wsEndpoint?: string;
399
424
  /** The room-serve block (G-iv-b) — see {@link QueryLeaseRealtime}. Absent ⇒ the lease is
400
425
  * byte-identical to the legacy daemon-served shape. */
401
426
  realtime?: QueryLeaseRealtime;
@@ -420,6 +445,9 @@ export interface QueryReadRequest<User> {
420
445
  * {@link QueryLeaseRequest.clientId}). Lets the SSR read co-locate on the follower the booting
421
446
  * client's first subscribe will hit (READ-ROUTER-DESIGN.md §2.4). */
422
447
  clientId?: string;
448
+ /** The browser's opaque follower-affinity ticket — see {@link QueryLeaseRequest.affinity}.
449
+ * Forwarded on the one-shot `query` so an SSR read lands on the same pinned follower. */
450
+ affinity?: string;
423
451
  }
424
452
 
425
453
  /** The assembled (nested-by-name) first-paint snapshot the server-side Store seeds + dehydrates
@@ -428,9 +456,6 @@ export interface QueryReadResponse {
428
456
  rows: Array<{ cols: Record<string, unknown>; [rel: string]: unknown }>;
429
457
  cvMin?: number;
430
458
  queryKey?: string;
431
- /** The follower this read warmed (READ-ROUTER-DESIGN.md §2.4) — inject it into the SSR bootstrap
432
- * so the booting client opens its ws to the same warm follower. Absent ⇒ single-daemon. */
433
- wsEndpoint?: string;
434
459
  }
435
460
 
436
461
  /** The context a {@link MutationBackend} needs to run one mutation inside its transaction. */
@@ -453,10 +478,10 @@ export type MutationOutcome =
453
478
 
454
479
  /**
455
480
  * Where a mutation runs and who stamps `lmid` — the seam that makes the mutator authoring surface
456
- * backend-agnostic (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6; MUTATORS-ISOMORPHIC plan). Two impls
457
- * ship: {@link daemonBackend} (the default writes accumulate, one batch to the daemon which stamps
458
- * `lmid` co-transactionally) and {@link postgresBackend} (a REAL interactive PG transaction — writes
459
- * and reads execute live, `lmid` committed in the same txn, confirmation riding the CDC loop down).
481
+ * backend-agnostic (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6; MUTATORS-ISOMORPHIC plan). Three
482
+ * implementations ship: {@link sqlBackend} is the preferred managed-SQL path; {@link daemonBackend}
483
+ * keeps the private control-plane compatibility path; and {@link postgresBackend} runs a real
484
+ * interactive PG transaction with confirmation riding the CDC loop down.
460
485
  *
461
486
  * The load-bearing invariant is that a mutation ALWAYS advances the client's `last_mutation_id`:
462
487
  * - `runMutation` runs the mutator inside the backend's transaction; on success it advances `lmid`
@@ -470,6 +495,9 @@ export type MutationOutcome =
470
495
  export interface MutationBackend {
471
496
  /** The SQL dialect this backend renders logical ops to (drives placeholder style). */
472
497
  readonly dialect: SqlDialect;
498
+ /** Optional raw-SQL surface outside the mutation transaction. Built-in backends provide it;
499
+ * custom backends may omit it, in which case `scope.sql` fails as an infrastructure error. */
500
+ readonly outsideSql?: ServerSql;
473
501
  runMutation(input: MutationRunInput): Promise<MutationOutcome>;
474
502
  reject(input: { envelope: MutationEnvelope; reason: string }): Promise<unknown>;
475
503
  }
@@ -548,6 +576,10 @@ export interface RoomBootResponse {
548
576
  /** Where the room opens its upstream subscription (a routed deploy's follower). Absent ⇒ the
549
577
  * shell's statically configured rindled ws endpoint. */
550
578
  upstreamWsEndpoint?: string;
579
+ /** Fresh opaque follower-placement ticket minted alongside `upstreamLeaseToken`. The DO offers
580
+ * it with `rindle.v1` on the separate upstream ws so a static fleet endpoint replays to the
581
+ * exact follower holding that local lease. Absent when daemon affinity is off. */
582
+ upstreamAffinity?: string;
551
583
  /** Per-footprint-table scope specs (H-iv-b), compiled from the resolved footprint AST + the
552
584
  * profile's context set (the legacy anonymous profile compiles with an empty context set):
553
585
  * what the shell hands the wasm room's `enableWritesV2` — the §3.3 commit gate. Optional
@@ -593,14 +625,16 @@ export interface RindleRealtimeOptions<User> {
593
625
  /** Lease TTL for the room's upstream footprint materialization (defaults to the server-wide
594
626
  * `leaseTtlMs`, else the daemon's default). */
595
627
  upstreamLeaseTtlMs?: number;
596
- /** Static override for where rooms open their upstream subscription; default is the
597
- * `wsEndpoint` the materialize returns (set in routed deploys), else absent. */
628
+ /** Static endpoint where rooms open their upstream subscription. In a follower fleet this is the
629
+ * fleet ws URL; `/room-boot` pairs it with the materialization's fresh placement ticket so the
630
+ * room lands on the exact follower holding its lease. Absent ⇒ no explicit upstream (the Node
631
+ * room shell may use its own default; the shipped DO shell requires this endpoint). */
598
632
  upstreamWsEndpoint?: string;
599
633
  /** Locate (or place) the room serving `doc` and return the ROOM ws endpoint a room-served
600
634
  * lease's client should open (G-iv-b; on the DO shell this is the Worker's room URL). The
601
- * endpoint rides the lease's dedicated `realtime.wsEndpoint` — NEVER the top-level
602
- * `wsEndpoint` (that field migrates the client's whole daemon session). Absent ⇒ room-serving
603
- * is OFF: labeled queries serve from the daemon exactly as today (fail-open). */
635
+ * endpoint rides the lease's dedicated `realtime.wsEndpoint` — its OWN connection, distinct from
636
+ * the daemon session's fixed ws host. Absent ⇒ room-serving is OFF: labeled queries serve from
637
+ * the daemon exactly as today (fail-open). */
604
638
  locateRoom?: (doc: string) => MaybePromise<{ wsEndpoint: string }>;
605
639
  /** The room lease token signing key (`@rindle/room/token`): `kid` + secret, matching an entry
606
640
  * in the room shell's `downstream.tokenKeys` ring. Required for room-serving (without it a
@@ -765,11 +799,30 @@ export async function verifyRoomFlushCredential(
765
799
  return payload;
766
800
  }
767
801
 
802
+ /** Database connection used by the API server's managed SQL path.
803
+ *
804
+ * `intMode` defaults to `"number"` because logical Rindle rows use the JSON-safe {@link WireValue}
805
+ * vocabulary — `"bigint"` does not survive `JSON.stringify`, and `"string"` silently retypes every
806
+ * integer, breaking arithmetic in mutator bodies. The cost is a HARD BOUND: a mutator read of an
807
+ * integer outside ±(2^53 − 1) rejects that mutation rather than silently rounding it. Tables with
808
+ * keys beyond that range (snowflake ids, and so on) must override `intMode` and have their mutators
809
+ * handle the resulting type. Commit receipts are unaffected — they never decode row values. */
810
+ export type RindleDatabaseOptions = Pick<SqlClientOptions, "url" | "authToken" | "fetch" | "intMode">;
811
+
768
812
  export interface RindleApiServerOptions<User> {
769
813
  daemon: RindleDaemonClient;
814
+ /** Preferred managed setup. The API server constructs and owns its SQL client; authoritative
815
+ * mutators, `tx.sql`, and `scope.sql` use it, while `daemon` remains only the lease/query/
816
+ * materialization/room control plane. Mutually exclusive with {@link sql} unless `backend`
817
+ * explicitly replaces both. */
818
+ database?: RindleDatabaseOptions;
819
+ /** Advanced injection/test seam for an already-created SQL session. Most applications should
820
+ * configure {@link database} and never import `createSqlClient`. When present (and `backend` is
821
+ * absent), authoritative mutators run through {@link sqlBackend}. */
822
+ sql?: SqlSession;
770
823
  /** Where mutations are applied and `lmid` is stamped ({@link MutationBackend}). Default:
771
- * `daemonBackend(daemon)` exactly today's behavior. Pass `postgresBackend(...)` when
772
- * Postgres is the source of truth (the daemon leg then serves only the read side). */
824
+ * managed `sqlBackend` when `database` or `sql` is configured, otherwise the compatibility
825
+ * `daemonBackend(daemon)`. Pass `postgresBackend(...)` when Postgres is the source of truth. */
773
826
  backend?: MutationBackend;
774
827
  /** The typed schema (`createSchema`/`refineSchema`). Required only when a mutator uses the LOGICAL
775
828
  * write vocabulary (`tx.insert`/`update`/`upsert`/`insertIgnore`/`delete`/`row`) — it drives the
@@ -816,12 +869,11 @@ export interface RindleApiServerOptions<User> {
816
869
  * HINT only — never authorization. Ignored by a single (unrouted) daemon, which has nothing to
817
870
  * route. */
818
871
  routingKey?: string | ((input: QueryLeaseRequest<User>) => MaybePromise<string | undefined>);
819
- /** The EXPLICIT fleet pin fan-out (READ-ROUTER-DESIGN.md §4.2 "push") — when set,
820
- * {@link RindleApiServer.assertPins} fans each resolved pin across ALL live followers through it
821
- * (e.g. `createRouterPinClient` from `@rindle/router`) instead of materializing each pin once on
822
- * the (single) daemon. A per-viewer `materialize` always routes ONE; a pin-assert always fans
823
- * ALL — never inferred from `policy.kind`. Absent ⇒ single-daemon behavior (one materialize per
824
- * pin). */
872
+ /** The EXPLICIT fleet pin fan-out — when set, {@link RindleApiServer.assertPins} fans each
873
+ * resolved pin across ALL live followers through it (a fleet control action over the machine
874
+ * list FOLLOWER-AFFINITY-DESIGN.md §11) instead of materializing each pin once on the (single)
875
+ * daemon. A per-viewer `materialize` always routes ONE; a pin-assert always fans ALL — never
876
+ * inferred from `policy.kind`. Absent ⇒ single-daemon behavior (one materialize per pin). */
825
877
  pinFanout?: PinFanout;
826
878
  /** Named queries to keep permanently materialized via {@link RindleApiServer.assertPins}.
827
879
  * Each is materialized with a `pinned` policy (survives zero subscribers) so late joiners
@@ -840,24 +892,15 @@ export interface RindleApiServerOptions<User> {
840
892
 
841
893
  export interface RindleApiServer<User> {
842
894
  readonly routes: RindleApiRoutes;
895
+ /** Close the SQL client created from {@link RindleApiServerOptions.database}. Injected SQL
896
+ * sessions and custom backends remain caller-owned. Idempotent. */
897
+ close(): void;
843
898
  createQueryLease(input: QueryLeaseRequest<User>): Promise<QueryLeaseResponse>;
844
899
  /** (Re-)materialize every `pinnedQueries` entry with a pinned policy. Idempotent — the daemon
845
900
  * dedupes by canonical query, so a re-assert reuses the existing materialization. Call it at
846
901
  * startup and whenever the daemon restarts (e.g. from the daemon-client `onBootId` hook), since
847
902
  * the daemon holds no durable materialization state. No-op when `pinnedQueries` is empty. */
848
903
  assertPins(): Promise<void>;
849
- /** The room-serving coverage DIAGNOSTIC (G-iv-b; the `assertPins`-style explicit check): for
850
- * every realtime-labeled query, resolve it (under `pinUser`, per exemplar args), resolve its
851
- * profile's footprint, and run the REAL daemon coverage check — the same verdict the lease
852
- * path serves by. Returns every verdict; `strict` throws when any labeled query is not
853
- * provably covered (deploy-gate mode). Deliberately ignores `locateRoom`/`roomTokenKey` — it
854
- * answers "would this query be coverable", not "is serving fully wired". */
855
- validateRealtime(opts?: {
856
- /** Per-query exemplar args (a query is checked once per exemplar; default: one `null`). */
857
- exemplars?: Partial<Record<string, readonly unknown[]>>;
858
- /** Throw when any labeled query is uncovered (instead of just reporting). */
859
- strict?: boolean;
860
- }): Promise<ValidateRealtimeReport>;
861
904
  pushMutation(input: PushMutationRequest<User>): Promise<PushMutationResponse>;
862
905
  /** Apply an in-order batch (the client mutation queue's flush). Envelopes run strictly
863
906
  * sequentially; a rejection still advances the daemon's lmid, so later envelopes in the
@@ -896,24 +939,6 @@ export interface RindleApiServer<User> {
896
939
  handleRoomBootJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
897
940
  }
898
941
 
899
- /** One {@link RindleApiServer.validateRealtime} verdict: a labeled query × exemplar args. */
900
- export interface ValidateRealtimeVerdict {
901
- query: string;
902
- profile: string;
903
- args: unknown;
904
- covered: boolean;
905
- /** Why it is not covered (uncovered verdicts only) — the daemon's reason strings, the
906
- * aggregate refusal, or a resolve/footprint error message. */
907
- reasons?: string[];
908
- }
909
-
910
- /** The {@link RindleApiServer.validateRealtime} report. */
911
- export interface ValidateRealtimeReport {
912
- verdicts: ValidateRealtimeVerdict[];
913
- /** The uncovered subset of `verdicts` (what `strict` throws on). */
914
- uncovered: ValidateRealtimeVerdict[];
915
- }
916
-
917
942
  export type RindleApiErrorCode = "bad-request" | "forbidden" | "not-found" | "rejected";
918
943
 
919
944
  export class RindleApiError extends Error {
@@ -1008,18 +1033,11 @@ export class SplitDaemonClient implements RindleDaemonClient {
1008
1033
  if (!lmids) return Promise.reject(new Error("the write master lacks roomLmids"));
1009
1034
  return lmids(input);
1010
1035
  }
1011
- // Pure computation, but routed to the MASTER like the rest of the room ops: the master is a
1012
- // real rindled that hosts `/cover-check`; the read router may not proxy it.
1013
- coverQuery(input: CoverQueryInput): Promise<CoverQueryOutput> {
1014
- const cover = this.writes.coverQuery?.bind(this.writes);
1015
- if (!cover) return Promise.reject(new Error("the write master lacks coverQuery"));
1016
- return cover(input);
1017
- }
1018
1036
  migrate(input: MigrateInput): Promise<MigrateOutput> {
1019
1037
  return this.writes.migrate(input);
1020
1038
  }
1021
1039
 
1022
- // reads → the router (it stamps `wsEndpoint` onto the outputs)
1040
+ // reads → the fleet (one FLEET_URL follower; the affinity ticket + Fly edge place the machine)
1023
1041
  materialize(input: MaterializeInput): Promise<MaterializeOutput> {
1024
1042
  return this.reads.materialize(input);
1025
1043
  }
@@ -1195,9 +1213,6 @@ export class BackendError extends Error {
1195
1213
  }
1196
1214
  }
1197
1215
 
1198
- /** The rindle/daemon server tx: logical writes render to SQLite and ACCUMULATE into one batch;
1199
- * raw `exec` accumulates too; `row` reads COMMITTED state through the daemon (no read-your-writes,
1200
- * the one interactive-txn limitation of the daemon backend). */
1201
1216
  /** Build the compiler {@link Catalog} for ONE ast from the render index: columns/pk from the
1202
1217
  * schema; relationship cardinality from the AST ITSELF — a Rindle relationship is declared at
1203
1218
  * the query site (`sub(alias, rel)` / `.one()`), never on the schema, so the alias→cardinality
@@ -1248,10 +1263,317 @@ class AbsorbedReplay extends Error {
1248
1263
  }
1249
1264
  }
1250
1265
 
1266
+ const MUTATOR_CONFLICT_MAX_ATTEMPTS = 5;
1267
+
1268
+ function isRetryableCommitConflict(error: unknown): boolean {
1269
+ if (error instanceof RindleSqlError) {
1270
+ return error.status === 409 && (error.code === "retryable-conflict" || error.code === "TRANSACTION_CONFLICT");
1271
+ }
1272
+ if (!(error instanceof DaemonHttpError) || error.status !== 409) return false;
1273
+ try {
1274
+ const body = JSON.parse(error.body) as { code?: unknown; retryable?: unknown };
1275
+ return body.code === "retryable-conflict" && body.retryable === true;
1276
+ } catch {
1277
+ return false;
1278
+ }
1279
+ }
1280
+
1281
+ async function mutatorConflictBackoff(attempt: number): Promise<void> {
1282
+ const ceiling = Math.min(32, 2 ** attempt);
1283
+ const millis = ceiling + Math.floor(Math.random() * 4);
1284
+ await new Promise<void>((resolve) => setTimeout(resolve, millis));
1285
+ }
1286
+
1287
+ /** True when this error is the SQL codec refusing a bind value outright (`undefined`, `Date`, `NaN`,
1288
+ * a binary view, an out-of-i64 bigint) rather than a transport or database failure. */
1289
+ function isUnencodableBind(error: unknown): boolean {
1290
+ return error instanceof RindleSqlError && error.code === "VALUE_UNSUPPORTED";
1291
+ }
1292
+
1293
+ /** Refuse an unencodable bind at the point the MUTATOR supplies it, so it surfaces as a BUSINESS
1294
+ * rejection (lmid advances, the browser retires its prediction) instead of an infrastructure
1295
+ * failure. Left as infra it is retried forever against a deterministic mutator, which wedges the
1296
+ * client's mutation queue behind a poison message.
1297
+ *
1298
+ * Only the SQL transport needs this: the legacy daemon encoder is JSON, which silently coerces the
1299
+ * same values (`undefined`/`NaN` -> null, `Date` -> an ISO string). Asserting there would invent a
1300
+ * failure that the wire does not actually have. */
1301
+ function assertEncodableParams(sql: string, params: readonly WireValue[] | undefined): void {
1302
+ if (params === undefined) return;
1303
+ for (let index = 0; index < params.length; index++) {
1304
+ try {
1305
+ encodeSqlValue(params[index] as Parameters<typeof encodeSqlValue>[0]);
1306
+ } catch (error) {
1307
+ if (!isUnencodableBind(error)) throw error;
1308
+ throw new Error(`bind ${index} of \`${sql}\` cannot be stored: ${errMessage(error)}`);
1309
+ }
1310
+ }
1311
+ }
1312
+
1313
+ /** Leading keywords the SQL mutation surface structurally REFUSES inside a mutator's write batch: a
1314
+ * read (`SELECT`/`EXPLAIN`), transaction control, a connection `PRAGMA`, or DDL. None can begin a
1315
+ * valid mutation write, so refusing them has no false positives — a `WITH`-prefixed statement is
1316
+ * deliberately absent because it may resolve to either a read or a write, and the server stays the
1317
+ * authority for that case. */
1318
+ const MUTATION_REFUSED_LEADING_KEYWORDS = new Set([
1319
+ "SELECT", "EXPLAIN", "VALUES",
1320
+ "BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE", "END",
1321
+ "PRAGMA", "VACUUM", "ATTACH", "DETACH",
1322
+ "CREATE", "ALTER", "DROP", "REINDEX", "ANALYZE",
1323
+ ]);
1324
+
1325
+ /** Refuse a statement whose CLASS the mutation surface rejects, at the point the MUTATOR supplies it,
1326
+ * so it surfaces as a business rejection instead of a poison. When the batch reaches the transport
1327
+ * the body has already returned, so a server 400 there is (mis)read as infrastructure and retried
1328
+ * forever — the same wedge {@link assertEncodableParams} prevents for bind values. Conservative by
1329
+ * design: it fires only for a leading keyword that can never start a valid write, and leaves every
1330
+ * ambiguous case (including CTE-prefixed writes) to the server's authoritative classifier. */
1331
+ function assertMutationWriteStatement(sql: string): void {
1332
+ const match = /^[\s;]*([a-zA-Z]+)/.exec(sql);
1333
+ if (match === null) return;
1334
+ const keyword = match[1]!.toUpperCase();
1335
+ if (MUTATION_REFUSED_LEADING_KEYWORDS.has(keyword)) {
1336
+ throw new Error(
1337
+ `a mutator write statement cannot begin with ${keyword} (\`${sql}\`); ` +
1338
+ `mutations write rows only — use tx.sql.query(...) for reads and migrations for DDL`,
1339
+ );
1340
+ }
1341
+ }
1342
+
1343
+ interface MutationTransportBegin {
1344
+ handle?: unknown;
1345
+ absorbed?: SqlTxnOutput;
1346
+ read?: SqlReadOutput;
1347
+ }
1348
+
1349
+ /** The mutation-only transport consumed by the API-server transaction harness. Both the legacy
1350
+ * daemon client and `@rindle/sql-client` adapt to this one shape, so lmid/rejection/lazy-session
1351
+ * policy is implemented once. */
1352
+ interface MutationTransport {
1353
+ readonly interactive: boolean;
1354
+ /** Whether this transport's wire REFUSES values the daemon's JSON encoder coerces. Drives
1355
+ * {@link assertEncodableParams} — see its docs for why the daemon adapter opts out. */
1356
+ readonly strictValues: boolean;
1357
+ execute(input: {
1358
+ envelope: MutationEnvelope;
1359
+ statements: SqlStatement[];
1360
+ idempotencyKey?: string;
1361
+ }): Promise<SqlTxnOutput>;
1362
+ reject(input: { envelope: MutationEnvelope; reason: string }): Promise<unknown>;
1363
+ begin(input: {
1364
+ envelope: MutationEnvelope;
1365
+ statements: SqlStatement[];
1366
+ query: SqlStatement;
1367
+ idempotencyKey?: string;
1368
+ }): Promise<MutationTransportBegin>;
1369
+ exec(handle: unknown, statements: SqlStatement[]): Promise<void>;
1370
+ query(handle: unknown, statement: SqlStatement): Promise<SqlReadOutput>;
1371
+ commit(handle: unknown): Promise<SqlTxnOutput>;
1372
+ rollback(handle: unknown): Promise<void>;
1373
+ readCommitted(statement: SqlStatement): Promise<SqlReadOutput>;
1374
+ }
1375
+
1376
+ function daemonMutationTransport(daemon: RindleDaemonClient): MutationTransport {
1377
+ return {
1378
+ interactive: daemon.beginMutationSession !== undefined,
1379
+ strictValues: false,
1380
+ execute({ envelope, statements, idempotencyKey }) {
1381
+ const txn: SqlTxn = { statements, clientID: envelope.clientID, mid: envelope.mid };
1382
+ if (idempotencyKey !== undefined) txn.idempotencyKey = idempotencyKey;
1383
+ return daemon.executeSqlTxn(txn);
1384
+ },
1385
+ reject({ envelope, reason }) {
1386
+ return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1387
+ },
1388
+ async begin({ envelope, statements, query, idempotencyKey }) {
1389
+ if (!daemon.beginMutationSession) throw new Error("the daemon client does not support mutation sessions");
1390
+ const input: MutationSessionBegin = {
1391
+ clientID: envelope.clientID,
1392
+ mid: envelope.mid,
1393
+ statements,
1394
+ query,
1395
+ };
1396
+ if (idempotencyKey !== undefined) input.idempotencyKey = idempotencyKey;
1397
+ const opened = await daemon.beginMutationSession(input);
1398
+ if (opened.absorbed) {
1399
+ const { absorbed: _absorbed, sessionId: _sessionId, read: _read, ...output } = opened;
1400
+ return { absorbed: output as SqlTxnOutput };
1401
+ }
1402
+ return { handle: opened.sessionId, read: opened.read };
1403
+ },
1404
+ async exec(handle, statements) {
1405
+ await daemon.execInMutationSession!({ sessionId: handle as string, statements });
1406
+ },
1407
+ query(handle, statement) {
1408
+ return daemon.queryInMutationSession!({
1409
+ sessionId: handle as string,
1410
+ sql: statement.sql,
1411
+ params: statement.params,
1412
+ });
1413
+ },
1414
+ commit(handle) {
1415
+ return daemon.commitMutationSession!({ sessionId: handle as string });
1416
+ },
1417
+ async rollback(handle) {
1418
+ await daemon.rollbackMutationSession!({ sessionId: handle as string });
1419
+ },
1420
+ readCommitted(statement) {
1421
+ return daemon.executeSqlRead({ sql: statement.sql, params: statement.params });
1422
+ },
1423
+ };
1424
+ }
1425
+
1426
+ function mutationReceiptOutput(receipt: SqlMutationReceipt, clientID: string): SqlTxnOutput {
1427
+ const output: SqlTxnOutput = {
1428
+ applied: receipt.applied,
1429
+ lmid: receipt.lmid,
1430
+ lmidAdvances: [{ clientID, lmid: receipt.lmid }],
1431
+ };
1432
+ if (receipt.commitCursor !== null) output.cursor = receipt.commitCursor;
1433
+ return output;
1434
+ }
1435
+
1436
+ function publicMutationStatement(statement: SqlStatement): PublicSqlStatement {
1437
+ return statement.params === undefined ? { sql: statement.sql } : { sql: statement.sql, args: statement.params };
1438
+ }
1439
+
1440
+ function mutationRowsOutput(rows: SqlMutationRows): SqlReadOutput {
1441
+ return { cols: rows.columns, rows: rows.rows as WireValue[][] };
1442
+ }
1443
+
1444
+ /** Convert the transports' compact positional rows into the ergonomic server-only raw-SQL shape. */
1445
+ function keyedSqlRows<Row = Record<string, unknown>>(
1446
+ columns: readonly string[],
1447
+ rows: readonly (readonly unknown[])[],
1448
+ ): Row[] {
1449
+ // Row objects are keyed by column NAME, so a read that projects the same name twice
1450
+ // (`SELECT parent.status, child.status ...`) would silently keep only the last value — and a
1451
+ // mutator branching on `row.status` would then authorize against the wrong cell. Refuse it loudly
1452
+ // so the collision surfaces as a rejection reason instead of silent, wrong data.
1453
+ const seen = new Set<string>();
1454
+ for (const column of columns) {
1455
+ if (seen.has(column)) {
1456
+ throw new Error(
1457
+ `raw SQL read projects the column name ${JSON.stringify(column)} more than once; ` +
1458
+ `alias them to distinct names (e.g. SELECT a.id AS a_id, b.id AS b_id)`,
1459
+ );
1460
+ }
1461
+ seen.add(column);
1462
+ }
1463
+ return rows.map((cells) => Object.fromEntries(columns.map((column, index) => [column, cells[index]])) as Row);
1464
+ }
1465
+
1466
+ function daemonOutsideSql(daemon: RindleDaemonClient): ServerSql {
1467
+ return {
1468
+ async execute(sql, params = []) {
1469
+ await daemon.executeSqlTxn({ statements: [{ sql, params: [...params] }] });
1470
+ },
1471
+ async batch(statements) {
1472
+ if (statements.length === 0) return;
1473
+ await daemon.executeSqlTxn({
1474
+ statements: statements.map((statement) => ({
1475
+ sql: statement.sql,
1476
+ ...(statement.params !== undefined ? { params: [...statement.params] } : {}),
1477
+ })),
1478
+ });
1479
+ },
1480
+ async query<Row = Record<string, unknown>>(sql: string, params: readonly WireValue[] = []): Promise<Row[]> {
1481
+ const out = await daemon.executeSqlRead({ sql, params: [...params], consistency: "strong" });
1482
+ return keyedSqlRows<Row>(out.cols, out.rows);
1483
+ },
1484
+ };
1485
+ }
1486
+
1487
+ function sqlSessionOutsideSql(sql: SqlSession): ServerSql {
1488
+ return {
1489
+ async execute(text, params = []) {
1490
+ await sql.execute({ sql: text, args: [...params] });
1491
+ },
1492
+ async batch(statements) {
1493
+ if (statements.length === 0) return;
1494
+ await sql.batch(statements.map(publicMutationStatement));
1495
+ },
1496
+ async query<Row = Record<string, unknown>>(text: string, params: readonly WireValue[] = []): Promise<Row[]> {
1497
+ const out = await sql.execute({ sql: text, args: [...params], wantRows: true }, { consistency: "strong" });
1498
+ return keyedSqlRows<Row>(
1499
+ out.result.columns.map((column) => column.name),
1500
+ out.result.rows,
1501
+ );
1502
+ },
1503
+ };
1504
+ }
1505
+
1506
+ function sqlClientMutationTransport(sql: SqlSession): MutationTransport {
1507
+ return {
1508
+ interactive: true,
1509
+ strictValues: true,
1510
+ // NOTE: `execute`/`begin` deliberately ignore the interface's optional `idempotencyKey`. On the
1511
+ // SQL mutation wire the (clientID, mid) pair IS the durable retry identity — a redelivery is
1512
+ // absorbed by mid, so there is no idempotency key to carry. The field stays on the shared
1513
+ // MutationTransport only because the legacy daemon foreign-write path still threads it.
1514
+ async execute({ envelope, statements }) {
1515
+ const receipt = await sql.executeMutation({
1516
+ clientId: envelope.clientID,
1517
+ mid: envelope.mid,
1518
+ statements: statements.map(publicMutationStatement),
1519
+ });
1520
+ return mutationReceiptOutput(receipt, envelope.clientID);
1521
+ },
1522
+ async reject({ envelope, reason }) {
1523
+ return mutationReceiptOutput(
1524
+ await sql.rejectMutation({ clientId: envelope.clientID, mid: envelope.mid, reason }),
1525
+ envelope.clientID,
1526
+ );
1527
+ },
1528
+ async begin({ envelope, statements, query }) {
1529
+ const opened = await sql.beginMutation({
1530
+ clientId: envelope.clientID,
1531
+ mid: envelope.mid,
1532
+ statements: statements.map(publicMutationStatement),
1533
+ query: publicMutationStatement(query),
1534
+ });
1535
+ if (opened.absorbed) {
1536
+ return { absorbed: mutationReceiptOutput(opened.receipt, envelope.clientID) };
1537
+ }
1538
+ return {
1539
+ handle: opened.transaction,
1540
+ ...(opened.read !== undefined ? { read: mutationRowsOutput(opened.read) } : {}),
1541
+ };
1542
+ },
1543
+ async exec(handle, statements) {
1544
+ await (handle as SqlMutationTransaction).batch(statements.map(publicMutationStatement));
1545
+ },
1546
+ async query(handle, statement) {
1547
+ return mutationRowsOutput(await (handle as SqlMutationTransaction).query(publicMutationStatement(statement)));
1548
+ },
1549
+ async commit(handle) {
1550
+ const receipt = await (handle as SqlMutationTransaction).commit();
1551
+ const advance = receipt.lmid;
1552
+ // The handle is opened for exactly one client; RemoteLazyTx patches the client id from its
1553
+ // envelope after this call so the legacy MutationBackend receipt remains byte-compatible.
1554
+ return {
1555
+ applied: receipt.applied,
1556
+ cursor: receipt.commitCursor ?? undefined,
1557
+ lmid: advance,
1558
+ };
1559
+ },
1560
+ async rollback(handle) {
1561
+ await (handle as SqlMutationTransaction).rollback();
1562
+ },
1563
+ async readCommitted(statement) {
1564
+ const result = await sql.execute(publicMutationStatement(statement), { consistency: "strong" });
1565
+ return {
1566
+ cols: result.result.columns.map((column) => column.name),
1567
+ rows: result.result.rows as WireValue[][],
1568
+ };
1569
+ },
1570
+ };
1571
+ }
1572
+
1251
1573
  /**
1252
- * The daemon server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
1574
+ * The remote SQLite server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
1253
1575
  * execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
1254
- * `/execute-sql-txn`, byte-identical to prior behavior — and LAZILY UPGRADES to an interactive
1576
+ * the selected mutation transport — and LAZILY UPGRADES to an interactive
1255
1577
  * mutation session at the mutator's first read: `begin` carries the envelope identity, the
1256
1578
  * accumulated statement prefix (sound to replay — nothing before the first read observed DB
1257
1579
  * state, §5.2), and the read itself, so a one-read mutator pays exactly one extra round trip.
@@ -1260,31 +1582,44 @@ class AbsorbedReplay extends Error {
1260
1582
  * reads cost k+2 round trips regardless of write count.
1261
1583
  *
1262
1584
  * Begin-time mid dedup can ABSORB the envelope (a redelivery whose commit response was lost):
1263
- * the replay output is latched on {@link DaemonLazyTx.absorbed} and {@link AbsorbedReplay}
1585
+ * the replay output is latched on {@link RemoteLazyTx.absorbed} and {@link AbsorbedReplay}
1264
1586
  * unwinds the body — the latch (not the throw) is authoritative, so a mutator that swallows
1265
1587
  * the unwind still cannot re-apply (no session opened; buffered writes are never shipped).
1266
1588
  * A daemon client without session support keeps the LEGACY committed-state point read.
1267
1589
  */
1268
- class DaemonLazyTx implements ServerMutationTx {
1590
+ class RemoteLazyTx implements ServerMutationTx {
1269
1591
  /** Pre-upgrade: the accumulated batch/prefix. Post-upgrade: writes buffered for the next flush. */
1270
1592
  private readonly stmts: SqlStatement[] = [];
1271
1593
  private readonly render: RenderIndex;
1272
- private readonly daemon: RindleDaemonClient;
1594
+ private readonly transport: MutationTransport;
1273
1595
  private readonly envelope: MutationEnvelope;
1274
- private sessionId?: string;
1596
+ private sessionHandle?: unknown;
1597
+ readonly sql: ServerSql;
1275
1598
  /** The begin-absorbed replay output (§4.1), latched for the backend. */
1276
1599
  absorbed?: SqlTxnOutput;
1277
1600
  idempotencyKey?: string;
1278
1601
 
1279
- constructor(render: RenderIndex, daemon: RindleDaemonClient, envelope: MutationEnvelope) {
1602
+ constructor(render: RenderIndex, transport: MutationTransport, envelope: MutationEnvelope) {
1280
1603
  this.render = render;
1281
- this.daemon = daemon;
1604
+ this.transport = transport;
1282
1605
  this.envelope = envelope;
1606
+ this.sql = {
1607
+ execute: async (sql, params = []) => {
1608
+ this.exec(sql, [...params]);
1609
+ },
1610
+ batch: async (statements) => {
1611
+ for (const statement of statements) {
1612
+ this.exec(statement.sql, statement.params === undefined ? [] : [...statement.params]);
1613
+ }
1614
+ },
1615
+ query: <Row = Record<string, unknown>>(sql: string, params: readonly WireValue[] = []) =>
1616
+ this.querySql<Row>(sql, params),
1617
+ };
1283
1618
  }
1284
1619
 
1285
1620
  /** True once the tx upgraded to an interactive session (the backend then commits it). */
1286
1621
  get session(): boolean {
1287
- return this.sessionId !== undefined;
1622
+ return this.sessionHandle !== undefined;
1288
1623
  }
1289
1624
 
1290
1625
  get statements(): readonly SqlStatement[] {
@@ -1292,12 +1627,21 @@ class DaemonLazyTx implements ServerMutationTx {
1292
1627
  }
1293
1628
 
1294
1629
  exec(sql: string, params: WireValue[] = []): void {
1630
+ // Refuse here, INSIDE the mutator body, so the harness reads it as a business rejection. By the
1631
+ // time the statement reaches the transport the body has returned and the throw is infra.
1632
+ if (this.transport.strictValues) {
1633
+ assertMutationWriteStatement(sql);
1634
+ assertEncodableParams(sql, params);
1635
+ }
1295
1636
  this.stmts.push({ sql, params });
1296
1637
  }
1297
1638
 
1298
1639
  private push(op: MutationOp): Promise<void> {
1299
1640
  const rendered = renderOp(op, tableMeta(this.render, op.table), sqliteDialect);
1300
- if (rendered) this.stmts.push(rendered);
1641
+ if (rendered) {
1642
+ if (this.transport.strictValues) assertEncodableParams(rendered.sql, rendered.params);
1643
+ this.stmts.push(rendered);
1644
+ }
1301
1645
  return Promise.resolve();
1302
1646
  }
1303
1647
 
@@ -1339,42 +1683,47 @@ class DaemonLazyTx implements ServerMutationTx {
1339
1683
  return JSON.parse(cell) as unknown;
1340
1684
  }
1341
1685
 
1686
+ private async querySql<Row>(sql: string, params: readonly WireValue[]): Promise<Row[]> {
1687
+ const out = await this.readThroughTxn({ sql, params: [...params] });
1688
+ return keyedSqlRows<Row>(out.cols, out.rows);
1689
+ }
1690
+
1342
1691
  /** Run one read: upgrade to a session at the first (§5.1), ride the open one after, or fall
1343
1692
  * back to the legacy committed-state read when the daemon client lacks sessions. */
1344
1693
  private async readThroughTxn(read: SqlStatement): Promise<SqlReadOutput> {
1345
1694
  if (this.absorbed) throw new AbsorbedReplay();
1346
- if (!this.daemon.beginMutationSession) {
1695
+ // Refuse an unencodable read bind at the mutator boundary, exactly as `exec` does for writes.
1696
+ // A read's parameters are encoded inside the transport, where the throw becomes a BackendError
1697
+ // (infra) that retries the deterministic mutator forever and wedges the client's queue; asserting
1698
+ // here makes it a business rejection instead.
1699
+ if (this.transport.strictValues) assertEncodableParams(read.sql, read.params);
1700
+ if (!this.transport.interactive) {
1347
1701
  try {
1348
- return await this.daemon.executeSqlRead({ sql: read.sql, params: read.params });
1702
+ return await this.transport.readCommitted(read);
1349
1703
  } catch (err) {
1350
1704
  throw new BackendError(err);
1351
1705
  }
1352
1706
  }
1353
1707
  try {
1354
- if (this.sessionId === undefined) {
1355
- const opened = await this.daemon.beginMutationSession({
1356
- clientID: this.envelope.clientID,
1357
- mid: this.envelope.mid,
1708
+ if (this.sessionHandle === undefined) {
1709
+ const opened = await this.transport.begin({
1710
+ envelope: this.envelope,
1358
1711
  statements: this.stmts.splice(0),
1359
1712
  query: read,
1713
+ ...(this.idempotencyKey !== undefined ? { idempotencyKey: this.idempotencyKey } : {}),
1360
1714
  });
1361
1715
  if (opened.absorbed) {
1362
- const { absorbed: _a, sessionId: _s, read: _r, ...output } = opened;
1363
- this.absorbed = output as SqlTxnOutput;
1716
+ this.absorbed = opened.absorbed;
1364
1717
  throw new AbsorbedReplay();
1365
1718
  }
1366
- if (!opened.sessionId || !opened.read) {
1719
+ if (opened.handle === undefined || !opened.read) {
1367
1720
  throw new Error(`malformed mutate-session begin reply: ${JSON.stringify(opened)}`);
1368
1721
  }
1369
- this.sessionId = opened.sessionId;
1722
+ this.sessionHandle = opened.handle;
1370
1723
  return opened.read;
1371
1724
  }
1372
1725
  await this.flush();
1373
- return await this.daemon.queryInMutationSession!({
1374
- sessionId: this.sessionId,
1375
- sql: read.sql,
1376
- params: read.params,
1377
- });
1726
+ return await this.transport.query(this.sessionHandle, read);
1378
1727
  } catch (err) {
1379
1728
  if (err instanceof AbsorbedReplay || err instanceof BackendError) throw err;
1380
1729
  throw new BackendError(err);
@@ -1384,10 +1733,7 @@ class DaemonLazyTx implements ServerMutationTx {
1384
1733
  /** Ship buffered writes into the open session, order-preserving; a no-op when none pend. */
1385
1734
  private async flush(): Promise<void> {
1386
1735
  if (this.stmts.length === 0) return;
1387
- await this.daemon.execInMutationSession!({
1388
- sessionId: this.sessionId!,
1389
- statements: this.stmts.splice(0),
1390
- });
1736
+ await this.transport.exec(this.sessionHandle!, this.stmts.splice(0));
1391
1737
  }
1392
1738
 
1393
1739
  /** Flush + commit the open session — the daemon stamps lmid co-transactionally (§4.4) and
@@ -1395,7 +1741,11 @@ class DaemonLazyTx implements ServerMutationTx {
1395
1741
  async commitSession(): Promise<SqlTxnOutput> {
1396
1742
  try {
1397
1743
  await this.flush();
1398
- return await this.daemon.commitMutationSession!({ sessionId: this.sessionId! });
1744
+ const output = await this.transport.commit(this.sessionHandle!);
1745
+ if (output.lmid !== undefined && output.lmidAdvances === undefined) {
1746
+ output.lmidAdvances = [{ clientID: this.envelope.clientID, lmid: output.lmid }];
1747
+ }
1748
+ return output;
1399
1749
  } catch (err) {
1400
1750
  throw err instanceof BackendError ? err : new BackendError(err);
1401
1751
  }
@@ -1404,11 +1754,11 @@ class DaemonLazyTx implements ServerMutationTx {
1404
1754
  /** Best-effort rollback (the daemon's deadline is the backstop). MUST be awaited before a
1405
1755
  * follow-up `/reject-mutation`: that lmid-only commit needs the writer this session holds. */
1406
1756
  async rollbackSessionQuietly(): Promise<void> {
1407
- if (this.sessionId === undefined) return;
1408
- const sessionId = this.sessionId;
1409
- this.sessionId = undefined;
1757
+ if (this.sessionHandle === undefined) return;
1758
+ const sessionHandle = this.sessionHandle;
1759
+ this.sessionHandle = undefined;
1410
1760
  try {
1411
- await this.daemon.rollbackMutationSession!({ sessionId });
1761
+ await this.transport.rollback(sessionHandle);
1412
1762
  } catch {
1413
1763
  // Unreachable daemon / already-expired session: the deadline rollback covers it.
1414
1764
  }
@@ -1425,12 +1775,27 @@ class PgLiveTx implements ServerMutationTx {
1425
1775
  private readonly q: PgQuery;
1426
1776
  private readonly render: RenderIndex;
1427
1777
  private readonly rewrite: (sql: string) => string;
1778
+ readonly sql: ServerSql;
1428
1779
  idempotencyKey?: string;
1429
1780
 
1430
1781
  constructor(q: PgQuery, render: RenderIndex, rewrite: (sql: string) => string) {
1431
1782
  this.q = q;
1432
1783
  this.render = render;
1433
1784
  this.rewrite = rewrite;
1785
+ this.sql = {
1786
+ execute: async (sql, params = []) => {
1787
+ this.exec(sql, [...params]);
1788
+ await this.settle();
1789
+ },
1790
+ batch: async (statements) => {
1791
+ for (const statement of statements) {
1792
+ this.exec(statement.sql, statement.params === undefined ? [] : [...statement.params]);
1793
+ }
1794
+ await this.settle();
1795
+ },
1796
+ query: <Row = Record<string, unknown>>(sql: string, params: readonly WireValue[] = []) =>
1797
+ this.querySql<Row>(sql, params),
1798
+ };
1434
1799
  }
1435
1800
 
1436
1801
  get statements(): readonly SqlStatement[] {
@@ -1505,58 +1870,92 @@ class PgLiveTx implements ServerMutationTx {
1505
1870
  ),
1506
1871
  );
1507
1872
  }
1873
+
1874
+ private async querySql<Row>(sql: string, params: readonly WireValue[]): Promise<Row[]> {
1875
+ await this.settle();
1876
+ try {
1877
+ return (await this.q.query(this.rewrite(sql), [...params])) as Row[];
1878
+ } catch (err) {
1879
+ throw new BackendError(err);
1880
+ }
1881
+ }
1508
1882
  }
1509
1883
 
1510
- /**
1511
- * The default {@link MutationBackend}. A pure-write mutator keeps the historical shape: writes
1512
- * ACCUMULATE and ship as ONE batch to `/execute-sql-txn`, which stamps `lmid` co-transactionally
1513
- * (and `/reject-mutation` advances it past a rejected mid) — byte-identical behavior. A
1514
- * READ-bearing mutator lazily upgrades to an interactive mutation session
1515
- * (DAEMON-INTERACTIVE-TXN-DESIGN.md): reads are read-your-writes through the open transaction
1516
- * (PG parity), the commit stamps `lmid` in the same atomic unit, and a begin-absorbed replay
1517
- * short-circuits without re-running the body.
1518
- */
1519
- export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
1884
+ /** Shared remote-SQL mutation backend. A pure-write mutator remains one request; a read-bearing
1885
+ * mutator lazily upgrades at its first read; accepted effects commit with lmid; business rejection
1886
+ * rolls effects back before an lmid-only commit. Both daemonBackend and sqlBackend use this exact
1887
+ * policy implementation. */
1888
+ function remoteMutationBackend(transport: MutationTransport, outsideSql: ServerSql): MutationBackend {
1520
1889
  return {
1521
1890
  dialect: sqliteDialect,
1522
- async runMutation({ envelope, render, run }) {
1523
- const tx = new DaemonLazyTx(render, daemon, envelope);
1524
- try {
1525
- await run(tx);
1526
- } catch (err) {
1527
- // A begin-absorbed replay: the authoritative outcome already committed — answer it,
1528
- // whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
1529
- if (tx.absorbed) return { accepted: true, output: tx.absorbed };
1530
- if (err instanceof BackendError) {
1531
- await tx.rollbackSessionQuietly();
1532
- throw err.driverError; // infra — never a user rejection
1533
- }
1534
- const reason = errMessage(err);
1535
- // Data first, watermark second: the rollback releases the single writer that the
1536
- // `/reject-mutation` lmid-only commit needs (§2.4 on the session path).
1537
- await tx.rollbackSessionQuietly();
1538
- const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1539
- return { accepted: false, reason, output };
1540
- }
1541
- if (tx.absorbed) return { accepted: true, output: tx.absorbed };
1542
- if (tx.session) {
1891
+ outsideSql,
1892
+ async runMutation(input) {
1893
+ for (let attempt = 0; attempt < MUTATOR_CONFLICT_MAX_ATTEMPTS; attempt++) {
1543
1894
  try {
1544
- return { accepted: true, output: await tx.commitSession() };
1545
- } catch (err) {
1546
- if (err instanceof BackendError) throw err.driverError; // infra (client retries; dedup absorbs)
1547
- throw err;
1895
+ const { envelope, render, run } = input;
1896
+ const tx = new RemoteLazyTx(render, transport, envelope);
1897
+ try {
1898
+ await run(tx);
1899
+ } catch (err) {
1900
+ // A begin-absorbed replay: the authoritative outcome already committed — answer it,
1901
+ // whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
1902
+ if (tx.absorbed) return { accepted: true, output: tx.absorbed };
1903
+ if (err instanceof BackendError) {
1904
+ await tx.rollbackSessionQuietly();
1905
+ throw err.driverError; // infra — never a user rejection
1906
+ }
1907
+ const reason = errMessage(err);
1908
+ // Data first, watermark second: rollback releases this session's connection before
1909
+ // the lmid-only rejection commit.
1910
+ await tx.rollbackSessionQuietly();
1911
+ const output = await transport.reject({ envelope, reason });
1912
+ return { accepted: false, reason, output };
1913
+ }
1914
+ if (tx.absorbed) return { accepted: true, output: tx.absorbed };
1915
+ if (tx.session) {
1916
+ try {
1917
+ return { accepted: true, output: await tx.commitSession() };
1918
+ } catch (err) {
1919
+ if (err instanceof BackendError) throw err.driverError;
1920
+ throw err;
1921
+ }
1922
+ }
1923
+ return {
1924
+ accepted: true,
1925
+ output: await transport.execute({
1926
+ envelope,
1927
+ statements: [...tx.statements],
1928
+ ...(tx.idempotencyKey !== undefined ? { idempotencyKey: tx.idempotencyKey } : {}),
1929
+ }),
1930
+ };
1931
+ } catch (error) {
1932
+ if (!isRetryableCommitConflict(error) || attempt + 1 === MUTATOR_CONFLICT_MAX_ATTEMPTS) {
1933
+ throw error;
1934
+ }
1935
+ await mutatorConflictBackoff(attempt);
1548
1936
  }
1549
1937
  }
1550
- const txn: SqlTxn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
1551
- if (tx.idempotencyKey !== undefined) txn.idempotencyKey = tx.idempotencyKey;
1552
- return { accepted: true, output: await daemon.executeSqlTxn(txn) };
1938
+ throw new Error("unreachable mutator conflict retry loop");
1553
1939
  },
1554
1940
  reject({ envelope, reason }) {
1555
- return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1941
+ return transport.reject({ envelope, reason });
1556
1942
  },
1557
1943
  };
1558
1944
  }
1559
1945
 
1946
+ /** Legacy/private-plane adapter. Kept for existing deployments; its mutation policy is shared with
1947
+ * {@link sqlBackend}, so the two transports cannot drift. */
1948
+ export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
1949
+ return remoteMutationBackend(daemonMutationTransport(daemon), daemonOutsideSql(daemon));
1950
+ }
1951
+
1952
+ /** Run API-server mutators through `@rindle/sql-client`'s explicit mutation facade. Query leases,
1953
+ * SSR reads and room control continue to use `daemon`; only authoritative mutation execution moves
1954
+ * to the versioned SQL transport. */
1955
+ export function sqlBackend(sql: SqlSession): MutationBackend {
1956
+ return remoteMutationBackend(sqlClientMutationTransport(sql), sqlSessionOutsideSql(sql));
1957
+ }
1958
+
1560
1959
  /** The query surface a {@link PostgresPlugger} transaction exposes. `exec` runs one statement;
1561
1960
  * `query` returns rows keyed by column name (read-your-own-writes inside the txn). */
1562
1961
  export interface PgQuery {
@@ -1607,8 +2006,27 @@ export function postgresBackend(plugger: PostgresPlugger, opts: PostgresBackendO
1607
2006
  await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
1608
2007
  return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
1609
2008
  });
2009
+ const outsideSql: ServerSql = {
2010
+ async execute(sql, params = []) {
2011
+ await plugger.transaction(async (q) => {
2012
+ await q.exec(rewrite(sql), [...params]);
2013
+ });
2014
+ },
2015
+ async batch(statements) {
2016
+ if (statements.length === 0) return;
2017
+ await plugger.transaction(async (q) => {
2018
+ for (const statement of statements) {
2019
+ await q.exec(rewrite(statement.sql), statement.params === undefined ? [] : [...statement.params]);
2020
+ }
2021
+ });
2022
+ },
2023
+ query<Row = Record<string, unknown>>(sql: string, params: readonly WireValue[] = []): Promise<Row[]> {
2024
+ return plugger.transaction(async (q) => (await q.query(rewrite(sql), [...params])) as Row[]);
2025
+ },
2026
+ };
1610
2027
  return {
1611
2028
  dialect: postgresDialect,
2029
+ outsideSql,
1612
2030
  async runMutation({ envelope, render, run }) {
1613
2031
  try {
1614
2032
  const output = await plugger.transaction(async (q) => {
@@ -1939,6 +2357,44 @@ function normalizeCondition(c: Condition): unknown {
1939
2357
  }
1940
2358
  }
1941
2359
 
2360
+ /** Keep outside-SQL driver failures on the infrastructure path even when they happen before the
2361
+ * scoped mutator has opened its mutation transaction. */
2362
+ function scopedOutsideSql(sql: ServerSql | undefined): ServerSql {
2363
+ const unavailable = (): BackendError =>
2364
+ new BackendError(new Error("scope.sql is unavailable on this custom MutationBackend"));
2365
+ // An unencodable bind is a deterministic authoring error, not a database failure. Wrapping it in
2366
+ // BackendError would latch `scope.infra` and retry the envelope forever; leaving it a plain throw
2367
+ // lets the scoped harness treat it as a business rejection and advance lmid.
2368
+ const infra = (error: unknown): unknown =>
2369
+ isUnencodableBind(error) ? new Error(errMessage(error)) : error instanceof BackendError ? error : new BackendError(error);
2370
+ return {
2371
+ async execute(text, params = []) {
2372
+ if (!sql) throw unavailable();
2373
+ try {
2374
+ await sql.execute(text, params);
2375
+ } catch (error) {
2376
+ throw infra(error);
2377
+ }
2378
+ },
2379
+ async batch(statements) {
2380
+ if (!sql) throw unavailable();
2381
+ try {
2382
+ await sql.batch(statements);
2383
+ } catch (error) {
2384
+ throw infra(error);
2385
+ }
2386
+ },
2387
+ async query<Row = Record<string, unknown>>(text: string, params: readonly WireValue[] = []): Promise<Row[]> {
2388
+ if (!sql) throw unavailable();
2389
+ try {
2390
+ return await sql.query<Row>(text, params);
2391
+ } catch (error) {
2392
+ throw infra(error);
2393
+ }
2394
+ },
2395
+ };
2396
+ }
2397
+
1942
2398
  /**
1943
2399
  * The runtime {@link MutationScope} handed to a {@link ScopedMutator}. It owns the single atomic
1944
2400
  * transaction (delegating to {@link MutationBackend.runMutation} — the exact machinery a tx-form
@@ -1956,6 +2412,7 @@ class MutationScopeImpl implements MutationScope {
1956
2412
  private readonly backend: MutationBackend;
1957
2413
  private readonly envelope: MutationEnvelope;
1958
2414
  private readonly render: RenderIndex;
2415
+ readonly sql: ServerSql;
1959
2416
  /** Set once `transact` resolved through the backend (accepted OR business-rejected). */
1960
2417
  outcome?: MutationOutcome;
1961
2418
  /** The value the backend threw on INFRA (the DB failed) — always propagated, never an `lmid`
@@ -1975,6 +2432,7 @@ class MutationScopeImpl implements MutationScope {
1975
2432
  this.backend = backend;
1976
2433
  this.envelope = envelope;
1977
2434
  this.render = render;
2435
+ this.sql = scopedOutsideSql(backend.outsideSql);
1978
2436
  }
1979
2437
 
1980
2438
  transact(
@@ -2024,8 +2482,24 @@ class MutationScopeImpl implements MutationScope {
2024
2482
  export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptions<User>): RindleApiServer<User> {
2025
2483
  const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
2026
2484
  const mode = opts.mode ?? "normalized";
2027
- // The mutation seam defaulting to the daemon's co-transactional lmid stamp (unchanged behavior).
2028
- const backend = opts.backend ?? daemonBackend(opts.daemon);
2485
+ // Explicit backend wins; otherwise prefer the versioned Rindle-SQL mutation transport and retain
2486
+ // daemonBackend as the compatibility path for deployments that have not exposed it yet.
2487
+ let ownedSql: SqlClient | undefined;
2488
+ let backend: MutationBackend;
2489
+ if (opts.backend !== undefined) {
2490
+ backend = opts.backend;
2491
+ } else {
2492
+ if (opts.database !== undefined && opts.sql !== undefined) {
2493
+ throw new TypeError("configure either database or sql, not both");
2494
+ }
2495
+ const sql =
2496
+ opts.sql ??
2497
+ (opts.database !== undefined
2498
+ ? // Default FIRST so `database.intMode` can override it; see RindleDatabaseOptions.
2499
+ (ownedSql = createSqlClient({ intMode: "number", ...opts.database }))
2500
+ : undefined);
2501
+ backend = sql === undefined ? daemonBackend(opts.daemon) : sqlBackend(sql);
2502
+ }
2029
2503
  // Schema-derived render metadata for logical mutator writes; `{}` when no schema is configured (a
2030
2504
  // logical op then throws loudly — the tx never silently drops a write). Each backend renders in its
2031
2505
  // own dialect (`backend.dialect`: daemon→sqlite, postgres→postgres).
@@ -2086,20 +2560,13 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2086
2560
  );
2087
2561
  };
2088
2562
 
2089
- // The §2.3 aggregate refusal: the client's aggregate overlay is daemon-gated until post-G, so
2090
- // an aggregate/reduce-shaped query is refused room-serving REGARDLESS of coverage.
2563
+ // Room-served aggregates are refused: a room-retargeted query carrying a count()/reduce reads an
2564
+ // `__agg` head only the daemon feed maintains, and the client's room gate DROPS the `__agg` rows
2565
+ // the room publishes — a known-unsupported shape (302 post-impl review). Room serving otherwise
2566
+ // trusts the declaration (302 §5: declared, not derived); this one shape stays a policy refusal
2567
+ // until room-served aggregates are designed.
2091
2568
  const AGGREGATE_REFUSAL =
2092
- "the query AST contains an aggregate/reduce shape — aggregate overlays are daemon-gated until post-G";
2093
-
2094
- // Verdict cache. The verdict is a pure function of exactly two inputs — the resolved footprint
2095
- // AST and the resolved query AST — so the tightest SOUND key is those two ASTs themselves
2096
- // (stable-stringified), scoped by (queryName, profile) for legibility. Args/user/ctx need no
2097
- // separate slot precisely because anything that changes the verdict must change one of the two
2098
- // ASTs (predicate literals embed the args; ctx-scoped queries embed the principal); keying on
2099
- // `(name, args)` alone would ALIAS two users' different ASTs under one verdict — unsound.
2100
- // Bounded FIFO (Map iterates in insertion order) so per-user literals can't grow it forever.
2101
- const coverVerdicts = new Map<string, CoverQueryOutput>();
2102
- const COVER_VERDICT_CACHE_MAX = 1024;
2569
+ "the query contains an aggregate/reduce shape — room-served aggregates are not yet supported (the room gate drops `__agg` rows)";
2103
2570
 
2104
2571
  const maybeRoomServe = async (
2105
2572
  input: QueryLeaseRequest<User>,
@@ -2118,13 +2585,6 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2118
2585
  warnRoomServeOnce(input.name, profile.name, ["realtime.locateRoom is not configured"]);
2119
2586
  return undefined;
2120
2587
  }
2121
- const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
2122
- if (coverQuery === undefined) {
2123
- warnRoomServeOnce(input.name, profile.name, [
2124
- "the configured daemon client does not implement coverQuery (/cover-check)",
2125
- ]);
2126
- return undefined;
2127
- }
2128
2588
  const tokenKey = realtime.roomTokenKey;
2129
2589
  if (tokenKey === undefined) {
2130
2590
  warnRoomServeOnce(input.name, profile.name, [
@@ -2153,26 +2613,15 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2153
2613
  const footprintAst = queryResultToAst(await profile.footprint(key, context));
2154
2614
  assertUnwindowedFootprint(footprintAst, profile.name);
2155
2615
 
2156
- const verdictKey = `${input.name}\u0000${profile.name}\u0000${stableStringify(footprintAst)}\u0000${stableStringify(queryAst)}`;
2157
- let verdict = coverVerdicts.get(verdictKey);
2158
- if (verdict === undefined) {
2159
- verdict = astHasAggregate(queryAst)
2160
- ? { covered: false, reasons: [AGGREGATE_REFUSAL] }
2161
- : await coverQuery({ footprint: footprintAst, query: queryAst });
2162
- // (A coverQuery THROW never lands here — the outer catch fail-opens without caching, so
2163
- // a transient daemon failure doesn't pin an uncovered verdict.)
2164
- if (coverVerdicts.size >= COVER_VERDICT_CACHE_MAX) {
2165
- coverVerdicts.delete(coverVerdicts.keys().next().value as string);
2166
- }
2167
- coverVerdicts.set(verdictKey, verdict);
2168
- }
2169
- if (!verdict.covered) {
2170
- warnRoomServeOnce(input.name, profile.name, verdict.reasons ?? ["not provably covered"]);
2616
+ // Trust the declaration (302 §5): a labeled + wired query is room-served, no coverage proof.
2617
+ // The one shape still refused is the aggregate (a policy gate, not a coverage verdict).
2618
+ if (astHasAggregate(queryAst)) {
2619
+ warnRoomServeOnce(input.name, profile.name, [AGGREGATE_REFUSAL]);
2171
2620
  return undefined;
2172
2621
  }
2173
2622
 
2174
- // Covered ⇒ assemble the realtime block. The room endpoint rides ITS OWN field — the
2175
- // top-level `wsEndpoint` stays exactly the daemon lease's (whole-session migration signal).
2623
+ // Assemble the realtime block. The room endpoint rides ITS OWN field
2624
+ // (`realtime.wsEndpoint`) a separate connection from the daemon session's fixed ws host.
2176
2625
  const { wsEndpoint } = await realtime.locateRoom(doc);
2177
2626
  const now = Date.now();
2178
2627
  const ttlMs = realtime.roomTokenTtlMs ?? DEFAULT_ROOM_TOKEN_TTL_MS;
@@ -2202,7 +2651,8 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2202
2651
  tables,
2203
2652
  };
2204
2653
  } catch (e) {
2205
- // Fail open — a lease is never blocked on the proof. Not cached (may be transient).
2654
+ // Fail open — a lease is never blocked on room-serve wiring (footprint resolution,
2655
+ // locateRoom, token minting). A failure here just serves the query from the daemon.
2206
2656
  warnRoomServeOnce(input.name, profile.name, [`room-serve failed: ${errMessage(e)}`]);
2207
2657
  return undefined;
2208
2658
  }
@@ -2266,11 +2716,14 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2266
2716
  subject,
2267
2717
  leaseTtlMs: opts.leaseTtlMs,
2268
2718
  metadata: routingKey !== undefined ? { routingKey } : undefined,
2719
+ // Lifecycle leases are follower-local exactly like the primary lease. Forward the SAME
2720
+ // opaque placement ticket so every doorbell/fence materialization is minted on the
2721
+ // browser socket's follower instead of independently anycasting across the fleet.
2722
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
2269
2723
  });
2270
2724
  const lease = (table: string, out: MaterializeOutput, id: { scope?: string; doc?: string; clientId?: string }): QueryLeaseLifecycleLease => ({
2271
2725
  table,
2272
2726
  leaseToken: out.leaseToken,
2273
- ...(out.wsEndpoint !== undefined ? { wsEndpoint: out.wsEndpoint } : {}),
2274
2727
  ...(id.scope !== undefined ? { scope: id.scope } : {}),
2275
2728
  ...(id.doc !== undefined ? { doc: id.doc } : {}),
2276
2729
  ...(id.clientId !== undefined ? { clientId: id.clientId } : {}),
@@ -2456,16 +2909,20 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2456
2909
  // The anonymous routing key rides `metadata.routingKey`; the router keys on
2457
2910
  // `subject ?? metadata.routingKey` (§2.2). Omitted when there is none.
2458
2911
  metadata: routingKey !== undefined ? { routingKey } : undefined,
2912
+ // Forward the browser's opaque affinity ticket (if any) so the fleet `fly-replay`s this
2913
+ // materialize to the follower the ws is pinned to (FOLLOWER-AFFINITY-DESIGN.md §4). Opaque —
2914
+ // never verified here. Inert when the reads client is a single daemon (no fleet edge).
2915
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
2459
2916
  });
2460
2917
  const res = queryLeaseResponse(out);
2461
2918
  // I-iv (§4.1): the occupancy step FIRST — session sweep+upsert, then the D6 gate verdict. A
2462
2919
  // closed gate suppresses the room-serve ONLY (the lease ships without the realtime block,
2463
- // indistinguishable from an uncovered query — the fail-open daemon path) while the doorbell
2920
+ // indistinguishable from a non-room-served query — the daemon path) while the doorbell
2464
2921
  // below still rides; lifecycle-off ⇒ `gateOpen: true` unconditionally and this line is inert.
2465
2922
  const occ = await lifecycleOccupancy(input);
2466
- // G-iv-b: a covered labeled query ADDITIONALLY gains the realtime block. The daemon lease
2923
+ // G-iv-b: a labeled + wired query ADDITIONALLY gains the realtime block. The daemon lease
2467
2924
  // above is unconditional (and its fields untouched) — room-serving only ever adds a field,
2468
- // so an uncovered/unwired/legacy lease stays byte-identical and nothing here can block one.
2925
+ // so a non-room-served/legacy lease stays byte-identical and nothing here can block one.
2469
2926
  const rt = occ.gateOpen ? await maybeRoomServe(input, ast, context, subject) : undefined;
2470
2927
  if (rt !== undefined) res.realtime = rt;
2471
2928
  // I-v (§4.2): the gate CLOSED and a room plausibly hosted this scope — drain it and ride the
@@ -2505,10 +2962,13 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2505
2962
  const subject = await resolveSubject(opts.subject, input);
2506
2963
  const routingKey = await resolveRoutingKey(opts.routingKey, input);
2507
2964
  const visibilityKey = subject ?? routingKey;
2508
- const out = await opts.daemon.query({ ast, visibilityKey, ttlMs: opts.readIdleTtlMs });
2509
- const res: QueryReadResponse = { rows: out.rows, cvMin: out.cvMin, queryKey: out.queryKey };
2510
- if (out.wsEndpoint !== undefined) res.wsEndpoint = out.wsEndpoint;
2511
- return res;
2965
+ const out = await opts.daemon.query({
2966
+ ast,
2967
+ visibilityKey,
2968
+ ttlMs: opts.readIdleTtlMs,
2969
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
2970
+ });
2971
+ return { rows: out.rows, cvMin: out.cvMin, queryKey: out.queryKey };
2512
2972
  };
2513
2973
 
2514
2974
  const pushMutation = async (input: PushMutationRequest<User>): Promise<PushMutationResponse> => {
@@ -2656,57 +3116,6 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2656
3116
  }
2657
3117
  };
2658
3118
 
2659
- // The explicit coverage diagnostic (the assertPins pattern: system-level, resolved under
2660
- // `pinUser`, per-query failures collected — never strand the rest). It runs the REAL check —
2661
- // the daemon's /cover-check on the actually-resolved ASTs — so its verdicts are exactly the
2662
- // lease path's, minus the serving wiring (locateRoom/roomTokenKey), which it deliberately
2663
- // ignores: it answers "is this labeled query coverable", the deployable-config question.
2664
- const validateRealtime = async (vopts?: {
2665
- exemplars?: Partial<Record<string, readonly unknown[]>>;
2666
- strict?: boolean;
2667
- }): Promise<ValidateRealtimeReport> => {
2668
- const context: ApiContext<User> = { user: opts.pinUser as User, request: undefined };
2669
- const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
2670
- const verdicts: ValidateRealtimeVerdict[] = [];
2671
- for (const [name, q] of Object.entries(opts.queries ?? {})) {
2672
- const label = queryRealtimeLabel(q);
2673
- if (label === undefined) continue;
2674
- const profile = roomProfiles.get(label.room);
2675
- if (profile === undefined) continue; // unreachable: construction asserted it exists
2676
- for (const args of vopts?.exemplars?.[name] ?? [null]) {
2677
- const verdict: ValidateRealtimeVerdict = { query: name, profile: profile.name, args, covered: false };
2678
- try {
2679
- const ast = await resolveAst(name, args, context);
2680
- const roomArgs = label.args !== undefined ? label.args(args) : args;
2681
- const footprintAst = queryResultToAst(await profile.footprint(profile.key(roomArgs), context));
2682
- assertUnwindowedFootprint(footprintAst, profile.name);
2683
- if (astHasAggregate(ast)) {
2684
- verdict.reasons = [AGGREGATE_REFUSAL];
2685
- } else if (coverQuery === undefined) {
2686
- verdict.reasons = ["the configured daemon client does not implement coverQuery (/cover-check)"];
2687
- } else {
2688
- const out = await coverQuery({ footprint: footprintAst, query: ast });
2689
- verdict.covered = out.covered;
2690
- if (!out.covered) verdict.reasons = out.reasons ?? ["not provably covered"];
2691
- }
2692
- } catch (e) {
2693
- verdict.reasons = [errMessage(e)];
2694
- }
2695
- verdicts.push(verdict);
2696
- }
2697
- }
2698
- const uncovered = verdicts.filter((v) => !v.covered);
2699
- if (vopts?.strict && uncovered.length > 0) {
2700
- throw new Error(
2701
- `validateRealtime: ${uncovered.length} labeled query verdict(s) not provably covered — ` +
2702
- uncovered
2703
- .map((v) => `${v.query} (profile "${v.profile}"): ${(v.reasons ?? []).join("; ")}`)
2704
- .join(" | "),
2705
- );
2706
- }
2707
- return { verdicts, uncovered };
2708
- };
2709
-
2710
3119
  // The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
2711
3120
  // the `realtime` block (which also activates `/room-boot`) or the deprecated bare
2712
3121
  // `authorizeRoom` (trio only). Hosting an authority is never a default.
@@ -2771,10 +3180,10 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2771
3180
 
2772
3181
  return {
2773
3182
  routes,
3183
+ close: () => ownedSql?.close(),
2774
3184
  createQueryLease,
2775
3185
  readQuery,
2776
3186
  assertPins,
2777
- validateRealtime,
2778
3187
  pushMutation,
2779
3188
  pushMutations,
2780
3189
  handleApplyRowChangeTxnJson: async (body, context) => {
@@ -2866,7 +3275,8 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2866
3275
  headers,
2867
3276
  },
2868
3277
  };
2869
- const upstreamWsEndpoint = realtime.upstreamWsEndpoint ?? lease.wsEndpoint;
3278
+ if (lease.affinity !== undefined) res.upstreamAffinity = lease.affinity;
3279
+ const upstreamWsEndpoint = realtime.upstreamWsEndpoint;
2870
3280
  if (upstreamWsEndpoint !== undefined) res.upstreamWsEndpoint = upstreamWsEndpoint;
2871
3281
  return { status: 200, body: res };
2872
3282
  } catch (e) {
@@ -2881,6 +3291,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2881
3291
  args: msg.args ?? null,
2882
3292
  request: context.request,
2883
3293
  clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
3294
+ affinity: typeof msg.affinity === "string" ? msg.affinity : undefined,
2884
3295
  });
2885
3296
  },
2886
3297
  handleReadJson: (body, context) => {
@@ -2891,6 +3302,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2891
3302
  args: msg.args ?? null,
2892
3303
  request: context.request,
2893
3304
  clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
3305
+ affinity: typeof msg.affinity === "string" ? msg.affinity : undefined,
2894
3306
  });
2895
3307
  },
2896
3308
  handleMutateJson: (body, context) => {
@@ -2958,8 +3370,9 @@ function applyOpToServerTx(tx: ServerWriteTx, op: MutationOp): Promise<void> {
2958
3370
 
2959
3371
  /** Feed a mutator's RETURNED result (the alternative to calling `tx.exec`/logical ops directly) into
2960
3372
  * the backend tx: a returned `SqlStatement[]` / `SqlTxn` is exec'd onto `tx`, and a carried
2961
- * `idempotencyKey` is stashed (the daemon backend honors it; PG ignores it). A `void` return is a
2962
- * no-op the mutator already drove the tx. Preserves the pre-existing return-style contract. */
3373
+ * `idempotencyKey` is stashed for the legacy daemon adapter; the SQL mutation facade uses `mid`
3374
+ * as its durable retry identity and PG ignores it. A `void` return is a no-op the mutator already
3375
+ * drove the tx. Preserves the return-style contract. */
2963
3376
  function applyResultToTx(result: ApiMutatorResult, tx: ServerMutationTx): void {
2964
3377
  if (!result) return;
2965
3378
  const statements = Array.isArray(result) ? result : result.statements;
@@ -3062,15 +3475,12 @@ async function resolveRoutingKey<User>(
3062
3475
  }
3063
3476
 
3064
3477
  function queryLeaseResponse(out: MaterializeOutput): QueryLeaseResponse {
3065
- const res: QueryLeaseResponse = {
3478
+ return {
3066
3479
  leaseToken: out.leaseToken,
3067
3480
  materializationId: out.materializationId,
3068
3481
  queryKey: out.queryKey,
3069
3482
  reused: out.reused,
3070
3483
  };
3071
- // Only present in a routed deploy — absent reproduces today's single-daemon response exactly.
3072
- if (out.wsEndpoint !== undefined) res.wsEndpoint = out.wsEndpoint;
3073
- return res;
3074
3484
  }
3075
3485
 
3076
3486
  function errMessage(reason: unknown): string {
@@ -3097,7 +3507,7 @@ const ROOM_MUTATION_OUTCOMES_TABLE = "_rindle_room_mutation_outcomes";
3097
3507
  // delta fanning to every subscribed solo client; no clientID/mid — a system write must never
3098
3508
  // advance an lmid — and no idempotencyKey — a renewal's re-upsert must re-run, that is the
3099
3509
  // refresh), and the count is one `executeSqlRead` with `consistency: "strong"` — the read surface
3100
- // the api-server already has against the daemon (the `DaemonLazyTx` fallback precedent above).
3510
+ // the api-server already has against the daemon (the `RemoteLazyTx` fallback precedent above).
3101
3511
  // "strong" routes the read to the WRITE MASTER in a split deploy, which just serialized our
3102
3512
  // upsert: read-your-writes without a mutation session (the interactive-txn machinery is optional
3103
3513
  // on the daemon interface and far heavier than this two-round-trip pair needs).
@@ -3176,19 +3586,6 @@ function docClientAst(table: string, doc: string, clientId: string | undefined):
3176
3586
  * lease through the api-server, never an extension of this token. */
3177
3587
  const DEFAULT_ROOM_TOKEN_TTL_MS = 5 * 60_000;
3178
3588
 
3179
- /** Deterministic JSON: object keys sorted recursively, so two structurally identical ASTs from
3180
- * independent resolves stringify identically (the verdict-cache key). */
3181
- function stableStringify(v: unknown): string {
3182
- return JSON.stringify(v, (_key, value: unknown) => {
3183
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
3184
- const rec = value as Record<string, unknown>;
3185
- const sorted: Record<string, unknown> = {};
3186
- for (const k of Object.keys(rec).sort()) sorted[k] = rec[k];
3187
- return sorted;
3188
- }
3189
- return value;
3190
- });
3191
- }
3192
3589
 
3193
3590
  /** Does the AST contain an aggregate/reduce shape ANYWHERE (root, a `related` subquery, or an
3194
3591
  * `EXISTS` child)? Room-serving refuses these regardless of coverage: the client's aggregate