@lunora/do 1.0.0-alpha.2 → 1.0.0-alpha.21

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.
Files changed (39) hide show
  1. package/__assets__/package-og.svg +1 -1
  2. package/dist/index.d.mts +1162 -114
  3. package/dist/index.d.ts +1162 -114
  4. package/dist/index.mjs +27 -22
  5. package/dist/packem_shared/{ADMIN_FUNCTION_PREFIX-Dzdqq5J2.mjs → ADMIN_FUNCTIONS-DSUQ5fX9.mjs} +49 -3
  6. package/dist/packem_shared/{applyCdcChanges-Ctdmxmrv.mjs → CDC_LOG_TABLE-DSycmnDf.mjs} +5 -1
  7. package/dist/packem_shared/{DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs → DATA_MIGRATION_STATE_TABLE-DfPxn8I0.mjs} +5 -1
  8. package/dist/packem_shared/{assertFlatPredicate-DyVYReuT.mjs → DEFAULT_MAX_RELATION_KEYS-DU-Y4-LJ.mjs} +51 -2
  9. package/dist/packem_shared/{assertValidClientId-CBZ1zC96.mjs → NotUniqueError-Do5h_jiW.mjs} +127 -37
  10. package/dist/packem_shared/{rank-CrkEIpF4.mjs → RANK_TIEBREAK-CXhdcA1o.mjs} +2 -13
  11. package/dist/packem_shared/{guardWriter-u3UlnCH5.mjs → RLS_UNWRAP_SYMBOL-EtGQdC9d.mjs} +6 -2
  12. package/dist/packem_shared/{ROOT_DO_SIZE_WARN_BYTES-DQkmGiCS.mjs → ROOT_DO_SIZE_WARN_BYTES-BfPNp3Jh.mjs} +2342 -200
  13. package/dist/packem_shared/{ReactiveCache-ByVzgH3d.mjs → ReactiveCache-BYlSGY0N.mjs} +1 -28
  14. package/dist/packem_shared/{applyOnDelete-CMif2RKw.mjs → applyOnDelete-BQ-8ZlZ1.mjs} +19 -9
  15. package/dist/packem_shared/{buildSeekWhere-lVsNXSLy.mjs → applySelect-BvZdFUBT.mjs} +18 -1
  16. package/dist/packem_shared/{backfillAggregateIndexes-BF5eL7kW.mjs → backfillAggregateIndexes-BZsOqDXP.mjs} +3 -2
  17. package/dist/packem_shared/{compileWhereSql-CXrhFA3G.mjs → compileWhereSql-MW_Lk8nJ.mjs} +3 -2
  18. package/dist/packem_shared/ctx-db-idempotency-BdcNpvY4.mjs +108 -0
  19. package/dist/packem_shared/ctx-db-shapes-0RaIOy7J.mjs +53 -0
  20. package/dist/packem_shared/diffExternalSource-Cx9HUPJj.mjs +44 -0
  21. package/dist/packem_shared/isSourceDue-9mJRJ9Ld.mjs +40 -0
  22. package/dist/packem_shared/materializeExternalRows-Bj6EXy50.mjs +23 -0
  23. package/dist/packem_shared/{runShardMigrations-C3bn5r93.mjs → runShardMigrations-nIwoQeOK.mjs} +6 -4
  24. package/dist/packem_shared/serialize-sql-BlRUoiQe.mjs +14 -0
  25. package/dist/packem_shared/{serveRelationFanout-Clr1a05L.mjs → serveRelationFanout-C5axmGDj.mjs} +1 -1
  26. package/dist/packem_shared/stableStringify-MydiuScU.mjs +40 -0
  27. package/dist/packem_shared/subscription-delivery-CK8qga-k.mjs +347 -0
  28. package/dist/packem_shared/subscriptionListDeltas-BxygQlXT.mjs +1 -0
  29. package/package.json +2 -2
  30. package/dist/packem_shared/ctx-db-idempotency-DkC9rP91.mjs +0 -35
  31. package/dist/packem_shared/encodePartitionKey-C6blLR5K.mjs +0 -1
  32. /package/dist/packem_shared/{matchesStaticWhere-CFk6adSu.mjs → AGGREGATE_SQL_FUNCTION-CFk6adSu.mjs} +0 -0
  33. /package/dist/packem_shared/{AUTH_METRICS_BUCKET_MS-CiHHYeJi.mjs → AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs} +0 -0
  34. /package/dist/packem_shared/{ensureFunctionMetricsTables-UDNVD7FS.mjs → FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs} +0 -0
  35. /package/dist/packem_shared/{clearCapturedMail-CPpgl-dX.mjs → MAIL_RETENTION-CPpgl-dX.mjs} +0 -0
  36. /package/dist/packem_shared/{assertReadonly-dDcFE1YZ.mjs → MAX_SQL_ROWS-dDcFE1YZ.mjs} +0 -0
  37. /package/dist/packem_shared/{buildSecurityAudit-CCAvoFlr.mjs → MIN_ADMIN_TOKEN_LENGTH-CCAvoFlr.mjs} +0 -0
  38. /package/dist/packem_shared/{ftsTableName-BLEMawrp.mjs → buildFtsMatch-BLEMawrp.mjs} +0 -0
  39. /package/dist/packem_shared/{runTriggers-5N6_Fx0A.mjs → hasTrigger-5N6_Fx0A.mjs} +0 -0
@@ -1,20 +1,25 @@
1
1
  import { drizzle } from 'drizzle-orm/durable-sqlite';
2
+ import { e as encodeWire, a as awaitWsDrain, t as trySendFrame, d as decodeWire, s as subscriptionListDeltas, b as sendDeltaFrames } from './subscription-delivery-CK8qga-k.mjs';
2
3
  import { parseExportShardArgs, parseImportShardArgs } from './exportShardRows-DZEhUeyI.mjs';
3
- import { recordAuthEvent, readAuthMetrics } from './AUTH_METRICS_BUCKET_MS-CiHHYeJi.mjs';
4
- import { DATA_MIGRATION_STATE_TABLE, readMigrationStatus } from './DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs';
4
+ import { recordAuthEvent, readAuthMetrics } from './AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs';
5
+ import { DATA_MIGRATION_STATE_TABLE, readMigrationStatus } from './DATA_MIGRATION_STATE_TABLE-DfPxn8I0.mjs';
5
6
  import { SCAN_DEP, createDependencyTracker, tableFromDepKey } from './SCAN_DEP-DLJF8dsj.mjs';
6
- import { readFunctionMetricsTotals, readFunctionMetricIndexHits, recordFunctionMetric, mergeScanAttribution, readFunctionMetrics, readFunctionMetricBuckets } from './ensureFunctionMetricsTables-UDNVD7FS.mjs';
7
- import { ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, selectMatchingIds, ADMIN_FUNCTIONS, findStorageReferences, listTables, summarizeSubscriptions, readTablePage, facetColumn, MAX_PAGE_SIZE } from './ADMIN_FUNCTION_PREFIX-Dzdqq5J2.mjs';
7
+ import { readFunctionMetricsTotals, readFunctionMetricIndexHits, recordFunctionMetric, mergeScanAttribution, readFunctionMetrics, readFunctionMetricBuckets } from './FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs';
8
+ 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-DSUQ5fX9.mjs';
8
9
  import { LogBuffer } from './LogBuffer-B_Ezju_N.mjs';
9
- import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './clearCapturedMail-CPpgl-dX.mjs';
10
+ import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './MAIL_RETENTION-CPpgl-dX.mjs';
10
11
  import { readBookmark, armRestore } from './armRestore-BJk53Ro8.mjs';
11
- import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-ByVzgH3d.mjs';
12
+ import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-BYlSGY0N.mjs';
13
+ import { stableStringify } from './stableStringify-MydiuScU.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
- import { runReadonlySql } from './assertReadonly-dDcFE1YZ.mjs';
16
+ import { runReadonlySql } from './MAX_SQL_ROWS-dDcFE1YZ.mjs';
15
17
  import { ConflictError } from './ConflictError-C0STs6bU.mjs';
16
- import { CDC_LOG_TABLE, readCdcChanges, readCdcCursor, readCdcEpoch, minCdcSeq, bumpCdcEpoch } from './applyCdcChanges-Ctdmxmrv.mjs';
17
- import { r as readIdempotent, w as writeIdempotent, t as trimIdempotent } from './ctx-db-idempotency-DkC9rP91.mjs';
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';
19
+ import { CDC_LOG_TABLE, readCdcChanges, readCdcCursor, readCdcEpoch, minCdcSeq, bumpCdcEpoch } from './CDC_LOG_TABLE-DSycmnDf.mjs';
20
+ import { a as selectShapeMemberIds, s as selectShapeRows } from './ctx-db-shapes-0RaIOy7J.mjs';
21
+
22
+ const MAX_BATCH_ENTRIES = 500;
18
23
 
19
24
  const AUDIT_LOG_TABLE = "__lunora_audit__";
20
25
  const AUDIT_LOG_RETENTION = 1e3;
@@ -76,6 +81,38 @@ const readAuditLog = (sql, options = {}) => {
76
81
  });
77
82
  };
78
83
 
84
+ const SHARED_BATCH_HEADERS = [
85
+ "x-lunora-userid",
86
+ "x-lunora-identity",
87
+ "x-d1-bookmark",
88
+ "x-lunora-client-ip",
89
+ "x-lunora-system",
90
+ "x-lunora-shard-binding"
91
+ ];
92
+ const buildBatchEntryRequest = (batchRequest, entry) => {
93
+ const headers = new Headers({ "content-type": "application/json" });
94
+ for (const name of SHARED_BATCH_HEADERS) {
95
+ const value = batchRequest.headers.get(name);
96
+ if (value !== null) {
97
+ headers.set(name, value);
98
+ }
99
+ }
100
+ if (entry.mutationId !== void 0) {
101
+ headers.set("x-lunora-mutation-id", entry.mutationId);
102
+ }
103
+ if (entry.clientId !== void 0) {
104
+ headers.set("x-lunora-client-id", entry.clientId);
105
+ }
106
+ if (entry.clientSeq !== void 0) {
107
+ headers.set("x-lunora-client-seq", String(entry.clientSeq));
108
+ }
109
+ return new Request("https://shard.internal/rpc", {
110
+ body: JSON.stringify({ args: entry.args ?? {}, functionPath: entry.functionPath }),
111
+ headers,
112
+ method: "POST"
113
+ });
114
+ };
115
+
79
116
  const QUERY_METRICS_TABLE = "__lunora_metrics_queries";
80
117
  const QUERY_METRICS_MAX_SQL_LEN = 512;
81
118
  const QUERY_METRICS_MAX_STATEMENTS = 500;
@@ -142,6 +179,798 @@ const readQueryMetrics = (sql) => {
142
179
  });
143
180
  };
144
181
 
182
+ const RELAY_NAME_INFIX = "::relay::";
183
+ const relayName = (ownerKey, index) => `${ownerKey}${RELAY_NAME_INFIX}${String(index)}`;
184
+ const parseRelayName = (name) => {
185
+ const at = name.lastIndexOf(RELAY_NAME_INFIX);
186
+ if (at === -1) {
187
+ return void 0;
188
+ }
189
+ const ownerKey = name.slice(0, at);
190
+ const indexText = name.slice(at + RELAY_NAME_INFIX.length);
191
+ const relayIndex = Number(indexText);
192
+ if (ownerKey.length === 0 || !Number.isInteger(relayIndex) || relayIndex < 0 || String(relayIndex) !== indexText) {
193
+ return void 0;
194
+ }
195
+ return { ownerKey, relayIndex };
196
+ };
197
+
198
+ const shapeRoutingKey = (name, args) => stableStringify({ args: args ?? {}, name });
199
+ const DEFAULT_PROMOTION_THRESHOLDS = { tDown: 4e3, tUp: 8e3 };
200
+ const nextPromotionState = (current, subscribers, thresholds = DEFAULT_PROMOTION_THRESHOLDS) => {
201
+ if (thresholds.tDown >= thresholds.tUp) {
202
+ throw new Error(`invalid promotion thresholds: tDown (${String(thresholds.tDown)}) must be < tUp (${String(thresholds.tUp)})`);
203
+ }
204
+ if (current === "owned") {
205
+ return subscribers >= thresholds.tUp ? "promoted" : "owned";
206
+ }
207
+ return subscribers < thresholds.tDown ? "owned" : "promoted";
208
+ };
209
+ const clampPromotionThresholds = (tUp, tDownRaw) => {
210
+ if (tDownRaw < tUp) {
211
+ return { tDown: tDownRaw, tUp };
212
+ }
213
+ return { tDown: Math.min(Math.max(1, Math.floor(tUp / 2)), tUp - 1), tUp };
214
+ };
215
+
216
+ const projectColumns = (document_, columns) => {
217
+ if (!columns) {
218
+ return document_;
219
+ }
220
+ const projected = /* @__PURE__ */ Object.create(null);
221
+ for (const key of ["_id", "_creationTime", ...columns]) {
222
+ if (Object.hasOwn(document_, key)) {
223
+ projected[key] = document_[key];
224
+ }
225
+ }
226
+ return projected;
227
+ };
228
+ const diffGlobalMembership = (rows, previous, options) => {
229
+ const { columns, table } = options;
230
+ const next = /* @__PURE__ */ new Map();
231
+ const rowsPatch = [];
232
+ for (const { doc, id } of rows) {
233
+ const value = projectColumns(doc, columns);
234
+ const json = JSON.stringify(encodeWire(value));
235
+ next.set(id, json);
236
+ const before = previous.get(id);
237
+ if (before === void 0) {
238
+ rowsPatch.push({ key: id, op: "insert", table, value });
239
+ } else if (before !== json) {
240
+ rowsPatch.push({ key: id, op: "update", table, value });
241
+ }
242
+ }
243
+ for (const id of previous.keys()) {
244
+ if (!next.has(id)) {
245
+ rowsPatch.push({ key: id, op: "delete", table });
246
+ }
247
+ }
248
+ return { next, rowsPatch };
249
+ };
250
+ const encodeRowsPatch = (rowsPatch) => rowsPatch.map((op) => op.value === void 0 ? op : { ...op, value: encodeWire(op.value) });
251
+ const buildPokeFrames = (parts, meta, options = {}) => {
252
+ const { baseCheckpoint, checkpoint, epoch, lastMutationId, pokeId } = meta;
253
+ const frames = [JSON.stringify({ baseCheckpoint, epoch, pokeId, type: "pokeStart" })];
254
+ for (const part of parts) {
255
+ const rowsPatch = options.preEncoded ? part.rowsPatch : encodeRowsPatch(part.rowsPatch);
256
+ frames.push(
257
+ JSON.stringify({
258
+ pokeId,
259
+ rowsPatch,
260
+ shapeId: part.shapeId,
261
+ type: "pokePart",
262
+ ...lastMutationId === void 0 ? {} : { lastMutationId }
263
+ })
264
+ );
265
+ }
266
+ frames.push(JSON.stringify({ checkpoint, epoch, pokeId, type: "pokeEnd" }));
267
+ return frames;
268
+ };
269
+
270
+ const DEFAULT_RELAY_FAN = 2;
271
+ const DEFAULT_MAX_RELAYS = 8;
272
+ const RELAY_SECRET_KEY = "LUNORA_RELAY_SECRET";
273
+ const RELAY_SIGNATURE_HEADER = "x-lunora-relay-sig";
274
+ const relaySecretOf = (env) => {
275
+ const value = env?.[RELAY_SECRET_KEY];
276
+ return typeof value === "string" && value.length > 0 ? value : void 0;
277
+ };
278
+ const constantTimeEqual$1 = (a, b) => {
279
+ if (a.length !== b.length) {
280
+ return false;
281
+ }
282
+ let mismatch = 0;
283
+ for (let index = 0; index < a.length; index += 1) {
284
+ const charA = a.charCodeAt(index);
285
+ const charB = b.charCodeAt(index);
286
+ mismatch |= charA ^ charB;
287
+ }
288
+ return mismatch === 0;
289
+ };
290
+ const signRelayBody = async (secret, body) => {
291
+ const encoder = new TextEncoder();
292
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { hash: "SHA-256", name: "HMAC" }, false, ["sign"]);
293
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
294
+ return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
295
+ };
296
+ const envPositiveInt = (env, key, fallback) => {
297
+ const raw = env?.[key];
298
+ let parsed = Number.NaN;
299
+ if (typeof raw === "string") {
300
+ parsed = Number.parseInt(raw, 10);
301
+ } else if (typeof raw === "number") {
302
+ parsed = raw;
303
+ }
304
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
305
+ };
306
+ const RELAY_MULTICAST_IDENTITY = {};
307
+ const assertNeverFrame = (frame) => {
308
+ throw new Error(`unhandled relay frame: ${JSON.stringify(frame)}`);
309
+ };
310
+ const asRelayNamespace = (value) => {
311
+ if (value === null || typeof value !== "object") {
312
+ return void 0;
313
+ }
314
+ const candidate = value;
315
+ return typeof candidate.idFromName === "function" && typeof candidate.get === "function" ? candidate : void 0;
316
+ };
317
+ const jsonRelayResponse = (body) => Response.json(body, { headers: { "content-type": "application/json" } });
318
+ const noContent = () => new Response(null, { status: 204 });
319
+ class RelayLink {
320
+ constructor(host, roleId) {
321
+ this.host = host;
322
+ this.roleId = roleId;
323
+ }
324
+ host;
325
+ roleId;
326
+ /**
327
+ * Serve the internal `/_lunora/relay` control channel: parse the frame, then
328
+ * dispatch to the role hooks. One exhaustive switch means a new frame type added
329
+ * to `relay.ts` without a case here is a COMPILE error, not a runtime mis-route.
330
+ */
331
+ async handleControl(request) {
332
+ let raw;
333
+ try {
334
+ raw = await request.text();
335
+ } catch {
336
+ return new Response("bad request", { status: 400 });
337
+ }
338
+ const secret = relaySecretOf(this.host.env());
339
+ if (secret !== void 0) {
340
+ const supplied = request.headers.get(RELAY_SIGNATURE_HEADER);
341
+ const expected = await signRelayBody(secret, raw);
342
+ if (supplied === null || !constantTimeEqual$1(supplied, expected)) {
343
+ return new Response("forbidden", { status: 403 });
344
+ }
345
+ }
346
+ let message;
347
+ try {
348
+ message = JSON.parse(raw);
349
+ } catch {
350
+ return new Response("bad request", { status: 400 });
351
+ }
352
+ switch (message.type) {
353
+ case "relay_attach": {
354
+ this.onAttach(message.relayIndex);
355
+ return noContent();
356
+ }
357
+ case "relay_detach": {
358
+ this.onDetach(message.relayIndex);
359
+ return noContent();
360
+ }
361
+ case "relay_frame": {
362
+ this.host.deliverWhisperLocal(message.topic, message.frame, void 0);
363
+ await this.onWhisperFrame(message);
364
+ return noContent();
365
+ }
366
+ case "relay_shape_poke": {
367
+ const iterated = this.host.getWebSockets().length;
368
+ const startMs = Date.now();
369
+ const delivered = this.onShapePoke(message);
370
+ this.host.recordShapePokeFanout(iterated, delivered, Date.now() - startMs);
371
+ return noContent();
372
+ }
373
+ case "relay_shape_subscribe": {
374
+ return jsonRelayResponse(this.onShapeSubscribe(message));
375
+ }
376
+ default: {
377
+ return assertNeverFrame(message);
378
+ }
379
+ }
380
+ }
381
+ /** The hard cap on relays per shard (`LUNORA_MAX_RELAYS`) — a deployment constant the runtime surfaces in Studio as the cost ceiling. */
382
+ maxRelays() {
383
+ return envPositiveInt(this.host.env(), "LUNORA_MAX_RELAYS", DEFAULT_MAX_RELAYS);
384
+ }
385
+ /** Whether this DO can currently address its siblings (a namespace binding has been learned) — the relay tier is inert in single-DO mode. */
386
+ canAddressSiblings() {
387
+ return this.relayNamespace() !== void 0;
388
+ }
389
+ /** Resolve this DO's own namespace binding so it can address sibling owners/relays, or `undefined` when unknown. */
390
+ relayNamespace() {
391
+ const binding = this.host.shardBinding();
392
+ if (binding === void 0) {
393
+ return void 0;
394
+ }
395
+ return asRelayNamespace(this.host.env()?.[binding]);
396
+ }
397
+ /** 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. */
398
+ async postRelayMessage(targetName, message) {
399
+ await this.requestRelayMessage(targetName, message);
400
+ }
401
+ /**
402
+ * POST a control frame to a sibling by name and return its response (the shape-seed
403
+ * path needs the owner's frames back). Best-effort: a transient cross-DO failure
404
+ * returns `undefined` rather than throwing.
405
+ * @returns the sibling's response, or `undefined` when it can't be reached
406
+ */
407
+ async requestRelayMessage(targetName, message) {
408
+ const namespace = this.relayNamespace();
409
+ if (namespace === void 0) {
410
+ return void 0;
411
+ }
412
+ const stub = typeof namespace.getByName === "function" ? namespace.getByName(targetName) : namespace.get(namespace.idFromName(targetName));
413
+ const body = JSON.stringify(message);
414
+ const headers = { "content-type": "application/json", "x-lunora-shard-binding": this.host.shardBinding() ?? "" };
415
+ const secret = relaySecretOf(this.host.env());
416
+ if (secret !== void 0) {
417
+ headers[RELAY_SIGNATURE_HEADER] = await signRelayBody(secret, body);
418
+ }
419
+ try {
420
+ return await stub.fetch("https://relay.internal/_lunora/relay", {
421
+ body,
422
+ headers,
423
+ method: "POST"
424
+ });
425
+ } catch {
426
+ return void 0;
427
+ }
428
+ }
429
+ }
430
+ class OwnerRelay extends RelayLink {
431
+ /** Memoized RLS-uniform verdict per `(name, args)` shape — uniformity is stable, so the gate probe runs at most once per distinct shape. */
432
+ shapeUniformCache = /* @__PURE__ */ new Map();
433
+ /** Active relay indices, hydrated once from `__lunora_relays` and cached for the synchronous forward path. */
434
+ relaySetCache;
435
+ /** Relay-uniform shapes a relay has subscribers for, keyed by `(name, args)`. `cursor` is the cohort frontier the owner has multicast deltas up to. */
436
+ relayShapeRegistry = /* @__PURE__ */ new Map();
437
+ /** 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. */
438
+ relayShapeProxies = /* @__PURE__ */ new Map();
439
+ /** The owner's current promotion state (plan 075 Phase 4), carried across `relayCount()` calls so hysteresis has memory — a shard hovering near the threshold can't flap. */
440
+ promotionState = "owned";
441
+ constructor(host, ownerKey) {
442
+ super(host, { ownerKey });
443
+ }
444
+ async forwardWhisper(topic, frame) {
445
+ if (!this.canAddressSiblings()) {
446
+ return;
447
+ }
448
+ const relays = this.ownerRelaySet();
449
+ if (relays.size === 0) {
450
+ return;
451
+ }
452
+ await Promise.all([...relays].map((index) => this.postRelayMessage(relayName(this.roleId.ownerKey, index), { frame, topic, type: "relay_frame" })));
453
+ }
454
+ async onFlush(changed, frameCursor) {
455
+ await Promise.all([this.multicastShapePokes(changed, frameCursor), this.proxyShapePokes(changed, frameCursor)]);
456
+ }
457
+ // eslint-disable-next-line class-methods-use-this -- role hook: an owner serves its own shape subscribers locally, never through a relay
458
+ seedRelayShape() {
459
+ return Promise.resolve(void 0);
460
+ }
461
+ // eslint-disable-next-line class-methods-use-this -- role hook: only a relay announces
462
+ announce() {
463
+ return Promise.resolve();
464
+ }
465
+ // eslint-disable-next-line class-methods-use-this -- role hook: only a relay drains
466
+ announceDrain() {
467
+ return Promise.resolve();
468
+ }
469
+ /**
470
+ * How many relays the runtime should spread new connections across for this shard
471
+ * (plan 075 Phase 2, hysteresis added in Phase 4). `0` keeps every connection on
472
+ * the owner. The owner promotes once its live socket count reaches
473
+ * `LUNORA_RELAY_THRESHOLD` (`tUp`), fanning to a fixed `LUNORA_RELAY_FAN` (capped
474
+ * by `LUNORA_MAX_RELAYS`, the cost ceiling), and only collapses back to
475
+ * owner-served once subscribers drain below `LUNORA_RELAY_COLLAPSE_THRESHOLD`
476
+ * (`tDown`) — the band between the two holds the current state so a shard
477
+ * hovering near the threshold can't flap. {@link clampPromotionThresholds}
478
+ * guarantees a valid `tDown < tUp` band even under a misconfigured collapse
479
+ * threshold. Advances the promotion latch, so it is not a pure read.
480
+ */
481
+ relayCount() {
482
+ const subscribers = this.host.getWebSockets().length;
483
+ const tUp = envPositiveInt(this.host.env(), "LUNORA_RELAY_THRESHOLD", DEFAULT_PROMOTION_THRESHOLDS.tUp);
484
+ const tDownRaw = envPositiveInt(this.host.env(), "LUNORA_RELAY_COLLAPSE_THRESHOLD", DEFAULT_PROMOTION_THRESHOLDS.tDown);
485
+ this.promotionState = nextPromotionState(this.promotionState, subscribers, clampPromotionThresholds(tUp, tDownRaw));
486
+ if (this.promotionState === "owned") {
487
+ return 0;
488
+ }
489
+ const maxRelays = envPositiveInt(this.host.env(), "LUNORA_MAX_RELAYS", DEFAULT_MAX_RELAYS);
490
+ const fan = envPositiveInt(this.host.env(), "LUNORA_RELAY_FAN", DEFAULT_RELAY_FAN);
491
+ return Math.min(maxRelays, Math.max(1, fan));
492
+ }
493
+ /**
494
+ * The RLS-uniform gate (plan 075 Phase 3, review-hardened): whether a reactive
495
+ * shape may be relay-multicast — i.e. one delta is correct for **every**
496
+ * subscriber. Fail-closed on four grounds — a static RLS read-policy guard, the
497
+ * anonymous multicast identity as the probe base, two `Proxy`-backed probes that
498
+ * yield a distinct value for ANY accessed claim (so any claim a `where` reads —
499
+ * even a custom one outside `rls()` — diverges), and a wholesale-copy backstop.
500
+ * Cached per `(name, args)`, whose uniformity is stable.
501
+ */
502
+ isShapeRelayUniform(name, args) {
503
+ const cacheKey = shapeRoutingKey(name, args);
504
+ const cached = this.shapeUniformCache.get(cacheKey);
505
+ if (cached !== void 0) {
506
+ return cached;
507
+ }
508
+ const uniform = this.probeShapeRelayUniform(name, args);
509
+ this.shapeUniformCache.set(cacheKey, uniform);
510
+ return uniform;
511
+ }
512
+ onAttach(index) {
513
+ this.addRelayToSet(index);
514
+ }
515
+ onDetach(index) {
516
+ this.removeRelayFromSet(index);
517
+ }
518
+ async onWhisperFrame(message) {
519
+ await Promise.all(
520
+ [...this.ownerRelaySet()].filter((index) => index !== message.originRelay).map(
521
+ (index) => this.postRelayMessage(relayName(this.roleId.ownerKey, index), { frame: message.frame, topic: message.topic, type: "relay_frame" })
522
+ )
523
+ );
524
+ }
525
+ onShapeSubscribe(message) {
526
+ return this.buildShapeSeedFrames(message);
527
+ }
528
+ // eslint-disable-next-line class-methods-use-this -- role hook: an owner doesn't receive multicast pokes (it sends them)
529
+ onShapePoke() {
530
+ return 0;
531
+ }
532
+ /**
533
+ * Owner side (slice B.2): for every registered relay-uniform shape whose table
534
+ * changed this flush, compute the membership diff ONCE over `(cohort cursor,
535
+ * frameCursor]` and multicast the `rowsPatch` to every relay. Advances the cohort
536
+ * cursor synchronously (before any await) so a seed interleaving during the
537
+ * multicast registers at `frameCursor` and is skipped by this in-flight poke.
538
+ */
539
+ async multicastShapePokes(changed, frameCursor) {
540
+ if (this.relayShapeRegistry.size === 0) {
541
+ return;
542
+ }
543
+ const relays = this.ownerRelaySet();
544
+ if (relays.size === 0) {
545
+ return;
546
+ }
547
+ const epoch = this.host.currentCdcEpoch();
548
+ const sends = [];
549
+ for (const entry of this.relayShapeRegistry.values()) {
550
+ let resolved;
551
+ try {
552
+ resolved = this.host.resolveShape(entry.name, entry.args, RELAY_MULTICAST_IDENTITY);
553
+ } catch {
554
+ continue;
555
+ }
556
+ if (resolved === void 0 || resolved.global === true || !changed.has(resolved.table)) {
557
+ continue;
558
+ }
559
+ const fromCursor = entry.cursor;
560
+ const rowsPatch = this.host.buildShapeDiff(resolved, fromCursor, frameCursor);
561
+ if (rowsPatch.length === 0) {
562
+ continue;
563
+ }
564
+ entry.cursor = frameCursor;
565
+ const poke = {
566
+ args: entry.args,
567
+ checkpoint: frameCursor,
568
+ epoch,
569
+ fromCursor,
570
+ name: entry.name,
571
+ // Wire-encode before the poke crosses the owner→relay `JSON.stringify`
572
+ // hop (`requestRelayMessage`); the relay re-frames it with
573
+ // `preEncoded` so a `bytes`/`bigint` shape column isn't dropped/truncated.
574
+ rowsPatch: encodeRowsPatch(rowsPatch),
575
+ type: "relay_shape_poke"
576
+ };
577
+ for (const index of relays) {
578
+ sends.push(this.postRelayMessage(relayName(this.roleId.ownerKey, index), poke));
579
+ }
580
+ }
581
+ await Promise.all(sends);
582
+ }
583
+ /**
584
+ * Owner side (review MEDIUM-3): for every NON-uniform relay-shape proxy whose
585
+ * table changed this flush, compute that one subscriber's diff over `(entry.cursor,
586
+ * frameCursor]` UNDER ITS OWN forwarded identity (RLS-correct) and deliver a
587
+ * `targetConnectionId`-addressed poke to just that socket's relay. Each entry
588
+ * tracks its own cursor — the diffs are identity-specific, no cohort sharing.
589
+ */
590
+ async proxyShapePokes(changed, frameCursor) {
591
+ if (this.relayShapeProxies.size === 0) {
592
+ return;
593
+ }
594
+ const epoch = this.host.currentCdcEpoch();
595
+ const sends = [];
596
+ for (const entry of this.relayShapeProxies.values()) {
597
+ let resolved;
598
+ try {
599
+ resolved = this.host.resolveShape(entry.name, entry.args, entry.identity);
600
+ } catch {
601
+ continue;
602
+ }
603
+ if (resolved === void 0 || resolved.global === true || !changed.has(resolved.table)) {
604
+ continue;
605
+ }
606
+ const fromCursor = entry.cursor;
607
+ const rowsPatch = this.host.buildShapeDiff(resolved, fromCursor, frameCursor);
608
+ if (rowsPatch.length === 0) {
609
+ continue;
610
+ }
611
+ entry.cursor = frameCursor;
612
+ const poke = {
613
+ args: entry.args,
614
+ checkpoint: frameCursor,
615
+ epoch,
616
+ fromCursor,
617
+ name: entry.name,
618
+ // Wire-encode before the owner→relay `JSON.stringify` hop (see the
619
+ // cohort-multicast path); the relay re-frames it with `preEncoded`.
620
+ rowsPatch: encodeRowsPatch(rowsPatch),
621
+ targetConnectionId: entry.connectionId,
622
+ type: "relay_shape_poke"
623
+ };
624
+ sends.push(this.postRelayMessage(relayName(this.roleId.ownerKey, entry.relayIndex), poke));
625
+ }
626
+ await Promise.all(sends);
627
+ }
628
+ /**
629
+ * Serialize a shape's seed poke frames for a relay to deliver verbatim. Resolves
630
+ * under the forwarded socket identity (so RLS applies exactly as for a local
631
+ * subscribe), self-heals the relay set, registers the shape for live updates
632
+ * (cohort multicast when uniform, per-socket proxy when not), and stamps the
633
+ * relay's cohort memo at the registry FRONTIER (not the global cursor) so a late
634
+ * joiner is never stranded. `lastMutationId` is omitted (relayed sockets are
635
+ * owner-served for custom mutators).
636
+ * @returns the serialized frames + the cohort-memo cursor, or an error
637
+ */
638
+ buildShapeSeedFrames(request) {
639
+ const identity = { identity: request.identity, userId: request.userId };
640
+ let resolved;
641
+ try {
642
+ resolved = this.host.resolveShape(request.name, request.args, identity);
643
+ } catch (error) {
644
+ return { error: { code: "SHAPE_RESOLVE_FAILED", message: error instanceof Error ? error.message : "shape resolve failed" } };
645
+ }
646
+ if (resolved === void 0 || resolved.global === true) {
647
+ return { error: { code: "SHAPE_NOT_FOUND", message: `shape not relayable: ${request.name}` } };
648
+ }
649
+ if (request.relayIndex !== void 0) {
650
+ this.addRelayToSet(request.relayIndex);
651
+ }
652
+ const { baseCheckpoint, cursor, epoch, rowsPatch } = this.host.computeOpLogShapeSeed(
653
+ { args: request.args, name: request.name, sinceEpoch: request.sinceEpoch, sinceSeq: request.sinceSeq },
654
+ resolved
655
+ );
656
+ let cohortCursor = cursor;
657
+ if (this.isShapeRelayUniform(request.name, request.args)) {
658
+ const routingKey = shapeRoutingKey(request.name, request.args);
659
+ let entry = this.relayShapeRegistry.get(routingKey);
660
+ if (entry === void 0) {
661
+ entry = { args: request.args, cursor, name: request.name };
662
+ this.relayShapeRegistry.set(routingKey, entry);
663
+ }
664
+ cohortCursor = entry.cursor;
665
+ } else if (request.relayIndex !== void 0 && request.connectionId !== void 0) {
666
+ this.relayShapeProxies.set(`${String(request.relayIndex)}:${request.connectionId}:${request.subId}`, {
667
+ args: request.args,
668
+ connectionId: request.connectionId,
669
+ cursor,
670
+ epoch,
671
+ identity,
672
+ name: request.name,
673
+ relayIndex: request.relayIndex,
674
+ subId: request.subId
675
+ });
676
+ }
677
+ const frames = buildPokeFrames([{ rowsPatch, shapeId: request.subId }], {
678
+ baseCheckpoint,
679
+ checkpoint: cursor,
680
+ epoch,
681
+ lastMutationId: void 0,
682
+ pokeId: this.host.nextPokeId()
683
+ });
684
+ return { cursor: cohortCursor, epoch, frames };
685
+ }
686
+ /** Ensure the reserved owner-side relay-set table exists (auto-hidden from the data browser by the `__lunora` prefix). */
687
+ ensureRelayTable() {
688
+ this.host.sql().exec("CREATE TABLE IF NOT EXISTS __lunora_relays (idx INTEGER PRIMARY KEY)");
689
+ }
690
+ /** The owner's active relay indices, hydrated once from `__lunora_relays` and cached for the synchronous forward path. */
691
+ ownerRelaySet() {
692
+ if (this.relaySetCache === void 0) {
693
+ this.ensureRelayTable();
694
+ const rows = this.host.sql().exec("SELECT idx FROM __lunora_relays").toArray();
695
+ this.relaySetCache = new Set(rows.map((row) => Number(row.idx)));
696
+ }
697
+ return this.relaySetCache;
698
+ }
699
+ /** Record a relay as active (idempotent), persisting it so the set survives the owner's hibernation. */
700
+ addRelayToSet(index) {
701
+ this.ensureRelayTable();
702
+ this.host.sql().exec("INSERT OR IGNORE INTO __lunora_relays (idx) VALUES (?)", index);
703
+ this.ownerRelaySet().add(index);
704
+ }
705
+ /** 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. */
706
+ removeRelayFromSet(index) {
707
+ this.ensureRelayTable();
708
+ this.host.sql().exec("DELETE FROM __lunora_relays WHERE idx = ?", index);
709
+ const set = this.ownerRelaySet();
710
+ set.delete(index);
711
+ for (const [key, entry] of this.relayShapeProxies) {
712
+ if (entry.relayIndex === index) {
713
+ this.relayShapeProxies.delete(key);
714
+ }
715
+ }
716
+ if (set.size === 0) {
717
+ this.relayShapeRegistry.clear();
718
+ this.shapeUniformCache.clear();
719
+ }
720
+ }
721
+ /**
722
+ * The one-shot computation behind {@link OwnerRelay.isShapeRelayUniform}, made
723
+ * sound against the cross-identity row-leak (review). Resolves under the anonymous
724
+ * multicast identity (the base) plus two `Proxy`-backed identities that return a
725
+ * distinct value for ANY accessed claim, requires all to agree on table + where +
726
+ * columns, rejects any table with an RLS read policy or ANY masked column
727
+ * defined (even if unprojected — see {@link OwnerRelay.tableHasAnyMask}), and
728
+ * fails closed if the claims are enumerated (a wholesale copy the proxy can't
729
+ * differentiate).
730
+ */
731
+ probeShapeRelayUniform(name, args) {
732
+ let base;
733
+ try {
734
+ base = this.host.resolveShape(name, args, RELAY_MULTICAST_IDENTITY);
735
+ } catch {
736
+ return false;
737
+ }
738
+ if (base === void 0 || base.global === true) {
739
+ return false;
740
+ }
741
+ if (this.host.rlsMetadata().policies.some((policy) => policy.on === "read" && policy.table === base.table)) {
742
+ return false;
743
+ }
744
+ if (this.tableHasAnyMask(base.table)) {
745
+ return false;
746
+ }
747
+ const baseWhere = stableStringify(base.effectiveWhere);
748
+ const baseColumns = stableStringify(base.columns);
749
+ let enumerated = false;
750
+ const populate = (side) => {
751
+ const backing = { groups: [`grp_${side}`], roles: [side], sub: `__lunora_probe_${side}__` };
752
+ const claims = /* @__PURE__ */ new Proxy(backing, {
753
+ get: (target, key) => {
754
+ if (typeof key === "symbol" || key in target) {
755
+ return Reflect.get(target, key);
756
+ }
757
+ return `${side}:${key}`;
758
+ },
759
+ getOwnPropertyDescriptor: (target, key) => {
760
+ enumerated = true;
761
+ return Reflect.getOwnPropertyDescriptor(target, key);
762
+ },
763
+ has: (target, key) => typeof key === "symbol" ? Reflect.has(target, key) : true,
764
+ ownKeys: (target) => {
765
+ enumerated = true;
766
+ return Reflect.ownKeys(target);
767
+ }
768
+ });
769
+ return { identity: claims, userId: `__lunora_probe_${side}__` };
770
+ };
771
+ const matches = [RELAY_MULTICAST_IDENTITY, populate("a"), populate("b")].every((probe) => {
772
+ let resolved;
773
+ try {
774
+ resolved = this.host.resolveShape(name, args, probe);
775
+ } catch {
776
+ return false;
777
+ }
778
+ return resolved !== void 0 && resolved.global !== true && resolved.table === base.table && stableStringify(resolved.effectiveWhere) === baseWhere && stableStringify(resolved.columns) === baseColumns;
779
+ });
780
+ return matches && !enumerated;
781
+ }
782
+ /**
783
+ * Whether `table` has ANY masked column defined (L7). Deliberately conservative:
784
+ * we disqualify a shape from relay-multicast if its table declares any mask at
785
+ * all, even when the current query doesn't project the masked column. A masked
786
+ * value is identity-dependent, and keying uniformity off the *projected* set
787
+ * meant a later column addition (or a `select` change) could silently widen a
788
+ * cohort to include an identity-dependent value. Refusing on any table-level
789
+ * mask removes that footgun; the cost is a few extra shapes falling back to the
790
+ * per-identity path, which is always correct.
791
+ */
792
+ tableHasAnyMask(table) {
793
+ return this.host.maskMetadata().columns.some((entry) => entry.table === table);
794
+ }
795
+ }
796
+ class RelayMember extends RelayLink {
797
+ /** `true` once this relay has announced itself to its owner this wake, so a hot socket churn doesn't re-attach on every subscribe. */
798
+ relayAnnounced = false;
799
+ /** 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`. */
800
+ shapeRelayMemos = /* @__PURE__ */ new WeakMap();
801
+ constructor(host, ownerKey, relayIndex) {
802
+ super(host, { ownerKey, relayIndex });
803
+ }
804
+ async forwardWhisper(topic, frame) {
805
+ if (!this.canAddressSiblings()) {
806
+ return;
807
+ }
808
+ await this.postRelayMessage(this.roleId.ownerKey, { frame, originRelay: this.roleId.relayIndex, topic, type: "relay_frame" });
809
+ }
810
+ // eslint-disable-next-line class-methods-use-this -- role hook: a relay receives no writes, so it never flushes its own CDC
811
+ onFlush() {
812
+ return Promise.resolve();
813
+ }
814
+ /**
815
+ * Seed a shape held by a socket on this relay by forwarding the request to the
816
+ * owner: the owner resolves under this socket's verified identity and computes the
817
+ * seed frames, which the relay delivers verbatim. Returns a structured error
818
+ * (surfaced as a `shape_subscribe` error) when the owner can't be reached.
819
+ */
820
+ async seedRelayShape(ws, subId, shape, identity) {
821
+ if (!this.canAddressSiblings()) {
822
+ return { code: "RELAY_MISCONFIGURED", message: "relay cannot address its owner" };
823
+ }
824
+ await this.announce();
825
+ const request = {
826
+ args: shape.args ?? {},
827
+ connectionId: this.host.readAttachment(ws).connectionId,
828
+ identity: identity.identity,
829
+ name: shape.name,
830
+ relayIndex: this.roleId.relayIndex,
831
+ sinceEpoch: shape.sinceEpoch,
832
+ sinceSeq: shape.sinceSeq,
833
+ subId,
834
+ type: "relay_shape_subscribe",
835
+ userId: identity.userId
836
+ };
837
+ const response = await this.requestRelayMessage(this.roleId.ownerKey, request);
838
+ if (response === void 0) {
839
+ return { code: "RELAY_SEED_FAILED", message: "owner did not answer the shape seed" };
840
+ }
841
+ let seed;
842
+ try {
843
+ seed = await response.json();
844
+ } catch {
845
+ return { code: "RELAY_SEED_FAILED", message: "malformed shape seed from owner" };
846
+ }
847
+ if (seed.error !== void 0) {
848
+ return seed.error;
849
+ }
850
+ if (seed.frames === void 0) {
851
+ return { code: "RELAY_SEED_FAILED", message: "owner returned no shape frames" };
852
+ }
853
+ await awaitWsDrain(ws);
854
+ for (const frame of seed.frames) {
855
+ trySendFrame(ws, frame);
856
+ }
857
+ this.recordRelayShapeMemo(ws, subId, seed.cursor ?? 0, seed.epoch);
858
+ return "ok";
859
+ }
860
+ /** 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). */
861
+ async announce() {
862
+ if (this.relayAnnounced || !this.canAddressSiblings()) {
863
+ return;
864
+ }
865
+ this.relayAnnounced = true;
866
+ const response = await this.requestRelayMessage(this.roleId.ownerKey, { relayIndex: this.roleId.relayIndex, type: "relay_attach" });
867
+ if (!response?.ok) {
868
+ this.relayAnnounced = false;
869
+ }
870
+ }
871
+ /** 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. */
872
+ async announceDrain(closing) {
873
+ if (!this.canAddressSiblings()) {
874
+ return;
875
+ }
876
+ if (this.host.getWebSockets().some((ws) => ws !== closing)) {
877
+ return;
878
+ }
879
+ this.relayAnnounced = false;
880
+ await this.postRelayMessage(this.roleId.ownerKey, { relayIndex: this.roleId.relayIndex, type: "relay_detach" });
881
+ }
882
+ // eslint-disable-next-line class-methods-use-this -- role hook: a relay never spreads connections (flat single tier)
883
+ relayCount() {
884
+ return 0;
885
+ }
886
+ // eslint-disable-next-line class-methods-use-this -- role hook: the RLS-uniform gate is an owner concern
887
+ isShapeRelayUniform() {
888
+ return false;
889
+ }
890
+ // eslint-disable-next-line class-methods-use-this -- role hook: only an owner tracks a relay set
891
+ onAttach() {
892
+ }
893
+ // eslint-disable-next-line class-methods-use-this -- role hook: only an owner tracks a relay set
894
+ onDetach() {
895
+ }
896
+ // eslint-disable-next-line class-methods-use-this -- role hook: only an owner re-distributes a forwarded whisper
897
+ onWhisperFrame() {
898
+ return Promise.resolve();
899
+ }
900
+ // eslint-disable-next-line class-methods-use-this -- role hook: a relay can't seed (no op-log) — the owner does
901
+ onShapeSubscribe() {
902
+ return { error: { code: "RELAY_CANNOT_SEED", message: "a relay has no op-log to seed from" } };
903
+ }
904
+ onShapePoke(poke) {
905
+ return this.deliverShapePoke(poke);
906
+ }
907
+ /** Record a relay socket's cohort cursor + epoch for `subId` (creating the per-socket map lazily). */
908
+ recordRelayShapeMemo(ws, subId, cursor, epoch) {
909
+ let memos = this.shapeRelayMemos.get(ws);
910
+ if (memos === void 0) {
911
+ memos = /* @__PURE__ */ new Map();
912
+ this.shapeRelayMemos.set(ws, memos);
913
+ }
914
+ memos.set(subId, { cursor, epoch });
915
+ }
916
+ /**
917
+ * Deliver an owner-multicast shape delta to this relay's cohort sockets. A socket
918
+ * receives it only while its memo matches the poke's `fromCursor` AND `epoch` (so a
919
+ * socket that seeded at a different cursor/epoch never double-applies), then
920
+ * advances to `checkpoint`. A targeted (per-socket proxy) poke goes ONLY to its one
921
+ * connection; a cohort multicast goes to every matching socket.
922
+ * @returns the number of sockets delivered to
923
+ */
924
+ deliverShapePoke(poke) {
925
+ const routingKey = shapeRoutingKey(poke.name, poke.args);
926
+ let delivered = 0;
927
+ for (const ws of this.host.getWebSockets()) {
928
+ const attachment = this.host.readAttachment(ws);
929
+ const { shapes } = attachment;
930
+ const memos = this.shapeRelayMemos.get(ws);
931
+ if (shapes === void 0 || memos === void 0) {
932
+ continue;
933
+ }
934
+ if (poke.targetConnectionId !== void 0 && attachment.connectionId !== poke.targetConnectionId) {
935
+ continue;
936
+ }
937
+ for (const [subId, sub] of Object.entries(shapes)) {
938
+ const memo = memos.get(subId);
939
+ if (memo?.cursor !== poke.fromCursor || memo.epoch !== poke.epoch || shapeRoutingKey(sub.name, sub.args) !== routingKey) {
940
+ continue;
941
+ }
942
+ const frames = buildPokeFrames(
943
+ [{ rowsPatch: poke.rowsPatch, shapeId: subId }],
944
+ {
945
+ baseCheckpoint: void 0,
946
+ checkpoint: poke.checkpoint,
947
+ epoch: poke.epoch,
948
+ lastMutationId: void 0,
949
+ pokeId: this.host.nextPokeId()
950
+ },
951
+ // `poke.rowsPatch` was wire-encoded by the owner before it crossed
952
+ // the hub — don't double-encode it here.
953
+ { preEncoded: true }
954
+ );
955
+ for (const frame of frames) {
956
+ trySendFrame(ws, frame);
957
+ }
958
+ memos.set(subId, { cursor: poke.checkpoint, epoch: poke.epoch });
959
+ delivered += 1;
960
+ }
961
+ }
962
+ return delivered;
963
+ }
964
+ }
965
+ const createRelayLink = (host) => {
966
+ const name = host.doName();
967
+ if (name === void 0) {
968
+ return void 0;
969
+ }
970
+ const parsed = parseRelayName(name);
971
+ return parsed === void 0 ? new OwnerRelay(host, name) : new RelayMember(host, parsed.ownerKey, parsed.relayIndex);
972
+ };
973
+
145
974
  const REQUEST_LOG_TABLE = "__lunora_reqlog__";
146
975
  const REQUEST_LOG_RETENTION = 1e3;
147
976
  const REQUEST_LOG_EVENT_SOURCE = "lunora";
@@ -349,6 +1178,23 @@ const readRequestLog = (sql, options = {}) => {
349
1178
  });
350
1179
  };
351
1180
 
1181
+ const runSocketPool = async (items, processOne, concurrency = 8) => {
1182
+ let cursor = 0;
1183
+ const worker = async () => {
1184
+ let item = items[cursor];
1185
+ cursor += 1;
1186
+ while (item !== void 0) {
1187
+ try {
1188
+ await processOne(item);
1189
+ } catch {
1190
+ }
1191
+ item = items[cursor];
1192
+ cursor += 1;
1193
+ }
1194
+ };
1195
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
1196
+ };
1197
+
352
1198
  const DANGLING_SCAN_CAP = 5e3;
353
1199
  const DANGLING_RESULT_CAP = 500;
354
1200
  const DOC_COLUMN = "__doc__";
@@ -413,116 +1259,13 @@ const findDanglingReferences = (sql, storageColumns, liveKeys) => {
413
1259
 
414
1260
  const WS_KEEPALIVE_PING = "lunora-ping";
415
1261
  const WS_KEEPALIVE_PONG = "lunora-pong";
416
- const ROW_ID_FIELD = "_id";
417
- const DELTA_FALLBACK_TABLE = "__lunora__";
418
- const readRowId = (row) => {
419
- if (typeof row !== "object" || row === null || Array.isArray(row)) {
420
- return void 0;
421
- }
422
- const id = row[ROW_ID_FIELD];
423
- return typeof id === "string" ? id : void 0;
424
- };
425
- const indexRowsById = (rows) => {
426
- const byId = /* @__PURE__ */ new Map();
427
- const order = [];
428
- for (const row of rows) {
429
- const id = readRowId(row);
430
- if (id === void 0 || byId.has(id)) {
431
- return void 0;
432
- }
433
- byId.set(id, row);
434
- order.push(id);
435
- }
436
- return { byId, order };
437
- };
438
- const survivorsKeepOrder = (previous, next) => {
439
- const survivingPrevious = previous.order.filter((id) => next.byId.has(id));
440
- const survivingNext = next.order.filter((id) => previous.byId.has(id));
441
- if (survivingPrevious.length !== survivingNext.length) {
442
- return false;
443
- }
444
- return survivingPrevious.every((id, index) => survivingNext[index] === id);
445
- };
446
- const collectDeleteDeltas = (previous, next, deltaTable, tableJson) => {
447
- const out = [];
448
- for (const id of previous.order) {
449
- if (!next.byId.has(id)) {
450
- out.push({
451
- delta: { key: id, op: "delete", table: deltaTable },
452
- frame: `{"key":${JSON.stringify(id)},"op":"delete","table":${tableJson}}`
453
- });
454
- }
455
- }
456
- return out;
457
- };
458
- const collectUpsertDeltas = (previous, next, deltaTable, tableJson) => {
459
- const out = [];
460
- for (const id of next.order) {
461
- const nextRow = next.byId.get(id);
462
- const previousRow = previous.byId.get(id);
463
- const nextFingerprint = JSON.stringify(nextRow);
464
- const previousFingerprint = previousRow === void 0 ? void 0 : JSON.stringify(previousRow);
465
- if (previousFingerprint === nextFingerprint) {
466
- continue;
467
- }
468
- const op = previousFingerprint === void 0 ? "insert" : "update";
469
- out.push({
470
- delta: { key: id, op, row: nextRow, table: deltaTable },
471
- frame: `{"key":${JSON.stringify(id)},"op":"${op}","row":${nextFingerprint},"table":${tableJson}}`
472
- });
473
- }
474
- return out;
475
- };
476
- const subscriptionListDeltas = (previousJson, nextResult, table, frames) => {
477
- let parsed;
478
- try {
479
- parsed = JSON.parse(previousJson);
480
- } catch {
481
- return void 0;
482
- }
483
- if (!Array.isArray(parsed) || !Array.isArray(nextResult)) {
484
- return void 0;
485
- }
486
- const previous = indexRowsById(parsed);
487
- const next = indexRowsById(nextResult);
488
- if (previous === void 0 || next === void 0) {
489
- return void 0;
490
- }
491
- if (!survivorsKeepOrder(previous, next)) {
492
- return void 0;
493
- }
494
- const deltaTable = table === "" ? DELTA_FALLBACK_TABLE : table;
495
- const tableJson = JSON.stringify(deltaTable);
496
- const framed = [...collectDeleteDeltas(previous, next, deltaTable, tableJson), ...collectUpsertDeltas(previous, next, deltaTable, tableJson)];
497
- if (framed.length > next.order.length) {
498
- return void 0;
499
- }
500
- if (frames !== void 0) {
501
- for (const { frame } of framed) {
502
- frames.push(frame);
503
- }
504
- }
505
- return framed.map(({ delta }) => delta);
506
- };
1262
+ const UNDELIVERED_BASELINE = "<undelivered>";
507
1263
  const ROOT_DO_SIZE_WARN_BYTES = 1073741824;
508
1264
  const CDC_RESUME_SCAN_LIMIT = 1e4;
509
1265
  const IDEMPOTENCY_RETENTION_MS = 864e5;
510
1266
  const IDEMPOTENCY_GC_INTERVAL_MS = 36e5;
511
1267
  const ROOT_SHARD_NAME = "__root__";
512
1268
  const ADMIN_WILDCARD = "*";
513
- const awaitWsDrain = async (ws) => {
514
- let attempts = 0;
515
- while (attempts < 100) {
516
- attempts += 1;
517
- const buffered = ws.bufferedAmount;
518
- if (typeof buffered !== "number" || buffered < 1048576) {
519
- return;
520
- }
521
- await new Promise((resolve) => {
522
- setTimeout(resolve, 20);
523
- });
524
- }
525
- };
526
1269
  const cdcSuffix = (cursor, epoch) => (cursor === void 0 ? "" : `,"cursor":${String(cursor)}`) + (epoch === void 0 ? "" : `,"epoch":${JSON.stringify(epoch)}`);
527
1270
  const setsIntersect = (a, b) => {
528
1271
  const [small, large] = a.size <= b.size ? [a, b] : [b, a];
@@ -918,6 +1661,13 @@ const parseIdentityHeader = (raw) => {
918
1661
  }
919
1662
  return void 0;
920
1663
  };
1664
+ const parseClientSeqHeader = (raw) => {
1665
+ if (!raw) {
1666
+ return void 0;
1667
+ }
1668
+ const seq = Number(raw);
1669
+ return Number.isInteger(seq) && seq > 0 ? seq : void 0;
1670
+ };
921
1671
  const tablesFromDeps = (deps) => {
922
1672
  const tables = /* @__PURE__ */ new Set();
923
1673
  for (const dep of deps) {
@@ -1002,6 +1752,28 @@ class ShardDO {
1002
1752
  * failure stays unlikely.
1003
1753
  */
1004
1754
  static MAX_SUBSCRIPTIONS_PER_SOCKET = 32;
1755
+ /**
1756
+ * Poll interval (ms) for `.global()`-table shapes. A global table lives in
1757
+ * D1 with no per-DO op-log, so its shapes can't be poke-live; the DO re-reads
1758
+ * each subscribed global shape's membership from D1 on an alarm every
1759
+ * `GLOBAL_SHAPE_POLL_INTERVAL_MS` and pokes only the diff. This is the
1760
+ * latency floor for a global-shape update — deliberately coarse (seconds, not
1761
+ * the sub-millisecond poke-live path) since the D1 read fans out per tick.
1762
+ */
1763
+ static GLOBAL_SHAPE_POLL_INTERVAL_MS = 2e3;
1764
+ /**
1765
+ * Upper bound on a `.global()`-shape's materialized membership. Each global
1766
+ * shape keeps its ENTIRE current membership as a per-socket snapshot
1767
+ * (`Map&lt;rowKey, hash&gt;`) so the poll loop can diff it; that snapshot — and the
1768
+ * read buffer feeding it — scale with the membership size, multiplied by every
1769
+ * subscribed socket. An unbounded membership (a global table with no narrowing
1770
+ * shape predicate or RLS read scope) would grow them without limit and evict
1771
+ * the DO. A shape whose membership exceeds this cap is failed closed (left
1772
+ * empty, logged) rather than retained — the developer must narrow it. Sized
1773
+ * well above any reasonable per-identity replicated set so legitimate shapes
1774
+ * never trip it.
1775
+ */
1776
+ static GLOBAL_SHAPE_MAX_ROWS = 5e4;
1005
1777
  /**
1006
1778
  * Per-socket whisper-topic cap. Topic membership rides the same hibernation
1007
1779
  * attachment as `subs`, so bound it for the same reason — a runaway
@@ -1103,6 +1875,39 @@ class ShardDO {
1103
1875
  * `finally` block.
1104
1876
  */
1105
1877
  currentRequestMutationId;
1878
+ /**
1879
+ * Stable per-device client id for the in-flight custom-mutator push,
1880
+ * forwarded via the `x-lunora-client-id` header. Backs the
1881
+ * `__client_watermark` table: the dispatch path classifies the paired
1882
+ * `currentRequestClientSeq` against the stored high-watermark (already
1883
+ * processed / next / out-of-order gap). Absent on legacy mutations and
1884
+ * queries (those keep the `__idempotency` path). Cleared in `fetch`'s
1885
+ * `finally`.
1886
+ */
1887
+ currentRequestClientId;
1888
+ /**
1889
+ * Monotonic per-client mutation sequence for the in-flight custom-mutator
1890
+ * push, forwarded via the `x-lunora-client-seq` header (numeric). Paired
1891
+ * with `currentRequestClientId` to drive the watermark classification.
1892
+ * `undefined` when absent or non-numeric.
1893
+ */
1894
+ currentRequestClientSeq;
1895
+ /**
1896
+ * The in-flight push's custom-mutator classification, stashed by `fetch`
1897
+ * before `handleRpc` so the in-transaction bookkeeping ({@link
1898
+ * ShardDO.commitMutationBookkeeping}) can advance the `__client_watermark` for
1899
+ * a `"next"` push inside the same commit as the writes. `undefined` for an
1900
+ * ordinary mutation / non-mutator push. Cleared per request.
1901
+ */
1902
+ currentMutatorClass;
1903
+ /**
1904
+ * Set once a mutation's replay bookkeeping (idempotency row + watermark
1905
+ * advance) has committed INSIDE the handler transaction, so the post-dispatch
1906
+ * path skips the now-redundant best-effort writes. Cleared per request; stays
1907
+ * `false` for actions/queries (no transaction wrapper) so their dispatch-level
1908
+ * idempotency persist still runs.
1909
+ */
1910
+ mutationBookkeepingCommitted = false;
1106
1911
  /**
1107
1912
  * Wall-clock millis of the last `__idempotency` GC sweep on this warm
1108
1913
  * instance. The dedup write throttles `trimIdempotent` to at most once an
@@ -1133,6 +1938,18 @@ class ShardDO {
1133
1938
  * the common read-only path allocates nothing.
1134
1939
  */
1135
1940
  pendingChangedTables = void 0;
1941
+ /**
1942
+ * Coalesced set of tables awaiting a subscription-refresh pass, merged
1943
+ * across every {@link ShardDO.flushChangedTables} call that lands while a
1944
+ * pass is already draining. The single drain loop
1945
+ * ({@link ShardDO.drainSubscriptionRefreshes}) owns this set; a burst of N
1946
+ * writes to the same table therefore collapses into one (or two) refresh
1947
+ * passes instead of N, so each affected subscription's handler re-runs once
1948
+ * per burst rather than once per write. `undefined` when nothing is pending.
1949
+ */
1950
+ pendingRefreshTables = void 0;
1951
+ /** True while {@link ShardDO.drainSubscriptionRefreshes} is running; the single-waiter gate that coalesces concurrent flushes. */
1952
+ refreshInFlight = false;
1136
1953
  /**
1137
1954
  * Last pushed result per `(socket, subId)`, keyed by socket. Lets
1138
1955
  * `refreshSubscriptions` skip re-running queries whose tables were
@@ -1141,6 +1958,39 @@ class ShardDO {
1141
1958
  * memo simply forces one re-run and (at most) one redundant push.
1142
1959
  */
1143
1960
  subMemos = /* @__PURE__ */ new WeakMap();
1961
+ /**
1962
+ * Per-socket poke baseline for shape subscriptions: maps each shape's
1963
+ * subscription id to the `__cdc_log` cursor it has been poked through.
1964
+ * `pokeShapeSubscribers` reads each op page since this cursor and advances
1965
+ * it to the flush watermark. In-memory only (like {@link ShardDO.subMemos});
1966
+ * a cold memo on a reconnected/hibernated socket re-seeds from the client's
1967
+ * `sinceCheckpoint`.
1968
+ */
1969
+ shapeMemos = /* @__PURE__ */ new WeakMap();
1970
+ /**
1971
+ * Per-socket, per-**global**-shape membership snapshot: maps each global
1972
+ * shape's subscription id to a `key → projected-value JSON` map of the rows
1973
+ * last poked to that socket. A `.global()` (D1) table has no op-log to diff,
1974
+ * so {@link ShardDO.refreshGlobalShape} re-reads the full membership on each
1975
+ * alarm tick and diffs it against this snapshot to compute the poke. Parallel
1976
+ * to {@link ShardDO.shapeMemos} (the cursor baseline for poke-live shapes).
1977
+ *
1978
+ * This is a hot in-memory **cache** over the durable `__global_shape_snapshot`
1979
+ * table (keyed by the socket's `connectionId` + subId): a hibernation eviction
1980
+ * clears the WeakMap, so on the next alarm wake {@link ShardDO.readGlobalSnapshot}
1981
+ * misses and re-loads the baseline from SQLite — without it, the diff would run
1982
+ * against an empty baseline and a row deleted from D1 while the DO slept would
1983
+ * never be poked as a `delete`, lingering on the client as a phantom row.
1984
+ */
1985
+ globalShapeSnapshots = /* @__PURE__ */ new WeakMap();
1986
+ /**
1987
+ * Whether a global-shape poll alarm is currently armed. Guards
1988
+ * {@link ShardDO.scheduleGlobalPoll} from re-arming on every seed; reset in
1989
+ * {@link ShardDO.alarm} before the poll so a still-subscribed shape re-arms.
1990
+ */
1991
+ globalPollScheduled = false;
1992
+ /** Monotonic per-DO poke id source; correlates a poke's `pokeStart`/`pokePart`/`pokeEnd` frames. */
1993
+ pokeSequence = 0;
1144
1994
  /** Per-socket whisper-rate token bucket (see {@link ShardDO.WHISPER_RATE_BURST}). In-memory; resets on hibernation. */
1145
1995
  whisperBuckets = /* @__PURE__ */ new WeakMap();
1146
1996
  /**
@@ -1158,6 +2008,35 @@ class ShardDO {
1158
2008
  * (durable aggregation would be a separate, heavier feature).
1159
2009
  */
1160
2010
  metrics = { errors: 0, requests: 0, sinceMs: Date.now() };
2011
+ /**
2012
+ * Running fan-out cost counters surfaced by the
2013
+ * `__lunora_admin__:getFanoutMetrics` RPC — one tally for the reactive
2014
+ * shape-poke path (`pokeShapeSubscribers`) and one for the whisper broadcast
2015
+ * path (`broadcastWhisper`). Each pass records the sockets it iterated (the
2016
+ * O(subscribers) cost) and delivered to. In-memory and reset on
2017
+ * hibernation/restart, sharing `metrics.sinceMs` as the "since this instance
2018
+ * woke" epoch. This is the observability half of plan 075's auto-elastic
2019
+ * relay tier (Phase 1): measure the per-flush fan-out cost so the promotion
2020
+ * threshold is grounded in real numbers, with no behavior change.
2021
+ */
2022
+ fanout = { shapePoke: createFanoutCounters(), whisper: createFanoutCounters() };
2023
+ /**
2024
+ * The runtime's Durable Object namespace binding name (e.g. `"SHARD"`),
2025
+ * forwarded as `x-lunora-shard-binding` on every request so a DO can address
2026
+ * its siblings (`this.env[binding].getByName(...)`) for the relay hub. Absent
2027
+ * in single-DO mode / the unit harness — when absent, the relay tier is inert
2028
+ * and whispers stay shard-local (no behavior change). In-memory; re-learned per
2029
+ * request.
2030
+ */
2031
+ shardBinding;
2032
+ /**
2033
+ * The auto-elastic fan-out relay collaborator (plan 075) — an {@link OwnerRelay}
2034
+ * or {@link RelayMember} chosen ONCE from this DO's name, or `undefined` for an
2035
+ * unnamed (single-DO) DO where the relay tier is inert. All relay state +
2036
+ * transport lives on it, reached back through the {@link RelayHost} adapter, so
2037
+ * owner-only state can never sit next to relay-only state on this class.
2038
+ */
2039
+ relay;
1161
2040
  /**
1162
2041
  * Declared indexes (`table:index`) a query has exercised since this instance
1163
2042
  * woke, stamped by `getCtxDbIndexUseHook`. In-memory and reset on
@@ -1244,6 +2123,29 @@ class ShardDO {
1244
2123
  if (options.reactiveCache) {
1245
2124
  this.reactiveCache = new ReactiveCache(options.reactiveCache);
1246
2125
  }
2126
+ const host = {
2127
+ buildShapeDiff: (resolved, fromCursor, toCursor) => this.buildShapeDiff(this.sql, resolved, fromCursor, toCursor),
2128
+ computeOpLogShapeSeed: (shape, resolved) => this.computeOpLogShapeSeed(shape, resolved),
2129
+ currentCdcEpoch: () => this.currentCdcEpoch(),
2130
+ deliverWhisperLocal: (topic, frame, exclude) => this.deliverWhisperLocal(topic, frame, exclude),
2131
+ doName: () => this.state.id?.name,
2132
+ env: () => this.env,
2133
+ getWebSockets: () => this.state.getWebSockets(),
2134
+ maskMetadata: () => this.maskMetadata(),
2135
+ nextPokeId: () => {
2136
+ this.pokeSequence += 1;
2137
+ return `poke-${String(this.pokeSequence)}`;
2138
+ },
2139
+ readAttachment: (ws) => this.readAttachment(ws),
2140
+ recordShapePokeFanout: (iterated, delivered, elapsedMs) => {
2141
+ this.fanout.shapePoke = recordFanoutPass(this.fanout.shapePoke, iterated, delivered, elapsedMs);
2142
+ },
2143
+ resolveShape: (name, args, identity) => this.resolveShape(name, args, identity),
2144
+ rlsMetadata: () => this.rlsMetadata(),
2145
+ shardBinding: () => this.shardBinding,
2146
+ sql: () => this.sql
2147
+ };
2148
+ this.relay = createRelayLink(host);
1247
2149
  this.armWebSocketKeepalive();
1248
2150
  }
1249
2151
  /** SQLite handle scoped to this Durable Object. */
@@ -1253,8 +2155,10 @@ class ShardDO {
1253
2155
  */
1254
2156
  async fetch(request) {
1255
2157
  const url = new URL(request.url);
1256
- if (request.headers.get("Upgrade") === "websocket") {
1257
- return this.handleWebSocketUpgrade(request);
2158
+ this.shardBinding = request.headers.get("x-lunora-shard-binding") ?? this.shardBinding;
2159
+ const early = await this.routeNonRpc(url, request);
2160
+ if (early !== void 0) {
2161
+ return early;
1258
2162
  }
1259
2163
  if (url.pathname !== "/rpc" || request.method !== "POST") {
1260
2164
  return new Response("Not found", { status: 404 });
@@ -1272,6 +2176,10 @@ class ShardDO {
1272
2176
  this.currentResponseBookmark = void 0;
1273
2177
  this.currentRequestUserId = request.headers.get("x-lunora-userid") ?? void 0;
1274
2178
  this.currentRequestMutationId = request.headers.get("x-lunora-mutation-id") ?? void 0;
2179
+ this.currentRequestClientId = request.headers.get("x-lunora-client-id") ?? void 0;
2180
+ this.currentRequestClientSeq = parseClientSeqHeader(request.headers.get("x-lunora-client-seq"));
2181
+ this.currentMutatorClass = void 0;
2182
+ this.mutationBookkeepingCommitted = false;
1275
2183
  this.currentRequestIdentity = parseIdentityHeader(request.headers.get("x-lunora-identity"));
1276
2184
  this.currentRequestIp = request.headers.get("x-lunora-client-ip") ?? void 0;
1277
2185
  this.currentRequestSystem = request.headers.get("x-lunora-system") === "1";
@@ -1287,20 +2195,28 @@ class ShardDO {
1287
2195
  const value = await this.runRelationFanoutRead(payload.functionPath, payload.args ?? {});
1288
2196
  return jsonResponse(value, 200, this.currentResponseBookmark);
1289
2197
  }
2198
+ const mutatorClass = this.isCustomMutator(payload.functionPath) ? this.classifyClientMutation() : void 0;
2199
+ this.currentMutatorClass = mutatorClass;
2200
+ const watermarkShortCircuit = this.rejectNonNextMutation(payload.functionPath, mutatorClass, dispatchStartedAt);
2201
+ if (watermarkShortCircuit !== void 0) {
2202
+ return watermarkShortCircuit;
2203
+ }
1290
2204
  const cached = this.readIdempotentResult(this.currentRequestMutationId);
1291
2205
  if (cached !== void 0) {
1292
- this.recordFunctionCall(payload.functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
1293
- return jsonResponse({ result: cached.value }, 200, this.currentResponseBookmark);
2206
+ return this.respondFromIdempotencyCache(payload.functionPath, dispatchStartedAt, mutatorClass, cached.value);
2207
+ }
2208
+ const result = await this.handleRpc(payload.functionPath, decodeWire(payload.args ?? {}));
2209
+ this.recordPostDispatchBookkeeping(result, mutatorClass);
2210
+ if (mutatorClass?.kind === "next") {
2211
+ this.advanceClientMutationWatermark();
1294
2212
  }
1295
- const result = await this.handleRpc(payload.functionPath, payload.args ?? {});
1296
- this.persistIdempotentResult(result);
1297
2213
  const durationMs = Date.now() - dispatchStartedAt;
1298
2214
  this.recordFunctionCall(payload.functionPath, durationMs, void 0, this.currentScannedTables, this.currentIndexHits);
1299
2215
  this.flushStmtSamples();
1300
2216
  const tablesWritten = [...this.pendingChangedTables ?? []];
1301
2217
  this.recordRequestLog(payload.functionPath, payload.args ?? {}, durationMs, "ok", tablesWritten);
1302
2218
  this.maybeWarnRootSize();
1303
- const response = jsonResponse({ result }, 200, this.currentResponseBookmark);
2219
+ const response = this.buildDispatchResponse(mutatorClass, encodeWire(result));
1304
2220
  await this.flushChangedTables();
1305
2221
  return response;
1306
2222
  } catch (error) {
@@ -1326,6 +2242,10 @@ class ShardDO {
1326
2242
  this.currentResponseBookmark = void 0;
1327
2243
  this.currentRequestUserId = void 0;
1328
2244
  this.currentRequestMutationId = void 0;
2245
+ this.currentRequestClientId = void 0;
2246
+ this.currentRequestClientSeq = void 0;
2247
+ this.currentMutatorClass = void 0;
2248
+ this.mutationBookkeepingCommitted = false;
1329
2249
  this.currentRequestIdentity = void 0;
1330
2250
  this.currentRequestIp = void 0;
1331
2251
  this.currentRequestSystem = false;
@@ -1363,6 +2283,9 @@ class ShardDO {
1363
2283
  if (envelope.context !== void 0) {
1364
2284
  attachment.context = envelope.context;
1365
2285
  }
2286
+ if (envelope.clientId !== void 0) {
2287
+ attachment.clientId = envelope.clientId;
2288
+ }
1366
2289
  attachment.connected = true;
1367
2290
  try {
1368
2291
  ws.serializeAttachment?.(attachment);
@@ -1394,24 +2317,42 @@ class ShardDO {
1394
2317
  }
1395
2318
  return;
1396
2319
  }
2320
+ if (envelope.type === "shape_subscribe" && envelope.shape) {
2321
+ await this.handleShapeSubscribe(ws, envelope.id, {
2322
+ args: envelope.shape.args,
2323
+ name: envelope.shape.name,
2324
+ sinceEpoch: envelope.sinceEpoch,
2325
+ sinceSeq: envelope.sinceCheckpoint
2326
+ });
2327
+ return;
2328
+ }
2329
+ if (envelope.type === "shape_unsubscribe") {
2330
+ this.shapeUnsubscribe(ws, envelope.id);
2331
+ ws.send(JSON.stringify({ id: envelope.id, type: "ack" }));
2332
+ return;
2333
+ }
1397
2334
  if (envelope.type === "stream" && envelope.query?.functionPath) {
1398
2335
  if (envelope.query.functionPath.startsWith(ADMIN_FUNCTION_PREFIX)) {
1399
2336
  ws.send(JSON.stringify({ id: envelope.id, message: "streams must be public", type: "error" }));
1400
2337
  return;
1401
2338
  }
1402
- this.handleStream(ws, envelope.id, envelope.query.functionPath, envelope.query.args ?? {}).catch(() => {
2339
+ this.handleStream(ws, envelope.id, envelope.query.functionPath, decodeWire(envelope.query.args ?? {})).catch(() => {
1403
2340
  });
1404
2341
  return;
1405
2342
  }
1406
2343
  if (envelope.type === "whisper_subscribe" || envelope.type === "whisper_unsubscribe") {
1407
2344
  if (typeof envelope.topic === "string" && envelope.topic.length > 0) {
1408
- this.setWhisperMembership(ws, envelope.topic, envelope.type === "whisper_subscribe");
2345
+ const join = envelope.type === "whisper_subscribe";
2346
+ this.setWhisperMembership(ws, envelope.topic, join);
2347
+ if (join) {
2348
+ await this.relay?.announce();
2349
+ }
1409
2350
  }
1410
2351
  return;
1411
2352
  }
1412
2353
  if (envelope.type === "whisper") {
1413
2354
  if (typeof envelope.topic === "string" && envelope.topic.length > 0) {
1414
- this.broadcastWhisper(ws, envelope.topic, envelope.data);
2355
+ await this.broadcastWhisper(ws, envelope.topic, envelope.data);
1415
2356
  }
1416
2357
  return;
1417
2358
  }
@@ -1444,12 +2385,49 @@ class ShardDO {
1444
2385
  this.streamCancellers.delete(ws);
1445
2386
  }
1446
2387
  this.subMemos.delete(ws);
2388
+ this.shapeMemos.delete(ws);
2389
+ this.globalShapeSnapshots.delete(ws);
2390
+ if (attachment.connectionId !== void 0) {
2391
+ try {
2392
+ deleteGlobalShapeSnapshotsForConnection(this.sql, attachment.connectionId);
2393
+ } catch {
2394
+ }
2395
+ }
1447
2396
  ws.serializeAttachment?.(void 0);
2397
+ await this.relay?.announceDrain(ws);
1448
2398
  }
1449
2399
  /** Hibernation API: invoked on socket error. */
1450
2400
  // 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
1451
2401
  webSocketError(_ws, _error) {
1452
2402
  }
2403
+ /**
2404
+ * Durable Object alarm handler — the heartbeat for `.global()`-table shapes.
2405
+ * The runtime wakes this when the poll alarm armed by `scheduleGlobalPoll`
2406
+ * fires; it refreshes every subscribed global shape (diff-poke from the global
2407
+ * backend) and re-arms while any remain. With no global subscribers left, the
2408
+ * alarm is not re-armed and the DO goes idle. A base-only / global-free DO
2409
+ * never arms it, so this stays dormant there.
2410
+ */
2411
+ async alarm() {
2412
+ this.globalPollScheduled = false;
2413
+ let remaining;
2414
+ try {
2415
+ remaining = await this.pollGlobalShapes();
2416
+ } catch (error) {
2417
+ this.recordShapeError("shape:poll", error);
2418
+ remaining = 1;
2419
+ }
2420
+ try {
2421
+ remaining += await this.pollExternalSources();
2422
+ } catch (error) {
2423
+ this.recordShapeError("source:poll", error);
2424
+ remaining += 1;
2425
+ }
2426
+ await this.flushChangedTables();
2427
+ if (remaining > 0) {
2428
+ await this.scheduleGlobalPoll();
2429
+ }
2430
+ }
1453
2431
  /**
1454
2432
  * The registered function paths to dispatch when a socket connects/disconnects.
1455
2433
  * Base default is empty; the codegen subclass overrides it to return the
@@ -1648,20 +2626,14 @@ class ShardDO {
1648
2626
  status: 500
1649
2627
  });
1650
2628
  }
1651
- const sqlExec = sqlHandle.exec.bind(sqlHandle);
2629
+ const transactionalStorage = this.state.storage;
1652
2630
  const run = async () => {
1653
2631
  this.transactionDepth = 1;
1654
- sqlExec("BEGIN");
1655
2632
  try {
1656
- const value = await handler();
1657
- sqlExec("COMMIT");
1658
- return value;
1659
- } catch (error) {
1660
- try {
1661
- sqlExec("ROLLBACK");
1662
- } catch {
2633
+ if (typeof transactionalStorage?.transaction === "function") {
2634
+ return await transactionalStorage.transaction(async () => handler());
1663
2635
  }
1664
- throw error;
2636
+ return await handler();
1665
2637
  } finally {
1666
2638
  this.transactionDepth = 0;
1667
2639
  }
@@ -1859,7 +2831,58 @@ class ShardDO {
1859
2831
  */
1860
2832
  // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the statically-discovered feature flags
1861
2833
  studioFeatures() {
1862
- return { mail: false, payments: false, scheduler: false, storage: false, vectors: false, workflows: false };
2834
+ return {
2835
+ analytics: false,
2836
+ auth: false,
2837
+ containers: false,
2838
+ flags: false,
2839
+ kv: false,
2840
+ mail: false,
2841
+ payments: false,
2842
+ queues: false,
2843
+ scheduler: false,
2844
+ storage: false,
2845
+ vectors: false,
2846
+ workflows: false
2847
+ };
2848
+ }
2849
+ /**
2850
+ * Evaluate every statically-discovered feature flag under `context` for the
2851
+ * studio's read-only Flags page (`__lunora_admin__:listFlags`). The flag keys
2852
+ * + value types are discovered by `@lunora/codegen` from the app's
2853
+ * `ctx.flags.&lt;type>("key", …)` reads and evaluated through the configured
2854
+ * `@lunora/flags` provider — work only the codegen subclass can do, so it
2855
+ * overrides this. The base class wires no provider and reports
2856
+ * `configured: false` with zero flags (an un-generated `ShardDO` has none).
2857
+ */
2858
+ // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with live OpenFeature evaluation over the discovered flag keys
2859
+ evaluateFlags(_context) {
2860
+ return Promise.resolve({ configured: false, flags: [] });
2861
+ }
2862
+ /**
2863
+ * Serve one reserved {@link FLAGS_FUNCTION_PREFIX} live flag read for the
2864
+ * React client's `useFlag`/`useFlags`. `functionPath` carries the flag key +
2865
+ * type and `args` the per-subscriber targeting context; the codegen subclass
2866
+ * overrides this to evaluate the flag through the app's `@lunora/flags`
2867
+ * provider under `identity` and return the resolved value. The base class
2868
+ * wires no provider, so it returns `null` — `resolveReactiveOutcome` reads
2869
+ * `null` as "nothing to deliver" and the subscriber keeps its default.
2870
+ */
2871
+ // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this to evaluate the flag through the configured provider
2872
+ runFlagSubscriptionRead(_functionPath, _arguments, _identity) {
2873
+ return Promise.resolve(null);
2874
+ }
2875
+ /**
2876
+ * The Cloudflare Queues declared by this app, surfaced via
2877
+ * `__lunora_admin__:listQueues` for the studio's Queues page. Queues are NOT
2878
+ * Durable Objects and hold no shard state, so this is pure declaration
2879
+ * metadata statically discovered by `@lunora/codegen` from `lunora/queues.ts`
2880
+ * and emitted into the generated subclass, which overrides this. The base
2881
+ * class can't see the user's project, so it reports none.
2882
+ */
2883
+ // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the statically-discovered queue metadata
2884
+ queuesMetadata() {
2885
+ return { queues: [] };
1863
2886
  }
1864
2887
  /**
1865
2888
  * The Cloudflare Workflows declared by this app, surfaced via
@@ -2124,13 +3147,15 @@ class ShardDO {
2124
3147
  * unless the request carried an `x-lunora-mutation-id` header (queries and
2125
3148
  * legacy clients leave `currentRequestMutationId` undefined).
2126
3149
  *
2127
- * Called on the live dispatch path right after the handler's writes have
2128
- * auto-committed, through the same `this.sql` handle, so the dedup row is
2129
- * durable iff those writes are. (The DO has no ambient BEGIN/COMMIT around a
2130
- * mutation `handleRpc` invokes the user handler directly so this can't
2131
- * piggyback on a surrounding transaction; it commits as its own statement
2132
- * immediately after.) `INSERT OR IGNORE` keeps a concurrent double-dispatch
2133
- * of the same id idempotent. Also runs the throttled dedup-table GC.
3150
+ * For a mutation this runs INSIDE the handler's transaction (via
3151
+ * {@link ShardDO.commitMutationBookkeeping}, which `handleRpc` invokes before
3152
+ * the transaction commits), so the dedup row is durable iff the writes are —
3153
+ * closing the crash window where the writes commit but the replay guard does
3154
+ * not. Actions/queries aren't transaction-wrapped, so they call this on the
3155
+ * live dispatch path right after the handler resolves, through the same
3156
+ * `this.sql` handle. `INSERT OR IGNORE` keeps a concurrent double-dispatch (or
3157
+ * the now-skipped post-dispatch call) of the same id idempotent. Also runs the
3158
+ * throttled dedup-table GC.
2134
3159
  */
2135
3160
  persistIdempotentResult(result) {
2136
3161
  if (this.currentRequestMutationId === void 0) {
@@ -2138,7 +3163,7 @@ class ShardDO {
2138
3163
  }
2139
3164
  const now = Date.now();
2140
3165
  try {
2141
- writeIdempotent(this.sql, this.currentRequestUserId ?? "", this.currentRequestMutationId, JSON.stringify(result) ?? "null", now);
3166
+ writeIdempotent(this.sql, this.currentRequestUserId ?? "", this.currentRequestMutationId, JSON.stringify(encodeWire(result)), now);
2142
3167
  if (now - this.lastIdempotencyTrimAt > IDEMPOTENCY_GC_INTERVAL_MS) {
2143
3168
  trimIdempotent(this.sql, now - IDEMPOTENCY_RETENTION_MS);
2144
3169
  this.lastIdempotencyTrimAt = now;
@@ -2146,6 +3171,191 @@ class ShardDO {
2146
3171
  } catch {
2147
3172
  }
2148
3173
  }
3174
+ /**
3175
+ * Whether `functionPath` names a registered custom mutator (a `defineMutator`
3176
+ * declaration) rather than an ordinary `mutation`. The base class knows of no
3177
+ * mutators, so the default is `false`; the codegen-generated subclass
3178
+ * overrides this to consult its mutator registry. When `true` (and the push
3179
+ * carries a `clientId`/`clientSeq`), the dispatch path applies the
3180
+ * `__client_watermark` ordering semantics instead of the legacy idempotency
3181
+ * dedup.
3182
+ */
3183
+ // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this to consult its mutator registry
3184
+ isCustomMutator(_functionPath) {
3185
+ return false;
3186
+ }
3187
+ /**
3188
+ * Classify an in-flight custom-mutator push against the shard's stored
3189
+ * high-watermark for `currentRequestClientId`. The watermark is the highest
3190
+ * per-client sequence the DO has applied, so the push is exactly one of:
3191
+ *
3192
+ * - `"already"` — `seq &lt;= watermark`: a replay of a confirmed (or in-flight,
3193
+ * now-resent) mutation. The handler must NOT re-run; the dispatch path returns
3194
+ * a benign ack so the client drops the pending overlay.
3195
+ * - `"next"` — `seq == watermark + 1`: the next mutation in order. Run the
3196
+ * authoritative `server` impl and advance the watermark in the same commit.
3197
+ * - `"gap"` — `seq > watermark + 1`: an out-of-order arrival (an earlier push
3198
+ * was lost). Halt: the client must resend from `watermark + 1`.
3199
+ *
3200
+ * Returns `undefined` when the push is not a watermarked custom mutator
3201
+ * (missing client id/seq, or a stub `sql` handle without the table) so the
3202
+ * caller falls through to the legacy idempotency path.
3203
+ */
3204
+ classifyClientMutation() {
3205
+ const clientId = this.currentRequestClientId;
3206
+ const seq = this.currentRequestClientSeq;
3207
+ if (clientId === void 0 || seq === void 0) {
3208
+ return void 0;
3209
+ }
3210
+ const identity = this.currentRequestUserId ?? "";
3211
+ let watermark;
3212
+ try {
3213
+ watermark = readClientWatermark(this.sql, identity, clientId);
3214
+ } catch {
3215
+ try {
3216
+ migrateClientWatermark(this.sql);
3217
+ watermark = readClientWatermark(this.sql, identity, clientId);
3218
+ } catch {
3219
+ return void 0;
3220
+ }
3221
+ }
3222
+ const expected = watermark + 1;
3223
+ if (seq <= watermark) {
3224
+ return { expected, kind: "already" };
3225
+ }
3226
+ return seq === expected ? { expected, kind: "next" } : { expected, kind: "gap" };
3227
+ }
3228
+ /**
3229
+ * Terminal response for a watermarked custom-mutator push that is NOT the
3230
+ * next-in-order mutation — an idempotent replay ack (`"already"`) or an
3231
+ * out-of-order halt (`"gap"`). Returns `undefined` for an ordinary mutation
3232
+ * or a `"next"` push so `fetch` proceeds to the authoritative handler. Records
3233
+ * the function call on the short-circuit paths so metrics stay attributed.
3234
+ */
3235
+ rejectNonNextMutation(functionPath, mutatorClass, dispatchStartedAt) {
3236
+ if (mutatorClass === void 0 || mutatorClass.kind === "next") {
3237
+ return void 0;
3238
+ }
3239
+ this.recordFunctionCall(functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
3240
+ if (mutatorClass.kind === "already") {
3241
+ return jsonResponse({ lastMutationId: mutatorClass.expected - 1, result: null }, 200, this.currentResponseBookmark);
3242
+ }
3243
+ return jsonResponse(
3244
+ {
3245
+ error: {
3246
+ code: "OUT_OF_ORDER",
3247
+ expectedMutationId: mutatorClass.expected,
3248
+ message: `out-of-order mutation; expected sequence ${String(mutatorClass.expected)}`
3249
+ }
3250
+ },
3251
+ 409,
3252
+ this.currentResponseBookmark
3253
+ );
3254
+ }
3255
+ /**
3256
+ * Respond to a dispatch that hit the `(identity, mutationId)` idempotency
3257
+ * cache. Records the (zero-work) function call, then: for a `"next"` custom
3258
+ * mutator whose handler already committed but whose watermark advance was
3259
+ * lost to a crash in between, re-advance and echo `lastMutationId` exactly as
3260
+ * the post-commit path does (otherwise the cached branch returns a bare
3261
+ * result with a stale watermark and the client reports every later seq as a
3262
+ * gap forever); for everything else, return the bare cached `{ result }`.
3263
+ */
3264
+ respondFromIdempotencyCache(functionPath, dispatchStartedAt, mutatorClass, cachedValue) {
3265
+ this.recordFunctionCall(functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
3266
+ if (mutatorClass?.kind === "next") {
3267
+ this.advanceClientMutationWatermark();
3268
+ return this.buildDispatchResponse(mutatorClass, cachedValue);
3269
+ }
3270
+ const commitCursor = this.mutationCommitCursor();
3271
+ return jsonResponse(commitCursor === void 0 ? { result: cachedValue } : { commitCursor, result: cachedValue }, 200, this.currentResponseBookmark);
3272
+ }
3273
+ /**
3274
+ * The CDC cursor a just-committed plain mutation landed at — the post-write
3275
+ * high-watermark, on the same scale as the `cursor` on `data`/`delta` frames.
3276
+ * The client drops a pending per-call optimistic overlay once it sees a frame
3277
+ * with `cursor >= commitCursor` (gapless reconciliation that keeps mutations
3278
+ * concurrent, without the serialized custom-mutator watermark). Scoped to a
3279
+ * mutation (carries an `x-lunora-mutation-id`) on a CDC-enabled shard;
3280
+ * `undefined` otherwise, leaving the wire byte-identical for queries/actions
3281
+ * and CDC-off shards.
3282
+ */
3283
+ mutationCommitCursor() {
3284
+ return this.currentRequestMutationId === void 0 ? void 0 : this.currentCdcCursor();
3285
+ }
3286
+ /**
3287
+ * Build the success response for a dispatched RPC. A `"next"` custom-mutator
3288
+ * push echoes the applied `lastMutationId` so the client drops the pending
3289
+ * optimistic overlay as soon as the ack lands; a plain mutation echoes
3290
+ * `commitCursor` (see {@link mutationCommitCursor}); other calls return the
3291
+ * bare `{ result }` envelope unchanged.
3292
+ */
3293
+ buildDispatchResponse(mutatorClass, result) {
3294
+ if (mutatorClass?.kind === "next") {
3295
+ return jsonResponse({ lastMutationId: this.currentRequestClientSeq, result }, 200, this.currentResponseBookmark);
3296
+ }
3297
+ const commitCursor = this.mutationCommitCursor();
3298
+ return jsonResponse(commitCursor === void 0 ? { result } : { commitCursor, result }, 200, this.currentResponseBookmark);
3299
+ }
3300
+ /**
3301
+ * Commit a mutation's replay bookkeeping — the `(identity, mutationId)`
3302
+ * idempotency dedup row and, for a `"next"` custom-mutator push, the
3303
+ * `__client_watermark` advance — INSIDE the handler's transaction. Called by
3304
+ * the generated `handleRpc` mutation branch after the user handler resolves
3305
+ * but before the transaction commits, so the writes, the dedup row, and the
3306
+ * watermark land in one atomic commit: a crash can't leave the writes durable
3307
+ * without the replay guard (which a re-dispatch would otherwise re-run) nor
3308
+ * without the watermark. Sets {@link ShardDO.mutationBookkeepingCommitted} so
3309
+ * `fetch` skips the redundant post-dispatch persist.
3310
+ */
3311
+ commitMutationBookkeeping(result) {
3312
+ this.persistIdempotentResult(result);
3313
+ if (this.currentMutatorClass?.kind === "next") {
3314
+ this.advanceClientMutationWatermark({ strict: true });
3315
+ }
3316
+ this.mutationBookkeepingCommitted = true;
3317
+ }
3318
+ /**
3319
+ * Best-effort replay bookkeeping for the live dispatch path, run after
3320
+ * `handleRpc` returns. A generated mutation already committed it atomically
3321
+ * inside its transaction (via {@link ShardDO.commitMutationBookkeeping}, which
3322
+ * sets the flag), so this skips. Actions/queries aren't transaction-wrapped,
3323
+ * so they record their dedup row here (a no-op without an `x-lunora-mutation-id`),
3324
+ * and a `"next"` push advances its watermark (the gap self-heals on replay).
3325
+ */
3326
+ recordPostDispatchBookkeeping(result, mutatorClass) {
3327
+ if (this.mutationBookkeepingCommitted) {
3328
+ return;
3329
+ }
3330
+ this.persistIdempotentResult(result);
3331
+ if (mutatorClass?.kind === "next") {
3332
+ this.advanceClientMutationWatermark();
3333
+ }
3334
+ }
3335
+ /**
3336
+ * Advance the stored high-watermark for the in-flight custom mutator to
3337
+ * `currentRequestClientSeq` through the same `this.sql` handle. On the
3338
+ * transactional path ({@link ShardDO.commitMutationBookkeeping}, `strict`) it
3339
+ * runs inside the handler's commit, so the watermark is durable iff the writes
3340
+ * are; a failure rethrows to roll the mutation back. On the best-effort
3341
+ * cache-hit recovery path (`strict` omitted) a missing table is swallowed —
3342
+ * the replay re-runs and re-advances (the read side treats a missing row as
3343
+ * watermark 0), so the gap self-heals.
3344
+ */
3345
+ advanceClientMutationWatermark(options) {
3346
+ const clientId = this.currentRequestClientId;
3347
+ const seq = this.currentRequestClientSeq;
3348
+ if (clientId === void 0 || seq === void 0) {
3349
+ return;
3350
+ }
3351
+ try {
3352
+ advanceClientWatermark(this.sql, this.currentRequestUserId ?? "", clientId, seq);
3353
+ } catch (error) {
3354
+ if (options?.strict) {
3355
+ throw error;
3356
+ }
3357
+ }
3358
+ }
2149
3359
  /**
2150
3360
  * Replay a batch of CDC changes into this shard (point-in-time recovery).
2151
3361
  * Schema-aware — it builds a `createShardCtxDb` writer — so the base class
@@ -2195,6 +3405,56 @@ class ShardDO {
2195
3405
  }
2196
3406
  this.subMemos.get(ws)?.delete(subId);
2197
3407
  }
3408
+ /**
3409
+ * Register a live shape subscription on a socket — the partial-replication
3410
+ * parallel to {@link ShardDO.subscribe}. Stores the descriptor in the
3411
+ * attachment's `shapes` registry (created lazily) so it survives
3412
+ * hibernation, sharing the per-socket cap with `subs`. Returns a status the
3413
+ * caller surfaces as a structured error frame; never throws (a thrown
3414
+ * `webSocketMessage` is a fatal-channel error under the hibernation API).
3415
+ */
3416
+ shapeSubscribe(ws, subId, shape) {
3417
+ const attachment = this.readAttachment(ws);
3418
+ const shapes = attachment.shapes ?? {};
3419
+ if (Object.keys(attachment.subs).length + Object.keys(shapes).length >= ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET) {
3420
+ return "too_many";
3421
+ }
3422
+ shapes[subId] = shape;
3423
+ attachment.shapes = shapes;
3424
+ try {
3425
+ ws.serializeAttachment?.(attachment);
3426
+ } catch {
3427
+ delete attachment.shapes[subId];
3428
+ return "serialize_failed";
3429
+ }
3430
+ return "ok";
3431
+ }
3432
+ /** Remove a shape subscription and its poke baseline. Mirrors {@link ShardDO.unsubscribe}'s rollback-on-serialize-failure contract. */
3433
+ shapeUnsubscribe(ws, subId) {
3434
+ const attachment = this.readAttachment(ws);
3435
+ const { shapes } = attachment;
3436
+ if (!shapes) {
3437
+ return;
3438
+ }
3439
+ const captured = shapes[subId];
3440
+ delete shapes[subId];
3441
+ try {
3442
+ ws.serializeAttachment?.(attachment);
3443
+ } catch {
3444
+ if (captured !== void 0) {
3445
+ shapes[subId] = captured;
3446
+ }
3447
+ return;
3448
+ }
3449
+ this.shapeMemos.get(ws)?.delete(subId);
3450
+ this.globalShapeSnapshots.get(ws)?.delete(subId);
3451
+ if (attachment.connectionId !== void 0) {
3452
+ try {
3453
+ deleteGlobalShapeSnapshot(this.sql, attachment.connectionId, subId);
3454
+ } catch {
3455
+ }
3456
+ }
3457
+ }
2198
3458
  /**
2199
3459
  * Decide whether a single subscription is interested in a mutation
2200
3460
  * delta. The default implementation checks the table name, then runs a
@@ -2243,10 +3503,7 @@ class ShardDO {
2243
3503
  if (!this.matchesSubscription(query, delta)) {
2244
3504
  continue;
2245
3505
  }
2246
- try {
2247
- ws.send(`{"type":"delta","id":${JSON.stringify(subId)},"delta":${deltaJson}}`);
2248
- } catch {
2249
- }
3506
+ trySendFrame(ws, `{"type":"delta","id":${JSON.stringify(subId)},"delta":${deltaJson}}`);
2250
3507
  }
2251
3508
  }
2252
3509
  }
@@ -2269,6 +3526,91 @@ class ShardDO {
2269
3526
  executeSubscription(_functionPath, _args, _identity) {
2270
3527
  return Promise.resolve(null);
2271
3528
  }
3529
+ /**
3530
+ * Resolve a named shape to its concrete query plan for `identity`. The base
3531
+ * class has no shape registry, so it returns `undefined` — partial
3532
+ * replication is disabled and a `shape_subscribe` is rejected. The
3533
+ * codegen-generated subclass overrides this to look the shape up in the
3534
+ * project's `defineShape` registry, evaluate its `where(ctx, args)` under the
3535
+ * subscriber's verified identity, and AND-compose it with the table's RLS
3536
+ * read base-where into {@link ResolvedShape.effectiveWhere}.
3537
+ *
3538
+ * `identity` is the socket's OWN verified identity (the same unforgeable
3539
+ * value `refreshSubscriptions` threads), passed by value so this never reads
3540
+ * the mutable per-request identity fields. Returning `undefined` is the
3541
+ * fail-closed signal — an unknown shape, or an RLS-required table with no
3542
+ * policy resolving for this identity, yields no subscription rather than
3543
+ * leaking rows.
3544
+ */
3545
+ // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to dispatch via the generated shape registry
3546
+ resolveShape(_name, _args, _identity) {
3547
+ return void 0;
3548
+ }
3549
+ /**
3550
+ * The RLS-uniform gate (plan 075 Phase 3): whether a reactive shape may be
3551
+ * relay-multicast — i.e. one delta is correct for **every** subscriber. The owner
3552
+ * decides it (see {@link OwnerRelay.isShapeRelayUniform} — a static RLS read-policy
3553
+ * guard plus claim-exhaustive `Proxy` probes, fail-closed); this thin delegation
3554
+ * is the seam the gate test exercises. A non-owner DO is never relay-uniform.
3555
+ */
3556
+ isShapeRelayUniform(name, args) {
3557
+ return this.relay?.isShapeRelayUniform(name, args) ?? false;
3558
+ }
3559
+ /**
3560
+ * Read the FULL current membership of a `.global()`-table shape from its D1
3561
+ * (or Hyperdrive) backend — the seed/poll source for the latency-tiered
3562
+ * global shape path. A `.global()` table lives in another store with no
3563
+ * per-DO op-log, so this is the only way to learn its rows from inside the
3564
+ * shard DO; {@link ShardDO.seedGlobalShape} calls it once on subscribe and
3565
+ * {@link ShardDO.refreshGlobalShape} on every alarm tick, diffing the result
3566
+ * against the per-socket snapshot to compute the poke.
3567
+ *
3568
+ * The base class has no global backend, so it returns `[]` (a base-only DO,
3569
+ * or a project with no global tables, never resolves a global shape). The
3570
+ * codegen subclass overrides it to drain `globalDb.findMany(table, { where:
3571
+ * effectiveWhere })` under the socket's verified `identity` — the same
3572
+ * unforgeable value `resolveShape` composed the RLS predicate with, so the
3573
+ * D1 read is identity-scoped exactly like the poke-live path.
3574
+ */
3575
+ // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this to read the global (D1) backend; the base has none
3576
+ readGlobalShapeRows(_resolved, _identity) {
3577
+ return Promise.resolve([]);
3578
+ }
3579
+ /**
3580
+ * Poll external-source (`.source(...)`) tables once (plan 077): materialize
3581
+ * each sourced table's freshly-pulled tenant slice into this DO's SQLite. The
3582
+ * base `ShardDO` has no sourced tables, so it returns `0` and the ingest tier
3583
+ * stays dormant — zero behavior change for every existing DO. The codegen
3584
+ * subclass overrides it to, per sourced table, build a `createShardCtxDb`
3585
+ * writer, read the tenant slice from Hyperdrive under this DO's shard key, and
3586
+ * run `runExternalSourceTick` (read local baseline → diff → apply via the
3587
+ * validated CDC writer). Returns the number of sourced tables still being
3588
+ * polled, so the shared poll alarm ({@link ShardDO.alarm}) re-arms while ingest
3589
+ * is active.
3590
+ */
3591
+ // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass implements the real Hyperdrive-backed poll
3592
+ pollExternalSources() {
3593
+ return Promise.resolve(0);
3594
+ }
3595
+ /**
3596
+ * Arm the shared poll alarm for external-source ingest (plan 077). The alarm is
3597
+ * shared with the global-shape poll tier; the codegen subclass calls this once
3598
+ * (on construction / first sourced write) so a sourced DO starts its ingest
3599
+ * loop, after which {@link ShardDO.alarm} re-arms itself while
3600
+ * {@link ShardDO.pollExternalSources} reports remaining work. Idempotent; a
3601
+ * no-op when the runtime exposes no `setAlarm` (unit harness).
3602
+ */
3603
+ scheduleSourcePoll() {
3604
+ return this.scheduleGlobalPoll();
3605
+ }
3606
+ /** This DO's shard key (its DO name), or `__root__` for the single-DO default. The `tenantBy` mapper binds it into the source query. */
3607
+ currentShardKey() {
3608
+ return this.state.id?.name ?? ROOT_SHARD_NAME;
3609
+ }
3610
+ /** Record a contained external-source ingest failure (one sourced table's poll) into the log ring without aborting the others. */
3611
+ recordExternalSourceError(table, error) {
3612
+ this.recordShapeError(`source:${table}`, error);
3613
+ }
2272
3614
  /**
2273
3615
  * Look up a streaming-query function and return a thunk that produces the
2274
3616
  * `AsyncIterable&lt;unknown>` when handed an {@link AbortSignal}. The codegen
@@ -2671,16 +4013,79 @@ class ShardDO {
2671
4013
  return jsonResponse({ error: { code: error.code, message: error.message } }, error.status);
2672
4014
  }
2673
4015
  if (error && typeof error === "object" && error.name === "ValidationError") {
2674
- const message2 = error instanceof Error ? error.message : "validation failed";
2675
- return jsonResponse({ error: { code: "VALIDATION_ERROR", message: message2 } }, 400);
4016
+ const message = error instanceof Error ? error.message : "validation failed";
4017
+ return jsonResponse({ error: { code: "VALIDATION_ERROR", message } }, 400);
2676
4018
  }
2677
4019
  if (error && typeof error === "object" && error.name === "LunoraError") {
2678
4020
  const lunoraError = error;
2679
4021
  const status = typeof lunoraError.status === "number" ? lunoraError.status : 500;
2680
- return jsonResponse({ error: { code: lunoraError.code ?? "INTERNAL", message: lunoraError.message ?? "internal error" } }, status);
4022
+ const body = {
4023
+ code: lunoraError.code ?? "INTERNAL",
4024
+ message: lunoraError.message ?? "internal error"
4025
+ };
4026
+ if (lunoraError.data !== void 0) {
4027
+ body.data = encodeWire(lunoraError.data);
4028
+ }
4029
+ return jsonResponse({ error: body }, status);
4030
+ }
4031
+ console.error("[@lunora/do] unhandled RPC error:", error);
4032
+ return jsonResponse({ error: { code: "RPC_FAILED", message: "internal error" } }, 500);
4033
+ }
4034
+ /**
4035
+ * Batch dispatch (plan 088). Applies each `calls[]` entry through the SAME
4036
+ * single-call `/rpc` path (via a nested `this.fetch`), **sequentially**, so
4037
+ * the per-`(identity, mutationId)` idempotency dedup and the per-client
4038
+ * `__client_watermark` ordering are enforced entry-by-entry exactly as for an
4039
+ * individual call — no duplication of the dispatch core, no reordering.
4040
+ *
4041
+ * Failures are **per-slot, not fail-fast**: an entry that throws (or a
4042
+ * custom-mutator `OUT_OF_ORDER` gap) is captured in its own result slot and
4043
+ * later entries still run. Ordering is still safe — a later same-client
4044
+ * mutator after a gap re-classifies as a gap too (the watermark never
4045
+ * advanced), so it cannot apply out of order; unrelated entries/queries are
4046
+ * independent. The response is `{ results: [{ id, status, body }] }` in
4047
+ * request order; each `body` is the untouched single-call envelope (its
4048
+ * `result` already wire-encoded), so the client demuxes + decodes each
4049
+ * exactly as one call.
4050
+ */
4051
+ async handleBatchRpc(request) {
4052
+ let payload;
4053
+ try {
4054
+ payload = await request.json();
4055
+ } catch {
4056
+ return jsonResponse({ error: { code: "BAD_REQUEST", message: "invalid JSON body" } }, 400);
4057
+ }
4058
+ if (!Array.isArray(payload.calls)) {
4059
+ return jsonResponse({ error: { code: "BAD_REQUEST", message: "batch `calls` must be an array" } }, 400);
4060
+ }
4061
+ if (payload.calls.length > MAX_BATCH_ENTRIES) {
4062
+ return jsonResponse({ error: { code: "BAD_REQUEST", message: `batch exceeds the ${String(MAX_BATCH_ENTRIES)}-call limit` } }, 400);
4063
+ }
4064
+ const results = [];
4065
+ let latestBookmark;
4066
+ for (const raw of payload.calls) {
4067
+ const outcome = await this.dispatchBatchEntry(request, raw);
4068
+ if (outcome.bookmark !== void 0) {
4069
+ latestBookmark = outcome.bookmark;
4070
+ }
4071
+ results.push({ body: outcome.body, id: outcome.id, status: outcome.status });
4072
+ }
4073
+ return jsonResponse({ results }, 200, latestBookmark);
4074
+ }
4075
+ /** Dispatch one batch entry through the single-call `/rpc` path and capture its envelope (plan 088). */
4076
+ async dispatchBatchEntry(batchRequest, entry) {
4077
+ try {
4078
+ const response = await this.fetch(buildBatchEntryRequest(batchRequest, entry));
4079
+ return { body: await response.json(), bookmark: response.headers.get("x-d1-bookmark") ?? void 0, id: entry.id, status: response.status };
4080
+ } catch (error) {
4081
+ const message = error instanceof Error ? error.message : String(error);
4082
+ return {
4083
+ body: { error: { code: "BATCH_ENTRY_FAILED", message } },
4084
+ bookmark: void 0,
4085
+ id: entry?.id,
4086
+ status: 500
4087
+ };
2681
4088
  }
2682
- const message = error instanceof Error ? error.message : "unknown error";
2683
- return jsonResponse({ error: { code: "RPC_FAILED", message } }, 500);
2684
4089
  }
2685
4090
  /**
2686
4091
  * Serve a reserved admin-introspection RPC (`__lunora_admin__:*`) for the
@@ -2802,6 +4207,9 @@ class ShardDO {
2802
4207
  if (functionPath === ADMIN_FUNCTIONS.getWorkflowInstanceStatus) {
2803
4208
  return this.handleGetWorkflowInstanceStatus(args);
2804
4209
  }
4210
+ if (functionPath === ADMIN_FUNCTIONS.listFlags) {
4211
+ return this.handleListFlags(args);
4212
+ }
2805
4213
  return this.handlePitrAdminOp(functionPath, args);
2806
4214
  }
2807
4215
  /**
@@ -2922,6 +4330,21 @@ class ShardDO {
2922
4330
  return jsonResponse({ result }, 200);
2923
4331
  }
2924
4332
  /* eslint-enable no-secrets/no-secrets */
4333
+ /**
4334
+ * Serve `__lunora_admin__:listFlags` — the studio's read-only Flags page.
4335
+ * Evaluates every statically-discovered feature flag under an optional
4336
+ * `args.context` targeting context (the studio's editable context editor)
4337
+ * via the {@link evaluateFlags} hook, which the codegen subclass overrides
4338
+ * with live OpenFeature evaluation. Read-only: a flag lookup mutates no shard
4339
+ * state, so nothing is flushed or audited. Admin-gated by `handleAdminRpc`'s
4340
+ * caller.
4341
+ */
4342
+ async handleListFlags(args) {
4343
+ const rawContext = args.context;
4344
+ const context = typeof rawContext === "object" && rawContext !== null && !Array.isArray(rawContext) ? rawContext : void 0;
4345
+ const result = await this.evaluateFlags(context);
4346
+ return jsonResponse({ result }, 200);
4347
+ }
2925
4348
  /**
2926
4349
  * Run `run()` with the per-request identity pinned to (`userId`, `identity`),
2927
4350
  * then restore the prior values in a `finally` (even if `run()` throws), so the
@@ -3274,6 +4697,9 @@ class ShardDO {
3274
4697
  if (functionPath === ADMIN_FUNCTIONS.listSubscriptions) {
3275
4698
  return this.collectSubscriptions();
3276
4699
  }
4700
+ if (functionPath === ADMIN_FUNCTIONS.getFanoutMetrics) {
4701
+ return this.collectFanoutMetrics();
4702
+ }
3277
4703
  if (functionPath === ADMIN_FUNCTIONS.getLogs) {
3278
4704
  return { entries: this.logs.entries() };
3279
4705
  }
@@ -3301,6 +4727,9 @@ class ShardDO {
3301
4727
  if (functionPath === ADMIN_FUNCTIONS.listWorkflows) {
3302
4728
  return this.workflowsMetadata();
3303
4729
  }
4730
+ if (functionPath === ADMIN_FUNCTIONS.listQueues) {
4731
+ return this.queuesMetadata();
4732
+ }
3304
4733
  return void 0;
3305
4734
  }
3306
4735
  /**
@@ -3313,6 +4742,32 @@ class ShardDO {
3313
4742
  collectSubscriptions() {
3314
4743
  return summarizeSubscriptions(this.state.getWebSockets().map((ws) => this.readAttachment(ws)));
3315
4744
  }
4745
+ /**
4746
+ * Assemble the `__lunora_admin__:getFanoutMetrics` payload for the Studio
4747
+ * fan-out observability panel (plan 075 Phase 1). The point-in-time topic
4748
+ * subscriber counts are folded live from each socket's attachment via
4749
+ * {@link summarizeFanoutTopics}; the running per-path cost counters are the
4750
+ * in-memory {@link ShardDO.fanout} tallies, sharing `metrics.sinceMs` as the
4751
+ * "since this instance woke" epoch. Touches no SQLite and mutates no socket
4752
+ * state; it does call `relay.relayCount()`, which advances the promotion
4753
+ * latch — safe here because that transition is a pure, monotonic function of
4754
+ * the live socket count (DOs are single-threaded), so a metrics poll only ever
4755
+ * drives the latch to the same state the routing path would compute for the
4756
+ * same count, never a divergent one.
4757
+ */
4758
+ collectFanoutMetrics() {
4759
+ const summary = summarizeFanoutTopics(this.state.getWebSockets().map((ws) => this.readAttachment(ws)));
4760
+ const relayCount = this.relay?.relayCount() ?? 0;
4761
+ return {
4762
+ ...summary,
4763
+ maxRelays: this.relay?.maxRelays() ?? DEFAULT_MAX_RELAYS,
4764
+ promoted: relayCount > 0,
4765
+ relayCount,
4766
+ shapePoke: this.fanout.shapePoke,
4767
+ sinceMs: this.metrics.sinceMs,
4768
+ whisper: this.fanout.whisper
4769
+ };
4770
+ }
3316
4771
  /** Resolve a `getAuditLog` admin read, parsing the optional `limit`/`sinceSeq` cursor args and ensuring the reserved table first. */
3317
4772
  // eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers and future per-instance state
3318
4773
  readAdminAuditLog(sql, args) {
@@ -3416,6 +4871,7 @@ class ShardDO {
3416
4871
  orderBy: parseTablePageOrderBy(args["orderBy"]),
3417
4872
  refs: this.tableRefs(table),
3418
4873
  search: typeof args["search"] === "string" ? args["search"] : void 0,
4874
+ skipCount: typeof args["skipCount"] === "boolean" ? args["skipCount"] : void 0,
3419
4875
  table
3420
4876
  });
3421
4877
  return { result: page, tables: /* @__PURE__ */ new Set([table === "" ? ADMIN_WILDCARD : table]) };
@@ -3460,6 +4916,73 @@ class ShardDO {
3460
4916
  const read = this.readAdminOp(functionPath, args);
3461
4917
  return read ? { result: read.result, tables: read.tables } : null;
3462
4918
  }
4919
+ /**
4920
+ * Resolve one subscription (seed or refresh) to its {@link SubscriptionOutcome}
4921
+ * by routing the `functionPath` to the right read path — shared by
4922
+ * {@link seedSubscription} and {@link refreshSubscriptions} so both branch
4923
+ * identically:
4924
+ * - `__lunora_admin__:*` → {@link executeAdminSubscription} (raw SQLite read).
4925
+ * - {@link FLAGS_FUNCTION_PREFIX} → {@link runFlagSubscriptionRead} (the codegen subclass evaluates the flag through the configured provider). The value isn't bound to any table, so it is tagged with the {@link ADMIN_WILDCARD} dep — re-evaluated on every write-flush so a live `useFlag` stays current within a session. A `null` read means "nothing to deliver" (no provider, or a flag that resolved to `null`).
4926
+ * - everything else → {@link executeSubscription} (the user query, under the socket's own by-value identity).
4927
+ */
4928
+ async resolveReactiveOutcome(functionPath, args, isAdmin, identity) {
4929
+ if (isAdmin) {
4930
+ return this.executeAdminSubscription(functionPath, args);
4931
+ }
4932
+ if (functionPath.startsWith(FLAGS_FUNCTION_PREFIX)) {
4933
+ const result = await this.runFlagSubscriptionRead(functionPath, args, identity);
4934
+ return result === null ? null : { result, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
4935
+ }
4936
+ return this.executeSubscription(functionPath, args, identity);
4937
+ }
4938
+ /**
4939
+ * SECURITY BOUNDARY for cross-socket reactive dedup. A read is
4940
+ * identity-INDEPENDENT only when its result cannot vary by the caller's
4941
+ * verified identity — i.e. the admin/reserved introspection reads, which
4942
+ * route to {@link executeAdminSubscription} and ignore the
4943
+ * {@link SubscriptionIdentity} entirely.
4944
+ *
4945
+ * Everything else is identity-DEPENDENT and must NEVER be shared across
4946
+ * sockets: a user query may be `rls()` / `ctx.auth`-scoped (different rows
4947
+ * per identity), and a flag read ({@link FLAGS_FUNCTION_PREFIX}) evaluates
4948
+ * the provider with the subscriber's identity (per-user targeting). Sharing
4949
+ * one socket's result with another would leak one identity's rows/flags to a
4950
+ * different identity, so this predicate gates {@link resolveReactiveOutcomeDeduped}
4951
+ * shut for them.
4952
+ */
4953
+ // eslint-disable-next-line class-methods-use-this, @typescript-eslint/member-ordering -- pure predicate over the function path; a protected method so the security boundary lives in one named place (and tests can probe it), co-located with the reactive dedup it gates rather than hoisted away from its only caller
4954
+ isIdentityIndependent(functionPath) {
4955
+ return functionPath.startsWith(ADMIN_FUNCTION_PREFIX);
4956
+ }
4957
+ /**
4958
+ * Memoizing wrapper over {@link resolveReactiveOutcome}: flush-local sharing across sockets.
4959
+ * Within a single {@link refreshSubscriptions} pass, N sockets subscribed to
4960
+ * the SAME identity-independent `(functionPath, args)` re-run the query N
4961
+ * times today (see the Case-6 fan-out characterization). When the read is
4962
+ * identity-independent (admin/reserved — see {@link isIdentityIndependent})
4963
+ * its result is the same for every socket, so the first run is cached (by its
4964
+ * in-flight Promise, since the bounded worker pool runs sockets in parallel)
4965
+ * and shared with the rest — collapsing N runs to ONE.
4966
+ *
4967
+ * Identity-DEPENDENT reads are passed straight through, UNCACHED: each socket
4968
+ * must evaluate under its own by-value identity (RLS / `ctx.auth` / per-user
4969
+ * flags), so they never share a result. The `cache` is created fresh per
4970
+ * flush by the caller, so a result is never reused across passes (it would go
4971
+ * stale after the next write).
4972
+ */
4973
+ resolveReactiveOutcomeDeduped(functionPath, args, isAdmin, identity, cache) {
4974
+ if (!this.isIdentityIndependent(functionPath)) {
4975
+ return this.resolveReactiveOutcome(functionPath, args, isAdmin, identity);
4976
+ }
4977
+ const key = reactiveCacheKey(functionPath, args, null);
4978
+ const cached = cache.get(key);
4979
+ if (cached !== void 0) {
4980
+ return cached;
4981
+ }
4982
+ const pending = this.resolveReactiveOutcome(functionPath, args, isAdmin, identity);
4983
+ cache.set(key, pending);
4984
+ return pending;
4985
+ }
3463
4986
  /**
3464
4987
  * Constant-time bearer check against `env.LUNORA_ADMIN_TOKEN`. Returns
3465
4988
  * `false` (closed) when the token is unset so admin introspection is
@@ -3484,6 +5007,7 @@ class ShardDO {
3484
5007
  * 4. On normal completion send `{type:"complete"}`; on throw send
3485
5008
  * `{type:"error"}`. Either way drop the controller.
3486
5009
  */
5010
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- the stream lifecycle (ack → chunk pump → complete/error) plus the structured-vs-redacted error branch is the wire protocol and reads clearer inline than split across helpers sharing the controller + socket
3487
5011
  async handleStream(ws, id, functionPath, args) {
3488
5012
  const iterable = this.executeStream(functionPath, args);
3489
5013
  if (!iterable) {
@@ -3517,17 +5041,22 @@ class ShardDO {
3517
5041
  break;
3518
5042
  }
3519
5043
  await awaitWsDrain(ws);
3520
- ws.send(JSON.stringify({ data: chunk, id, type: "chunk" }));
5044
+ ws.send(JSON.stringify({ data: encodeWire(chunk), id, type: "chunk" }));
3521
5045
  }
3522
5046
  if (!controller.signal.aborted) {
3523
5047
  ws.send(JSON.stringify({ id, type: "complete" }));
3524
5048
  }
3525
5049
  } catch (error) {
3526
5050
  const { code } = error;
3527
- const message = error instanceof Error ? error.message : String(error);
5051
+ const isStructured = typeof code === "string";
5052
+ if (!isStructured) {
5053
+ console.error("[@lunora/do] unhandled stream error:", error);
5054
+ }
5055
+ const rawMessage = error instanceof Error ? error.message : String(error);
5056
+ const message = isStructured ? rawMessage : "internal error";
3528
5057
  ws.send(
3529
5058
  JSON.stringify({
3530
- error: { code: typeof code === "string" ? code : "INTERNAL_SERVER_ERROR", message },
5059
+ error: { code: isStructured ? code : "INTERNAL_SERVER_ERROR", message },
3531
5060
  id,
3532
5061
  type: "error"
3533
5062
  })
@@ -3558,11 +5087,53 @@ class ShardDO {
3558
5087
  if (!changed || changed.size === 0) {
3559
5088
  return;
3560
5089
  }
5090
+ if (this.pendingRefreshTables) {
5091
+ for (const table of changed) {
5092
+ this.pendingRefreshTables.add(table);
5093
+ }
5094
+ } else {
5095
+ this.pendingRefreshTables = changed;
5096
+ }
5097
+ if (this.refreshInFlight) {
5098
+ return;
5099
+ }
3561
5100
  if (typeof this.state.waitUntil === "function") {
3562
- this.state.waitUntil(this.refreshSubscriptions(changed));
5101
+ this.state.waitUntil(this.drainSubscriptionRefreshes());
5102
+ return;
5103
+ }
5104
+ await this.drainSubscriptionRefreshes();
5105
+ }
5106
+ /**
5107
+ * Drain {@link ShardDO.pendingRefreshTables} one coalesced batch at a time
5108
+ * until it is empty, then release the {@link ShardDO.refreshInFlight} gate.
5109
+ * Tables merged by a `flushChangedTables` that lands mid-pass are picked up
5110
+ * by the next loop iteration, so every committed write is observed by a
5111
+ * refresh that runs after it — bursts simply share a pass. The post-write
5112
+ * high-watermark and live-socket set are re-read inside each
5113
+ * `refreshSubscriptions` / `pokeShapeSubscribers` call, so a later batch
5114
+ * always reflects the latest committed state.
5115
+ */
5116
+ async drainSubscriptionRefreshes() {
5117
+ if (this.refreshInFlight) {
3563
5118
  return;
3564
5119
  }
3565
- await this.refreshSubscriptions(changed);
5120
+ this.refreshInFlight = true;
5121
+ try {
5122
+ let batch = this.pendingRefreshTables;
5123
+ while (batch && batch.size > 0) {
5124
+ this.pendingRefreshTables = void 0;
5125
+ const frameCursor = this.currentCdcCursor();
5126
+ const frameEpoch = this.currentCdcEpoch();
5127
+ await Promise.all([
5128
+ this.refreshSubscriptions(batch),
5129
+ this.pokeShapeSubscribers(batch, frameCursor, frameEpoch),
5130
+ this.relay?.onFlush(batch, frameCursor ?? 0)
5131
+ ]);
5132
+ batch = this.pendingRefreshTables;
5133
+ }
5134
+ } finally {
5135
+ this.refreshInFlight = false;
5136
+ }
3566
5137
  }
3567
5138
  /**
3568
5139
  * For every live subscription whose query reads one of `changed`, re-run
@@ -3623,6 +5194,7 @@ class ShardDO {
3623
5194
  const sockets = [...this.state.getWebSockets()];
3624
5195
  const frameCursor = this.currentCdcCursor();
3625
5196
  const frameEpoch = this.currentCdcEpoch();
5197
+ const reactiveRunCache = /* @__PURE__ */ new Map();
3626
5198
  const refreshOne = async (ws) => {
3627
5199
  if (this.isSocketExpired(ws)) {
3628
5200
  this.dropExpiredSocket(ws);
@@ -3640,14 +5212,12 @@ class ShardDO {
3640
5212
  continue;
3641
5213
  }
3642
5214
  try {
3643
- const outcome = isAdmin ? this.executeAdminSubscription(functionPath, query.args ?? {}) : (
3644
- // Re-run under the socket's OWN verified identity (stamped on the
3645
- // attachment at upgrade, unforgeable by the client) — passed BY
3646
- // VALUE, so this deferred re-run never reads or mutates the shared
3647
- // per-request identity fields. Without it an `rls()` / `ctx.auth`
3648
- // scoped live query would evaluate anonymous and return zero rows.
3649
- // eslint-disable-next-line no-await-in-loop -- subscriptions on a socket re-run sequentially; each shares the single SQLite handle
3650
- await this.executeSubscription(functionPath, query.args ?? {}, { identity: attachment.identity, userId: attachment.userId })
5215
+ const outcome = await this.resolveReactiveOutcomeDeduped(
5216
+ functionPath,
5217
+ query.args ?? {},
5218
+ isAdmin,
5219
+ { identity: attachment.identity, userId: attachment.userId },
5220
+ reactiveRunCache
3651
5221
  );
3652
5222
  if (!outcome) {
3653
5223
  continue;
@@ -3659,18 +5229,7 @@ class ShardDO {
3659
5229
  }
3660
5230
  }
3661
5231
  };
3662
- const concurrency = 8;
3663
- let cursor = 0;
3664
- const worker = async () => {
3665
- let socket = sockets[cursor];
3666
- cursor += 1;
3667
- while (socket !== void 0) {
3668
- await refreshOne(socket);
3669
- socket = sockets[cursor];
3670
- cursor += 1;
3671
- }
3672
- };
3673
- await Promise.all(Array.from({ length: Math.min(concurrency, sockets.length) }, () => worker()));
5232
+ await runSocketPool(sockets, refreshOne);
3674
5233
  }
3675
5234
  /**
3676
5235
  * Seed a freshly-registered subscription with its first value. Runs the
@@ -3690,7 +5249,10 @@ class ShardDO {
3690
5249
  async seedSubscription(ws, subId, query, functionPath, isAdmin) {
3691
5250
  const seedArgs = query.args ?? {};
3692
5251
  const attachment = this.readAttachment(ws);
3693
- const outcome = isAdmin ? this.executeAdminSubscription(functionPath, seedArgs) : await this.executeSubscription(functionPath, seedArgs, { identity: attachment.identity, userId: attachment.userId });
5252
+ const outcome = await this.resolveReactiveOutcome(functionPath, seedArgs, isAdmin, {
5253
+ identity: attachment.identity,
5254
+ userId: attachment.userId
5255
+ });
3694
5256
  if (!outcome) {
3695
5257
  return;
3696
5258
  }
@@ -3707,6 +5269,559 @@ class ShardDO {
3707
5269
  }
3708
5270
  this.pushSubscriptionData(ws, subId, outcome, resume?.cursor ?? this.currentCdcCursor(), epoch);
3709
5271
  }
5272
+ /**
5273
+ * Drive the full `shape_subscribe` flow as one failure-aware unit: persist the
5274
+ * attachment, seed the shape, and ack ONLY once both succeed. A persist
5275
+ * rejection (`too_many`/`serialize_failed`) or a seed that can't resolve the
5276
+ * shape (unknown / RLS-denied / cross-shard-invalid) rolls the attachment back
5277
+ * and sends an `error` frame instead of acking — so a client is never left
5278
+ * acked but subscribed to a shape that will never deliver. Never throws (a
5279
+ * thrown `webSocketMessage` is fatal to the hibernating socket).
5280
+ */
5281
+ async handleShapeSubscribe(ws, subId, shape) {
5282
+ const status = this.shapeSubscribe(ws, subId, shape);
5283
+ if (status !== "ok") {
5284
+ const code = status === "too_many" ? "TOO_MANY_SUBSCRIPTIONS" : "SUBSCRIPTION_PERSIST_FAILED";
5285
+ const message = status === "too_many" ? `subscription cap of ${String(ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET)} reached on this socket` : "failed to persist shape subscription attachment";
5286
+ this.sendShapeSubscribeError(ws, subId, code, message);
5287
+ return;
5288
+ }
5289
+ const seed = await this.seedShapeSubscription(ws, subId, shape);
5290
+ if (seed !== "ok") {
5291
+ this.shapeUnsubscribe(ws, subId);
5292
+ this.sendShapeSubscribeError(ws, subId, seed.code, seed.message);
5293
+ return;
5294
+ }
5295
+ try {
5296
+ ws.send(JSON.stringify({ id: subId, type: "ack" }));
5297
+ } catch {
5298
+ }
5299
+ }
5300
+ /** Send a structured `error` frame for a failed `shape_subscribe`, swallowing a send on an already-closed socket. */
5301
+ // eslint-disable-next-line class-methods-use-this -- groups with the shape-subscribe flow; uses only its args + the socket
5302
+ sendShapeSubscribeError(ws, subId, code, message) {
5303
+ try {
5304
+ ws.send(JSON.stringify({ code, error: { code, message }, id: subId, type: "error" }));
5305
+ } catch {
5306
+ }
5307
+ }
5308
+ /**
5309
+ * Seed a freshly-registered shape subscription. Resolves the shape under the
5310
+ * socket's verified identity, then ships either:
5311
+ *
5312
+ * - a **catch-up** poke (the membership diff in `(sinceCheckpoint, cursor]`)
5313
+ * when the client supplied a still-current checkpoint within the CDC retention
5314
+ * window and on this epoch — the cheap reconnect path; or
5315
+ * - a **full** insert-poke of the shape's entire current membership — a
5316
+ * first-time subscribe, or a reconnect that fell outside retention / forked
5317
+ * epoch.
5318
+ *
5319
+ * Either way the per-socket shape memo advances to the flush watermark so
5320
+ * later `pokeShapeSubscribers` passes diff from the right point.
5321
+ *
5322
+ * Returns `"ok"` once the shape resolved and its seed poke was attempted, or a
5323
+ * `{ code, message }` failure when the shape can't be resolved — an unknown /
5324
+ * RLS-denied shape (a base class with no registry resolves nothing), or a
5325
+ * `resolveShape` that threw (e.g. a cross-shard-join guard). The caller rolls
5326
+ * back the persisted attachment and errors instead of acking, so a client is
5327
+ * never left subscribed to a shape that will never deliver.
5328
+ */
5329
+ async seedShapeSubscription(ws, subId, shape) {
5330
+ const attachment = this.readAttachment(ws);
5331
+ const identity = { identity: attachment.identity, userId: attachment.userId };
5332
+ const relayed = await this.relay?.seedRelayShape(ws, subId, shape, identity);
5333
+ if (relayed !== void 0) {
5334
+ return relayed;
5335
+ }
5336
+ let resolved;
5337
+ try {
5338
+ resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
5339
+ } catch (error) {
5340
+ this.recordShapeError(`shape:seed:${subId}`, error);
5341
+ const code = typeof error.code === "string" ? error.code : "SHAPE_RESOLVE_FAILED";
5342
+ return { code, message: error instanceof Error ? error.message : "shape resolution failed" };
5343
+ }
5344
+ if (!resolved) {
5345
+ return { code: "SHAPE_NOT_FOUND", message: `shape "${shape.name}" not found or not permitted` };
5346
+ }
5347
+ try {
5348
+ if (resolved.global) {
5349
+ return await this.seedGlobalShape(ws, subId, resolved, identity, attachment.connectionId ?? "");
5350
+ }
5351
+ return await this.seedOpLogShape(ws, subId, shape, resolved);
5352
+ } catch (error) {
5353
+ this.recordShapeError(`shape:seed:${subId}`, error);
5354
+ const code = typeof error.code === "string" ? error.code : "SHAPE_SEED_FAILED";
5355
+ return { code, message: error instanceof Error ? error.message : "shape seed failed" };
5356
+ }
5357
+ }
5358
+ /**
5359
+ * Seed a non-`.global()` (op-log-backed) shape: either a catch-up diff over
5360
+ * `(sinceSeq, cursor]` when the client supplied a still-current checkpoint on
5361
+ * this epoch within the CDC retention window, or a full membership insert-poke
5362
+ * otherwise. The memo advances to `cursor` only once the poke is delivered, so
5363
+ * a failed send re-diffs from the prior point rather than skipping rows. May
5364
+ * throw (a stub `sql` handle, a membership probe failure); the caller converts
5365
+ * it to a structured `shape_subscribe` error.
5366
+ */
5367
+ async seedOpLogShape(ws, subId, shape, resolved) {
5368
+ const { baseCheckpoint, cursor, epoch, rowsPatch } = this.computeOpLogShapeSeed(shape, resolved);
5369
+ await awaitWsDrain(ws);
5370
+ if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], cursor, epoch, baseCheckpoint)) {
5371
+ this.recordShapeMemo(ws, subId, cursor);
5372
+ }
5373
+ return "ok";
5374
+ }
5375
+ /**
5376
+ * Compute an op-log shape seed (cursor, epoch, the resume base, and the
5377
+ * membership `rowsPatch`) WITHOUT sending — the shared core of
5378
+ * {@link ShardDO.seedOpLogShape} (sends to a local socket) and the owner relay's
5379
+ * `buildShapeSeedFrames` (serializes the frames for a relay to deliver, plan 075
5380
+ * Phase 3, via the {@link RelayHost} seam). Resume only when CDC is on, the client is on this
5381
+ * epoch, its checkpoint doesn't run ahead of ours, and the log still covers it;
5382
+ * else a full re-seed. A fully-compacted log only proves "nothing missed" when
5383
+ * the client is already at `cursor`.
5384
+ * @returns the cursor/epoch, the resume base (`baseCheckpoint`), and the membership patch
5385
+ */
5386
+ computeOpLogShapeSeed(shape, resolved) {
5387
+ const sql = this.sql;
5388
+ const cursor = this.currentCdcCursor() ?? 0;
5389
+ const epoch = this.currentCdcEpoch();
5390
+ const floor = this.cdcEnabled() ? minCdcSeq(sql) : void 0;
5391
+ const canResume = this.cdcEnabled() && shape.sinceSeq !== void 0 && shape.sinceEpoch === epoch && shape.sinceSeq <= cursor && (shape.sinceSeq === cursor || floor !== void 0 && floor <= shape.sinceSeq + 1);
5392
+ const rowsPatch = canResume && shape.sinceSeq !== void 0 ? this.buildShapeDiff(sql, resolved, shape.sinceSeq, cursor) : this.buildShapeSeed(sql, resolved);
5393
+ return { baseCheckpoint: canResume ? shape.sinceSeq : void 0, cursor, epoch, rowsPatch };
5394
+ }
5395
+ /**
5396
+ * Fan the membership diff of every shape affected by this flush to its
5397
+ * subscribers — the partial-replication parallel to
5398
+ * {@link ShardDO.refreshSubscriptions}, called alongside it from
5399
+ * {@link ShardDO.flushChangedTables}. For each socket (bounded fan-out, same
5400
+ * concurrency + `awaitWsDrain` backpressure as the subscription path) it
5401
+ * resolves each shape under the socket's identity, diffs only the shapes
5402
+ * whose table changed in `(memoCursor, frameCursor]`, and emits one poke
5403
+ * carrying a part per changed shape. No-op when no socket holds a shape.
5404
+ */
5405
+ async pokeShapeSubscribers(changed, frameCursor, frameEpoch) {
5406
+ const sockets = [...this.state.getWebSockets()];
5407
+ const checkpoint = frameCursor ?? this.currentCdcCursor() ?? 0;
5408
+ const sql = this.sql;
5409
+ const opRangeCache = /* @__PURE__ */ new Map();
5410
+ let delivered = 0;
5411
+ const pokeOne = async (ws) => {
5412
+ if (this.isSocketExpired(ws)) {
5413
+ this.dropExpiredSocket(ws);
5414
+ return;
5415
+ }
5416
+ const attachment = this.readAttachment(ws);
5417
+ const { shapes } = attachment;
5418
+ if (!shapes) {
5419
+ return;
5420
+ }
5421
+ try {
5422
+ const identity = { identity: attachment.identity, userId: attachment.userId };
5423
+ const { emptyAdvanced, partAdvanced, parts } = this.collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql, opRangeCache);
5424
+ for (const subId of emptyAdvanced) {
5425
+ this.recordShapeMemo(ws, subId, checkpoint);
5426
+ }
5427
+ if (parts.length > 0) {
5428
+ await awaitWsDrain(ws);
5429
+ if (this.sendPoke(ws, parts, checkpoint, frameEpoch, void 0)) {
5430
+ delivered += 1;
5431
+ for (const subId of partAdvanced) {
5432
+ this.recordShapeMemo(ws, subId, checkpoint);
5433
+ }
5434
+ }
5435
+ }
5436
+ } catch {
5437
+ }
5438
+ };
5439
+ const startMs = Date.now();
5440
+ await runSocketPool(sockets, pokeOne);
5441
+ this.fanout.shapePoke = recordFanoutPass(this.fanout.shapePoke, sockets.length, delivered, Date.now() - startMs);
5442
+ }
5443
+ /**
5444
+ * Diff every op-log-backed shape a socket holds against this flush, splitting
5445
+ * the results into the poke parts to send and the per-shape memo advances. A
5446
+ * `.global()` shape (driven by the alarm poll loop, not this flush) and a shape
5447
+ * whose table didn't change are skipped; a shape whose resolve/diff throws is
5448
+ * logged and skipped with its memo unadvanced so a later flush retries. Empty
5449
+ * diffs advance unconditionally; part-bearing shapes advance only once the
5450
+ * caller confirms the poke was delivered.
5451
+ */
5452
+ collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql, opRangeCache) {
5453
+ const parts = [];
5454
+ const emptyAdvanced = [];
5455
+ const partAdvanced = [];
5456
+ for (const [subId, shape] of Object.entries(shapes)) {
5457
+ try {
5458
+ const resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
5459
+ if (!resolved || resolved.global || !changed.has(resolved.table)) {
5460
+ continue;
5461
+ }
5462
+ const memoCursor = this.shapeMemos.get(ws)?.get(subId)?.cursor ?? 0;
5463
+ const rowsPatch = this.buildShapeDiff(sql, resolved, memoCursor, checkpoint, opRangeCache);
5464
+ if (rowsPatch.length > 0) {
5465
+ parts.push({ rowsPatch, shapeId: subId });
5466
+ partAdvanced.push(subId);
5467
+ } else {
5468
+ emptyAdvanced.push(subId);
5469
+ }
5470
+ } catch (error) {
5471
+ this.recordShapeError(`shape:poke:${subId}`, error);
5472
+ }
5473
+ }
5474
+ return { emptyAdvanced, partAdvanced, parts };
5475
+ }
5476
+ /**
5477
+ * Drain the op-log range `(sinceSeq, upTo]` for `table` into the latest op per
5478
+ * row id (collapsing multiple ops on the same row to the newest). Within one
5479
+ * flush, every shape over the SAME `(table, sinceSeq, upTo)` reads the
5480
+ * identical changelog slice, so the drained map is memoized in the
5481
+ * caller-supplied `cache` (created fresh per flush) — N shapes on a table
5482
+ * share ONE changelog drain instead of re-scanning it per shape. The
5483
+ * per-shape membership probe still runs per shape (its predicate is
5484
+ * identity/args-specific), so only the shared op read is collapsed.
5485
+ */
5486
+ readShapeOpRange(sql, table, sinceSeq, upTo, cache) {
5487
+ const key = `${table}\0${String(sinceSeq)}\0${String(upTo)}`;
5488
+ const cached = cache?.get(key);
5489
+ if (cached !== void 0) {
5490
+ return cached;
5491
+ }
5492
+ const latest = /* @__PURE__ */ new Map();
5493
+ const tables = /* @__PURE__ */ new Set([table]);
5494
+ let from = sinceSeq;
5495
+ for (; ; ) {
5496
+ const { changes, cursor } = this.readShapeCdcPage(sql, from, tables);
5497
+ for (const change of changes) {
5498
+ latest.set(change.id, change);
5499
+ }
5500
+ if (changes.length === 0 || cursor === from || cursor >= upTo) {
5501
+ break;
5502
+ }
5503
+ from = cursor;
5504
+ }
5505
+ cache?.set(key, latest);
5506
+ return latest;
5507
+ }
5508
+ /**
5509
+ * Read one page of the `__cdc_log` for a shape diff (table-scoped). A thin
5510
+ * protected seam over {@link readCdcChanges}: it isolates the single
5511
+ * changelog read that {@link readShapeOpRange} memoizes per flush, and gives
5512
+ * tests a point to count the reads the op-range cache collapses.
5513
+ */
5514
+ // eslint-disable-next-line class-methods-use-this, @typescript-eslint/member-ordering -- thin pass-through seam over the module-level reader; a protected method so the op-range cache + tests share one read point, co-located with the poke path it serves rather than hoisted away from its only caller
5515
+ readShapeCdcPage(sql, sinceSeq, tables) {
5516
+ return readCdcChanges(sql, { sinceSeq, tables });
5517
+ }
5518
+ /**
5519
+ * Build the row-ops for a shape over the op range `(sinceSeq, upTo]`. Reads
5520
+ * the changelog (drained across pages via {@link readShapeOpRange}, shared
5521
+ * across same-range shapes in a flush), collapses to the latest op per row,
5522
+ * then runs ONE membership probe ({@link selectShapeMemberIds}) over the
5523
+ * changed ids: a row still in the set → upsert with its post-image doc
5524
+ * (projected to the shape's columns); a row that left the set, or any delete,
5525
+ * → `delete(key)` (a delete carries no post-image, so membership is
5526
+ * unknowable from the op alone — the client no-ops an unknown key).
5527
+ */
5528
+ buildShapeDiff(sql, resolved, sinceSeq, upTo, opRangeCache) {
5529
+ const latest = this.readShapeOpRange(sql, resolved.table, sinceSeq, upTo, opRangeCache);
5530
+ if (latest.size === 0) {
5531
+ return [];
5532
+ }
5533
+ const ids = [...latest.keys()];
5534
+ const members = selectShapeMemberIds(sql, resolved.table, resolved.effectiveWhere, ids);
5535
+ const ops = [];
5536
+ for (const [id, change] of latest) {
5537
+ if (members.has(id)) {
5538
+ if (change.doc !== void 0) {
5539
+ ops.push({ key: id, op: change.op, table: resolved.table, value: projectColumns(change.doc, resolved.columns) });
5540
+ }
5541
+ continue;
5542
+ }
5543
+ if (change.op !== "insert") {
5544
+ ops.push({ key: id, op: "delete", table: resolved.table });
5545
+ }
5546
+ }
5547
+ return ops;
5548
+ }
5549
+ /** Build the full insert-poke of a shape's current membership — the first-seed/full-reseed rowset. */
5550
+ // eslint-disable-next-line class-methods-use-this -- instance method for symmetry with `buildShapeDiff`; reads via the passed `sql` handle
5551
+ buildShapeSeed(sql, resolved) {
5552
+ return selectShapeRows(sql, resolved.table, resolved.effectiveWhere).map((row) => {
5553
+ return {
5554
+ key: row.id,
5555
+ op: "insert",
5556
+ table: resolved.table,
5557
+ value: projectColumns(row.doc, resolved.columns)
5558
+ };
5559
+ });
5560
+ }
5561
+ /**
5562
+ * Seed a `.global()`-table shape: read its full membership from D1, ship it
5563
+ * as one insert-poke, record the membership snapshot the alarm poll loop will
5564
+ * diff against, and arm the poll alarm. A global shape has no op-log cursor,
5565
+ * so the poke is stamped at this DO's current cursor (informational only) and
5566
+ * carries no resume base — a reconnect always re-seeds full.
5567
+ */
5568
+ async seedGlobalShape(ws, subId, resolved, identity, connectionId) {
5569
+ const rows = await this.readGlobalShapeRows(resolved, identity);
5570
+ if (!this.withinGlobalShapeBound(rows.length, `shape:seed:${subId}`, resolved.table)) {
5571
+ return {
5572
+ code: "SHAPE_GLOBAL_TOO_LARGE",
5573
+ message: `global shape membership for "${resolved.table}" exceeds the ${String(ShardDO.GLOBAL_SHAPE_MAX_ROWS)}-row cap; narrow it with a shape predicate or an RLS read policy`
5574
+ };
5575
+ }
5576
+ const { next: snapshot, rowsPatch } = diffGlobalMembership(rows, /* @__PURE__ */ new Map(), { columns: resolved.columns, table: resolved.table });
5577
+ await awaitWsDrain(ws);
5578
+ if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], this.currentCdcCursor() ?? 0, this.currentCdcEpoch(), void 0)) {
5579
+ this.recordGlobalSnapshot(ws, subId, snapshot);
5580
+ this.saveGlobalSnapshot(connectionId, subId, snapshot);
5581
+ }
5582
+ await this.scheduleGlobalPoll();
5583
+ return "ok";
5584
+ }
5585
+ /**
5586
+ * Re-read a global shape's membership from D1 and poke only the diff against
5587
+ * the socket's last snapshot: a new key → `insert`, a changed projected value
5588
+ * → `update`, a vanished key → `delete`. The snapshot advances to the fresh
5589
+ * membership even when the diff is empty, so the next tick compares from here.
5590
+ * No frame is sent when nothing changed (the common steady-state tick).
5591
+ */
5592
+ async refreshGlobalShape(ws, subId, resolved, identity, connectionId) {
5593
+ const rows = await this.readGlobalShapeRows(resolved, identity);
5594
+ if (!this.withinGlobalShapeBound(rows.length, `shape:poll:${subId}`, resolved.table)) {
5595
+ return;
5596
+ }
5597
+ const previous = this.readGlobalSnapshot(ws, subId, connectionId);
5598
+ const { next, rowsPatch } = diffGlobalMembership(rows, previous, { columns: resolved.columns, table: resolved.table });
5599
+ if (rowsPatch.length === 0) {
5600
+ this.recordGlobalSnapshot(ws, subId, next);
5601
+ return;
5602
+ }
5603
+ await awaitWsDrain(ws);
5604
+ if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], this.currentCdcCursor() ?? 0, this.currentCdcEpoch(), void 0)) {
5605
+ this.recordGlobalSnapshot(ws, subId, next);
5606
+ this.saveGlobalSnapshot(connectionId, subId, next);
5607
+ }
5608
+ }
5609
+ /**
5610
+ * Read a socket's global-shape baseline, preferring the hot in-memory cache
5611
+ * and falling back to the durable `__global_shape_snapshot` table on a miss (a
5612
+ * cold socket after a hibernation eviction). The loaded baseline repopulates
5613
+ * the cache so subsequent ticks in this wake hit memory. An empty
5614
+ * `connectionId` (a socket that never went through the lifecycle-aware upgrade,
5615
+ * e.g. a unit harness) skips the durable read and behaves as in-memory-only.
5616
+ */
5617
+ readGlobalSnapshot(ws, subId, connectionId) {
5618
+ const cached = this.globalShapeSnapshots.get(ws)?.get(subId);
5619
+ if (cached) {
5620
+ return cached;
5621
+ }
5622
+ const stored = this.loadGlobalSnapshot(connectionId, subId);
5623
+ this.recordGlobalSnapshot(ws, subId, stored);
5624
+ return stored;
5625
+ }
5626
+ /** Record a socket's latest global-shape membership snapshot in the in-memory cache (creating the per-socket map lazily). */
5627
+ recordGlobalSnapshot(ws, subId, snapshot) {
5628
+ let snapshots = this.globalShapeSnapshots.get(ws);
5629
+ if (!snapshots) {
5630
+ snapshots = /* @__PURE__ */ new Map();
5631
+ this.globalShapeSnapshots.set(ws, snapshots);
5632
+ }
5633
+ snapshots.set(subId, snapshot);
5634
+ }
5635
+ /**
5636
+ * Load a durable global-shape baseline from SQLite, or an empty map when none
5637
+ * is stored / the durable path is unavailable. A stub `sql` handle (unit
5638
+ * harness) or a missing table degrades to in-memory-only behavior rather than
5639
+ * failing the poll tick.
5640
+ */
5641
+ loadGlobalSnapshot(connectionId, subId) {
5642
+ if (connectionId === "") {
5643
+ return /* @__PURE__ */ new Map();
5644
+ }
5645
+ try {
5646
+ return readGlobalShapeSnapshot(this.sql, connectionId, subId);
5647
+ } catch {
5648
+ return /* @__PURE__ */ new Map();
5649
+ }
5650
+ }
5651
+ /**
5652
+ * Persist a socket's global-shape baseline to SQLite so the poll-loop diff
5653
+ * survives hibernation. A no-op for a connection-id-less socket or a stub
5654
+ * `sql` handle (the in-memory cache then carries the baseline for the DO's
5655
+ * lifetime, matching the pre-durable behavior).
5656
+ */
5657
+ saveGlobalSnapshot(connectionId, subId, snapshot) {
5658
+ if (connectionId === "") {
5659
+ return;
5660
+ }
5661
+ try {
5662
+ writeGlobalShapeSnapshot(this.sql, connectionId, subId, snapshot);
5663
+ } catch {
5664
+ }
5665
+ }
5666
+ /**
5667
+ * Arm the poll alarm for `.global()` shapes if one isn't already pending.
5668
+ * Idempotent — every global-shape seed calls it, but only the first arms the
5669
+ * alarm. Degrades to a no-op when the runtime exposes no `setAlarm` (the unit
5670
+ * harness): a global shape is then seed-only, which the poll-loop tests assert
5671
+ * by driving {@link ShardDO.alarm} directly.
5672
+ */
5673
+ async scheduleGlobalPoll() {
5674
+ if (this.globalPollScheduled) {
5675
+ return;
5676
+ }
5677
+ const { setAlarm } = this.state.storage;
5678
+ if (!setAlarm) {
5679
+ return;
5680
+ }
5681
+ this.globalPollScheduled = true;
5682
+ try {
5683
+ await setAlarm.call(this.state.storage, Date.now() + ShardDO.GLOBAL_SHAPE_POLL_INTERVAL_MS);
5684
+ } catch {
5685
+ this.globalPollScheduled = false;
5686
+ }
5687
+ }
5688
+ /**
5689
+ * Record a contained shape-tier error (poll / poke / seed) into the DO's log
5690
+ * ring without aborting the rest of the pass. The shape pipeline is a
5691
+ * best-effort fan-out: one socket's read or one shape's resolve failing must
5692
+ * never take down the others — so callers swallow the throw and surface it
5693
+ * here for diagnosis. `context` is a synthetic `shape:phase:subId` path.
5694
+ */
5695
+ recordShapeError(context, error) {
5696
+ this.logs.push({
5697
+ functionPath: context,
5698
+ level: "error",
5699
+ message: error instanceof Error ? error.message : String(error),
5700
+ timestamp: Date.now()
5701
+ });
5702
+ }
5703
+ /**
5704
+ * Guard a global shape's materialized membership against {@link
5705
+ * ShardDO.GLOBAL_SHAPE_MAX_ROWS}. Returns `true` when the row count is within
5706
+ * the cap; otherwise records a diagnosable error and returns `false` so the
5707
+ * caller fails the shape closed (no snapshot retained, no poke sent) rather
5708
+ * than risking a DO eviction on an unbounded global table. The transient read
5709
+ * buffer is bounded by the same gate — an over-cap membership is dropped, not
5710
+ * snapshotted per socket.
5711
+ */
5712
+ withinGlobalShapeBound(rowCount, context, table) {
5713
+ if (rowCount <= ShardDO.GLOBAL_SHAPE_MAX_ROWS) {
5714
+ return true;
5715
+ }
5716
+ this.recordShapeError(
5717
+ context,
5718
+ new Error(
5719
+ `global shape membership for "${table}" (${String(rowCount)} rows) exceeds the ${String(ShardDO.GLOBAL_SHAPE_MAX_ROWS)}-row cap; narrow it with a shape predicate or an RLS read policy`
5720
+ )
5721
+ );
5722
+ return false;
5723
+ }
5724
+ /**
5725
+ * Refresh every `.global()`-table shape held across all live sockets, one
5726
+ * diff-poke per (socket, shape). Returns the number of global shapes still
5727
+ * subscribed so {@link ShardDO.alarm} knows whether to re-arm. Expired sockets
5728
+ * are dropped in passing (mirrors {@link ShardDO.pokeShapeSubscribers}).
5729
+ */
5730
+ async pollGlobalShapes() {
5731
+ const sockets = [...this.state.getWebSockets()];
5732
+ let remaining = 0;
5733
+ for (const ws of sockets) {
5734
+ if (this.isSocketExpired(ws)) {
5735
+ this.dropExpiredSocket(ws);
5736
+ continue;
5737
+ }
5738
+ const attachment = this.readAttachment(ws);
5739
+ const { shapes } = attachment;
5740
+ if (!shapes) {
5741
+ continue;
5742
+ }
5743
+ const identity = { identity: attachment.identity, userId: attachment.userId };
5744
+ remaining += await this.pollSocketGlobalShapes(ws, shapes, identity, attachment.connectionId ?? "");
5745
+ }
5746
+ return remaining;
5747
+ }
5748
+ /**
5749
+ * Refresh one socket's `.global()`-table shapes, containing per-shape
5750
+ * failures so a single throw never aborts the poll tick (and with it the
5751
+ * re-arm). Returns the count of global shapes still subscribed on this socket
5752
+ * — a failed `resolveShape`/read keeps its shape counted so the alarm keeps
5753
+ * polling and retries next tick.
5754
+ */
5755
+ async pollSocketGlobalShapes(ws, shapes, identity, connectionId) {
5756
+ let count = 0;
5757
+ for (const [subId, shape] of Object.entries(shapes)) {
5758
+ let resolved;
5759
+ try {
5760
+ resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
5761
+ } catch (error) {
5762
+ count += 1;
5763
+ this.recordShapeError(`shape:poll:${subId}`, error);
5764
+ continue;
5765
+ }
5766
+ if (!resolved?.global) {
5767
+ continue;
5768
+ }
5769
+ count += 1;
5770
+ try {
5771
+ await this.refreshGlobalShape(ws, subId, resolved, identity, connectionId);
5772
+ } catch (error) {
5773
+ this.recordShapeError(`shape:poll:${subId}`, error);
5774
+ }
5775
+ }
5776
+ return count;
5777
+ }
5778
+ /**
5779
+ * Send one poke (`pokeStart` → `pokePart` per shape → `pokeEnd`) to a socket.
5780
+ * All parts apply atomically at `pokeEnd`. Returns `true` when every frame was
5781
+ * handed to the socket, `false` when a send threw mid-poke (the socket closed)
5782
+ * — callers must NOT advance their shape baselines on a `false` so the client
5783
+ * re-receives the rows on its next flush/reconnect instead of losing them.
5784
+ */
5785
+ sendPoke(ws, parts, checkpoint, epoch, baseCheckpoint) {
5786
+ this.pokeSequence += 1;
5787
+ const pokeId = `poke-${String(this.pokeSequence)}`;
5788
+ const frames = buildPokeFrames(parts, { baseCheckpoint, checkpoint, epoch, lastMutationId: this.socketClientWatermark(ws), pokeId });
5789
+ try {
5790
+ for (const frame of frames) {
5791
+ ws.send(frame);
5792
+ }
5793
+ return true;
5794
+ } catch {
5795
+ return false;
5796
+ }
5797
+ }
5798
+ /**
5799
+ * The recipient client's `__client_watermark` for stamping a poke's
5800
+ * `lastMutationId`, or `undefined` when the socket announced no `clientId`
5801
+ * (a client that doesn't use custom mutators — nothing to drop an overlay
5802
+ * for). Read off the attachment so it survives hibernation.
5803
+ */
5804
+ socketClientWatermark(ws) {
5805
+ const attachment = this.readAttachment(ws);
5806
+ const { clientId } = attachment;
5807
+ if (clientId === void 0) {
5808
+ return void 0;
5809
+ }
5810
+ try {
5811
+ return readClientWatermark(this.sql, attachment.userId ?? "", clientId);
5812
+ } catch {
5813
+ return void 0;
5814
+ }
5815
+ }
5816
+ /** Record a shape's poke baseline cursor on a socket (creating the per-socket map lazily). */
5817
+ recordShapeMemo(ws, subId, cursor) {
5818
+ let memos = this.shapeMemos.get(ws);
5819
+ if (!memos) {
5820
+ memos = /* @__PURE__ */ new Map();
5821
+ this.shapeMemos.set(ws, memos);
5822
+ }
5823
+ memos.set(subId, { cursor });
5824
+ }
3710
5825
  /**
3711
5826
  * Record `outcome` as this socket's diff baseline for `subId` without
3712
5827
  * sending a frame. Used by the resume fast-path, where the client keeps its
@@ -3719,7 +5834,7 @@ class ShardDO {
3719
5834
  memos = /* @__PURE__ */ new Map();
3720
5835
  this.subMemos.set(ws, memos);
3721
5836
  }
3722
- memos.set(subId, { lastJson: JSON.stringify(outcome.result ?? null), tables: outcome.tables });
5837
+ memos.set(subId, { lastJson: JSON.stringify(encodeWire(outcome.result ?? null)), tables: outcome.tables });
3723
5838
  }
3724
5839
  /**
3725
5840
  * Memoise `outcome` for `(ws, subId)` and push it to the socket, unless an
@@ -3746,29 +5861,19 @@ class ShardDO {
3746
5861
  this.subMemos.set(ws, memos);
3747
5862
  }
3748
5863
  const cursorSuffix = cdcSuffix(cursor, epoch);
3749
- const json = JSON.stringify(outcome.result ?? null);
5864
+ const json = JSON.stringify(encodeWire(outcome.result ?? null));
3750
5865
  const existing = memos.get(subId);
3751
5866
  if (existing?.lastJson === json) {
3752
5867
  existing.tables = outcome.tables;
5868
+ const settledWatermark = this.socketClientWatermark(ws);
5869
+ const watermarkField = settledWatermark === void 0 ? "" : `,"lastMutationId":${String(settledWatermark)}`;
5870
+ trySendFrame(ws, `{"type":"settled","id":${JSON.stringify(subId)}${watermarkField}${cursorSuffix}}`);
3753
5871
  return;
3754
5872
  }
3755
5873
  const deltaFrames = [];
3756
5874
  const deltas = existing === void 0 ? void 0 : subscriptionListDeltas(existing.lastJson, outcome.result, outcome.tables.values().next().value ?? "", deltaFrames);
3757
- memos.set(subId, { lastJson: json, tables: outcome.tables });
3758
- if (deltas !== void 0) {
3759
- const idJson = JSON.stringify(subId);
3760
- for (const deltaBody of deltaFrames) {
3761
- try {
3762
- ws.send(`{"type":"delta","id":${idJson},"delta":${deltaBody}${cursorSuffix}}`);
3763
- } catch {
3764
- }
3765
- }
3766
- return;
3767
- }
3768
- try {
3769
- ws.send(`{"type":"data","id":${JSON.stringify(subId)},"data":${json}${cursorSuffix}}`);
3770
- } catch {
3771
- }
5875
+ const delivered = deltas === void 0 ? trySendFrame(ws, `{"type":"data","id":${JSON.stringify(subId)},"data":${json}${cursorSuffix}}`) : sendDeltaFrames(ws, subId, deltaFrames, cursorSuffix);
5876
+ memos.set(subId, { lastJson: delivered ? json : existing?.lastJson ?? UNDELIVERED_BASELINE, tables: outcome.tables });
3772
5877
  }
3773
5878
  /**
3774
5879
  * Gate the upgrade request against two complementary controls:
@@ -3858,6 +5963,29 @@ class ShardDO {
3858
5963
  }
3859
5964
  setter.call(this.state, new WebSocketRequestResponsePair(WS_KEEPALIVE_PING, WS_KEEPALIVE_PONG));
3860
5965
  }
5966
+ /**
5967
+ * Route the non-RPC requests `fetch` handles before the shard-local RPC
5968
+ * endpoint: a WebSocket upgrade, and the internal `/_lunora/relay` owner↔relay
5969
+ * control channel (never reachable by a client — the runtime forwards only
5970
+ * worker-internal traffic there). Returns `undefined` for an RPC request, which
5971
+ * `fetch` then dispatches.
5972
+ * @returns the routed response, or `undefined` when this is an RPC request
5973
+ */
5974
+ async routeNonRpc(url, request) {
5975
+ if (url.pathname === "/_lunora/relay" && request.method === "POST") {
5976
+ return this.relay ? await this.relay.handleControl(request) : new Response("relay tier inactive", { status: 404 });
5977
+ }
5978
+ if (url.pathname === "/_lunora/route" && request.method === "GET") {
5979
+ return jsonResponse({ relayCount: this.relay?.relayCount() ?? 0 });
5980
+ }
5981
+ if (url.pathname === "/rpc-batch" && request.method === "POST") {
5982
+ return this.handleBatchRpc(request);
5983
+ }
5984
+ if (request.headers.get("Upgrade") === "websocket") {
5985
+ return this.handleWebSocketUpgrade(request);
5986
+ }
5987
+ return void 0;
5988
+ }
3861
5989
  handleWebSocketUpgrade(request) {
3862
5990
  if (!this.isUpgradeAllowed(request)) {
3863
5991
  return new Response("Forbidden", { status: 403 });
@@ -3975,7 +6103,7 @@ class ShardDO {
3975
6103
  * topic name. That matches the AnyCable model (and `from` is unforgeable),
3976
6104
  * but per-topic auth does not exist here; see `whisperSubscribe` on the client.
3977
6105
  */
3978
- broadcastWhisper(sender, topic, data) {
6106
+ async broadcastWhisper(sender, topic, data) {
3979
6107
  if (!this.allowWhisper(sender)) {
3980
6108
  return;
3981
6109
  }
@@ -3986,15 +6114,29 @@ class ShardDO {
3986
6114
  const from = this.readAttachment(sender).userId;
3987
6115
  const fromSuffix = from === void 0 ? "" : `,"from":${JSON.stringify(from)}`;
3988
6116
  const frame = `{"type":"whisper","topic":${JSON.stringify(topic)},"data":${dataJson}${fromSuffix}}`;
6117
+ this.deliverWhisperLocal(topic, frame, sender);
6118
+ await this.relay?.forwardWhisper(topic, frame);
6119
+ }
6120
+ /**
6121
+ * Deliver an already-serialized whisper `frame` to every local socket joined to
6122
+ * `topic`, excluding `exclude` (the sender, or `undefined` for a frame the relay
6123
+ * hub forwarded in — its sender lives on another DO). Records the fan-out pass
6124
+ * for `getFanoutMetrics` (plan 075 Phase 1). Pure delivery — no SQLite, no CDC.
6125
+ * @returns the number of sockets the frame was sent to
6126
+ */
6127
+ deliverWhisperLocal(topic, frame, exclude) {
6128
+ let scanned = 0;
6129
+ let delivered = 0;
3989
6130
  for (const ws of this.state.getWebSockets()) {
3990
- if (ws === sender || this.readAttachment(ws).whispers?.includes(topic) !== true) {
6131
+ scanned += 1;
6132
+ if (ws === exclude || this.readAttachment(ws).whispers?.includes(topic) !== true) {
3991
6133
  continue;
3992
6134
  }
3993
- try {
3994
- ws.send(frame);
3995
- } catch {
3996
- }
6135
+ trySendFrame(ws, frame);
6136
+ delivered += 1;
3997
6137
  }
6138
+ this.fanout.whisper = recordFanoutPass(this.fanout.whisper, scanned, delivered, 0);
6139
+ return delivered;
3998
6140
  }
3999
6141
  // eslint-disable-next-line class-methods-use-this -- cohesive DO instance method grouped with the hibernation/attachment helpers; reads only the socket
4000
6142
  readAttachment(ws) {