@rindle/api-server 0.3.1 → 0.4.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,5 +1,23 @@
1
- import type { Ast, Condition, MutationEnvelope, NamedQuery, Query, Schema } from "@rindle/client";
1
+ import { driveMutationAsync, insertCell, insertPlan, isoTx, toCell } from "@rindle/client";
2
+ import type {
3
+ Ast,
4
+ ColType,
5
+ Condition,
6
+ KeyedRow,
7
+ MutationEnvelope,
8
+ MutationOp,
9
+ MutatorCtx,
10
+ NamedQuery,
11
+ Query,
12
+ QueryResultRow,
13
+ Schema,
14
+ SharedMutator,
15
+ SharedMutatorWithArgs,
16
+ ServerWriteTx,
17
+ } from "@rindle/client";
2
18
  import { DaemonHttpError } from "@rindle/daemon-client";
19
+ import { compile as compileQueryAst } from "@rindle/query-compiler";
20
+ import type { Catalog, ColumnType as QueryColumnType, TableSchema } from "@rindle/query-compiler";
3
21
  import type {
4
22
  ClaimRoomEpochInput,
5
23
  ClaimRoomEpochOutput,
@@ -12,6 +30,11 @@ import type {
12
30
  MigrateOutput,
13
31
  MutationRejection,
14
32
  MutationRejectionOutput,
33
+ MutationSessionBegin,
34
+ MutationSessionBeginOutput,
35
+ MutationSessionExec,
36
+ MutationSessionQuery,
37
+ MutationSessionRef,
15
38
  QueryOnceInput,
16
39
  QueryOnceOutput,
17
40
  RindleDaemonClient,
@@ -28,6 +51,12 @@ import type {
28
51
  WireValue,
29
52
  } from "@rindle/daemon-client";
30
53
 
54
+ // Re-export the shared (generator) mutator seam so an app builds its server mutators from ONE import:
55
+ // co-locate each body with its arg schema (`shared`), bulk-drive the registry ({@link sharedApiMutators}),
56
+ // keeping only server-only authority as explicit overrides (see MUTATORS-ISOMORPHIC).
57
+ export { isoTx, shared } from "@rindle/client";
58
+ export type { ArgSchema, IsoTx, MutationGen, MutatorCtx, SharedMutator, SharedMutatorWithArgs } from "@rindle/client";
59
+
31
60
  export const DEFAULT_RINDLE_API_ROUTES = {
32
61
  query: "/api/rindle/query",
33
62
  read: "/api/rindle/read",
@@ -37,6 +66,8 @@ export const DEFAULT_RINDLE_API_ROUTES = {
37
66
  applyRowChangeTxn: "/api/rindle/apply-row-change-txn",
38
67
  claimRoomEpoch: "/api/rindle/claim-room-epoch",
39
68
  roomLmids: "/api/rindle/room-lmids",
69
+ // The DO shell's cold-boot callback (§10.1) — active only when `realtime` is configured.
70
+ roomBoot: "/api/rindle/room-boot",
40
71
  } as const;
41
72
 
42
73
  export type MaybePromise<T> = T | PromiseLike<T>;
@@ -82,14 +113,39 @@ export interface MutationContext<User> {
82
113
  request?: unknown;
83
114
  }
84
115
 
116
+ /** The legacy raw-SQL escape hatch (still the surface for relational/authority statements a keyed op
117
+ * can't express — an owner-gated cascade, a `NOT EXISTS` dedup). `exec` accumulates on the daemon
118
+ * backend and executes live on the Postgres backend; `statements` is the collected raw list. */
85
119
  export interface SqlMutationTx {
86
120
  exec(sql: string, params?: WireValue[]): void;
87
121
  readonly statements: readonly SqlStatement[];
88
122
  }
89
123
 
124
+ /** The write handle a server mutator runs against — the ASYNC twin of the client's `MutationTx`. It
125
+ * is both the isomorphic {@link ServerWriteTx} logical surface (insert/update/upsert/insertIgnore/
126
+ * delete/row, rendered to dialect SQL) AND the legacy {@link SqlMutationTx} raw escape hatch. Both
127
+ * backends run reads through the OPEN transaction (read-your-writes): the Postgres backend executes
128
+ * everything live; the daemon backend accumulates writes and lazily upgrades to an interactive
129
+ * mutation session at the first read (DAEMON-INTERACTIVE-TXN-DESIGN.md §5). */
130
+ export interface ServerMutationTx extends ServerWriteTx, SqlMutationTx {
131
+ /** Run a full query (a fluent `Query` or its wire `Ast`) INSIDE the open transaction —
132
+ * read-your-writes, like {@link ServerWriteTx.row} but for arbitrary shapes. Returns the
133
+ * parsed nested result tree: an array for a plural root, an object or `null` for a `.one()`
134
+ * root, with cells in their raw SQLite storage-class representations (the same vocabulary
135
+ * `row` speaks). Daemon backend: compiled by `@rindle/query-compiler`'s sqlite dialect (bind
136
+ * params, NO casts — §5.4) and executed through the mutation session. Postgres backend: lands
137
+ * with the read-compiler catalog integration (POSTGRES-READ-COMPILER-DESIGN.md Phase B). */
138
+ query(q: Ast | Query<any, any, any>): Promise<unknown>;
139
+ }
140
+
90
141
  export type ApiMutatorResult = void | SqlStatement[] | SqlTxn;
142
+ /** A server mutator: a plain async function against the live {@link ServerMutationTx}. Two ways to
143
+ * write one (MUTATORS-ISOMORPHIC): drive it directly (the raw escape hatch — an owner-gated cascade,
144
+ * a `NOT EXISTS` dedup — plus a returned `SqlStatement[]`/`SqlTxn`), OR delegate to a SHARED generator
145
+ * (the SAME body the client predicts) via {@link runSharedMutation}, keeping only the server-only
146
+ * authority (arg parse, principal, policy) in the wrapper. */
91
147
  export type ApiMutator<User, Args> = (
92
- tx: SqlMutationTx,
148
+ tx: ServerMutationTx,
93
149
  args: Args,
94
150
  ctx: MutationContext<User>,
95
151
  ) => MaybePromise<ApiMutatorResult>;
@@ -140,32 +196,44 @@ export interface QueryReadResponse {
140
196
  wsEndpoint?: string;
141
197
  }
142
198
 
143
- /** One mutation's collected effects, handed to a {@link MutationBackend} as ONE atomic unit. */
144
- export interface MutationApplyInput {
199
+ /** The context a {@link MutationBackend} needs to run one mutation inside its transaction. */
200
+ export interface MutationRunInput {
145
201
  envelope: MutationEnvelope;
146
- /** What the mutator emitted, in order. */
147
- statements: readonly SqlStatement[];
148
- /** Present when the mutator returned a full `SqlTxn` carrying one. */
149
- idempotencyKey?: string;
202
+ /** Schema-derived render metadata (from {@link RindleApiServerOptions.schema}). A logical op on a
203
+ * table absent here throws loudly — never a silent dropped write. `{}` when no schema is set. */
204
+ render: RenderIndex;
205
+ /** Invoke the (authorized) mutator against the backend-provided tx. A THROW that is NOT a
206
+ * {@link BackendError} is a BUSINESS rejection (roll the data back, then advance `lmid` alone,
207
+ * §2.4); a {@link BackendError} (a DB-layer failure) is INFRA and rejects the returned promise. */
208
+ run(tx: ServerMutationTx): Promise<void>;
150
209
  }
151
210
 
211
+ /** The result of {@link MutationBackend.runMutation}: either the data+lmid committed together, or a
212
+ * business rejection whose data was rolled back but whose `lmid` still advanced (§2.4). */
213
+ export type MutationOutcome =
214
+ | { accepted: true; output: SqlTxnOutput }
215
+ | { accepted: false; reason: string; output?: unknown };
216
+
152
217
  /**
153
- * Where a mutation's statements go, and who stamps `lmid` — the seam that makes the mutator
154
- * authoring surface backend-agnostic (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6). Two impls ship:
155
- * {@link daemonBackend} (today's behaviorthe daemon's co-transactional stamp; the default) and
156
- * {@link postgresBackend} (BYO-Postgres: the same guarantees discharged in one PG transaction,
157
- * confirmation riding the CDC loop back down).
218
+ * Where a mutation runs and who stamps `lmid` — the seam that makes the mutator authoring surface
219
+ * backend-agnostic (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6; MUTATORS-ISOMORPHIC plan). Two impls
220
+ * ship: {@link daemonBackend} (the defaultwrites accumulate, one batch to the daemon which stamps
221
+ * `lmid` co-transactionally) and {@link postgresBackend} (a REAL interactive PG transaction writes
222
+ * and reads execute live, `lmid` committed in the same txn, confirmation riding the CDC loop down).
158
223
  *
159
- * Both methods advance the client's `last_mutation_id`; that is the load-bearing invariant:
160
- * - `apply` runs the statements AND advances `lmid` to `envelope.mid` in the SAME atomic unit
161
- * (OPTIMISTIC-WRITES-DESIGN.md §8.2) never in a second transaction.
162
- * - `reject` applies NO data but STILL advances `lmid` past `envelope.mid` (§2.4 — the sharp
163
- * edge: without it the client's pending queue never drains and the optimistic stack wedges).
164
- * - A backend/infrastructure failure THROWS from either it is never reported as a user
165
- * rejection (the client retries; mid dedup absorbs any applied prefix).
224
+ * The load-bearing invariant is that a mutation ALWAYS advances the client's `last_mutation_id`:
225
+ * - `runMutation` runs the mutator inside the backend's transaction; on success it advances `lmid`
226
+ * to `envelope.mid` in the SAME atomic unit (OPTIMISTIC-WRITES-DESIGN.md §8.2); on a BUSINESS
227
+ * rejection it rolls the data back but STILL advances `lmid` (§2.4 — else the client's pending
228
+ * queue never drains and the optimistic stack wedges).
229
+ * - `reject` is the pre-flight path (unknown mutator, failed authorization): NO data, `lmid` alone.
230
+ * - An infrastructure failure THROWS from `runMutation` never a user rejection (the client
231
+ * retries; mid dedup absorbs any applied prefix).
166
232
  */
167
233
  export interface MutationBackend {
168
- apply(input: MutationApplyInput): Promise<SqlTxnOutput>;
234
+ /** The SQL dialect this backend renders logical ops to (drives placeholder style). */
235
+ readonly dialect: SqlDialect;
236
+ runMutation(input: MutationRunInput): Promise<MutationOutcome>;
169
237
  reject(input: { envelope: MutationEnvelope; reason: string }): Promise<unknown>;
170
238
  }
171
239
 
@@ -192,6 +260,7 @@ export interface RindleApiRoutes {
192
260
  applyRowChangeTxn: string;
193
261
  claimRoomEpoch: string;
194
262
  roomLmids: string;
263
+ roomBoot: string;
195
264
  }
196
265
 
197
266
  /** A room-host reply the transport writes VERBATIM (`status` + JSON `body`): the
@@ -217,21 +286,191 @@ export interface PinFanout {
217
286
  assertPins(pins: readonly MaterializeInput[]): Promise<void>;
218
287
  }
219
288
 
289
+ // --------------------------------------------------------------------------- realtime (room) host
290
+ //
291
+ // Enabling Rindle Realtime for an app (RINDLE-REALTIME-ENABLEMENT-DESIGN.md §3.1) is ONE named
292
+ // opt-in: the `realtime` options block. Its presence activates the flush trio AND `/room-boot`;
293
+ // its absence keeps every room endpoint 403 and adds no route. The deprecated bare `authorizeRoom`
294
+ // still gates the trio alone (it never activates `/room-boot`).
295
+
296
+ /** The room-boot flush leg (enablement §5): where the placed room's write-behind lands and what
297
+ * credential it presents. `urls` are the trio's ROUTE PATHS (root-relative — the shell resolves
298
+ * them against the boot call's origin), so an app that overrides `routes` needs no out-of-band
299
+ * sync; `headers` ride every flush call verbatim (`httpAuthority`'s `headers`). */
300
+ export interface RoomBootFlush {
301
+ urls: { apply: string; claim: string; lmids: string };
302
+ headers: Record<string, string>;
303
+ }
304
+
305
+ /** The `/room-boot` response (RINDLE-REALTIME-DESIGN.md §10.1 — the DO shell's cold-boot
306
+ * callback): the claimed placement epoch, the room's upstream footprint lease, and the flush
307
+ * leg. A cold room boots inert and serves nothing until this returns. */
308
+ export interface RoomBootResponse {
309
+ epoch: number;
310
+ upstreamLeaseToken: string;
311
+ /** Where the room opens its upstream subscription (a routed deploy's follower). Absent ⇒ the
312
+ * shell's statically configured rindled ws endpoint. */
313
+ upstreamWsEndpoint?: string;
314
+ flush: RoomBootFlush;
315
+ }
316
+
317
+ export interface RindleRealtimeOptions<User> {
318
+ /** The boot shell secret (§10.1 — a host binding, never client-derived). Authenticates the
319
+ * room's `Authorization: Bearer` on `/room-boot` (the default {@link authorizeBoot}) and keys
320
+ * the DEFAULT epoch-bound flush credential. */
321
+ shellSecret: string;
322
+ /** doc → the room's approved upstream footprint (§3.1) — an `Ast` or fluent `Query`. MAY
323
+ * delegate to the named-query registry internally; throw `RindleApiError("not-found", …, 404)`
324
+ * 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>;
327
+ /** Gates the flush trio — `authorizeRoom`, relocated. Default: verify the default flush
328
+ * credential from the {@link ROOM_FLUSH_CREDENTIAL_HEADER} request header — which requires the
329
+ * transport to pass its incoming request as `context.request` (Fetch `Request` and node
330
+ * `IncomingMessage` shapes are both understood). */
331
+ authorize?: Authorizer<ApiContext<User>>;
332
+ /** Gates `/room-boot`. Default: constant-time `Authorization: Bearer` check against
333
+ * {@link shellSecret} (same `context.request` requirement as {@link authorize}). */
334
+ authorizeBoot?: Authorizer<ApiContext<User>>;
335
+ /** Mint the flush headers a placed room presents on every flush call. Default:
336
+ * {@link mintRoomFlushCredential} under {@link ROOM_FLUSH_CREDENTIAL_HEADER}. Override this
337
+ * and {@link authorize} TOGETHER — they are the two ends of one credential. */
338
+ mintFlushHeaders?: (input: { doc: string; epoch: number }) => MaybePromise<Record<string, string>>;
339
+ /** Lease TTL for the room's upstream footprint materialization (defaults to the server-wide
340
+ * `leaseTtlMs`, else the daemon's default). */
341
+ 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. */
344
+ upstreamWsEndpoint?: string;
345
+ }
346
+
347
+ // The DEFAULT flush credential: `rfc1.<b64url payload>.<b64url hmac-sha256>`, payload
348
+ // `{v:1, doc, epoch, iat}`. Deliberately EPOCH-bound, not time-bound: the credential's lifecycle
349
+ // IS the placement fence (§8.3 — a superseded epoch's flushes 409 at the store no matter what
350
+ // credential they carry), and platform revocation is registry suspension, so an `exp` would only
351
+ // force spurious re-boots of long-lived rooms. HMAC via WebCrypto (`crypto.subtle`) so the exact
352
+ // same code runs in Node and a Cloudflare Worker — no `node:crypto` import (matching
353
+ // `@rindle/room`'s token module).
354
+
355
+ export const ROOM_FLUSH_CREDENTIAL_HEADER = "x-rindle-room-credential";
356
+
357
+ const FLUSH_CREDENTIAL_PREFIX = "rfc1";
358
+
359
+ export interface RoomFlushCredentialPayload {
360
+ v: 1;
361
+ doc: string;
362
+ epoch: number;
363
+ iat: number;
364
+ }
365
+
366
+ function b64url(bytes: Uint8Array): string {
367
+ let bin = "";
368
+ for (const b of bytes) bin += String.fromCharCode(b);
369
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
370
+ }
371
+
372
+ // Return type inferred (`Uint8Array<ArrayBuffer>` under TS ≥5.7 libs) — an explicit
373
+ // `Uint8Array` annotation widens to `ArrayBufferLike` and fails `crypto.subtle`'s
374
+ // `BufferSource` under consumers compiling this source with newer lib types.
375
+ function unb64url(s: string) {
376
+ const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
377
+ const out = new Uint8Array(bin.length);
378
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
379
+ return out;
380
+ }
381
+
382
+ async function flushHmacKey(secret: string, usage: "sign" | "verify") {
383
+ return crypto.subtle.importKey(
384
+ "raw",
385
+ new TextEncoder().encode(secret),
386
+ { name: "HMAC", hash: "SHA-256" },
387
+ false,
388
+ [usage],
389
+ );
390
+ }
391
+
392
+ /** Sign the default epoch-bound flush credential (`/room-boot` mints one per placement). */
393
+ export async function mintRoomFlushCredential(opts: {
394
+ shellSecret: string;
395
+ doc: string;
396
+ epoch: number;
397
+ /** Mint time; defaults to `Date.now()`. Injectable for tests. */
398
+ now?: number;
399
+ }): Promise<string> {
400
+ const payload: RoomFlushCredentialPayload = {
401
+ v: 1,
402
+ doc: opts.doc,
403
+ epoch: opts.epoch,
404
+ iat: opts.now ?? Date.now(),
405
+ };
406
+ const body = b64url(new TextEncoder().encode(JSON.stringify(payload)));
407
+ const signed = `${FLUSH_CREDENTIAL_PREFIX}.${body}`;
408
+ const key = await flushHmacKey(opts.shellSecret, "sign");
409
+ const sig = new Uint8Array(
410
+ await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signed)),
411
+ );
412
+ return `${signed}.${b64url(sig)}`;
413
+ }
414
+
415
+ /** Verify a flush credential's MAC, then its claims; returns the payload or throws. The MAC is
416
+ * checked FIRST — no claim is trusted before it passes. */
417
+ export async function verifyRoomFlushCredential(
418
+ credential: string,
419
+ shellSecret: string,
420
+ ): Promise<RoomFlushCredentialPayload> {
421
+ const parts = credential.split(".");
422
+ if (parts.length !== 3 || parts[0] !== FLUSH_CREDENTIAL_PREFIX) {
423
+ throw new Error("not a room flush credential");
424
+ }
425
+ const [, body, sig] = parts;
426
+ const key = await flushHmacKey(shellSecret, "verify");
427
+ const ok = await crypto.subtle.verify(
428
+ "HMAC",
429
+ key,
430
+ unb64url(sig),
431
+ new TextEncoder().encode(`${FLUSH_CREDENTIAL_PREFIX}.${body}`),
432
+ );
433
+ if (!ok) throw new Error("bad signature");
434
+ let payload: RoomFlushCredentialPayload;
435
+ try {
436
+ payload = JSON.parse(new TextDecoder().decode(unb64url(body))) as RoomFlushCredentialPayload;
437
+ } catch {
438
+ throw new Error("malformed payload");
439
+ }
440
+ if (payload.v !== 1) throw new Error("unknown version");
441
+ if (typeof payload.doc !== "string" || payload.doc.length === 0) {
442
+ throw new Error("missing doc");
443
+ }
444
+ if (typeof payload.epoch !== "number") throw new Error("missing epoch");
445
+ return payload;
446
+ }
447
+
220
448
  export interface RindleApiServerOptions<User> {
221
449
  daemon: RindleDaemonClient;
222
450
  /** Where mutations are applied and `lmid` is stamped ({@link MutationBackend}). Default:
223
451
  * `daemonBackend(daemon)` — exactly today's behavior. Pass `postgresBackend(...)` when
224
452
  * Postgres is the source of truth (the daemon leg then serves only the read side). */
225
453
  backend?: MutationBackend;
454
+ /** The typed schema (`createSchema`/`refineSchema`). Required only when a mutator uses the LOGICAL
455
+ * write vocabulary (`tx.insert`/`update`/`upsert`/`insertIgnore`/`delete`/`row`) — it drives the
456
+ * dialect SQL renderer (column order, pk, quoting). A logical op with no schema configured throws
457
+ * loudly. Pure raw-`tx.exec` mutators do not need it. */
458
+ schema?: Schema;
226
459
  queries?: ApiQueries<User>;
227
460
  runQuery?: RunQuery<User>;
228
461
  mutators?: ApiMutators<User>;
229
462
  authorizeQuery?: Authorizer<AuthorizeQueryInput<User>>;
230
463
  authorizeMutation?: Authorizer<AuthorizeMutationInput<User>>;
464
+ /** Rindle Realtime (RINDLE-REALTIME-ENABLEMENT-DESIGN.md §3.1): the ONE named opt-in.
465
+ * Presence activates the room flush trio AND `/room-boot`; absence keeps every room
466
+ * endpoint 403. Takes precedence over the deprecated {@link authorizeRoom}. */
467
+ realtime?: RindleRealtimeOptions<User>;
231
468
  /** The room write-authority gate (§5.3.1): validates the caller is a placed room —
232
469
  * the epoch-bound flush credential rides `context.request`, and what it means is
233
470
  * the app's to define. The room endpoints are DISABLED (403) until this is set:
234
- * hosting a write authority is an explicit opt-in, never a default. */
471
+ * hosting a write authority is an explicit opt-in, never a default.
472
+ * @deprecated Use {@link realtime} (its `authorize`) — this bare form gates the
473
+ * flush trio but never activates `/room-boot`. When both are set, `realtime` wins. */
235
474
  authorizeRoom?: Authorizer<ApiContext<User>>;
236
475
  routes?: Partial<RindleApiRoutes>;
237
476
  mode?: StreamMode;
@@ -313,6 +552,10 @@ export interface RindleApiServer<User> {
313
552
  handleClaimRoomEpochJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
314
553
  /** The room's boot probe (§3.3), same gate + envelope. */
315
554
  handleRoomLmidsJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
555
+ /** The DO shell's cold-boot callback (§10.1; enablement §3.1): authenticate the shell
556
+ * secret, resolve the doc's footprint, claim the placement epoch, mint the upstream
557
+ * lease and the flush leg. 403 until {@link RindleApiServerOptions.realtime} is set. */
558
+ handleRoomBootJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
316
559
  }
317
560
 
318
561
  export type RindleApiErrorCode = "bad-request" | "forbidden" | "not-found" | "rejected";
@@ -369,6 +612,33 @@ export class SplitDaemonClient implements RindleDaemonClient {
369
612
  rejectMutation(input: MutationRejection): Promise<MutationRejectionOutput> {
370
613
  return this.writes.rejectMutation(input);
371
614
  }
615
+ // Interactive mutation sessions hold the MASTER's write transaction — never a replica's
616
+ // (DAEMON-INTERACTIVE-TXN-DESIGN.md §4.1; the follower write-fence enforces the same).
617
+ beginMutationSession(input: MutationSessionBegin): Promise<MutationSessionBeginOutput> {
618
+ const begin = this.writes.beginMutationSession?.bind(this.writes);
619
+ if (!begin) return Promise.reject(new Error("the write master lacks mutation sessions"));
620
+ return begin(input);
621
+ }
622
+ execInMutationSession(input: MutationSessionExec): Promise<unknown> {
623
+ const exec = this.writes.execInMutationSession?.bind(this.writes);
624
+ if (!exec) return Promise.reject(new Error("the write master lacks mutation sessions"));
625
+ return exec(input);
626
+ }
627
+ queryInMutationSession(input: MutationSessionQuery): Promise<SqlReadOutput> {
628
+ const query = this.writes.queryInMutationSession?.bind(this.writes);
629
+ if (!query) return Promise.reject(new Error("the write master lacks mutation sessions"));
630
+ return query(input);
631
+ }
632
+ commitMutationSession(input: MutationSessionRef): Promise<SqlTxnOutput> {
633
+ const commit = this.writes.commitMutationSession?.bind(this.writes);
634
+ if (!commit) return Promise.reject(new Error("the write master lacks mutation sessions"));
635
+ return commit(input);
636
+ }
637
+ rollbackMutationSession(input: MutationSessionRef): Promise<unknown> {
638
+ const rollback = this.writes.rollbackMutationSession?.bind(this.writes);
639
+ if (!rollback) return Promise.reject(new Error("the write master lacks mutation sessions"));
640
+ return rollback(input);
641
+ }
372
642
  applyRowChangeTxn(input: RowChangeTxn): Promise<RowChangeTxnOutput> {
373
643
  return this.writes.applyRowChangeTxn(input);
374
644
  }
@@ -398,17 +668,525 @@ export class SplitDaemonClient implements RindleDaemonClient {
398
668
  }
399
669
  }
400
670
 
671
+ // --------------------------------------------------------------------------- dialect SQL renderer
672
+ //
673
+ // A logical {@link MutationOp} → dialect `SqlStatement`. The whole per-dialect delta is the
674
+ // PLACEHOLDER STYLE (`?` vs `$n`): identifiers are always double-quoted (SQLite tolerates it, PG
675
+ // requires it — `user` is reserved, camelCase folds), and upserts use portable `ON CONFLICT` (both
676
+ // engines). So `sqliteDialect`/`postgresDialect` differ only in `placeholder`.
677
+
678
+ /** A SQL dialect for the logical mutation renderer. */
679
+ export interface SqlDialect {
680
+ readonly name: "sqlite" | "postgres";
681
+ /** Render the i-th (1-based) bind placeholder. sqlite: `?`; postgres: `$i`. */
682
+ placeholder(oneBased: number): string;
683
+ /** Optional value coercion hook (e.g. a future SQLite `0/1` boolean). Default: identity. */
684
+ encodeValue?(v: WireValue, type: ColType): WireValue;
685
+ }
686
+
687
+ export const sqliteDialect: SqlDialect = { name: "sqlite", placeholder: () => "?" };
688
+ export const postgresDialect: SqlDialect = { name: "postgres", placeholder: (i) => `$${i}` };
689
+
690
+ /** Per-table metadata the renderer needs (all reachable from a `TableMeta`). */
691
+ export interface TableRenderMeta {
692
+ /** Columns in schema (wire) order — the stable INSERT column list + completeness check. */
693
+ columns: string[];
694
+ /** Primary-key column NAMES — the WHERE / ON CONFLICT target / SET partition. */
695
+ pkNames: string[];
696
+ /** Column name → declared type (only consulted by {@link SqlDialect.encodeValue}). */
697
+ types: Record<string, ColType>;
698
+ /** Columns a full insert must name — the non-nullable ones (design 206 §6.2). */
699
+ required: string[];
700
+ /** The nullable (omittable-to-null) columns — an omitted one binds `NULL` (design 206 §6.2). */
701
+ nullable: ReadonlySet<string>;
702
+ }
703
+
704
+ export type RenderIndex = Record<string, TableRenderMeta>;
705
+
706
+ /** Build the {@link RenderIndex} from a typed schema (`schema.tables[name]` is a `TableMeta`). */
707
+ export function buildRenderIndex(schema: Schema): RenderIndex {
708
+ const out: RenderIndex = {};
709
+ const tables = (schema as unknown as { tables: Record<string, { columns: Record<string, { type: ColType }>; primaryKey: readonly string[] }> }).tables;
710
+ for (const name of Object.keys(tables)) {
711
+ const meta = tables[name];
712
+ const columns = Object.keys(meta.columns);
713
+ const types: Record<string, ColType> = {};
714
+ for (const c of columns) types[c] = meta.columns[c].type;
715
+ // Same schema-derived plan the client funnel uses (design 206 §6.1), so their required-sets
716
+ // and null-fills can't drift.
717
+ const { required, nullable } = insertPlan(schema.tables[name]);
718
+ out[name] = { columns, pkNames: [...meta.primaryKey], types, required, nullable };
719
+ }
720
+ return out;
721
+ }
722
+
723
+ const quoteIdent = (name: string): string => `"${name.replace(/"/g, '""')}"`;
724
+
725
+ function tableMeta(render: RenderIndex, table: string): TableRenderMeta {
726
+ const meta = render[table];
727
+ if (!meta) {
728
+ const known = Object.keys(render);
729
+ throw new Error(
730
+ `logical mutator write to unknown table ${JSON.stringify(table)} — did you pass \`schema\` to createRindleApiServer? known tables: ${known.length ? known.join(", ") : "(none — no schema configured)"}`,
731
+ );
732
+ }
733
+ return meta;
734
+ }
735
+
736
+ /** Validate a keyed row against a table: reject unknown columns; require the pk columns; with
737
+ * `full`, require every NON-nullable column (a nullable column may be omitted and is filled with
738
+ * `NULL`, design 206 §6.2). Mirrors the client `trackingTx.checkColumns` messages so an author sees
739
+ * the same error on both tiers. */
740
+ function checkColumns(table: string, obj: KeyedRow, meta: TableRenderMeta, full: boolean): void {
741
+ const unknown = Object.keys(obj).filter((k) => !meta.columns.includes(k));
742
+ if (unknown.length) {
743
+ throw new Error(`unknown column${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} on ${table} — columns: ${meta.columns.join(", ")}`);
744
+ }
745
+ const required = full ? meta.required : meta.pkNames;
746
+ const missing = required.filter((c) => !(c in obj));
747
+ if (missing.length) {
748
+ throw new Error(`missing ${full ? "column" : "primary-key column"}${missing.length > 1 ? "s" : ""} ${missing.join(", ")} on ${table}`);
749
+ }
750
+ }
751
+
752
+ const encode = (dialect: SqlDialect, v: WireValue, type: ColType): WireValue =>
753
+ dialect.encodeValue ? dialect.encodeValue(v, type) : v;
754
+
755
+ /** Render one {@link MutationOp} to a `{sql, params}` for the dialect, or `null` for a no-op (an
756
+ * `update` whose row names only pk columns — nothing to SET, matching the client's no-op edit). */
757
+ export function renderOp(op: MutationOp, meta: TableRenderMeta, dialect: SqlDialect): SqlStatement | null {
758
+ const t = quoteIdent(op.table);
759
+ const params: WireValue[] = [];
760
+ // Bind a value and return its placeholder at the correct 1-based index (post-push length).
761
+ // `insertCell` fills NULL for an omitted nullable column on the insert arm (design 206 §6.2);
762
+ // on update/delete every bound column is guaranteed present, so it is a pass-through there.
763
+ // `toCell` stringifies a `json` object (a typed mutator passes the parsed object) — a string
764
+ // passes through, so an author may still pass pre-stringified json.
765
+ const bind = (c: string, row: KeyedRow): string => {
766
+ params.push(encode(dialect, toCell(insertCell(row, c), meta.types[c]), meta.types[c]));
767
+ return dialect.placeholder(params.length);
768
+ };
769
+
770
+ if (op.kind === "insert" || op.kind === "insertIgnore" || op.kind === "upsert") {
771
+ checkColumns(op.table, op.row, meta, true);
772
+ const cols = meta.columns;
773
+ const values = cols.map((c) => bind(c, op.row));
774
+ let sql = `INSERT INTO ${t} (${cols.map(quoteIdent).join(", ")}) VALUES (${values.join(", ")})`;
775
+ if (op.kind === "insertIgnore") {
776
+ sql += ` ON CONFLICT (${meta.pkNames.map(quoteIdent).join(", ")}) DO NOTHING`;
777
+ } else if (op.kind === "upsert") {
778
+ const nonPk = cols.filter((c) => !meta.pkNames.includes(c));
779
+ sql += ` ON CONFLICT (${meta.pkNames.map(quoteIdent).join(", ")}) `;
780
+ sql += nonPk.length
781
+ ? `DO UPDATE SET ${nonPk.map((c) => `${quoteIdent(c)} = excluded.${quoteIdent(c)}`).join(", ")}`
782
+ : `DO NOTHING`;
783
+ }
784
+ return { sql, params };
785
+ }
786
+
787
+ if (op.kind === "update") {
788
+ checkColumns(op.table, op.row, meta, false);
789
+ const setCols = meta.columns.filter((c) => !meta.pkNames.includes(c) && c in op.row);
790
+ if (!setCols.length) return null; // pk-only row → nothing to change (client no-op edit)
791
+ const setSql = setCols.map((c) => `${quoteIdent(c)} = ${bind(c, op.row)}`);
792
+ const whereSql = meta.pkNames.map((c) => `${quoteIdent(c)} = ${bind(c, op.row)}`);
793
+ return { sql: `UPDATE ${t} SET ${setSql.join(", ")} WHERE ${whereSql.join(" AND ")}`, params };
794
+ }
795
+
796
+ // delete
797
+ checkColumns(op.table, op.pk, meta, false);
798
+ const whereSql = meta.pkNames.map((c) => `${quoteIdent(c)} = ${bind(c, op.pk)}`);
799
+ return { sql: `DELETE FROM ${t} WHERE ${whereSql.join(" AND ")}`, params };
800
+ }
801
+
802
+ /** Render a point read (`tx.row`) — `SELECT <cols> FROM "T" WHERE <pk>` — for read-your-writes. */
803
+ export function renderPointRead(table: string, pk: KeyedRow, meta: TableRenderMeta, dialect: SqlDialect): SqlStatement {
804
+ checkColumns(table, pk, meta, false);
805
+ const params: WireValue[] = [];
806
+ const whereSql = meta.pkNames.map((c) => {
807
+ params.push(encode(dialect, pk[c], meta.types[c]));
808
+ return `${quoteIdent(c)} = ${dialect.placeholder(params.length)}`;
809
+ });
810
+ const cols = meta.columns.map(quoteIdent).join(", ");
811
+ return { sql: `SELECT ${cols} FROM ${quoteIdent(table)} WHERE ${whereSql.join(" AND ")}`, params };
812
+ }
813
+
814
+ /** Map a driver row (column-name keyed) to a {@link KeyedRow} over the table's known columns. */
815
+ function rowToKeyed(row: Record<string, unknown> | undefined, meta: TableRenderMeta): KeyedRow | undefined {
816
+ if (!row) return undefined;
817
+ const out: KeyedRow = {};
818
+ for (const c of meta.columns) out[c] = row[c] as WireValue;
819
+ return out;
820
+ }
821
+
822
+ // --------------------------------------------------------------------------- backends + server tx
823
+
824
+ /** Thrown (wrapping the driver error) by a server tx's DB calls, so the seam can tell an INFRA
825
+ * failure (retry) from a mutator-body throw (business rejection). */
826
+ export class BackendError extends Error {
827
+ readonly driverError: unknown;
828
+ constructor(driverError: unknown) {
829
+ super(driverError instanceof Error ? driverError.message : String(driverError));
830
+ this.name = "BackendError";
831
+ this.driverError = driverError;
832
+ }
833
+ }
834
+
835
+ /** The rindle/daemon server tx: logical writes render to SQLite and ACCUMULATE into one batch;
836
+ * raw `exec` accumulates too; `row` reads COMMITTED state through the daemon (no read-your-writes,
837
+ * the one interactive-txn limitation of the daemon backend). */
838
+ /** Build the compiler {@link Catalog} for ONE ast from the render index: columns/pk from the
839
+ * schema; relationship cardinality from the AST ITSELF — a Rindle relationship is declared at
840
+ * the query site (`sub(alias, rel)` / `.one()`), never on the schema, so the alias→cardinality
841
+ * map is inherently per-query. `columnTypes` are stubs: the sqlite dialect binds natives and
842
+ * never consults them (DAEMON-INTERACTIVE-TXN §5.4 — no casts). */
843
+ function catalogFor(render: RenderIndex, root: Ast): Catalog {
844
+ const tables: Record<string, TableSchema> = {};
845
+ const ensure = (table: string): TableSchema => {
846
+ const existing = tables[table];
847
+ if (existing) return existing;
848
+ const meta = tableMeta(render, table);
849
+ const columnTypes: Record<string, QueryColumnType> = {};
850
+ for (const c of meta.columns) columnTypes[c] = { type: "text", isEnum: false, isArray: false };
851
+ return (tables[table] = {
852
+ columns: [...meta.columns],
853
+ primaryKey: [...meta.pkNames],
854
+ columnTypes,
855
+ relationships: {},
856
+ });
857
+ };
858
+ const walkCondition = (cond: Condition | undefined): void => {
859
+ if (!cond) return;
860
+ if (cond.type === "and" || cond.type === "or") {
861
+ for (const c of cond.conditions) walkCondition(c);
862
+ } else if (cond.type === "correlatedSubquery") {
863
+ walkAst(cond.related.subquery);
864
+ }
865
+ };
866
+ const walkAst = (ast: Ast): void => {
867
+ const t = ensure(ast.table);
868
+ for (const rel of ast.related ?? []) {
869
+ const alias = rel.subquery.alias;
870
+ if (alias != null) t.relationships[alias] = rel.subquery.one === true ? "one" : "many";
871
+ walkAst(rel.subquery);
872
+ }
873
+ walkCondition(ast.where);
874
+ };
875
+ walkAst(root);
876
+ return { tables };
877
+ }
878
+
879
+ /** The control-flow unwind for a begin-absorbed replay (DAEMON-INTERACTIVE-TXN §4.1): thrown
880
+ * from the first read so the mutator body stops re-running an already-committed envelope; the
881
+ * backend answers with the tx's latched authoritative output. Never surfaces to users. */
882
+ class AbsorbedReplay extends Error {
883
+ constructor() {
884
+ super("mutation absorbed by mid dedup at session begin");
885
+ }
886
+ }
887
+
888
+ /**
889
+ * The daemon server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
890
+ * execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
891
+ * `/execute-sql-txn`, byte-identical to prior behavior — and LAZILY UPGRADES to an interactive
892
+ * mutation session at the mutator's first read: `begin` carries the envelope identity, the
893
+ * accumulated statement prefix (sound to replay — nothing before the first read observed DB
894
+ * state, §5.2), and the read itself, so a one-read mutator pays exactly one extra round trip.
895
+ * From then on reads run THROUGH the open transaction (read-your-writes — PG parity, and no
896
+ * read-then-write race) and writes buffer locally, flushing before the next read/commit: k
897
+ * reads cost k+2 round trips regardless of write count.
898
+ *
899
+ * Begin-time mid dedup can ABSORB the envelope (a redelivery whose commit response was lost):
900
+ * the replay output is latched on {@link DaemonLazyTx.absorbed} and {@link AbsorbedReplay}
901
+ * unwinds the body — the latch (not the throw) is authoritative, so a mutator that swallows
902
+ * the unwind still cannot re-apply (no session opened; buffered writes are never shipped).
903
+ * A daemon client without session support keeps the LEGACY committed-state point read.
904
+ */
905
+ class DaemonLazyTx implements ServerMutationTx {
906
+ /** Pre-upgrade: the accumulated batch/prefix. Post-upgrade: writes buffered for the next flush. */
907
+ private readonly stmts: SqlStatement[] = [];
908
+ private readonly render: RenderIndex;
909
+ private readonly daemon: RindleDaemonClient;
910
+ private readonly envelope: MutationEnvelope;
911
+ private sessionId?: string;
912
+ /** The begin-absorbed replay output (§4.1), latched for the backend. */
913
+ absorbed?: SqlTxnOutput;
914
+ idempotencyKey?: string;
915
+
916
+ constructor(render: RenderIndex, daemon: RindleDaemonClient, envelope: MutationEnvelope) {
917
+ this.render = render;
918
+ this.daemon = daemon;
919
+ this.envelope = envelope;
920
+ }
921
+
922
+ /** True once the tx upgraded to an interactive session (the backend then commits it). */
923
+ get session(): boolean {
924
+ return this.sessionId !== undefined;
925
+ }
926
+
927
+ get statements(): readonly SqlStatement[] {
928
+ return this.stmts;
929
+ }
930
+
931
+ exec(sql: string, params: WireValue[] = []): void {
932
+ this.stmts.push({ sql, params });
933
+ }
934
+
935
+ private push(op: MutationOp): Promise<void> {
936
+ const rendered = renderOp(op, tableMeta(this.render, op.table), sqliteDialect);
937
+ if (rendered) this.stmts.push(rendered);
938
+ return Promise.resolve();
939
+ }
940
+
941
+ insert(table: string, row: KeyedRow): Promise<void> {
942
+ return this.push({ kind: "insert", table, row });
943
+ }
944
+ update(table: string, row: KeyedRow): Promise<void> {
945
+ return this.push({ kind: "update", table, row });
946
+ }
947
+ upsert(table: string, row: KeyedRow): Promise<void> {
948
+ return this.push({ kind: "upsert", table, row });
949
+ }
950
+ insertIgnore(table: string, row: KeyedRow): Promise<void> {
951
+ return this.push({ kind: "insertIgnore", table, row });
952
+ }
953
+ delete(table: string, pk: KeyedRow): Promise<void> {
954
+ return this.push({ kind: "delete", table, pk });
955
+ }
956
+
957
+ async row(table: string, pk: KeyedRow): Promise<KeyedRow | undefined> {
958
+ const read = renderPointRead(table, pk, tableMeta(this.render, table), sqliteDialect);
959
+ const out = await this.readThroughTxn(read);
960
+ const cells = out.rows[0];
961
+ if (!cells) return undefined;
962
+ const keyed: KeyedRow = {}; // the daemon returns positional cells — zip with `cols`
963
+ out.cols.forEach((c, i) => (keyed[c] = cells[i]));
964
+ return keyed;
965
+ }
966
+
967
+ /** A full-shape read inside the open transaction (§5.4): compile to ONE SQLite `SELECT`
968
+ * (native bind params, no casts — SQLite is the canonical store), ride the session like
969
+ * `row` (including the lazy upgrade / begin ride-along), parse the single JSON cell. */
970
+ async query(q: Ast | Query<any, any, any>): Promise<unknown> {
971
+ const ast = typeof (q as Query<any, any, any>).ast === "function" ? (q as Query<any, any, any>).ast() : (q as Ast);
972
+ const compiled = compileQueryAst(ast, catalogFor(this.render, ast), { dialect: "sqlite" });
973
+ const out = await this.readThroughTxn({ sql: compiled.sql, params: compiled.params as WireValue[] });
974
+ const cell = out.rows[0]?.[0];
975
+ if (typeof cell !== "string") return ast.one === true ? null : [];
976
+ return JSON.parse(cell) as unknown;
977
+ }
978
+
979
+ /** Run one read: upgrade to a session at the first (§5.1), ride the open one after, or fall
980
+ * back to the legacy committed-state read when the daemon client lacks sessions. */
981
+ private async readThroughTxn(read: SqlStatement): Promise<SqlReadOutput> {
982
+ if (this.absorbed) throw new AbsorbedReplay();
983
+ if (!this.daemon.beginMutationSession) {
984
+ try {
985
+ return await this.daemon.executeSqlRead({ sql: read.sql, params: read.params });
986
+ } catch (err) {
987
+ throw new BackendError(err);
988
+ }
989
+ }
990
+ try {
991
+ if (this.sessionId === undefined) {
992
+ const opened = await this.daemon.beginMutationSession({
993
+ clientID: this.envelope.clientID,
994
+ mid: this.envelope.mid,
995
+ statements: this.stmts.splice(0),
996
+ query: read,
997
+ });
998
+ if (opened.absorbed) {
999
+ const { absorbed: _a, sessionId: _s, read: _r, ...output } = opened;
1000
+ this.absorbed = output as SqlTxnOutput;
1001
+ throw new AbsorbedReplay();
1002
+ }
1003
+ if (!opened.sessionId || !opened.read) {
1004
+ throw new Error(`malformed mutate-session begin reply: ${JSON.stringify(opened)}`);
1005
+ }
1006
+ this.sessionId = opened.sessionId;
1007
+ return opened.read;
1008
+ }
1009
+ await this.flush();
1010
+ return await this.daemon.queryInMutationSession!({
1011
+ sessionId: this.sessionId,
1012
+ sql: read.sql,
1013
+ params: read.params,
1014
+ });
1015
+ } catch (err) {
1016
+ if (err instanceof AbsorbedReplay || err instanceof BackendError) throw err;
1017
+ throw new BackendError(err);
1018
+ }
1019
+ }
1020
+
1021
+ /** Ship buffered writes into the open session, order-preserving; a no-op when none pend. */
1022
+ private async flush(): Promise<void> {
1023
+ if (this.stmts.length === 0) return;
1024
+ await this.daemon.execInMutationSession!({
1025
+ sessionId: this.sessionId!,
1026
+ statements: this.stmts.splice(0),
1027
+ });
1028
+ }
1029
+
1030
+ /** Flush + commit the open session — the daemon stamps lmid co-transactionally (§4.4) and
1031
+ * answers the same shape `/execute-sql-txn` does. */
1032
+ async commitSession(): Promise<SqlTxnOutput> {
1033
+ try {
1034
+ await this.flush();
1035
+ return await this.daemon.commitMutationSession!({ sessionId: this.sessionId! });
1036
+ } catch (err) {
1037
+ throw err instanceof BackendError ? err : new BackendError(err);
1038
+ }
1039
+ }
1040
+
1041
+ /** Best-effort rollback (the daemon's deadline is the backstop). MUST be awaited before a
1042
+ * follow-up `/reject-mutation`: that lmid-only commit needs the writer this session holds. */
1043
+ async rollbackSessionQuietly(): Promise<void> {
1044
+ if (this.sessionId === undefined) return;
1045
+ const sessionId = this.sessionId;
1046
+ this.sessionId = undefined;
1047
+ try {
1048
+ await this.daemon.rollbackMutationSession!({ sessionId });
1049
+ } catch {
1050
+ // Unreachable daemon / already-expired session: the deadline rollback covers it.
1051
+ }
1052
+ }
1053
+ }
1054
+
1055
+ /** The Postgres server tx: a REAL interactive transaction. Logical writes render to `$n` and run
1056
+ * LIVE against the open txn; raw `exec` runs live too (after `rewrite`); `row` reads the open txn
1057
+ * (read-your-writes). Ops append to an internally-serialized chain so order holds even when a legacy
1058
+ * sync mutator does not `await`; the backend drains the chain (`settle`) before the lmid upsert. */
1059
+ class PgLiveTx implements ServerMutationTx {
1060
+ private chain: Promise<void> = Promise.resolve();
1061
+ private readonly stmts: SqlStatement[] = [];
1062
+ private readonly q: PgQuery;
1063
+ private readonly render: RenderIndex;
1064
+ private readonly rewrite: (sql: string) => string;
1065
+ idempotencyKey?: string;
1066
+
1067
+ constructor(q: PgQuery, render: RenderIndex, rewrite: (sql: string) => string) {
1068
+ this.q = q;
1069
+ this.render = render;
1070
+ this.rewrite = rewrite;
1071
+ }
1072
+
1073
+ get statements(): readonly SqlStatement[] {
1074
+ return this.stmts;
1075
+ }
1076
+
1077
+ settle(): Promise<void> {
1078
+ return this.chain;
1079
+ }
1080
+
1081
+ private execLive(sql: string, params: WireValue[]): void {
1082
+ this.chain = this.chain.then(async () => {
1083
+ try {
1084
+ await this.q.exec(sql, params as unknown[]);
1085
+ } catch (err) {
1086
+ throw new BackendError(err);
1087
+ }
1088
+ });
1089
+ }
1090
+
1091
+ exec(sql: string, params: WireValue[] = []): void {
1092
+ const stmt = { sql: this.rewrite(sql), params };
1093
+ this.stmts.push(stmt);
1094
+ this.execLive(stmt.sql, params);
1095
+ }
1096
+
1097
+ private write(op: MutationOp): Promise<void> {
1098
+ let rendered: SqlStatement | null;
1099
+ try {
1100
+ rendered = renderOp(op, tableMeta(this.render, op.table), postgresDialect);
1101
+ } catch (err) {
1102
+ return Promise.reject(err); // a validation error (business rejection), synchronous shape
1103
+ }
1104
+ if (rendered) this.execLive(rendered.sql, rendered.params ?? []);
1105
+ return this.chain;
1106
+ }
1107
+
1108
+ insert(table: string, row: KeyedRow): Promise<void> {
1109
+ return this.write({ kind: "insert", table, row });
1110
+ }
1111
+ update(table: string, row: KeyedRow): Promise<void> {
1112
+ return this.write({ kind: "update", table, row });
1113
+ }
1114
+ upsert(table: string, row: KeyedRow): Promise<void> {
1115
+ return this.write({ kind: "upsert", table, row });
1116
+ }
1117
+ insertIgnore(table: string, row: KeyedRow): Promise<void> {
1118
+ return this.write({ kind: "insertIgnore", table, row });
1119
+ }
1120
+ delete(table: string, pk: KeyedRow): Promise<void> {
1121
+ return this.write({ kind: "delete", table, pk });
1122
+ }
1123
+ async row(table: string, pk: KeyedRow): Promise<KeyedRow | undefined> {
1124
+ const meta = tableMeta(this.render, table);
1125
+ const read = renderPointRead(table, pk, meta, postgresDialect); // validates before draining
1126
+ await this.settle(); // read-your-writes: drain queued writes first
1127
+ let rows: Array<Record<string, unknown>>;
1128
+ try {
1129
+ rows = await this.q.query(read.sql, read.params as unknown[]);
1130
+ } catch (err) {
1131
+ throw new BackendError(err);
1132
+ }
1133
+ return rowToKeyed(rows[0], meta);
1134
+ }
1135
+
1136
+ query(): Promise<unknown> {
1137
+ // The compiler's postgres dialect ships (@rindle/query-compiler); what remains is the §7
1138
+ // static-catalog + driver-pin wiring (POSTGRES-READ-COMPILER-DESIGN.md Phase B).
1139
+ return Promise.reject(
1140
+ new Error(
1141
+ "tx.query is not wired on the Postgres backend yet (POSTGRES-READ-COMPILER-DESIGN.md Phase B) — use tx.row for point reads meanwhile",
1142
+ ),
1143
+ );
1144
+ }
1145
+ }
1146
+
401
1147
  /**
402
- * Today's behavior as a {@link MutationBackend}: ship the statements + envelope to the daemon,
403
- * whose `/execute-sql-txn` path stamps `lmid` co-transactionally (and `/reject-mutation` advances
404
- * it past a rejected mid). This is the default backend existing users never see a change.
1148
+ * The default {@link MutationBackend}. A pure-write mutator keeps the historical shape: writes
1149
+ * ACCUMULATE and ship as ONE batch to `/execute-sql-txn`, which stamps `lmid` co-transactionally
1150
+ * (and `/reject-mutation` advances it past a rejected mid) — byte-identical behavior. A
1151
+ * READ-bearing mutator lazily upgrades to an interactive mutation session
1152
+ * (DAEMON-INTERACTIVE-TXN-DESIGN.md): reads are read-your-writes through the open transaction
1153
+ * (PG parity), the commit stamps `lmid` in the same atomic unit, and a begin-absorbed replay
1154
+ * short-circuits without re-running the body.
405
1155
  */
406
1156
  export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
407
1157
  return {
408
- apply({ envelope, statements, idempotencyKey }) {
409
- const txn: SqlTxn = { statements: [...statements], clientID: envelope.clientID, mid: envelope.mid };
410
- if (idempotencyKey !== undefined) txn.idempotencyKey = idempotencyKey;
411
- return daemon.executeSqlTxn(txn);
1158
+ 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) {
1180
+ 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;
1185
+ }
1186
+ }
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) };
412
1190
  },
413
1191
  reject({ envelope, reason }) {
414
1192
  return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
@@ -458,16 +1236,42 @@ ON CONFLICT ("client_id") DO UPDATE
458
1236
  */
459
1237
  export function postgresBackend(plugger: PostgresPlugger, opts: PostgresBackendOptions = {}): MutationBackend {
460
1238
  const rewrite = opts.rewriteSql ?? ((sql: string) => sql);
461
- const applyInTxn = (envelope: MutationEnvelope, statements: readonly SqlStatement[]): Promise<SqlTxnOutput> =>
1239
+ // A tagged business rejection escaping `plugger.transaction` the plugger rolls the data back on
1240
+ // any throw; this marker distinguishes "mutator said no" (reject) from an infra failure (rethrow).
1241
+ class RejectSignal extends Error {}
1242
+ const lmidOnly = (envelope: MutationEnvelope): Promise<SqlTxnOutput> =>
462
1243
  plugger.transaction(async (q) => {
463
- for (const s of statements) await q.exec(rewrite(s.sql), (s.params ?? []) as unknown[]);
464
- // ALWAYS — accepted or rejected — in this same transaction (§2.2, §2.4).
465
1244
  await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
466
1245
  return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
467
1246
  });
468
1247
  return {
469
- apply: ({ envelope, statements }) => applyInTxn(envelope, statements),
470
- reject: ({ envelope }) => applyInTxn(envelope, []),
1248
+ dialect: postgresDialect,
1249
+ async runMutation({ envelope, render, run }) {
1250
+ try {
1251
+ const output = await plugger.transaction(async (q) => {
1252
+ const tx = new PgLiveTx(q, render, rewrite);
1253
+ try {
1254
+ await run(tx);
1255
+ await tx.settle(); // drain any un-awaited queued writes before the lmid stamp
1256
+ } catch (err) {
1257
+ if (err instanceof BackendError) throw err; // infra → rollback + propagate
1258
+ throw new RejectSignal(errMessage(err)); // business → rollback data, tag for §2.4
1259
+ }
1260
+ // ALWAYS on the accepted path, SAME transaction (§2.2): the lmid upsert commits with data.
1261
+ await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
1262
+ return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
1263
+ });
1264
+ return { accepted: true, output };
1265
+ } catch (err) {
1266
+ if (err instanceof RejectSignal) {
1267
+ // Data rolled back; STILL advance lmid alone (§2.4 — the client's queue must drain).
1268
+ return { accepted: false, reason: err.message, output: await lmidOnly(envelope) };
1269
+ }
1270
+ if (err instanceof BackendError) throw err.driverError; // infra
1271
+ throw err;
1272
+ }
1273
+ },
1274
+ reject: ({ envelope }) => lmidOnly(envelope).then(() => undefined),
471
1275
  };
472
1276
  }
473
1277
 
@@ -595,6 +1399,38 @@ export function defineApiMutators<User, M extends ApiMutators<User>>(mutators: M
595
1399
  return mutators;
596
1400
  }
597
1401
 
1402
+ /**
1403
+ * Bulk-register a SHARED (generator) mutator registry as server mutators — the mutator twin of
1404
+ * {@link registerQueries} (which does the same for co-located `defineQuery` values). Each shared
1405
+ * mutator carries its own arg validator (`shared(schema, gen)`), so this wraps every one with the
1406
+ * UNIVERSAL server triad and nothing else: parse the UNTRUSTED wire args (its `.args`), map the server
1407
+ * {@link MutationContext} to the shared {@link MutatorCtx} principal, and drive the SAME body the
1408
+ * client predicts ({@link runSharedMutation}). The point is that a shared mutator whose server run
1409
+ * adds NO authority beyond that triad needs no hand-written wrapper.
1410
+ *
1411
+ * Server-only AUTHORITY the client cannot predict (a title guard, an owner-gated cascade, a
1412
+ * `NOT EXISTS` dedup) stays an explicit {@link ApiMutator} that OVERRIDES the auto-wrapped default —
1413
+ * spread this first, then the overrides win by key:
1414
+ *
1415
+ * ```ts
1416
+ * mutators: defineApiMutators({
1417
+ * ...sharedApiMutators(sharedMutators, (ctx) => ({ user: requireUser(ctx.user) })),
1418
+ * createIssue: withTitleGuard(sharedMutators.createIssue), // + server-only policy
1419
+ * deleteIssue: async (tx, raw, ctx) => { ... }, // raw owner-gated cascade
1420
+ * }),
1421
+ * ```
1422
+ */
1423
+ export function sharedApiMutators<User>(
1424
+ registry: Record<string, SharedMutatorWithArgs<any>>,
1425
+ principal: (ctx: MutationContext<User>) => MutatorCtx,
1426
+ ): ApiMutators<User> {
1427
+ const out: ApiMutators<User> = {};
1428
+ for (const [name, mutator] of Object.entries(registry)) {
1429
+ out[name] = (tx, raw, ctx) => runSharedMutation(mutator, mutator.args.parse(raw), principal(ctx), tx);
1430
+ }
1431
+ return out;
1432
+ }
1433
+
598
1434
  export function queryResultToAst(result: ApiQueryResult): Ast {
599
1435
  if (result && typeof result === "object" && "ast" in result && typeof result.ast === "function") {
600
1436
  return result.ast();
@@ -704,6 +1540,10 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
704
1540
  const mode = opts.mode ?? "normalized";
705
1541
  // The mutation seam — defaulting to the daemon's co-transactional lmid stamp (unchanged behavior).
706
1542
  const backend = opts.backend ?? daemonBackend(opts.daemon);
1543
+ // Schema-derived render metadata for logical mutator writes; `{}` when no schema is configured (a
1544
+ // logical op then throws loudly — the tx never silently drops a write). Each backend renders in its
1545
+ // own dialect (`backend.dialect`: daemon→sqlite, postgres→postgres).
1546
+ const renderIndex: RenderIndex = opts.schema ? buildRenderIndex(opts.schema) : {};
707
1547
  // Names that are ALSO configured pins — a lease for one is forced to a `pinned` policy (the lazy
708
1548
  // floor, §4.1) so the first viewer to route to a follower warms it for late joiners.
709
1549
  const pinnedNames = new Set((opts.pinnedQueries ?? []).map((p) => p.name));
@@ -777,31 +1617,34 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
777
1617
  const pushMutation = async (input: PushMutationRequest<User>): Promise<PushMutationResponse> => {
778
1618
  const context: ApiContext<User> = { user: input.user, request: input.request };
779
1619
  const mutator = opts.mutators?.[input.envelope.name];
1620
+ // PRE-FLIGHT rejections — no txn, no data, `lmid` alone (the queue must still drain).
780
1621
  if (!mutator) return reject(backend, input.envelope, `unknown mutator: ${input.envelope.name}`);
781
- let sql: Pick<SqlTxn, "statements" | "idempotencyKey">;
782
1622
  try {
783
1623
  await assertAuthorized(opts.authorizeMutation, {
784
1624
  user: input.user,
785
1625
  envelope: input.envelope,
786
1626
  context,
787
1627
  });
788
- const tx = new CollectingSqlMutationTx();
789
- const result = await mutator(tx, input.envelope.args as never, {
790
- user: input.user,
791
- envelope: input.envelope,
792
- daemon: opts.daemon,
793
- request: input.request,
794
- });
795
- sql = mutationResultToSqlTxn(result, tx);
796
1628
  } catch (err) {
797
- return reject(backend, input.envelope, String((err as Error)?.message ?? err));
1629
+ return reject(backend, input.envelope, errMessage(err));
798
1630
  }
799
- const output = await backend.apply({
1631
+ // Run the mutator INSIDE the backend's transaction. A throw from the mutator body is a business
1632
+ // rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
1633
+ const outcome = await backend.runMutation({
800
1634
  envelope: input.envelope,
801
- statements: sql.statements,
802
- idempotencyKey: sql.idempotencyKey,
1635
+ render: renderIndex,
1636
+ run: async (tx) => {
1637
+ const result = await mutator(tx, input.envelope.args as never, {
1638
+ user: input.user,
1639
+ envelope: input.envelope,
1640
+ daemon: opts.daemon,
1641
+ request: input.request,
1642
+ });
1643
+ applyResultToTx(result, tx);
1644
+ },
803
1645
  });
804
- return { accepted: true, rejected: false, output };
1646
+ if (outcome.accepted) return { accepted: true, rejected: false, output: outcome.output };
1647
+ return { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
805
1648
  };
806
1649
 
807
1650
  const pushMutations = async (input: PushMutationsRequest<User>): Promise<PushMutationResponse[]> => {
@@ -859,13 +1702,18 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
859
1702
  }
860
1703
  };
861
1704
 
862
- // The room write-authority gate (§5.3.1): endpoints are disabled until the app
863
- // opts in with `authorizeRoom` hosting an authority is never a default.
1705
+ // The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
1706
+ // the `realtime` block (which also activates `/room-boot`) or the deprecated bare
1707
+ // `authorizeRoom` (trio only). Hosting an authority is never a default.
1708
+ const realtime = opts.realtime;
1709
+ const roomAuthorizer: Authorizer<ApiContext<User>> | undefined = realtime
1710
+ ? (realtime.authorize ?? defaultFlushGate(realtime.shellSecret))
1711
+ : opts.authorizeRoom;
864
1712
  const roomGate = async (context: ApiContext<User>): Promise<void> => {
865
- if (!opts.authorizeRoom) {
1713
+ if (!roomAuthorizer) {
866
1714
  throw new RindleApiError("forbidden", "room authority not configured", 403);
867
1715
  }
868
- await assertAuthorized(opts.authorizeRoom, context);
1716
+ await assertAuthorized(roomAuthorizer, context);
869
1717
  };
870
1718
 
871
1719
  // The store's verdict rides specific statuses + body shapes (fence / conflict /
@@ -930,6 +1778,58 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
930
1778
  return daemonVerdict(e);
931
1779
  }
932
1780
  },
1781
+ handleRoomBootJson: async (body, context) => {
1782
+ if (!realtime) {
1783
+ throw new RindleApiError("forbidden", "realtime not configured", 403);
1784
+ }
1785
+ await assertAuthorized(realtime.authorizeBoot ?? defaultBootGate(realtime.shellSecret), context);
1786
+ const msg = parseObject(body, "room-boot request");
1787
+ const doc = parseString(msg.doc, "doc");
1788
+ if (msg.instance !== undefined) parseString(msg.instance, "instance"); // diagnostic identity only
1789
+ const ast = queryResultToAst(await realtime.resolveFootprint(doc, context));
1790
+ const claim = opts.daemon.claimRoomEpoch?.bind(opts.daemon);
1791
+ if (!claim) {
1792
+ throw new Error("the configured daemon client does not implement claimRoomEpoch");
1793
+ }
1794
+ try {
1795
+ // Claim FIRST (§2.5): the lease below is minted for THIS placement, so a boot
1796
+ // that loses the epoch race learns it here, before any materialization exists.
1797
+ const { epoch } = await claim({ doc });
1798
+ const lease = await opts.daemon.materialize({
1799
+ ast,
1800
+ // The room's upstream leg IS the normalized protocol (§3) — never the app's
1801
+ // viewer `mode`.
1802
+ mode: "normalized",
1803
+ leaseTtlMs: realtime.upstreamLeaseTtlMs ?? opts.leaseTtlMs,
1804
+ });
1805
+ const headers = realtime.mintFlushHeaders
1806
+ ? await realtime.mintFlushHeaders({ doc, epoch })
1807
+ : {
1808
+ [ROOM_FLUSH_CREDENTIAL_HEADER]: await mintRoomFlushCredential({
1809
+ shellSecret: realtime.shellSecret,
1810
+ doc,
1811
+ epoch,
1812
+ }),
1813
+ };
1814
+ const res: RoomBootResponse = {
1815
+ epoch,
1816
+ upstreamLeaseToken: lease.leaseToken,
1817
+ flush: {
1818
+ urls: {
1819
+ apply: routes.applyRowChangeTxn,
1820
+ claim: routes.claimRoomEpoch,
1821
+ lmids: routes.roomLmids,
1822
+ },
1823
+ headers,
1824
+ },
1825
+ };
1826
+ const upstreamWsEndpoint = realtime.upstreamWsEndpoint ?? lease.wsEndpoint;
1827
+ if (upstreamWsEndpoint !== undefined) res.upstreamWsEndpoint = upstreamWsEndpoint;
1828
+ return { status: 200, body: res };
1829
+ } catch (e) {
1830
+ return daemonVerdict(e);
1831
+ }
1832
+ },
933
1833
  handleQueryJson: (body, context) => {
934
1834
  const msg = parseObject(body, "query request");
935
1835
  return createQueryLease({
@@ -965,24 +1865,65 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
965
1865
  };
966
1866
  }
967
1867
 
968
- class CollectingSqlMutationTx implements SqlMutationTx {
969
- private readonly stmts: SqlStatement[] = [];
970
-
971
- get statements(): readonly SqlStatement[] {
972
- return this.stmts;
973
- }
1868
+ /** Run a SHARED generator mutator (the SAME body the client predicts) against a live server
1869
+ * transaction (MUTATORS-ISOMORPHIC): bind the tier-agnostic {@link isoTx} factory and drive it —
1870
+ * each yielded logical op renders + runs against `tx` (dialect SQL, per backend), each `tx.row`
1871
+ * suspends for read-your-writes, and `tx.all` fans out. A server mutator uses this to delegate its
1872
+ * write body after parsing untrusted args and applying its server-only authority (principal, policy).
1873
+ * A mutator-body throw remains a business rejection; a DB failure propagates as infra. */
1874
+ export function runSharedMutation<Args, Ctx extends MutatorCtx>(
1875
+ mutator: SharedMutator<Args, Ctx>,
1876
+ args: Args,
1877
+ ctx: Ctx,
1878
+ tx: ServerMutationTx,
1879
+ ): Promise<void> {
1880
+ return driveMutationAsync(mutator(isoTx, args, ctx), {
1881
+ apply: (op) => applyOpToServerTx(tx, op),
1882
+ read: (table, pk) => tx.row(table, pk),
1883
+ query: async (q) => {
1884
+ // The shared-seam contract is ALWAYS an array of rows: the client's `WriteTxn.query` returns
1885
+ // an array even for a root `.one()` (the root unwrap is the Store's `materialize()`, not the
1886
+ // in-mutator read — rust/src/wasm/db.rs). The server's `tx.query` returns a scalar (object|null)
1887
+ // for a `.one()` root, so normalize it to `[]`/`[row]` here — one body sees the same shape on
1888
+ // both tiers. (Postgres backend: `tx.query` rejects until POSTGRES-READ-COMPILER-DESIGN.md
1889
+ // Phase B; the daemon/SQLite backend compiles + rides the open session — DAEMON §5.4.)
1890
+ const ast = q.ast();
1891
+ const res = await tx.query(ast);
1892
+ if (ast.one === true) return res == null ? [] : [res as QueryResultRow];
1893
+ return (res ?? []) as QueryResultRow[];
1894
+ },
1895
+ });
1896
+ }
974
1897
 
975
- exec(sql: string, params: WireValue[] = []): void {
976
- this.stmts.push({ sql, params });
1898
+ /** Run one logical {@link MutationOp} (yielded by a shared generator mutator) against the live server
1899
+ * write surface — the SAME async methods a plain async mutator calls (they render dialect SQL and
1900
+ * execute/accumulate per backend). */
1901
+ function applyOpToServerTx(tx: ServerWriteTx, op: MutationOp): Promise<void> {
1902
+ switch (op.kind) {
1903
+ case "insert":
1904
+ return tx.insert(op.table, op.row);
1905
+ case "upsert":
1906
+ return tx.upsert(op.table, op.row);
1907
+ case "insertIgnore":
1908
+ return tx.insertIgnore(op.table, op.row);
1909
+ case "update":
1910
+ return tx.update(op.table, op.row);
1911
+ case "delete":
1912
+ return tx.delete(op.table, op.pk);
977
1913
  }
978
1914
  }
979
1915
 
980
- function mutationResultToSqlTxn(result: ApiMutatorResult, tx: SqlMutationTx): Pick<SqlTxn, "statements" | "idempotencyKey"> {
981
- if (Array.isArray(result)) return { statements: result };
982
- if (result && typeof result === "object" && "statements" in result) {
983
- return { statements: result.statements, idempotencyKey: result.idempotencyKey };
1916
+ /** Feed a mutator's RETURNED result (the alternative to calling `tx.exec`/logical ops directly) into
1917
+ * the backend tx: a returned `SqlStatement[]` / `SqlTxn` is exec'd onto `tx`, and a carried
1918
+ * `idempotencyKey` is stashed (the daemon backend honors it; PG ignores it). A `void` return is a
1919
+ * no-op the mutator already drove the tx. Preserves the pre-existing return-style contract. */
1920
+ function applyResultToTx(result: ApiMutatorResult, tx: ServerMutationTx): void {
1921
+ if (!result) return;
1922
+ const statements = Array.isArray(result) ? result : result.statements;
1923
+ for (const s of statements) tx.exec(s.sql, s.params);
1924
+ if (!Array.isArray(result) && result.idempotencyKey !== undefined) {
1925
+ (tx as { idempotencyKey?: string }).idempotencyKey = result.idempotencyKey;
984
1926
  }
985
- return { statements: [...tx.statements] };
986
1927
  }
987
1928
 
988
1929
  async function assertAuthorized<T>(authorizer: Authorizer<T> | undefined, input: T): Promise<void> {
@@ -991,6 +1932,66 @@ async function assertAuthorized<T>(authorizer: Authorizer<T> | undefined, input:
991
1932
  if (result === false) throw new RindleApiError("forbidden", "rindle request forbidden", 403);
992
1933
  }
993
1934
 
1935
+ /** The default flush-trio gate: verify the default epoch-bound credential from the request
1936
+ * header. The two refusal messages are deliberately distinct — "missing" is a transport wiring
1937
+ * bug (no `context.request`), "refused" is an invalid credential. */
1938
+ function defaultFlushGate<User>(shellSecret: string): Authorizer<ApiContext<User>> {
1939
+ return async (context) => {
1940
+ const credential = requestHeader(context.request, ROOM_FLUSH_CREDENTIAL_HEADER);
1941
+ if (!credential) {
1942
+ throw new RindleApiError(
1943
+ "forbidden",
1944
+ `missing ${ROOM_FLUSH_CREDENTIAL_HEADER} header — is the transport passing its incoming request as context.request?`,
1945
+ 403,
1946
+ );
1947
+ }
1948
+ try {
1949
+ await verifyRoomFlushCredential(credential, shellSecret);
1950
+ } catch (e) {
1951
+ throw new RindleApiError("forbidden", `flush credential refused: ${errMessage(e)}`, 403);
1952
+ }
1953
+ };
1954
+ }
1955
+
1956
+ /** The default `/room-boot` gate: `Authorization: Bearer <shell secret>` (README contract),
1957
+ * compared constant-time. */
1958
+ function defaultBootGate<User>(shellSecret: string): Authorizer<ApiContext<User>> {
1959
+ return (context) => {
1960
+ const auth = requestHeader(context.request, "authorization");
1961
+ const bearer = auth && /^bearer\s/i.test(auth) ? auth.replace(/^bearer\s+/i, "") : undefined;
1962
+ if (!bearer || !timingSafeEqualStr(bearer, shellSecret)) {
1963
+ throw new RindleApiError("forbidden", "room-boot: shell secret refused", 403);
1964
+ }
1965
+ };
1966
+ }
1967
+
1968
+ /** Best-effort header extraction from whatever the transport put in `context.request`: a Fetch
1969
+ * `Request` (`headers.get`), a node `IncomingMessage` (lowercased header map), or a plain
1970
+ * `{headers: {...}}`. `undefined` when there is no request or no such header. */
1971
+ function requestHeader(request: unknown, name: string): string | undefined {
1972
+ if (!request || typeof request !== "object") return undefined;
1973
+ const headers = (request as { headers?: unknown }).headers;
1974
+ if (!headers || typeof headers !== "object") return undefined;
1975
+ if (typeof (headers as { get?: unknown }).get === "function") {
1976
+ return (headers as { get(n: string): string | null }).get(name) ?? undefined;
1977
+ }
1978
+ const map = headers as Record<string, unknown>;
1979
+ const v = map[name.toLowerCase()] ?? map[name];
1980
+ if (typeof v === "string") return v;
1981
+ if (Array.isArray(v) && typeof v[0] === "string") return v[0];
1982
+ return undefined;
1983
+ }
1984
+
1985
+ /** Constant-time string equality (a length mismatch fails fast — length is not the secret). */
1986
+ function timingSafeEqualStr(a: string, b: string): boolean {
1987
+ const ab = new TextEncoder().encode(a);
1988
+ const bb = new TextEncoder().encode(b);
1989
+ if (ab.length !== bb.length) return false;
1990
+ let diff = 0;
1991
+ for (let i = 0; i < ab.length; i++) diff |= ab[i] ^ bb[i];
1992
+ return diff === 0;
1993
+ }
1994
+
994
1995
  async function resolvePolicy<User>(
995
1996
  policy: RindleApiServerOptions<User>["materializationPolicy"],
996
1997
  input: QueryLeaseRequest<User>,