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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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-DtZqOBIF.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__";
@@ -3091,16 +3108,16 @@ class ShardDO {
3091
3108
  return jsonResponse({ error: { code: error.code, message: error.message } }, error.status);
3092
3109
  }
3093
3110
  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);
3111
+ const message = error instanceof Error ? error.message : "validation failed";
3112
+ return jsonResponse({ error: { code: "VALIDATION_ERROR", message } }, 400);
3096
3113
  }
3097
3114
  if (error && typeof error === "object" && error.name === "LunoraError") {
3098
3115
  const lunoraError = error;
3099
3116
  const status = typeof lunoraError.status === "number" ? lunoraError.status : 500;
3100
3117
  return jsonResponse({ error: { code: lunoraError.code ?? "INTERNAL", message: lunoraError.message ?? "internal error" } }, status);
3101
3118
  }
3102
- const message = error instanceof Error ? error.message : "unknown error";
3103
- return jsonResponse({ error: { code: "RPC_FAILED", message } }, 500);
3119
+ console.error("[@lunora/do] unhandled RPC error:", error);
3120
+ return jsonResponse({ error: { code: "RPC_FAILED", message: "internal error" } }, 500);
3104
3121
  }
3105
3122
  /**
3106
3123
  * Serve a reserved admin-introspection RPC (`__lunora_admin__:*`) for the
@@ -3920,6 +3937,54 @@ class ShardDO {
3920
3937
  }
3921
3938
  return this.executeSubscription(functionPath, args, identity);
3922
3939
  }
3940
+ /**
3941
+ * SECURITY BOUNDARY for cross-socket reactive dedup. A read is
3942
+ * identity-INDEPENDENT only when its result cannot vary by the caller's
3943
+ * verified identity — i.e. the admin/reserved introspection reads, which
3944
+ * route to {@link executeAdminSubscription} and ignore the
3945
+ * {@link SubscriptionIdentity} entirely.
3946
+ *
3947
+ * Everything else is identity-DEPENDENT and must NEVER be shared across
3948
+ * sockets: a user query may be `rls()` / `ctx.auth`-scoped (different rows
3949
+ * per identity), and a flag read ({@link FLAGS_FUNCTION_PREFIX}) evaluates
3950
+ * the provider with the subscriber's identity (per-user targeting). Sharing
3951
+ * one socket's result with another would leak one identity's rows/flags to a
3952
+ * different identity, so this predicate gates {@link resolveReactiveOutcomeDeduped}
3953
+ * shut for them.
3954
+ */
3955
+ // eslint-disable-next-line class-methods-use-this, @typescript-eslint/member-ordering -- pure predicate over the function path; a protected method so the security boundary lives in one named place (and tests can probe it), co-located with the reactive dedup it gates rather than hoisted away from its only caller
3956
+ isIdentityIndependent(functionPath) {
3957
+ return functionPath.startsWith(ADMIN_FUNCTION_PREFIX);
3958
+ }
3959
+ /**
3960
+ * Memoizing wrapper over {@link resolveReactiveOutcome}: flush-local sharing across sockets.
3961
+ * Within a single {@link refreshSubscriptions} pass, N sockets subscribed to
3962
+ * the SAME identity-independent `(functionPath, args)` re-run the query N
3963
+ * times today (see the Case-6 fan-out characterization). When the read is
3964
+ * identity-independent (admin/reserved — see {@link isIdentityIndependent})
3965
+ * its result is the same for every socket, so the first run is cached (by its
3966
+ * in-flight Promise, since the bounded worker pool runs sockets in parallel)
3967
+ * and shared with the rest — collapsing N runs to ONE.
3968
+ *
3969
+ * Identity-DEPENDENT reads are passed straight through, UNCACHED: each socket
3970
+ * must evaluate under its own by-value identity (RLS / `ctx.auth` / per-user
3971
+ * flags), so they never share a result. The `cache` is created fresh per
3972
+ * flush by the caller, so a result is never reused across passes (it would go
3973
+ * stale after the next write).
3974
+ */
3975
+ resolveReactiveOutcomeDeduped(functionPath, args, isAdmin, identity, cache) {
3976
+ if (!this.isIdentityIndependent(functionPath)) {
3977
+ return this.resolveReactiveOutcome(functionPath, args, isAdmin, identity);
3978
+ }
3979
+ const key = reactiveCacheKey(functionPath, args, null);
3980
+ const cached = cache.get(key);
3981
+ if (cached !== void 0) {
3982
+ return cached;
3983
+ }
3984
+ const pending = this.resolveReactiveOutcome(functionPath, args, isAdmin, identity);
3985
+ cache.set(key, pending);
3986
+ return pending;
3987
+ }
3923
3988
  /**
3924
3989
  * Constant-time bearer check against `env.LUNORA_ADMIN_TOKEN`. Returns
3925
3990
  * `false` (closed) when the token is unset so admin introspection is
@@ -3944,6 +4009,7 @@ class ShardDO {
3944
4009
  * 4. On normal completion send `{type:"complete"}`; on throw send
3945
4010
  * `{type:"error"}`. Either way drop the controller.
3946
4011
  */
4012
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- the stream lifecycle (ack → chunk pump → complete/error) plus the structured-vs-redacted error branch is the wire protocol and reads clearer inline than split across helpers sharing the controller + socket
3947
4013
  async handleStream(ws, id, functionPath, args) {
3948
4014
  const iterable = this.executeStream(functionPath, args);
3949
4015
  if (!iterable) {
@@ -3984,10 +4050,15 @@ class ShardDO {
3984
4050
  }
3985
4051
  } catch (error) {
3986
4052
  const { code } = error;
3987
- const message = error instanceof Error ? error.message : String(error);
4053
+ const isStructured = typeof code === "string";
4054
+ if (!isStructured) {
4055
+ console.error("[@lunora/do] unhandled stream error:", error);
4056
+ }
4057
+ const rawMessage = error instanceof Error ? error.message : String(error);
4058
+ const message = isStructured ? rawMessage : "internal error";
3988
4059
  ws.send(
3989
4060
  JSON.stringify({
3990
- error: { code: typeof code === "string" ? code : "INTERNAL_SERVER_ERROR", message },
4061
+ error: { code: isStructured ? code : "INTERNAL_SERVER_ERROR", message },
3991
4062
  id,
3992
4063
  type: "error"
3993
4064
  })
@@ -4121,6 +4192,7 @@ class ShardDO {
4121
4192
  const sockets = [...this.state.getWebSockets()];
4122
4193
  const frameCursor = this.currentCdcCursor();
4123
4194
  const frameEpoch = this.currentCdcEpoch();
4195
+ const reactiveRunCache = /* @__PURE__ */ new Map();
4124
4196
  const refreshOne = async (ws) => {
4125
4197
  if (this.isSocketExpired(ws)) {
4126
4198
  this.dropExpiredSocket(ws);
@@ -4138,10 +4210,13 @@ class ShardDO {
4138
4210
  continue;
4139
4211
  }
4140
4212
  try {
4141
- const outcome = await this.resolveReactiveOutcome(functionPath, query.args ?? {}, isAdmin, {
4142
- identity: attachment.identity,
4143
- userId: attachment.userId
4144
- });
4213
+ const outcome = await this.resolveReactiveOutcomeDeduped(
4214
+ functionPath,
4215
+ query.args ?? {},
4216
+ isAdmin,
4217
+ { identity: attachment.identity, userId: attachment.userId },
4218
+ reactiveRunCache
4219
+ );
4145
4220
  if (!outcome) {
4146
4221
  continue;
4147
4222
  }
@@ -4152,18 +4227,7 @@ class ShardDO {
4152
4227
  }
4153
4228
  }
4154
4229
  };
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()));
4230
+ await runSocketPool(sockets, refreshOne);
4167
4231
  }
4168
4232
  /**
4169
4233
  * Seed a freshly-registered subscription with its first value. Runs the
@@ -4321,6 +4385,7 @@ class ShardDO {
4321
4385
  const sockets = [...this.state.getWebSockets()];
4322
4386
  const checkpoint = frameCursor ?? this.currentCdcCursor() ?? 0;
4323
4387
  const sql = this.sql;
4388
+ const opRangeCache = /* @__PURE__ */ new Map();
4324
4389
  const pokeOne = async (ws) => {
4325
4390
  if (this.isSocketExpired(ws)) {
4326
4391
  this.dropExpiredSocket(ws);
@@ -4331,32 +4396,24 @@ class ShardDO {
4331
4396
  if (!shapes) {
4332
4397
  return;
4333
4398
  }
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);
4399
+ try {
4400
+ const identity = { identity: attachment.identity, userId: attachment.userId };
4401
+ const { emptyAdvanced, partAdvanced, parts } = this.collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql, opRangeCache);
4402
+ for (const subId of emptyAdvanced) {
4403
+ this.recordShapeMemo(ws, subId, checkpoint);
4404
+ }
4405
+ if (parts.length > 0) {
4406
+ await awaitWsDrain(ws);
4407
+ if (this.sendPoke(ws, parts, checkpoint, frameEpoch, void 0)) {
4408
+ for (const subId of partAdvanced) {
4409
+ this.recordShapeMemo(ws, subId, checkpoint);
4410
+ }
4344
4411
  }
4345
4412
  }
4413
+ } catch {
4346
4414
  }
4347
4415
  };
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()));
4416
+ await runSocketPool(sockets, pokeOne);
4360
4417
  }
4361
4418
  /**
4362
4419
  * Diff every op-log-backed shape a socket holds against this flush, splitting
@@ -4367,7 +4424,7 @@ class ShardDO {
4367
4424
  * diffs advance unconditionally; part-bearing shapes advance only once the
4368
4425
  * caller confirms the poke was delivered.
4369
4426
  */
4370
- collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql) {
4427
+ collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql, opRangeCache) {
4371
4428
  const parts = [];
4372
4429
  const emptyAdvanced = [];
4373
4430
  const partAdvanced = [];
@@ -4378,7 +4435,7 @@ class ShardDO {
4378
4435
  continue;
4379
4436
  }
4380
4437
  const memoCursor = this.shapeMemos.get(ws)?.get(subId)?.cursor ?? 0;
4381
- const rowsPatch = this.buildShapeDiff(sql, resolved, memoCursor, checkpoint);
4438
+ const rowsPatch = this.buildShapeDiff(sql, resolved, memoCursor, checkpoint, opRangeCache);
4382
4439
  if (rowsPatch.length > 0) {
4383
4440
  parts.push({ rowsPatch, shapeId: subId });
4384
4441
  partAdvanced.push(subId);
@@ -4392,21 +4449,26 @@ class ShardDO {
4392
4449
  return { emptyAdvanced, partAdvanced, parts };
4393
4450
  }
4394
4451
  /**
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).
4452
+ * Drain the op-log range `(sinceSeq, upTo]` for `table` into the latest op per
4453
+ * row id (collapsing multiple ops on the same row to the newest). Within one
4454
+ * flush, every shape over the SAME `(table, sinceSeq, upTo)` reads the
4455
+ * identical changelog slice, so the drained map is memoized in the
4456
+ * caller-supplied `cache` (created fresh per flush) N shapes on a table
4457
+ * share ONE changelog drain instead of re-scanning it per shape. The
4458
+ * per-shape membership probe still runs per shape (its predicate is
4459
+ * identity/args-specific), so only the shared op read is collapsed.
4402
4460
  */
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) {
4461
+ readShapeOpRange(sql, table, sinceSeq, upTo, cache) {
4462
+ const key = `${table}\0${String(sinceSeq)}\0${String(upTo)}`;
4463
+ const cached = cache?.get(key);
4464
+ if (cached !== void 0) {
4465
+ return cached;
4466
+ }
4405
4467
  const latest = /* @__PURE__ */ new Map();
4406
- const tables = /* @__PURE__ */ new Set([resolved.table]);
4468
+ const tables = /* @__PURE__ */ new Set([table]);
4407
4469
  let from = sinceSeq;
4408
4470
  for (; ; ) {
4409
- const { changes, cursor } = readCdcChanges(sql, { sinceSeq: from, tables });
4471
+ const { changes, cursor } = this.readShapeCdcPage(sql, from, tables);
4410
4472
  for (const change of changes) {
4411
4473
  latest.set(change.id, change);
4412
4474
  }
@@ -4415,6 +4477,31 @@ class ShardDO {
4415
4477
  }
4416
4478
  from = cursor;
4417
4479
  }
4480
+ cache?.set(key, latest);
4481
+ return latest;
4482
+ }
4483
+ /**
4484
+ * Read one page of the `__cdc_log` for a shape diff (table-scoped). A thin
4485
+ * protected seam over {@link readCdcChanges}: it isolates the single
4486
+ * changelog read that {@link readShapeOpRange} memoizes per flush, and gives
4487
+ * tests a point to count the reads the op-range cache collapses.
4488
+ */
4489
+ // eslint-disable-next-line class-methods-use-this, @typescript-eslint/member-ordering -- thin pass-through seam over the module-level reader; a protected method so the op-range cache + tests share one read point, co-located with the poke path it serves rather than hoisted away from its only caller
4490
+ readShapeCdcPage(sql, sinceSeq, tables) {
4491
+ return readCdcChanges(sql, { sinceSeq, tables });
4492
+ }
4493
+ /**
4494
+ * Build the row-ops for a shape over the op range `(sinceSeq, upTo]`. Reads
4495
+ * the changelog (drained across pages via {@link readShapeOpRange}, shared
4496
+ * across same-range shapes in a flush), collapses to the latest op per row,
4497
+ * then runs ONE membership probe ({@link selectShapeMemberIds}) over the
4498
+ * changed ids: a row still in the set → upsert with its post-image doc
4499
+ * (projected to the shape's columns); a row that left the set, or any delete,
4500
+ * → `delete(key)` (a delete carries no post-image, so membership is
4501
+ * unknowable from the op alone — the client no-ops an unknown key).
4502
+ */
4503
+ buildShapeDiff(sql, resolved, sinceSeq, upTo, opRangeCache) {
4504
+ const latest = this.readShapeOpRange(sql, resolved.table, sinceSeq, upTo, opRangeCache);
4418
4505
  if (latest.size === 0) {
4419
4506
  return [];
4420
4507
  }
@@ -4753,6 +4840,10 @@ class ShardDO {
4753
4840
  const existing = memos.get(subId);
4754
4841
  if (existing?.lastJson === json) {
4755
4842
  existing.tables = outcome.tables;
4843
+ const settledWatermark = this.socketClientWatermark(ws);
4844
+ if (settledWatermark !== void 0) {
4845
+ trySendFrame(ws, `{"type":"settled","id":${JSON.stringify(subId)},"lastMutationId":${String(settledWatermark)}${cursorSuffix}}`);
4846
+ }
4756
4847
  return;
4757
4848
  }
4758
4849
  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.11",
4
4
  "description": "Lunora Durable Objects: ShardDO (SQLite, OCC, hibernated WebSocket subscriptions) and SessionDO",
5
5
  "keywords": [
6
6
  "cloudflare",