@rindle/api-server 0.3.1 → 0.4.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/dist/index.d.ts +233 -26
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +841 -45
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/src/index.ts +1066 -65
package/src/index.ts
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { driveMutationAsync, insertCell, insertPlan, isoTx, toCell } from "@rindle/client";
|
|
2
|
+
import type {
|
|
3
|
+
Ast,
|
|
4
|
+
ColType,
|
|
5
|
+
Condition,
|
|
6
|
+
KeyedRow,
|
|
7
|
+
MutationEnvelope,
|
|
8
|
+
MutationOp,
|
|
9
|
+
MutatorCtx,
|
|
10
|
+
NamedQuery,
|
|
11
|
+
Query,
|
|
12
|
+
QueryResultRow,
|
|
13
|
+
Schema,
|
|
14
|
+
SharedMutator,
|
|
15
|
+
SharedMutatorWithArgs,
|
|
16
|
+
ServerWriteTx,
|
|
17
|
+
} from "@rindle/client";
|
|
2
18
|
import { DaemonHttpError } from "@rindle/daemon-client";
|
|
19
|
+
import { compile as compileQueryAst } from "@rindle/query-compiler";
|
|
20
|
+
import type { Catalog, ColumnType as QueryColumnType, TableSchema } from "@rindle/query-compiler";
|
|
3
21
|
import type {
|
|
4
22
|
ClaimRoomEpochInput,
|
|
5
23
|
ClaimRoomEpochOutput,
|
|
@@ -12,6 +30,11 @@ import type {
|
|
|
12
30
|
MigrateOutput,
|
|
13
31
|
MutationRejection,
|
|
14
32
|
MutationRejectionOutput,
|
|
33
|
+
MutationSessionBegin,
|
|
34
|
+
MutationSessionBeginOutput,
|
|
35
|
+
MutationSessionExec,
|
|
36
|
+
MutationSessionQuery,
|
|
37
|
+
MutationSessionRef,
|
|
15
38
|
QueryOnceInput,
|
|
16
39
|
QueryOnceOutput,
|
|
17
40
|
RindleDaemonClient,
|
|
@@ -28,6 +51,12 @@ import type {
|
|
|
28
51
|
WireValue,
|
|
29
52
|
} from "@rindle/daemon-client";
|
|
30
53
|
|
|
54
|
+
// Re-export the shared (generator) mutator seam so an app builds its server mutators from ONE import:
|
|
55
|
+
// co-locate each body with its arg schema (`shared`), bulk-drive the registry ({@link sharedApiMutators}),
|
|
56
|
+
// keeping only server-only authority as explicit overrides (see MUTATORS-ISOMORPHIC).
|
|
57
|
+
export { isoTx, shared } from "@rindle/client";
|
|
58
|
+
export type { ArgSchema, IsoTx, MutationGen, MutatorCtx, SharedMutator, SharedMutatorWithArgs } from "@rindle/client";
|
|
59
|
+
|
|
31
60
|
export const DEFAULT_RINDLE_API_ROUTES = {
|
|
32
61
|
query: "/api/rindle/query",
|
|
33
62
|
read: "/api/rindle/read",
|
|
@@ -37,6 +66,8 @@ export const DEFAULT_RINDLE_API_ROUTES = {
|
|
|
37
66
|
applyRowChangeTxn: "/api/rindle/apply-row-change-txn",
|
|
38
67
|
claimRoomEpoch: "/api/rindle/claim-room-epoch",
|
|
39
68
|
roomLmids: "/api/rindle/room-lmids",
|
|
69
|
+
// The DO shell's cold-boot callback (§10.1) — active only when `realtime` is configured.
|
|
70
|
+
roomBoot: "/api/rindle/room-boot",
|
|
40
71
|
} as const;
|
|
41
72
|
|
|
42
73
|
export type MaybePromise<T> = T | PromiseLike<T>;
|
|
@@ -82,14 +113,39 @@ export interface MutationContext<User> {
|
|
|
82
113
|
request?: unknown;
|
|
83
114
|
}
|
|
84
115
|
|
|
116
|
+
/** The legacy raw-SQL escape hatch (still the surface for relational/authority statements a keyed op
|
|
117
|
+
* can't express — an owner-gated cascade, a `NOT EXISTS` dedup). `exec` accumulates on the daemon
|
|
118
|
+
* backend and executes live on the Postgres backend; `statements` is the collected raw list. */
|
|
85
119
|
export interface SqlMutationTx {
|
|
86
120
|
exec(sql: string, params?: WireValue[]): void;
|
|
87
121
|
readonly statements: readonly SqlStatement[];
|
|
88
122
|
}
|
|
89
123
|
|
|
124
|
+
/** The write handle a server mutator runs against — the ASYNC twin of the client's `MutationTx`. It
|
|
125
|
+
* is both the isomorphic {@link ServerWriteTx} logical surface (insert/update/upsert/insertIgnore/
|
|
126
|
+
* delete/row, rendered to dialect SQL) AND the legacy {@link SqlMutationTx} raw escape hatch. Both
|
|
127
|
+
* backends run reads through the OPEN transaction (read-your-writes): the Postgres backend executes
|
|
128
|
+
* everything live; the daemon backend accumulates writes and lazily upgrades to an interactive
|
|
129
|
+
* mutation session at the first read (DAEMON-INTERACTIVE-TXN-DESIGN.md §5). */
|
|
130
|
+
export interface ServerMutationTx extends ServerWriteTx, SqlMutationTx {
|
|
131
|
+
/** Run a full query (a fluent `Query` or its wire `Ast`) INSIDE the open transaction —
|
|
132
|
+
* read-your-writes, like {@link ServerWriteTx.row} but for arbitrary shapes. Returns the
|
|
133
|
+
* parsed nested result tree: an array for a plural root, an object or `null` for a `.one()`
|
|
134
|
+
* root, with cells in their raw SQLite storage-class representations (the same vocabulary
|
|
135
|
+
* `row` speaks). Daemon backend: compiled by `@rindle/query-compiler`'s sqlite dialect (bind
|
|
136
|
+
* params, NO casts — §5.4) and executed through the mutation session. Postgres backend: lands
|
|
137
|
+
* with the read-compiler catalog integration (POSTGRES-READ-COMPILER-DESIGN.md Phase B). */
|
|
138
|
+
query(q: Ast | Query<any, any, any>): Promise<unknown>;
|
|
139
|
+
}
|
|
140
|
+
|
|
90
141
|
export type ApiMutatorResult = void | SqlStatement[] | SqlTxn;
|
|
142
|
+
/** A server mutator: a plain async function against the live {@link ServerMutationTx}. Two ways to
|
|
143
|
+
* write one (MUTATORS-ISOMORPHIC): drive it directly (the raw escape hatch — an owner-gated cascade,
|
|
144
|
+
* a `NOT EXISTS` dedup — plus a returned `SqlStatement[]`/`SqlTxn`), OR delegate to a SHARED generator
|
|
145
|
+
* (the SAME body the client predicts) via {@link runSharedMutation}, keeping only the server-only
|
|
146
|
+
* authority (arg parse, principal, policy) in the wrapper. */
|
|
91
147
|
export type ApiMutator<User, Args> = (
|
|
92
|
-
tx:
|
|
148
|
+
tx: ServerMutationTx,
|
|
93
149
|
args: Args,
|
|
94
150
|
ctx: MutationContext<User>,
|
|
95
151
|
) => MaybePromise<ApiMutatorResult>;
|
|
@@ -140,32 +196,44 @@ export interface QueryReadResponse {
|
|
|
140
196
|
wsEndpoint?: string;
|
|
141
197
|
}
|
|
142
198
|
|
|
143
|
-
/**
|
|
144
|
-
export interface
|
|
199
|
+
/** The context a {@link MutationBackend} needs to run one mutation inside its transaction. */
|
|
200
|
+
export interface MutationRunInput {
|
|
145
201
|
envelope: MutationEnvelope;
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
202
|
+
/** Schema-derived render metadata (from {@link RindleApiServerOptions.schema}). A logical op on a
|
|
203
|
+
* table absent here throws loudly — never a silent dropped write. `{}` when no schema is set. */
|
|
204
|
+
render: RenderIndex;
|
|
205
|
+
/** Invoke the (authorized) mutator against the backend-provided tx. A THROW that is NOT a
|
|
206
|
+
* {@link BackendError} is a BUSINESS rejection (roll the data back, then advance `lmid` alone,
|
|
207
|
+
* §2.4); a {@link BackendError} (a DB-layer failure) is INFRA and rejects the returned promise. */
|
|
208
|
+
run(tx: ServerMutationTx): Promise<void>;
|
|
150
209
|
}
|
|
151
210
|
|
|
211
|
+
/** The result of {@link MutationBackend.runMutation}: either the data+lmid committed together, or a
|
|
212
|
+
* business rejection whose data was rolled back but whose `lmid` still advanced (§2.4). */
|
|
213
|
+
export type MutationOutcome =
|
|
214
|
+
| { accepted: true; output: SqlTxnOutput }
|
|
215
|
+
| { accepted: false; reason: string; output?: unknown };
|
|
216
|
+
|
|
152
217
|
/**
|
|
153
|
-
* Where a mutation
|
|
154
|
-
*
|
|
155
|
-
* {@link daemonBackend} (
|
|
156
|
-
* {@link postgresBackend} (
|
|
157
|
-
* confirmation riding the CDC loop
|
|
218
|
+
* Where a mutation runs and who stamps `lmid` — the seam that makes the mutator authoring surface
|
|
219
|
+
* backend-agnostic (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6; MUTATORS-ISOMORPHIC plan). Two impls
|
|
220
|
+
* ship: {@link daemonBackend} (the default — writes accumulate, one batch to the daemon which stamps
|
|
221
|
+
* `lmid` co-transactionally) and {@link postgresBackend} (a REAL interactive PG transaction — writes
|
|
222
|
+
* and reads execute live, `lmid` committed in the same txn, confirmation riding the CDC loop down).
|
|
158
223
|
*
|
|
159
|
-
*
|
|
160
|
-
* - `
|
|
161
|
-
* (OPTIMISTIC-WRITES-DESIGN.md §8.2)
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* -
|
|
165
|
-
*
|
|
224
|
+
* The load-bearing invariant is that a mutation ALWAYS advances the client's `last_mutation_id`:
|
|
225
|
+
* - `runMutation` runs the mutator inside the backend's transaction; on success it advances `lmid`
|
|
226
|
+
* to `envelope.mid` in the SAME atomic unit (OPTIMISTIC-WRITES-DESIGN.md §8.2); on a BUSINESS
|
|
227
|
+
* rejection it rolls the data back but STILL advances `lmid` (§2.4 — else the client's pending
|
|
228
|
+
* queue never drains and the optimistic stack wedges).
|
|
229
|
+
* - `reject` is the pre-flight path (unknown mutator, failed authorization): NO data, `lmid` alone.
|
|
230
|
+
* - An infrastructure failure THROWS from `runMutation` — never a user rejection (the client
|
|
231
|
+
* retries; mid dedup absorbs any applied prefix).
|
|
166
232
|
*/
|
|
167
233
|
export interface MutationBackend {
|
|
168
|
-
|
|
234
|
+
/** The SQL dialect this backend renders logical ops to (drives placeholder style). */
|
|
235
|
+
readonly dialect: SqlDialect;
|
|
236
|
+
runMutation(input: MutationRunInput): Promise<MutationOutcome>;
|
|
169
237
|
reject(input: { envelope: MutationEnvelope; reason: string }): Promise<unknown>;
|
|
170
238
|
}
|
|
171
239
|
|
|
@@ -192,6 +260,7 @@ export interface RindleApiRoutes {
|
|
|
192
260
|
applyRowChangeTxn: string;
|
|
193
261
|
claimRoomEpoch: string;
|
|
194
262
|
roomLmids: string;
|
|
263
|
+
roomBoot: string;
|
|
195
264
|
}
|
|
196
265
|
|
|
197
266
|
/** A room-host reply the transport writes VERBATIM (`status` + JSON `body`): the
|
|
@@ -217,21 +286,191 @@ export interface PinFanout {
|
|
|
217
286
|
assertPins(pins: readonly MaterializeInput[]): Promise<void>;
|
|
218
287
|
}
|
|
219
288
|
|
|
289
|
+
// --------------------------------------------------------------------------- realtime (room) host
|
|
290
|
+
//
|
|
291
|
+
// Enabling Rindle Realtime for an app (RINDLE-REALTIME-ENABLEMENT-DESIGN.md §3.1) is ONE named
|
|
292
|
+
// opt-in: the `realtime` options block. Its presence activates the flush trio AND `/room-boot`;
|
|
293
|
+
// its absence keeps every room endpoint 403 and adds no route. The deprecated bare `authorizeRoom`
|
|
294
|
+
// still gates the trio alone (it never activates `/room-boot`).
|
|
295
|
+
|
|
296
|
+
/** The room-boot flush leg (enablement §5): where the placed room's write-behind lands and what
|
|
297
|
+
* credential it presents. `urls` are the trio's ROUTE PATHS (root-relative — the shell resolves
|
|
298
|
+
* them against the boot call's origin), so an app that overrides `routes` needs no out-of-band
|
|
299
|
+
* sync; `headers` ride every flush call verbatim (`httpAuthority`'s `headers`). */
|
|
300
|
+
export interface RoomBootFlush {
|
|
301
|
+
urls: { apply: string; claim: string; lmids: string };
|
|
302
|
+
headers: Record<string, string>;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The `/room-boot` response (RINDLE-REALTIME-DESIGN.md §10.1 — the DO shell's cold-boot
|
|
306
|
+
* callback): the claimed placement epoch, the room's upstream footprint lease, and the flush
|
|
307
|
+
* leg. A cold room boots inert and serves nothing until this returns. */
|
|
308
|
+
export interface RoomBootResponse {
|
|
309
|
+
epoch: number;
|
|
310
|
+
upstreamLeaseToken: string;
|
|
311
|
+
/** Where the room opens its upstream subscription (a routed deploy's follower). Absent ⇒ the
|
|
312
|
+
* shell's statically configured rindled ws endpoint. */
|
|
313
|
+
upstreamWsEndpoint?: string;
|
|
314
|
+
flush: RoomBootFlush;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface RindleRealtimeOptions<User> {
|
|
318
|
+
/** The boot shell secret (§10.1 — a host binding, never client-derived). Authenticates the
|
|
319
|
+
* room's `Authorization: Bearer` on `/room-boot` (the default {@link authorizeBoot}) and keys
|
|
320
|
+
* the DEFAULT epoch-bound flush credential. */
|
|
321
|
+
shellSecret: string;
|
|
322
|
+
/** doc → the room's approved upstream footprint (§3.1) — an `Ast` or fluent `Query`. MAY
|
|
323
|
+
* delegate to the named-query registry internally; throw `RindleApiError("not-found", …, 404)`
|
|
324
|
+
* for a doc that shouldn't exist. The §9 footprint budget belongs here — it runs once per
|
|
325
|
+
* placement, at lease mint. */
|
|
326
|
+
resolveFootprint: (doc: string, ctx: ApiContext<User>) => MaybePromise<ApiQueryResult>;
|
|
327
|
+
/** Gates the flush trio — `authorizeRoom`, relocated. Default: verify the default flush
|
|
328
|
+
* credential from the {@link ROOM_FLUSH_CREDENTIAL_HEADER} request header — which requires the
|
|
329
|
+
* transport to pass its incoming request as `context.request` (Fetch `Request` and node
|
|
330
|
+
* `IncomingMessage` shapes are both understood). */
|
|
331
|
+
authorize?: Authorizer<ApiContext<User>>;
|
|
332
|
+
/** Gates `/room-boot`. Default: constant-time `Authorization: Bearer` check against
|
|
333
|
+
* {@link shellSecret} (same `context.request` requirement as {@link authorize}). */
|
|
334
|
+
authorizeBoot?: Authorizer<ApiContext<User>>;
|
|
335
|
+
/** Mint the flush headers a placed room presents on every flush call. Default:
|
|
336
|
+
* {@link mintRoomFlushCredential} under {@link ROOM_FLUSH_CREDENTIAL_HEADER}. Override this
|
|
337
|
+
* and {@link authorize} TOGETHER — they are the two ends of one credential. */
|
|
338
|
+
mintFlushHeaders?: (input: { doc: string; epoch: number }) => MaybePromise<Record<string, string>>;
|
|
339
|
+
/** Lease TTL for the room's upstream footprint materialization (defaults to the server-wide
|
|
340
|
+
* `leaseTtlMs`, else the daemon's default). */
|
|
341
|
+
upstreamLeaseTtlMs?: number;
|
|
342
|
+
/** Static override for where rooms open their upstream subscription; default is the
|
|
343
|
+
* `wsEndpoint` the materialize returns (set in routed deploys), else absent. */
|
|
344
|
+
upstreamWsEndpoint?: string;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// The DEFAULT flush credential: `rfc1.<b64url payload>.<b64url hmac-sha256>`, payload
|
|
348
|
+
// `{v:1, doc, epoch, iat}`. Deliberately EPOCH-bound, not time-bound: the credential's lifecycle
|
|
349
|
+
// IS the placement fence (§8.3 — a superseded epoch's flushes 409 at the store no matter what
|
|
350
|
+
// credential they carry), and platform revocation is registry suspension, so an `exp` would only
|
|
351
|
+
// force spurious re-boots of long-lived rooms. HMAC via WebCrypto (`crypto.subtle`) so the exact
|
|
352
|
+
// same code runs in Node and a Cloudflare Worker — no `node:crypto` import (matching
|
|
353
|
+
// `@rindle/room`'s token module).
|
|
354
|
+
|
|
355
|
+
export const ROOM_FLUSH_CREDENTIAL_HEADER = "x-rindle-room-credential";
|
|
356
|
+
|
|
357
|
+
const FLUSH_CREDENTIAL_PREFIX = "rfc1";
|
|
358
|
+
|
|
359
|
+
export interface RoomFlushCredentialPayload {
|
|
360
|
+
v: 1;
|
|
361
|
+
doc: string;
|
|
362
|
+
epoch: number;
|
|
363
|
+
iat: number;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function b64url(bytes: Uint8Array): string {
|
|
367
|
+
let bin = "";
|
|
368
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
369
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Return type inferred (`Uint8Array<ArrayBuffer>` under TS ≥5.7 libs) — an explicit
|
|
373
|
+
// `Uint8Array` annotation widens to `ArrayBufferLike` and fails `crypto.subtle`'s
|
|
374
|
+
// `BufferSource` under consumers compiling this source with newer lib types.
|
|
375
|
+
function unb64url(s: string) {
|
|
376
|
+
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
|
|
377
|
+
const out = new Uint8Array(bin.length);
|
|
378
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function flushHmacKey(secret: string, usage: "sign" | "verify") {
|
|
383
|
+
return crypto.subtle.importKey(
|
|
384
|
+
"raw",
|
|
385
|
+
new TextEncoder().encode(secret),
|
|
386
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
387
|
+
false,
|
|
388
|
+
[usage],
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Sign the default epoch-bound flush credential (`/room-boot` mints one per placement). */
|
|
393
|
+
export async function mintRoomFlushCredential(opts: {
|
|
394
|
+
shellSecret: string;
|
|
395
|
+
doc: string;
|
|
396
|
+
epoch: number;
|
|
397
|
+
/** Mint time; defaults to `Date.now()`. Injectable for tests. */
|
|
398
|
+
now?: number;
|
|
399
|
+
}): Promise<string> {
|
|
400
|
+
const payload: RoomFlushCredentialPayload = {
|
|
401
|
+
v: 1,
|
|
402
|
+
doc: opts.doc,
|
|
403
|
+
epoch: opts.epoch,
|
|
404
|
+
iat: opts.now ?? Date.now(),
|
|
405
|
+
};
|
|
406
|
+
const body = b64url(new TextEncoder().encode(JSON.stringify(payload)));
|
|
407
|
+
const signed = `${FLUSH_CREDENTIAL_PREFIX}.${body}`;
|
|
408
|
+
const key = await flushHmacKey(opts.shellSecret, "sign");
|
|
409
|
+
const sig = new Uint8Array(
|
|
410
|
+
await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signed)),
|
|
411
|
+
);
|
|
412
|
+
return `${signed}.${b64url(sig)}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Verify a flush credential's MAC, then its claims; returns the payload or throws. The MAC is
|
|
416
|
+
* checked FIRST — no claim is trusted before it passes. */
|
|
417
|
+
export async function verifyRoomFlushCredential(
|
|
418
|
+
credential: string,
|
|
419
|
+
shellSecret: string,
|
|
420
|
+
): Promise<RoomFlushCredentialPayload> {
|
|
421
|
+
const parts = credential.split(".");
|
|
422
|
+
if (parts.length !== 3 || parts[0] !== FLUSH_CREDENTIAL_PREFIX) {
|
|
423
|
+
throw new Error("not a room flush credential");
|
|
424
|
+
}
|
|
425
|
+
const [, body, sig] = parts;
|
|
426
|
+
const key = await flushHmacKey(shellSecret, "verify");
|
|
427
|
+
const ok = await crypto.subtle.verify(
|
|
428
|
+
"HMAC",
|
|
429
|
+
key,
|
|
430
|
+
unb64url(sig),
|
|
431
|
+
new TextEncoder().encode(`${FLUSH_CREDENTIAL_PREFIX}.${body}`),
|
|
432
|
+
);
|
|
433
|
+
if (!ok) throw new Error("bad signature");
|
|
434
|
+
let payload: RoomFlushCredentialPayload;
|
|
435
|
+
try {
|
|
436
|
+
payload = JSON.parse(new TextDecoder().decode(unb64url(body))) as RoomFlushCredentialPayload;
|
|
437
|
+
} catch {
|
|
438
|
+
throw new Error("malformed payload");
|
|
439
|
+
}
|
|
440
|
+
if (payload.v !== 1) throw new Error("unknown version");
|
|
441
|
+
if (typeof payload.doc !== "string" || payload.doc.length === 0) {
|
|
442
|
+
throw new Error("missing doc");
|
|
443
|
+
}
|
|
444
|
+
if (typeof payload.epoch !== "number") throw new Error("missing epoch");
|
|
445
|
+
return payload;
|
|
446
|
+
}
|
|
447
|
+
|
|
220
448
|
export interface RindleApiServerOptions<User> {
|
|
221
449
|
daemon: RindleDaemonClient;
|
|
222
450
|
/** Where mutations are applied and `lmid` is stamped ({@link MutationBackend}). Default:
|
|
223
451
|
* `daemonBackend(daemon)` — exactly today's behavior. Pass `postgresBackend(...)` when
|
|
224
452
|
* Postgres is the source of truth (the daemon leg then serves only the read side). */
|
|
225
453
|
backend?: MutationBackend;
|
|
454
|
+
/** The typed schema (`createSchema`/`refineSchema`). Required only when a mutator uses the LOGICAL
|
|
455
|
+
* write vocabulary (`tx.insert`/`update`/`upsert`/`insertIgnore`/`delete`/`row`) — it drives the
|
|
456
|
+
* dialect SQL renderer (column order, pk, quoting). A logical op with no schema configured throws
|
|
457
|
+
* loudly. Pure raw-`tx.exec` mutators do not need it. */
|
|
458
|
+
schema?: Schema;
|
|
226
459
|
queries?: ApiQueries<User>;
|
|
227
460
|
runQuery?: RunQuery<User>;
|
|
228
461
|
mutators?: ApiMutators<User>;
|
|
229
462
|
authorizeQuery?: Authorizer<AuthorizeQueryInput<User>>;
|
|
230
463
|
authorizeMutation?: Authorizer<AuthorizeMutationInput<User>>;
|
|
464
|
+
/** Rindle Realtime (RINDLE-REALTIME-ENABLEMENT-DESIGN.md §3.1): the ONE named opt-in.
|
|
465
|
+
* Presence activates the room flush trio AND `/room-boot`; absence keeps every room
|
|
466
|
+
* endpoint 403. Takes precedence over the deprecated {@link authorizeRoom}. */
|
|
467
|
+
realtime?: RindleRealtimeOptions<User>;
|
|
231
468
|
/** The room write-authority gate (§5.3.1): validates the caller is a placed room —
|
|
232
469
|
* the epoch-bound flush credential rides `context.request`, and what it means is
|
|
233
470
|
* the app's to define. The room endpoints are DISABLED (403) until this is set:
|
|
234
|
-
* hosting a write authority is an explicit opt-in, never a default.
|
|
471
|
+
* hosting a write authority is an explicit opt-in, never a default.
|
|
472
|
+
* @deprecated Use {@link realtime} (its `authorize`) — this bare form gates the
|
|
473
|
+
* flush trio but never activates `/room-boot`. When both are set, `realtime` wins. */
|
|
235
474
|
authorizeRoom?: Authorizer<ApiContext<User>>;
|
|
236
475
|
routes?: Partial<RindleApiRoutes>;
|
|
237
476
|
mode?: StreamMode;
|
|
@@ -313,6 +552,10 @@ export interface RindleApiServer<User> {
|
|
|
313
552
|
handleClaimRoomEpochJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
|
|
314
553
|
/** The room's boot probe (§3.3), same gate + envelope. */
|
|
315
554
|
handleRoomLmidsJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
|
|
555
|
+
/** The DO shell's cold-boot callback (§10.1; enablement §3.1): authenticate the shell
|
|
556
|
+
* secret, resolve the doc's footprint, claim the placement epoch, mint the upstream
|
|
557
|
+
* lease and the flush leg. 403 until {@link RindleApiServerOptions.realtime} is set. */
|
|
558
|
+
handleRoomBootJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
|
|
316
559
|
}
|
|
317
560
|
|
|
318
561
|
export type RindleApiErrorCode = "bad-request" | "forbidden" | "not-found" | "rejected";
|
|
@@ -369,6 +612,33 @@ export class SplitDaemonClient implements RindleDaemonClient {
|
|
|
369
612
|
rejectMutation(input: MutationRejection): Promise<MutationRejectionOutput> {
|
|
370
613
|
return this.writes.rejectMutation(input);
|
|
371
614
|
}
|
|
615
|
+
// Interactive mutation sessions hold the MASTER's write transaction — never a replica's
|
|
616
|
+
// (DAEMON-INTERACTIVE-TXN-DESIGN.md §4.1; the follower write-fence enforces the same).
|
|
617
|
+
beginMutationSession(input: MutationSessionBegin): Promise<MutationSessionBeginOutput> {
|
|
618
|
+
const begin = this.writes.beginMutationSession?.bind(this.writes);
|
|
619
|
+
if (!begin) return Promise.reject(new Error("the write master lacks mutation sessions"));
|
|
620
|
+
return begin(input);
|
|
621
|
+
}
|
|
622
|
+
execInMutationSession(input: MutationSessionExec): Promise<unknown> {
|
|
623
|
+
const exec = this.writes.execInMutationSession?.bind(this.writes);
|
|
624
|
+
if (!exec) return Promise.reject(new Error("the write master lacks mutation sessions"));
|
|
625
|
+
return exec(input);
|
|
626
|
+
}
|
|
627
|
+
queryInMutationSession(input: MutationSessionQuery): Promise<SqlReadOutput> {
|
|
628
|
+
const query = this.writes.queryInMutationSession?.bind(this.writes);
|
|
629
|
+
if (!query) return Promise.reject(new Error("the write master lacks mutation sessions"));
|
|
630
|
+
return query(input);
|
|
631
|
+
}
|
|
632
|
+
commitMutationSession(input: MutationSessionRef): Promise<SqlTxnOutput> {
|
|
633
|
+
const commit = this.writes.commitMutationSession?.bind(this.writes);
|
|
634
|
+
if (!commit) return Promise.reject(new Error("the write master lacks mutation sessions"));
|
|
635
|
+
return commit(input);
|
|
636
|
+
}
|
|
637
|
+
rollbackMutationSession(input: MutationSessionRef): Promise<unknown> {
|
|
638
|
+
const rollback = this.writes.rollbackMutationSession?.bind(this.writes);
|
|
639
|
+
if (!rollback) return Promise.reject(new Error("the write master lacks mutation sessions"));
|
|
640
|
+
return rollback(input);
|
|
641
|
+
}
|
|
372
642
|
applyRowChangeTxn(input: RowChangeTxn): Promise<RowChangeTxnOutput> {
|
|
373
643
|
return this.writes.applyRowChangeTxn(input);
|
|
374
644
|
}
|
|
@@ -398,17 +668,525 @@ export class SplitDaemonClient implements RindleDaemonClient {
|
|
|
398
668
|
}
|
|
399
669
|
}
|
|
400
670
|
|
|
671
|
+
// --------------------------------------------------------------------------- dialect SQL renderer
|
|
672
|
+
//
|
|
673
|
+
// A logical {@link MutationOp} → dialect `SqlStatement`. The whole per-dialect delta is the
|
|
674
|
+
// PLACEHOLDER STYLE (`?` vs `$n`): identifiers are always double-quoted (SQLite tolerates it, PG
|
|
675
|
+
// requires it — `user` is reserved, camelCase folds), and upserts use portable `ON CONFLICT` (both
|
|
676
|
+
// engines). So `sqliteDialect`/`postgresDialect` differ only in `placeholder`.
|
|
677
|
+
|
|
678
|
+
/** A SQL dialect for the logical mutation renderer. */
|
|
679
|
+
export interface SqlDialect {
|
|
680
|
+
readonly name: "sqlite" | "postgres";
|
|
681
|
+
/** Render the i-th (1-based) bind placeholder. sqlite: `?`; postgres: `$i`. */
|
|
682
|
+
placeholder(oneBased: number): string;
|
|
683
|
+
/** Optional value coercion hook (e.g. a future SQLite `0/1` boolean). Default: identity. */
|
|
684
|
+
encodeValue?(v: WireValue, type: ColType): WireValue;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export const sqliteDialect: SqlDialect = { name: "sqlite", placeholder: () => "?" };
|
|
688
|
+
export const postgresDialect: SqlDialect = { name: "postgres", placeholder: (i) => `$${i}` };
|
|
689
|
+
|
|
690
|
+
/** Per-table metadata the renderer needs (all reachable from a `TableMeta`). */
|
|
691
|
+
export interface TableRenderMeta {
|
|
692
|
+
/** Columns in schema (wire) order — the stable INSERT column list + completeness check. */
|
|
693
|
+
columns: string[];
|
|
694
|
+
/** Primary-key column NAMES — the WHERE / ON CONFLICT target / SET partition. */
|
|
695
|
+
pkNames: string[];
|
|
696
|
+
/** Column name → declared type (only consulted by {@link SqlDialect.encodeValue}). */
|
|
697
|
+
types: Record<string, ColType>;
|
|
698
|
+
/** Columns a full insert must name — the non-nullable ones (design 206 §6.2). */
|
|
699
|
+
required: string[];
|
|
700
|
+
/** The nullable (omittable-to-null) columns — an omitted one binds `NULL` (design 206 §6.2). */
|
|
701
|
+
nullable: ReadonlySet<string>;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export type RenderIndex = Record<string, TableRenderMeta>;
|
|
705
|
+
|
|
706
|
+
/** Build the {@link RenderIndex} from a typed schema (`schema.tables[name]` is a `TableMeta`). */
|
|
707
|
+
export function buildRenderIndex(schema: Schema): RenderIndex {
|
|
708
|
+
const out: RenderIndex = {};
|
|
709
|
+
const tables = (schema as unknown as { tables: Record<string, { columns: Record<string, { type: ColType }>; primaryKey: readonly string[] }> }).tables;
|
|
710
|
+
for (const name of Object.keys(tables)) {
|
|
711
|
+
const meta = tables[name];
|
|
712
|
+
const columns = Object.keys(meta.columns);
|
|
713
|
+
const types: Record<string, ColType> = {};
|
|
714
|
+
for (const c of columns) types[c] = meta.columns[c].type;
|
|
715
|
+
// Same schema-derived plan the client funnel uses (design 206 §6.1), so their required-sets
|
|
716
|
+
// and null-fills can't drift.
|
|
717
|
+
const { required, nullable } = insertPlan(schema.tables[name]);
|
|
718
|
+
out[name] = { columns, pkNames: [...meta.primaryKey], types, required, nullable };
|
|
719
|
+
}
|
|
720
|
+
return out;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const quoteIdent = (name: string): string => `"${name.replace(/"/g, '""')}"`;
|
|
724
|
+
|
|
725
|
+
function tableMeta(render: RenderIndex, table: string): TableRenderMeta {
|
|
726
|
+
const meta = render[table];
|
|
727
|
+
if (!meta) {
|
|
728
|
+
const known = Object.keys(render);
|
|
729
|
+
throw new Error(
|
|
730
|
+
`logical mutator write to unknown table ${JSON.stringify(table)} — did you pass \`schema\` to createRindleApiServer? known tables: ${known.length ? known.join(", ") : "(none — no schema configured)"}`,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
return meta;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Validate a keyed row against a table: reject unknown columns; require the pk columns; with
|
|
737
|
+
* `full`, require every NON-nullable column (a nullable column may be omitted and is filled with
|
|
738
|
+
* `NULL`, design 206 §6.2). Mirrors the client `trackingTx.checkColumns` messages so an author sees
|
|
739
|
+
* the same error on both tiers. */
|
|
740
|
+
function checkColumns(table: string, obj: KeyedRow, meta: TableRenderMeta, full: boolean): void {
|
|
741
|
+
const unknown = Object.keys(obj).filter((k) => !meta.columns.includes(k));
|
|
742
|
+
if (unknown.length) {
|
|
743
|
+
throw new Error(`unknown column${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} on ${table} — columns: ${meta.columns.join(", ")}`);
|
|
744
|
+
}
|
|
745
|
+
const required = full ? meta.required : meta.pkNames;
|
|
746
|
+
const missing = required.filter((c) => !(c in obj));
|
|
747
|
+
if (missing.length) {
|
|
748
|
+
throw new Error(`missing ${full ? "column" : "primary-key column"}${missing.length > 1 ? "s" : ""} ${missing.join(", ")} on ${table}`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const encode = (dialect: SqlDialect, v: WireValue, type: ColType): WireValue =>
|
|
753
|
+
dialect.encodeValue ? dialect.encodeValue(v, type) : v;
|
|
754
|
+
|
|
755
|
+
/** Render one {@link MutationOp} to a `{sql, params}` for the dialect, or `null` for a no-op (an
|
|
756
|
+
* `update` whose row names only pk columns — nothing to SET, matching the client's no-op edit). */
|
|
757
|
+
export function renderOp(op: MutationOp, meta: TableRenderMeta, dialect: SqlDialect): SqlStatement | null {
|
|
758
|
+
const t = quoteIdent(op.table);
|
|
759
|
+
const params: WireValue[] = [];
|
|
760
|
+
// Bind a value and return its placeholder at the correct 1-based index (post-push length).
|
|
761
|
+
// `insertCell` fills NULL for an omitted nullable column on the insert arm (design 206 §6.2);
|
|
762
|
+
// on update/delete every bound column is guaranteed present, so it is a pass-through there.
|
|
763
|
+
// `toCell` stringifies a `json` object (a typed mutator passes the parsed object) — a string
|
|
764
|
+
// passes through, so an author may still pass pre-stringified json.
|
|
765
|
+
const bind = (c: string, row: KeyedRow): string => {
|
|
766
|
+
params.push(encode(dialect, toCell(insertCell(row, c), meta.types[c]), meta.types[c]));
|
|
767
|
+
return dialect.placeholder(params.length);
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
if (op.kind === "insert" || op.kind === "insertIgnore" || op.kind === "upsert") {
|
|
771
|
+
checkColumns(op.table, op.row, meta, true);
|
|
772
|
+
const cols = meta.columns;
|
|
773
|
+
const values = cols.map((c) => bind(c, op.row));
|
|
774
|
+
let sql = `INSERT INTO ${t} (${cols.map(quoteIdent).join(", ")}) VALUES (${values.join(", ")})`;
|
|
775
|
+
if (op.kind === "insertIgnore") {
|
|
776
|
+
sql += ` ON CONFLICT (${meta.pkNames.map(quoteIdent).join(", ")}) DO NOTHING`;
|
|
777
|
+
} else if (op.kind === "upsert") {
|
|
778
|
+
const nonPk = cols.filter((c) => !meta.pkNames.includes(c));
|
|
779
|
+
sql += ` ON CONFLICT (${meta.pkNames.map(quoteIdent).join(", ")}) `;
|
|
780
|
+
sql += nonPk.length
|
|
781
|
+
? `DO UPDATE SET ${nonPk.map((c) => `${quoteIdent(c)} = excluded.${quoteIdent(c)}`).join(", ")}`
|
|
782
|
+
: `DO NOTHING`;
|
|
783
|
+
}
|
|
784
|
+
return { sql, params };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (op.kind === "update") {
|
|
788
|
+
checkColumns(op.table, op.row, meta, false);
|
|
789
|
+
const setCols = meta.columns.filter((c) => !meta.pkNames.includes(c) && c in op.row);
|
|
790
|
+
if (!setCols.length) return null; // pk-only row → nothing to change (client no-op edit)
|
|
791
|
+
const setSql = setCols.map((c) => `${quoteIdent(c)} = ${bind(c, op.row)}`);
|
|
792
|
+
const whereSql = meta.pkNames.map((c) => `${quoteIdent(c)} = ${bind(c, op.row)}`);
|
|
793
|
+
return { sql: `UPDATE ${t} SET ${setSql.join(", ")} WHERE ${whereSql.join(" AND ")}`, params };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// delete
|
|
797
|
+
checkColumns(op.table, op.pk, meta, false);
|
|
798
|
+
const whereSql = meta.pkNames.map((c) => `${quoteIdent(c)} = ${bind(c, op.pk)}`);
|
|
799
|
+
return { sql: `DELETE FROM ${t} WHERE ${whereSql.join(" AND ")}`, params };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Render a point read (`tx.row`) — `SELECT <cols> FROM "T" WHERE <pk>` — for read-your-writes. */
|
|
803
|
+
export function renderPointRead(table: string, pk: KeyedRow, meta: TableRenderMeta, dialect: SqlDialect): SqlStatement {
|
|
804
|
+
checkColumns(table, pk, meta, false);
|
|
805
|
+
const params: WireValue[] = [];
|
|
806
|
+
const whereSql = meta.pkNames.map((c) => {
|
|
807
|
+
params.push(encode(dialect, pk[c], meta.types[c]));
|
|
808
|
+
return `${quoteIdent(c)} = ${dialect.placeholder(params.length)}`;
|
|
809
|
+
});
|
|
810
|
+
const cols = meta.columns.map(quoteIdent).join(", ");
|
|
811
|
+
return { sql: `SELECT ${cols} FROM ${quoteIdent(table)} WHERE ${whereSql.join(" AND ")}`, params };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Map a driver row (column-name keyed) to a {@link KeyedRow} over the table's known columns. */
|
|
815
|
+
function rowToKeyed(row: Record<string, unknown> | undefined, meta: TableRenderMeta): KeyedRow | undefined {
|
|
816
|
+
if (!row) return undefined;
|
|
817
|
+
const out: KeyedRow = {};
|
|
818
|
+
for (const c of meta.columns) out[c] = row[c] as WireValue;
|
|
819
|
+
return out;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// --------------------------------------------------------------------------- backends + server tx
|
|
823
|
+
|
|
824
|
+
/** Thrown (wrapping the driver error) by a server tx's DB calls, so the seam can tell an INFRA
|
|
825
|
+
* failure (retry) from a mutator-body throw (business rejection). */
|
|
826
|
+
export class BackendError extends Error {
|
|
827
|
+
readonly driverError: unknown;
|
|
828
|
+
constructor(driverError: unknown) {
|
|
829
|
+
super(driverError instanceof Error ? driverError.message : String(driverError));
|
|
830
|
+
this.name = "BackendError";
|
|
831
|
+
this.driverError = driverError;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** The rindle/daemon server tx: logical writes render to SQLite and ACCUMULATE into one batch;
|
|
836
|
+
* raw `exec` accumulates too; `row` reads COMMITTED state through the daemon (no read-your-writes,
|
|
837
|
+
* the one interactive-txn limitation of the daemon backend). */
|
|
838
|
+
/** Build the compiler {@link Catalog} for ONE ast from the render index: columns/pk from the
|
|
839
|
+
* schema; relationship cardinality from the AST ITSELF — a Rindle relationship is declared at
|
|
840
|
+
* the query site (`sub(alias, rel)` / `.one()`), never on the schema, so the alias→cardinality
|
|
841
|
+
* map is inherently per-query. `columnTypes` are stubs: the sqlite dialect binds natives and
|
|
842
|
+
* never consults them (DAEMON-INTERACTIVE-TXN §5.4 — no casts). */
|
|
843
|
+
function catalogFor(render: RenderIndex, root: Ast): Catalog {
|
|
844
|
+
const tables: Record<string, TableSchema> = {};
|
|
845
|
+
const ensure = (table: string): TableSchema => {
|
|
846
|
+
const existing = tables[table];
|
|
847
|
+
if (existing) return existing;
|
|
848
|
+
const meta = tableMeta(render, table);
|
|
849
|
+
const columnTypes: Record<string, QueryColumnType> = {};
|
|
850
|
+
for (const c of meta.columns) columnTypes[c] = { type: "text", isEnum: false, isArray: false };
|
|
851
|
+
return (tables[table] = {
|
|
852
|
+
columns: [...meta.columns],
|
|
853
|
+
primaryKey: [...meta.pkNames],
|
|
854
|
+
columnTypes,
|
|
855
|
+
relationships: {},
|
|
856
|
+
});
|
|
857
|
+
};
|
|
858
|
+
const walkCondition = (cond: Condition | undefined): void => {
|
|
859
|
+
if (!cond) return;
|
|
860
|
+
if (cond.type === "and" || cond.type === "or") {
|
|
861
|
+
for (const c of cond.conditions) walkCondition(c);
|
|
862
|
+
} else if (cond.type === "correlatedSubquery") {
|
|
863
|
+
walkAst(cond.related.subquery);
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
const walkAst = (ast: Ast): void => {
|
|
867
|
+
const t = ensure(ast.table);
|
|
868
|
+
for (const rel of ast.related ?? []) {
|
|
869
|
+
const alias = rel.subquery.alias;
|
|
870
|
+
if (alias != null) t.relationships[alias] = rel.subquery.one === true ? "one" : "many";
|
|
871
|
+
walkAst(rel.subquery);
|
|
872
|
+
}
|
|
873
|
+
walkCondition(ast.where);
|
|
874
|
+
};
|
|
875
|
+
walkAst(root);
|
|
876
|
+
return { tables };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** The control-flow unwind for a begin-absorbed replay (DAEMON-INTERACTIVE-TXN §4.1): thrown
|
|
880
|
+
* from the first read so the mutator body stops re-running an already-committed envelope; the
|
|
881
|
+
* backend answers with the tx's latched authoritative output. Never surfaces to users. */
|
|
882
|
+
class AbsorbedReplay extends Error {
|
|
883
|
+
constructor() {
|
|
884
|
+
super("mutation absorbed by mid dedup at session begin");
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* The daemon server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
|
|
890
|
+
* execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
|
|
891
|
+
* `/execute-sql-txn`, byte-identical to prior behavior — and LAZILY UPGRADES to an interactive
|
|
892
|
+
* mutation session at the mutator's first read: `begin` carries the envelope identity, the
|
|
893
|
+
* accumulated statement prefix (sound to replay — nothing before the first read observed DB
|
|
894
|
+
* state, §5.2), and the read itself, so a one-read mutator pays exactly one extra round trip.
|
|
895
|
+
* From then on reads run THROUGH the open transaction (read-your-writes — PG parity, and no
|
|
896
|
+
* read-then-write race) and writes buffer locally, flushing before the next read/commit: k
|
|
897
|
+
* reads cost k+2 round trips regardless of write count.
|
|
898
|
+
*
|
|
899
|
+
* Begin-time mid dedup can ABSORB the envelope (a redelivery whose commit response was lost):
|
|
900
|
+
* the replay output is latched on {@link DaemonLazyTx.absorbed} and {@link AbsorbedReplay}
|
|
901
|
+
* unwinds the body — the latch (not the throw) is authoritative, so a mutator that swallows
|
|
902
|
+
* the unwind still cannot re-apply (no session opened; buffered writes are never shipped).
|
|
903
|
+
* A daemon client without session support keeps the LEGACY committed-state point read.
|
|
904
|
+
*/
|
|
905
|
+
class DaemonLazyTx implements ServerMutationTx {
|
|
906
|
+
/** Pre-upgrade: the accumulated batch/prefix. Post-upgrade: writes buffered for the next flush. */
|
|
907
|
+
private readonly stmts: SqlStatement[] = [];
|
|
908
|
+
private readonly render: RenderIndex;
|
|
909
|
+
private readonly daemon: RindleDaemonClient;
|
|
910
|
+
private readonly envelope: MutationEnvelope;
|
|
911
|
+
private sessionId?: string;
|
|
912
|
+
/** The begin-absorbed replay output (§4.1), latched for the backend. */
|
|
913
|
+
absorbed?: SqlTxnOutput;
|
|
914
|
+
idempotencyKey?: string;
|
|
915
|
+
|
|
916
|
+
constructor(render: RenderIndex, daemon: RindleDaemonClient, envelope: MutationEnvelope) {
|
|
917
|
+
this.render = render;
|
|
918
|
+
this.daemon = daemon;
|
|
919
|
+
this.envelope = envelope;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** True once the tx upgraded to an interactive session (the backend then commits it). */
|
|
923
|
+
get session(): boolean {
|
|
924
|
+
return this.sessionId !== undefined;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
get statements(): readonly SqlStatement[] {
|
|
928
|
+
return this.stmts;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
exec(sql: string, params: WireValue[] = []): void {
|
|
932
|
+
this.stmts.push({ sql, params });
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
private push(op: MutationOp): Promise<void> {
|
|
936
|
+
const rendered = renderOp(op, tableMeta(this.render, op.table), sqliteDialect);
|
|
937
|
+
if (rendered) this.stmts.push(rendered);
|
|
938
|
+
return Promise.resolve();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
insert(table: string, row: KeyedRow): Promise<void> {
|
|
942
|
+
return this.push({ kind: "insert", table, row });
|
|
943
|
+
}
|
|
944
|
+
update(table: string, row: KeyedRow): Promise<void> {
|
|
945
|
+
return this.push({ kind: "update", table, row });
|
|
946
|
+
}
|
|
947
|
+
upsert(table: string, row: KeyedRow): Promise<void> {
|
|
948
|
+
return this.push({ kind: "upsert", table, row });
|
|
949
|
+
}
|
|
950
|
+
insertIgnore(table: string, row: KeyedRow): Promise<void> {
|
|
951
|
+
return this.push({ kind: "insertIgnore", table, row });
|
|
952
|
+
}
|
|
953
|
+
delete(table: string, pk: KeyedRow): Promise<void> {
|
|
954
|
+
return this.push({ kind: "delete", table, pk });
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async row(table: string, pk: KeyedRow): Promise<KeyedRow | undefined> {
|
|
958
|
+
const read = renderPointRead(table, pk, tableMeta(this.render, table), sqliteDialect);
|
|
959
|
+
const out = await this.readThroughTxn(read);
|
|
960
|
+
const cells = out.rows[0];
|
|
961
|
+
if (!cells) return undefined;
|
|
962
|
+
const keyed: KeyedRow = {}; // the daemon returns positional cells — zip with `cols`
|
|
963
|
+
out.cols.forEach((c, i) => (keyed[c] = cells[i]));
|
|
964
|
+
return keyed;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** A full-shape read inside the open transaction (§5.4): compile to ONE SQLite `SELECT`
|
|
968
|
+
* (native bind params, no casts — SQLite is the canonical store), ride the session like
|
|
969
|
+
* `row` (including the lazy upgrade / begin ride-along), parse the single JSON cell. */
|
|
970
|
+
async query(q: Ast | Query<any, any, any>): Promise<unknown> {
|
|
971
|
+
const ast = typeof (q as Query<any, any, any>).ast === "function" ? (q as Query<any, any, any>).ast() : (q as Ast);
|
|
972
|
+
const compiled = compileQueryAst(ast, catalogFor(this.render, ast), { dialect: "sqlite" });
|
|
973
|
+
const out = await this.readThroughTxn({ sql: compiled.sql, params: compiled.params as WireValue[] });
|
|
974
|
+
const cell = out.rows[0]?.[0];
|
|
975
|
+
if (typeof cell !== "string") return ast.one === true ? null : [];
|
|
976
|
+
return JSON.parse(cell) as unknown;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** Run one read: upgrade to a session at the first (§5.1), ride the open one after, or fall
|
|
980
|
+
* back to the legacy committed-state read when the daemon client lacks sessions. */
|
|
981
|
+
private async readThroughTxn(read: SqlStatement): Promise<SqlReadOutput> {
|
|
982
|
+
if (this.absorbed) throw new AbsorbedReplay();
|
|
983
|
+
if (!this.daemon.beginMutationSession) {
|
|
984
|
+
try {
|
|
985
|
+
return await this.daemon.executeSqlRead({ sql: read.sql, params: read.params });
|
|
986
|
+
} catch (err) {
|
|
987
|
+
throw new BackendError(err);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
try {
|
|
991
|
+
if (this.sessionId === undefined) {
|
|
992
|
+
const opened = await this.daemon.beginMutationSession({
|
|
993
|
+
clientID: this.envelope.clientID,
|
|
994
|
+
mid: this.envelope.mid,
|
|
995
|
+
statements: this.stmts.splice(0),
|
|
996
|
+
query: read,
|
|
997
|
+
});
|
|
998
|
+
if (opened.absorbed) {
|
|
999
|
+
const { absorbed: _a, sessionId: _s, read: _r, ...output } = opened;
|
|
1000
|
+
this.absorbed = output as SqlTxnOutput;
|
|
1001
|
+
throw new AbsorbedReplay();
|
|
1002
|
+
}
|
|
1003
|
+
if (!opened.sessionId || !opened.read) {
|
|
1004
|
+
throw new Error(`malformed mutate-session begin reply: ${JSON.stringify(opened)}`);
|
|
1005
|
+
}
|
|
1006
|
+
this.sessionId = opened.sessionId;
|
|
1007
|
+
return opened.read;
|
|
1008
|
+
}
|
|
1009
|
+
await this.flush();
|
|
1010
|
+
return await this.daemon.queryInMutationSession!({
|
|
1011
|
+
sessionId: this.sessionId,
|
|
1012
|
+
sql: read.sql,
|
|
1013
|
+
params: read.params,
|
|
1014
|
+
});
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
if (err instanceof AbsorbedReplay || err instanceof BackendError) throw err;
|
|
1017
|
+
throw new BackendError(err);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** Ship buffered writes into the open session, order-preserving; a no-op when none pend. */
|
|
1022
|
+
private async flush(): Promise<void> {
|
|
1023
|
+
if (this.stmts.length === 0) return;
|
|
1024
|
+
await this.daemon.execInMutationSession!({
|
|
1025
|
+
sessionId: this.sessionId!,
|
|
1026
|
+
statements: this.stmts.splice(0),
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** Flush + commit the open session — the daemon stamps lmid co-transactionally (§4.4) and
|
|
1031
|
+
* answers the same shape `/execute-sql-txn` does. */
|
|
1032
|
+
async commitSession(): Promise<SqlTxnOutput> {
|
|
1033
|
+
try {
|
|
1034
|
+
await this.flush();
|
|
1035
|
+
return await this.daemon.commitMutationSession!({ sessionId: this.sessionId! });
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
throw err instanceof BackendError ? err : new BackendError(err);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** Best-effort rollback (the daemon's deadline is the backstop). MUST be awaited before a
|
|
1042
|
+
* follow-up `/reject-mutation`: that lmid-only commit needs the writer this session holds. */
|
|
1043
|
+
async rollbackSessionQuietly(): Promise<void> {
|
|
1044
|
+
if (this.sessionId === undefined) return;
|
|
1045
|
+
const sessionId = this.sessionId;
|
|
1046
|
+
this.sessionId = undefined;
|
|
1047
|
+
try {
|
|
1048
|
+
await this.daemon.rollbackMutationSession!({ sessionId });
|
|
1049
|
+
} catch {
|
|
1050
|
+
// Unreachable daemon / already-expired session: the deadline rollback covers it.
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** The Postgres server tx: a REAL interactive transaction. Logical writes render to `$n` and run
|
|
1056
|
+
* LIVE against the open txn; raw `exec` runs live too (after `rewrite`); `row` reads the open txn
|
|
1057
|
+
* (read-your-writes). Ops append to an internally-serialized chain so order holds even when a legacy
|
|
1058
|
+
* sync mutator does not `await`; the backend drains the chain (`settle`) before the lmid upsert. */
|
|
1059
|
+
class PgLiveTx implements ServerMutationTx {
|
|
1060
|
+
private chain: Promise<void> = Promise.resolve();
|
|
1061
|
+
private readonly stmts: SqlStatement[] = [];
|
|
1062
|
+
private readonly q: PgQuery;
|
|
1063
|
+
private readonly render: RenderIndex;
|
|
1064
|
+
private readonly rewrite: (sql: string) => string;
|
|
1065
|
+
idempotencyKey?: string;
|
|
1066
|
+
|
|
1067
|
+
constructor(q: PgQuery, render: RenderIndex, rewrite: (sql: string) => string) {
|
|
1068
|
+
this.q = q;
|
|
1069
|
+
this.render = render;
|
|
1070
|
+
this.rewrite = rewrite;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
get statements(): readonly SqlStatement[] {
|
|
1074
|
+
return this.stmts;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
settle(): Promise<void> {
|
|
1078
|
+
return this.chain;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
private execLive(sql: string, params: WireValue[]): void {
|
|
1082
|
+
this.chain = this.chain.then(async () => {
|
|
1083
|
+
try {
|
|
1084
|
+
await this.q.exec(sql, params as unknown[]);
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
throw new BackendError(err);
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
exec(sql: string, params: WireValue[] = []): void {
|
|
1092
|
+
const stmt = { sql: this.rewrite(sql), params };
|
|
1093
|
+
this.stmts.push(stmt);
|
|
1094
|
+
this.execLive(stmt.sql, params);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
private write(op: MutationOp): Promise<void> {
|
|
1098
|
+
let rendered: SqlStatement | null;
|
|
1099
|
+
try {
|
|
1100
|
+
rendered = renderOp(op, tableMeta(this.render, op.table), postgresDialect);
|
|
1101
|
+
} catch (err) {
|
|
1102
|
+
return Promise.reject(err); // a validation error (business rejection), synchronous shape
|
|
1103
|
+
}
|
|
1104
|
+
if (rendered) this.execLive(rendered.sql, rendered.params ?? []);
|
|
1105
|
+
return this.chain;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
insert(table: string, row: KeyedRow): Promise<void> {
|
|
1109
|
+
return this.write({ kind: "insert", table, row });
|
|
1110
|
+
}
|
|
1111
|
+
update(table: string, row: KeyedRow): Promise<void> {
|
|
1112
|
+
return this.write({ kind: "update", table, row });
|
|
1113
|
+
}
|
|
1114
|
+
upsert(table: string, row: KeyedRow): Promise<void> {
|
|
1115
|
+
return this.write({ kind: "upsert", table, row });
|
|
1116
|
+
}
|
|
1117
|
+
insertIgnore(table: string, row: KeyedRow): Promise<void> {
|
|
1118
|
+
return this.write({ kind: "insertIgnore", table, row });
|
|
1119
|
+
}
|
|
1120
|
+
delete(table: string, pk: KeyedRow): Promise<void> {
|
|
1121
|
+
return this.write({ kind: "delete", table, pk });
|
|
1122
|
+
}
|
|
1123
|
+
async row(table: string, pk: KeyedRow): Promise<KeyedRow | undefined> {
|
|
1124
|
+
const meta = tableMeta(this.render, table);
|
|
1125
|
+
const read = renderPointRead(table, pk, meta, postgresDialect); // validates before draining
|
|
1126
|
+
await this.settle(); // read-your-writes: drain queued writes first
|
|
1127
|
+
let rows: Array<Record<string, unknown>>;
|
|
1128
|
+
try {
|
|
1129
|
+
rows = await this.q.query(read.sql, read.params as unknown[]);
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
throw new BackendError(err);
|
|
1132
|
+
}
|
|
1133
|
+
return rowToKeyed(rows[0], meta);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
query(): Promise<unknown> {
|
|
1137
|
+
// The compiler's postgres dialect ships (@rindle/query-compiler); what remains is the §7
|
|
1138
|
+
// static-catalog + driver-pin wiring (POSTGRES-READ-COMPILER-DESIGN.md Phase B).
|
|
1139
|
+
return Promise.reject(
|
|
1140
|
+
new Error(
|
|
1141
|
+
"tx.query is not wired on the Postgres backend yet (POSTGRES-READ-COMPILER-DESIGN.md Phase B) — use tx.row for point reads meanwhile",
|
|
1142
|
+
),
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
401
1147
|
/**
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
* it past a rejected mid)
|
|
1148
|
+
* The default {@link MutationBackend}. A pure-write mutator keeps the historical shape: writes
|
|
1149
|
+
* ACCUMULATE and ship as ONE batch to `/execute-sql-txn`, which stamps `lmid` co-transactionally
|
|
1150
|
+
* (and `/reject-mutation` advances it past a rejected mid) — byte-identical behavior. A
|
|
1151
|
+
* READ-bearing mutator lazily upgrades to an interactive mutation session
|
|
1152
|
+
* (DAEMON-INTERACTIVE-TXN-DESIGN.md): reads are read-your-writes through the open transaction
|
|
1153
|
+
* (PG parity), the commit stamps `lmid` in the same atomic unit, and a begin-absorbed replay
|
|
1154
|
+
* short-circuits without re-running the body.
|
|
405
1155
|
*/
|
|
406
1156
|
export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
|
|
407
1157
|
return {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
1158
|
+
dialect: sqliteDialect,
|
|
1159
|
+
async runMutation({ envelope, render, run }) {
|
|
1160
|
+
const tx = new DaemonLazyTx(render, daemon, envelope);
|
|
1161
|
+
try {
|
|
1162
|
+
await run(tx);
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
// A begin-absorbed replay: the authoritative outcome already committed — answer it,
|
|
1165
|
+
// whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
|
|
1166
|
+
if (tx.absorbed) return { accepted: true, output: tx.absorbed };
|
|
1167
|
+
if (err instanceof BackendError) {
|
|
1168
|
+
await tx.rollbackSessionQuietly();
|
|
1169
|
+
throw err.driverError; // infra — never a user rejection
|
|
1170
|
+
}
|
|
1171
|
+
const reason = errMessage(err);
|
|
1172
|
+
// Data first, watermark second: the rollback releases the single writer that the
|
|
1173
|
+
// `/reject-mutation` lmid-only commit needs (§2.4 on the session path).
|
|
1174
|
+
await tx.rollbackSessionQuietly();
|
|
1175
|
+
const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
|
|
1176
|
+
return { accepted: false, reason, output };
|
|
1177
|
+
}
|
|
1178
|
+
if (tx.absorbed) return { accepted: true, output: tx.absorbed };
|
|
1179
|
+
if (tx.session) {
|
|
1180
|
+
try {
|
|
1181
|
+
return { accepted: true, output: await tx.commitSession() };
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
if (err instanceof BackendError) throw err.driverError; // infra (client retries; dedup absorbs)
|
|
1184
|
+
throw err;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
const txn: SqlTxn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
|
|
1188
|
+
if (tx.idempotencyKey !== undefined) txn.idempotencyKey = tx.idempotencyKey;
|
|
1189
|
+
return { accepted: true, output: await daemon.executeSqlTxn(txn) };
|
|
412
1190
|
},
|
|
413
1191
|
reject({ envelope, reason }) {
|
|
414
1192
|
return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
|
|
@@ -458,16 +1236,42 @@ ON CONFLICT ("client_id") DO UPDATE
|
|
|
458
1236
|
*/
|
|
459
1237
|
export function postgresBackend(plugger: PostgresPlugger, opts: PostgresBackendOptions = {}): MutationBackend {
|
|
460
1238
|
const rewrite = opts.rewriteSql ?? ((sql: string) => sql);
|
|
461
|
-
|
|
1239
|
+
// A tagged business rejection escaping `plugger.transaction` — the plugger rolls the data back on
|
|
1240
|
+
// any throw; this marker distinguishes "mutator said no" (reject) from an infra failure (rethrow).
|
|
1241
|
+
class RejectSignal extends Error {}
|
|
1242
|
+
const lmidOnly = (envelope: MutationEnvelope): Promise<SqlTxnOutput> =>
|
|
462
1243
|
plugger.transaction(async (q) => {
|
|
463
|
-
for (const s of statements) await q.exec(rewrite(s.sql), (s.params ?? []) as unknown[]);
|
|
464
|
-
// ALWAYS — accepted or rejected — in this same transaction (§2.2, §2.4).
|
|
465
1244
|
await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
|
|
466
1245
|
return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
|
|
467
1246
|
});
|
|
468
1247
|
return {
|
|
469
|
-
|
|
470
|
-
|
|
1248
|
+
dialect: postgresDialect,
|
|
1249
|
+
async runMutation({ envelope, render, run }) {
|
|
1250
|
+
try {
|
|
1251
|
+
const output = await plugger.transaction(async (q) => {
|
|
1252
|
+
const tx = new PgLiveTx(q, render, rewrite);
|
|
1253
|
+
try {
|
|
1254
|
+
await run(tx);
|
|
1255
|
+
await tx.settle(); // drain any un-awaited queued writes before the lmid stamp
|
|
1256
|
+
} catch (err) {
|
|
1257
|
+
if (err instanceof BackendError) throw err; // infra → rollback + propagate
|
|
1258
|
+
throw new RejectSignal(errMessage(err)); // business → rollback data, tag for §2.4
|
|
1259
|
+
}
|
|
1260
|
+
// ALWAYS on the accepted path, SAME transaction (§2.2): the lmid upsert commits with data.
|
|
1261
|
+
await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
|
|
1262
|
+
return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
|
|
1263
|
+
});
|
|
1264
|
+
return { accepted: true, output };
|
|
1265
|
+
} catch (err) {
|
|
1266
|
+
if (err instanceof RejectSignal) {
|
|
1267
|
+
// Data rolled back; STILL advance lmid alone (§2.4 — the client's queue must drain).
|
|
1268
|
+
return { accepted: false, reason: err.message, output: await lmidOnly(envelope) };
|
|
1269
|
+
}
|
|
1270
|
+
if (err instanceof BackendError) throw err.driverError; // infra
|
|
1271
|
+
throw err;
|
|
1272
|
+
}
|
|
1273
|
+
},
|
|
1274
|
+
reject: ({ envelope }) => lmidOnly(envelope).then(() => undefined),
|
|
471
1275
|
};
|
|
472
1276
|
}
|
|
473
1277
|
|
|
@@ -595,6 +1399,38 @@ export function defineApiMutators<User, M extends ApiMutators<User>>(mutators: M
|
|
|
595
1399
|
return mutators;
|
|
596
1400
|
}
|
|
597
1401
|
|
|
1402
|
+
/**
|
|
1403
|
+
* Bulk-register a SHARED (generator) mutator registry as server mutators — the mutator twin of
|
|
1404
|
+
* {@link registerQueries} (which does the same for co-located `defineQuery` values). Each shared
|
|
1405
|
+
* mutator carries its own arg validator (`shared(schema, gen)`), so this wraps every one with the
|
|
1406
|
+
* UNIVERSAL server triad and nothing else: parse the UNTRUSTED wire args (its `.args`), map the server
|
|
1407
|
+
* {@link MutationContext} to the shared {@link MutatorCtx} principal, and drive the SAME body the
|
|
1408
|
+
* client predicts ({@link runSharedMutation}). The point is that a shared mutator whose server run
|
|
1409
|
+
* adds NO authority beyond that triad needs no hand-written wrapper.
|
|
1410
|
+
*
|
|
1411
|
+
* Server-only AUTHORITY the client cannot predict (a title guard, an owner-gated cascade, a
|
|
1412
|
+
* `NOT EXISTS` dedup) stays an explicit {@link ApiMutator} that OVERRIDES the auto-wrapped default —
|
|
1413
|
+
* spread this first, then the overrides win by key:
|
|
1414
|
+
*
|
|
1415
|
+
* ```ts
|
|
1416
|
+
* mutators: defineApiMutators({
|
|
1417
|
+
* ...sharedApiMutators(sharedMutators, (ctx) => ({ user: requireUser(ctx.user) })),
|
|
1418
|
+
* createIssue: withTitleGuard(sharedMutators.createIssue), // + server-only policy
|
|
1419
|
+
* deleteIssue: async (tx, raw, ctx) => { ... }, // raw owner-gated cascade
|
|
1420
|
+
* }),
|
|
1421
|
+
* ```
|
|
1422
|
+
*/
|
|
1423
|
+
export function sharedApiMutators<User>(
|
|
1424
|
+
registry: Record<string, SharedMutatorWithArgs<any>>,
|
|
1425
|
+
principal: (ctx: MutationContext<User>) => MutatorCtx,
|
|
1426
|
+
): ApiMutators<User> {
|
|
1427
|
+
const out: ApiMutators<User> = {};
|
|
1428
|
+
for (const [name, mutator] of Object.entries(registry)) {
|
|
1429
|
+
out[name] = (tx, raw, ctx) => runSharedMutation(mutator, mutator.args.parse(raw), principal(ctx), tx);
|
|
1430
|
+
}
|
|
1431
|
+
return out;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
598
1434
|
export function queryResultToAst(result: ApiQueryResult): Ast {
|
|
599
1435
|
if (result && typeof result === "object" && "ast" in result && typeof result.ast === "function") {
|
|
600
1436
|
return result.ast();
|
|
@@ -704,6 +1540,10 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
704
1540
|
const mode = opts.mode ?? "normalized";
|
|
705
1541
|
// The mutation seam — defaulting to the daemon's co-transactional lmid stamp (unchanged behavior).
|
|
706
1542
|
const backend = opts.backend ?? daemonBackend(opts.daemon);
|
|
1543
|
+
// Schema-derived render metadata for logical mutator writes; `{}` when no schema is configured (a
|
|
1544
|
+
// logical op then throws loudly — the tx never silently drops a write). Each backend renders in its
|
|
1545
|
+
// own dialect (`backend.dialect`: daemon→sqlite, postgres→postgres).
|
|
1546
|
+
const renderIndex: RenderIndex = opts.schema ? buildRenderIndex(opts.schema) : {};
|
|
707
1547
|
// Names that are ALSO configured pins — a lease for one is forced to a `pinned` policy (the lazy
|
|
708
1548
|
// floor, §4.1) so the first viewer to route to a follower warms it for late joiners.
|
|
709
1549
|
const pinnedNames = new Set((opts.pinnedQueries ?? []).map((p) => p.name));
|
|
@@ -777,31 +1617,34 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
777
1617
|
const pushMutation = async (input: PushMutationRequest<User>): Promise<PushMutationResponse> => {
|
|
778
1618
|
const context: ApiContext<User> = { user: input.user, request: input.request };
|
|
779
1619
|
const mutator = opts.mutators?.[input.envelope.name];
|
|
1620
|
+
// PRE-FLIGHT rejections — no txn, no data, `lmid` alone (the queue must still drain).
|
|
780
1621
|
if (!mutator) return reject(backend, input.envelope, `unknown mutator: ${input.envelope.name}`);
|
|
781
|
-
let sql: Pick<SqlTxn, "statements" | "idempotencyKey">;
|
|
782
1622
|
try {
|
|
783
1623
|
await assertAuthorized(opts.authorizeMutation, {
|
|
784
1624
|
user: input.user,
|
|
785
1625
|
envelope: input.envelope,
|
|
786
1626
|
context,
|
|
787
1627
|
});
|
|
788
|
-
const tx = new CollectingSqlMutationTx();
|
|
789
|
-
const result = await mutator(tx, input.envelope.args as never, {
|
|
790
|
-
user: input.user,
|
|
791
|
-
envelope: input.envelope,
|
|
792
|
-
daemon: opts.daemon,
|
|
793
|
-
request: input.request,
|
|
794
|
-
});
|
|
795
|
-
sql = mutationResultToSqlTxn(result, tx);
|
|
796
1628
|
} catch (err) {
|
|
797
|
-
return reject(backend, input.envelope,
|
|
1629
|
+
return reject(backend, input.envelope, errMessage(err));
|
|
798
1630
|
}
|
|
799
|
-
|
|
1631
|
+
// Run the mutator INSIDE the backend's transaction. A throw from the mutator body is a business
|
|
1632
|
+
// rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
|
|
1633
|
+
const outcome = await backend.runMutation({
|
|
800
1634
|
envelope: input.envelope,
|
|
801
|
-
|
|
802
|
-
|
|
1635
|
+
render: renderIndex,
|
|
1636
|
+
run: async (tx) => {
|
|
1637
|
+
const result = await mutator(tx, input.envelope.args as never, {
|
|
1638
|
+
user: input.user,
|
|
1639
|
+
envelope: input.envelope,
|
|
1640
|
+
daemon: opts.daemon,
|
|
1641
|
+
request: input.request,
|
|
1642
|
+
});
|
|
1643
|
+
applyResultToTx(result, tx);
|
|
1644
|
+
},
|
|
803
1645
|
});
|
|
804
|
-
return { accepted: true, rejected: false, output };
|
|
1646
|
+
if (outcome.accepted) return { accepted: true, rejected: false, output: outcome.output };
|
|
1647
|
+
return { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
|
|
805
1648
|
};
|
|
806
1649
|
|
|
807
1650
|
const pushMutations = async (input: PushMutationsRequest<User>): Promise<PushMutationResponse[]> => {
|
|
@@ -859,13 +1702,18 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
859
1702
|
}
|
|
860
1703
|
};
|
|
861
1704
|
|
|
862
|
-
// The room write-authority gate (§5.3.1): endpoints are disabled until the app
|
|
863
|
-
//
|
|
1705
|
+
// The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
|
|
1706
|
+
// the `realtime` block (which also activates `/room-boot`) or the deprecated bare
|
|
1707
|
+
// `authorizeRoom` (trio only). Hosting an authority is never a default.
|
|
1708
|
+
const realtime = opts.realtime;
|
|
1709
|
+
const roomAuthorizer: Authorizer<ApiContext<User>> | undefined = realtime
|
|
1710
|
+
? (realtime.authorize ?? defaultFlushGate(realtime.shellSecret))
|
|
1711
|
+
: opts.authorizeRoom;
|
|
864
1712
|
const roomGate = async (context: ApiContext<User>): Promise<void> => {
|
|
865
|
-
if (!
|
|
1713
|
+
if (!roomAuthorizer) {
|
|
866
1714
|
throw new RindleApiError("forbidden", "room authority not configured", 403);
|
|
867
1715
|
}
|
|
868
|
-
await assertAuthorized(
|
|
1716
|
+
await assertAuthorized(roomAuthorizer, context);
|
|
869
1717
|
};
|
|
870
1718
|
|
|
871
1719
|
// The store's verdict rides specific statuses + body shapes (fence / conflict /
|
|
@@ -930,6 +1778,58 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
930
1778
|
return daemonVerdict(e);
|
|
931
1779
|
}
|
|
932
1780
|
},
|
|
1781
|
+
handleRoomBootJson: async (body, context) => {
|
|
1782
|
+
if (!realtime) {
|
|
1783
|
+
throw new RindleApiError("forbidden", "realtime not configured", 403);
|
|
1784
|
+
}
|
|
1785
|
+
await assertAuthorized(realtime.authorizeBoot ?? defaultBootGate(realtime.shellSecret), context);
|
|
1786
|
+
const msg = parseObject(body, "room-boot request");
|
|
1787
|
+
const doc = parseString(msg.doc, "doc");
|
|
1788
|
+
if (msg.instance !== undefined) parseString(msg.instance, "instance"); // diagnostic identity only
|
|
1789
|
+
const ast = queryResultToAst(await realtime.resolveFootprint(doc, context));
|
|
1790
|
+
const claim = opts.daemon.claimRoomEpoch?.bind(opts.daemon);
|
|
1791
|
+
if (!claim) {
|
|
1792
|
+
throw new Error("the configured daemon client does not implement claimRoomEpoch");
|
|
1793
|
+
}
|
|
1794
|
+
try {
|
|
1795
|
+
// Claim FIRST (§2.5): the lease below is minted for THIS placement, so a boot
|
|
1796
|
+
// that loses the epoch race learns it here, before any materialization exists.
|
|
1797
|
+
const { epoch } = await claim({ doc });
|
|
1798
|
+
const lease = await opts.daemon.materialize({
|
|
1799
|
+
ast,
|
|
1800
|
+
// The room's upstream leg IS the normalized protocol (§3) — never the app's
|
|
1801
|
+
// viewer `mode`.
|
|
1802
|
+
mode: "normalized",
|
|
1803
|
+
leaseTtlMs: realtime.upstreamLeaseTtlMs ?? opts.leaseTtlMs,
|
|
1804
|
+
});
|
|
1805
|
+
const headers = realtime.mintFlushHeaders
|
|
1806
|
+
? await realtime.mintFlushHeaders({ doc, epoch })
|
|
1807
|
+
: {
|
|
1808
|
+
[ROOM_FLUSH_CREDENTIAL_HEADER]: await mintRoomFlushCredential({
|
|
1809
|
+
shellSecret: realtime.shellSecret,
|
|
1810
|
+
doc,
|
|
1811
|
+
epoch,
|
|
1812
|
+
}),
|
|
1813
|
+
};
|
|
1814
|
+
const res: RoomBootResponse = {
|
|
1815
|
+
epoch,
|
|
1816
|
+
upstreamLeaseToken: lease.leaseToken,
|
|
1817
|
+
flush: {
|
|
1818
|
+
urls: {
|
|
1819
|
+
apply: routes.applyRowChangeTxn,
|
|
1820
|
+
claim: routes.claimRoomEpoch,
|
|
1821
|
+
lmids: routes.roomLmids,
|
|
1822
|
+
},
|
|
1823
|
+
headers,
|
|
1824
|
+
},
|
|
1825
|
+
};
|
|
1826
|
+
const upstreamWsEndpoint = realtime.upstreamWsEndpoint ?? lease.wsEndpoint;
|
|
1827
|
+
if (upstreamWsEndpoint !== undefined) res.upstreamWsEndpoint = upstreamWsEndpoint;
|
|
1828
|
+
return { status: 200, body: res };
|
|
1829
|
+
} catch (e) {
|
|
1830
|
+
return daemonVerdict(e);
|
|
1831
|
+
}
|
|
1832
|
+
},
|
|
933
1833
|
handleQueryJson: (body, context) => {
|
|
934
1834
|
const msg = parseObject(body, "query request");
|
|
935
1835
|
return createQueryLease({
|
|
@@ -965,24 +1865,65 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
965
1865
|
};
|
|
966
1866
|
}
|
|
967
1867
|
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1868
|
+
/** Run a SHARED generator mutator (the SAME body the client predicts) against a live server
|
|
1869
|
+
* transaction (MUTATORS-ISOMORPHIC): bind the tier-agnostic {@link isoTx} factory and drive it —
|
|
1870
|
+
* each yielded logical op renders + runs against `tx` (dialect SQL, per backend), each `tx.row`
|
|
1871
|
+
* suspends for read-your-writes, and `tx.all` fans out. A server mutator uses this to delegate its
|
|
1872
|
+
* write body after parsing untrusted args and applying its server-only authority (principal, policy).
|
|
1873
|
+
* A mutator-body throw remains a business rejection; a DB failure propagates as infra. */
|
|
1874
|
+
export function runSharedMutation<Args, Ctx extends MutatorCtx>(
|
|
1875
|
+
mutator: SharedMutator<Args, Ctx>,
|
|
1876
|
+
args: Args,
|
|
1877
|
+
ctx: Ctx,
|
|
1878
|
+
tx: ServerMutationTx,
|
|
1879
|
+
): Promise<void> {
|
|
1880
|
+
return driveMutationAsync(mutator(isoTx, args, ctx), {
|
|
1881
|
+
apply: (op) => applyOpToServerTx(tx, op),
|
|
1882
|
+
read: (table, pk) => tx.row(table, pk),
|
|
1883
|
+
query: async (q) => {
|
|
1884
|
+
// The shared-seam contract is ALWAYS an array of rows: the client's `WriteTxn.query` returns
|
|
1885
|
+
// an array even for a root `.one()` (the root unwrap is the Store's `materialize()`, not the
|
|
1886
|
+
// in-mutator read — rust/src/wasm/db.rs). The server's `tx.query` returns a scalar (object|null)
|
|
1887
|
+
// for a `.one()` root, so normalize it to `[]`/`[row]` here — one body sees the same shape on
|
|
1888
|
+
// both tiers. (Postgres backend: `tx.query` rejects until POSTGRES-READ-COMPILER-DESIGN.md
|
|
1889
|
+
// Phase B; the daemon/SQLite backend compiles + rides the open session — DAEMON §5.4.)
|
|
1890
|
+
const ast = q.ast();
|
|
1891
|
+
const res = await tx.query(ast);
|
|
1892
|
+
if (ast.one === true) return res == null ? [] : [res as QueryResultRow];
|
|
1893
|
+
return (res ?? []) as QueryResultRow[];
|
|
1894
|
+
},
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
974
1897
|
|
|
975
|
-
|
|
976
|
-
|
|
1898
|
+
/** Run one logical {@link MutationOp} (yielded by a shared generator mutator) against the live server
|
|
1899
|
+
* write surface — the SAME async methods a plain async mutator calls (they render dialect SQL and
|
|
1900
|
+
* execute/accumulate per backend). */
|
|
1901
|
+
function applyOpToServerTx(tx: ServerWriteTx, op: MutationOp): Promise<void> {
|
|
1902
|
+
switch (op.kind) {
|
|
1903
|
+
case "insert":
|
|
1904
|
+
return tx.insert(op.table, op.row);
|
|
1905
|
+
case "upsert":
|
|
1906
|
+
return tx.upsert(op.table, op.row);
|
|
1907
|
+
case "insertIgnore":
|
|
1908
|
+
return tx.insertIgnore(op.table, op.row);
|
|
1909
|
+
case "update":
|
|
1910
|
+
return tx.update(op.table, op.row);
|
|
1911
|
+
case "delete":
|
|
1912
|
+
return tx.delete(op.table, op.pk);
|
|
977
1913
|
}
|
|
978
1914
|
}
|
|
979
1915
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1916
|
+
/** Feed a mutator's RETURNED result (the alternative to calling `tx.exec`/logical ops directly) into
|
|
1917
|
+
* the backend tx: a returned `SqlStatement[]` / `SqlTxn` is exec'd onto `tx`, and a carried
|
|
1918
|
+
* `idempotencyKey` is stashed (the daemon backend honors it; PG ignores it). A `void` return is a
|
|
1919
|
+
* no-op — the mutator already drove the tx. Preserves the pre-existing return-style contract. */
|
|
1920
|
+
function applyResultToTx(result: ApiMutatorResult, tx: ServerMutationTx): void {
|
|
1921
|
+
if (!result) return;
|
|
1922
|
+
const statements = Array.isArray(result) ? result : result.statements;
|
|
1923
|
+
for (const s of statements) tx.exec(s.sql, s.params);
|
|
1924
|
+
if (!Array.isArray(result) && result.idempotencyKey !== undefined) {
|
|
1925
|
+
(tx as { idempotencyKey?: string }).idempotencyKey = result.idempotencyKey;
|
|
984
1926
|
}
|
|
985
|
-
return { statements: [...tx.statements] };
|
|
986
1927
|
}
|
|
987
1928
|
|
|
988
1929
|
async function assertAuthorized<T>(authorizer: Authorizer<T> | undefined, input: T): Promise<void> {
|
|
@@ -991,6 +1932,66 @@ async function assertAuthorized<T>(authorizer: Authorizer<T> | undefined, input:
|
|
|
991
1932
|
if (result === false) throw new RindleApiError("forbidden", "rindle request forbidden", 403);
|
|
992
1933
|
}
|
|
993
1934
|
|
|
1935
|
+
/** The default flush-trio gate: verify the default epoch-bound credential from the request
|
|
1936
|
+
* header. The two refusal messages are deliberately distinct — "missing" is a transport wiring
|
|
1937
|
+
* bug (no `context.request`), "refused" is an invalid credential. */
|
|
1938
|
+
function defaultFlushGate<User>(shellSecret: string): Authorizer<ApiContext<User>> {
|
|
1939
|
+
return async (context) => {
|
|
1940
|
+
const credential = requestHeader(context.request, ROOM_FLUSH_CREDENTIAL_HEADER);
|
|
1941
|
+
if (!credential) {
|
|
1942
|
+
throw new RindleApiError(
|
|
1943
|
+
"forbidden",
|
|
1944
|
+
`missing ${ROOM_FLUSH_CREDENTIAL_HEADER} header — is the transport passing its incoming request as context.request?`,
|
|
1945
|
+
403,
|
|
1946
|
+
);
|
|
1947
|
+
}
|
|
1948
|
+
try {
|
|
1949
|
+
await verifyRoomFlushCredential(credential, shellSecret);
|
|
1950
|
+
} catch (e) {
|
|
1951
|
+
throw new RindleApiError("forbidden", `flush credential refused: ${errMessage(e)}`, 403);
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
/** The default `/room-boot` gate: `Authorization: Bearer <shell secret>` (README contract),
|
|
1957
|
+
* compared constant-time. */
|
|
1958
|
+
function defaultBootGate<User>(shellSecret: string): Authorizer<ApiContext<User>> {
|
|
1959
|
+
return (context) => {
|
|
1960
|
+
const auth = requestHeader(context.request, "authorization");
|
|
1961
|
+
const bearer = auth && /^bearer\s/i.test(auth) ? auth.replace(/^bearer\s+/i, "") : undefined;
|
|
1962
|
+
if (!bearer || !timingSafeEqualStr(bearer, shellSecret)) {
|
|
1963
|
+
throw new RindleApiError("forbidden", "room-boot: shell secret refused", 403);
|
|
1964
|
+
}
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
/** Best-effort header extraction from whatever the transport put in `context.request`: a Fetch
|
|
1969
|
+
* `Request` (`headers.get`), a node `IncomingMessage` (lowercased header map), or a plain
|
|
1970
|
+
* `{headers: {...}}`. `undefined` when there is no request or no such header. */
|
|
1971
|
+
function requestHeader(request: unknown, name: string): string | undefined {
|
|
1972
|
+
if (!request || typeof request !== "object") return undefined;
|
|
1973
|
+
const headers = (request as { headers?: unknown }).headers;
|
|
1974
|
+
if (!headers || typeof headers !== "object") return undefined;
|
|
1975
|
+
if (typeof (headers as { get?: unknown }).get === "function") {
|
|
1976
|
+
return (headers as { get(n: string): string | null }).get(name) ?? undefined;
|
|
1977
|
+
}
|
|
1978
|
+
const map = headers as Record<string, unknown>;
|
|
1979
|
+
const v = map[name.toLowerCase()] ?? map[name];
|
|
1980
|
+
if (typeof v === "string") return v;
|
|
1981
|
+
if (Array.isArray(v) && typeof v[0] === "string") return v[0];
|
|
1982
|
+
return undefined;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
/** Constant-time string equality (a length mismatch fails fast — length is not the secret). */
|
|
1986
|
+
function timingSafeEqualStr(a: string, b: string): boolean {
|
|
1987
|
+
const ab = new TextEncoder().encode(a);
|
|
1988
|
+
const bb = new TextEncoder().encode(b);
|
|
1989
|
+
if (ab.length !== bb.length) return false;
|
|
1990
|
+
let diff = 0;
|
|
1991
|
+
for (let i = 0; i < ab.length; i++) diff |= ab[i] ^ bb[i];
|
|
1992
|
+
return diff === 0;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
994
1995
|
async function resolvePolicy<User>(
|
|
995
1996
|
policy: RindleApiServerOptions<User>["materializationPolicy"],
|
|
996
1997
|
input: QueryLeaseRequest<User>,
|