@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/LICENSE +201 -0
- package/README.md +73 -0
- package/dist/index.d.ts +256 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +388 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -0
- package/src/index.ts +656 -0
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
|
+
}
|