@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
|
|
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
|
|
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-
|
|
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
|
|
3095
|
-
return jsonResponse({ error: { code: "VALIDATION_ERROR", message
|
|
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
|
-
|
|
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
|
|
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:
|
|
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.
|
|
4142
|
-
|
|
4143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
4396
|
-
*
|
|
4397
|
-
*
|
|
4398
|
-
*
|
|
4399
|
-
* (
|
|
4400
|
-
*
|
|
4401
|
-
*
|
|
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
|
-
|
|
4404
|
-
|
|
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([
|
|
4468
|
+
const tables = /* @__PURE__ */ new Set([table]);
|
|
4407
4469
|
let from = sinceSeq;
|
|
4408
4470
|
for (; ; ) {
|
|
4409
|
-
const { changes, cursor } =
|
|
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 = [];
|