@rindle/api-server 0.1.0-rc.5

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/src/index.ts ADDED
@@ -0,0 +1,656 @@
1
+ import type { Ast, MutationEnvelope, NamedQuery, Query } from "@rindle/client";
2
+ import type {
3
+ DematerializeInput,
4
+ DematerializeOutput,
5
+ MaterializationPolicy,
6
+ MaterializeInput,
7
+ MaterializeOutput,
8
+ MigrateInput,
9
+ MigrateOutput,
10
+ MutationRejection,
11
+ MutationRejectionOutput,
12
+ QueryOnceInput,
13
+ QueryOnceOutput,
14
+ RindleDaemonClient,
15
+ RowChangeTxn,
16
+ RowChangeTxnOutput,
17
+ SqlStatement,
18
+ SqlTxn,
19
+ SqlTxnOutput,
20
+ StreamMode,
21
+ WireValue,
22
+ } from "@rindle/daemon-client";
23
+
24
+ export const DEFAULT_RINDLE_API_ROUTES = {
25
+ query: "/api/rindle/query",
26
+ read: "/api/rindle/read",
27
+ mutate: "/api/rindle/mutate",
28
+ } as const;
29
+
30
+ export type MaybePromise<T> = T | PromiseLike<T>;
31
+
32
+ export interface ApiContext<User> {
33
+ user: User;
34
+ request?: unknown;
35
+ }
36
+
37
+ export type ApiQueryResult = Ast | Query<any, any, any>;
38
+ export type ApiQuery<User, Args> = (ctx: ApiContext<User>, args: Args) => MaybePromise<ApiQueryResult>;
39
+ export type ApiQueries<User> = Record<string, ApiQuery<User, any>>;
40
+
41
+ export interface RunQueryInput<User> {
42
+ user: User;
43
+ name: string;
44
+ args: unknown;
45
+ query: ApiQuery<User, any>;
46
+ context: ApiContext<User>;
47
+ }
48
+
49
+ export type RunQuery<User> = (input: RunQueryInput<User>) => MaybePromise<ApiQueryResult>;
50
+
51
+ export interface AuthorizeQueryInput<User> {
52
+ user: User;
53
+ name: string;
54
+ args: unknown;
55
+ context: ApiContext<User>;
56
+ }
57
+
58
+ export interface AuthorizeMutationInput<User> {
59
+ user: User;
60
+ envelope: MutationEnvelope;
61
+ context: ApiContext<User>;
62
+ }
63
+
64
+ export type Authorizer<T> = (input: T) => MaybePromise<boolean | void>;
65
+
66
+ export interface MutationContext<User> {
67
+ user: User;
68
+ envelope: MutationEnvelope;
69
+ daemon: RindleDaemonClient;
70
+ request?: unknown;
71
+ }
72
+
73
+ export interface SqlMutationTx {
74
+ exec(sql: string, params?: WireValue[]): void;
75
+ readonly statements: readonly SqlStatement[];
76
+ }
77
+
78
+ export type ApiMutatorResult = void | SqlStatement[] | SqlTxn;
79
+ export type ApiMutator<User, Args> = (
80
+ tx: SqlMutationTx,
81
+ args: Args,
82
+ ctx: MutationContext<User>,
83
+ ) => MaybePromise<ApiMutatorResult>;
84
+ export type ApiMutators<User> = Record<string, ApiMutator<User, any>>;
85
+
86
+ export interface QueryLeaseRequest<User> {
87
+ user: User;
88
+ name: string;
89
+ args: unknown;
90
+ request?: unknown;
91
+ /** The browser's stable `clientId` (sent on the query POST) — the anonymous routing-key fallback
92
+ * when there is no authenticated subject and no session cookie (READ-ROUTER-DESIGN.md §1.5/§2.2).
93
+ * A routing HINT only, never authorization. */
94
+ clientId?: string;
95
+ }
96
+
97
+ export interface QueryLeaseResponse {
98
+ leaseToken: string;
99
+ materializationId: string;
100
+ queryKey?: string;
101
+ reused?: boolean;
102
+ /** The follower this lease lives on (READ-ROUTER-DESIGN.md §2.3) — the browser opens its
103
+ * subscription ws here. Absent ⇒ single-daemon (the client uses its static `wsUrl`). */
104
+ wsEndpoint?: string;
105
+ }
106
+
107
+ /** A one-shot SSR read of a named query (SSR-DESIGN.md §6): same `(name, args)` surface as a lease,
108
+ * but the daemon serializes the current view ONCE and registers no subscriber. */
109
+ export interface QueryReadRequest<User> {
110
+ user: User;
111
+ name: string;
112
+ args: unknown;
113
+ request?: unknown;
114
+ /** The browser's stable `clientId` — the anonymous routing-key fallback (see
115
+ * {@link QueryLeaseRequest.clientId}). Lets the SSR read co-locate on the follower the booting
116
+ * client's first subscribe will hit (READ-ROUTER-DESIGN.md §2.4). */
117
+ clientId?: string;
118
+ }
119
+
120
+ /** The assembled (nested-by-name) first-paint snapshot the server-side Store seeds + dehydrates
121
+ * (SSR-DESIGN.md §3.3). `rows` hydrate without an engine; `cvMin` is their watermark baseline. */
122
+ export interface QueryReadResponse {
123
+ rows: Array<{ cols: Record<string, unknown>; [rel: string]: unknown }>;
124
+ cvMin?: number;
125
+ queryKey?: string;
126
+ /** The follower this read warmed (READ-ROUTER-DESIGN.md §2.4) — inject it into the SSR bootstrap
127
+ * so the booting client opens its ws to the same warm follower. Absent ⇒ single-daemon. */
128
+ wsEndpoint?: string;
129
+ }
130
+
131
+ export interface PushMutationRequest<User> {
132
+ user: User;
133
+ envelope: MutationEnvelope;
134
+ request?: unknown;
135
+ }
136
+
137
+ export interface PushMutationsRequest<User> {
138
+ user: User;
139
+ envelopes: MutationEnvelope[];
140
+ request?: unknown;
141
+ }
142
+
143
+ export type PushMutationResponse =
144
+ | { accepted: true; rejected: false; output: SqlTxnOutput }
145
+ | { accepted: false; rejected: true; reason: string; output?: unknown };
146
+
147
+ export interface RindleApiRoutes {
148
+ query: string;
149
+ read: string;
150
+ mutate: string;
151
+ }
152
+
153
+ /** A named query to keep permanently materialized (warm with zero subscribers). */
154
+ export interface PinnedQuery {
155
+ name: string;
156
+ args?: unknown;
157
+ }
158
+
159
+ /** The explicit fleet pin fan-out seam (READ-ROUTER-DESIGN.md §4.2). The api-server resolves each
160
+ * pin's authoritative AST under `pinUser` and hands the ready {@link MaterializeInput}s here; the
161
+ * implementation (the read router) fans EACH across all live followers. Distinct, on purpose, from
162
+ * a per-viewer `materialize` (which routes ONE) — a pin-assert always sprays ALL. */
163
+ export interface PinFanout {
164
+ assertPins(pins: readonly MaterializeInput[]): Promise<void>;
165
+ }
166
+
167
+ export interface RindleApiServerOptions<User> {
168
+ daemon: RindleDaemonClient;
169
+ queries?: ApiQueries<User>;
170
+ runQuery?: RunQuery<User>;
171
+ mutators?: ApiMutators<User>;
172
+ authorizeQuery?: Authorizer<AuthorizeQueryInput<User>>;
173
+ authorizeMutation?: Authorizer<AuthorizeMutationInput<User>>;
174
+ routes?: Partial<RindleApiRoutes>;
175
+ mode?: StreamMode;
176
+ materializationPolicy?:
177
+ | MaterializationPolicy
178
+ | ((input: QueryLeaseRequest<User>) => MaybePromise<MaterializationPolicy>);
179
+ leaseTtlMs?: number;
180
+ /** Idle TTL (ms) the warm pipeline a one-shot SSR {@link RindleApiServer.readQuery read} leaves
181
+ * behind is held at (SSR-DESIGN.md §3.4) — it must comfortably cover page-load + client-boot +
182
+ * the follow-up live `subscribe` so the browser lands on a still-warm pipeline (the warm
183
+ * handoff). The TTL is NOT part of the dedup key (max-wins), so it only ever extends a shared
184
+ * query's window. Absent ⇒ the daemon's default idle TTL. */
185
+ readIdleTtlMs?: number;
186
+ subject?: string | ((input: QueryLeaseRequest<User>) => MaybePromise<string | undefined>);
187
+ /** The ANONYMOUS routing key forwarded to the read router (READ-ROUTER-DESIGN.md §2.2) — used by
188
+ * HRW placement when there is no authenticated `subject`. The router keys on `subject ?? this`;
189
+ * the resolved value rides `metadata.routingKey` to the daemon. Default: the browser-supplied
190
+ * `clientId` (from the query POST body). NOTE: the default cannot co-locate an ANONYMOUS SSR read
191
+ * with the booting client — an SSR server can't see the browser's localStorage `clientId`, so the
192
+ * two legs compute different keys and §2.4's warm handoff misses (still correct — just an extra
193
+ * first-touch materialize). For anonymous SSR co-location, set this to read a server-set session
194
+ * cookie from `input.request` (it rides the SSR request AND every browser request). A routing
195
+ * HINT only — never authorization. Ignored by a single (unrouted) daemon, which has nothing to
196
+ * route. */
197
+ routingKey?: string | ((input: QueryLeaseRequest<User>) => MaybePromise<string | undefined>);
198
+ /** The EXPLICIT fleet pin fan-out (READ-ROUTER-DESIGN.md §4.2 "push") — when set,
199
+ * {@link RindleApiServer.assertPins} fans each resolved pin across ALL live followers through it
200
+ * (e.g. `createRouterPinClient` from `@rindle/router`) instead of materializing each pin once on
201
+ * the (single) daemon. A per-viewer `materialize` always routes ONE; a pin-assert always fans
202
+ * ALL — never inferred from `policy.kind`. Absent ⇒ single-daemon behavior (one materialize per
203
+ * pin). */
204
+ pinFanout?: PinFanout;
205
+ /** Named queries to keep permanently materialized via {@link RindleApiServer.assertPins}.
206
+ * Each is materialized with a `pinned` policy (survives zero subscribers) so late joiners
207
+ * attach to an already-warm result. Pins are viewer-independent — resolved with `pinUser`. */
208
+ pinnedQueries?: PinnedQuery[];
209
+ /** The user context pins resolve under (pins are shared, so they should not depend on a
210
+ * per-viewer identity). Defaults to `undefined`. */
211
+ pinUser?: User;
212
+ }
213
+
214
+ export interface RindleApiServer<User> {
215
+ readonly routes: RindleApiRoutes;
216
+ createQueryLease(input: QueryLeaseRequest<User>): Promise<QueryLeaseResponse>;
217
+ /** (Re-)materialize every `pinnedQueries` entry with a pinned policy. Idempotent — the daemon
218
+ * dedupes by canonical query, so a re-assert reuses the existing materialization. Call it at
219
+ * startup and whenever the daemon restarts (e.g. from the daemon-client `onBootId` hook), since
220
+ * the daemon holds no durable materialization state. No-op when `pinnedQueries` is empty. */
221
+ assertPins(): Promise<void>;
222
+ pushMutation(input: PushMutationRequest<User>): Promise<PushMutationResponse>;
223
+ /** Apply an in-order batch (the client mutation queue's flush). Envelopes run strictly
224
+ * sequentially; a rejection still advances the daemon's lmid, so later envelopes in the
225
+ * batch stay contiguous and keep applying. A daemon ERROR throws for the whole batch —
226
+ * the client retries it and the daemon's mid dedup absorbs the already-applied prefix. */
227
+ pushMutations(input: PushMutationsRequest<User>): Promise<PushMutationResponse[]>;
228
+ /** One-shot SSR read (SSR-DESIGN.md §6): resolve `(name, args)` → AST (same authority path as a
229
+ * lease, `authorizeQuery` enforced), have the daemon serialize the current view once, and return
230
+ * the assembled rows for the loader to seed + dehydrate. Registers NO subscriber — a dropped
231
+ * render leaks nothing; the pipeline self-reclaims after the idle TTL ({@link
232
+ * RindleApiServerOptions.readIdleTtlMs}) unless the browser's follow-up `subscribe` lands first. */
233
+ readQuery(input: QueryReadRequest<User>): Promise<QueryReadResponse>;
234
+ handleQueryJson(body: unknown, context: ApiContext<User>): Promise<QueryLeaseResponse>;
235
+ /** Parse a default `{name, args}` read body and run {@link readQuery}. */
236
+ handleReadJson(body: unknown, context: ApiContext<User>): Promise<QueryReadResponse>;
237
+ /** Accepts `{envelope}` (one) or `{envelopes: [...]}` (an in-order batch → array reply). */
238
+ handleMutateJson(
239
+ body: unknown,
240
+ context: ApiContext<User>,
241
+ ): Promise<PushMutationResponse | PushMutationResponse[]>;
242
+ }
243
+
244
+ export type RindleApiErrorCode = "bad-request" | "forbidden" | "not-found" | "rejected";
245
+
246
+ export class RindleApiError extends Error {
247
+ readonly code: RindleApiErrorCode;
248
+ readonly status: number;
249
+
250
+ constructor(code: RindleApiErrorCode, message: string, status: number) {
251
+ super(message);
252
+ this.name = "RindleApiError";
253
+ this.code = code;
254
+ this.status = status;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * A read/write-split `RindleDaemonClient` (READ-ROUTER-DESIGN.md §2.1). Writes
260
+ * (`executeSqlTxn` / `rejectMutation` / `applyRowChangeTxn` / `migrate`) go to the single
261
+ * write-master, UNCHANGED and never through the router; reads (`materialize` / `query` /
262
+ * `dematerialize`) go to the read router. Hand one of these to {@link createRindleApiServer} as
263
+ * `daemon` to point the reads leg at the router while writes stay on the master — no placement
264
+ * logic enters the api-server.
265
+ *
266
+ * ```ts
267
+ * const daemon = new SplitDaemonClient(
268
+ * new HttpRindleDaemonClient({ baseUrl: MASTER_URL, headers: writeAuth }), // writes → master
269
+ * new HttpRindleDaemonClient({ baseUrl: ROUTER_URL, headers: routerAuth }), // reads → router
270
+ * );
271
+ * ```
272
+ */
273
+ export class SplitDaemonClient implements RindleDaemonClient {
274
+ private readonly writes: RindleDaemonClient;
275
+ private readonly reads: RindleDaemonClient;
276
+
277
+ constructor(writes: RindleDaemonClient, reads: RindleDaemonClient) {
278
+ this.writes = writes;
279
+ this.reads = reads;
280
+ }
281
+
282
+ // writes → the single master, never through the router
283
+ executeSqlTxn(input: SqlTxn): Promise<SqlTxnOutput> {
284
+ return this.writes.executeSqlTxn(input);
285
+ }
286
+ rejectMutation(input: MutationRejection): Promise<MutationRejectionOutput> {
287
+ return this.writes.rejectMutation(input);
288
+ }
289
+ applyRowChangeTxn(input: RowChangeTxn): Promise<RowChangeTxnOutput> {
290
+ return this.writes.applyRowChangeTxn(input);
291
+ }
292
+ migrate(input: MigrateInput): Promise<MigrateOutput> {
293
+ return this.writes.migrate(input);
294
+ }
295
+
296
+ // reads → the router (it stamps `wsEndpoint` onto the outputs)
297
+ materialize(input: MaterializeInput): Promise<MaterializeOutput> {
298
+ return this.reads.materialize(input);
299
+ }
300
+ query(input: QueryOnceInput): Promise<QueryOnceOutput> {
301
+ return this.reads.query(input);
302
+ }
303
+ dematerialize(input: DematerializeInput): Promise<DematerializeOutput> {
304
+ return this.reads.dematerialize(input);
305
+ }
306
+ }
307
+
308
+ export function defineApiQueries<User, Q extends ApiQueries<User>>(queries: Q): Q {
309
+ return queries;
310
+ }
311
+
312
+ /**
313
+ * Register a list of co-located client {@link NamedQuery `defineQuery`} values as the server's query
314
+ * surface — the bulk, no-boilerplate counterpart to {@link defineApiQueries}. Each query already
315
+ * carries its wire `name` and a `resolve` that re-runs its validator on the UNTRUSTED wire args and
316
+ * builds the authoritative `Query`, so the server just imports every co-located query and hands the
317
+ * list here. The same validated args build a byte-identical AST on both tiers.
318
+ *
319
+ * The query's AUTHORITATIVE {@link ApiContext} is forwarded as `resolve`'s ctx — so a context-scoped
320
+ * `defineQuery` (e.g. "my issues") is built from the server's trusted principal, never the client's.
321
+ * A context-free query simply ignores the extra argument.
322
+ *
323
+ * Use {@link defineApiQueries} instead (or in addition) only when the server must DIVERGE from the
324
+ * client — define a server-specific `defineQuery` with the same name and register that.
325
+ *
326
+ * ```ts
327
+ * queries: registerQueries<User>([issuesPageQuery, issueDetailQuery, recentCommentsQuery, usersQuery]),
328
+ * ```
329
+ */
330
+ export function registerQueries<User>(queries: readonly NamedQuery<any, any, any>[]): ApiQueries<User> {
331
+ const out: Record<string, ApiQuery<User, any>> = {};
332
+ for (const query of queries) {
333
+ if (Object.prototype.hasOwnProperty.call(out, query.queryName)) {
334
+ throw new Error(`registerQueries: duplicate query name "${query.queryName}"`);
335
+ }
336
+ out[query.queryName] = (ctx, args) => query.resolve(args, ctx);
337
+ }
338
+ return out;
339
+ }
340
+
341
+ export function defineApiMutators<User, M extends ApiMutators<User>>(mutators: M): M {
342
+ return mutators;
343
+ }
344
+
345
+ export function queryResultToAst(result: ApiQueryResult): Ast {
346
+ if (result && typeof result === "object" && "ast" in result && typeof result.ast === "function") {
347
+ return result.ast();
348
+ }
349
+ return result as Ast;
350
+ }
351
+
352
+ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptions<User>): RindleApiServer<User> {
353
+ const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
354
+ const mode = opts.mode ?? "normalized";
355
+ // Names that are ALSO configured pins — a lease for one is forced to a `pinned` policy (the lazy
356
+ // floor, §4.1) so the first viewer to route to a follower warms it for late joiners.
357
+ const pinnedNames = new Set((opts.pinnedQueries ?? []).map((p) => p.name));
358
+
359
+ // Resolve a named query (+ args) to its AST under a given context — the shared path for both
360
+ // a per-viewer lease and a system-level pin (which skips per-user authorization).
361
+ const resolveAst = async (name: string, args: unknown, context: ApiContext<User>): Promise<Ast> => {
362
+ const query = opts.queries?.[name];
363
+ if (!query) throw new RindleApiError("not-found", `unknown query: ${name}`, 404);
364
+ const result = opts.runQuery
365
+ ? await opts.runQuery({ user: context.user, name, args, query, context })
366
+ : await query(context, args as never);
367
+ return queryResultToAst(result);
368
+ };
369
+
370
+ const createQueryLease = async (input: QueryLeaseRequest<User>): Promise<QueryLeaseResponse> => {
371
+ const context: ApiContext<User> = { user: input.user, request: input.request };
372
+ await assertAuthorized(opts.authorizeQuery, {
373
+ user: input.user,
374
+ name: input.name,
375
+ args: input.args,
376
+ context,
377
+ });
378
+ const ast = await resolveAst(input.name, input.args, context);
379
+ // Lazy pin tier (§4.1): a leased query that is ALSO a configured pin is materialized with a
380
+ // `pinned` policy regardless of the configured default, so it survives its first viewer's
381
+ // departure. Otherwise use the configured policy.
382
+ const policy = pinnedNames.has(input.name)
383
+ ? ({ kind: "pinned", name: input.name } as MaterializationPolicy)
384
+ : await resolvePolicy(opts.materializationPolicy, input);
385
+ const subject = await resolveSubject(opts.subject, input);
386
+ const routingKey = await resolveRoutingKey(opts.routingKey, input);
387
+ const out = await opts.daemon.materialize({
388
+ ast,
389
+ mode,
390
+ policy,
391
+ subject,
392
+ leaseTtlMs: opts.leaseTtlMs,
393
+ // The anonymous routing key rides `metadata.routingKey`; the router keys on
394
+ // `subject ?? metadata.routingKey` (§2.2). Omitted when there is none.
395
+ metadata: routingKey !== undefined ? { routingKey } : undefined,
396
+ });
397
+ return queryLeaseResponse(out);
398
+ };
399
+
400
+ const readQuery = async (input: QueryReadRequest<User>): Promise<QueryReadResponse> => {
401
+ const context: ApiContext<User> = { user: input.user, request: input.request };
402
+ // Same per-viewer gate a live lease passes — a one-shot read returns the same rows a subscribe
403
+ // would, so it must clear the same authorization.
404
+ await assertAuthorized(opts.authorizeQuery, {
405
+ user: input.user,
406
+ name: input.name,
407
+ args: input.args,
408
+ context,
409
+ });
410
+ const ast = await resolveAst(input.name, input.args, context);
411
+ // Scope the one-shot's dedup `QueryKey` by the SAME key the lease path routes on — the routing
412
+ // key (`subject ?? cookie ?? clientId`) — so the warm pipeline this read leaves behind is the
413
+ // very one the browser's follow-up `subscribe` reuses, ON THE SAME FOLLOWER HRW will place it
414
+ // (the warm handoff, SSR-DESIGN.md §3.4 / READ-ROUTER-DESIGN.md §2.4) rather than a second,
415
+ // viewer-mismatched materialization.
416
+ const subject = await resolveSubject(opts.subject, input);
417
+ const routingKey = await resolveRoutingKey(opts.routingKey, input);
418
+ const visibilityKey = subject ?? routingKey;
419
+ const out = await opts.daemon.query({ ast, visibilityKey, ttlMs: opts.readIdleTtlMs });
420
+ const res: QueryReadResponse = { rows: out.rows, cvMin: out.cvMin, queryKey: out.queryKey };
421
+ if (out.wsEndpoint !== undefined) res.wsEndpoint = out.wsEndpoint;
422
+ return res;
423
+ };
424
+
425
+ const pushMutation = async (input: PushMutationRequest<User>): Promise<PushMutationResponse> => {
426
+ const context: ApiContext<User> = { user: input.user, request: input.request };
427
+ const mutator = opts.mutators?.[input.envelope.name];
428
+ if (!mutator) return reject(opts.daemon, input.envelope, `unknown mutator: ${input.envelope.name}`);
429
+ let sql: Pick<SqlTxn, "statements" | "idempotencyKey">;
430
+ try {
431
+ await assertAuthorized(opts.authorizeMutation, {
432
+ user: input.user,
433
+ envelope: input.envelope,
434
+ context,
435
+ });
436
+ const tx = new CollectingSqlMutationTx();
437
+ const result = await mutator(tx, input.envelope.args as never, {
438
+ user: input.user,
439
+ envelope: input.envelope,
440
+ daemon: opts.daemon,
441
+ request: input.request,
442
+ });
443
+ sql = mutationResultToSqlTxn(result, tx);
444
+ } catch (err) {
445
+ return reject(opts.daemon, input.envelope, String((err as Error)?.message ?? err));
446
+ }
447
+ const output = await opts.daemon.executeSqlTxn({
448
+ ...sql,
449
+ clientID: input.envelope.clientID,
450
+ mid: input.envelope.mid,
451
+ });
452
+ return { accepted: true, rejected: false, output };
453
+ };
454
+
455
+ const pushMutations = async (input: PushMutationsRequest<User>): Promise<PushMutationResponse[]> => {
456
+ const out: PushMutationResponse[] = [];
457
+ for (const envelope of input.envelopes) {
458
+ out.push(await pushMutation({ user: input.user, envelope, request: input.request }));
459
+ }
460
+ return out;
461
+ };
462
+
463
+ const assertPins = async (): Promise<void> => {
464
+ const pins = opts.pinnedQueries;
465
+ if (!pins?.length) return;
466
+ const context: ApiContext<User> = { user: opts.pinUser as User, request: undefined };
467
+ // Resolve every pin's authoritative AST under the system pin user (a transient failure on one
468
+ // shouldn't strand the rest), then drive the warm-up — fleet fan-out via the router if
469
+ // configured, else one materialize per pin on the single daemon.
470
+ const resolved = await Promise.allSettled(
471
+ pins.map(async (pin) => ({
472
+ pin,
473
+ req: {
474
+ ast: await resolveAst(pin.name, pin.args ?? null, context),
475
+ mode,
476
+ policy: { kind: "pinned", name: pin.name } as MaterializationPolicy,
477
+ leaseTtlMs: opts.leaseTtlMs,
478
+ } satisfies MaterializeInput,
479
+ })),
480
+ );
481
+ const failures: string[] = [];
482
+ const pairs: Array<{ pin: PinnedQuery; req: MaterializeInput }> = [];
483
+ resolved.forEach((r, i) => {
484
+ if (r.status === "fulfilled") pairs.push(r.value);
485
+ else failures.push(`${pins[i].name}: ${errMessage(r.reason)}`);
486
+ });
487
+ const reqs = pairs.map((p) => p.req);
488
+ if (reqs.length) {
489
+ if (opts.pinFanout) {
490
+ // Push tier (§4.2): fan EACH pin across ALL live followers via the router (fire-and-forget,
491
+ // router-stateless). A whole-fan-out failure is surfaced so the caller retries.
492
+ try {
493
+ await opts.pinFanout.assertPins(reqs);
494
+ } catch (e) {
495
+ failures.push(errMessage(e));
496
+ }
497
+ } else {
498
+ // Single-daemon: materialize each pin once (the daemon dedups by canonical query).
499
+ const results = await Promise.allSettled(reqs.map((req) => opts.daemon.materialize(req)));
500
+ results.forEach((r, i) => {
501
+ if (r.status === "rejected") failures.push(`${pairs[i].pin.name}: ${errMessage(r.reason)}`);
502
+ });
503
+ }
504
+ }
505
+ if (failures.length) {
506
+ throw new Error(`assertPins: ${failures.length} failed — ${failures.join("; ")}`);
507
+ }
508
+ };
509
+
510
+ return {
511
+ routes,
512
+ createQueryLease,
513
+ readQuery,
514
+ assertPins,
515
+ pushMutation,
516
+ pushMutations,
517
+ handleQueryJson: (body, context) => {
518
+ const msg = parseObject(body, "query request");
519
+ return createQueryLease({
520
+ user: context.user,
521
+ name: parseString(msg.name, "name"),
522
+ args: msg.args ?? null,
523
+ request: context.request,
524
+ clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
525
+ });
526
+ },
527
+ handleReadJson: (body, context) => {
528
+ const msg = parseObject(body, "read request");
529
+ return readQuery({
530
+ user: context.user,
531
+ name: parseString(msg.name, "name"),
532
+ args: msg.args ?? null,
533
+ request: context.request,
534
+ clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
535
+ });
536
+ },
537
+ handleMutateJson: (body, context) => {
538
+ const msg = parseObject(body, "mutation request");
539
+ if (Array.isArray(msg.envelopes)) {
540
+ return pushMutations({
541
+ user: context.user,
542
+ envelopes: msg.envelopes.map(parseEnvelope),
543
+ request: context.request,
544
+ });
545
+ }
546
+ const envelope = parseEnvelope(msg.envelope ?? msg);
547
+ return pushMutation({ user: context.user, envelope, request: context.request });
548
+ },
549
+ };
550
+ }
551
+
552
+ class CollectingSqlMutationTx implements SqlMutationTx {
553
+ private readonly stmts: SqlStatement[] = [];
554
+
555
+ get statements(): readonly SqlStatement[] {
556
+ return this.stmts;
557
+ }
558
+
559
+ exec(sql: string, params: WireValue[] = []): void {
560
+ this.stmts.push({ sql, params });
561
+ }
562
+ }
563
+
564
+ function mutationResultToSqlTxn(result: ApiMutatorResult, tx: SqlMutationTx): Pick<SqlTxn, "statements" | "idempotencyKey"> {
565
+ if (Array.isArray(result)) return { statements: result };
566
+ if (result && typeof result === "object" && "statements" in result) {
567
+ return { statements: result.statements, idempotencyKey: result.idempotencyKey };
568
+ }
569
+ return { statements: [...tx.statements] };
570
+ }
571
+
572
+ async function assertAuthorized<T>(authorizer: Authorizer<T> | undefined, input: T): Promise<void> {
573
+ if (!authorizer) return;
574
+ const result = await authorizer(input);
575
+ if (result === false) throw new RindleApiError("forbidden", "rindle request forbidden", 403);
576
+ }
577
+
578
+ async function resolvePolicy<User>(
579
+ policy: RindleApiServerOptions<User>["materializationPolicy"],
580
+ input: QueryLeaseRequest<User>,
581
+ ): Promise<MaterializationPolicy> {
582
+ if (!policy) return { kind: "whileSubscribed" };
583
+ return typeof policy === "function" ? await policy(input) : policy;
584
+ }
585
+
586
+ async function resolveSubject<User>(
587
+ subject: RindleApiServerOptions<User>["subject"],
588
+ input: QueryLeaseRequest<User>,
589
+ ): Promise<string | undefined> {
590
+ if (typeof subject === "function") return subject(input);
591
+ return subject;
592
+ }
593
+
594
+ /** Resolve the anonymous routing key (READ-ROUTER-DESIGN.md §2.2): an explicit value/resolver if
595
+ * configured, otherwise the browser-supplied `clientId`. The router keys on `subject ?? this`. */
596
+ async function resolveRoutingKey<User>(
597
+ routingKey: RindleApiServerOptions<User>["routingKey"],
598
+ input: QueryLeaseRequest<User>,
599
+ ): Promise<string | undefined> {
600
+ if (routingKey === undefined) return input.clientId;
601
+ return typeof routingKey === "function" ? routingKey(input) : routingKey;
602
+ }
603
+
604
+ function queryLeaseResponse(out: MaterializeOutput): QueryLeaseResponse {
605
+ const res: QueryLeaseResponse = {
606
+ leaseToken: out.leaseToken,
607
+ materializationId: out.materializationId,
608
+ queryKey: out.queryKey,
609
+ reused: out.reused,
610
+ };
611
+ // Only present in a routed deploy — absent reproduces today's single-daemon response exactly.
612
+ if (out.wsEndpoint !== undefined) res.wsEndpoint = out.wsEndpoint;
613
+ return res;
614
+ }
615
+
616
+ function errMessage(reason: unknown): string {
617
+ return String((reason as Error)?.message ?? reason);
618
+ }
619
+
620
+ async function reject(
621
+ daemon: RindleDaemonClient,
622
+ envelope: MutationEnvelope,
623
+ reason: string,
624
+ ): Promise<PushMutationResponse> {
625
+ const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
626
+ return { accepted: false, rejected: true, reason, output };
627
+ }
628
+
629
+ function parseObject(value: unknown, label: string): Record<string, unknown> {
630
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
631
+ throw new RindleApiError("bad-request", `invalid ${label}`, 400);
632
+ }
633
+ return value as Record<string, unknown>;
634
+ }
635
+
636
+ function parseString(value: unknown, label: string): string {
637
+ if (typeof value !== "string") throw new RindleApiError("bad-request", `invalid ${label}`, 400);
638
+ return value;
639
+ }
640
+
641
+ function parseEnvelope(value: unknown): MutationEnvelope {
642
+ const obj = parseObject(value, "mutation envelope");
643
+ return {
644
+ clientID: parseString(obj.clientID, "clientID"),
645
+ mid: parseNumber(obj.mid, "mid"),
646
+ name: parseString(obj.name, "name"),
647
+ args: obj.args ?? null,
648
+ };
649
+ }
650
+
651
+ function parseNumber(value: unknown, label: string): number {
652
+ if (typeof value !== "number" || !Number.isFinite(value)) {
653
+ throw new RindleApiError("bad-request", `invalid ${label}`, 400);
654
+ }
655
+ return value;
656
+ }