@rindle/api-server 0.5.0 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -11
- package/dist/index.d.ts +97 -85
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +535 -213
- package/dist/index.js.map +1 -1
- package/dist/rooms.js +2 -2
- package/dist/rooms.js.map +1 -1
- package/package.json +9 -6
- package/src/index.ts +685 -288
- package/src/rooms.ts +2 -2
package/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,
|
|
@@ -68,11 +76,13 @@ import {
|
|
|
68
76
|
import type { RoomProfile, RoomScopeSpec, RoomTableSpec } from "./rooms.ts";
|
|
69
77
|
// The room lease token (RINDLE-REALTIME §10.1): minted here, verified by the room SHELL against
|
|
70
78
|
// its `downstream.tokenKeys` ring — the `/token` subpath is pure WebCrypto (no wasm, no shell).
|
|
71
|
-
// Loaded LAZILY at the first mint: `@rindle/room` is
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// daemon-served leases plus a one-time
|
|
79
|
+
// Loaded LAZILY at the first mint: `@rindle/room` is an OPTIONAL dependency (see package.json), so
|
|
80
|
+
// it is installed transitively — a consumer bundling api-server (Vite/Rollup/esbuild) can resolve
|
|
81
|
+
// this dynamic import even when it never uses rooms — yet the mint only runs when
|
|
82
|
+
// `realtime.roomTokenKey` is configured. Should the module be genuinely absent (an install that
|
|
83
|
+
// skipped the optional dep), the serve decision fail-opens: daemon-served leases plus a one-time
|
|
84
|
+
// warning naming the missing module. It is NOT a hard `dependency` because the entire code path is
|
|
85
|
+
// optional; optionalDependencies keeps a failed install of it non-fatal.
|
|
76
86
|
import type { mintRoomToken as MintRoomToken, scopeSpecsHash as ScopeSpecsHash } from "@rindle/room/token";
|
|
77
87
|
let roomTokenModule: { mintRoomToken: typeof MintRoomToken; scopeSpecsHash: typeof ScopeSpecsHash } | undefined;
|
|
78
88
|
async function loadRoomTokenModule(): Promise<{ mintRoomToken: typeof MintRoomToken; scopeSpecsHash: typeof ScopeSpecsHash }> {
|
|
@@ -153,10 +163,24 @@ export interface MutationContext<User> {
|
|
|
153
163
|
request?: unknown;
|
|
154
164
|
}
|
|
155
165
|
|
|
156
|
-
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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. */
|
|
159
182
|
export interface SqlMutationTx {
|
|
183
|
+
readonly sql: ServerSql;
|
|
160
184
|
exec(sql: string, params?: WireValue[]): void;
|
|
161
185
|
readonly statements: readonly SqlStatement[];
|
|
162
186
|
}
|
|
@@ -164,17 +188,17 @@ export interface SqlMutationTx {
|
|
|
164
188
|
/** The write handle a server mutator runs against — the ASYNC twin of the client's `MutationTx`. It
|
|
165
189
|
* is both the isomorphic {@link ServerWriteTx} logical surface (insert/update/upsert/insertIgnore/
|
|
166
190
|
* delete/row, rendered to dialect SQL) AND the legacy {@link SqlMutationTx} raw escape hatch. Both
|
|
167
|
-
*
|
|
168
|
-
* everything live; the daemon
|
|
169
|
-
* 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). */
|
|
170
194
|
export interface ServerMutationTx extends ServerWriteTx, SqlMutationTx {
|
|
171
195
|
/** Run a full query (a fluent `Query` or its wire `Ast`) INSIDE the open transaction —
|
|
172
196
|
* read-your-writes, like {@link ServerWriteTx.row} but for arbitrary shapes. Returns the
|
|
173
197
|
* parsed nested result tree: an array for a plural root, an object or `null` for a `.one()`
|
|
174
198
|
* root, with cells in their raw SQLite storage-class representations (the same vocabulary
|
|
175
|
-
* `row` speaks).
|
|
176
|
-
* params, NO casts — §5.4) and executed through the mutation session. Postgres
|
|
177
|
-
*
|
|
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). */
|
|
178
202
|
query(q: Ast | Query<any, any, any>): Promise<unknown>;
|
|
179
203
|
}
|
|
180
204
|
|
|
@@ -221,6 +245,10 @@ export class MutationRejected extends Error {
|
|
|
221
245
|
* `transact`, or a swallowed {@link MutationRejected} still advances `lmid` and never wedges the
|
|
222
246
|
* client's pending queue. */
|
|
223
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;
|
|
224
252
|
/** Open the ONE atomic write transaction and drive `body` inside it, committing (stamping `lmid`
|
|
225
253
|
* co-transactionally) on a clean return. MAY be called at most once — a second call throws.
|
|
226
254
|
*
|
|
@@ -297,20 +325,23 @@ export interface QueryLeaseRequest<User> {
|
|
|
297
325
|
* when there is no authenticated subject and no session cookie (READ-ROUTER-DESIGN.md §1.5/§2.2).
|
|
298
326
|
* A routing HINT only, never authorization. */
|
|
299
327
|
clientId?: string;
|
|
328
|
+
/** The browser's opaque follower-affinity ticket (FOLLOWER-AFFINITY-DESIGN.md §3), read off the
|
|
329
|
+
* query POST and forwarded OPAQUELY on `materialize` so the fleet `fly-replay`s to the follower
|
|
330
|
+
* the browser's ws is pinned to (§2, §4) — both legs co-locate. The api-server does NOT verify
|
|
331
|
+
* it (the fleet does); it holds no signing key. Absent ⇒ single daemon / affinity off. */
|
|
332
|
+
affinity?: string;
|
|
300
333
|
}
|
|
301
334
|
|
|
302
335
|
/**
|
|
303
336
|
* The room-serve block on a query lease (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1 step 5 / §2.4,
|
|
304
|
-
* slice G-iv-b): present
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
* 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.
|
|
308
340
|
*
|
|
309
|
-
* It is a
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* ones.
|
|
341
|
+
* It is a dedicated block on purpose: the ROOM ws is a SEPARATE connection this query opens beside
|
|
342
|
+
* its daemon session (never a migration of the daemon session — the daemon ws host is fixed and
|
|
343
|
+
* placed by the affinity ticket). A room-served lease's top-level fields are byte-identical to the
|
|
344
|
+
* daemon-served ones.
|
|
314
345
|
*/
|
|
315
346
|
export interface QueryLeaseRealtime {
|
|
316
347
|
/** The client store's gate/domain key for this room source (`connectSource`) AND the string the
|
|
@@ -318,8 +349,8 @@ export interface QueryLeaseRealtime {
|
|
|
318
349
|
* parses as a room source, and the established convention is `"room:" + doc`
|
|
319
350
|
* (e.g. `room:document/doc:d1`). */
|
|
320
351
|
sourceKey: string;
|
|
321
|
-
/** Where the client opens the ROOM ws for this query (from `realtime.locateRoom`) —
|
|
322
|
-
*
|
|
352
|
+
/** Where the client opens the ROOM ws for this query (from `realtime.locateRoom`) — its OWN
|
|
353
|
+
* connection, distinct from the daemon session's fixed ws host. */
|
|
323
354
|
wsEndpoint: string;
|
|
324
355
|
/** The room's self-authorizing signed lease (`@rindle/room/token`): the APPROVED query AST +
|
|
325
356
|
* doc + subject, HMAC-signed with `realtime.roomTokenKey` so the room shell's
|
|
@@ -349,9 +380,6 @@ export interface QueryLeaseLifecycleLease {
|
|
|
349
380
|
/** Which system table this lease's subscription serves. */
|
|
350
381
|
table: string;
|
|
351
382
|
leaseToken: string;
|
|
352
|
-
/** The follower this system materialization lives on (routed deploys; mirrors the top-level
|
|
353
|
-
* `wsEndpoint` semantics). Absent ⇒ single-daemon. */
|
|
354
|
-
wsEndpoint?: string;
|
|
355
383
|
/** DOORBELL only: the §4.1 occupancy scope — the wire room doc (`"<profile>/<key>"`). */
|
|
356
384
|
scope?: string;
|
|
357
385
|
/** FENCE entries only: the room doc the predicate is scoped to. */
|
|
@@ -393,9 +421,6 @@ export interface QueryLeaseResponse {
|
|
|
393
421
|
materializationId: string;
|
|
394
422
|
queryKey?: string;
|
|
395
423
|
reused?: boolean;
|
|
396
|
-
/** The follower this lease lives on (READ-ROUTER-DESIGN.md §2.3) — the browser opens its
|
|
397
|
-
* subscription ws here. Absent ⇒ single-daemon (the client uses its static `wsUrl`). */
|
|
398
|
-
wsEndpoint?: string;
|
|
399
424
|
/** The room-serve block (G-iv-b) — see {@link QueryLeaseRealtime}. Absent ⇒ the lease is
|
|
400
425
|
* byte-identical to the legacy daemon-served shape. */
|
|
401
426
|
realtime?: QueryLeaseRealtime;
|
|
@@ -420,6 +445,9 @@ export interface QueryReadRequest<User> {
|
|
|
420
445
|
* {@link QueryLeaseRequest.clientId}). Lets the SSR read co-locate on the follower the booting
|
|
421
446
|
* client's first subscribe will hit (READ-ROUTER-DESIGN.md §2.4). */
|
|
422
447
|
clientId?: string;
|
|
448
|
+
/** The browser's opaque follower-affinity ticket — see {@link QueryLeaseRequest.affinity}.
|
|
449
|
+
* Forwarded on the one-shot `query` so an SSR read lands on the same pinned follower. */
|
|
450
|
+
affinity?: string;
|
|
423
451
|
}
|
|
424
452
|
|
|
425
453
|
/** The assembled (nested-by-name) first-paint snapshot the server-side Store seeds + dehydrates
|
|
@@ -428,9 +456,6 @@ export interface QueryReadResponse {
|
|
|
428
456
|
rows: Array<{ cols: Record<string, unknown>; [rel: string]: unknown }>;
|
|
429
457
|
cvMin?: number;
|
|
430
458
|
queryKey?: string;
|
|
431
|
-
/** The follower this read warmed (READ-ROUTER-DESIGN.md §2.4) — inject it into the SSR bootstrap
|
|
432
|
-
* so the booting client opens its ws to the same warm follower. Absent ⇒ single-daemon. */
|
|
433
|
-
wsEndpoint?: string;
|
|
434
459
|
}
|
|
435
460
|
|
|
436
461
|
/** The context a {@link MutationBackend} needs to run one mutation inside its transaction. */
|
|
@@ -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
|
}
|
|
@@ -548,6 +576,10 @@ export interface RoomBootResponse {
|
|
|
548
576
|
/** Where the room opens its upstream subscription (a routed deploy's follower). Absent ⇒ the
|
|
549
577
|
* shell's statically configured rindled ws endpoint. */
|
|
550
578
|
upstreamWsEndpoint?: string;
|
|
579
|
+
/** Fresh opaque follower-placement ticket minted alongside `upstreamLeaseToken`. The DO offers
|
|
580
|
+
* it with `rindle.v1` on the separate upstream ws so a static fleet endpoint replays to the
|
|
581
|
+
* exact follower holding that local lease. Absent when daemon affinity is off. */
|
|
582
|
+
upstreamAffinity?: string;
|
|
551
583
|
/** Per-footprint-table scope specs (H-iv-b), compiled from the resolved footprint AST + the
|
|
552
584
|
* profile's context set (the legacy anonymous profile compiles with an empty context set):
|
|
553
585
|
* what the shell hands the wasm room's `enableWritesV2` — the §3.3 commit gate. Optional
|
|
@@ -593,14 +625,16 @@ export interface RindleRealtimeOptions<User> {
|
|
|
593
625
|
/** Lease TTL for the room's upstream footprint materialization (defaults to the server-wide
|
|
594
626
|
* `leaseTtlMs`, else the daemon's default). */
|
|
595
627
|
upstreamLeaseTtlMs?: number;
|
|
596
|
-
/** Static
|
|
597
|
-
* `
|
|
628
|
+
/** Static endpoint where rooms open their upstream subscription. In a follower fleet this is the
|
|
629
|
+
* fleet ws URL; `/room-boot` pairs it with the materialization's fresh placement ticket so the
|
|
630
|
+
* room lands on the exact follower holding its lease. Absent ⇒ no explicit upstream (the Node
|
|
631
|
+
* room shell may use its own default; the shipped DO shell requires this endpoint). */
|
|
598
632
|
upstreamWsEndpoint?: string;
|
|
599
633
|
/** Locate (or place) the room serving `doc` and return the ROOM ws endpoint a room-served
|
|
600
634
|
* lease's client should open (G-iv-b; on the DO shell this is the Worker's room URL). The
|
|
601
|
-
* endpoint rides the lease's dedicated `realtime.wsEndpoint` —
|
|
602
|
-
*
|
|
603
|
-
*
|
|
635
|
+
* endpoint rides the lease's dedicated `realtime.wsEndpoint` — its OWN connection, distinct from
|
|
636
|
+
* the daemon session's fixed ws host. Absent ⇒ room-serving is OFF: labeled queries serve from
|
|
637
|
+
* the daemon exactly as today (fail-open). */
|
|
604
638
|
locateRoom?: (doc: string) => MaybePromise<{ wsEndpoint: string }>;
|
|
605
639
|
/** The room lease token signing key (`@rindle/room/token`): `kid` + secret, matching an entry
|
|
606
640
|
* in the room shell's `downstream.tokenKeys` ring. Required for room-serving (without it a
|
|
@@ -765,11 +799,30 @@ export async function verifyRoomFlushCredential(
|
|
|
765
799
|
return payload;
|
|
766
800
|
}
|
|
767
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
|
+
|
|
768
812
|
export interface RindleApiServerOptions<User> {
|
|
769
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;
|
|
770
823
|
/** Where mutations are applied and `lmid` is stamped ({@link MutationBackend}). Default:
|
|
771
|
-
* `
|
|
772
|
-
* 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. */
|
|
773
826
|
backend?: MutationBackend;
|
|
774
827
|
/** The typed schema (`createSchema`/`refineSchema`). Required only when a mutator uses the LOGICAL
|
|
775
828
|
* write vocabulary (`tx.insert`/`update`/`upsert`/`insertIgnore`/`delete`/`row`) — it drives the
|
|
@@ -816,12 +869,11 @@ export interface RindleApiServerOptions<User> {
|
|
|
816
869
|
* HINT only — never authorization. Ignored by a single (unrouted) daemon, which has nothing to
|
|
817
870
|
* route. */
|
|
818
871
|
routingKey?: string | ((input: QueryLeaseRequest<User>) => MaybePromise<string | undefined>);
|
|
819
|
-
/** The EXPLICIT fleet pin fan-out
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
* pin). */
|
|
872
|
+
/** The EXPLICIT fleet pin fan-out — when set, {@link RindleApiServer.assertPins} fans each
|
|
873
|
+
* resolved pin across ALL live followers through it (a fleet control action over the machine
|
|
874
|
+
* list — FOLLOWER-AFFINITY-DESIGN.md §11) instead of materializing each pin once on the (single)
|
|
875
|
+
* daemon. A per-viewer `materialize` always routes ONE; a pin-assert always fans ALL — never
|
|
876
|
+
* inferred from `policy.kind`. Absent ⇒ single-daemon behavior (one materialize per pin). */
|
|
825
877
|
pinFanout?: PinFanout;
|
|
826
878
|
/** Named queries to keep permanently materialized via {@link RindleApiServer.assertPins}.
|
|
827
879
|
* Each is materialized with a `pinned` policy (survives zero subscribers) so late joiners
|
|
@@ -840,24 +892,15 @@ export interface RindleApiServerOptions<User> {
|
|
|
840
892
|
|
|
841
893
|
export interface RindleApiServer<User> {
|
|
842
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;
|
|
843
898
|
createQueryLease(input: QueryLeaseRequest<User>): Promise<QueryLeaseResponse>;
|
|
844
899
|
/** (Re-)materialize every `pinnedQueries` entry with a pinned policy. Idempotent — the daemon
|
|
845
900
|
* dedupes by canonical query, so a re-assert reuses the existing materialization. Call it at
|
|
846
901
|
* startup and whenever the daemon restarts (e.g. from the daemon-client `onBootId` hook), since
|
|
847
902
|
* the daemon holds no durable materialization state. No-op when `pinnedQueries` is empty. */
|
|
848
903
|
assertPins(): Promise<void>;
|
|
849
|
-
/** The room-serving coverage DIAGNOSTIC (G-iv-b; the `assertPins`-style explicit check): for
|
|
850
|
-
* every realtime-labeled query, resolve it (under `pinUser`, per exemplar args), resolve its
|
|
851
|
-
* profile's footprint, and run the REAL daemon coverage check — the same verdict the lease
|
|
852
|
-
* path serves by. Returns every verdict; `strict` throws when any labeled query is not
|
|
853
|
-
* provably covered (deploy-gate mode). Deliberately ignores `locateRoom`/`roomTokenKey` — it
|
|
854
|
-
* answers "would this query be coverable", not "is serving fully wired". */
|
|
855
|
-
validateRealtime(opts?: {
|
|
856
|
-
/** Per-query exemplar args (a query is checked once per exemplar; default: one `null`). */
|
|
857
|
-
exemplars?: Partial<Record<string, readonly unknown[]>>;
|
|
858
|
-
/** Throw when any labeled query is uncovered (instead of just reporting). */
|
|
859
|
-
strict?: boolean;
|
|
860
|
-
}): Promise<ValidateRealtimeReport>;
|
|
861
904
|
pushMutation(input: PushMutationRequest<User>): Promise<PushMutationResponse>;
|
|
862
905
|
/** Apply an in-order batch (the client mutation queue's flush). Envelopes run strictly
|
|
863
906
|
* sequentially; a rejection still advances the daemon's lmid, so later envelopes in the
|
|
@@ -896,24 +939,6 @@ export interface RindleApiServer<User> {
|
|
|
896
939
|
handleRoomBootJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
|
|
897
940
|
}
|
|
898
941
|
|
|
899
|
-
/** One {@link RindleApiServer.validateRealtime} verdict: a labeled query × exemplar args. */
|
|
900
|
-
export interface ValidateRealtimeVerdict {
|
|
901
|
-
query: string;
|
|
902
|
-
profile: string;
|
|
903
|
-
args: unknown;
|
|
904
|
-
covered: boolean;
|
|
905
|
-
/** Why it is not covered (uncovered verdicts only) — the daemon's reason strings, the
|
|
906
|
-
* aggregate refusal, or a resolve/footprint error message. */
|
|
907
|
-
reasons?: string[];
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
/** The {@link RindleApiServer.validateRealtime} report. */
|
|
911
|
-
export interface ValidateRealtimeReport {
|
|
912
|
-
verdicts: ValidateRealtimeVerdict[];
|
|
913
|
-
/** The uncovered subset of `verdicts` (what `strict` throws on). */
|
|
914
|
-
uncovered: ValidateRealtimeVerdict[];
|
|
915
|
-
}
|
|
916
|
-
|
|
917
942
|
export type RindleApiErrorCode = "bad-request" | "forbidden" | "not-found" | "rejected";
|
|
918
943
|
|
|
919
944
|
export class RindleApiError extends Error {
|
|
@@ -1008,18 +1033,11 @@ export class SplitDaemonClient implements RindleDaemonClient {
|
|
|
1008
1033
|
if (!lmids) return Promise.reject(new Error("the write master lacks roomLmids"));
|
|
1009
1034
|
return lmids(input);
|
|
1010
1035
|
}
|
|
1011
|
-
// Pure computation, but routed to the MASTER like the rest of the room ops: the master is a
|
|
1012
|
-
// real rindled that hosts `/cover-check`; the read router may not proxy it.
|
|
1013
|
-
coverQuery(input: CoverQueryInput): Promise<CoverQueryOutput> {
|
|
1014
|
-
const cover = this.writes.coverQuery?.bind(this.writes);
|
|
1015
|
-
if (!cover) return Promise.reject(new Error("the write master lacks coverQuery"));
|
|
1016
|
-
return cover(input);
|
|
1017
|
-
}
|
|
1018
1036
|
migrate(input: MigrateInput): Promise<MigrateOutput> {
|
|
1019
1037
|
return this.writes.migrate(input);
|
|
1020
1038
|
}
|
|
1021
1039
|
|
|
1022
|
-
// reads → the
|
|
1040
|
+
// reads → the fleet (one FLEET_URL follower; the affinity ticket + Fly edge place the machine)
|
|
1023
1041
|
materialize(input: MaterializeInput): Promise<MaterializeOutput> {
|
|
1024
1042
|
return this.reads.materialize(input);
|
|
1025
1043
|
}
|
|
@@ -1195,9 +1213,6 @@ export class BackendError extends Error {
|
|
|
1195
1213
|
}
|
|
1196
1214
|
}
|
|
1197
1215
|
|
|
1198
|
-
/** The rindle/daemon server tx: logical writes render to SQLite and ACCUMULATE into one batch;
|
|
1199
|
-
* raw `exec` accumulates too; `row` reads COMMITTED state through the daemon (no read-your-writes,
|
|
1200
|
-
* the one interactive-txn limitation of the daemon backend). */
|
|
1201
1216
|
/** Build the compiler {@link Catalog} for ONE ast from the render index: columns/pk from the
|
|
1202
1217
|
* schema; relationship cardinality from the AST ITSELF — a Rindle relationship is declared at
|
|
1203
1218
|
* the query site (`sub(alias, rel)` / `.one()`), never on the schema, so the alias→cardinality
|
|
@@ -1248,10 +1263,317 @@ class AbsorbedReplay extends Error {
|
|
|
1248
1263
|
}
|
|
1249
1264
|
}
|
|
1250
1265
|
|
|
1266
|
+
const MUTATOR_CONFLICT_MAX_ATTEMPTS = 5;
|
|
1267
|
+
|
|
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
|
+
}
|
|
1272
|
+
if (!(error instanceof DaemonHttpError) || error.status !== 409) return false;
|
|
1273
|
+
try {
|
|
1274
|
+
const body = JSON.parse(error.body) as { code?: unknown; retryable?: unknown };
|
|
1275
|
+
return body.code === "retryable-conflict" && body.retryable === true;
|
|
1276
|
+
} catch {
|
|
1277
|
+
return false;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
async function mutatorConflictBackoff(attempt: number): Promise<void> {
|
|
1282
|
+
const ceiling = Math.min(32, 2 ** attempt);
|
|
1283
|
+
const millis = ceiling + Math.floor(Math.random() * 4);
|
|
1284
|
+
await new Promise<void>((resolve) => setTimeout(resolve, millis));
|
|
1285
|
+
}
|
|
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
|
+
|
|
1251
1573
|
/**
|
|
1252
|
-
* The
|
|
1574
|
+
* The remote SQLite server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
|
|
1253
1575
|
* execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
|
|
1254
|
-
*
|
|
1576
|
+
* the selected mutation transport — and LAZILY UPGRADES to an interactive
|
|
1255
1577
|
* mutation session at the mutator's first read: `begin` carries the envelope identity, the
|
|
1256
1578
|
* accumulated statement prefix (sound to replay — nothing before the first read observed DB
|
|
1257
1579
|
* state, §5.2), and the read itself, so a one-read mutator pays exactly one extra round trip.
|
|
@@ -1260,31 +1582,44 @@ class AbsorbedReplay extends Error {
|
|
|
1260
1582
|
* reads cost k+2 round trips regardless of write count.
|
|
1261
1583
|
*
|
|
1262
1584
|
* Begin-time mid dedup can ABSORB the envelope (a redelivery whose commit response was lost):
|
|
1263
|
-
* the replay output is latched on {@link
|
|
1585
|
+
* the replay output is latched on {@link RemoteLazyTx.absorbed} and {@link AbsorbedReplay}
|
|
1264
1586
|
* unwinds the body — the latch (not the throw) is authoritative, so a mutator that swallows
|
|
1265
1587
|
* the unwind still cannot re-apply (no session opened; buffered writes are never shipped).
|
|
1266
1588
|
* A daemon client without session support keeps the LEGACY committed-state point read.
|
|
1267
1589
|
*/
|
|
1268
|
-
class
|
|
1590
|
+
class RemoteLazyTx implements ServerMutationTx {
|
|
1269
1591
|
/** Pre-upgrade: the accumulated batch/prefix. Post-upgrade: writes buffered for the next flush. */
|
|
1270
1592
|
private readonly stmts: SqlStatement[] = [];
|
|
1271
1593
|
private readonly render: RenderIndex;
|
|
1272
|
-
private readonly
|
|
1594
|
+
private readonly transport: MutationTransport;
|
|
1273
1595
|
private readonly envelope: MutationEnvelope;
|
|
1274
|
-
private
|
|
1596
|
+
private sessionHandle?: unknown;
|
|
1597
|
+
readonly sql: ServerSql;
|
|
1275
1598
|
/** The begin-absorbed replay output (§4.1), latched for the backend. */
|
|
1276
1599
|
absorbed?: SqlTxnOutput;
|
|
1277
1600
|
idempotencyKey?: string;
|
|
1278
1601
|
|
|
1279
|
-
constructor(render: RenderIndex,
|
|
1602
|
+
constructor(render: RenderIndex, transport: MutationTransport, envelope: MutationEnvelope) {
|
|
1280
1603
|
this.render = render;
|
|
1281
|
-
this.
|
|
1604
|
+
this.transport = transport;
|
|
1282
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
|
+
};
|
|
1283
1618
|
}
|
|
1284
1619
|
|
|
1285
1620
|
/** True once the tx upgraded to an interactive session (the backend then commits it). */
|
|
1286
1621
|
get session(): boolean {
|
|
1287
|
-
return this.
|
|
1622
|
+
return this.sessionHandle !== undefined;
|
|
1288
1623
|
}
|
|
1289
1624
|
|
|
1290
1625
|
get statements(): readonly SqlStatement[] {
|
|
@@ -1292,12 +1627,21 @@ class DaemonLazyTx implements ServerMutationTx {
|
|
|
1292
1627
|
}
|
|
1293
1628
|
|
|
1294
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
|
+
}
|
|
1295
1636
|
this.stmts.push({ sql, params });
|
|
1296
1637
|
}
|
|
1297
1638
|
|
|
1298
1639
|
private push(op: MutationOp): Promise<void> {
|
|
1299
1640
|
const rendered = renderOp(op, tableMeta(this.render, op.table), sqliteDialect);
|
|
1300
|
-
if (rendered)
|
|
1641
|
+
if (rendered) {
|
|
1642
|
+
if (this.transport.strictValues) assertEncodableParams(rendered.sql, rendered.params);
|
|
1643
|
+
this.stmts.push(rendered);
|
|
1644
|
+
}
|
|
1301
1645
|
return Promise.resolve();
|
|
1302
1646
|
}
|
|
1303
1647
|
|
|
@@ -1339,42 +1683,47 @@ class DaemonLazyTx implements ServerMutationTx {
|
|
|
1339
1683
|
return JSON.parse(cell) as unknown;
|
|
1340
1684
|
}
|
|
1341
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
|
+
|
|
1342
1691
|
/** Run one read: upgrade to a session at the first (§5.1), ride the open one after, or fall
|
|
1343
1692
|
* back to the legacy committed-state read when the daemon client lacks sessions. */
|
|
1344
1693
|
private async readThroughTxn(read: SqlStatement): Promise<SqlReadOutput> {
|
|
1345
1694
|
if (this.absorbed) throw new AbsorbedReplay();
|
|
1346
|
-
|
|
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) {
|
|
1347
1701
|
try {
|
|
1348
|
-
return await this.
|
|
1702
|
+
return await this.transport.readCommitted(read);
|
|
1349
1703
|
} catch (err) {
|
|
1350
1704
|
throw new BackendError(err);
|
|
1351
1705
|
}
|
|
1352
1706
|
}
|
|
1353
1707
|
try {
|
|
1354
|
-
if (this.
|
|
1355
|
-
const opened = await this.
|
|
1356
|
-
|
|
1357
|
-
mid: this.envelope.mid,
|
|
1708
|
+
if (this.sessionHandle === undefined) {
|
|
1709
|
+
const opened = await this.transport.begin({
|
|
1710
|
+
envelope: this.envelope,
|
|
1358
1711
|
statements: this.stmts.splice(0),
|
|
1359
1712
|
query: read,
|
|
1713
|
+
...(this.idempotencyKey !== undefined ? { idempotencyKey: this.idempotencyKey } : {}),
|
|
1360
1714
|
});
|
|
1361
1715
|
if (opened.absorbed) {
|
|
1362
|
-
|
|
1363
|
-
this.absorbed = output as SqlTxnOutput;
|
|
1716
|
+
this.absorbed = opened.absorbed;
|
|
1364
1717
|
throw new AbsorbedReplay();
|
|
1365
1718
|
}
|
|
1366
|
-
if (
|
|
1719
|
+
if (opened.handle === undefined || !opened.read) {
|
|
1367
1720
|
throw new Error(`malformed mutate-session begin reply: ${JSON.stringify(opened)}`);
|
|
1368
1721
|
}
|
|
1369
|
-
this.
|
|
1722
|
+
this.sessionHandle = opened.handle;
|
|
1370
1723
|
return opened.read;
|
|
1371
1724
|
}
|
|
1372
1725
|
await this.flush();
|
|
1373
|
-
return await this.
|
|
1374
|
-
sessionId: this.sessionId,
|
|
1375
|
-
sql: read.sql,
|
|
1376
|
-
params: read.params,
|
|
1377
|
-
});
|
|
1726
|
+
return await this.transport.query(this.sessionHandle, read);
|
|
1378
1727
|
} catch (err) {
|
|
1379
1728
|
if (err instanceof AbsorbedReplay || err instanceof BackendError) throw err;
|
|
1380
1729
|
throw new BackendError(err);
|
|
@@ -1384,10 +1733,7 @@ class DaemonLazyTx implements ServerMutationTx {
|
|
|
1384
1733
|
/** Ship buffered writes into the open session, order-preserving; a no-op when none pend. */
|
|
1385
1734
|
private async flush(): Promise<void> {
|
|
1386
1735
|
if (this.stmts.length === 0) return;
|
|
1387
|
-
await this.
|
|
1388
|
-
sessionId: this.sessionId!,
|
|
1389
|
-
statements: this.stmts.splice(0),
|
|
1390
|
-
});
|
|
1736
|
+
await this.transport.exec(this.sessionHandle!, this.stmts.splice(0));
|
|
1391
1737
|
}
|
|
1392
1738
|
|
|
1393
1739
|
/** Flush + commit the open session — the daemon stamps lmid co-transactionally (§4.4) and
|
|
@@ -1395,7 +1741,11 @@ class DaemonLazyTx implements ServerMutationTx {
|
|
|
1395
1741
|
async commitSession(): Promise<SqlTxnOutput> {
|
|
1396
1742
|
try {
|
|
1397
1743
|
await this.flush();
|
|
1398
|
-
|
|
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;
|
|
1399
1749
|
} catch (err) {
|
|
1400
1750
|
throw err instanceof BackendError ? err : new BackendError(err);
|
|
1401
1751
|
}
|
|
@@ -1404,11 +1754,11 @@ class DaemonLazyTx implements ServerMutationTx {
|
|
|
1404
1754
|
/** Best-effort rollback (the daemon's deadline is the backstop). MUST be awaited before a
|
|
1405
1755
|
* follow-up `/reject-mutation`: that lmid-only commit needs the writer this session holds. */
|
|
1406
1756
|
async rollbackSessionQuietly(): Promise<void> {
|
|
1407
|
-
if (this.
|
|
1408
|
-
const
|
|
1409
|
-
this.
|
|
1757
|
+
if (this.sessionHandle === undefined) return;
|
|
1758
|
+
const sessionHandle = this.sessionHandle;
|
|
1759
|
+
this.sessionHandle = undefined;
|
|
1410
1760
|
try {
|
|
1411
|
-
await this.
|
|
1761
|
+
await this.transport.rollback(sessionHandle);
|
|
1412
1762
|
} catch {
|
|
1413
1763
|
// Unreachable daemon / already-expired session: the deadline rollback covers it.
|
|
1414
1764
|
}
|
|
@@ -1425,12 +1775,27 @@ class PgLiveTx implements ServerMutationTx {
|
|
|
1425
1775
|
private readonly q: PgQuery;
|
|
1426
1776
|
private readonly render: RenderIndex;
|
|
1427
1777
|
private readonly rewrite: (sql: string) => string;
|
|
1778
|
+
readonly sql: ServerSql;
|
|
1428
1779
|
idempotencyKey?: string;
|
|
1429
1780
|
|
|
1430
1781
|
constructor(q: PgQuery, render: RenderIndex, rewrite: (sql: string) => string) {
|
|
1431
1782
|
this.q = q;
|
|
1432
1783
|
this.render = render;
|
|
1433
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
|
+
};
|
|
1434
1799
|
}
|
|
1435
1800
|
|
|
1436
1801
|
get statements(): readonly SqlStatement[] {
|
|
@@ -1505,58 +1870,92 @@ class PgLiveTx implements ServerMutationTx {
|
|
|
1505
1870
|
),
|
|
1506
1871
|
);
|
|
1507
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
|
+
}
|
|
1508
1882
|
}
|
|
1509
1883
|
|
|
1510
|
-
/**
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
|
|
1515
|
-
* (DAEMON-INTERACTIVE-TXN-DESIGN.md): reads are read-your-writes through the open transaction
|
|
1516
|
-
* (PG parity), the commit stamps `lmid` in the same atomic unit, and a begin-absorbed replay
|
|
1517
|
-
* short-circuits without re-running the body.
|
|
1518
|
-
*/
|
|
1519
|
-
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 {
|
|
1520
1889
|
return {
|
|
1521
1890
|
dialect: sqliteDialect,
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
await run(tx);
|
|
1526
|
-
} catch (err) {
|
|
1527
|
-
// A begin-absorbed replay: the authoritative outcome already committed — answer it,
|
|
1528
|
-
// whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
|
|
1529
|
-
if (tx.absorbed) return { accepted: true, output: tx.absorbed };
|
|
1530
|
-
if (err instanceof BackendError) {
|
|
1531
|
-
await tx.rollbackSessionQuietly();
|
|
1532
|
-
throw err.driverError; // infra — never a user rejection
|
|
1533
|
-
}
|
|
1534
|
-
const reason = errMessage(err);
|
|
1535
|
-
// Data first, watermark second: the rollback releases the single writer that the
|
|
1536
|
-
// `/reject-mutation` lmid-only commit needs (§2.4 on the session path).
|
|
1537
|
-
await tx.rollbackSessionQuietly();
|
|
1538
|
-
const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
|
|
1539
|
-
return { accepted: false, reason, output };
|
|
1540
|
-
}
|
|
1541
|
-
if (tx.absorbed) return { accepted: true, output: tx.absorbed };
|
|
1542
|
-
if (tx.session) {
|
|
1891
|
+
outsideSql,
|
|
1892
|
+
async runMutation(input) {
|
|
1893
|
+
for (let attempt = 0; attempt < MUTATOR_CONFLICT_MAX_ATTEMPTS; attempt++) {
|
|
1543
1894
|
try {
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1895
|
+
const { envelope, render, run } = input;
|
|
1896
|
+
const tx = new RemoteLazyTx(render, transport, envelope);
|
|
1897
|
+
try {
|
|
1898
|
+
await run(tx);
|
|
1899
|
+
} catch (err) {
|
|
1900
|
+
// A begin-absorbed replay: the authoritative outcome already committed — answer it,
|
|
1901
|
+
// whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
|
|
1902
|
+
if (tx.absorbed) return { accepted: true, output: tx.absorbed };
|
|
1903
|
+
if (err instanceof BackendError) {
|
|
1904
|
+
await tx.rollbackSessionQuietly();
|
|
1905
|
+
throw err.driverError; // infra — never a user rejection
|
|
1906
|
+
}
|
|
1907
|
+
const reason = errMessage(err);
|
|
1908
|
+
// Data first, watermark second: rollback releases this session's connection before
|
|
1909
|
+
// the lmid-only rejection commit.
|
|
1910
|
+
await tx.rollbackSessionQuietly();
|
|
1911
|
+
const output = await transport.reject({ envelope, reason });
|
|
1912
|
+
return { accepted: false, reason, output };
|
|
1913
|
+
}
|
|
1914
|
+
if (tx.absorbed) return { accepted: true, output: tx.absorbed };
|
|
1915
|
+
if (tx.session) {
|
|
1916
|
+
try {
|
|
1917
|
+
return { accepted: true, output: await tx.commitSession() };
|
|
1918
|
+
} catch (err) {
|
|
1919
|
+
if (err instanceof BackendError) throw err.driverError;
|
|
1920
|
+
throw err;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
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
|
+
};
|
|
1931
|
+
} catch (error) {
|
|
1932
|
+
if (!isRetryableCommitConflict(error) || attempt + 1 === MUTATOR_CONFLICT_MAX_ATTEMPTS) {
|
|
1933
|
+
throw error;
|
|
1934
|
+
}
|
|
1935
|
+
await mutatorConflictBackoff(attempt);
|
|
1548
1936
|
}
|
|
1549
1937
|
}
|
|
1550
|
-
|
|
1551
|
-
if (tx.idempotencyKey !== undefined) txn.idempotencyKey = tx.idempotencyKey;
|
|
1552
|
-
return { accepted: true, output: await daemon.executeSqlTxn(txn) };
|
|
1938
|
+
throw new Error("unreachable mutator conflict retry loop");
|
|
1553
1939
|
},
|
|
1554
1940
|
reject({ envelope, reason }) {
|
|
1555
|
-
return
|
|
1941
|
+
return transport.reject({ envelope, reason });
|
|
1556
1942
|
},
|
|
1557
1943
|
};
|
|
1558
1944
|
}
|
|
1559
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
|
+
|
|
1560
1959
|
/** The query surface a {@link PostgresPlugger} transaction exposes. `exec` runs one statement;
|
|
1561
1960
|
* `query` returns rows keyed by column name (read-your-own-writes inside the txn). */
|
|
1562
1961
|
export interface PgQuery {
|
|
@@ -1607,8 +2006,27 @@ export function postgresBackend(plugger: PostgresPlugger, opts: PostgresBackendO
|
|
|
1607
2006
|
await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
|
|
1608
2007
|
return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
|
|
1609
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
|
+
};
|
|
1610
2027
|
return {
|
|
1611
2028
|
dialect: postgresDialect,
|
|
2029
|
+
outsideSql,
|
|
1612
2030
|
async runMutation({ envelope, render, run }) {
|
|
1613
2031
|
try {
|
|
1614
2032
|
const output = await plugger.transaction(async (q) => {
|
|
@@ -1939,6 +2357,44 @@ function normalizeCondition(c: Condition): unknown {
|
|
|
1939
2357
|
}
|
|
1940
2358
|
}
|
|
1941
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
|
+
|
|
1942
2398
|
/**
|
|
1943
2399
|
* The runtime {@link MutationScope} handed to a {@link ScopedMutator}. It owns the single atomic
|
|
1944
2400
|
* transaction (delegating to {@link MutationBackend.runMutation} — the exact machinery a tx-form
|
|
@@ -1956,6 +2412,7 @@ class MutationScopeImpl implements MutationScope {
|
|
|
1956
2412
|
private readonly backend: MutationBackend;
|
|
1957
2413
|
private readonly envelope: MutationEnvelope;
|
|
1958
2414
|
private readonly render: RenderIndex;
|
|
2415
|
+
readonly sql: ServerSql;
|
|
1959
2416
|
/** Set once `transact` resolved through the backend (accepted OR business-rejected). */
|
|
1960
2417
|
outcome?: MutationOutcome;
|
|
1961
2418
|
/** The value the backend threw on INFRA (the DB failed) — always propagated, never an `lmid`
|
|
@@ -1975,6 +2432,7 @@ class MutationScopeImpl implements MutationScope {
|
|
|
1975
2432
|
this.backend = backend;
|
|
1976
2433
|
this.envelope = envelope;
|
|
1977
2434
|
this.render = render;
|
|
2435
|
+
this.sql = scopedOutsideSql(backend.outsideSql);
|
|
1978
2436
|
}
|
|
1979
2437
|
|
|
1980
2438
|
transact(
|
|
@@ -2024,8 +2482,24 @@ class MutationScopeImpl implements MutationScope {
|
|
|
2024
2482
|
export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptions<User>): RindleApiServer<User> {
|
|
2025
2483
|
const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
|
|
2026
2484
|
const mode = opts.mode ?? "normalized";
|
|
2027
|
-
//
|
|
2028
|
-
|
|
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
|
+
}
|
|
2029
2503
|
// Schema-derived render metadata for logical mutator writes; `{}` when no schema is configured (a
|
|
2030
2504
|
// logical op then throws loudly — the tx never silently drops a write). Each backend renders in its
|
|
2031
2505
|
// own dialect (`backend.dialect`: daemon→sqlite, postgres→postgres).
|
|
@@ -2086,20 +2560,13 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2086
2560
|
);
|
|
2087
2561
|
};
|
|
2088
2562
|
|
|
2089
|
-
//
|
|
2090
|
-
//
|
|
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.
|
|
2091
2568
|
const AGGREGATE_REFUSAL =
|
|
2092
|
-
"the query
|
|
2093
|
-
|
|
2094
|
-
// Verdict cache. The verdict is a pure function of exactly two inputs — the resolved footprint
|
|
2095
|
-
// AST and the resolved query AST — so the tightest SOUND key is those two ASTs themselves
|
|
2096
|
-
// (stable-stringified), scoped by (queryName, profile) for legibility. Args/user/ctx need no
|
|
2097
|
-
// separate slot precisely because anything that changes the verdict must change one of the two
|
|
2098
|
-
// ASTs (predicate literals embed the args; ctx-scoped queries embed the principal); keying on
|
|
2099
|
-
// `(name, args)` alone would ALIAS two users' different ASTs under one verdict — unsound.
|
|
2100
|
-
// Bounded FIFO (Map iterates in insertion order) so per-user literals can't grow it forever.
|
|
2101
|
-
const coverVerdicts = new Map<string, CoverQueryOutput>();
|
|
2102
|
-
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)";
|
|
2103
2570
|
|
|
2104
2571
|
const maybeRoomServe = async (
|
|
2105
2572
|
input: QueryLeaseRequest<User>,
|
|
@@ -2118,13 +2585,6 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2118
2585
|
warnRoomServeOnce(input.name, profile.name, ["realtime.locateRoom is not configured"]);
|
|
2119
2586
|
return undefined;
|
|
2120
2587
|
}
|
|
2121
|
-
const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
|
|
2122
|
-
if (coverQuery === undefined) {
|
|
2123
|
-
warnRoomServeOnce(input.name, profile.name, [
|
|
2124
|
-
"the configured daemon client does not implement coverQuery (/cover-check)",
|
|
2125
|
-
]);
|
|
2126
|
-
return undefined;
|
|
2127
|
-
}
|
|
2128
2588
|
const tokenKey = realtime.roomTokenKey;
|
|
2129
2589
|
if (tokenKey === undefined) {
|
|
2130
2590
|
warnRoomServeOnce(input.name, profile.name, [
|
|
@@ -2153,26 +2613,15 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2153
2613
|
const footprintAst = queryResultToAst(await profile.footprint(key, context));
|
|
2154
2614
|
assertUnwindowedFootprint(footprintAst, profile.name);
|
|
2155
2615
|
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
if (
|
|
2159
|
-
|
|
2160
|
-
? { covered: false, reasons: [AGGREGATE_REFUSAL] }
|
|
2161
|
-
: await coverQuery({ footprint: footprintAst, query: queryAst });
|
|
2162
|
-
// (A coverQuery THROW never lands here — the outer catch fail-opens without caching, so
|
|
2163
|
-
// a transient daemon failure doesn't pin an uncovered verdict.)
|
|
2164
|
-
if (coverVerdicts.size >= COVER_VERDICT_CACHE_MAX) {
|
|
2165
|
-
coverVerdicts.delete(coverVerdicts.keys().next().value as string);
|
|
2166
|
-
}
|
|
2167
|
-
coverVerdicts.set(verdictKey, verdict);
|
|
2168
|
-
}
|
|
2169
|
-
if (!verdict.covered) {
|
|
2170
|
-
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]);
|
|
2171
2620
|
return undefined;
|
|
2172
2621
|
}
|
|
2173
2622
|
|
|
2174
|
-
//
|
|
2175
|
-
//
|
|
2623
|
+
// Assemble the realtime block. The room endpoint rides ITS OWN field
|
|
2624
|
+
// (`realtime.wsEndpoint`) — a separate connection from the daemon session's fixed ws host.
|
|
2176
2625
|
const { wsEndpoint } = await realtime.locateRoom(doc);
|
|
2177
2626
|
const now = Date.now();
|
|
2178
2627
|
const ttlMs = realtime.roomTokenTtlMs ?? DEFAULT_ROOM_TOKEN_TTL_MS;
|
|
@@ -2202,7 +2651,8 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2202
2651
|
tables,
|
|
2203
2652
|
};
|
|
2204
2653
|
} catch (e) {
|
|
2205
|
-
// 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.
|
|
2206
2656
|
warnRoomServeOnce(input.name, profile.name, [`room-serve failed: ${errMessage(e)}`]);
|
|
2207
2657
|
return undefined;
|
|
2208
2658
|
}
|
|
@@ -2266,11 +2716,14 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2266
2716
|
subject,
|
|
2267
2717
|
leaseTtlMs: opts.leaseTtlMs,
|
|
2268
2718
|
metadata: routingKey !== undefined ? { routingKey } : undefined,
|
|
2719
|
+
// Lifecycle leases are follower-local exactly like the primary lease. Forward the SAME
|
|
2720
|
+
// opaque placement ticket so every doorbell/fence materialization is minted on the
|
|
2721
|
+
// browser socket's follower instead of independently anycasting across the fleet.
|
|
2722
|
+
...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
|
|
2269
2723
|
});
|
|
2270
2724
|
const lease = (table: string, out: MaterializeOutput, id: { scope?: string; doc?: string; clientId?: string }): QueryLeaseLifecycleLease => ({
|
|
2271
2725
|
table,
|
|
2272
2726
|
leaseToken: out.leaseToken,
|
|
2273
|
-
...(out.wsEndpoint !== undefined ? { wsEndpoint: out.wsEndpoint } : {}),
|
|
2274
2727
|
...(id.scope !== undefined ? { scope: id.scope } : {}),
|
|
2275
2728
|
...(id.doc !== undefined ? { doc: id.doc } : {}),
|
|
2276
2729
|
...(id.clientId !== undefined ? { clientId: id.clientId } : {}),
|
|
@@ -2456,16 +2909,20 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2456
2909
|
// The anonymous routing key rides `metadata.routingKey`; the router keys on
|
|
2457
2910
|
// `subject ?? metadata.routingKey` (§2.2). Omitted when there is none.
|
|
2458
2911
|
metadata: routingKey !== undefined ? { routingKey } : undefined,
|
|
2912
|
+
// Forward the browser's opaque affinity ticket (if any) so the fleet `fly-replay`s this
|
|
2913
|
+
// materialize to the follower the ws is pinned to (FOLLOWER-AFFINITY-DESIGN.md §4). Opaque —
|
|
2914
|
+
// never verified here. Inert when the reads client is a single daemon (no fleet edge).
|
|
2915
|
+
...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
|
|
2459
2916
|
});
|
|
2460
2917
|
const res = queryLeaseResponse(out);
|
|
2461
2918
|
// I-iv (§4.1): the occupancy step FIRST — session sweep+upsert, then the D6 gate verdict. A
|
|
2462
2919
|
// closed gate suppresses the room-serve ONLY (the lease ships without the realtime block,
|
|
2463
|
-
// indistinguishable from
|
|
2920
|
+
// indistinguishable from a non-room-served query — the daemon path) while the doorbell
|
|
2464
2921
|
// below still rides; lifecycle-off ⇒ `gateOpen: true` unconditionally and this line is inert.
|
|
2465
2922
|
const occ = await lifecycleOccupancy(input);
|
|
2466
|
-
// G-iv-b: a
|
|
2923
|
+
// G-iv-b: a labeled + wired query ADDITIONALLY gains the realtime block. The daemon lease
|
|
2467
2924
|
// above is unconditional (and its fields untouched) — room-serving only ever adds a field,
|
|
2468
|
-
// so
|
|
2925
|
+
// so a non-room-served/legacy lease stays byte-identical and nothing here can block one.
|
|
2469
2926
|
const rt = occ.gateOpen ? await maybeRoomServe(input, ast, context, subject) : undefined;
|
|
2470
2927
|
if (rt !== undefined) res.realtime = rt;
|
|
2471
2928
|
// I-v (§4.2): the gate CLOSED and a room plausibly hosted this scope — drain it and ride the
|
|
@@ -2505,10 +2962,13 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2505
2962
|
const subject = await resolveSubject(opts.subject, input);
|
|
2506
2963
|
const routingKey = await resolveRoutingKey(opts.routingKey, input);
|
|
2507
2964
|
const visibilityKey = subject ?? routingKey;
|
|
2508
|
-
const out = await opts.daemon.query({
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2965
|
+
const out = await opts.daemon.query({
|
|
2966
|
+
ast,
|
|
2967
|
+
visibilityKey,
|
|
2968
|
+
ttlMs: opts.readIdleTtlMs,
|
|
2969
|
+
...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
|
|
2970
|
+
});
|
|
2971
|
+
return { rows: out.rows, cvMin: out.cvMin, queryKey: out.queryKey };
|
|
2512
2972
|
};
|
|
2513
2973
|
|
|
2514
2974
|
const pushMutation = async (input: PushMutationRequest<User>): Promise<PushMutationResponse> => {
|
|
@@ -2656,57 +3116,6 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2656
3116
|
}
|
|
2657
3117
|
};
|
|
2658
3118
|
|
|
2659
|
-
// The explicit coverage diagnostic (the assertPins pattern: system-level, resolved under
|
|
2660
|
-
// `pinUser`, per-query failures collected — never strand the rest). It runs the REAL check —
|
|
2661
|
-
// the daemon's /cover-check on the actually-resolved ASTs — so its verdicts are exactly the
|
|
2662
|
-
// lease path's, minus the serving wiring (locateRoom/roomTokenKey), which it deliberately
|
|
2663
|
-
// ignores: it answers "is this labeled query coverable", the deployable-config question.
|
|
2664
|
-
const validateRealtime = async (vopts?: {
|
|
2665
|
-
exemplars?: Partial<Record<string, readonly unknown[]>>;
|
|
2666
|
-
strict?: boolean;
|
|
2667
|
-
}): Promise<ValidateRealtimeReport> => {
|
|
2668
|
-
const context: ApiContext<User> = { user: opts.pinUser as User, request: undefined };
|
|
2669
|
-
const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
|
|
2670
|
-
const verdicts: ValidateRealtimeVerdict[] = [];
|
|
2671
|
-
for (const [name, q] of Object.entries(opts.queries ?? {})) {
|
|
2672
|
-
const label = queryRealtimeLabel(q);
|
|
2673
|
-
if (label === undefined) continue;
|
|
2674
|
-
const profile = roomProfiles.get(label.room);
|
|
2675
|
-
if (profile === undefined) continue; // unreachable: construction asserted it exists
|
|
2676
|
-
for (const args of vopts?.exemplars?.[name] ?? [null]) {
|
|
2677
|
-
const verdict: ValidateRealtimeVerdict = { query: name, profile: profile.name, args, covered: false };
|
|
2678
|
-
try {
|
|
2679
|
-
const ast = await resolveAst(name, args, context);
|
|
2680
|
-
const roomArgs = label.args !== undefined ? label.args(args) : args;
|
|
2681
|
-
const footprintAst = queryResultToAst(await profile.footprint(profile.key(roomArgs), context));
|
|
2682
|
-
assertUnwindowedFootprint(footprintAst, profile.name);
|
|
2683
|
-
if (astHasAggregate(ast)) {
|
|
2684
|
-
verdict.reasons = [AGGREGATE_REFUSAL];
|
|
2685
|
-
} else if (coverQuery === undefined) {
|
|
2686
|
-
verdict.reasons = ["the configured daemon client does not implement coverQuery (/cover-check)"];
|
|
2687
|
-
} else {
|
|
2688
|
-
const out = await coverQuery({ footprint: footprintAst, query: ast });
|
|
2689
|
-
verdict.covered = out.covered;
|
|
2690
|
-
if (!out.covered) verdict.reasons = out.reasons ?? ["not provably covered"];
|
|
2691
|
-
}
|
|
2692
|
-
} catch (e) {
|
|
2693
|
-
verdict.reasons = [errMessage(e)];
|
|
2694
|
-
}
|
|
2695
|
-
verdicts.push(verdict);
|
|
2696
|
-
}
|
|
2697
|
-
}
|
|
2698
|
-
const uncovered = verdicts.filter((v) => !v.covered);
|
|
2699
|
-
if (vopts?.strict && uncovered.length > 0) {
|
|
2700
|
-
throw new Error(
|
|
2701
|
-
`validateRealtime: ${uncovered.length} labeled query verdict(s) not provably covered — ` +
|
|
2702
|
-
uncovered
|
|
2703
|
-
.map((v) => `${v.query} (profile "${v.profile}"): ${(v.reasons ?? []).join("; ")}`)
|
|
2704
|
-
.join(" | "),
|
|
2705
|
-
);
|
|
2706
|
-
}
|
|
2707
|
-
return { verdicts, uncovered };
|
|
2708
|
-
};
|
|
2709
|
-
|
|
2710
3119
|
// The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
|
|
2711
3120
|
// the `realtime` block (which also activates `/room-boot`) or the deprecated bare
|
|
2712
3121
|
// `authorizeRoom` (trio only). Hosting an authority is never a default.
|
|
@@ -2771,10 +3180,10 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2771
3180
|
|
|
2772
3181
|
return {
|
|
2773
3182
|
routes,
|
|
3183
|
+
close: () => ownedSql?.close(),
|
|
2774
3184
|
createQueryLease,
|
|
2775
3185
|
readQuery,
|
|
2776
3186
|
assertPins,
|
|
2777
|
-
validateRealtime,
|
|
2778
3187
|
pushMutation,
|
|
2779
3188
|
pushMutations,
|
|
2780
3189
|
handleApplyRowChangeTxnJson: async (body, context) => {
|
|
@@ -2866,7 +3275,8 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2866
3275
|
headers,
|
|
2867
3276
|
},
|
|
2868
3277
|
};
|
|
2869
|
-
|
|
3278
|
+
if (lease.affinity !== undefined) res.upstreamAffinity = lease.affinity;
|
|
3279
|
+
const upstreamWsEndpoint = realtime.upstreamWsEndpoint;
|
|
2870
3280
|
if (upstreamWsEndpoint !== undefined) res.upstreamWsEndpoint = upstreamWsEndpoint;
|
|
2871
3281
|
return { status: 200, body: res };
|
|
2872
3282
|
} catch (e) {
|
|
@@ -2881,6 +3291,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2881
3291
|
args: msg.args ?? null,
|
|
2882
3292
|
request: context.request,
|
|
2883
3293
|
clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
|
|
3294
|
+
affinity: typeof msg.affinity === "string" ? msg.affinity : undefined,
|
|
2884
3295
|
});
|
|
2885
3296
|
},
|
|
2886
3297
|
handleReadJson: (body, context) => {
|
|
@@ -2891,6 +3302,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2891
3302
|
args: msg.args ?? null,
|
|
2892
3303
|
request: context.request,
|
|
2893
3304
|
clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
|
|
3305
|
+
affinity: typeof msg.affinity === "string" ? msg.affinity : undefined,
|
|
2894
3306
|
});
|
|
2895
3307
|
},
|
|
2896
3308
|
handleMutateJson: (body, context) => {
|
|
@@ -2958,8 +3370,9 @@ function applyOpToServerTx(tx: ServerWriteTx, op: MutationOp): Promise<void> {
|
|
|
2958
3370
|
|
|
2959
3371
|
/** Feed a mutator's RETURNED result (the alternative to calling `tx.exec`/logical ops directly) into
|
|
2960
3372
|
* the backend tx: a returned `SqlStatement[]` / `SqlTxn` is exec'd onto `tx`, and a carried
|
|
2961
|
-
* `idempotencyKey` is stashed
|
|
2962
|
-
*
|
|
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. */
|
|
2963
3376
|
function applyResultToTx(result: ApiMutatorResult, tx: ServerMutationTx): void {
|
|
2964
3377
|
if (!result) return;
|
|
2965
3378
|
const statements = Array.isArray(result) ? result : result.statements;
|
|
@@ -3062,15 +3475,12 @@ async function resolveRoutingKey<User>(
|
|
|
3062
3475
|
}
|
|
3063
3476
|
|
|
3064
3477
|
function queryLeaseResponse(out: MaterializeOutput): QueryLeaseResponse {
|
|
3065
|
-
|
|
3478
|
+
return {
|
|
3066
3479
|
leaseToken: out.leaseToken,
|
|
3067
3480
|
materializationId: out.materializationId,
|
|
3068
3481
|
queryKey: out.queryKey,
|
|
3069
3482
|
reused: out.reused,
|
|
3070
3483
|
};
|
|
3071
|
-
// Only present in a routed deploy — absent reproduces today's single-daemon response exactly.
|
|
3072
|
-
if (out.wsEndpoint !== undefined) res.wsEndpoint = out.wsEndpoint;
|
|
3073
|
-
return res;
|
|
3074
3484
|
}
|
|
3075
3485
|
|
|
3076
3486
|
function errMessage(reason: unknown): string {
|
|
@@ -3097,7 +3507,7 @@ const ROOM_MUTATION_OUTCOMES_TABLE = "_rindle_room_mutation_outcomes";
|
|
|
3097
3507
|
// delta fanning to every subscribed solo client; no clientID/mid — a system write must never
|
|
3098
3508
|
// advance an lmid — and no idempotencyKey — a renewal's re-upsert must re-run, that is the
|
|
3099
3509
|
// refresh), and the count is one `executeSqlRead` with `consistency: "strong"` — the read surface
|
|
3100
|
-
// the api-server already has against the daemon (the `
|
|
3510
|
+
// the api-server already has against the daemon (the `RemoteLazyTx` fallback precedent above).
|
|
3101
3511
|
// "strong" routes the read to the WRITE MASTER in a split deploy, which just serialized our
|
|
3102
3512
|
// upsert: read-your-writes without a mutation session (the interactive-txn machinery is optional
|
|
3103
3513
|
// on the daemon interface and far heavier than this two-round-trip pair needs).
|
|
@@ -3176,19 +3586,6 @@ function docClientAst(table: string, doc: string, clientId: string | undefined):
|
|
|
3176
3586
|
* lease through the api-server, never an extension of this token. */
|
|
3177
3587
|
const DEFAULT_ROOM_TOKEN_TTL_MS = 5 * 60_000;
|
|
3178
3588
|
|
|
3179
|
-
/** Deterministic JSON: object keys sorted recursively, so two structurally identical ASTs from
|
|
3180
|
-
* independent resolves stringify identically (the verdict-cache key). */
|
|
3181
|
-
function stableStringify(v: unknown): string {
|
|
3182
|
-
return JSON.stringify(v, (_key, value: unknown) => {
|
|
3183
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
3184
|
-
const rec = value as Record<string, unknown>;
|
|
3185
|
-
const sorted: Record<string, unknown> = {};
|
|
3186
|
-
for (const k of Object.keys(rec).sort()) sorted[k] = rec[k];
|
|
3187
|
-
return sorted;
|
|
3188
|
-
}
|
|
3189
|
-
return value;
|
|
3190
|
-
});
|
|
3191
|
-
}
|
|
3192
3589
|
|
|
3193
3590
|
/** Does the AST contain an aggregate/reduce shape ANYWHERE (root, a `related` subquery, or an
|
|
3194
3591
|
* `EXISTS` child)? Room-serving refuses these regardless of coverage: the client's aggregate
|