@rindle/api-server 0.5.0 → 0.6.3
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 +13 -11
- package/dist/index.d.ts +30 -27
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +90 -52
- package/dist/index.js.map +1 -1
- package/package.json +8 -6
- package/src/index.ts +121 -77
package/src/index.ts
CHANGED
|
@@ -68,11 +68,13 @@ import {
|
|
|
68
68
|
import type { RoomProfile, RoomScopeSpec, RoomTableSpec } from "./rooms.ts";
|
|
69
69
|
// The room lease token (RINDLE-REALTIME §10.1): minted here, verified by the room SHELL against
|
|
70
70
|
// 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
|
|
71
|
+
// Loaded LAZILY at the first mint: `@rindle/room` is an OPTIONAL dependency (see package.json), so
|
|
72
|
+
// it is installed transitively — a consumer bundling api-server (Vite/Rollup/esbuild) can resolve
|
|
73
|
+
// this dynamic import even when it never uses rooms — yet the mint only runs when
|
|
74
|
+
// `realtime.roomTokenKey` is configured. Should the module be genuinely absent (an install that
|
|
75
|
+
// skipped the optional dep), the serve decision fail-opens: daemon-served leases plus a one-time
|
|
76
|
+
// warning naming the missing module. It is NOT a hard `dependency` because the entire code path is
|
|
77
|
+
// optional; optionalDependencies keeps a failed install of it non-fatal.
|
|
76
78
|
import type { mintRoomToken as MintRoomToken, scopeSpecsHash as ScopeSpecsHash } from "@rindle/room/token";
|
|
77
79
|
let roomTokenModule: { mintRoomToken: typeof MintRoomToken; scopeSpecsHash: typeof ScopeSpecsHash } | undefined;
|
|
78
80
|
async function loadRoomTokenModule(): Promise<{ mintRoomToken: typeof MintRoomToken; scopeSpecsHash: typeof ScopeSpecsHash }> {
|
|
@@ -297,6 +299,11 @@ export interface QueryLeaseRequest<User> {
|
|
|
297
299
|
* when there is no authenticated subject and no session cookie (READ-ROUTER-DESIGN.md §1.5/§2.2).
|
|
298
300
|
* A routing HINT only, never authorization. */
|
|
299
301
|
clientId?: string;
|
|
302
|
+
/** The browser's opaque follower-affinity ticket (FOLLOWER-AFFINITY-DESIGN.md §3), read off the
|
|
303
|
+
* query POST and forwarded OPAQUELY on `materialize` so the fleet `fly-replay`s to the follower
|
|
304
|
+
* the browser's ws is pinned to (§2, §4) — both legs co-locate. The api-server does NOT verify
|
|
305
|
+
* it (the fleet does); it holds no signing key. Absent ⇒ single daemon / affinity off. */
|
|
306
|
+
affinity?: string;
|
|
300
307
|
}
|
|
301
308
|
|
|
302
309
|
/**
|
|
@@ -306,11 +313,10 @@ export interface QueryLeaseRequest<User> {
|
|
|
306
313
|
* client uses it to open the room transport for THIS query beside — never instead of — its daemon
|
|
307
314
|
* session.
|
|
308
315
|
*
|
|
309
|
-
* It is a
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* ones.
|
|
316
|
+
* It is a dedicated block on purpose: the ROOM ws is a SEPARATE connection this query opens beside
|
|
317
|
+
* its daemon session (never a migration of the daemon session — the daemon ws host is fixed and
|
|
318
|
+
* placed by the affinity ticket). A room-served lease's top-level fields are byte-identical to the
|
|
319
|
+
* daemon-served ones.
|
|
314
320
|
*/
|
|
315
321
|
export interface QueryLeaseRealtime {
|
|
316
322
|
/** The client store's gate/domain key for this room source (`connectSource`) AND the string the
|
|
@@ -318,8 +324,8 @@ export interface QueryLeaseRealtime {
|
|
|
318
324
|
* parses as a room source, and the established convention is `"room:" + doc`
|
|
319
325
|
* (e.g. `room:document/doc:d1`). */
|
|
320
326
|
sourceKey: string;
|
|
321
|
-
/** Where the client opens the ROOM ws for this query (from `realtime.locateRoom`) —
|
|
322
|
-
*
|
|
327
|
+
/** Where the client opens the ROOM ws for this query (from `realtime.locateRoom`) — its OWN
|
|
328
|
+
* connection, distinct from the daemon session's fixed ws host. */
|
|
323
329
|
wsEndpoint: string;
|
|
324
330
|
/** The room's self-authorizing signed lease (`@rindle/room/token`): the APPROVED query AST +
|
|
325
331
|
* doc + subject, HMAC-signed with `realtime.roomTokenKey` so the room shell's
|
|
@@ -349,9 +355,6 @@ export interface QueryLeaseLifecycleLease {
|
|
|
349
355
|
/** Which system table this lease's subscription serves. */
|
|
350
356
|
table: string;
|
|
351
357
|
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
358
|
/** DOORBELL only: the §4.1 occupancy scope — the wire room doc (`"<profile>/<key>"`). */
|
|
356
359
|
scope?: string;
|
|
357
360
|
/** FENCE entries only: the room doc the predicate is scoped to. */
|
|
@@ -393,9 +396,6 @@ export interface QueryLeaseResponse {
|
|
|
393
396
|
materializationId: string;
|
|
394
397
|
queryKey?: string;
|
|
395
398
|
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
399
|
/** The room-serve block (G-iv-b) — see {@link QueryLeaseRealtime}. Absent ⇒ the lease is
|
|
400
400
|
* byte-identical to the legacy daemon-served shape. */
|
|
401
401
|
realtime?: QueryLeaseRealtime;
|
|
@@ -420,6 +420,9 @@ export interface QueryReadRequest<User> {
|
|
|
420
420
|
* {@link QueryLeaseRequest.clientId}). Lets the SSR read co-locate on the follower the booting
|
|
421
421
|
* client's first subscribe will hit (READ-ROUTER-DESIGN.md §2.4). */
|
|
422
422
|
clientId?: string;
|
|
423
|
+
/** The browser's opaque follower-affinity ticket — see {@link QueryLeaseRequest.affinity}.
|
|
424
|
+
* Forwarded on the one-shot `query` so an SSR read lands on the same pinned follower. */
|
|
425
|
+
affinity?: string;
|
|
423
426
|
}
|
|
424
427
|
|
|
425
428
|
/** The assembled (nested-by-name) first-paint snapshot the server-side Store seeds + dehydrates
|
|
@@ -428,9 +431,6 @@ export interface QueryReadResponse {
|
|
|
428
431
|
rows: Array<{ cols: Record<string, unknown>; [rel: string]: unknown }>;
|
|
429
432
|
cvMin?: number;
|
|
430
433
|
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
434
|
}
|
|
435
435
|
|
|
436
436
|
/** The context a {@link MutationBackend} needs to run one mutation inside its transaction. */
|
|
@@ -548,6 +548,10 @@ export interface RoomBootResponse {
|
|
|
548
548
|
/** Where the room opens its upstream subscription (a routed deploy's follower). Absent ⇒ the
|
|
549
549
|
* shell's statically configured rindled ws endpoint. */
|
|
550
550
|
upstreamWsEndpoint?: string;
|
|
551
|
+
/** Fresh opaque follower-placement ticket minted alongside `upstreamLeaseToken`. The DO offers
|
|
552
|
+
* it with `rindle.v1` on the separate upstream ws so a static fleet endpoint replays to the
|
|
553
|
+
* exact follower holding that local lease. Absent when daemon affinity is off. */
|
|
554
|
+
upstreamAffinity?: string;
|
|
551
555
|
/** Per-footprint-table scope specs (H-iv-b), compiled from the resolved footprint AST + the
|
|
552
556
|
* profile's context set (the legacy anonymous profile compiles with an empty context set):
|
|
553
557
|
* what the shell hands the wasm room's `enableWritesV2` — the §3.3 commit gate. Optional
|
|
@@ -593,14 +597,16 @@ export interface RindleRealtimeOptions<User> {
|
|
|
593
597
|
/** Lease TTL for the room's upstream footprint materialization (defaults to the server-wide
|
|
594
598
|
* `leaseTtlMs`, else the daemon's default). */
|
|
595
599
|
upstreamLeaseTtlMs?: number;
|
|
596
|
-
/** Static
|
|
597
|
-
* `
|
|
600
|
+
/** Static endpoint where rooms open their upstream subscription. In a follower fleet this is the
|
|
601
|
+
* fleet ws URL; `/room-boot` pairs it with the materialization's fresh placement ticket so the
|
|
602
|
+
* room lands on the exact follower holding its lease. Absent ⇒ no explicit upstream (the Node
|
|
603
|
+
* room shell may use its own default; the shipped DO shell requires this endpoint). */
|
|
598
604
|
upstreamWsEndpoint?: string;
|
|
599
605
|
/** Locate (or place) the room serving `doc` and return the ROOM ws endpoint a room-served
|
|
600
606
|
* 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
|
-
*
|
|
607
|
+
* endpoint rides the lease's dedicated `realtime.wsEndpoint` — its OWN connection, distinct from
|
|
608
|
+
* the daemon session's fixed ws host. Absent ⇒ room-serving is OFF: labeled queries serve from
|
|
609
|
+
* the daemon exactly as today (fail-open). */
|
|
604
610
|
locateRoom?: (doc: string) => MaybePromise<{ wsEndpoint: string }>;
|
|
605
611
|
/** The room lease token signing key (`@rindle/room/token`): `kid` + secret, matching an entry
|
|
606
612
|
* in the room shell's `downstream.tokenKeys` ring. Required for room-serving (without it a
|
|
@@ -816,12 +822,11 @@ export interface RindleApiServerOptions<User> {
|
|
|
816
822
|
* HINT only — never authorization. Ignored by a single (unrouted) daemon, which has nothing to
|
|
817
823
|
* route. */
|
|
818
824
|
routingKey?: string | ((input: QueryLeaseRequest<User>) => MaybePromise<string | undefined>);
|
|
819
|
-
/** The EXPLICIT fleet pin fan-out
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
* pin). */
|
|
825
|
+
/** The EXPLICIT fleet pin fan-out — when set, {@link RindleApiServer.assertPins} fans each
|
|
826
|
+
* resolved pin across ALL live followers through it (a fleet control action over the machine
|
|
827
|
+
* list — FOLLOWER-AFFINITY-DESIGN.md §11) instead of materializing each pin once on the (single)
|
|
828
|
+
* daemon. A per-viewer `materialize` always routes ONE; a pin-assert always fans ALL — never
|
|
829
|
+
* inferred from `policy.kind`. Absent ⇒ single-daemon behavior (one materialize per pin). */
|
|
825
830
|
pinFanout?: PinFanout;
|
|
826
831
|
/** Named queries to keep permanently materialized via {@link RindleApiServer.assertPins}.
|
|
827
832
|
* Each is materialized with a `pinned` policy (survives zero subscribers) so late joiners
|
|
@@ -1008,18 +1013,18 @@ export class SplitDaemonClient implements RindleDaemonClient {
|
|
|
1008
1013
|
if (!lmids) return Promise.reject(new Error("the write master lacks roomLmids"));
|
|
1009
1014
|
return lmids(input);
|
|
1010
1015
|
}
|
|
1011
|
-
// Pure computation
|
|
1012
|
-
//
|
|
1016
|
+
// Pure computation hosted by rindled: keep it on the read/follower leg. The write master owns
|
|
1017
|
+
// room durability, not query-cover analysis.
|
|
1013
1018
|
coverQuery(input: CoverQueryInput): Promise<CoverQueryOutput> {
|
|
1014
|
-
const cover = this.
|
|
1015
|
-
if (!cover) return Promise.reject(new Error("the
|
|
1019
|
+
const cover = this.reads.coverQuery?.bind(this.reads);
|
|
1020
|
+
if (!cover) return Promise.reject(new Error("the read follower lacks coverQuery"));
|
|
1016
1021
|
return cover(input);
|
|
1017
1022
|
}
|
|
1018
1023
|
migrate(input: MigrateInput): Promise<MigrateOutput> {
|
|
1019
1024
|
return this.writes.migrate(input);
|
|
1020
1025
|
}
|
|
1021
1026
|
|
|
1022
|
-
// reads → the
|
|
1027
|
+
// reads → the fleet (one FLEET_URL follower; the affinity ticket + Fly edge place the machine)
|
|
1023
1028
|
materialize(input: MaterializeInput): Promise<MaterializeOutput> {
|
|
1024
1029
|
return this.reads.materialize(input);
|
|
1025
1030
|
}
|
|
@@ -1248,6 +1253,24 @@ class AbsorbedReplay extends Error {
|
|
|
1248
1253
|
}
|
|
1249
1254
|
}
|
|
1250
1255
|
|
|
1256
|
+
const MUTATOR_CONFLICT_MAX_ATTEMPTS = 5;
|
|
1257
|
+
|
|
1258
|
+
function isRetryableCommitConflict(error: unknown): boolean {
|
|
1259
|
+
if (!(error instanceof DaemonHttpError) || error.status !== 409) return false;
|
|
1260
|
+
try {
|
|
1261
|
+
const body = JSON.parse(error.body) as { code?: unknown; retryable?: unknown };
|
|
1262
|
+
return body.code === "retryable-conflict" && body.retryable === true;
|
|
1263
|
+
} catch {
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
async function mutatorConflictBackoff(attempt: number): Promise<void> {
|
|
1269
|
+
const ceiling = Math.min(32, 2 ** attempt);
|
|
1270
|
+
const millis = ceiling + Math.floor(Math.random() * 4);
|
|
1271
|
+
await new Promise<void>((resolve) => setTimeout(resolve, millis));
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1251
1274
|
/**
|
|
1252
1275
|
* The daemon server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
|
|
1253
1276
|
* execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
|
|
@@ -1519,37 +1542,48 @@ class PgLiveTx implements ServerMutationTx {
|
|
|
1519
1542
|
export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
|
|
1520
1543
|
return {
|
|
1521
1544
|
dialect: sqliteDialect,
|
|
1522
|
-
async runMutation(
|
|
1523
|
-
|
|
1524
|
-
try {
|
|
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) {
|
|
1545
|
+
async runMutation(input) {
|
|
1546
|
+
for (let attempt = 0; attempt < MUTATOR_CONFLICT_MAX_ATTEMPTS; attempt++) {
|
|
1543
1547
|
try {
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
+
const { envelope, render, run } = input;
|
|
1549
|
+
const tx = new DaemonLazyTx(render, daemon, envelope);
|
|
1550
|
+
try {
|
|
1551
|
+
await run(tx);
|
|
1552
|
+
} catch (err) {
|
|
1553
|
+
// A begin-absorbed replay: the authoritative outcome already committed — answer it,
|
|
1554
|
+
// whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
|
|
1555
|
+
if (tx.absorbed) return { accepted: true, output: tx.absorbed };
|
|
1556
|
+
if (err instanceof BackendError) {
|
|
1557
|
+
await tx.rollbackSessionQuietly();
|
|
1558
|
+
throw err.driverError; // infra — never a user rejection
|
|
1559
|
+
}
|
|
1560
|
+
const reason = errMessage(err);
|
|
1561
|
+
// Data first, watermark second: rollback releases this session's connection before
|
|
1562
|
+
// the lmid-only rejection commit.
|
|
1563
|
+
await tx.rollbackSessionQuietly();
|
|
1564
|
+
const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
|
|
1565
|
+
return { accepted: false, reason, output };
|
|
1566
|
+
}
|
|
1567
|
+
if (tx.absorbed) return { accepted: true, output: tx.absorbed };
|
|
1568
|
+
if (tx.session) {
|
|
1569
|
+
try {
|
|
1570
|
+
return { accepted: true, output: await tx.commitSession() };
|
|
1571
|
+
} catch (err) {
|
|
1572
|
+
if (err instanceof BackendError) throw err.driverError;
|
|
1573
|
+
throw err;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
const txn: SqlTxn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
|
|
1577
|
+
if (tx.idempotencyKey !== undefined) txn.idempotencyKey = tx.idempotencyKey;
|
|
1578
|
+
return { accepted: true, output: await daemon.executeSqlTxn(txn) };
|
|
1579
|
+
} catch (error) {
|
|
1580
|
+
if (!isRetryableCommitConflict(error) || attempt + 1 === MUTATOR_CONFLICT_MAX_ATTEMPTS) {
|
|
1581
|
+
throw error;
|
|
1582
|
+
}
|
|
1583
|
+
await mutatorConflictBackoff(attempt);
|
|
1548
1584
|
}
|
|
1549
1585
|
}
|
|
1550
|
-
|
|
1551
|
-
if (tx.idempotencyKey !== undefined) txn.idempotencyKey = tx.idempotencyKey;
|
|
1552
|
-
return { accepted: true, output: await daemon.executeSqlTxn(txn) };
|
|
1586
|
+
throw new Error("unreachable mutator conflict retry loop");
|
|
1553
1587
|
},
|
|
1554
1588
|
reject({ envelope, reason }) {
|
|
1555
1589
|
return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
|
|
@@ -2171,8 +2205,8 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2171
2205
|
return undefined;
|
|
2172
2206
|
}
|
|
2173
2207
|
|
|
2174
|
-
// Covered ⇒ assemble the realtime block. The room endpoint rides ITS OWN field
|
|
2175
|
-
//
|
|
2208
|
+
// Covered ⇒ assemble the realtime block. The room endpoint rides ITS OWN field
|
|
2209
|
+
// (`realtime.wsEndpoint`) — a separate connection from the daemon session's fixed ws host.
|
|
2176
2210
|
const { wsEndpoint } = await realtime.locateRoom(doc);
|
|
2177
2211
|
const now = Date.now();
|
|
2178
2212
|
const ttlMs = realtime.roomTokenTtlMs ?? DEFAULT_ROOM_TOKEN_TTL_MS;
|
|
@@ -2266,11 +2300,14 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2266
2300
|
subject,
|
|
2267
2301
|
leaseTtlMs: opts.leaseTtlMs,
|
|
2268
2302
|
metadata: routingKey !== undefined ? { routingKey } : undefined,
|
|
2303
|
+
// Lifecycle leases are follower-local exactly like the primary lease. Forward the SAME
|
|
2304
|
+
// opaque placement ticket so every doorbell/fence materialization is minted on the
|
|
2305
|
+
// browser socket's follower instead of independently anycasting across the fleet.
|
|
2306
|
+
...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
|
|
2269
2307
|
});
|
|
2270
2308
|
const lease = (table: string, out: MaterializeOutput, id: { scope?: string; doc?: string; clientId?: string }): QueryLeaseLifecycleLease => ({
|
|
2271
2309
|
table,
|
|
2272
2310
|
leaseToken: out.leaseToken,
|
|
2273
|
-
...(out.wsEndpoint !== undefined ? { wsEndpoint: out.wsEndpoint } : {}),
|
|
2274
2311
|
...(id.scope !== undefined ? { scope: id.scope } : {}),
|
|
2275
2312
|
...(id.doc !== undefined ? { doc: id.doc } : {}),
|
|
2276
2313
|
...(id.clientId !== undefined ? { clientId: id.clientId } : {}),
|
|
@@ -2456,6 +2493,10 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2456
2493
|
// The anonymous routing key rides `metadata.routingKey`; the router keys on
|
|
2457
2494
|
// `subject ?? metadata.routingKey` (§2.2). Omitted when there is none.
|
|
2458
2495
|
metadata: routingKey !== undefined ? { routingKey } : undefined,
|
|
2496
|
+
// Forward the browser's opaque affinity ticket (if any) so the fleet `fly-replay`s this
|
|
2497
|
+
// materialize to the follower the ws is pinned to (FOLLOWER-AFFINITY-DESIGN.md §4). Opaque —
|
|
2498
|
+
// never verified here. Inert when the reads client is a single daemon (no fleet edge).
|
|
2499
|
+
...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
|
|
2459
2500
|
});
|
|
2460
2501
|
const res = queryLeaseResponse(out);
|
|
2461
2502
|
// I-iv (§4.1): the occupancy step FIRST — session sweep+upsert, then the D6 gate verdict. A
|
|
@@ -2505,10 +2546,13 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2505
2546
|
const subject = await resolveSubject(opts.subject, input);
|
|
2506
2547
|
const routingKey = await resolveRoutingKey(opts.routingKey, input);
|
|
2507
2548
|
const visibilityKey = subject ?? routingKey;
|
|
2508
|
-
const out = await opts.daemon.query({
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2549
|
+
const out = await opts.daemon.query({
|
|
2550
|
+
ast,
|
|
2551
|
+
visibilityKey,
|
|
2552
|
+
ttlMs: opts.readIdleTtlMs,
|
|
2553
|
+
...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
|
|
2554
|
+
});
|
|
2555
|
+
return { rows: out.rows, cvMin: out.cvMin, queryKey: out.queryKey };
|
|
2512
2556
|
};
|
|
2513
2557
|
|
|
2514
2558
|
const pushMutation = async (input: PushMutationRequest<User>): Promise<PushMutationResponse> => {
|
|
@@ -2866,7 +2910,8 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2866
2910
|
headers,
|
|
2867
2911
|
},
|
|
2868
2912
|
};
|
|
2869
|
-
|
|
2913
|
+
if (lease.affinity !== undefined) res.upstreamAffinity = lease.affinity;
|
|
2914
|
+
const upstreamWsEndpoint = realtime.upstreamWsEndpoint;
|
|
2870
2915
|
if (upstreamWsEndpoint !== undefined) res.upstreamWsEndpoint = upstreamWsEndpoint;
|
|
2871
2916
|
return { status: 200, body: res };
|
|
2872
2917
|
} catch (e) {
|
|
@@ -2881,6 +2926,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2881
2926
|
args: msg.args ?? null,
|
|
2882
2927
|
request: context.request,
|
|
2883
2928
|
clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
|
|
2929
|
+
affinity: typeof msg.affinity === "string" ? msg.affinity : undefined,
|
|
2884
2930
|
});
|
|
2885
2931
|
},
|
|
2886
2932
|
handleReadJson: (body, context) => {
|
|
@@ -2891,6 +2937,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
2891
2937
|
args: msg.args ?? null,
|
|
2892
2938
|
request: context.request,
|
|
2893
2939
|
clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
|
|
2940
|
+
affinity: typeof msg.affinity === "string" ? msg.affinity : undefined,
|
|
2894
2941
|
});
|
|
2895
2942
|
},
|
|
2896
2943
|
handleMutateJson: (body, context) => {
|
|
@@ -3062,15 +3109,12 @@ async function resolveRoutingKey<User>(
|
|
|
3062
3109
|
}
|
|
3063
3110
|
|
|
3064
3111
|
function queryLeaseResponse(out: MaterializeOutput): QueryLeaseResponse {
|
|
3065
|
-
|
|
3112
|
+
return {
|
|
3066
3113
|
leaseToken: out.leaseToken,
|
|
3067
3114
|
materializationId: out.materializationId,
|
|
3068
3115
|
queryKey: out.queryKey,
|
|
3069
3116
|
reused: out.reused,
|
|
3070
3117
|
};
|
|
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
3118
|
}
|
|
3075
3119
|
|
|
3076
3120
|
function errMessage(reason: unknown): string {
|