@lunora/do 1.0.0-alpha.3 → 1.0.0-alpha.30

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 (51) hide show
  1. package/LICENSE.md +6 -0
  2. package/__assets__/package-og.svg +1 -1
  3. package/dist/index.d.mts +1375 -289
  4. package/dist/index.d.ts +1375 -289
  5. package/dist/index.mjs +35 -30
  6. package/dist/packem_shared/{ADMIN_FUNCTION_PREFIX-Dzdqq5J2.mjs → ADMIN_FUNCTIONS-CAHLZMj8.mjs} +62 -8
  7. package/dist/packem_shared/{matchesStaticWhere-CFk6adSu.mjs → AGGREGATE_SQL_FUNCTION-CQsu2Xga.mjs} +5 -3
  8. package/dist/packem_shared/{applyCdcChanges-Ctdmxmrv.mjs → CDC_LOG_TABLE-DjJEHiM2.mjs} +7 -3
  9. package/dist/packem_shared/{ConflictError-C0STs6bU.mjs → ConflictError-CLoq37xH.mjs} +4 -5
  10. package/dist/packem_shared/{CountRlsUnsupportedError-28ZvvwKS.mjs → CountRlsUnsupportedError-BGxj0pgS.mjs} +4 -4
  11. package/dist/packem_shared/{DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs → DATA_MIGRATION_STATE_TABLE-CYwBpyTr.mjs} +11 -3
  12. package/dist/packem_shared/{assertFlatPredicate-DyVYReuT.mjs → DEFAULT_MAX_RELATION_KEYS-BEan1CRD.mjs} +58 -7
  13. package/dist/packem_shared/{assertReadonly-dDcFE1YZ.mjs → MAX_SQL_ROWS-D57CJaT9.mjs} +3 -1
  14. package/dist/packem_shared/NotFoundError-C70b9hLw.mjs +9 -0
  15. package/dist/packem_shared/{assertValidClientId-CBZ1zC96.mjs → NotUniqueError-BigrdT_W.mjs} +241 -90
  16. package/dist/packem_shared/{rank-CrkEIpF4.mjs → RANK_TIEBREAK-CXhdcA1o.mjs} +2 -13
  17. package/dist/packem_shared/{guardWriter-u3UlnCH5.mjs → RLS_UNWRAP_SYMBOL-DTvHvRzY.mjs} +22 -9
  18. package/dist/packem_shared/{ROOT_DO_SIZE_WARN_BYTES-DQkmGiCS.mjs → ROOT_DO_SIZE_WARN_BYTES-BA8QOChj.mjs} +2878 -326
  19. package/dist/packem_shared/{ReactiveCache-ByVzgH3d.mjs → ReactiveCache-BYlSGY0N.mjs} +1 -28
  20. package/dist/packem_shared/{SESSION_DO_TTL_DEFAULT-ilPZsVwu.mjs → SESSION_DO_TTL_DEFAULT-BnSKgVO4.mjs} +16 -27
  21. package/dist/packem_shared/{SHARD_REGISTRY_DO_NAME-BsAbi5Mn.mjs → SHARD_REGISTRY_DO_NAME-D99roc-r.mjs} +12 -14
  22. package/dist/packem_shared/{applyOnDelete-CMif2RKw.mjs → applyOnDelete-BXSq3S70.mjs} +23 -12
  23. package/dist/packem_shared/{buildSeekWhere-lVsNXSLy.mjs → applySelect-WQY8m62C.mjs} +21 -2
  24. package/dist/packem_shared/{armRestore-BJk53Ro8.mjs → armRestore-4Px61hHS.mjs} +4 -10
  25. package/dist/packem_shared/{backfillAggregateIndexes-BF5eL7kW.mjs → backfillAggregateIndexes-DDoT-UUI.mjs} +3 -2
  26. package/dist/packem_shared/{compileWhereSql-CXrhFA3G.mjs → compileWhereSql-DE6yfRcQ.mjs} +5 -3
  27. package/dist/packem_shared/constant-time-equal-BVRWZgES.mjs +12 -0
  28. package/dist/packem_shared/{createSystemReader-8CzSZP9V.mjs → createSystemReader-D12eNH13.mjs} +4 -2
  29. package/dist/packem_shared/ctx-db-idempotency-BdcNpvY4.mjs +108 -0
  30. package/dist/packem_shared/ctx-db-shapes-Cz9dHyh1.mjs +53 -0
  31. package/dist/packem_shared/diffExternalSource-Cx9HUPJj.mjs +44 -0
  32. package/dist/packem_shared/{exportShardRows-DZEhUeyI.mjs → exportShardRows-Dy3oFZ26.mjs} +4 -3
  33. package/dist/packem_shared/isSourceDue-CYkt7Ru8.mjs +41 -0
  34. package/dist/packem_shared/json-response-BdbtpOhm.mjs +3 -0
  35. package/dist/packem_shared/materializeExternalRows-CTqZisSC.mjs +23 -0
  36. package/dist/packem_shared/{runShardMigrations-C3bn5r93.mjs → runShardMigrations-BGx4v2B6.mjs} +6 -4
  37. package/dist/packem_shared/serialize-sql-BlRUoiQe.mjs +14 -0
  38. package/dist/packem_shared/{serveRelationFanout-Clr1a05L.mjs → serveRelationFanout-BgaNg3Hu.mjs} +4 -7
  39. package/dist/packem_shared/stableStringify-MydiuScU.mjs +40 -0
  40. package/dist/packem_shared/subscription-delivery-CWigSEr3.mjs +348 -0
  41. package/dist/packem_shared/subscriptionListDeltas-DRLvFlM1.mjs +1 -0
  42. package/package.json +5 -3
  43. package/dist/packem_shared/NotFoundError-CMuMZt81.mjs +0 -10
  44. package/dist/packem_shared/ctx-db-idempotency-DkC9rP91.mjs +0 -35
  45. package/dist/packem_shared/encodePartitionKey-C6blLR5K.mjs +0 -1
  46. /package/dist/packem_shared/{AUTH_METRICS_BUCKET_MS-CiHHYeJi.mjs → AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs} +0 -0
  47. /package/dist/packem_shared/{ensureFunctionMetricsTables-UDNVD7FS.mjs → FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs} +0 -0
  48. /package/dist/packem_shared/{clearCapturedMail-CPpgl-dX.mjs → MAIL_RETENTION-CPpgl-dX.mjs} +0 -0
  49. /package/dist/packem_shared/{buildSecurityAudit-CCAvoFlr.mjs → MIN_ADMIN_TOKEN_LENGTH-CCAvoFlr.mjs} +0 -0
  50. /package/dist/packem_shared/{ftsTableName-BLEMawrp.mjs → buildFtsMatch-BLEMawrp.mjs} +0 -0
  51. /package/dist/packem_shared/{runTriggers-5N6_Fx0A.mjs → hasTrigger-5N6_Fx0A.mjs} +0 -0
@@ -1,29 +1,38 @@
1
+ import { LunoraError, toErrorBody } from '@lunora/errors';
1
2
  import { drizzle } from 'drizzle-orm/durable-sqlite';
2
- 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';
3
+ import { c as constantTimeEqual } from './constant-time-equal-BVRWZgES.mjs';
4
+ import { j as jsonResponse } from './json-response-BdbtpOhm.mjs';
5
+ import { e as encodeWire, a as awaitWsDrain, t as trySendFrame, d as decodeWire, s as subscriptionListDeltas, b as sendDeltaFrames } from './subscription-delivery-CWigSEr3.mjs';
6
+ import { parseExportShardArgs, parseImportShardArgs } from './exportShardRows-Dy3oFZ26.mjs';
7
+ import { recordAuthEvent, readAuthMetrics } from './AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs';
8
+ import { DATA_MIGRATION_STATE_TABLE, readMigrationStatus } from './DATA_MIGRATION_STATE_TABLE-CYwBpyTr.mjs';
5
9
  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';
10
+ import { readFunctionMetricsTotals, readFunctionMetricIndexHits, recordFunctionMetric, mergeScanAttribution, readFunctionMetrics, readFunctionMetricBuckets } from './FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs';
11
+ 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-CAHLZMj8.mjs';
8
12
  import { LogBuffer } from './LogBuffer-B_Ezju_N.mjs';
9
- import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './clearCapturedMail-CPpgl-dX.mjs';
10
- import { readBookmark, armRestore } from './armRestore-BJk53Ro8.mjs';
11
- import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-ByVzgH3d.mjs';
13
+ import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './MAIL_RETENTION-CPpgl-dX.mjs';
14
+ import { readBookmark, armRestore } from './armRestore-4Px61hHS.mjs';
15
+ import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-BYlSGY0N.mjs';
16
+ import { stableStringify } from './stableStringify-MydiuScU.mjs';
17
+ import { fingerprintError } from '@lunora/fingerprint';
12
18
  import { redact, standardRules } from '@visulima/redact';
13
19
  import { i as isDevEnvironment, c as buildSettings, b as buildSecurityAudit } from './security-audit-CucgBice.mjs';
14
- import { runReadonlySql } from './assertReadonly-dDcFE1YZ.mjs';
15
- 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';
20
+ import { runReadonlySql } from './MAX_SQL_ROWS-D57CJaT9.mjs';
21
+ import { ConflictError } from './ConflictError-CLoq37xH.mjs';
22
+ 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';
23
+ import { CDC_LOG_TABLE, readCdcChanges, readCdcCursor, readCdcEpoch, minCdcSeq, bumpCdcEpoch } from './CDC_LOG_TABLE-DjJEHiM2.mjs';
24
+ import { a as selectShapeMemberIds, s as selectShapeRows } from './ctx-db-shapes-Cz9dHyh1.mjs';
25
+
26
+ const MAX_BATCH_ENTRIES = 500;
18
27
 
19
28
  const AUDIT_LOG_TABLE = "__lunora_audit__";
20
29
  const AUDIT_LOG_RETENTION = 1e3;
21
- const runSql$2 = (sql, query, ...params) => {
30
+ const runSql$3 = (sql, query, ...params) => {
22
31
  const runner = sql.exec;
23
32
  return runner.call(sql, query, ...params);
24
33
  };
25
34
  const ensureAuditTable = (sql) => {
26
- runSql$2(
35
+ runSql$3(
27
36
  sql,
28
37
  `CREATE TABLE IF NOT EXISTS "${AUDIT_LOG_TABLE}" (
29
38
  seq INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -37,7 +46,7 @@ const ensureAuditTable = (sql) => {
37
46
  };
38
47
  const appendAuditEntry = (sql, entry) => {
39
48
  ensureAuditTable(sql);
40
- runSql$2(
49
+ runSql$3(
41
50
  sql,
42
51
  `INSERT INTO "${AUDIT_LOG_TABLE}" (ts, op, "table", id, detail) VALUES (?, ?, ?, ?, ?)`,
43
52
  entry.ts,
@@ -49,13 +58,13 @@ const appendAuditEntry = (sql, entry) => {
49
58
  // eslint-disable-next-line unicorn/no-null -- SQL NULL is the correct value for an op with no associated table/id/detail.
50
59
  entry.detail === void 0 ? null : JSON.stringify(entry.detail)
51
60
  );
52
- runSql$2(sql, `DELETE FROM "${AUDIT_LOG_TABLE}" WHERE seq <= (SELECT MAX(seq) - ? FROM "${AUDIT_LOG_TABLE}")`, AUDIT_LOG_RETENTION);
61
+ runSql$3(sql, `DELETE FROM "${AUDIT_LOG_TABLE}" WHERE seq <= (SELECT MAX(seq) - ? FROM "${AUDIT_LOG_TABLE}")`, AUDIT_LOG_RETENTION);
53
62
  };
54
63
  const readAuditLog = (sql, options = {}) => {
55
64
  ensureAuditTable(sql);
56
65
  const sinceSeq = options.sinceSeq ?? 0;
57
66
  const limit = Math.max(1, Math.min(options.limit ?? AUDIT_LOG_RETENTION, 1e4));
58
- const rows = runSql$2(
67
+ const rows = runSql$3(
59
68
  sql,
60
69
  `SELECT seq, ts, op, "table", id, detail FROM "${AUDIT_LOG_TABLE}" WHERE seq > ? ORDER BY seq DESC LIMIT ?`,
61
70
  sinceSeq,
@@ -76,10 +85,42 @@ const readAuditLog = (sql, options = {}) => {
76
85
  });
77
86
  };
78
87
 
88
+ const SHARED_BATCH_HEADERS = [
89
+ "x-lunora-userid",
90
+ "x-lunora-identity",
91
+ "x-d1-bookmark",
92
+ "x-lunora-client-ip",
93
+ "x-lunora-system",
94
+ "x-lunora-shard-binding"
95
+ ];
96
+ const buildBatchEntryRequest = (batchRequest, entry) => {
97
+ const headers = new Headers({ "content-type": "application/json" });
98
+ for (const name of SHARED_BATCH_HEADERS) {
99
+ const value = batchRequest.headers.get(name);
100
+ if (value !== null) {
101
+ headers.set(name, value);
102
+ }
103
+ }
104
+ if (entry.mutationId !== void 0) {
105
+ headers.set("x-lunora-mutation-id", entry.mutationId);
106
+ }
107
+ if (entry.clientId !== void 0) {
108
+ headers.set("x-lunora-client-id", entry.clientId);
109
+ }
110
+ if (entry.clientSeq !== void 0) {
111
+ headers.set("x-lunora-client-seq", String(entry.clientSeq));
112
+ }
113
+ return new Request("https://shard.internal/rpc", {
114
+ body: JSON.stringify({ args: entry.args ?? {}, functionPath: entry.functionPath }),
115
+ headers,
116
+ method: "POST"
117
+ });
118
+ };
119
+
79
120
  const QUERY_METRICS_TABLE = "__lunora_metrics_queries";
80
121
  const QUERY_METRICS_MAX_SQL_LEN = 512;
81
122
  const QUERY_METRICS_MAX_STATEMENTS = 500;
82
- const runSql$1 = (sql, query, ...params) => {
123
+ const runSql$2 = (sql, query, ...params) => {
83
124
  const runner = sql.exec;
84
125
  return runner.call(sql, query, ...params);
85
126
  };
@@ -91,7 +132,7 @@ const normalizeSql = (sql) => {
91
132
  return normalized;
92
133
  };
93
134
  const ensureQueryMetricsTable = (sql) => {
94
- runSql$1(
135
+ runSql$2(
95
136
  sql,
96
137
  `CREATE TABLE IF NOT EXISTS "${QUERY_METRICS_TABLE}" (
97
138
  normalized_sql TEXT PRIMARY KEY,
@@ -108,10 +149,10 @@ const recordQueryMetric = (sql, rawSql, durationMs, rowsRead, rowsWritten) => {
108
149
  return;
109
150
  }
110
151
  ensureQueryMetricsTable(sql);
111
- const countRow = runSql$1(sql, `SELECT COUNT(*) AS n FROM "${QUERY_METRICS_TABLE}"`).one();
152
+ const countRow = runSql$2(sql, `SELECT COUNT(*) AS n FROM "${QUERY_METRICS_TABLE}"`).one();
112
153
  const count = countRow.n;
113
154
  if (count >= QUERY_METRICS_MAX_STATEMENTS) {
114
- const existing = runSql$1(sql, `SELECT COUNT(*) AS c FROM "${QUERY_METRICS_TABLE}" WHERE normalized_sql = ?`, normalized).one();
155
+ const existing = runSql$2(sql, `SELECT COUNT(*) AS c FROM "${QUERY_METRICS_TABLE}" WHERE normalized_sql = ?`, normalized).one();
115
156
  if (existing.c === 0) {
116
157
  return;
117
158
  }
@@ -123,11 +164,11 @@ const recordQueryMetric = (sql, rawSql, durationMs, rowsRead, rowsWritten) => {
123
164
  total_duration_ms = total_duration_ms + excluded.total_duration_ms,
124
165
  rows_read = rows_read + excluded.rows_read,
125
166
  rows_written = rows_written + excluded.rows_written`;
126
- runSql$1(sql, upsertSql, normalized, durationMs, rowsRead, rowsWritten);
167
+ runSql$2(sql, upsertSql, normalized, durationMs, rowsRead, rowsWritten);
127
168
  };
128
169
  const readQueryMetrics = (sql) => {
129
170
  ensureQueryMetricsTable(sql);
130
- const rows = runSql$1(
171
+ const rows = runSql$2(
131
172
  sql,
132
173
  `SELECT normalized_sql, exec_count, total_duration_ms, rows_read, rows_written FROM "${QUERY_METRICS_TABLE}" ORDER BY total_duration_ms DESC`
133
174
  ).toArray();
@@ -142,6 +183,909 @@ const readQueryMetrics = (sql) => {
142
183
  });
143
184
  };
144
185
 
186
+ const QUEUE_TABLE = "__lunora_queue_messages";
187
+ const QUEUE_RETENTION = 500;
188
+ const MAX_BODY_CHARS = 128 * 1024;
189
+ const runSql$1 = (sql, query, ...params) => {
190
+ const runner = sql.exec;
191
+ return runner.call(sql, query, ...params);
192
+ };
193
+ const orNull = (value) => (
194
+ // eslint-disable-next-line unicorn/no-null -- SQL NULL is the correct value for an absent column.
195
+ value ?? null
196
+ );
197
+ const TRUNCATION_SUFFIX = "… [truncated by the dev queue catcher]";
198
+ const UNSERIALIZABLE_MARKER = "[unserializable message body]";
199
+ const encodeBody = (value) => {
200
+ if (value === void 0) {
201
+ return "null";
202
+ }
203
+ try {
204
+ const encoded = JSON.stringify(value);
205
+ if (encoded.length > MAX_BODY_CHARS) {
206
+ return JSON.stringify(`${encoded.slice(0, MAX_BODY_CHARS)}${TRUNCATION_SUFFIX}`);
207
+ }
208
+ return encoded;
209
+ } catch {
210
+ return JSON.stringify(UNSERIALIZABLE_MARKER);
211
+ }
212
+ };
213
+ const isLossyBody = (body) => typeof body === "string" && (body === UNSERIALIZABLE_MARKER || body.endsWith(TRUNCATION_SUFFIX));
214
+ const decodeBody = (value) => {
215
+ if (value === null || value === void 0 || value === "") {
216
+ return void 0;
217
+ }
218
+ try {
219
+ return JSON.parse(value);
220
+ } catch {
221
+ return void 0;
222
+ }
223
+ };
224
+ const ensureQueueTable = (sql) => {
225
+ runSql$1(
226
+ sql,
227
+ `CREATE TABLE IF NOT EXISTS "${QUEUE_TABLE}" (
228
+ id TEXT PRIMARY KEY,
229
+ captured_at INTEGER NOT NULL,
230
+ message_id TEXT NOT NULL,
231
+ queue TEXT NOT NULL,
232
+ export_name TEXT,
233
+ body TEXT NOT NULL,
234
+ attempts INTEGER NOT NULL,
235
+ outcome TEXT NOT NULL,
236
+ error TEXT,
237
+ dead_lettered INTEGER NOT NULL,
238
+ message_ts INTEGER NOT NULL
239
+ )`
240
+ );
241
+ };
242
+ const recordQueueMessages = (sql, inputs, capturedAt) => {
243
+ ensureQueueTable(sql);
244
+ for (const input of inputs) {
245
+ runSql$1(
246
+ sql,
247
+ `INSERT INTO "${QUEUE_TABLE}" (id, captured_at, message_id, queue, export_name, body, attempts, outcome, error, dead_lettered, message_ts)
248
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
249
+ crypto.randomUUID(),
250
+ capturedAt,
251
+ input.messageId,
252
+ input.queue,
253
+ orNull(input.exportName),
254
+ encodeBody(input.body),
255
+ input.attempts,
256
+ input.outcome,
257
+ orNull(input.error),
258
+ input.deadLettered === true ? 1 : 0,
259
+ input.timestamp
260
+ );
261
+ }
262
+ runSql$1(
263
+ sql,
264
+ `DELETE FROM "${QUEUE_TABLE}"
265
+ WHERE id NOT IN (
266
+ SELECT id FROM "${QUEUE_TABLE}" ORDER BY captured_at DESC, id DESC LIMIT ?
267
+ )`,
268
+ QUEUE_RETENTION
269
+ );
270
+ return { recorded: inputs.length };
271
+ };
272
+ const rowToEntry = (row) => {
273
+ return {
274
+ attempts: row.attempts,
275
+ body: decodeBody(row.body),
276
+ capturedAt: row.captured_at,
277
+ deadLettered: row.dead_lettered === 1,
278
+ error: row.error ?? void 0,
279
+ exportName: row.export_name ?? void 0,
280
+ id: row.id,
281
+ messageId: row.message_id,
282
+ outcome: row.outcome,
283
+ queue: row.queue,
284
+ timestamp: row.message_ts
285
+ };
286
+ };
287
+ const readQueueMessages = (sql, options = {}) => {
288
+ ensureQueueTable(sql);
289
+ const limit = Math.min(Math.max(options.limit ?? 100, 1), QUEUE_RETENTION);
290
+ const filterQueue = typeof options.queue === "string" && options.queue.length > 0 ? options.queue : void 0;
291
+ const where = filterQueue === void 0 ? "" : `WHERE queue = ?`;
292
+ const params = filterQueue === void 0 ? [limit] : [filterQueue, limit];
293
+ const rows = runSql$1(sql, `SELECT * FROM "${QUEUE_TABLE}" ${where} ORDER BY captured_at DESC, id DESC LIMIT ?`, ...params).toArray();
294
+ return { entries: rows.map((row) => rowToEntry(row)) };
295
+ };
296
+ const readQueueMessageById = (sql, id) => {
297
+ ensureQueueTable(sql);
298
+ const rows = runSql$1(sql, `SELECT * FROM "${QUEUE_TABLE}" WHERE id = ? LIMIT 1`, id).toArray();
299
+ const row = rows[0];
300
+ return row === void 0 ? void 0 : rowToEntry(row);
301
+ };
302
+ const clearQueueMessages = (sql) => {
303
+ ensureQueueTable(sql);
304
+ runSql$1(sql, `DELETE FROM "${QUEUE_TABLE}"`);
305
+ return { cleared: true };
306
+ };
307
+
308
+ const RELAY_NAME_INFIX = "::relay::";
309
+ const relayName = (ownerKey, index) => `${ownerKey}${RELAY_NAME_INFIX}${String(index)}`;
310
+ const parseRelayName = (name) => {
311
+ const at = name.lastIndexOf(RELAY_NAME_INFIX);
312
+ if (at === -1) {
313
+ return void 0;
314
+ }
315
+ const ownerKey = name.slice(0, at);
316
+ const indexText = name.slice(at + RELAY_NAME_INFIX.length);
317
+ const relayIndex = Number(indexText);
318
+ if (ownerKey.length === 0 || !Number.isInteger(relayIndex) || relayIndex < 0 || String(relayIndex) !== indexText) {
319
+ return void 0;
320
+ }
321
+ return { ownerKey, relayIndex };
322
+ };
323
+
324
+ const shapeRoutingKey = (name, args) => stableStringify({ args: args ?? {}, name });
325
+ const DEFAULT_PROMOTION_THRESHOLDS = { tDown: 4e3, tUp: 8e3 };
326
+ const nextPromotionState = (current, subscribers, thresholds = DEFAULT_PROMOTION_THRESHOLDS) => {
327
+ if (thresholds.tDown >= thresholds.tUp) {
328
+ throw new LunoraError("INTERNAL", `invalid promotion thresholds: tDown (${String(thresholds.tDown)}) must be < tUp (${String(thresholds.tUp)})`);
329
+ }
330
+ if (current === "owned") {
331
+ return subscribers >= thresholds.tUp ? "promoted" : "owned";
332
+ }
333
+ return subscribers < thresholds.tDown ? "owned" : "promoted";
334
+ };
335
+ const clampPromotionThresholds = (tUp, tDownRaw) => {
336
+ if (tDownRaw < tUp) {
337
+ return { tDown: tDownRaw, tUp };
338
+ }
339
+ return { tDown: Math.min(Math.max(1, Math.floor(tUp / 2)), tUp - 1), tUp };
340
+ };
341
+
342
+ const projectColumns = (document_, columns) => {
343
+ if (!columns) {
344
+ return document_;
345
+ }
346
+ const projected = /* @__PURE__ */ Object.create(null);
347
+ for (const key of ["_id", "_creationTime", ...columns]) {
348
+ if (Object.hasOwn(document_, key)) {
349
+ projected[key] = document_[key];
350
+ }
351
+ }
352
+ return projected;
353
+ };
354
+ const diffGlobalMembership = (rows, previous, options) => {
355
+ const { columns, table } = options;
356
+ const next = /* @__PURE__ */ new Map();
357
+ const rowsPatch = [];
358
+ for (const { doc, id } of rows) {
359
+ const value = projectColumns(doc, columns);
360
+ const json = JSON.stringify(encodeWire(value));
361
+ next.set(id, json);
362
+ const before = previous.get(id);
363
+ if (before === void 0) {
364
+ rowsPatch.push({ key: id, op: "insert", table, value });
365
+ } else if (before !== json) {
366
+ rowsPatch.push({ key: id, op: "update", table, value });
367
+ }
368
+ }
369
+ for (const id of previous.keys()) {
370
+ if (!next.has(id)) {
371
+ rowsPatch.push({ key: id, op: "delete", table });
372
+ }
373
+ }
374
+ return { next, rowsPatch };
375
+ };
376
+ const encodeRowsPatch = (rowsPatch) => rowsPatch.map((op) => op.value === void 0 ? op : { ...op, value: encodeWire(op.value) });
377
+ const buildPokeFrames = (parts, meta, options = {}) => {
378
+ const { baseCheckpoint, checkpoint, epoch, lastMutationId, pokeId } = meta;
379
+ const frames = [JSON.stringify({ baseCheckpoint, epoch, pokeId, type: "pokeStart" })];
380
+ for (const part of parts) {
381
+ const rowsPatch = options.preEncoded ? part.rowsPatch : encodeRowsPatch(part.rowsPatch);
382
+ frames.push(
383
+ JSON.stringify({
384
+ pokeId,
385
+ rowsPatch,
386
+ shapeId: part.shapeId,
387
+ type: "pokePart",
388
+ ...lastMutationId === void 0 ? {} : { lastMutationId }
389
+ })
390
+ );
391
+ }
392
+ frames.push(JSON.stringify({ checkpoint, epoch, pokeId, type: "pokeEnd" }));
393
+ return frames;
394
+ };
395
+
396
+ const DEFAULT_RELAY_FAN = 2;
397
+ const DEFAULT_MAX_RELAYS = 8;
398
+ const RELAY_SECRET_KEY = "LUNORA_RELAY_SECRET";
399
+ const RELAY_SIGNATURE_HEADER = "x-lunora-relay-sig";
400
+ const relaySecretOf = (env) => {
401
+ const value = env?.[RELAY_SECRET_KEY];
402
+ return typeof value === "string" && value.length > 0 ? value : void 0;
403
+ };
404
+ const signRelayBody = async (secret, body) => {
405
+ const encoder = new TextEncoder();
406
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { hash: "SHA-256", name: "HMAC" }, false, ["sign"]);
407
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
408
+ return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
409
+ };
410
+ const envPositiveInt = (env, key, fallback) => {
411
+ const raw = env?.[key];
412
+ let parsed = Number.NaN;
413
+ if (typeof raw === "string") {
414
+ parsed = Number.parseInt(raw, 10);
415
+ } else if (typeof raw === "number") {
416
+ parsed = raw;
417
+ }
418
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
419
+ };
420
+ const RELAY_MULTICAST_IDENTITY = {};
421
+ const assertNeverFrame = (frame) => {
422
+ throw new LunoraError("INTERNAL", `unhandled relay frame: ${JSON.stringify(frame)}`);
423
+ };
424
+ const asRelayNamespace = (value) => {
425
+ if (value === null || typeof value !== "object") {
426
+ return void 0;
427
+ }
428
+ const candidate = value;
429
+ return typeof candidate.idFromName === "function" && typeof candidate.get === "function" ? candidate : void 0;
430
+ };
431
+ const jsonRelayResponse = (body) => Response.json(body, { headers: { "content-type": "application/json" } });
432
+ const noContent = () => new Response(null, { status: 204 });
433
+ class RelayLink {
434
+ constructor(host, roleId) {
435
+ this.host = host;
436
+ this.roleId = roleId;
437
+ }
438
+ host;
439
+ roleId;
440
+ /**
441
+ * Serve the internal `/_lunora/relay` control channel: parse the frame, then
442
+ * dispatch to the role hooks. One exhaustive switch means a new frame type added
443
+ * to `relay.ts` without a case here is a COMPILE error, not a runtime mis-route.
444
+ */
445
+ async handleControl(request) {
446
+ let raw;
447
+ try {
448
+ raw = await request.text();
449
+ } catch {
450
+ return new Response("bad request", { status: 400 });
451
+ }
452
+ const secret = relaySecretOf(this.host.env());
453
+ if (secret !== void 0) {
454
+ const supplied = request.headers.get(RELAY_SIGNATURE_HEADER);
455
+ const expected = await signRelayBody(secret, raw);
456
+ if (supplied === null || !constantTimeEqual(supplied, expected)) {
457
+ return new Response("forbidden", { status: 403 });
458
+ }
459
+ }
460
+ let message;
461
+ try {
462
+ message = JSON.parse(raw);
463
+ } catch {
464
+ return new Response("bad request", { status: 400 });
465
+ }
466
+ switch (message.type) {
467
+ case "relay_attach": {
468
+ this.onAttach(message.relayIndex);
469
+ return noContent();
470
+ }
471
+ case "relay_detach": {
472
+ this.onDetach(message.relayIndex);
473
+ return noContent();
474
+ }
475
+ case "relay_frame": {
476
+ this.host.deliverWhisperLocal(message.topic, message.frame, void 0);
477
+ await this.onWhisperFrame(message);
478
+ return noContent();
479
+ }
480
+ case "relay_shape_poke": {
481
+ const iterated = this.host.getWebSockets().length;
482
+ const startMs = Date.now();
483
+ const delivered = this.onShapePoke(message);
484
+ this.host.recordShapePokeFanout(iterated, delivered, Date.now() - startMs);
485
+ return noContent();
486
+ }
487
+ case "relay_shape_subscribe": {
488
+ return jsonRelayResponse(this.onShapeSubscribe(message));
489
+ }
490
+ default: {
491
+ return assertNeverFrame(message);
492
+ }
493
+ }
494
+ }
495
+ /** The hard cap on relays per shard (`LUNORA_MAX_RELAYS`) — a deployment constant the runtime surfaces in Studio as the cost ceiling. */
496
+ maxRelays() {
497
+ return envPositiveInt(this.host.env(), "LUNORA_MAX_RELAYS", DEFAULT_MAX_RELAYS);
498
+ }
499
+ /** Whether this DO can currently address its siblings (a namespace binding has been learned) — the relay tier is inert in single-DO mode. */
500
+ canAddressSiblings() {
501
+ return this.relayNamespace() !== void 0;
502
+ }
503
+ /** Resolve this DO's own namespace binding so it can address sibling owners/relays, or `undefined` when unknown. */
504
+ relayNamespace() {
505
+ const binding = this.host.shardBinding();
506
+ if (binding === void 0) {
507
+ return void 0;
508
+ }
509
+ return asRelayNamespace(this.host.env()?.[binding]);
510
+ }
511
+ /** 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. */
512
+ async postRelayMessage(targetName, message) {
513
+ await this.requestRelayMessage(targetName, message);
514
+ }
515
+ /**
516
+ * POST a control frame to a sibling by name and return its response (the shape-seed
517
+ * path needs the owner's frames back). Best-effort: a transient cross-DO failure
518
+ * returns `undefined` rather than throwing.
519
+ * @returns the sibling's response, or `undefined` when it can't be reached
520
+ */
521
+ async requestRelayMessage(targetName, message) {
522
+ const namespace = this.relayNamespace();
523
+ if (namespace === void 0) {
524
+ return void 0;
525
+ }
526
+ const stub = typeof namespace.getByName === "function" ? namespace.getByName(targetName) : namespace.get(namespace.idFromName(targetName));
527
+ const body = JSON.stringify(message);
528
+ const headers = { "content-type": "application/json", "x-lunora-shard-binding": this.host.shardBinding() ?? "" };
529
+ const secret = relaySecretOf(this.host.env());
530
+ if (secret !== void 0) {
531
+ headers[RELAY_SIGNATURE_HEADER] = await signRelayBody(secret, body);
532
+ }
533
+ try {
534
+ return await stub.fetch("https://relay.internal/_lunora/relay", {
535
+ body,
536
+ headers,
537
+ method: "POST"
538
+ });
539
+ } catch {
540
+ return void 0;
541
+ }
542
+ }
543
+ }
544
+ class OwnerRelay extends RelayLink {
545
+ /** Memoized RLS-uniform verdict per `(name, args)` shape — uniformity is stable, so the gate probe runs at most once per distinct shape. */
546
+ shapeUniformCache = /* @__PURE__ */ new Map();
547
+ /** Active relay indices, hydrated once from `__lunora_relays` and cached for the synchronous forward path. */
548
+ relaySetCache;
549
+ /** Relay-uniform shapes a relay has subscribers for, keyed by `(name, args)`. `cursor` is the cohort frontier the owner has multicast deltas up to. */
550
+ relayShapeRegistry = /* @__PURE__ */ new Map();
551
+ /** 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. */
552
+ relayShapeProxies = /* @__PURE__ */ new Map();
553
+ /** 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. */
554
+ promotionState = "owned";
555
+ constructor(host, ownerKey) {
556
+ super(host, { ownerKey });
557
+ }
558
+ async forwardWhisper(topic, frame) {
559
+ if (!this.canAddressSiblings()) {
560
+ return;
561
+ }
562
+ const relays = this.ownerRelaySet();
563
+ if (relays.size === 0) {
564
+ return;
565
+ }
566
+ await Promise.all([...relays].map((index) => this.postRelayMessage(relayName(this.roleId.ownerKey, index), { frame, topic, type: "relay_frame" })));
567
+ }
568
+ async onFlush(changed, frameCursor) {
569
+ await Promise.all([this.multicastShapePokes(changed, frameCursor), this.proxyShapePokes(changed, frameCursor)]);
570
+ }
571
+ // eslint-disable-next-line class-methods-use-this -- role hook: an owner serves its own shape subscribers locally, never through a relay
572
+ seedRelayShape() {
573
+ return Promise.resolve(void 0);
574
+ }
575
+ // eslint-disable-next-line class-methods-use-this -- role hook: only a relay announces
576
+ announce() {
577
+ return Promise.resolve();
578
+ }
579
+ // eslint-disable-next-line class-methods-use-this -- role hook: only a relay drains
580
+ announceDrain() {
581
+ return Promise.resolve();
582
+ }
583
+ /**
584
+ * How many relays the runtime should spread new connections across for this shard
585
+ * (plan 075 Phase 2, hysteresis added in Phase 4). `0` keeps every connection on
586
+ * the owner. The owner promotes once its live socket count reaches
587
+ * `LUNORA_RELAY_THRESHOLD` (`tUp`), fanning to a fixed `LUNORA_RELAY_FAN` (capped
588
+ * by `LUNORA_MAX_RELAYS`, the cost ceiling), and only collapses back to
589
+ * owner-served once subscribers drain below `LUNORA_RELAY_COLLAPSE_THRESHOLD`
590
+ * (`tDown`) — the band between the two holds the current state so a shard
591
+ * hovering near the threshold can't flap. {@link clampPromotionThresholds}
592
+ * guarantees a valid `tDown < tUp` band even under a misconfigured collapse
593
+ * threshold. Advances the promotion latch, so it is not a pure read.
594
+ */
595
+ relayCount() {
596
+ const subscribers = this.host.getWebSockets().length;
597
+ const tUp = envPositiveInt(this.host.env(), "LUNORA_RELAY_THRESHOLD", DEFAULT_PROMOTION_THRESHOLDS.tUp);
598
+ const tDownRaw = envPositiveInt(this.host.env(), "LUNORA_RELAY_COLLAPSE_THRESHOLD", DEFAULT_PROMOTION_THRESHOLDS.tDown);
599
+ this.promotionState = nextPromotionState(this.promotionState, subscribers, clampPromotionThresholds(tUp, tDownRaw));
600
+ if (this.promotionState === "owned") {
601
+ return 0;
602
+ }
603
+ const maxRelays = envPositiveInt(this.host.env(), "LUNORA_MAX_RELAYS", DEFAULT_MAX_RELAYS);
604
+ const fan = envPositiveInt(this.host.env(), "LUNORA_RELAY_FAN", DEFAULT_RELAY_FAN);
605
+ return Math.min(maxRelays, Math.max(1, fan));
606
+ }
607
+ /**
608
+ * The RLS-uniform gate (plan 075 Phase 3, review-hardened): whether a reactive
609
+ * shape may be relay-multicast — i.e. one delta is correct for **every**
610
+ * subscriber. Fail-closed on four grounds — a static RLS read-policy guard, the
611
+ * anonymous multicast identity as the probe base, two `Proxy`-backed probes that
612
+ * yield a distinct value for ANY accessed claim (so any claim a `where` reads —
613
+ * even a custom one outside `rls()` — diverges), and a wholesale-copy backstop.
614
+ * Cached per `(name, args)`, whose uniformity is stable.
615
+ */
616
+ isShapeRelayUniform(name, args) {
617
+ const cacheKey = shapeRoutingKey(name, args);
618
+ const cached = this.shapeUniformCache.get(cacheKey);
619
+ if (cached !== void 0) {
620
+ return cached;
621
+ }
622
+ const uniform = this.probeShapeRelayUniform(name, args);
623
+ this.shapeUniformCache.set(cacheKey, uniform);
624
+ return uniform;
625
+ }
626
+ onAttach(index) {
627
+ this.addRelayToSet(index);
628
+ }
629
+ onDetach(index) {
630
+ this.removeRelayFromSet(index);
631
+ }
632
+ async onWhisperFrame(message) {
633
+ await Promise.all(
634
+ [...this.ownerRelaySet()].filter((index) => index !== message.originRelay).map(
635
+ (index) => this.postRelayMessage(relayName(this.roleId.ownerKey, index), { frame: message.frame, topic: message.topic, type: "relay_frame" })
636
+ )
637
+ );
638
+ }
639
+ onShapeSubscribe(message) {
640
+ return this.buildShapeSeedFrames(message);
641
+ }
642
+ // eslint-disable-next-line class-methods-use-this -- role hook: an owner doesn't receive multicast pokes (it sends them)
643
+ onShapePoke() {
644
+ return 0;
645
+ }
646
+ /**
647
+ * Owner side (slice B.2): for every registered relay-uniform shape whose table
648
+ * changed this flush, compute the membership diff ONCE over `(cohort cursor,
649
+ * frameCursor]` and multicast the `rowsPatch` to every relay. Advances the cohort
650
+ * cursor synchronously (before any await) so a seed interleaving during the
651
+ * multicast registers at `frameCursor` and is skipped by this in-flight poke.
652
+ */
653
+ async multicastShapePokes(changed, frameCursor) {
654
+ if (this.relayShapeRegistry.size === 0) {
655
+ return;
656
+ }
657
+ const relays = this.ownerRelaySet();
658
+ if (relays.size === 0) {
659
+ return;
660
+ }
661
+ const epoch = this.host.currentCdcEpoch();
662
+ const sends = [];
663
+ for (const entry of this.relayShapeRegistry.values()) {
664
+ let resolved;
665
+ try {
666
+ resolved = this.host.resolveShape(entry.name, entry.args, RELAY_MULTICAST_IDENTITY);
667
+ } catch {
668
+ continue;
669
+ }
670
+ if (resolved === void 0 || resolved.global === true || !changed.has(resolved.table)) {
671
+ continue;
672
+ }
673
+ const fromCursor = entry.cursor;
674
+ const rowsPatch = this.host.buildShapeDiff(resolved, fromCursor, frameCursor);
675
+ if (rowsPatch.length === 0) {
676
+ continue;
677
+ }
678
+ entry.cursor = frameCursor;
679
+ const poke = {
680
+ args: entry.args,
681
+ checkpoint: frameCursor,
682
+ epoch,
683
+ fromCursor,
684
+ name: entry.name,
685
+ // Wire-encode before the poke crosses the owner→relay `JSON.stringify`
686
+ // hop (`requestRelayMessage`); the relay re-frames it with
687
+ // `preEncoded` so a `bytes`/`bigint` shape column isn't dropped/truncated.
688
+ rowsPatch: encodeRowsPatch(rowsPatch),
689
+ type: "relay_shape_poke"
690
+ };
691
+ for (const index of relays) {
692
+ sends.push(this.postRelayMessage(relayName(this.roleId.ownerKey, index), poke));
693
+ }
694
+ }
695
+ await Promise.all(sends);
696
+ }
697
+ /**
698
+ * Owner side (review MEDIUM-3): for every NON-uniform relay-shape proxy whose
699
+ * table changed this flush, compute that one subscriber's diff over `(entry.cursor,
700
+ * frameCursor]` UNDER ITS OWN forwarded identity (RLS-correct) and deliver a
701
+ * `targetConnectionId`-addressed poke to just that socket's relay. Each entry
702
+ * tracks its own cursor — the diffs are identity-specific, no cohort sharing.
703
+ */
704
+ async proxyShapePokes(changed, frameCursor) {
705
+ if (this.relayShapeProxies.size === 0) {
706
+ return;
707
+ }
708
+ const epoch = this.host.currentCdcEpoch();
709
+ const sends = [];
710
+ for (const entry of this.relayShapeProxies.values()) {
711
+ let resolved;
712
+ try {
713
+ resolved = this.host.resolveShape(entry.name, entry.args, entry.identity);
714
+ } catch {
715
+ continue;
716
+ }
717
+ if (resolved === void 0 || resolved.global === true || !changed.has(resolved.table)) {
718
+ continue;
719
+ }
720
+ const fromCursor = entry.cursor;
721
+ const rowsPatch = this.host.buildShapeDiff(resolved, fromCursor, frameCursor);
722
+ if (rowsPatch.length === 0) {
723
+ continue;
724
+ }
725
+ entry.cursor = frameCursor;
726
+ const poke = {
727
+ args: entry.args,
728
+ checkpoint: frameCursor,
729
+ epoch,
730
+ fromCursor,
731
+ name: entry.name,
732
+ // Wire-encode before the owner→relay `JSON.stringify` hop (see the
733
+ // cohort-multicast path); the relay re-frames it with `preEncoded`.
734
+ rowsPatch: encodeRowsPatch(rowsPatch),
735
+ targetConnectionId: entry.connectionId,
736
+ type: "relay_shape_poke"
737
+ };
738
+ sends.push(this.postRelayMessage(relayName(this.roleId.ownerKey, entry.relayIndex), poke));
739
+ }
740
+ await Promise.all(sends);
741
+ }
742
+ /**
743
+ * Serialize a shape's seed poke frames for a relay to deliver verbatim. Resolves
744
+ * under the forwarded socket identity (so RLS applies exactly as for a local
745
+ * subscribe), self-heals the relay set, registers the shape for live updates
746
+ * (cohort multicast when uniform, per-socket proxy when not), and stamps the
747
+ * relay's cohort memo at the registry FRONTIER (not the global cursor) so a late
748
+ * joiner is never stranded. `lastMutationId` is omitted (relayed sockets are
749
+ * owner-served for custom mutators).
750
+ * @returns the serialized frames + the cohort-memo cursor, or an error
751
+ */
752
+ buildShapeSeedFrames(request) {
753
+ const identity = { identity: request.identity, userId: request.userId };
754
+ let resolved;
755
+ try {
756
+ resolved = this.host.resolveShape(request.name, request.args, identity);
757
+ } catch (error) {
758
+ const { body } = toErrorBody(error, { fallbackCode: "SHAPE_RESOLVE_FAILED", redactedMessage: "shape resolution failed" });
759
+ return { error: { code: body.code, message: body.message } };
760
+ }
761
+ if (resolved === void 0 || resolved.global === true) {
762
+ return { error: { code: "SHAPE_NOT_FOUND", message: `shape not relayable: ${request.name}` } };
763
+ }
764
+ if (request.relayIndex !== void 0) {
765
+ this.addRelayToSet(request.relayIndex);
766
+ }
767
+ const { baseCheckpoint, cursor, epoch, rowsPatch } = this.host.computeOpLogShapeSeed(
768
+ { args: request.args, name: request.name, sinceEpoch: request.sinceEpoch, sinceSeq: request.sinceSeq },
769
+ resolved
770
+ );
771
+ let cohortCursor = cursor;
772
+ if (this.isShapeRelayUniform(request.name, request.args)) {
773
+ const routingKey = shapeRoutingKey(request.name, request.args);
774
+ let entry = this.relayShapeRegistry.get(routingKey);
775
+ if (entry === void 0) {
776
+ entry = { args: request.args, cursor, name: request.name };
777
+ this.relayShapeRegistry.set(routingKey, entry);
778
+ }
779
+ cohortCursor = entry.cursor;
780
+ } else if (request.relayIndex !== void 0 && request.connectionId !== void 0) {
781
+ this.relayShapeProxies.set(`${String(request.relayIndex)}:${request.connectionId}:${request.subId}`, {
782
+ args: request.args,
783
+ connectionId: request.connectionId,
784
+ cursor,
785
+ epoch,
786
+ identity,
787
+ name: request.name,
788
+ relayIndex: request.relayIndex,
789
+ subId: request.subId
790
+ });
791
+ }
792
+ const frames = buildPokeFrames([{ rowsPatch, shapeId: request.subId }], {
793
+ baseCheckpoint,
794
+ checkpoint: cursor,
795
+ epoch,
796
+ lastMutationId: void 0,
797
+ pokeId: this.host.nextPokeId()
798
+ });
799
+ return { cursor: cohortCursor, epoch, frames };
800
+ }
801
+ /** Ensure the reserved owner-side relay-set table exists (auto-hidden from the data browser by the `__lunora` prefix). */
802
+ ensureRelayTable() {
803
+ this.host.sql().exec("CREATE TABLE IF NOT EXISTS __lunora_relays (idx INTEGER PRIMARY KEY)");
804
+ }
805
+ /** The owner's active relay indices, hydrated once from `__lunora_relays` and cached for the synchronous forward path. */
806
+ ownerRelaySet() {
807
+ if (this.relaySetCache === void 0) {
808
+ this.ensureRelayTable();
809
+ const rows = this.host.sql().exec("SELECT idx FROM __lunora_relays").toArray();
810
+ this.relaySetCache = new Set(rows.map((row) => Number(row.idx)));
811
+ }
812
+ return this.relaySetCache;
813
+ }
814
+ /** Record a relay as active (idempotent), persisting it so the set survives the owner's hibernation. */
815
+ addRelayToSet(index) {
816
+ this.ensureRelayTable();
817
+ this.host.sql().exec("INSERT OR IGNORE INTO __lunora_relays (idx) VALUES (?)", index);
818
+ this.ownerRelaySet().add(index);
819
+ }
820
+ /** 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. */
821
+ removeRelayFromSet(index) {
822
+ this.ensureRelayTable();
823
+ this.host.sql().exec("DELETE FROM __lunora_relays WHERE idx = ?", index);
824
+ const set = this.ownerRelaySet();
825
+ set.delete(index);
826
+ for (const [key, entry] of this.relayShapeProxies) {
827
+ if (entry.relayIndex === index) {
828
+ this.relayShapeProxies.delete(key);
829
+ }
830
+ }
831
+ if (set.size === 0) {
832
+ this.relayShapeRegistry.clear();
833
+ this.shapeUniformCache.clear();
834
+ }
835
+ }
836
+ /**
837
+ * The one-shot computation behind {@link OwnerRelay.isShapeRelayUniform}, made
838
+ * sound against the cross-identity row-leak (review). Resolves under the anonymous
839
+ * multicast identity (the base) plus two `Proxy`-backed identities that return a
840
+ * distinct value for ANY accessed claim, requires all to agree on table + where +
841
+ * columns, rejects any table with an RLS read policy or ANY masked column
842
+ * defined (even if unprojected — see {@link OwnerRelay.tableHasAnyMask}), and
843
+ * fails closed if the claims are enumerated (a wholesale copy the proxy can't
844
+ * differentiate).
845
+ */
846
+ probeShapeRelayUniform(name, args) {
847
+ let base;
848
+ try {
849
+ base = this.host.resolveShape(name, args, RELAY_MULTICAST_IDENTITY);
850
+ } catch {
851
+ return false;
852
+ }
853
+ if (base === void 0 || base.global === true) {
854
+ return false;
855
+ }
856
+ if (this.host.rlsMetadata().policies.some((policy) => policy.on === "read" && policy.table === base.table)) {
857
+ return false;
858
+ }
859
+ if (this.tableHasAnyMask(base.table)) {
860
+ return false;
861
+ }
862
+ const baseWhere = stableStringify(base.effectiveWhere);
863
+ const baseColumns = stableStringify(base.columns);
864
+ let enumerated = false;
865
+ const populate = (side) => {
866
+ const backing = { groups: [`grp_${side}`], roles: [side], sub: `__lunora_probe_${side}__` };
867
+ const claims = /* @__PURE__ */ new Proxy(backing, {
868
+ get: (target, key) => {
869
+ if (typeof key === "symbol" || key in target) {
870
+ return Reflect.get(target, key);
871
+ }
872
+ return `${side}:${key}`;
873
+ },
874
+ getOwnPropertyDescriptor: (target, key) => {
875
+ enumerated = true;
876
+ return Reflect.getOwnPropertyDescriptor(target, key);
877
+ },
878
+ has: (target, key) => typeof key === "symbol" ? Reflect.has(target, key) : true,
879
+ ownKeys: (target) => {
880
+ enumerated = true;
881
+ return Reflect.ownKeys(target);
882
+ }
883
+ });
884
+ return { identity: claims, userId: `__lunora_probe_${side}__` };
885
+ };
886
+ const matches = [RELAY_MULTICAST_IDENTITY, populate("a"), populate("b")].every((probe) => {
887
+ let resolved;
888
+ try {
889
+ resolved = this.host.resolveShape(name, args, probe);
890
+ } catch {
891
+ return false;
892
+ }
893
+ return resolved !== void 0 && resolved.global !== true && resolved.table === base.table && stableStringify(resolved.effectiveWhere) === baseWhere && stableStringify(resolved.columns) === baseColumns;
894
+ });
895
+ return matches && !enumerated;
896
+ }
897
+ /**
898
+ * Whether `table` has ANY masked column defined (L7). Deliberately conservative:
899
+ * we disqualify a shape from relay-multicast if its table declares any mask at
900
+ * all, even when the current query doesn't project the masked column. A masked
901
+ * value is identity-dependent, and keying uniformity off the *projected* set
902
+ * meant a later column addition (or a `select` change) could silently widen a
903
+ * cohort to include an identity-dependent value. Refusing on any table-level
904
+ * mask removes that footgun; the cost is a few extra shapes falling back to the
905
+ * per-identity path, which is always correct.
906
+ */
907
+ tableHasAnyMask(table) {
908
+ return this.host.maskMetadata().columns.some((entry) => entry.table === table);
909
+ }
910
+ }
911
+ class RelayMember extends RelayLink {
912
+ /** `true` once this relay has announced itself to its owner this wake, so a hot socket churn doesn't re-attach on every subscribe. */
913
+ relayAnnounced = false;
914
+ /** 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`. */
915
+ shapeRelayMemos = /* @__PURE__ */ new WeakMap();
916
+ constructor(host, ownerKey, relayIndex) {
917
+ super(host, { ownerKey, relayIndex });
918
+ }
919
+ async forwardWhisper(topic, frame) {
920
+ if (!this.canAddressSiblings()) {
921
+ return;
922
+ }
923
+ await this.postRelayMessage(this.roleId.ownerKey, { frame, originRelay: this.roleId.relayIndex, topic, type: "relay_frame" });
924
+ }
925
+ // eslint-disable-next-line class-methods-use-this -- role hook: a relay receives no writes, so it never flushes its own CDC
926
+ onFlush() {
927
+ return Promise.resolve();
928
+ }
929
+ /**
930
+ * Seed a shape held by a socket on this relay by forwarding the request to the
931
+ * owner: the owner resolves under this socket's verified identity and computes the
932
+ * seed frames, which the relay delivers verbatim. Returns a structured error
933
+ * (surfaced as a `shape_subscribe` error) when the owner can't be reached.
934
+ */
935
+ async seedRelayShape(ws, subId, shape, identity) {
936
+ if (!this.canAddressSiblings()) {
937
+ return { code: "RELAY_MISCONFIGURED", message: "relay cannot address its owner" };
938
+ }
939
+ await this.announce();
940
+ const request = {
941
+ args: shape.args ?? {},
942
+ connectionId: this.host.readAttachment(ws).connectionId,
943
+ identity: identity.identity,
944
+ name: shape.name,
945
+ relayIndex: this.roleId.relayIndex,
946
+ sinceEpoch: shape.sinceEpoch,
947
+ sinceSeq: shape.sinceSeq,
948
+ subId,
949
+ type: "relay_shape_subscribe",
950
+ userId: identity.userId
951
+ };
952
+ const response = await this.requestRelayMessage(this.roleId.ownerKey, request);
953
+ if (response === void 0) {
954
+ return { code: "RELAY_SEED_FAILED", message: "owner did not answer the shape seed" };
955
+ }
956
+ let seed;
957
+ try {
958
+ seed = await response.json();
959
+ } catch {
960
+ return { code: "RELAY_SEED_FAILED", message: "malformed shape seed from owner" };
961
+ }
962
+ if (seed.error !== void 0) {
963
+ return seed.error;
964
+ }
965
+ if (seed.frames === void 0) {
966
+ return { code: "RELAY_SEED_FAILED", message: "owner returned no shape frames" };
967
+ }
968
+ await awaitWsDrain(ws);
969
+ for (const frame of seed.frames) {
970
+ trySendFrame(ws, frame);
971
+ }
972
+ this.recordRelayShapeMemo(ws, subId, seed.cursor ?? 0, seed.epoch);
973
+ return "ok";
974
+ }
975
+ /** 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). */
976
+ async announce() {
977
+ if (this.relayAnnounced || !this.canAddressSiblings()) {
978
+ return;
979
+ }
980
+ this.relayAnnounced = true;
981
+ const response = await this.requestRelayMessage(this.roleId.ownerKey, { relayIndex: this.roleId.relayIndex, type: "relay_attach" });
982
+ if (!response?.ok) {
983
+ this.relayAnnounced = false;
984
+ }
985
+ }
986
+ /** 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. */
987
+ async announceDrain(closing) {
988
+ if (!this.canAddressSiblings()) {
989
+ return;
990
+ }
991
+ if (this.host.getWebSockets().some((ws) => ws !== closing)) {
992
+ return;
993
+ }
994
+ this.relayAnnounced = false;
995
+ await this.postRelayMessage(this.roleId.ownerKey, { relayIndex: this.roleId.relayIndex, type: "relay_detach" });
996
+ }
997
+ // eslint-disable-next-line class-methods-use-this -- role hook: a relay never spreads connections (flat single tier)
998
+ relayCount() {
999
+ return 0;
1000
+ }
1001
+ // eslint-disable-next-line class-methods-use-this -- role hook: the RLS-uniform gate is an owner concern
1002
+ isShapeRelayUniform() {
1003
+ return false;
1004
+ }
1005
+ // eslint-disable-next-line class-methods-use-this -- role hook: only an owner tracks a relay set
1006
+ onAttach() {
1007
+ }
1008
+ // eslint-disable-next-line class-methods-use-this -- role hook: only an owner tracks a relay set
1009
+ onDetach() {
1010
+ }
1011
+ // eslint-disable-next-line class-methods-use-this -- role hook: only an owner re-distributes a forwarded whisper
1012
+ onWhisperFrame() {
1013
+ return Promise.resolve();
1014
+ }
1015
+ // eslint-disable-next-line class-methods-use-this -- role hook: a relay can't seed (no op-log) — the owner does
1016
+ onShapeSubscribe() {
1017
+ return { error: { code: "RELAY_CANNOT_SEED", message: "a relay has no op-log to seed from" } };
1018
+ }
1019
+ onShapePoke(poke) {
1020
+ return this.deliverShapePoke(poke);
1021
+ }
1022
+ /** Record a relay socket's cohort cursor + epoch for `subId` (creating the per-socket map lazily). */
1023
+ recordRelayShapeMemo(ws, subId, cursor, epoch) {
1024
+ let memos = this.shapeRelayMemos.get(ws);
1025
+ if (memos === void 0) {
1026
+ memos = /* @__PURE__ */ new Map();
1027
+ this.shapeRelayMemos.set(ws, memos);
1028
+ }
1029
+ memos.set(subId, { cursor, epoch });
1030
+ }
1031
+ /**
1032
+ * Deliver an owner-multicast shape delta to this relay's cohort sockets. A socket
1033
+ * receives it only while its memo matches the poke's `fromCursor` AND `epoch` (so a
1034
+ * socket that seeded at a different cursor/epoch never double-applies), then
1035
+ * advances to `checkpoint`. A targeted (per-socket proxy) poke goes ONLY to its one
1036
+ * connection; a cohort multicast goes to every matching socket.
1037
+ * @returns the number of sockets delivered to
1038
+ */
1039
+ deliverShapePoke(poke) {
1040
+ const routingKey = shapeRoutingKey(poke.name, poke.args);
1041
+ let delivered = 0;
1042
+ for (const ws of this.host.getWebSockets()) {
1043
+ const attachment = this.host.readAttachment(ws);
1044
+ const { shapes } = attachment;
1045
+ const memos = this.shapeRelayMemos.get(ws);
1046
+ if (shapes === void 0 || memos === void 0) {
1047
+ continue;
1048
+ }
1049
+ if (poke.targetConnectionId !== void 0 && attachment.connectionId !== poke.targetConnectionId) {
1050
+ continue;
1051
+ }
1052
+ for (const [subId, sub] of Object.entries(shapes)) {
1053
+ const memo = memos.get(subId);
1054
+ if (memo?.cursor !== poke.fromCursor || memo.epoch !== poke.epoch || shapeRoutingKey(sub.name, sub.args) !== routingKey) {
1055
+ continue;
1056
+ }
1057
+ const frames = buildPokeFrames(
1058
+ [{ rowsPatch: poke.rowsPatch, shapeId: subId }],
1059
+ {
1060
+ baseCheckpoint: void 0,
1061
+ checkpoint: poke.checkpoint,
1062
+ epoch: poke.epoch,
1063
+ lastMutationId: void 0,
1064
+ pokeId: this.host.nextPokeId()
1065
+ },
1066
+ // `poke.rowsPatch` was wire-encoded by the owner before it crossed
1067
+ // the hub — don't double-encode it here.
1068
+ { preEncoded: true }
1069
+ );
1070
+ for (const frame of frames) {
1071
+ trySendFrame(ws, frame);
1072
+ }
1073
+ memos.set(subId, { cursor: poke.checkpoint, epoch: poke.epoch });
1074
+ delivered += 1;
1075
+ }
1076
+ }
1077
+ return delivered;
1078
+ }
1079
+ }
1080
+ const createRelayLink = (host) => {
1081
+ const name = host.doName();
1082
+ if (name === void 0) {
1083
+ return void 0;
1084
+ }
1085
+ const parsed = parseRelayName(name);
1086
+ return parsed === void 0 ? new OwnerRelay(host, name) : new RelayMember(host, parsed.ownerKey, parsed.relayIndex);
1087
+ };
1088
+
145
1089
  const REQUEST_LOG_TABLE = "__lunora_reqlog__";
146
1090
  const REQUEST_LOG_RETENTION = 1e3;
147
1091
  const REQUEST_LOG_EVENT_SOURCE = "lunora";
@@ -348,6 +1292,69 @@ const readRequestLog = (sql, options = {}) => {
348
1292
  return base;
349
1293
  });
350
1294
  };
1295
+ const readErrorIssues = (sql, options = {}) => {
1296
+ ensureRequestLogTable(sql);
1297
+ const limit = Math.max(1, Math.min(options.limit ?? REQUEST_LOG_RETENTION, 1e4));
1298
+ const conjuncts = ["outcome = 'error'"];
1299
+ const parameters = [];
1300
+ if (options.functionPathPrefix !== void 0 && options.functionPathPrefix !== "") {
1301
+ conjuncts.push(String.raw`function_path LIKE ? ESCAPE '\'`);
1302
+ parameters.push(`${escapeLike(options.functionPathPrefix)}%`);
1303
+ }
1304
+ if (options.userId !== void 0 && options.userId !== "") {
1305
+ conjuncts.push("user_id = ?");
1306
+ parameters.push(options.userId);
1307
+ }
1308
+ if (options.shardKey !== void 0 && options.shardKey !== "") {
1309
+ conjuncts.push("shard_key = ?");
1310
+ parameters.push(options.shardKey);
1311
+ }
1312
+ parameters.push(limit);
1313
+ const rows = runSql(
1314
+ sql,
1315
+ `SELECT function_path, error_message, ts
1316
+ FROM "${REQUEST_LOG_TABLE}" WHERE ${conjuncts.join(" AND ")} ORDER BY seq DESC LIMIT ?`,
1317
+ ...parameters
1318
+ ).toArray();
1319
+ const issues = /* @__PURE__ */ new Map();
1320
+ const sampleTs = /* @__PURE__ */ new Map();
1321
+ for (const row of rows) {
1322
+ const message = row.error_message ?? "";
1323
+ const { culprit, hash, title } = fingerprintError({ functionPath: row.function_path, message });
1324
+ const existing = issues.get(hash);
1325
+ if (existing === void 0) {
1326
+ issues.set(hash, { count: 1, culprit, firstSeen: row.ts, hash, lastSeen: row.ts, sampleMessage: message, title });
1327
+ sampleTs.set(hash, row.ts);
1328
+ continue;
1329
+ }
1330
+ existing.count += 1;
1331
+ existing.firstSeen = Math.min(existing.firstSeen, row.ts);
1332
+ existing.lastSeen = Math.max(existing.lastSeen, row.ts);
1333
+ if (row.ts > (sampleTs.get(hash) ?? Number.NEGATIVE_INFINITY)) {
1334
+ sampleTs.set(hash, row.ts);
1335
+ existing.sampleMessage = message;
1336
+ existing.title = title;
1337
+ }
1338
+ }
1339
+ return [...issues.values()].toSorted((a, b) => b.lastSeen - a.lastSeen);
1340
+ };
1341
+
1342
+ const runSocketPool = async (items, processOne, concurrency = 8) => {
1343
+ let cursor = 0;
1344
+ const worker = async () => {
1345
+ let item = items[cursor];
1346
+ cursor += 1;
1347
+ while (item !== void 0) {
1348
+ try {
1349
+ await processOne(item);
1350
+ } catch {
1351
+ }
1352
+ item = items[cursor];
1353
+ cursor += 1;
1354
+ }
1355
+ };
1356
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
1357
+ };
351
1358
 
352
1359
  const DANGLING_SCAN_CAP = 5e3;
353
1360
  const DANGLING_RESULT_CAP = 500;
@@ -413,116 +1420,13 @@ const findDanglingReferences = (sql, storageColumns, liveKeys) => {
413
1420
 
414
1421
  const WS_KEEPALIVE_PING = "lunora-ping";
415
1422
  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
- };
1423
+ const UNDELIVERED_BASELINE = "<undelivered>";
507
1424
  const ROOT_DO_SIZE_WARN_BYTES = 1073741824;
508
1425
  const CDC_RESUME_SCAN_LIMIT = 1e4;
509
1426
  const IDEMPOTENCY_RETENTION_MS = 864e5;
510
1427
  const IDEMPOTENCY_GC_INTERVAL_MS = 36e5;
511
1428
  const ROOT_SHARD_NAME = "__root__";
512
1429
  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
1430
  const cdcSuffix = (cursor, epoch) => (cursor === void 0 ? "" : `,"cursor":${String(cursor)}`) + (epoch === void 0 ? "" : `,"epoch":${JSON.stringify(epoch)}`);
527
1431
  const setsIntersect = (a, b) => {
528
1432
  const [small, large] = a.size <= b.size ? [a, b] : [b, a];
@@ -536,7 +1440,7 @@ const setsIntersect = (a, b) => {
536
1440
  const parseRunMigrationArgs = (args) => {
537
1441
  const id = typeof args["id"] === "string" ? args["id"] : "";
538
1442
  if (id.trim() === "") {
539
- throw Object.assign(new Error("runMigration: `id` is required"), { code: "MIGRATION_ID_REQUIRED", name: "LunoraError", status: 400 });
1443
+ throw new LunoraError("MIGRATION_ID_REQUIRED", "runMigration: `id` is required", { status: 400 });
540
1444
  }
541
1445
  return {
542
1446
  batchSize: typeof args["batchSize"] === "number" ? args["batchSize"] : void 0,
@@ -551,25 +1455,25 @@ const parseWriteRowArgs = (args) => {
551
1455
  const { op } = args;
552
1456
  const table = typeof args["table"] === "string" ? args["table"] : "";
553
1457
  if (op !== "insert" && op !== "patch" && op !== "replace" && op !== "delete") {
554
- throw Object.assign(new Error("writeRow: `op` must be insert|patch|replace|delete"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1458
+ throw new LunoraError("BAD_REQUEST", "writeRow: `op` must be insert|patch|replace|delete");
555
1459
  }
556
1460
  if (table.trim() === "") {
557
- throw Object.assign(new Error("writeRow: `table` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1461
+ throw new LunoraError("BAD_REQUEST", "writeRow: `table` is required");
558
1462
  }
559
1463
  const id = typeof args["id"] === "string" ? args["id"] : void 0;
560
1464
  const record = typeof args["doc"] === "object" && args["doc"] !== null && !Array.isArray(args["doc"]) ? args["doc"] : void 0;
561
1465
  if (op !== "insert" && (id === void 0 || id === "")) {
562
- throw Object.assign(new Error(`writeRow: \`id\` is required for op "${op}"`), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1466
+ throw new LunoraError("BAD_REQUEST", `writeRow: \`id\` is required for op "${op}"`);
563
1467
  }
564
1468
  if (op !== "delete" && record === void 0) {
565
- throw Object.assign(new Error(`writeRow: \`doc\` is required for op "${op}"`), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1469
+ throw new LunoraError("BAD_REQUEST", `writeRow: \`doc\` is required for op "${op}"`);
566
1470
  }
567
1471
  return { doc: record, id, op, table };
568
1472
  };
569
1473
  const parseCreateWorkflowInstanceArgs = (args) => {
570
1474
  const exportName = typeof args["exportName"] === "string" ? args["exportName"].trim() : "";
571
1475
  if (exportName === "") {
572
- throw Object.assign(new Error("createWorkflowInstance: `exportName` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1476
+ throw new LunoraError("BAD_REQUEST", "createWorkflowInstance: `exportName` is required");
573
1477
  }
574
1478
  const id = typeof args["id"] === "string" && args["id"] !== "" ? args["id"] : void 0;
575
1479
  return { exportName, id, params: args["params"] };
@@ -578,10 +1482,10 @@ const parseGetWorkflowInstanceStatusArgs = (args) => {
578
1482
  const exportName = typeof args["exportName"] === "string" ? args["exportName"].trim() : "";
579
1483
  const id = typeof args["id"] === "string" ? args["id"].trim() : "";
580
1484
  if (exportName === "") {
581
- throw Object.assign(new Error("getWorkflowInstanceStatus: `exportName` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1485
+ throw new LunoraError("BAD_REQUEST", "getWorkflowInstanceStatus: `exportName` is required");
582
1486
  }
583
1487
  if (id === "") {
584
- throw Object.assign(new Error("getWorkflowInstanceStatus: `id` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1488
+ throw new LunoraError("BAD_REQUEST", "getWorkflowInstanceStatus: `id` is required");
585
1489
  }
586
1490
  return { exportName, id };
587
1491
  };
@@ -636,7 +1540,7 @@ const parseTablePageOrderBy = (raw) => {
636
1540
  const parseBulkDeleteArgs = (args) => {
637
1541
  const table = typeof args["table"] === "string" ? args["table"] : "";
638
1542
  if (table.trim() === "") {
639
- throw Object.assign(new Error("deleteRows: `table` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1543
+ throw new LunoraError("BAD_REQUEST", "deleteRows: `table` is required");
640
1544
  }
641
1545
  return {
642
1546
  filters: parseTablePageFilters(args["filters"]),
@@ -648,37 +1552,39 @@ const parseBulkDeleteArgs = (args) => {
648
1552
  const parseClearTableArgs = (args) => {
649
1553
  const table = typeof args["table"] === "string" ? args["table"] : "";
650
1554
  if (table.trim() === "") {
651
- throw Object.assign(new Error("clearTable: `table` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1555
+ throw new LunoraError("BAD_REQUEST", "clearTable: `table` is required");
652
1556
  }
653
1557
  return { limit: typeof args["limit"] === "number" ? args["limit"] : void 0, table };
654
1558
  };
655
1559
  const parseRecordAuthEventArgs = (args) => {
656
1560
  const { outcome } = args;
657
1561
  if (outcome !== "ok" && outcome !== "fail") {
658
- throw Object.assign(new Error('recordAuthEvent: `outcome` must be "ok" or "fail"'), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1562
+ throw new LunoraError("BAD_REQUEST", 'recordAuthEvent: `outcome` must be "ok" or "fail"');
659
1563
  }
660
1564
  return { outcome };
661
1565
  };
1566
+ const CONTAINER_EXIT_CODE_PATTERN = /\(exit (\d+)\)/;
662
1567
  const parseRecordContainerEventArgs = (args) => {
663
1568
  const raw = args["event"];
664
1569
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
665
- throw Object.assign(new Error("recordContainerEvent: `event` must be an object"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1570
+ throw new LunoraError("BAD_REQUEST", "recordContainerEvent: `event` must be an object");
666
1571
  }
667
1572
  const envelope = raw;
668
1573
  const container = typeof envelope["container"] === "string" ? envelope["container"] : "";
669
1574
  const event = typeof envelope["event"] === "string" ? envelope["event"] : "";
670
1575
  if (container.trim() === "" || event.trim() === "") {
671
- throw Object.assign(new Error("recordContainerEvent: `event.container` and `event.event` are required"), {
672
- code: "BAD_REQUEST",
673
- name: "LunoraError",
674
- status: 400
675
- });
1576
+ throw new LunoraError("BAD_REQUEST", "recordContainerEvent: `event.container` and `event.event` are required");
676
1577
  }
677
1578
  const level = envelope["level"] === "error" ? "error" : "info";
678
1579
  const detail = typeof envelope["message"] === "string" ? envelope["message"] : void 0;
679
1580
  const timestamp = typeof envelope["ts"] === "number" ? envelope["ts"] : Date.now();
1581
+ const instance = typeof envelope["instance"] === "string" && envelope["instance"] !== "" ? envelope["instance"] : void 0;
1582
+ const exitRaw = detail === void 0 ? void 0 : CONTAINER_EXIT_CODE_PATTERN.exec(detail)?.[1];
1583
+ const exitCode = exitRaw === void 0 ? void 0 : Number.parseInt(exitRaw, 10);
680
1584
  return {
1585
+ exitCode,
681
1586
  functionPath: `container:${container}`,
1587
+ instance,
682
1588
  level,
683
1589
  message: detail === void 0 || detail === "" ? event : `${event}: ${detail}`,
684
1590
  timestamp
@@ -688,21 +1594,21 @@ const parseRunAsArgs = (args) => {
688
1594
  const functionPath = typeof args["functionPath"] === "string" ? args["functionPath"] : "";
689
1595
  const userId = typeof args["userId"] === "string" ? args["userId"] : "";
690
1596
  if (functionPath.trim() === "") {
691
- throw Object.assign(new Error("runAs: `functionPath` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1597
+ throw new LunoraError("BAD_REQUEST", "runAs: `functionPath` is required");
692
1598
  }
693
1599
  if (functionPath.startsWith(ADMIN_FUNCTION_PREFIX)) {
694
- throw Object.assign(new Error("runAs: cannot target a reserved admin function"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1600
+ throw new LunoraError("BAD_REQUEST", "runAs: cannot target a reserved admin function");
695
1601
  }
696
1602
  if (userId.trim() === "") {
697
- throw Object.assign(new Error("runAs: `userId` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1603
+ throw new LunoraError("BAD_REQUEST", "runAs: `userId` is required");
698
1604
  }
699
1605
  const rawArgs = args["args"];
700
1606
  if (rawArgs !== void 0 && (typeof rawArgs !== "object" || rawArgs === null || Array.isArray(rawArgs))) {
701
- throw Object.assign(new Error("runAs: `args` must be an object"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1607
+ throw new LunoraError("BAD_REQUEST", "runAs: `args` must be an object");
702
1608
  }
703
1609
  const rawIdentity = args["identity"];
704
1610
  if (rawIdentity !== void 0 && (typeof rawIdentity !== "object" || rawIdentity === null || Array.isArray(rawIdentity))) {
705
- throw Object.assign(new Error("runAs: `identity` must be an object"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1611
+ throw new LunoraError("BAD_REQUEST", "runAs: `identity` must be an object");
706
1612
  }
707
1613
  return {
708
1614
  args: rawArgs === void 0 ? {} : rawArgs,
@@ -713,7 +1619,7 @@ const parseRunAsArgs = (args) => {
713
1619
  };
714
1620
  const parseRecordMailArgs = (args) => {
715
1621
  const bad = (message) => {
716
- throw Object.assign(new Error(`recordMail: ${message}`), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1622
+ throw new LunoraError("BAD_REQUEST", `recordMail: ${message}`);
717
1623
  };
718
1624
  const { bcc, cc, from, headers, html, replyTo, subject, text, to } = args;
719
1625
  if (typeof subject !== "string") {
@@ -754,7 +1660,7 @@ const TEST_MAIL_DEFAULT_TO = "test@lunora.sh";
754
1660
  const buildTestMailInput = (args) => {
755
1661
  const { to } = args;
756
1662
  if (to !== void 0 && typeof to !== "string") {
757
- throw Object.assign(new Error("sendTestMail: `to` must be a string"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1663
+ throw new LunoraError("BAD_REQUEST", "sendTestMail: `to` must be a string");
758
1664
  }
759
1665
  const recipient = to ?? TEST_MAIL_DEFAULT_TO;
760
1666
  const link = "https://example.test/verify?token=demo";
@@ -768,29 +1674,99 @@ Verify your email: ${link}`,
768
1674
  to: recipient
769
1675
  };
770
1676
  };
1677
+ const parseRecordQueueMessageArgs = (args) => {
1678
+ const bad = (message) => {
1679
+ throw new LunoraError("BAD_REQUEST", `recordQueueMessage: ${message}`);
1680
+ };
1681
+ const raw = args["messages"];
1682
+ if (!Array.isArray(raw)) {
1683
+ bad("`messages` must be an array");
1684
+ }
1685
+ const outcomes = /* @__PURE__ */ new Set(["ack", "error", "retry"]);
1686
+ return raw.map((entry, index) => {
1687
+ if (typeof entry !== "object" || entry === null) {
1688
+ bad(`\`messages[${String(index)}]\` must be an object`);
1689
+ }
1690
+ const record = entry;
1691
+ const messageId = typeof record["messageId"] === "string" ? record["messageId"] : "";
1692
+ const queue = typeof record["queue"] === "string" ? record["queue"] : "";
1693
+ const outcome = typeof record["outcome"] === "string" ? record["outcome"] : "";
1694
+ if (messageId === "") {
1695
+ bad(`\`messages[${String(index)}].messageId\` is required`);
1696
+ }
1697
+ if (queue === "") {
1698
+ bad(`\`messages[${String(index)}].queue\` is required`);
1699
+ }
1700
+ if (!outcomes.has(outcome)) {
1701
+ bad(`\`messages[${String(index)}].outcome\` must be one of ack | error | retry`);
1702
+ }
1703
+ const { attempts, timestamp } = record;
1704
+ return {
1705
+ attempts: typeof attempts === "number" && Number.isFinite(attempts) ? attempts : 1,
1706
+ body: record["body"],
1707
+ deadLettered: record["deadLettered"] === true,
1708
+ error: typeof record["error"] === "string" ? record["error"] : void 0,
1709
+ exportName: typeof record["exportName"] === "string" ? record["exportName"] : void 0,
1710
+ messageId,
1711
+ outcome,
1712
+ queue,
1713
+ timestamp: typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : 0
1714
+ };
1715
+ });
1716
+ };
1717
+ const MAX_QUEUE_SEND_BATCH = 100;
1718
+ const parseSendQueueMessageArgs = (args) => {
1719
+ const exportName = typeof args["exportName"] === "string" ? args["exportName"].trim() : "";
1720
+ if (exportName === "") {
1721
+ throw new LunoraError("BAD_REQUEST", "sendQueueMessage: `exportName` is required");
1722
+ }
1723
+ const delayRaw = args["delaySeconds"];
1724
+ if (delayRaw !== void 0 && (typeof delayRaw !== "number" || !Number.isFinite(delayRaw) || delayRaw < 0)) {
1725
+ throw new LunoraError("BAD_REQUEST", "sendQueueMessage: `delaySeconds` must be a non-negative number");
1726
+ }
1727
+ const batch = Array.isArray(args["batch"]) ? args["batch"] : void 0;
1728
+ if (batch !== void 0 && (batch.length === 0 || batch.length > MAX_QUEUE_SEND_BATCH)) {
1729
+ throw new LunoraError("BAD_REQUEST", `sendQueueMessage: \`batch\` must contain between 1 and ${String(MAX_QUEUE_SEND_BATCH)} messages`);
1730
+ }
1731
+ return {
1732
+ batch,
1733
+ body: args["body"],
1734
+ contentType: typeof args["contentType"] === "string" ? args["contentType"] : void 0,
1735
+ delaySeconds: delayRaw,
1736
+ exportName
1737
+ };
1738
+ };
1739
+ const parseReplayQueueMessageArgs = (args) => {
1740
+ const id = typeof args["id"] === "string" ? args["id"].trim() : "";
1741
+ if (id === "") {
1742
+ throw new LunoraError("BAD_REQUEST", "replayQueueMessage: `id` is required");
1743
+ }
1744
+ const target = typeof args["target"] === "string" && args["target"].trim() !== "" ? args["target"].trim() : void 0;
1745
+ return { id, target };
1746
+ };
771
1747
  const parseRankBeforeArgs = (args) => {
772
1748
  const table = typeof args["table"] === "string" ? args["table"] : "";
773
1749
  const index = typeof args["index"] === "string" ? args["index"] : "";
774
1750
  const rowId = typeof args["rowId"] === "string" ? args["rowId"] : "";
775
1751
  if (table.trim() === "") {
776
- throw Object.assign(new Error("rankBefore: `table` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1752
+ throw new LunoraError("BAD_REQUEST", "rankBefore: `table` is required");
777
1753
  }
778
1754
  if (index.trim() === "") {
779
- throw Object.assign(new Error("rankBefore: `index` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1755
+ throw new LunoraError("BAD_REQUEST", "rankBefore: `index` is required");
780
1756
  }
781
1757
  if (typeof args["partitionKey"] !== "string") {
782
- throw Object.assign(new Error("rankBefore: `partitionKey` must be a string"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1758
+ throw new LunoraError("BAD_REQUEST", "rankBefore: `partitionKey` must be a string");
783
1759
  }
784
1760
  if (rowId.trim() === "") {
785
- throw Object.assign(new Error("rankBefore: `rowId` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1761
+ throw new LunoraError("BAD_REQUEST", "rankBefore: `rowId` is required");
786
1762
  }
787
1763
  if (!Array.isArray(args["sortValues"])) {
788
- throw Object.assign(new Error("rankBefore: `sortValues` must be an array"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1764
+ throw new LunoraError("BAD_REQUEST", "rankBefore: `sortValues` must be an array");
789
1765
  }
790
1766
  return { index, partitionKey: args["partitionKey"], rowId, sortValues: args["sortValues"], table };
791
1767
  };
792
1768
  const badRequest = (message) => {
793
- throw Object.assign(new Error(message), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1769
+ throw new LunoraError("BAD_REQUEST", message);
794
1770
  };
795
1771
  const requireNonEmptyString = (value, field) => {
796
1772
  if (typeof value !== "string" || value.trim() === "") {
@@ -850,7 +1826,7 @@ const decodeIndexHitKey = (key) => {
850
1826
  const parseApplyCdcArgs = (args) => {
851
1827
  const raw = args["changes"];
852
1828
  if (!Array.isArray(raw)) {
853
- throw Object.assign(new Error("applyCdc: `changes` must be an array"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
1829
+ throw new LunoraError("BAD_REQUEST", "applyCdc: `changes` must be an array");
854
1830
  }
855
1831
  const changes = raw.map((entry, index) => {
856
1832
  const record = entry;
@@ -858,27 +1834,15 @@ const parseApplyCdcArgs = (args) => {
858
1834
  const table = typeof record["table"] === "string" ? record["table"] : "";
859
1835
  const id = typeof record["id"] === "string" ? record["id"] : "";
860
1836
  if (table === "" || id === "" || op !== "insert" && op !== "update" && op !== "delete") {
861
- throw Object.assign(new Error(`applyCdc: changes[${String(index)}] must have a table, id, and op of insert|update|delete`), {
862
- code: "BAD_REQUEST",
863
- name: "LunoraError",
864
- status: 400
865
- });
1837
+ throw new LunoraError("BAD_REQUEST", `applyCdc: changes[${String(index)}] must have a table, id, and op of insert|update|delete`);
866
1838
  }
867
1839
  const rawDocument = record["doc"];
868
1840
  if (rawDocument !== void 0 && (typeof rawDocument !== "object" || rawDocument === null || Array.isArray(rawDocument))) {
869
- throw Object.assign(new Error(`applyCdc: changes[${String(index)}].doc must be an object`), {
870
- code: "BAD_REQUEST",
871
- name: "LunoraError",
872
- status: 400
873
- });
1841
+ throw new LunoraError("BAD_REQUEST", `applyCdc: changes[${String(index)}].doc must be an object`);
874
1842
  }
875
1843
  const document = rawDocument;
876
1844
  if (document !== void 0 && typeof document["_id"] === "string" && document["_id"] !== id) {
877
- throw Object.assign(new Error(`applyCdc: changes[${String(index)}].doc._id must match the entry id`), {
878
- code: "BAD_REQUEST",
879
- name: "LunoraError",
880
- status: 400
881
- });
1845
+ throw new LunoraError("BAD_REQUEST", `applyCdc: changes[${String(index)}].doc._id must match the entry id`);
882
1846
  }
883
1847
  return {
884
1848
  doc: document,
@@ -898,13 +1862,7 @@ const parseCdcSyncArgs = (args) => {
898
1862
  };
899
1863
  return { limit: toCount(args["limit"]), sinceSeq: toCount(args["sinceSeq"]) ?? 0 };
900
1864
  };
901
- const jsonResponse = (body, status = 200, bookmark) => {
902
- const headers = { "content-type": "application/json" };
903
- if (bookmark) {
904
- headers["x-d1-bookmark"] = bookmark;
905
- }
906
- return Response.json(body, { headers, status });
907
- };
1865
+ const bookmarkHeaders = (bookmark) => bookmark ? { "x-d1-bookmark": bookmark } : void 0;
908
1866
  const parseIdentityHeader = (raw) => {
909
1867
  if (!raw) {
910
1868
  return void 0;
@@ -918,6 +1876,13 @@ const parseIdentityHeader = (raw) => {
918
1876
  }
919
1877
  return void 0;
920
1878
  };
1879
+ const parseClientSeqHeader = (raw) => {
1880
+ if (!raw) {
1881
+ return void 0;
1882
+ }
1883
+ const seq = Number(raw);
1884
+ return Number.isInteger(seq) && seq > 0 ? seq : void 0;
1885
+ };
921
1886
  const tablesFromDeps = (deps) => {
922
1887
  const tables = /* @__PURE__ */ new Set();
923
1888
  for (const dep of deps) {
@@ -971,16 +1936,6 @@ const extractBearerToken = (authorization) => {
971
1936
  const value = rest.join(" ").trim();
972
1937
  return value.length > 0 ? value : void 0;
973
1938
  };
974
- const constantTimeEqual = (a, b) => {
975
- const max = Math.max(a.length, b.length);
976
- let diff = a.length ^ b.length;
977
- for (let index = 0; index < max; index += 1) {
978
- const charA = index < a.length ? a.charCodeAt(index) : 0;
979
- const charB = index < b.length ? b.charCodeAt(index) : 0;
980
- diff |= charA ^ charB;
981
- }
982
- return diff === 0;
983
- };
984
1939
  class ShardDO {
985
1940
  /**
986
1941
  * Per-socket cap on concurrent stream iterators. Each in-flight stream
@@ -1002,6 +1957,28 @@ class ShardDO {
1002
1957
  * failure stays unlikely.
1003
1958
  */
1004
1959
  static MAX_SUBSCRIPTIONS_PER_SOCKET = 32;
1960
+ /**
1961
+ * Poll interval (ms) for `.global()`-table shapes. A global table lives in
1962
+ * D1 with no per-DO op-log, so its shapes can't be poke-live; the DO re-reads
1963
+ * each subscribed global shape's membership from D1 on an alarm every
1964
+ * `GLOBAL_SHAPE_POLL_INTERVAL_MS` and pokes only the diff. This is the
1965
+ * latency floor for a global-shape update — deliberately coarse (seconds, not
1966
+ * the sub-millisecond poke-live path) since the D1 read fans out per tick.
1967
+ */
1968
+ static GLOBAL_SHAPE_POLL_INTERVAL_MS = 2e3;
1969
+ /**
1970
+ * Upper bound on a `.global()`-shape's materialized membership. Each global
1971
+ * shape keeps its ENTIRE current membership as a per-socket snapshot
1972
+ * (`Map&lt;rowKey, hash&gt;`) so the poll loop can diff it; that snapshot — and the
1973
+ * read buffer feeding it — scale with the membership size, multiplied by every
1974
+ * subscribed socket. An unbounded membership (a global table with no narrowing
1975
+ * shape predicate or RLS read scope) would grow them without limit and evict
1976
+ * the DO. A shape whose membership exceeds this cap is failed closed (left
1977
+ * empty, logged) rather than retained — the developer must narrow it. Sized
1978
+ * well above any reasonable per-identity replicated set so legitimate shapes
1979
+ * never trip it.
1980
+ */
1981
+ static GLOBAL_SHAPE_MAX_ROWS = 5e4;
1005
1982
  /**
1006
1983
  * Per-socket whisper-topic cap. Topic membership rides the same hibernation
1007
1984
  * attachment as `subs`, so bound it for the same reason — a runaway
@@ -1093,6 +2070,8 @@ class ShardDO {
1093
2070
  * cleared in the `finally` block of `fetch` like the other per-request fields.
1094
2071
  */
1095
2072
  currentRequestIp;
2073
+ /** W3C `traceparent` of the inbound RPC; forwarded onto outbound container fetches. */
2074
+ currentRequestTraceparent;
1096
2075
  /**
1097
2076
  * Client-issued idempotency key for the in-flight mutation, forwarded via the
1098
2077
  * `x-lunora-mutation-id` header. When set, the dispatch path dedups the call
@@ -1103,6 +2082,39 @@ class ShardDO {
1103
2082
  * `finally` block.
1104
2083
  */
1105
2084
  currentRequestMutationId;
2085
+ /**
2086
+ * Stable per-device client id for the in-flight custom-mutator push,
2087
+ * forwarded via the `x-lunora-client-id` header. Backs the
2088
+ * `__client_watermark` table: the dispatch path classifies the paired
2089
+ * `currentRequestClientSeq` against the stored high-watermark (already
2090
+ * processed / next / out-of-order gap). Absent on legacy mutations and
2091
+ * queries (those keep the `__idempotency` path). Cleared in `fetch`'s
2092
+ * `finally`.
2093
+ */
2094
+ currentRequestClientId;
2095
+ /**
2096
+ * Monotonic per-client mutation sequence for the in-flight custom-mutator
2097
+ * push, forwarded via the `x-lunora-client-seq` header (numeric). Paired
2098
+ * with `currentRequestClientId` to drive the watermark classification.
2099
+ * `undefined` when absent or non-numeric.
2100
+ */
2101
+ currentRequestClientSeq;
2102
+ /**
2103
+ * The in-flight push's custom-mutator classification, stashed by `fetch`
2104
+ * before `handleRpc` so the in-transaction bookkeeping ({@link
2105
+ * ShardDO.commitMutationBookkeeping}) can advance the `__client_watermark` for
2106
+ * a `"next"` push inside the same commit as the writes. `undefined` for an
2107
+ * ordinary mutation / non-mutator push. Cleared per request.
2108
+ */
2109
+ currentMutatorClass;
2110
+ /**
2111
+ * Set once a mutation's replay bookkeeping (idempotency row + watermark
2112
+ * advance) has committed INSIDE the handler transaction, so the post-dispatch
2113
+ * path skips the now-redundant best-effort writes. Cleared per request; stays
2114
+ * `false` for actions/queries (no transaction wrapper) so their dispatch-level
2115
+ * idempotency persist still runs.
2116
+ */
2117
+ mutationBookkeepingCommitted = false;
1106
2118
  /**
1107
2119
  * Wall-clock millis of the last `__idempotency` GC sweep on this warm
1108
2120
  * instance. The dedup write throttles `trimIdempotent` to at most once an
@@ -1133,6 +2145,18 @@ class ShardDO {
1133
2145
  * the common read-only path allocates nothing.
1134
2146
  */
1135
2147
  pendingChangedTables = void 0;
2148
+ /**
2149
+ * Coalesced set of tables awaiting a subscription-refresh pass, merged
2150
+ * across every {@link ShardDO.flushChangedTables} call that lands while a
2151
+ * pass is already draining. The single drain loop
2152
+ * ({@link ShardDO.drainSubscriptionRefreshes}) owns this set; a burst of N
2153
+ * writes to the same table therefore collapses into one (or two) refresh
2154
+ * passes instead of N, so each affected subscription's handler re-runs once
2155
+ * per burst rather than once per write. `undefined` when nothing is pending.
2156
+ */
2157
+ pendingRefreshTables = void 0;
2158
+ /** True while {@link ShardDO.drainSubscriptionRefreshes} is running; the single-waiter gate that coalesces concurrent flushes. */
2159
+ refreshInFlight = false;
1136
2160
  /**
1137
2161
  * Last pushed result per `(socket, subId)`, keyed by socket. Lets
1138
2162
  * `refreshSubscriptions` skip re-running queries whose tables were
@@ -1141,6 +2165,39 @@ class ShardDO {
1141
2165
  * memo simply forces one re-run and (at most) one redundant push.
1142
2166
  */
1143
2167
  subMemos = /* @__PURE__ */ new WeakMap();
2168
+ /**
2169
+ * Per-socket poke baseline for shape subscriptions: maps each shape's
2170
+ * subscription id to the `__cdc_log` cursor it has been poked through.
2171
+ * `pokeShapeSubscribers` reads each op page since this cursor and advances
2172
+ * it to the flush watermark. In-memory only (like {@link ShardDO.subMemos});
2173
+ * a cold memo on a reconnected/hibernated socket re-seeds from the client's
2174
+ * `sinceCheckpoint`.
2175
+ */
2176
+ shapeMemos = /* @__PURE__ */ new WeakMap();
2177
+ /**
2178
+ * Per-socket, per-**global**-shape membership snapshot: maps each global
2179
+ * shape's subscription id to a `key → projected-value JSON` map of the rows
2180
+ * last poked to that socket. A `.global()` (D1) table has no op-log to diff,
2181
+ * so {@link ShardDO.refreshGlobalShape} re-reads the full membership on each
2182
+ * alarm tick and diffs it against this snapshot to compute the poke. Parallel
2183
+ * to {@link ShardDO.shapeMemos} (the cursor baseline for poke-live shapes).
2184
+ *
2185
+ * This is a hot in-memory **cache** over the durable `__global_shape_snapshot`
2186
+ * table (keyed by the socket's `connectionId` + subId): a hibernation eviction
2187
+ * clears the WeakMap, so on the next alarm wake {@link ShardDO.readGlobalSnapshot}
2188
+ * misses and re-loads the baseline from SQLite — without it, the diff would run
2189
+ * against an empty baseline and a row deleted from D1 while the DO slept would
2190
+ * never be poked as a `delete`, lingering on the client as a phantom row.
2191
+ */
2192
+ globalShapeSnapshots = /* @__PURE__ */ new WeakMap();
2193
+ /**
2194
+ * Whether a global-shape poll alarm is currently armed. Guards
2195
+ * {@link ShardDO.scheduleGlobalPoll} from re-arming on every seed; reset in
2196
+ * {@link ShardDO.alarm} before the poll so a still-subscribed shape re-arms.
2197
+ */
2198
+ globalPollScheduled = false;
2199
+ /** Monotonic per-DO poke id source; correlates a poke's `pokeStart`/`pokePart`/`pokeEnd` frames. */
2200
+ pokeSequence = 0;
1144
2201
  /** Per-socket whisper-rate token bucket (see {@link ShardDO.WHISPER_RATE_BURST}). In-memory; resets on hibernation. */
1145
2202
  whisperBuckets = /* @__PURE__ */ new WeakMap();
1146
2203
  /**
@@ -1157,7 +2214,36 @@ class ShardDO {
1157
2214
  * is the right granularity for a "since this instance woke" health readout
1158
2215
  * (durable aggregation would be a separate, heavier feature).
1159
2216
  */
1160
- metrics = { errors: 0, requests: 0, sinceMs: Date.now() };
2217
+ metrics = { errors: 0, requests: 0, sinceMs: Date.now() };
2218
+ /**
2219
+ * Running fan-out cost counters surfaced by the
2220
+ * `__lunora_admin__:getFanoutMetrics` RPC — one tally for the reactive
2221
+ * shape-poke path (`pokeShapeSubscribers`) and one for the whisper broadcast
2222
+ * path (`broadcastWhisper`). Each pass records the sockets it iterated (the
2223
+ * O(subscribers) cost) and delivered to. In-memory and reset on
2224
+ * hibernation/restart, sharing `metrics.sinceMs` as the "since this instance
2225
+ * woke" epoch. This is the observability half of plan 075's auto-elastic
2226
+ * relay tier (Phase 1): measure the per-flush fan-out cost so the promotion
2227
+ * threshold is grounded in real numbers, with no behavior change.
2228
+ */
2229
+ fanout = { shapePoke: createFanoutCounters(), whisper: createFanoutCounters() };
2230
+ /**
2231
+ * The runtime's Durable Object namespace binding name (e.g. `"SHARD"`),
2232
+ * forwarded as `x-lunora-shard-binding` on every request so a DO can address
2233
+ * its siblings (`this.env[binding].getByName(...)`) for the relay hub. Absent
2234
+ * in single-DO mode / the unit harness — when absent, the relay tier is inert
2235
+ * and whispers stay shard-local (no behavior change). In-memory; re-learned per
2236
+ * request.
2237
+ */
2238
+ shardBinding;
2239
+ /**
2240
+ * The auto-elastic fan-out relay collaborator (plan 075) — an {@link OwnerRelay}
2241
+ * or {@link RelayMember} chosen ONCE from this DO's name, or `undefined` for an
2242
+ * unnamed (single-DO) DO where the relay tier is inert. All relay state +
2243
+ * transport lives on it, reached back through the {@link RelayHost} adapter, so
2244
+ * owner-only state can never sit next to relay-only state on this class.
2245
+ */
2246
+ relay;
1161
2247
  /**
1162
2248
  * Declared indexes (`table:index`) a query has exercised since this instance
1163
2249
  * woke, stamped by `getCtxDbIndexUseHook`. In-memory and reset on
@@ -1244,6 +2330,29 @@ class ShardDO {
1244
2330
  if (options.reactiveCache) {
1245
2331
  this.reactiveCache = new ReactiveCache(options.reactiveCache);
1246
2332
  }
2333
+ const host = {
2334
+ buildShapeDiff: (resolved, fromCursor, toCursor) => this.buildShapeDiff(this.sql, resolved, fromCursor, toCursor),
2335
+ computeOpLogShapeSeed: (shape, resolved) => this.computeOpLogShapeSeed(shape, resolved),
2336
+ currentCdcEpoch: () => this.currentCdcEpoch(),
2337
+ deliverWhisperLocal: (topic, frame, exclude) => this.deliverWhisperLocal(topic, frame, exclude),
2338
+ doName: () => this.state.id?.name,
2339
+ env: () => this.env,
2340
+ getWebSockets: () => this.state.getWebSockets(),
2341
+ maskMetadata: () => this.maskMetadata(),
2342
+ nextPokeId: () => {
2343
+ this.pokeSequence += 1;
2344
+ return `poke-${String(this.pokeSequence)}`;
2345
+ },
2346
+ readAttachment: (ws) => this.readAttachment(ws),
2347
+ recordShapePokeFanout: (iterated, delivered, elapsedMs) => {
2348
+ this.fanout.shapePoke = recordFanoutPass(this.fanout.shapePoke, iterated, delivered, elapsedMs);
2349
+ },
2350
+ resolveShape: (name, args, identity) => this.resolveShape(name, args, identity),
2351
+ rlsMetadata: () => this.rlsMetadata(),
2352
+ shardBinding: () => this.shardBinding,
2353
+ sql: () => this.sql
2354
+ };
2355
+ this.relay = createRelayLink(host);
1247
2356
  this.armWebSocketKeepalive();
1248
2357
  }
1249
2358
  /** SQLite handle scoped to this Durable Object. */
@@ -1253,8 +2362,10 @@ class ShardDO {
1253
2362
  */
1254
2363
  async fetch(request) {
1255
2364
  const url = new URL(request.url);
1256
- if (request.headers.get("Upgrade") === "websocket") {
1257
- return this.handleWebSocketUpgrade(request);
2365
+ this.shardBinding = request.headers.get("x-lunora-shard-binding") ?? this.shardBinding;
2366
+ const early = await this.routeNonRpc(url, request);
2367
+ if (early !== void 0) {
2368
+ return early;
1258
2369
  }
1259
2370
  if (url.pathname !== "/rpc" || request.method !== "POST") {
1260
2371
  return new Response("Not found", { status: 404 });
@@ -1272,9 +2383,14 @@ class ShardDO {
1272
2383
  this.currentResponseBookmark = void 0;
1273
2384
  this.currentRequestUserId = request.headers.get("x-lunora-userid") ?? void 0;
1274
2385
  this.currentRequestMutationId = request.headers.get("x-lunora-mutation-id") ?? void 0;
2386
+ this.currentRequestClientId = request.headers.get("x-lunora-client-id") ?? void 0;
2387
+ this.currentRequestClientSeq = parseClientSeqHeader(request.headers.get("x-lunora-client-seq"));
2388
+ this.currentMutatorClass = void 0;
2389
+ this.mutationBookkeepingCommitted = false;
1275
2390
  this.currentRequestIdentity = parseIdentityHeader(request.headers.get("x-lunora-identity"));
1276
2391
  this.currentRequestIp = request.headers.get("x-lunora-client-ip") ?? void 0;
1277
2392
  this.currentRequestSystem = request.headers.get("x-lunora-system") === "1";
2393
+ this.currentRequestTraceparent = request.headers.get("traceparent") ?? void 0;
1278
2394
  this.currentRequestReadTables = void 0;
1279
2395
  this.currentRequestCacheHit = void 0;
1280
2396
  this.metrics.requests += 1;
@@ -1285,22 +2401,30 @@ class ShardDO {
1285
2401
  try {
1286
2402
  if (payload.functionPath.startsWith(RELATION_FUNCTION_PREFIX)) {
1287
2403
  const value = await this.runRelationFanoutRead(payload.functionPath, payload.args ?? {});
1288
- return jsonResponse(value, 200, this.currentResponseBookmark);
2404
+ return jsonResponse(value, 200, bookmarkHeaders(this.currentResponseBookmark));
2405
+ }
2406
+ const mutatorClass = this.isCustomMutator(payload.functionPath) ? this.classifyClientMutation() : void 0;
2407
+ this.currentMutatorClass = mutatorClass;
2408
+ const watermarkShortCircuit = this.rejectNonNextMutation(payload.functionPath, mutatorClass, dispatchStartedAt);
2409
+ if (watermarkShortCircuit !== void 0) {
2410
+ return watermarkShortCircuit;
1289
2411
  }
1290
2412
  const cached = this.readIdempotentResult(this.currentRequestMutationId);
1291
2413
  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);
2414
+ return this.respondFromIdempotencyCache(payload.functionPath, dispatchStartedAt, mutatorClass, cached.value);
2415
+ }
2416
+ const result = await this.handleRpc(payload.functionPath, decodeWire(payload.args ?? {}));
2417
+ this.recordPostDispatchBookkeeping(result, mutatorClass);
2418
+ if (mutatorClass?.kind === "next") {
2419
+ this.advanceClientMutationWatermark();
1294
2420
  }
1295
- const result = await this.handleRpc(payload.functionPath, payload.args ?? {});
1296
- this.persistIdempotentResult(result);
1297
2421
  const durationMs = Date.now() - dispatchStartedAt;
1298
2422
  this.recordFunctionCall(payload.functionPath, durationMs, void 0, this.currentScannedTables, this.currentIndexHits);
1299
2423
  this.flushStmtSamples();
1300
2424
  const tablesWritten = [...this.pendingChangedTables ?? []];
1301
2425
  this.recordRequestLog(payload.functionPath, payload.args ?? {}, durationMs, "ok", tablesWritten);
1302
2426
  this.maybeWarnRootSize();
1303
- const response = jsonResponse({ result }, 200, this.currentResponseBookmark);
2427
+ const response = this.buildDispatchResponse(mutatorClass, encodeWire(result));
1304
2428
  await this.flushChangedTables();
1305
2429
  return response;
1306
2430
  } catch (error) {
@@ -1320,15 +2444,22 @@ class ShardDO {
1320
2444
  message,
1321
2445
  timestamp: Date.now()
1322
2446
  });
2447
+ this.recordChangedTable(REQUEST_LOG_TABLE);
2448
+ await this.flushChangedTables();
1323
2449
  return this.errorToResponse(error);
1324
2450
  } finally {
1325
2451
  this.currentRequestBookmark = void 0;
1326
2452
  this.currentResponseBookmark = void 0;
1327
2453
  this.currentRequestUserId = void 0;
1328
2454
  this.currentRequestMutationId = void 0;
2455
+ this.currentRequestClientId = void 0;
2456
+ this.currentRequestClientSeq = void 0;
2457
+ this.currentMutatorClass = void 0;
2458
+ this.mutationBookkeepingCommitted = false;
1329
2459
  this.currentRequestIdentity = void 0;
1330
2460
  this.currentRequestIp = void 0;
1331
2461
  this.currentRequestSystem = false;
2462
+ this.currentRequestTraceparent = void 0;
1332
2463
  this.currentScannedTables = void 0;
1333
2464
  this.currentIndexHits = void 0;
1334
2465
  this.currentRequestReadTables = void 0;
@@ -1363,6 +2494,9 @@ class ShardDO {
1363
2494
  if (envelope.context !== void 0) {
1364
2495
  attachment.context = envelope.context;
1365
2496
  }
2497
+ if (envelope.clientId !== void 0) {
2498
+ attachment.clientId = envelope.clientId;
2499
+ }
1366
2500
  attachment.connected = true;
1367
2501
  try {
1368
2502
  ws.serializeAttachment?.(attachment);
@@ -1394,24 +2528,42 @@ class ShardDO {
1394
2528
  }
1395
2529
  return;
1396
2530
  }
2531
+ if (envelope.type === "shape_subscribe" && envelope.shape) {
2532
+ await this.handleShapeSubscribe(ws, envelope.id, {
2533
+ args: envelope.shape.args,
2534
+ name: envelope.shape.name,
2535
+ sinceEpoch: envelope.sinceEpoch,
2536
+ sinceSeq: envelope.sinceCheckpoint
2537
+ });
2538
+ return;
2539
+ }
2540
+ if (envelope.type === "shape_unsubscribe") {
2541
+ this.shapeUnsubscribe(ws, envelope.id);
2542
+ ws.send(JSON.stringify({ id: envelope.id, type: "ack" }));
2543
+ return;
2544
+ }
1397
2545
  if (envelope.type === "stream" && envelope.query?.functionPath) {
1398
2546
  if (envelope.query.functionPath.startsWith(ADMIN_FUNCTION_PREFIX)) {
1399
2547
  ws.send(JSON.stringify({ id: envelope.id, message: "streams must be public", type: "error" }));
1400
2548
  return;
1401
2549
  }
1402
- this.handleStream(ws, envelope.id, envelope.query.functionPath, envelope.query.args ?? {}).catch(() => {
2550
+ this.handleStream(ws, envelope.id, envelope.query.functionPath, decodeWire(envelope.query.args ?? {})).catch(() => {
1403
2551
  });
1404
2552
  return;
1405
2553
  }
1406
2554
  if (envelope.type === "whisper_subscribe" || envelope.type === "whisper_unsubscribe") {
1407
2555
  if (typeof envelope.topic === "string" && envelope.topic.length > 0) {
1408
- this.setWhisperMembership(ws, envelope.topic, envelope.type === "whisper_subscribe");
2556
+ const join = envelope.type === "whisper_subscribe";
2557
+ this.setWhisperMembership(ws, envelope.topic, join);
2558
+ if (join) {
2559
+ await this.relay?.announce();
2560
+ }
1409
2561
  }
1410
2562
  return;
1411
2563
  }
1412
2564
  if (envelope.type === "whisper") {
1413
2565
  if (typeof envelope.topic === "string" && envelope.topic.length > 0) {
1414
- this.broadcastWhisper(ws, envelope.topic, envelope.data);
2566
+ await this.broadcastWhisper(ws, envelope.topic, envelope.data);
1415
2567
  }
1416
2568
  return;
1417
2569
  }
@@ -1444,12 +2596,49 @@ class ShardDO {
1444
2596
  this.streamCancellers.delete(ws);
1445
2597
  }
1446
2598
  this.subMemos.delete(ws);
2599
+ this.shapeMemos.delete(ws);
2600
+ this.globalShapeSnapshots.delete(ws);
2601
+ if (attachment.connectionId !== void 0) {
2602
+ try {
2603
+ deleteGlobalShapeSnapshotsForConnection(this.sql, attachment.connectionId);
2604
+ } catch {
2605
+ }
2606
+ }
1447
2607
  ws.serializeAttachment?.(void 0);
2608
+ await this.relay?.announceDrain(ws);
1448
2609
  }
1449
2610
  /** Hibernation API: invoked on socket error. */
1450
2611
  // 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
2612
  webSocketError(_ws, _error) {
1452
2613
  }
2614
+ /**
2615
+ * Durable Object alarm handler — the heartbeat for `.global()`-table shapes.
2616
+ * The runtime wakes this when the poll alarm armed by `scheduleGlobalPoll`
2617
+ * fires; it refreshes every subscribed global shape (diff-poke from the global
2618
+ * backend) and re-arms while any remain. With no global subscribers left, the
2619
+ * alarm is not re-armed and the DO goes idle. A base-only / global-free DO
2620
+ * never arms it, so this stays dormant there.
2621
+ */
2622
+ async alarm() {
2623
+ this.globalPollScheduled = false;
2624
+ let remaining;
2625
+ try {
2626
+ remaining = await this.pollGlobalShapes();
2627
+ } catch (error) {
2628
+ this.recordShapeError("shape:poll", error);
2629
+ remaining = 1;
2630
+ }
2631
+ try {
2632
+ remaining += await this.pollExternalSources();
2633
+ } catch (error) {
2634
+ this.recordShapeError("source:poll", error);
2635
+ remaining += 1;
2636
+ }
2637
+ await this.flushChangedTables();
2638
+ if (remaining > 0) {
2639
+ await this.scheduleGlobalPoll();
2640
+ }
2641
+ }
1453
2642
  /**
1454
2643
  * The registered function paths to dispatch when a socket connects/disconnects.
1455
2644
  * Base default is empty; the codegen subclass overrides it to return the
@@ -1505,9 +2694,7 @@ class ShardDO {
1505
2694
  */
1506
2695
  // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with a schema-aware reader that uses `this`
1507
2696
  runRelationFanoutRead(_functionPath, _args) {
1508
- throw Object.assign(new Error("__lunora_relation__: no schema bound — the base ShardDO cannot serve cross-shard relation reads"), {
1509
- code: "NOT_IMPLEMENTED",
1510
- name: "LunoraError",
2697
+ throw new LunoraError("NOT_IMPLEMENTED", "__lunora_relation__: no schema bound — the base ShardDO cannot serve cross-shard relation reads", {
1511
2698
  status: 500
1512
2699
  });
1513
2700
  }
@@ -1634,34 +2821,20 @@ class ShardDO {
1634
2821
  */
1635
2822
  async runInTransaction(handler) {
1636
2823
  if (this.transactionDepth > 0) {
1637
- throw Object.assign(new Error("nested transactions are not supported in SQLite-in-DO"), {
1638
- code: "NESTED_TRANSACTION",
1639
- name: "LunoraError",
1640
- status: 500
1641
- });
2824
+ throw new LunoraError("NESTED_TRANSACTION", "nested transactions are not supported in SQLite-in-DO", { status: 500 });
1642
2825
  }
1643
2826
  const sqlHandle = this.state.storage.sql;
1644
2827
  if (!sqlHandle || typeof sqlHandle.exec !== "function") {
1645
- throw Object.assign(new Error("storage.sql is not available on this ShardDO state"), {
1646
- code: "SQL_UNAVAILABLE",
1647
- name: "LunoraError",
1648
- status: 500
1649
- });
2828
+ throw new LunoraError("SQL_UNAVAILABLE", "storage.sql is not available on this ShardDO state", { status: 500 });
1650
2829
  }
1651
- const sqlExec = sqlHandle.exec.bind(sqlHandle);
2830
+ const transactionalStorage = this.state.storage;
1652
2831
  const run = async () => {
1653
2832
  this.transactionDepth = 1;
1654
- sqlExec("BEGIN");
1655
2833
  try {
1656
- const value = await handler();
1657
- sqlExec("COMMIT");
1658
- return value;
1659
- } catch (error) {
1660
- try {
1661
- sqlExec("ROLLBACK");
1662
- } catch {
2834
+ if (typeof transactionalStorage?.transaction === "function") {
2835
+ return await transactionalStorage.transaction(async () => handler());
1663
2836
  }
1664
- throw error;
2837
+ return await handler();
1665
2838
  } finally {
1666
2839
  this.transactionDepth = 0;
1667
2840
  }
@@ -1705,6 +2878,14 @@ class ShardDO {
1705
2878
  getCurrentIp() {
1706
2879
  return this.currentRequestIp;
1707
2880
  }
2881
+ /**
2882
+ * W3C `traceparent` of the inbound RPC (forwarded by the runtime), or
2883
+ * `undefined`. `buildCtx` passes it to `createContainerContext` so outbound
2884
+ * container fetches carry it and the container's spans join the same trace.
2885
+ */
2886
+ getCurrentTraceparent() {
2887
+ return this.currentRequestTraceparent;
2888
+ }
1708
2889
  /**
1709
2890
  * Identity claims (email, name, roles, …) forwarded by the runtime's
1710
2891
  * `resolveIdentity` hook. Returns `undefined` for anonymous requests
@@ -1732,9 +2913,7 @@ class ShardDO {
1732
2913
  */
1733
2914
  // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to reach the generated migration registry
1734
2915
  runShardDataMigration(args) {
1735
- return Promise.reject(
1736
- Object.assign(new Error(`data migration "${args.id}" is not registered`), { code: "MIGRATION_NOT_FOUND", name: "LunoraError", status: 404 })
1737
- );
2916
+ return Promise.reject(new LunoraError("MIGRATION_NOT_FOUND", `data migration "${args.id}" is not registered`, { status: 404 }));
1738
2917
  }
1739
2918
  /**
1740
2919
  * Lazily provision the shard's physical tables before an operation that
@@ -1859,7 +3038,58 @@ class ShardDO {
1859
3038
  */
1860
3039
  // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the statically-discovered feature flags
1861
3040
  studioFeatures() {
1862
- return { mail: false, payments: false, scheduler: false, storage: false, vectors: false, workflows: false };
3041
+ return {
3042
+ analytics: false,
3043
+ auth: false,
3044
+ containers: false,
3045
+ flags: false,
3046
+ kv: false,
3047
+ mail: false,
3048
+ payments: false,
3049
+ queues: false,
3050
+ scheduler: false,
3051
+ storage: false,
3052
+ vectors: false,
3053
+ workflows: false
3054
+ };
3055
+ }
3056
+ /**
3057
+ * Evaluate every statically-discovered feature flag under `context` for the
3058
+ * studio's read-only Flags page (`__lunora_admin__:listFlags`). The flag keys
3059
+ * + value types are discovered by `@lunora/codegen` from the app's
3060
+ * `ctx.flags.&lt;type>("key", …)` reads and evaluated through the configured
3061
+ * `@lunora/flags` provider — work only the codegen subclass can do, so it
3062
+ * overrides this. The base class wires no provider and reports
3063
+ * `configured: false` with zero flags (an un-generated `ShardDO` has none).
3064
+ */
3065
+ // 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
3066
+ evaluateFlags(_context) {
3067
+ return Promise.resolve({ configured: false, flags: [] });
3068
+ }
3069
+ /**
3070
+ * Serve one reserved {@link FLAGS_FUNCTION_PREFIX} live flag read for the
3071
+ * React client's `useFlag`/`useFlags`. `functionPath` carries the flag key +
3072
+ * type and `args` the per-subscriber targeting context; the codegen subclass
3073
+ * overrides this to evaluate the flag through the app's `@lunora/flags`
3074
+ * provider under `identity` and return the resolved value. The base class
3075
+ * wires no provider, so it returns `null` — `resolveReactiveOutcome` reads
3076
+ * `null` as "nothing to deliver" and the subscriber keeps its default.
3077
+ */
3078
+ // 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
3079
+ runFlagSubscriptionRead(_functionPath, _arguments, _identity) {
3080
+ return Promise.resolve(null);
3081
+ }
3082
+ /**
3083
+ * The Cloudflare Queues declared by this app, surfaced via
3084
+ * `__lunora_admin__:listQueues` for the studio's Queues page. Queues are NOT
3085
+ * Durable Objects and hold no shard state, so this is pure declaration
3086
+ * metadata statically discovered by `@lunora/codegen` from `lunora/queues.ts`
3087
+ * and emitted into the generated subclass, which overrides this. The base
3088
+ * class can't see the user's project, so it reports none.
3089
+ */
3090
+ // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the statically-discovered queue metadata
3091
+ queuesMetadata() {
3092
+ return { queues: [] };
1863
3093
  }
1864
3094
  /**
1865
3095
  * The Cloudflare Workflows declared by this app, surfaced via
@@ -1941,7 +3171,7 @@ class ShardDO {
1941
3171
  */
1942
3172
  // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to build a schema-aware writer
1943
3173
  runShardWrite(args) {
1944
- return Promise.reject(Object.assign(new Error(`unknown table: ${args.table}`), { code: "UNKNOWN_TABLE", name: "LunoraError", status: 404 }));
3174
+ return Promise.reject(new LunoraError("UNKNOWN_TABLE", `unknown table: ${args.table}`, { status: 404 }));
1945
3175
  }
1946
3176
  /**
1947
3177
  * Delete one row by primary key THROUGH the schema-aware writer — the
@@ -1956,7 +3186,7 @@ class ShardDO {
1956
3186
  */
1957
3187
  // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to build a schema-aware writer
1958
3188
  deleteRowThroughWriter(_table, _id) {
1959
- return Promise.reject(Object.assign(new Error(`unknown table: ${_table}`), { code: "UNKNOWN_TABLE", name: "LunoraError", status: 404 }));
3189
+ return Promise.reject(new LunoraError("UNKNOWN_TABLE", `unknown table: ${_table}`, { status: 404 }));
1960
3190
  }
1961
3191
  /**
1962
3192
  * Bulk-delete the rows of `table` matching the active `filters`/`search`
@@ -1998,9 +3228,7 @@ class ShardDO {
1998
3228
  */
1999
3229
  // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to build a schema-aware writer
2000
3230
  runShardRankBefore(_args) {
2001
- return Promise.reject(
2002
- Object.assign(new Error("rankBefore is not implemented in base ShardDO"), { code: "NOT_IMPLEMENTED", name: "LunoraError", status: 500 })
2003
- );
3231
+ return Promise.reject(new LunoraError("NOT_IMPLEMENTED", "rankBefore is not implemented in base ShardDO", { status: 500 }));
2004
3232
  }
2005
3233
  /**
2006
3234
  * Page this shard's local ranked slice under `index`, each row tagged with
@@ -2014,9 +3242,7 @@ class ShardDO {
2014
3242
  */
2015
3243
  // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to build a schema-aware writer
2016
3244
  runShardRankPage(_args) {
2017
- return Promise.reject(
2018
- Object.assign(new Error("rankPage is not implemented in base ShardDO"), { code: "NOT_IMPLEMENTED", name: "LunoraError", status: 500 })
2019
- );
3245
+ return Promise.reject(new LunoraError("NOT_IMPLEMENTED", "rankPage is not implemented in base ShardDO", { status: 500 }));
2020
3246
  }
2021
3247
  /**
2022
3248
  * Page this shard's change-data-capture log past `sinceSeq`. Read-only and
@@ -2124,13 +3350,15 @@ class ShardDO {
2124
3350
  * unless the request carried an `x-lunora-mutation-id` header (queries and
2125
3351
  * legacy clients leave `currentRequestMutationId` undefined).
2126
3352
  *
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.
3353
+ * For a mutation this runs INSIDE the handler's transaction (via
3354
+ * {@link ShardDO.commitMutationBookkeeping}, which `handleRpc` invokes before
3355
+ * the transaction commits), so the dedup row is durable iff the writes are —
3356
+ * closing the crash window where the writes commit but the replay guard does
3357
+ * not. Actions/queries aren't transaction-wrapped, so they call this on the
3358
+ * live dispatch path right after the handler resolves, through the same
3359
+ * `this.sql` handle. `INSERT OR IGNORE` keeps a concurrent double-dispatch (or
3360
+ * the now-skipped post-dispatch call) of the same id idempotent. Also runs the
3361
+ * throttled dedup-table GC.
2134
3362
  */
2135
3363
  persistIdempotentResult(result) {
2136
3364
  if (this.currentRequestMutationId === void 0) {
@@ -2138,7 +3366,7 @@ class ShardDO {
2138
3366
  }
2139
3367
  const now = Date.now();
2140
3368
  try {
2141
- writeIdempotent(this.sql, this.currentRequestUserId ?? "", this.currentRequestMutationId, JSON.stringify(result) ?? "null", now);
3369
+ writeIdempotent(this.sql, this.currentRequestUserId ?? "", this.currentRequestMutationId, JSON.stringify(encodeWire(result)), now);
2142
3370
  if (now - this.lastIdempotencyTrimAt > IDEMPOTENCY_GC_INTERVAL_MS) {
2143
3371
  trimIdempotent(this.sql, now - IDEMPOTENCY_RETENTION_MS);
2144
3372
  this.lastIdempotencyTrimAt = now;
@@ -2146,6 +3374,195 @@ class ShardDO {
2146
3374
  } catch {
2147
3375
  }
2148
3376
  }
3377
+ /**
3378
+ * Whether `functionPath` names a registered custom mutator (a `defineMutator`
3379
+ * declaration) rather than an ordinary `mutation`. The base class knows of no
3380
+ * mutators, so the default is `false`; the codegen-generated subclass
3381
+ * overrides this to consult its mutator registry. When `true` (and the push
3382
+ * carries a `clientId`/`clientSeq`), the dispatch path applies the
3383
+ * `__client_watermark` ordering semantics instead of the legacy idempotency
3384
+ * dedup.
3385
+ */
3386
+ // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this to consult its mutator registry
3387
+ isCustomMutator(_functionPath) {
3388
+ return false;
3389
+ }
3390
+ /**
3391
+ * Classify an in-flight custom-mutator push against the shard's stored
3392
+ * high-watermark for `currentRequestClientId`. The watermark is the highest
3393
+ * per-client sequence the DO has applied, so the push is exactly one of:
3394
+ *
3395
+ * - `"already"` — `seq &lt;= watermark`: a replay of a confirmed (or in-flight,
3396
+ * now-resent) mutation. The handler must NOT re-run; the dispatch path returns
3397
+ * a benign ack so the client drops the pending overlay.
3398
+ * - `"next"` — `seq == watermark + 1`: the next mutation in order. Run the
3399
+ * authoritative `server` impl and advance the watermark in the same commit.
3400
+ * - `"gap"` — `seq > watermark + 1`: an out-of-order arrival (an earlier push
3401
+ * was lost). Halt: the client must resend from `watermark + 1`.
3402
+ *
3403
+ * Returns `undefined` when the push is not a watermarked custom mutator
3404
+ * (missing client id/seq, or a stub `sql` handle without the table) so the
3405
+ * caller falls through to the legacy idempotency path.
3406
+ */
3407
+ classifyClientMutation() {
3408
+ const clientId = this.currentRequestClientId;
3409
+ const seq = this.currentRequestClientSeq;
3410
+ if (clientId === void 0 || seq === void 0) {
3411
+ return void 0;
3412
+ }
3413
+ const identity = this.currentRequestUserId ?? "";
3414
+ let watermark;
3415
+ try {
3416
+ watermark = readClientWatermark(this.sql, identity, clientId);
3417
+ } catch {
3418
+ try {
3419
+ migrateClientWatermark(this.sql);
3420
+ watermark = readClientWatermark(this.sql, identity, clientId);
3421
+ } catch {
3422
+ return void 0;
3423
+ }
3424
+ }
3425
+ const expected = watermark + 1;
3426
+ if (seq <= watermark) {
3427
+ return { expected, kind: "already" };
3428
+ }
3429
+ return seq === expected ? { expected, kind: "next" } : { expected, kind: "gap" };
3430
+ }
3431
+ /**
3432
+ * Terminal response for a watermarked custom-mutator push that is NOT the
3433
+ * next-in-order mutation — an idempotent replay ack (`"already"`) or an
3434
+ * out-of-order halt (`"gap"`). Returns `undefined` for an ordinary mutation
3435
+ * or a `"next"` push so `fetch` proceeds to the authoritative handler. Records
3436
+ * the function call on the short-circuit paths so metrics stay attributed.
3437
+ */
3438
+ rejectNonNextMutation(functionPath, mutatorClass, dispatchStartedAt) {
3439
+ if (mutatorClass === void 0 || mutatorClass.kind === "next") {
3440
+ return void 0;
3441
+ }
3442
+ this.recordFunctionCall(functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
3443
+ if (mutatorClass.kind === "already") {
3444
+ return jsonResponse({ lastMutationId: mutatorClass.expected - 1, result: null }, 200, bookmarkHeaders(this.currentResponseBookmark));
3445
+ }
3446
+ return jsonResponse(
3447
+ {
3448
+ error: {
3449
+ code: "OUT_OF_ORDER",
3450
+ expectedMutationId: mutatorClass.expected,
3451
+ message: `out-of-order mutation; expected sequence ${String(mutatorClass.expected)}`
3452
+ }
3453
+ },
3454
+ 409,
3455
+ bookmarkHeaders(this.currentResponseBookmark)
3456
+ );
3457
+ }
3458
+ /**
3459
+ * Respond to a dispatch that hit the `(identity, mutationId)` idempotency
3460
+ * cache. Records the (zero-work) function call, then: for a `"next"` custom
3461
+ * mutator whose handler already committed but whose watermark advance was
3462
+ * lost to a crash in between, re-advance and echo `lastMutationId` exactly as
3463
+ * the post-commit path does (otherwise the cached branch returns a bare
3464
+ * result with a stale watermark and the client reports every later seq as a
3465
+ * gap forever); for everything else, return the bare cached `{ result }`.
3466
+ */
3467
+ respondFromIdempotencyCache(functionPath, dispatchStartedAt, mutatorClass, cachedValue) {
3468
+ this.recordFunctionCall(functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
3469
+ if (mutatorClass?.kind === "next") {
3470
+ this.advanceClientMutationWatermark();
3471
+ return this.buildDispatchResponse(mutatorClass, cachedValue);
3472
+ }
3473
+ const commitCursor = this.mutationCommitCursor();
3474
+ return jsonResponse(
3475
+ commitCursor === void 0 ? { result: cachedValue } : { commitCursor, result: cachedValue },
3476
+ 200,
3477
+ bookmarkHeaders(this.currentResponseBookmark)
3478
+ );
3479
+ }
3480
+ /**
3481
+ * The CDC cursor a just-committed plain mutation landed at — the post-write
3482
+ * high-watermark, on the same scale as the `cursor` on `data`/`delta` frames.
3483
+ * The client drops a pending per-call optimistic overlay once it sees a frame
3484
+ * with `cursor >= commitCursor` (gapless reconciliation that keeps mutations
3485
+ * concurrent, without the serialized custom-mutator watermark). Scoped to a
3486
+ * mutation (carries an `x-lunora-mutation-id`) on a CDC-enabled shard;
3487
+ * `undefined` otherwise, leaving the wire byte-identical for queries/actions
3488
+ * and CDC-off shards.
3489
+ */
3490
+ mutationCommitCursor() {
3491
+ return this.currentRequestMutationId === void 0 ? void 0 : this.currentCdcCursor();
3492
+ }
3493
+ /**
3494
+ * Build the success response for a dispatched RPC. A `"next"` custom-mutator
3495
+ * push echoes the applied `lastMutationId` so the client drops the pending
3496
+ * optimistic overlay as soon as the ack lands; a plain mutation echoes
3497
+ * `commitCursor` (see {@link mutationCommitCursor}); other calls return the
3498
+ * bare `{ result }` envelope unchanged.
3499
+ */
3500
+ buildDispatchResponse(mutatorClass, result) {
3501
+ if (mutatorClass?.kind === "next") {
3502
+ return jsonResponse({ lastMutationId: this.currentRequestClientSeq, result }, 200, bookmarkHeaders(this.currentResponseBookmark));
3503
+ }
3504
+ const commitCursor = this.mutationCommitCursor();
3505
+ return jsonResponse(commitCursor === void 0 ? { result } : { commitCursor, result }, 200, bookmarkHeaders(this.currentResponseBookmark));
3506
+ }
3507
+ /**
3508
+ * Commit a mutation's replay bookkeeping — the `(identity, mutationId)`
3509
+ * idempotency dedup row and, for a `"next"` custom-mutator push, the
3510
+ * `__client_watermark` advance — INSIDE the handler's transaction. Called by
3511
+ * the generated `handleRpc` mutation branch after the user handler resolves
3512
+ * but before the transaction commits, so the writes, the dedup row, and the
3513
+ * watermark land in one atomic commit: a crash can't leave the writes durable
3514
+ * without the replay guard (which a re-dispatch would otherwise re-run) nor
3515
+ * without the watermark. Sets {@link ShardDO.mutationBookkeepingCommitted} so
3516
+ * `fetch` skips the redundant post-dispatch persist.
3517
+ */
3518
+ commitMutationBookkeeping(result) {
3519
+ this.persistIdempotentResult(result);
3520
+ if (this.currentMutatorClass?.kind === "next") {
3521
+ this.advanceClientMutationWatermark({ strict: true });
3522
+ }
3523
+ this.mutationBookkeepingCommitted = true;
3524
+ }
3525
+ /**
3526
+ * Best-effort replay bookkeeping for the live dispatch path, run after
3527
+ * `handleRpc` returns. A generated mutation already committed it atomically
3528
+ * inside its transaction (via {@link ShardDO.commitMutationBookkeeping}, which
3529
+ * sets the flag), so this skips. Actions/queries aren't transaction-wrapped,
3530
+ * so they record their dedup row here (a no-op without an `x-lunora-mutation-id`),
3531
+ * and a `"next"` push advances its watermark (the gap self-heals on replay).
3532
+ */
3533
+ recordPostDispatchBookkeeping(result, mutatorClass) {
3534
+ if (this.mutationBookkeepingCommitted) {
3535
+ return;
3536
+ }
3537
+ this.persistIdempotentResult(result);
3538
+ if (mutatorClass?.kind === "next") {
3539
+ this.advanceClientMutationWatermark();
3540
+ }
3541
+ }
3542
+ /**
3543
+ * Advance the stored high-watermark for the in-flight custom mutator to
3544
+ * `currentRequestClientSeq` through the same `this.sql` handle. On the
3545
+ * transactional path ({@link ShardDO.commitMutationBookkeeping}, `strict`) it
3546
+ * runs inside the handler's commit, so the watermark is durable iff the writes
3547
+ * are; a failure rethrows to roll the mutation back. On the best-effort
3548
+ * cache-hit recovery path (`strict` omitted) a missing table is swallowed —
3549
+ * the replay re-runs and re-advances (the read side treats a missing row as
3550
+ * watermark 0), so the gap self-heals.
3551
+ */
3552
+ advanceClientMutationWatermark(options) {
3553
+ const clientId = this.currentRequestClientId;
3554
+ const seq = this.currentRequestClientSeq;
3555
+ if (clientId === void 0 || seq === void 0) {
3556
+ return;
3557
+ }
3558
+ try {
3559
+ advanceClientWatermark(this.sql, this.currentRequestUserId ?? "", clientId, seq);
3560
+ } catch (error) {
3561
+ if (options?.strict) {
3562
+ throw error;
3563
+ }
3564
+ }
3565
+ }
2149
3566
  /**
2150
3567
  * Replay a batch of CDC changes into this shard (point-in-time recovery).
2151
3568
  * Schema-aware — it builds a `createShardCtxDb` writer — so the base class
@@ -2154,9 +3571,7 @@ class ShardDO {
2154
3571
  */
2155
3572
  // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to build a schema-aware writer
2156
3573
  runShardApplyCdc(_args) {
2157
- return Promise.reject(
2158
- Object.assign(new Error("applyCdc is not implemented in base ShardDO"), { code: "NOT_IMPLEMENTED", name: "LunoraError", status: 500 })
2159
- );
3574
+ return Promise.reject(new LunoraError("NOT_IMPLEMENTED", "applyCdc is not implemented in base ShardDO", { status: 500 }));
2160
3575
  }
2161
3576
  /**
2162
3577
  * Register a subscription on the given socket. Stored via
@@ -2195,6 +3610,56 @@ class ShardDO {
2195
3610
  }
2196
3611
  this.subMemos.get(ws)?.delete(subId);
2197
3612
  }
3613
+ /**
3614
+ * Register a live shape subscription on a socket — the partial-replication
3615
+ * parallel to {@link ShardDO.subscribe}. Stores the descriptor in the
3616
+ * attachment's `shapes` registry (created lazily) so it survives
3617
+ * hibernation, sharing the per-socket cap with `subs`. Returns a status the
3618
+ * caller surfaces as a structured error frame; never throws (a thrown
3619
+ * `webSocketMessage` is a fatal-channel error under the hibernation API).
3620
+ */
3621
+ shapeSubscribe(ws, subId, shape) {
3622
+ const attachment = this.readAttachment(ws);
3623
+ const shapes = attachment.shapes ?? {};
3624
+ if (Object.keys(attachment.subs).length + Object.keys(shapes).length >= ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET) {
3625
+ return "too_many";
3626
+ }
3627
+ shapes[subId] = shape;
3628
+ attachment.shapes = shapes;
3629
+ try {
3630
+ ws.serializeAttachment?.(attachment);
3631
+ } catch {
3632
+ delete attachment.shapes[subId];
3633
+ return "serialize_failed";
3634
+ }
3635
+ return "ok";
3636
+ }
3637
+ /** Remove a shape subscription and its poke baseline. Mirrors {@link ShardDO.unsubscribe}'s rollback-on-serialize-failure contract. */
3638
+ shapeUnsubscribe(ws, subId) {
3639
+ const attachment = this.readAttachment(ws);
3640
+ const { shapes } = attachment;
3641
+ if (!shapes) {
3642
+ return;
3643
+ }
3644
+ const captured = shapes[subId];
3645
+ delete shapes[subId];
3646
+ try {
3647
+ ws.serializeAttachment?.(attachment);
3648
+ } catch {
3649
+ if (captured !== void 0) {
3650
+ shapes[subId] = captured;
3651
+ }
3652
+ return;
3653
+ }
3654
+ this.shapeMemos.get(ws)?.delete(subId);
3655
+ this.globalShapeSnapshots.get(ws)?.delete(subId);
3656
+ if (attachment.connectionId !== void 0) {
3657
+ try {
3658
+ deleteGlobalShapeSnapshot(this.sql, attachment.connectionId, subId);
3659
+ } catch {
3660
+ }
3661
+ }
3662
+ }
2198
3663
  /**
2199
3664
  * Decide whether a single subscription is interested in a mutation
2200
3665
  * delta. The default implementation checks the table name, then runs a
@@ -2243,10 +3708,7 @@ class ShardDO {
2243
3708
  if (!this.matchesSubscription(query, delta)) {
2244
3709
  continue;
2245
3710
  }
2246
- try {
2247
- ws.send(`{"type":"delta","id":${JSON.stringify(subId)},"delta":${deltaJson}}`);
2248
- } catch {
2249
- }
3711
+ trySendFrame(ws, `{"type":"delta","id":${JSON.stringify(subId)},"delta":${deltaJson}}`);
2250
3712
  }
2251
3713
  }
2252
3714
  }
@@ -2269,6 +3731,91 @@ class ShardDO {
2269
3731
  executeSubscription(_functionPath, _args, _identity) {
2270
3732
  return Promise.resolve(null);
2271
3733
  }
3734
+ /**
3735
+ * Resolve a named shape to its concrete query plan for `identity`. The base
3736
+ * class has no shape registry, so it returns `undefined` — partial
3737
+ * replication is disabled and a `shape_subscribe` is rejected. The
3738
+ * codegen-generated subclass overrides this to look the shape up in the
3739
+ * project's `defineShape` registry, evaluate its `where(ctx, args)` under the
3740
+ * subscriber's verified identity, and AND-compose it with the table's RLS
3741
+ * read base-where into {@link ResolvedShape.effectiveWhere}.
3742
+ *
3743
+ * `identity` is the socket's OWN verified identity (the same unforgeable
3744
+ * value `refreshSubscriptions` threads), passed by value so this never reads
3745
+ * the mutable per-request identity fields. Returning `undefined` is the
3746
+ * fail-closed signal — an unknown shape, or an RLS-required table with no
3747
+ * policy resolving for this identity, yields no subscription rather than
3748
+ * leaking rows.
3749
+ */
3750
+ // 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
3751
+ resolveShape(_name, _args, _identity) {
3752
+ return void 0;
3753
+ }
3754
+ /**
3755
+ * The RLS-uniform gate (plan 075 Phase 3): whether a reactive shape may be
3756
+ * relay-multicast — i.e. one delta is correct for **every** subscriber. The owner
3757
+ * decides it (see {@link OwnerRelay.isShapeRelayUniform} — a static RLS read-policy
3758
+ * guard plus claim-exhaustive `Proxy` probes, fail-closed); this thin delegation
3759
+ * is the seam the gate test exercises. A non-owner DO is never relay-uniform.
3760
+ */
3761
+ isShapeRelayUniform(name, args) {
3762
+ return this.relay?.isShapeRelayUniform(name, args) ?? false;
3763
+ }
3764
+ /**
3765
+ * Read the FULL current membership of a `.global()`-table shape from its D1
3766
+ * (or Hyperdrive) backend — the seed/poll source for the latency-tiered
3767
+ * global shape path. A `.global()` table lives in another store with no
3768
+ * per-DO op-log, so this is the only way to learn its rows from inside the
3769
+ * shard DO; {@link ShardDO.seedGlobalShape} calls it once on subscribe and
3770
+ * {@link ShardDO.refreshGlobalShape} on every alarm tick, diffing the result
3771
+ * against the per-socket snapshot to compute the poke.
3772
+ *
3773
+ * The base class has no global backend, so it returns `[]` (a base-only DO,
3774
+ * or a project with no global tables, never resolves a global shape). The
3775
+ * codegen subclass overrides it to drain `globalDb.findMany(table, { where:
3776
+ * effectiveWhere })` under the socket's verified `identity` — the same
3777
+ * unforgeable value `resolveShape` composed the RLS predicate with, so the
3778
+ * D1 read is identity-scoped exactly like the poke-live path.
3779
+ */
3780
+ // 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
3781
+ readGlobalShapeRows(_resolved, _identity) {
3782
+ return Promise.resolve([]);
3783
+ }
3784
+ /**
3785
+ * Poll external-source (`.source(...)`) tables once (plan 077): materialize
3786
+ * each sourced table's freshly-pulled tenant slice into this DO's SQLite. The
3787
+ * base `ShardDO` has no sourced tables, so it returns `0` and the ingest tier
3788
+ * stays dormant — zero behavior change for every existing DO. The codegen
3789
+ * subclass overrides it to, per sourced table, build a `createShardCtxDb`
3790
+ * writer, read the tenant slice from Hyperdrive under this DO's shard key, and
3791
+ * run `runExternalSourceTick` (read local baseline → diff → apply via the
3792
+ * validated CDC writer). Returns the number of sourced tables still being
3793
+ * polled, so the shared poll alarm ({@link ShardDO.alarm}) re-arms while ingest
3794
+ * is active.
3795
+ */
3796
+ // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass implements the real Hyperdrive-backed poll
3797
+ pollExternalSources() {
3798
+ return Promise.resolve(0);
3799
+ }
3800
+ /**
3801
+ * Arm the shared poll alarm for external-source ingest (plan 077). The alarm is
3802
+ * shared with the global-shape poll tier; the codegen subclass calls this once
3803
+ * (on construction / first sourced write) so a sourced DO starts its ingest
3804
+ * loop, after which {@link ShardDO.alarm} re-arms itself while
3805
+ * {@link ShardDO.pollExternalSources} reports remaining work. Idempotent; a
3806
+ * no-op when the runtime exposes no `setAlarm` (unit harness).
3807
+ */
3808
+ scheduleSourcePoll() {
3809
+ return this.scheduleGlobalPoll();
3810
+ }
3811
+ /** This DO's shard key (its DO name), or `__root__` for the single-DO default. The `tenantBy` mapper binds it into the source query. */
3812
+ currentShardKey() {
3813
+ return this.state.id?.name ?? ROOT_SHARD_NAME;
3814
+ }
3815
+ /** Record a contained external-source ingest failure (one sourced table's poll) into the log ring without aborting the others. */
3816
+ recordExternalSourceError(table, error) {
3817
+ this.recordShapeError(`source:${table}`, error);
3818
+ }
2272
3819
  /**
2273
3820
  * Look up a streaming-query function and return a thunk that produces the
2274
3821
  * `AsyncIterable&lt;unknown>` when handed an {@link AbortSignal}. The codegen
@@ -2308,8 +3855,17 @@ class ShardDO {
2308
3855
  const tracker = createDependencyTracker();
2309
3856
  this.currentTracker = tracker;
2310
3857
  const hitsBefore = this.reactiveCache.stats().hits;
3858
+ const userId = this.getCurrentUserId();
3859
+ const claims = this.getCurrentIdentity();
3860
+ const identity = userId === void 0 && claims === void 0 ? (
3861
+ // eslint-disable-next-line unicorn/no-null -- reactiveCacheKey's identity arg is `null | string`; null is the documented "anonymous caller" discriminator
3862
+ null
3863
+ ) : (
3864
+ // eslint-disable-next-line unicorn/no-null -- fold userId/claims into the discriminator; missing fields serialize as null so the shape stays canonical
3865
+ stableStringify({ claims: claims ?? null, userId: userId ?? null })
3866
+ );
2311
3867
  try {
2312
- const result = await this.reactiveCache.run(reactiveCacheKey(functionPath, args, this.getCurrentUserId() ?? null), tracker.collect(), run);
3868
+ const result = await this.reactiveCache.run(reactiveCacheKey(functionPath, args, identity), tracker.collect(), run);
2313
3869
  this.currentRequestCacheHit = this.reactiveCache.stats().hits > hitsBefore;
2314
3870
  this.currentRequestReadTables = tablesFromDeps(tracker.collect());
2315
3871
  return result;
@@ -2667,20 +4223,67 @@ class ShardDO {
2667
4223
  */
2668
4224
  // eslint-disable-next-line class-methods-use-this -- cohesive DO instance method (groups with the request handlers); kept non-static so subclasses can override the error mapping
2669
4225
  errorToResponse(error) {
2670
- if (error instanceof ConflictError) {
2671
- return jsonResponse({ error: { code: error.code, message: error.message } }, error.status);
4226
+ const { body, redacted, status } = toErrorBody(error, { encodeData: encodeWire, fallbackCode: "RPC_FAILED", redactedMessage: "internal error" });
4227
+ if (redacted) {
4228
+ console.error("[@lunora/do] internal error:", error);
4229
+ }
4230
+ return jsonResponse({ error: body }, status);
4231
+ }
4232
+ /**
4233
+ * Batch dispatch (plan 088). Applies each `calls[]` entry through the SAME
4234
+ * single-call `/rpc` path (via a nested `this.fetch`), **sequentially**, so
4235
+ * the per-`(identity, mutationId)` idempotency dedup and the per-client
4236
+ * `__client_watermark` ordering are enforced entry-by-entry exactly as for an
4237
+ * individual call — no duplication of the dispatch core, no reordering.
4238
+ *
4239
+ * Failures are **per-slot, not fail-fast**: an entry that throws (or a
4240
+ * custom-mutator `OUT_OF_ORDER` gap) is captured in its own result slot and
4241
+ * later entries still run. Ordering is still safe — a later same-client
4242
+ * mutator after a gap re-classifies as a gap too (the watermark never
4243
+ * advanced), so it cannot apply out of order; unrelated entries/queries are
4244
+ * independent. The response is `{ results: [{ id, status, body }] }` in
4245
+ * request order; each `body` is the untouched single-call envelope (its
4246
+ * `result` already wire-encoded), so the client demuxes + decodes each
4247
+ * exactly as one call.
4248
+ */
4249
+ async handleBatchRpc(request) {
4250
+ let payload;
4251
+ try {
4252
+ payload = await request.json();
4253
+ } catch {
4254
+ return jsonResponse({ error: { code: "BAD_REQUEST", message: "invalid JSON body" } }, 400);
4255
+ }
4256
+ if (!Array.isArray(payload.calls)) {
4257
+ return jsonResponse({ error: { code: "BAD_REQUEST", message: "batch `calls` must be an array" } }, 400);
2672
4258
  }
2673
- 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);
4259
+ if (payload.calls.length > MAX_BATCH_ENTRIES) {
4260
+ return jsonResponse({ error: { code: "BAD_REQUEST", message: `batch exceeds the ${String(MAX_BATCH_ENTRIES)}-call limit` } }, 400);
4261
+ }
4262
+ const results = [];
4263
+ let latestBookmark;
4264
+ for (const raw of payload.calls) {
4265
+ const outcome = await this.dispatchBatchEntry(request, raw);
4266
+ if (outcome.bookmark !== void 0) {
4267
+ latestBookmark = outcome.bookmark;
4268
+ }
4269
+ results.push({ body: outcome.body, id: outcome.id, status: outcome.status });
2676
4270
  }
2677
- if (error && typeof error === "object" && error.name === "LunoraError") {
2678
- const lunoraError = error;
2679
- const status = typeof lunoraError.status === "number" ? lunoraError.status : 500;
2680
- return jsonResponse({ error: { code: lunoraError.code ?? "INTERNAL", message: lunoraError.message ?? "internal error" } }, status);
4271
+ return jsonResponse({ results }, 200, bookmarkHeaders(latestBookmark));
4272
+ }
4273
+ /** Dispatch one batch entry through the single-call `/rpc` path and capture its envelope (plan 088). */
4274
+ async dispatchBatchEntry(batchRequest, entry) {
4275
+ try {
4276
+ const response = await this.fetch(buildBatchEntryRequest(batchRequest, entry));
4277
+ return { body: await response.json(), bookmark: response.headers.get("x-d1-bookmark") ?? void 0, id: entry.id, status: response.status };
4278
+ } catch (error) {
4279
+ const { body, status } = toErrorBody(error, { fallbackCode: "BATCH_ENTRY_FAILED" });
4280
+ return {
4281
+ body: { error: body },
4282
+ bookmark: void 0,
4283
+ id: entry?.id,
4284
+ status
4285
+ };
2681
4286
  }
2682
- const message = error instanceof Error ? error.message : "unknown error";
2683
- return jsonResponse({ error: { code: "RPC_FAILED", message } }, 500);
2684
4287
  }
2685
4288
  /**
2686
4289
  * Serve a reserved admin-introspection RPC (`__lunora_admin__:*`) for the
@@ -2796,12 +4399,27 @@ class ShardDO {
2796
4399
  if (functionPath === ADMIN_FUNCTIONS.sendTestMail) {
2797
4400
  return this.handleSendTestMail(args);
2798
4401
  }
4402
+ if (functionPath === ADMIN_FUNCTIONS.recordQueueMessage) {
4403
+ return this.handleRecordQueueMessage(args);
4404
+ }
4405
+ if (functionPath === ADMIN_FUNCTIONS.clearQueueMessages) {
4406
+ return this.handleClearQueueMessages();
4407
+ }
4408
+ if (functionPath === ADMIN_FUNCTIONS.sendQueueMessage) {
4409
+ return this.handleSendQueueMessage(args);
4410
+ }
4411
+ if (functionPath === ADMIN_FUNCTIONS.replayQueueMessage) {
4412
+ return this.handleReplayQueueMessage(args);
4413
+ }
2799
4414
  if (functionPath === ADMIN_FUNCTIONS.createWorkflowInstance) {
2800
4415
  return this.handleCreateWorkflowInstance(args);
2801
4416
  }
2802
4417
  if (functionPath === ADMIN_FUNCTIONS.getWorkflowInstanceStatus) {
2803
4418
  return this.handleGetWorkflowInstanceStatus(args);
2804
4419
  }
4420
+ if (functionPath === ADMIN_FUNCTIONS.listFlags) {
4421
+ return this.handleListFlags(args);
4422
+ }
2805
4423
  return this.handlePitrAdminOp(functionPath, args);
2806
4424
  }
2807
4425
  /**
@@ -2831,10 +4449,32 @@ class ShardDO {
2831
4449
  * the panel renders it alongside `ctx.log` lines. Admin-gated by
2832
4450
  * `handleAdminRpc`'s caller (the same `LUNORA_ADMIN_TOKEN` bearer as every
2833
4451
  * other admin write).
2834
- */
2835
- handleRecordContainerEvent(args) {
4452
+ *
4453
+ * An `error`-level lifecycle event (a crash/`onError`, or a non-zero-exit
4454
+ * `stop`) is ALSO appended as an `error`-outcome row to the durable
4455
+ * `__lunora_reqlog__` — the same readout `getIssues` groups over — so a
4456
+ * crash-looping container folds into the Issues list right beside Worker
4457
+ * errors (they share the `fingerprintError` hash over `functionPath ::
4458
+ * bucket(message)`). The in-memory buffer stays the live Logs feed; the
4459
+ * durable row is what survives hibernation for triage.
4460
+ */
4461
+ async handleRecordContainerEvent(args) {
2836
4462
  const entry = parseRecordContainerEventArgs(args);
2837
4463
  this.logs.push(entry);
4464
+ const crashed = entry.level === "error" || entry.exitCode !== void 0 && entry.exitCode !== 0;
4465
+ if (crashed) {
4466
+ const logEntry = {
4467
+ durationMs: 0,
4468
+ errorMessage: entry.message,
4469
+ functionPath: entry.functionPath,
4470
+ outcome: "error",
4471
+ shardKey: this.state.id?.name,
4472
+ ts: entry.timestamp
4473
+ };
4474
+ this.persistRequestLog(logEntry, this.requestLogConfig());
4475
+ this.recordChangedTable(REQUEST_LOG_TABLE);
4476
+ await this.flushChangedTables();
4477
+ }
2838
4478
  return jsonResponse({ result: { recorded: true } }, 200);
2839
4479
  }
2840
4480
  /**
@@ -2872,15 +4512,11 @@ class ShardDO {
2872
4512
  resolveWorkflowBinding(exportName) {
2873
4513
  const metadata = this.workflowsMetadata().workflows.find((workflow) => workflow.exportName === exportName);
2874
4514
  if (!metadata) {
2875
- throw Object.assign(new Error(`workflow "${exportName}" is not declared`), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
4515
+ throw new LunoraError("BAD_REQUEST", `workflow "${exportName}" is not declared`);
2876
4516
  }
2877
4517
  const binding = this.env?.[metadata.binding];
2878
4518
  if (typeof binding !== "object" || binding === null || typeof binding.create !== "function" || typeof binding.get !== "function") {
2879
- throw Object.assign(new Error(`workflow binding "${metadata.binding}" is not available on this deployment`), {
2880
- code: "BAD_REQUEST",
2881
- name: "LunoraError",
2882
- status: 400
2883
- });
4519
+ throw new LunoraError("BAD_REQUEST", `workflow binding "${metadata.binding}" is not available on this deployment`);
2884
4520
  }
2885
4521
  return binding;
2886
4522
  }
@@ -2922,6 +4558,21 @@ class ShardDO {
2922
4558
  return jsonResponse({ result }, 200);
2923
4559
  }
2924
4560
  /* eslint-enable no-secrets/no-secrets */
4561
+ /**
4562
+ * Serve `__lunora_admin__:listFlags` — the studio's read-only Flags page.
4563
+ * Evaluates every statically-discovered feature flag under an optional
4564
+ * `args.context` targeting context (the studio's editable context editor)
4565
+ * via the {@link evaluateFlags} hook, which the codegen subclass overrides
4566
+ * with live OpenFeature evaluation. Read-only: a flag lookup mutates no shard
4567
+ * state, so nothing is flushed or audited. Admin-gated by `handleAdminRpc`'s
4568
+ * caller.
4569
+ */
4570
+ async handleListFlags(args) {
4571
+ const rawContext = args.context;
4572
+ const context = typeof rawContext === "object" && rawContext !== null && !Array.isArray(rawContext) ? rawContext : void 0;
4573
+ const result = await this.evaluateFlags(context);
4574
+ return jsonResponse({ result }, 200);
4575
+ }
2925
4576
  /**
2926
4577
  * Run `run()` with the per-request identity pinned to (`userId`, `identity`),
2927
4578
  * then restore the prior values in a `finally` (even if `run()` throws), so the
@@ -2987,6 +4638,119 @@ class ShardDO {
2987
4638
  const result = recordCapturedMail(this.state.storage.sql, input, Date.now());
2988
4639
  return jsonResponse({ result }, 200);
2989
4640
  }
4641
+ /**
4642
+ * Serve `__lunora_admin__:recordQueueMessage` — the capture sink the generated
4643
+ * worker `queue()` handler (via `@lunora/queue`'s `dispatchQueueBatch`) posts
4644
+ * every consumed message batch to. Records it into the reserved
4645
+ * `__lunora_queue_messages` table (bounded, auto-trimmed) so the studio Queues
4646
+ * panel shows one unified consumed-message log across every push consumer. Like
4647
+ * the mail catcher, the gate is the admin token alone — a token holder can
4648
+ * already mutate the shard, so a token-gated capture insert adds no privilege.
4649
+ */
4650
+ handleRecordQueueMessage(args) {
4651
+ const messages = parseRecordQueueMessageArgs(args);
4652
+ const result = recordQueueMessages(this.state.storage.sql, messages, Date.now());
4653
+ return jsonResponse({ result }, 200);
4654
+ }
4655
+ /** Empty the dev queue consumed-message log (studio "clear log" action). Admin-gated by the caller. */
4656
+ handleClearQueueMessages() {
4657
+ const result = clearQueueMessages(this.state.storage.sql);
4658
+ return jsonResponse({ result }, 200);
4659
+ }
4660
+ /**
4661
+ * Serve `__lunora_admin__:sendQueueMessage` — the studio's "Send test message"
4662
+ * button. Resolves the declared queue's `QUEUE_*` producer binding and calls
4663
+ * `.send(body, { delaySeconds?, contentType? })`, or `.sendBatch(...)` when a
4664
+ * `batch` array is supplied. No SQLite write happens here (the message is only
4665
+ * captured once a consumer processes it), so this only records an audit entry.
4666
+ * Admin-gated by `handleAdminRpc`'s caller.
4667
+ */
4668
+ async handleSendQueueMessage(args) {
4669
+ const parsed = parseSendQueueMessageArgs(args);
4670
+ const { binding } = this.resolveQueueBinding(parsed.exportName);
4671
+ let sent;
4672
+ if (parsed.batch === void 0) {
4673
+ await binding.send(parsed.body, { contentType: parsed.contentType, delaySeconds: parsed.delaySeconds });
4674
+ sent = 1;
4675
+ } else {
4676
+ await binding.sendBatch(
4677
+ parsed.batch.map((body) => {
4678
+ return { body, contentType: parsed.contentType, delaySeconds: parsed.delaySeconds };
4679
+ })
4680
+ );
4681
+ sent = parsed.batch.length;
4682
+ }
4683
+ this.recordAudit("sendQueueMessage", { detail: { count: sent, exportName: parsed.exportName } });
4684
+ return jsonResponse({ result: { sent } }, 200);
4685
+ }
4686
+ /**
4687
+ * Serve `__lunora_admin__:replayQueueMessage` — the studio's one-click replay /
4688
+ * DLQ redrive. Looks the captured row up by id, resolves the destination export
4689
+ * (explicit `target` → the parent queue when the message was captured off a
4690
+ * dead-letter queue → the queue it was consumed from), and re-enqueues the
4691
+ * stored body onto that producer. Records an audit entry; no SQLite write beyond
4692
+ * that (the replayed message is re-captured when a consumer processes it).
4693
+ * Admin-gated by `handleAdminRpc`'s caller.
4694
+ */
4695
+ async handleReplayQueueMessage(args) {
4696
+ const parsed = parseReplayQueueMessageArgs(args);
4697
+ const row = readQueueMessageById(this.state.storage.sql, parsed.id);
4698
+ if (row === void 0) {
4699
+ throw new LunoraError("BAD_REQUEST", `replayQueueMessage: captured message "${parsed.id}" was not found`, { status: 404 });
4700
+ }
4701
+ if (isLossyBody(row.body)) {
4702
+ throw new LunoraError(
4703
+ "BAD_REQUEST",
4704
+ `replayQueueMessage: captured message "${parsed.id}" has a truncated or unserializable body and can't be replayed faithfully`,
4705
+ { status: 422 }
4706
+ );
4707
+ }
4708
+ const target = parsed.target ?? this.resolveReplayTarget(row.queue) ?? row.exportName;
4709
+ if (typeof target !== "string" || target === "") {
4710
+ throw new LunoraError(
4711
+ "BAD_REQUEST",
4712
+ `replayQueueMessage: captured message "${parsed.id}" has no declared producer to replay onto (pass \`target\`)`
4713
+ );
4714
+ }
4715
+ const { binding } = this.resolveQueueBinding(target);
4716
+ await binding.send(row.body);
4717
+ this.recordAudit("replayQueueMessage", { detail: { messageId: row.messageId, target }, id: parsed.id });
4718
+ return jsonResponse({ result: { sent: 1, target } }, 200);
4719
+ }
4720
+ /**
4721
+ * Resolve a declared queue's runtime producer binding from this shard's `env`.
4722
+ * Looks the `exportName` up in {@link queuesMetadata} (the codegen subclass's
4723
+ * statically-discovered list) to find its generated `QUEUE_*` binding, then
4724
+ * reads `env[binding]` and validates it carries `send`/`sendBatch`. A bad export
4725
+ * name or a missing/malformed binding throws a 400 `LunoraError` so the studio
4726
+ * surfaces an actionable message. Mirrors {@link resolveWorkflowBinding}.
4727
+ */
4728
+ resolveQueueBinding(exportName) {
4729
+ const metadata = this.queuesMetadata().queues.find((queue) => queue.exportName === exportName);
4730
+ if (!metadata) {
4731
+ throw new LunoraError("BAD_REQUEST", `queue "${exportName}" is not declared`);
4732
+ }
4733
+ const binding = this.env?.[metadata.binding];
4734
+ if (typeof binding !== "object" || binding === null || typeof binding.send !== "function") {
4735
+ throw new LunoraError("BAD_REQUEST", `queue binding "${metadata.binding}" is not available on this deployment`);
4736
+ }
4737
+ return { binding, metadata };
4738
+ }
4739
+ /**
4740
+ * Pick the replay destination export for a captured message's origin queue.
4741
+ * When the message was consumed off a queue that is another queue's dead-letter
4742
+ * queue, prefer that PARENT queue's producer (a DLQ usually has no producer of
4743
+ * its own) so replay redrives onto the original; otherwise re-enqueue onto the
4744
+ * queue the message came from. Returns `undefined` when neither is declared.
4745
+ */
4746
+ resolveReplayTarget(queueName) {
4747
+ const { queues } = this.queuesMetadata();
4748
+ const parent = queues.find((queue) => queue.deadLetterQueue === queueName);
4749
+ if (parent !== void 0) {
4750
+ return parent.exportName;
4751
+ }
4752
+ return queues.find((queue) => queue.name === queueName)?.exportName;
4753
+ }
2990
4754
  /**
2991
4755
  * Append one durable audit entry for a state-changing admin op that just
2992
4756
  * succeeded, folding the acting user (from `getCurrentUserId`) into `detail`.
@@ -3051,12 +4815,33 @@ class ShardDO {
3051
4815
  ts: Date.now(),
3052
4816
  userId: this.getCurrentUserId()
3053
4817
  };
4818
+ this.persistRequestLog(entry, config);
4819
+ }
4820
+ /**
4821
+ * The single durable-row + Logpush write seam for `__lunora_reqlog__`. Both
4822
+ * the per-dispatch {@link recordRequestLog} and the container-crash path
4823
+ * ({@link handleRecordContainerEvent}) funnel through here so they can't
4824
+ * drift — before this was extracted the container writer persisted the row
4825
+ * but silently skipped the Logpush emit every other error got.
4826
+ *
4827
+ * Best-effort by contract: a SQL failure (e.g. a test double with no `sql`
4828
+ * handle) or a serialization hiccup in the emit must NEVER turn a served
4829
+ * request — or a container event push — into a failed one, so each half is
4830
+ * swallowed independently.
4831
+ *
4832
+ * Errors are always streamed to `console` (rare, high-value — they ride CF
4833
+ * Workers Logs at error level and the dev-server formats them in the
4834
+ * terminal), redacted in prod like any other event. The full per-dispatch
4835
+ * summary stream (successful OKs too) stays opt-in behind
4836
+ * `LUNORA_REQUEST_LOG_EMIT` so a hot shard doesn't emit a line per call.
4837
+ */
4838
+ persistRequestLog(entry, config) {
3054
4839
  const writeOptions = { captureRaw: config.captureRaw, retention: config.retention };
3055
4840
  try {
3056
4841
  appendRequestLogEntry(this.state.storage.sql, entry, writeOptions);
3057
4842
  } catch {
3058
4843
  }
3059
- if (config.emit || outcome === "error") {
4844
+ if (config.emit || entry.outcome === "error") {
3060
4845
  try {
3061
4846
  emitRequestLogEvent(entry, writeOptions);
3062
4847
  } catch {
@@ -3149,6 +4934,9 @@ class ShardDO {
3149
4934
  if (functionPath === ADMIN_FUNCTIONS.getRequestLog) {
3150
4935
  return this.readAdminRequestLog(sql, args);
3151
4936
  }
4937
+ if (functionPath === ADMIN_FUNCTIONS.getIssues) {
4938
+ return this.readAdminIssues(sql, args);
4939
+ }
3152
4940
  const durable = this.readAdminDurableSignal(functionPath, sql, args);
3153
4941
  if (durable) {
3154
4942
  return durable;
@@ -3274,6 +5062,9 @@ class ShardDO {
3274
5062
  if (functionPath === ADMIN_FUNCTIONS.listSubscriptions) {
3275
5063
  return this.collectSubscriptions();
3276
5064
  }
5065
+ if (functionPath === ADMIN_FUNCTIONS.getFanoutMetrics) {
5066
+ return this.collectFanoutMetrics();
5067
+ }
3277
5068
  if (functionPath === ADMIN_FUNCTIONS.getLogs) {
3278
5069
  return { entries: this.logs.entries() };
3279
5070
  }
@@ -3301,6 +5092,9 @@ class ShardDO {
3301
5092
  if (functionPath === ADMIN_FUNCTIONS.listWorkflows) {
3302
5093
  return this.workflowsMetadata();
3303
5094
  }
5095
+ if (functionPath === ADMIN_FUNCTIONS.listQueues) {
5096
+ return this.queuesMetadata();
5097
+ }
3304
5098
  return void 0;
3305
5099
  }
3306
5100
  /**
@@ -3313,6 +5107,32 @@ class ShardDO {
3313
5107
  collectSubscriptions() {
3314
5108
  return summarizeSubscriptions(this.state.getWebSockets().map((ws) => this.readAttachment(ws)));
3315
5109
  }
5110
+ /**
5111
+ * Assemble the `__lunora_admin__:getFanoutMetrics` payload for the Studio
5112
+ * fan-out observability panel (plan 075 Phase 1). The point-in-time topic
5113
+ * subscriber counts are folded live from each socket's attachment via
5114
+ * {@link summarizeFanoutTopics}; the running per-path cost counters are the
5115
+ * in-memory {@link ShardDO.fanout} tallies, sharing `metrics.sinceMs` as the
5116
+ * "since this instance woke" epoch. Touches no SQLite and mutates no socket
5117
+ * state; it does call `relay.relayCount()`, which advances the promotion
5118
+ * latch — safe here because that transition is a pure, monotonic function of
5119
+ * the live socket count (DOs are single-threaded), so a metrics poll only ever
5120
+ * drives the latch to the same state the routing path would compute for the
5121
+ * same count, never a divergent one.
5122
+ */
5123
+ collectFanoutMetrics() {
5124
+ const summary = summarizeFanoutTopics(this.state.getWebSockets().map((ws) => this.readAttachment(ws)));
5125
+ const relayCount = this.relay?.relayCount() ?? 0;
5126
+ return {
5127
+ ...summary,
5128
+ maxRelays: this.relay?.maxRelays() ?? DEFAULT_MAX_RELAYS,
5129
+ promoted: relayCount > 0,
5130
+ relayCount,
5131
+ shapePoke: this.fanout.shapePoke,
5132
+ sinceMs: this.metrics.sinceMs,
5133
+ whisper: this.fanout.whisper
5134
+ };
5135
+ }
3316
5136
  /** Resolve a `getAuditLog` admin read, parsing the optional `limit`/`sinceSeq` cursor args and ensuring the reserved table first. */
3317
5137
  // 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
5138
  readAdminAuditLog(sql, args) {
@@ -3338,10 +5158,34 @@ class ShardDO {
3338
5158
  entries: readRequestLog(sql, {
3339
5159
  functionPathPrefix: typeof args["functionPathPrefix"] === "string" ? args["functionPathPrefix"] : void 0,
3340
5160
  limit: typeof args["limit"] === "number" ? args["limit"] : void 0,
3341
- outcome,
5161
+ outcome,
5162
+ shardKey: typeof args["shardKey"] === "string" ? args["shardKey"] : void 0,
5163
+ sinceSeq: typeof args["sinceSeq"] === "number" ? args["sinceSeq"] : void 0,
5164
+ tableTouched: typeof args["tableTouched"] === "string" ? args["tableTouched"] : void 0,
5165
+ userId: typeof args["userId"] === "string" ? args["userId"] : void 0
5166
+ })
5167
+ };
5168
+ return { result, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
5169
+ }
5170
+ /**
5171
+ * Resolve a `getIssues` admin read: fold the recent `error`-outcome
5172
+ * request-log rows into grouped {@link readErrorIssues Issues} by fingerprint,
5173
+ * accepting the same optional correlation filters as `getRequestLog`
5174
+ * (function-path prefix, exact shardKey/userId) plus a `limit` on rows
5175
+ * scanned. This is a read over the bounded reqlog readout — no new store —
5176
+ * so a self-hosted worker gets grouped error triage for free. Carries the
5177
+ * {@link ADMIN_WILDCARD} like the other log reads so a live Issues
5178
+ * subscription re-runs on every write-flush (the per-socket JSON memo still
5179
+ * suppresses byte-identical pushes).
5180
+ */
5181
+ // eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers
5182
+ readAdminIssues(sql, args) {
5183
+ ensureRequestLogTable(sql);
5184
+ const result = {
5185
+ issues: readErrorIssues(sql, {
5186
+ functionPathPrefix: typeof args["functionPathPrefix"] === "string" ? args["functionPathPrefix"] : void 0,
5187
+ limit: typeof args["limit"] === "number" ? args["limit"] : void 0,
3342
5188
  shardKey: typeof args["shardKey"] === "string" ? args["shardKey"] : void 0,
3343
- sinceSeq: typeof args["sinceSeq"] === "number" ? args["sinceSeq"] : void 0,
3344
- tableTouched: typeof args["tableTouched"] === "string" ? args["tableTouched"] : void 0,
3345
5189
  userId: typeof args["userId"] === "string" ? args["userId"] : void 0
3346
5190
  })
3347
5191
  };
@@ -3376,6 +5220,9 @@ class ShardDO {
3376
5220
  if (functionPath === ADMIN_FUNCTIONS.getCapturedMail) {
3377
5221
  return this.readAdminCapturedMail(sql, args);
3378
5222
  }
5223
+ if (functionPath === ADMIN_FUNCTIONS.getQueueMessages) {
5224
+ return this.readAdminQueueMessages(sql, args);
5225
+ }
3379
5226
  return void 0;
3380
5227
  }
3381
5228
  // eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers
@@ -3406,6 +5253,28 @@ class ShardDO {
3406
5253
  }
3407
5254
  return { result, tables: /* @__PURE__ */ new Set([MAIL_TABLE]) };
3408
5255
  }
5256
+ /**
5257
+ * Resolve a `getQueueMessages` admin read — the dev queue catcher's consumed
5258
+ * message log (`queue-catcher.ts`), newest-first, optionally filtered to one
5259
+ * queue. Best-effort: a SQL failure returns an empty log rather than throwing.
5260
+ * Reported against the {@link QUEUE_TABLE} so this read participates in
5261
+ * table-scoped subscription invalidation, but new captures arrive via the
5262
+ * worker→root-shard `recordQueueMessage` write, which (like the mail catcher)
5263
+ * inserts directly without a `flushChangedTables` — so the panel refreshes on
5264
+ * its poll (`useAutoRefresh`) rather than a live push.
5265
+ */
5266
+ // eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers
5267
+ readAdminQueueMessages(sql, args) {
5268
+ const limit = typeof args["limit"] === "number" ? args["limit"] : void 0;
5269
+ const queue = typeof args["queue"] === "string" ? args["queue"] : void 0;
5270
+ let result;
5271
+ try {
5272
+ result = readQueueMessages(sql, { limit, queue });
5273
+ } catch {
5274
+ result = { entries: [] };
5275
+ }
5276
+ return { result, tables: /* @__PURE__ */ new Set([QUEUE_TABLE]) };
5277
+ }
3409
5278
  /** Resolve a `readTablePage` admin read, parsing the loosely-typed args into the reader's options. */
3410
5279
  readAdminTablePage(sql, args) {
3411
5280
  const table = typeof args["table"] === "string" ? args["table"] : "";
@@ -3416,6 +5285,7 @@ class ShardDO {
3416
5285
  orderBy: parseTablePageOrderBy(args["orderBy"]),
3417
5286
  refs: this.tableRefs(table),
3418
5287
  search: typeof args["search"] === "string" ? args["search"] : void 0,
5288
+ skipCount: typeof args["skipCount"] === "boolean" ? args["skipCount"] : void 0,
3419
5289
  table
3420
5290
  });
3421
5291
  return { result: page, tables: /* @__PURE__ */ new Set([table === "" ? ADMIN_WILDCARD : table]) };
@@ -3460,6 +5330,73 @@ class ShardDO {
3460
5330
  const read = this.readAdminOp(functionPath, args);
3461
5331
  return read ? { result: read.result, tables: read.tables } : null;
3462
5332
  }
5333
+ /**
5334
+ * Resolve one subscription (seed or refresh) to its {@link SubscriptionOutcome}
5335
+ * by routing the `functionPath` to the right read path — shared by
5336
+ * {@link seedSubscription} and {@link refreshSubscriptions} so both branch
5337
+ * identically:
5338
+ * - `__lunora_admin__:*` → {@link executeAdminSubscription} (raw SQLite read).
5339
+ * - {@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`).
5340
+ * - everything else → {@link executeSubscription} (the user query, under the socket's own by-value identity).
5341
+ */
5342
+ async resolveReactiveOutcome(functionPath, args, isAdmin, identity) {
5343
+ if (isAdmin) {
5344
+ return this.executeAdminSubscription(functionPath, args);
5345
+ }
5346
+ if (functionPath.startsWith(FLAGS_FUNCTION_PREFIX)) {
5347
+ const result = await this.runFlagSubscriptionRead(functionPath, args, identity);
5348
+ return result === null ? null : { result, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
5349
+ }
5350
+ return this.executeSubscription(functionPath, args, identity);
5351
+ }
5352
+ /**
5353
+ * SECURITY BOUNDARY for cross-socket reactive dedup. A read is
5354
+ * identity-INDEPENDENT only when its result cannot vary by the caller's
5355
+ * verified identity — i.e. the admin/reserved introspection reads, which
5356
+ * route to {@link executeAdminSubscription} and ignore the
5357
+ * {@link SubscriptionIdentity} entirely.
5358
+ *
5359
+ * Everything else is identity-DEPENDENT and must NEVER be shared across
5360
+ * sockets: a user query may be `rls()` / `ctx.auth`-scoped (different rows
5361
+ * per identity), and a flag read ({@link FLAGS_FUNCTION_PREFIX}) evaluates
5362
+ * the provider with the subscriber's identity (per-user targeting). Sharing
5363
+ * one socket's result with another would leak one identity's rows/flags to a
5364
+ * different identity, so this predicate gates {@link resolveReactiveOutcomeDeduped}
5365
+ * shut for them.
5366
+ */
5367
+ // 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
5368
+ isIdentityIndependent(functionPath) {
5369
+ return functionPath.startsWith(ADMIN_FUNCTION_PREFIX);
5370
+ }
5371
+ /**
5372
+ * Memoizing wrapper over {@link resolveReactiveOutcome}: flush-local sharing across sockets.
5373
+ * Within a single {@link refreshSubscriptions} pass, N sockets subscribed to
5374
+ * the SAME identity-independent `(functionPath, args)` re-run the query N
5375
+ * times today (see the Case-6 fan-out characterization). When the read is
5376
+ * identity-independent (admin/reserved — see {@link isIdentityIndependent})
5377
+ * its result is the same for every socket, so the first run is cached (by its
5378
+ * in-flight Promise, since the bounded worker pool runs sockets in parallel)
5379
+ * and shared with the rest — collapsing N runs to ONE.
5380
+ *
5381
+ * Identity-DEPENDENT reads are passed straight through, UNCACHED: each socket
5382
+ * must evaluate under its own by-value identity (RLS / `ctx.auth` / per-user
5383
+ * flags), so they never share a result. The `cache` is created fresh per
5384
+ * flush by the caller, so a result is never reused across passes (it would go
5385
+ * stale after the next write).
5386
+ */
5387
+ resolveReactiveOutcomeDeduped(functionPath, args, isAdmin, identity, cache) {
5388
+ if (!this.isIdentityIndependent(functionPath)) {
5389
+ return this.resolveReactiveOutcome(functionPath, args, isAdmin, identity);
5390
+ }
5391
+ const key = reactiveCacheKey(functionPath, args, null);
5392
+ const cached = cache.get(key);
5393
+ if (cached !== void 0) {
5394
+ return cached;
5395
+ }
5396
+ const pending = this.resolveReactiveOutcome(functionPath, args, isAdmin, identity);
5397
+ cache.set(key, pending);
5398
+ return pending;
5399
+ }
3463
5400
  /**
3464
5401
  * Constant-time bearer check against `env.LUNORA_ADMIN_TOKEN`. Returns
3465
5402
  * `false` (closed) when the token is unset so admin introspection is
@@ -3517,17 +5454,19 @@ class ShardDO {
3517
5454
  break;
3518
5455
  }
3519
5456
  await awaitWsDrain(ws);
3520
- ws.send(JSON.stringify({ data: chunk, id, type: "chunk" }));
5457
+ ws.send(JSON.stringify({ data: encodeWire(chunk), id, type: "chunk" }));
3521
5458
  }
3522
5459
  if (!controller.signal.aborted) {
3523
5460
  ws.send(JSON.stringify({ id, type: "complete" }));
3524
5461
  }
3525
5462
  } catch (error) {
3526
- const { code } = error;
3527
- const message = error instanceof Error ? error.message : String(error);
5463
+ const { body, redacted } = toErrorBody(error, { fallbackCode: "INTERNAL_SERVER_ERROR", redactedMessage: "internal error" });
5464
+ if (redacted) {
5465
+ console.error("[@lunora/do] unhandled stream error:", error);
5466
+ }
3528
5467
  ws.send(
3529
5468
  JSON.stringify({
3530
- error: { code: typeof code === "string" ? code : "INTERNAL_SERVER_ERROR", message },
5469
+ error: { code: body.code, message: body.message },
3531
5470
  id,
3532
5471
  type: "error"
3533
5472
  })
@@ -3558,11 +5497,53 @@ class ShardDO {
3558
5497
  if (!changed || changed.size === 0) {
3559
5498
  return;
3560
5499
  }
5500
+ if (this.pendingRefreshTables) {
5501
+ for (const table of changed) {
5502
+ this.pendingRefreshTables.add(table);
5503
+ }
5504
+ } else {
5505
+ this.pendingRefreshTables = changed;
5506
+ }
5507
+ if (this.refreshInFlight) {
5508
+ return;
5509
+ }
3561
5510
  if (typeof this.state.waitUntil === "function") {
3562
- this.state.waitUntil(this.refreshSubscriptions(changed));
5511
+ this.state.waitUntil(this.drainSubscriptionRefreshes());
5512
+ return;
5513
+ }
5514
+ await this.drainSubscriptionRefreshes();
5515
+ }
5516
+ /**
5517
+ * Drain {@link ShardDO.pendingRefreshTables} one coalesced batch at a time
5518
+ * until it is empty, then release the {@link ShardDO.refreshInFlight} gate.
5519
+ * Tables merged by a `flushChangedTables` that lands mid-pass are picked up
5520
+ * by the next loop iteration, so every committed write is observed by a
5521
+ * refresh that runs after it — bursts simply share a pass. The post-write
5522
+ * high-watermark and live-socket set are re-read inside each
5523
+ * `refreshSubscriptions` / `pokeShapeSubscribers` call, so a later batch
5524
+ * always reflects the latest committed state.
5525
+ */
5526
+ async drainSubscriptionRefreshes() {
5527
+ if (this.refreshInFlight) {
3563
5528
  return;
3564
5529
  }
3565
- await this.refreshSubscriptions(changed);
5530
+ this.refreshInFlight = true;
5531
+ try {
5532
+ let batch = this.pendingRefreshTables;
5533
+ while (batch && batch.size > 0) {
5534
+ this.pendingRefreshTables = void 0;
5535
+ const frameCursor = this.currentCdcCursor();
5536
+ const frameEpoch = this.currentCdcEpoch();
5537
+ await Promise.all([
5538
+ this.refreshSubscriptions(batch),
5539
+ this.pokeShapeSubscribers(batch, frameCursor, frameEpoch),
5540
+ this.relay?.onFlush(batch, frameCursor ?? 0)
5541
+ ]);
5542
+ batch = this.pendingRefreshTables;
5543
+ }
5544
+ } finally {
5545
+ this.refreshInFlight = false;
5546
+ }
3566
5547
  }
3567
5548
  /**
3568
5549
  * For every live subscription whose query reads one of `changed`, re-run
@@ -3623,6 +5604,7 @@ class ShardDO {
3623
5604
  const sockets = [...this.state.getWebSockets()];
3624
5605
  const frameCursor = this.currentCdcCursor();
3625
5606
  const frameEpoch = this.currentCdcEpoch();
5607
+ const reactiveRunCache = /* @__PURE__ */ new Map();
3626
5608
  const refreshOne = async (ws) => {
3627
5609
  if (this.isSocketExpired(ws)) {
3628
5610
  this.dropExpiredSocket(ws);
@@ -3640,14 +5622,12 @@ class ShardDO {
3640
5622
  continue;
3641
5623
  }
3642
5624
  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 })
5625
+ const outcome = await this.resolveReactiveOutcomeDeduped(
5626
+ functionPath,
5627
+ query.args ?? {},
5628
+ isAdmin,
5629
+ { identity: attachment.identity, userId: attachment.userId },
5630
+ reactiveRunCache
3651
5631
  );
3652
5632
  if (!outcome) {
3653
5633
  continue;
@@ -3659,18 +5639,7 @@ class ShardDO {
3659
5639
  }
3660
5640
  }
3661
5641
  };
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()));
5642
+ await runSocketPool(sockets, refreshOne);
3674
5643
  }
3675
5644
  /**
3676
5645
  * Seed a freshly-registered subscription with its first value. Runs the
@@ -3690,7 +5659,10 @@ class ShardDO {
3690
5659
  async seedSubscription(ws, subId, query, functionPath, isAdmin) {
3691
5660
  const seedArgs = query.args ?? {};
3692
5661
  const attachment = this.readAttachment(ws);
3693
- const outcome = isAdmin ? this.executeAdminSubscription(functionPath, seedArgs) : await this.executeSubscription(functionPath, seedArgs, { identity: attachment.identity, userId: attachment.userId });
5662
+ const outcome = await this.resolveReactiveOutcome(functionPath, seedArgs, isAdmin, {
5663
+ identity: attachment.identity,
5664
+ userId: attachment.userId
5665
+ });
3694
5666
  if (!outcome) {
3695
5667
  return;
3696
5668
  }
@@ -3707,6 +5679,559 @@ class ShardDO {
3707
5679
  }
3708
5680
  this.pushSubscriptionData(ws, subId, outcome, resume?.cursor ?? this.currentCdcCursor(), epoch);
3709
5681
  }
5682
+ /**
5683
+ * Drive the full `shape_subscribe` flow as one failure-aware unit: persist the
5684
+ * attachment, seed the shape, and ack ONLY once both succeed. A persist
5685
+ * rejection (`too_many`/`serialize_failed`) or a seed that can't resolve the
5686
+ * shape (unknown / RLS-denied / cross-shard-invalid) rolls the attachment back
5687
+ * and sends an `error` frame instead of acking — so a client is never left
5688
+ * acked but subscribed to a shape that will never deliver. Never throws (a
5689
+ * thrown `webSocketMessage` is fatal to the hibernating socket).
5690
+ */
5691
+ async handleShapeSubscribe(ws, subId, shape) {
5692
+ const status = this.shapeSubscribe(ws, subId, shape);
5693
+ if (status !== "ok") {
5694
+ const code = status === "too_many" ? "TOO_MANY_SUBSCRIPTIONS" : "SUBSCRIPTION_PERSIST_FAILED";
5695
+ const message = status === "too_many" ? `subscription cap of ${String(ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET)} reached on this socket` : "failed to persist shape subscription attachment";
5696
+ this.sendShapeSubscribeError(ws, subId, code, message);
5697
+ return;
5698
+ }
5699
+ const seed = await this.seedShapeSubscription(ws, subId, shape);
5700
+ if (seed !== "ok") {
5701
+ this.shapeUnsubscribe(ws, subId);
5702
+ this.sendShapeSubscribeError(ws, subId, seed.code, seed.message);
5703
+ return;
5704
+ }
5705
+ try {
5706
+ ws.send(JSON.stringify({ id: subId, type: "ack" }));
5707
+ } catch {
5708
+ }
5709
+ }
5710
+ /** Send a structured `error` frame for a failed `shape_subscribe`, swallowing a send on an already-closed socket. */
5711
+ // eslint-disable-next-line class-methods-use-this -- groups with the shape-subscribe flow; uses only its args + the socket
5712
+ sendShapeSubscribeError(ws, subId, code, message) {
5713
+ try {
5714
+ ws.send(JSON.stringify({ code, error: { code, message }, id: subId, type: "error" }));
5715
+ } catch {
5716
+ }
5717
+ }
5718
+ /**
5719
+ * Seed a freshly-registered shape subscription. Resolves the shape under the
5720
+ * socket's verified identity, then ships either:
5721
+ *
5722
+ * - a **catch-up** poke (the membership diff in `(sinceCheckpoint, cursor]`)
5723
+ * when the client supplied a still-current checkpoint within the CDC retention
5724
+ * window and on this epoch — the cheap reconnect path; or
5725
+ * - a **full** insert-poke of the shape's entire current membership — a
5726
+ * first-time subscribe, or a reconnect that fell outside retention / forked
5727
+ * epoch.
5728
+ *
5729
+ * Either way the per-socket shape memo advances to the flush watermark so
5730
+ * later `pokeShapeSubscribers` passes diff from the right point.
5731
+ *
5732
+ * Returns `"ok"` once the shape resolved and its seed poke was attempted, or a
5733
+ * `{ code, message }` failure when the shape can't be resolved — an unknown /
5734
+ * RLS-denied shape (a base class with no registry resolves nothing), or a
5735
+ * `resolveShape` that threw (e.g. a cross-shard-join guard). The caller rolls
5736
+ * back the persisted attachment and errors instead of acking, so a client is
5737
+ * never left subscribed to a shape that will never deliver.
5738
+ */
5739
+ async seedShapeSubscription(ws, subId, shape) {
5740
+ const attachment = this.readAttachment(ws);
5741
+ const identity = { identity: attachment.identity, userId: attachment.userId };
5742
+ const relayed = await this.relay?.seedRelayShape(ws, subId, shape, identity);
5743
+ if (relayed !== void 0) {
5744
+ return relayed;
5745
+ }
5746
+ let resolved;
5747
+ try {
5748
+ resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
5749
+ } catch (error) {
5750
+ this.recordShapeError(`shape:seed:${subId}`, error);
5751
+ const { body } = toErrorBody(error, { fallbackCode: "SHAPE_RESOLVE_FAILED", redactedMessage: "shape resolution failed" });
5752
+ return { code: body.code, message: body.message };
5753
+ }
5754
+ if (!resolved) {
5755
+ return { code: "SHAPE_NOT_FOUND", message: `shape "${shape.name}" not found or not permitted` };
5756
+ }
5757
+ try {
5758
+ if (resolved.global) {
5759
+ return await this.seedGlobalShape(ws, subId, resolved, identity, attachment.connectionId ?? "");
5760
+ }
5761
+ return await this.seedOpLogShape(ws, subId, shape, resolved);
5762
+ } catch (error) {
5763
+ this.recordShapeError(`shape:seed:${subId}`, error);
5764
+ const { body } = toErrorBody(error, { fallbackCode: "SHAPE_SEED_FAILED", redactedMessage: "shape seed failed" });
5765
+ return { code: body.code, message: body.message };
5766
+ }
5767
+ }
5768
+ /**
5769
+ * Seed a non-`.global()` (op-log-backed) shape: either a catch-up diff over
5770
+ * `(sinceSeq, cursor]` when the client supplied a still-current checkpoint on
5771
+ * this epoch within the CDC retention window, or a full membership insert-poke
5772
+ * otherwise. The memo advances to `cursor` only once the poke is delivered, so
5773
+ * a failed send re-diffs from the prior point rather than skipping rows. May
5774
+ * throw (a stub `sql` handle, a membership probe failure); the caller converts
5775
+ * it to a structured `shape_subscribe` error.
5776
+ */
5777
+ async seedOpLogShape(ws, subId, shape, resolved) {
5778
+ const { baseCheckpoint, cursor, epoch, rowsPatch } = this.computeOpLogShapeSeed(shape, resolved);
5779
+ await awaitWsDrain(ws);
5780
+ if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], cursor, epoch, baseCheckpoint)) {
5781
+ this.recordShapeMemo(ws, subId, cursor);
5782
+ }
5783
+ return "ok";
5784
+ }
5785
+ /**
5786
+ * Compute an op-log shape seed (cursor, epoch, the resume base, and the
5787
+ * membership `rowsPatch`) WITHOUT sending — the shared core of
5788
+ * {@link ShardDO.seedOpLogShape} (sends to a local socket) and the owner relay's
5789
+ * `buildShapeSeedFrames` (serializes the frames for a relay to deliver, plan 075
5790
+ * Phase 3, via the {@link RelayHost} seam). Resume only when CDC is on, the client is on this
5791
+ * epoch, its checkpoint doesn't run ahead of ours, and the log still covers it;
5792
+ * else a full re-seed. A fully-compacted log only proves "nothing missed" when
5793
+ * the client is already at `cursor`.
5794
+ * @returns the cursor/epoch, the resume base (`baseCheckpoint`), and the membership patch
5795
+ */
5796
+ computeOpLogShapeSeed(shape, resolved) {
5797
+ const sql = this.sql;
5798
+ const cursor = this.currentCdcCursor() ?? 0;
5799
+ const epoch = this.currentCdcEpoch();
5800
+ const floor = this.cdcEnabled() ? minCdcSeq(sql) : void 0;
5801
+ const canResume = this.cdcEnabled() && shape.sinceSeq !== void 0 && shape.sinceEpoch === epoch && shape.sinceSeq <= cursor && (shape.sinceSeq === cursor || floor !== void 0 && floor <= shape.sinceSeq + 1);
5802
+ const rowsPatch = canResume && shape.sinceSeq !== void 0 ? this.buildShapeDiff(sql, resolved, shape.sinceSeq, cursor) : this.buildShapeSeed(sql, resolved);
5803
+ return { baseCheckpoint: canResume ? shape.sinceSeq : void 0, cursor, epoch, rowsPatch };
5804
+ }
5805
+ /**
5806
+ * Fan the membership diff of every shape affected by this flush to its
5807
+ * subscribers — the partial-replication parallel to
5808
+ * {@link ShardDO.refreshSubscriptions}, called alongside it from
5809
+ * {@link ShardDO.flushChangedTables}. For each socket (bounded fan-out, same
5810
+ * concurrency + `awaitWsDrain` backpressure as the subscription path) it
5811
+ * resolves each shape under the socket's identity, diffs only the shapes
5812
+ * whose table changed in `(memoCursor, frameCursor]`, and emits one poke
5813
+ * carrying a part per changed shape. No-op when no socket holds a shape.
5814
+ */
5815
+ async pokeShapeSubscribers(changed, frameCursor, frameEpoch) {
5816
+ const sockets = [...this.state.getWebSockets()];
5817
+ const checkpoint = frameCursor ?? this.currentCdcCursor() ?? 0;
5818
+ const sql = this.sql;
5819
+ const opRangeCache = /* @__PURE__ */ new Map();
5820
+ let delivered = 0;
5821
+ const pokeOne = async (ws) => {
5822
+ if (this.isSocketExpired(ws)) {
5823
+ this.dropExpiredSocket(ws);
5824
+ return;
5825
+ }
5826
+ const attachment = this.readAttachment(ws);
5827
+ const { shapes } = attachment;
5828
+ if (!shapes) {
5829
+ return;
5830
+ }
5831
+ try {
5832
+ const identity = { identity: attachment.identity, userId: attachment.userId };
5833
+ const { emptyAdvanced, partAdvanced, parts } = this.collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql, opRangeCache);
5834
+ for (const subId of emptyAdvanced) {
5835
+ this.recordShapeMemo(ws, subId, checkpoint);
5836
+ }
5837
+ if (parts.length > 0) {
5838
+ await awaitWsDrain(ws);
5839
+ if (this.sendPoke(ws, parts, checkpoint, frameEpoch, void 0)) {
5840
+ delivered += 1;
5841
+ for (const subId of partAdvanced) {
5842
+ this.recordShapeMemo(ws, subId, checkpoint);
5843
+ }
5844
+ }
5845
+ }
5846
+ } catch {
5847
+ }
5848
+ };
5849
+ const startMs = Date.now();
5850
+ await runSocketPool(sockets, pokeOne);
5851
+ this.fanout.shapePoke = recordFanoutPass(this.fanout.shapePoke, sockets.length, delivered, Date.now() - startMs);
5852
+ }
5853
+ /**
5854
+ * Diff every op-log-backed shape a socket holds against this flush, splitting
5855
+ * the results into the poke parts to send and the per-shape memo advances. A
5856
+ * `.global()` shape (driven by the alarm poll loop, not this flush) and a shape
5857
+ * whose table didn't change are skipped; a shape whose resolve/diff throws is
5858
+ * logged and skipped with its memo unadvanced so a later flush retries. Empty
5859
+ * diffs advance unconditionally; part-bearing shapes advance only once the
5860
+ * caller confirms the poke was delivered.
5861
+ */
5862
+ collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql, opRangeCache) {
5863
+ const parts = [];
5864
+ const emptyAdvanced = [];
5865
+ const partAdvanced = [];
5866
+ for (const [subId, shape] of Object.entries(shapes)) {
5867
+ try {
5868
+ const resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
5869
+ if (!resolved || resolved.global || !changed.has(resolved.table)) {
5870
+ continue;
5871
+ }
5872
+ const memoCursor = this.shapeMemos.get(ws)?.get(subId)?.cursor ?? 0;
5873
+ const rowsPatch = this.buildShapeDiff(sql, resolved, memoCursor, checkpoint, opRangeCache);
5874
+ if (rowsPatch.length > 0) {
5875
+ parts.push({ rowsPatch, shapeId: subId });
5876
+ partAdvanced.push(subId);
5877
+ } else {
5878
+ emptyAdvanced.push(subId);
5879
+ }
5880
+ } catch (error) {
5881
+ this.recordShapeError(`shape:poke:${subId}`, error);
5882
+ }
5883
+ }
5884
+ return { emptyAdvanced, partAdvanced, parts };
5885
+ }
5886
+ /**
5887
+ * Drain the op-log range `(sinceSeq, upTo]` for `table` into the latest op per
5888
+ * row id (collapsing multiple ops on the same row to the newest). Within one
5889
+ * flush, every shape over the SAME `(table, sinceSeq, upTo)` reads the
5890
+ * identical changelog slice, so the drained map is memoized in the
5891
+ * caller-supplied `cache` (created fresh per flush) — N shapes on a table
5892
+ * share ONE changelog drain instead of re-scanning it per shape. The
5893
+ * per-shape membership probe still runs per shape (its predicate is
5894
+ * identity/args-specific), so only the shared op read is collapsed.
5895
+ */
5896
+ readShapeOpRange(sql, table, sinceSeq, upTo, cache) {
5897
+ const key = `${table}\0${String(sinceSeq)}\0${String(upTo)}`;
5898
+ const cached = cache?.get(key);
5899
+ if (cached !== void 0) {
5900
+ return cached;
5901
+ }
5902
+ const latest = /* @__PURE__ */ new Map();
5903
+ const tables = /* @__PURE__ */ new Set([table]);
5904
+ let from = sinceSeq;
5905
+ for (; ; ) {
5906
+ const { changes, cursor } = this.readShapeCdcPage(sql, from, tables);
5907
+ for (const change of changes) {
5908
+ latest.set(change.id, change);
5909
+ }
5910
+ if (changes.length === 0 || cursor === from || cursor >= upTo) {
5911
+ break;
5912
+ }
5913
+ from = cursor;
5914
+ }
5915
+ cache?.set(key, latest);
5916
+ return latest;
5917
+ }
5918
+ /**
5919
+ * Read one page of the `__cdc_log` for a shape diff (table-scoped). A thin
5920
+ * protected seam over {@link readCdcChanges}: it isolates the single
5921
+ * changelog read that {@link readShapeOpRange} memoizes per flush, and gives
5922
+ * tests a point to count the reads the op-range cache collapses.
5923
+ */
5924
+ // 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
5925
+ readShapeCdcPage(sql, sinceSeq, tables) {
5926
+ return readCdcChanges(sql, { sinceSeq, tables });
5927
+ }
5928
+ /**
5929
+ * Build the row-ops for a shape over the op range `(sinceSeq, upTo]`. Reads
5930
+ * the changelog (drained across pages via {@link readShapeOpRange}, shared
5931
+ * across same-range shapes in a flush), collapses to the latest op per row,
5932
+ * then runs ONE membership probe ({@link selectShapeMemberIds}) over the
5933
+ * changed ids: a row still in the set → upsert with its post-image doc
5934
+ * (projected to the shape's columns); a row that left the set, or any delete,
5935
+ * → `delete(key)` (a delete carries no post-image, so membership is
5936
+ * unknowable from the op alone — the client no-ops an unknown key).
5937
+ */
5938
+ buildShapeDiff(sql, resolved, sinceSeq, upTo, opRangeCache) {
5939
+ const latest = this.readShapeOpRange(sql, resolved.table, sinceSeq, upTo, opRangeCache);
5940
+ if (latest.size === 0) {
5941
+ return [];
5942
+ }
5943
+ const ids = [...latest.keys()];
5944
+ const members = selectShapeMemberIds(sql, resolved.table, resolved.effectiveWhere, ids);
5945
+ const ops = [];
5946
+ for (const [id, change] of latest) {
5947
+ if (members.has(id)) {
5948
+ if (change.doc !== void 0) {
5949
+ ops.push({ key: id, op: change.op, table: resolved.table, value: projectColumns(change.doc, resolved.columns) });
5950
+ }
5951
+ continue;
5952
+ }
5953
+ if (change.op !== "insert") {
5954
+ ops.push({ key: id, op: "delete", table: resolved.table });
5955
+ }
5956
+ }
5957
+ return ops;
5958
+ }
5959
+ /** Build the full insert-poke of a shape's current membership — the first-seed/full-reseed rowset. */
5960
+ // eslint-disable-next-line class-methods-use-this -- instance method for symmetry with `buildShapeDiff`; reads via the passed `sql` handle
5961
+ buildShapeSeed(sql, resolved) {
5962
+ return selectShapeRows(sql, resolved.table, resolved.effectiveWhere).map((row) => {
5963
+ return {
5964
+ key: row.id,
5965
+ op: "insert",
5966
+ table: resolved.table,
5967
+ value: projectColumns(row.doc, resolved.columns)
5968
+ };
5969
+ });
5970
+ }
5971
+ /**
5972
+ * Seed a `.global()`-table shape: read its full membership from D1, ship it
5973
+ * as one insert-poke, record the membership snapshot the alarm poll loop will
5974
+ * diff against, and arm the poll alarm. A global shape has no op-log cursor,
5975
+ * so the poke is stamped at this DO's current cursor (informational only) and
5976
+ * carries no resume base — a reconnect always re-seeds full.
5977
+ */
5978
+ async seedGlobalShape(ws, subId, resolved, identity, connectionId) {
5979
+ const rows = await this.readGlobalShapeRows(resolved, identity);
5980
+ if (!this.withinGlobalShapeBound(rows.length, `shape:seed:${subId}`, resolved.table)) {
5981
+ return {
5982
+ code: "SHAPE_GLOBAL_TOO_LARGE",
5983
+ 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`
5984
+ };
5985
+ }
5986
+ const { next: snapshot, rowsPatch } = diffGlobalMembership(rows, /* @__PURE__ */ new Map(), { columns: resolved.columns, table: resolved.table });
5987
+ await awaitWsDrain(ws);
5988
+ if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], this.currentCdcCursor() ?? 0, this.currentCdcEpoch(), void 0)) {
5989
+ this.recordGlobalSnapshot(ws, subId, snapshot);
5990
+ this.saveGlobalSnapshot(connectionId, subId, snapshot);
5991
+ }
5992
+ await this.scheduleGlobalPoll();
5993
+ return "ok";
5994
+ }
5995
+ /**
5996
+ * Re-read a global shape's membership from D1 and poke only the diff against
5997
+ * the socket's last snapshot: a new key → `insert`, a changed projected value
5998
+ * → `update`, a vanished key → `delete`. The snapshot advances to the fresh
5999
+ * membership even when the diff is empty, so the next tick compares from here.
6000
+ * No frame is sent when nothing changed (the common steady-state tick).
6001
+ */
6002
+ async refreshGlobalShape(ws, subId, resolved, identity, connectionId) {
6003
+ const rows = await this.readGlobalShapeRows(resolved, identity);
6004
+ if (!this.withinGlobalShapeBound(rows.length, `shape:poll:${subId}`, resolved.table)) {
6005
+ return;
6006
+ }
6007
+ const previous = this.readGlobalSnapshot(ws, subId, connectionId);
6008
+ const { next, rowsPatch } = diffGlobalMembership(rows, previous, { columns: resolved.columns, table: resolved.table });
6009
+ if (rowsPatch.length === 0) {
6010
+ this.recordGlobalSnapshot(ws, subId, next);
6011
+ return;
6012
+ }
6013
+ await awaitWsDrain(ws);
6014
+ if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], this.currentCdcCursor() ?? 0, this.currentCdcEpoch(), void 0)) {
6015
+ this.recordGlobalSnapshot(ws, subId, next);
6016
+ this.saveGlobalSnapshot(connectionId, subId, next);
6017
+ }
6018
+ }
6019
+ /**
6020
+ * Read a socket's global-shape baseline, preferring the hot in-memory cache
6021
+ * and falling back to the durable `__global_shape_snapshot` table on a miss (a
6022
+ * cold socket after a hibernation eviction). The loaded baseline repopulates
6023
+ * the cache so subsequent ticks in this wake hit memory. An empty
6024
+ * `connectionId` (a socket that never went through the lifecycle-aware upgrade,
6025
+ * e.g. a unit harness) skips the durable read and behaves as in-memory-only.
6026
+ */
6027
+ readGlobalSnapshot(ws, subId, connectionId) {
6028
+ const cached = this.globalShapeSnapshots.get(ws)?.get(subId);
6029
+ if (cached) {
6030
+ return cached;
6031
+ }
6032
+ const stored = this.loadGlobalSnapshot(connectionId, subId);
6033
+ this.recordGlobalSnapshot(ws, subId, stored);
6034
+ return stored;
6035
+ }
6036
+ /** Record a socket's latest global-shape membership snapshot in the in-memory cache (creating the per-socket map lazily). */
6037
+ recordGlobalSnapshot(ws, subId, snapshot) {
6038
+ let snapshots = this.globalShapeSnapshots.get(ws);
6039
+ if (!snapshots) {
6040
+ snapshots = /* @__PURE__ */ new Map();
6041
+ this.globalShapeSnapshots.set(ws, snapshots);
6042
+ }
6043
+ snapshots.set(subId, snapshot);
6044
+ }
6045
+ /**
6046
+ * Load a durable global-shape baseline from SQLite, or an empty map when none
6047
+ * is stored / the durable path is unavailable. A stub `sql` handle (unit
6048
+ * harness) or a missing table degrades to in-memory-only behavior rather than
6049
+ * failing the poll tick.
6050
+ */
6051
+ loadGlobalSnapshot(connectionId, subId) {
6052
+ if (connectionId === "") {
6053
+ return /* @__PURE__ */ new Map();
6054
+ }
6055
+ try {
6056
+ return readGlobalShapeSnapshot(this.sql, connectionId, subId);
6057
+ } catch {
6058
+ return /* @__PURE__ */ new Map();
6059
+ }
6060
+ }
6061
+ /**
6062
+ * Persist a socket's global-shape baseline to SQLite so the poll-loop diff
6063
+ * survives hibernation. A no-op for a connection-id-less socket or a stub
6064
+ * `sql` handle (the in-memory cache then carries the baseline for the DO's
6065
+ * lifetime, matching the pre-durable behavior).
6066
+ */
6067
+ saveGlobalSnapshot(connectionId, subId, snapshot) {
6068
+ if (connectionId === "") {
6069
+ return;
6070
+ }
6071
+ try {
6072
+ writeGlobalShapeSnapshot(this.sql, connectionId, subId, snapshot);
6073
+ } catch {
6074
+ }
6075
+ }
6076
+ /**
6077
+ * Arm the poll alarm for `.global()` shapes if one isn't already pending.
6078
+ * Idempotent — every global-shape seed calls it, but only the first arms the
6079
+ * alarm. Degrades to a no-op when the runtime exposes no `setAlarm` (the unit
6080
+ * harness): a global shape is then seed-only, which the poll-loop tests assert
6081
+ * by driving {@link ShardDO.alarm} directly.
6082
+ */
6083
+ async scheduleGlobalPoll() {
6084
+ if (this.globalPollScheduled) {
6085
+ return;
6086
+ }
6087
+ const { setAlarm } = this.state.storage;
6088
+ if (!setAlarm) {
6089
+ return;
6090
+ }
6091
+ this.globalPollScheduled = true;
6092
+ try {
6093
+ await setAlarm.call(this.state.storage, Date.now() + ShardDO.GLOBAL_SHAPE_POLL_INTERVAL_MS);
6094
+ } catch {
6095
+ this.globalPollScheduled = false;
6096
+ }
6097
+ }
6098
+ /**
6099
+ * Record a contained shape-tier error (poll / poke / seed) into the DO's log
6100
+ * ring without aborting the rest of the pass. The shape pipeline is a
6101
+ * best-effort fan-out: one socket's read or one shape's resolve failing must
6102
+ * never take down the others — so callers swallow the throw and surface it
6103
+ * here for diagnosis. `context` is a synthetic `shape:phase:subId` path.
6104
+ */
6105
+ recordShapeError(context, error) {
6106
+ this.logs.push({
6107
+ functionPath: context,
6108
+ level: "error",
6109
+ message: error instanceof Error ? error.message : String(error),
6110
+ timestamp: Date.now()
6111
+ });
6112
+ }
6113
+ /**
6114
+ * Guard a global shape's materialized membership against {@link
6115
+ * ShardDO.GLOBAL_SHAPE_MAX_ROWS}. Returns `true` when the row count is within
6116
+ * the cap; otherwise records a diagnosable error and returns `false` so the
6117
+ * caller fails the shape closed (no snapshot retained, no poke sent) rather
6118
+ * than risking a DO eviction on an unbounded global table. The transient read
6119
+ * buffer is bounded by the same gate — an over-cap membership is dropped, not
6120
+ * snapshotted per socket.
6121
+ */
6122
+ withinGlobalShapeBound(rowCount, context, table) {
6123
+ if (rowCount <= ShardDO.GLOBAL_SHAPE_MAX_ROWS) {
6124
+ return true;
6125
+ }
6126
+ this.recordShapeError(
6127
+ context,
6128
+ new Error(
6129
+ `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`
6130
+ )
6131
+ );
6132
+ return false;
6133
+ }
6134
+ /**
6135
+ * Refresh every `.global()`-table shape held across all live sockets, one
6136
+ * diff-poke per (socket, shape). Returns the number of global shapes still
6137
+ * subscribed so {@link ShardDO.alarm} knows whether to re-arm. Expired sockets
6138
+ * are dropped in passing (mirrors {@link ShardDO.pokeShapeSubscribers}).
6139
+ */
6140
+ async pollGlobalShapes() {
6141
+ const sockets = [...this.state.getWebSockets()];
6142
+ let remaining = 0;
6143
+ for (const ws of sockets) {
6144
+ if (this.isSocketExpired(ws)) {
6145
+ this.dropExpiredSocket(ws);
6146
+ continue;
6147
+ }
6148
+ const attachment = this.readAttachment(ws);
6149
+ const { shapes } = attachment;
6150
+ if (!shapes) {
6151
+ continue;
6152
+ }
6153
+ const identity = { identity: attachment.identity, userId: attachment.userId };
6154
+ remaining += await this.pollSocketGlobalShapes(ws, shapes, identity, attachment.connectionId ?? "");
6155
+ }
6156
+ return remaining;
6157
+ }
6158
+ /**
6159
+ * Refresh one socket's `.global()`-table shapes, containing per-shape
6160
+ * failures so a single throw never aborts the poll tick (and with it the
6161
+ * re-arm). Returns the count of global shapes still subscribed on this socket
6162
+ * — a failed `resolveShape`/read keeps its shape counted so the alarm keeps
6163
+ * polling and retries next tick.
6164
+ */
6165
+ async pollSocketGlobalShapes(ws, shapes, identity, connectionId) {
6166
+ let count = 0;
6167
+ for (const [subId, shape] of Object.entries(shapes)) {
6168
+ let resolved;
6169
+ try {
6170
+ resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
6171
+ } catch (error) {
6172
+ count += 1;
6173
+ this.recordShapeError(`shape:poll:${subId}`, error);
6174
+ continue;
6175
+ }
6176
+ if (!resolved?.global) {
6177
+ continue;
6178
+ }
6179
+ count += 1;
6180
+ try {
6181
+ await this.refreshGlobalShape(ws, subId, resolved, identity, connectionId);
6182
+ } catch (error) {
6183
+ this.recordShapeError(`shape:poll:${subId}`, error);
6184
+ }
6185
+ }
6186
+ return count;
6187
+ }
6188
+ /**
6189
+ * Send one poke (`pokeStart` → `pokePart` per shape → `pokeEnd`) to a socket.
6190
+ * All parts apply atomically at `pokeEnd`. Returns `true` when every frame was
6191
+ * handed to the socket, `false` when a send threw mid-poke (the socket closed)
6192
+ * — callers must NOT advance their shape baselines on a `false` so the client
6193
+ * re-receives the rows on its next flush/reconnect instead of losing them.
6194
+ */
6195
+ sendPoke(ws, parts, checkpoint, epoch, baseCheckpoint) {
6196
+ this.pokeSequence += 1;
6197
+ const pokeId = `poke-${String(this.pokeSequence)}`;
6198
+ const frames = buildPokeFrames(parts, { baseCheckpoint, checkpoint, epoch, lastMutationId: this.socketClientWatermark(ws), pokeId });
6199
+ try {
6200
+ for (const frame of frames) {
6201
+ ws.send(frame);
6202
+ }
6203
+ return true;
6204
+ } catch {
6205
+ return false;
6206
+ }
6207
+ }
6208
+ /**
6209
+ * The recipient client's `__client_watermark` for stamping a poke's
6210
+ * `lastMutationId`, or `undefined` when the socket announced no `clientId`
6211
+ * (a client that doesn't use custom mutators — nothing to drop an overlay
6212
+ * for). Read off the attachment so it survives hibernation.
6213
+ */
6214
+ socketClientWatermark(ws) {
6215
+ const attachment = this.readAttachment(ws);
6216
+ const { clientId } = attachment;
6217
+ if (clientId === void 0) {
6218
+ return void 0;
6219
+ }
6220
+ try {
6221
+ return readClientWatermark(this.sql, attachment.userId ?? "", clientId);
6222
+ } catch {
6223
+ return void 0;
6224
+ }
6225
+ }
6226
+ /** Record a shape's poke baseline cursor on a socket (creating the per-socket map lazily). */
6227
+ recordShapeMemo(ws, subId, cursor) {
6228
+ let memos = this.shapeMemos.get(ws);
6229
+ if (!memos) {
6230
+ memos = /* @__PURE__ */ new Map();
6231
+ this.shapeMemos.set(ws, memos);
6232
+ }
6233
+ memos.set(subId, { cursor });
6234
+ }
3710
6235
  /**
3711
6236
  * Record `outcome` as this socket's diff baseline for `subId` without
3712
6237
  * sending a frame. Used by the resume fast-path, where the client keeps its
@@ -3719,7 +6244,7 @@ class ShardDO {
3719
6244
  memos = /* @__PURE__ */ new Map();
3720
6245
  this.subMemos.set(ws, memos);
3721
6246
  }
3722
- memos.set(subId, { lastJson: JSON.stringify(outcome.result ?? null), tables: outcome.tables });
6247
+ memos.set(subId, { lastJson: JSON.stringify(encodeWire(outcome.result ?? null)), tables: outcome.tables });
3723
6248
  }
3724
6249
  /**
3725
6250
  * Memoise `outcome` for `(ws, subId)` and push it to the socket, unless an
@@ -3746,29 +6271,19 @@ class ShardDO {
3746
6271
  this.subMemos.set(ws, memos);
3747
6272
  }
3748
6273
  const cursorSuffix = cdcSuffix(cursor, epoch);
3749
- const json = JSON.stringify(outcome.result ?? null);
6274
+ const json = JSON.stringify(encodeWire(outcome.result ?? null));
3750
6275
  const existing = memos.get(subId);
3751
6276
  if (existing?.lastJson === json) {
3752
6277
  existing.tables = outcome.tables;
6278
+ const settledWatermark = this.socketClientWatermark(ws);
6279
+ const watermarkField = settledWatermark === void 0 ? "" : `,"lastMutationId":${String(settledWatermark)}`;
6280
+ trySendFrame(ws, `{"type":"settled","id":${JSON.stringify(subId)}${watermarkField}${cursorSuffix}}`);
3753
6281
  return;
3754
6282
  }
3755
6283
  const deltaFrames = [];
3756
6284
  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
- }
6285
+ const delivered = deltas === void 0 ? trySendFrame(ws, `{"type":"data","id":${JSON.stringify(subId)},"data":${json}${cursorSuffix}}`) : sendDeltaFrames(ws, subId, deltaFrames, cursorSuffix);
6286
+ memos.set(subId, { lastJson: delivered ? json : existing?.lastJson ?? UNDELIVERED_BASELINE, tables: outcome.tables });
3772
6287
  }
3773
6288
  /**
3774
6289
  * Gate the upgrade request against two complementary controls:
@@ -3858,6 +6373,29 @@ class ShardDO {
3858
6373
  }
3859
6374
  setter.call(this.state, new WebSocketRequestResponsePair(WS_KEEPALIVE_PING, WS_KEEPALIVE_PONG));
3860
6375
  }
6376
+ /**
6377
+ * Route the non-RPC requests `fetch` handles before the shard-local RPC
6378
+ * endpoint: a WebSocket upgrade, and the internal `/_lunora/relay` owner↔relay
6379
+ * control channel (never reachable by a client — the runtime forwards only
6380
+ * worker-internal traffic there). Returns `undefined` for an RPC request, which
6381
+ * `fetch` then dispatches.
6382
+ * @returns the routed response, or `undefined` when this is an RPC request
6383
+ */
6384
+ async routeNonRpc(url, request) {
6385
+ if (url.pathname === "/_lunora/relay" && request.method === "POST") {
6386
+ return this.relay ? await this.relay.handleControl(request) : new Response("relay tier inactive", { status: 404 });
6387
+ }
6388
+ if (url.pathname === "/_lunora/route" && request.method === "GET") {
6389
+ return jsonResponse({ relayCount: this.relay?.relayCount() ?? 0 });
6390
+ }
6391
+ if (url.pathname === "/rpc-batch" && request.method === "POST") {
6392
+ return this.handleBatchRpc(request);
6393
+ }
6394
+ if (request.headers.get("Upgrade") === "websocket") {
6395
+ return this.handleWebSocketUpgrade(request);
6396
+ }
6397
+ return void 0;
6398
+ }
3861
6399
  handleWebSocketUpgrade(request) {
3862
6400
  if (!this.isUpgradeAllowed(request)) {
3863
6401
  return new Response("Forbidden", { status: 403 });
@@ -3975,7 +6513,7 @@ class ShardDO {
3975
6513
  * topic name. That matches the AnyCable model (and `from` is unforgeable),
3976
6514
  * but per-topic auth does not exist here; see `whisperSubscribe` on the client.
3977
6515
  */
3978
- broadcastWhisper(sender, topic, data) {
6516
+ async broadcastWhisper(sender, topic, data) {
3979
6517
  if (!this.allowWhisper(sender)) {
3980
6518
  return;
3981
6519
  }
@@ -3986,15 +6524,29 @@ class ShardDO {
3986
6524
  const from = this.readAttachment(sender).userId;
3987
6525
  const fromSuffix = from === void 0 ? "" : `,"from":${JSON.stringify(from)}`;
3988
6526
  const frame = `{"type":"whisper","topic":${JSON.stringify(topic)},"data":${dataJson}${fromSuffix}}`;
6527
+ this.deliverWhisperLocal(topic, frame, sender);
6528
+ await this.relay?.forwardWhisper(topic, frame);
6529
+ }
6530
+ /**
6531
+ * Deliver an already-serialized whisper `frame` to every local socket joined to
6532
+ * `topic`, excluding `exclude` (the sender, or `undefined` for a frame the relay
6533
+ * hub forwarded in — its sender lives on another DO). Records the fan-out pass
6534
+ * for `getFanoutMetrics` (plan 075 Phase 1). Pure delivery — no SQLite, no CDC.
6535
+ * @returns the number of sockets the frame was sent to
6536
+ */
6537
+ deliverWhisperLocal(topic, frame, exclude) {
6538
+ let scanned = 0;
6539
+ let delivered = 0;
3989
6540
  for (const ws of this.state.getWebSockets()) {
3990
- if (ws === sender || this.readAttachment(ws).whispers?.includes(topic) !== true) {
6541
+ scanned += 1;
6542
+ if (ws === exclude || this.readAttachment(ws).whispers?.includes(topic) !== true) {
3991
6543
  continue;
3992
6544
  }
3993
- try {
3994
- ws.send(frame);
3995
- } catch {
3996
- }
6545
+ trySendFrame(ws, frame);
6546
+ delivered += 1;
3997
6547
  }
6548
+ this.fanout.whisper = recordFanoutPass(this.fanout.whisper, scanned, delivered, 0);
6549
+ return delivered;
3998
6550
  }
3999
6551
  // eslint-disable-next-line class-methods-use-this -- cohesive DO instance method grouped with the hibernation/attachment helpers; reads only the socket
4000
6552
  readAttachment(ws) {