@lunora/do 1.0.0-alpha.1 → 1.0.0-alpha.10
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/__assets__/package-og.svg +1 -1
- package/dist/index.d.mts +832 -84
- package/dist/index.d.ts +832 -84
- package/dist/index.mjs +22 -20
- package/dist/packem_shared/{ADMIN_FUNCTION_PREFIX-Dzdqq5J2.mjs → ADMIN_FUNCTIONS-D_UiYJFk.mjs} +4 -1
- package/dist/packem_shared/{applyCdcChanges-Ctdmxmrv.mjs → CDC_LOG_TABLE-DSycmnDf.mjs} +5 -1
- package/dist/packem_shared/{assertFlatPredicate-DyVYReuT.mjs → DEFAULT_MAX_RELATION_KEYS-DU-Y4-LJ.mjs} +51 -2
- package/dist/packem_shared/{assertValidClientId-CBZ1zC96.mjs → NotUniqueError-DZQtH02h.mjs} +126 -36
- package/dist/packem_shared/{rank-CrkEIpF4.mjs → RANK_TIEBREAK-CXhdcA1o.mjs} +2 -13
- package/dist/packem_shared/{guardWriter-u3UlnCH5.mjs → RLS_UNWRAP_SYMBOL-EtGQdC9d.mjs} +6 -2
- package/dist/packem_shared/{ROOT_DO_SIZE_WARN_BYTES-DQkmGiCS.mjs → ROOT_DO_SIZE_WARN_BYTES-DKwBF3Jp.mjs} +1143 -156
- package/dist/packem_shared/{ReactiveCache-ByVzgH3d.mjs → ReactiveCache-1hDydFyv.mjs} +1 -28
- package/dist/packem_shared/{applyOnDelete-CMif2RKw.mjs → applyOnDelete-BQ-8ZlZ1.mjs} +19 -9
- package/dist/packem_shared/{buildSeekWhere-lVsNXSLy.mjs → applySelect-BvZdFUBT.mjs} +18 -1
- package/dist/packem_shared/{backfillAggregateIndexes-BF5eL7kW.mjs → backfillAggregateIndexes-BZsOqDXP.mjs} +3 -2
- package/dist/packem_shared/ctx-db-idempotency-BdcNpvY4.mjs +108 -0
- package/dist/packem_shared/ctx-db-shapes-DVoeZpo-.mjs +53 -0
- package/dist/packem_shared/{runShardMigrations-C3bn5r93.mjs → runShardMigrations-nIwoQeOK.mjs} +6 -4
- package/dist/packem_shared/serialize-sql-BlRUoiQe.mjs +14 -0
- package/dist/packem_shared/{serveRelationFanout-Clr1a05L.mjs → serveRelationFanout-C6lDaesn.mjs} +1 -1
- package/dist/packem_shared/stableStringify-CyHKJXre.mjs +30 -0
- package/dist/packem_shared/subscriptionListDeltas-ce84gpwL.mjs +111 -0
- package/package.json +2 -2
- package/dist/packem_shared/ctx-db-idempotency-DkC9rP91.mjs +0 -35
- package/dist/packem_shared/encodePartitionKey-C6blLR5K.mjs +0 -1
- /package/dist/packem_shared/{matchesStaticWhere-CFk6adSu.mjs → AGGREGATE_SQL_FUNCTION-CFk6adSu.mjs} +0 -0
- /package/dist/packem_shared/{AUTH_METRICS_BUCKET_MS-CiHHYeJi.mjs → AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs} +0 -0
- /package/dist/packem_shared/{ensureFunctionMetricsTables-UDNVD7FS.mjs → FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs} +0 -0
- /package/dist/packem_shared/{clearCapturedMail-CPpgl-dX.mjs → MAIL_RETENTION-CPpgl-dX.mjs} +0 -0
- /package/dist/packem_shared/{assertReadonly-dDcFE1YZ.mjs → MAX_SQL_ROWS-dDcFE1YZ.mjs} +0 -0
- /package/dist/packem_shared/{buildSecurityAudit-CCAvoFlr.mjs → MIN_ADMIN_TOKEN_LENGTH-CCAvoFlr.mjs} +0 -0
- /package/dist/packem_shared/{ftsTableName-BLEMawrp.mjs → buildFtsMatch-BLEMawrp.mjs} +0 -0
- /package/dist/packem_shared/{runTriggers-5N6_Fx0A.mjs → hasTrigger-5N6_Fx0A.mjs} +0 -0
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import { drizzle } from 'drizzle-orm/durable-sqlite';
|
|
2
2
|
import { parseExportShardArgs, parseImportShardArgs } from './exportShardRows-DZEhUeyI.mjs';
|
|
3
|
-
import { recordAuthEvent, readAuthMetrics } from './
|
|
3
|
+
import { recordAuthEvent, readAuthMetrics } from './AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs';
|
|
4
4
|
import { DATA_MIGRATION_STATE_TABLE, readMigrationStatus } from './DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs';
|
|
5
5
|
import { SCAN_DEP, createDependencyTracker, tableFromDepKey } from './SCAN_DEP-DLJF8dsj.mjs';
|
|
6
|
-
import { readFunctionMetricsTotals, readFunctionMetricIndexHits, recordFunctionMetric, mergeScanAttribution, readFunctionMetrics, readFunctionMetricBuckets } from './
|
|
7
|
-
import { ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, selectMatchingIds, ADMIN_FUNCTIONS, findStorageReferences, listTables, summarizeSubscriptions, readTablePage, facetColumn, MAX_PAGE_SIZE } from './
|
|
6
|
+
import { readFunctionMetricsTotals, readFunctionMetricIndexHits, recordFunctionMetric, mergeScanAttribution, readFunctionMetrics, readFunctionMetricBuckets } from './FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs';
|
|
7
|
+
import { ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, selectMatchingIds, ADMIN_FUNCTIONS, findStorageReferences, listTables, summarizeSubscriptions, readTablePage, facetColumn, FLAGS_FUNCTION_PREFIX, MAX_PAGE_SIZE } from './ADMIN_FUNCTIONS-D_UiYJFk.mjs';
|
|
8
8
|
import { LogBuffer } from './LogBuffer-B_Ezju_N.mjs';
|
|
9
|
-
import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './
|
|
9
|
+
import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './MAIL_RETENTION-CPpgl-dX.mjs';
|
|
10
10
|
import { readBookmark, armRestore } from './armRestore-BJk53Ro8.mjs';
|
|
11
|
-
import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-
|
|
11
|
+
import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-1hDydFyv.mjs';
|
|
12
12
|
import { redact, standardRules } from '@visulima/redact';
|
|
13
13
|
import { i as isDevEnvironment, c as buildSettings, b as buildSecurityAudit } from './security-audit-CucgBice.mjs';
|
|
14
|
-
import { runReadonlySql } from './
|
|
14
|
+
import { runReadonlySql } from './MAX_SQL_ROWS-dDcFE1YZ.mjs';
|
|
15
|
+
import { trySendFrame, subscriptionListDeltas, sendDeltaFrames } from './subscriptionListDeltas-ce84gpwL.mjs';
|
|
15
16
|
import { ConflictError } from './ConflictError-C0STs6bU.mjs';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
17
|
+
import { e as deleteGlobalShapeSnapshotsForConnection, g as readIdempotent, h as writeIdempotent, t as trimIdempotent, r as readClientWatermark, m as migrateClientWatermark, c as advanceClientWatermark, d as deleteGlobalShapeSnapshot, f as readGlobalShapeSnapshot, w as writeGlobalShapeSnapshot } from './ctx-db-idempotency-BdcNpvY4.mjs';
|
|
18
|
+
import { CDC_LOG_TABLE, readCdcChanges, readCdcCursor, readCdcEpoch, minCdcSeq, bumpCdcEpoch } from './CDC_LOG_TABLE-DSycmnDf.mjs';
|
|
19
|
+
import { s as selectShapeMemberIds, a as selectShapeRows } from './ctx-db-shapes-DVoeZpo-.mjs';
|
|
18
20
|
|
|
19
21
|
const AUDIT_LOG_TABLE = "__lunora_audit__";
|
|
20
22
|
const AUDIT_LOG_RETENTION = 1e3;
|
|
@@ -349,6 +351,58 @@ const readRequestLog = (sql, options = {}) => {
|
|
|
349
351
|
});
|
|
350
352
|
};
|
|
351
353
|
|
|
354
|
+
const projectColumns = (document_, columns) => {
|
|
355
|
+
if (!columns) {
|
|
356
|
+
return document_;
|
|
357
|
+
}
|
|
358
|
+
const projected = /* @__PURE__ */ Object.create(null);
|
|
359
|
+
for (const key of ["_id", "_creationTime", ...columns]) {
|
|
360
|
+
if (Object.hasOwn(document_, key)) {
|
|
361
|
+
projected[key] = document_[key];
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return projected;
|
|
365
|
+
};
|
|
366
|
+
const diffGlobalMembership = (rows, previous, options) => {
|
|
367
|
+
const { columns, table } = options;
|
|
368
|
+
const next = /* @__PURE__ */ new Map();
|
|
369
|
+
const rowsPatch = [];
|
|
370
|
+
for (const { doc, id } of rows) {
|
|
371
|
+
const value = projectColumns(doc, columns);
|
|
372
|
+
const json = JSON.stringify(value);
|
|
373
|
+
next.set(id, json);
|
|
374
|
+
const before = previous.get(id);
|
|
375
|
+
if (before === void 0) {
|
|
376
|
+
rowsPatch.push({ key: id, op: "insert", table, value });
|
|
377
|
+
} else if (before !== json) {
|
|
378
|
+
rowsPatch.push({ key: id, op: "update", table, value });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
for (const id of previous.keys()) {
|
|
382
|
+
if (!next.has(id)) {
|
|
383
|
+
rowsPatch.push({ key: id, op: "delete", table });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { next, rowsPatch };
|
|
387
|
+
};
|
|
388
|
+
const buildPokeFrames = (parts, meta) => {
|
|
389
|
+
const { baseCheckpoint, checkpoint, epoch, lastMutationId, pokeId } = meta;
|
|
390
|
+
const frames = [JSON.stringify({ baseCheckpoint, epoch, pokeId, type: "pokeStart" })];
|
|
391
|
+
for (const part of parts) {
|
|
392
|
+
frames.push(
|
|
393
|
+
JSON.stringify({
|
|
394
|
+
pokeId,
|
|
395
|
+
rowsPatch: part.rowsPatch,
|
|
396
|
+
shapeId: part.shapeId,
|
|
397
|
+
type: "pokePart",
|
|
398
|
+
...lastMutationId === void 0 ? {} : { lastMutationId }
|
|
399
|
+
})
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
frames.push(JSON.stringify({ checkpoint, epoch, pokeId, type: "pokeEnd" }));
|
|
403
|
+
return frames;
|
|
404
|
+
};
|
|
405
|
+
|
|
352
406
|
const DANGLING_SCAN_CAP = 5e3;
|
|
353
407
|
const DANGLING_RESULT_CAP = 500;
|
|
354
408
|
const DOC_COLUMN = "__doc__";
|
|
@@ -413,97 +467,7 @@ const findDanglingReferences = (sql, storageColumns, liveKeys) => {
|
|
|
413
467
|
|
|
414
468
|
const WS_KEEPALIVE_PING = "lunora-ping";
|
|
415
469
|
const WS_KEEPALIVE_PONG = "lunora-pong";
|
|
416
|
-
const
|
|
417
|
-
const DELTA_FALLBACK_TABLE = "__lunora__";
|
|
418
|
-
const readRowId = (row) => {
|
|
419
|
-
if (typeof row !== "object" || row === null || Array.isArray(row)) {
|
|
420
|
-
return void 0;
|
|
421
|
-
}
|
|
422
|
-
const id = row[ROW_ID_FIELD];
|
|
423
|
-
return typeof id === "string" ? id : void 0;
|
|
424
|
-
};
|
|
425
|
-
const indexRowsById = (rows) => {
|
|
426
|
-
const byId = /* @__PURE__ */ new Map();
|
|
427
|
-
const order = [];
|
|
428
|
-
for (const row of rows) {
|
|
429
|
-
const id = readRowId(row);
|
|
430
|
-
if (id === void 0 || byId.has(id)) {
|
|
431
|
-
return void 0;
|
|
432
|
-
}
|
|
433
|
-
byId.set(id, row);
|
|
434
|
-
order.push(id);
|
|
435
|
-
}
|
|
436
|
-
return { byId, order };
|
|
437
|
-
};
|
|
438
|
-
const survivorsKeepOrder = (previous, next) => {
|
|
439
|
-
const survivingPrevious = previous.order.filter((id) => next.byId.has(id));
|
|
440
|
-
const survivingNext = next.order.filter((id) => previous.byId.has(id));
|
|
441
|
-
if (survivingPrevious.length !== survivingNext.length) {
|
|
442
|
-
return false;
|
|
443
|
-
}
|
|
444
|
-
return survivingPrevious.every((id, index) => survivingNext[index] === id);
|
|
445
|
-
};
|
|
446
|
-
const collectDeleteDeltas = (previous, next, deltaTable, tableJson) => {
|
|
447
|
-
const out = [];
|
|
448
|
-
for (const id of previous.order) {
|
|
449
|
-
if (!next.byId.has(id)) {
|
|
450
|
-
out.push({
|
|
451
|
-
delta: { key: id, op: "delete", table: deltaTable },
|
|
452
|
-
frame: `{"key":${JSON.stringify(id)},"op":"delete","table":${tableJson}}`
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
return out;
|
|
457
|
-
};
|
|
458
|
-
const collectUpsertDeltas = (previous, next, deltaTable, tableJson) => {
|
|
459
|
-
const out = [];
|
|
460
|
-
for (const id of next.order) {
|
|
461
|
-
const nextRow = next.byId.get(id);
|
|
462
|
-
const previousRow = previous.byId.get(id);
|
|
463
|
-
const nextFingerprint = JSON.stringify(nextRow);
|
|
464
|
-
const previousFingerprint = previousRow === void 0 ? void 0 : JSON.stringify(previousRow);
|
|
465
|
-
if (previousFingerprint === nextFingerprint) {
|
|
466
|
-
continue;
|
|
467
|
-
}
|
|
468
|
-
const op = previousFingerprint === void 0 ? "insert" : "update";
|
|
469
|
-
out.push({
|
|
470
|
-
delta: { key: id, op, row: nextRow, table: deltaTable },
|
|
471
|
-
frame: `{"key":${JSON.stringify(id)},"op":"${op}","row":${nextFingerprint},"table":${tableJson}}`
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
return out;
|
|
475
|
-
};
|
|
476
|
-
const subscriptionListDeltas = (previousJson, nextResult, table, frames) => {
|
|
477
|
-
let parsed;
|
|
478
|
-
try {
|
|
479
|
-
parsed = JSON.parse(previousJson);
|
|
480
|
-
} catch {
|
|
481
|
-
return void 0;
|
|
482
|
-
}
|
|
483
|
-
if (!Array.isArray(parsed) || !Array.isArray(nextResult)) {
|
|
484
|
-
return void 0;
|
|
485
|
-
}
|
|
486
|
-
const previous = indexRowsById(parsed);
|
|
487
|
-
const next = indexRowsById(nextResult);
|
|
488
|
-
if (previous === void 0 || next === void 0) {
|
|
489
|
-
return void 0;
|
|
490
|
-
}
|
|
491
|
-
if (!survivorsKeepOrder(previous, next)) {
|
|
492
|
-
return void 0;
|
|
493
|
-
}
|
|
494
|
-
const deltaTable = table === "" ? DELTA_FALLBACK_TABLE : table;
|
|
495
|
-
const tableJson = JSON.stringify(deltaTable);
|
|
496
|
-
const framed = [...collectDeleteDeltas(previous, next, deltaTable, tableJson), ...collectUpsertDeltas(previous, next, deltaTable, tableJson)];
|
|
497
|
-
if (framed.length > next.order.length) {
|
|
498
|
-
return void 0;
|
|
499
|
-
}
|
|
500
|
-
if (frames !== void 0) {
|
|
501
|
-
for (const { frame } of framed) {
|
|
502
|
-
frames.push(frame);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
return framed.map(({ delta }) => delta);
|
|
506
|
-
};
|
|
470
|
+
const UNDELIVERED_BASELINE = "<undelivered>";
|
|
507
471
|
const ROOT_DO_SIZE_WARN_BYTES = 1073741824;
|
|
508
472
|
const CDC_RESUME_SCAN_LIMIT = 1e4;
|
|
509
473
|
const IDEMPOTENCY_RETENTION_MS = 864e5;
|
|
@@ -918,6 +882,13 @@ const parseIdentityHeader = (raw) => {
|
|
|
918
882
|
}
|
|
919
883
|
return void 0;
|
|
920
884
|
};
|
|
885
|
+
const parseClientSeqHeader = (raw) => {
|
|
886
|
+
if (!raw) {
|
|
887
|
+
return void 0;
|
|
888
|
+
}
|
|
889
|
+
const seq = Number(raw);
|
|
890
|
+
return Number.isInteger(seq) && seq > 0 ? seq : void 0;
|
|
891
|
+
};
|
|
921
892
|
const tablesFromDeps = (deps) => {
|
|
922
893
|
const tables = /* @__PURE__ */ new Set();
|
|
923
894
|
for (const dep of deps) {
|
|
@@ -1002,6 +973,28 @@ class ShardDO {
|
|
|
1002
973
|
* failure stays unlikely.
|
|
1003
974
|
*/
|
|
1004
975
|
static MAX_SUBSCRIPTIONS_PER_SOCKET = 32;
|
|
976
|
+
/**
|
|
977
|
+
* Poll interval (ms) for `.global()`-table shapes. A global table lives in
|
|
978
|
+
* D1 with no per-DO op-log, so its shapes can't be poke-live; the DO re-reads
|
|
979
|
+
* each subscribed global shape's membership from D1 on an alarm every
|
|
980
|
+
* `GLOBAL_SHAPE_POLL_INTERVAL_MS` and pokes only the diff. This is the
|
|
981
|
+
* latency floor for a global-shape update — deliberately coarse (seconds, not
|
|
982
|
+
* the sub-millisecond poke-live path) since the D1 read fans out per tick.
|
|
983
|
+
*/
|
|
984
|
+
static GLOBAL_SHAPE_POLL_INTERVAL_MS = 2e3;
|
|
985
|
+
/**
|
|
986
|
+
* Upper bound on a `.global()`-shape's materialized membership. Each global
|
|
987
|
+
* shape keeps its ENTIRE current membership as a per-socket snapshot
|
|
988
|
+
* (`Map<rowKey, hash>`) so the poll loop can diff it; that snapshot — and the
|
|
989
|
+
* read buffer feeding it — scale with the membership size, multiplied by every
|
|
990
|
+
* subscribed socket. An unbounded membership (a global table with no narrowing
|
|
991
|
+
* shape predicate or RLS read scope) would grow them without limit and evict
|
|
992
|
+
* the DO. A shape whose membership exceeds this cap is failed closed (left
|
|
993
|
+
* empty, logged) rather than retained — the developer must narrow it. Sized
|
|
994
|
+
* well above any reasonable per-identity replicated set so legitimate shapes
|
|
995
|
+
* never trip it.
|
|
996
|
+
*/
|
|
997
|
+
static GLOBAL_SHAPE_MAX_ROWS = 5e4;
|
|
1005
998
|
/**
|
|
1006
999
|
* Per-socket whisper-topic cap. Topic membership rides the same hibernation
|
|
1007
1000
|
* attachment as `subs`, so bound it for the same reason — a runaway
|
|
@@ -1103,6 +1096,39 @@ class ShardDO {
|
|
|
1103
1096
|
* `finally` block.
|
|
1104
1097
|
*/
|
|
1105
1098
|
currentRequestMutationId;
|
|
1099
|
+
/**
|
|
1100
|
+
* Stable per-device client id for the in-flight custom-mutator push,
|
|
1101
|
+
* forwarded via the `x-lunora-client-id` header. Backs the
|
|
1102
|
+
* `__client_watermark` table: the dispatch path classifies the paired
|
|
1103
|
+
* `currentRequestClientSeq` against the stored high-watermark (already
|
|
1104
|
+
* processed / next / out-of-order gap). Absent on legacy mutations and
|
|
1105
|
+
* queries (those keep the `__idempotency` path). Cleared in `fetch`'s
|
|
1106
|
+
* `finally`.
|
|
1107
|
+
*/
|
|
1108
|
+
currentRequestClientId;
|
|
1109
|
+
/**
|
|
1110
|
+
* Monotonic per-client mutation sequence for the in-flight custom-mutator
|
|
1111
|
+
* push, forwarded via the `x-lunora-client-seq` header (numeric). Paired
|
|
1112
|
+
* with `currentRequestClientId` to drive the watermark classification.
|
|
1113
|
+
* `undefined` when absent or non-numeric.
|
|
1114
|
+
*/
|
|
1115
|
+
currentRequestClientSeq;
|
|
1116
|
+
/**
|
|
1117
|
+
* The in-flight push's custom-mutator classification, stashed by `fetch`
|
|
1118
|
+
* before `handleRpc` so the in-transaction bookkeeping ({@link
|
|
1119
|
+
* ShardDO.commitMutationBookkeeping}) can advance the `__client_watermark` for
|
|
1120
|
+
* a `"next"` push inside the same commit as the writes. `undefined` for an
|
|
1121
|
+
* ordinary mutation / non-mutator push. Cleared per request.
|
|
1122
|
+
*/
|
|
1123
|
+
currentMutatorClass;
|
|
1124
|
+
/**
|
|
1125
|
+
* Set once a mutation's replay bookkeeping (idempotency row + watermark
|
|
1126
|
+
* advance) has committed INSIDE the handler transaction, so the post-dispatch
|
|
1127
|
+
* path skips the now-redundant best-effort writes. Cleared per request; stays
|
|
1128
|
+
* `false` for actions/queries (no transaction wrapper) so their dispatch-level
|
|
1129
|
+
* idempotency persist still runs.
|
|
1130
|
+
*/
|
|
1131
|
+
mutationBookkeepingCommitted = false;
|
|
1106
1132
|
/**
|
|
1107
1133
|
* Wall-clock millis of the last `__idempotency` GC sweep on this warm
|
|
1108
1134
|
* instance. The dedup write throttles `trimIdempotent` to at most once an
|
|
@@ -1133,6 +1159,18 @@ class ShardDO {
|
|
|
1133
1159
|
* the common read-only path allocates nothing.
|
|
1134
1160
|
*/
|
|
1135
1161
|
pendingChangedTables = void 0;
|
|
1162
|
+
/**
|
|
1163
|
+
* Coalesced set of tables awaiting a subscription-refresh pass, merged
|
|
1164
|
+
* across every {@link ShardDO.flushChangedTables} call that lands while a
|
|
1165
|
+
* pass is already draining. The single drain loop
|
|
1166
|
+
* ({@link ShardDO.drainSubscriptionRefreshes}) owns this set; a burst of N
|
|
1167
|
+
* writes to the same table therefore collapses into one (or two) refresh
|
|
1168
|
+
* passes instead of N, so each affected subscription's handler re-runs once
|
|
1169
|
+
* per burst rather than once per write. `undefined` when nothing is pending.
|
|
1170
|
+
*/
|
|
1171
|
+
pendingRefreshTables = void 0;
|
|
1172
|
+
/** True while {@link ShardDO.drainSubscriptionRefreshes} is running; the single-waiter gate that coalesces concurrent flushes. */
|
|
1173
|
+
refreshInFlight = false;
|
|
1136
1174
|
/**
|
|
1137
1175
|
* Last pushed result per `(socket, subId)`, keyed by socket. Lets
|
|
1138
1176
|
* `refreshSubscriptions` skip re-running queries whose tables were
|
|
@@ -1141,6 +1179,39 @@ class ShardDO {
|
|
|
1141
1179
|
* memo simply forces one re-run and (at most) one redundant push.
|
|
1142
1180
|
*/
|
|
1143
1181
|
subMemos = /* @__PURE__ */ new WeakMap();
|
|
1182
|
+
/**
|
|
1183
|
+
* Per-socket poke baseline for shape subscriptions: maps each shape's
|
|
1184
|
+
* subscription id to the `__cdc_log` cursor it has been poked through.
|
|
1185
|
+
* `pokeShapeSubscribers` reads each op page since this cursor and advances
|
|
1186
|
+
* it to the flush watermark. In-memory only (like {@link ShardDO.subMemos});
|
|
1187
|
+
* a cold memo on a reconnected/hibernated socket re-seeds from the client's
|
|
1188
|
+
* `sinceCheckpoint`.
|
|
1189
|
+
*/
|
|
1190
|
+
shapeMemos = /* @__PURE__ */ new WeakMap();
|
|
1191
|
+
/**
|
|
1192
|
+
* Per-socket, per-**global**-shape membership snapshot: maps each global
|
|
1193
|
+
* shape's subscription id to a `key → projected-value JSON` map of the rows
|
|
1194
|
+
* last poked to that socket. A `.global()` (D1) table has no op-log to diff,
|
|
1195
|
+
* so {@link ShardDO.refreshGlobalShape} re-reads the full membership on each
|
|
1196
|
+
* alarm tick and diffs it against this snapshot to compute the poke. Parallel
|
|
1197
|
+
* to {@link ShardDO.shapeMemos} (the cursor baseline for poke-live shapes).
|
|
1198
|
+
*
|
|
1199
|
+
* This is a hot in-memory **cache** over the durable `__global_shape_snapshot`
|
|
1200
|
+
* table (keyed by the socket's `connectionId` + subId): a hibernation eviction
|
|
1201
|
+
* clears the WeakMap, so on the next alarm wake {@link ShardDO.readGlobalSnapshot}
|
|
1202
|
+
* misses and re-loads the baseline from SQLite — without it, the diff would run
|
|
1203
|
+
* against an empty baseline and a row deleted from D1 while the DO slept would
|
|
1204
|
+
* never be poked as a `delete`, lingering on the client as a phantom row.
|
|
1205
|
+
*/
|
|
1206
|
+
globalShapeSnapshots = /* @__PURE__ */ new WeakMap();
|
|
1207
|
+
/**
|
|
1208
|
+
* Whether a global-shape poll alarm is currently armed. Guards
|
|
1209
|
+
* {@link ShardDO.scheduleGlobalPoll} from re-arming on every seed; reset in
|
|
1210
|
+
* {@link ShardDO.alarm} before the poll so a still-subscribed shape re-arms.
|
|
1211
|
+
*/
|
|
1212
|
+
globalPollScheduled = false;
|
|
1213
|
+
/** Monotonic per-DO poke id source; correlates a poke's `pokeStart`/`pokePart`/`pokeEnd` frames. */
|
|
1214
|
+
pokeSequence = 0;
|
|
1144
1215
|
/** Per-socket whisper-rate token bucket (see {@link ShardDO.WHISPER_RATE_BURST}). In-memory; resets on hibernation. */
|
|
1145
1216
|
whisperBuckets = /* @__PURE__ */ new WeakMap();
|
|
1146
1217
|
/**
|
|
@@ -1272,6 +1343,10 @@ class ShardDO {
|
|
|
1272
1343
|
this.currentResponseBookmark = void 0;
|
|
1273
1344
|
this.currentRequestUserId = request.headers.get("x-lunora-userid") ?? void 0;
|
|
1274
1345
|
this.currentRequestMutationId = request.headers.get("x-lunora-mutation-id") ?? void 0;
|
|
1346
|
+
this.currentRequestClientId = request.headers.get("x-lunora-client-id") ?? void 0;
|
|
1347
|
+
this.currentRequestClientSeq = parseClientSeqHeader(request.headers.get("x-lunora-client-seq"));
|
|
1348
|
+
this.currentMutatorClass = void 0;
|
|
1349
|
+
this.mutationBookkeepingCommitted = false;
|
|
1275
1350
|
this.currentRequestIdentity = parseIdentityHeader(request.headers.get("x-lunora-identity"));
|
|
1276
1351
|
this.currentRequestIp = request.headers.get("x-lunora-client-ip") ?? void 0;
|
|
1277
1352
|
this.currentRequestSystem = request.headers.get("x-lunora-system") === "1";
|
|
@@ -1287,20 +1362,25 @@ class ShardDO {
|
|
|
1287
1362
|
const value = await this.runRelationFanoutRead(payload.functionPath, payload.args ?? {});
|
|
1288
1363
|
return jsonResponse(value, 200, this.currentResponseBookmark);
|
|
1289
1364
|
}
|
|
1365
|
+
const mutatorClass = this.isCustomMutator(payload.functionPath) ? this.classifyClientMutation() : void 0;
|
|
1366
|
+
this.currentMutatorClass = mutatorClass;
|
|
1367
|
+
const watermarkShortCircuit = this.rejectNonNextMutation(payload.functionPath, mutatorClass, dispatchStartedAt);
|
|
1368
|
+
if (watermarkShortCircuit !== void 0) {
|
|
1369
|
+
return watermarkShortCircuit;
|
|
1370
|
+
}
|
|
1290
1371
|
const cached = this.readIdempotentResult(this.currentRequestMutationId);
|
|
1291
1372
|
if (cached !== void 0) {
|
|
1292
|
-
this.
|
|
1293
|
-
return jsonResponse({ result: cached.value }, 200, this.currentResponseBookmark);
|
|
1373
|
+
return this.respondFromIdempotencyCache(payload.functionPath, dispatchStartedAt, mutatorClass, cached.value);
|
|
1294
1374
|
}
|
|
1295
1375
|
const result = await this.handleRpc(payload.functionPath, payload.args ?? {});
|
|
1296
|
-
this.
|
|
1376
|
+
this.recordPostDispatchBookkeeping(result, mutatorClass);
|
|
1297
1377
|
const durationMs = Date.now() - dispatchStartedAt;
|
|
1298
1378
|
this.recordFunctionCall(payload.functionPath, durationMs, void 0, this.currentScannedTables, this.currentIndexHits);
|
|
1299
1379
|
this.flushStmtSamples();
|
|
1300
1380
|
const tablesWritten = [...this.pendingChangedTables ?? []];
|
|
1301
1381
|
this.recordRequestLog(payload.functionPath, payload.args ?? {}, durationMs, "ok", tablesWritten);
|
|
1302
1382
|
this.maybeWarnRootSize();
|
|
1303
|
-
const response =
|
|
1383
|
+
const response = this.buildDispatchResponse(mutatorClass, result);
|
|
1304
1384
|
await this.flushChangedTables();
|
|
1305
1385
|
return response;
|
|
1306
1386
|
} catch (error) {
|
|
@@ -1326,6 +1406,10 @@ class ShardDO {
|
|
|
1326
1406
|
this.currentResponseBookmark = void 0;
|
|
1327
1407
|
this.currentRequestUserId = void 0;
|
|
1328
1408
|
this.currentRequestMutationId = void 0;
|
|
1409
|
+
this.currentRequestClientId = void 0;
|
|
1410
|
+
this.currentRequestClientSeq = void 0;
|
|
1411
|
+
this.currentMutatorClass = void 0;
|
|
1412
|
+
this.mutationBookkeepingCommitted = false;
|
|
1329
1413
|
this.currentRequestIdentity = void 0;
|
|
1330
1414
|
this.currentRequestIp = void 0;
|
|
1331
1415
|
this.currentRequestSystem = false;
|
|
@@ -1363,6 +1447,9 @@ class ShardDO {
|
|
|
1363
1447
|
if (envelope.context !== void 0) {
|
|
1364
1448
|
attachment.context = envelope.context;
|
|
1365
1449
|
}
|
|
1450
|
+
if (envelope.clientId !== void 0) {
|
|
1451
|
+
attachment.clientId = envelope.clientId;
|
|
1452
|
+
}
|
|
1366
1453
|
attachment.connected = true;
|
|
1367
1454
|
try {
|
|
1368
1455
|
ws.serializeAttachment?.(attachment);
|
|
@@ -1394,6 +1481,20 @@ class ShardDO {
|
|
|
1394
1481
|
}
|
|
1395
1482
|
return;
|
|
1396
1483
|
}
|
|
1484
|
+
if (envelope.type === "shape_subscribe" && envelope.shape) {
|
|
1485
|
+
await this.handleShapeSubscribe(ws, envelope.id, {
|
|
1486
|
+
args: envelope.shape.args,
|
|
1487
|
+
name: envelope.shape.name,
|
|
1488
|
+
sinceEpoch: envelope.sinceEpoch,
|
|
1489
|
+
sinceSeq: envelope.sinceCheckpoint
|
|
1490
|
+
});
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
if (envelope.type === "shape_unsubscribe") {
|
|
1494
|
+
this.shapeUnsubscribe(ws, envelope.id);
|
|
1495
|
+
ws.send(JSON.stringify({ id: envelope.id, type: "ack" }));
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1397
1498
|
if (envelope.type === "stream" && envelope.query?.functionPath) {
|
|
1398
1499
|
if (envelope.query.functionPath.startsWith(ADMIN_FUNCTION_PREFIX)) {
|
|
1399
1500
|
ws.send(JSON.stringify({ id: envelope.id, message: "streams must be public", type: "error" }));
|
|
@@ -1444,12 +1545,41 @@ class ShardDO {
|
|
|
1444
1545
|
this.streamCancellers.delete(ws);
|
|
1445
1546
|
}
|
|
1446
1547
|
this.subMemos.delete(ws);
|
|
1548
|
+
this.shapeMemos.delete(ws);
|
|
1549
|
+
this.globalShapeSnapshots.delete(ws);
|
|
1550
|
+
if (attachment.connectionId !== void 0) {
|
|
1551
|
+
try {
|
|
1552
|
+
deleteGlobalShapeSnapshotsForConnection(this.sql, attachment.connectionId);
|
|
1553
|
+
} catch {
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1447
1556
|
ws.serializeAttachment?.(void 0);
|
|
1448
1557
|
}
|
|
1449
1558
|
/** Hibernation API: invoked on socket error. */
|
|
1450
1559
|
// eslint-disable-next-line class-methods-use-this -- Workers hibernation handler: the platform invokes it on the instance; the signature must stay an instance method
|
|
1451
1560
|
webSocketError(_ws, _error) {
|
|
1452
1561
|
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Durable Object alarm handler — the heartbeat for `.global()`-table shapes.
|
|
1564
|
+
* The runtime wakes this when the poll alarm armed by `scheduleGlobalPoll`
|
|
1565
|
+
* fires; it refreshes every subscribed global shape (diff-poke from the global
|
|
1566
|
+
* backend) and re-arms while any remain. With no global subscribers left, the
|
|
1567
|
+
* alarm is not re-armed and the DO goes idle. A base-only / global-free DO
|
|
1568
|
+
* never arms it, so this stays dormant there.
|
|
1569
|
+
*/
|
|
1570
|
+
async alarm() {
|
|
1571
|
+
this.globalPollScheduled = false;
|
|
1572
|
+
let remaining;
|
|
1573
|
+
try {
|
|
1574
|
+
remaining = await this.pollGlobalShapes();
|
|
1575
|
+
} catch (error) {
|
|
1576
|
+
this.recordShapeError("shape:poll", error);
|
|
1577
|
+
remaining = 1;
|
|
1578
|
+
}
|
|
1579
|
+
if (remaining > 0) {
|
|
1580
|
+
await this.scheduleGlobalPoll();
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1453
1583
|
/**
|
|
1454
1584
|
* The registered function paths to dispatch when a socket connects/disconnects.
|
|
1455
1585
|
* Base default is empty; the codegen subclass overrides it to return the
|
|
@@ -1648,20 +1778,14 @@ class ShardDO {
|
|
|
1648
1778
|
status: 500
|
|
1649
1779
|
});
|
|
1650
1780
|
}
|
|
1651
|
-
const
|
|
1781
|
+
const transactionalStorage = this.state.storage;
|
|
1652
1782
|
const run = async () => {
|
|
1653
1783
|
this.transactionDepth = 1;
|
|
1654
|
-
sqlExec("BEGIN");
|
|
1655
1784
|
try {
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
return value;
|
|
1659
|
-
} catch (error) {
|
|
1660
|
-
try {
|
|
1661
|
-
sqlExec("ROLLBACK");
|
|
1662
|
-
} catch {
|
|
1785
|
+
if (typeof transactionalStorage?.transaction === "function") {
|
|
1786
|
+
return await transactionalStorage.transaction(async () => handler());
|
|
1663
1787
|
}
|
|
1664
|
-
|
|
1788
|
+
return await handler();
|
|
1665
1789
|
} finally {
|
|
1666
1790
|
this.transactionDepth = 0;
|
|
1667
1791
|
}
|
|
@@ -1859,7 +1983,45 @@ class ShardDO {
|
|
|
1859
1983
|
*/
|
|
1860
1984
|
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the statically-discovered feature flags
|
|
1861
1985
|
studioFeatures() {
|
|
1862
|
-
return { mail: false, payments: false, scheduler: false, storage: false, vectors: false, workflows: false };
|
|
1986
|
+
return { flags: false, mail: false, payments: false, queues: false, scheduler: false, storage: false, vectors: false, workflows: false };
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* Evaluate every statically-discovered feature flag under `context` for the
|
|
1990
|
+
* studio's read-only Flags page (`__lunora_admin__:listFlags`). The flag keys
|
|
1991
|
+
* + value types are discovered by `@lunora/codegen` from the app's
|
|
1992
|
+
* `ctx.flags.<type>("key", …)` reads and evaluated through the configured
|
|
1993
|
+
* `@lunora/flags` provider — work only the codegen subclass can do, so it
|
|
1994
|
+
* overrides this. The base class wires no provider and reports
|
|
1995
|
+
* `configured: false` with zero flags (an un-generated `ShardDO` has none).
|
|
1996
|
+
*/
|
|
1997
|
+
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with live OpenFeature evaluation over the discovered flag keys
|
|
1998
|
+
evaluateFlags(_context) {
|
|
1999
|
+
return Promise.resolve({ configured: false, flags: [] });
|
|
2000
|
+
}
|
|
2001
|
+
/**
|
|
2002
|
+
* Serve one reserved {@link FLAGS_FUNCTION_PREFIX} live flag read for the
|
|
2003
|
+
* React client's `useFlag`/`useFlags`. `functionPath` carries the flag key +
|
|
2004
|
+
* type and `args` the per-subscriber targeting context; the codegen subclass
|
|
2005
|
+
* overrides this to evaluate the flag through the app's `@lunora/flags`
|
|
2006
|
+
* provider under `identity` and return the resolved value. The base class
|
|
2007
|
+
* wires no provider, so it returns `null` — `resolveReactiveOutcome` reads
|
|
2008
|
+
* `null` as "nothing to deliver" and the subscriber keeps its default.
|
|
2009
|
+
*/
|
|
2010
|
+
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this to evaluate the flag through the configured provider
|
|
2011
|
+
runFlagSubscriptionRead(_functionPath, _arguments, _identity) {
|
|
2012
|
+
return Promise.resolve(null);
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* The Cloudflare Queues declared by this app, surfaced via
|
|
2016
|
+
* `__lunora_admin__:listQueues` for the studio's Queues page. Queues are NOT
|
|
2017
|
+
* Durable Objects and hold no shard state, so this is pure declaration
|
|
2018
|
+
* metadata statically discovered by `@lunora/codegen` from `lunora/queues.ts`
|
|
2019
|
+
* and emitted into the generated subclass, which overrides this. The base
|
|
2020
|
+
* class can't see the user's project, so it reports none.
|
|
2021
|
+
*/
|
|
2022
|
+
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the statically-discovered queue metadata
|
|
2023
|
+
queuesMetadata() {
|
|
2024
|
+
return { queues: [] };
|
|
1863
2025
|
}
|
|
1864
2026
|
/**
|
|
1865
2027
|
* The Cloudflare Workflows declared by this app, surfaced via
|
|
@@ -2124,13 +2286,15 @@ class ShardDO {
|
|
|
2124
2286
|
* unless the request carried an `x-lunora-mutation-id` header (queries and
|
|
2125
2287
|
* legacy clients leave `currentRequestMutationId` undefined).
|
|
2126
2288
|
*
|
|
2127
|
-
*
|
|
2128
|
-
*
|
|
2129
|
-
*
|
|
2130
|
-
*
|
|
2131
|
-
*
|
|
2132
|
-
*
|
|
2133
|
-
*
|
|
2289
|
+
* For a mutation this runs INSIDE the handler's transaction (via
|
|
2290
|
+
* {@link ShardDO.commitMutationBookkeeping}, which `handleRpc` invokes before
|
|
2291
|
+
* the transaction commits), so the dedup row is durable iff the writes are —
|
|
2292
|
+
* closing the crash window where the writes commit but the replay guard does
|
|
2293
|
+
* not. Actions/queries aren't transaction-wrapped, so they call this on the
|
|
2294
|
+
* live dispatch path right after the handler resolves, through the same
|
|
2295
|
+
* `this.sql` handle. `INSERT OR IGNORE` keeps a concurrent double-dispatch (or
|
|
2296
|
+
* the now-skipped post-dispatch call) of the same id idempotent. Also runs the
|
|
2297
|
+
* throttled dedup-table GC.
|
|
2134
2298
|
*/
|
|
2135
2299
|
persistIdempotentResult(result) {
|
|
2136
2300
|
if (this.currentRequestMutationId === void 0) {
|
|
@@ -2146,6 +2310,175 @@ class ShardDO {
|
|
|
2146
2310
|
} catch {
|
|
2147
2311
|
}
|
|
2148
2312
|
}
|
|
2313
|
+
/**
|
|
2314
|
+
* Whether `functionPath` names a registered custom mutator (a `defineMutator`
|
|
2315
|
+
* declaration) rather than an ordinary `mutation`. The base class knows of no
|
|
2316
|
+
* mutators, so the default is `false`; the codegen-generated subclass
|
|
2317
|
+
* overrides this to consult its mutator registry. When `true` (and the push
|
|
2318
|
+
* carries a `clientId`/`clientSeq`), the dispatch path applies the
|
|
2319
|
+
* `__client_watermark` ordering semantics instead of the legacy idempotency
|
|
2320
|
+
* dedup.
|
|
2321
|
+
*/
|
|
2322
|
+
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this to consult its mutator registry
|
|
2323
|
+
isCustomMutator(_functionPath) {
|
|
2324
|
+
return false;
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* Classify an in-flight custom-mutator push against the shard's stored
|
|
2328
|
+
* high-watermark for `currentRequestClientId`. The watermark is the highest
|
|
2329
|
+
* per-client sequence the DO has applied, so the push is exactly one of:
|
|
2330
|
+
*
|
|
2331
|
+
* - `"already"` — `seq <= watermark`: a replay of a confirmed (or in-flight,
|
|
2332
|
+
* now-resent) mutation. The handler must NOT re-run; the dispatch path returns
|
|
2333
|
+
* a benign ack so the client drops the pending overlay.
|
|
2334
|
+
* - `"next"` — `seq == watermark + 1`: the next mutation in order. Run the
|
|
2335
|
+
* authoritative `server` impl and advance the watermark in the same commit.
|
|
2336
|
+
* - `"gap"` — `seq > watermark + 1`: an out-of-order arrival (an earlier push
|
|
2337
|
+
* was lost). Halt: the client must resend from `watermark + 1`.
|
|
2338
|
+
*
|
|
2339
|
+
* Returns `undefined` when the push is not a watermarked custom mutator
|
|
2340
|
+
* (missing client id/seq, or a stub `sql` handle without the table) so the
|
|
2341
|
+
* caller falls through to the legacy idempotency path.
|
|
2342
|
+
*/
|
|
2343
|
+
classifyClientMutation() {
|
|
2344
|
+
const clientId = this.currentRequestClientId;
|
|
2345
|
+
const seq = this.currentRequestClientSeq;
|
|
2346
|
+
if (clientId === void 0 || seq === void 0) {
|
|
2347
|
+
return void 0;
|
|
2348
|
+
}
|
|
2349
|
+
const identity = this.currentRequestUserId ?? "";
|
|
2350
|
+
let watermark;
|
|
2351
|
+
try {
|
|
2352
|
+
watermark = readClientWatermark(this.sql, identity, clientId);
|
|
2353
|
+
} catch {
|
|
2354
|
+
try {
|
|
2355
|
+
migrateClientWatermark(this.sql);
|
|
2356
|
+
watermark = readClientWatermark(this.sql, identity, clientId);
|
|
2357
|
+
} catch {
|
|
2358
|
+
return void 0;
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
const expected = watermark + 1;
|
|
2362
|
+
if (seq <= watermark) {
|
|
2363
|
+
return { expected, kind: "already" };
|
|
2364
|
+
}
|
|
2365
|
+
return seq === expected ? { expected, kind: "next" } : { expected, kind: "gap" };
|
|
2366
|
+
}
|
|
2367
|
+
/**
|
|
2368
|
+
* Terminal response for a watermarked custom-mutator push that is NOT the
|
|
2369
|
+
* next-in-order mutation — an idempotent replay ack (`"already"`) or an
|
|
2370
|
+
* out-of-order halt (`"gap"`). Returns `undefined` for an ordinary mutation
|
|
2371
|
+
* or a `"next"` push so `fetch` proceeds to the authoritative handler. Records
|
|
2372
|
+
* the function call on the short-circuit paths so metrics stay attributed.
|
|
2373
|
+
*/
|
|
2374
|
+
rejectNonNextMutation(functionPath, mutatorClass, dispatchStartedAt) {
|
|
2375
|
+
if (mutatorClass === void 0 || mutatorClass.kind === "next") {
|
|
2376
|
+
return void 0;
|
|
2377
|
+
}
|
|
2378
|
+
this.recordFunctionCall(functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
|
|
2379
|
+
if (mutatorClass.kind === "already") {
|
|
2380
|
+
return jsonResponse({ lastMutationId: mutatorClass.expected - 1, result: null }, 200, this.currentResponseBookmark);
|
|
2381
|
+
}
|
|
2382
|
+
return jsonResponse(
|
|
2383
|
+
{
|
|
2384
|
+
error: {
|
|
2385
|
+
code: "OUT_OF_ORDER",
|
|
2386
|
+
expectedMutationId: mutatorClass.expected,
|
|
2387
|
+
message: `out-of-order mutation; expected sequence ${String(mutatorClass.expected)}`
|
|
2388
|
+
}
|
|
2389
|
+
},
|
|
2390
|
+
409,
|
|
2391
|
+
this.currentResponseBookmark
|
|
2392
|
+
);
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* Respond to a dispatch that hit the `(identity, mutationId)` idempotency
|
|
2396
|
+
* cache. Records the (zero-work) function call, then: for a `"next"` custom
|
|
2397
|
+
* mutator whose handler already committed but whose watermark advance was
|
|
2398
|
+
* lost to a crash in between, re-advance and echo `lastMutationId` exactly as
|
|
2399
|
+
* the post-commit path does (otherwise the cached branch returns a bare
|
|
2400
|
+
* result with a stale watermark and the client reports every later seq as a
|
|
2401
|
+
* gap forever); for everything else, return the bare cached `{ result }`.
|
|
2402
|
+
*/
|
|
2403
|
+
respondFromIdempotencyCache(functionPath, dispatchStartedAt, mutatorClass, cachedValue) {
|
|
2404
|
+
this.recordFunctionCall(functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
|
|
2405
|
+
if (mutatorClass?.kind === "next") {
|
|
2406
|
+
this.advanceClientMutationWatermark();
|
|
2407
|
+
return this.buildDispatchResponse(mutatorClass, cachedValue);
|
|
2408
|
+
}
|
|
2409
|
+
return jsonResponse({ result: cachedValue }, 200, this.currentResponseBookmark);
|
|
2410
|
+
}
|
|
2411
|
+
/**
|
|
2412
|
+
* Build the success response for a dispatched RPC. A `"next"` custom-mutator
|
|
2413
|
+
* push echoes the applied `lastMutationId` so the client drops the pending
|
|
2414
|
+
* optimistic overlay as soon as the ack lands; ordinary calls return the bare
|
|
2415
|
+
* `{ result }` envelope unchanged.
|
|
2416
|
+
*/
|
|
2417
|
+
buildDispatchResponse(mutatorClass, result) {
|
|
2418
|
+
if (mutatorClass?.kind === "next") {
|
|
2419
|
+
return jsonResponse({ lastMutationId: this.currentRequestClientSeq, result }, 200, this.currentResponseBookmark);
|
|
2420
|
+
}
|
|
2421
|
+
return jsonResponse({ result }, 200, this.currentResponseBookmark);
|
|
2422
|
+
}
|
|
2423
|
+
/**
|
|
2424
|
+
* Commit a mutation's replay bookkeeping — the `(identity, mutationId)`
|
|
2425
|
+
* idempotency dedup row and, for a `"next"` custom-mutator push, the
|
|
2426
|
+
* `__client_watermark` advance — INSIDE the handler's transaction. Called by
|
|
2427
|
+
* the generated `handleRpc` mutation branch after the user handler resolves
|
|
2428
|
+
* but before the transaction commits, so the writes, the dedup row, and the
|
|
2429
|
+
* watermark land in one atomic commit: a crash can't leave the writes durable
|
|
2430
|
+
* without the replay guard (which a re-dispatch would otherwise re-run) nor
|
|
2431
|
+
* without the watermark. Sets {@link ShardDO.mutationBookkeepingCommitted} so
|
|
2432
|
+
* `fetch` skips the redundant post-dispatch persist.
|
|
2433
|
+
*/
|
|
2434
|
+
commitMutationBookkeeping(result) {
|
|
2435
|
+
this.persistIdempotentResult(result);
|
|
2436
|
+
if (this.currentMutatorClass?.kind === "next") {
|
|
2437
|
+
this.advanceClientMutationWatermark({ strict: true });
|
|
2438
|
+
}
|
|
2439
|
+
this.mutationBookkeepingCommitted = true;
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* Best-effort replay bookkeeping for the live dispatch path, run after
|
|
2443
|
+
* `handleRpc` returns. A generated mutation already committed it atomically
|
|
2444
|
+
* inside its transaction (via {@link ShardDO.commitMutationBookkeeping}, which
|
|
2445
|
+
* sets the flag), so this skips. Actions/queries aren't transaction-wrapped,
|
|
2446
|
+
* so they record their dedup row here (a no-op without an `x-lunora-mutation-id`),
|
|
2447
|
+
* and a `"next"` push advances its watermark (the gap self-heals on replay).
|
|
2448
|
+
*/
|
|
2449
|
+
recordPostDispatchBookkeeping(result, mutatorClass) {
|
|
2450
|
+
if (this.mutationBookkeepingCommitted) {
|
|
2451
|
+
return;
|
|
2452
|
+
}
|
|
2453
|
+
this.persistIdempotentResult(result);
|
|
2454
|
+
if (mutatorClass?.kind === "next") {
|
|
2455
|
+
this.advanceClientMutationWatermark();
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
/**
|
|
2459
|
+
* Advance the stored high-watermark for the in-flight custom mutator to
|
|
2460
|
+
* `currentRequestClientSeq` through the same `this.sql` handle. On the
|
|
2461
|
+
* transactional path ({@link ShardDO.commitMutationBookkeeping}, `strict`) it
|
|
2462
|
+
* runs inside the handler's commit, so the watermark is durable iff the writes
|
|
2463
|
+
* are; a failure rethrows to roll the mutation back. On the best-effort
|
|
2464
|
+
* cache-hit recovery path (`strict` omitted) a missing table is swallowed —
|
|
2465
|
+
* the replay re-runs and re-advances (the read side treats a missing row as
|
|
2466
|
+
* watermark 0), so the gap self-heals.
|
|
2467
|
+
*/
|
|
2468
|
+
advanceClientMutationWatermark(options) {
|
|
2469
|
+
const clientId = this.currentRequestClientId;
|
|
2470
|
+
const seq = this.currentRequestClientSeq;
|
|
2471
|
+
if (clientId === void 0 || seq === void 0) {
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
try {
|
|
2475
|
+
advanceClientWatermark(this.sql, this.currentRequestUserId ?? "", clientId, seq);
|
|
2476
|
+
} catch (error) {
|
|
2477
|
+
if (options?.strict) {
|
|
2478
|
+
throw error;
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2149
2482
|
/**
|
|
2150
2483
|
* Replay a batch of CDC changes into this shard (point-in-time recovery).
|
|
2151
2484
|
* Schema-aware — it builds a `createShardCtxDb` writer — so the base class
|
|
@@ -2195,6 +2528,56 @@ class ShardDO {
|
|
|
2195
2528
|
}
|
|
2196
2529
|
this.subMemos.get(ws)?.delete(subId);
|
|
2197
2530
|
}
|
|
2531
|
+
/**
|
|
2532
|
+
* Register a live shape subscription on a socket — the partial-replication
|
|
2533
|
+
* parallel to {@link ShardDO.subscribe}. Stores the descriptor in the
|
|
2534
|
+
* attachment's `shapes` registry (created lazily) so it survives
|
|
2535
|
+
* hibernation, sharing the per-socket cap with `subs`. Returns a status the
|
|
2536
|
+
* caller surfaces as a structured error frame; never throws (a thrown
|
|
2537
|
+
* `webSocketMessage` is a fatal-channel error under the hibernation API).
|
|
2538
|
+
*/
|
|
2539
|
+
shapeSubscribe(ws, subId, shape) {
|
|
2540
|
+
const attachment = this.readAttachment(ws);
|
|
2541
|
+
const shapes = attachment.shapes ?? {};
|
|
2542
|
+
if (Object.keys(attachment.subs).length + Object.keys(shapes).length >= ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET) {
|
|
2543
|
+
return "too_many";
|
|
2544
|
+
}
|
|
2545
|
+
shapes[subId] = shape;
|
|
2546
|
+
attachment.shapes = shapes;
|
|
2547
|
+
try {
|
|
2548
|
+
ws.serializeAttachment?.(attachment);
|
|
2549
|
+
} catch {
|
|
2550
|
+
delete attachment.shapes[subId];
|
|
2551
|
+
return "serialize_failed";
|
|
2552
|
+
}
|
|
2553
|
+
return "ok";
|
|
2554
|
+
}
|
|
2555
|
+
/** Remove a shape subscription and its poke baseline. Mirrors {@link ShardDO.unsubscribe}'s rollback-on-serialize-failure contract. */
|
|
2556
|
+
shapeUnsubscribe(ws, subId) {
|
|
2557
|
+
const attachment = this.readAttachment(ws);
|
|
2558
|
+
const { shapes } = attachment;
|
|
2559
|
+
if (!shapes) {
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
const captured = shapes[subId];
|
|
2563
|
+
delete shapes[subId];
|
|
2564
|
+
try {
|
|
2565
|
+
ws.serializeAttachment?.(attachment);
|
|
2566
|
+
} catch {
|
|
2567
|
+
if (captured !== void 0) {
|
|
2568
|
+
shapes[subId] = captured;
|
|
2569
|
+
}
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
this.shapeMemos.get(ws)?.delete(subId);
|
|
2573
|
+
this.globalShapeSnapshots.get(ws)?.delete(subId);
|
|
2574
|
+
if (attachment.connectionId !== void 0) {
|
|
2575
|
+
try {
|
|
2576
|
+
deleteGlobalShapeSnapshot(this.sql, attachment.connectionId, subId);
|
|
2577
|
+
} catch {
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2198
2581
|
/**
|
|
2199
2582
|
* Decide whether a single subscription is interested in a mutation
|
|
2200
2583
|
* delta. The default implementation checks the table name, then runs a
|
|
@@ -2243,10 +2626,7 @@ class ShardDO {
|
|
|
2243
2626
|
if (!this.matchesSubscription(query, delta)) {
|
|
2244
2627
|
continue;
|
|
2245
2628
|
}
|
|
2246
|
-
|
|
2247
|
-
ws.send(`{"type":"delta","id":${JSON.stringify(subId)},"delta":${deltaJson}}`);
|
|
2248
|
-
} catch {
|
|
2249
|
-
}
|
|
2629
|
+
trySendFrame(ws, `{"type":"delta","id":${JSON.stringify(subId)},"delta":${deltaJson}}`);
|
|
2250
2630
|
}
|
|
2251
2631
|
}
|
|
2252
2632
|
}
|
|
@@ -2269,6 +2649,46 @@ class ShardDO {
|
|
|
2269
2649
|
executeSubscription(_functionPath, _args, _identity) {
|
|
2270
2650
|
return Promise.resolve(null);
|
|
2271
2651
|
}
|
|
2652
|
+
/**
|
|
2653
|
+
* Resolve a named shape to its concrete query plan for `identity`. The base
|
|
2654
|
+
* class has no shape registry, so it returns `undefined` — partial
|
|
2655
|
+
* replication is disabled and a `shape_subscribe` is rejected. The
|
|
2656
|
+
* codegen-generated subclass overrides this to look the shape up in the
|
|
2657
|
+
* project's `defineShape` registry, evaluate its `where(ctx, args)` under the
|
|
2658
|
+
* subscriber's verified identity, and AND-compose it with the table's RLS
|
|
2659
|
+
* read base-where into {@link ResolvedShape.effectiveWhere}.
|
|
2660
|
+
*
|
|
2661
|
+
* `identity` is the socket's OWN verified identity (the same unforgeable
|
|
2662
|
+
* value `refreshSubscriptions` threads), passed by value so this never reads
|
|
2663
|
+
* the mutable per-request identity fields. Returning `undefined` is the
|
|
2664
|
+
* fail-closed signal — an unknown shape, or an RLS-required table with no
|
|
2665
|
+
* policy resolving for this identity, yields no subscription rather than
|
|
2666
|
+
* leaking rows.
|
|
2667
|
+
*/
|
|
2668
|
+
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to dispatch via the generated shape registry
|
|
2669
|
+
resolveShape(_name, _args, _identity) {
|
|
2670
|
+
return void 0;
|
|
2671
|
+
}
|
|
2672
|
+
/**
|
|
2673
|
+
* Read the FULL current membership of a `.global()`-table shape from its D1
|
|
2674
|
+
* (or Hyperdrive) backend — the seed/poll source for the latency-tiered
|
|
2675
|
+
* global shape path. A `.global()` table lives in another store with no
|
|
2676
|
+
* per-DO op-log, so this is the only way to learn its rows from inside the
|
|
2677
|
+
* shard DO; {@link ShardDO.seedGlobalShape} calls it once on subscribe and
|
|
2678
|
+
* {@link ShardDO.refreshGlobalShape} on every alarm tick, diffing the result
|
|
2679
|
+
* against the per-socket snapshot to compute the poke.
|
|
2680
|
+
*
|
|
2681
|
+
* The base class has no global backend, so it returns `[]` (a base-only DO,
|
|
2682
|
+
* or a project with no global tables, never resolves a global shape). The
|
|
2683
|
+
* codegen subclass overrides it to drain `globalDb.findMany(table, { where:
|
|
2684
|
+
* effectiveWhere })` under the socket's verified `identity` — the same
|
|
2685
|
+
* unforgeable value `resolveShape` composed the RLS predicate with, so the
|
|
2686
|
+
* D1 read is identity-scoped exactly like the poke-live path.
|
|
2687
|
+
*/
|
|
2688
|
+
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this to read the global (D1) backend; the base has none
|
|
2689
|
+
readGlobalShapeRows(_resolved, _identity) {
|
|
2690
|
+
return Promise.resolve([]);
|
|
2691
|
+
}
|
|
2272
2692
|
/**
|
|
2273
2693
|
* Look up a streaming-query function and return a thunk that produces the
|
|
2274
2694
|
* `AsyncIterable<unknown>` when handed an {@link AbortSignal}. The codegen
|
|
@@ -2802,6 +3222,9 @@ class ShardDO {
|
|
|
2802
3222
|
if (functionPath === ADMIN_FUNCTIONS.getWorkflowInstanceStatus) {
|
|
2803
3223
|
return this.handleGetWorkflowInstanceStatus(args);
|
|
2804
3224
|
}
|
|
3225
|
+
if (functionPath === ADMIN_FUNCTIONS.listFlags) {
|
|
3226
|
+
return this.handleListFlags(args);
|
|
3227
|
+
}
|
|
2805
3228
|
return this.handlePitrAdminOp(functionPath, args);
|
|
2806
3229
|
}
|
|
2807
3230
|
/**
|
|
@@ -2922,6 +3345,21 @@ class ShardDO {
|
|
|
2922
3345
|
return jsonResponse({ result }, 200);
|
|
2923
3346
|
}
|
|
2924
3347
|
/* eslint-enable no-secrets/no-secrets */
|
|
3348
|
+
/**
|
|
3349
|
+
* Serve `__lunora_admin__:listFlags` — the studio's read-only Flags page.
|
|
3350
|
+
* Evaluates every statically-discovered feature flag under an optional
|
|
3351
|
+
* `args.context` targeting context (the studio's editable context editor)
|
|
3352
|
+
* via the {@link evaluateFlags} hook, which the codegen subclass overrides
|
|
3353
|
+
* with live OpenFeature evaluation. Read-only: a flag lookup mutates no shard
|
|
3354
|
+
* state, so nothing is flushed or audited. Admin-gated by `handleAdminRpc`'s
|
|
3355
|
+
* caller.
|
|
3356
|
+
*/
|
|
3357
|
+
async handleListFlags(args) {
|
|
3358
|
+
const rawContext = args.context;
|
|
3359
|
+
const context = typeof rawContext === "object" && rawContext !== null && !Array.isArray(rawContext) ? rawContext : void 0;
|
|
3360
|
+
const result = await this.evaluateFlags(context);
|
|
3361
|
+
return jsonResponse({ result }, 200);
|
|
3362
|
+
}
|
|
2925
3363
|
/**
|
|
2926
3364
|
* Run `run()` with the per-request identity pinned to (`userId`, `identity`),
|
|
2927
3365
|
* then restore the prior values in a `finally` (even if `run()` throws), so the
|
|
@@ -3301,6 +3739,9 @@ class ShardDO {
|
|
|
3301
3739
|
if (functionPath === ADMIN_FUNCTIONS.listWorkflows) {
|
|
3302
3740
|
return this.workflowsMetadata();
|
|
3303
3741
|
}
|
|
3742
|
+
if (functionPath === ADMIN_FUNCTIONS.listQueues) {
|
|
3743
|
+
return this.queuesMetadata();
|
|
3744
|
+
}
|
|
3304
3745
|
return void 0;
|
|
3305
3746
|
}
|
|
3306
3747
|
/**
|
|
@@ -3460,6 +3901,25 @@ class ShardDO {
|
|
|
3460
3901
|
const read = this.readAdminOp(functionPath, args);
|
|
3461
3902
|
return read ? { result: read.result, tables: read.tables } : null;
|
|
3462
3903
|
}
|
|
3904
|
+
/**
|
|
3905
|
+
* Resolve one subscription (seed or refresh) to its {@link SubscriptionOutcome}
|
|
3906
|
+
* by routing the `functionPath` to the right read path — shared by
|
|
3907
|
+
* {@link seedSubscription} and {@link refreshSubscriptions} so both branch
|
|
3908
|
+
* identically:
|
|
3909
|
+
* - `__lunora_admin__:*` → {@link executeAdminSubscription} (raw SQLite read).
|
|
3910
|
+
* - {@link FLAGS_FUNCTION_PREFIX} → {@link runFlagSubscriptionRead} (the codegen subclass evaluates the flag through the configured provider). The value isn't bound to any table, so it is tagged with the {@link ADMIN_WILDCARD} dep — re-evaluated on every write-flush so a live `useFlag` stays current within a session. A `null` read means "nothing to deliver" (no provider, or a flag that resolved to `null`).
|
|
3911
|
+
* - everything else → {@link executeSubscription} (the user query, under the socket's own by-value identity).
|
|
3912
|
+
*/
|
|
3913
|
+
async resolveReactiveOutcome(functionPath, args, isAdmin, identity) {
|
|
3914
|
+
if (isAdmin) {
|
|
3915
|
+
return this.executeAdminSubscription(functionPath, args);
|
|
3916
|
+
}
|
|
3917
|
+
if (functionPath.startsWith(FLAGS_FUNCTION_PREFIX)) {
|
|
3918
|
+
const result = await this.runFlagSubscriptionRead(functionPath, args, identity);
|
|
3919
|
+
return result === null ? null : { result, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
|
|
3920
|
+
}
|
|
3921
|
+
return this.executeSubscription(functionPath, args, identity);
|
|
3922
|
+
}
|
|
3463
3923
|
/**
|
|
3464
3924
|
* Constant-time bearer check against `env.LUNORA_ADMIN_TOKEN`. Returns
|
|
3465
3925
|
* `false` (closed) when the token is unset so admin introspection is
|
|
@@ -3558,11 +4018,49 @@ class ShardDO {
|
|
|
3558
4018
|
if (!changed || changed.size === 0) {
|
|
3559
4019
|
return;
|
|
3560
4020
|
}
|
|
4021
|
+
if (this.pendingRefreshTables) {
|
|
4022
|
+
for (const table of changed) {
|
|
4023
|
+
this.pendingRefreshTables.add(table);
|
|
4024
|
+
}
|
|
4025
|
+
} else {
|
|
4026
|
+
this.pendingRefreshTables = changed;
|
|
4027
|
+
}
|
|
4028
|
+
if (this.refreshInFlight) {
|
|
4029
|
+
return;
|
|
4030
|
+
}
|
|
3561
4031
|
if (typeof this.state.waitUntil === "function") {
|
|
3562
|
-
this.state.waitUntil(this.
|
|
4032
|
+
this.state.waitUntil(this.drainSubscriptionRefreshes());
|
|
3563
4033
|
return;
|
|
3564
4034
|
}
|
|
3565
|
-
await this.
|
|
4035
|
+
await this.drainSubscriptionRefreshes();
|
|
4036
|
+
}
|
|
4037
|
+
/**
|
|
4038
|
+
* Drain {@link ShardDO.pendingRefreshTables} one coalesced batch at a time
|
|
4039
|
+
* until it is empty, then release the {@link ShardDO.refreshInFlight} gate.
|
|
4040
|
+
* Tables merged by a `flushChangedTables` that lands mid-pass are picked up
|
|
4041
|
+
* by the next loop iteration, so every committed write is observed by a
|
|
4042
|
+
* refresh that runs after it — bursts simply share a pass. The post-write
|
|
4043
|
+
* high-watermark and live-socket set are re-read inside each
|
|
4044
|
+
* `refreshSubscriptions` / `pokeShapeSubscribers` call, so a later batch
|
|
4045
|
+
* always reflects the latest committed state.
|
|
4046
|
+
*/
|
|
4047
|
+
async drainSubscriptionRefreshes() {
|
|
4048
|
+
if (this.refreshInFlight) {
|
|
4049
|
+
return;
|
|
4050
|
+
}
|
|
4051
|
+
this.refreshInFlight = true;
|
|
4052
|
+
try {
|
|
4053
|
+
let batch = this.pendingRefreshTables;
|
|
4054
|
+
while (batch && batch.size > 0) {
|
|
4055
|
+
this.pendingRefreshTables = void 0;
|
|
4056
|
+
const frameCursor = this.currentCdcCursor();
|
|
4057
|
+
const frameEpoch = this.currentCdcEpoch();
|
|
4058
|
+
await Promise.all([this.refreshSubscriptions(batch), this.pokeShapeSubscribers(batch, frameCursor, frameEpoch)]);
|
|
4059
|
+
batch = this.pendingRefreshTables;
|
|
4060
|
+
}
|
|
4061
|
+
} finally {
|
|
4062
|
+
this.refreshInFlight = false;
|
|
4063
|
+
}
|
|
3566
4064
|
}
|
|
3567
4065
|
/**
|
|
3568
4066
|
* For every live subscription whose query reads one of `changed`, re-run
|
|
@@ -3640,15 +4138,10 @@ class ShardDO {
|
|
|
3640
4138
|
continue;
|
|
3641
4139
|
}
|
|
3642
4140
|
try {
|
|
3643
|
-
const outcome =
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
// per-request identity fields. Without it an `rls()` / `ctx.auth`
|
|
3648
|
-
// scoped live query would evaluate anonymous and return zero rows.
|
|
3649
|
-
// eslint-disable-next-line no-await-in-loop -- subscriptions on a socket re-run sequentially; each shares the single SQLite handle
|
|
3650
|
-
await this.executeSubscription(functionPath, query.args ?? {}, { identity: attachment.identity, userId: attachment.userId })
|
|
3651
|
-
);
|
|
4141
|
+
const outcome = await this.resolveReactiveOutcome(functionPath, query.args ?? {}, isAdmin, {
|
|
4142
|
+
identity: attachment.identity,
|
|
4143
|
+
userId: attachment.userId
|
|
4144
|
+
});
|
|
3652
4145
|
if (!outcome) {
|
|
3653
4146
|
continue;
|
|
3654
4147
|
}
|
|
@@ -3690,7 +4183,10 @@ class ShardDO {
|
|
|
3690
4183
|
async seedSubscription(ws, subId, query, functionPath, isAdmin) {
|
|
3691
4184
|
const seedArgs = query.args ?? {};
|
|
3692
4185
|
const attachment = this.readAttachment(ws);
|
|
3693
|
-
const outcome =
|
|
4186
|
+
const outcome = await this.resolveReactiveOutcome(functionPath, seedArgs, isAdmin, {
|
|
4187
|
+
identity: attachment.identity,
|
|
4188
|
+
userId: attachment.userId
|
|
4189
|
+
});
|
|
3694
4190
|
if (!outcome) {
|
|
3695
4191
|
return;
|
|
3696
4192
|
}
|
|
@@ -3707,6 +4203,513 @@ class ShardDO {
|
|
|
3707
4203
|
}
|
|
3708
4204
|
this.pushSubscriptionData(ws, subId, outcome, resume?.cursor ?? this.currentCdcCursor(), epoch);
|
|
3709
4205
|
}
|
|
4206
|
+
/**
|
|
4207
|
+
* Drive the full `shape_subscribe` flow as one failure-aware unit: persist the
|
|
4208
|
+
* attachment, seed the shape, and ack ONLY once both succeed. A persist
|
|
4209
|
+
* rejection (`too_many`/`serialize_failed`) or a seed that can't resolve the
|
|
4210
|
+
* shape (unknown / RLS-denied / cross-shard-invalid) rolls the attachment back
|
|
4211
|
+
* and sends an `error` frame instead of acking — so a client is never left
|
|
4212
|
+
* acked but subscribed to a shape that will never deliver. Never throws (a
|
|
4213
|
+
* thrown `webSocketMessage` is fatal to the hibernating socket).
|
|
4214
|
+
*/
|
|
4215
|
+
async handleShapeSubscribe(ws, subId, shape) {
|
|
4216
|
+
const status = this.shapeSubscribe(ws, subId, shape);
|
|
4217
|
+
if (status !== "ok") {
|
|
4218
|
+
const code = status === "too_many" ? "TOO_MANY_SUBSCRIPTIONS" : "SUBSCRIPTION_PERSIST_FAILED";
|
|
4219
|
+
const message = status === "too_many" ? `subscription cap of ${String(ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET)} reached on this socket` : "failed to persist shape subscription attachment";
|
|
4220
|
+
this.sendShapeSubscribeError(ws, subId, code, message);
|
|
4221
|
+
return;
|
|
4222
|
+
}
|
|
4223
|
+
const seed = await this.seedShapeSubscription(ws, subId, shape);
|
|
4224
|
+
if (seed !== "ok") {
|
|
4225
|
+
this.shapeUnsubscribe(ws, subId);
|
|
4226
|
+
this.sendShapeSubscribeError(ws, subId, seed.code, seed.message);
|
|
4227
|
+
return;
|
|
4228
|
+
}
|
|
4229
|
+
try {
|
|
4230
|
+
ws.send(JSON.stringify({ id: subId, type: "ack" }));
|
|
4231
|
+
} catch {
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
/** Send a structured `error` frame for a failed `shape_subscribe`, swallowing a send on an already-closed socket. */
|
|
4235
|
+
// eslint-disable-next-line class-methods-use-this -- groups with the shape-subscribe flow; uses only its args + the socket
|
|
4236
|
+
sendShapeSubscribeError(ws, subId, code, message) {
|
|
4237
|
+
try {
|
|
4238
|
+
ws.send(JSON.stringify({ code, error: { code, message }, id: subId, type: "error" }));
|
|
4239
|
+
} catch {
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
/**
|
|
4243
|
+
* Seed a freshly-registered shape subscription. Resolves the shape under the
|
|
4244
|
+
* socket's verified identity, then ships either:
|
|
4245
|
+
*
|
|
4246
|
+
* - a **catch-up** poke (the membership diff in `(sinceCheckpoint, cursor]`)
|
|
4247
|
+
* when the client supplied a still-current checkpoint within the CDC retention
|
|
4248
|
+
* window and on this epoch — the cheap reconnect path; or
|
|
4249
|
+
* - a **full** insert-poke of the shape's entire current membership — a
|
|
4250
|
+
* first-time subscribe, or a reconnect that fell outside retention / forked
|
|
4251
|
+
* epoch.
|
|
4252
|
+
*
|
|
4253
|
+
* Either way the per-socket shape memo advances to the flush watermark so
|
|
4254
|
+
* later `pokeShapeSubscribers` passes diff from the right point.
|
|
4255
|
+
*
|
|
4256
|
+
* Returns `"ok"` once the shape resolved and its seed poke was attempted, or a
|
|
4257
|
+
* `{ code, message }` failure when the shape can't be resolved — an unknown /
|
|
4258
|
+
* RLS-denied shape (a base class with no registry resolves nothing), or a
|
|
4259
|
+
* `resolveShape` that threw (e.g. a cross-shard-join guard). The caller rolls
|
|
4260
|
+
* back the persisted attachment and errors instead of acking, so a client is
|
|
4261
|
+
* never left subscribed to a shape that will never deliver.
|
|
4262
|
+
*/
|
|
4263
|
+
async seedShapeSubscription(ws, subId, shape) {
|
|
4264
|
+
const attachment = this.readAttachment(ws);
|
|
4265
|
+
const identity = { identity: attachment.identity, userId: attachment.userId };
|
|
4266
|
+
let resolved;
|
|
4267
|
+
try {
|
|
4268
|
+
resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
|
|
4269
|
+
} catch (error) {
|
|
4270
|
+
this.recordShapeError(`shape:seed:${subId}`, error);
|
|
4271
|
+
const code = typeof error.code === "string" ? error.code : "SHAPE_RESOLVE_FAILED";
|
|
4272
|
+
return { code, message: error instanceof Error ? error.message : "shape resolution failed" };
|
|
4273
|
+
}
|
|
4274
|
+
if (!resolved) {
|
|
4275
|
+
return { code: "SHAPE_NOT_FOUND", message: `shape "${shape.name}" not found or not permitted` };
|
|
4276
|
+
}
|
|
4277
|
+
try {
|
|
4278
|
+
if (resolved.global) {
|
|
4279
|
+
return await this.seedGlobalShape(ws, subId, resolved, identity, attachment.connectionId ?? "");
|
|
4280
|
+
}
|
|
4281
|
+
return await this.seedOpLogShape(ws, subId, shape, resolved);
|
|
4282
|
+
} catch (error) {
|
|
4283
|
+
this.recordShapeError(`shape:seed:${subId}`, error);
|
|
4284
|
+
const code = typeof error.code === "string" ? error.code : "SHAPE_SEED_FAILED";
|
|
4285
|
+
return { code, message: error instanceof Error ? error.message : "shape seed failed" };
|
|
4286
|
+
}
|
|
4287
|
+
}
|
|
4288
|
+
/**
|
|
4289
|
+
* Seed a non-`.global()` (op-log-backed) shape: either a catch-up diff over
|
|
4290
|
+
* `(sinceSeq, cursor]` when the client supplied a still-current checkpoint on
|
|
4291
|
+
* this epoch within the CDC retention window, or a full membership insert-poke
|
|
4292
|
+
* otherwise. The memo advances to `cursor` only once the poke is delivered, so
|
|
4293
|
+
* a failed send re-diffs from the prior point rather than skipping rows. May
|
|
4294
|
+
* throw (a stub `sql` handle, a membership probe failure); the caller converts
|
|
4295
|
+
* it to a structured `shape_subscribe` error.
|
|
4296
|
+
*/
|
|
4297
|
+
async seedOpLogShape(ws, subId, shape, resolved) {
|
|
4298
|
+
const sql = this.sql;
|
|
4299
|
+
const cursor = this.currentCdcCursor() ?? 0;
|
|
4300
|
+
const epoch = this.currentCdcEpoch();
|
|
4301
|
+
const floor = this.cdcEnabled() ? minCdcSeq(sql) : void 0;
|
|
4302
|
+
const canResume = this.cdcEnabled() && shape.sinceSeq !== void 0 && shape.sinceEpoch === epoch && shape.sinceSeq <= cursor && (shape.sinceSeq === cursor || floor !== void 0 && floor <= shape.sinceSeq + 1);
|
|
4303
|
+
const rowsPatch = canResume && shape.sinceSeq !== void 0 ? this.buildShapeDiff(sql, resolved, shape.sinceSeq, cursor) : this.buildShapeSeed(sql, resolved);
|
|
4304
|
+
await awaitWsDrain(ws);
|
|
4305
|
+
if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], cursor, epoch, canResume ? shape.sinceSeq : void 0)) {
|
|
4306
|
+
this.recordShapeMemo(ws, subId, cursor);
|
|
4307
|
+
}
|
|
4308
|
+
return "ok";
|
|
4309
|
+
}
|
|
4310
|
+
/**
|
|
4311
|
+
* Fan the membership diff of every shape affected by this flush to its
|
|
4312
|
+
* subscribers — the partial-replication parallel to
|
|
4313
|
+
* {@link ShardDO.refreshSubscriptions}, called alongside it from
|
|
4314
|
+
* {@link ShardDO.flushChangedTables}. For each socket (bounded fan-out, same
|
|
4315
|
+
* concurrency + `awaitWsDrain` backpressure as the subscription path) it
|
|
4316
|
+
* resolves each shape under the socket's identity, diffs only the shapes
|
|
4317
|
+
* whose table changed in `(memoCursor, frameCursor]`, and emits one poke
|
|
4318
|
+
* carrying a part per changed shape. No-op when no socket holds a shape.
|
|
4319
|
+
*/
|
|
4320
|
+
async pokeShapeSubscribers(changed, frameCursor, frameEpoch) {
|
|
4321
|
+
const sockets = [...this.state.getWebSockets()];
|
|
4322
|
+
const checkpoint = frameCursor ?? this.currentCdcCursor() ?? 0;
|
|
4323
|
+
const sql = this.sql;
|
|
4324
|
+
const pokeOne = async (ws) => {
|
|
4325
|
+
if (this.isSocketExpired(ws)) {
|
|
4326
|
+
this.dropExpiredSocket(ws);
|
|
4327
|
+
return;
|
|
4328
|
+
}
|
|
4329
|
+
const attachment = this.readAttachment(ws);
|
|
4330
|
+
const { shapes } = attachment;
|
|
4331
|
+
if (!shapes) {
|
|
4332
|
+
return;
|
|
4333
|
+
}
|
|
4334
|
+
const identity = { identity: attachment.identity, userId: attachment.userId };
|
|
4335
|
+
const { emptyAdvanced, partAdvanced, parts } = this.collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql);
|
|
4336
|
+
for (const subId of emptyAdvanced) {
|
|
4337
|
+
this.recordShapeMemo(ws, subId, checkpoint);
|
|
4338
|
+
}
|
|
4339
|
+
if (parts.length > 0) {
|
|
4340
|
+
await awaitWsDrain(ws);
|
|
4341
|
+
if (this.sendPoke(ws, parts, checkpoint, frameEpoch, void 0)) {
|
|
4342
|
+
for (const subId of partAdvanced) {
|
|
4343
|
+
this.recordShapeMemo(ws, subId, checkpoint);
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
}
|
|
4347
|
+
};
|
|
4348
|
+
const concurrency = 8;
|
|
4349
|
+
let index = 0;
|
|
4350
|
+
const worker = async () => {
|
|
4351
|
+
let socket = sockets[index];
|
|
4352
|
+
index += 1;
|
|
4353
|
+
while (socket !== void 0) {
|
|
4354
|
+
await pokeOne(socket);
|
|
4355
|
+
socket = sockets[index];
|
|
4356
|
+
index += 1;
|
|
4357
|
+
}
|
|
4358
|
+
};
|
|
4359
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, sockets.length) }, () => worker()));
|
|
4360
|
+
}
|
|
4361
|
+
/**
|
|
4362
|
+
* Diff every op-log-backed shape a socket holds against this flush, splitting
|
|
4363
|
+
* the results into the poke parts to send and the per-shape memo advances. A
|
|
4364
|
+
* `.global()` shape (driven by the alarm poll loop, not this flush) and a shape
|
|
4365
|
+
* whose table didn't change are skipped; a shape whose resolve/diff throws is
|
|
4366
|
+
* logged and skipped with its memo unadvanced so a later flush retries. Empty
|
|
4367
|
+
* diffs advance unconditionally; part-bearing shapes advance only once the
|
|
4368
|
+
* caller confirms the poke was delivered.
|
|
4369
|
+
*/
|
|
4370
|
+
collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql) {
|
|
4371
|
+
const parts = [];
|
|
4372
|
+
const emptyAdvanced = [];
|
|
4373
|
+
const partAdvanced = [];
|
|
4374
|
+
for (const [subId, shape] of Object.entries(shapes)) {
|
|
4375
|
+
try {
|
|
4376
|
+
const resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
|
|
4377
|
+
if (!resolved || resolved.global || !changed.has(resolved.table)) {
|
|
4378
|
+
continue;
|
|
4379
|
+
}
|
|
4380
|
+
const memoCursor = this.shapeMemos.get(ws)?.get(subId)?.cursor ?? 0;
|
|
4381
|
+
const rowsPatch = this.buildShapeDiff(sql, resolved, memoCursor, checkpoint);
|
|
4382
|
+
if (rowsPatch.length > 0) {
|
|
4383
|
+
parts.push({ rowsPatch, shapeId: subId });
|
|
4384
|
+
partAdvanced.push(subId);
|
|
4385
|
+
} else {
|
|
4386
|
+
emptyAdvanced.push(subId);
|
|
4387
|
+
}
|
|
4388
|
+
} catch (error) {
|
|
4389
|
+
this.recordShapeError(`shape:poke:${subId}`, error);
|
|
4390
|
+
}
|
|
4391
|
+
}
|
|
4392
|
+
return { emptyAdvanced, partAdvanced, parts };
|
|
4393
|
+
}
|
|
4394
|
+
/**
|
|
4395
|
+
* Build the row-ops for a shape over the op range `(sinceSeq, upTo]`. Reads
|
|
4396
|
+
* the changelog (drained across pages), collapses to the latest op per row,
|
|
4397
|
+
* then runs ONE membership probe ({@link selectShapeMemberIds}) over the
|
|
4398
|
+
* changed ids: a row still in the set → upsert with its post-image doc
|
|
4399
|
+
* (projected to the shape's columns); a row that left the set, or any delete,
|
|
4400
|
+
* → `delete(key)` (a delete carries no post-image, so membership is
|
|
4401
|
+
* unknowable from the op alone — the client no-ops an unknown key).
|
|
4402
|
+
*/
|
|
4403
|
+
// eslint-disable-next-line class-methods-use-this -- a pure op-page→membership-diff transform that reads only its args; kept a private method to sit beside the shape-poke pipeline it belongs to.
|
|
4404
|
+
buildShapeDiff(sql, resolved, sinceSeq, upTo) {
|
|
4405
|
+
const latest = /* @__PURE__ */ new Map();
|
|
4406
|
+
const tables = /* @__PURE__ */ new Set([resolved.table]);
|
|
4407
|
+
let from = sinceSeq;
|
|
4408
|
+
for (; ; ) {
|
|
4409
|
+
const { changes, cursor } = readCdcChanges(sql, { sinceSeq: from, tables });
|
|
4410
|
+
for (const change of changes) {
|
|
4411
|
+
latest.set(change.id, change);
|
|
4412
|
+
}
|
|
4413
|
+
if (changes.length === 0 || cursor === from || cursor >= upTo) {
|
|
4414
|
+
break;
|
|
4415
|
+
}
|
|
4416
|
+
from = cursor;
|
|
4417
|
+
}
|
|
4418
|
+
if (latest.size === 0) {
|
|
4419
|
+
return [];
|
|
4420
|
+
}
|
|
4421
|
+
const ids = [...latest.keys()];
|
|
4422
|
+
const members = selectShapeMemberIds(sql, resolved.table, resolved.effectiveWhere, ids);
|
|
4423
|
+
const ops = [];
|
|
4424
|
+
for (const [id, change] of latest) {
|
|
4425
|
+
if (members.has(id)) {
|
|
4426
|
+
if (change.doc !== void 0) {
|
|
4427
|
+
ops.push({ key: id, op: change.op, table: resolved.table, value: projectColumns(change.doc, resolved.columns) });
|
|
4428
|
+
}
|
|
4429
|
+
continue;
|
|
4430
|
+
}
|
|
4431
|
+
if (change.op !== "insert") {
|
|
4432
|
+
ops.push({ key: id, op: "delete", table: resolved.table });
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
return ops;
|
|
4436
|
+
}
|
|
4437
|
+
/** Build the full insert-poke of a shape's current membership — the first-seed/full-reseed rowset. */
|
|
4438
|
+
// eslint-disable-next-line class-methods-use-this -- instance method for symmetry with `buildShapeDiff`; reads via the passed `sql` handle
|
|
4439
|
+
buildShapeSeed(sql, resolved) {
|
|
4440
|
+
return selectShapeRows(sql, resolved.table, resolved.effectiveWhere).map((row) => {
|
|
4441
|
+
return {
|
|
4442
|
+
key: row.id,
|
|
4443
|
+
op: "insert",
|
|
4444
|
+
table: resolved.table,
|
|
4445
|
+
value: projectColumns(row.doc, resolved.columns)
|
|
4446
|
+
};
|
|
4447
|
+
});
|
|
4448
|
+
}
|
|
4449
|
+
/**
|
|
4450
|
+
* Seed a `.global()`-table shape: read its full membership from D1, ship it
|
|
4451
|
+
* as one insert-poke, record the membership snapshot the alarm poll loop will
|
|
4452
|
+
* diff against, and arm the poll alarm. A global shape has no op-log cursor,
|
|
4453
|
+
* so the poke is stamped at this DO's current cursor (informational only) and
|
|
4454
|
+
* carries no resume base — a reconnect always re-seeds full.
|
|
4455
|
+
*/
|
|
4456
|
+
async seedGlobalShape(ws, subId, resolved, identity, connectionId) {
|
|
4457
|
+
const rows = await this.readGlobalShapeRows(resolved, identity);
|
|
4458
|
+
if (!this.withinGlobalShapeBound(rows.length, `shape:seed:${subId}`, resolved.table)) {
|
|
4459
|
+
return {
|
|
4460
|
+
code: "SHAPE_GLOBAL_TOO_LARGE",
|
|
4461
|
+
message: `global shape membership for "${resolved.table}" exceeds the ${String(ShardDO.GLOBAL_SHAPE_MAX_ROWS)}-row cap; narrow it with a shape predicate or an RLS read policy`
|
|
4462
|
+
};
|
|
4463
|
+
}
|
|
4464
|
+
const { next: snapshot, rowsPatch } = diffGlobalMembership(rows, /* @__PURE__ */ new Map(), { columns: resolved.columns, table: resolved.table });
|
|
4465
|
+
await awaitWsDrain(ws);
|
|
4466
|
+
if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], this.currentCdcCursor() ?? 0, this.currentCdcEpoch(), void 0)) {
|
|
4467
|
+
this.recordGlobalSnapshot(ws, subId, snapshot);
|
|
4468
|
+
this.saveGlobalSnapshot(connectionId, subId, snapshot);
|
|
4469
|
+
}
|
|
4470
|
+
await this.scheduleGlobalPoll();
|
|
4471
|
+
return "ok";
|
|
4472
|
+
}
|
|
4473
|
+
/**
|
|
4474
|
+
* Re-read a global shape's membership from D1 and poke only the diff against
|
|
4475
|
+
* the socket's last snapshot: a new key → `insert`, a changed projected value
|
|
4476
|
+
* → `update`, a vanished key → `delete`. The snapshot advances to the fresh
|
|
4477
|
+
* membership even when the diff is empty, so the next tick compares from here.
|
|
4478
|
+
* No frame is sent when nothing changed (the common steady-state tick).
|
|
4479
|
+
*/
|
|
4480
|
+
async refreshGlobalShape(ws, subId, resolved, identity, connectionId) {
|
|
4481
|
+
const rows = await this.readGlobalShapeRows(resolved, identity);
|
|
4482
|
+
if (!this.withinGlobalShapeBound(rows.length, `shape:poll:${subId}`, resolved.table)) {
|
|
4483
|
+
return;
|
|
4484
|
+
}
|
|
4485
|
+
const previous = this.readGlobalSnapshot(ws, subId, connectionId);
|
|
4486
|
+
const { next, rowsPatch } = diffGlobalMembership(rows, previous, { columns: resolved.columns, table: resolved.table });
|
|
4487
|
+
if (rowsPatch.length === 0) {
|
|
4488
|
+
this.recordGlobalSnapshot(ws, subId, next);
|
|
4489
|
+
return;
|
|
4490
|
+
}
|
|
4491
|
+
await awaitWsDrain(ws);
|
|
4492
|
+
if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], this.currentCdcCursor() ?? 0, this.currentCdcEpoch(), void 0)) {
|
|
4493
|
+
this.recordGlobalSnapshot(ws, subId, next);
|
|
4494
|
+
this.saveGlobalSnapshot(connectionId, subId, next);
|
|
4495
|
+
}
|
|
4496
|
+
}
|
|
4497
|
+
/**
|
|
4498
|
+
* Read a socket's global-shape baseline, preferring the hot in-memory cache
|
|
4499
|
+
* and falling back to the durable `__global_shape_snapshot` table on a miss (a
|
|
4500
|
+
* cold socket after a hibernation eviction). The loaded baseline repopulates
|
|
4501
|
+
* the cache so subsequent ticks in this wake hit memory. An empty
|
|
4502
|
+
* `connectionId` (a socket that never went through the lifecycle-aware upgrade,
|
|
4503
|
+
* e.g. a unit harness) skips the durable read and behaves as in-memory-only.
|
|
4504
|
+
*/
|
|
4505
|
+
readGlobalSnapshot(ws, subId, connectionId) {
|
|
4506
|
+
const cached = this.globalShapeSnapshots.get(ws)?.get(subId);
|
|
4507
|
+
if (cached) {
|
|
4508
|
+
return cached;
|
|
4509
|
+
}
|
|
4510
|
+
const stored = this.loadGlobalSnapshot(connectionId, subId);
|
|
4511
|
+
this.recordGlobalSnapshot(ws, subId, stored);
|
|
4512
|
+
return stored;
|
|
4513
|
+
}
|
|
4514
|
+
/** Record a socket's latest global-shape membership snapshot in the in-memory cache (creating the per-socket map lazily). */
|
|
4515
|
+
recordGlobalSnapshot(ws, subId, snapshot) {
|
|
4516
|
+
let snapshots = this.globalShapeSnapshots.get(ws);
|
|
4517
|
+
if (!snapshots) {
|
|
4518
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
4519
|
+
this.globalShapeSnapshots.set(ws, snapshots);
|
|
4520
|
+
}
|
|
4521
|
+
snapshots.set(subId, snapshot);
|
|
4522
|
+
}
|
|
4523
|
+
/**
|
|
4524
|
+
* Load a durable global-shape baseline from SQLite, or an empty map when none
|
|
4525
|
+
* is stored / the durable path is unavailable. A stub `sql` handle (unit
|
|
4526
|
+
* harness) or a missing table degrades to in-memory-only behavior rather than
|
|
4527
|
+
* failing the poll tick.
|
|
4528
|
+
*/
|
|
4529
|
+
loadGlobalSnapshot(connectionId, subId) {
|
|
4530
|
+
if (connectionId === "") {
|
|
4531
|
+
return /* @__PURE__ */ new Map();
|
|
4532
|
+
}
|
|
4533
|
+
try {
|
|
4534
|
+
return readGlobalShapeSnapshot(this.sql, connectionId, subId);
|
|
4535
|
+
} catch {
|
|
4536
|
+
return /* @__PURE__ */ new Map();
|
|
4537
|
+
}
|
|
4538
|
+
}
|
|
4539
|
+
/**
|
|
4540
|
+
* Persist a socket's global-shape baseline to SQLite so the poll-loop diff
|
|
4541
|
+
* survives hibernation. A no-op for a connection-id-less socket or a stub
|
|
4542
|
+
* `sql` handle (the in-memory cache then carries the baseline for the DO's
|
|
4543
|
+
* lifetime, matching the pre-durable behavior).
|
|
4544
|
+
*/
|
|
4545
|
+
saveGlobalSnapshot(connectionId, subId, snapshot) {
|
|
4546
|
+
if (connectionId === "") {
|
|
4547
|
+
return;
|
|
4548
|
+
}
|
|
4549
|
+
try {
|
|
4550
|
+
writeGlobalShapeSnapshot(this.sql, connectionId, subId, snapshot);
|
|
4551
|
+
} catch {
|
|
4552
|
+
}
|
|
4553
|
+
}
|
|
4554
|
+
/**
|
|
4555
|
+
* Arm the poll alarm for `.global()` shapes if one isn't already pending.
|
|
4556
|
+
* Idempotent — every global-shape seed calls it, but only the first arms the
|
|
4557
|
+
* alarm. Degrades to a no-op when the runtime exposes no `setAlarm` (the unit
|
|
4558
|
+
* harness): a global shape is then seed-only, which the poll-loop tests assert
|
|
4559
|
+
* by driving {@link ShardDO.alarm} directly.
|
|
4560
|
+
*/
|
|
4561
|
+
async scheduleGlobalPoll() {
|
|
4562
|
+
if (this.globalPollScheduled) {
|
|
4563
|
+
return;
|
|
4564
|
+
}
|
|
4565
|
+
const { setAlarm } = this.state.storage;
|
|
4566
|
+
if (!setAlarm) {
|
|
4567
|
+
return;
|
|
4568
|
+
}
|
|
4569
|
+
this.globalPollScheduled = true;
|
|
4570
|
+
try {
|
|
4571
|
+
await setAlarm.call(this.state.storage, Date.now() + ShardDO.GLOBAL_SHAPE_POLL_INTERVAL_MS);
|
|
4572
|
+
} catch {
|
|
4573
|
+
this.globalPollScheduled = false;
|
|
4574
|
+
}
|
|
4575
|
+
}
|
|
4576
|
+
/**
|
|
4577
|
+
* Record a contained shape-tier error (poll / poke / seed) into the DO's log
|
|
4578
|
+
* ring without aborting the rest of the pass. The shape pipeline is a
|
|
4579
|
+
* best-effort fan-out: one socket's read or one shape's resolve failing must
|
|
4580
|
+
* never take down the others — so callers swallow the throw and surface it
|
|
4581
|
+
* here for diagnosis. `context` is a synthetic `shape:phase:subId` path.
|
|
4582
|
+
*/
|
|
4583
|
+
recordShapeError(context, error) {
|
|
4584
|
+
this.logs.push({
|
|
4585
|
+
functionPath: context,
|
|
4586
|
+
level: "error",
|
|
4587
|
+
message: error instanceof Error ? error.message : String(error),
|
|
4588
|
+
timestamp: Date.now()
|
|
4589
|
+
});
|
|
4590
|
+
}
|
|
4591
|
+
/**
|
|
4592
|
+
* Guard a global shape's materialized membership against {@link
|
|
4593
|
+
* ShardDO.GLOBAL_SHAPE_MAX_ROWS}. Returns `true` when the row count is within
|
|
4594
|
+
* the cap; otherwise records a diagnosable error and returns `false` so the
|
|
4595
|
+
* caller fails the shape closed (no snapshot retained, no poke sent) rather
|
|
4596
|
+
* than risking a DO eviction on an unbounded global table. The transient read
|
|
4597
|
+
* buffer is bounded by the same gate — an over-cap membership is dropped, not
|
|
4598
|
+
* snapshotted per socket.
|
|
4599
|
+
*/
|
|
4600
|
+
withinGlobalShapeBound(rowCount, context, table) {
|
|
4601
|
+
if (rowCount <= ShardDO.GLOBAL_SHAPE_MAX_ROWS) {
|
|
4602
|
+
return true;
|
|
4603
|
+
}
|
|
4604
|
+
this.recordShapeError(
|
|
4605
|
+
context,
|
|
4606
|
+
new Error(
|
|
4607
|
+
`global shape membership for "${table}" (${String(rowCount)} rows) exceeds the ${String(ShardDO.GLOBAL_SHAPE_MAX_ROWS)}-row cap; narrow it with a shape predicate or an RLS read policy`
|
|
4608
|
+
)
|
|
4609
|
+
);
|
|
4610
|
+
return false;
|
|
4611
|
+
}
|
|
4612
|
+
/**
|
|
4613
|
+
* Refresh every `.global()`-table shape held across all live sockets, one
|
|
4614
|
+
* diff-poke per (socket, shape). Returns the number of global shapes still
|
|
4615
|
+
* subscribed so {@link ShardDO.alarm} knows whether to re-arm. Expired sockets
|
|
4616
|
+
* are dropped in passing (mirrors {@link ShardDO.pokeShapeSubscribers}).
|
|
4617
|
+
*/
|
|
4618
|
+
async pollGlobalShapes() {
|
|
4619
|
+
const sockets = [...this.state.getWebSockets()];
|
|
4620
|
+
let remaining = 0;
|
|
4621
|
+
for (const ws of sockets) {
|
|
4622
|
+
if (this.isSocketExpired(ws)) {
|
|
4623
|
+
this.dropExpiredSocket(ws);
|
|
4624
|
+
continue;
|
|
4625
|
+
}
|
|
4626
|
+
const attachment = this.readAttachment(ws);
|
|
4627
|
+
const { shapes } = attachment;
|
|
4628
|
+
if (!shapes) {
|
|
4629
|
+
continue;
|
|
4630
|
+
}
|
|
4631
|
+
const identity = { identity: attachment.identity, userId: attachment.userId };
|
|
4632
|
+
remaining += await this.pollSocketGlobalShapes(ws, shapes, identity, attachment.connectionId ?? "");
|
|
4633
|
+
}
|
|
4634
|
+
return remaining;
|
|
4635
|
+
}
|
|
4636
|
+
/**
|
|
4637
|
+
* Refresh one socket's `.global()`-table shapes, containing per-shape
|
|
4638
|
+
* failures so a single throw never aborts the poll tick (and with it the
|
|
4639
|
+
* re-arm). Returns the count of global shapes still subscribed on this socket
|
|
4640
|
+
* — a failed `resolveShape`/read keeps its shape counted so the alarm keeps
|
|
4641
|
+
* polling and retries next tick.
|
|
4642
|
+
*/
|
|
4643
|
+
async pollSocketGlobalShapes(ws, shapes, identity, connectionId) {
|
|
4644
|
+
let count = 0;
|
|
4645
|
+
for (const [subId, shape] of Object.entries(shapes)) {
|
|
4646
|
+
let resolved;
|
|
4647
|
+
try {
|
|
4648
|
+
resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
|
|
4649
|
+
} catch (error) {
|
|
4650
|
+
count += 1;
|
|
4651
|
+
this.recordShapeError(`shape:poll:${subId}`, error);
|
|
4652
|
+
continue;
|
|
4653
|
+
}
|
|
4654
|
+
if (!resolved?.global) {
|
|
4655
|
+
continue;
|
|
4656
|
+
}
|
|
4657
|
+
count += 1;
|
|
4658
|
+
try {
|
|
4659
|
+
await this.refreshGlobalShape(ws, subId, resolved, identity, connectionId);
|
|
4660
|
+
} catch (error) {
|
|
4661
|
+
this.recordShapeError(`shape:poll:${subId}`, error);
|
|
4662
|
+
}
|
|
4663
|
+
}
|
|
4664
|
+
return count;
|
|
4665
|
+
}
|
|
4666
|
+
/**
|
|
4667
|
+
* Send one poke (`pokeStart` → `pokePart` per shape → `pokeEnd`) to a socket.
|
|
4668
|
+
* All parts apply atomically at `pokeEnd`. Returns `true` when every frame was
|
|
4669
|
+
* handed to the socket, `false` when a send threw mid-poke (the socket closed)
|
|
4670
|
+
* — callers must NOT advance their shape baselines on a `false` so the client
|
|
4671
|
+
* re-receives the rows on its next flush/reconnect instead of losing them.
|
|
4672
|
+
*/
|
|
4673
|
+
sendPoke(ws, parts, checkpoint, epoch, baseCheckpoint) {
|
|
4674
|
+
this.pokeSequence += 1;
|
|
4675
|
+
const pokeId = `poke-${String(this.pokeSequence)}`;
|
|
4676
|
+
const frames = buildPokeFrames(parts, { baseCheckpoint, checkpoint, epoch, lastMutationId: this.socketClientWatermark(ws), pokeId });
|
|
4677
|
+
try {
|
|
4678
|
+
for (const frame of frames) {
|
|
4679
|
+
ws.send(frame);
|
|
4680
|
+
}
|
|
4681
|
+
return true;
|
|
4682
|
+
} catch {
|
|
4683
|
+
return false;
|
|
4684
|
+
}
|
|
4685
|
+
}
|
|
4686
|
+
/**
|
|
4687
|
+
* The recipient client's `__client_watermark` for stamping a poke's
|
|
4688
|
+
* `lastMutationId`, or `undefined` when the socket announced no `clientId`
|
|
4689
|
+
* (a client that doesn't use custom mutators — nothing to drop an overlay
|
|
4690
|
+
* for). Read off the attachment so it survives hibernation.
|
|
4691
|
+
*/
|
|
4692
|
+
socketClientWatermark(ws) {
|
|
4693
|
+
const attachment = this.readAttachment(ws);
|
|
4694
|
+
const { clientId } = attachment;
|
|
4695
|
+
if (clientId === void 0) {
|
|
4696
|
+
return void 0;
|
|
4697
|
+
}
|
|
4698
|
+
try {
|
|
4699
|
+
return readClientWatermark(this.sql, attachment.userId ?? "", clientId);
|
|
4700
|
+
} catch {
|
|
4701
|
+
return void 0;
|
|
4702
|
+
}
|
|
4703
|
+
}
|
|
4704
|
+
/** Record a shape's poke baseline cursor on a socket (creating the per-socket map lazily). */
|
|
4705
|
+
recordShapeMemo(ws, subId, cursor) {
|
|
4706
|
+
let memos = this.shapeMemos.get(ws);
|
|
4707
|
+
if (!memos) {
|
|
4708
|
+
memos = /* @__PURE__ */ new Map();
|
|
4709
|
+
this.shapeMemos.set(ws, memos);
|
|
4710
|
+
}
|
|
4711
|
+
memos.set(subId, { cursor });
|
|
4712
|
+
}
|
|
3710
4713
|
/**
|
|
3711
4714
|
* Record `outcome` as this socket's diff baseline for `subId` without
|
|
3712
4715
|
* sending a frame. Used by the resume fast-path, where the client keeps its
|
|
@@ -3754,21 +4757,8 @@ class ShardDO {
|
|
|
3754
4757
|
}
|
|
3755
4758
|
const deltaFrames = [];
|
|
3756
4759
|
const deltas = existing === void 0 ? void 0 : subscriptionListDeltas(existing.lastJson, outcome.result, outcome.tables.values().next().value ?? "", deltaFrames);
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
const idJson = JSON.stringify(subId);
|
|
3760
|
-
for (const deltaBody of deltaFrames) {
|
|
3761
|
-
try {
|
|
3762
|
-
ws.send(`{"type":"delta","id":${idJson},"delta":${deltaBody}${cursorSuffix}}`);
|
|
3763
|
-
} catch {
|
|
3764
|
-
}
|
|
3765
|
-
}
|
|
3766
|
-
return;
|
|
3767
|
-
}
|
|
3768
|
-
try {
|
|
3769
|
-
ws.send(`{"type":"data","id":${JSON.stringify(subId)},"data":${json}${cursorSuffix}}`);
|
|
3770
|
-
} catch {
|
|
3771
|
-
}
|
|
4760
|
+
const delivered = deltas === void 0 ? trySendFrame(ws, `{"type":"data","id":${JSON.stringify(subId)},"data":${json}${cursorSuffix}}`) : sendDeltaFrames(ws, subId, deltaFrames, cursorSuffix);
|
|
4761
|
+
memos.set(subId, { lastJson: delivered ? json : existing?.lastJson ?? UNDELIVERED_BASELINE, tables: outcome.tables });
|
|
3772
4762
|
}
|
|
3773
4763
|
/**
|
|
3774
4764
|
* Gate the upgrade request against two complementary controls:
|
|
@@ -3990,10 +4980,7 @@ class ShardDO {
|
|
|
3990
4980
|
if (ws === sender || this.readAttachment(ws).whispers?.includes(topic) !== true) {
|
|
3991
4981
|
continue;
|
|
3992
4982
|
}
|
|
3993
|
-
|
|
3994
|
-
ws.send(frame);
|
|
3995
|
-
} catch {
|
|
3996
|
-
}
|
|
4983
|
+
trySendFrame(ws, frame);
|
|
3997
4984
|
}
|
|
3998
4985
|
}
|
|
3999
4986
|
// eslint-disable-next-line class-methods-use-this -- cohesive DO instance method grouped with the hibernation/attachment helpers; reads only the socket
|