@lunora/do 1.0.0-alpha.10 → 1.0.0-alpha.12

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.
package/dist/index.d.mts CHANGED
@@ -5740,6 +5740,39 @@ declare abstract class ShardDO {
5740
5740
  */
5741
5741
  private resolveReactiveOutcome;
5742
5742
  /**
5743
+ * SECURITY BOUNDARY for cross-socket reactive dedup. A read is
5744
+ * identity-INDEPENDENT only when its result cannot vary by the caller's
5745
+ * verified identity — i.e. the admin/reserved introspection reads, which
5746
+ * route to {@link executeAdminSubscription} and ignore the
5747
+ * {@link SubscriptionIdentity} entirely.
5748
+ *
5749
+ * Everything else is identity-DEPENDENT and must NEVER be shared across
5750
+ * sockets: a user query may be `rls()` / `ctx.auth`-scoped (different rows
5751
+ * per identity), and a flag read ({@link FLAGS_FUNCTION_PREFIX}) evaluates
5752
+ * the provider with the subscriber's identity (per-user targeting). Sharing
5753
+ * one socket's result with another would leak one identity's rows/flags to a
5754
+ * different identity, so this predicate gates {@link resolveReactiveOutcomeDeduped}
5755
+ * shut for them.
5756
+ */
5757
+ protected isIdentityIndependent(functionPath: string): boolean;
5758
+ /**
5759
+ * Memoizing wrapper over {@link resolveReactiveOutcome}: flush-local sharing across sockets.
5760
+ * Within a single {@link refreshSubscriptions} pass, N sockets subscribed to
5761
+ * the SAME identity-independent `(functionPath, args)` re-run the query N
5762
+ * times today (see the Case-6 fan-out characterization). When the read is
5763
+ * identity-independent (admin/reserved — see {@link isIdentityIndependent})
5764
+ * its result is the same for every socket, so the first run is cached (by its
5765
+ * in-flight Promise, since the bounded worker pool runs sockets in parallel)
5766
+ * and shared with the rest — collapsing N runs to ONE.
5767
+ *
5768
+ * Identity-DEPENDENT reads are passed straight through, UNCACHED: each socket
5769
+ * must evaluate under its own by-value identity (RLS / `ctx.auth` / per-user
5770
+ * flags), so they never share a result. The `cache` is created fresh per
5771
+ * flush by the caller, so a result is never reused across passes (it would go
5772
+ * stale after the next write).
5773
+ */
5774
+ private resolveReactiveOutcomeDeduped;
5775
+ /**
5743
5776
  * Constant-time bearer check against `env.LUNORA_ADMIN_TOKEN`. Returns
5744
5777
  * `false` (closed) when the token is unset so admin introspection is
5745
5778
  * opt-in rather than exposed by default.
@@ -5919,8 +5952,30 @@ declare abstract class ShardDO {
5919
5952
  */
5920
5953
  private collectShapePokeParts;
5921
5954
  /**
5955
+ * Drain the op-log range `(sinceSeq, upTo]` for `table` into the latest op per
5956
+ * row id (collapsing multiple ops on the same row to the newest). Within one
5957
+ * flush, every shape over the SAME `(table, sinceSeq, upTo)` reads the
5958
+ * identical changelog slice, so the drained map is memoized in the
5959
+ * caller-supplied `cache` (created fresh per flush) — N shapes on a table
5960
+ * share ONE changelog drain instead of re-scanning it per shape. The
5961
+ * per-shape membership probe still runs per shape (its predicate is
5962
+ * identity/args-specific), so only the shared op read is collapsed.
5963
+ */
5964
+ private readShapeOpRange;
5965
+ /**
5966
+ * Read one page of the `__cdc_log` for a shape diff (table-scoped). A thin
5967
+ * protected seam over {@link readCdcChanges}: it isolates the single
5968
+ * changelog read that {@link readShapeOpRange} memoizes per flush, and gives
5969
+ * tests a point to count the reads the op-range cache collapses.
5970
+ */
5971
+ protected readShapeCdcPage(sql: SqlExec, sinceSeq: number, tables: ReadonlySet<string>): {
5972
+ changes: CdcChange[];
5973
+ cursor: number;
5974
+ };
5975
+ /**
5922
5976
  * Build the row-ops for a shape over the op range `(sinceSeq, upTo]`. Reads
5923
- * the changelog (drained across pages), collapses to the latest op per row,
5977
+ * the changelog (drained across pages via {@link readShapeOpRange}, shared
5978
+ * across same-range shapes in a flush), collapses to the latest op per row,
5924
5979
  * then runs ONE membership probe ({@link selectShapeMemberIds}) over the
5925
5980
  * changed ids: a row still in the set → upsert with its post-image doc
5926
5981
  * (projected to the shape's columns); a row that left the set, or any delete,
package/dist/index.d.ts CHANGED
@@ -5740,6 +5740,39 @@ declare abstract class ShardDO {
5740
5740
  */
5741
5741
  private resolveReactiveOutcome;
5742
5742
  /**
5743
+ * SECURITY BOUNDARY for cross-socket reactive dedup. A read is
5744
+ * identity-INDEPENDENT only when its result cannot vary by the caller's
5745
+ * verified identity — i.e. the admin/reserved introspection reads, which
5746
+ * route to {@link executeAdminSubscription} and ignore the
5747
+ * {@link SubscriptionIdentity} entirely.
5748
+ *
5749
+ * Everything else is identity-DEPENDENT and must NEVER be shared across
5750
+ * sockets: a user query may be `rls()` / `ctx.auth`-scoped (different rows
5751
+ * per identity), and a flag read ({@link FLAGS_FUNCTION_PREFIX}) evaluates
5752
+ * the provider with the subscriber's identity (per-user targeting). Sharing
5753
+ * one socket's result with another would leak one identity's rows/flags to a
5754
+ * different identity, so this predicate gates {@link resolveReactiveOutcomeDeduped}
5755
+ * shut for them.
5756
+ */
5757
+ protected isIdentityIndependent(functionPath: string): boolean;
5758
+ /**
5759
+ * Memoizing wrapper over {@link resolveReactiveOutcome}: flush-local sharing across sockets.
5760
+ * Within a single {@link refreshSubscriptions} pass, N sockets subscribed to
5761
+ * the SAME identity-independent `(functionPath, args)` re-run the query N
5762
+ * times today (see the Case-6 fan-out characterization). When the read is
5763
+ * identity-independent (admin/reserved — see {@link isIdentityIndependent})
5764
+ * its result is the same for every socket, so the first run is cached (by its
5765
+ * in-flight Promise, since the bounded worker pool runs sockets in parallel)
5766
+ * and shared with the rest — collapsing N runs to ONE.
5767
+ *
5768
+ * Identity-DEPENDENT reads are passed straight through, UNCACHED: each socket
5769
+ * must evaluate under its own by-value identity (RLS / `ctx.auth` / per-user
5770
+ * flags), so they never share a result. The `cache` is created fresh per
5771
+ * flush by the caller, so a result is never reused across passes (it would go
5772
+ * stale after the next write).
5773
+ */
5774
+ private resolveReactiveOutcomeDeduped;
5775
+ /**
5743
5776
  * Constant-time bearer check against `env.LUNORA_ADMIN_TOKEN`. Returns
5744
5777
  * `false` (closed) when the token is unset so admin introspection is
5745
5778
  * opt-in rather than exposed by default.
@@ -5919,8 +5952,30 @@ declare abstract class ShardDO {
5919
5952
  */
5920
5953
  private collectShapePokeParts;
5921
5954
  /**
5955
+ * Drain the op-log range `(sinceSeq, upTo]` for `table` into the latest op per
5956
+ * row id (collapsing multiple ops on the same row to the newest). Within one
5957
+ * flush, every shape over the SAME `(table, sinceSeq, upTo)` reads the
5958
+ * identical changelog slice, so the drained map is memoized in the
5959
+ * caller-supplied `cache` (created fresh per flush) — N shapes on a table
5960
+ * share ONE changelog drain instead of re-scanning it per shape. The
5961
+ * per-shape membership probe still runs per shape (its predicate is
5962
+ * identity/args-specific), so only the shared op read is collapsed.
5963
+ */
5964
+ private readShapeOpRange;
5965
+ /**
5966
+ * Read one page of the `__cdc_log` for a shape diff (table-scoped). A thin
5967
+ * protected seam over {@link readCdcChanges}: it isolates the single
5968
+ * changelog read that {@link readShapeOpRange} memoizes per flush, and gives
5969
+ * tests a point to count the reads the op-range cache collapses.
5970
+ */
5971
+ protected readShapeCdcPage(sql: SqlExec, sinceSeq: number, tables: ReadonlySet<string>): {
5972
+ changes: CdcChange[];
5973
+ cursor: number;
5974
+ };
5975
+ /**
5922
5976
  * Build the row-ops for a shape over the op range `(sinceSeq, upTo]`. Reads
5923
- * the changelog (drained across pages), collapses to the latest op per row,
5977
+ * the changelog (drained across pages via {@link readShapeOpRange}, shared
5978
+ * across same-range shapes in a flush), collapses to the latest op per row,
5924
5979
  * then runs ONE membership probe ({@link selectShapeMemberIds}) over the
5925
5980
  * changed ids: a row still in the set → upsert with its post-image doc
5926
5981
  * (projected to the shape's columns); a row that left the set, or any delete,
package/dist/index.mjs CHANGED
@@ -23,7 +23,7 @@ export { RLS_UNWRAP_SYMBOL, RlsRequiredError, guardWriter } from './packem_share
23
23
  export { buildFtsMatch, ftsTableName, scoreDocument, stringifySearchText, tokenizeSearch } from './packem_shared/buildFtsMatch-BLEMawrp.mjs';
24
24
  export { M as MIN_ADMIN_TOKEN_LENGTH, a as MIN_AUTH_SECRET_LENGTH, b as buildSecurityAudit } from './packem_shared/security-audit-CucgBice.mjs';
25
25
  export { SESSION_DO_TTL_DEFAULT, SessionDO } from './packem_shared/SESSION_DO_TTL_DEFAULT-ilPZsVwu.mjs';
26
- export { ROOT_DO_SIZE_WARN_BYTES, ROOT_SHARD_NAME, ShardDO } from './packem_shared/ROOT_DO_SIZE_WARN_BYTES-DKwBF3Jp.mjs';
26
+ export { ROOT_DO_SIZE_WARN_BYTES, ROOT_SHARD_NAME, ShardDO } from './packem_shared/ROOT_DO_SIZE_WARN_BYTES-CM5_hVnA.mjs';
27
27
  export { SHARD_REGISTRY_DO_NAME, ShardRegistryDO } from './packem_shared/SHARD_REGISTRY_DO_NAME-BsAbi5Mn.mjs';
28
28
  export { MAX_SQL_ROWS, assertReadonly, runReadonlySql } from './packem_shared/MAX_SQL_ROWS-dDcFE1YZ.mjs';
29
29
  export { createSystemReader } from './packem_shared/createSystemReader-8CzSZP9V.mjs';
@@ -403,6 +403,23 @@ const buildPokeFrames = (parts, meta) => {
403
403
  return frames;
404
404
  };
405
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
+
406
423
  const DANGLING_SCAN_CAP = 5e3;
407
424
  const DANGLING_RESULT_CAP = 500;
408
425
  const DOC_COLUMN = "__doc__";
@@ -1374,6 +1391,9 @@ class ShardDO {
1374
1391
  }
1375
1392
  const result = await this.handleRpc(payload.functionPath, payload.args ?? {});
1376
1393
  this.recordPostDispatchBookkeeping(result, mutatorClass);
1394
+ if (mutatorClass?.kind === "next") {
1395
+ this.advanceClientMutationWatermark();
1396
+ }
1377
1397
  const durationMs = Date.now() - dispatchStartedAt;
1378
1398
  this.recordFunctionCall(payload.functionPath, durationMs, void 0, this.currentScannedTables, this.currentIndexHits);
1379
1399
  this.flushStmtSamples();
@@ -3091,16 +3111,16 @@ class ShardDO {
3091
3111
  return jsonResponse({ error: { code: error.code, message: error.message } }, error.status);
3092
3112
  }
3093
3113
  if (error && typeof error === "object" && error.name === "ValidationError") {
3094
- const message2 = error instanceof Error ? error.message : "validation failed";
3095
- return jsonResponse({ error: { code: "VALIDATION_ERROR", message: message2 } }, 400);
3114
+ const message = error instanceof Error ? error.message : "validation failed";
3115
+ return jsonResponse({ error: { code: "VALIDATION_ERROR", message } }, 400);
3096
3116
  }
3097
3117
  if (error && typeof error === "object" && error.name === "LunoraError") {
3098
3118
  const lunoraError = error;
3099
3119
  const status = typeof lunoraError.status === "number" ? lunoraError.status : 500;
3100
3120
  return jsonResponse({ error: { code: lunoraError.code ?? "INTERNAL", message: lunoraError.message ?? "internal error" } }, status);
3101
3121
  }
3102
- const message = error instanceof Error ? error.message : "unknown error";
3103
- return jsonResponse({ error: { code: "RPC_FAILED", message } }, 500);
3122
+ console.error("[@lunora/do] unhandled RPC error:", error);
3123
+ return jsonResponse({ error: { code: "RPC_FAILED", message: "internal error" } }, 500);
3104
3124
  }
3105
3125
  /**
3106
3126
  * Serve a reserved admin-introspection RPC (`__lunora_admin__:*`) for the
@@ -3920,6 +3940,54 @@ class ShardDO {
3920
3940
  }
3921
3941
  return this.executeSubscription(functionPath, args, identity);
3922
3942
  }
3943
+ /**
3944
+ * SECURITY BOUNDARY for cross-socket reactive dedup. A read is
3945
+ * identity-INDEPENDENT only when its result cannot vary by the caller's
3946
+ * verified identity — i.e. the admin/reserved introspection reads, which
3947
+ * route to {@link executeAdminSubscription} and ignore the
3948
+ * {@link SubscriptionIdentity} entirely.
3949
+ *
3950
+ * Everything else is identity-DEPENDENT and must NEVER be shared across
3951
+ * sockets: a user query may be `rls()` / `ctx.auth`-scoped (different rows
3952
+ * per identity), and a flag read ({@link FLAGS_FUNCTION_PREFIX}) evaluates
3953
+ * the provider with the subscriber's identity (per-user targeting). Sharing
3954
+ * one socket's result with another would leak one identity's rows/flags to a
3955
+ * different identity, so this predicate gates {@link resolveReactiveOutcomeDeduped}
3956
+ * shut for them.
3957
+ */
3958
+ // 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
3959
+ isIdentityIndependent(functionPath) {
3960
+ return functionPath.startsWith(ADMIN_FUNCTION_PREFIX);
3961
+ }
3962
+ /**
3963
+ * Memoizing wrapper over {@link resolveReactiveOutcome}: flush-local sharing across sockets.
3964
+ * Within a single {@link refreshSubscriptions} pass, N sockets subscribed to
3965
+ * the SAME identity-independent `(functionPath, args)` re-run the query N
3966
+ * times today (see the Case-6 fan-out characterization). When the read is
3967
+ * identity-independent (admin/reserved — see {@link isIdentityIndependent})
3968
+ * its result is the same for every socket, so the first run is cached (by its
3969
+ * in-flight Promise, since the bounded worker pool runs sockets in parallel)
3970
+ * and shared with the rest — collapsing N runs to ONE.
3971
+ *
3972
+ * Identity-DEPENDENT reads are passed straight through, UNCACHED: each socket
3973
+ * must evaluate under its own by-value identity (RLS / `ctx.auth` / per-user
3974
+ * flags), so they never share a result. The `cache` is created fresh per
3975
+ * flush by the caller, so a result is never reused across passes (it would go
3976
+ * stale after the next write).
3977
+ */
3978
+ resolveReactiveOutcomeDeduped(functionPath, args, isAdmin, identity, cache) {
3979
+ if (!this.isIdentityIndependent(functionPath)) {
3980
+ return this.resolveReactiveOutcome(functionPath, args, isAdmin, identity);
3981
+ }
3982
+ const key = reactiveCacheKey(functionPath, args, null);
3983
+ const cached = cache.get(key);
3984
+ if (cached !== void 0) {
3985
+ return cached;
3986
+ }
3987
+ const pending = this.resolveReactiveOutcome(functionPath, args, isAdmin, identity);
3988
+ cache.set(key, pending);
3989
+ return pending;
3990
+ }
3923
3991
  /**
3924
3992
  * Constant-time bearer check against `env.LUNORA_ADMIN_TOKEN`. Returns
3925
3993
  * `false` (closed) when the token is unset so admin introspection is
@@ -3944,6 +4012,7 @@ class ShardDO {
3944
4012
  * 4. On normal completion send `{type:"complete"}`; on throw send
3945
4013
  * `{type:"error"}`. Either way drop the controller.
3946
4014
  */
4015
+ // 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
3947
4016
  async handleStream(ws, id, functionPath, args) {
3948
4017
  const iterable = this.executeStream(functionPath, args);
3949
4018
  if (!iterable) {
@@ -3984,10 +4053,15 @@ class ShardDO {
3984
4053
  }
3985
4054
  } catch (error) {
3986
4055
  const { code } = error;
3987
- const message = error instanceof Error ? error.message : String(error);
4056
+ const isStructured = typeof code === "string";
4057
+ if (!isStructured) {
4058
+ console.error("[@lunora/do] unhandled stream error:", error);
4059
+ }
4060
+ const rawMessage = error instanceof Error ? error.message : String(error);
4061
+ const message = isStructured ? rawMessage : "internal error";
3988
4062
  ws.send(
3989
4063
  JSON.stringify({
3990
- error: { code: typeof code === "string" ? code : "INTERNAL_SERVER_ERROR", message },
4064
+ error: { code: isStructured ? code : "INTERNAL_SERVER_ERROR", message },
3991
4065
  id,
3992
4066
  type: "error"
3993
4067
  })
@@ -4121,6 +4195,7 @@ class ShardDO {
4121
4195
  const sockets = [...this.state.getWebSockets()];
4122
4196
  const frameCursor = this.currentCdcCursor();
4123
4197
  const frameEpoch = this.currentCdcEpoch();
4198
+ const reactiveRunCache = /* @__PURE__ */ new Map();
4124
4199
  const refreshOne = async (ws) => {
4125
4200
  if (this.isSocketExpired(ws)) {
4126
4201
  this.dropExpiredSocket(ws);
@@ -4138,10 +4213,13 @@ class ShardDO {
4138
4213
  continue;
4139
4214
  }
4140
4215
  try {
4141
- const outcome = await this.resolveReactiveOutcome(functionPath, query.args ?? {}, isAdmin, {
4142
- identity: attachment.identity,
4143
- userId: attachment.userId
4144
- });
4216
+ const outcome = await this.resolveReactiveOutcomeDeduped(
4217
+ functionPath,
4218
+ query.args ?? {},
4219
+ isAdmin,
4220
+ { identity: attachment.identity, userId: attachment.userId },
4221
+ reactiveRunCache
4222
+ );
4145
4223
  if (!outcome) {
4146
4224
  continue;
4147
4225
  }
@@ -4152,18 +4230,7 @@ class ShardDO {
4152
4230
  }
4153
4231
  }
4154
4232
  };
4155
- const concurrency = 8;
4156
- let cursor = 0;
4157
- const worker = async () => {
4158
- let socket = sockets[cursor];
4159
- cursor += 1;
4160
- while (socket !== void 0) {
4161
- await refreshOne(socket);
4162
- socket = sockets[cursor];
4163
- cursor += 1;
4164
- }
4165
- };
4166
- await Promise.all(Array.from({ length: Math.min(concurrency, sockets.length) }, () => worker()));
4233
+ await runSocketPool(sockets, refreshOne);
4167
4234
  }
4168
4235
  /**
4169
4236
  * Seed a freshly-registered subscription with its first value. Runs the
@@ -4321,6 +4388,7 @@ class ShardDO {
4321
4388
  const sockets = [...this.state.getWebSockets()];
4322
4389
  const checkpoint = frameCursor ?? this.currentCdcCursor() ?? 0;
4323
4390
  const sql = this.sql;
4391
+ const opRangeCache = /* @__PURE__ */ new Map();
4324
4392
  const pokeOne = async (ws) => {
4325
4393
  if (this.isSocketExpired(ws)) {
4326
4394
  this.dropExpiredSocket(ws);
@@ -4331,32 +4399,24 @@ class ShardDO {
4331
4399
  if (!shapes) {
4332
4400
  return;
4333
4401
  }
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);
4402
+ try {
4403
+ const identity = { identity: attachment.identity, userId: attachment.userId };
4404
+ const { emptyAdvanced, partAdvanced, parts } = this.collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql, opRangeCache);
4405
+ for (const subId of emptyAdvanced) {
4406
+ this.recordShapeMemo(ws, subId, checkpoint);
4407
+ }
4408
+ if (parts.length > 0) {
4409
+ await awaitWsDrain(ws);
4410
+ if (this.sendPoke(ws, parts, checkpoint, frameEpoch, void 0)) {
4411
+ for (const subId of partAdvanced) {
4412
+ this.recordShapeMemo(ws, subId, checkpoint);
4413
+ }
4344
4414
  }
4345
4415
  }
4416
+ } catch {
4346
4417
  }
4347
4418
  };
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()));
4419
+ await runSocketPool(sockets, pokeOne);
4360
4420
  }
4361
4421
  /**
4362
4422
  * Diff every op-log-backed shape a socket holds against this flush, splitting
@@ -4367,7 +4427,7 @@ class ShardDO {
4367
4427
  * diffs advance unconditionally; part-bearing shapes advance only once the
4368
4428
  * caller confirms the poke was delivered.
4369
4429
  */
4370
- collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql) {
4430
+ collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql, opRangeCache) {
4371
4431
  const parts = [];
4372
4432
  const emptyAdvanced = [];
4373
4433
  const partAdvanced = [];
@@ -4378,7 +4438,7 @@ class ShardDO {
4378
4438
  continue;
4379
4439
  }
4380
4440
  const memoCursor = this.shapeMemos.get(ws)?.get(subId)?.cursor ?? 0;
4381
- const rowsPatch = this.buildShapeDiff(sql, resolved, memoCursor, checkpoint);
4441
+ const rowsPatch = this.buildShapeDiff(sql, resolved, memoCursor, checkpoint, opRangeCache);
4382
4442
  if (rowsPatch.length > 0) {
4383
4443
  parts.push({ rowsPatch, shapeId: subId });
4384
4444
  partAdvanced.push(subId);
@@ -4392,21 +4452,26 @@ class ShardDO {
4392
4452
  return { emptyAdvanced, partAdvanced, parts };
4393
4453
  }
4394
4454
  /**
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).
4455
+ * Drain the op-log range `(sinceSeq, upTo]` for `table` into the latest op per
4456
+ * row id (collapsing multiple ops on the same row to the newest). Within one
4457
+ * flush, every shape over the SAME `(table, sinceSeq, upTo)` reads the
4458
+ * identical changelog slice, so the drained map is memoized in the
4459
+ * caller-supplied `cache` (created fresh per flush) N shapes on a table
4460
+ * share ONE changelog drain instead of re-scanning it per shape. The
4461
+ * per-shape membership probe still runs per shape (its predicate is
4462
+ * identity/args-specific), so only the shared op read is collapsed.
4402
4463
  */
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) {
4464
+ readShapeOpRange(sql, table, sinceSeq, upTo, cache) {
4465
+ const key = `${table}\0${String(sinceSeq)}\0${String(upTo)}`;
4466
+ const cached = cache?.get(key);
4467
+ if (cached !== void 0) {
4468
+ return cached;
4469
+ }
4405
4470
  const latest = /* @__PURE__ */ new Map();
4406
- const tables = /* @__PURE__ */ new Set([resolved.table]);
4471
+ const tables = /* @__PURE__ */ new Set([table]);
4407
4472
  let from = sinceSeq;
4408
4473
  for (; ; ) {
4409
- const { changes, cursor } = readCdcChanges(sql, { sinceSeq: from, tables });
4474
+ const { changes, cursor } = this.readShapeCdcPage(sql, from, tables);
4410
4475
  for (const change of changes) {
4411
4476
  latest.set(change.id, change);
4412
4477
  }
@@ -4415,6 +4480,31 @@ class ShardDO {
4415
4480
  }
4416
4481
  from = cursor;
4417
4482
  }
4483
+ cache?.set(key, latest);
4484
+ return latest;
4485
+ }
4486
+ /**
4487
+ * Read one page of the `__cdc_log` for a shape diff (table-scoped). A thin
4488
+ * protected seam over {@link readCdcChanges}: it isolates the single
4489
+ * changelog read that {@link readShapeOpRange} memoizes per flush, and gives
4490
+ * tests a point to count the reads the op-range cache collapses.
4491
+ */
4492
+ // 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
4493
+ readShapeCdcPage(sql, sinceSeq, tables) {
4494
+ return readCdcChanges(sql, { sinceSeq, tables });
4495
+ }
4496
+ /**
4497
+ * Build the row-ops for a shape over the op range `(sinceSeq, upTo]`. Reads
4498
+ * the changelog (drained across pages via {@link readShapeOpRange}, shared
4499
+ * across same-range shapes in a flush), collapses to the latest op per row,
4500
+ * then runs ONE membership probe ({@link selectShapeMemberIds}) over the
4501
+ * changed ids: a row still in the set → upsert with its post-image doc
4502
+ * (projected to the shape's columns); a row that left the set, or any delete,
4503
+ * → `delete(key)` (a delete carries no post-image, so membership is
4504
+ * unknowable from the op alone — the client no-ops an unknown key).
4505
+ */
4506
+ buildShapeDiff(sql, resolved, sinceSeq, upTo, opRangeCache) {
4507
+ const latest = this.readShapeOpRange(sql, resolved.table, sinceSeq, upTo, opRangeCache);
4418
4508
  if (latest.size === 0) {
4419
4509
  return [];
4420
4510
  }
@@ -4753,6 +4843,10 @@ class ShardDO {
4753
4843
  const existing = memos.get(subId);
4754
4844
  if (existing?.lastJson === json) {
4755
4845
  existing.tables = outcome.tables;
4846
+ const settledWatermark = this.socketClientWatermark(ws);
4847
+ if (settledWatermark !== void 0) {
4848
+ trySendFrame(ws, `{"type":"settled","id":${JSON.stringify(subId)},"lastMutationId":${String(settledWatermark)}${cursorSuffix}}`);
4849
+ }
4756
4850
  return;
4757
4851
  }
4758
4852
  const deltaFrames = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/do",
3
- "version": "1.0.0-alpha.10",
3
+ "version": "1.0.0-alpha.12",
4
4
  "description": "Lunora Durable Objects: ShardDO (SQLite, OCC, hibernated WebSocket subscriptions) and SessionDO",
5
5
  "keywords": [
6
6
  "cloudflare",