@rindle/api-server 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -5
- package/dist/index.d.ts +67 -58
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +456 -172
- package/dist/index.js.map +1 -1
- package/dist/rooms.js +2 -2
- package/dist/rooms.js.map +1 -1
- package/package.json +6 -5
- package/src/index.ts +574 -221
- 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,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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
719
|
+
transport;
|
|
489
720
|
envelope;
|
|
490
|
-
|
|
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,
|
|
726
|
+
constructor(render, transport, envelope) {
|
|
495
727
|
this.render = render;
|
|
496
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
567
|
-
const opened = await this.
|
|
568
|
-
|
|
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
|
-
|
|
575
|
-
this.absorbed = output;
|
|
836
|
+
this.absorbed = opened.absorbed;
|
|
576
837
|
throw new AbsorbedReplay();
|
|
577
838
|
}
|
|
578
|
-
if (
|
|
839
|
+
if (opened.handle === undefined || !opened.read) {
|
|
579
840
|
throw new Error(`malformed mutate-session begin reply: ${JSON.stringify(opened)}`);
|
|
580
841
|
}
|
|
581
|
-
this.
|
|
842
|
+
this.sessionHandle = opened.handle;
|
|
582
843
|
return opened.read;
|
|
583
844
|
}
|
|
584
845
|
await this.flush();
|
|
585
|
-
return await this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
878
|
+
if (this.sessionHandle === undefined)
|
|
621
879
|
return;
|
|
622
|
-
const
|
|
623
|
-
this.
|
|
880
|
+
const sessionHandle = this.sessionHandle;
|
|
881
|
+
this.sessionHandle = undefined;
|
|
624
882
|
try {
|
|
625
|
-
await this.
|
|
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
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
|
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
|
-
//
|
|
1183
|
-
|
|
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
|
-
//
|
|
1238
|
-
//
|
|
1239
|
-
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
|
|
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
|
-
|
|
1296
|
-
|
|
1297
|
-
if (
|
|
1298
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2091
|
-
*
|
|
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 `
|
|
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
|