@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.
@@ -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-D_UiYJFk.mjs';
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 { s as selectShapeMemberIds, a as selectShapeRows } from './ctx-db-shapes-DVoeZpo-.mjs';
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
- if (request.headers.get("Upgrade") === "websocket") {
1345
- return this.handleWebSocketUpgrade(request);
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
- this.setWhisperMembership(ws, envelope.topic, envelope.type === "whisper_subscribe");
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&lt;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([this.refreshSubscriptions(batch), this.pokeShapeSubscribers(batch, frameCursor, frameEpoch)]);
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
- await awaitWsDrain(ws);
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
- if (ws === sender || this.readAttachment(ws).whispers?.includes(topic) !== true) {
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) {