@rindle/api-server 0.4.4 → 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/dist/index.d.ts +326 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +854 -22
- 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 +1213 -23
- package/src/rooms.ts +578 -0
package/dist/index.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
import { driveMutationAsync, insertCell, insertPlan, isoTx, toCell } from "@rindle/client";
|
|
1
|
+
import { driveMutationAsync, insertCell, insertPlan, isGeneratorMutator, isoTx, toCell } from "@rindle/client";
|
|
2
2
|
import { DaemonHttpError } from "@rindle/daemon-client";
|
|
3
3
|
import { compile as compileQueryAst } from "@rindle/query-compiler";
|
|
4
|
+
import { assertLabeledProfilesExist, assertUnwindowedFootprint, attachRealtimeLabel, compileRoomProfiles, compileRoomScopeSpecs, compileRoomTableSpecs, mintRoomDoc, queryRealtimeLabel, queryResultToAst, splitRoomDoc, } from "./rooms.js";
|
|
5
|
+
let roomTokenModule;
|
|
6
|
+
async function loadRoomTokenModule() {
|
|
7
|
+
if (roomTokenModule === undefined) {
|
|
8
|
+
const m = await import("@rindle/room/token");
|
|
9
|
+
roomTokenModule = { mintRoomToken: m.mintRoomToken, scopeSpecsHash: m.scopeSpecsHash };
|
|
10
|
+
}
|
|
11
|
+
return roomTokenModule;
|
|
12
|
+
}
|
|
4
13
|
// Re-export the shared (generator) mutator seam so an app builds its server mutators from ONE import:
|
|
5
14
|
// co-locate each body with its arg schema (`shared`), bulk-drive the registry ({@link sharedApiMutators}),
|
|
6
15
|
// keeping only server-only authority as explicit overrides (see MUTATORS-ISOMORPHIC).
|
|
7
16
|
export { isoTx, shared } from "@rindle/client";
|
|
17
|
+
// The room-profile declaration layer (RINDLE-REALTIME-QUERY-ENABLEMENT §2, slice G-iv-a). The
|
|
18
|
+
// compiled-profile shapes stay internal to `./rooms.ts` — G-iv-b consumes them in-package.
|
|
19
|
+
// `RoomTableSpec` (G-iv-b) is public: it rides the lease wire (`QueryLeaseResponse.realtime`).
|
|
20
|
+
// `RoomScopeSpec` (H-iv-b) is public: it rides the boot wire (`RoomBootResponse.scopes`).
|
|
21
|
+
export { queryRealtimeLabel, queryResultToAst } from "./rooms.js";
|
|
8
22
|
export const DEFAULT_RINDLE_API_ROUTES = {
|
|
9
23
|
query: "/api/rindle/query",
|
|
10
24
|
read: "/api/rindle/read",
|
|
@@ -17,6 +31,58 @@ export const DEFAULT_RINDLE_API_ROUTES = {
|
|
|
17
31
|
// The DO shell's cold-boot callback (§10.1) — active only when `realtime` is configured.
|
|
18
32
|
roomBoot: "/api/rindle/room-boot",
|
|
19
33
|
};
|
|
34
|
+
// --------------------------------------------------------------------------- scoped (outside-tx) mutators
|
|
35
|
+
//
|
|
36
|
+
// The tx-form {@link ApiMutator} above runs ENTIRELY inside the transaction. A SCOPED mutator
|
|
37
|
+
// (WORK-OUTSIDE-TX) instead controls the boundary itself: it receives a {@link MutationScope}, runs
|
|
38
|
+
// server-only code BEFORE opening the one atomic transaction (`scope.transact`), and MAY run code
|
|
39
|
+
// AFTER it commits. The outside-tx code is server-only by nature (the client's optimistic prediction
|
|
40
|
+
// can't call Stripe), so it lives HERE, never in the isomorphic body — the shared generator stays
|
|
41
|
+
// pure and identical on both tiers; server-computed values flow into it through `ctx`, exactly like
|
|
42
|
+
// `ctx.user` (undefined/predicted on the client, authoritative here).
|
|
43
|
+
/** Thrown by {@link MutationScope.transact} when the transacted body BUSINESS-rejects: the data
|
|
44
|
+
* rolled back and `lmid` advanced alone (§2.4). Catch it to COMPENSATE an outside-tx side effect
|
|
45
|
+
* (refund the charge), then rethrow or return — the mutation's protocol outcome is already sealed
|
|
46
|
+
* as rejected, so a post-reject throw can't change it. A DB/infra failure is NOT this — it
|
|
47
|
+
* propagates as the raw driver error (the client retries; `lmid` did not advance). */
|
|
48
|
+
export class MutationRejected extends Error {
|
|
49
|
+
reason;
|
|
50
|
+
constructor(reason) {
|
|
51
|
+
super(reason);
|
|
52
|
+
this.name = "MutationRejected";
|
|
53
|
+
this.reason = reason;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Mark a mutator as SCOPED so the api-server gives it a {@link MutationScope} (author-controlled tx
|
|
57
|
+
* boundary via `scope.transact`) rather than running its whole body inside the transaction. Register
|
|
58
|
+
* it alongside the tx-form mutators — it wins by key like any override:
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* mutators: defineApiMutators({
|
|
62
|
+
* ...sharedApiMutators(sharedMutators, sharedCtx), // tx-form (common case)
|
|
63
|
+
* createOrder: scoped(async (scope, raw, ctx) => { // needs outside-tx work
|
|
64
|
+
* const args = createOrder.args.parse(raw);
|
|
65
|
+
* const chargeId = await stripe.charge(args.amount, { idempotencyKey: ctx.envelope.mid }); // outside tx
|
|
66
|
+
* try {
|
|
67
|
+
* await scope.transact(createOrder, args, { ...sharedCtx(ctx), chargeId }); // inside tx
|
|
68
|
+
* } catch (e) {
|
|
69
|
+
* await stripe.refund(chargeId); // compensate — the write rejected
|
|
70
|
+
* throw e;
|
|
71
|
+
* }
|
|
72
|
+
* await sendReceipt(ctx.user); // after commit
|
|
73
|
+
* }),
|
|
74
|
+
* }),
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export function scoped(fn) {
|
|
78
|
+
// The brand carries the scoped runtime shape; typing the RETURN as the (branded) tx-form keeps it
|
|
79
|
+
// assignable into `mutators` WITHOUT unioning that record — the harness routes on the brand and
|
|
80
|
+
// never calls it as a tx-form mutator, so the cast is sound.
|
|
81
|
+
return Object.assign(fn, { __rindleScoped: true });
|
|
82
|
+
}
|
|
83
|
+
function isScoped(m) {
|
|
84
|
+
return m.__rindleScoped === true;
|
|
85
|
+
}
|
|
20
86
|
// The DEFAULT flush credential: `rfc1.<b64url payload>.<b64url hmac-sha256>`, payload
|
|
21
87
|
// `{v:1, doc, epoch, iat}`. Deliberately EPOCH-bound, not time-bound: the credential's lifecycle
|
|
22
88
|
// IS the placement fence (§8.3 — a superseded epoch's flushes 409 at the store no matter what
|
|
@@ -182,6 +248,14 @@ export class SplitDaemonClient {
|
|
|
182
248
|
return Promise.reject(new Error("the write master lacks roomLmids"));
|
|
183
249
|
return lmids(input);
|
|
184
250
|
}
|
|
251
|
+
// Pure computation, but routed to the MASTER like the rest of the room ops: the master is a
|
|
252
|
+
// real rindled that hosts `/cover-check`; the read router may not proxy it.
|
|
253
|
+
coverQuery(input) {
|
|
254
|
+
const cover = this.writes.coverQuery?.bind(this.writes);
|
|
255
|
+
if (!cover)
|
|
256
|
+
return Promise.reject(new Error("the write master lacks coverQuery"));
|
|
257
|
+
return cover(input);
|
|
258
|
+
}
|
|
185
259
|
migrate(input) {
|
|
186
260
|
return this.writes.migrate(input);
|
|
187
261
|
}
|
|
@@ -851,7 +925,11 @@ export function registerQueries(queries) {
|
|
|
851
925
|
if (Object.prototype.hasOwnProperty.call(out, query.queryName)) {
|
|
852
926
|
throw new Error(`registerQueries: duplicate query name "${query.queryName}"`);
|
|
853
927
|
}
|
|
854
|
-
|
|
928
|
+
const wrapped = (ctx, args) => query.resolve(args, ctx);
|
|
929
|
+
// The §2.1 realtime label survives this seam (read it back with {@link queryRealtimeLabel}) —
|
|
930
|
+
// the lease path looks up (room profile, args mapping) by query name. Unlabeled queries get
|
|
931
|
+
// the exact bare wrapper they always did.
|
|
932
|
+
out[query.queryName] = query.realtime === undefined ? wrapped : attachRealtimeLabel(wrapped, query.realtime);
|
|
855
933
|
}
|
|
856
934
|
return out;
|
|
857
935
|
}
|
|
@@ -886,11 +964,42 @@ export function sharedApiMutators(registry, principal) {
|
|
|
886
964
|
}
|
|
887
965
|
return out;
|
|
888
966
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
967
|
+
/**
|
|
968
|
+
* Wrap a SHARED (generator) mutator with a row-level ACCESS GUARD — the multi-tenant authz twin of
|
|
969
|
+
* {@link sharedApiMutators}. It parses the untrusted wire args, derives the {@link MutatorCtx}
|
|
970
|
+
* principal (the SAME mapping you pass to `sharedApiMutators`), evaluates `predicate` against the OPEN
|
|
971
|
+
* mutation txn (so it can READ the rows the write depends on), and throws `forbidden` (403 — the
|
|
972
|
+
* client's optimistic write snaps back) when access is denied; otherwise it drives the SAME body the
|
|
973
|
+
* client predicts ({@link runSharedMutation}). Use it for the entries that need server-only authority
|
|
974
|
+
* the client cannot predict, OVERRIDING the auto-wrapped default (spread `sharedApiMutators(...)`
|
|
975
|
+
* first, then the guarded overrides win by key):
|
|
976
|
+
*
|
|
977
|
+
* ```ts
|
|
978
|
+
* const principal = (ctx) => ({ user: requireUser(ctx.user) });
|
|
979
|
+
* mutators: defineApiMutators({
|
|
980
|
+
* ...sharedApiMutators(sharedMutators, principal),
|
|
981
|
+
* updateSlide: guardMutator(sharedMutators.updateSlide, principal,
|
|
982
|
+
* async (tx, a, { user }) =>
|
|
983
|
+
* (await tx.query(q.slide.where.id(a.slideId).where(editableBy(user)).one())) != null,
|
|
984
|
+
* { message: "not permitted to edit this slide" }),
|
|
985
|
+
* }),
|
|
986
|
+
* ```
|
|
987
|
+
*
|
|
988
|
+
* The predicate keeps the shared body READ-FREE, so the client's `.folded` hot paths (drag/keystroke)
|
|
989
|
+
* still fold — the read is server-side only. Return `false` to deny (→ the default or `opts.message`
|
|
990
|
+
* forbidden); return `true`/nothing to allow. To reject with a different status/message (a business
|
|
991
|
+
* rejection, a not-found), throw a {@link RindleApiError} from inside the predicate instead. `principal`
|
|
992
|
+
* runs before the predicate, so it too may throw `forbidden` for an anonymous caller.
|
|
993
|
+
*/
|
|
994
|
+
export function guardMutator(gen, principal, predicate, opts) {
|
|
995
|
+
return async (tx, raw, ctx) => {
|
|
996
|
+
const args = gen.args.parse(raw);
|
|
997
|
+
const pctx = principal(ctx);
|
|
998
|
+
if ((await predicate(tx, args, pctx)) === false) {
|
|
999
|
+
throw new RindleApiError("forbidden", opts?.message ?? "not permitted", 403);
|
|
1000
|
+
}
|
|
1001
|
+
return runSharedMutation(gen, args, pctx, tx);
|
|
1002
|
+
};
|
|
894
1003
|
}
|
|
895
1004
|
/**
|
|
896
1005
|
* Dump every registered named query's wire AST — feeder 1 ("exemplar enumeration") of
|
|
@@ -968,6 +1077,76 @@ function normalizeCondition(c) {
|
|
|
968
1077
|
};
|
|
969
1078
|
}
|
|
970
1079
|
}
|
|
1080
|
+
/**
|
|
1081
|
+
* The runtime {@link MutationScope} handed to a {@link ScopedMutator}. It owns the single atomic
|
|
1082
|
+
* transaction (delegating to {@link MutationBackend.runMutation} — the exact machinery a tx-form
|
|
1083
|
+
* mutator uses), but lets the AUTHOR decide when it opens, so server-only work can run outside it.
|
|
1084
|
+
*
|
|
1085
|
+
* It records its outcome so the harness — not the author — enforces the `lmid`-always-advances
|
|
1086
|
+
* invariant: `phase` reports whether the tx committed, business-rejected, or never ran, and `infra`
|
|
1087
|
+
* latches a backend (DB) failure. Because the backend's `runMutation` RETURNS `{accepted:false}` for
|
|
1088
|
+
* a business rejection (having already advanced `lmid` alone) and THROWS only for infra, `transact`
|
|
1089
|
+
* can cleanly re-throw {@link MutationRejected} on the former (for author compensation) and propagate
|
|
1090
|
+
* the raw driver error on the latter.
|
|
1091
|
+
*/
|
|
1092
|
+
class MutationScopeImpl {
|
|
1093
|
+
attempted = false;
|
|
1094
|
+
backend;
|
|
1095
|
+
envelope;
|
|
1096
|
+
render;
|
|
1097
|
+
/** Set once `transact` resolved through the backend (accepted OR business-rejected). */
|
|
1098
|
+
outcome;
|
|
1099
|
+
/** The value the backend threw on INFRA (the DB failed) — always propagated, never an `lmid`
|
|
1100
|
+
* advance. Its presence is tracked by {@link infraLatched}, NOT by testing this for `undefined`:
|
|
1101
|
+
* a driver may legitimately reject with a falsy value, and misreading that as "no infra" would
|
|
1102
|
+
* reclassify a lost-connection failure as a business rejection and wrongly advance `lmid`. */
|
|
1103
|
+
infra;
|
|
1104
|
+
/** True once an INFRA failure latched, regardless of its (possibly falsy) value. */
|
|
1105
|
+
infraLatched = false;
|
|
1106
|
+
/** The in-flight `transact` promise. `settle` awaits it before sealing, so a transact the author
|
|
1107
|
+
* FORGOT to await (a floating promise — nothing here lints against it) is still resolved to its
|
|
1108
|
+
* real outcome first; otherwise the seal would read `untouched`, reply with a phantom no-op, and
|
|
1109
|
+
* let the real write commit out-of-band after the response was already sent. */
|
|
1110
|
+
pending;
|
|
1111
|
+
constructor(backend, envelope, render) {
|
|
1112
|
+
this.backend = backend;
|
|
1113
|
+
this.envelope = envelope;
|
|
1114
|
+
this.render = render;
|
|
1115
|
+
}
|
|
1116
|
+
transact(first, args, ctx) {
|
|
1117
|
+
if (this.attempted)
|
|
1118
|
+
throw new Error("scope.transact may be called at most once per mutation");
|
|
1119
|
+
this.attempted = true;
|
|
1120
|
+
const promise = this.drive(first, args, ctx);
|
|
1121
|
+
// Record the in-flight promise so `settle` can await it even when the author didn't. Errors are
|
|
1122
|
+
// latched onto `this` (outcome / infra), so this tracking copy swallows them — the author's
|
|
1123
|
+
// returned `promise` still rejects for them to await/catch.
|
|
1124
|
+
this.pending = promise.then(() => undefined, () => undefined);
|
|
1125
|
+
return promise;
|
|
1126
|
+
}
|
|
1127
|
+
async drive(first, args, ctx) {
|
|
1128
|
+
// A shared (generator) mutator is driven via the isomorphic seam; a plain callback gets the raw tx.
|
|
1129
|
+
const run = isGeneratorMutator(first)
|
|
1130
|
+
? async (tx) => {
|
|
1131
|
+
await runSharedMutation(first, args, ctx, tx);
|
|
1132
|
+
}
|
|
1133
|
+
: async (tx) => {
|
|
1134
|
+
await first(tx);
|
|
1135
|
+
};
|
|
1136
|
+
let outcome;
|
|
1137
|
+
try {
|
|
1138
|
+
outcome = await this.backend.runMutation({ envelope: this.envelope, render: this.render, run });
|
|
1139
|
+
}
|
|
1140
|
+
catch (err) {
|
|
1141
|
+
this.infra = err; // the backend throws ONLY for infra; a business rejection returns {accepted:false}
|
|
1142
|
+
this.infraLatched = true;
|
|
1143
|
+
throw err;
|
|
1144
|
+
}
|
|
1145
|
+
this.outcome = outcome;
|
|
1146
|
+
if (!outcome.accepted)
|
|
1147
|
+
throw new MutationRejected(outcome.reason);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
971
1150
|
export function createRindleApiServer(opts) {
|
|
972
1151
|
const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
|
|
973
1152
|
const mode = opts.mode ?? "normalized";
|
|
@@ -980,6 +1159,22 @@ export function createRindleApiServer(opts) {
|
|
|
980
1159
|
// Names that are ALSO configured pins — a lease for one is forced to a `pinned` policy (the lazy
|
|
981
1160
|
// floor, §4.1) so the first viewer to route to a follower warms it for late joiners.
|
|
982
1161
|
const pinnedNames = new Set((opts.pinnedQueries ?? []).map((p) => p.name));
|
|
1162
|
+
// Rindle Realtime declaration layer (RINDLE-REALTIME-QUERY-ENABLEMENT §2, slice G-iv-a):
|
|
1163
|
+
// compile the named room profiles and run every "loud at registration" (§2.3) check NOW —
|
|
1164
|
+
// construction is the moment a misconfigured profile or label can still fail the deploy,
|
|
1165
|
+
// not a 3am room boot. The legacy flat `resolveFootprint` stays the anonymous profile and
|
|
1166
|
+
// is deliberately NOT probed or validated (byte-identical legacy behavior).
|
|
1167
|
+
const realtime = opts.realtime;
|
|
1168
|
+
const roomProfiles = compileRoomProfiles({
|
|
1169
|
+
rooms: realtime?.rooms,
|
|
1170
|
+
schema: opts.schema,
|
|
1171
|
+
warn: realtime?.warn,
|
|
1172
|
+
});
|
|
1173
|
+
assertLabeledProfilesExist(opts.queries, roomProfiles);
|
|
1174
|
+
if (realtime !== undefined && roomProfiles.size === 0 && realtime.resolveFootprint === undefined) {
|
|
1175
|
+
throw new Error("realtime: configure at least one room profile (realtime.rooms) or the legacy resolveFootprint — " +
|
|
1176
|
+
"a realtime host with neither can never boot a room.");
|
|
1177
|
+
}
|
|
983
1178
|
// Resolve a named query (+ args) to its AST under a given context — the shared path for both
|
|
984
1179
|
// a per-viewer lease and a system-level pin (which skips per-user authorization).
|
|
985
1180
|
const resolveAst = async (name, args, context) => {
|
|
@@ -991,6 +1186,341 @@ export function createRindleApiServer(opts) {
|
|
|
991
1186
|
: await query(context, args);
|
|
992
1187
|
return queryResultToAst(result);
|
|
993
1188
|
};
|
|
1189
|
+
// ---------------------------------------------------------------- the room-serve decision
|
|
1190
|
+
//
|
|
1191
|
+
// RINDLE-REALTIME-QUERY-ENABLEMENT §2.1 lease-flow steps 2–5, slice G-iv-b. Everything here is
|
|
1192
|
+
// FAIL-OPEN: any missing wiring, refused proof, or thrown error means the lease is served from
|
|
1193
|
+
// the daemon EXACTLY as today (no `realtime` block, top-level fields untouched) plus a one-time
|
|
1194
|
+
// diagnostic — a coverage/config problem must never block a lease.
|
|
1195
|
+
const realtimeWarn = realtime?.warn ?? ((message) => console.warn(message));
|
|
1196
|
+
// One-time per (queryName, profile): the serve decision runs on EVERY lease, so an uncovered
|
|
1197
|
+
// labeled query would otherwise warn once per viewer per mount.
|
|
1198
|
+
const warnedRoomServe = new Set();
|
|
1199
|
+
const warnRoomServeOnce = (queryName, profile, reasons) => {
|
|
1200
|
+
const key = `${queryName}\u0000${profile}`;
|
|
1201
|
+
if (warnedRoomServe.has(key))
|
|
1202
|
+
return;
|
|
1203
|
+
warnedRoomServe.add(key);
|
|
1204
|
+
realtimeWarn(`query "${queryName}" is labeled realtime (room profile "${profile}") but is NOT room-served — ` +
|
|
1205
|
+
`${reasons.join("; ")}. It serves from the daemon (correct, just not room-accelerated). ` +
|
|
1206
|
+
`This warning fires once per (query, profile).`);
|
|
1207
|
+
};
|
|
1208
|
+
// The §2.3 aggregate refusal: the client's aggregate overlay is daemon-gated until post-G, so
|
|
1209
|
+
// an aggregate/reduce-shaped query is refused room-serving REGARDLESS of coverage.
|
|
1210
|
+
const AGGREGATE_REFUSAL = "the query AST contains an aggregate/reduce shape — aggregate overlays are daemon-gated until post-G";
|
|
1211
|
+
// Verdict cache. The verdict is a pure function of exactly two inputs — the resolved footprint
|
|
1212
|
+
// AST and the resolved query AST — so the tightest SOUND key is those two ASTs themselves
|
|
1213
|
+
// (stable-stringified), scoped by (queryName, profile) for legibility. Args/user/ctx need no
|
|
1214
|
+
// separate slot precisely because anything that changes the verdict must change one of the two
|
|
1215
|
+
// ASTs (predicate literals embed the args; ctx-scoped queries embed the principal); keying on
|
|
1216
|
+
// `(name, args)` alone would ALIAS two users' different ASTs under one verdict — unsound.
|
|
1217
|
+
// Bounded FIFO (Map iterates in insertion order) so per-user literals can't grow it forever.
|
|
1218
|
+
const coverVerdicts = new Map();
|
|
1219
|
+
const COVER_VERDICT_CACHE_MAX = 1024;
|
|
1220
|
+
const maybeRoomServe = async (input, queryAst, context, subject) => {
|
|
1221
|
+
// (a) the label + (b) its profile — the fast bail keeps unlabeled leases byte-identical.
|
|
1222
|
+
const label = queryRealtimeLabel(opts.queries?.[input.name]);
|
|
1223
|
+
if (label === undefined)
|
|
1224
|
+
return undefined;
|
|
1225
|
+
const profile = roomProfiles.get(label.room);
|
|
1226
|
+
if (profile === undefined)
|
|
1227
|
+
return undefined; // unreachable: construction asserted it exists
|
|
1228
|
+
try {
|
|
1229
|
+
// (c) the wiring gates — each absence fail-opens with a one-time, named reason.
|
|
1230
|
+
if (realtime?.locateRoom === undefined) {
|
|
1231
|
+
warnRoomServeOnce(input.name, profile.name, ["realtime.locateRoom is not configured"]);
|
|
1232
|
+
return undefined;
|
|
1233
|
+
}
|
|
1234
|
+
const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
|
|
1235
|
+
if (coverQuery === undefined) {
|
|
1236
|
+
warnRoomServeOnce(input.name, profile.name, [
|
|
1237
|
+
"the configured daemon client does not implement coverQuery (/cover-check)",
|
|
1238
|
+
]);
|
|
1239
|
+
return undefined;
|
|
1240
|
+
}
|
|
1241
|
+
const tokenKey = realtime.roomTokenKey;
|
|
1242
|
+
if (tokenKey === undefined) {
|
|
1243
|
+
warnRoomServeOnce(input.name, profile.name, [
|
|
1244
|
+
"realtime.roomTokenKey is not configured — the room lease token cannot be signed",
|
|
1245
|
+
]);
|
|
1246
|
+
return undefined;
|
|
1247
|
+
}
|
|
1248
|
+
// The token's subject: the same resolved subject the daemon lease carries, else the
|
|
1249
|
+
// browser's clientId. The shell refuses a subject-less token, so with neither we fail open.
|
|
1250
|
+
const sub = subject ?? input.clientId;
|
|
1251
|
+
if (sub === undefined) {
|
|
1252
|
+
warnRoomServeOnce(input.name, profile.name, [
|
|
1253
|
+
"no token subject — configure `subject` (or have the client send clientId)",
|
|
1254
|
+
]);
|
|
1255
|
+
return undefined;
|
|
1256
|
+
}
|
|
1257
|
+
// §2.1: (roomProfile, roomArgs) via the label's args mapping; key + doc minted SERVER-side
|
|
1258
|
+
// (input.args just passed the query's own validation inside resolveAst).
|
|
1259
|
+
const roomArgs = label.args !== undefined ? label.args(input.args) : input.args;
|
|
1260
|
+
const key = profile.key(roomArgs);
|
|
1261
|
+
const doc = mintRoomDoc(profile.name, key);
|
|
1262
|
+
// The profile footprint for THIS key under the request ctx (works for non-static
|
|
1263
|
+
// profiles), with the §2.3 unwindowed backstop `/room-boot` also applies.
|
|
1264
|
+
const footprintAst = queryResultToAst(await profile.footprint(key, context));
|
|
1265
|
+
assertUnwindowedFootprint(footprintAst, profile.name);
|
|
1266
|
+
const verdictKey = `${input.name}\u0000${profile.name}\u0000${stableStringify(footprintAst)}\u0000${stableStringify(queryAst)}`;
|
|
1267
|
+
let verdict = coverVerdicts.get(verdictKey);
|
|
1268
|
+
if (verdict === undefined) {
|
|
1269
|
+
verdict = astHasAggregate(queryAst)
|
|
1270
|
+
? { covered: false, reasons: [AGGREGATE_REFUSAL] }
|
|
1271
|
+
: await coverQuery({ footprint: footprintAst, query: queryAst });
|
|
1272
|
+
// (A coverQuery THROW never lands here — the outer catch fail-opens without caching, so
|
|
1273
|
+
// a transient daemon failure doesn't pin an uncovered verdict.)
|
|
1274
|
+
if (coverVerdicts.size >= COVER_VERDICT_CACHE_MAX) {
|
|
1275
|
+
coverVerdicts.delete(coverVerdicts.keys().next().value);
|
|
1276
|
+
}
|
|
1277
|
+
coverVerdicts.set(verdictKey, verdict);
|
|
1278
|
+
}
|
|
1279
|
+
if (!verdict.covered) {
|
|
1280
|
+
warnRoomServeOnce(input.name, profile.name, verdict.reasons ?? ["not provably covered"]);
|
|
1281
|
+
return undefined;
|
|
1282
|
+
}
|
|
1283
|
+
// Covered ⇒ assemble the realtime block. The room endpoint rides ITS OWN field — the
|
|
1284
|
+
// top-level `wsEndpoint` stays exactly the daemon lease's (whole-session migration signal).
|
|
1285
|
+
const { wsEndpoint } = await realtime.locateRoom(doc);
|
|
1286
|
+
const now = Date.now();
|
|
1287
|
+
const ttlMs = realtime.roomTokenTtlMs ?? DEFAULT_ROOM_TOKEN_TTL_MS;
|
|
1288
|
+
const { mintRoomToken, scopeSpecsHash } = await loadRoomTokenModule();
|
|
1289
|
+
// The lease-wire specs, hashed ONCE: the same value is stamped on the token (so the
|
|
1290
|
+
// shell can flag scope skew — a profile edited under a live room, whose gate armed
|
|
1291
|
+
// with the OLD specs at boot) and returned as the client's `tables`.
|
|
1292
|
+
const tables = compileRoomTableSpecs(footprintAst, profile.context);
|
|
1293
|
+
const roomToken = await mintRoomToken({
|
|
1294
|
+
doc,
|
|
1295
|
+
ast: queryAst, // the APPROVED resolved AST — the client carries it, it can't mint/alter it
|
|
1296
|
+
sub,
|
|
1297
|
+
kid: tokenKey.kid,
|
|
1298
|
+
key: tokenKey.secret,
|
|
1299
|
+
ttlMs,
|
|
1300
|
+
now,
|
|
1301
|
+
scopesHash: scopeSpecsHash(tables),
|
|
1302
|
+
});
|
|
1303
|
+
return {
|
|
1304
|
+
// `parse_source_key` (rust/src/wasm/db.rs): anything but the reserved "daemon" is a room
|
|
1305
|
+
// source; the client-store convention is `room:` + the wire doc.
|
|
1306
|
+
sourceKey: `room:${doc}`,
|
|
1307
|
+
wsEndpoint,
|
|
1308
|
+
roomToken,
|
|
1309
|
+
exp: now + ttlMs,
|
|
1310
|
+
doc,
|
|
1311
|
+
tables,
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
catch (e) {
|
|
1315
|
+
// Fail open — a lease is never blocked on the proof. Not cached (may be transient).
|
|
1316
|
+
warnRoomServeOnce(input.name, profile.name, [`room-serve failed: ${errMessage(e)}`]);
|
|
1317
|
+
return undefined;
|
|
1318
|
+
}
|
|
1319
|
+
};
|
|
1320
|
+
// ---------------------------------------------------------------- the §4 lifecycle mint (I-iii)
|
|
1321
|
+
//
|
|
1322
|
+
// Gated on the OPT-IN `realtime.lifecycle` block: absent, this whole section is dead code and
|
|
1323
|
+
// every lease response is byte-identical to pre-lifecycle. Present, a labeled lease gains the
|
|
1324
|
+
// doorbell system lease and a ROOM-SERVED one the fence bundle (see {@link QueryLeaseLifecycle}).
|
|
1325
|
+
// FAIL-OPEN like the room-serve decision: a mint failure (e.g. a daemon that never ran
|
|
1326
|
+
// `enable_realtime_lifecycle`) warns once per query and the lease ships without the block.
|
|
1327
|
+
const warnedLifecycle = new Set();
|
|
1328
|
+
const warnLifecycleOnce = (queryName, reason) => {
|
|
1329
|
+
if (warnedLifecycle.has(queryName))
|
|
1330
|
+
return;
|
|
1331
|
+
warnedLifecycle.add(queryName);
|
|
1332
|
+
realtimeWarn(`query "${queryName}" is realtime-labeled with lifecycle configured, but its lifecycle ` +
|
|
1333
|
+
`system leases were not minted — ${reason}. The lease serves without the lifecycle block ` +
|
|
1334
|
+
`(correct, just no §4 upgrade/downgrade plane). This warning fires once per query.`);
|
|
1335
|
+
};
|
|
1336
|
+
/** THE SCOPE-KEY DECISION (§4.1): the doorbell scope IS the wire room doc — `"<profile>/<key>"`
|
|
1337
|
+
* via {@link mintRoomDoc}, the same computation `maybeRoomServe` runs (label args mapping →
|
|
1338
|
+
* `profile.key`) and the same key `locateRoom`/`/room-boot` address the room by. Occupancy
|
|
1339
|
+
* (I-iv writes the `_rindle_scope_sessions` rows) must be counted on EXACTLY the key the 1→2
|
|
1340
|
+
* transition provisions, and this is that key. Computed independently of the room-serve
|
|
1341
|
+
* decision on purpose: the doorbell rides every LABELED lease — an uncovered/unwired labeled
|
|
1342
|
+
* query still counts toward occupancy (its collaborators still want the upgrade). */
|
|
1343
|
+
const lifecycleScopeDoc = (input) => {
|
|
1344
|
+
const label = queryRealtimeLabel(opts.queries?.[input.name]);
|
|
1345
|
+
if (label === undefined)
|
|
1346
|
+
return undefined; // unlabeled — no scope to count on
|
|
1347
|
+
const profile = roomProfiles.get(label.room);
|
|
1348
|
+
if (profile === undefined)
|
|
1349
|
+
return undefined; // unreachable: construction asserted it exists
|
|
1350
|
+
const roomArgs = label.args !== undefined ? label.args(input.args) : input.args;
|
|
1351
|
+
return mintRoomDoc(profile.name, profile.key(roomArgs));
|
|
1352
|
+
};
|
|
1353
|
+
const maybeLifecycle = async (input, roomServed, subject, routingKey) => {
|
|
1354
|
+
if (realtime?.lifecycle === undefined)
|
|
1355
|
+
return undefined; // the opt-in gate — mint NOTHING
|
|
1356
|
+
try {
|
|
1357
|
+
const doc = lifecycleScopeDoc(input);
|
|
1358
|
+
if (doc === undefined)
|
|
1359
|
+
return undefined;
|
|
1360
|
+
// Each system lease is an ordinary daemon materialization (the room-boot direct pattern),
|
|
1361
|
+
// carrying the SAME subject/routingKey as the primary lease so a routed deploy co-locates
|
|
1362
|
+
// the system streams on the follower the client's daemon session already lives on. The
|
|
1363
|
+
// daemon dedups by canonical query, so N clients' doorbells over one scope share ONE
|
|
1364
|
+
// materialization (each still minting its own leaseToken); the client-scoped fence ASTs
|
|
1365
|
+
// are per-client by construction.
|
|
1366
|
+
const mint = (ast) => opts.daemon.materialize({
|
|
1367
|
+
ast,
|
|
1368
|
+
mode,
|
|
1369
|
+
subject,
|
|
1370
|
+
leaseTtlMs: opts.leaseTtlMs,
|
|
1371
|
+
metadata: routingKey !== undefined ? { routingKey } : undefined,
|
|
1372
|
+
});
|
|
1373
|
+
const lease = (table, out, id) => ({
|
|
1374
|
+
table,
|
|
1375
|
+
leaseToken: out.leaseToken,
|
|
1376
|
+
...(out.wsEndpoint !== undefined ? { wsEndpoint: out.wsEndpoint } : {}),
|
|
1377
|
+
...(id.scope !== undefined ? { scope: id.scope } : {}),
|
|
1378
|
+
...(id.doc !== undefined ? { doc: id.doc } : {}),
|
|
1379
|
+
...(id.clientId !== undefined ? { clientId: id.clientId } : {}),
|
|
1380
|
+
});
|
|
1381
|
+
const lifecycle = {
|
|
1382
|
+
doorbell: lease(SCOPE_SESSIONS_TABLE, await mint(scopeSessionsAst(doc)), { scope: doc }),
|
|
1383
|
+
};
|
|
1384
|
+
// The fence bundle only where a room domain exists to fence (room-served leases): the
|
|
1385
|
+
// §4.2 watermark, the §7.1 daemon-carried ledger, and the §3.3 outcome rows.
|
|
1386
|
+
if (roomServed) {
|
|
1387
|
+
const clientId = input.clientId;
|
|
1388
|
+
lifecycle.fence = [
|
|
1389
|
+
lease(ROOM_WATERMARK_TABLE, await mint(roomWatermarkAst(doc)), { doc }),
|
|
1390
|
+
lease(ROOM_CLIENT_MUTATIONS_TABLE, await mint(docClientAst(ROOM_CLIENT_MUTATIONS_TABLE, doc, clientId)), { doc, clientId }),
|
|
1391
|
+
lease(ROOM_MUTATION_OUTCOMES_TABLE, await mint(docClientAst(ROOM_MUTATION_OUTCOMES_TABLE, doc, clientId)), { doc, clientId }),
|
|
1392
|
+
];
|
|
1393
|
+
}
|
|
1394
|
+
return lifecycle;
|
|
1395
|
+
}
|
|
1396
|
+
catch (e) {
|
|
1397
|
+
warnLifecycleOnce(input.name, errMessage(e)); // fail open — a lease is never blocked
|
|
1398
|
+
return undefined;
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
// ------------------------------------------------------------ the §4.1 occupancy gate (I-iv)
|
|
1402
|
+
//
|
|
1403
|
+
// Runs on EVERY labeled lease under the opt-in `realtime.lifecycle` config (mint AND renewal —
|
|
1404
|
+
// both land on this same route), BEFORE the room-serve decision: (1) sweep + upsert the
|
|
1405
|
+
// caller's session row through the normal write path (the write is the doorbell — I-i's CDC
|
|
1406
|
+
// capture fans the row delta to every solo watcher's doorbell subscription), then (2) read the
|
|
1407
|
+
// occupancy count and return the D6 gate verdict `maybeRoomServe` is conditioned on. The upsert
|
|
1408
|
+
// deliberately precedes the count so the caller's own row is on disk when the verdict is
|
|
1409
|
+
// computed (its own presence rides the `+ 1`, and — more importantly — a concurrent second
|
|
1410
|
+
// client's read sees it). Ordering within the pair is otherwise value-neutral: the count
|
|
1411
|
+
// EXCLUDES the caller's clientId and adds the `+ 1` analytically.
|
|
1412
|
+
//
|
|
1413
|
+
// THE RENEWAL-vs-FRESH DECISION (grounded here because the task forces it): this server is
|
|
1414
|
+
// stateless and the lease request carries no "I am currently room-attached" field, so the gate
|
|
1415
|
+
// CANNOT distinguish a fresh mint from a live room's renewal. Instead of gating on the raw
|
|
1416
|
+
// count (which would suppress a momentarily-solo room's renewal and force the loud client-side
|
|
1417
|
+
// downgrade anomaly), the gate applies the §9.1 hysteresis DIRECTLY FROM THE LINGERING ROWS the
|
|
1418
|
+
// D4 sweep preserves: room-serve iff `liveOthers + self ≥ minSessions` OR some other session
|
|
1419
|
+
// expired within `graceMs`. A renewal is therefore never suppressed until the scope has been
|
|
1420
|
+
// solo SUSTAINED past the grace window — which is exactly Slice I-v's downgrade condition, read
|
|
1421
|
+
// from the same rows; I-v replaces that post-grace loud suppression with the fenced downgrade
|
|
1422
|
+
// dance, refining (not re-deciding) this verdict. A truly fresh solo scope (no other row, live
|
|
1423
|
+
// or lingering) is suppressed immediately — the D6 point.
|
|
1424
|
+
//
|
|
1425
|
+
// Timestamps are `Date.now()` server-side throughout (mint, sweep, count): occupancy tolerates
|
|
1426
|
+
// clock skew between api-server instances up to ~grace — a skewed `now` moves a session between
|
|
1427
|
+
// "live" and "in-grace", both of which hold the gate open; only skew past the grace+slack band
|
|
1428
|
+
// could mis-sweep, and the slack exists to keep that band clear.
|
|
1429
|
+
//
|
|
1430
|
+
// A request with NO `clientId` (a non-shipped client — the shipped one always sends it, see
|
|
1431
|
+
// `postLease`) upserts NO row and contributes NOTHING to occupancy, including to its own gate:
|
|
1432
|
+
// it room-serves only if the OTHER sessions alone reach `minSessions` (there is no session
|
|
1433
|
+
// identity to count it under, D7). It still gets its doorbell (`maybeLifecycle` is independent).
|
|
1434
|
+
//
|
|
1435
|
+
// FAIL-OPEN, like every lifecycle surface: an occupancy failure (e.g. a daemon that never ran
|
|
1436
|
+
// `enable_realtime_lifecycle`) warns once per query and returns `true` — the gate falls away
|
|
1437
|
+
// and the lease serves exactly as pre-I-iv. Suppressing on infrastructure failure would turn
|
|
1438
|
+
// realtime off fleet-wide from one missing table; never block, never suppress, on an error.
|
|
1439
|
+
const warnedOccupancy = new Set();
|
|
1440
|
+
const lifecycleOccupancy = async (input) => {
|
|
1441
|
+
const lc = realtime?.lifecycle;
|
|
1442
|
+
if (lc === undefined)
|
|
1443
|
+
return { gateOpen: true, roomPlausible: false, doc: undefined }; // lifecycle off — the gate does not exist (inert-until-fed)
|
|
1444
|
+
const doc = lifecycleScopeDoc(input);
|
|
1445
|
+
if (doc === undefined)
|
|
1446
|
+
return { gateOpen: true, roomPlausible: false, doc: undefined }; // unlabeled — no scope to count on, nothing to gate
|
|
1447
|
+
try {
|
|
1448
|
+
const now = Date.now();
|
|
1449
|
+
const minSessions = lc.minSessions ?? DEFAULT_LIFECYCLE_MIN_SESSIONS;
|
|
1450
|
+
const graceMs = lc.graceMs ?? DEFAULT_LIFECYCLE_GRACE_MS;
|
|
1451
|
+
const sessionTtlMs = lc.sessionTtlMs ?? opts.leaseTtlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
1452
|
+
const clientId = input.clientId;
|
|
1453
|
+
// (1) sweep + upsert, ONE write txn (D4: the sweep shares the upsert's transaction — no
|
|
1454
|
+
// separate maintenance pass, and the linger bound holds atomically with the refresh).
|
|
1455
|
+
const statements = [
|
|
1456
|
+
{ sql: SESSION_SWEEP_SQL, params: [doc, now - (graceMs + SESSION_SWEEP_SLACK_MS)] },
|
|
1457
|
+
];
|
|
1458
|
+
if (clientId !== undefined) {
|
|
1459
|
+
statements.push({ sql: SESSION_UPSERT_SQL, params: [doc, clientId, now + sessionTtlMs] });
|
|
1460
|
+
}
|
|
1461
|
+
await opts.daemon.executeSqlTxn({ statements });
|
|
1462
|
+
// (2) the count — read-your-writes via `consistency: "strong"` (see the section note above).
|
|
1463
|
+
const read = await opts.daemon.executeSqlRead({
|
|
1464
|
+
sql: clientId !== undefined ? SESSION_COUNT_OTHERS_SQL : SESSION_COUNT_SQL,
|
|
1465
|
+
params: clientId !== undefined
|
|
1466
|
+
? [now, now, now - graceMs, doc, clientId]
|
|
1467
|
+
: [now, now, now - graceMs, doc],
|
|
1468
|
+
consistency: "strong",
|
|
1469
|
+
});
|
|
1470
|
+
const cells = read.rows[0] ?? [];
|
|
1471
|
+
const liveOthers = Number(cells[0] ?? 0); // SUM over zero rows is NULL — coerce
|
|
1472
|
+
const graceOthers = Number(cells[1] ?? 0);
|
|
1473
|
+
const totalOthers = Number(cells[2] ?? 0); // ALL other rows (any expiry, pre-sweep)
|
|
1474
|
+
const self = clientId !== undefined ? 1 : 0;
|
|
1475
|
+
return {
|
|
1476
|
+
gateOpen: liveOthers + self >= minSessions || graceOthers > 0,
|
|
1477
|
+
// Room plausibly exists ⇒ this scope was shared (a room was provisioned on the 1→2). A
|
|
1478
|
+
// never-shared solo doc has NO other row and must never drain (no wasted room boot).
|
|
1479
|
+
roomPlausible: totalOthers > 0,
|
|
1480
|
+
doc,
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
catch (e) {
|
|
1484
|
+
if (!warnedOccupancy.has(input.name)) {
|
|
1485
|
+
warnedOccupancy.add(input.name);
|
|
1486
|
+
realtimeWarn(`query "${input.name}" is realtime-labeled with lifecycle configured, but the §4.1 ` +
|
|
1487
|
+
`occupancy step failed — ${errMessage(e)}. The occupancy gate fail-opens (the lease ` +
|
|
1488
|
+
`serves exactly as pre-I-iv; no session row was counted). This warning fires once per query.`);
|
|
1489
|
+
}
|
|
1490
|
+
return { gateOpen: true, roomPlausible: false, doc };
|
|
1491
|
+
}
|
|
1492
|
+
};
|
|
1493
|
+
// ------------------------------------------------------------ the §4.2 downgrade drain (I-v)
|
|
1494
|
+
//
|
|
1495
|
+
// When the occupancy gate closes for a scope a room plausibly hosted, drain that room to a
|
|
1496
|
+
// COMMITTED flush seq and hand it back as the fence. `drainRoom` (deployment-wired to the room
|
|
1497
|
+
// shell / DO `/drain`) is idempotent (concurrent api-server instances may both call it) and
|
|
1498
|
+
// fails OPEN — a downgrade must never block a lease, so an unconfigured or throwing hook simply
|
|
1499
|
+
// omits the fence (warn-once) and the client falls to its loud legacy downgrade path.
|
|
1500
|
+
const warnedDrain = new Set();
|
|
1501
|
+
const warnDrainOnce = (queryName, reason) => {
|
|
1502
|
+
if (warnedDrain.has(queryName))
|
|
1503
|
+
return;
|
|
1504
|
+
warnedDrain.add(queryName);
|
|
1505
|
+
realtimeWarn(`query "${queryName}" downgraded (occupancy gate closed) but no §4.2 fence was attached — ` +
|
|
1506
|
+
`${reason}. The lease ships without the fence; a room-attached client falls back to its ` +
|
|
1507
|
+
`loud legacy downgrade (correct, just not graceful). This warning fires once per query.`);
|
|
1508
|
+
};
|
|
1509
|
+
const maybeDrainRoom = async (queryName, doc) => {
|
|
1510
|
+
const drainRoom = realtime?.lifecycle?.drainRoom;
|
|
1511
|
+
if (drainRoom === undefined) {
|
|
1512
|
+
warnDrainOnce(queryName, "realtime.lifecycle.drainRoom is not configured");
|
|
1513
|
+
return undefined;
|
|
1514
|
+
}
|
|
1515
|
+
try {
|
|
1516
|
+
const { finalFlushSeq } = await drainRoom(doc);
|
|
1517
|
+
return { sourceKey: `room:${doc}`, doc, finalFlushSeq };
|
|
1518
|
+
}
|
|
1519
|
+
catch (e) {
|
|
1520
|
+
warnDrainOnce(queryName, `drainRoom threw — ${errMessage(e)}`);
|
|
1521
|
+
return undefined;
|
|
1522
|
+
}
|
|
1523
|
+
};
|
|
994
1524
|
const createQueryLease = async (input) => {
|
|
995
1525
|
const context = { user: input.user, request: input.request };
|
|
996
1526
|
await assertAuthorized(opts.authorizeQuery, {
|
|
@@ -1018,7 +1548,36 @@ export function createRindleApiServer(opts) {
|
|
|
1018
1548
|
// `subject ?? metadata.routingKey` (§2.2). Omitted when there is none.
|
|
1019
1549
|
metadata: routingKey !== undefined ? { routingKey } : undefined,
|
|
1020
1550
|
});
|
|
1021
|
-
|
|
1551
|
+
const res = queryLeaseResponse(out);
|
|
1552
|
+
// I-iv (§4.1): the occupancy step FIRST — session sweep+upsert, then the D6 gate verdict. A
|
|
1553
|
+
// closed gate suppresses the room-serve ONLY (the lease ships without the realtime block,
|
|
1554
|
+
// indistinguishable from an uncovered query — the fail-open daemon path) while the doorbell
|
|
1555
|
+
// below still rides; lifecycle-off ⇒ `gateOpen: true` unconditionally and this line is inert.
|
|
1556
|
+
const occ = await lifecycleOccupancy(input);
|
|
1557
|
+
// G-iv-b: a covered labeled query ADDITIONALLY gains the realtime block. The daemon lease
|
|
1558
|
+
// above is unconditional (and its fields untouched) — room-serving only ever adds a field,
|
|
1559
|
+
// so an uncovered/unwired/legacy lease stays byte-identical and nothing here can block one.
|
|
1560
|
+
const rt = occ.gateOpen ? await maybeRoomServe(input, ast, context, subject) : undefined;
|
|
1561
|
+
if (rt !== undefined)
|
|
1562
|
+
res.realtime = rt;
|
|
1563
|
+
// I-v (§4.2): the gate CLOSED and a room plausibly hosted this scope — drain it and ride the
|
|
1564
|
+
// fence back so a room-attached client runs the GRACEFUL downgrade instead of the loud legacy
|
|
1565
|
+
// anomaly. A never-shared solo doc (`!roomPlausible`) never drains (no wasted room boot); a
|
|
1566
|
+
// daemon-attached client that receives a stray fence ignores it (its resolver reads only the
|
|
1567
|
+
// daemon fields). `drainRoom` absent/throwing ⇒ no fence (fail-open, warn-once).
|
|
1568
|
+
if (!occ.gateOpen && occ.roomPlausible && occ.doc !== undefined) {
|
|
1569
|
+
const fence = await maybeDrainRoom(input.name, occ.doc);
|
|
1570
|
+
if (fence !== undefined)
|
|
1571
|
+
res.realtimeFence = fence;
|
|
1572
|
+
}
|
|
1573
|
+
// I-iii: under the opt-in `realtime.lifecycle` config a LABELED lease additionally gains the
|
|
1574
|
+
// §4 system-stream block (doorbell always; the fence bundle iff room-served OR downgrade-fenced
|
|
1575
|
+
// — a downgrading client needs the watermark/ledger/outcome streams to run the ghost drop).
|
|
1576
|
+
// Same additive discipline as the realtime block: absent config ⇒ byte-identical response.
|
|
1577
|
+
const lc = await maybeLifecycle(input, rt !== undefined || res.realtimeFence !== undefined, subject, routingKey);
|
|
1578
|
+
if (lc !== undefined)
|
|
1579
|
+
res.lifecycle = lc;
|
|
1580
|
+
return res;
|
|
1022
1581
|
};
|
|
1023
1582
|
const readQuery = async (input) => {
|
|
1024
1583
|
const context = { user: input.user, request: input.request };
|
|
@@ -1061,24 +1620,82 @@ export function createRindleApiServer(opts) {
|
|
|
1061
1620
|
catch (err) {
|
|
1062
1621
|
return reject(backend, input.envelope, errMessage(err));
|
|
1063
1622
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1623
|
+
const mctx = {
|
|
1624
|
+
user: input.user,
|
|
1625
|
+
envelope: input.envelope,
|
|
1626
|
+
daemon: opts.daemon,
|
|
1627
|
+
request: input.request,
|
|
1628
|
+
};
|
|
1629
|
+
// SCOPED mutator (WORK-OUTSIDE-TX): the author controls the tx boundary via `scope.transact`,
|
|
1630
|
+
// running server-only code before/after it. The `lmid`-always-advances invariant is OURS, not
|
|
1631
|
+
// the author's — we seal the response from the scope's recorded state, so an early return, a
|
|
1632
|
+
// never-called transact, or a swallowed rejection can't wedge the client's pending queue.
|
|
1633
|
+
if (isScoped(mutator)) {
|
|
1634
|
+
const scope = new MutationScopeImpl(backend, input.envelope, renderIndex);
|
|
1635
|
+
// A throw that reaches `settle` AFTER the outcome is already sealed (post-commit effect, or a
|
|
1636
|
+
// compensation handler after a business rejection) can't change the response — but it must not
|
|
1637
|
+
// vanish. Route it to the app's hook, else log so a failed refund is never fully silent.
|
|
1638
|
+
const reportSealed = (err, phase) => {
|
|
1639
|
+
if (opts.onScopeError)
|
|
1640
|
+
opts.onScopeError(err, { phase, envelope: input.envelope });
|
|
1641
|
+
else
|
|
1642
|
+
console.error(`[rindle api-server] scoped mutator ${input.envelope.name}: post-${phase} code threw (outcome already sealed):`, err);
|
|
1643
|
+
};
|
|
1644
|
+
// Derive the response from the scope's OUTCOME (not the body's return), so control flow in the
|
|
1645
|
+
// author's function can't skip the lmid advance. `caught` distinguishes "the body threw"
|
|
1646
|
+
// (present, even if the thrown value was `undefined`) from "it returned cleanly".
|
|
1647
|
+
const settle = async (caught) => {
|
|
1648
|
+
// Seal from the REAL outcome even if the author forgot to `await` transact: draining its
|
|
1649
|
+
// in-flight promise here records the outcome/infra before we read it (else a phantom no-op
|
|
1650
|
+
// ships while the real write commits out-of-band). Already-resolved when it WAS awaited.
|
|
1651
|
+
if (scope.pending)
|
|
1652
|
+
await scope.pending;
|
|
1653
|
+
// Infra always wins: the backend threw, the commit state is unknown — never advance lmid.
|
|
1654
|
+
// Keyed on the latched BOOLEAN, so a driver that rejects with a falsy value is still infra.
|
|
1655
|
+
if (scope.infraLatched)
|
|
1656
|
+
throw scope.infra;
|
|
1657
|
+
// transact resolved (committed OR business-rejected): seal from its recorded outcome. A
|
|
1658
|
+
// post-commit / post-reject-compensation throw can't change the sealed outcome (its effects
|
|
1659
|
+
// can't roll the tx back, and lmid already advanced §2.4). Rethrowing the MutationRejected is
|
|
1660
|
+
// the sanctioned "compensated, stay rejected" signal — expected, not surfaced. Any OTHER throw
|
|
1661
|
+
// (a FAILED refund, a post-commit effect) must not vanish — surface it.
|
|
1662
|
+
if (scope.outcome) {
|
|
1663
|
+
if (caught && !(caught.err instanceof MutationRejected)) {
|
|
1664
|
+
reportSealed(caught.err, scope.outcome.accepted ? "committed" : "rejected");
|
|
1665
|
+
}
|
|
1666
|
+
return outcomeToResponse(scope.outcome);
|
|
1667
|
+
}
|
|
1668
|
+
// Never transacted:
|
|
1669
|
+
if (caught) {
|
|
1670
|
+
// A throw before/around transact. A BackendError is the author signaling INFRA (retry);
|
|
1671
|
+
// any other throw is a BUSINESS rejection — advance lmid alone so the prediction snaps back.
|
|
1672
|
+
if (caught.err instanceof BackendError)
|
|
1673
|
+
throw caught.err.driverError;
|
|
1674
|
+
return reject(backend, input.envelope, errMessage(caught.err));
|
|
1675
|
+
}
|
|
1676
|
+
// Clean return with no transact — an accepted no-op that STILL advances lmid (the client
|
|
1677
|
+
// predicted a write; its pending entry must resolve).
|
|
1678
|
+
return outcomeToResponse(await backend.runMutation({ envelope: input.envelope, render: renderIndex, run: async () => { } }));
|
|
1679
|
+
};
|
|
1680
|
+
try {
|
|
1681
|
+
await mutator(scope, input.envelope.args, mctx);
|
|
1682
|
+
}
|
|
1683
|
+
catch (err) {
|
|
1684
|
+
return settle({ err });
|
|
1685
|
+
}
|
|
1686
|
+
return settle();
|
|
1687
|
+
}
|
|
1688
|
+
// Run the (tx-form) mutator INSIDE the backend's transaction. A throw from the mutator body is a
|
|
1689
|
+
// business rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
|
|
1066
1690
|
const outcome = await backend.runMutation({
|
|
1067
1691
|
envelope: input.envelope,
|
|
1068
1692
|
render: renderIndex,
|
|
1069
1693
|
run: async (tx) => {
|
|
1070
|
-
const result = await mutator(tx, input.envelope.args,
|
|
1071
|
-
user: input.user,
|
|
1072
|
-
envelope: input.envelope,
|
|
1073
|
-
daemon: opts.daemon,
|
|
1074
|
-
request: input.request,
|
|
1075
|
-
});
|
|
1694
|
+
const result = await mutator(tx, input.envelope.args, mctx);
|
|
1076
1695
|
applyResultToTx(result, tx);
|
|
1077
1696
|
},
|
|
1078
1697
|
});
|
|
1079
|
-
|
|
1080
|
-
return { accepted: true, rejected: false, output: outcome.output };
|
|
1081
|
-
return { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
|
|
1698
|
+
return outcomeToResponse(outcome);
|
|
1082
1699
|
};
|
|
1083
1700
|
const pushMutations = async (input) => {
|
|
1084
1701
|
const out = [];
|
|
@@ -1137,10 +1754,60 @@ export function createRindleApiServer(opts) {
|
|
|
1137
1754
|
throw new Error(`assertPins: ${failures.length} failed — ${failures.join("; ")}`);
|
|
1138
1755
|
}
|
|
1139
1756
|
};
|
|
1757
|
+
// The explicit coverage diagnostic (the assertPins pattern: system-level, resolved under
|
|
1758
|
+
// `pinUser`, per-query failures collected — never strand the rest). It runs the REAL check —
|
|
1759
|
+
// the daemon's /cover-check on the actually-resolved ASTs — so its verdicts are exactly the
|
|
1760
|
+
// lease path's, minus the serving wiring (locateRoom/roomTokenKey), which it deliberately
|
|
1761
|
+
// ignores: it answers "is this labeled query coverable", the deployable-config question.
|
|
1762
|
+
const validateRealtime = async (vopts) => {
|
|
1763
|
+
const context = { user: opts.pinUser, request: undefined };
|
|
1764
|
+
const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
|
|
1765
|
+
const verdicts = [];
|
|
1766
|
+
for (const [name, q] of Object.entries(opts.queries ?? {})) {
|
|
1767
|
+
const label = queryRealtimeLabel(q);
|
|
1768
|
+
if (label === undefined)
|
|
1769
|
+
continue;
|
|
1770
|
+
const profile = roomProfiles.get(label.room);
|
|
1771
|
+
if (profile === undefined)
|
|
1772
|
+
continue; // unreachable: construction asserted it exists
|
|
1773
|
+
for (const args of vopts?.exemplars?.[name] ?? [null]) {
|
|
1774
|
+
const verdict = { query: name, profile: profile.name, args, covered: false };
|
|
1775
|
+
try {
|
|
1776
|
+
const ast = await resolveAst(name, args, context);
|
|
1777
|
+
const roomArgs = label.args !== undefined ? label.args(args) : args;
|
|
1778
|
+
const footprintAst = queryResultToAst(await profile.footprint(profile.key(roomArgs), context));
|
|
1779
|
+
assertUnwindowedFootprint(footprintAst, profile.name);
|
|
1780
|
+
if (astHasAggregate(ast)) {
|
|
1781
|
+
verdict.reasons = [AGGREGATE_REFUSAL];
|
|
1782
|
+
}
|
|
1783
|
+
else if (coverQuery === undefined) {
|
|
1784
|
+
verdict.reasons = ["the configured daemon client does not implement coverQuery (/cover-check)"];
|
|
1785
|
+
}
|
|
1786
|
+
else {
|
|
1787
|
+
const out = await coverQuery({ footprint: footprintAst, query: ast });
|
|
1788
|
+
verdict.covered = out.covered;
|
|
1789
|
+
if (!out.covered)
|
|
1790
|
+
verdict.reasons = out.reasons ?? ["not provably covered"];
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
catch (e) {
|
|
1794
|
+
verdict.reasons = [errMessage(e)];
|
|
1795
|
+
}
|
|
1796
|
+
verdicts.push(verdict);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
const uncovered = verdicts.filter((v) => !v.covered);
|
|
1800
|
+
if (vopts?.strict && uncovered.length > 0) {
|
|
1801
|
+
throw new Error(`validateRealtime: ${uncovered.length} labeled query verdict(s) not provably covered — ` +
|
|
1802
|
+
uncovered
|
|
1803
|
+
.map((v) => `${v.query} (profile "${v.profile}"): ${(v.reasons ?? []).join("; ")}`)
|
|
1804
|
+
.join(" | "));
|
|
1805
|
+
}
|
|
1806
|
+
return { verdicts, uncovered };
|
|
1807
|
+
};
|
|
1140
1808
|
// The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
|
|
1141
1809
|
// the `realtime` block (which also activates `/room-boot`) or the deprecated bare
|
|
1142
1810
|
// `authorizeRoom` (trio only). Hosting an authority is never a default.
|
|
1143
|
-
const realtime = opts.realtime;
|
|
1144
1811
|
const roomAuthorizer = realtime
|
|
1145
1812
|
? (realtime.authorize ?? defaultFlushGate(realtime.shellSecret))
|
|
1146
1813
|
: opts.authorizeRoom;
|
|
@@ -1150,6 +1817,31 @@ export function createRindleApiServer(opts) {
|
|
|
1150
1817
|
}
|
|
1151
1818
|
await assertAuthorized(roomAuthorizer, context);
|
|
1152
1819
|
};
|
|
1820
|
+
// §2.1 room-key routing: a "<profile>/<key>" doc resolves through its NAMED profile — with the
|
|
1821
|
+
// boot-time unwindowed backstop (§2.3), which covers footprints that weren't statically
|
|
1822
|
+
// resolvable at construction AND key-dependent branches that window only some docs. Anything
|
|
1823
|
+
// else falls through to the legacy single-profile alias BYTE-IDENTICALLY (the anonymous
|
|
1824
|
+
// profile, bare-key form). A named profile wins over a legacy doc that merely contains "/".
|
|
1825
|
+
// Returns the profile's context set beside the AST (H-iv-b: the scope-spec compilation needs
|
|
1826
|
+
// the §2.2 owned/followed split; the legacy anonymous profile has no declaration — empty set).
|
|
1827
|
+
const resolveRoomFootprint = async (rt, doc, context) => {
|
|
1828
|
+
const split = splitRoomDoc(doc);
|
|
1829
|
+
if (split !== undefined) {
|
|
1830
|
+
const profile = roomProfiles.get(split.profile);
|
|
1831
|
+
if (profile !== undefined) {
|
|
1832
|
+
const ast = queryResultToAst(await profile.footprint(split.key, context));
|
|
1833
|
+
assertUnwindowedFootprint(ast, profile.name);
|
|
1834
|
+
return { ast, contextTables: profile.context };
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
if (rt.resolveFootprint) {
|
|
1838
|
+
return {
|
|
1839
|
+
ast: queryResultToAst(await rt.resolveFootprint(doc, context)),
|
|
1840
|
+
contextTables: new Set(),
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
throw new RindleApiError("not-found", `no room profile matches doc "${doc}" — named profiles are addressed as "<profile>/<key>"`, 404);
|
|
1844
|
+
};
|
|
1153
1845
|
// The store's verdict rides specific statuses + body shapes (fence / conflict /
|
|
1154
1846
|
// identity) the room decodes — pass a daemon HTTP error through VERBATIM.
|
|
1155
1847
|
const daemonVerdict = (e) => {
|
|
@@ -1170,6 +1862,7 @@ export function createRindleApiServer(opts) {
|
|
|
1170
1862
|
createQueryLease,
|
|
1171
1863
|
readQuery,
|
|
1172
1864
|
assertPins,
|
|
1865
|
+
validateRealtime,
|
|
1173
1866
|
pushMutation,
|
|
1174
1867
|
pushMutations,
|
|
1175
1868
|
handleApplyRowChangeTxnJson: async (body, context) => {
|
|
@@ -1201,6 +1894,7 @@ export function createRindleApiServer(opts) {
|
|
|
1201
1894
|
handleRoomLmidsJson: async (body, context) => {
|
|
1202
1895
|
await roomGate(context);
|
|
1203
1896
|
const msg = parseObject(body, "room-lmids request");
|
|
1897
|
+
const doc = parseString(msg.doc, "doc");
|
|
1204
1898
|
if (!Array.isArray(msg.clients) || msg.clients.some((c) => typeof c !== "string")) {
|
|
1205
1899
|
throw new RindleApiError("bad-request", "clients must be an array of strings", 400);
|
|
1206
1900
|
}
|
|
@@ -1209,7 +1903,7 @@ export function createRindleApiServer(opts) {
|
|
|
1209
1903
|
throw new Error("the configured daemon client does not implement roomLmids");
|
|
1210
1904
|
}
|
|
1211
1905
|
try {
|
|
1212
|
-
return { status: 200, body: await lmids({ clients: msg.clients }) };
|
|
1906
|
+
return { status: 200, body: await lmids({ doc, clients: msg.clients }) };
|
|
1213
1907
|
}
|
|
1214
1908
|
catch (e) {
|
|
1215
1909
|
return daemonVerdict(e);
|
|
@@ -1224,7 +1918,7 @@ export function createRindleApiServer(opts) {
|
|
|
1224
1918
|
const doc = parseString(msg.doc, "doc");
|
|
1225
1919
|
if (msg.instance !== undefined)
|
|
1226
1920
|
parseString(msg.instance, "instance"); // diagnostic identity only
|
|
1227
|
-
const ast =
|
|
1921
|
+
const { ast, contextTables } = await resolveRoomFootprint(realtime, doc, context);
|
|
1228
1922
|
const claim = opts.daemon.claimRoomEpoch?.bind(opts.daemon);
|
|
1229
1923
|
if (!claim) {
|
|
1230
1924
|
throw new Error("the configured daemon client does not implement claimRoomEpoch");
|
|
@@ -1252,6 +1946,9 @@ export function createRindleApiServer(opts) {
|
|
|
1252
1946
|
const res = {
|
|
1253
1947
|
epoch,
|
|
1254
1948
|
upstreamLeaseToken: lease.leaseToken,
|
|
1949
|
+
// H-iv-b: the §3.3 commit-gate scope specs, for named-profile AND legacy docs alike
|
|
1950
|
+
// (the footprint AST is resolved either way; legacy has an empty context set).
|
|
1951
|
+
scopes: compileRoomScopeSpecs(ast, contextTables),
|
|
1255
1952
|
flush: {
|
|
1256
1953
|
urls: {
|
|
1257
1954
|
apply: routes.applyRowChangeTxn,
|
|
@@ -1458,10 +2155,145 @@ function queryLeaseResponse(out) {
|
|
|
1458
2155
|
function errMessage(reason) {
|
|
1459
2156
|
return String(reason?.message ?? reason);
|
|
1460
2157
|
}
|
|
2158
|
+
// --------------------------------------------------------- lifecycle system leases (Slice I-iii)
|
|
2159
|
+
// The four §4 lifecycle system tables, mirrored VERBATIM from the daemon DDL — the source of
|
|
2160
|
+
// truth is `rust/rindle-replica/src/mutations.rs` (`realtime_lifecycle_ddl()` + the room-ledger
|
|
2161
|
+
// DDL in `enable_client_mutations`); duplicated here like `DEFAULT_ROUTES` is client-side so this
|
|
2162
|
+
// package needs no engine import. `Db::enable_realtime_lifecycle` REGISTERS all four, so a
|
|
2163
|
+
// hand-built AST over them materializes and resolves `hello` like any base table (the room-boot
|
|
2164
|
+
// direct-materialize pattern).
|
|
2165
|
+
const SCOPE_SESSIONS_TABLE = "_rindle_scope_sessions";
|
|
2166
|
+
const ROOM_WATERMARK_TABLE = "_rindle_room_watermark";
|
|
2167
|
+
const ROOM_CLIENT_MUTATIONS_TABLE = "_rindle_room_client_mutations";
|
|
2168
|
+
const ROOM_MUTATION_OUTCOMES_TABLE = "_rindle_room_mutation_outcomes";
|
|
2169
|
+
// --------------------------------------------------------- occupancy counting (Slice I-iv, §4.1)
|
|
2170
|
+
//
|
|
2171
|
+
// The occupancy step rides the NORMAL surfaces end to end: the session upsert + lazy sweep are one
|
|
2172
|
+
// `executeSqlTxn` (a plain write txn — CDC-captured since I-i, so the row landing IS the doorbell
|
|
2173
|
+
// delta fanning to every subscribed solo client; no clientID/mid — a system write must never
|
|
2174
|
+
// advance an lmid — and no idempotencyKey — a renewal's re-upsert must re-run, that is the
|
|
2175
|
+
// refresh), and the count is one `executeSqlRead` with `consistency: "strong"` — the read surface
|
|
2176
|
+
// the api-server already has against the daemon (the `DaemonLazyTx` fallback precedent above).
|
|
2177
|
+
// "strong" routes the read to the WRITE MASTER in a split deploy, which just serialized our
|
|
2178
|
+
// upsert: read-your-writes without a mutation session (the interactive-txn machinery is optional
|
|
2179
|
+
// on the daemon interface and far heavier than this two-round-trip pair needs).
|
|
2180
|
+
/** Default {@link RindleRealtimeLifecycleOptions.minSessions} — the §4.1 1→2 trigger. */
|
|
2181
|
+
const DEFAULT_LIFECYCLE_MIN_SESSIONS = 2;
|
|
2182
|
+
/** Default {@link RindleRealtimeLifecycleOptions.graceMs} — the §9.1 hysteresis window. */
|
|
2183
|
+
const DEFAULT_LIFECYCLE_GRACE_MS = 120_000;
|
|
2184
|
+
/** Default {@link RindleRealtimeLifecycleOptions.sessionTtlMs} fallback when no `leaseTtlMs` is
|
|
2185
|
+
* configured either — 5 minutes, the {@link DEFAULT_ROOM_TOKEN_TTL_MS} cadence (see the field doc). */
|
|
2186
|
+
const DEFAULT_SESSION_TTL_MS = 5 * 60_000;
|
|
2187
|
+
/** Sweep slack past the grace window (D4): rows are deleted only once expired for MORE than
|
|
2188
|
+
* `graceMs + this` — the linger I-v's downgrade decision reads must comfortably outlive the
|
|
2189
|
+
* grace comparison itself under clock skew between api-server instances (occupancy tolerates
|
|
2190
|
+
* skew ≤ grace; the slack keeps the boundary case out of the deletable band). */
|
|
2191
|
+
const SESSION_SWEEP_SLACK_MS = 60_000;
|
|
2192
|
+
/** D7 upsert: one row per (scope, clientId) — `(scope, client_id)` is the table's PRIMARY KEY
|
|
2193
|
+
* (`realtime_lifecycle_ddl()`), so a renewal refreshes `expires_at` in place. */
|
|
2194
|
+
const SESSION_UPSERT_SQL = `INSERT INTO ${SCOPE_SESSIONS_TABLE} (scope, client_id, expires_at) VALUES (?, ?, ?) ` +
|
|
2195
|
+
`ON CONFLICT(scope, client_id) DO UPDATE SET expires_at = excluded.expires_at`;
|
|
2196
|
+
/** The D4 lazy sweep, in the SAME txn as the upsert: age out THIS scope's long-expired rows.
|
|
2197
|
+
* Param 2 is `now − (graceMs + SESSION_SWEEP_SLACK_MS)` — never tighter (the linger contract). */
|
|
2198
|
+
const SESSION_SWEEP_SQL = `DELETE FROM ${SCOPE_SESSIONS_TABLE} WHERE scope = ? AND expires_at < ?`;
|
|
2199
|
+
/** The occupancy read, one SELECT: cell 0 = DISTINCT unexpired sessions (`expires_at > now`;
|
|
2200
|
+
* distinct by construction — `(scope, client_id)` is the PK), cell 1 = sessions expired WITHIN
|
|
2201
|
+
* the grace window (`now − graceMs < expires_at ≤ now`) — the upward hysteresis input, cell 2 =
|
|
2202
|
+
* ALL matching rows regardless of expiry (the I-v "room plausibly exists" signal: a scope with
|
|
2203
|
+
* ANY other-session row — live OR still lingering pre-sweep — was shared, so a room was
|
|
2204
|
+
* provisioned; a never-shared solo doc has none and must never drain). Params:
|
|
2205
|
+
* `[now, now, now − graceMs, scope]`. */
|
|
2206
|
+
const SESSION_COUNT_SQL = `SELECT SUM(CASE WHEN expires_at > ? THEN 1 ELSE 0 END), ` +
|
|
2207
|
+
`SUM(CASE WHEN expires_at <= ? AND expires_at > ? THEN 1 ELSE 0 END), ` +
|
|
2208
|
+
`COUNT(*) ` +
|
|
2209
|
+
`FROM ${SCOPE_SESSIONS_TABLE} WHERE scope = ?`;
|
|
2210
|
+
/** {@link SESSION_COUNT_SQL} excluding the CALLER's own row (D6 counts *other* sessions; the
|
|
2211
|
+
* caller contributes itself as the `+ 1`). One extra trailing param: the caller's clientId. */
|
|
2212
|
+
const SESSION_COUNT_OTHERS_SQL = `${SESSION_COUNT_SQL} AND client_id <> ?`;
|
|
2213
|
+
/** `col = <string literal>` — the only predicate shape the lifecycle ASTs need. */
|
|
2214
|
+
function colEq(name, value) {
|
|
2215
|
+
return { type: "simple", op: "=", left: { type: "column", name }, right: { type: "literal", value } };
|
|
2216
|
+
}
|
|
2217
|
+
/** The doorbell AST (§4.1): every unexpired row under the scope is one live session; the row
|
|
2218
|
+
* delta arriving through a solo client's daemon subscription IS the 1→2 upgrade signal. The
|
|
2219
|
+
* expiry filter is deliberately NOT in the predicate — `expires_at > now()` would freeze `now`
|
|
2220
|
+
* at mint time; liveness is the READER's judgment (I-iv), the stream just carries the rows. */
|
|
2221
|
+
function scopeSessionsAst(scope) {
|
|
2222
|
+
return { table: SCOPE_SESSIONS_TABLE, where: colEq("scope", scope) };
|
|
2223
|
+
}
|
|
2224
|
+
/** The §4.2 fence AST: the doc's monotone `flush_seq` row. */
|
|
2225
|
+
function roomWatermarkAst(doc) {
|
|
2226
|
+
return { table: ROOM_WATERMARK_TABLE, where: colEq("doc", doc) };
|
|
2227
|
+
}
|
|
2228
|
+
/** The §7.1 ledger / §3.3 outcome ASTs share one shape: doc-scoped, and ADDITIONALLY
|
|
2229
|
+
* client-scoped when the lease request carried the browser's stable `clientId` (the same id the
|
|
2230
|
+
* mutation envelopes stamp, so it is exactly the ledger/outcome `client_id`). Without it the
|
|
2231
|
+
* predicate stays doc-only and the client filters to its own rows (defense in depth either
|
|
2232
|
+
* way — the client always filters). */
|
|
2233
|
+
function docClientAst(table, doc, clientId) {
|
|
2234
|
+
const docCond = colEq("doc", doc);
|
|
2235
|
+
return {
|
|
2236
|
+
table,
|
|
2237
|
+
where: clientId === undefined ? docCond : { type: "and", conditions: [docCond, colEq("client_id", clientId)] },
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
// --------------------------------------------------------- room-serve helpers (G-iv-b)
|
|
2241
|
+
/** Default room lease token TTL: short (minutes) per RINDLE-REALTIME §4.1 — renewal is a fresh
|
|
2242
|
+
* lease through the api-server, never an extension of this token. */
|
|
2243
|
+
const DEFAULT_ROOM_TOKEN_TTL_MS = 5 * 60_000;
|
|
2244
|
+
/** Deterministic JSON: object keys sorted recursively, so two structurally identical ASTs from
|
|
2245
|
+
* independent resolves stringify identically (the verdict-cache key). */
|
|
2246
|
+
function stableStringify(v) {
|
|
2247
|
+
return JSON.stringify(v, (_key, value) => {
|
|
2248
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
2249
|
+
const rec = value;
|
|
2250
|
+
const sorted = {};
|
|
2251
|
+
for (const k of Object.keys(rec).sort())
|
|
2252
|
+
sorted[k] = rec[k];
|
|
2253
|
+
return sorted;
|
|
2254
|
+
}
|
|
2255
|
+
return value;
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2258
|
+
/** Does the AST contain an aggregate/reduce shape ANYWHERE (root, a `related` subquery, or an
|
|
2259
|
+
* `EXISTS` child)? Room-serving refuses these regardless of coverage: the client's aggregate
|
|
2260
|
+
* overlay (AGGREGATE-SYNC) is computed against the DAEMON's normalized stream and stays
|
|
2261
|
+
* daemon-gated until post-G. (`groupBy`/`having` only occur alongside `aggregate`, so testing
|
|
2262
|
+
* `aggregate` covers them; `having` is still walked for nested EXISTS aggregates.) */
|
|
2263
|
+
function astHasAggregate(ast) {
|
|
2264
|
+
if (ast.aggregate !== undefined)
|
|
2265
|
+
return true;
|
|
2266
|
+
for (const rel of ast.related ?? []) {
|
|
2267
|
+
if (astHasAggregate(rel.subquery))
|
|
2268
|
+
return true;
|
|
2269
|
+
}
|
|
2270
|
+
return conditionHasAggregate(ast.where) || conditionHasAggregate(ast.having);
|
|
2271
|
+
}
|
|
2272
|
+
function conditionHasAggregate(cond) {
|
|
2273
|
+
if (cond === undefined)
|
|
2274
|
+
return false;
|
|
2275
|
+
switch (cond.type) {
|
|
2276
|
+
case "simple":
|
|
2277
|
+
return false;
|
|
2278
|
+
case "and":
|
|
2279
|
+
case "or":
|
|
2280
|
+
return cond.conditions.some(conditionHasAggregate);
|
|
2281
|
+
case "correlatedSubquery":
|
|
2282
|
+
return astHasAggregate(cond.related.subquery);
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
1461
2285
|
async function reject(backend, envelope, reason) {
|
|
1462
2286
|
const output = await backend.reject({ envelope, reason });
|
|
1463
2287
|
return { accepted: false, rejected: true, reason, output };
|
|
1464
2288
|
}
|
|
2289
|
+
/** The single place a {@link MutationOutcome} becomes the wire {@link PushMutationResponse} — shared
|
|
2290
|
+
* by the tx-form path and every scoped-mutator seal branch so the accepted/rejected shape can never
|
|
2291
|
+
* drift between them. */
|
|
2292
|
+
function outcomeToResponse(outcome) {
|
|
2293
|
+
return outcome.accepted
|
|
2294
|
+
? { accepted: true, rejected: false, output: outcome.output }
|
|
2295
|
+
: { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
|
|
2296
|
+
}
|
|
1465
2297
|
function parseObject(value, label) {
|
|
1466
2298
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1467
2299
|
throw new RindleApiError("bad-request", `invalid ${label}`, 400);
|