@rindle/api-server 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -60
- package/dist/index.d.ts +326 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +857 -24
- package/dist/index.js.map +1 -1
- package/dist/rooms.d.ts +194 -0
- package/dist/rooms.d.ts.map +1 -0
- package/dist/rooms.js +393 -0
- package/dist/rooms.js.map +1 -0
- package/package.json +6 -5
- package/src/index.ts +1216 -25
- package/src/rooms.ts +578 -0
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { driveMutationAsync, insertCell, insertPlan, isoTx, toCell } from "@rindle/client";
|
|
1
|
+
import { driveMutationAsync, insertCell, insertPlan, isGeneratorMutator, isoTx, toCell } from "@rindle/client";
|
|
2
2
|
import type {
|
|
3
3
|
Ast,
|
|
4
4
|
ColType,
|
|
@@ -21,6 +21,8 @@ import type { Catalog, ColumnType as QueryColumnType, TableSchema } from "@rindl
|
|
|
21
21
|
import type {
|
|
22
22
|
ClaimRoomEpochInput,
|
|
23
23
|
ClaimRoomEpochOutput,
|
|
24
|
+
CoverQueryInput,
|
|
25
|
+
CoverQueryOutput,
|
|
24
26
|
DematerializeInput,
|
|
25
27
|
DematerializeOutput,
|
|
26
28
|
MaterializationPolicy,
|
|
@@ -51,12 +53,50 @@ import type {
|
|
|
51
53
|
WireValue,
|
|
52
54
|
} from "@rindle/daemon-client";
|
|
53
55
|
|
|
56
|
+
import {
|
|
57
|
+
assertLabeledProfilesExist,
|
|
58
|
+
assertUnwindowedFootprint,
|
|
59
|
+
attachRealtimeLabel,
|
|
60
|
+
compileRoomProfiles,
|
|
61
|
+
compileRoomScopeSpecs,
|
|
62
|
+
compileRoomTableSpecs,
|
|
63
|
+
mintRoomDoc,
|
|
64
|
+
queryRealtimeLabel,
|
|
65
|
+
queryResultToAst,
|
|
66
|
+
splitRoomDoc,
|
|
67
|
+
} from "./rooms.ts";
|
|
68
|
+
import type { RoomProfile, RoomScopeSpec, RoomTableSpec } from "./rooms.ts";
|
|
69
|
+
// The room lease token (RINDLE-REALTIME §10.1): minted here, verified by the room SHELL against
|
|
70
|
+
// its `downstream.tokenKeys` ring — the `/token` subpath is pure WebCrypto (no wasm, no shell).
|
|
71
|
+
// Loaded LAZILY at the first mint: `@rindle/room` is a dev-only workspace dep (it is not published
|
|
72
|
+
// to npm yet — infra/tests/publish-sync.mjs would flag a runtime dep on it), and the mint only
|
|
73
|
+
// runs when `realtime.roomTokenKey` is configured. In-repo (workspace linking) the import always
|
|
74
|
+
// resolves; an npm consumer without the package gets the serve decision's ordinary fail-open —
|
|
75
|
+
// daemon-served leases plus a one-time warning naming the missing module.
|
|
76
|
+
import type { mintRoomToken as MintRoomToken, scopeSpecsHash as ScopeSpecsHash } from "@rindle/room/token";
|
|
77
|
+
let roomTokenModule: { mintRoomToken: typeof MintRoomToken; scopeSpecsHash: typeof ScopeSpecsHash } | undefined;
|
|
78
|
+
async function loadRoomTokenModule(): Promise<{ mintRoomToken: typeof MintRoomToken; scopeSpecsHash: typeof ScopeSpecsHash }> {
|
|
79
|
+
if (roomTokenModule === undefined) {
|
|
80
|
+
const m = await import("@rindle/room/token");
|
|
81
|
+
roomTokenModule = { mintRoomToken: m.mintRoomToken, scopeSpecsHash: m.scopeSpecsHash };
|
|
82
|
+
}
|
|
83
|
+
return roomTokenModule;
|
|
84
|
+
}
|
|
85
|
+
|
|
54
86
|
// Re-export the shared (generator) mutator seam so an app builds its server mutators from ONE import:
|
|
55
87
|
// co-locate each body with its arg schema (`shared`), bulk-drive the registry ({@link sharedApiMutators}),
|
|
56
88
|
// keeping only server-only authority as explicit overrides (see MUTATORS-ISOMORPHIC).
|
|
57
89
|
export { isoTx, shared } from "@rindle/client";
|
|
58
90
|
export type { ArgSchema, IsoTx, MutationGen, MutatorCtx, SharedMutator, SharedMutatorWithArgs } from "@rindle/client";
|
|
59
91
|
|
|
92
|
+
// The room-profile declaration layer (RINDLE-REALTIME-QUERY-ENABLEMENT §2, slice G-iv-a). The
|
|
93
|
+
// compiled-profile shapes stay internal to `./rooms.ts` — G-iv-b consumes them in-package.
|
|
94
|
+
// `RoomTableSpec` (G-iv-b) is public: it rides the lease wire (`QueryLeaseResponse.realtime`).
|
|
95
|
+
// `RoomScopeSpec` (H-iv-b) is public: it rides the boot wire (`RoomBootResponse.scopes`).
|
|
96
|
+
export { queryRealtimeLabel, queryResultToAst } from "./rooms.ts";
|
|
97
|
+
export type { RoomProfile, RoomScopeSpec, RoomTableSpec } from "./rooms.ts";
|
|
98
|
+
export type { RealtimeQueryLabel } from "@rindle/client";
|
|
99
|
+
|
|
60
100
|
export const DEFAULT_RINDLE_API_ROUTES = {
|
|
61
101
|
query: "/api/rindle/query",
|
|
62
102
|
read: "/api/rindle/read",
|
|
@@ -149,6 +189,103 @@ export type ApiMutator<User, Args> = (
|
|
|
149
189
|
args: Args,
|
|
150
190
|
ctx: MutationContext<User>,
|
|
151
191
|
) => MaybePromise<ApiMutatorResult>;
|
|
192
|
+
|
|
193
|
+
// --------------------------------------------------------------------------- scoped (outside-tx) mutators
|
|
194
|
+
//
|
|
195
|
+
// The tx-form {@link ApiMutator} above runs ENTIRELY inside the transaction. A SCOPED mutator
|
|
196
|
+
// (WORK-OUTSIDE-TX) instead controls the boundary itself: it receives a {@link MutationScope}, runs
|
|
197
|
+
// server-only code BEFORE opening the one atomic transaction (`scope.transact`), and MAY run code
|
|
198
|
+
// AFTER it commits. The outside-tx code is server-only by nature (the client's optimistic prediction
|
|
199
|
+
// can't call Stripe), so it lives HERE, never in the isomorphic body — the shared generator stays
|
|
200
|
+
// pure and identical on both tiers; server-computed values flow into it through `ctx`, exactly like
|
|
201
|
+
// `ctx.user` (undefined/predicted on the client, authoritative here).
|
|
202
|
+
|
|
203
|
+
/** Thrown by {@link MutationScope.transact} when the transacted body BUSINESS-rejects: the data
|
|
204
|
+
* rolled back and `lmid` advanced alone (§2.4). Catch it to COMPENSATE an outside-tx side effect
|
|
205
|
+
* (refund the charge), then rethrow or return — the mutation's protocol outcome is already sealed
|
|
206
|
+
* as rejected, so a post-reject throw can't change it. A DB/infra failure is NOT this — it
|
|
207
|
+
* propagates as the raw driver error (the client retries; `lmid` did not advance). */
|
|
208
|
+
export class MutationRejected extends Error {
|
|
209
|
+
readonly reason: string;
|
|
210
|
+
constructor(reason: string) {
|
|
211
|
+
super(reason);
|
|
212
|
+
this.name = "MutationRejected";
|
|
213
|
+
this.reason = reason;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The per-mutation server handle a {@link ScopedMutator} runs against. Code before {@link transact}
|
|
218
|
+
* runs OUTSIDE the transaction; code after a clean `transact` runs AFTER the commit. The
|
|
219
|
+
* `lmid`-always-advances invariant is the HARNESS's, not the author's: {@link RindleApiServer.pushMutation}
|
|
220
|
+
* seals the response from this handle's recorded outcome, so an early return, a never-called
|
|
221
|
+
* `transact`, or a swallowed {@link MutationRejected} still advances `lmid` and never wedges the
|
|
222
|
+
* client's pending queue. */
|
|
223
|
+
export interface MutationScope {
|
|
224
|
+
/** Open the ONE atomic write transaction and drive `body` inside it, committing (stamping `lmid`
|
|
225
|
+
* co-transactionally) on a clean return. MAY be called at most once — a second call throws.
|
|
226
|
+
*
|
|
227
|
+
* Two forms:
|
|
228
|
+
* - `transact(sharedMutator, args, ctx)` — drive a SHARED (generator) mutator (the same body the
|
|
229
|
+
* client predicts); pass the already-parsed `args` and the server `ctx` (fold server-only
|
|
230
|
+
* values like a charge id into `ctx` here).
|
|
231
|
+
* - `transact(run)` — a raw callback receiving the live {@link ServerMutationTx} (the escape
|
|
232
|
+
* hatch: `tx.exec`, logical writes, read-your-writes reads).
|
|
233
|
+
*
|
|
234
|
+
* A THROW from the body that is not a {@link BackendError} is a BUSINESS rejection: the data rolls
|
|
235
|
+
* back, `lmid` advances alone, and this method throws {@link MutationRejected} (so surrounding
|
|
236
|
+
* code can compensate). A {@link BackendError} is INFRA: it propagates (the client retries). */
|
|
237
|
+
transact(run: (tx: ServerMutationTx) => void | Promise<void>): Promise<void>;
|
|
238
|
+
transact<A, C extends MutatorCtx>(mutator: SharedMutator<A, C>, args: A, ctx: C): Promise<void>;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A SCOPED server mutator (WORK-OUTSIDE-TX): server-only code, ONE `scope.transact`, optional
|
|
242
|
+
* post-commit code. Register it by wrapping in {@link scoped} — the tag the api-server routes on to
|
|
243
|
+
* hand it a {@link MutationScope} instead of running its whole body inside the transaction. */
|
|
244
|
+
export type ScopedMutator<User, Args> = (
|
|
245
|
+
scope: MutationScope,
|
|
246
|
+
args: Args,
|
|
247
|
+
ctx: MutationContext<User>,
|
|
248
|
+
) => void | Promise<void>;
|
|
249
|
+
|
|
250
|
+
/** A {@link ScopedMutator} tagged by {@link scoped} so the harness invokes it with a
|
|
251
|
+
* {@link MutationScope}. Typed as a BRANDED tx-form {@link ApiMutator} purely so it registers in the
|
|
252
|
+
* `mutators` record without widening it to a union (which would break contextual inference for every
|
|
253
|
+
* plain tx-form entry). Its true runtime shape is `(scope, args, ctx)`; the tag — not the type —
|
|
254
|
+
* routes it, and it is never actually called as a tx-form mutator. */
|
|
255
|
+
export type ScopedApiMutator<User, Args> = ApiMutator<User, Args> & { readonly __rindleScoped: true };
|
|
256
|
+
|
|
257
|
+
/** Mark a mutator as SCOPED so the api-server gives it a {@link MutationScope} (author-controlled tx
|
|
258
|
+
* boundary via `scope.transact`) rather than running its whole body inside the transaction. Register
|
|
259
|
+
* it alongside the tx-form mutators — it wins by key like any override:
|
|
260
|
+
*
|
|
261
|
+
* ```ts
|
|
262
|
+
* mutators: defineApiMutators({
|
|
263
|
+
* ...sharedApiMutators(sharedMutators, sharedCtx), // tx-form (common case)
|
|
264
|
+
* createOrder: scoped(async (scope, raw, ctx) => { // needs outside-tx work
|
|
265
|
+
* const args = createOrder.args.parse(raw);
|
|
266
|
+
* const chargeId = await stripe.charge(args.amount, { idempotencyKey: ctx.envelope.mid }); // outside tx
|
|
267
|
+
* try {
|
|
268
|
+
* await scope.transact(createOrder, args, { ...sharedCtx(ctx), chargeId }); // inside tx
|
|
269
|
+
* } catch (e) {
|
|
270
|
+
* await stripe.refund(chargeId); // compensate — the write rejected
|
|
271
|
+
* throw e;
|
|
272
|
+
* }
|
|
273
|
+
* await sendReceipt(ctx.user); // after commit
|
|
274
|
+
* }),
|
|
275
|
+
* }),
|
|
276
|
+
* ```
|
|
277
|
+
*/
|
|
278
|
+
export function scoped<User, Args>(fn: ScopedMutator<User, Args>): ScopedApiMutator<User, Args> {
|
|
279
|
+
// The brand carries the scoped runtime shape; typing the RETURN as the (branded) tx-form keeps it
|
|
280
|
+
// assignable into `mutators` WITHOUT unioning that record — the harness routes on the brand and
|
|
281
|
+
// never calls it as a tx-form mutator, so the cast is sound.
|
|
282
|
+
return Object.assign(fn, { __rindleScoped: true as const }) as unknown as ScopedApiMutator<User, Args>;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isScoped<User>(m: ApiMutator<User, any>): m is ScopedApiMutator<User, any> {
|
|
286
|
+
return (m as { __rindleScoped?: boolean }).__rindleScoped === true;
|
|
287
|
+
}
|
|
288
|
+
|
|
152
289
|
export type ApiMutators<User> = Record<string, ApiMutator<User, any>>;
|
|
153
290
|
|
|
154
291
|
export interface QueryLeaseRequest<User> {
|
|
@@ -162,6 +299,95 @@ export interface QueryLeaseRequest<User> {
|
|
|
162
299
|
clientId?: string;
|
|
163
300
|
}
|
|
164
301
|
|
|
302
|
+
/**
|
|
303
|
+
* The room-serve block on a query lease (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1 step 5 / §2.4,
|
|
304
|
+
* slice G-iv-b): present only when the named query carries a realtime label AND its resolved AST
|
|
305
|
+
* was PROVABLY covered by its room profile's footprint (the daemon's `/cover-check`). The G-v
|
|
306
|
+
* client uses it to open the room transport for THIS query beside — never instead of — its daemon
|
|
307
|
+
* session.
|
|
308
|
+
*
|
|
309
|
+
* It is a NEW dedicated block on purpose: the TOP-LEVEL `wsEndpoint` is the read-router's
|
|
310
|
+
* whole-session migration signal (`packages/remote/src/optimistic-source.ts` — a lease whose
|
|
311
|
+
* `wsEndpoint` differs migrates the client's ENTIRE ws session), so the room endpoint must never
|
|
312
|
+
* ride that field. A room-served lease's top-level fields are byte-identical to the daemon-served
|
|
313
|
+
* ones.
|
|
314
|
+
*/
|
|
315
|
+
export interface QueryLeaseRealtime {
|
|
316
|
+
/** The client store's gate/domain key for this room source (`connectSource`) AND the string the
|
|
317
|
+
* wasm engine's `parse_source_key` accepts: any string other than the reserved `"daemon"`
|
|
318
|
+
* parses as a room source, and the established convention is `"room:" + doc`
|
|
319
|
+
* (e.g. `room:document/doc:d1`). */
|
|
320
|
+
sourceKey: string;
|
|
321
|
+
/** Where the client opens the ROOM ws for this query (from `realtime.locateRoom`) — a separate
|
|
322
|
+
* field from the top-level `wsEndpoint` (see above: no whole-session migration). */
|
|
323
|
+
wsEndpoint: string;
|
|
324
|
+
/** The room's self-authorizing signed lease (`@rindle/room/token`): the APPROVED query AST +
|
|
325
|
+
* doc + subject, HMAC-signed with `realtime.roomTokenKey` so the room shell's
|
|
326
|
+
* `downstream.tokenKeys` ring verifies it. The room materializes on first presentation. */
|
|
327
|
+
roomToken: string;
|
|
328
|
+
/** Token expiry (ms epoch) — the client's renewal clock (renewal = a fresh lease). */
|
|
329
|
+
exp: number;
|
|
330
|
+
/** The wire room doc (`"<profile>/<key>"`, minted server-side — never client-derived). */
|
|
331
|
+
doc: string;
|
|
332
|
+
/** Per-footprint-table specs: the §2.2 owned/followed split + §3.2 routing metadata. Since
|
|
333
|
+
* H-iii each spec also carries `footprintWhere` — the same exact membership predicate the boot
|
|
334
|
+
* wire ships the room gate (one compiler, `compileRoomScopeSpecs`) — feeding the client's §3
|
|
335
|
+
* prove-or-slow-path router. Advisory routing metadata, never a credential (§3.2). */
|
|
336
|
+
tables: RoomTableSpec[];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* One minted SYSTEM-STREAM lease on a query lease's `lifecycle` block (RINDLE-REALTIME-QUERY-
|
|
341
|
+
* ENABLEMENT §4, Slice I-iii): an ordinary daemon materialization over one of the four
|
|
342
|
+
* `_rindle_*` lifecycle system tables (registered by the daemon's `enable_realtime_lifecycle` —
|
|
343
|
+
* `rust/rindle-replica/src/mutations.rs`), attachable by the EXISTING client subscribe path
|
|
344
|
+
* (present `leaseToken` on a `subscribe` frame, exactly like the primary lease). The identity
|
|
345
|
+
* fields (`scope`/`doc`/`clientId`) document the minted AST's predicate — the client keys its
|
|
346
|
+
* retains and its release-time row filters on them.
|
|
347
|
+
*/
|
|
348
|
+
export interface QueryLeaseLifecycleLease {
|
|
349
|
+
/** Which system table this lease's subscription serves. */
|
|
350
|
+
table: string;
|
|
351
|
+
leaseToken: string;
|
|
352
|
+
/** The follower this system materialization lives on (routed deploys; mirrors the top-level
|
|
353
|
+
* `wsEndpoint` semantics). Absent ⇒ single-daemon. */
|
|
354
|
+
wsEndpoint?: string;
|
|
355
|
+
/** DOORBELL only: the §4.1 occupancy scope — the wire room doc (`"<profile>/<key>"`). */
|
|
356
|
+
scope?: string;
|
|
357
|
+
/** FENCE entries only: the room doc the predicate is scoped to. */
|
|
358
|
+
doc?: string;
|
|
359
|
+
/** FENCE ledger/outcome entries: present iff the predicate was ALSO client-scoped (the lease
|
|
360
|
+
* request carried `clientId`). Absent ⇒ doc-only predicate — the client filters to its own
|
|
361
|
+
* rows regardless (defense in depth). */
|
|
362
|
+
clientId?: string;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The §4 lifecycle block on a query lease (Slice I-iii): present only when BOTH the realtime
|
|
366
|
+
* `lifecycle` config is on AND the query is realtime-labeled. `doorbell` rides EVERY labeled
|
|
367
|
+
* lease (occupancy is counted whether or not the query is room-served — the 1→2 upgrade trigger
|
|
368
|
+
* needs solo watchers subscribed BEFORE any room exists, §4.1); `fence` rides only a ROOM-SERVED
|
|
369
|
+
* lease (the §4.2/§7.1/§3.3 downgrade surfaces are meaningful only where a room domain exists).
|
|
370
|
+
* The §4.2 fence VALUE (`finalFlushSeq`) is deliberately NOT here — it arrives with the I-v
|
|
371
|
+
* downgrade response; I-iii only stands up the streams. */
|
|
372
|
+
export interface QueryLeaseLifecycle {
|
|
373
|
+
doorbell: QueryLeaseLifecycleLease;
|
|
374
|
+
fence?: QueryLeaseLifecycleLease[];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The §4.2 downgrade fence on a query lease (Slice I-v): rides a labeled reply whose §4.1
|
|
378
|
+
* occupancy gate CLOSED (so there is NO `realtime` block) when the server could drain the room.
|
|
379
|
+
* A SIBLING of `realtime`, never nested inside it — block-ABSENCE is the downgrade signal, and
|
|
380
|
+
* the fence rides alongside that absence. Its `finalFlushSeq` is the room's last COMMITTED flush
|
|
381
|
+
* seq; the client's frozen room ghost holds visible until the daemon plane has provably absorbed
|
|
382
|
+
* it (`_rindle_room_watermark(doc) ≥ finalFlushSeq`). Absent from every non-downgrade reply. */
|
|
383
|
+
export interface QueryLeaseRealtimeFence {
|
|
384
|
+
/** The retiring room source's gate/domain key — `"room:" + doc`, matching what the room-served
|
|
385
|
+
* lease's {@link QueryLeaseRealtime.sourceKey} carried. */
|
|
386
|
+
sourceKey: string;
|
|
387
|
+
doc: string;
|
|
388
|
+
finalFlushSeq: number;
|
|
389
|
+
}
|
|
390
|
+
|
|
165
391
|
export interface QueryLeaseResponse {
|
|
166
392
|
leaseToken: string;
|
|
167
393
|
materializationId: string;
|
|
@@ -170,6 +396,17 @@ export interface QueryLeaseResponse {
|
|
|
170
396
|
/** The follower this lease lives on (READ-ROUTER-DESIGN.md §2.3) — the browser opens its
|
|
171
397
|
* subscription ws here. Absent ⇒ single-daemon (the client uses its static `wsUrl`). */
|
|
172
398
|
wsEndpoint?: string;
|
|
399
|
+
/** The room-serve block (G-iv-b) — see {@link QueryLeaseRealtime}. Absent ⇒ the lease is
|
|
400
|
+
* byte-identical to the legacy daemon-served shape. */
|
|
401
|
+
realtime?: QueryLeaseRealtime;
|
|
402
|
+
/** The §4.2 downgrade fence (Slice I-v) — see {@link QueryLeaseRealtimeFence}. Present only on a
|
|
403
|
+
* labeled reply whose occupancy gate closed AND `realtime.lifecycle.drainRoom` could drain the
|
|
404
|
+
* room; absent otherwise (including on every room-served reply). */
|
|
405
|
+
realtimeFence?: QueryLeaseRealtimeFence;
|
|
406
|
+
/** The §4 lifecycle system-stream block (Slice I-iii) — see {@link QueryLeaseLifecycle}.
|
|
407
|
+
* Minted ONLY under the opt-in `realtime.lifecycle` config; absent ⇒ byte-identical to the
|
|
408
|
+
* pre-lifecycle response. */
|
|
409
|
+
lifecycle?: QueryLeaseLifecycle;
|
|
173
410
|
}
|
|
174
411
|
|
|
175
412
|
/** A one-shot SSR read of a named query (SSR-DESIGN.md §6): same `(name, args)` surface as a lease,
|
|
@@ -311,6 +548,12 @@ export interface RoomBootResponse {
|
|
|
311
548
|
/** Where the room opens its upstream subscription (a routed deploy's follower). Absent ⇒ the
|
|
312
549
|
* shell's statically configured rindled ws endpoint. */
|
|
313
550
|
upstreamWsEndpoint?: string;
|
|
551
|
+
/** Per-footprint-table scope specs (H-iv-b), compiled from the resolved footprint AST + the
|
|
552
|
+
* profile's context set (the legacy anonymous profile compiles with an empty context set):
|
|
553
|
+
* what the shell hands the wasm room's `enableWritesV2` — the §3.3 commit gate. Optional
|
|
554
|
+
* only for wire compatibility with pre-H-iv-b servers; a shell that doesn't receive them
|
|
555
|
+
* enables the v1 table-granular write plane exactly as before. */
|
|
556
|
+
scopes?: RoomScopeSpec[];
|
|
314
557
|
flush: RoomBootFlush;
|
|
315
558
|
}
|
|
316
559
|
|
|
@@ -319,11 +562,22 @@ export interface RindleRealtimeOptions<User> {
|
|
|
319
562
|
* room's `Authorization: Bearer` on `/room-boot` (the default {@link authorizeBoot}) and keys
|
|
320
563
|
* the DEFAULT epoch-bound flush credential. */
|
|
321
564
|
shellSecret: string;
|
|
565
|
+
/** NAMED room profiles (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §2.1): profile name → key
|
|
566
|
+
* derivation + canonical unwindowed footprint + read-only context tables. The wire room key
|
|
567
|
+
* for a named profile is `"<profile>/<key>"`; `/room-boot` splits it and resolves the
|
|
568
|
+
* profile's footprint with the bare key. Compiled + validated LOUDLY at construction (§2.3):
|
|
569
|
+
* a windowed footprint, a context table missing from the schema/footprint, or a registered
|
|
570
|
+
* query whose realtime label names a missing profile all throw from `createRindleApiServer`. */
|
|
571
|
+
rooms?: Record<string, RoomProfile<User>>;
|
|
322
572
|
/** doc → the room's approved upstream footprint (§3.1) — an `Ast` or fluent `Query`. MAY
|
|
323
573
|
* delegate to the named-query registry internally; throw `RindleApiError("not-found", …, 404)`
|
|
324
574
|
* for a doc that shouldn't exist. The §9 footprint budget belongs here — it runs once per
|
|
325
|
-
* placement, at lease mint.
|
|
326
|
-
|
|
575
|
+
* placement, at lease mint.
|
|
576
|
+
* @deprecated Prefer named {@link rooms} profiles (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1).
|
|
577
|
+
* This bare form remains as the single-profile LEGACY alias — the anonymous/default profile:
|
|
578
|
+
* a doc with no known `"<profile>/"` prefix resolves here, byte-identically to before named
|
|
579
|
+
* profiles existed (and with none of their construction/boot-time validation). */
|
|
580
|
+
resolveFootprint?: (doc: string, ctx: ApiContext<User>) => MaybePromise<ApiQueryResult>;
|
|
327
581
|
/** Gates the flush trio — `authorizeRoom`, relocated. Default: verify the default flush
|
|
328
582
|
* credential from the {@link ROOM_FLUSH_CREDENTIAL_HEADER} request header — which requires the
|
|
329
583
|
* transport to pass its incoming request as `context.request` (Fetch `Request` and node
|
|
@@ -342,6 +596,72 @@ export interface RindleRealtimeOptions<User> {
|
|
|
342
596
|
/** Static override for where rooms open their upstream subscription; default is the
|
|
343
597
|
* `wsEndpoint` the materialize returns (set in routed deploys), else absent. */
|
|
344
598
|
upstreamWsEndpoint?: string;
|
|
599
|
+
/** Locate (or place) the room serving `doc` and return the ROOM ws endpoint a room-served
|
|
600
|
+
* lease's client should open (G-iv-b; on the DO shell this is the Worker's room URL). The
|
|
601
|
+
* endpoint rides the lease's dedicated `realtime.wsEndpoint` — NEVER the top-level
|
|
602
|
+
* `wsEndpoint` (that field migrates the client's whole daemon session). Absent ⇒ room-serving
|
|
603
|
+
* is OFF: labeled queries serve from the daemon exactly as today (fail-open). */
|
|
604
|
+
locateRoom?: (doc: string) => MaybePromise<{ wsEndpoint: string }>;
|
|
605
|
+
/** The room lease token signing key (`@rindle/room/token`): `kid` + secret, matching an entry
|
|
606
|
+
* in the room shell's `downstream.tokenKeys` ring. Required for room-serving (without it a
|
|
607
|
+
* labeled query fail-opens to the daemon with a one-time warning). A separate secret from
|
|
608
|
+
* `shellSecret` on purpose — the shell's ring is the client-token trust domain, the shell
|
|
609
|
+
* secret is the boot/flush trust domain. */
|
|
610
|
+
roomTokenKey?: { kid: string; secret: string };
|
|
611
|
+
/** Room lease token TTL, ms (default 5 minutes — the §4.1 short-TTL backstop; renewal is a
|
|
612
|
+
* fresh lease through this server, never an extension). */
|
|
613
|
+
roomTokenTtlMs?: number;
|
|
614
|
+
/** Loud-diagnostics sink for the realtime layer (profile compilation warnings + the one-time
|
|
615
|
+
* per-(query, profile) "not room-served" serve-decision warnings). Defaults to
|
|
616
|
+
* `console.warn`; injectable for tests. */
|
|
617
|
+
warn?: (message: string) => void;
|
|
618
|
+
/** The §4 upgrade/downgrade lifecycle plane (RINDLE-REALTIME-QUERY-ENABLEMENT §4, Slice
|
|
619
|
+
* I-iii): PRESENCE of this block is the opt-in — every realtime-labeled lease then
|
|
620
|
+
* additionally mints the doorbell system lease (occupancy, §4.1) and every ROOM-SERVED lease
|
|
621
|
+
* the fence bundle (watermark + ledger + outcomes, §4.2/§7.1/§3.3) — see
|
|
622
|
+
* {@link QueryLeaseLifecycle}. Requires the daemon to have run `enable_realtime_lifecycle`
|
|
623
|
+
* (the four `_rindle_*` system tables must be registered or the minted materializations fail
|
|
624
|
+
* — which fail-opens with a one-time warning, never blocking the lease). Absent ⇒ the lease
|
|
625
|
+
* response is byte-identical to pre-lifecycle. */
|
|
626
|
+
lifecycle?: RindleRealtimeLifecycleOptions;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** {@link RindleRealtimeOptions.lifecycle}. PRESENCE of the block is the opt-in switch (I-iii);
|
|
630
|
+
* the fields below are the Slice I-iv occupancy knobs (§4.1, decisions D4/D6/D7). All optional —
|
|
631
|
+
* `lifecycle: {}` gets the designed defaults. */
|
|
632
|
+
export interface RindleRealtimeLifecycleOptions {
|
|
633
|
+
/** D6 (§4.1): the occupancy threshold for room-serving. A labeled lease whose scope counts
|
|
634
|
+
* FEWER than this many distinct unexpired sessions (the caller's own included) ships WITHOUT
|
|
635
|
+
* the realtime block — served from the daemon, indistinguishable from an uncovered query —
|
|
636
|
+
* but WITH the doorbell, so the 1→2 transition wakes it (that is the point: solo docs never
|
|
637
|
+
* cost room infrastructure). Default **2** (the design's 1→2 trigger). Set `1` to room-serve
|
|
638
|
+
* solo viewers (the pre-I-iv behavior under lifecycle config). */
|
|
639
|
+
minSessions?: number;
|
|
640
|
+
/** The §9.1 hysteresis window, ms (default **120_000**). Two consumers: (a) the lazy sweep
|
|
641
|
+
* (D4) keeps expired session rows lingering at least this long past expiry — Slice I-v's
|
|
642
|
+
* downgrade decision ("no other unexpired row AND the newest other row expired > graceMs
|
|
643
|
+
* ago") is read FROM those rows, so they must survive to be read; (b) this slice's gate
|
|
644
|
+
* applies the same hysteresis upward: a scope with an other-session row expired ≤ graceMs
|
|
645
|
+
* ago keeps room-serving through the window (see `lifecycleOccupancy` — no flap on one
|
|
646
|
+
* client's brief lapse). §9.1-tunable: raise it for docs where collaborators churn slowly. */
|
|
647
|
+
graceMs?: number;
|
|
648
|
+
/** TTL of an occupancy session row, ms — `expires_at = now + sessionTtlMs` on every labeled
|
|
649
|
+
* lease mint/renewal (D7: session identity = the request's `clientId`; two tabs are two
|
|
650
|
+
* sessions iff their clientIds differ). Default = the server's `leaseTtlMs`, else 5 minutes —
|
|
651
|
+
* matching the room-token renewal cadence (`roomTokenTtlMs`, renewed 30s early), so a
|
|
652
|
+
* room-attached client's renewals keep its row unexpired; a daemon-attached solo client's row
|
|
653
|
+
* MAY lapse (it has no renewal timer) and is refreshed by its next doorbell-triggered
|
|
654
|
+
* re-lease — occupancy converges through the doorbell itself. */
|
|
655
|
+
sessionTtlMs?: number;
|
|
656
|
+
/** The §4.2 downgrade drain hook (Slice I-v). When the occupancy gate CLOSES for a labeled
|
|
657
|
+
* lease whose scope PLAUSIBLY hosted a room (an other-session row still lingers — never a
|
|
658
|
+
* never-shared solo doc), the api-server calls this to drain the room's pending write-behind
|
|
659
|
+
* and learn its last COMMITTED `flush_seq`, then rides the value back on the lease as the
|
|
660
|
+
* {@link QueryLeaseRealtimeFence}. The deployment wires it to the room shell's / DO's `/drain`
|
|
661
|
+
* control. Absent ⇒ no fence is attached (the client hits its loud legacy downgrade path);
|
|
662
|
+
* a throw fails OPEN to the same (a downgrade never blocks the lease). Concurrent drains across
|
|
663
|
+
* api-server instances are fine — `/drain` is idempotent. */
|
|
664
|
+
drainRoom?: (doc: string) => Promise<{ finalFlushSeq: number }>;
|
|
345
665
|
}
|
|
346
666
|
|
|
347
667
|
// The DEFAULT flush credential: `rfc1.<b64url payload>.<b64url hmac-sha256>`, payload
|
|
@@ -510,6 +830,12 @@ export interface RindleApiServerOptions<User> {
|
|
|
510
830
|
/** The user context pins resolve under (pins are shared, so they should not depend on a
|
|
511
831
|
* per-viewer identity). Defaults to `undefined`. */
|
|
512
832
|
pinUser?: User;
|
|
833
|
+
/** Surfaced when a SCOPED mutator ({@link scoped}) throws from code that runs AFTER `scope.transact`
|
|
834
|
+
* has already sealed the protocol outcome — a post-commit effect, or a compensation handler running
|
|
835
|
+
* after a business rejection. The outcome is fixed (this callback CANNOT change the client's
|
|
836
|
+
* response or the `lmid` advance), but the throw must not vanish: a failed refund is real money.
|
|
837
|
+
* Absent ⇒ the error is logged to `console.error`. */
|
|
838
|
+
onScopeError?: (err: unknown, info: { phase: "committed" | "rejected"; envelope: MutationEnvelope }) => void;
|
|
513
839
|
}
|
|
514
840
|
|
|
515
841
|
export interface RindleApiServer<User> {
|
|
@@ -520,6 +846,18 @@ export interface RindleApiServer<User> {
|
|
|
520
846
|
* startup and whenever the daemon restarts (e.g. from the daemon-client `onBootId` hook), since
|
|
521
847
|
* the daemon holds no durable materialization state. No-op when `pinnedQueries` is empty. */
|
|
522
848
|
assertPins(): Promise<void>;
|
|
849
|
+
/** The room-serving coverage DIAGNOSTIC (G-iv-b; the `assertPins`-style explicit check): for
|
|
850
|
+
* every realtime-labeled query, resolve it (under `pinUser`, per exemplar args), resolve its
|
|
851
|
+
* profile's footprint, and run the REAL daemon coverage check — the same verdict the lease
|
|
852
|
+
* path serves by. Returns every verdict; `strict` throws when any labeled query is not
|
|
853
|
+
* provably covered (deploy-gate mode). Deliberately ignores `locateRoom`/`roomTokenKey` — it
|
|
854
|
+
* answers "would this query be coverable", not "is serving fully wired". */
|
|
855
|
+
validateRealtime(opts?: {
|
|
856
|
+
/** Per-query exemplar args (a query is checked once per exemplar; default: one `null`). */
|
|
857
|
+
exemplars?: Partial<Record<string, readonly unknown[]>>;
|
|
858
|
+
/** Throw when any labeled query is uncovered (instead of just reporting). */
|
|
859
|
+
strict?: boolean;
|
|
860
|
+
}): Promise<ValidateRealtimeReport>;
|
|
523
861
|
pushMutation(input: PushMutationRequest<User>): Promise<PushMutationResponse>;
|
|
524
862
|
/** Apply an in-order batch (the client mutation queue's flush). Envelopes run strictly
|
|
525
863
|
* sequentially; a rejection still advances the daemon's lmid, so later envelopes in the
|
|
@@ -558,6 +896,24 @@ export interface RindleApiServer<User> {
|
|
|
558
896
|
handleRoomBootJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
|
|
559
897
|
}
|
|
560
898
|
|
|
899
|
+
/** One {@link RindleApiServer.validateRealtime} verdict: a labeled query × exemplar args. */
|
|
900
|
+
export interface ValidateRealtimeVerdict {
|
|
901
|
+
query: string;
|
|
902
|
+
profile: string;
|
|
903
|
+
args: unknown;
|
|
904
|
+
covered: boolean;
|
|
905
|
+
/** Why it is not covered (uncovered verdicts only) — the daemon's reason strings, the
|
|
906
|
+
* aggregate refusal, or a resolve/footprint error message. */
|
|
907
|
+
reasons?: string[];
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** The {@link RindleApiServer.validateRealtime} report. */
|
|
911
|
+
export interface ValidateRealtimeReport {
|
|
912
|
+
verdicts: ValidateRealtimeVerdict[];
|
|
913
|
+
/** The uncovered subset of `verdicts` (what `strict` throws on). */
|
|
914
|
+
uncovered: ValidateRealtimeVerdict[];
|
|
915
|
+
}
|
|
916
|
+
|
|
561
917
|
export type RindleApiErrorCode = "bad-request" | "forbidden" | "not-found" | "rejected";
|
|
562
918
|
|
|
563
919
|
export class RindleApiError extends Error {
|
|
@@ -652,6 +1008,13 @@ export class SplitDaemonClient implements RindleDaemonClient {
|
|
|
652
1008
|
if (!lmids) return Promise.reject(new Error("the write master lacks roomLmids"));
|
|
653
1009
|
return lmids(input);
|
|
654
1010
|
}
|
|
1011
|
+
// Pure computation, but routed to the MASTER like the rest of the room ops: the master is a
|
|
1012
|
+
// real rindled that hosts `/cover-check`; the read router may not proxy it.
|
|
1013
|
+
coverQuery(input: CoverQueryInput): Promise<CoverQueryOutput> {
|
|
1014
|
+
const cover = this.writes.coverQuery?.bind(this.writes);
|
|
1015
|
+
if (!cover) return Promise.reject(new Error("the write master lacks coverQuery"));
|
|
1016
|
+
return cover(input);
|
|
1017
|
+
}
|
|
655
1018
|
migrate(input: MigrateInput): Promise<MigrateOutput> {
|
|
656
1019
|
return this.writes.migrate(input);
|
|
657
1020
|
}
|
|
@@ -1390,7 +1753,11 @@ export function registerQueries<User>(queries: readonly NamedQuery<any, any, any
|
|
|
1390
1753
|
if (Object.prototype.hasOwnProperty.call(out, query.queryName)) {
|
|
1391
1754
|
throw new Error(`registerQueries: duplicate query name "${query.queryName}"`);
|
|
1392
1755
|
}
|
|
1393
|
-
|
|
1756
|
+
const wrapped: ApiQuery<User, any> = (ctx, args) => query.resolve(args, ctx);
|
|
1757
|
+
// The §2.1 realtime label survives this seam (read it back with {@link queryRealtimeLabel}) —
|
|
1758
|
+
// the lease path looks up (room profile, args mapping) by query name. Unlabeled queries get
|
|
1759
|
+
// the exact bare wrapper they always did.
|
|
1760
|
+
out[query.queryName] = query.realtime === undefined ? wrapped : attachRealtimeLabel(wrapped, query.realtime);
|
|
1394
1761
|
}
|
|
1395
1762
|
return out;
|
|
1396
1763
|
}
|
|
@@ -1431,11 +1798,47 @@ export function sharedApiMutators<User>(
|
|
|
1431
1798
|
return out;
|
|
1432
1799
|
}
|
|
1433
1800
|
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1801
|
+
/**
|
|
1802
|
+
* Wrap a SHARED (generator) mutator with a row-level ACCESS GUARD — the multi-tenant authz twin of
|
|
1803
|
+
* {@link sharedApiMutators}. It parses the untrusted wire args, derives the {@link MutatorCtx}
|
|
1804
|
+
* principal (the SAME mapping you pass to `sharedApiMutators`), evaluates `predicate` against the OPEN
|
|
1805
|
+
* mutation txn (so it can READ the rows the write depends on), and throws `forbidden` (403 — the
|
|
1806
|
+
* client's optimistic write snaps back) when access is denied; otherwise it drives the SAME body the
|
|
1807
|
+
* client predicts ({@link runSharedMutation}). Use it for the entries that need server-only authority
|
|
1808
|
+
* the client cannot predict, OVERRIDING the auto-wrapped default (spread `sharedApiMutators(...)`
|
|
1809
|
+
* first, then the guarded overrides win by key):
|
|
1810
|
+
*
|
|
1811
|
+
* ```ts
|
|
1812
|
+
* const principal = (ctx) => ({ user: requireUser(ctx.user) });
|
|
1813
|
+
* mutators: defineApiMutators({
|
|
1814
|
+
* ...sharedApiMutators(sharedMutators, principal),
|
|
1815
|
+
* updateSlide: guardMutator(sharedMutators.updateSlide, principal,
|
|
1816
|
+
* async (tx, a, { user }) =>
|
|
1817
|
+
* (await tx.query(q.slide.where.id(a.slideId).where(editableBy(user)).one())) != null,
|
|
1818
|
+
* { message: "not permitted to edit this slide" }),
|
|
1819
|
+
* }),
|
|
1820
|
+
* ```
|
|
1821
|
+
*
|
|
1822
|
+
* The predicate keeps the shared body READ-FREE, so the client's `.folded` hot paths (drag/keystroke)
|
|
1823
|
+
* still fold — the read is server-side only. Return `false` to deny (→ the default or `opts.message`
|
|
1824
|
+
* forbidden); return `true`/nothing to allow. To reject with a different status/message (a business
|
|
1825
|
+
* rejection, a not-found), throw a {@link RindleApiError} from inside the predicate instead. `principal`
|
|
1826
|
+
* runs before the predicate, so it too may throw `forbidden` for an anonymous caller.
|
|
1827
|
+
*/
|
|
1828
|
+
export function guardMutator<User, Args>(
|
|
1829
|
+
gen: SharedMutatorWithArgs<Args>,
|
|
1830
|
+
principal: (ctx: MutationContext<User>) => MutatorCtx,
|
|
1831
|
+
predicate: (tx: ServerMutationTx, args: Args, ctx: MutatorCtx) => boolean | void | Promise<boolean | void>,
|
|
1832
|
+
opts?: { message?: string },
|
|
1833
|
+
): ApiMutator<User, unknown> {
|
|
1834
|
+
return async (tx, raw, ctx) => {
|
|
1835
|
+
const args = gen.args.parse(raw);
|
|
1836
|
+
const pctx = principal(ctx);
|
|
1837
|
+
if ((await predicate(tx, args, pctx)) === false) {
|
|
1838
|
+
throw new RindleApiError("forbidden", opts?.message ?? "not permitted", 403);
|
|
1839
|
+
}
|
|
1840
|
+
return runSharedMutation(gen, args, pctx, tx);
|
|
1841
|
+
};
|
|
1439
1842
|
}
|
|
1440
1843
|
|
|
1441
1844
|
/** One exemplar invocation for {@link dumpQueryShapes} — the `args`/`user` a query is built with.
|
|
@@ -1472,8 +1875,9 @@ export async function dumpQueryShapes<User>(opts: {
|
|
|
1472
1875
|
exemplars?: Partial<Record<string, ReadonlyArray<ShapeExemplar<User>>>>;
|
|
1473
1876
|
}): Promise<QueryShapesDoc> {
|
|
1474
1877
|
const tables = Object.values(opts.schema.tables)
|
|
1475
|
-
// Local-only tables live in the browser's memory source,
|
|
1476
|
-
|
|
1878
|
+
// Local-only tables (both `true` and `"session"`) live in the browser's memory source,
|
|
1879
|
+
// never a TableSource — no indexes.
|
|
1880
|
+
.filter((t) => !t.local)
|
|
1477
1881
|
.map((t) => ({ name: t.name, primaryKey: [...t.primaryKey] }))
|
|
1478
1882
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1479
1883
|
const queries: QueryShapesDoc["queries"] = [];
|
|
@@ -1535,6 +1939,88 @@ function normalizeCondition(c: Condition): unknown {
|
|
|
1535
1939
|
}
|
|
1536
1940
|
}
|
|
1537
1941
|
|
|
1942
|
+
/**
|
|
1943
|
+
* The runtime {@link MutationScope} handed to a {@link ScopedMutator}. It owns the single atomic
|
|
1944
|
+
* transaction (delegating to {@link MutationBackend.runMutation} — the exact machinery a tx-form
|
|
1945
|
+
* mutator uses), but lets the AUTHOR decide when it opens, so server-only work can run outside it.
|
|
1946
|
+
*
|
|
1947
|
+
* It records its outcome so the harness — not the author — enforces the `lmid`-always-advances
|
|
1948
|
+
* invariant: `phase` reports whether the tx committed, business-rejected, or never ran, and `infra`
|
|
1949
|
+
* latches a backend (DB) failure. Because the backend's `runMutation` RETURNS `{accepted:false}` for
|
|
1950
|
+
* a business rejection (having already advanced `lmid` alone) and THROWS only for infra, `transact`
|
|
1951
|
+
* can cleanly re-throw {@link MutationRejected} on the former (for author compensation) and propagate
|
|
1952
|
+
* the raw driver error on the latter.
|
|
1953
|
+
*/
|
|
1954
|
+
class MutationScopeImpl implements MutationScope {
|
|
1955
|
+
private attempted = false;
|
|
1956
|
+
private readonly backend: MutationBackend;
|
|
1957
|
+
private readonly envelope: MutationEnvelope;
|
|
1958
|
+
private readonly render: RenderIndex;
|
|
1959
|
+
/** Set once `transact` resolved through the backend (accepted OR business-rejected). */
|
|
1960
|
+
outcome?: MutationOutcome;
|
|
1961
|
+
/** The value the backend threw on INFRA (the DB failed) — always propagated, never an `lmid`
|
|
1962
|
+
* advance. Its presence is tracked by {@link infraLatched}, NOT by testing this for `undefined`:
|
|
1963
|
+
* a driver may legitimately reject with a falsy value, and misreading that as "no infra" would
|
|
1964
|
+
* reclassify a lost-connection failure as a business rejection and wrongly advance `lmid`. */
|
|
1965
|
+
infra?: unknown;
|
|
1966
|
+
/** True once an INFRA failure latched, regardless of its (possibly falsy) value. */
|
|
1967
|
+
infraLatched = false;
|
|
1968
|
+
/** The in-flight `transact` promise. `settle` awaits it before sealing, so a transact the author
|
|
1969
|
+
* FORGOT to await (a floating promise — nothing here lints against it) is still resolved to its
|
|
1970
|
+
* real outcome first; otherwise the seal would read `untouched`, reply with a phantom no-op, and
|
|
1971
|
+
* let the real write commit out-of-band after the response was already sent. */
|
|
1972
|
+
pending?: Promise<void>;
|
|
1973
|
+
|
|
1974
|
+
constructor(backend: MutationBackend, envelope: MutationEnvelope, render: RenderIndex) {
|
|
1975
|
+
this.backend = backend;
|
|
1976
|
+
this.envelope = envelope;
|
|
1977
|
+
this.render = render;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
transact(
|
|
1981
|
+
first: SharedMutator<any, any> | ((tx: ServerMutationTx) => void | Promise<void>),
|
|
1982
|
+
args?: unknown,
|
|
1983
|
+
ctx?: MutatorCtx,
|
|
1984
|
+
): Promise<void> {
|
|
1985
|
+
if (this.attempted) throw new Error("scope.transact may be called at most once per mutation");
|
|
1986
|
+
this.attempted = true;
|
|
1987
|
+
const promise = this.drive(first, args, ctx);
|
|
1988
|
+
// Record the in-flight promise so `settle` can await it even when the author didn't. Errors are
|
|
1989
|
+
// latched onto `this` (outcome / infra), so this tracking copy swallows them — the author's
|
|
1990
|
+
// returned `promise` still rejects for them to await/catch.
|
|
1991
|
+
this.pending = promise.then(
|
|
1992
|
+
() => undefined,
|
|
1993
|
+
() => undefined,
|
|
1994
|
+
);
|
|
1995
|
+
return promise;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
private async drive(
|
|
1999
|
+
first: SharedMutator<any, any> | ((tx: ServerMutationTx) => void | Promise<void>),
|
|
2000
|
+
args?: unknown,
|
|
2001
|
+
ctx?: MutatorCtx,
|
|
2002
|
+
): Promise<void> {
|
|
2003
|
+
// A shared (generator) mutator is driven via the isomorphic seam; a plain callback gets the raw tx.
|
|
2004
|
+
const run = isGeneratorMutator(first)
|
|
2005
|
+
? async (tx: ServerMutationTx) => {
|
|
2006
|
+
await runSharedMutation(first as SharedMutator<unknown, MutatorCtx>, args, ctx as MutatorCtx, tx);
|
|
2007
|
+
}
|
|
2008
|
+
: async (tx: ServerMutationTx) => {
|
|
2009
|
+
await (first as (tx: ServerMutationTx) => void | Promise<void>)(tx);
|
|
2010
|
+
};
|
|
2011
|
+
let outcome: MutationOutcome;
|
|
2012
|
+
try {
|
|
2013
|
+
outcome = await this.backend.runMutation({ envelope: this.envelope, render: this.render, run });
|
|
2014
|
+
} catch (err) {
|
|
2015
|
+
this.infra = err; // the backend throws ONLY for infra; a business rejection returns {accepted:false}
|
|
2016
|
+
this.infraLatched = true;
|
|
2017
|
+
throw err;
|
|
2018
|
+
}
|
|
2019
|
+
this.outcome = outcome;
|
|
2020
|
+
if (!outcome.accepted) throw new MutationRejected(outcome.reason);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
1538
2024
|
export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptions<User>): RindleApiServer<User> {
|
|
1539
2025
|
const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
|
|
1540
2026
|
const mode = opts.mode ?? "normalized";
|
|
@@ -1548,6 +2034,25 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
1548
2034
|
// floor, §4.1) so the first viewer to route to a follower warms it for late joiners.
|
|
1549
2035
|
const pinnedNames = new Set((opts.pinnedQueries ?? []).map((p) => p.name));
|
|
1550
2036
|
|
|
2037
|
+
// Rindle Realtime declaration layer (RINDLE-REALTIME-QUERY-ENABLEMENT §2, slice G-iv-a):
|
|
2038
|
+
// compile the named room profiles and run every "loud at registration" (§2.3) check NOW —
|
|
2039
|
+
// construction is the moment a misconfigured profile or label can still fail the deploy,
|
|
2040
|
+
// not a 3am room boot. The legacy flat `resolveFootprint` stays the anonymous profile and
|
|
2041
|
+
// is deliberately NOT probed or validated (byte-identical legacy behavior).
|
|
2042
|
+
const realtime = opts.realtime;
|
|
2043
|
+
const roomProfiles = compileRoomProfiles<User>({
|
|
2044
|
+
rooms: realtime?.rooms,
|
|
2045
|
+
schema: opts.schema,
|
|
2046
|
+
warn: realtime?.warn,
|
|
2047
|
+
});
|
|
2048
|
+
assertLabeledProfilesExist(opts.queries, roomProfiles);
|
|
2049
|
+
if (realtime !== undefined && roomProfiles.size === 0 && realtime.resolveFootprint === undefined) {
|
|
2050
|
+
throw new Error(
|
|
2051
|
+
"realtime: configure at least one room profile (realtime.rooms) or the legacy resolveFootprint — " +
|
|
2052
|
+
"a realtime host with neither can never boot a room.",
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
|
|
1551
2056
|
// Resolve a named query (+ args) to its AST under a given context — the shared path for both
|
|
1552
2057
|
// a per-viewer lease and a system-level pin (which skips per-user authorization).
|
|
1553
2058
|
const resolveAst = async (name: string, args: unknown, context: ApiContext<User>): Promise<Ast> => {
|
|
@@ -1559,6 +2064,372 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
1559
2064
|
return queryResultToAst(result);
|
|
1560
2065
|
};
|
|
1561
2066
|
|
|
2067
|
+
// ---------------------------------------------------------------- the room-serve decision
|
|
2068
|
+
//
|
|
2069
|
+
// RINDLE-REALTIME-QUERY-ENABLEMENT §2.1 lease-flow steps 2–5, slice G-iv-b. Everything here is
|
|
2070
|
+
// FAIL-OPEN: any missing wiring, refused proof, or thrown error means the lease is served from
|
|
2071
|
+
// the daemon EXACTLY as today (no `realtime` block, top-level fields untouched) plus a one-time
|
|
2072
|
+
// diagnostic — a coverage/config problem must never block a lease.
|
|
2073
|
+
|
|
2074
|
+
const realtimeWarn = realtime?.warn ?? ((message: string) => console.warn(message));
|
|
2075
|
+
// One-time per (queryName, profile): the serve decision runs on EVERY lease, so an uncovered
|
|
2076
|
+
// labeled query would otherwise warn once per viewer per mount.
|
|
2077
|
+
const warnedRoomServe = new Set<string>();
|
|
2078
|
+
const warnRoomServeOnce = (queryName: string, profile: string, reasons: readonly string[]): void => {
|
|
2079
|
+
const key = `${queryName}\u0000${profile}`;
|
|
2080
|
+
if (warnedRoomServe.has(key)) return;
|
|
2081
|
+
warnedRoomServe.add(key);
|
|
2082
|
+
realtimeWarn(
|
|
2083
|
+
`query "${queryName}" is labeled realtime (room profile "${profile}") but is NOT room-served — ` +
|
|
2084
|
+
`${reasons.join("; ")}. It serves from the daemon (correct, just not room-accelerated). ` +
|
|
2085
|
+
`This warning fires once per (query, profile).`,
|
|
2086
|
+
);
|
|
2087
|
+
};
|
|
2088
|
+
|
|
2089
|
+
// The §2.3 aggregate refusal: the client's aggregate overlay is daemon-gated until post-G, so
|
|
2090
|
+
// an aggregate/reduce-shaped query is refused room-serving REGARDLESS of coverage.
|
|
2091
|
+
const AGGREGATE_REFUSAL =
|
|
2092
|
+
"the query AST contains an aggregate/reduce shape — aggregate overlays are daemon-gated until post-G";
|
|
2093
|
+
|
|
2094
|
+
// Verdict cache. The verdict is a pure function of exactly two inputs — the resolved footprint
|
|
2095
|
+
// AST and the resolved query AST — so the tightest SOUND key is those two ASTs themselves
|
|
2096
|
+
// (stable-stringified), scoped by (queryName, profile) for legibility. Args/user/ctx need no
|
|
2097
|
+
// separate slot precisely because anything that changes the verdict must change one of the two
|
|
2098
|
+
// ASTs (predicate literals embed the args; ctx-scoped queries embed the principal); keying on
|
|
2099
|
+
// `(name, args)` alone would ALIAS two users' different ASTs under one verdict — unsound.
|
|
2100
|
+
// Bounded FIFO (Map iterates in insertion order) so per-user literals can't grow it forever.
|
|
2101
|
+
const coverVerdicts = new Map<string, CoverQueryOutput>();
|
|
2102
|
+
const COVER_VERDICT_CACHE_MAX = 1024;
|
|
2103
|
+
|
|
2104
|
+
const maybeRoomServe = async (
|
|
2105
|
+
input: QueryLeaseRequest<User>,
|
|
2106
|
+
queryAst: Ast,
|
|
2107
|
+
context: ApiContext<User>,
|
|
2108
|
+
subject: string | undefined,
|
|
2109
|
+
): Promise<QueryLeaseRealtime | undefined> => {
|
|
2110
|
+
// (a) the label + (b) its profile — the fast bail keeps unlabeled leases byte-identical.
|
|
2111
|
+
const label = queryRealtimeLabel(opts.queries?.[input.name]);
|
|
2112
|
+
if (label === undefined) return undefined;
|
|
2113
|
+
const profile = roomProfiles.get(label.room);
|
|
2114
|
+
if (profile === undefined) return undefined; // unreachable: construction asserted it exists
|
|
2115
|
+
try {
|
|
2116
|
+
// (c) the wiring gates — each absence fail-opens with a one-time, named reason.
|
|
2117
|
+
if (realtime?.locateRoom === undefined) {
|
|
2118
|
+
warnRoomServeOnce(input.name, profile.name, ["realtime.locateRoom is not configured"]);
|
|
2119
|
+
return undefined;
|
|
2120
|
+
}
|
|
2121
|
+
const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
|
|
2122
|
+
if (coverQuery === undefined) {
|
|
2123
|
+
warnRoomServeOnce(input.name, profile.name, [
|
|
2124
|
+
"the configured daemon client does not implement coverQuery (/cover-check)",
|
|
2125
|
+
]);
|
|
2126
|
+
return undefined;
|
|
2127
|
+
}
|
|
2128
|
+
const tokenKey = realtime.roomTokenKey;
|
|
2129
|
+
if (tokenKey === undefined) {
|
|
2130
|
+
warnRoomServeOnce(input.name, profile.name, [
|
|
2131
|
+
"realtime.roomTokenKey is not configured — the room lease token cannot be signed",
|
|
2132
|
+
]);
|
|
2133
|
+
return undefined;
|
|
2134
|
+
}
|
|
2135
|
+
// The token's subject: the same resolved subject the daemon lease carries, else the
|
|
2136
|
+
// browser's clientId. The shell refuses a subject-less token, so with neither we fail open.
|
|
2137
|
+
const sub = subject ?? input.clientId;
|
|
2138
|
+
if (sub === undefined) {
|
|
2139
|
+
warnRoomServeOnce(input.name, profile.name, [
|
|
2140
|
+
"no token subject — configure `subject` (or have the client send clientId)",
|
|
2141
|
+
]);
|
|
2142
|
+
return undefined;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
// §2.1: (roomProfile, roomArgs) via the label's args mapping; key + doc minted SERVER-side
|
|
2146
|
+
// (input.args just passed the query's own validation inside resolveAst).
|
|
2147
|
+
const roomArgs = label.args !== undefined ? label.args(input.args) : input.args;
|
|
2148
|
+
const key = profile.key(roomArgs);
|
|
2149
|
+
const doc = mintRoomDoc(profile.name, key);
|
|
2150
|
+
|
|
2151
|
+
// The profile footprint for THIS key under the request ctx (works for non-static
|
|
2152
|
+
// profiles), with the §2.3 unwindowed backstop `/room-boot` also applies.
|
|
2153
|
+
const footprintAst = queryResultToAst(await profile.footprint(key, context));
|
|
2154
|
+
assertUnwindowedFootprint(footprintAst, profile.name);
|
|
2155
|
+
|
|
2156
|
+
const verdictKey = `${input.name}\u0000${profile.name}\u0000${stableStringify(footprintAst)}\u0000${stableStringify(queryAst)}`;
|
|
2157
|
+
let verdict = coverVerdicts.get(verdictKey);
|
|
2158
|
+
if (verdict === undefined) {
|
|
2159
|
+
verdict = astHasAggregate(queryAst)
|
|
2160
|
+
? { covered: false, reasons: [AGGREGATE_REFUSAL] }
|
|
2161
|
+
: await coverQuery({ footprint: footprintAst, query: queryAst });
|
|
2162
|
+
// (A coverQuery THROW never lands here — the outer catch fail-opens without caching, so
|
|
2163
|
+
// a transient daemon failure doesn't pin an uncovered verdict.)
|
|
2164
|
+
if (coverVerdicts.size >= COVER_VERDICT_CACHE_MAX) {
|
|
2165
|
+
coverVerdicts.delete(coverVerdicts.keys().next().value as string);
|
|
2166
|
+
}
|
|
2167
|
+
coverVerdicts.set(verdictKey, verdict);
|
|
2168
|
+
}
|
|
2169
|
+
if (!verdict.covered) {
|
|
2170
|
+
warnRoomServeOnce(input.name, profile.name, verdict.reasons ?? ["not provably covered"]);
|
|
2171
|
+
return undefined;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
// Covered ⇒ assemble the realtime block. The room endpoint rides ITS OWN field — the
|
|
2175
|
+
// top-level `wsEndpoint` stays exactly the daemon lease's (whole-session migration signal).
|
|
2176
|
+
const { wsEndpoint } = await realtime.locateRoom(doc);
|
|
2177
|
+
const now = Date.now();
|
|
2178
|
+
const ttlMs = realtime.roomTokenTtlMs ?? DEFAULT_ROOM_TOKEN_TTL_MS;
|
|
2179
|
+
const { mintRoomToken, scopeSpecsHash } = await loadRoomTokenModule();
|
|
2180
|
+
// The lease-wire specs, hashed ONCE: the same value is stamped on the token (so the
|
|
2181
|
+
// shell can flag scope skew — a profile edited under a live room, whose gate armed
|
|
2182
|
+
// with the OLD specs at boot) and returned as the client's `tables`.
|
|
2183
|
+
const tables = compileRoomTableSpecs(footprintAst, profile.context);
|
|
2184
|
+
const roomToken = await mintRoomToken({
|
|
2185
|
+
doc,
|
|
2186
|
+
ast: queryAst, // the APPROVED resolved AST — the client carries it, it can't mint/alter it
|
|
2187
|
+
sub,
|
|
2188
|
+
kid: tokenKey.kid,
|
|
2189
|
+
key: tokenKey.secret,
|
|
2190
|
+
ttlMs,
|
|
2191
|
+
now,
|
|
2192
|
+
scopesHash: scopeSpecsHash(tables),
|
|
2193
|
+
});
|
|
2194
|
+
return {
|
|
2195
|
+
// `parse_source_key` (rust/src/wasm/db.rs): anything but the reserved "daemon" is a room
|
|
2196
|
+
// source; the client-store convention is `room:` + the wire doc.
|
|
2197
|
+
sourceKey: `room:${doc}`,
|
|
2198
|
+
wsEndpoint,
|
|
2199
|
+
roomToken,
|
|
2200
|
+
exp: now + ttlMs,
|
|
2201
|
+
doc,
|
|
2202
|
+
tables,
|
|
2203
|
+
};
|
|
2204
|
+
} catch (e) {
|
|
2205
|
+
// Fail open — a lease is never blocked on the proof. Not cached (may be transient).
|
|
2206
|
+
warnRoomServeOnce(input.name, profile.name, [`room-serve failed: ${errMessage(e)}`]);
|
|
2207
|
+
return undefined;
|
|
2208
|
+
}
|
|
2209
|
+
};
|
|
2210
|
+
|
|
2211
|
+
// ---------------------------------------------------------------- the §4 lifecycle mint (I-iii)
|
|
2212
|
+
//
|
|
2213
|
+
// Gated on the OPT-IN `realtime.lifecycle` block: absent, this whole section is dead code and
|
|
2214
|
+
// every lease response is byte-identical to pre-lifecycle. Present, a labeled lease gains the
|
|
2215
|
+
// doorbell system lease and a ROOM-SERVED one the fence bundle (see {@link QueryLeaseLifecycle}).
|
|
2216
|
+
// FAIL-OPEN like the room-serve decision: a mint failure (e.g. a daemon that never ran
|
|
2217
|
+
// `enable_realtime_lifecycle`) warns once per query and the lease ships without the block.
|
|
2218
|
+
|
|
2219
|
+
const warnedLifecycle = new Set<string>();
|
|
2220
|
+
const warnLifecycleOnce = (queryName: string, reason: string): void => {
|
|
2221
|
+
if (warnedLifecycle.has(queryName)) return;
|
|
2222
|
+
warnedLifecycle.add(queryName);
|
|
2223
|
+
realtimeWarn(
|
|
2224
|
+
`query "${queryName}" is realtime-labeled with lifecycle configured, but its lifecycle ` +
|
|
2225
|
+
`system leases were not minted — ${reason}. The lease serves without the lifecycle block ` +
|
|
2226
|
+
`(correct, just no §4 upgrade/downgrade plane). This warning fires once per query.`,
|
|
2227
|
+
);
|
|
2228
|
+
};
|
|
2229
|
+
|
|
2230
|
+
/** THE SCOPE-KEY DECISION (§4.1): the doorbell scope IS the wire room doc — `"<profile>/<key>"`
|
|
2231
|
+
* via {@link mintRoomDoc}, the same computation `maybeRoomServe` runs (label args mapping →
|
|
2232
|
+
* `profile.key`) and the same key `locateRoom`/`/room-boot` address the room by. Occupancy
|
|
2233
|
+
* (I-iv writes the `_rindle_scope_sessions` rows) must be counted on EXACTLY the key the 1→2
|
|
2234
|
+
* transition provisions, and this is that key. Computed independently of the room-serve
|
|
2235
|
+
* decision on purpose: the doorbell rides every LABELED lease — an uncovered/unwired labeled
|
|
2236
|
+
* query still counts toward occupancy (its collaborators still want the upgrade). */
|
|
2237
|
+
const lifecycleScopeDoc = (input: QueryLeaseRequest<User>): string | undefined => {
|
|
2238
|
+
const label = queryRealtimeLabel(opts.queries?.[input.name]);
|
|
2239
|
+
if (label === undefined) return undefined; // unlabeled — no scope to count on
|
|
2240
|
+
const profile = roomProfiles.get(label.room);
|
|
2241
|
+
if (profile === undefined) return undefined; // unreachable: construction asserted it exists
|
|
2242
|
+
const roomArgs = label.args !== undefined ? label.args(input.args) : input.args;
|
|
2243
|
+
return mintRoomDoc(profile.name, profile.key(roomArgs));
|
|
2244
|
+
};
|
|
2245
|
+
|
|
2246
|
+
const maybeLifecycle = async (
|
|
2247
|
+
input: QueryLeaseRequest<User>,
|
|
2248
|
+
roomServed: boolean,
|
|
2249
|
+
subject: string | undefined,
|
|
2250
|
+
routingKey: string | undefined,
|
|
2251
|
+
): Promise<QueryLeaseLifecycle | undefined> => {
|
|
2252
|
+
if (realtime?.lifecycle === undefined) return undefined; // the opt-in gate — mint NOTHING
|
|
2253
|
+
try {
|
|
2254
|
+
const doc = lifecycleScopeDoc(input);
|
|
2255
|
+
if (doc === undefined) return undefined;
|
|
2256
|
+
// Each system lease is an ordinary daemon materialization (the room-boot direct pattern),
|
|
2257
|
+
// carrying the SAME subject/routingKey as the primary lease so a routed deploy co-locates
|
|
2258
|
+
// the system streams on the follower the client's daemon session already lives on. The
|
|
2259
|
+
// daemon dedups by canonical query, so N clients' doorbells over one scope share ONE
|
|
2260
|
+
// materialization (each still minting its own leaseToken); the client-scoped fence ASTs
|
|
2261
|
+
// are per-client by construction.
|
|
2262
|
+
const mint = (ast: Ast) =>
|
|
2263
|
+
opts.daemon.materialize({
|
|
2264
|
+
ast,
|
|
2265
|
+
mode,
|
|
2266
|
+
subject,
|
|
2267
|
+
leaseTtlMs: opts.leaseTtlMs,
|
|
2268
|
+
metadata: routingKey !== undefined ? { routingKey } : undefined,
|
|
2269
|
+
});
|
|
2270
|
+
const lease = (table: string, out: MaterializeOutput, id: { scope?: string; doc?: string; clientId?: string }): QueryLeaseLifecycleLease => ({
|
|
2271
|
+
table,
|
|
2272
|
+
leaseToken: out.leaseToken,
|
|
2273
|
+
...(out.wsEndpoint !== undefined ? { wsEndpoint: out.wsEndpoint } : {}),
|
|
2274
|
+
...(id.scope !== undefined ? { scope: id.scope } : {}),
|
|
2275
|
+
...(id.doc !== undefined ? { doc: id.doc } : {}),
|
|
2276
|
+
...(id.clientId !== undefined ? { clientId: id.clientId } : {}),
|
|
2277
|
+
});
|
|
2278
|
+
const lifecycle: QueryLeaseLifecycle = {
|
|
2279
|
+
doorbell: lease(SCOPE_SESSIONS_TABLE, await mint(scopeSessionsAst(doc)), { scope: doc }),
|
|
2280
|
+
};
|
|
2281
|
+
// The fence bundle only where a room domain exists to fence (room-served leases): the
|
|
2282
|
+
// §4.2 watermark, the §7.1 daemon-carried ledger, and the §3.3 outcome rows.
|
|
2283
|
+
if (roomServed) {
|
|
2284
|
+
const clientId = input.clientId;
|
|
2285
|
+
lifecycle.fence = [
|
|
2286
|
+
lease(ROOM_WATERMARK_TABLE, await mint(roomWatermarkAst(doc)), { doc }),
|
|
2287
|
+
lease(ROOM_CLIENT_MUTATIONS_TABLE, await mint(docClientAst(ROOM_CLIENT_MUTATIONS_TABLE, doc, clientId)), { doc, clientId }),
|
|
2288
|
+
lease(ROOM_MUTATION_OUTCOMES_TABLE, await mint(docClientAst(ROOM_MUTATION_OUTCOMES_TABLE, doc, clientId)), { doc, clientId }),
|
|
2289
|
+
];
|
|
2290
|
+
}
|
|
2291
|
+
return lifecycle;
|
|
2292
|
+
} catch (e) {
|
|
2293
|
+
warnLifecycleOnce(input.name, errMessage(e)); // fail open — a lease is never blocked
|
|
2294
|
+
return undefined;
|
|
2295
|
+
}
|
|
2296
|
+
};
|
|
2297
|
+
|
|
2298
|
+
// ------------------------------------------------------------ the §4.1 occupancy gate (I-iv)
|
|
2299
|
+
//
|
|
2300
|
+
// Runs on EVERY labeled lease under the opt-in `realtime.lifecycle` config (mint AND renewal —
|
|
2301
|
+
// both land on this same route), BEFORE the room-serve decision: (1) sweep + upsert the
|
|
2302
|
+
// caller's session row through the normal write path (the write is the doorbell — I-i's CDC
|
|
2303
|
+
// capture fans the row delta to every solo watcher's doorbell subscription), then (2) read the
|
|
2304
|
+
// occupancy count and return the D6 gate verdict `maybeRoomServe` is conditioned on. The upsert
|
|
2305
|
+
// deliberately precedes the count so the caller's own row is on disk when the verdict is
|
|
2306
|
+
// computed (its own presence rides the `+ 1`, and — more importantly — a concurrent second
|
|
2307
|
+
// client's read sees it). Ordering within the pair is otherwise value-neutral: the count
|
|
2308
|
+
// EXCLUDES the caller's clientId and adds the `+ 1` analytically.
|
|
2309
|
+
//
|
|
2310
|
+
// THE RENEWAL-vs-FRESH DECISION (grounded here because the task forces it): this server is
|
|
2311
|
+
// stateless and the lease request carries no "I am currently room-attached" field, so the gate
|
|
2312
|
+
// CANNOT distinguish a fresh mint from a live room's renewal. Instead of gating on the raw
|
|
2313
|
+
// count (which would suppress a momentarily-solo room's renewal and force the loud client-side
|
|
2314
|
+
// downgrade anomaly), the gate applies the §9.1 hysteresis DIRECTLY FROM THE LINGERING ROWS the
|
|
2315
|
+
// D4 sweep preserves: room-serve iff `liveOthers + self ≥ minSessions` OR some other session
|
|
2316
|
+
// expired within `graceMs`. A renewal is therefore never suppressed until the scope has been
|
|
2317
|
+
// solo SUSTAINED past the grace window — which is exactly Slice I-v's downgrade condition, read
|
|
2318
|
+
// from the same rows; I-v replaces that post-grace loud suppression with the fenced downgrade
|
|
2319
|
+
// dance, refining (not re-deciding) this verdict. A truly fresh solo scope (no other row, live
|
|
2320
|
+
// or lingering) is suppressed immediately — the D6 point.
|
|
2321
|
+
//
|
|
2322
|
+
// Timestamps are `Date.now()` server-side throughout (mint, sweep, count): occupancy tolerates
|
|
2323
|
+
// clock skew between api-server instances up to ~grace — a skewed `now` moves a session between
|
|
2324
|
+
// "live" and "in-grace", both of which hold the gate open; only skew past the grace+slack band
|
|
2325
|
+
// could mis-sweep, and the slack exists to keep that band clear.
|
|
2326
|
+
//
|
|
2327
|
+
// A request with NO `clientId` (a non-shipped client — the shipped one always sends it, see
|
|
2328
|
+
// `postLease`) upserts NO row and contributes NOTHING to occupancy, including to its own gate:
|
|
2329
|
+
// it room-serves only if the OTHER sessions alone reach `minSessions` (there is no session
|
|
2330
|
+
// identity to count it under, D7). It still gets its doorbell (`maybeLifecycle` is independent).
|
|
2331
|
+
//
|
|
2332
|
+
// FAIL-OPEN, like every lifecycle surface: an occupancy failure (e.g. a daemon that never ran
|
|
2333
|
+
// `enable_realtime_lifecycle`) warns once per query and returns `true` — the gate falls away
|
|
2334
|
+
// and the lease serves exactly as pre-I-iv. Suppressing on infrastructure failure would turn
|
|
2335
|
+
// realtime off fleet-wide from one missing table; never block, never suppress, on an error.
|
|
2336
|
+
|
|
2337
|
+
const warnedOccupancy = new Set<string>();
|
|
2338
|
+
interface LifecycleOccupancy {
|
|
2339
|
+
/** The D6 room-serve gate verdict — `false` ⇒ suppress the room block (solo/uncovered). */
|
|
2340
|
+
gateOpen: boolean;
|
|
2341
|
+
/** The I-v downgrade guard: a room plausibly hosts this scope (some other-session row lives
|
|
2342
|
+
* or lingers). Only a `!gateOpen && roomPlausible` reply drains — never a never-shared doc. */
|
|
2343
|
+
roomPlausible: boolean;
|
|
2344
|
+
/** The wire doc the scope maps to (`"<profile>/<key>"`); `undefined` when unlabeled / off. */
|
|
2345
|
+
doc: string | undefined;
|
|
2346
|
+
}
|
|
2347
|
+
const lifecycleOccupancy = async (input: QueryLeaseRequest<User>): Promise<LifecycleOccupancy> => {
|
|
2348
|
+
const lc = realtime?.lifecycle;
|
|
2349
|
+
if (lc === undefined) return { gateOpen: true, roomPlausible: false, doc: undefined }; // lifecycle off — the gate does not exist (inert-until-fed)
|
|
2350
|
+
const doc = lifecycleScopeDoc(input);
|
|
2351
|
+
if (doc === undefined) return { gateOpen: true, roomPlausible: false, doc: undefined }; // unlabeled — no scope to count on, nothing to gate
|
|
2352
|
+
try {
|
|
2353
|
+
const now = Date.now();
|
|
2354
|
+
const minSessions = lc.minSessions ?? DEFAULT_LIFECYCLE_MIN_SESSIONS;
|
|
2355
|
+
const graceMs = lc.graceMs ?? DEFAULT_LIFECYCLE_GRACE_MS;
|
|
2356
|
+
const sessionTtlMs = lc.sessionTtlMs ?? opts.leaseTtlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
2357
|
+
const clientId = input.clientId;
|
|
2358
|
+
// (1) sweep + upsert, ONE write txn (D4: the sweep shares the upsert's transaction — no
|
|
2359
|
+
// separate maintenance pass, and the linger bound holds atomically with the refresh).
|
|
2360
|
+
const statements: SqlStatement[] = [
|
|
2361
|
+
{ sql: SESSION_SWEEP_SQL, params: [doc, now - (graceMs + SESSION_SWEEP_SLACK_MS)] },
|
|
2362
|
+
];
|
|
2363
|
+
if (clientId !== undefined) {
|
|
2364
|
+
statements.push({ sql: SESSION_UPSERT_SQL, params: [doc, clientId, now + sessionTtlMs] });
|
|
2365
|
+
}
|
|
2366
|
+
await opts.daemon.executeSqlTxn({ statements });
|
|
2367
|
+
// (2) the count — read-your-writes via `consistency: "strong"` (see the section note above).
|
|
2368
|
+
const read = await opts.daemon.executeSqlRead({
|
|
2369
|
+
sql: clientId !== undefined ? SESSION_COUNT_OTHERS_SQL : SESSION_COUNT_SQL,
|
|
2370
|
+
params:
|
|
2371
|
+
clientId !== undefined
|
|
2372
|
+
? [now, now, now - graceMs, doc, clientId]
|
|
2373
|
+
: [now, now, now - graceMs, doc],
|
|
2374
|
+
consistency: "strong",
|
|
2375
|
+
});
|
|
2376
|
+
const cells = read.rows[0] ?? [];
|
|
2377
|
+
const liveOthers = Number(cells[0] ?? 0); // SUM over zero rows is NULL — coerce
|
|
2378
|
+
const graceOthers = Number(cells[1] ?? 0);
|
|
2379
|
+
const totalOthers = Number(cells[2] ?? 0); // ALL other rows (any expiry, pre-sweep)
|
|
2380
|
+
const self = clientId !== undefined ? 1 : 0;
|
|
2381
|
+
return {
|
|
2382
|
+
gateOpen: liveOthers + self >= minSessions || graceOthers > 0,
|
|
2383
|
+
// Room plausibly exists ⇒ this scope was shared (a room was provisioned on the 1→2). A
|
|
2384
|
+
// never-shared solo doc has NO other row and must never drain (no wasted room boot).
|
|
2385
|
+
roomPlausible: totalOthers > 0,
|
|
2386
|
+
doc,
|
|
2387
|
+
};
|
|
2388
|
+
} catch (e) {
|
|
2389
|
+
if (!warnedOccupancy.has(input.name)) {
|
|
2390
|
+
warnedOccupancy.add(input.name);
|
|
2391
|
+
realtimeWarn(
|
|
2392
|
+
`query "${input.name}" is realtime-labeled with lifecycle configured, but the §4.1 ` +
|
|
2393
|
+
`occupancy step failed — ${errMessage(e)}. The occupancy gate fail-opens (the lease ` +
|
|
2394
|
+
`serves exactly as pre-I-iv; no session row was counted). This warning fires once per query.`,
|
|
2395
|
+
);
|
|
2396
|
+
}
|
|
2397
|
+
return { gateOpen: true, roomPlausible: false, doc };
|
|
2398
|
+
}
|
|
2399
|
+
};
|
|
2400
|
+
|
|
2401
|
+
// ------------------------------------------------------------ the §4.2 downgrade drain (I-v)
|
|
2402
|
+
//
|
|
2403
|
+
// When the occupancy gate closes for a scope a room plausibly hosted, drain that room to a
|
|
2404
|
+
// COMMITTED flush seq and hand it back as the fence. `drainRoom` (deployment-wired to the room
|
|
2405
|
+
// shell / DO `/drain`) is idempotent (concurrent api-server instances may both call it) and
|
|
2406
|
+
// fails OPEN — a downgrade must never block a lease, so an unconfigured or throwing hook simply
|
|
2407
|
+
// omits the fence (warn-once) and the client falls to its loud legacy downgrade path.
|
|
2408
|
+
const warnedDrain = new Set<string>();
|
|
2409
|
+
const warnDrainOnce = (queryName: string, reason: string): void => {
|
|
2410
|
+
if (warnedDrain.has(queryName)) return;
|
|
2411
|
+
warnedDrain.add(queryName);
|
|
2412
|
+
realtimeWarn(
|
|
2413
|
+
`query "${queryName}" downgraded (occupancy gate closed) but no §4.2 fence was attached — ` +
|
|
2414
|
+
`${reason}. The lease ships without the fence; a room-attached client falls back to its ` +
|
|
2415
|
+
`loud legacy downgrade (correct, just not graceful). This warning fires once per query.`,
|
|
2416
|
+
);
|
|
2417
|
+
};
|
|
2418
|
+
const maybeDrainRoom = async (queryName: string, doc: string): Promise<QueryLeaseRealtimeFence | undefined> => {
|
|
2419
|
+
const drainRoom = realtime?.lifecycle?.drainRoom;
|
|
2420
|
+
if (drainRoom === undefined) {
|
|
2421
|
+
warnDrainOnce(queryName, "realtime.lifecycle.drainRoom is not configured");
|
|
2422
|
+
return undefined;
|
|
2423
|
+
}
|
|
2424
|
+
try {
|
|
2425
|
+
const { finalFlushSeq } = await drainRoom(doc);
|
|
2426
|
+
return { sourceKey: `room:${doc}`, doc, finalFlushSeq };
|
|
2427
|
+
} catch (e) {
|
|
2428
|
+
warnDrainOnce(queryName, `drainRoom threw — ${errMessage(e)}`);
|
|
2429
|
+
return undefined;
|
|
2430
|
+
}
|
|
2431
|
+
};
|
|
2432
|
+
|
|
1562
2433
|
const createQueryLease = async (input: QueryLeaseRequest<User>): Promise<QueryLeaseResponse> => {
|
|
1563
2434
|
const context: ApiContext<User> = { user: input.user, request: input.request };
|
|
1564
2435
|
await assertAuthorized(opts.authorizeQuery, {
|
|
@@ -1586,7 +2457,33 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
1586
2457
|
// `subject ?? metadata.routingKey` (§2.2). Omitted when there is none.
|
|
1587
2458
|
metadata: routingKey !== undefined ? { routingKey } : undefined,
|
|
1588
2459
|
});
|
|
1589
|
-
|
|
2460
|
+
const res = queryLeaseResponse(out);
|
|
2461
|
+
// I-iv (§4.1): the occupancy step FIRST — session sweep+upsert, then the D6 gate verdict. A
|
|
2462
|
+
// closed gate suppresses the room-serve ONLY (the lease ships without the realtime block,
|
|
2463
|
+
// indistinguishable from an uncovered query — the fail-open daemon path) while the doorbell
|
|
2464
|
+
// below still rides; lifecycle-off ⇒ `gateOpen: true` unconditionally and this line is inert.
|
|
2465
|
+
const occ = await lifecycleOccupancy(input);
|
|
2466
|
+
// G-iv-b: a covered labeled query ADDITIONALLY gains the realtime block. The daemon lease
|
|
2467
|
+
// above is unconditional (and its fields untouched) — room-serving only ever adds a field,
|
|
2468
|
+
// so an uncovered/unwired/legacy lease stays byte-identical and nothing here can block one.
|
|
2469
|
+
const rt = occ.gateOpen ? await maybeRoomServe(input, ast, context, subject) : undefined;
|
|
2470
|
+
if (rt !== undefined) res.realtime = rt;
|
|
2471
|
+
// I-v (§4.2): the gate CLOSED and a room plausibly hosted this scope — drain it and ride the
|
|
2472
|
+
// fence back so a room-attached client runs the GRACEFUL downgrade instead of the loud legacy
|
|
2473
|
+
// anomaly. A never-shared solo doc (`!roomPlausible`) never drains (no wasted room boot); a
|
|
2474
|
+
// daemon-attached client that receives a stray fence ignores it (its resolver reads only the
|
|
2475
|
+
// daemon fields). `drainRoom` absent/throwing ⇒ no fence (fail-open, warn-once).
|
|
2476
|
+
if (!occ.gateOpen && occ.roomPlausible && occ.doc !== undefined) {
|
|
2477
|
+
const fence = await maybeDrainRoom(input.name, occ.doc);
|
|
2478
|
+
if (fence !== undefined) res.realtimeFence = fence;
|
|
2479
|
+
}
|
|
2480
|
+
// I-iii: under the opt-in `realtime.lifecycle` config a LABELED lease additionally gains the
|
|
2481
|
+
// §4 system-stream block (doorbell always; the fence bundle iff room-served OR downgrade-fenced
|
|
2482
|
+
// — a downgrading client needs the watermark/ledger/outcome streams to run the ghost drop).
|
|
2483
|
+
// Same additive discipline as the realtime block: absent config ⇒ byte-identical response.
|
|
2484
|
+
const lc = await maybeLifecycle(input, rt !== undefined || res.realtimeFence !== undefined, subject, routingKey);
|
|
2485
|
+
if (lc !== undefined) res.lifecycle = lc;
|
|
2486
|
+
return res;
|
|
1590
2487
|
};
|
|
1591
2488
|
|
|
1592
2489
|
const readQuery = async (input: QueryReadRequest<User>): Promise<QueryReadResponse> => {
|
|
@@ -1628,23 +2525,80 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
1628
2525
|
} catch (err) {
|
|
1629
2526
|
return reject(backend, input.envelope, errMessage(err));
|
|
1630
2527
|
}
|
|
1631
|
-
|
|
1632
|
-
|
|
2528
|
+
const mctx: MutationContext<User> = {
|
|
2529
|
+
user: input.user,
|
|
2530
|
+
envelope: input.envelope,
|
|
2531
|
+
daemon: opts.daemon,
|
|
2532
|
+
request: input.request,
|
|
2533
|
+
};
|
|
2534
|
+
|
|
2535
|
+
// SCOPED mutator (WORK-OUTSIDE-TX): the author controls the tx boundary via `scope.transact`,
|
|
2536
|
+
// running server-only code before/after it. The `lmid`-always-advances invariant is OURS, not
|
|
2537
|
+
// the author's — we seal the response from the scope's recorded state, so an early return, a
|
|
2538
|
+
// never-called transact, or a swallowed rejection can't wedge the client's pending queue.
|
|
2539
|
+
if (isScoped<User>(mutator)) {
|
|
2540
|
+
const scope = new MutationScopeImpl(backend, input.envelope, renderIndex);
|
|
2541
|
+
// A throw that reaches `settle` AFTER the outcome is already sealed (post-commit effect, or a
|
|
2542
|
+
// compensation handler after a business rejection) can't change the response — but it must not
|
|
2543
|
+
// vanish. Route it to the app's hook, else log so a failed refund is never fully silent.
|
|
2544
|
+
const reportSealed = (err: unknown, phase: "committed" | "rejected"): void => {
|
|
2545
|
+
if (opts.onScopeError) opts.onScopeError(err, { phase, envelope: input.envelope });
|
|
2546
|
+
else console.error(`[rindle api-server] scoped mutator ${input.envelope.name}: post-${phase} code threw (outcome already sealed):`, err);
|
|
2547
|
+
};
|
|
2548
|
+
// Derive the response from the scope's OUTCOME (not the body's return), so control flow in the
|
|
2549
|
+
// author's function can't skip the lmid advance. `caught` distinguishes "the body threw"
|
|
2550
|
+
// (present, even if the thrown value was `undefined`) from "it returned cleanly".
|
|
2551
|
+
const settle = async (caught?: { err: unknown }): Promise<PushMutationResponse> => {
|
|
2552
|
+
// Seal from the REAL outcome even if the author forgot to `await` transact: draining its
|
|
2553
|
+
// in-flight promise here records the outcome/infra before we read it (else a phantom no-op
|
|
2554
|
+
// ships while the real write commits out-of-band). Already-resolved when it WAS awaited.
|
|
2555
|
+
if (scope.pending) await scope.pending;
|
|
2556
|
+
// Infra always wins: the backend threw, the commit state is unknown — never advance lmid.
|
|
2557
|
+
// Keyed on the latched BOOLEAN, so a driver that rejects with a falsy value is still infra.
|
|
2558
|
+
if (scope.infraLatched) throw scope.infra;
|
|
2559
|
+
// transact resolved (committed OR business-rejected): seal from its recorded outcome. A
|
|
2560
|
+
// post-commit / post-reject-compensation throw can't change the sealed outcome (its effects
|
|
2561
|
+
// can't roll the tx back, and lmid already advanced §2.4). Rethrowing the MutationRejected is
|
|
2562
|
+
// the sanctioned "compensated, stay rejected" signal — expected, not surfaced. Any OTHER throw
|
|
2563
|
+
// (a FAILED refund, a post-commit effect) must not vanish — surface it.
|
|
2564
|
+
if (scope.outcome) {
|
|
2565
|
+
if (caught && !(caught.err instanceof MutationRejected)) {
|
|
2566
|
+
reportSealed(caught.err, scope.outcome.accepted ? "committed" : "rejected");
|
|
2567
|
+
}
|
|
2568
|
+
return outcomeToResponse(scope.outcome);
|
|
2569
|
+
}
|
|
2570
|
+
// Never transacted:
|
|
2571
|
+
if (caught) {
|
|
2572
|
+
// A throw before/around transact. A BackendError is the author signaling INFRA (retry);
|
|
2573
|
+
// any other throw is a BUSINESS rejection — advance lmid alone so the prediction snaps back.
|
|
2574
|
+
if (caught.err instanceof BackendError) throw caught.err.driverError;
|
|
2575
|
+
return reject(backend, input.envelope, errMessage(caught.err));
|
|
2576
|
+
}
|
|
2577
|
+
// Clean return with no transact — an accepted no-op that STILL advances lmid (the client
|
|
2578
|
+
// predicted a write; its pending entry must resolve).
|
|
2579
|
+
return outcomeToResponse(
|
|
2580
|
+
await backend.runMutation({ envelope: input.envelope, render: renderIndex, run: async () => {} }),
|
|
2581
|
+
);
|
|
2582
|
+
};
|
|
2583
|
+
try {
|
|
2584
|
+
await (mutator as unknown as ScopedMutator<User, unknown>)(scope, input.envelope.args as never, mctx);
|
|
2585
|
+
} catch (err) {
|
|
2586
|
+
return settle({ err });
|
|
2587
|
+
}
|
|
2588
|
+
return settle();
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
// Run the (tx-form) mutator INSIDE the backend's transaction. A throw from the mutator body is a
|
|
2592
|
+
// business rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
|
|
1633
2593
|
const outcome = await backend.runMutation({
|
|
1634
2594
|
envelope: input.envelope,
|
|
1635
2595
|
render: renderIndex,
|
|
1636
2596
|
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
|
-
});
|
|
2597
|
+
const result = await mutator(tx, input.envelope.args as never, mctx);
|
|
1643
2598
|
applyResultToTx(result, tx);
|
|
1644
2599
|
},
|
|
1645
2600
|
});
|
|
1646
|
-
|
|
1647
|
-
return { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
|
|
2601
|
+
return outcomeToResponse(outcome);
|
|
1648
2602
|
};
|
|
1649
2603
|
|
|
1650
2604
|
const pushMutations = async (input: PushMutationsRequest<User>): Promise<PushMutationResponse[]> => {
|
|
@@ -1702,10 +2656,60 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
1702
2656
|
}
|
|
1703
2657
|
};
|
|
1704
2658
|
|
|
2659
|
+
// The explicit coverage diagnostic (the assertPins pattern: system-level, resolved under
|
|
2660
|
+
// `pinUser`, per-query failures collected — never strand the rest). It runs the REAL check —
|
|
2661
|
+
// the daemon's /cover-check on the actually-resolved ASTs — so its verdicts are exactly the
|
|
2662
|
+
// lease path's, minus the serving wiring (locateRoom/roomTokenKey), which it deliberately
|
|
2663
|
+
// ignores: it answers "is this labeled query coverable", the deployable-config question.
|
|
2664
|
+
const validateRealtime = async (vopts?: {
|
|
2665
|
+
exemplars?: Partial<Record<string, readonly unknown[]>>;
|
|
2666
|
+
strict?: boolean;
|
|
2667
|
+
}): Promise<ValidateRealtimeReport> => {
|
|
2668
|
+
const context: ApiContext<User> = { user: opts.pinUser as User, request: undefined };
|
|
2669
|
+
const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
|
|
2670
|
+
const verdicts: ValidateRealtimeVerdict[] = [];
|
|
2671
|
+
for (const [name, q] of Object.entries(opts.queries ?? {})) {
|
|
2672
|
+
const label = queryRealtimeLabel(q);
|
|
2673
|
+
if (label === undefined) continue;
|
|
2674
|
+
const profile = roomProfiles.get(label.room);
|
|
2675
|
+
if (profile === undefined) continue; // unreachable: construction asserted it exists
|
|
2676
|
+
for (const args of vopts?.exemplars?.[name] ?? [null]) {
|
|
2677
|
+
const verdict: ValidateRealtimeVerdict = { query: name, profile: profile.name, args, covered: false };
|
|
2678
|
+
try {
|
|
2679
|
+
const ast = await resolveAst(name, args, context);
|
|
2680
|
+
const roomArgs = label.args !== undefined ? label.args(args) : args;
|
|
2681
|
+
const footprintAst = queryResultToAst(await profile.footprint(profile.key(roomArgs), context));
|
|
2682
|
+
assertUnwindowedFootprint(footprintAst, profile.name);
|
|
2683
|
+
if (astHasAggregate(ast)) {
|
|
2684
|
+
verdict.reasons = [AGGREGATE_REFUSAL];
|
|
2685
|
+
} else if (coverQuery === undefined) {
|
|
2686
|
+
verdict.reasons = ["the configured daemon client does not implement coverQuery (/cover-check)"];
|
|
2687
|
+
} else {
|
|
2688
|
+
const out = await coverQuery({ footprint: footprintAst, query: ast });
|
|
2689
|
+
verdict.covered = out.covered;
|
|
2690
|
+
if (!out.covered) verdict.reasons = out.reasons ?? ["not provably covered"];
|
|
2691
|
+
}
|
|
2692
|
+
} catch (e) {
|
|
2693
|
+
verdict.reasons = [errMessage(e)];
|
|
2694
|
+
}
|
|
2695
|
+
verdicts.push(verdict);
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
const uncovered = verdicts.filter((v) => !v.covered);
|
|
2699
|
+
if (vopts?.strict && uncovered.length > 0) {
|
|
2700
|
+
throw new Error(
|
|
2701
|
+
`validateRealtime: ${uncovered.length} labeled query verdict(s) not provably covered — ` +
|
|
2702
|
+
uncovered
|
|
2703
|
+
.map((v) => `${v.query} (profile "${v.profile}"): ${(v.reasons ?? []).join("; ")}`)
|
|
2704
|
+
.join(" | "),
|
|
2705
|
+
);
|
|
2706
|
+
}
|
|
2707
|
+
return { verdicts, uncovered };
|
|
2708
|
+
};
|
|
2709
|
+
|
|
1705
2710
|
// The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
|
|
1706
2711
|
// the `realtime` block (which also activates `/room-boot`) or the deprecated bare
|
|
1707
2712
|
// `authorizeRoom` (trio only). Hosting an authority is never a default.
|
|
1708
|
-
const realtime = opts.realtime;
|
|
1709
2713
|
const roomAuthorizer: Authorizer<ApiContext<User>> | undefined = realtime
|
|
1710
2714
|
? (realtime.authorize ?? defaultFlushGate(realtime.shellSecret))
|
|
1711
2715
|
: opts.authorizeRoom;
|
|
@@ -1716,6 +2720,40 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
1716
2720
|
await assertAuthorized(roomAuthorizer, context);
|
|
1717
2721
|
};
|
|
1718
2722
|
|
|
2723
|
+
// §2.1 room-key routing: a "<profile>/<key>" doc resolves through its NAMED profile — with the
|
|
2724
|
+
// boot-time unwindowed backstop (§2.3), which covers footprints that weren't statically
|
|
2725
|
+
// resolvable at construction AND key-dependent branches that window only some docs. Anything
|
|
2726
|
+
// else falls through to the legacy single-profile alias BYTE-IDENTICALLY (the anonymous
|
|
2727
|
+
// profile, bare-key form). A named profile wins over a legacy doc that merely contains "/".
|
|
2728
|
+
// Returns the profile's context set beside the AST (H-iv-b: the scope-spec compilation needs
|
|
2729
|
+
// the §2.2 owned/followed split; the legacy anonymous profile has no declaration — empty set).
|
|
2730
|
+
const resolveRoomFootprint = async (
|
|
2731
|
+
rt: RindleRealtimeOptions<User>,
|
|
2732
|
+
doc: string,
|
|
2733
|
+
context: ApiContext<User>,
|
|
2734
|
+
): Promise<{ ast: Ast; contextTables: ReadonlySet<string> }> => {
|
|
2735
|
+
const split = splitRoomDoc(doc);
|
|
2736
|
+
if (split !== undefined) {
|
|
2737
|
+
const profile = roomProfiles.get(split.profile);
|
|
2738
|
+
if (profile !== undefined) {
|
|
2739
|
+
const ast = queryResultToAst(await profile.footprint(split.key, context));
|
|
2740
|
+
assertUnwindowedFootprint(ast, profile.name);
|
|
2741
|
+
return { ast, contextTables: profile.context };
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
if (rt.resolveFootprint) {
|
|
2745
|
+
return {
|
|
2746
|
+
ast: queryResultToAst(await rt.resolveFootprint(doc, context)),
|
|
2747
|
+
contextTables: new Set<string>(),
|
|
2748
|
+
};
|
|
2749
|
+
}
|
|
2750
|
+
throw new RindleApiError(
|
|
2751
|
+
"not-found",
|
|
2752
|
+
`no room profile matches doc "${doc}" — named profiles are addressed as "<profile>/<key>"`,
|
|
2753
|
+
404,
|
|
2754
|
+
);
|
|
2755
|
+
};
|
|
2756
|
+
|
|
1719
2757
|
// The store's verdict rides specific statuses + body shapes (fence / conflict /
|
|
1720
2758
|
// identity) the room decodes — pass a daemon HTTP error through VERBATIM.
|
|
1721
2759
|
const daemonVerdict = (e: unknown): RoomHostResponse => {
|
|
@@ -1736,6 +2774,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
1736
2774
|
createQueryLease,
|
|
1737
2775
|
readQuery,
|
|
1738
2776
|
assertPins,
|
|
2777
|
+
validateRealtime,
|
|
1739
2778
|
pushMutation,
|
|
1740
2779
|
pushMutations,
|
|
1741
2780
|
handleApplyRowChangeTxnJson: async (body, context) => {
|
|
@@ -1765,6 +2804,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
1765
2804
|
handleRoomLmidsJson: async (body, context) => {
|
|
1766
2805
|
await roomGate(context);
|
|
1767
2806
|
const msg = parseObject(body, "room-lmids request");
|
|
2807
|
+
const doc = parseString(msg.doc, "doc");
|
|
1768
2808
|
if (!Array.isArray(msg.clients) || msg.clients.some((c) => typeof c !== "string")) {
|
|
1769
2809
|
throw new RindleApiError("bad-request", "clients must be an array of strings", 400);
|
|
1770
2810
|
}
|
|
@@ -1773,7 +2813,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
1773
2813
|
throw new Error("the configured daemon client does not implement roomLmids");
|
|
1774
2814
|
}
|
|
1775
2815
|
try {
|
|
1776
|
-
return { status: 200, body: await lmids({ clients: msg.clients as string[] }) };
|
|
2816
|
+
return { status: 200, body: await lmids({ doc, clients: msg.clients as string[] }) };
|
|
1777
2817
|
} catch (e) {
|
|
1778
2818
|
return daemonVerdict(e);
|
|
1779
2819
|
}
|
|
@@ -1786,7 +2826,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
1786
2826
|
const msg = parseObject(body, "room-boot request");
|
|
1787
2827
|
const doc = parseString(msg.doc, "doc");
|
|
1788
2828
|
if (msg.instance !== undefined) parseString(msg.instance, "instance"); // diagnostic identity only
|
|
1789
|
-
const ast =
|
|
2829
|
+
const { ast, contextTables } = await resolveRoomFootprint(realtime, doc, context);
|
|
1790
2830
|
const claim = opts.daemon.claimRoomEpoch?.bind(opts.daemon);
|
|
1791
2831
|
if (!claim) {
|
|
1792
2832
|
throw new Error("the configured daemon client does not implement claimRoomEpoch");
|
|
@@ -1814,6 +2854,9 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
|
|
|
1814
2854
|
const res: RoomBootResponse = {
|
|
1815
2855
|
epoch,
|
|
1816
2856
|
upstreamLeaseToken: lease.leaseToken,
|
|
2857
|
+
// H-iv-b: the §3.3 commit-gate scope specs, for named-profile AND legacy docs alike
|
|
2858
|
+
// (the footprint AST is resolved either way; legacy has an empty context set).
|
|
2859
|
+
scopes: compileRoomScopeSpecs(ast, contextTables),
|
|
1817
2860
|
flush: {
|
|
1818
2861
|
urls: {
|
|
1819
2862
|
apply: routes.applyRowChangeTxn,
|
|
@@ -2034,6 +3077,145 @@ function errMessage(reason: unknown): string {
|
|
|
2034
3077
|
return String((reason as Error)?.message ?? reason);
|
|
2035
3078
|
}
|
|
2036
3079
|
|
|
3080
|
+
// --------------------------------------------------------- lifecycle system leases (Slice I-iii)
|
|
3081
|
+
|
|
3082
|
+
// The four §4 lifecycle system tables, mirrored VERBATIM from the daemon DDL — the source of
|
|
3083
|
+
// truth is `rust/rindle-replica/src/mutations.rs` (`realtime_lifecycle_ddl()` + the room-ledger
|
|
3084
|
+
// DDL in `enable_client_mutations`); duplicated here like `DEFAULT_ROUTES` is client-side so this
|
|
3085
|
+
// package needs no engine import. `Db::enable_realtime_lifecycle` REGISTERS all four, so a
|
|
3086
|
+
// hand-built AST over them materializes and resolves `hello` like any base table (the room-boot
|
|
3087
|
+
// direct-materialize pattern).
|
|
3088
|
+
const SCOPE_SESSIONS_TABLE = "_rindle_scope_sessions";
|
|
3089
|
+
const ROOM_WATERMARK_TABLE = "_rindle_room_watermark";
|
|
3090
|
+
const ROOM_CLIENT_MUTATIONS_TABLE = "_rindle_room_client_mutations";
|
|
3091
|
+
const ROOM_MUTATION_OUTCOMES_TABLE = "_rindle_room_mutation_outcomes";
|
|
3092
|
+
|
|
3093
|
+
// --------------------------------------------------------- occupancy counting (Slice I-iv, §4.1)
|
|
3094
|
+
//
|
|
3095
|
+
// The occupancy step rides the NORMAL surfaces end to end: the session upsert + lazy sweep are one
|
|
3096
|
+
// `executeSqlTxn` (a plain write txn — CDC-captured since I-i, so the row landing IS the doorbell
|
|
3097
|
+
// delta fanning to every subscribed solo client; no clientID/mid — a system write must never
|
|
3098
|
+
// advance an lmid — and no idempotencyKey — a renewal's re-upsert must re-run, that is the
|
|
3099
|
+
// refresh), and the count is one `executeSqlRead` with `consistency: "strong"` — the read surface
|
|
3100
|
+
// the api-server already has against the daemon (the `DaemonLazyTx` fallback precedent above).
|
|
3101
|
+
// "strong" routes the read to the WRITE MASTER in a split deploy, which just serialized our
|
|
3102
|
+
// upsert: read-your-writes without a mutation session (the interactive-txn machinery is optional
|
|
3103
|
+
// on the daemon interface and far heavier than this two-round-trip pair needs).
|
|
3104
|
+
|
|
3105
|
+
/** Default {@link RindleRealtimeLifecycleOptions.minSessions} — the §4.1 1→2 trigger. */
|
|
3106
|
+
const DEFAULT_LIFECYCLE_MIN_SESSIONS = 2;
|
|
3107
|
+
/** Default {@link RindleRealtimeLifecycleOptions.graceMs} — the §9.1 hysteresis window. */
|
|
3108
|
+
const DEFAULT_LIFECYCLE_GRACE_MS = 120_000;
|
|
3109
|
+
/** Default {@link RindleRealtimeLifecycleOptions.sessionTtlMs} fallback when no `leaseTtlMs` is
|
|
3110
|
+
* configured either — 5 minutes, the {@link DEFAULT_ROOM_TOKEN_TTL_MS} cadence (see the field doc). */
|
|
3111
|
+
const DEFAULT_SESSION_TTL_MS = 5 * 60_000;
|
|
3112
|
+
/** Sweep slack past the grace window (D4): rows are deleted only once expired for MORE than
|
|
3113
|
+
* `graceMs + this` — the linger I-v's downgrade decision reads must comfortably outlive the
|
|
3114
|
+
* grace comparison itself under clock skew between api-server instances (occupancy tolerates
|
|
3115
|
+
* skew ≤ grace; the slack keeps the boundary case out of the deletable band). */
|
|
3116
|
+
const SESSION_SWEEP_SLACK_MS = 60_000;
|
|
3117
|
+
|
|
3118
|
+
/** D7 upsert: one row per (scope, clientId) — `(scope, client_id)` is the table's PRIMARY KEY
|
|
3119
|
+
* (`realtime_lifecycle_ddl()`), so a renewal refreshes `expires_at` in place. */
|
|
3120
|
+
const SESSION_UPSERT_SQL =
|
|
3121
|
+
`INSERT INTO ${SCOPE_SESSIONS_TABLE} (scope, client_id, expires_at) VALUES (?, ?, ?) ` +
|
|
3122
|
+
`ON CONFLICT(scope, client_id) DO UPDATE SET expires_at = excluded.expires_at`;
|
|
3123
|
+
/** The D4 lazy sweep, in the SAME txn as the upsert: age out THIS scope's long-expired rows.
|
|
3124
|
+
* Param 2 is `now − (graceMs + SESSION_SWEEP_SLACK_MS)` — never tighter (the linger contract). */
|
|
3125
|
+
const SESSION_SWEEP_SQL = `DELETE FROM ${SCOPE_SESSIONS_TABLE} WHERE scope = ? AND expires_at < ?`;
|
|
3126
|
+
/** The occupancy read, one SELECT: cell 0 = DISTINCT unexpired sessions (`expires_at > now`;
|
|
3127
|
+
* distinct by construction — `(scope, client_id)` is the PK), cell 1 = sessions expired WITHIN
|
|
3128
|
+
* the grace window (`now − graceMs < expires_at ≤ now`) — the upward hysteresis input, cell 2 =
|
|
3129
|
+
* ALL matching rows regardless of expiry (the I-v "room plausibly exists" signal: a scope with
|
|
3130
|
+
* ANY other-session row — live OR still lingering pre-sweep — was shared, so a room was
|
|
3131
|
+
* provisioned; a never-shared solo doc has none and must never drain). Params:
|
|
3132
|
+
* `[now, now, now − graceMs, scope]`. */
|
|
3133
|
+
const SESSION_COUNT_SQL =
|
|
3134
|
+
`SELECT SUM(CASE WHEN expires_at > ? THEN 1 ELSE 0 END), ` +
|
|
3135
|
+
`SUM(CASE WHEN expires_at <= ? AND expires_at > ? THEN 1 ELSE 0 END), ` +
|
|
3136
|
+
`COUNT(*) ` +
|
|
3137
|
+
`FROM ${SCOPE_SESSIONS_TABLE} WHERE scope = ?`;
|
|
3138
|
+
/** {@link SESSION_COUNT_SQL} excluding the CALLER's own row (D6 counts *other* sessions; the
|
|
3139
|
+
* caller contributes itself as the `+ 1`). One extra trailing param: the caller's clientId. */
|
|
3140
|
+
const SESSION_COUNT_OTHERS_SQL = `${SESSION_COUNT_SQL} AND client_id <> ?`;
|
|
3141
|
+
|
|
3142
|
+
/** `col = <string literal>` — the only predicate shape the lifecycle ASTs need. */
|
|
3143
|
+
function colEq(name: string, value: string): Condition {
|
|
3144
|
+
return { type: "simple", op: "=", left: { type: "column", name }, right: { type: "literal", value } };
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
/** The doorbell AST (§4.1): every unexpired row under the scope is one live session; the row
|
|
3148
|
+
* delta arriving through a solo client's daemon subscription IS the 1→2 upgrade signal. The
|
|
3149
|
+
* expiry filter is deliberately NOT in the predicate — `expires_at > now()` would freeze `now`
|
|
3150
|
+
* at mint time; liveness is the READER's judgment (I-iv), the stream just carries the rows. */
|
|
3151
|
+
function scopeSessionsAst(scope: string): Ast {
|
|
3152
|
+
return { table: SCOPE_SESSIONS_TABLE, where: colEq("scope", scope) };
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
/** The §4.2 fence AST: the doc's monotone `flush_seq` row. */
|
|
3156
|
+
function roomWatermarkAst(doc: string): Ast {
|
|
3157
|
+
return { table: ROOM_WATERMARK_TABLE, where: colEq("doc", doc) };
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
/** The §7.1 ledger / §3.3 outcome ASTs share one shape: doc-scoped, and ADDITIONALLY
|
|
3161
|
+
* client-scoped when the lease request carried the browser's stable `clientId` (the same id the
|
|
3162
|
+
* mutation envelopes stamp, so it is exactly the ledger/outcome `client_id`). Without it the
|
|
3163
|
+
* predicate stays doc-only and the client filters to its own rows (defense in depth either
|
|
3164
|
+
* way — the client always filters). */
|
|
3165
|
+
function docClientAst(table: string, doc: string, clientId: string | undefined): Ast {
|
|
3166
|
+
const docCond = colEq("doc", doc);
|
|
3167
|
+
return {
|
|
3168
|
+
table,
|
|
3169
|
+
where: clientId === undefined ? docCond : { type: "and", conditions: [docCond, colEq("client_id", clientId)] },
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
// --------------------------------------------------------- room-serve helpers (G-iv-b)
|
|
3174
|
+
|
|
3175
|
+
/** Default room lease token TTL: short (minutes) per RINDLE-REALTIME §4.1 — renewal is a fresh
|
|
3176
|
+
* lease through the api-server, never an extension of this token. */
|
|
3177
|
+
const DEFAULT_ROOM_TOKEN_TTL_MS = 5 * 60_000;
|
|
3178
|
+
|
|
3179
|
+
/** Deterministic JSON: object keys sorted recursively, so two structurally identical ASTs from
|
|
3180
|
+
* independent resolves stringify identically (the verdict-cache key). */
|
|
3181
|
+
function stableStringify(v: unknown): string {
|
|
3182
|
+
return JSON.stringify(v, (_key, value: unknown) => {
|
|
3183
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
3184
|
+
const rec = value as Record<string, unknown>;
|
|
3185
|
+
const sorted: Record<string, unknown> = {};
|
|
3186
|
+
for (const k of Object.keys(rec).sort()) sorted[k] = rec[k];
|
|
3187
|
+
return sorted;
|
|
3188
|
+
}
|
|
3189
|
+
return value;
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
/** Does the AST contain an aggregate/reduce shape ANYWHERE (root, a `related` subquery, or an
|
|
3194
|
+
* `EXISTS` child)? Room-serving refuses these regardless of coverage: the client's aggregate
|
|
3195
|
+
* overlay (AGGREGATE-SYNC) is computed against the DAEMON's normalized stream and stays
|
|
3196
|
+
* daemon-gated until post-G. (`groupBy`/`having` only occur alongside `aggregate`, so testing
|
|
3197
|
+
* `aggregate` covers them; `having` is still walked for nested EXISTS aggregates.) */
|
|
3198
|
+
function astHasAggregate(ast: Ast): boolean {
|
|
3199
|
+
if (ast.aggregate !== undefined) return true;
|
|
3200
|
+
for (const rel of ast.related ?? []) {
|
|
3201
|
+
if (astHasAggregate(rel.subquery)) return true;
|
|
3202
|
+
}
|
|
3203
|
+
return conditionHasAggregate(ast.where) || conditionHasAggregate(ast.having);
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
function conditionHasAggregate(cond: Condition | undefined): boolean {
|
|
3207
|
+
if (cond === undefined) return false;
|
|
3208
|
+
switch (cond.type) {
|
|
3209
|
+
case "simple":
|
|
3210
|
+
return false;
|
|
3211
|
+
case "and":
|
|
3212
|
+
case "or":
|
|
3213
|
+
return cond.conditions.some(conditionHasAggregate);
|
|
3214
|
+
case "correlatedSubquery":
|
|
3215
|
+
return astHasAggregate(cond.related.subquery);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
|
|
2037
3219
|
async function reject(
|
|
2038
3220
|
backend: MutationBackend,
|
|
2039
3221
|
envelope: MutationEnvelope,
|
|
@@ -2043,6 +3225,15 @@ async function reject(
|
|
|
2043
3225
|
return { accepted: false, rejected: true, reason, output };
|
|
2044
3226
|
}
|
|
2045
3227
|
|
|
3228
|
+
/** The single place a {@link MutationOutcome} becomes the wire {@link PushMutationResponse} — shared
|
|
3229
|
+
* by the tx-form path and every scoped-mutator seal branch so the accepted/rejected shape can never
|
|
3230
|
+
* drift between them. */
|
|
3231
|
+
function outcomeToResponse(outcome: MutationOutcome): PushMutationResponse {
|
|
3232
|
+
return outcome.accepted
|
|
3233
|
+
? { accepted: true, rejected: false, output: outcome.output }
|
|
3234
|
+
: { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
|
|
3235
|
+
}
|
|
3236
|
+
|
|
2046
3237
|
function parseObject(value: unknown, label: string): Record<string, unknown> {
|
|
2047
3238
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2048
3239
|
throw new RindleApiError("bad-request", `invalid ${label}`, 400);
|