@rindle/api-server 0.6.3 → 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,14 +249,6 @@ 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 hosted by rindled: keep it on the read/follower leg. The write master owns
252
- // room durability, not query-cover analysis.
253
- coverQuery(input) {
254
- const cover = this.reads.coverQuery?.bind(this.reads);
255
- if (!cover)
256
- return Promise.reject(new Error("the read follower lacks coverQuery"));
257
- return cover(input);
258
- }
259
252
  migrate(input) {
260
253
  return this.writes.migrate(input);
261
254
  }
@@ -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
@@ -449,6 +439,9 @@ class AbsorbedReplay extends Error {
449
439
  }
450
440
  const MUTATOR_CONFLICT_MAX_ATTEMPTS = 5;
451
441
  function isRetryableCommitConflict(error) {
442
+ if (error instanceof RindleSqlError) {
443
+ return error.status === 409 && (error.code === "retryable-conflict" || error.code === "TRANSACTION_CONFLICT");
444
+ }
452
445
  if (!(error instanceof DaemonHttpError) || error.status !== 409)
453
446
  return false;
454
447
  try {
@@ -464,10 +457,248 @@ async function mutatorConflictBackoff(attempt) {
464
457
  const millis = ceiling + Math.floor(Math.random() * 4);
465
458
  await new Promise((resolve) => setTimeout(resolve, millis));
466
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
+ }
467
698
  /**
468
- * 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
469
700
  * execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
470
- * `/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
471
702
  * mutation session at the mutator's first read: `begin` carries the envelope identity, the
472
703
  * accumulated statement prefix (sound to replay — nothing before the first read observed DB
473
704
  * state, §5.2), and the read itself, so a one-read mutator pays exactly one extra round trip.
@@ -476,40 +707,61 @@ async function mutatorConflictBackoff(attempt) {
476
707
  * reads cost k+2 round trips regardless of write count.
477
708
  *
478
709
  * Begin-time mid dedup can ABSORB the envelope (a redelivery whose commit response was lost):
479
- * 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}
480
711
  * unwinds the body — the latch (not the throw) is authoritative, so a mutator that swallows
481
712
  * the unwind still cannot re-apply (no session opened; buffered writes are never shipped).
482
713
  * A daemon client without session support keeps the LEGACY committed-state point read.
483
714
  */
484
- class DaemonLazyTx {
715
+ class RemoteLazyTx {
485
716
  /** Pre-upgrade: the accumulated batch/prefix. Post-upgrade: writes buffered for the next flush. */
486
717
  stmts = [];
487
718
  render;
488
- daemon;
719
+ transport;
489
720
  envelope;
490
- sessionId;
721
+ sessionHandle;
722
+ sql;
491
723
  /** The begin-absorbed replay output (§4.1), latched for the backend. */
492
724
  absorbed;
493
725
  idempotencyKey;
494
- constructor(render, daemon, envelope) {
726
+ constructor(render, transport, envelope) {
495
727
  this.render = render;
496
- this.daemon = daemon;
728
+ this.transport = transport;
497
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
+ };
498
741
  }
499
742
  /** True once the tx upgraded to an interactive session (the backend then commits it). */
500
743
  get session() {
501
- return this.sessionId !== undefined;
744
+ return this.sessionHandle !== undefined;
502
745
  }
503
746
  get statements() {
504
747
  return this.stmts;
505
748
  }
506
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
+ }
507
756
  this.stmts.push({ sql, params });
508
757
  }
509
758
  push(op) {
510
759
  const rendered = renderOp(op, tableMeta(this.render, op.table), sqliteDialect);
511
- if (rendered)
760
+ if (rendered) {
761
+ if (this.transport.strictValues)
762
+ assertEncodableParams(rendered.sql, rendered.params);
512
763
  this.stmts.push(rendered);
764
+ }
513
765
  return Promise.resolve();
514
766
  }
515
767
  insert(table, row) {
@@ -549,44 +801,49 @@ class DaemonLazyTx {
549
801
  return ast.one === true ? null : [];
550
802
  return JSON.parse(cell);
551
803
  }
804
+ async querySql(sql, params) {
805
+ const out = await this.readThroughTxn({ sql, params: [...params] });
806
+ return keyedSqlRows(out.cols, out.rows);
807
+ }
552
808
  /** Run one read: upgrade to a session at the first (§5.1), ride the open one after, or fall
553
809
  * back to the legacy committed-state read when the daemon client lacks sessions. */
554
810
  async readThroughTxn(read) {
555
811
  if (this.absorbed)
556
812
  throw new AbsorbedReplay();
557
- 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) {
558
820
  try {
559
- return await this.daemon.executeSqlRead({ sql: read.sql, params: read.params });
821
+ return await this.transport.readCommitted(read);
560
822
  }
561
823
  catch (err) {
562
824
  throw new BackendError(err);
563
825
  }
564
826
  }
565
827
  try {
566
- if (this.sessionId === undefined) {
567
- const opened = await this.daemon.beginMutationSession({
568
- clientID: this.envelope.clientID,
569
- mid: this.envelope.mid,
828
+ if (this.sessionHandle === undefined) {
829
+ const opened = await this.transport.begin({
830
+ envelope: this.envelope,
570
831
  statements: this.stmts.splice(0),
571
832
  query: read,
833
+ ...(this.idempotencyKey !== undefined ? { idempotencyKey: this.idempotencyKey } : {}),
572
834
  });
573
835
  if (opened.absorbed) {
574
- const { absorbed: _a, sessionId: _s, read: _r, ...output } = opened;
575
- this.absorbed = output;
836
+ this.absorbed = opened.absorbed;
576
837
  throw new AbsorbedReplay();
577
838
  }
578
- if (!opened.sessionId || !opened.read) {
839
+ if (opened.handle === undefined || !opened.read) {
579
840
  throw new Error(`malformed mutate-session begin reply: ${JSON.stringify(opened)}`);
580
841
  }
581
- this.sessionId = opened.sessionId;
842
+ this.sessionHandle = opened.handle;
582
843
  return opened.read;
583
844
  }
584
845
  await this.flush();
585
- return await this.daemon.queryInMutationSession({
586
- sessionId: this.sessionId,
587
- sql: read.sql,
588
- params: read.params,
589
- });
846
+ return await this.transport.query(this.sessionHandle, read);
590
847
  }
591
848
  catch (err) {
592
849
  if (err instanceof AbsorbedReplay || err instanceof BackendError)
@@ -598,17 +855,18 @@ class DaemonLazyTx {
598
855
  async flush() {
599
856
  if (this.stmts.length === 0)
600
857
  return;
601
- await this.daemon.execInMutationSession({
602
- sessionId: this.sessionId,
603
- statements: this.stmts.splice(0),
604
- });
858
+ await this.transport.exec(this.sessionHandle, this.stmts.splice(0));
605
859
  }
606
860
  /** Flush + commit the open session — the daemon stamps lmid co-transactionally (§4.4) and
607
861
  * answers the same shape `/execute-sql-txn` does. */
608
862
  async commitSession() {
609
863
  try {
610
864
  await this.flush();
611
- 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;
612
870
  }
613
871
  catch (err) {
614
872
  throw err instanceof BackendError ? err : new BackendError(err);
@@ -617,12 +875,12 @@ class DaemonLazyTx {
617
875
  /** Best-effort rollback (the daemon's deadline is the backstop). MUST be awaited before a
618
876
  * follow-up `/reject-mutation`: that lmid-only commit needs the writer this session holds. */
619
877
  async rollbackSessionQuietly() {
620
- if (this.sessionId === undefined)
878
+ if (this.sessionHandle === undefined)
621
879
  return;
622
- const sessionId = this.sessionId;
623
- this.sessionId = undefined;
880
+ const sessionHandle = this.sessionHandle;
881
+ this.sessionHandle = undefined;
624
882
  try {
625
- await this.daemon.rollbackMutationSession({ sessionId });
883
+ await this.transport.rollback(sessionHandle);
626
884
  }
627
885
  catch {
628
886
  // Unreachable daemon / already-expired session: the deadline rollback covers it.
@@ -639,11 +897,25 @@ class PgLiveTx {
639
897
  q;
640
898
  render;
641
899
  rewrite;
900
+ sql;
642
901
  idempotencyKey;
643
902
  constructor(q, render, rewrite) {
644
903
  this.q = q;
645
904
  this.render = render;
646
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
+ };
647
919
  }
648
920
  get statements() {
649
921
  return this.stmts;
@@ -711,24 +983,29 @@ class PgLiveTx {
711
983
  // static-catalog + driver-pin wiring (POSTGRES-READ-COMPILER-DESIGN.md Phase B).
712
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"));
713
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
+ }
714
995
  }
715
- /**
716
- * The default {@link MutationBackend}. A pure-write mutator keeps the historical shape: writes
717
- * ACCUMULATE and ship as ONE batch to `/execute-sql-txn`, which stamps `lmid` co-transactionally
718
- * (and `/reject-mutation` advances it past a rejected mid) — byte-identical behavior. A
719
- * READ-bearing mutator lazily upgrades to an interactive mutation session
720
- * (DAEMON-INTERACTIVE-TXN-DESIGN.md): reads are read-your-writes through the open transaction
721
- * (PG parity), the commit stamps `lmid` in the same atomic unit, and a begin-absorbed replay
722
- * short-circuits without re-running the body.
723
- */
724
- 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) {
725
1001
  return {
726
1002
  dialect: sqliteDialect,
1003
+ outsideSql,
727
1004
  async runMutation(input) {
728
1005
  for (let attempt = 0; attempt < MUTATOR_CONFLICT_MAX_ATTEMPTS; attempt++) {
729
1006
  try {
730
1007
  const { envelope, render, run } = input;
731
- const tx = new DaemonLazyTx(render, daemon, envelope);
1008
+ const tx = new RemoteLazyTx(render, transport, envelope);
732
1009
  try {
733
1010
  await run(tx);
734
1011
  }
@@ -745,7 +1022,7 @@ export function daemonBackend(daemon) {
745
1022
  // Data first, watermark second: rollback releases this session's connection before
746
1023
  // the lmid-only rejection commit.
747
1024
  await tx.rollbackSessionQuietly();
748
- const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1025
+ const output = await transport.reject({ envelope, reason });
749
1026
  return { accepted: false, reason, output };
750
1027
  }
751
1028
  if (tx.absorbed)
@@ -760,10 +1037,14 @@ export function daemonBackend(daemon) {
760
1037
  throw err;
761
1038
  }
762
1039
  }
763
- const txn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
764
- if (tx.idempotencyKey !== undefined)
765
- txn.idempotencyKey = tx.idempotencyKey;
766
- return { accepted: true, output: await daemon.executeSqlTxn(txn) };
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
+ };
767
1048
  }
768
1049
  catch (error) {
769
1050
  if (!isRetryableCommitConflict(error) || attempt + 1 === MUTATOR_CONFLICT_MAX_ATTEMPTS) {
@@ -775,10 +1056,21 @@ export function daemonBackend(daemon) {
775
1056
  throw new Error("unreachable mutator conflict retry loop");
776
1057
  },
777
1058
  reject({ envelope, reason }) {
778
- return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1059
+ return transport.reject({ envelope, reason });
779
1060
  },
780
1061
  };
781
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
+ }
782
1074
  /** The §2.3 upsert, verbatim from the contract: monotonic via GREATEST, keyed by client. The
783
1075
  * identifiers are lowercase so quoting is cosmetic, but quote-everything is the repo's PG rule. */
784
1076
  const LMID_UPSERT = `INSERT INTO "_rindle_client_mutations" ("client_id", "last_mutation_id")
@@ -807,8 +1099,28 @@ export function postgresBackend(plugger, opts = {}) {
807
1099
  await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
808
1100
  return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
809
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
+ };
810
1121
  return {
811
1122
  dialect: postgresDialect,
1123
+ outsideSql,
812
1124
  async runMutation({ envelope, render, run }) {
813
1125
  try {
814
1126
  const output = await plugger.transaction(async (q) => {
@@ -1106,6 +1418,47 @@ function normalizeCondition(c) {
1106
1418
  };
1107
1419
  }
1108
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
+ }
1109
1462
  /**
1110
1463
  * The runtime {@link MutationScope} handed to a {@link ScopedMutator}. It owns the single atomic
1111
1464
  * transaction (delegating to {@link MutationBackend.runMutation} — the exact machinery a tx-form
@@ -1123,6 +1476,7 @@ class MutationScopeImpl {
1123
1476
  backend;
1124
1477
  envelope;
1125
1478
  render;
1479
+ sql;
1126
1480
  /** Set once `transact` resolved through the backend (accepted OR business-rejected). */
1127
1481
  outcome;
1128
1482
  /** The value the backend threw on INFRA (the DB failed) — always propagated, never an `lmid`
@@ -1141,6 +1495,7 @@ class MutationScopeImpl {
1141
1495
  this.backend = backend;
1142
1496
  this.envelope = envelope;
1143
1497
  this.render = render;
1498
+ this.sql = scopedOutsideSql(backend.outsideSql);
1144
1499
  }
1145
1500
  transact(first, args, ctx) {
1146
1501
  if (this.attempted)
@@ -1179,8 +1534,24 @@ class MutationScopeImpl {
1179
1534
  export function createRindleApiServer(opts) {
1180
1535
  const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
1181
1536
  const mode = opts.mode ?? "normalized";
1182
- // The mutation seam defaulting to the daemon's co-transactional lmid stamp (unchanged behavior).
1183
- 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
+ }
1184
1555
  // Schema-derived render metadata for logical mutator writes; `{}` when no schema is configured (a
1185
1556
  // logical op then throws loudly — the tx never silently drops a write). Each backend renders in its
1186
1557
  // own dialect (`backend.dialect`: daemon→sqlite, postgres→postgres).
@@ -1234,18 +1605,12 @@ export function createRindleApiServer(opts) {
1234
1605
  `${reasons.join("; ")}. It serves from the daemon (correct, just not room-accelerated). ` +
1235
1606
  `This warning fires once per (query, profile).`);
1236
1607
  };
1237
- // The §2.3 aggregate refusal: the client's aggregate overlay is daemon-gated until post-G, so
1238
- // an aggregate/reduce-shaped query is refused room-serving REGARDLESS of coverage.
1239
- const AGGREGATE_REFUSAL = "the query AST contains an aggregate/reduce shape aggregate overlays are daemon-gated until post-G";
1240
- // Verdict cache. The verdict is a pure function of exactly two inputs the resolved footprint
1241
- // AST and the resolved query AST — so the tightest SOUND key is those two ASTs themselves
1242
- // (stable-stringified), scoped by (queryName, profile) for legibility. Args/user/ctx need no
1243
- // separate slot precisely because anything that changes the verdict must change one of the two
1244
- // ASTs (predicate literals embed the args; ctx-scoped queries embed the principal); keying on
1245
- // `(name, args)` alone would ALIAS two users' different ASTs under one verdict — unsound.
1246
- // Bounded FIFO (Map iterates in insertion order) so per-user literals can't grow it forever.
1247
- const coverVerdicts = new Map();
1248
- 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)";
1249
1614
  const maybeRoomServe = async (input, queryAst, context, subject) => {
1250
1615
  // (a) the label + (b) its profile — the fast bail keeps unlabeled leases byte-identical.
1251
1616
  const label = queryRealtimeLabel(opts.queries?.[input.name]);
@@ -1260,13 +1625,6 @@ export function createRindleApiServer(opts) {
1260
1625
  warnRoomServeOnce(input.name, profile.name, ["realtime.locateRoom is not configured"]);
1261
1626
  return undefined;
1262
1627
  }
1263
- const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
1264
- if (coverQuery === undefined) {
1265
- warnRoomServeOnce(input.name, profile.name, [
1266
- "the configured daemon client does not implement coverQuery (/cover-check)",
1267
- ]);
1268
- return undefined;
1269
- }
1270
1628
  const tokenKey = realtime.roomTokenKey;
1271
1629
  if (tokenKey === undefined) {
1272
1630
  warnRoomServeOnce(input.name, profile.name, [
@@ -1292,24 +1650,13 @@ export function createRindleApiServer(opts) {
1292
1650
  // profiles), with the §2.3 unwindowed backstop `/room-boot` also applies.
1293
1651
  const footprintAst = queryResultToAst(await profile.footprint(key, context));
1294
1652
  assertUnwindowedFootprint(footprintAst, profile.name);
1295
- const verdictKey = `${input.name}\u0000${profile.name}\u0000${stableStringify(footprintAst)}\u0000${stableStringify(queryAst)}`;
1296
- let verdict = coverVerdicts.get(verdictKey);
1297
- if (verdict === undefined) {
1298
- verdict = astHasAggregate(queryAst)
1299
- ? { covered: false, reasons: [AGGREGATE_REFUSAL] }
1300
- : await coverQuery({ footprint: footprintAst, query: queryAst });
1301
- // (A coverQuery THROW never lands here — the outer catch fail-opens without caching, so
1302
- // a transient daemon failure doesn't pin an uncovered verdict.)
1303
- if (coverVerdicts.size >= COVER_VERDICT_CACHE_MAX) {
1304
- coverVerdicts.delete(coverVerdicts.keys().next().value);
1305
- }
1306
- coverVerdicts.set(verdictKey, verdict);
1307
- }
1308
- if (!verdict.covered) {
1309
- 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]);
1310
1657
  return undefined;
1311
1658
  }
1312
- // Covered ⇒ assemble the realtime block. The room endpoint rides ITS OWN field
1659
+ // Assemble the realtime block. The room endpoint rides ITS OWN field
1313
1660
  // (`realtime.wsEndpoint`) — a separate connection from the daemon session's fixed ws host.
1314
1661
  const { wsEndpoint } = await realtime.locateRoom(doc);
1315
1662
  const now = Date.now();
@@ -1341,7 +1688,8 @@ export function createRindleApiServer(opts) {
1341
1688
  };
1342
1689
  }
1343
1690
  catch (e) {
1344
- // 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.
1345
1693
  warnRoomServeOnce(input.name, profile.name, [`room-serve failed: ${errMessage(e)}`]);
1346
1694
  return undefined;
1347
1695
  }
@@ -1587,12 +1935,12 @@ export function createRindleApiServer(opts) {
1587
1935
  const res = queryLeaseResponse(out);
1588
1936
  // I-iv (§4.1): the occupancy step FIRST — session sweep+upsert, then the D6 gate verdict. A
1589
1937
  // closed gate suppresses the room-serve ONLY (the lease ships without the realtime block,
1590
- // 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
1591
1939
  // below still rides; lifecycle-off ⇒ `gateOpen: true` unconditionally and this line is inert.
1592
1940
  const occ = await lifecycleOccupancy(input);
1593
- // 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
1594
1942
  // above is unconditional (and its fields untouched) — room-serving only ever adds a field,
1595
- // 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.
1596
1944
  const rt = occ.gateOpen ? await maybeRoomServe(input, ast, context, subject) : undefined;
1597
1945
  if (rt !== undefined)
1598
1946
  res.realtime = rt;
@@ -1792,57 +2140,6 @@ export function createRindleApiServer(opts) {
1792
2140
  throw new Error(`assertPins: ${failures.length} failed — ${failures.join("; ")}`);
1793
2141
  }
1794
2142
  };
1795
- // The explicit coverage diagnostic (the assertPins pattern: system-level, resolved under
1796
- // `pinUser`, per-query failures collected — never strand the rest). It runs the REAL check —
1797
- // the daemon's /cover-check on the actually-resolved ASTs — so its verdicts are exactly the
1798
- // lease path's, minus the serving wiring (locateRoom/roomTokenKey), which it deliberately
1799
- // ignores: it answers "is this labeled query coverable", the deployable-config question.
1800
- const validateRealtime = async (vopts) => {
1801
- const context = { user: opts.pinUser, request: undefined };
1802
- const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
1803
- const verdicts = [];
1804
- for (const [name, q] of Object.entries(opts.queries ?? {})) {
1805
- const label = queryRealtimeLabel(q);
1806
- if (label === undefined)
1807
- continue;
1808
- const profile = roomProfiles.get(label.room);
1809
- if (profile === undefined)
1810
- continue; // unreachable: construction asserted it exists
1811
- for (const args of vopts?.exemplars?.[name] ?? [null]) {
1812
- const verdict = { query: name, profile: profile.name, args, covered: false };
1813
- try {
1814
- const ast = await resolveAst(name, args, context);
1815
- const roomArgs = label.args !== undefined ? label.args(args) : args;
1816
- const footprintAst = queryResultToAst(await profile.footprint(profile.key(roomArgs), context));
1817
- assertUnwindowedFootprint(footprintAst, profile.name);
1818
- if (astHasAggregate(ast)) {
1819
- verdict.reasons = [AGGREGATE_REFUSAL];
1820
- }
1821
- else if (coverQuery === undefined) {
1822
- verdict.reasons = ["the configured daemon client does not implement coverQuery (/cover-check)"];
1823
- }
1824
- else {
1825
- const out = await coverQuery({ footprint: footprintAst, query: ast });
1826
- verdict.covered = out.covered;
1827
- if (!out.covered)
1828
- verdict.reasons = out.reasons ?? ["not provably covered"];
1829
- }
1830
- }
1831
- catch (e) {
1832
- verdict.reasons = [errMessage(e)];
1833
- }
1834
- verdicts.push(verdict);
1835
- }
1836
- }
1837
- const uncovered = verdicts.filter((v) => !v.covered);
1838
- if (vopts?.strict && uncovered.length > 0) {
1839
- throw new Error(`validateRealtime: ${uncovered.length} labeled query verdict(s) not provably covered — ` +
1840
- uncovered
1841
- .map((v) => `${v.query} (profile "${v.profile}"): ${(v.reasons ?? []).join("; ")}`)
1842
- .join(" | "));
1843
- }
1844
- return { verdicts, uncovered };
1845
- };
1846
2143
  // The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
1847
2144
  // the `realtime` block (which also activates `/room-boot`) or the deprecated bare
1848
2145
  // `authorizeRoom` (trio only). Hosting an authority is never a default.
@@ -1897,10 +2194,10 @@ export function createRindleApiServer(opts) {
1897
2194
  };
1898
2195
  return {
1899
2196
  routes,
2197
+ close: () => ownedSql?.close(),
1900
2198
  createQueryLease,
1901
2199
  readQuery,
1902
2200
  assertPins,
1903
- validateRealtime,
1904
2201
  pushMutation,
1905
2202
  pushMutations,
1906
2203
  handleApplyRowChangeTxnJson: async (body, context) => {
@@ -2087,8 +2384,9 @@ function applyOpToServerTx(tx, op) {
2087
2384
  }
2088
2385
  /** Feed a mutator's RETURNED result (the alternative to calling `tx.exec`/logical ops directly) into
2089
2386
  * the backend tx: a returned `SqlStatement[]` / `SqlTxn` is exec'd onto `tx`, and a carried
2090
- * `idempotencyKey` is stashed (the daemon backend honors it; PG ignores it). A `void` return is a
2091
- * 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. */
2092
2390
  function applyResultToTx(result, tx) {
2093
2391
  if (!result)
2094
2392
  return;
@@ -2211,7 +2509,7 @@ const ROOM_MUTATION_OUTCOMES_TABLE = "_rindle_room_mutation_outcomes";
2211
2509
  // delta fanning to every subscribed solo client; no clientID/mid — a system write must never
2212
2510
  // advance an lmid — and no idempotencyKey — a renewal's re-upsert must re-run, that is the
2213
2511
  // refresh), and the count is one `executeSqlRead` with `consistency: "strong"` — the read surface
2214
- // 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).
2215
2513
  // "strong" routes the read to the WRITE MASTER in a split deploy, which just serialized our
2216
2514
  // upsert: read-your-writes without a mutation session (the interactive-txn machinery is optional
2217
2515
  // on the daemon interface and far heavier than this two-round-trip pair needs).
@@ -2279,20 +2577,6 @@ function docClientAst(table, doc, clientId) {
2279
2577
  /** Default room lease token TTL: short (minutes) per RINDLE-REALTIME §4.1 — renewal is a fresh
2280
2578
  * lease through the api-server, never an extension of this token. */
2281
2579
  const DEFAULT_ROOM_TOKEN_TTL_MS = 5 * 60_000;
2282
- /** Deterministic JSON: object keys sorted recursively, so two structurally identical ASTs from
2283
- * independent resolves stringify identically (the verdict-cache key). */
2284
- function stableStringify(v) {
2285
- return JSON.stringify(v, (_key, value) => {
2286
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
2287
- const rec = value;
2288
- const sorted = {};
2289
- for (const k of Object.keys(rec).sort())
2290
- sorted[k] = rec[k];
2291
- return sorted;
2292
- }
2293
- return value;
2294
- });
2295
- }
2296
2580
  /** Does the AST contain an aggregate/reduce shape ANYWHERE (root, a `related` subquery, or an
2297
2581
  * `EXISTS` child)? Room-serving refuses these regardless of coverage: the client's aggregate
2298
2582
  * overlay (AGGREGATE-SYNC) is computed against the DAEMON's normalized stream and stays