@lunora/do 1.0.0-alpha.13 → 1.0.0-alpha.15
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 +260 -55
- package/dist/index.d.ts +260 -55
- package/dist/index.mjs +10 -7
- package/dist/packem_shared/{ADMIN_FUNCTIONS-D_UiYJFk.mjs → ADMIN_FUNCTIONS-fMl1vyt1.mjs} +40 -1
- package/dist/packem_shared/{NotUniqueError-DZQtH02h.mjs → NotUniqueError-BXn2j5vu.mjs} +1 -1
- package/dist/packem_shared/{ROOT_DO_SIZE_WARN_BYTES-XEs4csIH.mjs → ROOT_DO_SIZE_WARN_BYTES-CEVhBec2.mjs} +1033 -89
- package/dist/packem_shared/{ReactiveCache-1hDydFyv.mjs → ReactiveCache-BYlSGY0N.mjs} +1 -1
- package/dist/packem_shared/{ctx-db-shapes-DVoeZpo-.mjs → ctx-db-shapes-BQapRFjB.mjs} +1 -1
- package/dist/packem_shared/diffExternalSource-Cx9HUPJj.mjs +44 -0
- package/dist/packem_shared/isSourceDue-BMTJv65e.mjs +40 -0
- package/dist/packem_shared/materializeExternalRows-uKBx9H0s.mjs +23 -0
- package/dist/packem_shared/{serveRelationFanout-C6lDaesn.mjs → serveRelationFanout-EZLD9-O3.mjs} +1 -1
- package/dist/packem_shared/{stableStringify-CyHKJXre.mjs → stableStringify-MydiuScU.mjs} +10 -0
- package/dist/packem_shared/subscription-delivery-Z6nXHvQN.mjs +340 -0
- package/dist/packem_shared/subscriptionListDeltas-CXwfoEE_.mjs +1 -0
- package/package.json +1 -1
- package/dist/packem_shared/subscriptionListDeltas-ce84gpwL.mjs +0 -111
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
import { drizzle } from 'drizzle-orm/durable-sqlite';
|
|
2
|
+
import { e as encodeWire, a as awaitWsDrain, t as trySendFrame, d as decodeWire, s as subscriptionListDeltas, b as sendDeltaFrames } from './subscription-delivery-Z6nXHvQN.mjs';
|
|
2
3
|
import { parseExportShardArgs, parseImportShardArgs } from './exportShardRows-DZEhUeyI.mjs';
|
|
3
4
|
import { recordAuthEvent, readAuthMetrics } from './AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs';
|
|
4
5
|
import { DATA_MIGRATION_STATE_TABLE, readMigrationStatus } from './DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs';
|
|
5
6
|
import { SCAN_DEP, createDependencyTracker, tableFromDepKey } from './SCAN_DEP-DLJF8dsj.mjs';
|
|
6
7
|
import { readFunctionMetricsTotals, readFunctionMetricIndexHits, recordFunctionMetric, mergeScanAttribution, readFunctionMetrics, readFunctionMetricBuckets } from './FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs';
|
|
7
|
-
import { ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, selectMatchingIds, ADMIN_FUNCTIONS, findStorageReferences, listTables, summarizeSubscriptions, readTablePage, facetColumn, FLAGS_FUNCTION_PREFIX, MAX_PAGE_SIZE } from './ADMIN_FUNCTIONS-
|
|
8
|
+
import { createFanoutCounters, ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, selectMatchingIds, ADMIN_FUNCTIONS, findStorageReferences, listTables, summarizeSubscriptions, summarizeFanoutTopics, readTablePage, facetColumn, FLAGS_FUNCTION_PREFIX, recordFanoutPass, MAX_PAGE_SIZE } from './ADMIN_FUNCTIONS-fMl1vyt1.mjs';
|
|
8
9
|
import { LogBuffer } from './LogBuffer-B_Ezju_N.mjs';
|
|
9
10
|
import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './MAIL_RETENTION-CPpgl-dX.mjs';
|
|
10
11
|
import { readBookmark, armRestore } from './armRestore-BJk53Ro8.mjs';
|
|
11
|
-
import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-
|
|
12
|
+
import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-BYlSGY0N.mjs';
|
|
13
|
+
import { stableStringify } from './stableStringify-MydiuScU.mjs';
|
|
12
14
|
import { redact, standardRules } from '@visulima/redact';
|
|
13
15
|
import { i as isDevEnvironment, c as buildSettings, b as buildSecurityAudit } from './security-audit-CucgBice.mjs';
|
|
14
16
|
import { runReadonlySql } from './MAX_SQL_ROWS-dDcFE1YZ.mjs';
|
|
15
|
-
import { trySendFrame, subscriptionListDeltas, sendDeltaFrames } from './subscriptionListDeltas-ce84gpwL.mjs';
|
|
16
17
|
import { ConflictError } from './ConflictError-C0STs6bU.mjs';
|
|
17
18
|
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
19
|
import { CDC_LOG_TABLE, readCdcChanges, readCdcCursor, readCdcEpoch, minCdcSeq, bumpCdcEpoch } from './CDC_LOG_TABLE-DSycmnDf.mjs';
|
|
19
|
-
import {
|
|
20
|
+
import { a as selectShapeMemberIds, s as selectShapeRows } from './ctx-db-shapes-BQapRFjB.mjs';
|
|
21
|
+
|
|
22
|
+
const MAX_BATCH_ENTRIES = 500;
|
|
20
23
|
|
|
21
24
|
const AUDIT_LOG_TABLE = "__lunora_audit__";
|
|
22
25
|
const AUDIT_LOG_RETENTION = 1e3;
|
|
@@ -78,6 +81,38 @@ const readAuditLog = (sql, options = {}) => {
|
|
|
78
81
|
});
|
|
79
82
|
};
|
|
80
83
|
|
|
84
|
+
const SHARED_BATCH_HEADERS = [
|
|
85
|
+
"x-lunora-userid",
|
|
86
|
+
"x-lunora-identity",
|
|
87
|
+
"x-d1-bookmark",
|
|
88
|
+
"x-lunora-client-ip",
|
|
89
|
+
"x-lunora-system",
|
|
90
|
+
"x-lunora-shard-binding"
|
|
91
|
+
];
|
|
92
|
+
const buildBatchEntryRequest = (batchRequest, entry) => {
|
|
93
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
94
|
+
for (const name of SHARED_BATCH_HEADERS) {
|
|
95
|
+
const value = batchRequest.headers.get(name);
|
|
96
|
+
if (value !== null) {
|
|
97
|
+
headers.set(name, value);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (entry.mutationId !== void 0) {
|
|
101
|
+
headers.set("x-lunora-mutation-id", entry.mutationId);
|
|
102
|
+
}
|
|
103
|
+
if (entry.clientId !== void 0) {
|
|
104
|
+
headers.set("x-lunora-client-id", entry.clientId);
|
|
105
|
+
}
|
|
106
|
+
if (entry.clientSeq !== void 0) {
|
|
107
|
+
headers.set("x-lunora-client-seq", String(entry.clientSeq));
|
|
108
|
+
}
|
|
109
|
+
return new Request("https://shard.internal/rpc", {
|
|
110
|
+
body: JSON.stringify({ args: entry.args ?? {}, functionPath: entry.functionPath }),
|
|
111
|
+
headers,
|
|
112
|
+
method: "POST"
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
81
116
|
const QUERY_METRICS_TABLE = "__lunora_metrics_queries";
|
|
82
117
|
const QUERY_METRICS_MAX_SQL_LEN = 512;
|
|
83
118
|
const QUERY_METRICS_MAX_STATEMENTS = 500;
|
|
@@ -144,6 +179,714 @@ const readQueryMetrics = (sql) => {
|
|
|
144
179
|
});
|
|
145
180
|
};
|
|
146
181
|
|
|
182
|
+
const RELAY_NAME_INFIX = "::relay::";
|
|
183
|
+
const relayName = (ownerKey, index) => `${ownerKey}${RELAY_NAME_INFIX}${String(index)}`;
|
|
184
|
+
const parseRelayName = (name) => {
|
|
185
|
+
const at = name.lastIndexOf(RELAY_NAME_INFIX);
|
|
186
|
+
if (at === -1) {
|
|
187
|
+
return void 0;
|
|
188
|
+
}
|
|
189
|
+
const ownerKey = name.slice(0, at);
|
|
190
|
+
const indexText = name.slice(at + RELAY_NAME_INFIX.length);
|
|
191
|
+
const relayIndex = Number(indexText);
|
|
192
|
+
if (ownerKey.length === 0 || !Number.isInteger(relayIndex) || relayIndex < 0 || String(relayIndex) !== indexText) {
|
|
193
|
+
return void 0;
|
|
194
|
+
}
|
|
195
|
+
return { ownerKey, relayIndex };
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const shapeRoutingKey = (name, args) => stableStringify({ args: args ?? {}, name });
|
|
199
|
+
const DEFAULT_PROMOTION_THRESHOLDS = { tUp: 8e3 };
|
|
200
|
+
|
|
201
|
+
const projectColumns = (document_, columns) => {
|
|
202
|
+
if (!columns) {
|
|
203
|
+
return document_;
|
|
204
|
+
}
|
|
205
|
+
const projected = /* @__PURE__ */ Object.create(null);
|
|
206
|
+
for (const key of ["_id", "_creationTime", ...columns]) {
|
|
207
|
+
if (Object.hasOwn(document_, key)) {
|
|
208
|
+
projected[key] = document_[key];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return projected;
|
|
212
|
+
};
|
|
213
|
+
const diffGlobalMembership = (rows, previous, options) => {
|
|
214
|
+
const { columns, table } = options;
|
|
215
|
+
const next = /* @__PURE__ */ new Map();
|
|
216
|
+
const rowsPatch = [];
|
|
217
|
+
for (const { doc, id } of rows) {
|
|
218
|
+
const value = projectColumns(doc, columns);
|
|
219
|
+
const json = JSON.stringify(encodeWire(value));
|
|
220
|
+
next.set(id, json);
|
|
221
|
+
const before = previous.get(id);
|
|
222
|
+
if (before === void 0) {
|
|
223
|
+
rowsPatch.push({ key: id, op: "insert", table, value });
|
|
224
|
+
} else if (before !== json) {
|
|
225
|
+
rowsPatch.push({ key: id, op: "update", table, value });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const id of previous.keys()) {
|
|
229
|
+
if (!next.has(id)) {
|
|
230
|
+
rowsPatch.push({ key: id, op: "delete", table });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { next, rowsPatch };
|
|
234
|
+
};
|
|
235
|
+
const buildPokeFrames = (parts, meta) => {
|
|
236
|
+
const { baseCheckpoint, checkpoint, epoch, lastMutationId, pokeId } = meta;
|
|
237
|
+
const frames = [JSON.stringify({ baseCheckpoint, epoch, pokeId, type: "pokeStart" })];
|
|
238
|
+
for (const part of parts) {
|
|
239
|
+
const rowsPatch = part.rowsPatch.map((op) => op.value === void 0 ? op : { ...op, value: encodeWire(op.value) });
|
|
240
|
+
frames.push(
|
|
241
|
+
JSON.stringify({
|
|
242
|
+
pokeId,
|
|
243
|
+
rowsPatch,
|
|
244
|
+
shapeId: part.shapeId,
|
|
245
|
+
type: "pokePart",
|
|
246
|
+
...lastMutationId === void 0 ? {} : { lastMutationId }
|
|
247
|
+
})
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
frames.push(JSON.stringify({ checkpoint, epoch, pokeId, type: "pokeEnd" }));
|
|
251
|
+
return frames;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const DEFAULT_RELAY_FAN = 2;
|
|
255
|
+
const DEFAULT_MAX_RELAYS = 8;
|
|
256
|
+
const envPositiveInt = (env, key, fallback) => {
|
|
257
|
+
const raw = env?.[key];
|
|
258
|
+
let parsed = Number.NaN;
|
|
259
|
+
if (typeof raw === "string") {
|
|
260
|
+
parsed = Number.parseInt(raw, 10);
|
|
261
|
+
} else if (typeof raw === "number") {
|
|
262
|
+
parsed = raw;
|
|
263
|
+
}
|
|
264
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
265
|
+
};
|
|
266
|
+
const RELAY_MULTICAST_IDENTITY = {};
|
|
267
|
+
const assertNeverFrame = (frame) => {
|
|
268
|
+
throw new Error(`unhandled relay frame: ${JSON.stringify(frame)}`);
|
|
269
|
+
};
|
|
270
|
+
const asRelayNamespace = (value) => {
|
|
271
|
+
if (value === null || typeof value !== "object") {
|
|
272
|
+
return void 0;
|
|
273
|
+
}
|
|
274
|
+
const candidate = value;
|
|
275
|
+
return typeof candidate.idFromName === "function" && typeof candidate.get === "function" ? candidate : void 0;
|
|
276
|
+
};
|
|
277
|
+
const jsonRelayResponse = (body) => Response.json(body, { headers: { "content-type": "application/json" } });
|
|
278
|
+
const noContent = () => new Response(null, { status: 204 });
|
|
279
|
+
class RelayLink {
|
|
280
|
+
constructor(host, roleId) {
|
|
281
|
+
this.host = host;
|
|
282
|
+
this.roleId = roleId;
|
|
283
|
+
}
|
|
284
|
+
host;
|
|
285
|
+
roleId;
|
|
286
|
+
/**
|
|
287
|
+
* Serve the internal `/_lunora/relay` control channel: parse the frame, then
|
|
288
|
+
* dispatch to the role hooks. One exhaustive switch means a new frame type added
|
|
289
|
+
* to `relay.ts` without a case here is a COMPILE error, not a runtime mis-route.
|
|
290
|
+
*/
|
|
291
|
+
async handleControl(request) {
|
|
292
|
+
let message;
|
|
293
|
+
try {
|
|
294
|
+
message = await request.json();
|
|
295
|
+
} catch {
|
|
296
|
+
return new Response("bad request", { status: 400 });
|
|
297
|
+
}
|
|
298
|
+
switch (message.type) {
|
|
299
|
+
case "relay_attach": {
|
|
300
|
+
this.onAttach(message.relayIndex);
|
|
301
|
+
return noContent();
|
|
302
|
+
}
|
|
303
|
+
case "relay_detach": {
|
|
304
|
+
this.onDetach(message.relayIndex);
|
|
305
|
+
return noContent();
|
|
306
|
+
}
|
|
307
|
+
case "relay_frame": {
|
|
308
|
+
this.host.deliverWhisperLocal(message.topic, message.frame, void 0);
|
|
309
|
+
await this.onWhisperFrame(message);
|
|
310
|
+
return noContent();
|
|
311
|
+
}
|
|
312
|
+
case "relay_shape_poke": {
|
|
313
|
+
const iterated = this.host.getWebSockets().length;
|
|
314
|
+
const startMs = Date.now();
|
|
315
|
+
const delivered = this.onShapePoke(message);
|
|
316
|
+
this.host.recordShapePokeFanout(iterated, delivered, Date.now() - startMs);
|
|
317
|
+
return noContent();
|
|
318
|
+
}
|
|
319
|
+
case "relay_shape_subscribe": {
|
|
320
|
+
return jsonRelayResponse(this.onShapeSubscribe(message));
|
|
321
|
+
}
|
|
322
|
+
default: {
|
|
323
|
+
return assertNeverFrame(message);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** The hard cap on relays per shard (`LUNORA_MAX_RELAYS`) — a deployment constant the runtime surfaces in Studio as the cost ceiling. */
|
|
328
|
+
maxRelays() {
|
|
329
|
+
return envPositiveInt(this.host.env(), "LUNORA_MAX_RELAYS", DEFAULT_MAX_RELAYS);
|
|
330
|
+
}
|
|
331
|
+
/** Whether this DO can currently address its siblings (a namespace binding has been learned) — the relay tier is inert in single-DO mode. */
|
|
332
|
+
canAddressSiblings() {
|
|
333
|
+
return this.relayNamespace() !== void 0;
|
|
334
|
+
}
|
|
335
|
+
/** Resolve this DO's own namespace binding so it can address sibling owners/relays, or `undefined` when unknown. */
|
|
336
|
+
relayNamespace() {
|
|
337
|
+
const binding = this.host.shardBinding();
|
|
338
|
+
if (binding === void 0) {
|
|
339
|
+
return void 0;
|
|
340
|
+
}
|
|
341
|
+
return asRelayNamespace(this.host.env()?.[binding]);
|
|
342
|
+
}
|
|
343
|
+
/** POST a control frame to a sibling by name, fire-and-forget. Best-effort: a transient cross-DO failure drops the frame rather than throwing into the handler. */
|
|
344
|
+
async postRelayMessage(targetName, message) {
|
|
345
|
+
await this.requestRelayMessage(targetName, message);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* POST a control frame to a sibling by name and return its response (the shape-seed
|
|
349
|
+
* path needs the owner's frames back). Best-effort: a transient cross-DO failure
|
|
350
|
+
* returns `undefined` rather than throwing.
|
|
351
|
+
* @returns the sibling's response, or `undefined` when it can't be reached
|
|
352
|
+
*/
|
|
353
|
+
async requestRelayMessage(targetName, message) {
|
|
354
|
+
const namespace = this.relayNamespace();
|
|
355
|
+
if (namespace === void 0) {
|
|
356
|
+
return void 0;
|
|
357
|
+
}
|
|
358
|
+
const stub = typeof namespace.getByName === "function" ? namespace.getByName(targetName) : namespace.get(namespace.idFromName(targetName));
|
|
359
|
+
try {
|
|
360
|
+
return await stub.fetch("https://relay.internal/_lunora/relay", {
|
|
361
|
+
body: JSON.stringify(message),
|
|
362
|
+
headers: { "content-type": "application/json", "x-lunora-shard-binding": this.host.shardBinding() ?? "" },
|
|
363
|
+
method: "POST"
|
|
364
|
+
});
|
|
365
|
+
} catch {
|
|
366
|
+
return void 0;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
class OwnerRelay extends RelayLink {
|
|
371
|
+
/** Memoized RLS-uniform verdict per `(name, args)` shape — uniformity is stable, so the gate probe runs at most once per distinct shape. */
|
|
372
|
+
shapeUniformCache = /* @__PURE__ */ new Map();
|
|
373
|
+
/** Active relay indices, hydrated once from `__lunora_relays` and cached for the synchronous forward path. */
|
|
374
|
+
relaySetCache;
|
|
375
|
+
/** Relay-uniform shapes a relay has subscribers for, keyed by `(name, args)`. `cursor` is the cohort frontier the owner has multicast deltas up to. */
|
|
376
|
+
relayShapeRegistry = /* @__PURE__ */ new Map();
|
|
377
|
+
/** NON-uniform (identity-scoped) relay shapes, one entry per relay socket, keyed `relayIndex:connectionId:subId`. Each is served live by a per-socket proxy poke. */
|
|
378
|
+
relayShapeProxies = /* @__PURE__ */ new Map();
|
|
379
|
+
constructor(host, ownerKey) {
|
|
380
|
+
super(host, { ownerKey });
|
|
381
|
+
}
|
|
382
|
+
async forwardWhisper(topic, frame) {
|
|
383
|
+
if (!this.canAddressSiblings()) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const relays = this.ownerRelaySet();
|
|
387
|
+
if (relays.size === 0) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
await Promise.all([...relays].map((index) => this.postRelayMessage(relayName(this.roleId.ownerKey, index), { frame, topic, type: "relay_frame" })));
|
|
391
|
+
}
|
|
392
|
+
async onFlush(changed, frameCursor) {
|
|
393
|
+
await Promise.all([this.multicastShapePokes(changed, frameCursor), this.proxyShapePokes(changed, frameCursor)]);
|
|
394
|
+
}
|
|
395
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: an owner serves its own shape subscribers locally, never through a relay
|
|
396
|
+
seedRelayShape() {
|
|
397
|
+
return Promise.resolve(void 0);
|
|
398
|
+
}
|
|
399
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only a relay announces
|
|
400
|
+
announce() {
|
|
401
|
+
return Promise.resolve();
|
|
402
|
+
}
|
|
403
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only a relay drains
|
|
404
|
+
announceDrain() {
|
|
405
|
+
return Promise.resolve();
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* How many relays the runtime should spread new connections across for this shard
|
|
409
|
+
* (plan 075 Phase 2). `0` keeps every connection on the owner. The owner promotes
|
|
410
|
+
* once its live socket count crosses `LUNORA_RELAY_THRESHOLD`, fanning to a fixed
|
|
411
|
+
* `LUNORA_RELAY_FAN` (capped by `LUNORA_MAX_RELAYS`, the cost ceiling).
|
|
412
|
+
*/
|
|
413
|
+
relayCount() {
|
|
414
|
+
const threshold = envPositiveInt(this.host.env(), "LUNORA_RELAY_THRESHOLD", DEFAULT_PROMOTION_THRESHOLDS.tUp);
|
|
415
|
+
if (this.host.getWebSockets().length < threshold) {
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
const maxRelays = envPositiveInt(this.host.env(), "LUNORA_MAX_RELAYS", DEFAULT_MAX_RELAYS);
|
|
419
|
+
const fan = envPositiveInt(this.host.env(), "LUNORA_RELAY_FAN", DEFAULT_RELAY_FAN);
|
|
420
|
+
return Math.min(maxRelays, Math.max(1, fan));
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* The RLS-uniform gate (plan 075 Phase 3, review-hardened): whether a reactive
|
|
424
|
+
* shape may be relay-multicast — i.e. one delta is correct for **every**
|
|
425
|
+
* subscriber. Fail-closed on four grounds — a static RLS read-policy guard, the
|
|
426
|
+
* anonymous multicast identity as the probe base, two `Proxy`-backed probes that
|
|
427
|
+
* yield a distinct value for ANY accessed claim (so any claim a `where` reads —
|
|
428
|
+
* even a custom one outside `rls()` — diverges), and a wholesale-copy backstop.
|
|
429
|
+
* Cached per `(name, args)`, whose uniformity is stable.
|
|
430
|
+
*/
|
|
431
|
+
isShapeRelayUniform(name, args) {
|
|
432
|
+
const cacheKey = shapeRoutingKey(name, args);
|
|
433
|
+
const cached = this.shapeUniformCache.get(cacheKey);
|
|
434
|
+
if (cached !== void 0) {
|
|
435
|
+
return cached;
|
|
436
|
+
}
|
|
437
|
+
const uniform = this.probeShapeRelayUniform(name, args);
|
|
438
|
+
this.shapeUniformCache.set(cacheKey, uniform);
|
|
439
|
+
return uniform;
|
|
440
|
+
}
|
|
441
|
+
onAttach(index) {
|
|
442
|
+
this.addRelayToSet(index);
|
|
443
|
+
}
|
|
444
|
+
onDetach(index) {
|
|
445
|
+
this.removeRelayFromSet(index);
|
|
446
|
+
}
|
|
447
|
+
async onWhisperFrame(message) {
|
|
448
|
+
await Promise.all(
|
|
449
|
+
[...this.ownerRelaySet()].filter((index) => index !== message.originRelay).map(
|
|
450
|
+
(index) => this.postRelayMessage(relayName(this.roleId.ownerKey, index), { frame: message.frame, topic: message.topic, type: "relay_frame" })
|
|
451
|
+
)
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
onShapeSubscribe(message) {
|
|
455
|
+
return this.buildShapeSeedFrames(message);
|
|
456
|
+
}
|
|
457
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: an owner doesn't receive multicast pokes (it sends them)
|
|
458
|
+
onShapePoke() {
|
|
459
|
+
return 0;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Owner side (slice B.2): for every registered relay-uniform shape whose table
|
|
463
|
+
* changed this flush, compute the membership diff ONCE over `(cohort cursor,
|
|
464
|
+
* frameCursor]` and multicast the `rowsPatch` to every relay. Advances the cohort
|
|
465
|
+
* cursor synchronously (before any await) so a seed interleaving during the
|
|
466
|
+
* multicast registers at `frameCursor` and is skipped by this in-flight poke.
|
|
467
|
+
*/
|
|
468
|
+
async multicastShapePokes(changed, frameCursor) {
|
|
469
|
+
if (this.relayShapeRegistry.size === 0) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const relays = this.ownerRelaySet();
|
|
473
|
+
if (relays.size === 0) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const epoch = this.host.currentCdcEpoch();
|
|
477
|
+
const sends = [];
|
|
478
|
+
for (const entry of this.relayShapeRegistry.values()) {
|
|
479
|
+
let resolved;
|
|
480
|
+
try {
|
|
481
|
+
resolved = this.host.resolveShape(entry.name, entry.args, RELAY_MULTICAST_IDENTITY);
|
|
482
|
+
} catch {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (resolved === void 0 || resolved.global === true || !changed.has(resolved.table)) {
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const fromCursor = entry.cursor;
|
|
489
|
+
const rowsPatch = this.host.buildShapeDiff(resolved, fromCursor, frameCursor);
|
|
490
|
+
if (rowsPatch.length === 0) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
entry.cursor = frameCursor;
|
|
494
|
+
const poke = {
|
|
495
|
+
args: entry.args,
|
|
496
|
+
checkpoint: frameCursor,
|
|
497
|
+
epoch,
|
|
498
|
+
fromCursor,
|
|
499
|
+
name: entry.name,
|
|
500
|
+
rowsPatch,
|
|
501
|
+
type: "relay_shape_poke"
|
|
502
|
+
};
|
|
503
|
+
for (const index of relays) {
|
|
504
|
+
sends.push(this.postRelayMessage(relayName(this.roleId.ownerKey, index), poke));
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
await Promise.all(sends);
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Owner side (review MEDIUM-3): for every NON-uniform relay-shape proxy whose
|
|
511
|
+
* table changed this flush, compute that one subscriber's diff over `(entry.cursor,
|
|
512
|
+
* frameCursor]` UNDER ITS OWN forwarded identity (RLS-correct) and deliver a
|
|
513
|
+
* `targetConnectionId`-addressed poke to just that socket's relay. Each entry
|
|
514
|
+
* tracks its own cursor — the diffs are identity-specific, no cohort sharing.
|
|
515
|
+
*/
|
|
516
|
+
async proxyShapePokes(changed, frameCursor) {
|
|
517
|
+
if (this.relayShapeProxies.size === 0) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const epoch = this.host.currentCdcEpoch();
|
|
521
|
+
const sends = [];
|
|
522
|
+
for (const entry of this.relayShapeProxies.values()) {
|
|
523
|
+
let resolved;
|
|
524
|
+
try {
|
|
525
|
+
resolved = this.host.resolveShape(entry.name, entry.args, entry.identity);
|
|
526
|
+
} catch {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (resolved === void 0 || resolved.global === true || !changed.has(resolved.table)) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const fromCursor = entry.cursor;
|
|
533
|
+
const rowsPatch = this.host.buildShapeDiff(resolved, fromCursor, frameCursor);
|
|
534
|
+
if (rowsPatch.length === 0) {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
entry.cursor = frameCursor;
|
|
538
|
+
const poke = {
|
|
539
|
+
args: entry.args,
|
|
540
|
+
checkpoint: frameCursor,
|
|
541
|
+
epoch,
|
|
542
|
+
fromCursor,
|
|
543
|
+
name: entry.name,
|
|
544
|
+
rowsPatch,
|
|
545
|
+
targetConnectionId: entry.connectionId,
|
|
546
|
+
type: "relay_shape_poke"
|
|
547
|
+
};
|
|
548
|
+
sends.push(this.postRelayMessage(relayName(this.roleId.ownerKey, entry.relayIndex), poke));
|
|
549
|
+
}
|
|
550
|
+
await Promise.all(sends);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Serialize a shape's seed poke frames for a relay to deliver verbatim. Resolves
|
|
554
|
+
* under the forwarded socket identity (so RLS applies exactly as for a local
|
|
555
|
+
* subscribe), self-heals the relay set, registers the shape for live updates
|
|
556
|
+
* (cohort multicast when uniform, per-socket proxy when not), and stamps the
|
|
557
|
+
* relay's cohort memo at the registry FRONTIER (not the global cursor) so a late
|
|
558
|
+
* joiner is never stranded. `lastMutationId` is omitted (relayed sockets are
|
|
559
|
+
* owner-served for custom mutators).
|
|
560
|
+
* @returns the serialized frames + the cohort-memo cursor, or an error
|
|
561
|
+
*/
|
|
562
|
+
buildShapeSeedFrames(request) {
|
|
563
|
+
const identity = { identity: request.identity, userId: request.userId };
|
|
564
|
+
let resolved;
|
|
565
|
+
try {
|
|
566
|
+
resolved = this.host.resolveShape(request.name, request.args, identity);
|
|
567
|
+
} catch (error) {
|
|
568
|
+
return { error: { code: "SHAPE_RESOLVE_FAILED", message: error instanceof Error ? error.message : "shape resolve failed" } };
|
|
569
|
+
}
|
|
570
|
+
if (resolved === void 0 || resolved.global === true) {
|
|
571
|
+
return { error: { code: "SHAPE_NOT_FOUND", message: `shape not relayable: ${request.name}` } };
|
|
572
|
+
}
|
|
573
|
+
if (request.relayIndex !== void 0) {
|
|
574
|
+
this.addRelayToSet(request.relayIndex);
|
|
575
|
+
}
|
|
576
|
+
const { baseCheckpoint, cursor, epoch, rowsPatch } = this.host.computeOpLogShapeSeed(
|
|
577
|
+
{ args: request.args, name: request.name, sinceEpoch: request.sinceEpoch, sinceSeq: request.sinceSeq },
|
|
578
|
+
resolved
|
|
579
|
+
);
|
|
580
|
+
let cohortCursor = cursor;
|
|
581
|
+
if (this.isShapeRelayUniform(request.name, request.args)) {
|
|
582
|
+
const routingKey = shapeRoutingKey(request.name, request.args);
|
|
583
|
+
let entry = this.relayShapeRegistry.get(routingKey);
|
|
584
|
+
if (entry === void 0) {
|
|
585
|
+
entry = { args: request.args, cursor, name: request.name };
|
|
586
|
+
this.relayShapeRegistry.set(routingKey, entry);
|
|
587
|
+
}
|
|
588
|
+
cohortCursor = entry.cursor;
|
|
589
|
+
} else if (request.relayIndex !== void 0 && request.connectionId !== void 0) {
|
|
590
|
+
this.relayShapeProxies.set(`${String(request.relayIndex)}:${request.connectionId}:${request.subId}`, {
|
|
591
|
+
args: request.args,
|
|
592
|
+
connectionId: request.connectionId,
|
|
593
|
+
cursor,
|
|
594
|
+
epoch,
|
|
595
|
+
identity,
|
|
596
|
+
name: request.name,
|
|
597
|
+
relayIndex: request.relayIndex,
|
|
598
|
+
subId: request.subId
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
const frames = buildPokeFrames([{ rowsPatch, shapeId: request.subId }], {
|
|
602
|
+
baseCheckpoint,
|
|
603
|
+
checkpoint: cursor,
|
|
604
|
+
epoch,
|
|
605
|
+
lastMutationId: void 0,
|
|
606
|
+
pokeId: this.host.nextPokeId()
|
|
607
|
+
});
|
|
608
|
+
return { cursor: cohortCursor, epoch, frames };
|
|
609
|
+
}
|
|
610
|
+
/** Ensure the reserved owner-side relay-set table exists (auto-hidden from the data browser by the `__lunora` prefix). */
|
|
611
|
+
ensureRelayTable() {
|
|
612
|
+
this.host.sql().exec("CREATE TABLE IF NOT EXISTS __lunora_relays (idx INTEGER PRIMARY KEY)");
|
|
613
|
+
}
|
|
614
|
+
/** The owner's active relay indices, hydrated once from `__lunora_relays` and cached for the synchronous forward path. */
|
|
615
|
+
ownerRelaySet() {
|
|
616
|
+
if (this.relaySetCache === void 0) {
|
|
617
|
+
this.ensureRelayTable();
|
|
618
|
+
const rows = this.host.sql().exec("SELECT idx FROM __lunora_relays").toArray();
|
|
619
|
+
this.relaySetCache = new Set(rows.map((row) => Number(row.idx)));
|
|
620
|
+
}
|
|
621
|
+
return this.relaySetCache;
|
|
622
|
+
}
|
|
623
|
+
/** Record a relay as active (idempotent), persisting it so the set survives the owner's hibernation. */
|
|
624
|
+
addRelayToSet(index) {
|
|
625
|
+
this.ensureRelayTable();
|
|
626
|
+
this.host.sql().exec("INSERT OR IGNORE INTO __lunora_relays (idx) VALUES (?)", index);
|
|
627
|
+
this.ownerRelaySet().add(index);
|
|
628
|
+
}
|
|
629
|
+
/** Drop a drained relay from the set and prune its dead per-socket proxy entries; on full drain, clear the (now dead) registry + uniform cache to bound growth. */
|
|
630
|
+
removeRelayFromSet(index) {
|
|
631
|
+
this.ensureRelayTable();
|
|
632
|
+
this.host.sql().exec("DELETE FROM __lunora_relays WHERE idx = ?", index);
|
|
633
|
+
const set = this.ownerRelaySet();
|
|
634
|
+
set.delete(index);
|
|
635
|
+
for (const [key, entry] of this.relayShapeProxies) {
|
|
636
|
+
if (entry.relayIndex === index) {
|
|
637
|
+
this.relayShapeProxies.delete(key);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (set.size === 0) {
|
|
641
|
+
this.relayShapeRegistry.clear();
|
|
642
|
+
this.shapeUniformCache.clear();
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* The one-shot computation behind {@link OwnerRelay.isShapeRelayUniform}, made
|
|
647
|
+
* sound against the cross-identity row-leak (review). Resolves under the anonymous
|
|
648
|
+
* multicast identity (the base) plus two `Proxy`-backed identities that return a
|
|
649
|
+
* distinct value for ANY accessed claim, requires all to agree on table + where +
|
|
650
|
+
* columns, rejects any table with an RLS read policy or a masked projected column,
|
|
651
|
+
* and fails closed if the claims are enumerated (a wholesale copy the proxy can't
|
|
652
|
+
* differentiate).
|
|
653
|
+
*/
|
|
654
|
+
probeShapeRelayUniform(name, args) {
|
|
655
|
+
let base;
|
|
656
|
+
try {
|
|
657
|
+
base = this.host.resolveShape(name, args, RELAY_MULTICAST_IDENTITY);
|
|
658
|
+
} catch {
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
if (base === void 0 || base.global === true) {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
if (this.host.rlsMetadata().policies.some((policy) => policy.on === "read" && policy.table === base.table)) {
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
if (this.shapeColumnsMasked(base.table, base.columns)) {
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
const baseWhere = stableStringify(base.effectiveWhere);
|
|
671
|
+
const baseColumns = stableStringify(base.columns);
|
|
672
|
+
let enumerated = false;
|
|
673
|
+
const populate = (side) => {
|
|
674
|
+
const backing = { groups: [`grp_${side}`], roles: [side], sub: `__lunora_probe_${side}__` };
|
|
675
|
+
const claims = /* @__PURE__ */ new Proxy(backing, {
|
|
676
|
+
get: (target, key) => {
|
|
677
|
+
if (typeof key === "symbol" || key in target) {
|
|
678
|
+
return Reflect.get(target, key);
|
|
679
|
+
}
|
|
680
|
+
return `${side}:${key}`;
|
|
681
|
+
},
|
|
682
|
+
getOwnPropertyDescriptor: (target, key) => {
|
|
683
|
+
enumerated = true;
|
|
684
|
+
return Reflect.getOwnPropertyDescriptor(target, key);
|
|
685
|
+
},
|
|
686
|
+
has: (target, key) => typeof key === "symbol" ? Reflect.has(target, key) : true,
|
|
687
|
+
ownKeys: (target) => {
|
|
688
|
+
enumerated = true;
|
|
689
|
+
return Reflect.ownKeys(target);
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
return { identity: claims, userId: `__lunora_probe_${side}__` };
|
|
693
|
+
};
|
|
694
|
+
const matches = [RELAY_MULTICAST_IDENTITY, populate("a"), populate("b")].every((probe) => {
|
|
695
|
+
let resolved;
|
|
696
|
+
try {
|
|
697
|
+
resolved = this.host.resolveShape(name, args, probe);
|
|
698
|
+
} catch {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
return resolved !== void 0 && resolved.global !== true && resolved.table === base.table && stableStringify(resolved.effectiveWhere) === baseWhere && stableStringify(resolved.columns) === baseColumns;
|
|
702
|
+
});
|
|
703
|
+
return matches && !enumerated;
|
|
704
|
+
}
|
|
705
|
+
/** Whether any column the shape projects from `table` is masked — a masked value is identity-dependent, so the shape can't be relay-uniform. */
|
|
706
|
+
shapeColumnsMasked(table, columns) {
|
|
707
|
+
const masked = this.host.maskMetadata().columns.filter((entry) => entry.table === table);
|
|
708
|
+
if (masked.length === 0) {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
if (columns === void 0) {
|
|
712
|
+
return true;
|
|
713
|
+
}
|
|
714
|
+
const projected = new Set(columns);
|
|
715
|
+
return masked.some((entry) => projected.has(entry.column));
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
class RelayMember extends RelayLink {
|
|
719
|
+
/** `true` once this relay has announced itself to its owner this wake, so a hot socket churn doesn't re-attach on every subscribe. */
|
|
720
|
+
relayAnnounced = false;
|
|
721
|
+
/** Per-socket cohort memo `ws → subId → { cursor, epoch }`: the relay delivers a poke to a socket only while its memo matches the poke's `fromCursor`+`epoch`. */
|
|
722
|
+
shapeRelayMemos = /* @__PURE__ */ new WeakMap();
|
|
723
|
+
constructor(host, ownerKey, relayIndex) {
|
|
724
|
+
super(host, { ownerKey, relayIndex });
|
|
725
|
+
}
|
|
726
|
+
async forwardWhisper(topic, frame) {
|
|
727
|
+
if (!this.canAddressSiblings()) {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
await this.postRelayMessage(this.roleId.ownerKey, { frame, originRelay: this.roleId.relayIndex, topic, type: "relay_frame" });
|
|
731
|
+
}
|
|
732
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: a relay receives no writes, so it never flushes its own CDC
|
|
733
|
+
onFlush() {
|
|
734
|
+
return Promise.resolve();
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Seed a shape held by a socket on this relay by forwarding the request to the
|
|
738
|
+
* owner: the owner resolves under this socket's verified identity and computes the
|
|
739
|
+
* seed frames, which the relay delivers verbatim. Returns a structured error
|
|
740
|
+
* (surfaced as a `shape_subscribe` error) when the owner can't be reached.
|
|
741
|
+
*/
|
|
742
|
+
async seedRelayShape(ws, subId, shape, identity) {
|
|
743
|
+
if (!this.canAddressSiblings()) {
|
|
744
|
+
return { code: "RELAY_MISCONFIGURED", message: "relay cannot address its owner" };
|
|
745
|
+
}
|
|
746
|
+
await this.announce();
|
|
747
|
+
const request = {
|
|
748
|
+
args: shape.args ?? {},
|
|
749
|
+
connectionId: this.host.readAttachment(ws).connectionId,
|
|
750
|
+
identity: identity.identity,
|
|
751
|
+
name: shape.name,
|
|
752
|
+
relayIndex: this.roleId.relayIndex,
|
|
753
|
+
sinceEpoch: shape.sinceEpoch,
|
|
754
|
+
sinceSeq: shape.sinceSeq,
|
|
755
|
+
subId,
|
|
756
|
+
type: "relay_shape_subscribe",
|
|
757
|
+
userId: identity.userId
|
|
758
|
+
};
|
|
759
|
+
const response = await this.requestRelayMessage(this.roleId.ownerKey, request);
|
|
760
|
+
if (response === void 0) {
|
|
761
|
+
return { code: "RELAY_SEED_FAILED", message: "owner did not answer the shape seed" };
|
|
762
|
+
}
|
|
763
|
+
let seed;
|
|
764
|
+
try {
|
|
765
|
+
seed = await response.json();
|
|
766
|
+
} catch {
|
|
767
|
+
return { code: "RELAY_SEED_FAILED", message: "malformed shape seed from owner" };
|
|
768
|
+
}
|
|
769
|
+
if (seed.error !== void 0) {
|
|
770
|
+
return seed.error;
|
|
771
|
+
}
|
|
772
|
+
if (seed.frames === void 0) {
|
|
773
|
+
return { code: "RELAY_SEED_FAILED", message: "owner returned no shape frames" };
|
|
774
|
+
}
|
|
775
|
+
await awaitWsDrain(ws);
|
|
776
|
+
for (const frame of seed.frames) {
|
|
777
|
+
trySendFrame(ws, frame);
|
|
778
|
+
}
|
|
779
|
+
this.recordRelayShapeMemo(ws, subId, seed.cursor ?? 0, seed.epoch);
|
|
780
|
+
return "ok";
|
|
781
|
+
}
|
|
782
|
+
/** A relay announces itself to its owner (once per wake) on its first subscriber, retrying on a failed attach so a dropped frame can't strand its sockets (LOW-4). */
|
|
783
|
+
async announce() {
|
|
784
|
+
if (this.relayAnnounced || !this.canAddressSiblings()) {
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
this.relayAnnounced = true;
|
|
788
|
+
const response = await this.requestRelayMessage(this.roleId.ownerKey, { relayIndex: this.roleId.relayIndex, type: "relay_attach" });
|
|
789
|
+
if (!response?.ok) {
|
|
790
|
+
this.relayAnnounced = false;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/** Once this relay loses its last socket (the `closing` one excluded), detach from the owner and re-arm the announce latch for a future subscriber. */
|
|
794
|
+
async announceDrain(closing) {
|
|
795
|
+
if (!this.canAddressSiblings()) {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (this.host.getWebSockets().some((ws) => ws !== closing)) {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
this.relayAnnounced = false;
|
|
802
|
+
await this.postRelayMessage(this.roleId.ownerKey, { relayIndex: this.roleId.relayIndex, type: "relay_detach" });
|
|
803
|
+
}
|
|
804
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: a relay never spreads connections (flat single tier)
|
|
805
|
+
relayCount() {
|
|
806
|
+
return 0;
|
|
807
|
+
}
|
|
808
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: the RLS-uniform gate is an owner concern
|
|
809
|
+
isShapeRelayUniform() {
|
|
810
|
+
return false;
|
|
811
|
+
}
|
|
812
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only an owner tracks a relay set
|
|
813
|
+
onAttach() {
|
|
814
|
+
}
|
|
815
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only an owner tracks a relay set
|
|
816
|
+
onDetach() {
|
|
817
|
+
}
|
|
818
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only an owner re-distributes a forwarded whisper
|
|
819
|
+
onWhisperFrame() {
|
|
820
|
+
return Promise.resolve();
|
|
821
|
+
}
|
|
822
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: a relay can't seed (no op-log) — the owner does
|
|
823
|
+
onShapeSubscribe() {
|
|
824
|
+
return { error: { code: "RELAY_CANNOT_SEED", message: "a relay has no op-log to seed from" } };
|
|
825
|
+
}
|
|
826
|
+
onShapePoke(poke) {
|
|
827
|
+
return this.deliverShapePoke(poke);
|
|
828
|
+
}
|
|
829
|
+
/** Record a relay socket's cohort cursor + epoch for `subId` (creating the per-socket map lazily). */
|
|
830
|
+
recordRelayShapeMemo(ws, subId, cursor, epoch) {
|
|
831
|
+
let memos = this.shapeRelayMemos.get(ws);
|
|
832
|
+
if (memos === void 0) {
|
|
833
|
+
memos = /* @__PURE__ */ new Map();
|
|
834
|
+
this.shapeRelayMemos.set(ws, memos);
|
|
835
|
+
}
|
|
836
|
+
memos.set(subId, { cursor, epoch });
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Deliver an owner-multicast shape delta to this relay's cohort sockets. A socket
|
|
840
|
+
* receives it only while its memo matches the poke's `fromCursor` AND `epoch` (so a
|
|
841
|
+
* socket that seeded at a different cursor/epoch never double-applies), then
|
|
842
|
+
* advances to `checkpoint`. A targeted (per-socket proxy) poke goes ONLY to its one
|
|
843
|
+
* connection; a cohort multicast goes to every matching socket.
|
|
844
|
+
* @returns the number of sockets delivered to
|
|
845
|
+
*/
|
|
846
|
+
deliverShapePoke(poke) {
|
|
847
|
+
const routingKey = shapeRoutingKey(poke.name, poke.args);
|
|
848
|
+
let delivered = 0;
|
|
849
|
+
for (const ws of this.host.getWebSockets()) {
|
|
850
|
+
const attachment = this.host.readAttachment(ws);
|
|
851
|
+
const { shapes } = attachment;
|
|
852
|
+
const memos = this.shapeRelayMemos.get(ws);
|
|
853
|
+
if (shapes === void 0 || memos === void 0) {
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
if (poke.targetConnectionId !== void 0 && attachment.connectionId !== poke.targetConnectionId) {
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
for (const [subId, sub] of Object.entries(shapes)) {
|
|
860
|
+
const memo = memos.get(subId);
|
|
861
|
+
if (memo?.cursor !== poke.fromCursor || memo.epoch !== poke.epoch || shapeRoutingKey(sub.name, sub.args) !== routingKey) {
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
const frames = buildPokeFrames([{ rowsPatch: poke.rowsPatch, shapeId: subId }], {
|
|
865
|
+
baseCheckpoint: void 0,
|
|
866
|
+
checkpoint: poke.checkpoint,
|
|
867
|
+
epoch: poke.epoch,
|
|
868
|
+
lastMutationId: void 0,
|
|
869
|
+
pokeId: this.host.nextPokeId()
|
|
870
|
+
});
|
|
871
|
+
for (const frame of frames) {
|
|
872
|
+
trySendFrame(ws, frame);
|
|
873
|
+
}
|
|
874
|
+
memos.set(subId, { cursor: poke.checkpoint, epoch: poke.epoch });
|
|
875
|
+
delivered += 1;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return delivered;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
const createRelayLink = (host) => {
|
|
882
|
+
const name = host.doName();
|
|
883
|
+
if (name === void 0) {
|
|
884
|
+
return void 0;
|
|
885
|
+
}
|
|
886
|
+
const parsed = parseRelayName(name);
|
|
887
|
+
return parsed === void 0 ? new OwnerRelay(host, name) : new RelayMember(host, parsed.ownerKey, parsed.relayIndex);
|
|
888
|
+
};
|
|
889
|
+
|
|
147
890
|
const REQUEST_LOG_TABLE = "__lunora_reqlog__";
|
|
148
891
|
const REQUEST_LOG_RETENTION = 1e3;
|
|
149
892
|
const REQUEST_LOG_EVENT_SOURCE = "lunora";
|
|
@@ -351,58 +1094,6 @@ const readRequestLog = (sql, options = {}) => {
|
|
|
351
1094
|
});
|
|
352
1095
|
};
|
|
353
1096
|
|
|
354
|
-
const projectColumns = (document_, columns) => {
|
|
355
|
-
if (!columns) {
|
|
356
|
-
return document_;
|
|
357
|
-
}
|
|
358
|
-
const projected = /* @__PURE__ */ Object.create(null);
|
|
359
|
-
for (const key of ["_id", "_creationTime", ...columns]) {
|
|
360
|
-
if (Object.hasOwn(document_, key)) {
|
|
361
|
-
projected[key] = document_[key];
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
return projected;
|
|
365
|
-
};
|
|
366
|
-
const diffGlobalMembership = (rows, previous, options) => {
|
|
367
|
-
const { columns, table } = options;
|
|
368
|
-
const next = /* @__PURE__ */ new Map();
|
|
369
|
-
const rowsPatch = [];
|
|
370
|
-
for (const { doc, id } of rows) {
|
|
371
|
-
const value = projectColumns(doc, columns);
|
|
372
|
-
const json = JSON.stringify(value);
|
|
373
|
-
next.set(id, json);
|
|
374
|
-
const before = previous.get(id);
|
|
375
|
-
if (before === void 0) {
|
|
376
|
-
rowsPatch.push({ key: id, op: "insert", table, value });
|
|
377
|
-
} else if (before !== json) {
|
|
378
|
-
rowsPatch.push({ key: id, op: "update", table, value });
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
for (const id of previous.keys()) {
|
|
382
|
-
if (!next.has(id)) {
|
|
383
|
-
rowsPatch.push({ key: id, op: "delete", table });
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
return { next, rowsPatch };
|
|
387
|
-
};
|
|
388
|
-
const buildPokeFrames = (parts, meta) => {
|
|
389
|
-
const { baseCheckpoint, checkpoint, epoch, lastMutationId, pokeId } = meta;
|
|
390
|
-
const frames = [JSON.stringify({ baseCheckpoint, epoch, pokeId, type: "pokeStart" })];
|
|
391
|
-
for (const part of parts) {
|
|
392
|
-
frames.push(
|
|
393
|
-
JSON.stringify({
|
|
394
|
-
pokeId,
|
|
395
|
-
rowsPatch: part.rowsPatch,
|
|
396
|
-
shapeId: part.shapeId,
|
|
397
|
-
type: "pokePart",
|
|
398
|
-
...lastMutationId === void 0 ? {} : { lastMutationId }
|
|
399
|
-
})
|
|
400
|
-
);
|
|
401
|
-
}
|
|
402
|
-
frames.push(JSON.stringify({ checkpoint, epoch, pokeId, type: "pokeEnd" }));
|
|
403
|
-
return frames;
|
|
404
|
-
};
|
|
405
|
-
|
|
406
1097
|
const runSocketPool = async (items, processOne, concurrency = 8) => {
|
|
407
1098
|
let cursor = 0;
|
|
408
1099
|
const worker = async () => {
|
|
@@ -491,19 +1182,6 @@ const IDEMPOTENCY_RETENTION_MS = 864e5;
|
|
|
491
1182
|
const IDEMPOTENCY_GC_INTERVAL_MS = 36e5;
|
|
492
1183
|
const ROOT_SHARD_NAME = "__root__";
|
|
493
1184
|
const ADMIN_WILDCARD = "*";
|
|
494
|
-
const awaitWsDrain = async (ws) => {
|
|
495
|
-
let attempts = 0;
|
|
496
|
-
while (attempts < 100) {
|
|
497
|
-
attempts += 1;
|
|
498
|
-
const buffered = ws.bufferedAmount;
|
|
499
|
-
if (typeof buffered !== "number" || buffered < 1048576) {
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
await new Promise((resolve) => {
|
|
503
|
-
setTimeout(resolve, 20);
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
};
|
|
507
1185
|
const cdcSuffix = (cursor, epoch) => (cursor === void 0 ? "" : `,"cursor":${String(cursor)}`) + (epoch === void 0 ? "" : `,"epoch":${JSON.stringify(epoch)}`);
|
|
508
1186
|
const setsIntersect = (a, b) => {
|
|
509
1187
|
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
|
|
@@ -1246,6 +1924,35 @@ class ShardDO {
|
|
|
1246
1924
|
* (durable aggregation would be a separate, heavier feature).
|
|
1247
1925
|
*/
|
|
1248
1926
|
metrics = { errors: 0, requests: 0, sinceMs: Date.now() };
|
|
1927
|
+
/**
|
|
1928
|
+
* Running fan-out cost counters surfaced by the
|
|
1929
|
+
* `__lunora_admin__:getFanoutMetrics` RPC — one tally for the reactive
|
|
1930
|
+
* shape-poke path (`pokeShapeSubscribers`) and one for the whisper broadcast
|
|
1931
|
+
* path (`broadcastWhisper`). Each pass records the sockets it iterated (the
|
|
1932
|
+
* O(subscribers) cost) and delivered to. In-memory and reset on
|
|
1933
|
+
* hibernation/restart, sharing `metrics.sinceMs` as the "since this instance
|
|
1934
|
+
* woke" epoch. This is the observability half of plan 075's auto-elastic
|
|
1935
|
+
* relay tier (Phase 1): measure the per-flush fan-out cost so the promotion
|
|
1936
|
+
* threshold is grounded in real numbers, with no behavior change.
|
|
1937
|
+
*/
|
|
1938
|
+
fanout = { shapePoke: createFanoutCounters(), whisper: createFanoutCounters() };
|
|
1939
|
+
/**
|
|
1940
|
+
* The runtime's Durable Object namespace binding name (e.g. `"SHARD"`),
|
|
1941
|
+
* forwarded as `x-lunora-shard-binding` on every request so a DO can address
|
|
1942
|
+
* its siblings (`this.env[binding].getByName(...)`) for the relay hub. Absent
|
|
1943
|
+
* in single-DO mode / the unit harness — when absent, the relay tier is inert
|
|
1944
|
+
* and whispers stay shard-local (no behavior change). In-memory; re-learned per
|
|
1945
|
+
* request.
|
|
1946
|
+
*/
|
|
1947
|
+
shardBinding;
|
|
1948
|
+
/**
|
|
1949
|
+
* The auto-elastic fan-out relay collaborator (plan 075) — an {@link OwnerRelay}
|
|
1950
|
+
* or {@link RelayMember} chosen ONCE from this DO's name, or `undefined` for an
|
|
1951
|
+
* unnamed (single-DO) DO where the relay tier is inert. All relay state +
|
|
1952
|
+
* transport lives on it, reached back through the {@link RelayHost} adapter, so
|
|
1953
|
+
* owner-only state can never sit next to relay-only state on this class.
|
|
1954
|
+
*/
|
|
1955
|
+
relay;
|
|
1249
1956
|
/**
|
|
1250
1957
|
* Declared indexes (`table:index`) a query has exercised since this instance
|
|
1251
1958
|
* woke, stamped by `getCtxDbIndexUseHook`. In-memory and reset on
|
|
@@ -1332,6 +2039,29 @@ class ShardDO {
|
|
|
1332
2039
|
if (options.reactiveCache) {
|
|
1333
2040
|
this.reactiveCache = new ReactiveCache(options.reactiveCache);
|
|
1334
2041
|
}
|
|
2042
|
+
const host = {
|
|
2043
|
+
buildShapeDiff: (resolved, fromCursor, toCursor) => this.buildShapeDiff(this.sql, resolved, fromCursor, toCursor),
|
|
2044
|
+
computeOpLogShapeSeed: (shape, resolved) => this.computeOpLogShapeSeed(shape, resolved),
|
|
2045
|
+
currentCdcEpoch: () => this.currentCdcEpoch(),
|
|
2046
|
+
deliverWhisperLocal: (topic, frame, exclude) => this.deliverWhisperLocal(topic, frame, exclude),
|
|
2047
|
+
doName: () => this.state.id?.name,
|
|
2048
|
+
env: () => this.env,
|
|
2049
|
+
getWebSockets: () => this.state.getWebSockets(),
|
|
2050
|
+
maskMetadata: () => this.maskMetadata(),
|
|
2051
|
+
nextPokeId: () => {
|
|
2052
|
+
this.pokeSequence += 1;
|
|
2053
|
+
return `poke-${String(this.pokeSequence)}`;
|
|
2054
|
+
},
|
|
2055
|
+
readAttachment: (ws) => this.readAttachment(ws),
|
|
2056
|
+
recordShapePokeFanout: (iterated, delivered, elapsedMs) => {
|
|
2057
|
+
this.fanout.shapePoke = recordFanoutPass(this.fanout.shapePoke, iterated, delivered, elapsedMs);
|
|
2058
|
+
},
|
|
2059
|
+
resolveShape: (name, args, identity) => this.resolveShape(name, args, identity),
|
|
2060
|
+
rlsMetadata: () => this.rlsMetadata(),
|
|
2061
|
+
shardBinding: () => this.shardBinding,
|
|
2062
|
+
sql: () => this.sql
|
|
2063
|
+
};
|
|
2064
|
+
this.relay = createRelayLink(host);
|
|
1335
2065
|
this.armWebSocketKeepalive();
|
|
1336
2066
|
}
|
|
1337
2067
|
/** SQLite handle scoped to this Durable Object. */
|
|
@@ -1341,8 +2071,10 @@ class ShardDO {
|
|
|
1341
2071
|
*/
|
|
1342
2072
|
async fetch(request) {
|
|
1343
2073
|
const url = new URL(request.url);
|
|
1344
|
-
|
|
1345
|
-
|
|
2074
|
+
this.shardBinding = request.headers.get("x-lunora-shard-binding") ?? this.shardBinding;
|
|
2075
|
+
const early = await this.routeNonRpc(url, request);
|
|
2076
|
+
if (early !== void 0) {
|
|
2077
|
+
return early;
|
|
1346
2078
|
}
|
|
1347
2079
|
if (url.pathname !== "/rpc" || request.method !== "POST") {
|
|
1348
2080
|
return new Response("Not found", { status: 404 });
|
|
@@ -1389,7 +2121,7 @@ class ShardDO {
|
|
|
1389
2121
|
if (cached !== void 0) {
|
|
1390
2122
|
return this.respondFromIdempotencyCache(payload.functionPath, dispatchStartedAt, mutatorClass, cached.value);
|
|
1391
2123
|
}
|
|
1392
|
-
const result = await this.handleRpc(payload.functionPath, payload.args ?? {});
|
|
2124
|
+
const result = await this.handleRpc(payload.functionPath, decodeWire(payload.args ?? {}));
|
|
1393
2125
|
this.recordPostDispatchBookkeeping(result, mutatorClass);
|
|
1394
2126
|
if (mutatorClass?.kind === "next") {
|
|
1395
2127
|
this.advanceClientMutationWatermark();
|
|
@@ -1400,7 +2132,7 @@ class ShardDO {
|
|
|
1400
2132
|
const tablesWritten = [...this.pendingChangedTables ?? []];
|
|
1401
2133
|
this.recordRequestLog(payload.functionPath, payload.args ?? {}, durationMs, "ok", tablesWritten);
|
|
1402
2134
|
this.maybeWarnRootSize();
|
|
1403
|
-
const response = this.buildDispatchResponse(mutatorClass, result);
|
|
2135
|
+
const response = this.buildDispatchResponse(mutatorClass, encodeWire(result));
|
|
1404
2136
|
await this.flushChangedTables();
|
|
1405
2137
|
return response;
|
|
1406
2138
|
} catch (error) {
|
|
@@ -1520,19 +2252,23 @@ class ShardDO {
|
|
|
1520
2252
|
ws.send(JSON.stringify({ id: envelope.id, message: "streams must be public", type: "error" }));
|
|
1521
2253
|
return;
|
|
1522
2254
|
}
|
|
1523
|
-
this.handleStream(ws, envelope.id, envelope.query.functionPath, envelope.query.args ?? {}).catch(() => {
|
|
2255
|
+
this.handleStream(ws, envelope.id, envelope.query.functionPath, decodeWire(envelope.query.args ?? {})).catch(() => {
|
|
1524
2256
|
});
|
|
1525
2257
|
return;
|
|
1526
2258
|
}
|
|
1527
2259
|
if (envelope.type === "whisper_subscribe" || envelope.type === "whisper_unsubscribe") {
|
|
1528
2260
|
if (typeof envelope.topic === "string" && envelope.topic.length > 0) {
|
|
1529
|
-
|
|
2261
|
+
const join = envelope.type === "whisper_subscribe";
|
|
2262
|
+
this.setWhisperMembership(ws, envelope.topic, join);
|
|
2263
|
+
if (join) {
|
|
2264
|
+
await this.relay?.announce();
|
|
2265
|
+
}
|
|
1530
2266
|
}
|
|
1531
2267
|
return;
|
|
1532
2268
|
}
|
|
1533
2269
|
if (envelope.type === "whisper") {
|
|
1534
2270
|
if (typeof envelope.topic === "string" && envelope.topic.length > 0) {
|
|
1535
|
-
this.broadcastWhisper(ws, envelope.topic, envelope.data);
|
|
2271
|
+
await this.broadcastWhisper(ws, envelope.topic, envelope.data);
|
|
1536
2272
|
}
|
|
1537
2273
|
return;
|
|
1538
2274
|
}
|
|
@@ -1574,6 +2310,7 @@ class ShardDO {
|
|
|
1574
2310
|
}
|
|
1575
2311
|
}
|
|
1576
2312
|
ws.serializeAttachment?.(void 0);
|
|
2313
|
+
await this.relay?.announceDrain(ws);
|
|
1577
2314
|
}
|
|
1578
2315
|
/** Hibernation API: invoked on socket error. */
|
|
1579
2316
|
// 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
|
|
@@ -1596,6 +2333,13 @@ class ShardDO {
|
|
|
1596
2333
|
this.recordShapeError("shape:poll", error);
|
|
1597
2334
|
remaining = 1;
|
|
1598
2335
|
}
|
|
2336
|
+
try {
|
|
2337
|
+
remaining += await this.pollExternalSources();
|
|
2338
|
+
} catch (error) {
|
|
2339
|
+
this.recordShapeError("source:poll", error);
|
|
2340
|
+
remaining += 1;
|
|
2341
|
+
}
|
|
2342
|
+
await this.flushChangedTables();
|
|
1599
2343
|
if (remaining > 0) {
|
|
1600
2344
|
await this.scheduleGlobalPoll();
|
|
1601
2345
|
}
|
|
@@ -2322,7 +3066,7 @@ class ShardDO {
|
|
|
2322
3066
|
}
|
|
2323
3067
|
const now = Date.now();
|
|
2324
3068
|
try {
|
|
2325
|
-
writeIdempotent(this.sql, this.currentRequestUserId ?? "", this.currentRequestMutationId, JSON.stringify(result)
|
|
3069
|
+
writeIdempotent(this.sql, this.currentRequestUserId ?? "", this.currentRequestMutationId, JSON.stringify(encodeWire(result)), now);
|
|
2326
3070
|
if (now - this.lastIdempotencyTrimAt > IDEMPOTENCY_GC_INTERVAL_MS) {
|
|
2327
3071
|
trimIdempotent(this.sql, now - IDEMPOTENCY_RETENTION_MS);
|
|
2328
3072
|
this.lastIdempotencyTrimAt = now;
|
|
@@ -2705,6 +3449,16 @@ class ShardDO {
|
|
|
2705
3449
|
resolveShape(_name, _args, _identity) {
|
|
2706
3450
|
return void 0;
|
|
2707
3451
|
}
|
|
3452
|
+
/**
|
|
3453
|
+
* The RLS-uniform gate (plan 075 Phase 3): whether a reactive shape may be
|
|
3454
|
+
* relay-multicast — i.e. one delta is correct for **every** subscriber. The owner
|
|
3455
|
+
* decides it (see {@link OwnerRelay.isShapeRelayUniform} — a static RLS read-policy
|
|
3456
|
+
* guard plus claim-exhaustive `Proxy` probes, fail-closed); this thin delegation
|
|
3457
|
+
* is the seam the gate test exercises. A non-owner DO is never relay-uniform.
|
|
3458
|
+
*/
|
|
3459
|
+
isShapeRelayUniform(name, args) {
|
|
3460
|
+
return this.relay?.isShapeRelayUniform(name, args) ?? false;
|
|
3461
|
+
}
|
|
2708
3462
|
/**
|
|
2709
3463
|
* Read the FULL current membership of a `.global()`-table shape from its D1
|
|
2710
3464
|
* (or Hyperdrive) backend — the seed/poll source for the latency-tiered
|
|
@@ -2725,6 +3479,41 @@ class ShardDO {
|
|
|
2725
3479
|
readGlobalShapeRows(_resolved, _identity) {
|
|
2726
3480
|
return Promise.resolve([]);
|
|
2727
3481
|
}
|
|
3482
|
+
/**
|
|
3483
|
+
* Poll external-source (`.source(...)`) tables once (plan 077): materialize
|
|
3484
|
+
* each sourced table's freshly-pulled tenant slice into this DO's SQLite. The
|
|
3485
|
+
* base `ShardDO` has no sourced tables, so it returns `0` and the ingest tier
|
|
3486
|
+
* stays dormant — zero behavior change for every existing DO. The codegen
|
|
3487
|
+
* subclass overrides it to, per sourced table, build a `createShardCtxDb`
|
|
3488
|
+
* writer, read the tenant slice from Hyperdrive under this DO's shard key, and
|
|
3489
|
+
* run `runExternalSourceTick` (read local baseline → diff → apply via the
|
|
3490
|
+
* validated CDC writer). Returns the number of sourced tables still being
|
|
3491
|
+
* polled, so the shared poll alarm ({@link ShardDO.alarm}) re-arms while ingest
|
|
3492
|
+
* is active.
|
|
3493
|
+
*/
|
|
3494
|
+
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass implements the real Hyperdrive-backed poll
|
|
3495
|
+
pollExternalSources() {
|
|
3496
|
+
return Promise.resolve(0);
|
|
3497
|
+
}
|
|
3498
|
+
/**
|
|
3499
|
+
* Arm the shared poll alarm for external-source ingest (plan 077). The alarm is
|
|
3500
|
+
* shared with the global-shape poll tier; the codegen subclass calls this once
|
|
3501
|
+
* (on construction / first sourced write) so a sourced DO starts its ingest
|
|
3502
|
+
* loop, after which {@link ShardDO.alarm} re-arms itself while
|
|
3503
|
+
* {@link ShardDO.pollExternalSources} reports remaining work. Idempotent; a
|
|
3504
|
+
* no-op when the runtime exposes no `setAlarm` (unit harness).
|
|
3505
|
+
*/
|
|
3506
|
+
scheduleSourcePoll() {
|
|
3507
|
+
return this.scheduleGlobalPoll();
|
|
3508
|
+
}
|
|
3509
|
+
/** This DO's shard key (its DO name), or `__root__` for the single-DO default. The `tenantBy` mapper binds it into the source query. */
|
|
3510
|
+
currentShardKey() {
|
|
3511
|
+
return this.state.id?.name ?? ROOT_SHARD_NAME;
|
|
3512
|
+
}
|
|
3513
|
+
/** Record a contained external-source ingest failure (one sourced table's poll) into the log ring without aborting the others. */
|
|
3514
|
+
recordExternalSourceError(table, error) {
|
|
3515
|
+
this.recordShapeError(`source:${table}`, error);
|
|
3516
|
+
}
|
|
2728
3517
|
/**
|
|
2729
3518
|
* Look up a streaming-query function and return a thunk that produces the
|
|
2730
3519
|
* `AsyncIterable<unknown>` when handed an {@link AbortSignal}. The codegen
|
|
@@ -3133,11 +3922,74 @@ class ShardDO {
|
|
|
3133
3922
|
if (error && typeof error === "object" && error.name === "LunoraError") {
|
|
3134
3923
|
const lunoraError = error;
|
|
3135
3924
|
const status = typeof lunoraError.status === "number" ? lunoraError.status : 500;
|
|
3136
|
-
|
|
3925
|
+
const body = {
|
|
3926
|
+
code: lunoraError.code ?? "INTERNAL",
|
|
3927
|
+
message: lunoraError.message ?? "internal error"
|
|
3928
|
+
};
|
|
3929
|
+
if (lunoraError.data !== void 0) {
|
|
3930
|
+
body.data = encodeWire(lunoraError.data);
|
|
3931
|
+
}
|
|
3932
|
+
return jsonResponse({ error: body }, status);
|
|
3137
3933
|
}
|
|
3138
3934
|
console.error("[@lunora/do] unhandled RPC error:", error);
|
|
3139
3935
|
return jsonResponse({ error: { code: "RPC_FAILED", message: "internal error" } }, 500);
|
|
3140
3936
|
}
|
|
3937
|
+
/**
|
|
3938
|
+
* Batch dispatch (plan 088). Applies each `calls[]` entry through the SAME
|
|
3939
|
+
* single-call `/rpc` path (via a nested `this.fetch`), **sequentially**, so
|
|
3940
|
+
* the per-`(identity, mutationId)` idempotency dedup and the per-client
|
|
3941
|
+
* `__client_watermark` ordering are enforced entry-by-entry exactly as for an
|
|
3942
|
+
* individual call — no duplication of the dispatch core, no reordering.
|
|
3943
|
+
*
|
|
3944
|
+
* Failures are **per-slot, not fail-fast**: an entry that throws (or a
|
|
3945
|
+
* custom-mutator `OUT_OF_ORDER` gap) is captured in its own result slot and
|
|
3946
|
+
* later entries still run. Ordering is still safe — a later same-client
|
|
3947
|
+
* mutator after a gap re-classifies as a gap too (the watermark never
|
|
3948
|
+
* advanced), so it cannot apply out of order; unrelated entries/queries are
|
|
3949
|
+
* independent. The response is `{ results: [{ id, status, body }] }` in
|
|
3950
|
+
* request order; each `body` is the untouched single-call envelope (its
|
|
3951
|
+
* `result` already wire-encoded), so the client demuxes + decodes each
|
|
3952
|
+
* exactly as one call.
|
|
3953
|
+
*/
|
|
3954
|
+
async handleBatchRpc(request) {
|
|
3955
|
+
let payload;
|
|
3956
|
+
try {
|
|
3957
|
+
payload = await request.json();
|
|
3958
|
+
} catch {
|
|
3959
|
+
return jsonResponse({ error: { code: "BAD_REQUEST", message: "invalid JSON body" } }, 400);
|
|
3960
|
+
}
|
|
3961
|
+
if (!Array.isArray(payload.calls)) {
|
|
3962
|
+
return jsonResponse({ error: { code: "BAD_REQUEST", message: "batch `calls` must be an array" } }, 400);
|
|
3963
|
+
}
|
|
3964
|
+
if (payload.calls.length > MAX_BATCH_ENTRIES) {
|
|
3965
|
+
return jsonResponse({ error: { code: "BAD_REQUEST", message: `batch exceeds the ${String(MAX_BATCH_ENTRIES)}-call limit` } }, 400);
|
|
3966
|
+
}
|
|
3967
|
+
const results = [];
|
|
3968
|
+
let latestBookmark;
|
|
3969
|
+
for (const raw of payload.calls) {
|
|
3970
|
+
const outcome = await this.dispatchBatchEntry(request, raw);
|
|
3971
|
+
if (outcome.bookmark !== void 0) {
|
|
3972
|
+
latestBookmark = outcome.bookmark;
|
|
3973
|
+
}
|
|
3974
|
+
results.push({ body: outcome.body, id: outcome.id, status: outcome.status });
|
|
3975
|
+
}
|
|
3976
|
+
return jsonResponse({ results }, 200, latestBookmark);
|
|
3977
|
+
}
|
|
3978
|
+
/** Dispatch one batch entry through the single-call `/rpc` path and capture its envelope (plan 088). */
|
|
3979
|
+
async dispatchBatchEntry(batchRequest, entry) {
|
|
3980
|
+
try {
|
|
3981
|
+
const response = await this.fetch(buildBatchEntryRequest(batchRequest, entry));
|
|
3982
|
+
return { body: await response.json(), bookmark: response.headers.get("x-d1-bookmark") ?? void 0, id: entry.id, status: response.status };
|
|
3983
|
+
} catch (error) {
|
|
3984
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3985
|
+
return {
|
|
3986
|
+
body: { error: { code: "BATCH_ENTRY_FAILED", message } },
|
|
3987
|
+
bookmark: void 0,
|
|
3988
|
+
id: entry?.id,
|
|
3989
|
+
status: 500
|
|
3990
|
+
};
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3141
3993
|
/**
|
|
3142
3994
|
* Serve a reserved admin-introspection RPC (`__lunora_admin__:*`) for the
|
|
3143
3995
|
* data browser. Gated by `env.LUNORA_ADMIN_TOKEN`: introspection is
|
|
@@ -3748,6 +4600,9 @@ class ShardDO {
|
|
|
3748
4600
|
if (functionPath === ADMIN_FUNCTIONS.listSubscriptions) {
|
|
3749
4601
|
return this.collectSubscriptions();
|
|
3750
4602
|
}
|
|
4603
|
+
if (functionPath === ADMIN_FUNCTIONS.getFanoutMetrics) {
|
|
4604
|
+
return this.collectFanoutMetrics();
|
|
4605
|
+
}
|
|
3751
4606
|
if (functionPath === ADMIN_FUNCTIONS.getLogs) {
|
|
3752
4607
|
return { entries: this.logs.entries() };
|
|
3753
4608
|
}
|
|
@@ -3790,6 +4645,28 @@ class ShardDO {
|
|
|
3790
4645
|
collectSubscriptions() {
|
|
3791
4646
|
return summarizeSubscriptions(this.state.getWebSockets().map((ws) => this.readAttachment(ws)));
|
|
3792
4647
|
}
|
|
4648
|
+
/**
|
|
4649
|
+
* Assemble the `__lunora_admin__:getFanoutMetrics` payload for the Studio
|
|
4650
|
+
* fan-out observability panel (plan 075 Phase 1). The point-in-time topic
|
|
4651
|
+
* subscriber counts are folded live from each socket's attachment via
|
|
4652
|
+
* {@link summarizeFanoutTopics}; the running per-path cost counters are the
|
|
4653
|
+
* in-memory {@link ShardDO.fanout} tallies, sharing `metrics.sinceMs` as the
|
|
4654
|
+
* "since this instance woke" epoch. Read-only: touches no SQLite and mutates
|
|
4655
|
+
* no socket state.
|
|
4656
|
+
*/
|
|
4657
|
+
collectFanoutMetrics() {
|
|
4658
|
+
const summary = summarizeFanoutTopics(this.state.getWebSockets().map((ws) => this.readAttachment(ws)));
|
|
4659
|
+
const relayCount = this.relay?.relayCount() ?? 0;
|
|
4660
|
+
return {
|
|
4661
|
+
...summary,
|
|
4662
|
+
maxRelays: this.relay?.maxRelays() ?? DEFAULT_MAX_RELAYS,
|
|
4663
|
+
promoted: relayCount > 0,
|
|
4664
|
+
relayCount,
|
|
4665
|
+
shapePoke: this.fanout.shapePoke,
|
|
4666
|
+
sinceMs: this.metrics.sinceMs,
|
|
4667
|
+
whisper: this.fanout.whisper
|
|
4668
|
+
};
|
|
4669
|
+
}
|
|
3793
4670
|
/** Resolve a `getAuditLog` admin read, parsing the optional `limit`/`sinceSeq` cursor args and ensuring the reserved table first. */
|
|
3794
4671
|
// eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers and future per-instance state
|
|
3795
4672
|
readAdminAuditLog(sql, args) {
|
|
@@ -4062,7 +4939,7 @@ class ShardDO {
|
|
|
4062
4939
|
break;
|
|
4063
4940
|
}
|
|
4064
4941
|
await awaitWsDrain(ws);
|
|
4065
|
-
ws.send(JSON.stringify({ data: chunk, id, type: "chunk" }));
|
|
4942
|
+
ws.send(JSON.stringify({ data: encodeWire(chunk), id, type: "chunk" }));
|
|
4066
4943
|
}
|
|
4067
4944
|
if (!controller.signal.aborted) {
|
|
4068
4945
|
ws.send(JSON.stringify({ id, type: "complete" }));
|
|
@@ -4145,7 +5022,11 @@ class ShardDO {
|
|
|
4145
5022
|
this.pendingRefreshTables = void 0;
|
|
4146
5023
|
const frameCursor = this.currentCdcCursor();
|
|
4147
5024
|
const frameEpoch = this.currentCdcEpoch();
|
|
4148
|
-
await Promise.all([
|
|
5025
|
+
await Promise.all([
|
|
5026
|
+
this.refreshSubscriptions(batch),
|
|
5027
|
+
this.pokeShapeSubscribers(batch, frameCursor, frameEpoch),
|
|
5028
|
+
this.relay?.onFlush(batch, frameCursor ?? 0)
|
|
5029
|
+
]);
|
|
4149
5030
|
batch = this.pendingRefreshTables;
|
|
4150
5031
|
}
|
|
4151
5032
|
} finally {
|
|
@@ -4346,6 +5227,10 @@ class ShardDO {
|
|
|
4346
5227
|
async seedShapeSubscription(ws, subId, shape) {
|
|
4347
5228
|
const attachment = this.readAttachment(ws);
|
|
4348
5229
|
const identity = { identity: attachment.identity, userId: attachment.userId };
|
|
5230
|
+
const relayed = await this.relay?.seedRelayShape(ws, subId, shape, identity);
|
|
5231
|
+
if (relayed !== void 0) {
|
|
5232
|
+
return relayed;
|
|
5233
|
+
}
|
|
4349
5234
|
let resolved;
|
|
4350
5235
|
try {
|
|
4351
5236
|
resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
|
|
@@ -4378,17 +5263,32 @@ class ShardDO {
|
|
|
4378
5263
|
* it to a structured `shape_subscribe` error.
|
|
4379
5264
|
*/
|
|
4380
5265
|
async seedOpLogShape(ws, subId, shape, resolved) {
|
|
5266
|
+
const { baseCheckpoint, cursor, epoch, rowsPatch } = this.computeOpLogShapeSeed(shape, resolved);
|
|
5267
|
+
await awaitWsDrain(ws);
|
|
5268
|
+
if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], cursor, epoch, baseCheckpoint)) {
|
|
5269
|
+
this.recordShapeMemo(ws, subId, cursor);
|
|
5270
|
+
}
|
|
5271
|
+
return "ok";
|
|
5272
|
+
}
|
|
5273
|
+
/**
|
|
5274
|
+
* Compute an op-log shape seed (cursor, epoch, the resume base, and the
|
|
5275
|
+
* membership `rowsPatch`) WITHOUT sending — the shared core of
|
|
5276
|
+
* {@link ShardDO.seedOpLogShape} (sends to a local socket) and the owner relay's
|
|
5277
|
+
* `buildShapeSeedFrames` (serializes the frames for a relay to deliver, plan 075
|
|
5278
|
+
* Phase 3, via the {@link RelayHost} seam). Resume only when CDC is on, the client is on this
|
|
5279
|
+
* epoch, its checkpoint doesn't run ahead of ours, and the log still covers it;
|
|
5280
|
+
* else a full re-seed. A fully-compacted log only proves "nothing missed" when
|
|
5281
|
+
* the client is already at `cursor`.
|
|
5282
|
+
* @returns the cursor/epoch, the resume base (`baseCheckpoint`), and the membership patch
|
|
5283
|
+
*/
|
|
5284
|
+
computeOpLogShapeSeed(shape, resolved) {
|
|
4381
5285
|
const sql = this.sql;
|
|
4382
5286
|
const cursor = this.currentCdcCursor() ?? 0;
|
|
4383
5287
|
const epoch = this.currentCdcEpoch();
|
|
4384
5288
|
const floor = this.cdcEnabled() ? minCdcSeq(sql) : void 0;
|
|
4385
5289
|
const canResume = this.cdcEnabled() && shape.sinceSeq !== void 0 && shape.sinceEpoch === epoch && shape.sinceSeq <= cursor && (shape.sinceSeq === cursor || floor !== void 0 && floor <= shape.sinceSeq + 1);
|
|
4386
5290
|
const rowsPatch = canResume && shape.sinceSeq !== void 0 ? this.buildShapeDiff(sql, resolved, shape.sinceSeq, cursor) : this.buildShapeSeed(sql, resolved);
|
|
4387
|
-
|
|
4388
|
-
if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], cursor, epoch, canResume ? shape.sinceSeq : void 0)) {
|
|
4389
|
-
this.recordShapeMemo(ws, subId, cursor);
|
|
4390
|
-
}
|
|
4391
|
-
return "ok";
|
|
5291
|
+
return { baseCheckpoint: canResume ? shape.sinceSeq : void 0, cursor, epoch, rowsPatch };
|
|
4392
5292
|
}
|
|
4393
5293
|
/**
|
|
4394
5294
|
* Fan the membership diff of every shape affected by this flush to its
|
|
@@ -4405,6 +5305,7 @@ class ShardDO {
|
|
|
4405
5305
|
const checkpoint = frameCursor ?? this.currentCdcCursor() ?? 0;
|
|
4406
5306
|
const sql = this.sql;
|
|
4407
5307
|
const opRangeCache = /* @__PURE__ */ new Map();
|
|
5308
|
+
let delivered = 0;
|
|
4408
5309
|
const pokeOne = async (ws) => {
|
|
4409
5310
|
if (this.isSocketExpired(ws)) {
|
|
4410
5311
|
this.dropExpiredSocket(ws);
|
|
@@ -4424,6 +5325,7 @@ class ShardDO {
|
|
|
4424
5325
|
if (parts.length > 0) {
|
|
4425
5326
|
await awaitWsDrain(ws);
|
|
4426
5327
|
if (this.sendPoke(ws, parts, checkpoint, frameEpoch, void 0)) {
|
|
5328
|
+
delivered += 1;
|
|
4427
5329
|
for (const subId of partAdvanced) {
|
|
4428
5330
|
this.recordShapeMemo(ws, subId, checkpoint);
|
|
4429
5331
|
}
|
|
@@ -4432,7 +5334,9 @@ class ShardDO {
|
|
|
4432
5334
|
} catch {
|
|
4433
5335
|
}
|
|
4434
5336
|
};
|
|
5337
|
+
const startMs = Date.now();
|
|
4435
5338
|
await runSocketPool(sockets, pokeOne);
|
|
5339
|
+
this.fanout.shapePoke = recordFanoutPass(this.fanout.shapePoke, sockets.length, delivered, Date.now() - startMs);
|
|
4436
5340
|
}
|
|
4437
5341
|
/**
|
|
4438
5342
|
* Diff every op-log-backed shape a socket holds against this flush, splitting
|
|
@@ -4828,7 +5732,7 @@ class ShardDO {
|
|
|
4828
5732
|
memos = /* @__PURE__ */ new Map();
|
|
4829
5733
|
this.subMemos.set(ws, memos);
|
|
4830
5734
|
}
|
|
4831
|
-
memos.set(subId, { lastJson: JSON.stringify(outcome.result ?? null), tables: outcome.tables });
|
|
5735
|
+
memos.set(subId, { lastJson: JSON.stringify(encodeWire(outcome.result ?? null)), tables: outcome.tables });
|
|
4832
5736
|
}
|
|
4833
5737
|
/**
|
|
4834
5738
|
* Memoise `outcome` for `(ws, subId)` and push it to the socket, unless an
|
|
@@ -4855,7 +5759,7 @@ class ShardDO {
|
|
|
4855
5759
|
this.subMemos.set(ws, memos);
|
|
4856
5760
|
}
|
|
4857
5761
|
const cursorSuffix = cdcSuffix(cursor, epoch);
|
|
4858
|
-
const json = JSON.stringify(outcome.result ?? null);
|
|
5762
|
+
const json = JSON.stringify(encodeWire(outcome.result ?? null));
|
|
4859
5763
|
const existing = memos.get(subId);
|
|
4860
5764
|
if (existing?.lastJson === json) {
|
|
4861
5765
|
existing.tables = outcome.tables;
|
|
@@ -4957,6 +5861,29 @@ class ShardDO {
|
|
|
4957
5861
|
}
|
|
4958
5862
|
setter.call(this.state, new WebSocketRequestResponsePair(WS_KEEPALIVE_PING, WS_KEEPALIVE_PONG));
|
|
4959
5863
|
}
|
|
5864
|
+
/**
|
|
5865
|
+
* Route the non-RPC requests `fetch` handles before the shard-local RPC
|
|
5866
|
+
* endpoint: a WebSocket upgrade, and the internal `/_lunora/relay` owner↔relay
|
|
5867
|
+
* control channel (never reachable by a client — the runtime forwards only
|
|
5868
|
+
* worker-internal traffic there). Returns `undefined` for an RPC request, which
|
|
5869
|
+
* `fetch` then dispatches.
|
|
5870
|
+
* @returns the routed response, or `undefined` when this is an RPC request
|
|
5871
|
+
*/
|
|
5872
|
+
async routeNonRpc(url, request) {
|
|
5873
|
+
if (url.pathname === "/_lunora/relay" && request.method === "POST") {
|
|
5874
|
+
return this.relay ? await this.relay.handleControl(request) : new Response("relay tier inactive", { status: 404 });
|
|
5875
|
+
}
|
|
5876
|
+
if (url.pathname === "/_lunora/route" && request.method === "GET") {
|
|
5877
|
+
return jsonResponse({ relayCount: this.relay?.relayCount() ?? 0 });
|
|
5878
|
+
}
|
|
5879
|
+
if (url.pathname === "/rpc-batch" && request.method === "POST") {
|
|
5880
|
+
return this.handleBatchRpc(request);
|
|
5881
|
+
}
|
|
5882
|
+
if (request.headers.get("Upgrade") === "websocket") {
|
|
5883
|
+
return this.handleWebSocketUpgrade(request);
|
|
5884
|
+
}
|
|
5885
|
+
return void 0;
|
|
5886
|
+
}
|
|
4960
5887
|
handleWebSocketUpgrade(request) {
|
|
4961
5888
|
if (!this.isUpgradeAllowed(request)) {
|
|
4962
5889
|
return new Response("Forbidden", { status: 403 });
|
|
@@ -5074,7 +6001,7 @@ class ShardDO {
|
|
|
5074
6001
|
* topic name. That matches the AnyCable model (and `from` is unforgeable),
|
|
5075
6002
|
* but per-topic auth does not exist here; see `whisperSubscribe` on the client.
|
|
5076
6003
|
*/
|
|
5077
|
-
broadcastWhisper(sender, topic, data) {
|
|
6004
|
+
async broadcastWhisper(sender, topic, data) {
|
|
5078
6005
|
if (!this.allowWhisper(sender)) {
|
|
5079
6006
|
return;
|
|
5080
6007
|
}
|
|
@@ -5085,12 +6012,29 @@ class ShardDO {
|
|
|
5085
6012
|
const from = this.readAttachment(sender).userId;
|
|
5086
6013
|
const fromSuffix = from === void 0 ? "" : `,"from":${JSON.stringify(from)}`;
|
|
5087
6014
|
const frame = `{"type":"whisper","topic":${JSON.stringify(topic)},"data":${dataJson}${fromSuffix}}`;
|
|
6015
|
+
this.deliverWhisperLocal(topic, frame, sender);
|
|
6016
|
+
await this.relay?.forwardWhisper(topic, frame);
|
|
6017
|
+
}
|
|
6018
|
+
/**
|
|
6019
|
+
* Deliver an already-serialized whisper `frame` to every local socket joined to
|
|
6020
|
+
* `topic`, excluding `exclude` (the sender, or `undefined` for a frame the relay
|
|
6021
|
+
* hub forwarded in — its sender lives on another DO). Records the fan-out pass
|
|
6022
|
+
* for `getFanoutMetrics` (plan 075 Phase 1). Pure delivery — no SQLite, no CDC.
|
|
6023
|
+
* @returns the number of sockets the frame was sent to
|
|
6024
|
+
*/
|
|
6025
|
+
deliverWhisperLocal(topic, frame, exclude) {
|
|
6026
|
+
let scanned = 0;
|
|
6027
|
+
let delivered = 0;
|
|
5088
6028
|
for (const ws of this.state.getWebSockets()) {
|
|
5089
|
-
|
|
6029
|
+
scanned += 1;
|
|
6030
|
+
if (ws === exclude || this.readAttachment(ws).whispers?.includes(topic) !== true) {
|
|
5090
6031
|
continue;
|
|
5091
6032
|
}
|
|
5092
6033
|
trySendFrame(ws, frame);
|
|
6034
|
+
delivered += 1;
|
|
5093
6035
|
}
|
|
6036
|
+
this.fanout.whisper = recordFanoutPass(this.fanout.whisper, scanned, delivered, 0);
|
|
6037
|
+
return delivered;
|
|
5094
6038
|
}
|
|
5095
6039
|
// eslint-disable-next-line class-methods-use-this -- cohesive DO instance method grouped with the hibernation/attachment helpers; reads only the socket
|
|
5096
6040
|
readAttachment(ws) {
|