@lunora/do 1.0.0-alpha.1 → 1.0.0-alpha.11

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 (33) hide show
  1. package/__assets__/package-og.svg +1 -1
  2. package/dist/index.d.mts +887 -84
  3. package/dist/index.d.ts +887 -84
  4. package/dist/index.mjs +22 -20
  5. package/dist/packem_shared/{ADMIN_FUNCTION_PREFIX-Dzdqq5J2.mjs → ADMIN_FUNCTIONS-D_UiYJFk.mjs} +4 -1
  6. package/dist/packem_shared/{applyCdcChanges-Ctdmxmrv.mjs → CDC_LOG_TABLE-DSycmnDf.mjs} +5 -1
  7. package/dist/packem_shared/{assertFlatPredicate-DyVYReuT.mjs → DEFAULT_MAX_RELATION_KEYS-DU-Y4-LJ.mjs} +51 -2
  8. package/dist/packem_shared/{assertValidClientId-CBZ1zC96.mjs → NotUniqueError-DZQtH02h.mjs} +126 -36
  9. package/dist/packem_shared/{rank-CrkEIpF4.mjs → RANK_TIEBREAK-CXhdcA1o.mjs} +2 -13
  10. package/dist/packem_shared/{guardWriter-u3UlnCH5.mjs → RLS_UNWRAP_SYMBOL-EtGQdC9d.mjs} +6 -2
  11. package/dist/packem_shared/{ROOT_DO_SIZE_WARN_BYTES-DQkmGiCS.mjs → ROOT_DO_SIZE_WARN_BYTES-DtZqOBIF.mjs} +1251 -173
  12. package/dist/packem_shared/{ReactiveCache-ByVzgH3d.mjs → ReactiveCache-1hDydFyv.mjs} +1 -28
  13. package/dist/packem_shared/{applyOnDelete-CMif2RKw.mjs → applyOnDelete-BQ-8ZlZ1.mjs} +19 -9
  14. package/dist/packem_shared/{buildSeekWhere-lVsNXSLy.mjs → applySelect-BvZdFUBT.mjs} +18 -1
  15. package/dist/packem_shared/{backfillAggregateIndexes-BF5eL7kW.mjs → backfillAggregateIndexes-BZsOqDXP.mjs} +3 -2
  16. package/dist/packem_shared/ctx-db-idempotency-BdcNpvY4.mjs +108 -0
  17. package/dist/packem_shared/ctx-db-shapes-DVoeZpo-.mjs +53 -0
  18. package/dist/packem_shared/{runShardMigrations-C3bn5r93.mjs → runShardMigrations-nIwoQeOK.mjs} +6 -4
  19. package/dist/packem_shared/serialize-sql-BlRUoiQe.mjs +14 -0
  20. package/dist/packem_shared/{serveRelationFanout-Clr1a05L.mjs → serveRelationFanout-C6lDaesn.mjs} +1 -1
  21. package/dist/packem_shared/stableStringify-CyHKJXre.mjs +30 -0
  22. package/dist/packem_shared/subscriptionListDeltas-ce84gpwL.mjs +111 -0
  23. package/package.json +2 -2
  24. package/dist/packem_shared/ctx-db-idempotency-DkC9rP91.mjs +0 -35
  25. package/dist/packem_shared/encodePartitionKey-C6blLR5K.mjs +0 -1
  26. /package/dist/packem_shared/{matchesStaticWhere-CFk6adSu.mjs → AGGREGATE_SQL_FUNCTION-CFk6adSu.mjs} +0 -0
  27. /package/dist/packem_shared/{AUTH_METRICS_BUCKET_MS-CiHHYeJi.mjs → AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs} +0 -0
  28. /package/dist/packem_shared/{ensureFunctionMetricsTables-UDNVD7FS.mjs → FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs} +0 -0
  29. /package/dist/packem_shared/{clearCapturedMail-CPpgl-dX.mjs → MAIL_RETENTION-CPpgl-dX.mjs} +0 -0
  30. /package/dist/packem_shared/{assertReadonly-dDcFE1YZ.mjs → MAX_SQL_ROWS-dDcFE1YZ.mjs} +0 -0
  31. /package/dist/packem_shared/{buildSecurityAudit-CCAvoFlr.mjs → MIN_ADMIN_TOKEN_LENGTH-CCAvoFlr.mjs} +0 -0
  32. /package/dist/packem_shared/{ftsTableName-BLEMawrp.mjs → buildFtsMatch-BLEMawrp.mjs} +0 -0
  33. /package/dist/packem_shared/{runTriggers-5N6_Fx0A.mjs → hasTrigger-5N6_Fx0A.mjs} +0 -0
@@ -1,20 +1,22 @@
1
1
  import { drizzle } from 'drizzle-orm/durable-sqlite';
2
2
  import { parseExportShardArgs, parseImportShardArgs } from './exportShardRows-DZEhUeyI.mjs';
3
- import { recordAuthEvent, readAuthMetrics } from './AUTH_METRICS_BUCKET_MS-CiHHYeJi.mjs';
3
+ import { recordAuthEvent, readAuthMetrics } from './AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs';
4
4
  import { DATA_MIGRATION_STATE_TABLE, readMigrationStatus } from './DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs';
5
5
  import { SCAN_DEP, createDependencyTracker, tableFromDepKey } from './SCAN_DEP-DLJF8dsj.mjs';
6
- 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';
6
+ import { readFunctionMetricsTotals, readFunctionMetricIndexHits, recordFunctionMetric, mergeScanAttribution, readFunctionMetrics, readFunctionMetricBuckets } from './FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs';
7
+ import { ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, selectMatchingIds, ADMIN_FUNCTIONS, findStorageReferences, listTables, summarizeSubscriptions, readTablePage, facetColumn, FLAGS_FUNCTION_PREFIX, MAX_PAGE_SIZE } from './ADMIN_FUNCTIONS-D_UiYJFk.mjs';
8
8
  import { LogBuffer } from './LogBuffer-B_Ezju_N.mjs';
9
- import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './clearCapturedMail-CPpgl-dX.mjs';
9
+ import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './MAIL_RETENTION-CPpgl-dX.mjs';
10
10
  import { readBookmark, armRestore } from './armRestore-BJk53Ro8.mjs';
11
- import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-ByVzgH3d.mjs';
11
+ import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-1hDydFyv.mjs';
12
12
  import { redact, standardRules } from '@visulima/redact';
13
13
  import { i as isDevEnvironment, c as buildSettings, b as buildSecurityAudit } from './security-audit-CucgBice.mjs';
14
- import { runReadonlySql } from './assertReadonly-dDcFE1YZ.mjs';
14
+ import { runReadonlySql } from './MAX_SQL_ROWS-dDcFE1YZ.mjs';
15
+ import { trySendFrame, subscriptionListDeltas, sendDeltaFrames } from './subscriptionListDeltas-ce84gpwL.mjs';
15
16
  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';
17
+ import { e as deleteGlobalShapeSnapshotsForConnection, g as readIdempotent, h as writeIdempotent, t as trimIdempotent, r as readClientWatermark, m as migrateClientWatermark, c as advanceClientWatermark, d as deleteGlobalShapeSnapshot, f as readGlobalShapeSnapshot, w as writeGlobalShapeSnapshot } from './ctx-db-idempotency-BdcNpvY4.mjs';
18
+ import { CDC_LOG_TABLE, readCdcChanges, readCdcCursor, readCdcEpoch, minCdcSeq, bumpCdcEpoch } from './CDC_LOG_TABLE-DSycmnDf.mjs';
19
+ import { s as selectShapeMemberIds, a as selectShapeRows } from './ctx-db-shapes-DVoeZpo-.mjs';
18
20
 
19
21
  const AUDIT_LOG_TABLE = "__lunora_audit__";
20
22
  const AUDIT_LOG_RETENTION = 1e3;
@@ -349,6 +351,75 @@ const readRequestLog = (sql, options = {}) => {
349
351
  });
350
352
  };
351
353
 
354
+ const projectColumns = (document_, columns) => {
355
+ if (!columns) {
356
+ return document_;
357
+ }
358
+ const projected = /* @__PURE__ */ Object.create(null);
359
+ for (const key of ["_id", "_creationTime", ...columns]) {
360
+ if (Object.hasOwn(document_, key)) {
361
+ projected[key] = document_[key];
362
+ }
363
+ }
364
+ return projected;
365
+ };
366
+ const diffGlobalMembership = (rows, previous, options) => {
367
+ const { columns, table } = options;
368
+ const next = /* @__PURE__ */ new Map();
369
+ const rowsPatch = [];
370
+ for (const { doc, id } of rows) {
371
+ const value = projectColumns(doc, columns);
372
+ const json = JSON.stringify(value);
373
+ next.set(id, json);
374
+ const before = previous.get(id);
375
+ if (before === void 0) {
376
+ rowsPatch.push({ key: id, op: "insert", table, value });
377
+ } else if (before !== json) {
378
+ rowsPatch.push({ key: id, op: "update", table, value });
379
+ }
380
+ }
381
+ for (const id of previous.keys()) {
382
+ if (!next.has(id)) {
383
+ rowsPatch.push({ key: id, op: "delete", table });
384
+ }
385
+ }
386
+ return { next, rowsPatch };
387
+ };
388
+ const buildPokeFrames = (parts, meta) => {
389
+ const { baseCheckpoint, checkpoint, epoch, lastMutationId, pokeId } = meta;
390
+ const frames = [JSON.stringify({ baseCheckpoint, epoch, pokeId, type: "pokeStart" })];
391
+ for (const part of parts) {
392
+ frames.push(
393
+ JSON.stringify({
394
+ pokeId,
395
+ rowsPatch: part.rowsPatch,
396
+ shapeId: part.shapeId,
397
+ type: "pokePart",
398
+ ...lastMutationId === void 0 ? {} : { lastMutationId }
399
+ })
400
+ );
401
+ }
402
+ frames.push(JSON.stringify({ checkpoint, epoch, pokeId, type: "pokeEnd" }));
403
+ return frames;
404
+ };
405
+
406
+ const runSocketPool = async (items, processOne, concurrency = 8) => {
407
+ let cursor = 0;
408
+ const worker = async () => {
409
+ let item = items[cursor];
410
+ cursor += 1;
411
+ while (item !== void 0) {
412
+ try {
413
+ await processOne(item);
414
+ } catch {
415
+ }
416
+ item = items[cursor];
417
+ cursor += 1;
418
+ }
419
+ };
420
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
421
+ };
422
+
352
423
  const DANGLING_SCAN_CAP = 5e3;
353
424
  const DANGLING_RESULT_CAP = 500;
354
425
  const DOC_COLUMN = "__doc__";
@@ -413,97 +484,7 @@ const findDanglingReferences = (sql, storageColumns, liveKeys) => {
413
484
 
414
485
  const WS_KEEPALIVE_PING = "lunora-ping";
415
486
  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
- };
487
+ const UNDELIVERED_BASELINE = "<undelivered>";
507
488
  const ROOT_DO_SIZE_WARN_BYTES = 1073741824;
508
489
  const CDC_RESUME_SCAN_LIMIT = 1e4;
509
490
  const IDEMPOTENCY_RETENTION_MS = 864e5;
@@ -918,6 +899,13 @@ const parseIdentityHeader = (raw) => {
918
899
  }
919
900
  return void 0;
920
901
  };
902
+ const parseClientSeqHeader = (raw) => {
903
+ if (!raw) {
904
+ return void 0;
905
+ }
906
+ const seq = Number(raw);
907
+ return Number.isInteger(seq) && seq > 0 ? seq : void 0;
908
+ };
921
909
  const tablesFromDeps = (deps) => {
922
910
  const tables = /* @__PURE__ */ new Set();
923
911
  for (const dep of deps) {
@@ -1002,6 +990,28 @@ class ShardDO {
1002
990
  * failure stays unlikely.
1003
991
  */
1004
992
  static MAX_SUBSCRIPTIONS_PER_SOCKET = 32;
993
+ /**
994
+ * Poll interval (ms) for `.global()`-table shapes. A global table lives in
995
+ * D1 with no per-DO op-log, so its shapes can't be poke-live; the DO re-reads
996
+ * each subscribed global shape's membership from D1 on an alarm every
997
+ * `GLOBAL_SHAPE_POLL_INTERVAL_MS` and pokes only the diff. This is the
998
+ * latency floor for a global-shape update — deliberately coarse (seconds, not
999
+ * the sub-millisecond poke-live path) since the D1 read fans out per tick.
1000
+ */
1001
+ static GLOBAL_SHAPE_POLL_INTERVAL_MS = 2e3;
1002
+ /**
1003
+ * Upper bound on a `.global()`-shape's materialized membership. Each global
1004
+ * shape keeps its ENTIRE current membership as a per-socket snapshot
1005
+ * (`Map&lt;rowKey, hash&gt;`) so the poll loop can diff it; that snapshot — and the
1006
+ * read buffer feeding it — scale with the membership size, multiplied by every
1007
+ * subscribed socket. An unbounded membership (a global table with no narrowing
1008
+ * shape predicate or RLS read scope) would grow them without limit and evict
1009
+ * the DO. A shape whose membership exceeds this cap is failed closed (left
1010
+ * empty, logged) rather than retained — the developer must narrow it. Sized
1011
+ * well above any reasonable per-identity replicated set so legitimate shapes
1012
+ * never trip it.
1013
+ */
1014
+ static GLOBAL_SHAPE_MAX_ROWS = 5e4;
1005
1015
  /**
1006
1016
  * Per-socket whisper-topic cap. Topic membership rides the same hibernation
1007
1017
  * attachment as `subs`, so bound it for the same reason — a runaway
@@ -1103,6 +1113,39 @@ class ShardDO {
1103
1113
  * `finally` block.
1104
1114
  */
1105
1115
  currentRequestMutationId;
1116
+ /**
1117
+ * Stable per-device client id for the in-flight custom-mutator push,
1118
+ * forwarded via the `x-lunora-client-id` header. Backs the
1119
+ * `__client_watermark` table: the dispatch path classifies the paired
1120
+ * `currentRequestClientSeq` against the stored high-watermark (already
1121
+ * processed / next / out-of-order gap). Absent on legacy mutations and
1122
+ * queries (those keep the `__idempotency` path). Cleared in `fetch`'s
1123
+ * `finally`.
1124
+ */
1125
+ currentRequestClientId;
1126
+ /**
1127
+ * Monotonic per-client mutation sequence for the in-flight custom-mutator
1128
+ * push, forwarded via the `x-lunora-client-seq` header (numeric). Paired
1129
+ * with `currentRequestClientId` to drive the watermark classification.
1130
+ * `undefined` when absent or non-numeric.
1131
+ */
1132
+ currentRequestClientSeq;
1133
+ /**
1134
+ * The in-flight push's custom-mutator classification, stashed by `fetch`
1135
+ * before `handleRpc` so the in-transaction bookkeeping ({@link
1136
+ * ShardDO.commitMutationBookkeeping}) can advance the `__client_watermark` for
1137
+ * a `"next"` push inside the same commit as the writes. `undefined` for an
1138
+ * ordinary mutation / non-mutator push. Cleared per request.
1139
+ */
1140
+ currentMutatorClass;
1141
+ /**
1142
+ * Set once a mutation's replay bookkeeping (idempotency row + watermark
1143
+ * advance) has committed INSIDE the handler transaction, so the post-dispatch
1144
+ * path skips the now-redundant best-effort writes. Cleared per request; stays
1145
+ * `false` for actions/queries (no transaction wrapper) so their dispatch-level
1146
+ * idempotency persist still runs.
1147
+ */
1148
+ mutationBookkeepingCommitted = false;
1106
1149
  /**
1107
1150
  * Wall-clock millis of the last `__idempotency` GC sweep on this warm
1108
1151
  * instance. The dedup write throttles `trimIdempotent` to at most once an
@@ -1133,6 +1176,18 @@ class ShardDO {
1133
1176
  * the common read-only path allocates nothing.
1134
1177
  */
1135
1178
  pendingChangedTables = void 0;
1179
+ /**
1180
+ * Coalesced set of tables awaiting a subscription-refresh pass, merged
1181
+ * across every {@link ShardDO.flushChangedTables} call that lands while a
1182
+ * pass is already draining. The single drain loop
1183
+ * ({@link ShardDO.drainSubscriptionRefreshes}) owns this set; a burst of N
1184
+ * writes to the same table therefore collapses into one (or two) refresh
1185
+ * passes instead of N, so each affected subscription's handler re-runs once
1186
+ * per burst rather than once per write. `undefined` when nothing is pending.
1187
+ */
1188
+ pendingRefreshTables = void 0;
1189
+ /** True while {@link ShardDO.drainSubscriptionRefreshes} is running; the single-waiter gate that coalesces concurrent flushes. */
1190
+ refreshInFlight = false;
1136
1191
  /**
1137
1192
  * Last pushed result per `(socket, subId)`, keyed by socket. Lets
1138
1193
  * `refreshSubscriptions` skip re-running queries whose tables were
@@ -1141,6 +1196,39 @@ class ShardDO {
1141
1196
  * memo simply forces one re-run and (at most) one redundant push.
1142
1197
  */
1143
1198
  subMemos = /* @__PURE__ */ new WeakMap();
1199
+ /**
1200
+ * Per-socket poke baseline for shape subscriptions: maps each shape's
1201
+ * subscription id to the `__cdc_log` cursor it has been poked through.
1202
+ * `pokeShapeSubscribers` reads each op page since this cursor and advances
1203
+ * it to the flush watermark. In-memory only (like {@link ShardDO.subMemos});
1204
+ * a cold memo on a reconnected/hibernated socket re-seeds from the client's
1205
+ * `sinceCheckpoint`.
1206
+ */
1207
+ shapeMemos = /* @__PURE__ */ new WeakMap();
1208
+ /**
1209
+ * Per-socket, per-**global**-shape membership snapshot: maps each global
1210
+ * shape's subscription id to a `key → projected-value JSON` map of the rows
1211
+ * last poked to that socket. A `.global()` (D1) table has no op-log to diff,
1212
+ * so {@link ShardDO.refreshGlobalShape} re-reads the full membership on each
1213
+ * alarm tick and diffs it against this snapshot to compute the poke. Parallel
1214
+ * to {@link ShardDO.shapeMemos} (the cursor baseline for poke-live shapes).
1215
+ *
1216
+ * This is a hot in-memory **cache** over the durable `__global_shape_snapshot`
1217
+ * table (keyed by the socket's `connectionId` + subId): a hibernation eviction
1218
+ * clears the WeakMap, so on the next alarm wake {@link ShardDO.readGlobalSnapshot}
1219
+ * misses and re-loads the baseline from SQLite — without it, the diff would run
1220
+ * against an empty baseline and a row deleted from D1 while the DO slept would
1221
+ * never be poked as a `delete`, lingering on the client as a phantom row.
1222
+ */
1223
+ globalShapeSnapshots = /* @__PURE__ */ new WeakMap();
1224
+ /**
1225
+ * Whether a global-shape poll alarm is currently armed. Guards
1226
+ * {@link ShardDO.scheduleGlobalPoll} from re-arming on every seed; reset in
1227
+ * {@link ShardDO.alarm} before the poll so a still-subscribed shape re-arms.
1228
+ */
1229
+ globalPollScheduled = false;
1230
+ /** Monotonic per-DO poke id source; correlates a poke's `pokeStart`/`pokePart`/`pokeEnd` frames. */
1231
+ pokeSequence = 0;
1144
1232
  /** Per-socket whisper-rate token bucket (see {@link ShardDO.WHISPER_RATE_BURST}). In-memory; resets on hibernation. */
1145
1233
  whisperBuckets = /* @__PURE__ */ new WeakMap();
1146
1234
  /**
@@ -1272,6 +1360,10 @@ class ShardDO {
1272
1360
  this.currentResponseBookmark = void 0;
1273
1361
  this.currentRequestUserId = request.headers.get("x-lunora-userid") ?? void 0;
1274
1362
  this.currentRequestMutationId = request.headers.get("x-lunora-mutation-id") ?? void 0;
1363
+ this.currentRequestClientId = request.headers.get("x-lunora-client-id") ?? void 0;
1364
+ this.currentRequestClientSeq = parseClientSeqHeader(request.headers.get("x-lunora-client-seq"));
1365
+ this.currentMutatorClass = void 0;
1366
+ this.mutationBookkeepingCommitted = false;
1275
1367
  this.currentRequestIdentity = parseIdentityHeader(request.headers.get("x-lunora-identity"));
1276
1368
  this.currentRequestIp = request.headers.get("x-lunora-client-ip") ?? void 0;
1277
1369
  this.currentRequestSystem = request.headers.get("x-lunora-system") === "1";
@@ -1287,20 +1379,25 @@ class ShardDO {
1287
1379
  const value = await this.runRelationFanoutRead(payload.functionPath, payload.args ?? {});
1288
1380
  return jsonResponse(value, 200, this.currentResponseBookmark);
1289
1381
  }
1382
+ const mutatorClass = this.isCustomMutator(payload.functionPath) ? this.classifyClientMutation() : void 0;
1383
+ this.currentMutatorClass = mutatorClass;
1384
+ const watermarkShortCircuit = this.rejectNonNextMutation(payload.functionPath, mutatorClass, dispatchStartedAt);
1385
+ if (watermarkShortCircuit !== void 0) {
1386
+ return watermarkShortCircuit;
1387
+ }
1290
1388
  const cached = this.readIdempotentResult(this.currentRequestMutationId);
1291
1389
  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);
1390
+ return this.respondFromIdempotencyCache(payload.functionPath, dispatchStartedAt, mutatorClass, cached.value);
1294
1391
  }
1295
1392
  const result = await this.handleRpc(payload.functionPath, payload.args ?? {});
1296
- this.persistIdempotentResult(result);
1393
+ this.recordPostDispatchBookkeeping(result, mutatorClass);
1297
1394
  const durationMs = Date.now() - dispatchStartedAt;
1298
1395
  this.recordFunctionCall(payload.functionPath, durationMs, void 0, this.currentScannedTables, this.currentIndexHits);
1299
1396
  this.flushStmtSamples();
1300
1397
  const tablesWritten = [...this.pendingChangedTables ?? []];
1301
1398
  this.recordRequestLog(payload.functionPath, payload.args ?? {}, durationMs, "ok", tablesWritten);
1302
1399
  this.maybeWarnRootSize();
1303
- const response = jsonResponse({ result }, 200, this.currentResponseBookmark);
1400
+ const response = this.buildDispatchResponse(mutatorClass, result);
1304
1401
  await this.flushChangedTables();
1305
1402
  return response;
1306
1403
  } catch (error) {
@@ -1326,6 +1423,10 @@ class ShardDO {
1326
1423
  this.currentResponseBookmark = void 0;
1327
1424
  this.currentRequestUserId = void 0;
1328
1425
  this.currentRequestMutationId = void 0;
1426
+ this.currentRequestClientId = void 0;
1427
+ this.currentRequestClientSeq = void 0;
1428
+ this.currentMutatorClass = void 0;
1429
+ this.mutationBookkeepingCommitted = false;
1329
1430
  this.currentRequestIdentity = void 0;
1330
1431
  this.currentRequestIp = void 0;
1331
1432
  this.currentRequestSystem = false;
@@ -1363,6 +1464,9 @@ class ShardDO {
1363
1464
  if (envelope.context !== void 0) {
1364
1465
  attachment.context = envelope.context;
1365
1466
  }
1467
+ if (envelope.clientId !== void 0) {
1468
+ attachment.clientId = envelope.clientId;
1469
+ }
1366
1470
  attachment.connected = true;
1367
1471
  try {
1368
1472
  ws.serializeAttachment?.(attachment);
@@ -1394,6 +1498,20 @@ class ShardDO {
1394
1498
  }
1395
1499
  return;
1396
1500
  }
1501
+ if (envelope.type === "shape_subscribe" && envelope.shape) {
1502
+ await this.handleShapeSubscribe(ws, envelope.id, {
1503
+ args: envelope.shape.args,
1504
+ name: envelope.shape.name,
1505
+ sinceEpoch: envelope.sinceEpoch,
1506
+ sinceSeq: envelope.sinceCheckpoint
1507
+ });
1508
+ return;
1509
+ }
1510
+ if (envelope.type === "shape_unsubscribe") {
1511
+ this.shapeUnsubscribe(ws, envelope.id);
1512
+ ws.send(JSON.stringify({ id: envelope.id, type: "ack" }));
1513
+ return;
1514
+ }
1397
1515
  if (envelope.type === "stream" && envelope.query?.functionPath) {
1398
1516
  if (envelope.query.functionPath.startsWith(ADMIN_FUNCTION_PREFIX)) {
1399
1517
  ws.send(JSON.stringify({ id: envelope.id, message: "streams must be public", type: "error" }));
@@ -1444,12 +1562,41 @@ class ShardDO {
1444
1562
  this.streamCancellers.delete(ws);
1445
1563
  }
1446
1564
  this.subMemos.delete(ws);
1565
+ this.shapeMemos.delete(ws);
1566
+ this.globalShapeSnapshots.delete(ws);
1567
+ if (attachment.connectionId !== void 0) {
1568
+ try {
1569
+ deleteGlobalShapeSnapshotsForConnection(this.sql, attachment.connectionId);
1570
+ } catch {
1571
+ }
1572
+ }
1447
1573
  ws.serializeAttachment?.(void 0);
1448
1574
  }
1449
1575
  /** Hibernation API: invoked on socket error. */
1450
1576
  // 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
1577
  webSocketError(_ws, _error) {
1452
1578
  }
1579
+ /**
1580
+ * Durable Object alarm handler — the heartbeat for `.global()`-table shapes.
1581
+ * The runtime wakes this when the poll alarm armed by `scheduleGlobalPoll`
1582
+ * fires; it refreshes every subscribed global shape (diff-poke from the global
1583
+ * backend) and re-arms while any remain. With no global subscribers left, the
1584
+ * alarm is not re-armed and the DO goes idle. A base-only / global-free DO
1585
+ * never arms it, so this stays dormant there.
1586
+ */
1587
+ async alarm() {
1588
+ this.globalPollScheduled = false;
1589
+ let remaining;
1590
+ try {
1591
+ remaining = await this.pollGlobalShapes();
1592
+ } catch (error) {
1593
+ this.recordShapeError("shape:poll", error);
1594
+ remaining = 1;
1595
+ }
1596
+ if (remaining > 0) {
1597
+ await this.scheduleGlobalPoll();
1598
+ }
1599
+ }
1453
1600
  /**
1454
1601
  * The registered function paths to dispatch when a socket connects/disconnects.
1455
1602
  * Base default is empty; the codegen subclass overrides it to return the
@@ -1648,20 +1795,14 @@ class ShardDO {
1648
1795
  status: 500
1649
1796
  });
1650
1797
  }
1651
- const sqlExec = sqlHandle.exec.bind(sqlHandle);
1798
+ const transactionalStorage = this.state.storage;
1652
1799
  const run = async () => {
1653
1800
  this.transactionDepth = 1;
1654
- sqlExec("BEGIN");
1655
1801
  try {
1656
- const value = await handler();
1657
- sqlExec("COMMIT");
1658
- return value;
1659
- } catch (error) {
1660
- try {
1661
- sqlExec("ROLLBACK");
1662
- } catch {
1802
+ if (typeof transactionalStorage?.transaction === "function") {
1803
+ return await transactionalStorage.transaction(async () => handler());
1663
1804
  }
1664
- throw error;
1805
+ return await handler();
1665
1806
  } finally {
1666
1807
  this.transactionDepth = 0;
1667
1808
  }
@@ -1859,7 +2000,45 @@ class ShardDO {
1859
2000
  */
1860
2001
  // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the statically-discovered feature flags
1861
2002
  studioFeatures() {
1862
- return { mail: false, payments: false, scheduler: false, storage: false, vectors: false, workflows: false };
2003
+ return { flags: false, mail: false, payments: false, queues: false, scheduler: false, storage: false, vectors: false, workflows: false };
2004
+ }
2005
+ /**
2006
+ * Evaluate every statically-discovered feature flag under `context` for the
2007
+ * studio's read-only Flags page (`__lunora_admin__:listFlags`). The flag keys
2008
+ * + value types are discovered by `@lunora/codegen` from the app's
2009
+ * `ctx.flags.&lt;type>("key", …)` reads and evaluated through the configured
2010
+ * `@lunora/flags` provider — work only the codegen subclass can do, so it
2011
+ * overrides this. The base class wires no provider and reports
2012
+ * `configured: false` with zero flags (an un-generated `ShardDO` has none).
2013
+ */
2014
+ // 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
2015
+ evaluateFlags(_context) {
2016
+ return Promise.resolve({ configured: false, flags: [] });
2017
+ }
2018
+ /**
2019
+ * Serve one reserved {@link FLAGS_FUNCTION_PREFIX} live flag read for the
2020
+ * React client's `useFlag`/`useFlags`. `functionPath` carries the flag key +
2021
+ * type and `args` the per-subscriber targeting context; the codegen subclass
2022
+ * overrides this to evaluate the flag through the app's `@lunora/flags`
2023
+ * provider under `identity` and return the resolved value. The base class
2024
+ * wires no provider, so it returns `null` — `resolveReactiveOutcome` reads
2025
+ * `null` as "nothing to deliver" and the subscriber keeps its default.
2026
+ */
2027
+ // 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
2028
+ runFlagSubscriptionRead(_functionPath, _arguments, _identity) {
2029
+ return Promise.resolve(null);
2030
+ }
2031
+ /**
2032
+ * The Cloudflare Queues declared by this app, surfaced via
2033
+ * `__lunora_admin__:listQueues` for the studio's Queues page. Queues are NOT
2034
+ * Durable Objects and hold no shard state, so this is pure declaration
2035
+ * metadata statically discovered by `@lunora/codegen` from `lunora/queues.ts`
2036
+ * and emitted into the generated subclass, which overrides this. The base
2037
+ * class can't see the user's project, so it reports none.
2038
+ */
2039
+ // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the statically-discovered queue metadata
2040
+ queuesMetadata() {
2041
+ return { queues: [] };
1863
2042
  }
1864
2043
  /**
1865
2044
  * The Cloudflare Workflows declared by this app, surfaced via
@@ -2124,13 +2303,15 @@ class ShardDO {
2124
2303
  * unless the request carried an `x-lunora-mutation-id` header (queries and
2125
2304
  * legacy clients leave `currentRequestMutationId` undefined).
2126
2305
  *
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.
2306
+ * For a mutation this runs INSIDE the handler's transaction (via
2307
+ * {@link ShardDO.commitMutationBookkeeping}, which `handleRpc` invokes before
2308
+ * the transaction commits), so the dedup row is durable iff the writes are —
2309
+ * closing the crash window where the writes commit but the replay guard does
2310
+ * not. Actions/queries aren't transaction-wrapped, so they call this on the
2311
+ * live dispatch path right after the handler resolves, through the same
2312
+ * `this.sql` handle. `INSERT OR IGNORE` keeps a concurrent double-dispatch (or
2313
+ * the now-skipped post-dispatch call) of the same id idempotent. Also runs the
2314
+ * throttled dedup-table GC.
2134
2315
  */
2135
2316
  persistIdempotentResult(result) {
2136
2317
  if (this.currentRequestMutationId === void 0) {
@@ -2146,6 +2327,175 @@ class ShardDO {
2146
2327
  } catch {
2147
2328
  }
2148
2329
  }
2330
+ /**
2331
+ * Whether `functionPath` names a registered custom mutator (a `defineMutator`
2332
+ * declaration) rather than an ordinary `mutation`. The base class knows of no
2333
+ * mutators, so the default is `false`; the codegen-generated subclass
2334
+ * overrides this to consult its mutator registry. When `true` (and the push
2335
+ * carries a `clientId`/`clientSeq`), the dispatch path applies the
2336
+ * `__client_watermark` ordering semantics instead of the legacy idempotency
2337
+ * dedup.
2338
+ */
2339
+ // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this to consult its mutator registry
2340
+ isCustomMutator(_functionPath) {
2341
+ return false;
2342
+ }
2343
+ /**
2344
+ * Classify an in-flight custom-mutator push against the shard's stored
2345
+ * high-watermark for `currentRequestClientId`. The watermark is the highest
2346
+ * per-client sequence the DO has applied, so the push is exactly one of:
2347
+ *
2348
+ * - `"already"` — `seq &lt;= watermark`: a replay of a confirmed (or in-flight,
2349
+ * now-resent) mutation. The handler must NOT re-run; the dispatch path returns
2350
+ * a benign ack so the client drops the pending overlay.
2351
+ * - `"next"` — `seq == watermark + 1`: the next mutation in order. Run the
2352
+ * authoritative `server` impl and advance the watermark in the same commit.
2353
+ * - `"gap"` — `seq > watermark + 1`: an out-of-order arrival (an earlier push
2354
+ * was lost). Halt: the client must resend from `watermark + 1`.
2355
+ *
2356
+ * Returns `undefined` when the push is not a watermarked custom mutator
2357
+ * (missing client id/seq, or a stub `sql` handle without the table) so the
2358
+ * caller falls through to the legacy idempotency path.
2359
+ */
2360
+ classifyClientMutation() {
2361
+ const clientId = this.currentRequestClientId;
2362
+ const seq = this.currentRequestClientSeq;
2363
+ if (clientId === void 0 || seq === void 0) {
2364
+ return void 0;
2365
+ }
2366
+ const identity = this.currentRequestUserId ?? "";
2367
+ let watermark;
2368
+ try {
2369
+ watermark = readClientWatermark(this.sql, identity, clientId);
2370
+ } catch {
2371
+ try {
2372
+ migrateClientWatermark(this.sql);
2373
+ watermark = readClientWatermark(this.sql, identity, clientId);
2374
+ } catch {
2375
+ return void 0;
2376
+ }
2377
+ }
2378
+ const expected = watermark + 1;
2379
+ if (seq <= watermark) {
2380
+ return { expected, kind: "already" };
2381
+ }
2382
+ return seq === expected ? { expected, kind: "next" } : { expected, kind: "gap" };
2383
+ }
2384
+ /**
2385
+ * Terminal response for a watermarked custom-mutator push that is NOT the
2386
+ * next-in-order mutation — an idempotent replay ack (`"already"`) or an
2387
+ * out-of-order halt (`"gap"`). Returns `undefined` for an ordinary mutation
2388
+ * or a `"next"` push so `fetch` proceeds to the authoritative handler. Records
2389
+ * the function call on the short-circuit paths so metrics stay attributed.
2390
+ */
2391
+ rejectNonNextMutation(functionPath, mutatorClass, dispatchStartedAt) {
2392
+ if (mutatorClass === void 0 || mutatorClass.kind === "next") {
2393
+ return void 0;
2394
+ }
2395
+ this.recordFunctionCall(functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
2396
+ if (mutatorClass.kind === "already") {
2397
+ return jsonResponse({ lastMutationId: mutatorClass.expected - 1, result: null }, 200, this.currentResponseBookmark);
2398
+ }
2399
+ return jsonResponse(
2400
+ {
2401
+ error: {
2402
+ code: "OUT_OF_ORDER",
2403
+ expectedMutationId: mutatorClass.expected,
2404
+ message: `out-of-order mutation; expected sequence ${String(mutatorClass.expected)}`
2405
+ }
2406
+ },
2407
+ 409,
2408
+ this.currentResponseBookmark
2409
+ );
2410
+ }
2411
+ /**
2412
+ * Respond to a dispatch that hit the `(identity, mutationId)` idempotency
2413
+ * cache. Records the (zero-work) function call, then: for a `"next"` custom
2414
+ * mutator whose handler already committed but whose watermark advance was
2415
+ * lost to a crash in between, re-advance and echo `lastMutationId` exactly as
2416
+ * the post-commit path does (otherwise the cached branch returns a bare
2417
+ * result with a stale watermark and the client reports every later seq as a
2418
+ * gap forever); for everything else, return the bare cached `{ result }`.
2419
+ */
2420
+ respondFromIdempotencyCache(functionPath, dispatchStartedAt, mutatorClass, cachedValue) {
2421
+ this.recordFunctionCall(functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
2422
+ if (mutatorClass?.kind === "next") {
2423
+ this.advanceClientMutationWatermark();
2424
+ return this.buildDispatchResponse(mutatorClass, cachedValue);
2425
+ }
2426
+ return jsonResponse({ result: cachedValue }, 200, this.currentResponseBookmark);
2427
+ }
2428
+ /**
2429
+ * Build the success response for a dispatched RPC. A `"next"` custom-mutator
2430
+ * push echoes the applied `lastMutationId` so the client drops the pending
2431
+ * optimistic overlay as soon as the ack lands; ordinary calls return the bare
2432
+ * `{ result }` envelope unchanged.
2433
+ */
2434
+ buildDispatchResponse(mutatorClass, result) {
2435
+ if (mutatorClass?.kind === "next") {
2436
+ return jsonResponse({ lastMutationId: this.currentRequestClientSeq, result }, 200, this.currentResponseBookmark);
2437
+ }
2438
+ return jsonResponse({ result }, 200, this.currentResponseBookmark);
2439
+ }
2440
+ /**
2441
+ * Commit a mutation's replay bookkeeping — the `(identity, mutationId)`
2442
+ * idempotency dedup row and, for a `"next"` custom-mutator push, the
2443
+ * `__client_watermark` advance — INSIDE the handler's transaction. Called by
2444
+ * the generated `handleRpc` mutation branch after the user handler resolves
2445
+ * but before the transaction commits, so the writes, the dedup row, and the
2446
+ * watermark land in one atomic commit: a crash can't leave the writes durable
2447
+ * without the replay guard (which a re-dispatch would otherwise re-run) nor
2448
+ * without the watermark. Sets {@link ShardDO.mutationBookkeepingCommitted} so
2449
+ * `fetch` skips the redundant post-dispatch persist.
2450
+ */
2451
+ commitMutationBookkeeping(result) {
2452
+ this.persistIdempotentResult(result);
2453
+ if (this.currentMutatorClass?.kind === "next") {
2454
+ this.advanceClientMutationWatermark({ strict: true });
2455
+ }
2456
+ this.mutationBookkeepingCommitted = true;
2457
+ }
2458
+ /**
2459
+ * Best-effort replay bookkeeping for the live dispatch path, run after
2460
+ * `handleRpc` returns. A generated mutation already committed it atomically
2461
+ * inside its transaction (via {@link ShardDO.commitMutationBookkeeping}, which
2462
+ * sets the flag), so this skips. Actions/queries aren't transaction-wrapped,
2463
+ * so they record their dedup row here (a no-op without an `x-lunora-mutation-id`),
2464
+ * and a `"next"` push advances its watermark (the gap self-heals on replay).
2465
+ */
2466
+ recordPostDispatchBookkeeping(result, mutatorClass) {
2467
+ if (this.mutationBookkeepingCommitted) {
2468
+ return;
2469
+ }
2470
+ this.persistIdempotentResult(result);
2471
+ if (mutatorClass?.kind === "next") {
2472
+ this.advanceClientMutationWatermark();
2473
+ }
2474
+ }
2475
+ /**
2476
+ * Advance the stored high-watermark for the in-flight custom mutator to
2477
+ * `currentRequestClientSeq` through the same `this.sql` handle. On the
2478
+ * transactional path ({@link ShardDO.commitMutationBookkeeping}, `strict`) it
2479
+ * runs inside the handler's commit, so the watermark is durable iff the writes
2480
+ * are; a failure rethrows to roll the mutation back. On the best-effort
2481
+ * cache-hit recovery path (`strict` omitted) a missing table is swallowed —
2482
+ * the replay re-runs and re-advances (the read side treats a missing row as
2483
+ * watermark 0), so the gap self-heals.
2484
+ */
2485
+ advanceClientMutationWatermark(options) {
2486
+ const clientId = this.currentRequestClientId;
2487
+ const seq = this.currentRequestClientSeq;
2488
+ if (clientId === void 0 || seq === void 0) {
2489
+ return;
2490
+ }
2491
+ try {
2492
+ advanceClientWatermark(this.sql, this.currentRequestUserId ?? "", clientId, seq);
2493
+ } catch (error) {
2494
+ if (options?.strict) {
2495
+ throw error;
2496
+ }
2497
+ }
2498
+ }
2149
2499
  /**
2150
2500
  * Replay a batch of CDC changes into this shard (point-in-time recovery).
2151
2501
  * Schema-aware — it builds a `createShardCtxDb` writer — so the base class
@@ -2195,6 +2545,56 @@ class ShardDO {
2195
2545
  }
2196
2546
  this.subMemos.get(ws)?.delete(subId);
2197
2547
  }
2548
+ /**
2549
+ * Register a live shape subscription on a socket — the partial-replication
2550
+ * parallel to {@link ShardDO.subscribe}. Stores the descriptor in the
2551
+ * attachment's `shapes` registry (created lazily) so it survives
2552
+ * hibernation, sharing the per-socket cap with `subs`. Returns a status the
2553
+ * caller surfaces as a structured error frame; never throws (a thrown
2554
+ * `webSocketMessage` is a fatal-channel error under the hibernation API).
2555
+ */
2556
+ shapeSubscribe(ws, subId, shape) {
2557
+ const attachment = this.readAttachment(ws);
2558
+ const shapes = attachment.shapes ?? {};
2559
+ if (Object.keys(attachment.subs).length + Object.keys(shapes).length >= ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET) {
2560
+ return "too_many";
2561
+ }
2562
+ shapes[subId] = shape;
2563
+ attachment.shapes = shapes;
2564
+ try {
2565
+ ws.serializeAttachment?.(attachment);
2566
+ } catch {
2567
+ delete attachment.shapes[subId];
2568
+ return "serialize_failed";
2569
+ }
2570
+ return "ok";
2571
+ }
2572
+ /** Remove a shape subscription and its poke baseline. Mirrors {@link ShardDO.unsubscribe}'s rollback-on-serialize-failure contract. */
2573
+ shapeUnsubscribe(ws, subId) {
2574
+ const attachment = this.readAttachment(ws);
2575
+ const { shapes } = attachment;
2576
+ if (!shapes) {
2577
+ return;
2578
+ }
2579
+ const captured = shapes[subId];
2580
+ delete shapes[subId];
2581
+ try {
2582
+ ws.serializeAttachment?.(attachment);
2583
+ } catch {
2584
+ if (captured !== void 0) {
2585
+ shapes[subId] = captured;
2586
+ }
2587
+ return;
2588
+ }
2589
+ this.shapeMemos.get(ws)?.delete(subId);
2590
+ this.globalShapeSnapshots.get(ws)?.delete(subId);
2591
+ if (attachment.connectionId !== void 0) {
2592
+ try {
2593
+ deleteGlobalShapeSnapshot(this.sql, attachment.connectionId, subId);
2594
+ } catch {
2595
+ }
2596
+ }
2597
+ }
2198
2598
  /**
2199
2599
  * Decide whether a single subscription is interested in a mutation
2200
2600
  * delta. The default implementation checks the table name, then runs a
@@ -2243,10 +2643,7 @@ class ShardDO {
2243
2643
  if (!this.matchesSubscription(query, delta)) {
2244
2644
  continue;
2245
2645
  }
2246
- try {
2247
- ws.send(`{"type":"delta","id":${JSON.stringify(subId)},"delta":${deltaJson}}`);
2248
- } catch {
2249
- }
2646
+ trySendFrame(ws, `{"type":"delta","id":${JSON.stringify(subId)},"delta":${deltaJson}}`);
2250
2647
  }
2251
2648
  }
2252
2649
  }
@@ -2269,6 +2666,46 @@ class ShardDO {
2269
2666
  executeSubscription(_functionPath, _args, _identity) {
2270
2667
  return Promise.resolve(null);
2271
2668
  }
2669
+ /**
2670
+ * Resolve a named shape to its concrete query plan for `identity`. The base
2671
+ * class has no shape registry, so it returns `undefined` — partial
2672
+ * replication is disabled and a `shape_subscribe` is rejected. The
2673
+ * codegen-generated subclass overrides this to look the shape up in the
2674
+ * project's `defineShape` registry, evaluate its `where(ctx, args)` under the
2675
+ * subscriber's verified identity, and AND-compose it with the table's RLS
2676
+ * read base-where into {@link ResolvedShape.effectiveWhere}.
2677
+ *
2678
+ * `identity` is the socket's OWN verified identity (the same unforgeable
2679
+ * value `refreshSubscriptions` threads), passed by value so this never reads
2680
+ * the mutable per-request identity fields. Returning `undefined` is the
2681
+ * fail-closed signal — an unknown shape, or an RLS-required table with no
2682
+ * policy resolving for this identity, yields no subscription rather than
2683
+ * leaking rows.
2684
+ */
2685
+ // 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
2686
+ resolveShape(_name, _args, _identity) {
2687
+ return void 0;
2688
+ }
2689
+ /**
2690
+ * Read the FULL current membership of a `.global()`-table shape from its D1
2691
+ * (or Hyperdrive) backend — the seed/poll source for the latency-tiered
2692
+ * global shape path. A `.global()` table lives in another store with no
2693
+ * per-DO op-log, so this is the only way to learn its rows from inside the
2694
+ * shard DO; {@link ShardDO.seedGlobalShape} calls it once on subscribe and
2695
+ * {@link ShardDO.refreshGlobalShape} on every alarm tick, diffing the result
2696
+ * against the per-socket snapshot to compute the poke.
2697
+ *
2698
+ * The base class has no global backend, so it returns `[]` (a base-only DO,
2699
+ * or a project with no global tables, never resolves a global shape). The
2700
+ * codegen subclass overrides it to drain `globalDb.findMany(table, { where:
2701
+ * effectiveWhere })` under the socket's verified `identity` — the same
2702
+ * unforgeable value `resolveShape` composed the RLS predicate with, so the
2703
+ * D1 read is identity-scoped exactly like the poke-live path.
2704
+ */
2705
+ // 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
2706
+ readGlobalShapeRows(_resolved, _identity) {
2707
+ return Promise.resolve([]);
2708
+ }
2272
2709
  /**
2273
2710
  * Look up a streaming-query function and return a thunk that produces the
2274
2711
  * `AsyncIterable&lt;unknown>` when handed an {@link AbortSignal}. The codegen
@@ -2671,16 +3108,16 @@ class ShardDO {
2671
3108
  return jsonResponse({ error: { code: error.code, message: error.message } }, error.status);
2672
3109
  }
2673
3110
  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);
3111
+ const message = error instanceof Error ? error.message : "validation failed";
3112
+ return jsonResponse({ error: { code: "VALIDATION_ERROR", message } }, 400);
2676
3113
  }
2677
3114
  if (error && typeof error === "object" && error.name === "LunoraError") {
2678
3115
  const lunoraError = error;
2679
3116
  const status = typeof lunoraError.status === "number" ? lunoraError.status : 500;
2680
3117
  return jsonResponse({ error: { code: lunoraError.code ?? "INTERNAL", message: lunoraError.message ?? "internal error" } }, status);
2681
3118
  }
2682
- const message = error instanceof Error ? error.message : "unknown error";
2683
- return jsonResponse({ error: { code: "RPC_FAILED", message } }, 500);
3119
+ console.error("[@lunora/do] unhandled RPC error:", error);
3120
+ return jsonResponse({ error: { code: "RPC_FAILED", message: "internal error" } }, 500);
2684
3121
  }
2685
3122
  /**
2686
3123
  * Serve a reserved admin-introspection RPC (`__lunora_admin__:*`) for the
@@ -2802,6 +3239,9 @@ class ShardDO {
2802
3239
  if (functionPath === ADMIN_FUNCTIONS.getWorkflowInstanceStatus) {
2803
3240
  return this.handleGetWorkflowInstanceStatus(args);
2804
3241
  }
3242
+ if (functionPath === ADMIN_FUNCTIONS.listFlags) {
3243
+ return this.handleListFlags(args);
3244
+ }
2805
3245
  return this.handlePitrAdminOp(functionPath, args);
2806
3246
  }
2807
3247
  /**
@@ -2922,6 +3362,21 @@ class ShardDO {
2922
3362
  return jsonResponse({ result }, 200);
2923
3363
  }
2924
3364
  /* eslint-enable no-secrets/no-secrets */
3365
+ /**
3366
+ * Serve `__lunora_admin__:listFlags` — the studio's read-only Flags page.
3367
+ * Evaluates every statically-discovered feature flag under an optional
3368
+ * `args.context` targeting context (the studio's editable context editor)
3369
+ * via the {@link evaluateFlags} hook, which the codegen subclass overrides
3370
+ * with live OpenFeature evaluation. Read-only: a flag lookup mutates no shard
3371
+ * state, so nothing is flushed or audited. Admin-gated by `handleAdminRpc`'s
3372
+ * caller.
3373
+ */
3374
+ async handleListFlags(args) {
3375
+ const rawContext = args.context;
3376
+ const context = typeof rawContext === "object" && rawContext !== null && !Array.isArray(rawContext) ? rawContext : void 0;
3377
+ const result = await this.evaluateFlags(context);
3378
+ return jsonResponse({ result }, 200);
3379
+ }
2925
3380
  /**
2926
3381
  * Run `run()` with the per-request identity pinned to (`userId`, `identity`),
2927
3382
  * then restore the prior values in a `finally` (even if `run()` throws), so the
@@ -3301,6 +3756,9 @@ class ShardDO {
3301
3756
  if (functionPath === ADMIN_FUNCTIONS.listWorkflows) {
3302
3757
  return this.workflowsMetadata();
3303
3758
  }
3759
+ if (functionPath === ADMIN_FUNCTIONS.listQueues) {
3760
+ return this.queuesMetadata();
3761
+ }
3304
3762
  return void 0;
3305
3763
  }
3306
3764
  /**
@@ -3460,6 +3918,73 @@ class ShardDO {
3460
3918
  const read = this.readAdminOp(functionPath, args);
3461
3919
  return read ? { result: read.result, tables: read.tables } : null;
3462
3920
  }
3921
+ /**
3922
+ * Resolve one subscription (seed or refresh) to its {@link SubscriptionOutcome}
3923
+ * by routing the `functionPath` to the right read path — shared by
3924
+ * {@link seedSubscription} and {@link refreshSubscriptions} so both branch
3925
+ * identically:
3926
+ * - `__lunora_admin__:*` → {@link executeAdminSubscription} (raw SQLite read).
3927
+ * - {@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`).
3928
+ * - everything else → {@link executeSubscription} (the user query, under the socket's own by-value identity).
3929
+ */
3930
+ async resolveReactiveOutcome(functionPath, args, isAdmin, identity) {
3931
+ if (isAdmin) {
3932
+ return this.executeAdminSubscription(functionPath, args);
3933
+ }
3934
+ if (functionPath.startsWith(FLAGS_FUNCTION_PREFIX)) {
3935
+ const result = await this.runFlagSubscriptionRead(functionPath, args, identity);
3936
+ return result === null ? null : { result, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
3937
+ }
3938
+ return this.executeSubscription(functionPath, args, identity);
3939
+ }
3940
+ /**
3941
+ * SECURITY BOUNDARY for cross-socket reactive dedup. A read is
3942
+ * identity-INDEPENDENT only when its result cannot vary by the caller's
3943
+ * verified identity — i.e. the admin/reserved introspection reads, which
3944
+ * route to {@link executeAdminSubscription} and ignore the
3945
+ * {@link SubscriptionIdentity} entirely.
3946
+ *
3947
+ * Everything else is identity-DEPENDENT and must NEVER be shared across
3948
+ * sockets: a user query may be `rls()` / `ctx.auth`-scoped (different rows
3949
+ * per identity), and a flag read ({@link FLAGS_FUNCTION_PREFIX}) evaluates
3950
+ * the provider with the subscriber's identity (per-user targeting). Sharing
3951
+ * one socket's result with another would leak one identity's rows/flags to a
3952
+ * different identity, so this predicate gates {@link resolveReactiveOutcomeDeduped}
3953
+ * shut for them.
3954
+ */
3955
+ // 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
3956
+ isIdentityIndependent(functionPath) {
3957
+ return functionPath.startsWith(ADMIN_FUNCTION_PREFIX);
3958
+ }
3959
+ /**
3960
+ * Memoizing wrapper over {@link resolveReactiveOutcome}: flush-local sharing across sockets.
3961
+ * Within a single {@link refreshSubscriptions} pass, N sockets subscribed to
3962
+ * the SAME identity-independent `(functionPath, args)` re-run the query N
3963
+ * times today (see the Case-6 fan-out characterization). When the read is
3964
+ * identity-independent (admin/reserved — see {@link isIdentityIndependent})
3965
+ * its result is the same for every socket, so the first run is cached (by its
3966
+ * in-flight Promise, since the bounded worker pool runs sockets in parallel)
3967
+ * and shared with the rest — collapsing N runs to ONE.
3968
+ *
3969
+ * Identity-DEPENDENT reads are passed straight through, UNCACHED: each socket
3970
+ * must evaluate under its own by-value identity (RLS / `ctx.auth` / per-user
3971
+ * flags), so they never share a result. The `cache` is created fresh per
3972
+ * flush by the caller, so a result is never reused across passes (it would go
3973
+ * stale after the next write).
3974
+ */
3975
+ resolveReactiveOutcomeDeduped(functionPath, args, isAdmin, identity, cache) {
3976
+ if (!this.isIdentityIndependent(functionPath)) {
3977
+ return this.resolveReactiveOutcome(functionPath, args, isAdmin, identity);
3978
+ }
3979
+ const key = reactiveCacheKey(functionPath, args, null);
3980
+ const cached = cache.get(key);
3981
+ if (cached !== void 0) {
3982
+ return cached;
3983
+ }
3984
+ const pending = this.resolveReactiveOutcome(functionPath, args, isAdmin, identity);
3985
+ cache.set(key, pending);
3986
+ return pending;
3987
+ }
3463
3988
  /**
3464
3989
  * Constant-time bearer check against `env.LUNORA_ADMIN_TOKEN`. Returns
3465
3990
  * `false` (closed) when the token is unset so admin introspection is
@@ -3484,6 +4009,7 @@ class ShardDO {
3484
4009
  * 4. On normal completion send `{type:"complete"}`; on throw send
3485
4010
  * `{type:"error"}`. Either way drop the controller.
3486
4011
  */
4012
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- the stream lifecycle (ack → chunk pump → complete/error) plus the structured-vs-redacted error branch is the wire protocol and reads clearer inline than split across helpers sharing the controller + socket
3487
4013
  async handleStream(ws, id, functionPath, args) {
3488
4014
  const iterable = this.executeStream(functionPath, args);
3489
4015
  if (!iterable) {
@@ -3524,10 +4050,15 @@ class ShardDO {
3524
4050
  }
3525
4051
  } catch (error) {
3526
4052
  const { code } = error;
3527
- const message = error instanceof Error ? error.message : String(error);
4053
+ const isStructured = typeof code === "string";
4054
+ if (!isStructured) {
4055
+ console.error("[@lunora/do] unhandled stream error:", error);
4056
+ }
4057
+ const rawMessage = error instanceof Error ? error.message : String(error);
4058
+ const message = isStructured ? rawMessage : "internal error";
3528
4059
  ws.send(
3529
4060
  JSON.stringify({
3530
- error: { code: typeof code === "string" ? code : "INTERNAL_SERVER_ERROR", message },
4061
+ error: { code: isStructured ? code : "INTERNAL_SERVER_ERROR", message },
3531
4062
  id,
3532
4063
  type: "error"
3533
4064
  })
@@ -3558,11 +4089,49 @@ class ShardDO {
3558
4089
  if (!changed || changed.size === 0) {
3559
4090
  return;
3560
4091
  }
4092
+ if (this.pendingRefreshTables) {
4093
+ for (const table of changed) {
4094
+ this.pendingRefreshTables.add(table);
4095
+ }
4096
+ } else {
4097
+ this.pendingRefreshTables = changed;
4098
+ }
4099
+ if (this.refreshInFlight) {
4100
+ return;
4101
+ }
3561
4102
  if (typeof this.state.waitUntil === "function") {
3562
- this.state.waitUntil(this.refreshSubscriptions(changed));
4103
+ this.state.waitUntil(this.drainSubscriptionRefreshes());
3563
4104
  return;
3564
4105
  }
3565
- await this.refreshSubscriptions(changed);
4106
+ await this.drainSubscriptionRefreshes();
4107
+ }
4108
+ /**
4109
+ * Drain {@link ShardDO.pendingRefreshTables} one coalesced batch at a time
4110
+ * until it is empty, then release the {@link ShardDO.refreshInFlight} gate.
4111
+ * Tables merged by a `flushChangedTables` that lands mid-pass are picked up
4112
+ * by the next loop iteration, so every committed write is observed by a
4113
+ * refresh that runs after it — bursts simply share a pass. The post-write
4114
+ * high-watermark and live-socket set are re-read inside each
4115
+ * `refreshSubscriptions` / `pokeShapeSubscribers` call, so a later batch
4116
+ * always reflects the latest committed state.
4117
+ */
4118
+ async drainSubscriptionRefreshes() {
4119
+ if (this.refreshInFlight) {
4120
+ return;
4121
+ }
4122
+ this.refreshInFlight = true;
4123
+ try {
4124
+ let batch = this.pendingRefreshTables;
4125
+ while (batch && batch.size > 0) {
4126
+ this.pendingRefreshTables = void 0;
4127
+ const frameCursor = this.currentCdcCursor();
4128
+ const frameEpoch = this.currentCdcEpoch();
4129
+ await Promise.all([this.refreshSubscriptions(batch), this.pokeShapeSubscribers(batch, frameCursor, frameEpoch)]);
4130
+ batch = this.pendingRefreshTables;
4131
+ }
4132
+ } finally {
4133
+ this.refreshInFlight = false;
4134
+ }
3566
4135
  }
3567
4136
  /**
3568
4137
  * For every live subscription whose query reads one of `changed`, re-run
@@ -3623,6 +4192,7 @@ class ShardDO {
3623
4192
  const sockets = [...this.state.getWebSockets()];
3624
4193
  const frameCursor = this.currentCdcCursor();
3625
4194
  const frameEpoch = this.currentCdcEpoch();
4195
+ const reactiveRunCache = /* @__PURE__ */ new Map();
3626
4196
  const refreshOne = async (ws) => {
3627
4197
  if (this.isSocketExpired(ws)) {
3628
4198
  this.dropExpiredSocket(ws);
@@ -3640,14 +4210,12 @@ class ShardDO {
3640
4210
  continue;
3641
4211
  }
3642
4212
  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 })
4213
+ const outcome = await this.resolveReactiveOutcomeDeduped(
4214
+ functionPath,
4215
+ query.args ?? {},
4216
+ isAdmin,
4217
+ { identity: attachment.identity, userId: attachment.userId },
4218
+ reactiveRunCache
3651
4219
  );
3652
4220
  if (!outcome) {
3653
4221
  continue;
@@ -3659,18 +4227,7 @@ class ShardDO {
3659
4227
  }
3660
4228
  }
3661
4229
  };
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()));
4230
+ await runSocketPool(sockets, refreshOne);
3674
4231
  }
3675
4232
  /**
3676
4233
  * Seed a freshly-registered subscription with its first value. Runs the
@@ -3690,7 +4247,10 @@ class ShardDO {
3690
4247
  async seedSubscription(ws, subId, query, functionPath, isAdmin) {
3691
4248
  const seedArgs = query.args ?? {};
3692
4249
  const attachment = this.readAttachment(ws);
3693
- const outcome = isAdmin ? this.executeAdminSubscription(functionPath, seedArgs) : await this.executeSubscription(functionPath, seedArgs, { identity: attachment.identity, userId: attachment.userId });
4250
+ const outcome = await this.resolveReactiveOutcome(functionPath, seedArgs, isAdmin, {
4251
+ identity: attachment.identity,
4252
+ userId: attachment.userId
4253
+ });
3694
4254
  if (!outcome) {
3695
4255
  return;
3696
4256
  }
@@ -3707,6 +4267,536 @@ class ShardDO {
3707
4267
  }
3708
4268
  this.pushSubscriptionData(ws, subId, outcome, resume?.cursor ?? this.currentCdcCursor(), epoch);
3709
4269
  }
4270
+ /**
4271
+ * Drive the full `shape_subscribe` flow as one failure-aware unit: persist the
4272
+ * attachment, seed the shape, and ack ONLY once both succeed. A persist
4273
+ * rejection (`too_many`/`serialize_failed`) or a seed that can't resolve the
4274
+ * shape (unknown / RLS-denied / cross-shard-invalid) rolls the attachment back
4275
+ * and sends an `error` frame instead of acking — so a client is never left
4276
+ * acked but subscribed to a shape that will never deliver. Never throws (a
4277
+ * thrown `webSocketMessage` is fatal to the hibernating socket).
4278
+ */
4279
+ async handleShapeSubscribe(ws, subId, shape) {
4280
+ const status = this.shapeSubscribe(ws, subId, shape);
4281
+ if (status !== "ok") {
4282
+ const code = status === "too_many" ? "TOO_MANY_SUBSCRIPTIONS" : "SUBSCRIPTION_PERSIST_FAILED";
4283
+ const message = status === "too_many" ? `subscription cap of ${String(ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET)} reached on this socket` : "failed to persist shape subscription attachment";
4284
+ this.sendShapeSubscribeError(ws, subId, code, message);
4285
+ return;
4286
+ }
4287
+ const seed = await this.seedShapeSubscription(ws, subId, shape);
4288
+ if (seed !== "ok") {
4289
+ this.shapeUnsubscribe(ws, subId);
4290
+ this.sendShapeSubscribeError(ws, subId, seed.code, seed.message);
4291
+ return;
4292
+ }
4293
+ try {
4294
+ ws.send(JSON.stringify({ id: subId, type: "ack" }));
4295
+ } catch {
4296
+ }
4297
+ }
4298
+ /** Send a structured `error` frame for a failed `shape_subscribe`, swallowing a send on an already-closed socket. */
4299
+ // eslint-disable-next-line class-methods-use-this -- groups with the shape-subscribe flow; uses only its args + the socket
4300
+ sendShapeSubscribeError(ws, subId, code, message) {
4301
+ try {
4302
+ ws.send(JSON.stringify({ code, error: { code, message }, id: subId, type: "error" }));
4303
+ } catch {
4304
+ }
4305
+ }
4306
+ /**
4307
+ * Seed a freshly-registered shape subscription. Resolves the shape under the
4308
+ * socket's verified identity, then ships either:
4309
+ *
4310
+ * - a **catch-up** poke (the membership diff in `(sinceCheckpoint, cursor]`)
4311
+ * when the client supplied a still-current checkpoint within the CDC retention
4312
+ * window and on this epoch — the cheap reconnect path; or
4313
+ * - a **full** insert-poke of the shape's entire current membership — a
4314
+ * first-time subscribe, or a reconnect that fell outside retention / forked
4315
+ * epoch.
4316
+ *
4317
+ * Either way the per-socket shape memo advances to the flush watermark so
4318
+ * later `pokeShapeSubscribers` passes diff from the right point.
4319
+ *
4320
+ * Returns `"ok"` once the shape resolved and its seed poke was attempted, or a
4321
+ * `{ code, message }` failure when the shape can't be resolved — an unknown /
4322
+ * RLS-denied shape (a base class with no registry resolves nothing), or a
4323
+ * `resolveShape` that threw (e.g. a cross-shard-join guard). The caller rolls
4324
+ * back the persisted attachment and errors instead of acking, so a client is
4325
+ * never left subscribed to a shape that will never deliver.
4326
+ */
4327
+ async seedShapeSubscription(ws, subId, shape) {
4328
+ const attachment = this.readAttachment(ws);
4329
+ const identity = { identity: attachment.identity, userId: attachment.userId };
4330
+ let resolved;
4331
+ try {
4332
+ resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
4333
+ } catch (error) {
4334
+ this.recordShapeError(`shape:seed:${subId}`, error);
4335
+ const code = typeof error.code === "string" ? error.code : "SHAPE_RESOLVE_FAILED";
4336
+ return { code, message: error instanceof Error ? error.message : "shape resolution failed" };
4337
+ }
4338
+ if (!resolved) {
4339
+ return { code: "SHAPE_NOT_FOUND", message: `shape "${shape.name}" not found or not permitted` };
4340
+ }
4341
+ try {
4342
+ if (resolved.global) {
4343
+ return await this.seedGlobalShape(ws, subId, resolved, identity, attachment.connectionId ?? "");
4344
+ }
4345
+ return await this.seedOpLogShape(ws, subId, shape, resolved);
4346
+ } catch (error) {
4347
+ this.recordShapeError(`shape:seed:${subId}`, error);
4348
+ const code = typeof error.code === "string" ? error.code : "SHAPE_SEED_FAILED";
4349
+ return { code, message: error instanceof Error ? error.message : "shape seed failed" };
4350
+ }
4351
+ }
4352
+ /**
4353
+ * Seed a non-`.global()` (op-log-backed) shape: either a catch-up diff over
4354
+ * `(sinceSeq, cursor]` when the client supplied a still-current checkpoint on
4355
+ * this epoch within the CDC retention window, or a full membership insert-poke
4356
+ * otherwise. The memo advances to `cursor` only once the poke is delivered, so
4357
+ * a failed send re-diffs from the prior point rather than skipping rows. May
4358
+ * throw (a stub `sql` handle, a membership probe failure); the caller converts
4359
+ * it to a structured `shape_subscribe` error.
4360
+ */
4361
+ async seedOpLogShape(ws, subId, shape, resolved) {
4362
+ const sql = this.sql;
4363
+ const cursor = this.currentCdcCursor() ?? 0;
4364
+ const epoch = this.currentCdcEpoch();
4365
+ const floor = this.cdcEnabled() ? minCdcSeq(sql) : void 0;
4366
+ const canResume = this.cdcEnabled() && shape.sinceSeq !== void 0 && shape.sinceEpoch === epoch && shape.sinceSeq <= cursor && (shape.sinceSeq === cursor || floor !== void 0 && floor <= shape.sinceSeq + 1);
4367
+ const rowsPatch = canResume && shape.sinceSeq !== void 0 ? this.buildShapeDiff(sql, resolved, shape.sinceSeq, cursor) : this.buildShapeSeed(sql, resolved);
4368
+ await awaitWsDrain(ws);
4369
+ if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], cursor, epoch, canResume ? shape.sinceSeq : void 0)) {
4370
+ this.recordShapeMemo(ws, subId, cursor);
4371
+ }
4372
+ return "ok";
4373
+ }
4374
+ /**
4375
+ * Fan the membership diff of every shape affected by this flush to its
4376
+ * subscribers — the partial-replication parallel to
4377
+ * {@link ShardDO.refreshSubscriptions}, called alongside it from
4378
+ * {@link ShardDO.flushChangedTables}. For each socket (bounded fan-out, same
4379
+ * concurrency + `awaitWsDrain` backpressure as the subscription path) it
4380
+ * resolves each shape under the socket's identity, diffs only the shapes
4381
+ * whose table changed in `(memoCursor, frameCursor]`, and emits one poke
4382
+ * carrying a part per changed shape. No-op when no socket holds a shape.
4383
+ */
4384
+ async pokeShapeSubscribers(changed, frameCursor, frameEpoch) {
4385
+ const sockets = [...this.state.getWebSockets()];
4386
+ const checkpoint = frameCursor ?? this.currentCdcCursor() ?? 0;
4387
+ const sql = this.sql;
4388
+ const opRangeCache = /* @__PURE__ */ new Map();
4389
+ const pokeOne = async (ws) => {
4390
+ if (this.isSocketExpired(ws)) {
4391
+ this.dropExpiredSocket(ws);
4392
+ return;
4393
+ }
4394
+ const attachment = this.readAttachment(ws);
4395
+ const { shapes } = attachment;
4396
+ if (!shapes) {
4397
+ return;
4398
+ }
4399
+ try {
4400
+ const identity = { identity: attachment.identity, userId: attachment.userId };
4401
+ const { emptyAdvanced, partAdvanced, parts } = this.collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql, opRangeCache);
4402
+ for (const subId of emptyAdvanced) {
4403
+ this.recordShapeMemo(ws, subId, checkpoint);
4404
+ }
4405
+ if (parts.length > 0) {
4406
+ await awaitWsDrain(ws);
4407
+ if (this.sendPoke(ws, parts, checkpoint, frameEpoch, void 0)) {
4408
+ for (const subId of partAdvanced) {
4409
+ this.recordShapeMemo(ws, subId, checkpoint);
4410
+ }
4411
+ }
4412
+ }
4413
+ } catch {
4414
+ }
4415
+ };
4416
+ await runSocketPool(sockets, pokeOne);
4417
+ }
4418
+ /**
4419
+ * Diff every op-log-backed shape a socket holds against this flush, splitting
4420
+ * the results into the poke parts to send and the per-shape memo advances. A
4421
+ * `.global()` shape (driven by the alarm poll loop, not this flush) and a shape
4422
+ * whose table didn't change are skipped; a shape whose resolve/diff throws is
4423
+ * logged and skipped with its memo unadvanced so a later flush retries. Empty
4424
+ * diffs advance unconditionally; part-bearing shapes advance only once the
4425
+ * caller confirms the poke was delivered.
4426
+ */
4427
+ collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql, opRangeCache) {
4428
+ const parts = [];
4429
+ const emptyAdvanced = [];
4430
+ const partAdvanced = [];
4431
+ for (const [subId, shape] of Object.entries(shapes)) {
4432
+ try {
4433
+ const resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
4434
+ if (!resolved || resolved.global || !changed.has(resolved.table)) {
4435
+ continue;
4436
+ }
4437
+ const memoCursor = this.shapeMemos.get(ws)?.get(subId)?.cursor ?? 0;
4438
+ const rowsPatch = this.buildShapeDiff(sql, resolved, memoCursor, checkpoint, opRangeCache);
4439
+ if (rowsPatch.length > 0) {
4440
+ parts.push({ rowsPatch, shapeId: subId });
4441
+ partAdvanced.push(subId);
4442
+ } else {
4443
+ emptyAdvanced.push(subId);
4444
+ }
4445
+ } catch (error) {
4446
+ this.recordShapeError(`shape:poke:${subId}`, error);
4447
+ }
4448
+ }
4449
+ return { emptyAdvanced, partAdvanced, parts };
4450
+ }
4451
+ /**
4452
+ * Drain the op-log range `(sinceSeq, upTo]` for `table` into the latest op per
4453
+ * row id (collapsing multiple ops on the same row to the newest). Within one
4454
+ * flush, every shape over the SAME `(table, sinceSeq, upTo)` reads the
4455
+ * identical changelog slice, so the drained map is memoized in the
4456
+ * caller-supplied `cache` (created fresh per flush) — N shapes on a table
4457
+ * share ONE changelog drain instead of re-scanning it per shape. The
4458
+ * per-shape membership probe still runs per shape (its predicate is
4459
+ * identity/args-specific), so only the shared op read is collapsed.
4460
+ */
4461
+ readShapeOpRange(sql, table, sinceSeq, upTo, cache) {
4462
+ const key = `${table}\0${String(sinceSeq)}\0${String(upTo)}`;
4463
+ const cached = cache?.get(key);
4464
+ if (cached !== void 0) {
4465
+ return cached;
4466
+ }
4467
+ const latest = /* @__PURE__ */ new Map();
4468
+ const tables = /* @__PURE__ */ new Set([table]);
4469
+ let from = sinceSeq;
4470
+ for (; ; ) {
4471
+ const { changes, cursor } = this.readShapeCdcPage(sql, from, tables);
4472
+ for (const change of changes) {
4473
+ latest.set(change.id, change);
4474
+ }
4475
+ if (changes.length === 0 || cursor === from || cursor >= upTo) {
4476
+ break;
4477
+ }
4478
+ from = cursor;
4479
+ }
4480
+ cache?.set(key, latest);
4481
+ return latest;
4482
+ }
4483
+ /**
4484
+ * Read one page of the `__cdc_log` for a shape diff (table-scoped). A thin
4485
+ * protected seam over {@link readCdcChanges}: it isolates the single
4486
+ * changelog read that {@link readShapeOpRange} memoizes per flush, and gives
4487
+ * tests a point to count the reads the op-range cache collapses.
4488
+ */
4489
+ // 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
4490
+ readShapeCdcPage(sql, sinceSeq, tables) {
4491
+ return readCdcChanges(sql, { sinceSeq, tables });
4492
+ }
4493
+ /**
4494
+ * Build the row-ops for a shape over the op range `(sinceSeq, upTo]`. Reads
4495
+ * the changelog (drained across pages via {@link readShapeOpRange}, shared
4496
+ * across same-range shapes in a flush), collapses to the latest op per row,
4497
+ * then runs ONE membership probe ({@link selectShapeMemberIds}) over the
4498
+ * changed ids: a row still in the set → upsert with its post-image doc
4499
+ * (projected to the shape's columns); a row that left the set, or any delete,
4500
+ * → `delete(key)` (a delete carries no post-image, so membership is
4501
+ * unknowable from the op alone — the client no-ops an unknown key).
4502
+ */
4503
+ buildShapeDiff(sql, resolved, sinceSeq, upTo, opRangeCache) {
4504
+ const latest = this.readShapeOpRange(sql, resolved.table, sinceSeq, upTo, opRangeCache);
4505
+ if (latest.size === 0) {
4506
+ return [];
4507
+ }
4508
+ const ids = [...latest.keys()];
4509
+ const members = selectShapeMemberIds(sql, resolved.table, resolved.effectiveWhere, ids);
4510
+ const ops = [];
4511
+ for (const [id, change] of latest) {
4512
+ if (members.has(id)) {
4513
+ if (change.doc !== void 0) {
4514
+ ops.push({ key: id, op: change.op, table: resolved.table, value: projectColumns(change.doc, resolved.columns) });
4515
+ }
4516
+ continue;
4517
+ }
4518
+ if (change.op !== "insert") {
4519
+ ops.push({ key: id, op: "delete", table: resolved.table });
4520
+ }
4521
+ }
4522
+ return ops;
4523
+ }
4524
+ /** Build the full insert-poke of a shape's current membership — the first-seed/full-reseed rowset. */
4525
+ // eslint-disable-next-line class-methods-use-this -- instance method for symmetry with `buildShapeDiff`; reads via the passed `sql` handle
4526
+ buildShapeSeed(sql, resolved) {
4527
+ return selectShapeRows(sql, resolved.table, resolved.effectiveWhere).map((row) => {
4528
+ return {
4529
+ key: row.id,
4530
+ op: "insert",
4531
+ table: resolved.table,
4532
+ value: projectColumns(row.doc, resolved.columns)
4533
+ };
4534
+ });
4535
+ }
4536
+ /**
4537
+ * Seed a `.global()`-table shape: read its full membership from D1, ship it
4538
+ * as one insert-poke, record the membership snapshot the alarm poll loop will
4539
+ * diff against, and arm the poll alarm. A global shape has no op-log cursor,
4540
+ * so the poke is stamped at this DO's current cursor (informational only) and
4541
+ * carries no resume base — a reconnect always re-seeds full.
4542
+ */
4543
+ async seedGlobalShape(ws, subId, resolved, identity, connectionId) {
4544
+ const rows = await this.readGlobalShapeRows(resolved, identity);
4545
+ if (!this.withinGlobalShapeBound(rows.length, `shape:seed:${subId}`, resolved.table)) {
4546
+ return {
4547
+ code: "SHAPE_GLOBAL_TOO_LARGE",
4548
+ 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`
4549
+ };
4550
+ }
4551
+ const { next: snapshot, rowsPatch } = diffGlobalMembership(rows, /* @__PURE__ */ new Map(), { columns: resolved.columns, table: resolved.table });
4552
+ await awaitWsDrain(ws);
4553
+ if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], this.currentCdcCursor() ?? 0, this.currentCdcEpoch(), void 0)) {
4554
+ this.recordGlobalSnapshot(ws, subId, snapshot);
4555
+ this.saveGlobalSnapshot(connectionId, subId, snapshot);
4556
+ }
4557
+ await this.scheduleGlobalPoll();
4558
+ return "ok";
4559
+ }
4560
+ /**
4561
+ * Re-read a global shape's membership from D1 and poke only the diff against
4562
+ * the socket's last snapshot: a new key → `insert`, a changed projected value
4563
+ * → `update`, a vanished key → `delete`. The snapshot advances to the fresh
4564
+ * membership even when the diff is empty, so the next tick compares from here.
4565
+ * No frame is sent when nothing changed (the common steady-state tick).
4566
+ */
4567
+ async refreshGlobalShape(ws, subId, resolved, identity, connectionId) {
4568
+ const rows = await this.readGlobalShapeRows(resolved, identity);
4569
+ if (!this.withinGlobalShapeBound(rows.length, `shape:poll:${subId}`, resolved.table)) {
4570
+ return;
4571
+ }
4572
+ const previous = this.readGlobalSnapshot(ws, subId, connectionId);
4573
+ const { next, rowsPatch } = diffGlobalMembership(rows, previous, { columns: resolved.columns, table: resolved.table });
4574
+ if (rowsPatch.length === 0) {
4575
+ this.recordGlobalSnapshot(ws, subId, next);
4576
+ return;
4577
+ }
4578
+ await awaitWsDrain(ws);
4579
+ if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], this.currentCdcCursor() ?? 0, this.currentCdcEpoch(), void 0)) {
4580
+ this.recordGlobalSnapshot(ws, subId, next);
4581
+ this.saveGlobalSnapshot(connectionId, subId, next);
4582
+ }
4583
+ }
4584
+ /**
4585
+ * Read a socket's global-shape baseline, preferring the hot in-memory cache
4586
+ * and falling back to the durable `__global_shape_snapshot` table on a miss (a
4587
+ * cold socket after a hibernation eviction). The loaded baseline repopulates
4588
+ * the cache so subsequent ticks in this wake hit memory. An empty
4589
+ * `connectionId` (a socket that never went through the lifecycle-aware upgrade,
4590
+ * e.g. a unit harness) skips the durable read and behaves as in-memory-only.
4591
+ */
4592
+ readGlobalSnapshot(ws, subId, connectionId) {
4593
+ const cached = this.globalShapeSnapshots.get(ws)?.get(subId);
4594
+ if (cached) {
4595
+ return cached;
4596
+ }
4597
+ const stored = this.loadGlobalSnapshot(connectionId, subId);
4598
+ this.recordGlobalSnapshot(ws, subId, stored);
4599
+ return stored;
4600
+ }
4601
+ /** Record a socket's latest global-shape membership snapshot in the in-memory cache (creating the per-socket map lazily). */
4602
+ recordGlobalSnapshot(ws, subId, snapshot) {
4603
+ let snapshots = this.globalShapeSnapshots.get(ws);
4604
+ if (!snapshots) {
4605
+ snapshots = /* @__PURE__ */ new Map();
4606
+ this.globalShapeSnapshots.set(ws, snapshots);
4607
+ }
4608
+ snapshots.set(subId, snapshot);
4609
+ }
4610
+ /**
4611
+ * Load a durable global-shape baseline from SQLite, or an empty map when none
4612
+ * is stored / the durable path is unavailable. A stub `sql` handle (unit
4613
+ * harness) or a missing table degrades to in-memory-only behavior rather than
4614
+ * failing the poll tick.
4615
+ */
4616
+ loadGlobalSnapshot(connectionId, subId) {
4617
+ if (connectionId === "") {
4618
+ return /* @__PURE__ */ new Map();
4619
+ }
4620
+ try {
4621
+ return readGlobalShapeSnapshot(this.sql, connectionId, subId);
4622
+ } catch {
4623
+ return /* @__PURE__ */ new Map();
4624
+ }
4625
+ }
4626
+ /**
4627
+ * Persist a socket's global-shape baseline to SQLite so the poll-loop diff
4628
+ * survives hibernation. A no-op for a connection-id-less socket or a stub
4629
+ * `sql` handle (the in-memory cache then carries the baseline for the DO's
4630
+ * lifetime, matching the pre-durable behavior).
4631
+ */
4632
+ saveGlobalSnapshot(connectionId, subId, snapshot) {
4633
+ if (connectionId === "") {
4634
+ return;
4635
+ }
4636
+ try {
4637
+ writeGlobalShapeSnapshot(this.sql, connectionId, subId, snapshot);
4638
+ } catch {
4639
+ }
4640
+ }
4641
+ /**
4642
+ * Arm the poll alarm for `.global()` shapes if one isn't already pending.
4643
+ * Idempotent — every global-shape seed calls it, but only the first arms the
4644
+ * alarm. Degrades to a no-op when the runtime exposes no `setAlarm` (the unit
4645
+ * harness): a global shape is then seed-only, which the poll-loop tests assert
4646
+ * by driving {@link ShardDO.alarm} directly.
4647
+ */
4648
+ async scheduleGlobalPoll() {
4649
+ if (this.globalPollScheduled) {
4650
+ return;
4651
+ }
4652
+ const { setAlarm } = this.state.storage;
4653
+ if (!setAlarm) {
4654
+ return;
4655
+ }
4656
+ this.globalPollScheduled = true;
4657
+ try {
4658
+ await setAlarm.call(this.state.storage, Date.now() + ShardDO.GLOBAL_SHAPE_POLL_INTERVAL_MS);
4659
+ } catch {
4660
+ this.globalPollScheduled = false;
4661
+ }
4662
+ }
4663
+ /**
4664
+ * Record a contained shape-tier error (poll / poke / seed) into the DO's log
4665
+ * ring without aborting the rest of the pass. The shape pipeline is a
4666
+ * best-effort fan-out: one socket's read or one shape's resolve failing must
4667
+ * never take down the others — so callers swallow the throw and surface it
4668
+ * here for diagnosis. `context` is a synthetic `shape:phase:subId` path.
4669
+ */
4670
+ recordShapeError(context, error) {
4671
+ this.logs.push({
4672
+ functionPath: context,
4673
+ level: "error",
4674
+ message: error instanceof Error ? error.message : String(error),
4675
+ timestamp: Date.now()
4676
+ });
4677
+ }
4678
+ /**
4679
+ * Guard a global shape's materialized membership against {@link
4680
+ * ShardDO.GLOBAL_SHAPE_MAX_ROWS}. Returns `true` when the row count is within
4681
+ * the cap; otherwise records a diagnosable error and returns `false` so the
4682
+ * caller fails the shape closed (no snapshot retained, no poke sent) rather
4683
+ * than risking a DO eviction on an unbounded global table. The transient read
4684
+ * buffer is bounded by the same gate — an over-cap membership is dropped, not
4685
+ * snapshotted per socket.
4686
+ */
4687
+ withinGlobalShapeBound(rowCount, context, table) {
4688
+ if (rowCount <= ShardDO.GLOBAL_SHAPE_MAX_ROWS) {
4689
+ return true;
4690
+ }
4691
+ this.recordShapeError(
4692
+ context,
4693
+ new Error(
4694
+ `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`
4695
+ )
4696
+ );
4697
+ return false;
4698
+ }
4699
+ /**
4700
+ * Refresh every `.global()`-table shape held across all live sockets, one
4701
+ * diff-poke per (socket, shape). Returns the number of global shapes still
4702
+ * subscribed so {@link ShardDO.alarm} knows whether to re-arm. Expired sockets
4703
+ * are dropped in passing (mirrors {@link ShardDO.pokeShapeSubscribers}).
4704
+ */
4705
+ async pollGlobalShapes() {
4706
+ const sockets = [...this.state.getWebSockets()];
4707
+ let remaining = 0;
4708
+ for (const ws of sockets) {
4709
+ if (this.isSocketExpired(ws)) {
4710
+ this.dropExpiredSocket(ws);
4711
+ continue;
4712
+ }
4713
+ const attachment = this.readAttachment(ws);
4714
+ const { shapes } = attachment;
4715
+ if (!shapes) {
4716
+ continue;
4717
+ }
4718
+ const identity = { identity: attachment.identity, userId: attachment.userId };
4719
+ remaining += await this.pollSocketGlobalShapes(ws, shapes, identity, attachment.connectionId ?? "");
4720
+ }
4721
+ return remaining;
4722
+ }
4723
+ /**
4724
+ * Refresh one socket's `.global()`-table shapes, containing per-shape
4725
+ * failures so a single throw never aborts the poll tick (and with it the
4726
+ * re-arm). Returns the count of global shapes still subscribed on this socket
4727
+ * — a failed `resolveShape`/read keeps its shape counted so the alarm keeps
4728
+ * polling and retries next tick.
4729
+ */
4730
+ async pollSocketGlobalShapes(ws, shapes, identity, connectionId) {
4731
+ let count = 0;
4732
+ for (const [subId, shape] of Object.entries(shapes)) {
4733
+ let resolved;
4734
+ try {
4735
+ resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
4736
+ } catch (error) {
4737
+ count += 1;
4738
+ this.recordShapeError(`shape:poll:${subId}`, error);
4739
+ continue;
4740
+ }
4741
+ if (!resolved?.global) {
4742
+ continue;
4743
+ }
4744
+ count += 1;
4745
+ try {
4746
+ await this.refreshGlobalShape(ws, subId, resolved, identity, connectionId);
4747
+ } catch (error) {
4748
+ this.recordShapeError(`shape:poll:${subId}`, error);
4749
+ }
4750
+ }
4751
+ return count;
4752
+ }
4753
+ /**
4754
+ * Send one poke (`pokeStart` → `pokePart` per shape → `pokeEnd`) to a socket.
4755
+ * All parts apply atomically at `pokeEnd`. Returns `true` when every frame was
4756
+ * handed to the socket, `false` when a send threw mid-poke (the socket closed)
4757
+ * — callers must NOT advance their shape baselines on a `false` so the client
4758
+ * re-receives the rows on its next flush/reconnect instead of losing them.
4759
+ */
4760
+ sendPoke(ws, parts, checkpoint, epoch, baseCheckpoint) {
4761
+ this.pokeSequence += 1;
4762
+ const pokeId = `poke-${String(this.pokeSequence)}`;
4763
+ const frames = buildPokeFrames(parts, { baseCheckpoint, checkpoint, epoch, lastMutationId: this.socketClientWatermark(ws), pokeId });
4764
+ try {
4765
+ for (const frame of frames) {
4766
+ ws.send(frame);
4767
+ }
4768
+ return true;
4769
+ } catch {
4770
+ return false;
4771
+ }
4772
+ }
4773
+ /**
4774
+ * The recipient client's `__client_watermark` for stamping a poke's
4775
+ * `lastMutationId`, or `undefined` when the socket announced no `clientId`
4776
+ * (a client that doesn't use custom mutators — nothing to drop an overlay
4777
+ * for). Read off the attachment so it survives hibernation.
4778
+ */
4779
+ socketClientWatermark(ws) {
4780
+ const attachment = this.readAttachment(ws);
4781
+ const { clientId } = attachment;
4782
+ if (clientId === void 0) {
4783
+ return void 0;
4784
+ }
4785
+ try {
4786
+ return readClientWatermark(this.sql, attachment.userId ?? "", clientId);
4787
+ } catch {
4788
+ return void 0;
4789
+ }
4790
+ }
4791
+ /** Record a shape's poke baseline cursor on a socket (creating the per-socket map lazily). */
4792
+ recordShapeMemo(ws, subId, cursor) {
4793
+ let memos = this.shapeMemos.get(ws);
4794
+ if (!memos) {
4795
+ memos = /* @__PURE__ */ new Map();
4796
+ this.shapeMemos.set(ws, memos);
4797
+ }
4798
+ memos.set(subId, { cursor });
4799
+ }
3710
4800
  /**
3711
4801
  * Record `outcome` as this socket's diff baseline for `subId` without
3712
4802
  * sending a frame. Used by the resume fast-path, where the client keeps its
@@ -3750,25 +4840,16 @@ class ShardDO {
3750
4840
  const existing = memos.get(subId);
3751
4841
  if (existing?.lastJson === json) {
3752
4842
  existing.tables = outcome.tables;
4843
+ const settledWatermark = this.socketClientWatermark(ws);
4844
+ if (settledWatermark !== void 0) {
4845
+ trySendFrame(ws, `{"type":"settled","id":${JSON.stringify(subId)},"lastMutationId":${String(settledWatermark)}${cursorSuffix}}`);
4846
+ }
3753
4847
  return;
3754
4848
  }
3755
4849
  const deltaFrames = [];
3756
4850
  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
- }
4851
+ const delivered = deltas === void 0 ? trySendFrame(ws, `{"type":"data","id":${JSON.stringify(subId)},"data":${json}${cursorSuffix}}`) : sendDeltaFrames(ws, subId, deltaFrames, cursorSuffix);
4852
+ memos.set(subId, { lastJson: delivered ? json : existing?.lastJson ?? UNDELIVERED_BASELINE, tables: outcome.tables });
3772
4853
  }
3773
4854
  /**
3774
4855
  * Gate the upgrade request against two complementary controls:
@@ -3990,10 +5071,7 @@ class ShardDO {
3990
5071
  if (ws === sender || this.readAttachment(ws).whispers?.includes(topic) !== true) {
3991
5072
  continue;
3992
5073
  }
3993
- try {
3994
- ws.send(frame);
3995
- } catch {
3996
- }
5074
+ trySendFrame(ws, frame);
3997
5075
  }
3998
5076
  }
3999
5077
  // eslint-disable-next-line class-methods-use-this -- cohesive DO instance method grouped with the hibernation/attachment helpers; reads only the socket