@lunora/do 1.0.0-alpha.13 → 1.0.0-alpha.14
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.mts +240 -55
- package/dist/index.d.ts +240 -55
- package/dist/index.mjs +8 -5
- package/dist/packem_shared/{ADMIN_FUNCTIONS-D_UiYJFk.mjs → ADMIN_FUNCTIONS-fMl1vyt1.mjs} +40 -1
- package/dist/packem_shared/{NotUniqueError-DZQtH02h.mjs → NotUniqueError-BXn2j5vu.mjs} +1 -1
- package/dist/packem_shared/{ROOT_DO_SIZE_WARN_BYTES-XEs4csIH.mjs → ROOT_DO_SIZE_WARN_BYTES-DXVOGLBS.mjs} +923 -80
- package/dist/packem_shared/{ctx-db-shapes-DVoeZpo-.mjs → ctx-db-shapes-BQapRFjB.mjs} +1 -1
- package/dist/packem_shared/diffExternalSource-Br2GYVZB.mjs +44 -0
- package/dist/packem_shared/isSourceDue-D_wTv871.mjs +40 -0
- package/dist/packem_shared/materializeExternalRows-CcsfmiDe.mjs +23 -0
- package/dist/packem_shared/{serveRelationFanout-C6lDaesn.mjs → serveRelationFanout-EZLD9-O3.mjs} +1 -1
- package/dist/packem_shared/{subscriptionListDeltas-ce84gpwL.mjs → subscriptionListDeltas-DoB136JT.mjs} +14 -1
- package/package.json +1 -1
|
@@ -4,19 +4,20 @@ import { recordAuthEvent, readAuthMetrics } from './AUTH_METRICS_BUCKETS_TABLE-C
|
|
|
4
4
|
import { DATA_MIGRATION_STATE_TABLE, readMigrationStatus } from './DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs';
|
|
5
5
|
import { SCAN_DEP, createDependencyTracker, tableFromDepKey } from './SCAN_DEP-DLJF8dsj.mjs';
|
|
6
6
|
import { readFunctionMetricsTotals, readFunctionMetricIndexHits, recordFunctionMetric, mergeScanAttribution, readFunctionMetrics, readFunctionMetricBuckets } from './FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs';
|
|
7
|
-
import { ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, selectMatchingIds, ADMIN_FUNCTIONS, findStorageReferences, listTables, summarizeSubscriptions, readTablePage, facetColumn, FLAGS_FUNCTION_PREFIX, MAX_PAGE_SIZE } from './ADMIN_FUNCTIONS-
|
|
7
|
+
import { createFanoutCounters, ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, selectMatchingIds, ADMIN_FUNCTIONS, findStorageReferences, listTables, summarizeSubscriptions, summarizeFanoutTopics, readTablePage, facetColumn, FLAGS_FUNCTION_PREFIX, recordFanoutPass, MAX_PAGE_SIZE } from './ADMIN_FUNCTIONS-fMl1vyt1.mjs';
|
|
8
8
|
import { LogBuffer } from './LogBuffer-B_Ezju_N.mjs';
|
|
9
9
|
import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './MAIL_RETENTION-CPpgl-dX.mjs';
|
|
10
10
|
import { readBookmark, armRestore } from './armRestore-BJk53Ro8.mjs';
|
|
11
11
|
import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-1hDydFyv.mjs';
|
|
12
|
+
import { stableStringify } from './stableStringify-CyHKJXre.mjs';
|
|
13
|
+
import { awaitWsDrain, trySendFrame, subscriptionListDeltas, sendDeltaFrames } from './subscriptionListDeltas-DoB136JT.mjs';
|
|
12
14
|
import { redact, standardRules } from '@visulima/redact';
|
|
13
15
|
import { i as isDevEnvironment, c as buildSettings, b as buildSecurityAudit } from './security-audit-CucgBice.mjs';
|
|
14
16
|
import { runReadonlySql } from './MAX_SQL_ROWS-dDcFE1YZ.mjs';
|
|
15
|
-
import { trySendFrame, subscriptionListDeltas, sendDeltaFrames } from './subscriptionListDeltas-ce84gpwL.mjs';
|
|
16
17
|
import { ConflictError } from './ConflictError-C0STs6bU.mjs';
|
|
17
18
|
import { e as deleteGlobalShapeSnapshotsForConnection, g as readIdempotent, h as writeIdempotent, t as trimIdempotent, r as readClientWatermark, m as migrateClientWatermark, c as advanceClientWatermark, d as deleteGlobalShapeSnapshot, f as readGlobalShapeSnapshot, w as writeGlobalShapeSnapshot } from './ctx-db-idempotency-BdcNpvY4.mjs';
|
|
18
19
|
import { CDC_LOG_TABLE, readCdcChanges, readCdcCursor, readCdcEpoch, minCdcSeq, bumpCdcEpoch } from './CDC_LOG_TABLE-DSycmnDf.mjs';
|
|
19
|
-
import {
|
|
20
|
+
import { a as selectShapeMemberIds, s as selectShapeRows } from './ctx-db-shapes-BQapRFjB.mjs';
|
|
20
21
|
|
|
21
22
|
const AUDIT_LOG_TABLE = "__lunora_audit__";
|
|
22
23
|
const AUDIT_LOG_RETENTION = 1e3;
|
|
@@ -144,6 +145,713 @@ const readQueryMetrics = (sql) => {
|
|
|
144
145
|
});
|
|
145
146
|
};
|
|
146
147
|
|
|
148
|
+
const RELAY_NAME_INFIX = "::relay::";
|
|
149
|
+
const relayName = (ownerKey, index) => `${ownerKey}${RELAY_NAME_INFIX}${String(index)}`;
|
|
150
|
+
const parseRelayName = (name) => {
|
|
151
|
+
const at = name.lastIndexOf(RELAY_NAME_INFIX);
|
|
152
|
+
if (at === -1) {
|
|
153
|
+
return void 0;
|
|
154
|
+
}
|
|
155
|
+
const ownerKey = name.slice(0, at);
|
|
156
|
+
const indexText = name.slice(at + RELAY_NAME_INFIX.length);
|
|
157
|
+
const relayIndex = Number(indexText);
|
|
158
|
+
if (ownerKey.length === 0 || !Number.isInteger(relayIndex) || relayIndex < 0 || String(relayIndex) !== indexText) {
|
|
159
|
+
return void 0;
|
|
160
|
+
}
|
|
161
|
+
return { ownerKey, relayIndex };
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const shapeRoutingKey = (name, args) => stableStringify({ args: args ?? {}, name });
|
|
165
|
+
const DEFAULT_PROMOTION_THRESHOLDS = { tUp: 8e3 };
|
|
166
|
+
|
|
167
|
+
const projectColumns = (document_, columns) => {
|
|
168
|
+
if (!columns) {
|
|
169
|
+
return document_;
|
|
170
|
+
}
|
|
171
|
+
const projected = /* @__PURE__ */ Object.create(null);
|
|
172
|
+
for (const key of ["_id", "_creationTime", ...columns]) {
|
|
173
|
+
if (Object.hasOwn(document_, key)) {
|
|
174
|
+
projected[key] = document_[key];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return projected;
|
|
178
|
+
};
|
|
179
|
+
const diffGlobalMembership = (rows, previous, options) => {
|
|
180
|
+
const { columns, table } = options;
|
|
181
|
+
const next = /* @__PURE__ */ new Map();
|
|
182
|
+
const rowsPatch = [];
|
|
183
|
+
for (const { doc, id } of rows) {
|
|
184
|
+
const value = projectColumns(doc, columns);
|
|
185
|
+
const json = JSON.stringify(value);
|
|
186
|
+
next.set(id, json);
|
|
187
|
+
const before = previous.get(id);
|
|
188
|
+
if (before === void 0) {
|
|
189
|
+
rowsPatch.push({ key: id, op: "insert", table, value });
|
|
190
|
+
} else if (before !== json) {
|
|
191
|
+
rowsPatch.push({ key: id, op: "update", table, value });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const id of previous.keys()) {
|
|
195
|
+
if (!next.has(id)) {
|
|
196
|
+
rowsPatch.push({ key: id, op: "delete", table });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { next, rowsPatch };
|
|
200
|
+
};
|
|
201
|
+
const buildPokeFrames = (parts, meta) => {
|
|
202
|
+
const { baseCheckpoint, checkpoint, epoch, lastMutationId, pokeId } = meta;
|
|
203
|
+
const frames = [JSON.stringify({ baseCheckpoint, epoch, pokeId, type: "pokeStart" })];
|
|
204
|
+
for (const part of parts) {
|
|
205
|
+
frames.push(
|
|
206
|
+
JSON.stringify({
|
|
207
|
+
pokeId,
|
|
208
|
+
rowsPatch: part.rowsPatch,
|
|
209
|
+
shapeId: part.shapeId,
|
|
210
|
+
type: "pokePart",
|
|
211
|
+
...lastMutationId === void 0 ? {} : { lastMutationId }
|
|
212
|
+
})
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
frames.push(JSON.stringify({ checkpoint, epoch, pokeId, type: "pokeEnd" }));
|
|
216
|
+
return frames;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const DEFAULT_RELAY_FAN = 2;
|
|
220
|
+
const DEFAULT_MAX_RELAYS = 8;
|
|
221
|
+
const envPositiveInt = (env, key, fallback) => {
|
|
222
|
+
const raw = env?.[key];
|
|
223
|
+
let parsed = Number.NaN;
|
|
224
|
+
if (typeof raw === "string") {
|
|
225
|
+
parsed = Number.parseInt(raw, 10);
|
|
226
|
+
} else if (typeof raw === "number") {
|
|
227
|
+
parsed = raw;
|
|
228
|
+
}
|
|
229
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
230
|
+
};
|
|
231
|
+
const RELAY_MULTICAST_IDENTITY = {};
|
|
232
|
+
const assertNeverFrame = (frame) => {
|
|
233
|
+
throw new Error(`unhandled relay frame: ${JSON.stringify(frame)}`);
|
|
234
|
+
};
|
|
235
|
+
const asRelayNamespace = (value) => {
|
|
236
|
+
if (value === null || typeof value !== "object") {
|
|
237
|
+
return void 0;
|
|
238
|
+
}
|
|
239
|
+
const candidate = value;
|
|
240
|
+
return typeof candidate.idFromName === "function" && typeof candidate.get === "function" ? candidate : void 0;
|
|
241
|
+
};
|
|
242
|
+
const jsonRelayResponse = (body) => Response.json(body, { headers: { "content-type": "application/json" } });
|
|
243
|
+
const noContent = () => new Response(null, { status: 204 });
|
|
244
|
+
class RelayLink {
|
|
245
|
+
constructor(host, roleId) {
|
|
246
|
+
this.host = host;
|
|
247
|
+
this.roleId = roleId;
|
|
248
|
+
}
|
|
249
|
+
host;
|
|
250
|
+
roleId;
|
|
251
|
+
/**
|
|
252
|
+
* Serve the internal `/_lunora/relay` control channel: parse the frame, then
|
|
253
|
+
* dispatch to the role hooks. One exhaustive switch means a new frame type added
|
|
254
|
+
* to `relay.ts` without a case here is a COMPILE error, not a runtime mis-route.
|
|
255
|
+
*/
|
|
256
|
+
async handleControl(request) {
|
|
257
|
+
let message;
|
|
258
|
+
try {
|
|
259
|
+
message = await request.json();
|
|
260
|
+
} catch {
|
|
261
|
+
return new Response("bad request", { status: 400 });
|
|
262
|
+
}
|
|
263
|
+
switch (message.type) {
|
|
264
|
+
case "relay_attach": {
|
|
265
|
+
this.onAttach(message.relayIndex);
|
|
266
|
+
return noContent();
|
|
267
|
+
}
|
|
268
|
+
case "relay_detach": {
|
|
269
|
+
this.onDetach(message.relayIndex);
|
|
270
|
+
return noContent();
|
|
271
|
+
}
|
|
272
|
+
case "relay_frame": {
|
|
273
|
+
this.host.deliverWhisperLocal(message.topic, message.frame, void 0);
|
|
274
|
+
await this.onWhisperFrame(message);
|
|
275
|
+
return noContent();
|
|
276
|
+
}
|
|
277
|
+
case "relay_shape_poke": {
|
|
278
|
+
const iterated = this.host.getWebSockets().length;
|
|
279
|
+
const startMs = Date.now();
|
|
280
|
+
const delivered = this.onShapePoke(message);
|
|
281
|
+
this.host.recordShapePokeFanout(iterated, delivered, Date.now() - startMs);
|
|
282
|
+
return noContent();
|
|
283
|
+
}
|
|
284
|
+
case "relay_shape_subscribe": {
|
|
285
|
+
return jsonRelayResponse(this.onShapeSubscribe(message));
|
|
286
|
+
}
|
|
287
|
+
default: {
|
|
288
|
+
return assertNeverFrame(message);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** The hard cap on relays per shard (`LUNORA_MAX_RELAYS`) — a deployment constant the runtime surfaces in Studio as the cost ceiling. */
|
|
293
|
+
maxRelays() {
|
|
294
|
+
return envPositiveInt(this.host.env(), "LUNORA_MAX_RELAYS", DEFAULT_MAX_RELAYS);
|
|
295
|
+
}
|
|
296
|
+
/** Whether this DO can currently address its siblings (a namespace binding has been learned) — the relay tier is inert in single-DO mode. */
|
|
297
|
+
canAddressSiblings() {
|
|
298
|
+
return this.relayNamespace() !== void 0;
|
|
299
|
+
}
|
|
300
|
+
/** Resolve this DO's own namespace binding so it can address sibling owners/relays, or `undefined` when unknown. */
|
|
301
|
+
relayNamespace() {
|
|
302
|
+
const binding = this.host.shardBinding();
|
|
303
|
+
if (binding === void 0) {
|
|
304
|
+
return void 0;
|
|
305
|
+
}
|
|
306
|
+
return asRelayNamespace(this.host.env()?.[binding]);
|
|
307
|
+
}
|
|
308
|
+
/** POST a control frame to a sibling by name, fire-and-forget. Best-effort: a transient cross-DO failure drops the frame rather than throwing into the handler. */
|
|
309
|
+
async postRelayMessage(targetName, message) {
|
|
310
|
+
await this.requestRelayMessage(targetName, message);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* POST a control frame to a sibling by name and return its response (the shape-seed
|
|
314
|
+
* path needs the owner's frames back). Best-effort: a transient cross-DO failure
|
|
315
|
+
* returns `undefined` rather than throwing.
|
|
316
|
+
* @returns the sibling's response, or `undefined` when it can't be reached
|
|
317
|
+
*/
|
|
318
|
+
async requestRelayMessage(targetName, message) {
|
|
319
|
+
const namespace = this.relayNamespace();
|
|
320
|
+
if (namespace === void 0) {
|
|
321
|
+
return void 0;
|
|
322
|
+
}
|
|
323
|
+
const stub = typeof namespace.getByName === "function" ? namespace.getByName(targetName) : namespace.get(namespace.idFromName(targetName));
|
|
324
|
+
try {
|
|
325
|
+
return await stub.fetch("https://relay.internal/_lunora/relay", {
|
|
326
|
+
body: JSON.stringify(message),
|
|
327
|
+
headers: { "content-type": "application/json", "x-lunora-shard-binding": this.host.shardBinding() ?? "" },
|
|
328
|
+
method: "POST"
|
|
329
|
+
});
|
|
330
|
+
} catch {
|
|
331
|
+
return void 0;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
class OwnerRelay extends RelayLink {
|
|
336
|
+
/** Memoized RLS-uniform verdict per `(name, args)` shape — uniformity is stable, so the gate probe runs at most once per distinct shape. */
|
|
337
|
+
shapeUniformCache = /* @__PURE__ */ new Map();
|
|
338
|
+
/** Active relay indices, hydrated once from `__lunora_relays` and cached for the synchronous forward path. */
|
|
339
|
+
relaySetCache;
|
|
340
|
+
/** Relay-uniform shapes a relay has subscribers for, keyed by `(name, args)`. `cursor` is the cohort frontier the owner has multicast deltas up to. */
|
|
341
|
+
relayShapeRegistry = /* @__PURE__ */ new Map();
|
|
342
|
+
/** NON-uniform (identity-scoped) relay shapes, one entry per relay socket, keyed `relayIndex:connectionId:subId`. Each is served live by a per-socket proxy poke. */
|
|
343
|
+
relayShapeProxies = /* @__PURE__ */ new Map();
|
|
344
|
+
constructor(host, ownerKey) {
|
|
345
|
+
super(host, { ownerKey });
|
|
346
|
+
}
|
|
347
|
+
async forwardWhisper(topic, frame) {
|
|
348
|
+
if (!this.canAddressSiblings()) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const relays = this.ownerRelaySet();
|
|
352
|
+
if (relays.size === 0) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
await Promise.all([...relays].map((index) => this.postRelayMessage(relayName(this.roleId.ownerKey, index), { frame, topic, type: "relay_frame" })));
|
|
356
|
+
}
|
|
357
|
+
async onFlush(changed, frameCursor) {
|
|
358
|
+
await Promise.all([this.multicastShapePokes(changed, frameCursor), this.proxyShapePokes(changed, frameCursor)]);
|
|
359
|
+
}
|
|
360
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: an owner serves its own shape subscribers locally, never through a relay
|
|
361
|
+
seedRelayShape() {
|
|
362
|
+
return Promise.resolve(void 0);
|
|
363
|
+
}
|
|
364
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only a relay announces
|
|
365
|
+
announce() {
|
|
366
|
+
return Promise.resolve();
|
|
367
|
+
}
|
|
368
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only a relay drains
|
|
369
|
+
announceDrain() {
|
|
370
|
+
return Promise.resolve();
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* How many relays the runtime should spread new connections across for this shard
|
|
374
|
+
* (plan 075 Phase 2). `0` keeps every connection on the owner. The owner promotes
|
|
375
|
+
* once its live socket count crosses `LUNORA_RELAY_THRESHOLD`, fanning to a fixed
|
|
376
|
+
* `LUNORA_RELAY_FAN` (capped by `LUNORA_MAX_RELAYS`, the cost ceiling).
|
|
377
|
+
*/
|
|
378
|
+
relayCount() {
|
|
379
|
+
const threshold = envPositiveInt(this.host.env(), "LUNORA_RELAY_THRESHOLD", DEFAULT_PROMOTION_THRESHOLDS.tUp);
|
|
380
|
+
if (this.host.getWebSockets().length < threshold) {
|
|
381
|
+
return 0;
|
|
382
|
+
}
|
|
383
|
+
const maxRelays = envPositiveInt(this.host.env(), "LUNORA_MAX_RELAYS", DEFAULT_MAX_RELAYS);
|
|
384
|
+
const fan = envPositiveInt(this.host.env(), "LUNORA_RELAY_FAN", DEFAULT_RELAY_FAN);
|
|
385
|
+
return Math.min(maxRelays, Math.max(1, fan));
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* The RLS-uniform gate (plan 075 Phase 3, review-hardened): whether a reactive
|
|
389
|
+
* shape may be relay-multicast — i.e. one delta is correct for **every**
|
|
390
|
+
* subscriber. Fail-closed on four grounds — a static RLS read-policy guard, the
|
|
391
|
+
* anonymous multicast identity as the probe base, two `Proxy`-backed probes that
|
|
392
|
+
* yield a distinct value for ANY accessed claim (so any claim a `where` reads —
|
|
393
|
+
* even a custom one outside `rls()` — diverges), and a wholesale-copy backstop.
|
|
394
|
+
* Cached per `(name, args)`, whose uniformity is stable.
|
|
395
|
+
*/
|
|
396
|
+
isShapeRelayUniform(name, args) {
|
|
397
|
+
const cacheKey = shapeRoutingKey(name, args);
|
|
398
|
+
const cached = this.shapeUniformCache.get(cacheKey);
|
|
399
|
+
if (cached !== void 0) {
|
|
400
|
+
return cached;
|
|
401
|
+
}
|
|
402
|
+
const uniform = this.probeShapeRelayUniform(name, args);
|
|
403
|
+
this.shapeUniformCache.set(cacheKey, uniform);
|
|
404
|
+
return uniform;
|
|
405
|
+
}
|
|
406
|
+
onAttach(index) {
|
|
407
|
+
this.addRelayToSet(index);
|
|
408
|
+
}
|
|
409
|
+
onDetach(index) {
|
|
410
|
+
this.removeRelayFromSet(index);
|
|
411
|
+
}
|
|
412
|
+
async onWhisperFrame(message) {
|
|
413
|
+
await Promise.all(
|
|
414
|
+
[...this.ownerRelaySet()].filter((index) => index !== message.originRelay).map(
|
|
415
|
+
(index) => this.postRelayMessage(relayName(this.roleId.ownerKey, index), { frame: message.frame, topic: message.topic, type: "relay_frame" })
|
|
416
|
+
)
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
onShapeSubscribe(message) {
|
|
420
|
+
return this.buildShapeSeedFrames(message);
|
|
421
|
+
}
|
|
422
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: an owner doesn't receive multicast pokes (it sends them)
|
|
423
|
+
onShapePoke() {
|
|
424
|
+
return 0;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Owner side (slice B.2): for every registered relay-uniform shape whose table
|
|
428
|
+
* changed this flush, compute the membership diff ONCE over `(cohort cursor,
|
|
429
|
+
* frameCursor]` and multicast the `rowsPatch` to every relay. Advances the cohort
|
|
430
|
+
* cursor synchronously (before any await) so a seed interleaving during the
|
|
431
|
+
* multicast registers at `frameCursor` and is skipped by this in-flight poke.
|
|
432
|
+
*/
|
|
433
|
+
async multicastShapePokes(changed, frameCursor) {
|
|
434
|
+
if (this.relayShapeRegistry.size === 0) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const relays = this.ownerRelaySet();
|
|
438
|
+
if (relays.size === 0) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const epoch = this.host.currentCdcEpoch();
|
|
442
|
+
const sends = [];
|
|
443
|
+
for (const entry of this.relayShapeRegistry.values()) {
|
|
444
|
+
let resolved;
|
|
445
|
+
try {
|
|
446
|
+
resolved = this.host.resolveShape(entry.name, entry.args, RELAY_MULTICAST_IDENTITY);
|
|
447
|
+
} catch {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (resolved === void 0 || resolved.global === true || !changed.has(resolved.table)) {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const fromCursor = entry.cursor;
|
|
454
|
+
const rowsPatch = this.host.buildShapeDiff(resolved, fromCursor, frameCursor);
|
|
455
|
+
if (rowsPatch.length === 0) {
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
entry.cursor = frameCursor;
|
|
459
|
+
const poke = {
|
|
460
|
+
args: entry.args,
|
|
461
|
+
checkpoint: frameCursor,
|
|
462
|
+
epoch,
|
|
463
|
+
fromCursor,
|
|
464
|
+
name: entry.name,
|
|
465
|
+
rowsPatch,
|
|
466
|
+
type: "relay_shape_poke"
|
|
467
|
+
};
|
|
468
|
+
for (const index of relays) {
|
|
469
|
+
sends.push(this.postRelayMessage(relayName(this.roleId.ownerKey, index), poke));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
await Promise.all(sends);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Owner side (review MEDIUM-3): for every NON-uniform relay-shape proxy whose
|
|
476
|
+
* table changed this flush, compute that one subscriber's diff over `(entry.cursor,
|
|
477
|
+
* frameCursor]` UNDER ITS OWN forwarded identity (RLS-correct) and deliver a
|
|
478
|
+
* `targetConnectionId`-addressed poke to just that socket's relay. Each entry
|
|
479
|
+
* tracks its own cursor — the diffs are identity-specific, no cohort sharing.
|
|
480
|
+
*/
|
|
481
|
+
async proxyShapePokes(changed, frameCursor) {
|
|
482
|
+
if (this.relayShapeProxies.size === 0) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const epoch = this.host.currentCdcEpoch();
|
|
486
|
+
const sends = [];
|
|
487
|
+
for (const entry of this.relayShapeProxies.values()) {
|
|
488
|
+
let resolved;
|
|
489
|
+
try {
|
|
490
|
+
resolved = this.host.resolveShape(entry.name, entry.args, entry.identity);
|
|
491
|
+
} catch {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (resolved === void 0 || resolved.global === true || !changed.has(resolved.table)) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const fromCursor = entry.cursor;
|
|
498
|
+
const rowsPatch = this.host.buildShapeDiff(resolved, fromCursor, frameCursor);
|
|
499
|
+
if (rowsPatch.length === 0) {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
entry.cursor = frameCursor;
|
|
503
|
+
const poke = {
|
|
504
|
+
args: entry.args,
|
|
505
|
+
checkpoint: frameCursor,
|
|
506
|
+
epoch,
|
|
507
|
+
fromCursor,
|
|
508
|
+
name: entry.name,
|
|
509
|
+
rowsPatch,
|
|
510
|
+
targetConnectionId: entry.connectionId,
|
|
511
|
+
type: "relay_shape_poke"
|
|
512
|
+
};
|
|
513
|
+
sends.push(this.postRelayMessage(relayName(this.roleId.ownerKey, entry.relayIndex), poke));
|
|
514
|
+
}
|
|
515
|
+
await Promise.all(sends);
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Serialize a shape's seed poke frames for a relay to deliver verbatim. Resolves
|
|
519
|
+
* under the forwarded socket identity (so RLS applies exactly as for a local
|
|
520
|
+
* subscribe), self-heals the relay set, registers the shape for live updates
|
|
521
|
+
* (cohort multicast when uniform, per-socket proxy when not), and stamps the
|
|
522
|
+
* relay's cohort memo at the registry FRONTIER (not the global cursor) so a late
|
|
523
|
+
* joiner is never stranded. `lastMutationId` is omitted (relayed sockets are
|
|
524
|
+
* owner-served for custom mutators).
|
|
525
|
+
* @returns the serialized frames + the cohort-memo cursor, or an error
|
|
526
|
+
*/
|
|
527
|
+
buildShapeSeedFrames(request) {
|
|
528
|
+
const identity = { identity: request.identity, userId: request.userId };
|
|
529
|
+
let resolved;
|
|
530
|
+
try {
|
|
531
|
+
resolved = this.host.resolveShape(request.name, request.args, identity);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
return { error: { code: "SHAPE_RESOLVE_FAILED", message: error instanceof Error ? error.message : "shape resolve failed" } };
|
|
534
|
+
}
|
|
535
|
+
if (resolved === void 0 || resolved.global === true) {
|
|
536
|
+
return { error: { code: "SHAPE_NOT_FOUND", message: `shape not relayable: ${request.name}` } };
|
|
537
|
+
}
|
|
538
|
+
if (request.relayIndex !== void 0) {
|
|
539
|
+
this.addRelayToSet(request.relayIndex);
|
|
540
|
+
}
|
|
541
|
+
const { baseCheckpoint, cursor, epoch, rowsPatch } = this.host.computeOpLogShapeSeed(
|
|
542
|
+
{ args: request.args, name: request.name, sinceEpoch: request.sinceEpoch, sinceSeq: request.sinceSeq },
|
|
543
|
+
resolved
|
|
544
|
+
);
|
|
545
|
+
let cohortCursor = cursor;
|
|
546
|
+
if (this.isShapeRelayUniform(request.name, request.args)) {
|
|
547
|
+
const routingKey = shapeRoutingKey(request.name, request.args);
|
|
548
|
+
let entry = this.relayShapeRegistry.get(routingKey);
|
|
549
|
+
if (entry === void 0) {
|
|
550
|
+
entry = { args: request.args, cursor, name: request.name };
|
|
551
|
+
this.relayShapeRegistry.set(routingKey, entry);
|
|
552
|
+
}
|
|
553
|
+
cohortCursor = entry.cursor;
|
|
554
|
+
} else if (request.relayIndex !== void 0 && request.connectionId !== void 0) {
|
|
555
|
+
this.relayShapeProxies.set(`${String(request.relayIndex)}:${request.connectionId}:${request.subId}`, {
|
|
556
|
+
args: request.args,
|
|
557
|
+
connectionId: request.connectionId,
|
|
558
|
+
cursor,
|
|
559
|
+
epoch,
|
|
560
|
+
identity,
|
|
561
|
+
name: request.name,
|
|
562
|
+
relayIndex: request.relayIndex,
|
|
563
|
+
subId: request.subId
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
const frames = buildPokeFrames([{ rowsPatch, shapeId: request.subId }], {
|
|
567
|
+
baseCheckpoint,
|
|
568
|
+
checkpoint: cursor,
|
|
569
|
+
epoch,
|
|
570
|
+
lastMutationId: void 0,
|
|
571
|
+
pokeId: this.host.nextPokeId()
|
|
572
|
+
});
|
|
573
|
+
return { cursor: cohortCursor, epoch, frames };
|
|
574
|
+
}
|
|
575
|
+
/** Ensure the reserved owner-side relay-set table exists (auto-hidden from the data browser by the `__lunora` prefix). */
|
|
576
|
+
ensureRelayTable() {
|
|
577
|
+
this.host.sql().exec("CREATE TABLE IF NOT EXISTS __lunora_relays (idx INTEGER PRIMARY KEY)");
|
|
578
|
+
}
|
|
579
|
+
/** The owner's active relay indices, hydrated once from `__lunora_relays` and cached for the synchronous forward path. */
|
|
580
|
+
ownerRelaySet() {
|
|
581
|
+
if (this.relaySetCache === void 0) {
|
|
582
|
+
this.ensureRelayTable();
|
|
583
|
+
const rows = this.host.sql().exec("SELECT idx FROM __lunora_relays").toArray();
|
|
584
|
+
this.relaySetCache = new Set(rows.map((row) => Number(row.idx)));
|
|
585
|
+
}
|
|
586
|
+
return this.relaySetCache;
|
|
587
|
+
}
|
|
588
|
+
/** Record a relay as active (idempotent), persisting it so the set survives the owner's hibernation. */
|
|
589
|
+
addRelayToSet(index) {
|
|
590
|
+
this.ensureRelayTable();
|
|
591
|
+
this.host.sql().exec("INSERT OR IGNORE INTO __lunora_relays (idx) VALUES (?)", index);
|
|
592
|
+
this.ownerRelaySet().add(index);
|
|
593
|
+
}
|
|
594
|
+
/** Drop a drained relay from the set and prune its dead per-socket proxy entries; on full drain, clear the (now dead) registry + uniform cache to bound growth. */
|
|
595
|
+
removeRelayFromSet(index) {
|
|
596
|
+
this.ensureRelayTable();
|
|
597
|
+
this.host.sql().exec("DELETE FROM __lunora_relays WHERE idx = ?", index);
|
|
598
|
+
const set = this.ownerRelaySet();
|
|
599
|
+
set.delete(index);
|
|
600
|
+
for (const [key, entry] of this.relayShapeProxies) {
|
|
601
|
+
if (entry.relayIndex === index) {
|
|
602
|
+
this.relayShapeProxies.delete(key);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (set.size === 0) {
|
|
606
|
+
this.relayShapeRegistry.clear();
|
|
607
|
+
this.shapeUniformCache.clear();
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* The one-shot computation behind {@link OwnerRelay.isShapeRelayUniform}, made
|
|
612
|
+
* sound against the cross-identity row-leak (review). Resolves under the anonymous
|
|
613
|
+
* multicast identity (the base) plus two `Proxy`-backed identities that return a
|
|
614
|
+
* distinct value for ANY accessed claim, requires all to agree on table + where +
|
|
615
|
+
* columns, rejects any table with an RLS read policy or a masked projected column,
|
|
616
|
+
* and fails closed if the claims are enumerated (a wholesale copy the proxy can't
|
|
617
|
+
* differentiate).
|
|
618
|
+
*/
|
|
619
|
+
probeShapeRelayUniform(name, args) {
|
|
620
|
+
let base;
|
|
621
|
+
try {
|
|
622
|
+
base = this.host.resolveShape(name, args, RELAY_MULTICAST_IDENTITY);
|
|
623
|
+
} catch {
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
if (base === void 0 || base.global === true) {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
if (this.host.rlsMetadata().policies.some((policy) => policy.on === "read" && policy.table === base.table)) {
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
if (this.shapeColumnsMasked(base.table, base.columns)) {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
const baseWhere = stableStringify(base.effectiveWhere);
|
|
636
|
+
const baseColumns = stableStringify(base.columns);
|
|
637
|
+
let enumerated = false;
|
|
638
|
+
const populate = (side) => {
|
|
639
|
+
const backing = { groups: [`grp_${side}`], roles: [side], sub: `__lunora_probe_${side}__` };
|
|
640
|
+
const claims = /* @__PURE__ */ new Proxy(backing, {
|
|
641
|
+
get: (target, key) => {
|
|
642
|
+
if (typeof key === "symbol" || key in target) {
|
|
643
|
+
return Reflect.get(target, key);
|
|
644
|
+
}
|
|
645
|
+
return `${side}:${key}`;
|
|
646
|
+
},
|
|
647
|
+
getOwnPropertyDescriptor: (target, key) => {
|
|
648
|
+
enumerated = true;
|
|
649
|
+
return Reflect.getOwnPropertyDescriptor(target, key);
|
|
650
|
+
},
|
|
651
|
+
has: (target, key) => typeof key === "symbol" ? Reflect.has(target, key) : true,
|
|
652
|
+
ownKeys: (target) => {
|
|
653
|
+
enumerated = true;
|
|
654
|
+
return Reflect.ownKeys(target);
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
return { identity: claims, userId: `__lunora_probe_${side}__` };
|
|
658
|
+
};
|
|
659
|
+
const matches = [RELAY_MULTICAST_IDENTITY, populate("a"), populate("b")].every((probe) => {
|
|
660
|
+
let resolved;
|
|
661
|
+
try {
|
|
662
|
+
resolved = this.host.resolveShape(name, args, probe);
|
|
663
|
+
} catch {
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
return resolved !== void 0 && resolved.global !== true && resolved.table === base.table && stableStringify(resolved.effectiveWhere) === baseWhere && stableStringify(resolved.columns) === baseColumns;
|
|
667
|
+
});
|
|
668
|
+
return matches && !enumerated;
|
|
669
|
+
}
|
|
670
|
+
/** Whether any column the shape projects from `table` is masked — a masked value is identity-dependent, so the shape can't be relay-uniform. */
|
|
671
|
+
shapeColumnsMasked(table, columns) {
|
|
672
|
+
const masked = this.host.maskMetadata().columns.filter((entry) => entry.table === table);
|
|
673
|
+
if (masked.length === 0) {
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
if (columns === void 0) {
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
const projected = new Set(columns);
|
|
680
|
+
return masked.some((entry) => projected.has(entry.column));
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
class RelayMember extends RelayLink {
|
|
684
|
+
/** `true` once this relay has announced itself to its owner this wake, so a hot socket churn doesn't re-attach on every subscribe. */
|
|
685
|
+
relayAnnounced = false;
|
|
686
|
+
/** Per-socket cohort memo `ws → subId → { cursor, epoch }`: the relay delivers a poke to a socket only while its memo matches the poke's `fromCursor`+`epoch`. */
|
|
687
|
+
shapeRelayMemos = /* @__PURE__ */ new WeakMap();
|
|
688
|
+
constructor(host, ownerKey, relayIndex) {
|
|
689
|
+
super(host, { ownerKey, relayIndex });
|
|
690
|
+
}
|
|
691
|
+
async forwardWhisper(topic, frame) {
|
|
692
|
+
if (!this.canAddressSiblings()) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
await this.postRelayMessage(this.roleId.ownerKey, { frame, originRelay: this.roleId.relayIndex, topic, type: "relay_frame" });
|
|
696
|
+
}
|
|
697
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: a relay receives no writes, so it never flushes its own CDC
|
|
698
|
+
onFlush() {
|
|
699
|
+
return Promise.resolve();
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Seed a shape held by a socket on this relay by forwarding the request to the
|
|
703
|
+
* owner: the owner resolves under this socket's verified identity and computes the
|
|
704
|
+
* seed frames, which the relay delivers verbatim. Returns a structured error
|
|
705
|
+
* (surfaced as a `shape_subscribe` error) when the owner can't be reached.
|
|
706
|
+
*/
|
|
707
|
+
async seedRelayShape(ws, subId, shape, identity) {
|
|
708
|
+
if (!this.canAddressSiblings()) {
|
|
709
|
+
return { code: "RELAY_MISCONFIGURED", message: "relay cannot address its owner" };
|
|
710
|
+
}
|
|
711
|
+
await this.announce();
|
|
712
|
+
const request = {
|
|
713
|
+
args: shape.args ?? {},
|
|
714
|
+
connectionId: this.host.readAttachment(ws).connectionId,
|
|
715
|
+
identity: identity.identity,
|
|
716
|
+
name: shape.name,
|
|
717
|
+
relayIndex: this.roleId.relayIndex,
|
|
718
|
+
sinceEpoch: shape.sinceEpoch,
|
|
719
|
+
sinceSeq: shape.sinceSeq,
|
|
720
|
+
subId,
|
|
721
|
+
type: "relay_shape_subscribe",
|
|
722
|
+
userId: identity.userId
|
|
723
|
+
};
|
|
724
|
+
const response = await this.requestRelayMessage(this.roleId.ownerKey, request);
|
|
725
|
+
if (response === void 0) {
|
|
726
|
+
return { code: "RELAY_SEED_FAILED", message: "owner did not answer the shape seed" };
|
|
727
|
+
}
|
|
728
|
+
let seed;
|
|
729
|
+
try {
|
|
730
|
+
seed = await response.json();
|
|
731
|
+
} catch {
|
|
732
|
+
return { code: "RELAY_SEED_FAILED", message: "malformed shape seed from owner" };
|
|
733
|
+
}
|
|
734
|
+
if (seed.error !== void 0) {
|
|
735
|
+
return seed.error;
|
|
736
|
+
}
|
|
737
|
+
if (seed.frames === void 0) {
|
|
738
|
+
return { code: "RELAY_SEED_FAILED", message: "owner returned no shape frames" };
|
|
739
|
+
}
|
|
740
|
+
await awaitWsDrain(ws);
|
|
741
|
+
for (const frame of seed.frames) {
|
|
742
|
+
trySendFrame(ws, frame);
|
|
743
|
+
}
|
|
744
|
+
this.recordRelayShapeMemo(ws, subId, seed.cursor ?? 0, seed.epoch);
|
|
745
|
+
return "ok";
|
|
746
|
+
}
|
|
747
|
+
/** A relay announces itself to its owner (once per wake) on its first subscriber, retrying on a failed attach so a dropped frame can't strand its sockets (LOW-4). */
|
|
748
|
+
async announce() {
|
|
749
|
+
if (this.relayAnnounced || !this.canAddressSiblings()) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
this.relayAnnounced = true;
|
|
753
|
+
const response = await this.requestRelayMessage(this.roleId.ownerKey, { relayIndex: this.roleId.relayIndex, type: "relay_attach" });
|
|
754
|
+
if (!response?.ok) {
|
|
755
|
+
this.relayAnnounced = false;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/** Once this relay loses its last socket (the `closing` one excluded), detach from the owner and re-arm the announce latch for a future subscriber. */
|
|
759
|
+
async announceDrain(closing) {
|
|
760
|
+
if (!this.canAddressSiblings()) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
if (this.host.getWebSockets().some((ws) => ws !== closing)) {
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
this.relayAnnounced = false;
|
|
767
|
+
await this.postRelayMessage(this.roleId.ownerKey, { relayIndex: this.roleId.relayIndex, type: "relay_detach" });
|
|
768
|
+
}
|
|
769
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: a relay never spreads connections (flat single tier)
|
|
770
|
+
relayCount() {
|
|
771
|
+
return 0;
|
|
772
|
+
}
|
|
773
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: the RLS-uniform gate is an owner concern
|
|
774
|
+
isShapeRelayUniform() {
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only an owner tracks a relay set
|
|
778
|
+
onAttach() {
|
|
779
|
+
}
|
|
780
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only an owner tracks a relay set
|
|
781
|
+
onDetach() {
|
|
782
|
+
}
|
|
783
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only an owner re-distributes a forwarded whisper
|
|
784
|
+
onWhisperFrame() {
|
|
785
|
+
return Promise.resolve();
|
|
786
|
+
}
|
|
787
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: a relay can't seed (no op-log) — the owner does
|
|
788
|
+
onShapeSubscribe() {
|
|
789
|
+
return { error: { code: "RELAY_CANNOT_SEED", message: "a relay has no op-log to seed from" } };
|
|
790
|
+
}
|
|
791
|
+
onShapePoke(poke) {
|
|
792
|
+
return this.deliverShapePoke(poke);
|
|
793
|
+
}
|
|
794
|
+
/** Record a relay socket's cohort cursor + epoch for `subId` (creating the per-socket map lazily). */
|
|
795
|
+
recordRelayShapeMemo(ws, subId, cursor, epoch) {
|
|
796
|
+
let memos = this.shapeRelayMemos.get(ws);
|
|
797
|
+
if (memos === void 0) {
|
|
798
|
+
memos = /* @__PURE__ */ new Map();
|
|
799
|
+
this.shapeRelayMemos.set(ws, memos);
|
|
800
|
+
}
|
|
801
|
+
memos.set(subId, { cursor, epoch });
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Deliver an owner-multicast shape delta to this relay's cohort sockets. A socket
|
|
805
|
+
* receives it only while its memo matches the poke's `fromCursor` AND `epoch` (so a
|
|
806
|
+
* socket that seeded at a different cursor/epoch never double-applies), then
|
|
807
|
+
* advances to `checkpoint`. A targeted (per-socket proxy) poke goes ONLY to its one
|
|
808
|
+
* connection; a cohort multicast goes to every matching socket.
|
|
809
|
+
* @returns the number of sockets delivered to
|
|
810
|
+
*/
|
|
811
|
+
deliverShapePoke(poke) {
|
|
812
|
+
const routingKey = shapeRoutingKey(poke.name, poke.args);
|
|
813
|
+
let delivered = 0;
|
|
814
|
+
for (const ws of this.host.getWebSockets()) {
|
|
815
|
+
const attachment = this.host.readAttachment(ws);
|
|
816
|
+
const { shapes } = attachment;
|
|
817
|
+
const memos = this.shapeRelayMemos.get(ws);
|
|
818
|
+
if (shapes === void 0 || memos === void 0) {
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (poke.targetConnectionId !== void 0 && attachment.connectionId !== poke.targetConnectionId) {
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
for (const [subId, sub] of Object.entries(shapes)) {
|
|
825
|
+
const memo = memos.get(subId);
|
|
826
|
+
if (memo?.cursor !== poke.fromCursor || memo.epoch !== poke.epoch || shapeRoutingKey(sub.name, sub.args) !== routingKey) {
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
const frames = buildPokeFrames([{ rowsPatch: poke.rowsPatch, shapeId: subId }], {
|
|
830
|
+
baseCheckpoint: void 0,
|
|
831
|
+
checkpoint: poke.checkpoint,
|
|
832
|
+
epoch: poke.epoch,
|
|
833
|
+
lastMutationId: void 0,
|
|
834
|
+
pokeId: this.host.nextPokeId()
|
|
835
|
+
});
|
|
836
|
+
for (const frame of frames) {
|
|
837
|
+
trySendFrame(ws, frame);
|
|
838
|
+
}
|
|
839
|
+
memos.set(subId, { cursor: poke.checkpoint, epoch: poke.epoch });
|
|
840
|
+
delivered += 1;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return delivered;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const createRelayLink = (host) => {
|
|
847
|
+
const name = host.doName();
|
|
848
|
+
if (name === void 0) {
|
|
849
|
+
return void 0;
|
|
850
|
+
}
|
|
851
|
+
const parsed = parseRelayName(name);
|
|
852
|
+
return parsed === void 0 ? new OwnerRelay(host, name) : new RelayMember(host, parsed.ownerKey, parsed.relayIndex);
|
|
853
|
+
};
|
|
854
|
+
|
|
147
855
|
const REQUEST_LOG_TABLE = "__lunora_reqlog__";
|
|
148
856
|
const REQUEST_LOG_RETENTION = 1e3;
|
|
149
857
|
const REQUEST_LOG_EVENT_SOURCE = "lunora";
|
|
@@ -351,58 +1059,6 @@ const readRequestLog = (sql, options = {}) => {
|
|
|
351
1059
|
});
|
|
352
1060
|
};
|
|
353
1061
|
|
|
354
|
-
const projectColumns = (document_, columns) => {
|
|
355
|
-
if (!columns) {
|
|
356
|
-
return document_;
|
|
357
|
-
}
|
|
358
|
-
const projected = /* @__PURE__ */ Object.create(null);
|
|
359
|
-
for (const key of ["_id", "_creationTime", ...columns]) {
|
|
360
|
-
if (Object.hasOwn(document_, key)) {
|
|
361
|
-
projected[key] = document_[key];
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
return projected;
|
|
365
|
-
};
|
|
366
|
-
const diffGlobalMembership = (rows, previous, options) => {
|
|
367
|
-
const { columns, table } = options;
|
|
368
|
-
const next = /* @__PURE__ */ new Map();
|
|
369
|
-
const rowsPatch = [];
|
|
370
|
-
for (const { doc, id } of rows) {
|
|
371
|
-
const value = projectColumns(doc, columns);
|
|
372
|
-
const json = JSON.stringify(value);
|
|
373
|
-
next.set(id, json);
|
|
374
|
-
const before = previous.get(id);
|
|
375
|
-
if (before === void 0) {
|
|
376
|
-
rowsPatch.push({ key: id, op: "insert", table, value });
|
|
377
|
-
} else if (before !== json) {
|
|
378
|
-
rowsPatch.push({ key: id, op: "update", table, value });
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
for (const id of previous.keys()) {
|
|
382
|
-
if (!next.has(id)) {
|
|
383
|
-
rowsPatch.push({ key: id, op: "delete", table });
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
return { next, rowsPatch };
|
|
387
|
-
};
|
|
388
|
-
const buildPokeFrames = (parts, meta) => {
|
|
389
|
-
const { baseCheckpoint, checkpoint, epoch, lastMutationId, pokeId } = meta;
|
|
390
|
-
const frames = [JSON.stringify({ baseCheckpoint, epoch, pokeId, type: "pokeStart" })];
|
|
391
|
-
for (const part of parts) {
|
|
392
|
-
frames.push(
|
|
393
|
-
JSON.stringify({
|
|
394
|
-
pokeId,
|
|
395
|
-
rowsPatch: part.rowsPatch,
|
|
396
|
-
shapeId: part.shapeId,
|
|
397
|
-
type: "pokePart",
|
|
398
|
-
...lastMutationId === void 0 ? {} : { lastMutationId }
|
|
399
|
-
})
|
|
400
|
-
);
|
|
401
|
-
}
|
|
402
|
-
frames.push(JSON.stringify({ checkpoint, epoch, pokeId, type: "pokeEnd" }));
|
|
403
|
-
return frames;
|
|
404
|
-
};
|
|
405
|
-
|
|
406
1062
|
const runSocketPool = async (items, processOne, concurrency = 8) => {
|
|
407
1063
|
let cursor = 0;
|
|
408
1064
|
const worker = async () => {
|
|
@@ -491,19 +1147,6 @@ const IDEMPOTENCY_RETENTION_MS = 864e5;
|
|
|
491
1147
|
const IDEMPOTENCY_GC_INTERVAL_MS = 36e5;
|
|
492
1148
|
const ROOT_SHARD_NAME = "__root__";
|
|
493
1149
|
const ADMIN_WILDCARD = "*";
|
|
494
|
-
const awaitWsDrain = async (ws) => {
|
|
495
|
-
let attempts = 0;
|
|
496
|
-
while (attempts < 100) {
|
|
497
|
-
attempts += 1;
|
|
498
|
-
const buffered = ws.bufferedAmount;
|
|
499
|
-
if (typeof buffered !== "number" || buffered < 1048576) {
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
await new Promise((resolve) => {
|
|
503
|
-
setTimeout(resolve, 20);
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
};
|
|
507
1150
|
const cdcSuffix = (cursor, epoch) => (cursor === void 0 ? "" : `,"cursor":${String(cursor)}`) + (epoch === void 0 ? "" : `,"epoch":${JSON.stringify(epoch)}`);
|
|
508
1151
|
const setsIntersect = (a, b) => {
|
|
509
1152
|
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
|
|
@@ -1246,6 +1889,35 @@ class ShardDO {
|
|
|
1246
1889
|
* (durable aggregation would be a separate, heavier feature).
|
|
1247
1890
|
*/
|
|
1248
1891
|
metrics = { errors: 0, requests: 0, sinceMs: Date.now() };
|
|
1892
|
+
/**
|
|
1893
|
+
* Running fan-out cost counters surfaced by the
|
|
1894
|
+
* `__lunora_admin__:getFanoutMetrics` RPC — one tally for the reactive
|
|
1895
|
+
* shape-poke path (`pokeShapeSubscribers`) and one for the whisper broadcast
|
|
1896
|
+
* path (`broadcastWhisper`). Each pass records the sockets it iterated (the
|
|
1897
|
+
* O(subscribers) cost) and delivered to. In-memory and reset on
|
|
1898
|
+
* hibernation/restart, sharing `metrics.sinceMs` as the "since this instance
|
|
1899
|
+
* woke" epoch. This is the observability half of plan 075's auto-elastic
|
|
1900
|
+
* relay tier (Phase 1): measure the per-flush fan-out cost so the promotion
|
|
1901
|
+
* threshold is grounded in real numbers, with no behavior change.
|
|
1902
|
+
*/
|
|
1903
|
+
fanout = { shapePoke: createFanoutCounters(), whisper: createFanoutCounters() };
|
|
1904
|
+
/**
|
|
1905
|
+
* The runtime's Durable Object namespace binding name (e.g. `"SHARD"`),
|
|
1906
|
+
* forwarded as `x-lunora-shard-binding` on every request so a DO can address
|
|
1907
|
+
* its siblings (`this.env[binding].getByName(...)`) for the relay hub. Absent
|
|
1908
|
+
* in single-DO mode / the unit harness — when absent, the relay tier is inert
|
|
1909
|
+
* and whispers stay shard-local (no behavior change). In-memory; re-learned per
|
|
1910
|
+
* request.
|
|
1911
|
+
*/
|
|
1912
|
+
shardBinding;
|
|
1913
|
+
/**
|
|
1914
|
+
* The auto-elastic fan-out relay collaborator (plan 075) — an {@link OwnerRelay}
|
|
1915
|
+
* or {@link RelayMember} chosen ONCE from this DO's name, or `undefined` for an
|
|
1916
|
+
* unnamed (single-DO) DO where the relay tier is inert. All relay state +
|
|
1917
|
+
* transport lives on it, reached back through the {@link RelayHost} adapter, so
|
|
1918
|
+
* owner-only state can never sit next to relay-only state on this class.
|
|
1919
|
+
*/
|
|
1920
|
+
relay;
|
|
1249
1921
|
/**
|
|
1250
1922
|
* Declared indexes (`table:index`) a query has exercised since this instance
|
|
1251
1923
|
* woke, stamped by `getCtxDbIndexUseHook`. In-memory and reset on
|
|
@@ -1332,6 +2004,29 @@ class ShardDO {
|
|
|
1332
2004
|
if (options.reactiveCache) {
|
|
1333
2005
|
this.reactiveCache = new ReactiveCache(options.reactiveCache);
|
|
1334
2006
|
}
|
|
2007
|
+
const host = {
|
|
2008
|
+
buildShapeDiff: (resolved, fromCursor, toCursor) => this.buildShapeDiff(this.sql, resolved, fromCursor, toCursor),
|
|
2009
|
+
computeOpLogShapeSeed: (shape, resolved) => this.computeOpLogShapeSeed(shape, resolved),
|
|
2010
|
+
currentCdcEpoch: () => this.currentCdcEpoch(),
|
|
2011
|
+
deliverWhisperLocal: (topic, frame, exclude) => this.deliverWhisperLocal(topic, frame, exclude),
|
|
2012
|
+
doName: () => this.state.id?.name,
|
|
2013
|
+
env: () => this.env,
|
|
2014
|
+
getWebSockets: () => this.state.getWebSockets(),
|
|
2015
|
+
maskMetadata: () => this.maskMetadata(),
|
|
2016
|
+
nextPokeId: () => {
|
|
2017
|
+
this.pokeSequence += 1;
|
|
2018
|
+
return `poke-${String(this.pokeSequence)}`;
|
|
2019
|
+
},
|
|
2020
|
+
readAttachment: (ws) => this.readAttachment(ws),
|
|
2021
|
+
recordShapePokeFanout: (iterated, delivered, elapsedMs) => {
|
|
2022
|
+
this.fanout.shapePoke = recordFanoutPass(this.fanout.shapePoke, iterated, delivered, elapsedMs);
|
|
2023
|
+
},
|
|
2024
|
+
resolveShape: (name, args, identity) => this.resolveShape(name, args, identity),
|
|
2025
|
+
rlsMetadata: () => this.rlsMetadata(),
|
|
2026
|
+
shardBinding: () => this.shardBinding,
|
|
2027
|
+
sql: () => this.sql
|
|
2028
|
+
};
|
|
2029
|
+
this.relay = createRelayLink(host);
|
|
1335
2030
|
this.armWebSocketKeepalive();
|
|
1336
2031
|
}
|
|
1337
2032
|
/** SQLite handle scoped to this Durable Object. */
|
|
@@ -1341,8 +2036,10 @@ class ShardDO {
|
|
|
1341
2036
|
*/
|
|
1342
2037
|
async fetch(request) {
|
|
1343
2038
|
const url = new URL(request.url);
|
|
1344
|
-
|
|
1345
|
-
|
|
2039
|
+
this.shardBinding = request.headers.get("x-lunora-shard-binding") ?? this.shardBinding;
|
|
2040
|
+
const early = await this.routeNonRpc(url, request);
|
|
2041
|
+
if (early !== void 0) {
|
|
2042
|
+
return early;
|
|
1346
2043
|
}
|
|
1347
2044
|
if (url.pathname !== "/rpc" || request.method !== "POST") {
|
|
1348
2045
|
return new Response("Not found", { status: 404 });
|
|
@@ -1526,13 +2223,17 @@ class ShardDO {
|
|
|
1526
2223
|
}
|
|
1527
2224
|
if (envelope.type === "whisper_subscribe" || envelope.type === "whisper_unsubscribe") {
|
|
1528
2225
|
if (typeof envelope.topic === "string" && envelope.topic.length > 0) {
|
|
1529
|
-
|
|
2226
|
+
const join = envelope.type === "whisper_subscribe";
|
|
2227
|
+
this.setWhisperMembership(ws, envelope.topic, join);
|
|
2228
|
+
if (join) {
|
|
2229
|
+
await this.relay?.announce();
|
|
2230
|
+
}
|
|
1530
2231
|
}
|
|
1531
2232
|
return;
|
|
1532
2233
|
}
|
|
1533
2234
|
if (envelope.type === "whisper") {
|
|
1534
2235
|
if (typeof envelope.topic === "string" && envelope.topic.length > 0) {
|
|
1535
|
-
this.broadcastWhisper(ws, envelope.topic, envelope.data);
|
|
2236
|
+
await this.broadcastWhisper(ws, envelope.topic, envelope.data);
|
|
1536
2237
|
}
|
|
1537
2238
|
return;
|
|
1538
2239
|
}
|
|
@@ -1574,6 +2275,7 @@ class ShardDO {
|
|
|
1574
2275
|
}
|
|
1575
2276
|
}
|
|
1576
2277
|
ws.serializeAttachment?.(void 0);
|
|
2278
|
+
await this.relay?.announceDrain(ws);
|
|
1577
2279
|
}
|
|
1578
2280
|
/** Hibernation API: invoked on socket error. */
|
|
1579
2281
|
// eslint-disable-next-line class-methods-use-this -- Workers hibernation handler: the platform invokes it on the instance; the signature must stay an instance method
|
|
@@ -1596,6 +2298,13 @@ class ShardDO {
|
|
|
1596
2298
|
this.recordShapeError("shape:poll", error);
|
|
1597
2299
|
remaining = 1;
|
|
1598
2300
|
}
|
|
2301
|
+
try {
|
|
2302
|
+
remaining += await this.pollExternalSources();
|
|
2303
|
+
} catch (error) {
|
|
2304
|
+
this.recordShapeError("source:poll", error);
|
|
2305
|
+
remaining += 1;
|
|
2306
|
+
}
|
|
2307
|
+
await this.flushChangedTables();
|
|
1599
2308
|
if (remaining > 0) {
|
|
1600
2309
|
await this.scheduleGlobalPoll();
|
|
1601
2310
|
}
|
|
@@ -2705,6 +3414,16 @@ class ShardDO {
|
|
|
2705
3414
|
resolveShape(_name, _args, _identity) {
|
|
2706
3415
|
return void 0;
|
|
2707
3416
|
}
|
|
3417
|
+
/**
|
|
3418
|
+
* The RLS-uniform gate (plan 075 Phase 3): whether a reactive shape may be
|
|
3419
|
+
* relay-multicast — i.e. one delta is correct for **every** subscriber. The owner
|
|
3420
|
+
* decides it (see {@link OwnerRelay.isShapeRelayUniform} — a static RLS read-policy
|
|
3421
|
+
* guard plus claim-exhaustive `Proxy` probes, fail-closed); this thin delegation
|
|
3422
|
+
* is the seam the gate test exercises. A non-owner DO is never relay-uniform.
|
|
3423
|
+
*/
|
|
3424
|
+
isShapeRelayUniform(name, args) {
|
|
3425
|
+
return this.relay?.isShapeRelayUniform(name, args) ?? false;
|
|
3426
|
+
}
|
|
2708
3427
|
/**
|
|
2709
3428
|
* Read the FULL current membership of a `.global()`-table shape from its D1
|
|
2710
3429
|
* (or Hyperdrive) backend — the seed/poll source for the latency-tiered
|
|
@@ -2725,6 +3444,41 @@ class ShardDO {
|
|
|
2725
3444
|
readGlobalShapeRows(_resolved, _identity) {
|
|
2726
3445
|
return Promise.resolve([]);
|
|
2727
3446
|
}
|
|
3447
|
+
/**
|
|
3448
|
+
* Poll external-source (`.source(...)`) tables once (plan 077): materialize
|
|
3449
|
+
* each sourced table's freshly-pulled tenant slice into this DO's SQLite. The
|
|
3450
|
+
* base `ShardDO` has no sourced tables, so it returns `0` and the ingest tier
|
|
3451
|
+
* stays dormant — zero behavior change for every existing DO. The codegen
|
|
3452
|
+
* subclass overrides it to, per sourced table, build a `createShardCtxDb`
|
|
3453
|
+
* writer, read the tenant slice from Hyperdrive under this DO's shard key, and
|
|
3454
|
+
* run `runExternalSourceTick` (read local baseline → diff → apply via the
|
|
3455
|
+
* validated CDC writer). Returns the number of sourced tables still being
|
|
3456
|
+
* polled, so the shared poll alarm ({@link ShardDO.alarm}) re-arms while ingest
|
|
3457
|
+
* is active.
|
|
3458
|
+
*/
|
|
3459
|
+
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass implements the real Hyperdrive-backed poll
|
|
3460
|
+
pollExternalSources() {
|
|
3461
|
+
return Promise.resolve(0);
|
|
3462
|
+
}
|
|
3463
|
+
/**
|
|
3464
|
+
* Arm the shared poll alarm for external-source ingest (plan 077). The alarm is
|
|
3465
|
+
* shared with the global-shape poll tier; the codegen subclass calls this once
|
|
3466
|
+
* (on construction / first sourced write) so a sourced DO starts its ingest
|
|
3467
|
+
* loop, after which {@link ShardDO.alarm} re-arms itself while
|
|
3468
|
+
* {@link ShardDO.pollExternalSources} reports remaining work. Idempotent; a
|
|
3469
|
+
* no-op when the runtime exposes no `setAlarm` (unit harness).
|
|
3470
|
+
*/
|
|
3471
|
+
scheduleSourcePoll() {
|
|
3472
|
+
return this.scheduleGlobalPoll();
|
|
3473
|
+
}
|
|
3474
|
+
/** This DO's shard key (its DO name), or `__root__` for the single-DO default. The `tenantBy` mapper binds it into the source query. */
|
|
3475
|
+
currentShardKey() {
|
|
3476
|
+
return this.state.id?.name ?? ROOT_SHARD_NAME;
|
|
3477
|
+
}
|
|
3478
|
+
/** Record a contained external-source ingest failure (one sourced table's poll) into the log ring without aborting the others. */
|
|
3479
|
+
recordExternalSourceError(table, error) {
|
|
3480
|
+
this.recordShapeError(`source:${table}`, error);
|
|
3481
|
+
}
|
|
2728
3482
|
/**
|
|
2729
3483
|
* Look up a streaming-query function and return a thunk that produces the
|
|
2730
3484
|
* `AsyncIterable<unknown>` when handed an {@link AbortSignal}. The codegen
|
|
@@ -3748,6 +4502,9 @@ class ShardDO {
|
|
|
3748
4502
|
if (functionPath === ADMIN_FUNCTIONS.listSubscriptions) {
|
|
3749
4503
|
return this.collectSubscriptions();
|
|
3750
4504
|
}
|
|
4505
|
+
if (functionPath === ADMIN_FUNCTIONS.getFanoutMetrics) {
|
|
4506
|
+
return this.collectFanoutMetrics();
|
|
4507
|
+
}
|
|
3751
4508
|
if (functionPath === ADMIN_FUNCTIONS.getLogs) {
|
|
3752
4509
|
return { entries: this.logs.entries() };
|
|
3753
4510
|
}
|
|
@@ -3790,6 +4547,28 @@ class ShardDO {
|
|
|
3790
4547
|
collectSubscriptions() {
|
|
3791
4548
|
return summarizeSubscriptions(this.state.getWebSockets().map((ws) => this.readAttachment(ws)));
|
|
3792
4549
|
}
|
|
4550
|
+
/**
|
|
4551
|
+
* Assemble the `__lunora_admin__:getFanoutMetrics` payload for the Studio
|
|
4552
|
+
* fan-out observability panel (plan 075 Phase 1). The point-in-time topic
|
|
4553
|
+
* subscriber counts are folded live from each socket's attachment via
|
|
4554
|
+
* {@link summarizeFanoutTopics}; the running per-path cost counters are the
|
|
4555
|
+
* in-memory {@link ShardDO.fanout} tallies, sharing `metrics.sinceMs` as the
|
|
4556
|
+
* "since this instance woke" epoch. Read-only: touches no SQLite and mutates
|
|
4557
|
+
* no socket state.
|
|
4558
|
+
*/
|
|
4559
|
+
collectFanoutMetrics() {
|
|
4560
|
+
const summary = summarizeFanoutTopics(this.state.getWebSockets().map((ws) => this.readAttachment(ws)));
|
|
4561
|
+
const relayCount = this.relay?.relayCount() ?? 0;
|
|
4562
|
+
return {
|
|
4563
|
+
...summary,
|
|
4564
|
+
maxRelays: this.relay?.maxRelays() ?? DEFAULT_MAX_RELAYS,
|
|
4565
|
+
promoted: relayCount > 0,
|
|
4566
|
+
relayCount,
|
|
4567
|
+
shapePoke: this.fanout.shapePoke,
|
|
4568
|
+
sinceMs: this.metrics.sinceMs,
|
|
4569
|
+
whisper: this.fanout.whisper
|
|
4570
|
+
};
|
|
4571
|
+
}
|
|
3793
4572
|
/** Resolve a `getAuditLog` admin read, parsing the optional `limit`/`sinceSeq` cursor args and ensuring the reserved table first. */
|
|
3794
4573
|
// eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers and future per-instance state
|
|
3795
4574
|
readAdminAuditLog(sql, args) {
|
|
@@ -4145,7 +4924,11 @@ class ShardDO {
|
|
|
4145
4924
|
this.pendingRefreshTables = void 0;
|
|
4146
4925
|
const frameCursor = this.currentCdcCursor();
|
|
4147
4926
|
const frameEpoch = this.currentCdcEpoch();
|
|
4148
|
-
await Promise.all([
|
|
4927
|
+
await Promise.all([
|
|
4928
|
+
this.refreshSubscriptions(batch),
|
|
4929
|
+
this.pokeShapeSubscribers(batch, frameCursor, frameEpoch),
|
|
4930
|
+
this.relay?.onFlush(batch, frameCursor ?? 0)
|
|
4931
|
+
]);
|
|
4149
4932
|
batch = this.pendingRefreshTables;
|
|
4150
4933
|
}
|
|
4151
4934
|
} finally {
|
|
@@ -4346,6 +5129,10 @@ class ShardDO {
|
|
|
4346
5129
|
async seedShapeSubscription(ws, subId, shape) {
|
|
4347
5130
|
const attachment = this.readAttachment(ws);
|
|
4348
5131
|
const identity = { identity: attachment.identity, userId: attachment.userId };
|
|
5132
|
+
const relayed = await this.relay?.seedRelayShape(ws, subId, shape, identity);
|
|
5133
|
+
if (relayed !== void 0) {
|
|
5134
|
+
return relayed;
|
|
5135
|
+
}
|
|
4349
5136
|
let resolved;
|
|
4350
5137
|
try {
|
|
4351
5138
|
resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
|
|
@@ -4378,17 +5165,32 @@ class ShardDO {
|
|
|
4378
5165
|
* it to a structured `shape_subscribe` error.
|
|
4379
5166
|
*/
|
|
4380
5167
|
async seedOpLogShape(ws, subId, shape, resolved) {
|
|
5168
|
+
const { baseCheckpoint, cursor, epoch, rowsPatch } = this.computeOpLogShapeSeed(shape, resolved);
|
|
5169
|
+
await awaitWsDrain(ws);
|
|
5170
|
+
if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], cursor, epoch, baseCheckpoint)) {
|
|
5171
|
+
this.recordShapeMemo(ws, subId, cursor);
|
|
5172
|
+
}
|
|
5173
|
+
return "ok";
|
|
5174
|
+
}
|
|
5175
|
+
/**
|
|
5176
|
+
* Compute an op-log shape seed (cursor, epoch, the resume base, and the
|
|
5177
|
+
* membership `rowsPatch`) WITHOUT sending — the shared core of
|
|
5178
|
+
* {@link ShardDO.seedOpLogShape} (sends to a local socket) and the owner relay's
|
|
5179
|
+
* `buildShapeSeedFrames` (serializes the frames for a relay to deliver, plan 075
|
|
5180
|
+
* Phase 3, via the {@link RelayHost} seam). Resume only when CDC is on, the client is on this
|
|
5181
|
+
* epoch, its checkpoint doesn't run ahead of ours, and the log still covers it;
|
|
5182
|
+
* else a full re-seed. A fully-compacted log only proves "nothing missed" when
|
|
5183
|
+
* the client is already at `cursor`.
|
|
5184
|
+
* @returns the cursor/epoch, the resume base (`baseCheckpoint`), and the membership patch
|
|
5185
|
+
*/
|
|
5186
|
+
computeOpLogShapeSeed(shape, resolved) {
|
|
4381
5187
|
const sql = this.sql;
|
|
4382
5188
|
const cursor = this.currentCdcCursor() ?? 0;
|
|
4383
5189
|
const epoch = this.currentCdcEpoch();
|
|
4384
5190
|
const floor = this.cdcEnabled() ? minCdcSeq(sql) : void 0;
|
|
4385
5191
|
const canResume = this.cdcEnabled() && shape.sinceSeq !== void 0 && shape.sinceEpoch === epoch && shape.sinceSeq <= cursor && (shape.sinceSeq === cursor || floor !== void 0 && floor <= shape.sinceSeq + 1);
|
|
4386
5192
|
const rowsPatch = canResume && shape.sinceSeq !== void 0 ? this.buildShapeDiff(sql, resolved, shape.sinceSeq, cursor) : this.buildShapeSeed(sql, resolved);
|
|
4387
|
-
|
|
4388
|
-
if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], cursor, epoch, canResume ? shape.sinceSeq : void 0)) {
|
|
4389
|
-
this.recordShapeMemo(ws, subId, cursor);
|
|
4390
|
-
}
|
|
4391
|
-
return "ok";
|
|
5193
|
+
return { baseCheckpoint: canResume ? shape.sinceSeq : void 0, cursor, epoch, rowsPatch };
|
|
4392
5194
|
}
|
|
4393
5195
|
/**
|
|
4394
5196
|
* Fan the membership diff of every shape affected by this flush to its
|
|
@@ -4405,6 +5207,7 @@ class ShardDO {
|
|
|
4405
5207
|
const checkpoint = frameCursor ?? this.currentCdcCursor() ?? 0;
|
|
4406
5208
|
const sql = this.sql;
|
|
4407
5209
|
const opRangeCache = /* @__PURE__ */ new Map();
|
|
5210
|
+
let delivered = 0;
|
|
4408
5211
|
const pokeOne = async (ws) => {
|
|
4409
5212
|
if (this.isSocketExpired(ws)) {
|
|
4410
5213
|
this.dropExpiredSocket(ws);
|
|
@@ -4424,6 +5227,7 @@ class ShardDO {
|
|
|
4424
5227
|
if (parts.length > 0) {
|
|
4425
5228
|
await awaitWsDrain(ws);
|
|
4426
5229
|
if (this.sendPoke(ws, parts, checkpoint, frameEpoch, void 0)) {
|
|
5230
|
+
delivered += 1;
|
|
4427
5231
|
for (const subId of partAdvanced) {
|
|
4428
5232
|
this.recordShapeMemo(ws, subId, checkpoint);
|
|
4429
5233
|
}
|
|
@@ -4432,7 +5236,9 @@ class ShardDO {
|
|
|
4432
5236
|
} catch {
|
|
4433
5237
|
}
|
|
4434
5238
|
};
|
|
5239
|
+
const startMs = Date.now();
|
|
4435
5240
|
await runSocketPool(sockets, pokeOne);
|
|
5241
|
+
this.fanout.shapePoke = recordFanoutPass(this.fanout.shapePoke, sockets.length, delivered, Date.now() - startMs);
|
|
4436
5242
|
}
|
|
4437
5243
|
/**
|
|
4438
5244
|
* Diff every op-log-backed shape a socket holds against this flush, splitting
|
|
@@ -4957,6 +5763,26 @@ class ShardDO {
|
|
|
4957
5763
|
}
|
|
4958
5764
|
setter.call(this.state, new WebSocketRequestResponsePair(WS_KEEPALIVE_PING, WS_KEEPALIVE_PONG));
|
|
4959
5765
|
}
|
|
5766
|
+
/**
|
|
5767
|
+
* Route the non-RPC requests `fetch` handles before the shard-local RPC
|
|
5768
|
+
* endpoint: a WebSocket upgrade, and the internal `/_lunora/relay` owner↔relay
|
|
5769
|
+
* control channel (never reachable by a client — the runtime forwards only
|
|
5770
|
+
* worker-internal traffic there). Returns `undefined` for an RPC request, which
|
|
5771
|
+
* `fetch` then dispatches.
|
|
5772
|
+
* @returns the routed response, or `undefined` when this is an RPC request
|
|
5773
|
+
*/
|
|
5774
|
+
async routeNonRpc(url, request) {
|
|
5775
|
+
if (url.pathname === "/_lunora/relay" && request.method === "POST") {
|
|
5776
|
+
return this.relay ? await this.relay.handleControl(request) : new Response("relay tier inactive", { status: 404 });
|
|
5777
|
+
}
|
|
5778
|
+
if (url.pathname === "/_lunora/route" && request.method === "GET") {
|
|
5779
|
+
return jsonResponse({ relayCount: this.relay?.relayCount() ?? 0 });
|
|
5780
|
+
}
|
|
5781
|
+
if (request.headers.get("Upgrade") === "websocket") {
|
|
5782
|
+
return this.handleWebSocketUpgrade(request);
|
|
5783
|
+
}
|
|
5784
|
+
return void 0;
|
|
5785
|
+
}
|
|
4960
5786
|
handleWebSocketUpgrade(request) {
|
|
4961
5787
|
if (!this.isUpgradeAllowed(request)) {
|
|
4962
5788
|
return new Response("Forbidden", { status: 403 });
|
|
@@ -5074,7 +5900,7 @@ class ShardDO {
|
|
|
5074
5900
|
* topic name. That matches the AnyCable model (and `from` is unforgeable),
|
|
5075
5901
|
* but per-topic auth does not exist here; see `whisperSubscribe` on the client.
|
|
5076
5902
|
*/
|
|
5077
|
-
broadcastWhisper(sender, topic, data) {
|
|
5903
|
+
async broadcastWhisper(sender, topic, data) {
|
|
5078
5904
|
if (!this.allowWhisper(sender)) {
|
|
5079
5905
|
return;
|
|
5080
5906
|
}
|
|
@@ -5085,12 +5911,29 @@ class ShardDO {
|
|
|
5085
5911
|
const from = this.readAttachment(sender).userId;
|
|
5086
5912
|
const fromSuffix = from === void 0 ? "" : `,"from":${JSON.stringify(from)}`;
|
|
5087
5913
|
const frame = `{"type":"whisper","topic":${JSON.stringify(topic)},"data":${dataJson}${fromSuffix}}`;
|
|
5914
|
+
this.deliverWhisperLocal(topic, frame, sender);
|
|
5915
|
+
await this.relay?.forwardWhisper(topic, frame);
|
|
5916
|
+
}
|
|
5917
|
+
/**
|
|
5918
|
+
* Deliver an already-serialized whisper `frame` to every local socket joined to
|
|
5919
|
+
* `topic`, excluding `exclude` (the sender, or `undefined` for a frame the relay
|
|
5920
|
+
* hub forwarded in — its sender lives on another DO). Records the fan-out pass
|
|
5921
|
+
* for `getFanoutMetrics` (plan 075 Phase 1). Pure delivery — no SQLite, no CDC.
|
|
5922
|
+
* @returns the number of sockets the frame was sent to
|
|
5923
|
+
*/
|
|
5924
|
+
deliverWhisperLocal(topic, frame, exclude) {
|
|
5925
|
+
let scanned = 0;
|
|
5926
|
+
let delivered = 0;
|
|
5088
5927
|
for (const ws of this.state.getWebSockets()) {
|
|
5089
|
-
|
|
5928
|
+
scanned += 1;
|
|
5929
|
+
if (ws === exclude || this.readAttachment(ws).whispers?.includes(topic) !== true) {
|
|
5090
5930
|
continue;
|
|
5091
5931
|
}
|
|
5092
5932
|
trySendFrame(ws, frame);
|
|
5933
|
+
delivered += 1;
|
|
5093
5934
|
}
|
|
5935
|
+
this.fanout.whisper = recordFanoutPass(this.fanout.whisper, scanned, delivered, 0);
|
|
5936
|
+
return delivered;
|
|
5094
5937
|
}
|
|
5095
5938
|
// eslint-disable-next-line class-methods-use-this -- cohesive DO instance method grouped with the hibernation/attachment helpers; reads only the socket
|
|
5096
5939
|
readAttachment(ws) {
|