@rindle/api-server 0.6.3 → 0.7.0

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,
@@ -155,10 +163,24 @@ export interface MutationContext<User> {
155
163
  request?: unknown;
156
164
  }
157
165
 
158
- /** The legacy raw-SQL escape hatch (still the surface for relational/authority statements a keyed op
159
- * can't express an owner-gated cascade, a `NOT EXISTS` dedup). `exec` accumulates on the daemon
160
- * 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. */
161
182
  export interface SqlMutationTx {
183
+ readonly sql: ServerSql;
162
184
  exec(sql: string, params?: WireValue[]): void;
163
185
  readonly statements: readonly SqlStatement[];
164
186
  }
@@ -166,17 +188,17 @@ export interface SqlMutationTx {
166
188
  /** The write handle a server mutator runs against — the ASYNC twin of the client's `MutationTx`. It
167
189
  * is both the isomorphic {@link ServerWriteTx} logical surface (insert/update/upsert/insertIgnore/
168
190
  * delete/row, rendered to dialect SQL) AND the legacy {@link SqlMutationTx} raw escape hatch. Both
169
- * backends run reads through the OPEN transaction (read-your-writes): the Postgres backend executes
170
- * everything live; the daemon backend accumulates writes and lazily upgrades to an interactive
171
- * 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). */
172
194
  export interface ServerMutationTx extends ServerWriteTx, SqlMutationTx {
173
195
  /** Run a full query (a fluent `Query` or its wire `Ast`) INSIDE the open transaction —
174
196
  * read-your-writes, like {@link ServerWriteTx.row} but for arbitrary shapes. Returns the
175
197
  * parsed nested result tree: an array for a plural root, an object or `null` for a `.one()`
176
198
  * root, with cells in their raw SQLite storage-class representations (the same vocabulary
177
- * `row` speaks). Daemon backend: compiled by `@rindle/query-compiler`'s sqlite dialect (bind
178
- * params, NO casts — §5.4) and executed through the mutation session. Postgres backend: lands
179
- * 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). */
180
202
  query(q: Ast | Query<any, any, any>): Promise<unknown>;
181
203
  }
182
204
 
@@ -223,6 +245,10 @@ export class MutationRejected extends Error {
223
245
  * `transact`, or a swallowed {@link MutationRejected} still advances `lmid` and never wedges the
224
246
  * client's pending queue. */
225
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;
226
252
  /** Open the ONE atomic write transaction and drive `body` inside it, committing (stamping `lmid`
227
253
  * co-transactionally) on a clean return. MAY be called at most once — a second call throws.
228
254
  *
@@ -308,10 +334,9 @@ export interface QueryLeaseRequest<User> {
308
334
 
309
335
  /**
310
336
  * The room-serve block on a query lease (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1 step 5 / §2.4,
311
- * slice G-iv-b): present only when the named query carries a realtime label AND its resolved AST
312
- * was PROVABLY covered by its room profile's footprint (the daemon's `/cover-check`). The G-v
313
- * client uses it to open the room transport for THIS query beside — never instead of — its daemon
314
- * session.
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.
315
340
  *
316
341
  * It is a dedicated block on purpose: the ROOM ws is a SEPARATE connection this query opens beside
317
342
  * its daemon session (never a migration of the daemon session — the daemon ws host is fixed and
@@ -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
  }
@@ -771,11 +799,30 @@ export async function verifyRoomFlushCredential(
771
799
  return payload;
772
800
  }
773
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
+
774
812
  export interface RindleApiServerOptions<User> {
775
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;
776
823
  /** Where mutations are applied and `lmid` is stamped ({@link MutationBackend}). Default:
777
- * `daemonBackend(daemon)` exactly today's behavior. Pass `postgresBackend(...)` when
778
- * 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. */
779
826
  backend?: MutationBackend;
780
827
  /** The typed schema (`createSchema`/`refineSchema`). Required only when a mutator uses the LOGICAL
781
828
  * write vocabulary (`tx.insert`/`update`/`upsert`/`insertIgnore`/`delete`/`row`) — it drives the
@@ -845,24 +892,15 @@ export interface RindleApiServerOptions<User> {
845
892
 
846
893
  export interface RindleApiServer<User> {
847
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;
848
898
  createQueryLease(input: QueryLeaseRequest<User>): Promise<QueryLeaseResponse>;
849
899
  /** (Re-)materialize every `pinnedQueries` entry with a pinned policy. Idempotent — the daemon
850
900
  * dedupes by canonical query, so a re-assert reuses the existing materialization. Call it at
851
901
  * startup and whenever the daemon restarts (e.g. from the daemon-client `onBootId` hook), since
852
902
  * the daemon holds no durable materialization state. No-op when `pinnedQueries` is empty. */
853
903
  assertPins(): Promise<void>;
854
- /** The room-serving coverage DIAGNOSTIC (G-iv-b; the `assertPins`-style explicit check): for
855
- * every realtime-labeled query, resolve it (under `pinUser`, per exemplar args), resolve its
856
- * profile's footprint, and run the REAL daemon coverage check — the same verdict the lease
857
- * path serves by. Returns every verdict; `strict` throws when any labeled query is not
858
- * provably covered (deploy-gate mode). Deliberately ignores `locateRoom`/`roomTokenKey` — it
859
- * answers "would this query be coverable", not "is serving fully wired". */
860
- validateRealtime(opts?: {
861
- /** Per-query exemplar args (a query is checked once per exemplar; default: one `null`). */
862
- exemplars?: Partial<Record<string, readonly unknown[]>>;
863
- /** Throw when any labeled query is uncovered (instead of just reporting). */
864
- strict?: boolean;
865
- }): Promise<ValidateRealtimeReport>;
866
904
  pushMutation(input: PushMutationRequest<User>): Promise<PushMutationResponse>;
867
905
  /** Apply an in-order batch (the client mutation queue's flush). Envelopes run strictly
868
906
  * sequentially; a rejection still advances the daemon's lmid, so later envelopes in the
@@ -901,24 +939,6 @@ export interface RindleApiServer<User> {
901
939
  handleRoomBootJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
902
940
  }
903
941
 
904
- /** One {@link RindleApiServer.validateRealtime} verdict: a labeled query × exemplar args. */
905
- export interface ValidateRealtimeVerdict {
906
- query: string;
907
- profile: string;
908
- args: unknown;
909
- covered: boolean;
910
- /** Why it is not covered (uncovered verdicts only) — the daemon's reason strings, the
911
- * aggregate refusal, or a resolve/footprint error message. */
912
- reasons?: string[];
913
- }
914
-
915
- /** The {@link RindleApiServer.validateRealtime} report. */
916
- export interface ValidateRealtimeReport {
917
- verdicts: ValidateRealtimeVerdict[];
918
- /** The uncovered subset of `verdicts` (what `strict` throws on). */
919
- uncovered: ValidateRealtimeVerdict[];
920
- }
921
-
922
942
  export type RindleApiErrorCode = "bad-request" | "forbidden" | "not-found" | "rejected";
923
943
 
924
944
  export class RindleApiError extends Error {
@@ -1013,13 +1033,6 @@ export class SplitDaemonClient implements RindleDaemonClient {
1013
1033
  if (!lmids) return Promise.reject(new Error("the write master lacks roomLmids"));
1014
1034
  return lmids(input);
1015
1035
  }
1016
- // Pure computation hosted by rindled: keep it on the read/follower leg. The write master owns
1017
- // room durability, not query-cover analysis.
1018
- coverQuery(input: CoverQueryInput): Promise<CoverQueryOutput> {
1019
- const cover = this.reads.coverQuery?.bind(this.reads);
1020
- if (!cover) return Promise.reject(new Error("the read follower lacks coverQuery"));
1021
- return cover(input);
1022
- }
1023
1036
  migrate(input: MigrateInput): Promise<MigrateOutput> {
1024
1037
  return this.writes.migrate(input);
1025
1038
  }
@@ -1200,9 +1213,6 @@ export class BackendError extends Error {
1200
1213
  }
1201
1214
  }
1202
1215
 
1203
- /** The rindle/daemon server tx: logical writes render to SQLite and ACCUMULATE into one batch;
1204
- * raw `exec` accumulates too; `row` reads COMMITTED state through the daemon (no read-your-writes,
1205
- * the one interactive-txn limitation of the daemon backend). */
1206
1216
  /** Build the compiler {@link Catalog} for ONE ast from the render index: columns/pk from the
1207
1217
  * schema; relationship cardinality from the AST ITSELF — a Rindle relationship is declared at
1208
1218
  * the query site (`sub(alias, rel)` / `.one()`), never on the schema, so the alias→cardinality
@@ -1256,6 +1266,9 @@ class AbsorbedReplay extends Error {
1256
1266
  const MUTATOR_CONFLICT_MAX_ATTEMPTS = 5;
1257
1267
 
1258
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
+ }
1259
1272
  if (!(error instanceof DaemonHttpError) || error.status !== 409) return false;
1260
1273
  try {
1261
1274
  const body = JSON.parse(error.body) as { code?: unknown; retryable?: unknown };
@@ -1271,10 +1284,296 @@ async function mutatorConflictBackoff(attempt: number): Promise<void> {
1271
1284
  await new Promise<void>((resolve) => setTimeout(resolve, millis));
1272
1285
  }
1273
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
+
1274
1573
  /**
1275
- * 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
1276
1575
  * execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
1277
- * `/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
1278
1577
  * mutation session at the mutator's first read: `begin` carries the envelope identity, the
1279
1578
  * accumulated statement prefix (sound to replay — nothing before the first read observed DB
1280
1579
  * state, §5.2), and the read itself, so a one-read mutator pays exactly one extra round trip.
@@ -1283,31 +1582,44 @@ async function mutatorConflictBackoff(attempt: number): Promise<void> {
1283
1582
  * reads cost k+2 round trips regardless of write count.
1284
1583
  *
1285
1584
  * Begin-time mid dedup can ABSORB the envelope (a redelivery whose commit response was lost):
1286
- * 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}
1287
1586
  * unwinds the body — the latch (not the throw) is authoritative, so a mutator that swallows
1288
1587
  * the unwind still cannot re-apply (no session opened; buffered writes are never shipped).
1289
1588
  * A daemon client without session support keeps the LEGACY committed-state point read.
1290
1589
  */
1291
- class DaemonLazyTx implements ServerMutationTx {
1590
+ class RemoteLazyTx implements ServerMutationTx {
1292
1591
  /** Pre-upgrade: the accumulated batch/prefix. Post-upgrade: writes buffered for the next flush. */
1293
1592
  private readonly stmts: SqlStatement[] = [];
1294
1593
  private readonly render: RenderIndex;
1295
- private readonly daemon: RindleDaemonClient;
1594
+ private readonly transport: MutationTransport;
1296
1595
  private readonly envelope: MutationEnvelope;
1297
- private sessionId?: string;
1596
+ private sessionHandle?: unknown;
1597
+ readonly sql: ServerSql;
1298
1598
  /** The begin-absorbed replay output (§4.1), latched for the backend. */
1299
1599
  absorbed?: SqlTxnOutput;
1300
1600
  idempotencyKey?: string;
1301
1601
 
1302
- constructor(render: RenderIndex, daemon: RindleDaemonClient, envelope: MutationEnvelope) {
1602
+ constructor(render: RenderIndex, transport: MutationTransport, envelope: MutationEnvelope) {
1303
1603
  this.render = render;
1304
- this.daemon = daemon;
1604
+ this.transport = transport;
1305
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
+ };
1306
1618
  }
1307
1619
 
1308
1620
  /** True once the tx upgraded to an interactive session (the backend then commits it). */
1309
1621
  get session(): boolean {
1310
- return this.sessionId !== undefined;
1622
+ return this.sessionHandle !== undefined;
1311
1623
  }
1312
1624
 
1313
1625
  get statements(): readonly SqlStatement[] {
@@ -1315,12 +1627,21 @@ class DaemonLazyTx implements ServerMutationTx {
1315
1627
  }
1316
1628
 
1317
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
+ }
1318
1636
  this.stmts.push({ sql, params });
1319
1637
  }
1320
1638
 
1321
1639
  private push(op: MutationOp): Promise<void> {
1322
1640
  const rendered = renderOp(op, tableMeta(this.render, op.table), sqliteDialect);
1323
- 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
+ }
1324
1645
  return Promise.resolve();
1325
1646
  }
1326
1647
 
@@ -1362,42 +1683,47 @@ class DaemonLazyTx implements ServerMutationTx {
1362
1683
  return JSON.parse(cell) as unknown;
1363
1684
  }
1364
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
+
1365
1691
  /** Run one read: upgrade to a session at the first (§5.1), ride the open one after, or fall
1366
1692
  * back to the legacy committed-state read when the daemon client lacks sessions. */
1367
1693
  private async readThroughTxn(read: SqlStatement): Promise<SqlReadOutput> {
1368
1694
  if (this.absorbed) throw new AbsorbedReplay();
1369
- 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) {
1370
1701
  try {
1371
- return await this.daemon.executeSqlRead({ sql: read.sql, params: read.params });
1702
+ return await this.transport.readCommitted(read);
1372
1703
  } catch (err) {
1373
1704
  throw new BackendError(err);
1374
1705
  }
1375
1706
  }
1376
1707
  try {
1377
- if (this.sessionId === undefined) {
1378
- const opened = await this.daemon.beginMutationSession({
1379
- clientID: this.envelope.clientID,
1380
- mid: this.envelope.mid,
1708
+ if (this.sessionHandle === undefined) {
1709
+ const opened = await this.transport.begin({
1710
+ envelope: this.envelope,
1381
1711
  statements: this.stmts.splice(0),
1382
1712
  query: read,
1713
+ ...(this.idempotencyKey !== undefined ? { idempotencyKey: this.idempotencyKey } : {}),
1383
1714
  });
1384
1715
  if (opened.absorbed) {
1385
- const { absorbed: _a, sessionId: _s, read: _r, ...output } = opened;
1386
- this.absorbed = output as SqlTxnOutput;
1716
+ this.absorbed = opened.absorbed;
1387
1717
  throw new AbsorbedReplay();
1388
1718
  }
1389
- if (!opened.sessionId || !opened.read) {
1719
+ if (opened.handle === undefined || !opened.read) {
1390
1720
  throw new Error(`malformed mutate-session begin reply: ${JSON.stringify(opened)}`);
1391
1721
  }
1392
- this.sessionId = opened.sessionId;
1722
+ this.sessionHandle = opened.handle;
1393
1723
  return opened.read;
1394
1724
  }
1395
1725
  await this.flush();
1396
- return await this.daemon.queryInMutationSession!({
1397
- sessionId: this.sessionId,
1398
- sql: read.sql,
1399
- params: read.params,
1400
- });
1726
+ return await this.transport.query(this.sessionHandle, read);
1401
1727
  } catch (err) {
1402
1728
  if (err instanceof AbsorbedReplay || err instanceof BackendError) throw err;
1403
1729
  throw new BackendError(err);
@@ -1407,10 +1733,7 @@ class DaemonLazyTx implements ServerMutationTx {
1407
1733
  /** Ship buffered writes into the open session, order-preserving; a no-op when none pend. */
1408
1734
  private async flush(): Promise<void> {
1409
1735
  if (this.stmts.length === 0) return;
1410
- await this.daemon.execInMutationSession!({
1411
- sessionId: this.sessionId!,
1412
- statements: this.stmts.splice(0),
1413
- });
1736
+ await this.transport.exec(this.sessionHandle!, this.stmts.splice(0));
1414
1737
  }
1415
1738
 
1416
1739
  /** Flush + commit the open session — the daemon stamps lmid co-transactionally (§4.4) and
@@ -1418,7 +1741,11 @@ class DaemonLazyTx implements ServerMutationTx {
1418
1741
  async commitSession(): Promise<SqlTxnOutput> {
1419
1742
  try {
1420
1743
  await this.flush();
1421
- 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;
1422
1749
  } catch (err) {
1423
1750
  throw err instanceof BackendError ? err : new BackendError(err);
1424
1751
  }
@@ -1427,11 +1754,11 @@ class DaemonLazyTx implements ServerMutationTx {
1427
1754
  /** Best-effort rollback (the daemon's deadline is the backstop). MUST be awaited before a
1428
1755
  * follow-up `/reject-mutation`: that lmid-only commit needs the writer this session holds. */
1429
1756
  async rollbackSessionQuietly(): Promise<void> {
1430
- if (this.sessionId === undefined) return;
1431
- const sessionId = this.sessionId;
1432
- this.sessionId = undefined;
1757
+ if (this.sessionHandle === undefined) return;
1758
+ const sessionHandle = this.sessionHandle;
1759
+ this.sessionHandle = undefined;
1433
1760
  try {
1434
- await this.daemon.rollbackMutationSession!({ sessionId });
1761
+ await this.transport.rollback(sessionHandle);
1435
1762
  } catch {
1436
1763
  // Unreachable daemon / already-expired session: the deadline rollback covers it.
1437
1764
  }
@@ -1448,12 +1775,27 @@ class PgLiveTx implements ServerMutationTx {
1448
1775
  private readonly q: PgQuery;
1449
1776
  private readonly render: RenderIndex;
1450
1777
  private readonly rewrite: (sql: string) => string;
1778
+ readonly sql: ServerSql;
1451
1779
  idempotencyKey?: string;
1452
1780
 
1453
1781
  constructor(q: PgQuery, render: RenderIndex, rewrite: (sql: string) => string) {
1454
1782
  this.q = q;
1455
1783
  this.render = render;
1456
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
+ };
1457
1799
  }
1458
1800
 
1459
1801
  get statements(): readonly SqlStatement[] {
@@ -1528,25 +1870,30 @@ class PgLiveTx implements ServerMutationTx {
1528
1870
  ),
1529
1871
  );
1530
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
+ }
1531
1882
  }
1532
1883
 
1533
- /**
1534
- * The default {@link MutationBackend}. A pure-write mutator keeps the historical shape: writes
1535
- * ACCUMULATE and ship as ONE batch to `/execute-sql-txn`, which stamps `lmid` co-transactionally
1536
- * (and `/reject-mutation` advances it past a rejected mid) — byte-identical behavior. A
1537
- * READ-bearing mutator lazily upgrades to an interactive mutation session
1538
- * (DAEMON-INTERACTIVE-TXN-DESIGN.md): reads are read-your-writes through the open transaction
1539
- * (PG parity), the commit stamps `lmid` in the same atomic unit, and a begin-absorbed replay
1540
- * short-circuits without re-running the body.
1541
- */
1542
- 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 {
1543
1889
  return {
1544
1890
  dialect: sqliteDialect,
1891
+ outsideSql,
1545
1892
  async runMutation(input) {
1546
1893
  for (let attempt = 0; attempt < MUTATOR_CONFLICT_MAX_ATTEMPTS; attempt++) {
1547
1894
  try {
1548
1895
  const { envelope, render, run } = input;
1549
- const tx = new DaemonLazyTx(render, daemon, envelope);
1896
+ const tx = new RemoteLazyTx(render, transport, envelope);
1550
1897
  try {
1551
1898
  await run(tx);
1552
1899
  } catch (err) {
@@ -1561,7 +1908,7 @@ export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
1561
1908
  // Data first, watermark second: rollback releases this session's connection before
1562
1909
  // the lmid-only rejection commit.
1563
1910
  await tx.rollbackSessionQuietly();
1564
- const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1911
+ const output = await transport.reject({ envelope, reason });
1565
1912
  return { accepted: false, reason, output };
1566
1913
  }
1567
1914
  if (tx.absorbed) return { accepted: true, output: tx.absorbed };
@@ -1573,9 +1920,14 @@ export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
1573
1920
  throw err;
1574
1921
  }
1575
1922
  }
1576
- const txn: SqlTxn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
1577
- if (tx.idempotencyKey !== undefined) txn.idempotencyKey = tx.idempotencyKey;
1578
- return { accepted: true, output: await daemon.executeSqlTxn(txn) };
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
+ };
1579
1931
  } catch (error) {
1580
1932
  if (!isRetryableCommitConflict(error) || attempt + 1 === MUTATOR_CONFLICT_MAX_ATTEMPTS) {
1581
1933
  throw error;
@@ -1586,11 +1938,24 @@ export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
1586
1938
  throw new Error("unreachable mutator conflict retry loop");
1587
1939
  },
1588
1940
  reject({ envelope, reason }) {
1589
- return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1941
+ return transport.reject({ envelope, reason });
1590
1942
  },
1591
1943
  };
1592
1944
  }
1593
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
+
1594
1959
  /** The query surface a {@link PostgresPlugger} transaction exposes. `exec` runs one statement;
1595
1960
  * `query` returns rows keyed by column name (read-your-own-writes inside the txn). */
1596
1961
  export interface PgQuery {
@@ -1641,8 +2006,27 @@ export function postgresBackend(plugger: PostgresPlugger, opts: PostgresBackendO
1641
2006
  await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
1642
2007
  return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
1643
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
+ };
1644
2027
  return {
1645
2028
  dialect: postgresDialect,
2029
+ outsideSql,
1646
2030
  async runMutation({ envelope, render, run }) {
1647
2031
  try {
1648
2032
  const output = await plugger.transaction(async (q) => {
@@ -1973,6 +2357,44 @@ function normalizeCondition(c: Condition): unknown {
1973
2357
  }
1974
2358
  }
1975
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
+
1976
2398
  /**
1977
2399
  * The runtime {@link MutationScope} handed to a {@link ScopedMutator}. It owns the single atomic
1978
2400
  * transaction (delegating to {@link MutationBackend.runMutation} — the exact machinery a tx-form
@@ -1990,6 +2412,7 @@ class MutationScopeImpl implements MutationScope {
1990
2412
  private readonly backend: MutationBackend;
1991
2413
  private readonly envelope: MutationEnvelope;
1992
2414
  private readonly render: RenderIndex;
2415
+ readonly sql: ServerSql;
1993
2416
  /** Set once `transact` resolved through the backend (accepted OR business-rejected). */
1994
2417
  outcome?: MutationOutcome;
1995
2418
  /** The value the backend threw on INFRA (the DB failed) — always propagated, never an `lmid`
@@ -2009,6 +2432,7 @@ class MutationScopeImpl implements MutationScope {
2009
2432
  this.backend = backend;
2010
2433
  this.envelope = envelope;
2011
2434
  this.render = render;
2435
+ this.sql = scopedOutsideSql(backend.outsideSql);
2012
2436
  }
2013
2437
 
2014
2438
  transact(
@@ -2058,8 +2482,24 @@ class MutationScopeImpl implements MutationScope {
2058
2482
  export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptions<User>): RindleApiServer<User> {
2059
2483
  const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
2060
2484
  const mode = opts.mode ?? "normalized";
2061
- // The mutation seam defaulting to the daemon's co-transactional lmid stamp (unchanged behavior).
2062
- 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
+ }
2063
2503
  // Schema-derived render metadata for logical mutator writes; `{}` when no schema is configured (a
2064
2504
  // logical op then throws loudly — the tx never silently drops a write). Each backend renders in its
2065
2505
  // own dialect (`backend.dialect`: daemon→sqlite, postgres→postgres).
@@ -2120,20 +2560,13 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2120
2560
  );
2121
2561
  };
2122
2562
 
2123
- // The §2.3 aggregate refusal: the client's aggregate overlay is daemon-gated until post-G, so
2124
- // an aggregate/reduce-shaped query is refused room-serving REGARDLESS of coverage.
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.
2125
2568
  const AGGREGATE_REFUSAL =
2126
- "the query AST contains an aggregate/reduce shape — aggregate overlays are daemon-gated until post-G";
2127
-
2128
- // Verdict cache. The verdict is a pure function of exactly two inputs — the resolved footprint
2129
- // AST and the resolved query AST — so the tightest SOUND key is those two ASTs themselves
2130
- // (stable-stringified), scoped by (queryName, profile) for legibility. Args/user/ctx need no
2131
- // separate slot precisely because anything that changes the verdict must change one of the two
2132
- // ASTs (predicate literals embed the args; ctx-scoped queries embed the principal); keying on
2133
- // `(name, args)` alone would ALIAS two users' different ASTs under one verdict — unsound.
2134
- // Bounded FIFO (Map iterates in insertion order) so per-user literals can't grow it forever.
2135
- const coverVerdicts = new Map<string, CoverQueryOutput>();
2136
- const COVER_VERDICT_CACHE_MAX = 1024;
2569
+ "the query contains an aggregate/reduce shape — room-served aggregates are not yet supported (the room gate drops `__agg` rows)";
2137
2570
 
2138
2571
  const maybeRoomServe = async (
2139
2572
  input: QueryLeaseRequest<User>,
@@ -2152,13 +2585,6 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2152
2585
  warnRoomServeOnce(input.name, profile.name, ["realtime.locateRoom is not configured"]);
2153
2586
  return undefined;
2154
2587
  }
2155
- const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
2156
- if (coverQuery === undefined) {
2157
- warnRoomServeOnce(input.name, profile.name, [
2158
- "the configured daemon client does not implement coverQuery (/cover-check)",
2159
- ]);
2160
- return undefined;
2161
- }
2162
2588
  const tokenKey = realtime.roomTokenKey;
2163
2589
  if (tokenKey === undefined) {
2164
2590
  warnRoomServeOnce(input.name, profile.name, [
@@ -2187,25 +2613,14 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2187
2613
  const footprintAst = queryResultToAst(await profile.footprint(key, context));
2188
2614
  assertUnwindowedFootprint(footprintAst, profile.name);
2189
2615
 
2190
- const verdictKey = `${input.name}\u0000${profile.name}\u0000${stableStringify(footprintAst)}\u0000${stableStringify(queryAst)}`;
2191
- let verdict = coverVerdicts.get(verdictKey);
2192
- if (verdict === undefined) {
2193
- verdict = astHasAggregate(queryAst)
2194
- ? { covered: false, reasons: [AGGREGATE_REFUSAL] }
2195
- : await coverQuery({ footprint: footprintAst, query: queryAst });
2196
- // (A coverQuery THROW never lands here — the outer catch fail-opens without caching, so
2197
- // a transient daemon failure doesn't pin an uncovered verdict.)
2198
- if (coverVerdicts.size >= COVER_VERDICT_CACHE_MAX) {
2199
- coverVerdicts.delete(coverVerdicts.keys().next().value as string);
2200
- }
2201
- coverVerdicts.set(verdictKey, verdict);
2202
- }
2203
- if (!verdict.covered) {
2204
- warnRoomServeOnce(input.name, profile.name, verdict.reasons ?? ["not provably covered"]);
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]);
2205
2620
  return undefined;
2206
2621
  }
2207
2622
 
2208
- // Covered ⇒ assemble the realtime block. The room endpoint rides ITS OWN field
2623
+ // Assemble the realtime block. The room endpoint rides ITS OWN field
2209
2624
  // (`realtime.wsEndpoint`) — a separate connection from the daemon session's fixed ws host.
2210
2625
  const { wsEndpoint } = await realtime.locateRoom(doc);
2211
2626
  const now = Date.now();
@@ -2236,7 +2651,8 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2236
2651
  tables,
2237
2652
  };
2238
2653
  } catch (e) {
2239
- // 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.
2240
2656
  warnRoomServeOnce(input.name, profile.name, [`room-serve failed: ${errMessage(e)}`]);
2241
2657
  return undefined;
2242
2658
  }
@@ -2501,12 +2917,12 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2501
2917
  const res = queryLeaseResponse(out);
2502
2918
  // I-iv (§4.1): the occupancy step FIRST — session sweep+upsert, then the D6 gate verdict. A
2503
2919
  // closed gate suppresses the room-serve ONLY (the lease ships without the realtime block,
2504
- // indistinguishable from an uncovered query — the fail-open daemon path) while the doorbell
2920
+ // indistinguishable from a non-room-served query — the daemon path) while the doorbell
2505
2921
  // below still rides; lifecycle-off ⇒ `gateOpen: true` unconditionally and this line is inert.
2506
2922
  const occ = await lifecycleOccupancy(input);
2507
- // 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
2508
2924
  // above is unconditional (and its fields untouched) — room-serving only ever adds a field,
2509
- // so an uncovered/unwired/legacy lease stays byte-identical and nothing here can block one.
2925
+ // so a non-room-served/legacy lease stays byte-identical and nothing here can block one.
2510
2926
  const rt = occ.gateOpen ? await maybeRoomServe(input, ast, context, subject) : undefined;
2511
2927
  if (rt !== undefined) res.realtime = rt;
2512
2928
  // I-v (§4.2): the gate CLOSED and a room plausibly hosted this scope — drain it and ride the
@@ -2700,57 +3116,6 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2700
3116
  }
2701
3117
  };
2702
3118
 
2703
- // The explicit coverage diagnostic (the assertPins pattern: system-level, resolved under
2704
- // `pinUser`, per-query failures collected — never strand the rest). It runs the REAL check —
2705
- // the daemon's /cover-check on the actually-resolved ASTs — so its verdicts are exactly the
2706
- // lease path's, minus the serving wiring (locateRoom/roomTokenKey), which it deliberately
2707
- // ignores: it answers "is this labeled query coverable", the deployable-config question.
2708
- const validateRealtime = async (vopts?: {
2709
- exemplars?: Partial<Record<string, readonly unknown[]>>;
2710
- strict?: boolean;
2711
- }): Promise<ValidateRealtimeReport> => {
2712
- const context: ApiContext<User> = { user: opts.pinUser as User, request: undefined };
2713
- const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
2714
- const verdicts: ValidateRealtimeVerdict[] = [];
2715
- for (const [name, q] of Object.entries(opts.queries ?? {})) {
2716
- const label = queryRealtimeLabel(q);
2717
- if (label === undefined) continue;
2718
- const profile = roomProfiles.get(label.room);
2719
- if (profile === undefined) continue; // unreachable: construction asserted it exists
2720
- for (const args of vopts?.exemplars?.[name] ?? [null]) {
2721
- const verdict: ValidateRealtimeVerdict = { query: name, profile: profile.name, args, covered: false };
2722
- try {
2723
- const ast = await resolveAst(name, args, context);
2724
- const roomArgs = label.args !== undefined ? label.args(args) : args;
2725
- const footprintAst = queryResultToAst(await profile.footprint(profile.key(roomArgs), context));
2726
- assertUnwindowedFootprint(footprintAst, profile.name);
2727
- if (astHasAggregate(ast)) {
2728
- verdict.reasons = [AGGREGATE_REFUSAL];
2729
- } else if (coverQuery === undefined) {
2730
- verdict.reasons = ["the configured daemon client does not implement coverQuery (/cover-check)"];
2731
- } else {
2732
- const out = await coverQuery({ footprint: footprintAst, query: ast });
2733
- verdict.covered = out.covered;
2734
- if (!out.covered) verdict.reasons = out.reasons ?? ["not provably covered"];
2735
- }
2736
- } catch (e) {
2737
- verdict.reasons = [errMessage(e)];
2738
- }
2739
- verdicts.push(verdict);
2740
- }
2741
- }
2742
- const uncovered = verdicts.filter((v) => !v.covered);
2743
- if (vopts?.strict && uncovered.length > 0) {
2744
- throw new Error(
2745
- `validateRealtime: ${uncovered.length} labeled query verdict(s) not provably covered — ` +
2746
- uncovered
2747
- .map((v) => `${v.query} (profile "${v.profile}"): ${(v.reasons ?? []).join("; ")}`)
2748
- .join(" | "),
2749
- );
2750
- }
2751
- return { verdicts, uncovered };
2752
- };
2753
-
2754
3119
  // The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
2755
3120
  // the `realtime` block (which also activates `/room-boot`) or the deprecated bare
2756
3121
  // `authorizeRoom` (trio only). Hosting an authority is never a default.
@@ -2815,10 +3180,10 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
2815
3180
 
2816
3181
  return {
2817
3182
  routes,
3183
+ close: () => ownedSql?.close(),
2818
3184
  createQueryLease,
2819
3185
  readQuery,
2820
3186
  assertPins,
2821
- validateRealtime,
2822
3187
  pushMutation,
2823
3188
  pushMutations,
2824
3189
  handleApplyRowChangeTxnJson: async (body, context) => {
@@ -3005,8 +3370,9 @@ function applyOpToServerTx(tx: ServerWriteTx, op: MutationOp): Promise<void> {
3005
3370
 
3006
3371
  /** Feed a mutator's RETURNED result (the alternative to calling `tx.exec`/logical ops directly) into
3007
3372
  * the backend tx: a returned `SqlStatement[]` / `SqlTxn` is exec'd onto `tx`, and a carried
3008
- * `idempotencyKey` is stashed (the daemon backend honors it; PG ignores it). A `void` return is a
3009
- * 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. */
3010
3376
  function applyResultToTx(result: ApiMutatorResult, tx: ServerMutationTx): void {
3011
3377
  if (!result) return;
3012
3378
  const statements = Array.isArray(result) ? result : result.statements;
@@ -3141,7 +3507,7 @@ const ROOM_MUTATION_OUTCOMES_TABLE = "_rindle_room_mutation_outcomes";
3141
3507
  // delta fanning to every subscribed solo client; no clientID/mid — a system write must never
3142
3508
  // advance an lmid — and no idempotencyKey — a renewal's re-upsert must re-run, that is the
3143
3509
  // refresh), and the count is one `executeSqlRead` with `consistency: "strong"` — the read surface
3144
- // the api-server already has against the daemon (the `DaemonLazyTx` fallback precedent above).
3510
+ // the api-server already has against the daemon (the `RemoteLazyTx` fallback precedent above).
3145
3511
  // "strong" routes the read to the WRITE MASTER in a split deploy, which just serialized our
3146
3512
  // upsert: read-your-writes without a mutation session (the interactive-txn machinery is optional
3147
3513
  // on the daemon interface and far heavier than this two-round-trip pair needs).
@@ -3220,19 +3586,6 @@ function docClientAst(table: string, doc: string, clientId: string | undefined):
3220
3586
  * lease through the api-server, never an extension of this token. */
3221
3587
  const DEFAULT_ROOM_TOKEN_TTL_MS = 5 * 60_000;
3222
3588
 
3223
- /** Deterministic JSON: object keys sorted recursively, so two structurally identical ASTs from
3224
- * independent resolves stringify identically (the verdict-cache key). */
3225
- function stableStringify(v: unknown): string {
3226
- return JSON.stringify(v, (_key, value: unknown) => {
3227
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
3228
- const rec = value as Record<string, unknown>;
3229
- const sorted: Record<string, unknown> = {};
3230
- for (const k of Object.keys(rec).sort()) sorted[k] = rec[k];
3231
- return sorted;
3232
- }
3233
- return value;
3234
- });
3235
- }
3236
3589
 
3237
3590
  /** Does the AST contain an aggregate/reduce shape ANYWHERE (root, a `related` subquery, or an
3238
3591
  * `EXISTS` child)? Room-serving refuses these regardless of coverage: the client's aggregate