@rindle/api-server 0.5.0 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { driveMutationAsync, insertCell, insertPlan, isGeneratorMutator, isoTx, toCell } from "@rindle/client";
2
2
  import { DaemonHttpError } from "@rindle/daemon-client";
3
+ import { createSqlClient, encodeSqlValue, RindleSqlError } from "@rindle/sql-client";
3
4
  import { compile as compileQueryAst } from "@rindle/query-compiler";
4
5
  import { assertLabeledProfilesExist, assertUnwindowedFootprint, attachRealtimeLabel, compileRoomProfiles, compileRoomScopeSpecs, compileRoomTableSpecs, mintRoomDoc, queryRealtimeLabel, queryResultToAst, splitRoomDoc, } from "./rooms.js";
5
6
  let roomTokenModule;
@@ -248,18 +249,10 @@ export class SplitDaemonClient {
248
249
  return Promise.reject(new Error("the write master lacks roomLmids"));
249
250
  return lmids(input);
250
251
  }
251
- // Pure computation, but routed to the MASTER like the rest of the room ops: the master is a
252
- // real rindled that hosts `/cover-check`; the read router may not proxy it.
253
- coverQuery(input) {
254
- const cover = this.writes.coverQuery?.bind(this.writes);
255
- if (!cover)
256
- return Promise.reject(new Error("the write master lacks coverQuery"));
257
- return cover(input);
258
- }
259
252
  migrate(input) {
260
253
  return this.writes.migrate(input);
261
254
  }
262
- // reads → the router (it stamps `wsEndpoint` onto the outputs)
255
+ // reads → the fleet (one FLEET_URL follower; the affinity ticket + Fly edge place the machine)
263
256
  materialize(input) {
264
257
  return this.reads.materialize(input);
265
258
  }
@@ -390,9 +383,6 @@ export class BackendError extends Error {
390
383
  this.driverError = driverError;
391
384
  }
392
385
  }
393
- /** The rindle/daemon server tx: logical writes render to SQLite and ACCUMULATE into one batch;
394
- * raw `exec` accumulates too; `row` reads COMMITTED state through the daemon (no read-your-writes,
395
- * the one interactive-txn limitation of the daemon backend). */
396
386
  /** Build the compiler {@link Catalog} for ONE ast from the render index: columns/pk from the
397
387
  * schema; relationship cardinality from the AST ITSELF — a Rindle relationship is declared at
398
388
  * the query site (`sub(alias, rel)` / `.one()`), never on the schema, so the alias→cardinality
@@ -447,10 +437,268 @@ class AbsorbedReplay extends Error {
447
437
  super("mutation absorbed by mid dedup at session begin");
448
438
  }
449
439
  }
440
+ const MUTATOR_CONFLICT_MAX_ATTEMPTS = 5;
441
+ function isRetryableCommitConflict(error) {
442
+ if (error instanceof RindleSqlError) {
443
+ return error.status === 409 && (error.code === "retryable-conflict" || error.code === "TRANSACTION_CONFLICT");
444
+ }
445
+ if (!(error instanceof DaemonHttpError) || error.status !== 409)
446
+ return false;
447
+ try {
448
+ const body = JSON.parse(error.body);
449
+ return body.code === "retryable-conflict" && body.retryable === true;
450
+ }
451
+ catch {
452
+ return false;
453
+ }
454
+ }
455
+ async function mutatorConflictBackoff(attempt) {
456
+ const ceiling = Math.min(32, 2 ** attempt);
457
+ const millis = ceiling + Math.floor(Math.random() * 4);
458
+ await new Promise((resolve) => setTimeout(resolve, millis));
459
+ }
460
+ /** True when this error is the SQL codec refusing a bind value outright (`undefined`, `Date`, `NaN`,
461
+ * a binary view, an out-of-i64 bigint) rather than a transport or database failure. */
462
+ function isUnencodableBind(error) {
463
+ return error instanceof RindleSqlError && error.code === "VALUE_UNSUPPORTED";
464
+ }
465
+ /** Refuse an unencodable bind at the point the MUTATOR supplies it, so it surfaces as a BUSINESS
466
+ * rejection (lmid advances, the browser retires its prediction) instead of an infrastructure
467
+ * failure. Left as infra it is retried forever against a deterministic mutator, which wedges the
468
+ * client's mutation queue behind a poison message.
469
+ *
470
+ * Only the SQL transport needs this: the legacy daemon encoder is JSON, which silently coerces the
471
+ * same values (`undefined`/`NaN` -> null, `Date` -> an ISO string). Asserting there would invent a
472
+ * failure that the wire does not actually have. */
473
+ function assertEncodableParams(sql, params) {
474
+ if (params === undefined)
475
+ return;
476
+ for (let index = 0; index < params.length; index++) {
477
+ try {
478
+ encodeSqlValue(params[index]);
479
+ }
480
+ catch (error) {
481
+ if (!isUnencodableBind(error))
482
+ throw error;
483
+ throw new Error(`bind ${index} of \`${sql}\` cannot be stored: ${errMessage(error)}`);
484
+ }
485
+ }
486
+ }
487
+ /** Leading keywords the SQL mutation surface structurally REFUSES inside a mutator's write batch: a
488
+ * read (`SELECT`/`EXPLAIN`), transaction control, a connection `PRAGMA`, or DDL. None can begin a
489
+ * valid mutation write, so refusing them has no false positives — a `WITH`-prefixed statement is
490
+ * deliberately absent because it may resolve to either a read or a write, and the server stays the
491
+ * authority for that case. */
492
+ const MUTATION_REFUSED_LEADING_KEYWORDS = new Set([
493
+ "SELECT", "EXPLAIN", "VALUES",
494
+ "BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE", "END",
495
+ "PRAGMA", "VACUUM", "ATTACH", "DETACH",
496
+ "CREATE", "ALTER", "DROP", "REINDEX", "ANALYZE",
497
+ ]);
498
+ /** Refuse a statement whose CLASS the mutation surface rejects, at the point the MUTATOR supplies it,
499
+ * so it surfaces as a business rejection instead of a poison. When the batch reaches the transport
500
+ * the body has already returned, so a server 400 there is (mis)read as infrastructure and retried
501
+ * forever — the same wedge {@link assertEncodableParams} prevents for bind values. Conservative by
502
+ * design: it fires only for a leading keyword that can never start a valid write, and leaves every
503
+ * ambiguous case (including CTE-prefixed writes) to the server's authoritative classifier. */
504
+ function assertMutationWriteStatement(sql) {
505
+ const match = /^[\s;]*([a-zA-Z]+)/.exec(sql);
506
+ if (match === null)
507
+ return;
508
+ const keyword = match[1].toUpperCase();
509
+ if (MUTATION_REFUSED_LEADING_KEYWORDS.has(keyword)) {
510
+ throw new Error(`a mutator write statement cannot begin with ${keyword} (\`${sql}\`); ` +
511
+ `mutations write rows only — use tx.sql.query(...) for reads and migrations for DDL`);
512
+ }
513
+ }
514
+ function daemonMutationTransport(daemon) {
515
+ return {
516
+ interactive: daemon.beginMutationSession !== undefined,
517
+ strictValues: false,
518
+ execute({ envelope, statements, idempotencyKey }) {
519
+ const txn = { statements, clientID: envelope.clientID, mid: envelope.mid };
520
+ if (idempotencyKey !== undefined)
521
+ txn.idempotencyKey = idempotencyKey;
522
+ return daemon.executeSqlTxn(txn);
523
+ },
524
+ reject({ envelope, reason }) {
525
+ return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
526
+ },
527
+ async begin({ envelope, statements, query, idempotencyKey }) {
528
+ if (!daemon.beginMutationSession)
529
+ throw new Error("the daemon client does not support mutation sessions");
530
+ const input = {
531
+ clientID: envelope.clientID,
532
+ mid: envelope.mid,
533
+ statements,
534
+ query,
535
+ };
536
+ if (idempotencyKey !== undefined)
537
+ input.idempotencyKey = idempotencyKey;
538
+ const opened = await daemon.beginMutationSession(input);
539
+ if (opened.absorbed) {
540
+ const { absorbed: _absorbed, sessionId: _sessionId, read: _read, ...output } = opened;
541
+ return { absorbed: output };
542
+ }
543
+ return { handle: opened.sessionId, read: opened.read };
544
+ },
545
+ async exec(handle, statements) {
546
+ await daemon.execInMutationSession({ sessionId: handle, statements });
547
+ },
548
+ query(handle, statement) {
549
+ return daemon.queryInMutationSession({
550
+ sessionId: handle,
551
+ sql: statement.sql,
552
+ params: statement.params,
553
+ });
554
+ },
555
+ commit(handle) {
556
+ return daemon.commitMutationSession({ sessionId: handle });
557
+ },
558
+ async rollback(handle) {
559
+ await daemon.rollbackMutationSession({ sessionId: handle });
560
+ },
561
+ readCommitted(statement) {
562
+ return daemon.executeSqlRead({ sql: statement.sql, params: statement.params });
563
+ },
564
+ };
565
+ }
566
+ function mutationReceiptOutput(receipt, clientID) {
567
+ const output = {
568
+ applied: receipt.applied,
569
+ lmid: receipt.lmid,
570
+ lmidAdvances: [{ clientID, lmid: receipt.lmid }],
571
+ };
572
+ if (receipt.commitCursor !== null)
573
+ output.cursor = receipt.commitCursor;
574
+ return output;
575
+ }
576
+ function publicMutationStatement(statement) {
577
+ return statement.params === undefined ? { sql: statement.sql } : { sql: statement.sql, args: statement.params };
578
+ }
579
+ function mutationRowsOutput(rows) {
580
+ return { cols: rows.columns, rows: rows.rows };
581
+ }
582
+ /** Convert the transports' compact positional rows into the ergonomic server-only raw-SQL shape. */
583
+ function keyedSqlRows(columns, rows) {
584
+ // Row objects are keyed by column NAME, so a read that projects the same name twice
585
+ // (`SELECT parent.status, child.status ...`) would silently keep only the last value — and a
586
+ // mutator branching on `row.status` would then authorize against the wrong cell. Refuse it loudly
587
+ // so the collision surfaces as a rejection reason instead of silent, wrong data.
588
+ const seen = new Set();
589
+ for (const column of columns) {
590
+ if (seen.has(column)) {
591
+ throw new Error(`raw SQL read projects the column name ${JSON.stringify(column)} more than once; ` +
592
+ `alias them to distinct names (e.g. SELECT a.id AS a_id, b.id AS b_id)`);
593
+ }
594
+ seen.add(column);
595
+ }
596
+ return rows.map((cells) => Object.fromEntries(columns.map((column, index) => [column, cells[index]])));
597
+ }
598
+ function daemonOutsideSql(daemon) {
599
+ return {
600
+ async execute(sql, params = []) {
601
+ await daemon.executeSqlTxn({ statements: [{ sql, params: [...params] }] });
602
+ },
603
+ async batch(statements) {
604
+ if (statements.length === 0)
605
+ return;
606
+ await daemon.executeSqlTxn({
607
+ statements: statements.map((statement) => ({
608
+ sql: statement.sql,
609
+ ...(statement.params !== undefined ? { params: [...statement.params] } : {}),
610
+ })),
611
+ });
612
+ },
613
+ async query(sql, params = []) {
614
+ const out = await daemon.executeSqlRead({ sql, params: [...params], consistency: "strong" });
615
+ return keyedSqlRows(out.cols, out.rows);
616
+ },
617
+ };
618
+ }
619
+ function sqlSessionOutsideSql(sql) {
620
+ return {
621
+ async execute(text, params = []) {
622
+ await sql.execute({ sql: text, args: [...params] });
623
+ },
624
+ async batch(statements) {
625
+ if (statements.length === 0)
626
+ return;
627
+ await sql.batch(statements.map(publicMutationStatement));
628
+ },
629
+ async query(text, params = []) {
630
+ const out = await sql.execute({ sql: text, args: [...params], wantRows: true }, { consistency: "strong" });
631
+ return keyedSqlRows(out.result.columns.map((column) => column.name), out.result.rows);
632
+ },
633
+ };
634
+ }
635
+ function sqlClientMutationTransport(sql) {
636
+ return {
637
+ interactive: true,
638
+ strictValues: true,
639
+ // NOTE: `execute`/`begin` deliberately ignore the interface's optional `idempotencyKey`. On the
640
+ // SQL mutation wire the (clientID, mid) pair IS the durable retry identity — a redelivery is
641
+ // absorbed by mid, so there is no idempotency key to carry. The field stays on the shared
642
+ // MutationTransport only because the legacy daemon foreign-write path still threads it.
643
+ async execute({ envelope, statements }) {
644
+ const receipt = await sql.executeMutation({
645
+ clientId: envelope.clientID,
646
+ mid: envelope.mid,
647
+ statements: statements.map(publicMutationStatement),
648
+ });
649
+ return mutationReceiptOutput(receipt, envelope.clientID);
650
+ },
651
+ async reject({ envelope, reason }) {
652
+ return mutationReceiptOutput(await sql.rejectMutation({ clientId: envelope.clientID, mid: envelope.mid, reason }), envelope.clientID);
653
+ },
654
+ async begin({ envelope, statements, query }) {
655
+ const opened = await sql.beginMutation({
656
+ clientId: envelope.clientID,
657
+ mid: envelope.mid,
658
+ statements: statements.map(publicMutationStatement),
659
+ query: publicMutationStatement(query),
660
+ });
661
+ if (opened.absorbed) {
662
+ return { absorbed: mutationReceiptOutput(opened.receipt, envelope.clientID) };
663
+ }
664
+ return {
665
+ handle: opened.transaction,
666
+ ...(opened.read !== undefined ? { read: mutationRowsOutput(opened.read) } : {}),
667
+ };
668
+ },
669
+ async exec(handle, statements) {
670
+ await handle.batch(statements.map(publicMutationStatement));
671
+ },
672
+ async query(handle, statement) {
673
+ return mutationRowsOutput(await handle.query(publicMutationStatement(statement)));
674
+ },
675
+ async commit(handle) {
676
+ const receipt = await handle.commit();
677
+ const advance = receipt.lmid;
678
+ // The handle is opened for exactly one client; RemoteLazyTx patches the client id from its
679
+ // envelope after this call so the legacy MutationBackend receipt remains byte-compatible.
680
+ return {
681
+ applied: receipt.applied,
682
+ cursor: receipt.commitCursor ?? undefined,
683
+ lmid: advance,
684
+ };
685
+ },
686
+ async rollback(handle) {
687
+ await handle.rollback();
688
+ },
689
+ async readCommitted(statement) {
690
+ const result = await sql.execute(publicMutationStatement(statement), { consistency: "strong" });
691
+ return {
692
+ cols: result.result.columns.map((column) => column.name),
693
+ rows: result.result.rows,
694
+ };
695
+ },
696
+ };
697
+ }
450
698
  /**
451
- * The daemon server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
699
+ * The remote SQLite server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
452
700
  * execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
453
- * `/execute-sql-txn`, byte-identical to prior behavior — and LAZILY UPGRADES to an interactive
701
+ * the selected mutation transport — and LAZILY UPGRADES to an interactive
454
702
  * mutation session at the mutator's first read: `begin` carries the envelope identity, the
455
703
  * accumulated statement prefix (sound to replay — nothing before the first read observed DB
456
704
  * state, §5.2), and the read itself, so a one-read mutator pays exactly one extra round trip.
@@ -459,40 +707,61 @@ class AbsorbedReplay extends Error {
459
707
  * reads cost k+2 round trips regardless of write count.
460
708
  *
461
709
  * Begin-time mid dedup can ABSORB the envelope (a redelivery whose commit response was lost):
462
- * the replay output is latched on {@link DaemonLazyTx.absorbed} and {@link AbsorbedReplay}
710
+ * the replay output is latched on {@link RemoteLazyTx.absorbed} and {@link AbsorbedReplay}
463
711
  * unwinds the body — the latch (not the throw) is authoritative, so a mutator that swallows
464
712
  * the unwind still cannot re-apply (no session opened; buffered writes are never shipped).
465
713
  * A daemon client without session support keeps the LEGACY committed-state point read.
466
714
  */
467
- class DaemonLazyTx {
715
+ class RemoteLazyTx {
468
716
  /** Pre-upgrade: the accumulated batch/prefix. Post-upgrade: writes buffered for the next flush. */
469
717
  stmts = [];
470
718
  render;
471
- daemon;
719
+ transport;
472
720
  envelope;
473
- sessionId;
721
+ sessionHandle;
722
+ sql;
474
723
  /** The begin-absorbed replay output (§4.1), latched for the backend. */
475
724
  absorbed;
476
725
  idempotencyKey;
477
- constructor(render, daemon, envelope) {
726
+ constructor(render, transport, envelope) {
478
727
  this.render = render;
479
- this.daemon = daemon;
728
+ this.transport = transport;
480
729
  this.envelope = envelope;
730
+ this.sql = {
731
+ execute: async (sql, params = []) => {
732
+ this.exec(sql, [...params]);
733
+ },
734
+ batch: async (statements) => {
735
+ for (const statement of statements) {
736
+ this.exec(statement.sql, statement.params === undefined ? [] : [...statement.params]);
737
+ }
738
+ },
739
+ query: (sql, params = []) => this.querySql(sql, params),
740
+ };
481
741
  }
482
742
  /** True once the tx upgraded to an interactive session (the backend then commits it). */
483
743
  get session() {
484
- return this.sessionId !== undefined;
744
+ return this.sessionHandle !== undefined;
485
745
  }
486
746
  get statements() {
487
747
  return this.stmts;
488
748
  }
489
749
  exec(sql, params = []) {
750
+ // Refuse here, INSIDE the mutator body, so the harness reads it as a business rejection. By the
751
+ // time the statement reaches the transport the body has returned and the throw is infra.
752
+ if (this.transport.strictValues) {
753
+ assertMutationWriteStatement(sql);
754
+ assertEncodableParams(sql, params);
755
+ }
490
756
  this.stmts.push({ sql, params });
491
757
  }
492
758
  push(op) {
493
759
  const rendered = renderOp(op, tableMeta(this.render, op.table), sqliteDialect);
494
- if (rendered)
760
+ if (rendered) {
761
+ if (this.transport.strictValues)
762
+ assertEncodableParams(rendered.sql, rendered.params);
495
763
  this.stmts.push(rendered);
764
+ }
496
765
  return Promise.resolve();
497
766
  }
498
767
  insert(table, row) {
@@ -532,44 +801,49 @@ class DaemonLazyTx {
532
801
  return ast.one === true ? null : [];
533
802
  return JSON.parse(cell);
534
803
  }
804
+ async querySql(sql, params) {
805
+ const out = await this.readThroughTxn({ sql, params: [...params] });
806
+ return keyedSqlRows(out.cols, out.rows);
807
+ }
535
808
  /** Run one read: upgrade to a session at the first (§5.1), ride the open one after, or fall
536
809
  * back to the legacy committed-state read when the daemon client lacks sessions. */
537
810
  async readThroughTxn(read) {
538
811
  if (this.absorbed)
539
812
  throw new AbsorbedReplay();
540
- if (!this.daemon.beginMutationSession) {
813
+ // Refuse an unencodable read bind at the mutator boundary, exactly as `exec` does for writes.
814
+ // A read's parameters are encoded inside the transport, where the throw becomes a BackendError
815
+ // (infra) that retries the deterministic mutator forever and wedges the client's queue; asserting
816
+ // here makes it a business rejection instead.
817
+ if (this.transport.strictValues)
818
+ assertEncodableParams(read.sql, read.params);
819
+ if (!this.transport.interactive) {
541
820
  try {
542
- return await this.daemon.executeSqlRead({ sql: read.sql, params: read.params });
821
+ return await this.transport.readCommitted(read);
543
822
  }
544
823
  catch (err) {
545
824
  throw new BackendError(err);
546
825
  }
547
826
  }
548
827
  try {
549
- if (this.sessionId === undefined) {
550
- const opened = await this.daemon.beginMutationSession({
551
- clientID: this.envelope.clientID,
552
- mid: this.envelope.mid,
828
+ if (this.sessionHandle === undefined) {
829
+ const opened = await this.transport.begin({
830
+ envelope: this.envelope,
553
831
  statements: this.stmts.splice(0),
554
832
  query: read,
833
+ ...(this.idempotencyKey !== undefined ? { idempotencyKey: this.idempotencyKey } : {}),
555
834
  });
556
835
  if (opened.absorbed) {
557
- const { absorbed: _a, sessionId: _s, read: _r, ...output } = opened;
558
- this.absorbed = output;
836
+ this.absorbed = opened.absorbed;
559
837
  throw new AbsorbedReplay();
560
838
  }
561
- if (!opened.sessionId || !opened.read) {
839
+ if (opened.handle === undefined || !opened.read) {
562
840
  throw new Error(`malformed mutate-session begin reply: ${JSON.stringify(opened)}`);
563
841
  }
564
- this.sessionId = opened.sessionId;
842
+ this.sessionHandle = opened.handle;
565
843
  return opened.read;
566
844
  }
567
845
  await this.flush();
568
- return await this.daemon.queryInMutationSession({
569
- sessionId: this.sessionId,
570
- sql: read.sql,
571
- params: read.params,
572
- });
846
+ return await this.transport.query(this.sessionHandle, read);
573
847
  }
574
848
  catch (err) {
575
849
  if (err instanceof AbsorbedReplay || err instanceof BackendError)
@@ -581,17 +855,18 @@ class DaemonLazyTx {
581
855
  async flush() {
582
856
  if (this.stmts.length === 0)
583
857
  return;
584
- await this.daemon.execInMutationSession({
585
- sessionId: this.sessionId,
586
- statements: this.stmts.splice(0),
587
- });
858
+ await this.transport.exec(this.sessionHandle, this.stmts.splice(0));
588
859
  }
589
860
  /** Flush + commit the open session — the daemon stamps lmid co-transactionally (§4.4) and
590
861
  * answers the same shape `/execute-sql-txn` does. */
591
862
  async commitSession() {
592
863
  try {
593
864
  await this.flush();
594
- return await this.daemon.commitMutationSession({ sessionId: this.sessionId });
865
+ const output = await this.transport.commit(this.sessionHandle);
866
+ if (output.lmid !== undefined && output.lmidAdvances === undefined) {
867
+ output.lmidAdvances = [{ clientID: this.envelope.clientID, lmid: output.lmid }];
868
+ }
869
+ return output;
595
870
  }
596
871
  catch (err) {
597
872
  throw err instanceof BackendError ? err : new BackendError(err);
@@ -600,12 +875,12 @@ class DaemonLazyTx {
600
875
  /** Best-effort rollback (the daemon's deadline is the backstop). MUST be awaited before a
601
876
  * follow-up `/reject-mutation`: that lmid-only commit needs the writer this session holds. */
602
877
  async rollbackSessionQuietly() {
603
- if (this.sessionId === undefined)
878
+ if (this.sessionHandle === undefined)
604
879
  return;
605
- const sessionId = this.sessionId;
606
- this.sessionId = undefined;
880
+ const sessionHandle = this.sessionHandle;
881
+ this.sessionHandle = undefined;
607
882
  try {
608
- await this.daemon.rollbackMutationSession({ sessionId });
883
+ await this.transport.rollback(sessionHandle);
609
884
  }
610
885
  catch {
611
886
  // Unreachable daemon / already-expired session: the deadline rollback covers it.
@@ -622,11 +897,25 @@ class PgLiveTx {
622
897
  q;
623
898
  render;
624
899
  rewrite;
900
+ sql;
625
901
  idempotencyKey;
626
902
  constructor(q, render, rewrite) {
627
903
  this.q = q;
628
904
  this.render = render;
629
905
  this.rewrite = rewrite;
906
+ this.sql = {
907
+ execute: async (sql, params = []) => {
908
+ this.exec(sql, [...params]);
909
+ await this.settle();
910
+ },
911
+ batch: async (statements) => {
912
+ for (const statement of statements) {
913
+ this.exec(statement.sql, statement.params === undefined ? [] : [...statement.params]);
914
+ }
915
+ await this.settle();
916
+ },
917
+ query: (sql, params = []) => this.querySql(sql, params),
918
+ };
630
919
  }
631
920
  get statements() {
632
921
  return this.stmts;
@@ -694,62 +983,94 @@ class PgLiveTx {
694
983
  // static-catalog + driver-pin wiring (POSTGRES-READ-COMPILER-DESIGN.md Phase B).
695
984
  return Promise.reject(new Error("tx.query is not wired on the Postgres backend yet (POSTGRES-READ-COMPILER-DESIGN.md Phase B) — use tx.row for point reads meanwhile"));
696
985
  }
986
+ async querySql(sql, params) {
987
+ await this.settle();
988
+ try {
989
+ return (await this.q.query(this.rewrite(sql), [...params]));
990
+ }
991
+ catch (err) {
992
+ throw new BackendError(err);
993
+ }
994
+ }
697
995
  }
698
- /**
699
- * The default {@link MutationBackend}. A pure-write mutator keeps the historical shape: writes
700
- * ACCUMULATE and ship as ONE batch to `/execute-sql-txn`, which stamps `lmid` co-transactionally
701
- * (and `/reject-mutation` advances it past a rejected mid) — byte-identical behavior. A
702
- * READ-bearing mutator lazily upgrades to an interactive mutation session
703
- * (DAEMON-INTERACTIVE-TXN-DESIGN.md): reads are read-your-writes through the open transaction
704
- * (PG parity), the commit stamps `lmid` in the same atomic unit, and a begin-absorbed replay
705
- * short-circuits without re-running the body.
706
- */
707
- export function daemonBackend(daemon) {
996
+ /** Shared remote-SQL mutation backend. A pure-write mutator remains one request; a read-bearing
997
+ * mutator lazily upgrades at its first read; accepted effects commit with lmid; business rejection
998
+ * rolls effects back before an lmid-only commit. Both daemonBackend and sqlBackend use this exact
999
+ * policy implementation. */
1000
+ function remoteMutationBackend(transport, outsideSql) {
708
1001
  return {
709
1002
  dialect: sqliteDialect,
710
- async runMutation({ envelope, render, run }) {
711
- const tx = new DaemonLazyTx(render, daemon, envelope);
712
- try {
713
- await run(tx);
714
- }
715
- catch (err) {
716
- // A begin-absorbed replay: the authoritative outcome already committed — answer it,
717
- // whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
718
- if (tx.absorbed)
719
- return { accepted: true, output: tx.absorbed };
720
- if (err instanceof BackendError) {
721
- await tx.rollbackSessionQuietly();
722
- throw err.driverError; // infra — never a user rejection
723
- }
724
- const reason = errMessage(err);
725
- // Data first, watermark second: the rollback releases the single writer that the
726
- // `/reject-mutation` lmid-only commit needs (§2.4 on the session path).
727
- await tx.rollbackSessionQuietly();
728
- const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
729
- return { accepted: false, reason, output };
730
- }
731
- if (tx.absorbed)
732
- return { accepted: true, output: tx.absorbed };
733
- if (tx.session) {
1003
+ outsideSql,
1004
+ async runMutation(input) {
1005
+ for (let attempt = 0; attempt < MUTATOR_CONFLICT_MAX_ATTEMPTS; attempt++) {
734
1006
  try {
735
- return { accepted: true, output: await tx.commitSession() };
1007
+ const { envelope, render, run } = input;
1008
+ const tx = new RemoteLazyTx(render, transport, envelope);
1009
+ try {
1010
+ await run(tx);
1011
+ }
1012
+ catch (err) {
1013
+ // A begin-absorbed replay: the authoritative outcome already committed — answer it,
1014
+ // whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
1015
+ if (tx.absorbed)
1016
+ return { accepted: true, output: tx.absorbed };
1017
+ if (err instanceof BackendError) {
1018
+ await tx.rollbackSessionQuietly();
1019
+ throw err.driverError; // infra — never a user rejection
1020
+ }
1021
+ const reason = errMessage(err);
1022
+ // Data first, watermark second: rollback releases this session's connection before
1023
+ // the lmid-only rejection commit.
1024
+ await tx.rollbackSessionQuietly();
1025
+ const output = await transport.reject({ envelope, reason });
1026
+ return { accepted: false, reason, output };
1027
+ }
1028
+ if (tx.absorbed)
1029
+ return { accepted: true, output: tx.absorbed };
1030
+ if (tx.session) {
1031
+ try {
1032
+ return { accepted: true, output: await tx.commitSession() };
1033
+ }
1034
+ catch (err) {
1035
+ if (err instanceof BackendError)
1036
+ throw err.driverError;
1037
+ throw err;
1038
+ }
1039
+ }
1040
+ return {
1041
+ accepted: true,
1042
+ output: await transport.execute({
1043
+ envelope,
1044
+ statements: [...tx.statements],
1045
+ ...(tx.idempotencyKey !== undefined ? { idempotencyKey: tx.idempotencyKey } : {}),
1046
+ }),
1047
+ };
736
1048
  }
737
- catch (err) {
738
- if (err instanceof BackendError)
739
- throw err.driverError; // infra (client retries; dedup absorbs)
740
- throw err;
1049
+ catch (error) {
1050
+ if (!isRetryableCommitConflict(error) || attempt + 1 === MUTATOR_CONFLICT_MAX_ATTEMPTS) {
1051
+ throw error;
1052
+ }
1053
+ await mutatorConflictBackoff(attempt);
741
1054
  }
742
1055
  }
743
- const txn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
744
- if (tx.idempotencyKey !== undefined)
745
- txn.idempotencyKey = tx.idempotencyKey;
746
- return { accepted: true, output: await daemon.executeSqlTxn(txn) };
1056
+ throw new Error("unreachable mutator conflict retry loop");
747
1057
  },
748
1058
  reject({ envelope, reason }) {
749
- return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1059
+ return transport.reject({ envelope, reason });
750
1060
  },
751
1061
  };
752
1062
  }
1063
+ /** Legacy/private-plane adapter. Kept for existing deployments; its mutation policy is shared with
1064
+ * {@link sqlBackend}, so the two transports cannot drift. */
1065
+ export function daemonBackend(daemon) {
1066
+ return remoteMutationBackend(daemonMutationTransport(daemon), daemonOutsideSql(daemon));
1067
+ }
1068
+ /** Run API-server mutators through `@rindle/sql-client`'s explicit mutation facade. Query leases,
1069
+ * SSR reads and room control continue to use `daemon`; only authoritative mutation execution moves
1070
+ * to the versioned SQL transport. */
1071
+ export function sqlBackend(sql) {
1072
+ return remoteMutationBackend(sqlClientMutationTransport(sql), sqlSessionOutsideSql(sql));
1073
+ }
753
1074
  /** The §2.3 upsert, verbatim from the contract: monotonic via GREATEST, keyed by client. The
754
1075
  * identifiers are lowercase so quoting is cosmetic, but quote-everything is the repo's PG rule. */
755
1076
  const LMID_UPSERT = `INSERT INTO "_rindle_client_mutations" ("client_id", "last_mutation_id")
@@ -778,8 +1099,28 @@ export function postgresBackend(plugger, opts = {}) {
778
1099
  await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
779
1100
  return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
780
1101
  });
1102
+ const outsideSql = {
1103
+ async execute(sql, params = []) {
1104
+ await plugger.transaction(async (q) => {
1105
+ await q.exec(rewrite(sql), [...params]);
1106
+ });
1107
+ },
1108
+ async batch(statements) {
1109
+ if (statements.length === 0)
1110
+ return;
1111
+ await plugger.transaction(async (q) => {
1112
+ for (const statement of statements) {
1113
+ await q.exec(rewrite(statement.sql), statement.params === undefined ? [] : [...statement.params]);
1114
+ }
1115
+ });
1116
+ },
1117
+ query(sql, params = []) {
1118
+ return plugger.transaction(async (q) => (await q.query(rewrite(sql), [...params])));
1119
+ },
1120
+ };
781
1121
  return {
782
1122
  dialect: postgresDialect,
1123
+ outsideSql,
783
1124
  async runMutation({ envelope, render, run }) {
784
1125
  try {
785
1126
  const output = await plugger.transaction(async (q) => {
@@ -1077,6 +1418,47 @@ function normalizeCondition(c) {
1077
1418
  };
1078
1419
  }
1079
1420
  }
1421
+ /** Keep outside-SQL driver failures on the infrastructure path even when they happen before the
1422
+ * scoped mutator has opened its mutation transaction. */
1423
+ function scopedOutsideSql(sql) {
1424
+ const unavailable = () => new BackendError(new Error("scope.sql is unavailable on this custom MutationBackend"));
1425
+ // An unencodable bind is a deterministic authoring error, not a database failure. Wrapping it in
1426
+ // BackendError would latch `scope.infra` and retry the envelope forever; leaving it a plain throw
1427
+ // lets the scoped harness treat it as a business rejection and advance lmid.
1428
+ const infra = (error) => isUnencodableBind(error) ? new Error(errMessage(error)) : error instanceof BackendError ? error : new BackendError(error);
1429
+ return {
1430
+ async execute(text, params = []) {
1431
+ if (!sql)
1432
+ throw unavailable();
1433
+ try {
1434
+ await sql.execute(text, params);
1435
+ }
1436
+ catch (error) {
1437
+ throw infra(error);
1438
+ }
1439
+ },
1440
+ async batch(statements) {
1441
+ if (!sql)
1442
+ throw unavailable();
1443
+ try {
1444
+ await sql.batch(statements);
1445
+ }
1446
+ catch (error) {
1447
+ throw infra(error);
1448
+ }
1449
+ },
1450
+ async query(text, params = []) {
1451
+ if (!sql)
1452
+ throw unavailable();
1453
+ try {
1454
+ return await sql.query(text, params);
1455
+ }
1456
+ catch (error) {
1457
+ throw infra(error);
1458
+ }
1459
+ },
1460
+ };
1461
+ }
1080
1462
  /**
1081
1463
  * The runtime {@link MutationScope} handed to a {@link ScopedMutator}. It owns the single atomic
1082
1464
  * transaction (delegating to {@link MutationBackend.runMutation} — the exact machinery a tx-form
@@ -1094,6 +1476,7 @@ class MutationScopeImpl {
1094
1476
  backend;
1095
1477
  envelope;
1096
1478
  render;
1479
+ sql;
1097
1480
  /** Set once `transact` resolved through the backend (accepted OR business-rejected). */
1098
1481
  outcome;
1099
1482
  /** The value the backend threw on INFRA (the DB failed) — always propagated, never an `lmid`
@@ -1112,6 +1495,7 @@ class MutationScopeImpl {
1112
1495
  this.backend = backend;
1113
1496
  this.envelope = envelope;
1114
1497
  this.render = render;
1498
+ this.sql = scopedOutsideSql(backend.outsideSql);
1115
1499
  }
1116
1500
  transact(first, args, ctx) {
1117
1501
  if (this.attempted)
@@ -1150,8 +1534,24 @@ class MutationScopeImpl {
1150
1534
  export function createRindleApiServer(opts) {
1151
1535
  const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
1152
1536
  const mode = opts.mode ?? "normalized";
1153
- // The mutation seam defaulting to the daemon's co-transactional lmid stamp (unchanged behavior).
1154
- const backend = opts.backend ?? daemonBackend(opts.daemon);
1537
+ // Explicit backend wins; otherwise prefer the versioned Rindle-SQL mutation transport and retain
1538
+ // daemonBackend as the compatibility path for deployments that have not exposed it yet.
1539
+ let ownedSql;
1540
+ let backend;
1541
+ if (opts.backend !== undefined) {
1542
+ backend = opts.backend;
1543
+ }
1544
+ else {
1545
+ if (opts.database !== undefined && opts.sql !== undefined) {
1546
+ throw new TypeError("configure either database or sql, not both");
1547
+ }
1548
+ const sql = opts.sql ??
1549
+ (opts.database !== undefined
1550
+ ? // Default FIRST so `database.intMode` can override it; see RindleDatabaseOptions.
1551
+ (ownedSql = createSqlClient({ intMode: "number", ...opts.database }))
1552
+ : undefined);
1553
+ backend = sql === undefined ? daemonBackend(opts.daemon) : sqlBackend(sql);
1554
+ }
1155
1555
  // Schema-derived render metadata for logical mutator writes; `{}` when no schema is configured (a
1156
1556
  // logical op then throws loudly — the tx never silently drops a write). Each backend renders in its
1157
1557
  // own dialect (`backend.dialect`: daemon→sqlite, postgres→postgres).
@@ -1205,18 +1605,12 @@ export function createRindleApiServer(opts) {
1205
1605
  `${reasons.join("; ")}. It serves from the daemon (correct, just not room-accelerated). ` +
1206
1606
  `This warning fires once per (query, profile).`);
1207
1607
  };
1208
- // The §2.3 aggregate refusal: the client's aggregate overlay is daemon-gated until post-G, so
1209
- // an aggregate/reduce-shaped query is refused room-serving REGARDLESS of coverage.
1210
- const AGGREGATE_REFUSAL = "the query AST contains an aggregate/reduce shape aggregate overlays are daemon-gated until post-G";
1211
- // Verdict cache. The verdict is a pure function of exactly two inputs the resolved footprint
1212
- // AST and the resolved query AST — so the tightest SOUND key is those two ASTs themselves
1213
- // (stable-stringified), scoped by (queryName, profile) for legibility. Args/user/ctx need no
1214
- // separate slot precisely because anything that changes the verdict must change one of the two
1215
- // ASTs (predicate literals embed the args; ctx-scoped queries embed the principal); keying on
1216
- // `(name, args)` alone would ALIAS two users' different ASTs under one verdict — unsound.
1217
- // Bounded FIFO (Map iterates in insertion order) so per-user literals can't grow it forever.
1218
- const coverVerdicts = new Map();
1219
- const COVER_VERDICT_CACHE_MAX = 1024;
1608
+ // Room-served aggregates are refused: a room-retargeted query carrying a count()/reduce reads an
1609
+ // `__agg` head only the daemon feed maintains, and the client's room gate DROPS the `__agg` rows
1610
+ // the room publishes a known-unsupported shape (302 post-impl review). Room serving otherwise
1611
+ // trusts the declaration (302 §5: declared, not derived); this one shape stays a policy refusal
1612
+ // until room-served aggregates are designed.
1613
+ const AGGREGATE_REFUSAL = "the query contains an aggregate/reduce shape — room-served aggregates are not yet supported (the room gate drops `__agg` rows)";
1220
1614
  const maybeRoomServe = async (input, queryAst, context, subject) => {
1221
1615
  // (a) the label + (b) its profile — the fast bail keeps unlabeled leases byte-identical.
1222
1616
  const label = queryRealtimeLabel(opts.queries?.[input.name]);
@@ -1231,13 +1625,6 @@ export function createRindleApiServer(opts) {
1231
1625
  warnRoomServeOnce(input.name, profile.name, ["realtime.locateRoom is not configured"]);
1232
1626
  return undefined;
1233
1627
  }
1234
- const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
1235
- if (coverQuery === undefined) {
1236
- warnRoomServeOnce(input.name, profile.name, [
1237
- "the configured daemon client does not implement coverQuery (/cover-check)",
1238
- ]);
1239
- return undefined;
1240
- }
1241
1628
  const tokenKey = realtime.roomTokenKey;
1242
1629
  if (tokenKey === undefined) {
1243
1630
  warnRoomServeOnce(input.name, profile.name, [
@@ -1263,25 +1650,14 @@ export function createRindleApiServer(opts) {
1263
1650
  // profiles), with the §2.3 unwindowed backstop `/room-boot` also applies.
1264
1651
  const footprintAst = queryResultToAst(await profile.footprint(key, context));
1265
1652
  assertUnwindowedFootprint(footprintAst, profile.name);
1266
- const verdictKey = `${input.name}\u0000${profile.name}\u0000${stableStringify(footprintAst)}\u0000${stableStringify(queryAst)}`;
1267
- let verdict = coverVerdicts.get(verdictKey);
1268
- if (verdict === undefined) {
1269
- verdict = astHasAggregate(queryAst)
1270
- ? { covered: false, reasons: [AGGREGATE_REFUSAL] }
1271
- : await coverQuery({ footprint: footprintAst, query: queryAst });
1272
- // (A coverQuery THROW never lands here — the outer catch fail-opens without caching, so
1273
- // a transient daemon failure doesn't pin an uncovered verdict.)
1274
- if (coverVerdicts.size >= COVER_VERDICT_CACHE_MAX) {
1275
- coverVerdicts.delete(coverVerdicts.keys().next().value);
1276
- }
1277
- coverVerdicts.set(verdictKey, verdict);
1278
- }
1279
- if (!verdict.covered) {
1280
- warnRoomServeOnce(input.name, profile.name, verdict.reasons ?? ["not provably covered"]);
1653
+ // Trust the declaration (302 §5): a labeled + wired query is room-served, no coverage proof.
1654
+ // The one shape still refused is the aggregate (a policy gate, not a coverage verdict).
1655
+ if (astHasAggregate(queryAst)) {
1656
+ warnRoomServeOnce(input.name, profile.name, [AGGREGATE_REFUSAL]);
1281
1657
  return undefined;
1282
1658
  }
1283
- // Covered ⇒ assemble the realtime block. The room endpoint rides ITS OWN field — the
1284
- // top-level `wsEndpoint` stays exactly the daemon lease's (whole-session migration signal).
1659
+ // Assemble the realtime block. The room endpoint rides ITS OWN field
1660
+ // (`realtime.wsEndpoint`) a separate connection from the daemon session's fixed ws host.
1285
1661
  const { wsEndpoint } = await realtime.locateRoom(doc);
1286
1662
  const now = Date.now();
1287
1663
  const ttlMs = realtime.roomTokenTtlMs ?? DEFAULT_ROOM_TOKEN_TTL_MS;
@@ -1312,7 +1688,8 @@ export function createRindleApiServer(opts) {
1312
1688
  };
1313
1689
  }
1314
1690
  catch (e) {
1315
- // Fail open — a lease is never blocked on the proof. Not cached (may be transient).
1691
+ // Fail open — a lease is never blocked on room-serve wiring (footprint resolution,
1692
+ // locateRoom, token minting). A failure here just serves the query from the daemon.
1316
1693
  warnRoomServeOnce(input.name, profile.name, [`room-serve failed: ${errMessage(e)}`]);
1317
1694
  return undefined;
1318
1695
  }
@@ -1369,11 +1746,14 @@ export function createRindleApiServer(opts) {
1369
1746
  subject,
1370
1747
  leaseTtlMs: opts.leaseTtlMs,
1371
1748
  metadata: routingKey !== undefined ? { routingKey } : undefined,
1749
+ // Lifecycle leases are follower-local exactly like the primary lease. Forward the SAME
1750
+ // opaque placement ticket so every doorbell/fence materialization is minted on the
1751
+ // browser socket's follower instead of independently anycasting across the fleet.
1752
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
1372
1753
  });
1373
1754
  const lease = (table, out, id) => ({
1374
1755
  table,
1375
1756
  leaseToken: out.leaseToken,
1376
- ...(out.wsEndpoint !== undefined ? { wsEndpoint: out.wsEndpoint } : {}),
1377
1757
  ...(id.scope !== undefined ? { scope: id.scope } : {}),
1378
1758
  ...(id.doc !== undefined ? { doc: id.doc } : {}),
1379
1759
  ...(id.clientId !== undefined ? { clientId: id.clientId } : {}),
@@ -1547,16 +1927,20 @@ export function createRindleApiServer(opts) {
1547
1927
  // The anonymous routing key rides `metadata.routingKey`; the router keys on
1548
1928
  // `subject ?? metadata.routingKey` (§2.2). Omitted when there is none.
1549
1929
  metadata: routingKey !== undefined ? { routingKey } : undefined,
1930
+ // Forward the browser's opaque affinity ticket (if any) so the fleet `fly-replay`s this
1931
+ // materialize to the follower the ws is pinned to (FOLLOWER-AFFINITY-DESIGN.md §4). Opaque —
1932
+ // never verified here. Inert when the reads client is a single daemon (no fleet edge).
1933
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
1550
1934
  });
1551
1935
  const res = queryLeaseResponse(out);
1552
1936
  // I-iv (§4.1): the occupancy step FIRST — session sweep+upsert, then the D6 gate verdict. A
1553
1937
  // closed gate suppresses the room-serve ONLY (the lease ships without the realtime block,
1554
- // indistinguishable from an uncovered query — the fail-open daemon path) while the doorbell
1938
+ // indistinguishable from a non-room-served query — the daemon path) while the doorbell
1555
1939
  // below still rides; lifecycle-off ⇒ `gateOpen: true` unconditionally and this line is inert.
1556
1940
  const occ = await lifecycleOccupancy(input);
1557
- // G-iv-b: a covered labeled query ADDITIONALLY gains the realtime block. The daemon lease
1941
+ // G-iv-b: a labeled + wired query ADDITIONALLY gains the realtime block. The daemon lease
1558
1942
  // above is unconditional (and its fields untouched) — room-serving only ever adds a field,
1559
- // so an uncovered/unwired/legacy lease stays byte-identical and nothing here can block one.
1943
+ // so a non-room-served/legacy lease stays byte-identical and nothing here can block one.
1560
1944
  const rt = occ.gateOpen ? await maybeRoomServe(input, ast, context, subject) : undefined;
1561
1945
  if (rt !== undefined)
1562
1946
  res.realtime = rt;
@@ -1598,11 +1982,13 @@ export function createRindleApiServer(opts) {
1598
1982
  const subject = await resolveSubject(opts.subject, input);
1599
1983
  const routingKey = await resolveRoutingKey(opts.routingKey, input);
1600
1984
  const visibilityKey = subject ?? routingKey;
1601
- const out = await opts.daemon.query({ ast, visibilityKey, ttlMs: opts.readIdleTtlMs });
1602
- const res = { rows: out.rows, cvMin: out.cvMin, queryKey: out.queryKey };
1603
- if (out.wsEndpoint !== undefined)
1604
- res.wsEndpoint = out.wsEndpoint;
1605
- return res;
1985
+ const out = await opts.daemon.query({
1986
+ ast,
1987
+ visibilityKey,
1988
+ ttlMs: opts.readIdleTtlMs,
1989
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
1990
+ });
1991
+ return { rows: out.rows, cvMin: out.cvMin, queryKey: out.queryKey };
1606
1992
  };
1607
1993
  const pushMutation = async (input) => {
1608
1994
  const context = { user: input.user, request: input.request };
@@ -1754,57 +2140,6 @@ export function createRindleApiServer(opts) {
1754
2140
  throw new Error(`assertPins: ${failures.length} failed — ${failures.join("; ")}`);
1755
2141
  }
1756
2142
  };
1757
- // The explicit coverage diagnostic (the assertPins pattern: system-level, resolved under
1758
- // `pinUser`, per-query failures collected — never strand the rest). It runs the REAL check —
1759
- // the daemon's /cover-check on the actually-resolved ASTs — so its verdicts are exactly the
1760
- // lease path's, minus the serving wiring (locateRoom/roomTokenKey), which it deliberately
1761
- // ignores: it answers "is this labeled query coverable", the deployable-config question.
1762
- const validateRealtime = async (vopts) => {
1763
- const context = { user: opts.pinUser, request: undefined };
1764
- const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
1765
- const verdicts = [];
1766
- for (const [name, q] of Object.entries(opts.queries ?? {})) {
1767
- const label = queryRealtimeLabel(q);
1768
- if (label === undefined)
1769
- continue;
1770
- const profile = roomProfiles.get(label.room);
1771
- if (profile === undefined)
1772
- continue; // unreachable: construction asserted it exists
1773
- for (const args of vopts?.exemplars?.[name] ?? [null]) {
1774
- const verdict = { query: name, profile: profile.name, args, covered: false };
1775
- try {
1776
- const ast = await resolveAst(name, args, context);
1777
- const roomArgs = label.args !== undefined ? label.args(args) : args;
1778
- const footprintAst = queryResultToAst(await profile.footprint(profile.key(roomArgs), context));
1779
- assertUnwindowedFootprint(footprintAst, profile.name);
1780
- if (astHasAggregate(ast)) {
1781
- verdict.reasons = [AGGREGATE_REFUSAL];
1782
- }
1783
- else if (coverQuery === undefined) {
1784
- verdict.reasons = ["the configured daemon client does not implement coverQuery (/cover-check)"];
1785
- }
1786
- else {
1787
- const out = await coverQuery({ footprint: footprintAst, query: ast });
1788
- verdict.covered = out.covered;
1789
- if (!out.covered)
1790
- verdict.reasons = out.reasons ?? ["not provably covered"];
1791
- }
1792
- }
1793
- catch (e) {
1794
- verdict.reasons = [errMessage(e)];
1795
- }
1796
- verdicts.push(verdict);
1797
- }
1798
- }
1799
- const uncovered = verdicts.filter((v) => !v.covered);
1800
- if (vopts?.strict && uncovered.length > 0) {
1801
- throw new Error(`validateRealtime: ${uncovered.length} labeled query verdict(s) not provably covered — ` +
1802
- uncovered
1803
- .map((v) => `${v.query} (profile "${v.profile}"): ${(v.reasons ?? []).join("; ")}`)
1804
- .join(" | "));
1805
- }
1806
- return { verdicts, uncovered };
1807
- };
1808
2143
  // The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
1809
2144
  // the `realtime` block (which also activates `/room-boot`) or the deprecated bare
1810
2145
  // `authorizeRoom` (trio only). Hosting an authority is never a default.
@@ -1859,10 +2194,10 @@ export function createRindleApiServer(opts) {
1859
2194
  };
1860
2195
  return {
1861
2196
  routes,
2197
+ close: () => ownedSql?.close(),
1862
2198
  createQueryLease,
1863
2199
  readQuery,
1864
2200
  assertPins,
1865
- validateRealtime,
1866
2201
  pushMutation,
1867
2202
  pushMutations,
1868
2203
  handleApplyRowChangeTxnJson: async (body, context) => {
@@ -1958,7 +2293,9 @@ export function createRindleApiServer(opts) {
1958
2293
  headers,
1959
2294
  },
1960
2295
  };
1961
- const upstreamWsEndpoint = realtime.upstreamWsEndpoint ?? lease.wsEndpoint;
2296
+ if (lease.affinity !== undefined)
2297
+ res.upstreamAffinity = lease.affinity;
2298
+ const upstreamWsEndpoint = realtime.upstreamWsEndpoint;
1962
2299
  if (upstreamWsEndpoint !== undefined)
1963
2300
  res.upstreamWsEndpoint = upstreamWsEndpoint;
1964
2301
  return { status: 200, body: res };
@@ -1975,6 +2312,7 @@ export function createRindleApiServer(opts) {
1975
2312
  args: msg.args ?? null,
1976
2313
  request: context.request,
1977
2314
  clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
2315
+ affinity: typeof msg.affinity === "string" ? msg.affinity : undefined,
1978
2316
  });
1979
2317
  },
1980
2318
  handleReadJson: (body, context) => {
@@ -1985,6 +2323,7 @@ export function createRindleApiServer(opts) {
1985
2323
  args: msg.args ?? null,
1986
2324
  request: context.request,
1987
2325
  clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
2326
+ affinity: typeof msg.affinity === "string" ? msg.affinity : undefined,
1988
2327
  });
1989
2328
  },
1990
2329
  handleMutateJson: (body, context) => {
@@ -2045,8 +2384,9 @@ function applyOpToServerTx(tx, op) {
2045
2384
  }
2046
2385
  /** Feed a mutator's RETURNED result (the alternative to calling `tx.exec`/logical ops directly) into
2047
2386
  * the backend tx: a returned `SqlStatement[]` / `SqlTxn` is exec'd onto `tx`, and a carried
2048
- * `idempotencyKey` is stashed (the daemon backend honors it; PG ignores it). A `void` return is a
2049
- * no-op the mutator already drove the tx. Preserves the pre-existing return-style contract. */
2387
+ * `idempotencyKey` is stashed for the legacy daemon adapter; the SQL mutation facade uses `mid`
2388
+ * as its durable retry identity and PG ignores it. A `void` return is a no-op the mutator already
2389
+ * drove the tx. Preserves the return-style contract. */
2050
2390
  function applyResultToTx(result, tx) {
2051
2391
  if (!result)
2052
2392
  return;
@@ -2141,16 +2481,12 @@ async function resolveRoutingKey(routingKey, input) {
2141
2481
  return typeof routingKey === "function" ? routingKey(input) : routingKey;
2142
2482
  }
2143
2483
  function queryLeaseResponse(out) {
2144
- const res = {
2484
+ return {
2145
2485
  leaseToken: out.leaseToken,
2146
2486
  materializationId: out.materializationId,
2147
2487
  queryKey: out.queryKey,
2148
2488
  reused: out.reused,
2149
2489
  };
2150
- // Only present in a routed deploy — absent reproduces today's single-daemon response exactly.
2151
- if (out.wsEndpoint !== undefined)
2152
- res.wsEndpoint = out.wsEndpoint;
2153
- return res;
2154
2490
  }
2155
2491
  function errMessage(reason) {
2156
2492
  return String(reason?.message ?? reason);
@@ -2173,7 +2509,7 @@ const ROOM_MUTATION_OUTCOMES_TABLE = "_rindle_room_mutation_outcomes";
2173
2509
  // delta fanning to every subscribed solo client; no clientID/mid — a system write must never
2174
2510
  // advance an lmid — and no idempotencyKey — a renewal's re-upsert must re-run, that is the
2175
2511
  // refresh), and the count is one `executeSqlRead` with `consistency: "strong"` — the read surface
2176
- // the api-server already has against the daemon (the `DaemonLazyTx` fallback precedent above).
2512
+ // the api-server already has against the daemon (the `RemoteLazyTx` fallback precedent above).
2177
2513
  // "strong" routes the read to the WRITE MASTER in a split deploy, which just serialized our
2178
2514
  // upsert: read-your-writes without a mutation session (the interactive-txn machinery is optional
2179
2515
  // on the daemon interface and far heavier than this two-round-trip pair needs).
@@ -2241,20 +2577,6 @@ function docClientAst(table, doc, clientId) {
2241
2577
  /** Default room lease token TTL: short (minutes) per RINDLE-REALTIME §4.1 — renewal is a fresh
2242
2578
  * lease through the api-server, never an extension of this token. */
2243
2579
  const DEFAULT_ROOM_TOKEN_TTL_MS = 5 * 60_000;
2244
- /** Deterministic JSON: object keys sorted recursively, so two structurally identical ASTs from
2245
- * independent resolves stringify identically (the verdict-cache key). */
2246
- function stableStringify(v) {
2247
- return JSON.stringify(v, (_key, value) => {
2248
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
2249
- const rec = value;
2250
- const sorted = {};
2251
- for (const k of Object.keys(rec).sort())
2252
- sorted[k] = rec[k];
2253
- return sorted;
2254
- }
2255
- return value;
2256
- });
2257
- }
2258
2580
  /** Does the AST contain an aggregate/reduce shape ANYWHERE (root, a `related` subquery, or an
2259
2581
  * `EXISTS` child)? Room-serving refuses these regardless of coverage: the client's aggregate
2260
2582
  * overlay (AGGREGATE-SYNC) is computed against the DAEMON's normalized stream and stays