@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/src/index.ts
CHANGED
|
@@ -16,13 +16,21 @@ import type {
|
|
|
16
16
|
ServerWriteTx,
|
|
17
17
|
} from "@rindle/client";
|
|
18
18
|
import { DaemonHttpError } from "@rindle/daemon-client";
|
|
19
|
+
import { createSqlClient, encodeSqlValue, RindleSqlError } from "@rindle/sql-client";
|
|
20
|
+
import type {
|
|
21
|
+
ClientOptions as SqlClientOptions,
|
|
22
|
+
MutationReceipt as SqlMutationReceipt,
|
|
23
|
+
MutationRows as SqlMutationRows,
|
|
24
|
+
SqlClient,
|
|
25
|
+
SqlMutationTransaction,
|
|
26
|
+
SqlSession,
|
|
27
|
+
Statement as PublicSqlStatement,
|
|
28
|
+
} from "@rindle/sql-client";
|
|
19
29
|
import { compile as compileQueryAst } from "@rindle/query-compiler";
|
|
20
30
|
import type { Catalog, ColumnType as QueryColumnType, TableSchema } from "@rindle/query-compiler";
|
|
21
31
|
import type {
|
|
22
32
|
ClaimRoomEpochInput,
|
|
23
33
|
ClaimRoomEpochOutput,
|
|
24
|
-
CoverQueryInput,
|
|
25
|
-
CoverQueryOutput,
|
|
26
34
|
DematerializeInput,
|
|
27
35
|
DematerializeOutput,
|
|
28
36
|
MaterializationPolicy,
|
|
@@ -155,10 +163,24 @@ export interface MutationContext<User> {
|
|
|
155
163
|
request?: unknown;
|
|
156
164
|
}
|
|
157
165
|
|
|
158
|
-
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
166
|
+
/** A deliberately narrow raw-SQL facade exposed by the API server. On {@link ServerMutationTx}
|
|
167
|
+
* it is bound to the open mutation transaction; on {@link MutationScope} each call runs in its
|
|
168
|
+
* own transaction outside the mutation boundary. Column aliases should be unique: positional
|
|
169
|
+
* driver rows are keyed by column name, so a duplicate alias is represented by its last value. */
|
|
170
|
+
export interface ServerSql {
|
|
171
|
+
/** Queue/execute one statement. A transaction-bound call commits with the surrounding mutation. */
|
|
172
|
+
execute(sql: string, params?: readonly WireValue[]): Promise<void>;
|
|
173
|
+
/** Queue/execute an ordered statement batch. An empty batch is a no-op. */
|
|
174
|
+
batch(statements: readonly SqlStatement[]): Promise<void>;
|
|
175
|
+
/** Run a read and return rows keyed by their column names. */
|
|
176
|
+
query<Row = Record<string, unknown>>(sql: string, params?: readonly WireValue[]): Promise<Row[]>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The raw-SQL escape hatch for relational/authority statements a keyed op can't express — an
|
|
180
|
+
* owner-gated cascade, a `NOT EXISTS` dedup. Prefer `tx.sql`; `exec` remains the synchronous
|
|
181
|
+
* compatibility shorthand for a queued `tx.sql.execute`, and `statements` is the raw write list. */
|
|
161
182
|
export interface SqlMutationTx {
|
|
183
|
+
readonly sql: ServerSql;
|
|
162
184
|
exec(sql: string, params?: WireValue[]): void;
|
|
163
185
|
readonly statements: readonly SqlStatement[];
|
|
164
186
|
}
|
|
@@ -166,17 +188,17 @@ export interface SqlMutationTx {
|
|
|
166
188
|
/** The write handle a server mutator runs against — the ASYNC twin of the client's `MutationTx`. It
|
|
167
189
|
* is both the isomorphic {@link ServerWriteTx} logical surface (insert/update/upsert/insertIgnore/
|
|
168
190
|
* delete/row, rendered to dialect SQL) AND the legacy {@link SqlMutationTx} raw escape hatch. Both
|
|
169
|
-
*
|
|
170
|
-
* everything live; the daemon
|
|
171
|
-
* mutation session at the first read (DAEMON-INTERACTIVE-TXN-DESIGN.md §5). */
|
|
191
|
+
* implementations run reads through the OPEN transaction (read-your-writes): Postgres executes
|
|
192
|
+
* everything live; the SQL-client and daemon adapters accumulate writes and lazily upgrade to an
|
|
193
|
+
* interactive mutation session at the first read (DAEMON-INTERACTIVE-TXN-DESIGN.md §5). */
|
|
172
194
|
export interface ServerMutationTx extends ServerWriteTx, SqlMutationTx {
|
|
173
195
|
/** Run a full query (a fluent `Query` or its wire `Ast`) INSIDE the open transaction —
|
|
174
196
|
* read-your-writes, like {@link ServerWriteTx.row} but for arbitrary shapes. Returns the
|
|
175
197
|
* parsed nested result tree: an array for a plural root, an object or `null` for a `.one()`
|
|
176
198
|
* root, with cells in their raw SQLite storage-class representations (the same vocabulary
|
|
177
|
-
* `row` speaks).
|
|
178
|
-
* params, NO casts — §5.4) and executed through the mutation session. Postgres
|
|
179
|
-
*
|
|
199
|
+
* `row` speaks). Remote SQLite backends: compiled by `@rindle/query-compiler`'s sqlite dialect
|
|
200
|
+
* (bind params, NO casts — §5.4) and executed through the mutation session. Postgres: lands with
|
|
201
|
+
* the read-compiler catalog integration (POSTGRES-READ-COMPILER-DESIGN.md Phase B). */
|
|
180
202
|
query(q: Ast | Query<any, any, any>): Promise<unknown>;
|
|
181
203
|
}
|
|
182
204
|
|
|
@@ -223,6 +245,10 @@ export class MutationRejected extends Error {
|
|
|
223
245
|
* `transact`, or a swallowed {@link MutationRejected} still advances `lmid` and never wedges the
|
|
224
246
|
* client's pending queue. */
|
|
225
247
|
export interface MutationScope {
|
|
248
|
+
/** Raw SQL OUTSIDE the mutation transaction. Every call commits independently and therefore may
|
|
249
|
+
* be observed even if {@link transact} later rejects or fails. Calls may also repeat when an
|
|
250
|
+
* envelope is retried, so outside writes need their own idempotency key/unique constraint. */
|
|
251
|
+
readonly sql: ServerSql;
|
|
226
252
|
/** Open the ONE atomic write transaction and drive `body` inside it, committing (stamping `lmid`
|
|
227
253
|
* co-transactionally) on a clean return. MAY be called at most once — a second call throws.
|
|
228
254
|
*
|
|
@@ -308,10 +334,9 @@ export interface QueryLeaseRequest<User> {
|
|
|
308
334
|
|
|
309
335
|
/**
|
|
310
336
|
* The room-serve block on a query lease (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1 step 5 / §2.4,
|
|
311
|
-
* slice G-iv-b): present
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
* session.
|
|
337
|
+
* slice G-iv-b): present when the named query carries a realtime label naming a configured room
|
|
338
|
+
* profile (302 §5 — declared, not derived; no coverage proof). The G-v client uses it to open the
|
|
339
|
+
* room transport for THIS query beside — never instead of — its daemon session.
|
|
315
340
|
*
|
|
316
341
|
* It is a dedicated block on purpose: the ROOM ws is a SEPARATE connection this query opens beside
|
|
317
342
|
* its daemon session (never a migration of the daemon session — the daemon ws host is fixed and
|
|
@@ -453,10 +478,10 @@ export type MutationOutcome =
|
|
|
453
478
|
|
|
454
479
|
/**
|
|
455
480
|
* Where a mutation runs and who stamps `lmid` — the seam that makes the mutator authoring surface
|
|
456
|
-
* backend-agnostic (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6; MUTATORS-ISOMORPHIC plan).
|
|
457
|
-
* ship: {@link
|
|
458
|
-
*
|
|
459
|
-
*
|
|
481
|
+
* backend-agnostic (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6; MUTATORS-ISOMORPHIC plan). Three
|
|
482
|
+
* implementations ship: {@link sqlBackend} is the preferred managed-SQL path; {@link daemonBackend}
|
|
483
|
+
* keeps the private control-plane compatibility path; and {@link postgresBackend} runs a real
|
|
484
|
+
* interactive PG transaction with confirmation riding the CDC loop down.
|
|
460
485
|
*
|
|
461
486
|
* The load-bearing invariant is that a mutation ALWAYS advances the client's `last_mutation_id`:
|
|
462
487
|
* - `runMutation` runs the mutator inside the backend's transaction; on success it advances `lmid`
|
|
@@ -470,6 +495,9 @@ export type MutationOutcome =
|
|
|
470
495
|
export interface MutationBackend {
|
|
471
496
|
/** The SQL dialect this backend renders logical ops to (drives placeholder style). */
|
|
472
497
|
readonly dialect: SqlDialect;
|
|
498
|
+
/** Optional raw-SQL surface outside the mutation transaction. Built-in backends provide it;
|
|
499
|
+
* custom backends may omit it, in which case `scope.sql` fails as an infrastructure error. */
|
|
500
|
+
readonly outsideSql?: ServerSql;
|
|
473
501
|
runMutation(input: MutationRunInput): Promise<MutationOutcome>;
|
|
474
502
|
reject(input: { envelope: MutationEnvelope; reason: string }): Promise<unknown>;
|
|
475
503
|
}
|
|
@@ -771,11 +799,30 @@ export async function verifyRoomFlushCredential(
|
|
|
771
799
|
return payload;
|
|
772
800
|
}
|
|
773
801
|
|
|
802
|
+
/** Database connection used by the API server's managed SQL path.
|
|
803
|
+
*
|
|
804
|
+
* `intMode` defaults to `"number"` because logical Rindle rows use the JSON-safe {@link WireValue}
|
|
805
|
+
* vocabulary — `"bigint"` does not survive `JSON.stringify`, and `"string"` silently retypes every
|
|
806
|
+
* integer, breaking arithmetic in mutator bodies. The cost is a HARD BOUND: a mutator read of an
|
|
807
|
+
* integer outside ±(2^53 − 1) rejects that mutation rather than silently rounding it. Tables with
|
|
808
|
+
* keys beyond that range (snowflake ids, and so on) must override `intMode` and have their mutators
|
|
809
|
+
* handle the resulting type. Commit receipts are unaffected — they never decode row values. */
|
|
810
|
+
export type RindleDatabaseOptions = Pick<SqlClientOptions, "url" | "authToken" | "fetch" | "intMode">;
|
|
811
|
+
|
|
774
812
|
export interface RindleApiServerOptions<User> {
|
|
775
813
|
daemon: RindleDaemonClient;
|
|
814
|
+
/** Preferred managed setup. The API server constructs and owns its SQL client; authoritative
|
|
815
|
+
* mutators, `tx.sql`, and `scope.sql` use it, while `daemon` remains only the lease/query/
|
|
816
|
+
* materialization/room control plane. Mutually exclusive with {@link sql} unless `backend`
|
|
817
|
+
* explicitly replaces both. */
|
|
818
|
+
database?: RindleDatabaseOptions;
|
|
819
|
+
/** Advanced injection/test seam for an already-created SQL session. Most applications should
|
|
820
|
+
* configure {@link database} and never import `createSqlClient`. When present (and `backend` is
|
|
821
|
+
* absent), authoritative mutators run through {@link sqlBackend}. */
|
|
822
|
+
sql?: SqlSession;
|
|
776
823
|
/** Where mutations are applied and `lmid` is stamped ({@link MutationBackend}). Default:
|
|
777
|
-
* `
|
|
778
|
-
* Postgres is the source of truth
|
|
824
|
+
* managed `sqlBackend` when `database` or `sql` is configured, otherwise the compatibility
|
|
825
|
+
* `daemonBackend(daemon)`. Pass `postgresBackend(...)` when Postgres is the source of truth. */
|
|
779
826
|
backend?: MutationBackend;
|
|
780
827
|
/** The typed schema (`createSchema`/`refineSchema`). Required only when a mutator uses the LOGICAL
|
|
781
828
|
* write vocabulary (`tx.insert`/`update`/`upsert`/`insertIgnore`/`delete`/`row`) — it drives the
|
|
@@ -845,24 +892,15 @@ export interface RindleApiServerOptions<User> {
|
|
|
845
892
|
|
|
846
893
|
export interface RindleApiServer<User> {
|
|
847
894
|
readonly routes: RindleApiRoutes;
|
|
895
|
+
/** Close the SQL client created from {@link RindleApiServerOptions.database}. Injected SQL
|
|
896
|
+
* sessions and custom backends remain caller-owned. Idempotent. */
|
|
897
|
+
close(): void;
|
|
848
898
|
createQueryLease(input: QueryLeaseRequest<User>): Promise<QueryLeaseResponse>;
|
|
849
899
|
/** (Re-)materialize every `pinnedQueries` entry with a pinned policy. Idempotent — the daemon
|
|
850
900
|
* dedupes by canonical query, so a re-assert reuses the existing materialization. Call it at
|
|
851
901
|
* startup and whenever the daemon restarts (e.g. from the daemon-client `onBootId` hook), since
|
|
852
902
|
* the daemon holds no durable materialization state. No-op when `pinnedQueries` is empty. */
|
|
853
903
|
assertPins(): Promise<void>;
|
|
854
|
-
/** The room-serving coverage DIAGNOSTIC (G-iv-b; the `assertPins`-style explicit check): for
|
|
855
|
-
* every realtime-labeled query, resolve it (under `pinUser`, per exemplar args), resolve its
|
|
856
|
-
* profile's footprint, and run the REAL daemon coverage check — the same verdict the lease
|
|
857
|
-
* path serves by. Returns every verdict; `strict` throws when any labeled query is not
|
|
858
|
-
* provably covered (deploy-gate mode). Deliberately ignores `locateRoom`/`roomTokenKey` — it
|
|
859
|
-
* answers "would this query be coverable", not "is serving fully wired". */
|
|
860
|
-
validateRealtime(opts?: {
|
|
861
|
-
/** Per-query exemplar args (a query is checked once per exemplar; default: one `null`). */
|
|
862
|
-
exemplars?: Partial<Record<string, readonly unknown[]>>;
|
|
863
|
-
/** Throw when any labeled query is uncovered (instead of just reporting). */
|
|
864
|
-
strict?: boolean;
|
|
865
|
-
}): Promise<ValidateRealtimeReport>;
|
|
866
904
|
pushMutation(input: PushMutationRequest<User>): Promise<PushMutationResponse>;
|
|
867
905
|
/** Apply an in-order batch (the client mutation queue's flush). Envelopes run strictly
|
|
868
906
|
* sequentially; a rejection still advances the daemon's lmid, so later envelopes in the
|
|
@@ -901,24 +939,6 @@ export interface RindleApiServer<User> {
|
|
|
901
939
|
handleRoomBootJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
|
|
902
940
|
}
|
|
903
941
|
|
|
904
|
-
/** One {@link RindleApiServer.validateRealtime} verdict: a labeled query × exemplar args. */
|
|
905
|
-
export interface ValidateRealtimeVerdict {
|
|
906
|
-
query: string;
|
|
907
|
-
profile: string;
|
|
908
|
-
args: unknown;
|
|
909
|
-
covered: boolean;
|
|
910
|
-
/** Why it is not covered (uncovered verdicts only) — the daemon's reason strings, the
|
|
911
|
-
* aggregate refusal, or a resolve/footprint error message. */
|
|
912
|
-
reasons?: string[];
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
/** The {@link RindleApiServer.validateRealtime} report. */
|
|
916
|
-
export interface ValidateRealtimeReport {
|
|
917
|
-
verdicts: ValidateRealtimeVerdict[];
|
|
918
|
-
/** The uncovered subset of `verdicts` (what `strict` throws on). */
|
|
919
|
-
uncovered: ValidateRealtimeVerdict[];
|
|
920
|
-
}
|
|
921
|
-
|
|
922
942
|
export type RindleApiErrorCode = "bad-request" | "forbidden" | "not-found" | "rejected";
|
|
923
943
|
|
|
924
944
|
export class RindleApiError extends Error {
|
|
@@ -1013,13 +1033,6 @@ export class SplitDaemonClient implements RindleDaemonClient {
|
|
|
1013
1033
|
if (!lmids) return Promise.reject(new Error("the write master lacks roomLmids"));
|
|
1014
1034
|
return lmids(input);
|
|
1015
1035
|
}
|
|
1016
|
-
// Pure computation hosted by rindled: keep it on the read/follower leg. The write master owns
|
|
1017
|
-
// room durability, not query-cover analysis.
|
|
1018
|
-
coverQuery(input: CoverQueryInput): Promise<CoverQueryOutput> {
|
|
1019
|
-
const cover = this.reads.coverQuery?.bind(this.reads);
|
|
1020
|
-
if (!cover) return Promise.reject(new Error("the read follower lacks coverQuery"));
|
|
1021
|
-
return cover(input);
|
|
1022
|
-
}
|
|
1023
1036
|
migrate(input: MigrateInput): Promise<MigrateOutput> {
|
|
1024
1037
|
return this.writes.migrate(input);
|
|
1025
1038
|
}
|
|
@@ -1200,9 +1213,6 @@ export class BackendError extends Error {
|
|
|
1200
1213
|
}
|
|
1201
1214
|
}
|
|
1202
1215
|
|
|
1203
|
-
/** The rindle/daemon server tx: logical writes render to SQLite and ACCUMULATE into one batch;
|
|
1204
|
-
* raw `exec` accumulates too; `row` reads COMMITTED state through the daemon (no read-your-writes,
|
|
1205
|
-
* the one interactive-txn limitation of the daemon backend). */
|
|
1206
1216
|
/** Build the compiler {@link Catalog} for ONE ast from the render index: columns/pk from the
|
|
1207
1217
|
* schema; relationship cardinality from the AST ITSELF — a Rindle relationship is declared at
|
|
1208
1218
|
* the query site (`sub(alias, rel)` / `.one()`), never on the schema, so the alias→cardinality
|
|
@@ -1256,6 +1266,9 @@ class AbsorbedReplay extends Error {
|
|
|
1256
1266
|
const MUTATOR_CONFLICT_MAX_ATTEMPTS = 5;
|
|
1257
1267
|
|
|
1258
1268
|
function isRetryableCommitConflict(error: unknown): boolean {
|
|
1269
|
+
if (error instanceof RindleSqlError) {
|
|
1270
|
+
return error.status === 409 && (error.code === "retryable-conflict" || error.code === "TRANSACTION_CONFLICT");
|
|
1271
|
+
}
|
|
1259
1272
|
if (!(error instanceof DaemonHttpError) || error.status !== 409) return false;
|
|
1260
1273
|
try {
|
|
1261
1274
|
const body = JSON.parse(error.body) as { code?: unknown; retryable?: unknown };
|
|
@@ -1271,10 +1284,296 @@ async function mutatorConflictBackoff(attempt: number): Promise<void> {
|
|
|
1271
1284
|
await new Promise<void>((resolve) => setTimeout(resolve, millis));
|
|
1272
1285
|
}
|
|
1273
1286
|
|
|
1287
|
+
/** True when this error is the SQL codec refusing a bind value outright (`undefined`, `Date`, `NaN`,
|
|
1288
|
+
* a binary view, an out-of-i64 bigint) rather than a transport or database failure. */
|
|
1289
|
+
function isUnencodableBind(error: unknown): boolean {
|
|
1290
|
+
return error instanceof RindleSqlError && error.code === "VALUE_UNSUPPORTED";
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** Refuse an unencodable bind at the point the MUTATOR supplies it, so it surfaces as a BUSINESS
|
|
1294
|
+
* rejection (lmid advances, the browser retires its prediction) instead of an infrastructure
|
|
1295
|
+
* failure. Left as infra it is retried forever against a deterministic mutator, which wedges the
|
|
1296
|
+
* client's mutation queue behind a poison message.
|
|
1297
|
+
*
|
|
1298
|
+
* Only the SQL transport needs this: the legacy daemon encoder is JSON, which silently coerces the
|
|
1299
|
+
* same values (`undefined`/`NaN` -> null, `Date` -> an ISO string). Asserting there would invent a
|
|
1300
|
+
* failure that the wire does not actually have. */
|
|
1301
|
+
function assertEncodableParams(sql: string, params: readonly WireValue[] | undefined): void {
|
|
1302
|
+
if (params === undefined) return;
|
|
1303
|
+
for (let index = 0; index < params.length; index++) {
|
|
1304
|
+
try {
|
|
1305
|
+
encodeSqlValue(params[index] as Parameters<typeof encodeSqlValue>[0]);
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
if (!isUnencodableBind(error)) throw error;
|
|
1308
|
+
throw new Error(`bind ${index} of \`${sql}\` cannot be stored: ${errMessage(error)}`);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/** Leading keywords the SQL mutation surface structurally REFUSES inside a mutator's write batch: a
|
|
1314
|
+
* read (`SELECT`/`EXPLAIN`), transaction control, a connection `PRAGMA`, or DDL. None can begin a
|
|
1315
|
+
* valid mutation write, so refusing them has no false positives — a `WITH`-prefixed statement is
|
|
1316
|
+
* deliberately absent because it may resolve to either a read or a write, and the server stays the
|
|
1317
|
+
* authority for that case. */
|
|
1318
|
+
const MUTATION_REFUSED_LEADING_KEYWORDS = new Set([
|
|
1319
|
+
"SELECT", "EXPLAIN", "VALUES",
|
|
1320
|
+
"BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE", "END",
|
|
1321
|
+
"PRAGMA", "VACUUM", "ATTACH", "DETACH",
|
|
1322
|
+
"CREATE", "ALTER", "DROP", "REINDEX", "ANALYZE",
|
|
1323
|
+
]);
|
|
1324
|
+
|
|
1325
|
+
/** Refuse a statement whose CLASS the mutation surface rejects, at the point the MUTATOR supplies it,
|
|
1326
|
+
* so it surfaces as a business rejection instead of a poison. When the batch reaches the transport
|
|
1327
|
+
* the body has already returned, so a server 400 there is (mis)read as infrastructure and retried
|
|
1328
|
+
* forever — the same wedge {@link assertEncodableParams} prevents for bind values. Conservative by
|
|
1329
|
+
* design: it fires only for a leading keyword that can never start a valid write, and leaves every
|
|
1330
|
+
* ambiguous case (including CTE-prefixed writes) to the server's authoritative classifier. */
|
|
1331
|
+
function assertMutationWriteStatement(sql: string): void {
|
|
1332
|
+
const match = /^[\s;]*([a-zA-Z]+)/.exec(sql);
|
|
1333
|
+
if (match === null) return;
|
|
1334
|
+
const keyword = match[1]!.toUpperCase();
|
|
1335
|
+
if (MUTATION_REFUSED_LEADING_KEYWORDS.has(keyword)) {
|
|
1336
|
+
throw new Error(
|
|
1337
|
+
`a mutator write statement cannot begin with ${keyword} (\`${sql}\`); ` +
|
|
1338
|
+
`mutations write rows only — use tx.sql.query(...) for reads and migrations for DDL`,
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
interface MutationTransportBegin {
|
|
1344
|
+
handle?: unknown;
|
|
1345
|
+
absorbed?: SqlTxnOutput;
|
|
1346
|
+
read?: SqlReadOutput;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** The mutation-only transport consumed by the API-server transaction harness. Both the legacy
|
|
1350
|
+
* daemon client and `@rindle/sql-client` adapt to this one shape, so lmid/rejection/lazy-session
|
|
1351
|
+
* policy is implemented once. */
|
|
1352
|
+
interface MutationTransport {
|
|
1353
|
+
readonly interactive: boolean;
|
|
1354
|
+
/** Whether this transport's wire REFUSES values the daemon's JSON encoder coerces. Drives
|
|
1355
|
+
* {@link assertEncodableParams} — see its docs for why the daemon adapter opts out. */
|
|
1356
|
+
readonly strictValues: boolean;
|
|
1357
|
+
execute(input: {
|
|
1358
|
+
envelope: MutationEnvelope;
|
|
1359
|
+
statements: SqlStatement[];
|
|
1360
|
+
idempotencyKey?: string;
|
|
1361
|
+
}): Promise<SqlTxnOutput>;
|
|
1362
|
+
reject(input: { envelope: MutationEnvelope; reason: string }): Promise<unknown>;
|
|
1363
|
+
begin(input: {
|
|
1364
|
+
envelope: MutationEnvelope;
|
|
1365
|
+
statements: SqlStatement[];
|
|
1366
|
+
query: SqlStatement;
|
|
1367
|
+
idempotencyKey?: string;
|
|
1368
|
+
}): Promise<MutationTransportBegin>;
|
|
1369
|
+
exec(handle: unknown, statements: SqlStatement[]): Promise<void>;
|
|
1370
|
+
query(handle: unknown, statement: SqlStatement): Promise<SqlReadOutput>;
|
|
1371
|
+
commit(handle: unknown): Promise<SqlTxnOutput>;
|
|
1372
|
+
rollback(handle: unknown): Promise<void>;
|
|
1373
|
+
readCommitted(statement: SqlStatement): Promise<SqlReadOutput>;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function daemonMutationTransport(daemon: RindleDaemonClient): MutationTransport {
|
|
1377
|
+
return {
|
|
1378
|
+
interactive: daemon.beginMutationSession !== undefined,
|
|
1379
|
+
strictValues: false,
|
|
1380
|
+
execute({ envelope, statements, idempotencyKey }) {
|
|
1381
|
+
const txn: SqlTxn = { statements, clientID: envelope.clientID, mid: envelope.mid };
|
|
1382
|
+
if (idempotencyKey !== undefined) txn.idempotencyKey = idempotencyKey;
|
|
1383
|
+
return daemon.executeSqlTxn(txn);
|
|
1384
|
+
},
|
|
1385
|
+
reject({ envelope, reason }) {
|
|
1386
|
+
return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
|
|
1387
|
+
},
|
|
1388
|
+
async begin({ envelope, statements, query, idempotencyKey }) {
|
|
1389
|
+
if (!daemon.beginMutationSession) throw new Error("the daemon client does not support mutation sessions");
|
|
1390
|
+
const input: MutationSessionBegin = {
|
|
1391
|
+
clientID: envelope.clientID,
|
|
1392
|
+
mid: envelope.mid,
|
|
1393
|
+
statements,
|
|
1394
|
+
query,
|
|
1395
|
+
};
|
|
1396
|
+
if (idempotencyKey !== undefined) input.idempotencyKey = idempotencyKey;
|
|
1397
|
+
const opened = await daemon.beginMutationSession(input);
|
|
1398
|
+
if (opened.absorbed) {
|
|
1399
|
+
const { absorbed: _absorbed, sessionId: _sessionId, read: _read, ...output } = opened;
|
|
1400
|
+
return { absorbed: output as SqlTxnOutput };
|
|
1401
|
+
}
|
|
1402
|
+
return { handle: opened.sessionId, read: opened.read };
|
|
1403
|
+
},
|
|
1404
|
+
async exec(handle, statements) {
|
|
1405
|
+
await daemon.execInMutationSession!({ sessionId: handle as string, statements });
|
|
1406
|
+
},
|
|
1407
|
+
query(handle, statement) {
|
|
1408
|
+
return daemon.queryInMutationSession!({
|
|
1409
|
+
sessionId: handle as string,
|
|
1410
|
+
sql: statement.sql,
|
|
1411
|
+
params: statement.params,
|
|
1412
|
+
});
|
|
1413
|
+
},
|
|
1414
|
+
commit(handle) {
|
|
1415
|
+
return daemon.commitMutationSession!({ sessionId: handle as string });
|
|
1416
|
+
},
|
|
1417
|
+
async rollback(handle) {
|
|
1418
|
+
await daemon.rollbackMutationSession!({ sessionId: handle as string });
|
|
1419
|
+
},
|
|
1420
|
+
readCommitted(statement) {
|
|
1421
|
+
return daemon.executeSqlRead({ sql: statement.sql, params: statement.params });
|
|
1422
|
+
},
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function mutationReceiptOutput(receipt: SqlMutationReceipt, clientID: string): SqlTxnOutput {
|
|
1427
|
+
const output: SqlTxnOutput = {
|
|
1428
|
+
applied: receipt.applied,
|
|
1429
|
+
lmid: receipt.lmid,
|
|
1430
|
+
lmidAdvances: [{ clientID, lmid: receipt.lmid }],
|
|
1431
|
+
};
|
|
1432
|
+
if (receipt.commitCursor !== null) output.cursor = receipt.commitCursor;
|
|
1433
|
+
return output;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function publicMutationStatement(statement: SqlStatement): PublicSqlStatement {
|
|
1437
|
+
return statement.params === undefined ? { sql: statement.sql } : { sql: statement.sql, args: statement.params };
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function mutationRowsOutput(rows: SqlMutationRows): SqlReadOutput {
|
|
1441
|
+
return { cols: rows.columns, rows: rows.rows as WireValue[][] };
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/** Convert the transports' compact positional rows into the ergonomic server-only raw-SQL shape. */
|
|
1445
|
+
function keyedSqlRows<Row = Record<string, unknown>>(
|
|
1446
|
+
columns: readonly string[],
|
|
1447
|
+
rows: readonly (readonly unknown[])[],
|
|
1448
|
+
): Row[] {
|
|
1449
|
+
// Row objects are keyed by column NAME, so a read that projects the same name twice
|
|
1450
|
+
// (`SELECT parent.status, child.status ...`) would silently keep only the last value — and a
|
|
1451
|
+
// mutator branching on `row.status` would then authorize against the wrong cell. Refuse it loudly
|
|
1452
|
+
// so the collision surfaces as a rejection reason instead of silent, wrong data.
|
|
1453
|
+
const seen = new Set<string>();
|
|
1454
|
+
for (const column of columns) {
|
|
1455
|
+
if (seen.has(column)) {
|
|
1456
|
+
throw new Error(
|
|
1457
|
+
`raw SQL read projects the column name ${JSON.stringify(column)} more than once; ` +
|
|
1458
|
+
`alias them to distinct names (e.g. SELECT a.id AS a_id, b.id AS b_id)`,
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
seen.add(column);
|
|
1462
|
+
}
|
|
1463
|
+
return rows.map((cells) => Object.fromEntries(columns.map((column, index) => [column, cells[index]])) as Row);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function daemonOutsideSql(daemon: RindleDaemonClient): ServerSql {
|
|
1467
|
+
return {
|
|
1468
|
+
async execute(sql, params = []) {
|
|
1469
|
+
await daemon.executeSqlTxn({ statements: [{ sql, params: [...params] }] });
|
|
1470
|
+
},
|
|
1471
|
+
async batch(statements) {
|
|
1472
|
+
if (statements.length === 0) return;
|
|
1473
|
+
await daemon.executeSqlTxn({
|
|
1474
|
+
statements: statements.map((statement) => ({
|
|
1475
|
+
sql: statement.sql,
|
|
1476
|
+
...(statement.params !== undefined ? { params: [...statement.params] } : {}),
|
|
1477
|
+
})),
|
|
1478
|
+
});
|
|
1479
|
+
},
|
|
1480
|
+
async query<Row = Record<string, unknown>>(sql: string, params: readonly WireValue[] = []): Promise<Row[]> {
|
|
1481
|
+
const out = await daemon.executeSqlRead({ sql, params: [...params], consistency: "strong" });
|
|
1482
|
+
return keyedSqlRows<Row>(out.cols, out.rows);
|
|
1483
|
+
},
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function sqlSessionOutsideSql(sql: SqlSession): ServerSql {
|
|
1488
|
+
return {
|
|
1489
|
+
async execute(text, params = []) {
|
|
1490
|
+
await sql.execute({ sql: text, args: [...params] });
|
|
1491
|
+
},
|
|
1492
|
+
async batch(statements) {
|
|
1493
|
+
if (statements.length === 0) return;
|
|
1494
|
+
await sql.batch(statements.map(publicMutationStatement));
|
|
1495
|
+
},
|
|
1496
|
+
async query<Row = Record<string, unknown>>(text: string, params: readonly WireValue[] = []): Promise<Row[]> {
|
|
1497
|
+
const out = await sql.execute({ sql: text, args: [...params], wantRows: true }, { consistency: "strong" });
|
|
1498
|
+
return keyedSqlRows<Row>(
|
|
1499
|
+
out.result.columns.map((column) => column.name),
|
|
1500
|
+
out.result.rows,
|
|
1501
|
+
);
|
|
1502
|
+
},
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function sqlClientMutationTransport(sql: SqlSession): MutationTransport {
|
|
1507
|
+
return {
|
|
1508
|
+
interactive: true,
|
|
1509
|
+
strictValues: true,
|
|
1510
|
+
// NOTE: `execute`/`begin` deliberately ignore the interface's optional `idempotencyKey`. On the
|
|
1511
|
+
// SQL mutation wire the (clientID, mid) pair IS the durable retry identity — a redelivery is
|
|
1512
|
+
// absorbed by mid, so there is no idempotency key to carry. The field stays on the shared
|
|
1513
|
+
// MutationTransport only because the legacy daemon foreign-write path still threads it.
|
|
1514
|
+
async execute({ envelope, statements }) {
|
|
1515
|
+
const receipt = await sql.executeMutation({
|
|
1516
|
+
clientId: envelope.clientID,
|
|
1517
|
+
mid: envelope.mid,
|
|
1518
|
+
statements: statements.map(publicMutationStatement),
|
|
1519
|
+
});
|
|
1520
|
+
return mutationReceiptOutput(receipt, envelope.clientID);
|
|
1521
|
+
},
|
|
1522
|
+
async reject({ envelope, reason }) {
|
|
1523
|
+
return mutationReceiptOutput(
|
|
1524
|
+
await sql.rejectMutation({ clientId: envelope.clientID, mid: envelope.mid, reason }),
|
|
1525
|
+
envelope.clientID,
|
|
1526
|
+
);
|
|
1527
|
+
},
|
|
1528
|
+
async begin({ envelope, statements, query }) {
|
|
1529
|
+
const opened = await sql.beginMutation({
|
|
1530
|
+
clientId: envelope.clientID,
|
|
1531
|
+
mid: envelope.mid,
|
|
1532
|
+
statements: statements.map(publicMutationStatement),
|
|
1533
|
+
query: publicMutationStatement(query),
|
|
1534
|
+
});
|
|
1535
|
+
if (opened.absorbed) {
|
|
1536
|
+
return { absorbed: mutationReceiptOutput(opened.receipt, envelope.clientID) };
|
|
1537
|
+
}
|
|
1538
|
+
return {
|
|
1539
|
+
handle: opened.transaction,
|
|
1540
|
+
...(opened.read !== undefined ? { read: mutationRowsOutput(opened.read) } : {}),
|
|
1541
|
+
};
|
|
1542
|
+
},
|
|
1543
|
+
async exec(handle, statements) {
|
|
1544
|
+
await (handle as SqlMutationTransaction).batch(statements.map(publicMutationStatement));
|
|
1545
|
+
},
|
|
1546
|
+
async query(handle, statement) {
|
|
1547
|
+
return mutationRowsOutput(await (handle as SqlMutationTransaction).query(publicMutationStatement(statement)));
|
|
1548
|
+
},
|
|
1549
|
+
async commit(handle) {
|
|
1550
|
+
const receipt = await (handle as SqlMutationTransaction).commit();
|
|
1551
|
+
const advance = receipt.lmid;
|
|
1552
|
+
// The handle is opened for exactly one client; RemoteLazyTx patches the client id from its
|
|
1553
|
+
// envelope after this call so the legacy MutationBackend receipt remains byte-compatible.
|
|
1554
|
+
return {
|
|
1555
|
+
applied: receipt.applied,
|
|
1556
|
+
cursor: receipt.commitCursor ?? undefined,
|
|
1557
|
+
lmid: advance,
|
|
1558
|
+
};
|
|
1559
|
+
},
|
|
1560
|
+
async rollback(handle) {
|
|
1561
|
+
await (handle as SqlMutationTransaction).rollback();
|
|
1562
|
+
},
|
|
1563
|
+
async readCommitted(statement) {
|
|
1564
|
+
const result = await sql.execute(publicMutationStatement(statement), { consistency: "strong" });
|
|
1565
|
+
return {
|
|
1566
|
+
cols: result.result.columns.map((column) => column.name),
|
|
1567
|
+
rows: result.result.rows as WireValue[][],
|
|
1568
|
+
};
|
|
1569
|
+
},
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1274
1573
|
/**
|
|
1275
|
-
* The
|
|
1574
|
+
* The remote SQLite server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
|
|
1276
1575
|
* execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
|
|
1277
|
-
*
|
|
1576
|
+
* the selected mutation transport — and LAZILY UPGRADES to an interactive
|
|
1278
1577
|
* mutation session at the mutator's first read: `begin` carries the envelope identity, the
|
|
1279
1578
|
* accumulated statement prefix (sound to replay — nothing before the first read observed DB
|
|
1280
1579
|
* state, §5.2), and the read itself, so a one-read mutator pays exactly one extra round trip.
|
|
@@ -1283,31 +1582,44 @@ async function mutatorConflictBackoff(attempt: number): Promise<void> {
|
|
|
1283
1582
|
* reads cost k+2 round trips regardless of write count.
|
|
1284
1583
|
*
|
|
1285
1584
|
* Begin-time mid dedup can ABSORB the envelope (a redelivery whose commit response was lost):
|
|
1286
|
-
* the replay output is latched on {@link
|
|
1585
|
+
* the replay output is latched on {@link RemoteLazyTx.absorbed} and {@link AbsorbedReplay}
|
|
1287
1586
|
* unwinds the body — the latch (not the throw) is authoritative, so a mutator that swallows
|
|
1288
1587
|
* the unwind still cannot re-apply (no session opened; buffered writes are never shipped).
|
|
1289
1588
|
* A daemon client without session support keeps the LEGACY committed-state point read.
|
|
1290
1589
|
*/
|
|
1291
|
-
class
|
|
1590
|
+
class RemoteLazyTx implements ServerMutationTx {
|
|
1292
1591
|
/** Pre-upgrade: the accumulated batch/prefix. Post-upgrade: writes buffered for the next flush. */
|
|
1293
1592
|
private readonly stmts: SqlStatement[] = [];
|
|
1294
1593
|
private readonly render: RenderIndex;
|
|
1295
|
-
private readonly
|
|
1594
|
+
private readonly transport: MutationTransport;
|
|
1296
1595
|
private readonly envelope: MutationEnvelope;
|
|
1297
|
-
private
|
|
1596
|
+
private sessionHandle?: unknown;
|
|
1597
|
+
readonly sql: ServerSql;
|
|
1298
1598
|
/** The begin-absorbed replay output (§4.1), latched for the backend. */
|
|
1299
1599
|
absorbed?: SqlTxnOutput;
|
|
1300
1600
|
idempotencyKey?: string;
|
|
1301
1601
|
|
|
1302
|
-
constructor(render: RenderIndex,
|
|
1602
|
+
constructor(render: RenderIndex, transport: MutationTransport, envelope: MutationEnvelope) {
|
|
1303
1603
|
this.render = render;
|
|
1304
|
-
this.
|
|
1604
|
+
this.transport = transport;
|
|
1305
1605
|
this.envelope = envelope;
|
|
1606
|
+
this.sql = {
|
|
1607
|
+
execute: async (sql, params = []) => {
|
|
1608
|
+
this.exec(sql, [...params]);
|
|
1609
|
+
},
|
|
1610
|
+
batch: async (statements) => {
|
|
1611
|
+
for (const statement of statements) {
|
|
1612
|
+
this.exec(statement.sql, statement.params === undefined ? [] : [...statement.params]);
|
|
1613
|
+
}
|
|
1614
|
+
},
|
|
1615
|
+
query: <Row = Record<string, unknown>>(sql: string, params: readonly WireValue[] = []) =>
|
|
1616
|
+
this.querySql<Row>(sql, params),
|
|
1617
|
+
};
|
|
1306
1618
|
}
|
|
1307
1619
|
|
|
1308
1620
|
/** True once the tx upgraded to an interactive session (the backend then commits it). */
|
|
1309
1621
|
get session(): boolean {
|
|
1310
|
-
return this.
|
|
1622
|
+
return this.sessionHandle !== undefined;
|
|
1311
1623
|
}
|
|
1312
1624
|
|
|
1313
1625
|
get statements(): readonly SqlStatement[] {
|
|
@@ -1315,12 +1627,21 @@ class DaemonLazyTx implements ServerMutationTx {
|
|
|
1315
1627
|
}
|
|
1316
1628
|
|
|
1317
1629
|
exec(sql: string, params: WireValue[] = []): void {
|
|
1630
|
+
// Refuse here, INSIDE the mutator body, so the harness reads it as a business rejection. By the
|
|
1631
|
+
// time the statement reaches the transport the body has returned and the throw is infra.
|
|
1632
|
+
if (this.transport.strictValues) {
|
|
1633
|
+
assertMutationWriteStatement(sql);
|
|
1634
|
+
assertEncodableParams(sql, params);
|
|
1635
|
+
}
|
|
1318
1636
|
this.stmts.push({ sql, params });
|
|
1319
1637
|
}
|
|
1320
1638
|
|
|
1321
1639
|
private push(op: MutationOp): Promise<void> {
|
|
1322
1640
|
const rendered = renderOp(op, tableMeta(this.render, op.table), sqliteDialect);
|
|
1323
|
-
if (rendered)
|
|
1641
|
+
if (rendered) {
|
|
1642
|
+
if (this.transport.strictValues) assertEncodableParams(rendered.sql, rendered.params);
|
|
1643
|
+
this.stmts.push(rendered);
|
|
1644
|
+
}
|
|
1324
1645
|
return Promise.resolve();
|
|
1325
1646
|
}
|
|
1326
1647
|
|
|
@@ -1362,42 +1683,47 @@ class DaemonLazyTx implements ServerMutationTx {
|
|
|
1362
1683
|
return JSON.parse(cell) as unknown;
|
|
1363
1684
|
}
|
|
1364
1685
|
|
|
1686
|
+
private async querySql<Row>(sql: string, params: readonly WireValue[]): Promise<Row[]> {
|
|
1687
|
+
const out = await this.readThroughTxn({ sql, params: [...params] });
|
|
1688
|
+
return keyedSqlRows<Row>(out.cols, out.rows);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1365
1691
|
/** Run one read: upgrade to a session at the first (§5.1), ride the open one after, or fall
|
|
1366
1692
|
* back to the legacy committed-state read when the daemon client lacks sessions. */
|
|
1367
1693
|
private async readThroughTxn(read: SqlStatement): Promise<SqlReadOutput> {
|
|
1368
1694
|
if (this.absorbed) throw new AbsorbedReplay();
|
|
1369
|
-
|
|
1695
|
+
// Refuse an unencodable read bind at the mutator boundary, exactly as `exec` does for writes.
|
|
1696
|
+
// A read's parameters are encoded inside the transport, where the throw becomes a BackendError
|
|
1697
|
+
// (infra) that retries the deterministic mutator forever and wedges the client's queue; asserting
|
|
1698
|
+
// here makes it a business rejection instead.
|
|
1699
|
+
if (this.transport.strictValues) assertEncodableParams(read.sql, read.params);
|
|
1700
|
+
if (!this.transport.interactive) {
|
|
1370
1701
|
try {
|
|
1371
|
-
return await this.
|
|
1702
|
+
return await this.transport.readCommitted(read);
|
|
1372
1703
|
} catch (err) {
|
|
1373
1704
|
throw new BackendError(err);
|
|
1374
1705
|
}
|
|
1375
1706
|
}
|
|
1376
1707
|
try {
|
|
1377
|
-
if (this.
|
|
1378
|
-
const opened = await this.
|
|
1379
|
-
|
|
1380
|
-
mid: this.envelope.mid,
|
|
1708
|
+
if (this.sessionHandle === undefined) {
|
|
1709
|
+
const opened = await this.transport.begin({
|
|
1710
|
+
envelope: this.envelope,
|
|
1381
1711
|
statements: this.stmts.splice(0),
|
|
1382
1712
|
query: read,
|
|
1713
|
+
...(this.idempotencyKey !== undefined ? { idempotencyKey: this.idempotencyKey } : {}),
|
|
1383
1714
|
});
|
|
1384
1715
|
if (opened.absorbed) {
|
|
1385
|
-
|
|
1386
|
-
this.absorbed = output as SqlTxnOutput;
|
|
1716
|
+
this.absorbed = opened.absorbed;
|
|
1387
1717
|
throw new AbsorbedReplay();
|
|
1388
1718
|
}
|
|
1389
|
-
if (
|
|
1719
|
+
if (opened.handle === undefined || !opened.read) {
|
|
1390
1720
|
throw new Error(`malformed mutate-session begin reply: ${JSON.stringify(opened)}`);
|
|
1391
1721
|
}
|
|
1392
|
-
this.
|
|
1722
|
+
this.sessionHandle = opened.handle;
|
|
1393
1723
|
return opened.read;
|
|
1394
1724
|
}
|
|
1395
1725
|
await this.flush();
|
|
1396
|
-
return await this.
|
|
1397
|
-
sessionId: this.sessionId,
|
|
1398
|
-
sql: read.sql,
|
|
1399
|
-
params: read.params,
|
|
1400
|
-
});
|
|
1726
|
+
return await this.transport.query(this.sessionHandle, read);
|
|
1401
1727
|
} catch (err) {
|
|
1402
1728
|
if (err instanceof AbsorbedReplay || err instanceof BackendError) throw err;
|
|
1403
1729
|
throw new BackendError(err);
|
|
@@ -1407,10 +1733,7 @@ class DaemonLazyTx implements ServerMutationTx {
|
|
|
1407
1733
|
/** Ship buffered writes into the open session, order-preserving; a no-op when none pend. */
|
|
1408
1734
|
private async flush(): Promise<void> {
|
|
1409
1735
|
if (this.stmts.length === 0) return;
|
|
1410
|
-
await this.
|
|
1411
|
-
sessionId: this.sessionId!,
|
|
1412
|
-
statements: this.stmts.splice(0),
|
|
1413
|
-
});
|
|
1736
|
+
await this.transport.exec(this.sessionHandle!, this.stmts.splice(0));
|
|
1414
1737
|
}
|
|
1415
1738
|
|
|
1416
1739
|
/** Flush + commit the open session — the daemon stamps lmid co-transactionally (§4.4) and
|
|
@@ -1418,7 +1741,11 @@ class DaemonLazyTx implements ServerMutationTx {
|
|
|
1418
1741
|
async commitSession(): Promise<SqlTxnOutput> {
|
|
1419
1742
|
try {
|
|
1420
1743
|
await this.flush();
|
|
1421
|
-
|
|
1744
|
+
const output = await this.transport.commit(this.sessionHandle!);
|
|
1745
|
+
if (output.lmid !== undefined && output.lmidAdvances === undefined) {
|
|
1746
|
+
output.lmidAdvances = [{ clientID: this.envelope.clientID, lmid: output.lmid }];
|
|
1747
|
+
}
|
|
1748
|
+
return output;
|
|
1422
1749
|
} catch (err) {
|
|
1423
1750
|
throw err instanceof BackendError ? err : new BackendError(err);
|
|
1424
1751
|
}
|
|
@@ -1427,11 +1754,11 @@ class DaemonLazyTx implements ServerMutationTx {
|
|
|
1427
1754
|
/** Best-effort rollback (the daemon's deadline is the backstop). MUST be awaited before a
|
|
1428
1755
|
* follow-up `/reject-mutation`: that lmid-only commit needs the writer this session holds. */
|
|
1429
1756
|
async rollbackSessionQuietly(): Promise<void> {
|
|
1430
|
-
if (this.
|
|
1431
|
-
const
|
|
1432
|
-
this.
|
|
1757
|
+
if (this.sessionHandle === undefined) return;
|
|
1758
|
+
const sessionHandle = this.sessionHandle;
|
|
1759
|
+
this.sessionHandle = undefined;
|
|
1433
1760
|
try {
|
|
1434
|
-
await this.
|
|
1761
|
+
await this.transport.rollback(sessionHandle);
|
|
1435
1762
|
} catch {
|
|
1436
1763
|
// Unreachable daemon / already-expired session: the deadline rollback covers it.
|
|
1437
1764
|
}
|
|
@@ -1448,12 +1775,27 @@ class PgLiveTx implements ServerMutationTx {
|
|
|
1448
1775
|
private readonly q: PgQuery;
|
|
1449
1776
|
private readonly render: RenderIndex;
|
|
1450
1777
|
private readonly rewrite: (sql: string) => string;
|
|
1778
|
+
readonly sql: ServerSql;
|
|
1451
1779
|
idempotencyKey?: string;
|
|
1452
1780
|
|
|
1453
1781
|
constructor(q: PgQuery, render: RenderIndex, rewrite: (sql: string) => string) {
|
|
1454
1782
|
this.q = q;
|
|
1455
1783
|
this.render = render;
|
|
1456
1784
|
this.rewrite = rewrite;
|
|
1785
|
+
this.sql = {
|
|
1786
|
+
execute: async (sql, params = []) => {
|
|
1787
|
+
this.exec(sql, [...params]);
|
|
1788
|
+
await this.settle();
|
|
1789
|
+
},
|
|
1790
|
+
batch: async (statements) => {
|
|
1791
|
+
for (const statement of statements) {
|
|
1792
|
+
this.exec(statement.sql, statement.params === undefined ? [] : [...statement.params]);
|
|
1793
|
+
}
|
|
1794
|
+
await this.settle();
|
|
1795
|
+
},
|
|
1796
|
+
query: <Row = Record<string, unknown>>(sql: string, params: readonly WireValue[] = []) =>
|
|
1797
|
+
this.querySql<Row>(sql, params),
|
|
1798
|
+
};
|
|
1457
1799
|
}
|
|
1458
1800
|
|
|
1459
1801
|
get statements(): readonly SqlStatement[] {
|
|
@@ -1528,25 +1870,30 @@ class PgLiveTx implements ServerMutationTx {
|
|
|
1528
1870
|
),
|
|
1529
1871
|
);
|
|
1530
1872
|
}
|
|
1873
|
+
|
|
1874
|
+
private async querySql<Row>(sql: string, params: readonly WireValue[]): Promise<Row[]> {
|
|
1875
|
+
await this.settle();
|
|
1876
|
+
try {
|
|
1877
|
+
return (await this.q.query(this.rewrite(sql), [...params])) as Row[];
|
|
1878
|
+
} catch (err) {
|
|
1879
|
+
throw new BackendError(err);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1531
1882
|
}
|
|
1532
1883
|
|
|
1533
|
-
/**
|
|
1534
|
-
*
|
|
1535
|
-
*
|
|
1536
|
-
*
|
|
1537
|
-
|
|
1538
|
-
* (DAEMON-INTERACTIVE-TXN-DESIGN.md): reads are read-your-writes through the open transaction
|
|
1539
|
-
* (PG parity), the commit stamps `lmid` in the same atomic unit, and a begin-absorbed replay
|
|
1540
|
-
* short-circuits without re-running the body.
|
|
1541
|
-
*/
|
|
1542
|
-
export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
|
|
1884
|
+
/** Shared remote-SQL mutation backend. A pure-write mutator remains one request; a read-bearing
|
|
1885
|
+
* mutator lazily upgrades at its first read; accepted effects commit with lmid; business rejection
|
|
1886
|
+
* rolls effects back before an lmid-only commit. Both daemonBackend and sqlBackend use this exact
|
|
1887
|
+
* policy implementation. */
|
|
1888
|
+
function remoteMutationBackend(transport: MutationTransport, outsideSql: ServerSql): MutationBackend {
|
|
1543
1889
|
return {
|
|
1544
1890
|
dialect: sqliteDialect,
|
|
1891
|
+
outsideSql,
|
|
1545
1892
|
async runMutation(input) {
|
|
1546
1893
|
for (let attempt = 0; attempt < MUTATOR_CONFLICT_MAX_ATTEMPTS; attempt++) {
|
|
1547
1894
|
try {
|
|
1548
1895
|
const { envelope, render, run } = input;
|
|
1549
|
-
const tx = new
|
|
1896
|
+
const tx = new RemoteLazyTx(render, transport, envelope);
|
|
1550
1897
|
try {
|
|
1551
1898
|
await run(tx);
|
|
1552
1899
|
} catch (err) {
|
|
@@ -1561,7 +1908,7 @@ export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
|
|
|
1561
1908
|
// Data first, watermark second: rollback releases this session's connection before
|
|
1562
1909
|
// the lmid-only rejection commit.
|
|
1563
1910
|
await tx.rollbackSessionQuietly();
|
|
1564
|
-
const output = await
|
|
1911
|
+
const output = await transport.reject({ envelope, reason });
|
|
1565
1912
|
return { accepted: false, reason, output };
|
|
1566
1913
|
}
|
|
1567
1914
|
if (tx.absorbed) return { accepted: true, output: tx.absorbed };
|
|
@@ -1573,9 +1920,14 @@ export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
|
|
|
1573
1920
|
throw err;
|
|
1574
1921
|
}
|
|
1575
1922
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1923
|
+
return {
|
|
1924
|
+
accepted: true,
|
|
1925
|
+
output: await transport.execute({
|
|
1926
|
+
envelope,
|
|
1927
|
+
statements: [...tx.statements],
|
|
1928
|
+
...(tx.idempotencyKey !== undefined ? { idempotencyKey: tx.idempotencyKey } : {}),
|
|
1929
|
+
}),
|
|
1930
|
+
};
|
|
1579
1931
|
} catch (error) {
|
|
1580
1932
|
if (!isRetryableCommitConflict(error) || attempt + 1 === MUTATOR_CONFLICT_MAX_ATTEMPTS) {
|
|
1581
1933
|
throw error;
|
|
@@ -1586,11 +1938,24 @@ export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
|
|
|
1586
1938
|
throw new Error("unreachable mutator conflict retry loop");
|
|
1587
1939
|
},
|
|
1588
1940
|
reject({ envelope, reason }) {
|
|
1589
|
-
return
|
|
1941
|
+
return transport.reject({ envelope, reason });
|
|
1590
1942
|
},
|
|
1591
1943
|
};
|
|
1592
1944
|
}
|
|
1593
1945
|
|
|
1946
|
+
/** Legacy/private-plane adapter. Kept for existing deployments; its mutation policy is shared with
|
|
1947
|
+
* {@link sqlBackend}, so the two transports cannot drift. */
|
|
1948
|
+
export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
|
|
1949
|
+
return remoteMutationBackend(daemonMutationTransport(daemon), daemonOutsideSql(daemon));
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
/** Run API-server mutators through `@rindle/sql-client`'s explicit mutation facade. Query leases,
|
|
1953
|
+
* SSR reads and room control continue to use `daemon`; only authoritative mutation execution moves
|
|
1954
|
+
* to the versioned SQL transport. */
|
|
1955
|
+
export function sqlBackend(sql: SqlSession): MutationBackend {
|
|
1956
|
+
return remoteMutationBackend(sqlClientMutationTransport(sql), sqlSessionOutsideSql(sql));
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1594
1959
|
/** The query surface a {@link PostgresPlugger} transaction exposes. `exec` runs one statement;
|
|
1595
1960
|
* `query` returns rows keyed by column name (read-your-own-writes inside the txn). */
|
|
1596
1961
|
export interface PgQuery {
|
|
@@ -1641,8 +2006,27 @@ export function postgresBackend(plugger: PostgresPlugger, opts: PostgresBackendO
|
|
|
1641
2006
|
await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
|
|
1642
2007
|
return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
|
|
1643
2008
|
});
|
|
2009
|
+
const outsideSql: ServerSql = {
|
|
2010
|
+
async execute(sql, params = []) {
|
|
2011
|
+
await plugger.transaction(async (q) => {
|
|
2012
|
+
await q.exec(rewrite(sql), [...params]);
|
|
2013
|
+
});
|
|
2014
|
+
},
|
|
2015
|
+
async batch(statements) {
|
|
2016
|
+
if (statements.length === 0) return;
|
|
2017
|
+
await plugger.transaction(async (q) => {
|
|
2018
|
+
for (const statement of statements) {
|
|
2019
|
+
await q.exec(rewrite(statement.sql), statement.params === undefined ? [] : [...statement.params]);
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
2022
|
+
},
|
|
2023
|
+
query<Row = Record<string, unknown>>(sql: string, params: readonly WireValue[] = []): Promise<Row[]> {
|
|
2024
|
+
return plugger.transaction(async (q) => (await q.query(rewrite(sql), [...params])) as Row[]);
|
|
2025
|
+
},
|
|
2026
|
+
};
|
|
1644
2027
|
return {
|
|
1645
2028
|
dialect: postgresDialect,
|
|
2029
|
+
outsideSql,
|
|
1646
2030
|
async runMutation({ envelope, render, run }) {
|
|
1647
2031
|
try {
|
|
1648
2032
|
const output = await plugger.transaction(async (q) => {
|
|
@@ -1973,6 +2357,44 @@ function normalizeCondition(c: Condition): unknown {
|
|
|
1973
2357
|
}
|
|
1974
2358
|
}
|
|
1975
2359
|
|
|
2360
|
+
/** Keep outside-SQL driver failures on the infrastructure path even when they happen before the
|
|
2361
|
+
* scoped mutator has opened its mutation transaction. */
|
|
2362
|
+
function scopedOutsideSql(sql: ServerSql | undefined): ServerSql {
|
|
2363
|
+
const unavailable = (): BackendError =>
|
|
2364
|
+
new BackendError(new Error("scope.sql is unavailable on this custom MutationBackend"));
|
|
2365
|
+
// An unencodable bind is a deterministic authoring error, not a database failure. Wrapping it in
|
|
2366
|
+
// BackendError would latch `scope.infra` and retry the envelope forever; leaving it a plain throw
|
|
2367
|
+
// lets the scoped harness treat it as a business rejection and advance lmid.
|
|
2368
|
+
const infra = (error: unknown): unknown =>
|
|
2369
|
+
isUnencodableBind(error) ? new Error(errMessage(error)) : error instanceof BackendError ? error : new BackendError(error);
|
|
2370
|
+
return {
|
|
2371
|
+
async execute(text, params = []) {
|
|
2372
|
+
if (!sql) throw unavailable();
|
|
2373
|
+
try {
|
|
2374
|
+
await sql.execute(text, params);
|
|
2375
|
+
} catch (error) {
|
|
2376
|
+
throw infra(error);
|
|
2377
|
+
}
|
|
2378
|
+
},
|
|
2379
|
+
async batch(statements) {
|
|
2380
|
+
if (!sql) throw unavailable();
|
|
2381
|
+
try {
|
|
2382
|
+
await sql.batch(statements);
|
|
2383
|
+
} catch (error) {
|
|
2384
|
+
throw infra(error);
|
|
2385
|
+
}
|
|
2386
|
+
},
|
|
2387
|
+
async query<Row = Record<string, unknown>>(text: string, params: readonly WireValue[] = []): Promise<Row[]> {
|
|
2388
|
+
if (!sql) throw unavailable();
|
|
2389
|
+
try {
|
|
2390
|
+
return await sql.query<Row>(text, params);
|
|
2391
|
+
} catch (error) {
|
|
2392
|
+
throw infra(error);
|
|
2393
|
+
}
|
|
2394
|
+
},
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
|
|
1976
2398
|
/**
|
|
1977
2399
|
* The runtime {@link MutationScope} handed to a {@link ScopedMutator}. It owns the single atomic
|
|
1978
2400
|
* transaction (delegating to {@link MutationBackend.runMutation} — the exact machinery a tx-form
|
|
@@ -1990,6 +2412,7 @@ class MutationScopeImpl implements MutationScope {
|
|
|
1990
2412
|
private readonly backend: MutationBackend;
|
|
1991
2413
|
private readonly envelope: MutationEnvelope;
|
|
1992
2414
|
private readonly render: RenderIndex;
|
|
2415
|
+
readonly sql: ServerSql;
|
|
1993
2416
|
/** Set once `transact` resolved through the backend (accepted OR business-rejected). */
|
|
1994
2417
|
outcome?: MutationOutcome;
|
|
1995
2418
|
/** The value the backend threw on INFRA (the DB failed) — always propagated, never an `lmid`
|
|
@@ -2009,6 +2432,7 @@ class MutationScopeImpl implements MutationScope {
|
|
|
2009
2432
|
this.backend = backend;
|
|
2010
2433
|
this.envelope = envelope;
|
|
2011
2434
|
this.render = render;
|
|
2435
|
+
this.sql = scopedOutsideSql(backend.outsideSql);
|
|
2012
2436
|
}
|
|
2013
2437
|
|
|
2014
2438
|
transact(
|
|
@@ -2058,8 +2482,24 @@ class MutationScopeImpl implements MutationScope {
|
|
|
2058
2482
|
export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptions<User>): RindleApiServer<User> {
|
|
2059
2483
|
const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
|
|
2060
2484
|
const mode = opts.mode ?? "normalized";
|
|
2061
|
-
//
|
|
2062
|
-
|
|
2485
|
+
// Explicit backend wins; otherwise prefer the versioned Rindle-SQL mutation transport and retain
|
|
2486
|
+
// daemonBackend as the compatibility path for deployments that have not exposed it yet.
|
|
2487
|
+
let ownedSql: SqlClient | undefined;
|
|
2488
|
+
let backend: MutationBackend;
|
|
2489
|
+
if (opts.backend !== undefined) {
|
|
2490
|
+
backend = opts.backend;
|
|
2491
|
+
} else {
|
|
2492
|
+
if (opts.database !== undefined && opts.sql !== undefined) {
|
|
2493
|
+
throw new TypeError("configure either database or sql, not both");
|
|
2494
|
+
}
|
|
2495
|
+
const sql =
|
|
2496
|
+
opts.sql ??
|
|
2497
|
+
(opts.database !== undefined
|
|
2498
|
+
? // Default FIRST so `database.intMode` can override it; see RindleDatabaseOptions.
|
|
2499
|
+
(ownedSql = createSqlClient({ intMode: "number", ...opts.database }))
|
|
2500
|
+
: undefined);
|
|
2501
|
+
backend = sql === undefined ? daemonBackend(opts.daemon) : sqlBackend(sql);
|
|
2502
|
+
}
|
|
2063
2503
|
// Schema-derived render metadata for logical mutator writes; `{}` when no schema is configured (a
|
|
2064
2504
|
// logical op then throws loudly — the tx never silently drops a write). Each backend renders in its
|
|
2065
2505
|
// own dialect (`backend.dialect`: daemon→sqlite, postgres→postgres).
|
|
@@ -2120,20 +2560,13 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2120
2560
|
);
|
|
2121
2561
|
};
|
|
2122
2562
|
|
|
2123
|
-
//
|
|
2124
|
-
//
|
|
2563
|
+
// Room-served aggregates are refused: a room-retargeted query carrying a count()/reduce reads an
|
|
2564
|
+
// `__agg` head only the daemon feed maintains, and the client's room gate DROPS the `__agg` rows
|
|
2565
|
+
// the room publishes — a known-unsupported shape (302 post-impl review). Room serving otherwise
|
|
2566
|
+
// trusts the declaration (302 §5: declared, not derived); this one shape stays a policy refusal
|
|
2567
|
+
// until room-served aggregates are designed.
|
|
2125
2568
|
const AGGREGATE_REFUSAL =
|
|
2126
|
-
"the query
|
|
2127
|
-
|
|
2128
|
-
// Verdict cache. The verdict is a pure function of exactly two inputs — the resolved footprint
|
|
2129
|
-
// AST and the resolved query AST — so the tightest SOUND key is those two ASTs themselves
|
|
2130
|
-
// (stable-stringified), scoped by (queryName, profile) for legibility. Args/user/ctx need no
|
|
2131
|
-
// separate slot precisely because anything that changes the verdict must change one of the two
|
|
2132
|
-
// ASTs (predicate literals embed the args; ctx-scoped queries embed the principal); keying on
|
|
2133
|
-
// `(name, args)` alone would ALIAS two users' different ASTs under one verdict — unsound.
|
|
2134
|
-
// Bounded FIFO (Map iterates in insertion order) so per-user literals can't grow it forever.
|
|
2135
|
-
const coverVerdicts = new Map<string, CoverQueryOutput>();
|
|
2136
|
-
const COVER_VERDICT_CACHE_MAX = 1024;
|
|
2569
|
+
"the query contains an aggregate/reduce shape — room-served aggregates are not yet supported (the room gate drops `__agg` rows)";
|
|
2137
2570
|
|
|
2138
2571
|
const maybeRoomServe = async (
|
|
2139
2572
|
input: QueryLeaseRequest<User>,
|
|
@@ -2152,13 +2585,6 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2152
2585
|
warnRoomServeOnce(input.name, profile.name, ["realtime.locateRoom is not configured"]);
|
|
2153
2586
|
return undefined;
|
|
2154
2587
|
}
|
|
2155
|
-
const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
|
|
2156
|
-
if (coverQuery === undefined) {
|
|
2157
|
-
warnRoomServeOnce(input.name, profile.name, [
|
|
2158
|
-
"the configured daemon client does not implement coverQuery (/cover-check)",
|
|
2159
|
-
]);
|
|
2160
|
-
return undefined;
|
|
2161
|
-
}
|
|
2162
2588
|
const tokenKey = realtime.roomTokenKey;
|
|
2163
2589
|
if (tokenKey === undefined) {
|
|
2164
2590
|
warnRoomServeOnce(input.name, profile.name, [
|
|
@@ -2187,25 +2613,14 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2187
2613
|
const footprintAst = queryResultToAst(await profile.footprint(key, context));
|
|
2188
2614
|
assertUnwindowedFootprint(footprintAst, profile.name);
|
|
2189
2615
|
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
if (
|
|
2193
|
-
|
|
2194
|
-
? { covered: false, reasons: [AGGREGATE_REFUSAL] }
|
|
2195
|
-
: await coverQuery({ footprint: footprintAst, query: queryAst });
|
|
2196
|
-
// (A coverQuery THROW never lands here — the outer catch fail-opens without caching, so
|
|
2197
|
-
// a transient daemon failure doesn't pin an uncovered verdict.)
|
|
2198
|
-
if (coverVerdicts.size >= COVER_VERDICT_CACHE_MAX) {
|
|
2199
|
-
coverVerdicts.delete(coverVerdicts.keys().next().value as string);
|
|
2200
|
-
}
|
|
2201
|
-
coverVerdicts.set(verdictKey, verdict);
|
|
2202
|
-
}
|
|
2203
|
-
if (!verdict.covered) {
|
|
2204
|
-
warnRoomServeOnce(input.name, profile.name, verdict.reasons ?? ["not provably covered"]);
|
|
2616
|
+
// Trust the declaration (302 §5): a labeled + wired query is room-served, no coverage proof.
|
|
2617
|
+
// The one shape still refused is the aggregate (a policy gate, not a coverage verdict).
|
|
2618
|
+
if (astHasAggregate(queryAst)) {
|
|
2619
|
+
warnRoomServeOnce(input.name, profile.name, [AGGREGATE_REFUSAL]);
|
|
2205
2620
|
return undefined;
|
|
2206
2621
|
}
|
|
2207
2622
|
|
|
2208
|
-
//
|
|
2623
|
+
// Assemble the realtime block. The room endpoint rides ITS OWN field
|
|
2209
2624
|
// (`realtime.wsEndpoint`) — a separate connection from the daemon session's fixed ws host.
|
|
2210
2625
|
const { wsEndpoint } = await realtime.locateRoom(doc);
|
|
2211
2626
|
const now = Date.now();
|
|
@@ -2236,7 +2651,8 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2236
2651
|
tables,
|
|
2237
2652
|
};
|
|
2238
2653
|
} catch (e) {
|
|
2239
|
-
// Fail open — a lease is never blocked on
|
|
2654
|
+
// Fail open — a lease is never blocked on room-serve wiring (footprint resolution,
|
|
2655
|
+
// locateRoom, token minting). A failure here just serves the query from the daemon.
|
|
2240
2656
|
warnRoomServeOnce(input.name, profile.name, [`room-serve failed: ${errMessage(e)}`]);
|
|
2241
2657
|
return undefined;
|
|
2242
2658
|
}
|
|
@@ -2501,12 +2917,12 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2501
2917
|
const res = queryLeaseResponse(out);
|
|
2502
2918
|
// I-iv (§4.1): the occupancy step FIRST — session sweep+upsert, then the D6 gate verdict. A
|
|
2503
2919
|
// closed gate suppresses the room-serve ONLY (the lease ships without the realtime block,
|
|
2504
|
-
// indistinguishable from
|
|
2920
|
+
// indistinguishable from a non-room-served query — the daemon path) while the doorbell
|
|
2505
2921
|
// below still rides; lifecycle-off ⇒ `gateOpen: true` unconditionally and this line is inert.
|
|
2506
2922
|
const occ = await lifecycleOccupancy(input);
|
|
2507
|
-
// G-iv-b: a
|
|
2923
|
+
// G-iv-b: a labeled + wired query ADDITIONALLY gains the realtime block. The daemon lease
|
|
2508
2924
|
// above is unconditional (and its fields untouched) — room-serving only ever adds a field,
|
|
2509
|
-
// so
|
|
2925
|
+
// so a non-room-served/legacy lease stays byte-identical and nothing here can block one.
|
|
2510
2926
|
const rt = occ.gateOpen ? await maybeRoomServe(input, ast, context, subject) : undefined;
|
|
2511
2927
|
if (rt !== undefined) res.realtime = rt;
|
|
2512
2928
|
// I-v (§4.2): the gate CLOSED and a room plausibly hosted this scope — drain it and ride the
|
|
@@ -2700,57 +3116,6 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2700
3116
|
}
|
|
2701
3117
|
};
|
|
2702
3118
|
|
|
2703
|
-
// The explicit coverage diagnostic (the assertPins pattern: system-level, resolved under
|
|
2704
|
-
// `pinUser`, per-query failures collected — never strand the rest). It runs the REAL check —
|
|
2705
|
-
// the daemon's /cover-check on the actually-resolved ASTs — so its verdicts are exactly the
|
|
2706
|
-
// lease path's, minus the serving wiring (locateRoom/roomTokenKey), which it deliberately
|
|
2707
|
-
// ignores: it answers "is this labeled query coverable", the deployable-config question.
|
|
2708
|
-
const validateRealtime = async (vopts?: {
|
|
2709
|
-
exemplars?: Partial<Record<string, readonly unknown[]>>;
|
|
2710
|
-
strict?: boolean;
|
|
2711
|
-
}): Promise<ValidateRealtimeReport> => {
|
|
2712
|
-
const context: ApiContext<User> = { user: opts.pinUser as User, request: undefined };
|
|
2713
|
-
const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
|
|
2714
|
-
const verdicts: ValidateRealtimeVerdict[] = [];
|
|
2715
|
-
for (const [name, q] of Object.entries(opts.queries ?? {})) {
|
|
2716
|
-
const label = queryRealtimeLabel(q);
|
|
2717
|
-
if (label === undefined) continue;
|
|
2718
|
-
const profile = roomProfiles.get(label.room);
|
|
2719
|
-
if (profile === undefined) continue; // unreachable: construction asserted it exists
|
|
2720
|
-
for (const args of vopts?.exemplars?.[name] ?? [null]) {
|
|
2721
|
-
const verdict: ValidateRealtimeVerdict = { query: name, profile: profile.name, args, covered: false };
|
|
2722
|
-
try {
|
|
2723
|
-
const ast = await resolveAst(name, args, context);
|
|
2724
|
-
const roomArgs = label.args !== undefined ? label.args(args) : args;
|
|
2725
|
-
const footprintAst = queryResultToAst(await profile.footprint(profile.key(roomArgs), context));
|
|
2726
|
-
assertUnwindowedFootprint(footprintAst, profile.name);
|
|
2727
|
-
if (astHasAggregate(ast)) {
|
|
2728
|
-
verdict.reasons = [AGGREGATE_REFUSAL];
|
|
2729
|
-
} else if (coverQuery === undefined) {
|
|
2730
|
-
verdict.reasons = ["the configured daemon client does not implement coverQuery (/cover-check)"];
|
|
2731
|
-
} else {
|
|
2732
|
-
const out = await coverQuery({ footprint: footprintAst, query: ast });
|
|
2733
|
-
verdict.covered = out.covered;
|
|
2734
|
-
if (!out.covered) verdict.reasons = out.reasons ?? ["not provably covered"];
|
|
2735
|
-
}
|
|
2736
|
-
} catch (e) {
|
|
2737
|
-
verdict.reasons = [errMessage(e)];
|
|
2738
|
-
}
|
|
2739
|
-
verdicts.push(verdict);
|
|
2740
|
-
}
|
|
2741
|
-
}
|
|
2742
|
-
const uncovered = verdicts.filter((v) => !v.covered);
|
|
2743
|
-
if (vopts?.strict && uncovered.length > 0) {
|
|
2744
|
-
throw new Error(
|
|
2745
|
-
`validateRealtime: ${uncovered.length} labeled query verdict(s) not provably covered — ` +
|
|
2746
|
-
uncovered
|
|
2747
|
-
.map((v) => `${v.query} (profile "${v.profile}"): ${(v.reasons ?? []).join("; ")}`)
|
|
2748
|
-
.join(" | "),
|
|
2749
|
-
);
|
|
2750
|
-
}
|
|
2751
|
-
return { verdicts, uncovered };
|
|
2752
|
-
};
|
|
2753
|
-
|
|
2754
3119
|
// The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
|
|
2755
3120
|
// the `realtime` block (which also activates `/room-boot`) or the deprecated bare
|
|
2756
3121
|
// `authorizeRoom` (trio only). Hosting an authority is never a default.
|
|
@@ -2815,10 +3180,10 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2815
3180
|
|
|
2816
3181
|
return {
|
|
2817
3182
|
routes,
|
|
3183
|
+
close: () => ownedSql?.close(),
|
|
2818
3184
|
createQueryLease,
|
|
2819
3185
|
readQuery,
|
|
2820
3186
|
assertPins,
|
|
2821
|
-
validateRealtime,
|
|
2822
3187
|
pushMutation,
|
|
2823
3188
|
pushMutations,
|
|
2824
3189
|
handleApplyRowChangeTxnJson: async (body, context) => {
|
|
@@ -3005,8 +3370,9 @@ function applyOpToServerTx(tx: ServerWriteTx, op: MutationOp): Promise<void> {
|
|
|
3005
3370
|
|
|
3006
3371
|
/** Feed a mutator's RETURNED result (the alternative to calling `tx.exec`/logical ops directly) into
|
|
3007
3372
|
* the backend tx: a returned `SqlStatement[]` / `SqlTxn` is exec'd onto `tx`, and a carried
|
|
3008
|
-
* `idempotencyKey` is stashed
|
|
3009
|
-
*
|
|
3373
|
+
* `idempotencyKey` is stashed for the legacy daemon adapter; the SQL mutation facade uses `mid`
|
|
3374
|
+
* as its durable retry identity and PG ignores it. A `void` return is a no-op — the mutator already
|
|
3375
|
+
* drove the tx. Preserves the return-style contract. */
|
|
3010
3376
|
function applyResultToTx(result: ApiMutatorResult, tx: ServerMutationTx): void {
|
|
3011
3377
|
if (!result) return;
|
|
3012
3378
|
const statements = Array.isArray(result) ? result : result.statements;
|
|
@@ -3141,7 +3507,7 @@ const ROOM_MUTATION_OUTCOMES_TABLE = "_rindle_room_mutation_outcomes";
|
|
|
3141
3507
|
// delta fanning to every subscribed solo client; no clientID/mid — a system write must never
|
|
3142
3508
|
// advance an lmid — and no idempotencyKey — a renewal's re-upsert must re-run, that is the
|
|
3143
3509
|
// refresh), and the count is one `executeSqlRead` with `consistency: "strong"` — the read surface
|
|
3144
|
-
// the api-server already has against the daemon (the `
|
|
3510
|
+
// the api-server already has against the daemon (the `RemoteLazyTx` fallback precedent above).
|
|
3145
3511
|
// "strong" routes the read to the WRITE MASTER in a split deploy, which just serialized our
|
|
3146
3512
|
// upsert: read-your-writes without a mutation session (the interactive-txn machinery is optional
|
|
3147
3513
|
// on the daemon interface and far heavier than this two-round-trip pair needs).
|
|
@@ -3220,19 +3586,6 @@ function docClientAst(table: string, doc: string, clientId: string | undefined):
|
|
|
3220
3586
|
* lease through the api-server, never an extension of this token. */
|
|
3221
3587
|
const DEFAULT_ROOM_TOKEN_TTL_MS = 5 * 60_000;
|
|
3222
3588
|
|
|
3223
|
-
/** Deterministic JSON: object keys sorted recursively, so two structurally identical ASTs from
|
|
3224
|
-
* independent resolves stringify identically (the verdict-cache key). */
|
|
3225
|
-
function stableStringify(v: unknown): string {
|
|
3226
|
-
return JSON.stringify(v, (_key, value: unknown) => {
|
|
3227
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
3228
|
-
const rec = value as Record<string, unknown>;
|
|
3229
|
-
const sorted: Record<string, unknown> = {};
|
|
3230
|
-
for (const k of Object.keys(rec).sort()) sorted[k] = rec[k];
|
|
3231
|
-
return sorted;
|
|
3232
|
-
}
|
|
3233
|
-
return value;
|
|
3234
|
-
});
|
|
3235
|
-
}
|
|
3236
3589
|
|
|
3237
3590
|
/** Does the AST contain an aggregate/reduce shape ANYWHERE (root, a `related` subquery, or an
|
|
3238
3591
|
* `EXISTS` child)? Room-serving refuses these regardless of coverage: the client's aggregate
|