@lunora/do 1.0.0-alpha.7 → 1.0.0-alpha.9

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 (20) hide show
  1. package/dist/index.d.mts +627 -21
  2. package/dist/index.d.ts +627 -21
  3. package/dist/index.mjs +11 -10
  4. package/dist/packem_shared/{ADMIN_FUNCTIONS-CHcC8fKV.mjs → ADMIN_FUNCTIONS-D_UiYJFk.mjs} +3 -1
  5. package/dist/packem_shared/{CDC_LOG_TABLE-Ctdmxmrv.mjs → CDC_LOG_TABLE-DSycmnDf.mjs} +5 -1
  6. package/dist/packem_shared/{DEFAULT_MAX_RELATION_KEYS-Dou2PWdO.mjs → DEFAULT_MAX_RELATION_KEYS-CHEvKjZt.mjs} +50 -1
  7. package/dist/packem_shared/{NotUniqueError-h_thNFSZ.mjs → NotUniqueError-Cwv7Pe7J.mjs} +9 -7
  8. package/dist/packem_shared/{rank-CrkEIpF4.mjs → RANK_TIEBREAK-CXhdcA1o.mjs} +2 -13
  9. package/dist/packem_shared/{ROOT_DO_SIZE_WARN_BYTES-2DxWrdla.mjs → ROOT_DO_SIZE_WARN_BYTES-DKwBF3Jp.mjs} +1067 -29
  10. package/dist/packem_shared/{ReactiveCache-ByVzgH3d.mjs → ReactiveCache-1hDydFyv.mjs} +1 -28
  11. package/dist/packem_shared/{backfillAggregateIndexes-BbVPvciS.mjs → backfillAggregateIndexes-BZsOqDXP.mjs} +2 -1
  12. package/dist/packem_shared/ctx-db-idempotency-BdcNpvY4.mjs +108 -0
  13. package/dist/packem_shared/ctx-db-shapes-DVoeZpo-.mjs +53 -0
  14. package/dist/packem_shared/{runShardMigrations-PabobOjF.mjs → runShardMigrations-nIwoQeOK.mjs} +5 -3
  15. package/dist/packem_shared/serialize-sql-BlRUoiQe.mjs +14 -0
  16. package/dist/packem_shared/{serveRelationFanout-oxaM6_WL.mjs → serveRelationFanout-C6lDaesn.mjs} +1 -1
  17. package/dist/packem_shared/stableStringify-CyHKJXre.mjs +30 -0
  18. package/package.json +1 -1
  19. package/dist/packem_shared/RANK_TIEBREAK-C6blLR5K.mjs +0 -1
  20. package/dist/packem_shared/ctx-db-idempotency-DkC9rP91.mjs +0 -35
@@ -4,18 +4,19 @@ import { recordAuthEvent, readAuthMetrics } from './AUTH_METRICS_BUCKETS_TABLE-C
4
4
  import { DATA_MIGRATION_STATE_TABLE, readMigrationStatus } from './DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs';
5
5
  import { SCAN_DEP, createDependencyTracker, tableFromDepKey } from './SCAN_DEP-DLJF8dsj.mjs';
6
6
  import { readFunctionMetricsTotals, readFunctionMetricIndexHits, recordFunctionMetric, mergeScanAttribution, readFunctionMetrics, readFunctionMetricBuckets } from './FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs';
7
- import { ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, selectMatchingIds, ADMIN_FUNCTIONS, findStorageReferences, listTables, summarizeSubscriptions, readTablePage, facetColumn, MAX_PAGE_SIZE } from './ADMIN_FUNCTIONS-CHcC8fKV.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
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
14
  import { runReadonlySql } from './MAX_SQL_ROWS-dDcFE1YZ.mjs';
15
15
  import { trySendFrame, subscriptionListDeltas, sendDeltaFrames } from './subscriptionListDeltas-ce84gpwL.mjs';
16
16
  import { ConflictError } from './ConflictError-C0STs6bU.mjs';
17
- import { CDC_LOG_TABLE, readCdcChanges, readCdcCursor, readCdcEpoch, minCdcSeq, bumpCdcEpoch } from './CDC_LOG_TABLE-Ctdmxmrv.mjs';
18
- 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';
19
20
 
20
21
  const AUDIT_LOG_TABLE = "__lunora_audit__";
21
22
  const AUDIT_LOG_RETENTION = 1e3;
@@ -350,6 +351,58 @@ const readRequestLog = (sql, options = {}) => {
350
351
  });
351
352
  };
352
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
+
353
406
  const DANGLING_SCAN_CAP = 5e3;
354
407
  const DANGLING_RESULT_CAP = 500;
355
408
  const DOC_COLUMN = "__doc__";
@@ -829,6 +882,13 @@ const parseIdentityHeader = (raw) => {
829
882
  }
830
883
  return void 0;
831
884
  };
885
+ const parseClientSeqHeader = (raw) => {
886
+ if (!raw) {
887
+ return void 0;
888
+ }
889
+ const seq = Number(raw);
890
+ return Number.isInteger(seq) && seq > 0 ? seq : void 0;
891
+ };
832
892
  const tablesFromDeps = (deps) => {
833
893
  const tables = /* @__PURE__ */ new Set();
834
894
  for (const dep of deps) {
@@ -913,6 +973,28 @@ class ShardDO {
913
973
  * failure stays unlikely.
914
974
  */
915
975
  static MAX_SUBSCRIPTIONS_PER_SOCKET = 32;
976
+ /**
977
+ * Poll interval (ms) for `.global()`-table shapes. A global table lives in
978
+ * D1 with no per-DO op-log, so its shapes can't be poke-live; the DO re-reads
979
+ * each subscribed global shape's membership from D1 on an alarm every
980
+ * `GLOBAL_SHAPE_POLL_INTERVAL_MS` and pokes only the diff. This is the
981
+ * latency floor for a global-shape update — deliberately coarse (seconds, not
982
+ * the sub-millisecond poke-live path) since the D1 read fans out per tick.
983
+ */
984
+ static GLOBAL_SHAPE_POLL_INTERVAL_MS = 2e3;
985
+ /**
986
+ * Upper bound on a `.global()`-shape's materialized membership. Each global
987
+ * shape keeps its ENTIRE current membership as a per-socket snapshot
988
+ * (`Map<rowKey, hash>`) so the poll loop can diff it; that snapshot — and the
989
+ * read buffer feeding it — scale with the membership size, multiplied by every
990
+ * subscribed socket. An unbounded membership (a global table with no narrowing
991
+ * shape predicate or RLS read scope) would grow them without limit and evict
992
+ * the DO. A shape whose membership exceeds this cap is failed closed (left
993
+ * empty, logged) rather than retained — the developer must narrow it. Sized
994
+ * well above any reasonable per-identity replicated set so legitimate shapes
995
+ * never trip it.
996
+ */
997
+ static GLOBAL_SHAPE_MAX_ROWS = 5e4;
916
998
  /**
917
999
  * Per-socket whisper-topic cap. Topic membership rides the same hibernation
918
1000
  * attachment as `subs`, so bound it for the same reason — a runaway
@@ -1014,6 +1096,39 @@ class ShardDO {
1014
1096
  * `finally` block.
1015
1097
  */
1016
1098
  currentRequestMutationId;
1099
+ /**
1100
+ * Stable per-device client id for the in-flight custom-mutator push,
1101
+ * forwarded via the `x-lunora-client-id` header. Backs the
1102
+ * `__client_watermark` table: the dispatch path classifies the paired
1103
+ * `currentRequestClientSeq` against the stored high-watermark (already
1104
+ * processed / next / out-of-order gap). Absent on legacy mutations and
1105
+ * queries (those keep the `__idempotency` path). Cleared in `fetch`'s
1106
+ * `finally`.
1107
+ */
1108
+ currentRequestClientId;
1109
+ /**
1110
+ * Monotonic per-client mutation sequence for the in-flight custom-mutator
1111
+ * push, forwarded via the `x-lunora-client-seq` header (numeric). Paired
1112
+ * with `currentRequestClientId` to drive the watermark classification.
1113
+ * `undefined` when absent or non-numeric.
1114
+ */
1115
+ currentRequestClientSeq;
1116
+ /**
1117
+ * The in-flight push's custom-mutator classification, stashed by `fetch`
1118
+ * before `handleRpc` so the in-transaction bookkeeping ({@link
1119
+ * ShardDO.commitMutationBookkeeping}) can advance the `__client_watermark` for
1120
+ * a `"next"` push inside the same commit as the writes. `undefined` for an
1121
+ * ordinary mutation / non-mutator push. Cleared per request.
1122
+ */
1123
+ currentMutatorClass;
1124
+ /**
1125
+ * Set once a mutation's replay bookkeeping (idempotency row + watermark
1126
+ * advance) has committed INSIDE the handler transaction, so the post-dispatch
1127
+ * path skips the now-redundant best-effort writes. Cleared per request; stays
1128
+ * `false` for actions/queries (no transaction wrapper) so their dispatch-level
1129
+ * idempotency persist still runs.
1130
+ */
1131
+ mutationBookkeepingCommitted = false;
1017
1132
  /**
1018
1133
  * Wall-clock millis of the last `__idempotency` GC sweep on this warm
1019
1134
  * instance. The dedup write throttles `trimIdempotent` to at most once an
@@ -1064,6 +1179,39 @@ class ShardDO {
1064
1179
  * memo simply forces one re-run and (at most) one redundant push.
1065
1180
  */
1066
1181
  subMemos = /* @__PURE__ */ new WeakMap();
1182
+ /**
1183
+ * Per-socket poke baseline for shape subscriptions: maps each shape's
1184
+ * subscription id to the `__cdc_log` cursor it has been poked through.
1185
+ * `pokeShapeSubscribers` reads each op page since this cursor and advances
1186
+ * it to the flush watermark. In-memory only (like {@link ShardDO.subMemos});
1187
+ * a cold memo on a reconnected/hibernated socket re-seeds from the client's
1188
+ * `sinceCheckpoint`.
1189
+ */
1190
+ shapeMemos = /* @__PURE__ */ new WeakMap();
1191
+ /**
1192
+ * Per-socket, per-**global**-shape membership snapshot: maps each global
1193
+ * shape's subscription id to a `key → projected-value JSON` map of the rows
1194
+ * last poked to that socket. A `.global()` (D1) table has no op-log to diff,
1195
+ * so {@link ShardDO.refreshGlobalShape} re-reads the full membership on each
1196
+ * alarm tick and diffs it against this snapshot to compute the poke. Parallel
1197
+ * to {@link ShardDO.shapeMemos} (the cursor baseline for poke-live shapes).
1198
+ *
1199
+ * This is a hot in-memory **cache** over the durable `__global_shape_snapshot`
1200
+ * table (keyed by the socket's `connectionId` + subId): a hibernation eviction
1201
+ * clears the WeakMap, so on the next alarm wake {@link ShardDO.readGlobalSnapshot}
1202
+ * misses and re-loads the baseline from SQLite — without it, the diff would run
1203
+ * against an empty baseline and a row deleted from D1 while the DO slept would
1204
+ * never be poked as a `delete`, lingering on the client as a phantom row.
1205
+ */
1206
+ globalShapeSnapshots = /* @__PURE__ */ new WeakMap();
1207
+ /**
1208
+ * Whether a global-shape poll alarm is currently armed. Guards
1209
+ * {@link ShardDO.scheduleGlobalPoll} from re-arming on every seed; reset in
1210
+ * {@link ShardDO.alarm} before the poll so a still-subscribed shape re-arms.
1211
+ */
1212
+ globalPollScheduled = false;
1213
+ /** Monotonic per-DO poke id source; correlates a poke's `pokeStart`/`pokePart`/`pokeEnd` frames. */
1214
+ pokeSequence = 0;
1067
1215
  /** Per-socket whisper-rate token bucket (see {@link ShardDO.WHISPER_RATE_BURST}). In-memory; resets on hibernation. */
1068
1216
  whisperBuckets = /* @__PURE__ */ new WeakMap();
1069
1217
  /**
@@ -1195,6 +1343,10 @@ class ShardDO {
1195
1343
  this.currentResponseBookmark = void 0;
1196
1344
  this.currentRequestUserId = request.headers.get("x-lunora-userid") ?? void 0;
1197
1345
  this.currentRequestMutationId = request.headers.get("x-lunora-mutation-id") ?? void 0;
1346
+ this.currentRequestClientId = request.headers.get("x-lunora-client-id") ?? void 0;
1347
+ this.currentRequestClientSeq = parseClientSeqHeader(request.headers.get("x-lunora-client-seq"));
1348
+ this.currentMutatorClass = void 0;
1349
+ this.mutationBookkeepingCommitted = false;
1198
1350
  this.currentRequestIdentity = parseIdentityHeader(request.headers.get("x-lunora-identity"));
1199
1351
  this.currentRequestIp = request.headers.get("x-lunora-client-ip") ?? void 0;
1200
1352
  this.currentRequestSystem = request.headers.get("x-lunora-system") === "1";
@@ -1210,20 +1362,25 @@ class ShardDO {
1210
1362
  const value = await this.runRelationFanoutRead(payload.functionPath, payload.args ?? {});
1211
1363
  return jsonResponse(value, 200, this.currentResponseBookmark);
1212
1364
  }
1365
+ const mutatorClass = this.isCustomMutator(payload.functionPath) ? this.classifyClientMutation() : void 0;
1366
+ this.currentMutatorClass = mutatorClass;
1367
+ const watermarkShortCircuit = this.rejectNonNextMutation(payload.functionPath, mutatorClass, dispatchStartedAt);
1368
+ if (watermarkShortCircuit !== void 0) {
1369
+ return watermarkShortCircuit;
1370
+ }
1213
1371
  const cached = this.readIdempotentResult(this.currentRequestMutationId);
1214
1372
  if (cached !== void 0) {
1215
- this.recordFunctionCall(payload.functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
1216
- return jsonResponse({ result: cached.value }, 200, this.currentResponseBookmark);
1373
+ return this.respondFromIdempotencyCache(payload.functionPath, dispatchStartedAt, mutatorClass, cached.value);
1217
1374
  }
1218
1375
  const result = await this.handleRpc(payload.functionPath, payload.args ?? {});
1219
- this.persistIdempotentResult(result);
1376
+ this.recordPostDispatchBookkeeping(result, mutatorClass);
1220
1377
  const durationMs = Date.now() - dispatchStartedAt;
1221
1378
  this.recordFunctionCall(payload.functionPath, durationMs, void 0, this.currentScannedTables, this.currentIndexHits);
1222
1379
  this.flushStmtSamples();
1223
1380
  const tablesWritten = [...this.pendingChangedTables ?? []];
1224
1381
  this.recordRequestLog(payload.functionPath, payload.args ?? {}, durationMs, "ok", tablesWritten);
1225
1382
  this.maybeWarnRootSize();
1226
- const response = jsonResponse({ result }, 200, this.currentResponseBookmark);
1383
+ const response = this.buildDispatchResponse(mutatorClass, result);
1227
1384
  await this.flushChangedTables();
1228
1385
  return response;
1229
1386
  } catch (error) {
@@ -1249,6 +1406,10 @@ class ShardDO {
1249
1406
  this.currentResponseBookmark = void 0;
1250
1407
  this.currentRequestUserId = void 0;
1251
1408
  this.currentRequestMutationId = void 0;
1409
+ this.currentRequestClientId = void 0;
1410
+ this.currentRequestClientSeq = void 0;
1411
+ this.currentMutatorClass = void 0;
1412
+ this.mutationBookkeepingCommitted = false;
1252
1413
  this.currentRequestIdentity = void 0;
1253
1414
  this.currentRequestIp = void 0;
1254
1415
  this.currentRequestSystem = false;
@@ -1286,6 +1447,9 @@ class ShardDO {
1286
1447
  if (envelope.context !== void 0) {
1287
1448
  attachment.context = envelope.context;
1288
1449
  }
1450
+ if (envelope.clientId !== void 0) {
1451
+ attachment.clientId = envelope.clientId;
1452
+ }
1289
1453
  attachment.connected = true;
1290
1454
  try {
1291
1455
  ws.serializeAttachment?.(attachment);
@@ -1317,6 +1481,20 @@ class ShardDO {
1317
1481
  }
1318
1482
  return;
1319
1483
  }
1484
+ if (envelope.type === "shape_subscribe" && envelope.shape) {
1485
+ await this.handleShapeSubscribe(ws, envelope.id, {
1486
+ args: envelope.shape.args,
1487
+ name: envelope.shape.name,
1488
+ sinceEpoch: envelope.sinceEpoch,
1489
+ sinceSeq: envelope.sinceCheckpoint
1490
+ });
1491
+ return;
1492
+ }
1493
+ if (envelope.type === "shape_unsubscribe") {
1494
+ this.shapeUnsubscribe(ws, envelope.id);
1495
+ ws.send(JSON.stringify({ id: envelope.id, type: "ack" }));
1496
+ return;
1497
+ }
1320
1498
  if (envelope.type === "stream" && envelope.query?.functionPath) {
1321
1499
  if (envelope.query.functionPath.startsWith(ADMIN_FUNCTION_PREFIX)) {
1322
1500
  ws.send(JSON.stringify({ id: envelope.id, message: "streams must be public", type: "error" }));
@@ -1367,12 +1545,41 @@ class ShardDO {
1367
1545
  this.streamCancellers.delete(ws);
1368
1546
  }
1369
1547
  this.subMemos.delete(ws);
1548
+ this.shapeMemos.delete(ws);
1549
+ this.globalShapeSnapshots.delete(ws);
1550
+ if (attachment.connectionId !== void 0) {
1551
+ try {
1552
+ deleteGlobalShapeSnapshotsForConnection(this.sql, attachment.connectionId);
1553
+ } catch {
1554
+ }
1555
+ }
1370
1556
  ws.serializeAttachment?.(void 0);
1371
1557
  }
1372
1558
  /** Hibernation API: invoked on socket error. */
1373
1559
  // 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
1374
1560
  webSocketError(_ws, _error) {
1375
1561
  }
1562
+ /**
1563
+ * Durable Object alarm handler — the heartbeat for `.global()`-table shapes.
1564
+ * The runtime wakes this when the poll alarm armed by `scheduleGlobalPoll`
1565
+ * fires; it refreshes every subscribed global shape (diff-poke from the global
1566
+ * backend) and re-arms while any remain. With no global subscribers left, the
1567
+ * alarm is not re-armed and the DO goes idle. A base-only / global-free DO
1568
+ * never arms it, so this stays dormant there.
1569
+ */
1570
+ async alarm() {
1571
+ this.globalPollScheduled = false;
1572
+ let remaining;
1573
+ try {
1574
+ remaining = await this.pollGlobalShapes();
1575
+ } catch (error) {
1576
+ this.recordShapeError("shape:poll", error);
1577
+ remaining = 1;
1578
+ }
1579
+ if (remaining > 0) {
1580
+ await this.scheduleGlobalPoll();
1581
+ }
1582
+ }
1376
1583
  /**
1377
1584
  * The registered function paths to dispatch when a socket connects/disconnects.
1378
1585
  * Base default is empty; the codegen subclass overrides it to return the
@@ -1776,7 +1983,33 @@ class ShardDO {
1776
1983
  */
1777
1984
  // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the statically-discovered feature flags
1778
1985
  studioFeatures() {
1779
- return { mail: false, payments: false, queues: false, scheduler: false, storage: false, vectors: false, workflows: false };
1986
+ return { flags: false, mail: false, payments: false, queues: false, scheduler: false, storage: false, vectors: false, workflows: false };
1987
+ }
1988
+ /**
1989
+ * Evaluate every statically-discovered feature flag under `context` for the
1990
+ * studio's read-only Flags page (`__lunora_admin__:listFlags`). The flag keys
1991
+ * + value types are discovered by `@lunora/codegen` from the app's
1992
+ * `ctx.flags.<type>("key", …)` reads and evaluated through the configured
1993
+ * `@lunora/flags` provider — work only the codegen subclass can do, so it
1994
+ * overrides this. The base class wires no provider and reports
1995
+ * `configured: false` with zero flags (an un-generated `ShardDO` has none).
1996
+ */
1997
+ // 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
1998
+ evaluateFlags(_context) {
1999
+ return Promise.resolve({ configured: false, flags: [] });
2000
+ }
2001
+ /**
2002
+ * Serve one reserved {@link FLAGS_FUNCTION_PREFIX} live flag read for the
2003
+ * React client's `useFlag`/`useFlags`. `functionPath` carries the flag key +
2004
+ * type and `args` the per-subscriber targeting context; the codegen subclass
2005
+ * overrides this to evaluate the flag through the app's `@lunora/flags`
2006
+ * provider under `identity` and return the resolved value. The base class
2007
+ * wires no provider, so it returns `null` — `resolveReactiveOutcome` reads
2008
+ * `null` as "nothing to deliver" and the subscriber keeps its default.
2009
+ */
2010
+ // 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
2011
+ runFlagSubscriptionRead(_functionPath, _arguments, _identity) {
2012
+ return Promise.resolve(null);
1780
2013
  }
1781
2014
  /**
1782
2015
  * The Cloudflare Queues declared by this app, surfaced via
@@ -2053,13 +2286,15 @@ class ShardDO {
2053
2286
  * unless the request carried an `x-lunora-mutation-id` header (queries and
2054
2287
  * legacy clients leave `currentRequestMutationId` undefined).
2055
2288
  *
2056
- * Called on the live dispatch path right after the handler's writes have
2057
- * auto-committed, through the same `this.sql` handle, so the dedup row is
2058
- * durable iff those writes are. (The DO has no ambient BEGIN/COMMIT around a
2059
- * mutation `handleRpc` invokes the user handler directly so this can't
2060
- * piggyback on a surrounding transaction; it commits as its own statement
2061
- * immediately after.) `INSERT OR IGNORE` keeps a concurrent double-dispatch
2062
- * of the same id idempotent. Also runs the throttled dedup-table GC.
2289
+ * For a mutation this runs INSIDE the handler's transaction (via
2290
+ * {@link ShardDO.commitMutationBookkeeping}, which `handleRpc` invokes before
2291
+ * the transaction commits), so the dedup row is durable iff the writes are —
2292
+ * closing the crash window where the writes commit but the replay guard does
2293
+ * not. Actions/queries aren't transaction-wrapped, so they call this on the
2294
+ * live dispatch path right after the handler resolves, through the same
2295
+ * `this.sql` handle. `INSERT OR IGNORE` keeps a concurrent double-dispatch (or
2296
+ * the now-skipped post-dispatch call) of the same id idempotent. Also runs the
2297
+ * throttled dedup-table GC.
2063
2298
  */
2064
2299
  persistIdempotentResult(result) {
2065
2300
  if (this.currentRequestMutationId === void 0) {
@@ -2075,6 +2310,175 @@ class ShardDO {
2075
2310
  } catch {
2076
2311
  }
2077
2312
  }
2313
+ /**
2314
+ * Whether `functionPath` names a registered custom mutator (a `defineMutator`
2315
+ * declaration) rather than an ordinary `mutation`. The base class knows of no
2316
+ * mutators, so the default is `false`; the codegen-generated subclass
2317
+ * overrides this to consult its mutator registry. When `true` (and the push
2318
+ * carries a `clientId`/`clientSeq`), the dispatch path applies the
2319
+ * `__client_watermark` ordering semantics instead of the legacy idempotency
2320
+ * dedup.
2321
+ */
2322
+ // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this to consult its mutator registry
2323
+ isCustomMutator(_functionPath) {
2324
+ return false;
2325
+ }
2326
+ /**
2327
+ * Classify an in-flight custom-mutator push against the shard's stored
2328
+ * high-watermark for `currentRequestClientId`. The watermark is the highest
2329
+ * per-client sequence the DO has applied, so the push is exactly one of:
2330
+ *
2331
+ * - `"already"` — `seq <= watermark`: a replay of a confirmed (or in-flight,
2332
+ * now-resent) mutation. The handler must NOT re-run; the dispatch path returns
2333
+ * a benign ack so the client drops the pending overlay.
2334
+ * - `"next"` — `seq == watermark + 1`: the next mutation in order. Run the
2335
+ * authoritative `server` impl and advance the watermark in the same commit.
2336
+ * - `"gap"` — `seq > watermark + 1`: an out-of-order arrival (an earlier push
2337
+ * was lost). Halt: the client must resend from `watermark + 1`.
2338
+ *
2339
+ * Returns `undefined` when the push is not a watermarked custom mutator
2340
+ * (missing client id/seq, or a stub `sql` handle without the table) so the
2341
+ * caller falls through to the legacy idempotency path.
2342
+ */
2343
+ classifyClientMutation() {
2344
+ const clientId = this.currentRequestClientId;
2345
+ const seq = this.currentRequestClientSeq;
2346
+ if (clientId === void 0 || seq === void 0) {
2347
+ return void 0;
2348
+ }
2349
+ const identity = this.currentRequestUserId ?? "";
2350
+ let watermark;
2351
+ try {
2352
+ watermark = readClientWatermark(this.sql, identity, clientId);
2353
+ } catch {
2354
+ try {
2355
+ migrateClientWatermark(this.sql);
2356
+ watermark = readClientWatermark(this.sql, identity, clientId);
2357
+ } catch {
2358
+ return void 0;
2359
+ }
2360
+ }
2361
+ const expected = watermark + 1;
2362
+ if (seq <= watermark) {
2363
+ return { expected, kind: "already" };
2364
+ }
2365
+ return seq === expected ? { expected, kind: "next" } : { expected, kind: "gap" };
2366
+ }
2367
+ /**
2368
+ * Terminal response for a watermarked custom-mutator push that is NOT the
2369
+ * next-in-order mutation — an idempotent replay ack (`"already"`) or an
2370
+ * out-of-order halt (`"gap"`). Returns `undefined` for an ordinary mutation
2371
+ * or a `"next"` push so `fetch` proceeds to the authoritative handler. Records
2372
+ * the function call on the short-circuit paths so metrics stay attributed.
2373
+ */
2374
+ rejectNonNextMutation(functionPath, mutatorClass, dispatchStartedAt) {
2375
+ if (mutatorClass === void 0 || mutatorClass.kind === "next") {
2376
+ return void 0;
2377
+ }
2378
+ this.recordFunctionCall(functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
2379
+ if (mutatorClass.kind === "already") {
2380
+ return jsonResponse({ lastMutationId: mutatorClass.expected - 1, result: null }, 200, this.currentResponseBookmark);
2381
+ }
2382
+ return jsonResponse(
2383
+ {
2384
+ error: {
2385
+ code: "OUT_OF_ORDER",
2386
+ expectedMutationId: mutatorClass.expected,
2387
+ message: `out-of-order mutation; expected sequence ${String(mutatorClass.expected)}`
2388
+ }
2389
+ },
2390
+ 409,
2391
+ this.currentResponseBookmark
2392
+ );
2393
+ }
2394
+ /**
2395
+ * Respond to a dispatch that hit the `(identity, mutationId)` idempotency
2396
+ * cache. Records the (zero-work) function call, then: for a `"next"` custom
2397
+ * mutator whose handler already committed but whose watermark advance was
2398
+ * lost to a crash in between, re-advance and echo `lastMutationId` exactly as
2399
+ * the post-commit path does (otherwise the cached branch returns a bare
2400
+ * result with a stale watermark and the client reports every later seq as a
2401
+ * gap forever); for everything else, return the bare cached `{ result }`.
2402
+ */
2403
+ respondFromIdempotencyCache(functionPath, dispatchStartedAt, mutatorClass, cachedValue) {
2404
+ this.recordFunctionCall(functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
2405
+ if (mutatorClass?.kind === "next") {
2406
+ this.advanceClientMutationWatermark();
2407
+ return this.buildDispatchResponse(mutatorClass, cachedValue);
2408
+ }
2409
+ return jsonResponse({ result: cachedValue }, 200, this.currentResponseBookmark);
2410
+ }
2411
+ /**
2412
+ * Build the success response for a dispatched RPC. A `"next"` custom-mutator
2413
+ * push echoes the applied `lastMutationId` so the client drops the pending
2414
+ * optimistic overlay as soon as the ack lands; ordinary calls return the bare
2415
+ * `{ result }` envelope unchanged.
2416
+ */
2417
+ buildDispatchResponse(mutatorClass, result) {
2418
+ if (mutatorClass?.kind === "next") {
2419
+ return jsonResponse({ lastMutationId: this.currentRequestClientSeq, result }, 200, this.currentResponseBookmark);
2420
+ }
2421
+ return jsonResponse({ result }, 200, this.currentResponseBookmark);
2422
+ }
2423
+ /**
2424
+ * Commit a mutation's replay bookkeeping — the `(identity, mutationId)`
2425
+ * idempotency dedup row and, for a `"next"` custom-mutator push, the
2426
+ * `__client_watermark` advance — INSIDE the handler's transaction. Called by
2427
+ * the generated `handleRpc` mutation branch after the user handler resolves
2428
+ * but before the transaction commits, so the writes, the dedup row, and the
2429
+ * watermark land in one atomic commit: a crash can't leave the writes durable
2430
+ * without the replay guard (which a re-dispatch would otherwise re-run) nor
2431
+ * without the watermark. Sets {@link ShardDO.mutationBookkeepingCommitted} so
2432
+ * `fetch` skips the redundant post-dispatch persist.
2433
+ */
2434
+ commitMutationBookkeeping(result) {
2435
+ this.persistIdempotentResult(result);
2436
+ if (this.currentMutatorClass?.kind === "next") {
2437
+ this.advanceClientMutationWatermark({ strict: true });
2438
+ }
2439
+ this.mutationBookkeepingCommitted = true;
2440
+ }
2441
+ /**
2442
+ * Best-effort replay bookkeeping for the live dispatch path, run after
2443
+ * `handleRpc` returns. A generated mutation already committed it atomically
2444
+ * inside its transaction (via {@link ShardDO.commitMutationBookkeeping}, which
2445
+ * sets the flag), so this skips. Actions/queries aren't transaction-wrapped,
2446
+ * so they record their dedup row here (a no-op without an `x-lunora-mutation-id`),
2447
+ * and a `"next"` push advances its watermark (the gap self-heals on replay).
2448
+ */
2449
+ recordPostDispatchBookkeeping(result, mutatorClass) {
2450
+ if (this.mutationBookkeepingCommitted) {
2451
+ return;
2452
+ }
2453
+ this.persistIdempotentResult(result);
2454
+ if (mutatorClass?.kind === "next") {
2455
+ this.advanceClientMutationWatermark();
2456
+ }
2457
+ }
2458
+ /**
2459
+ * Advance the stored high-watermark for the in-flight custom mutator to
2460
+ * `currentRequestClientSeq` through the same `this.sql` handle. On the
2461
+ * transactional path ({@link ShardDO.commitMutationBookkeeping}, `strict`) it
2462
+ * runs inside the handler's commit, so the watermark is durable iff the writes
2463
+ * are; a failure rethrows to roll the mutation back. On the best-effort
2464
+ * cache-hit recovery path (`strict` omitted) a missing table is swallowed —
2465
+ * the replay re-runs and re-advances (the read side treats a missing row as
2466
+ * watermark 0), so the gap self-heals.
2467
+ */
2468
+ advanceClientMutationWatermark(options) {
2469
+ const clientId = this.currentRequestClientId;
2470
+ const seq = this.currentRequestClientSeq;
2471
+ if (clientId === void 0 || seq === void 0) {
2472
+ return;
2473
+ }
2474
+ try {
2475
+ advanceClientWatermark(this.sql, this.currentRequestUserId ?? "", clientId, seq);
2476
+ } catch (error) {
2477
+ if (options?.strict) {
2478
+ throw error;
2479
+ }
2480
+ }
2481
+ }
2078
2482
  /**
2079
2483
  * Replay a batch of CDC changes into this shard (point-in-time recovery).
2080
2484
  * Schema-aware — it builds a `createShardCtxDb` writer — so the base class
@@ -2124,6 +2528,56 @@ class ShardDO {
2124
2528
  }
2125
2529
  this.subMemos.get(ws)?.delete(subId);
2126
2530
  }
2531
+ /**
2532
+ * Register a live shape subscription on a socket — the partial-replication
2533
+ * parallel to {@link ShardDO.subscribe}. Stores the descriptor in the
2534
+ * attachment's `shapes` registry (created lazily) so it survives
2535
+ * hibernation, sharing the per-socket cap with `subs`. Returns a status the
2536
+ * caller surfaces as a structured error frame; never throws (a thrown
2537
+ * `webSocketMessage` is a fatal-channel error under the hibernation API).
2538
+ */
2539
+ shapeSubscribe(ws, subId, shape) {
2540
+ const attachment = this.readAttachment(ws);
2541
+ const shapes = attachment.shapes ?? {};
2542
+ if (Object.keys(attachment.subs).length + Object.keys(shapes).length >= ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET) {
2543
+ return "too_many";
2544
+ }
2545
+ shapes[subId] = shape;
2546
+ attachment.shapes = shapes;
2547
+ try {
2548
+ ws.serializeAttachment?.(attachment);
2549
+ } catch {
2550
+ delete attachment.shapes[subId];
2551
+ return "serialize_failed";
2552
+ }
2553
+ return "ok";
2554
+ }
2555
+ /** Remove a shape subscription and its poke baseline. Mirrors {@link ShardDO.unsubscribe}'s rollback-on-serialize-failure contract. */
2556
+ shapeUnsubscribe(ws, subId) {
2557
+ const attachment = this.readAttachment(ws);
2558
+ const { shapes } = attachment;
2559
+ if (!shapes) {
2560
+ return;
2561
+ }
2562
+ const captured = shapes[subId];
2563
+ delete shapes[subId];
2564
+ try {
2565
+ ws.serializeAttachment?.(attachment);
2566
+ } catch {
2567
+ if (captured !== void 0) {
2568
+ shapes[subId] = captured;
2569
+ }
2570
+ return;
2571
+ }
2572
+ this.shapeMemos.get(ws)?.delete(subId);
2573
+ this.globalShapeSnapshots.get(ws)?.delete(subId);
2574
+ if (attachment.connectionId !== void 0) {
2575
+ try {
2576
+ deleteGlobalShapeSnapshot(this.sql, attachment.connectionId, subId);
2577
+ } catch {
2578
+ }
2579
+ }
2580
+ }
2127
2581
  /**
2128
2582
  * Decide whether a single subscription is interested in a mutation
2129
2583
  * delta. The default implementation checks the table name, then runs a
@@ -2195,6 +2649,46 @@ class ShardDO {
2195
2649
  executeSubscription(_functionPath, _args, _identity) {
2196
2650
  return Promise.resolve(null);
2197
2651
  }
2652
+ /**
2653
+ * Resolve a named shape to its concrete query plan for `identity`. The base
2654
+ * class has no shape registry, so it returns `undefined` — partial
2655
+ * replication is disabled and a `shape_subscribe` is rejected. The
2656
+ * codegen-generated subclass overrides this to look the shape up in the
2657
+ * project's `defineShape` registry, evaluate its `where(ctx, args)` under the
2658
+ * subscriber's verified identity, and AND-compose it with the table's RLS
2659
+ * read base-where into {@link ResolvedShape.effectiveWhere}.
2660
+ *
2661
+ * `identity` is the socket's OWN verified identity (the same unforgeable
2662
+ * value `refreshSubscriptions` threads), passed by value so this never reads
2663
+ * the mutable per-request identity fields. Returning `undefined` is the
2664
+ * fail-closed signal — an unknown shape, or an RLS-required table with no
2665
+ * policy resolving for this identity, yields no subscription rather than
2666
+ * leaking rows.
2667
+ */
2668
+ // 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
2669
+ resolveShape(_name, _args, _identity) {
2670
+ return void 0;
2671
+ }
2672
+ /**
2673
+ * Read the FULL current membership of a `.global()`-table shape from its D1
2674
+ * (or Hyperdrive) backend — the seed/poll source for the latency-tiered
2675
+ * global shape path. A `.global()` table lives in another store with no
2676
+ * per-DO op-log, so this is the only way to learn its rows from inside the
2677
+ * shard DO; {@link ShardDO.seedGlobalShape} calls it once on subscribe and
2678
+ * {@link ShardDO.refreshGlobalShape} on every alarm tick, diffing the result
2679
+ * against the per-socket snapshot to compute the poke.
2680
+ *
2681
+ * The base class has no global backend, so it returns `[]` (a base-only DO,
2682
+ * or a project with no global tables, never resolves a global shape). The
2683
+ * codegen subclass overrides it to drain `globalDb.findMany(table, { where:
2684
+ * effectiveWhere })` under the socket's verified `identity` — the same
2685
+ * unforgeable value `resolveShape` composed the RLS predicate with, so the
2686
+ * D1 read is identity-scoped exactly like the poke-live path.
2687
+ */
2688
+ // 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
2689
+ readGlobalShapeRows(_resolved, _identity) {
2690
+ return Promise.resolve([]);
2691
+ }
2198
2692
  /**
2199
2693
  * Look up a streaming-query function and return a thunk that produces the
2200
2694
  * `AsyncIterable&lt;unknown>` when handed an {@link AbortSignal}. The codegen
@@ -2728,6 +3222,9 @@ class ShardDO {
2728
3222
  if (functionPath === ADMIN_FUNCTIONS.getWorkflowInstanceStatus) {
2729
3223
  return this.handleGetWorkflowInstanceStatus(args);
2730
3224
  }
3225
+ if (functionPath === ADMIN_FUNCTIONS.listFlags) {
3226
+ return this.handleListFlags(args);
3227
+ }
2731
3228
  return this.handlePitrAdminOp(functionPath, args);
2732
3229
  }
2733
3230
  /**
@@ -2848,6 +3345,21 @@ class ShardDO {
2848
3345
  return jsonResponse({ result }, 200);
2849
3346
  }
2850
3347
  /* eslint-enable no-secrets/no-secrets */
3348
+ /**
3349
+ * Serve `__lunora_admin__:listFlags` — the studio's read-only Flags page.
3350
+ * Evaluates every statically-discovered feature flag under an optional
3351
+ * `args.context` targeting context (the studio's editable context editor)
3352
+ * via the {@link evaluateFlags} hook, which the codegen subclass overrides
3353
+ * with live OpenFeature evaluation. Read-only: a flag lookup mutates no shard
3354
+ * state, so nothing is flushed or audited. Admin-gated by `handleAdminRpc`'s
3355
+ * caller.
3356
+ */
3357
+ async handleListFlags(args) {
3358
+ const rawContext = args.context;
3359
+ const context = typeof rawContext === "object" && rawContext !== null && !Array.isArray(rawContext) ? rawContext : void 0;
3360
+ const result = await this.evaluateFlags(context);
3361
+ return jsonResponse({ result }, 200);
3362
+ }
2851
3363
  /**
2852
3364
  * Run `run()` with the per-request identity pinned to (`userId`, `identity`),
2853
3365
  * then restore the prior values in a `finally` (even if `run()` throws), so the
@@ -3389,6 +3901,25 @@ class ShardDO {
3389
3901
  const read = this.readAdminOp(functionPath, args);
3390
3902
  return read ? { result: read.result, tables: read.tables } : null;
3391
3903
  }
3904
+ /**
3905
+ * Resolve one subscription (seed or refresh) to its {@link SubscriptionOutcome}
3906
+ * by routing the `functionPath` to the right read path — shared by
3907
+ * {@link seedSubscription} and {@link refreshSubscriptions} so both branch
3908
+ * identically:
3909
+ * - `__lunora_admin__:*` → {@link executeAdminSubscription} (raw SQLite read).
3910
+ * - {@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`).
3911
+ * - everything else → {@link executeSubscription} (the user query, under the socket's own by-value identity).
3912
+ */
3913
+ async resolveReactiveOutcome(functionPath, args, isAdmin, identity) {
3914
+ if (isAdmin) {
3915
+ return this.executeAdminSubscription(functionPath, args);
3916
+ }
3917
+ if (functionPath.startsWith(FLAGS_FUNCTION_PREFIX)) {
3918
+ const result = await this.runFlagSubscriptionRead(functionPath, args, identity);
3919
+ return result === null ? null : { result, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
3920
+ }
3921
+ return this.executeSubscription(functionPath, args, identity);
3922
+ }
3392
3923
  /**
3393
3924
  * Constant-time bearer check against `env.LUNORA_ADMIN_TOKEN`. Returns
3394
3925
  * `false` (closed) when the token is unset so admin introspection is
@@ -3510,8 +4041,8 @@ class ShardDO {
3510
4041
  * by the next loop iteration, so every committed write is observed by a
3511
4042
  * refresh that runs after it — bursts simply share a pass. The post-write
3512
4043
  * high-watermark and live-socket set are re-read inside each
3513
- * `refreshSubscriptions` call, so a later batch always reflects the latest
3514
- * committed state.
4044
+ * `refreshSubscriptions` / `pokeShapeSubscribers` call, so a later batch
4045
+ * always reflects the latest committed state.
3515
4046
  */
3516
4047
  async drainSubscriptionRefreshes() {
3517
4048
  if (this.refreshInFlight) {
@@ -3522,7 +4053,9 @@ class ShardDO {
3522
4053
  let batch = this.pendingRefreshTables;
3523
4054
  while (batch && batch.size > 0) {
3524
4055
  this.pendingRefreshTables = void 0;
3525
- await this.refreshSubscriptions(batch);
4056
+ const frameCursor = this.currentCdcCursor();
4057
+ const frameEpoch = this.currentCdcEpoch();
4058
+ await Promise.all([this.refreshSubscriptions(batch), this.pokeShapeSubscribers(batch, frameCursor, frameEpoch)]);
3526
4059
  batch = this.pendingRefreshTables;
3527
4060
  }
3528
4061
  } finally {
@@ -3605,15 +4138,10 @@ class ShardDO {
3605
4138
  continue;
3606
4139
  }
3607
4140
  try {
3608
- const outcome = isAdmin ? this.executeAdminSubscription(functionPath, query.args ?? {}) : (
3609
- // Re-run under the socket's OWN verified identity (stamped on the
3610
- // attachment at upgrade, unforgeable by the client) — passed BY
3611
- // VALUE, so this deferred re-run never reads or mutates the shared
3612
- // per-request identity fields. Without it an `rls()` / `ctx.auth`
3613
- // scoped live query would evaluate anonymous and return zero rows.
3614
- // eslint-disable-next-line no-await-in-loop -- subscriptions on a socket re-run sequentially; each shares the single SQLite handle
3615
- await this.executeSubscription(functionPath, query.args ?? {}, { identity: attachment.identity, userId: attachment.userId })
3616
- );
4141
+ const outcome = await this.resolveReactiveOutcome(functionPath, query.args ?? {}, isAdmin, {
4142
+ identity: attachment.identity,
4143
+ userId: attachment.userId
4144
+ });
3617
4145
  if (!outcome) {
3618
4146
  continue;
3619
4147
  }
@@ -3655,7 +4183,10 @@ class ShardDO {
3655
4183
  async seedSubscription(ws, subId, query, functionPath, isAdmin) {
3656
4184
  const seedArgs = query.args ?? {};
3657
4185
  const attachment = this.readAttachment(ws);
3658
- const outcome = isAdmin ? this.executeAdminSubscription(functionPath, seedArgs) : await this.executeSubscription(functionPath, seedArgs, { identity: attachment.identity, userId: attachment.userId });
4186
+ const outcome = await this.resolveReactiveOutcome(functionPath, seedArgs, isAdmin, {
4187
+ identity: attachment.identity,
4188
+ userId: attachment.userId
4189
+ });
3659
4190
  if (!outcome) {
3660
4191
  return;
3661
4192
  }
@@ -3672,6 +4203,513 @@ class ShardDO {
3672
4203
  }
3673
4204
  this.pushSubscriptionData(ws, subId, outcome, resume?.cursor ?? this.currentCdcCursor(), epoch);
3674
4205
  }
4206
+ /**
4207
+ * Drive the full `shape_subscribe` flow as one failure-aware unit: persist the
4208
+ * attachment, seed the shape, and ack ONLY once both succeed. A persist
4209
+ * rejection (`too_many`/`serialize_failed`) or a seed that can't resolve the
4210
+ * shape (unknown / RLS-denied / cross-shard-invalid) rolls the attachment back
4211
+ * and sends an `error` frame instead of acking — so a client is never left
4212
+ * acked but subscribed to a shape that will never deliver. Never throws (a
4213
+ * thrown `webSocketMessage` is fatal to the hibernating socket).
4214
+ */
4215
+ async handleShapeSubscribe(ws, subId, shape) {
4216
+ const status = this.shapeSubscribe(ws, subId, shape);
4217
+ if (status !== "ok") {
4218
+ const code = status === "too_many" ? "TOO_MANY_SUBSCRIPTIONS" : "SUBSCRIPTION_PERSIST_FAILED";
4219
+ const message = status === "too_many" ? `subscription cap of ${String(ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET)} reached on this socket` : "failed to persist shape subscription attachment";
4220
+ this.sendShapeSubscribeError(ws, subId, code, message);
4221
+ return;
4222
+ }
4223
+ const seed = await this.seedShapeSubscription(ws, subId, shape);
4224
+ if (seed !== "ok") {
4225
+ this.shapeUnsubscribe(ws, subId);
4226
+ this.sendShapeSubscribeError(ws, subId, seed.code, seed.message);
4227
+ return;
4228
+ }
4229
+ try {
4230
+ ws.send(JSON.stringify({ id: subId, type: "ack" }));
4231
+ } catch {
4232
+ }
4233
+ }
4234
+ /** Send a structured `error` frame for a failed `shape_subscribe`, swallowing a send on an already-closed socket. */
4235
+ // eslint-disable-next-line class-methods-use-this -- groups with the shape-subscribe flow; uses only its args + the socket
4236
+ sendShapeSubscribeError(ws, subId, code, message) {
4237
+ try {
4238
+ ws.send(JSON.stringify({ code, error: { code, message }, id: subId, type: "error" }));
4239
+ } catch {
4240
+ }
4241
+ }
4242
+ /**
4243
+ * Seed a freshly-registered shape subscription. Resolves the shape under the
4244
+ * socket's verified identity, then ships either:
4245
+ *
4246
+ * - a **catch-up** poke (the membership diff in `(sinceCheckpoint, cursor]`)
4247
+ * when the client supplied a still-current checkpoint within the CDC retention
4248
+ * window and on this epoch — the cheap reconnect path; or
4249
+ * - a **full** insert-poke of the shape's entire current membership — a
4250
+ * first-time subscribe, or a reconnect that fell outside retention / forked
4251
+ * epoch.
4252
+ *
4253
+ * Either way the per-socket shape memo advances to the flush watermark so
4254
+ * later `pokeShapeSubscribers` passes diff from the right point.
4255
+ *
4256
+ * Returns `"ok"` once the shape resolved and its seed poke was attempted, or a
4257
+ * `{ code, message }` failure when the shape can't be resolved — an unknown /
4258
+ * RLS-denied shape (a base class with no registry resolves nothing), or a
4259
+ * `resolveShape` that threw (e.g. a cross-shard-join guard). The caller rolls
4260
+ * back the persisted attachment and errors instead of acking, so a client is
4261
+ * never left subscribed to a shape that will never deliver.
4262
+ */
4263
+ async seedShapeSubscription(ws, subId, shape) {
4264
+ const attachment = this.readAttachment(ws);
4265
+ const identity = { identity: attachment.identity, userId: attachment.userId };
4266
+ let resolved;
4267
+ try {
4268
+ resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
4269
+ } catch (error) {
4270
+ this.recordShapeError(`shape:seed:${subId}`, error);
4271
+ const code = typeof error.code === "string" ? error.code : "SHAPE_RESOLVE_FAILED";
4272
+ return { code, message: error instanceof Error ? error.message : "shape resolution failed" };
4273
+ }
4274
+ if (!resolved) {
4275
+ return { code: "SHAPE_NOT_FOUND", message: `shape "${shape.name}" not found or not permitted` };
4276
+ }
4277
+ try {
4278
+ if (resolved.global) {
4279
+ return await this.seedGlobalShape(ws, subId, resolved, identity, attachment.connectionId ?? "");
4280
+ }
4281
+ return await this.seedOpLogShape(ws, subId, shape, resolved);
4282
+ } catch (error) {
4283
+ this.recordShapeError(`shape:seed:${subId}`, error);
4284
+ const code = typeof error.code === "string" ? error.code : "SHAPE_SEED_FAILED";
4285
+ return { code, message: error instanceof Error ? error.message : "shape seed failed" };
4286
+ }
4287
+ }
4288
+ /**
4289
+ * Seed a non-`.global()` (op-log-backed) shape: either a catch-up diff over
4290
+ * `(sinceSeq, cursor]` when the client supplied a still-current checkpoint on
4291
+ * this epoch within the CDC retention window, or a full membership insert-poke
4292
+ * otherwise. The memo advances to `cursor` only once the poke is delivered, so
4293
+ * a failed send re-diffs from the prior point rather than skipping rows. May
4294
+ * throw (a stub `sql` handle, a membership probe failure); the caller converts
4295
+ * it to a structured `shape_subscribe` error.
4296
+ */
4297
+ async seedOpLogShape(ws, subId, shape, resolved) {
4298
+ const sql = this.sql;
4299
+ const cursor = this.currentCdcCursor() ?? 0;
4300
+ const epoch = this.currentCdcEpoch();
4301
+ const floor = this.cdcEnabled() ? minCdcSeq(sql) : void 0;
4302
+ const canResume = this.cdcEnabled() && shape.sinceSeq !== void 0 && shape.sinceEpoch === epoch && shape.sinceSeq <= cursor && (shape.sinceSeq === cursor || floor !== void 0 && floor <= shape.sinceSeq + 1);
4303
+ const rowsPatch = canResume && shape.sinceSeq !== void 0 ? this.buildShapeDiff(sql, resolved, shape.sinceSeq, cursor) : this.buildShapeSeed(sql, resolved);
4304
+ await awaitWsDrain(ws);
4305
+ if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], cursor, epoch, canResume ? shape.sinceSeq : void 0)) {
4306
+ this.recordShapeMemo(ws, subId, cursor);
4307
+ }
4308
+ return "ok";
4309
+ }
4310
+ /**
4311
+ * Fan the membership diff of every shape affected by this flush to its
4312
+ * subscribers — the partial-replication parallel to
4313
+ * {@link ShardDO.refreshSubscriptions}, called alongside it from
4314
+ * {@link ShardDO.flushChangedTables}. For each socket (bounded fan-out, same
4315
+ * concurrency + `awaitWsDrain` backpressure as the subscription path) it
4316
+ * resolves each shape under the socket's identity, diffs only the shapes
4317
+ * whose table changed in `(memoCursor, frameCursor]`, and emits one poke
4318
+ * carrying a part per changed shape. No-op when no socket holds a shape.
4319
+ */
4320
+ async pokeShapeSubscribers(changed, frameCursor, frameEpoch) {
4321
+ const sockets = [...this.state.getWebSockets()];
4322
+ const checkpoint = frameCursor ?? this.currentCdcCursor() ?? 0;
4323
+ const sql = this.sql;
4324
+ const pokeOne = async (ws) => {
4325
+ if (this.isSocketExpired(ws)) {
4326
+ this.dropExpiredSocket(ws);
4327
+ return;
4328
+ }
4329
+ const attachment = this.readAttachment(ws);
4330
+ const { shapes } = attachment;
4331
+ if (!shapes) {
4332
+ return;
4333
+ }
4334
+ const identity = { identity: attachment.identity, userId: attachment.userId };
4335
+ const { emptyAdvanced, partAdvanced, parts } = this.collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql);
4336
+ for (const subId of emptyAdvanced) {
4337
+ this.recordShapeMemo(ws, subId, checkpoint);
4338
+ }
4339
+ if (parts.length > 0) {
4340
+ await awaitWsDrain(ws);
4341
+ if (this.sendPoke(ws, parts, checkpoint, frameEpoch, void 0)) {
4342
+ for (const subId of partAdvanced) {
4343
+ this.recordShapeMemo(ws, subId, checkpoint);
4344
+ }
4345
+ }
4346
+ }
4347
+ };
4348
+ const concurrency = 8;
4349
+ let index = 0;
4350
+ const worker = async () => {
4351
+ let socket = sockets[index];
4352
+ index += 1;
4353
+ while (socket !== void 0) {
4354
+ await pokeOne(socket);
4355
+ socket = sockets[index];
4356
+ index += 1;
4357
+ }
4358
+ };
4359
+ await Promise.all(Array.from({ length: Math.min(concurrency, sockets.length) }, () => worker()));
4360
+ }
4361
+ /**
4362
+ * Diff every op-log-backed shape a socket holds against this flush, splitting
4363
+ * the results into the poke parts to send and the per-shape memo advances. A
4364
+ * `.global()` shape (driven by the alarm poll loop, not this flush) and a shape
4365
+ * whose table didn't change are skipped; a shape whose resolve/diff throws is
4366
+ * logged and skipped with its memo unadvanced so a later flush retries. Empty
4367
+ * diffs advance unconditionally; part-bearing shapes advance only once the
4368
+ * caller confirms the poke was delivered.
4369
+ */
4370
+ collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql) {
4371
+ const parts = [];
4372
+ const emptyAdvanced = [];
4373
+ const partAdvanced = [];
4374
+ for (const [subId, shape] of Object.entries(shapes)) {
4375
+ try {
4376
+ const resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
4377
+ if (!resolved || resolved.global || !changed.has(resolved.table)) {
4378
+ continue;
4379
+ }
4380
+ const memoCursor = this.shapeMemos.get(ws)?.get(subId)?.cursor ?? 0;
4381
+ const rowsPatch = this.buildShapeDiff(sql, resolved, memoCursor, checkpoint);
4382
+ if (rowsPatch.length > 0) {
4383
+ parts.push({ rowsPatch, shapeId: subId });
4384
+ partAdvanced.push(subId);
4385
+ } else {
4386
+ emptyAdvanced.push(subId);
4387
+ }
4388
+ } catch (error) {
4389
+ this.recordShapeError(`shape:poke:${subId}`, error);
4390
+ }
4391
+ }
4392
+ return { emptyAdvanced, partAdvanced, parts };
4393
+ }
4394
+ /**
4395
+ * Build the row-ops for a shape over the op range `(sinceSeq, upTo]`. Reads
4396
+ * the changelog (drained across pages), collapses to the latest op per row,
4397
+ * then runs ONE membership probe ({@link selectShapeMemberIds}) over the
4398
+ * changed ids: a row still in the set → upsert with its post-image doc
4399
+ * (projected to the shape's columns); a row that left the set, or any delete,
4400
+ * → `delete(key)` (a delete carries no post-image, so membership is
4401
+ * unknowable from the op alone — the client no-ops an unknown key).
4402
+ */
4403
+ // eslint-disable-next-line class-methods-use-this -- a pure op-page→membership-diff transform that reads only its args; kept a private method to sit beside the shape-poke pipeline it belongs to.
4404
+ buildShapeDiff(sql, resolved, sinceSeq, upTo) {
4405
+ const latest = /* @__PURE__ */ new Map();
4406
+ const tables = /* @__PURE__ */ new Set([resolved.table]);
4407
+ let from = sinceSeq;
4408
+ for (; ; ) {
4409
+ const { changes, cursor } = readCdcChanges(sql, { sinceSeq: from, tables });
4410
+ for (const change of changes) {
4411
+ latest.set(change.id, change);
4412
+ }
4413
+ if (changes.length === 0 || cursor === from || cursor >= upTo) {
4414
+ break;
4415
+ }
4416
+ from = cursor;
4417
+ }
4418
+ if (latest.size === 0) {
4419
+ return [];
4420
+ }
4421
+ const ids = [...latest.keys()];
4422
+ const members = selectShapeMemberIds(sql, resolved.table, resolved.effectiveWhere, ids);
4423
+ const ops = [];
4424
+ for (const [id, change] of latest) {
4425
+ if (members.has(id)) {
4426
+ if (change.doc !== void 0) {
4427
+ ops.push({ key: id, op: change.op, table: resolved.table, value: projectColumns(change.doc, resolved.columns) });
4428
+ }
4429
+ continue;
4430
+ }
4431
+ if (change.op !== "insert") {
4432
+ ops.push({ key: id, op: "delete", table: resolved.table });
4433
+ }
4434
+ }
4435
+ return ops;
4436
+ }
4437
+ /** Build the full insert-poke of a shape's current membership — the first-seed/full-reseed rowset. */
4438
+ // eslint-disable-next-line class-methods-use-this -- instance method for symmetry with `buildShapeDiff`; reads via the passed `sql` handle
4439
+ buildShapeSeed(sql, resolved) {
4440
+ return selectShapeRows(sql, resolved.table, resolved.effectiveWhere).map((row) => {
4441
+ return {
4442
+ key: row.id,
4443
+ op: "insert",
4444
+ table: resolved.table,
4445
+ value: projectColumns(row.doc, resolved.columns)
4446
+ };
4447
+ });
4448
+ }
4449
+ /**
4450
+ * Seed a `.global()`-table shape: read its full membership from D1, ship it
4451
+ * as one insert-poke, record the membership snapshot the alarm poll loop will
4452
+ * diff against, and arm the poll alarm. A global shape has no op-log cursor,
4453
+ * so the poke is stamped at this DO's current cursor (informational only) and
4454
+ * carries no resume base — a reconnect always re-seeds full.
4455
+ */
4456
+ async seedGlobalShape(ws, subId, resolved, identity, connectionId) {
4457
+ const rows = await this.readGlobalShapeRows(resolved, identity);
4458
+ if (!this.withinGlobalShapeBound(rows.length, `shape:seed:${subId}`, resolved.table)) {
4459
+ return {
4460
+ code: "SHAPE_GLOBAL_TOO_LARGE",
4461
+ 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`
4462
+ };
4463
+ }
4464
+ const { next: snapshot, rowsPatch } = diffGlobalMembership(rows, /* @__PURE__ */ new Map(), { columns: resolved.columns, table: resolved.table });
4465
+ await awaitWsDrain(ws);
4466
+ if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], this.currentCdcCursor() ?? 0, this.currentCdcEpoch(), void 0)) {
4467
+ this.recordGlobalSnapshot(ws, subId, snapshot);
4468
+ this.saveGlobalSnapshot(connectionId, subId, snapshot);
4469
+ }
4470
+ await this.scheduleGlobalPoll();
4471
+ return "ok";
4472
+ }
4473
+ /**
4474
+ * Re-read a global shape's membership from D1 and poke only the diff against
4475
+ * the socket's last snapshot: a new key → `insert`, a changed projected value
4476
+ * → `update`, a vanished key → `delete`. The snapshot advances to the fresh
4477
+ * membership even when the diff is empty, so the next tick compares from here.
4478
+ * No frame is sent when nothing changed (the common steady-state tick).
4479
+ */
4480
+ async refreshGlobalShape(ws, subId, resolved, identity, connectionId) {
4481
+ const rows = await this.readGlobalShapeRows(resolved, identity);
4482
+ if (!this.withinGlobalShapeBound(rows.length, `shape:poll:${subId}`, resolved.table)) {
4483
+ return;
4484
+ }
4485
+ const previous = this.readGlobalSnapshot(ws, subId, connectionId);
4486
+ const { next, rowsPatch } = diffGlobalMembership(rows, previous, { columns: resolved.columns, table: resolved.table });
4487
+ if (rowsPatch.length === 0) {
4488
+ this.recordGlobalSnapshot(ws, subId, next);
4489
+ return;
4490
+ }
4491
+ await awaitWsDrain(ws);
4492
+ if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], this.currentCdcCursor() ?? 0, this.currentCdcEpoch(), void 0)) {
4493
+ this.recordGlobalSnapshot(ws, subId, next);
4494
+ this.saveGlobalSnapshot(connectionId, subId, next);
4495
+ }
4496
+ }
4497
+ /**
4498
+ * Read a socket's global-shape baseline, preferring the hot in-memory cache
4499
+ * and falling back to the durable `__global_shape_snapshot` table on a miss (a
4500
+ * cold socket after a hibernation eviction). The loaded baseline repopulates
4501
+ * the cache so subsequent ticks in this wake hit memory. An empty
4502
+ * `connectionId` (a socket that never went through the lifecycle-aware upgrade,
4503
+ * e.g. a unit harness) skips the durable read and behaves as in-memory-only.
4504
+ */
4505
+ readGlobalSnapshot(ws, subId, connectionId) {
4506
+ const cached = this.globalShapeSnapshots.get(ws)?.get(subId);
4507
+ if (cached) {
4508
+ return cached;
4509
+ }
4510
+ const stored = this.loadGlobalSnapshot(connectionId, subId);
4511
+ this.recordGlobalSnapshot(ws, subId, stored);
4512
+ return stored;
4513
+ }
4514
+ /** Record a socket's latest global-shape membership snapshot in the in-memory cache (creating the per-socket map lazily). */
4515
+ recordGlobalSnapshot(ws, subId, snapshot) {
4516
+ let snapshots = this.globalShapeSnapshots.get(ws);
4517
+ if (!snapshots) {
4518
+ snapshots = /* @__PURE__ */ new Map();
4519
+ this.globalShapeSnapshots.set(ws, snapshots);
4520
+ }
4521
+ snapshots.set(subId, snapshot);
4522
+ }
4523
+ /**
4524
+ * Load a durable global-shape baseline from SQLite, or an empty map when none
4525
+ * is stored / the durable path is unavailable. A stub `sql` handle (unit
4526
+ * harness) or a missing table degrades to in-memory-only behavior rather than
4527
+ * failing the poll tick.
4528
+ */
4529
+ loadGlobalSnapshot(connectionId, subId) {
4530
+ if (connectionId === "") {
4531
+ return /* @__PURE__ */ new Map();
4532
+ }
4533
+ try {
4534
+ return readGlobalShapeSnapshot(this.sql, connectionId, subId);
4535
+ } catch {
4536
+ return /* @__PURE__ */ new Map();
4537
+ }
4538
+ }
4539
+ /**
4540
+ * Persist a socket's global-shape baseline to SQLite so the poll-loop diff
4541
+ * survives hibernation. A no-op for a connection-id-less socket or a stub
4542
+ * `sql` handle (the in-memory cache then carries the baseline for the DO's
4543
+ * lifetime, matching the pre-durable behavior).
4544
+ */
4545
+ saveGlobalSnapshot(connectionId, subId, snapshot) {
4546
+ if (connectionId === "") {
4547
+ return;
4548
+ }
4549
+ try {
4550
+ writeGlobalShapeSnapshot(this.sql, connectionId, subId, snapshot);
4551
+ } catch {
4552
+ }
4553
+ }
4554
+ /**
4555
+ * Arm the poll alarm for `.global()` shapes if one isn't already pending.
4556
+ * Idempotent — every global-shape seed calls it, but only the first arms the
4557
+ * alarm. Degrades to a no-op when the runtime exposes no `setAlarm` (the unit
4558
+ * harness): a global shape is then seed-only, which the poll-loop tests assert
4559
+ * by driving {@link ShardDO.alarm} directly.
4560
+ */
4561
+ async scheduleGlobalPoll() {
4562
+ if (this.globalPollScheduled) {
4563
+ return;
4564
+ }
4565
+ const { setAlarm } = this.state.storage;
4566
+ if (!setAlarm) {
4567
+ return;
4568
+ }
4569
+ this.globalPollScheduled = true;
4570
+ try {
4571
+ await setAlarm.call(this.state.storage, Date.now() + ShardDO.GLOBAL_SHAPE_POLL_INTERVAL_MS);
4572
+ } catch {
4573
+ this.globalPollScheduled = false;
4574
+ }
4575
+ }
4576
+ /**
4577
+ * Record a contained shape-tier error (poll / poke / seed) into the DO's log
4578
+ * ring without aborting the rest of the pass. The shape pipeline is a
4579
+ * best-effort fan-out: one socket's read or one shape's resolve failing must
4580
+ * never take down the others — so callers swallow the throw and surface it
4581
+ * here for diagnosis. `context` is a synthetic `shape:phase:subId` path.
4582
+ */
4583
+ recordShapeError(context, error) {
4584
+ this.logs.push({
4585
+ functionPath: context,
4586
+ level: "error",
4587
+ message: error instanceof Error ? error.message : String(error),
4588
+ timestamp: Date.now()
4589
+ });
4590
+ }
4591
+ /**
4592
+ * Guard a global shape's materialized membership against {@link
4593
+ * ShardDO.GLOBAL_SHAPE_MAX_ROWS}. Returns `true` when the row count is within
4594
+ * the cap; otherwise records a diagnosable error and returns `false` so the
4595
+ * caller fails the shape closed (no snapshot retained, no poke sent) rather
4596
+ * than risking a DO eviction on an unbounded global table. The transient read
4597
+ * buffer is bounded by the same gate — an over-cap membership is dropped, not
4598
+ * snapshotted per socket.
4599
+ */
4600
+ withinGlobalShapeBound(rowCount, context, table) {
4601
+ if (rowCount <= ShardDO.GLOBAL_SHAPE_MAX_ROWS) {
4602
+ return true;
4603
+ }
4604
+ this.recordShapeError(
4605
+ context,
4606
+ new Error(
4607
+ `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`
4608
+ )
4609
+ );
4610
+ return false;
4611
+ }
4612
+ /**
4613
+ * Refresh every `.global()`-table shape held across all live sockets, one
4614
+ * diff-poke per (socket, shape). Returns the number of global shapes still
4615
+ * subscribed so {@link ShardDO.alarm} knows whether to re-arm. Expired sockets
4616
+ * are dropped in passing (mirrors {@link ShardDO.pokeShapeSubscribers}).
4617
+ */
4618
+ async pollGlobalShapes() {
4619
+ const sockets = [...this.state.getWebSockets()];
4620
+ let remaining = 0;
4621
+ for (const ws of sockets) {
4622
+ if (this.isSocketExpired(ws)) {
4623
+ this.dropExpiredSocket(ws);
4624
+ continue;
4625
+ }
4626
+ const attachment = this.readAttachment(ws);
4627
+ const { shapes } = attachment;
4628
+ if (!shapes) {
4629
+ continue;
4630
+ }
4631
+ const identity = { identity: attachment.identity, userId: attachment.userId };
4632
+ remaining += await this.pollSocketGlobalShapes(ws, shapes, identity, attachment.connectionId ?? "");
4633
+ }
4634
+ return remaining;
4635
+ }
4636
+ /**
4637
+ * Refresh one socket's `.global()`-table shapes, containing per-shape
4638
+ * failures so a single throw never aborts the poll tick (and with it the
4639
+ * re-arm). Returns the count of global shapes still subscribed on this socket
4640
+ * — a failed `resolveShape`/read keeps its shape counted so the alarm keeps
4641
+ * polling and retries next tick.
4642
+ */
4643
+ async pollSocketGlobalShapes(ws, shapes, identity, connectionId) {
4644
+ let count = 0;
4645
+ for (const [subId, shape] of Object.entries(shapes)) {
4646
+ let resolved;
4647
+ try {
4648
+ resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
4649
+ } catch (error) {
4650
+ count += 1;
4651
+ this.recordShapeError(`shape:poll:${subId}`, error);
4652
+ continue;
4653
+ }
4654
+ if (!resolved?.global) {
4655
+ continue;
4656
+ }
4657
+ count += 1;
4658
+ try {
4659
+ await this.refreshGlobalShape(ws, subId, resolved, identity, connectionId);
4660
+ } catch (error) {
4661
+ this.recordShapeError(`shape:poll:${subId}`, error);
4662
+ }
4663
+ }
4664
+ return count;
4665
+ }
4666
+ /**
4667
+ * Send one poke (`pokeStart` → `pokePart` per shape → `pokeEnd`) to a socket.
4668
+ * All parts apply atomically at `pokeEnd`. Returns `true` when every frame was
4669
+ * handed to the socket, `false` when a send threw mid-poke (the socket closed)
4670
+ * — callers must NOT advance their shape baselines on a `false` so the client
4671
+ * re-receives the rows on its next flush/reconnect instead of losing them.
4672
+ */
4673
+ sendPoke(ws, parts, checkpoint, epoch, baseCheckpoint) {
4674
+ this.pokeSequence += 1;
4675
+ const pokeId = `poke-${String(this.pokeSequence)}`;
4676
+ const frames = buildPokeFrames(parts, { baseCheckpoint, checkpoint, epoch, lastMutationId: this.socketClientWatermark(ws), pokeId });
4677
+ try {
4678
+ for (const frame of frames) {
4679
+ ws.send(frame);
4680
+ }
4681
+ return true;
4682
+ } catch {
4683
+ return false;
4684
+ }
4685
+ }
4686
+ /**
4687
+ * The recipient client's `__client_watermark` for stamping a poke's
4688
+ * `lastMutationId`, or `undefined` when the socket announced no `clientId`
4689
+ * (a client that doesn't use custom mutators — nothing to drop an overlay
4690
+ * for). Read off the attachment so it survives hibernation.
4691
+ */
4692
+ socketClientWatermark(ws) {
4693
+ const attachment = this.readAttachment(ws);
4694
+ const { clientId } = attachment;
4695
+ if (clientId === void 0) {
4696
+ return void 0;
4697
+ }
4698
+ try {
4699
+ return readClientWatermark(this.sql, attachment.userId ?? "", clientId);
4700
+ } catch {
4701
+ return void 0;
4702
+ }
4703
+ }
4704
+ /** Record a shape's poke baseline cursor on a socket (creating the per-socket map lazily). */
4705
+ recordShapeMemo(ws, subId, cursor) {
4706
+ let memos = this.shapeMemos.get(ws);
4707
+ if (!memos) {
4708
+ memos = /* @__PURE__ */ new Map();
4709
+ this.shapeMemos.set(ws, memos);
4710
+ }
4711
+ memos.set(subId, { cursor });
4712
+ }
3675
4713
  /**
3676
4714
  * Record `outcome` as this socket's diff baseline for `subId` without
3677
4715
  * sending a frame. Used by the resume fast-path, where the client keeps its