@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/README.md +52 -11
- package/dist/index.d.ts +97 -85
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +535 -213
- package/dist/index.js.map +1 -1
- package/dist/rooms.js +2 -2
- package/dist/rooms.js.map +1 -1
- package/package.json +9 -6
- package/src/index.ts +685 -288
- package/src/rooms.ts +2 -2
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
719
|
+
transport;
|
|
472
720
|
envelope;
|
|
473
|
-
|
|
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,
|
|
726
|
+
constructor(render, transport, envelope) {
|
|
478
727
|
this.render = render;
|
|
479
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
550
|
-
const opened = await this.
|
|
551
|
-
|
|
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
|
-
|
|
558
|
-
this.absorbed = output;
|
|
836
|
+
this.absorbed = opened.absorbed;
|
|
559
837
|
throw new AbsorbedReplay();
|
|
560
838
|
}
|
|
561
|
-
if (
|
|
839
|
+
if (opened.handle === undefined || !opened.read) {
|
|
562
840
|
throw new Error(`malformed mutate-session begin reply: ${JSON.stringify(opened)}`);
|
|
563
841
|
}
|
|
564
|
-
this.
|
|
842
|
+
this.sessionHandle = opened.handle;
|
|
565
843
|
return opened.read;
|
|
566
844
|
}
|
|
567
845
|
await this.flush();
|
|
568
|
-
return await this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
878
|
+
if (this.sessionHandle === undefined)
|
|
604
879
|
return;
|
|
605
|
-
const
|
|
606
|
-
this.
|
|
880
|
+
const sessionHandle = this.sessionHandle;
|
|
881
|
+
this.sessionHandle = undefined;
|
|
607
882
|
try {
|
|
608
|
-
await this.
|
|
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
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
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 (
|
|
738
|
-
if (
|
|
739
|
-
throw
|
|
740
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
1154
|
-
|
|
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
|
-
//
|
|
1209
|
-
//
|
|
1210
|
-
|
|
1211
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
|
|
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
|
-
|
|
1267
|
-
|
|
1268
|
-
if (
|
|
1269
|
-
|
|
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
|
-
//
|
|
1284
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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({
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
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
|
-
|
|
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
|
|
2049
|
-
*
|
|
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
|
-
|
|
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 `
|
|
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
|