@lunora/do 1.0.0-alpha.3 → 1.0.0-alpha.31
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/LICENSE.md +6 -0
- package/__assets__/package-og.svg +1 -1
- package/dist/index.d.mts +1402 -294
- package/dist/index.d.ts +1402 -294
- package/dist/index.mjs +36 -30
- package/dist/packem_shared/{ADMIN_FUNCTION_PREFIX-Dzdqq5J2.mjs → ADMIN_FUNCTIONS-CAHLZMj8.mjs} +62 -8
- package/dist/packem_shared/{matchesStaticWhere-CFk6adSu.mjs → AGGREGATE_SQL_FUNCTION-CQsu2Xga.mjs} +5 -3
- package/dist/packem_shared/{applyCdcChanges-Ctdmxmrv.mjs → CDC_LOG_TABLE-DjJEHiM2.mjs} +7 -3
- package/dist/packem_shared/{ConflictError-C0STs6bU.mjs → ConflictError-CLoq37xH.mjs} +4 -5
- package/dist/packem_shared/{CountRlsUnsupportedError-28ZvvwKS.mjs → CountRlsUnsupportedError-BGxj0pgS.mjs} +4 -4
- package/dist/packem_shared/{DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs → DATA_MIGRATION_STATE_TABLE-CYwBpyTr.mjs} +11 -3
- package/dist/packem_shared/{assertFlatPredicate-DyVYReuT.mjs → DEFAULT_MAX_RELATION_KEYS-BEan1CRD.mjs} +58 -7
- package/dist/packem_shared/{assertReadonly-dDcFE1YZ.mjs → MAX_SQL_ROWS-iFAA8FbD.mjs} +35 -3
- package/dist/packem_shared/NotFoundError-C70b9hLw.mjs +9 -0
- package/dist/packem_shared/{assertValidClientId-CBZ1zC96.mjs → NotUniqueError-BigrdT_W.mjs} +241 -90
- package/dist/packem_shared/{rank-CrkEIpF4.mjs → RANK_TIEBREAK-CXhdcA1o.mjs} +2 -13
- package/dist/packem_shared/{guardWriter-u3UlnCH5.mjs → RLS_UNWRAP_SYMBOL-DTvHvRzY.mjs} +22 -9
- package/dist/packem_shared/{ROOT_DO_SIZE_WARN_BYTES-DQkmGiCS.mjs → ROOT_DO_SIZE_WARN_BYTES-BWz51mpB.mjs} +3017 -341
- package/dist/packem_shared/{ReactiveCache-ByVzgH3d.mjs → ReactiveCache-DnSvbjil.mjs} +4 -30
- package/dist/packem_shared/{SESSION_DO_TTL_DEFAULT-ilPZsVwu.mjs → SESSION_DO_TTL_DEFAULT-BnSKgVO4.mjs} +16 -27
- package/dist/packem_shared/{SHARD_REGISTRY_DO_NAME-BsAbi5Mn.mjs → SHARD_REGISTRY_DO_NAME-D99roc-r.mjs} +12 -14
- package/dist/packem_shared/{applyOnDelete-CMif2RKw.mjs → applyOnDelete-BXSq3S70.mjs} +23 -12
- package/dist/packem_shared/{buildSeekWhere-lVsNXSLy.mjs → applySelect-WQY8m62C.mjs} +21 -2
- package/dist/packem_shared/{armRestore-BJk53Ro8.mjs → armRestore-4Px61hHS.mjs} +4 -10
- package/dist/packem_shared/{backfillAggregateIndexes-BF5eL7kW.mjs → backfillAggregateIndexes-DDoT-UUI.mjs} +3 -2
- package/dist/packem_shared/{compileWhereSql-CXrhFA3G.mjs → compileWhereSql-DE6yfRcQ.mjs} +5 -3
- package/dist/packem_shared/constant-time-equal-BVRWZgES.mjs +12 -0
- package/dist/packem_shared/{createSystemReader-8CzSZP9V.mjs → createSystemReader-D12eNH13.mjs} +4 -2
- package/dist/packem_shared/ctx-db-idempotency-BdcNpvY4.mjs +108 -0
- package/dist/packem_shared/ctx-db-shapes-Cz9dHyh1.mjs +53 -0
- package/dist/packem_shared/diffExternalSource-CovHfdyo.mjs +44 -0
- package/dist/packem_shared/{exportShardRows-DZEhUeyI.mjs → exportShardRows-Dy3oFZ26.mjs} +4 -3
- package/dist/packem_shared/isSourceDue-Bj7I3v1b.mjs +41 -0
- package/dist/packem_shared/json-response-BdbtpOhm.mjs +3 -0
- package/dist/packem_shared/materializeExternalRows-CoGmFmsY.mjs +23 -0
- package/dist/packem_shared/{runShardMigrations-C3bn5r93.mjs → runShardMigrations-BGx4v2B6.mjs} +6 -4
- package/dist/packem_shared/serialize-sql-BlRUoiQe.mjs +14 -0
- package/dist/packem_shared/{serveRelationFanout-Clr1a05L.mjs → serveRelationFanout-BgaNg3Hu.mjs} +4 -7
- package/dist/packem_shared/stableStringify-mC40mZts.mjs +40 -0
- package/dist/packem_shared/stableWireKey-DKuXO7T5.mjs +6 -0
- package/dist/packem_shared/subscriptionListDeltas-CT76bYny.mjs +126 -0
- package/dist/packem_shared/wire-codec-CzQc1pvf.mjs +225 -0
- package/package.json +5 -3
- package/dist/packem_shared/NotFoundError-CMuMZt81.mjs +0 -10
- package/dist/packem_shared/ctx-db-idempotency-DkC9rP91.mjs +0 -35
- package/dist/packem_shared/encodePartitionKey-C6blLR5K.mjs +0 -1
- /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/{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,29 +1,103 @@
|
|
|
1
|
+
import { LunoraError, toErrorBody } from '@lunora/errors';
|
|
1
2
|
import { drizzle } from 'drizzle-orm/durable-sqlite';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { c as constantTimeEqual } from './constant-time-equal-BVRWZgES.mjs';
|
|
4
|
+
import { j as jsonResponse } from './json-response-BdbtpOhm.mjs';
|
|
5
|
+
import { e as encodeWire, d as decodeWire } from './wire-codec-CzQc1pvf.mjs';
|
|
6
|
+
import { parseExportShardArgs, parseImportShardArgs } from './exportShardRows-Dy3oFZ26.mjs';
|
|
7
|
+
import { recordAuthEvent, readAuthMetrics } from './AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs';
|
|
8
|
+
import { DATA_MIGRATION_STATE_TABLE, readMigrationStatus } from './DATA_MIGRATION_STATE_TABLE-CYwBpyTr.mjs';
|
|
5
9
|
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 './
|
|
10
|
+
import { readFunctionMetricsTotals, readFunctionMetricIndexHits, recordFunctionMetric, mergeScanAttribution, readFunctionMetrics, readFunctionMetricBuckets } from './FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs';
|
|
11
|
+
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-CAHLZMj8.mjs';
|
|
8
12
|
import { LogBuffer } from './LogBuffer-B_Ezju_N.mjs';
|
|
9
|
-
import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './
|
|
10
|
-
import { readBookmark, armRestore } from './armRestore-
|
|
11
|
-
import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-
|
|
13
|
+
import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './MAIL_RETENTION-CPpgl-dX.mjs';
|
|
14
|
+
import { readBookmark, armRestore } from './armRestore-4Px61hHS.mjs';
|
|
15
|
+
import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-DnSvbjil.mjs';
|
|
16
|
+
import { stableWireKey } from './stableWireKey-DKuXO7T5.mjs';
|
|
17
|
+
import { awaitWsDrain, trySendFrame, subscriptionListDeltas, sendDeltaFrames } from './subscriptionListDeltas-CT76bYny.mjs';
|
|
18
|
+
import { fingerprintError } from '@lunora/fingerprint';
|
|
12
19
|
import { redact, standardRules } from '@visulima/redact';
|
|
13
20
|
import { i as isDevEnvironment, c as buildSettings, b as buildSecurityAudit } from './security-audit-CucgBice.mjs';
|
|
14
|
-
import { runReadonlySql } from './
|
|
15
|
-
import { ConflictError } from './ConflictError-
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
21
|
+
import { runReadonlySql } from './MAX_SQL_ROWS-iFAA8FbD.mjs';
|
|
22
|
+
import { ConflictError } from './ConflictError-CLoq37xH.mjs';
|
|
23
|
+
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';
|
|
24
|
+
import { CDC_LOG_TABLE, readCdcChanges, readCdcCursor, readCdcEpoch, minCdcSeq, bumpCdcEpoch } from './CDC_LOG_TABLE-DjJEHiM2.mjs';
|
|
25
|
+
import { stableStringify } from './stableStringify-mC40mZts.mjs';
|
|
26
|
+
import { a as selectShapeMemberIds, s as selectShapeRows } from './ctx-db-shapes-Cz9dHyh1.mjs';
|
|
27
|
+
|
|
28
|
+
const MAX_BATCH_ENTRIES = 500;
|
|
29
|
+
|
|
30
|
+
const evictOldestEntry = (map, capacity) => {
|
|
31
|
+
if (map.size < capacity) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const oldest = map.keys().next().value;
|
|
35
|
+
if (oldest !== void 0) {
|
|
36
|
+
map.delete(oldest);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const textEncoder = new TextEncoder();
|
|
41
|
+
const fromBase64Url = (input) => {
|
|
42
|
+
const padded = input.replaceAll("-", "+").replaceAll("_", "/") + "===".slice((input.length + 3) % 4);
|
|
43
|
+
const binary = atob(padded);
|
|
44
|
+
const bytes = new Uint8Array(binary.length);
|
|
45
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
46
|
+
bytes[index] = binary.codePointAt(index) ?? 0;
|
|
47
|
+
}
|
|
48
|
+
return bytes;
|
|
49
|
+
};
|
|
50
|
+
const KEY_CACHE_MAX = 64;
|
|
51
|
+
const keyCache = /* @__PURE__ */ new Map();
|
|
52
|
+
const importHmacKey = async (secret) => {
|
|
53
|
+
const cached = keyCache.get(secret);
|
|
54
|
+
if (cached) {
|
|
55
|
+
return cached;
|
|
56
|
+
}
|
|
57
|
+
evictOldestEntry(keyCache, KEY_CACHE_MAX);
|
|
58
|
+
const keyPromise = crypto.subtle.importKey("raw", textEncoder.encode(secret), { hash: "SHA-256", name: "HMAC" }, false, ["sign", "verify"]);
|
|
59
|
+
keyCache.set(secret, keyPromise);
|
|
60
|
+
return keyPromise;
|
|
61
|
+
};
|
|
62
|
+
const verifyCanonical = async (secret, canonical, sigBytes) => {
|
|
63
|
+
const cryptoKey = await importHmacKey(secret);
|
|
64
|
+
return crypto.subtle.verify("HMAC", cryptoKey, sigBytes, textEncoder.encode(canonical));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const WS_ADMIN_TOKEN_VERSION = "v1";
|
|
68
|
+
const verifyWsAdminToken = async (secret, token, now = Date.now()) => {
|
|
69
|
+
if (secret.length === 0 || token.length === 0) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const parts = token.split(".");
|
|
73
|
+
if (parts.length !== 3) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const [version, expString, signature] = parts;
|
|
77
|
+
if (version !== WS_ADMIN_TOKEN_VERSION || signature.length === 0) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const expiresAtMs = Number(expString);
|
|
81
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
let signatureBytes;
|
|
85
|
+
try {
|
|
86
|
+
signatureBytes = fromBase64Url(signature);
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return verifyCanonical(secret, `${version}.${expString}`, signatureBytes);
|
|
91
|
+
};
|
|
18
92
|
|
|
19
93
|
const AUDIT_LOG_TABLE = "__lunora_audit__";
|
|
20
94
|
const AUDIT_LOG_RETENTION = 1e3;
|
|
21
|
-
const runSql$
|
|
95
|
+
const runSql$3 = (sql, query, ...params) => {
|
|
22
96
|
const runner = sql.exec;
|
|
23
97
|
return runner.call(sql, query, ...params);
|
|
24
98
|
};
|
|
25
99
|
const ensureAuditTable = (sql) => {
|
|
26
|
-
runSql$
|
|
100
|
+
runSql$3(
|
|
27
101
|
sql,
|
|
28
102
|
`CREATE TABLE IF NOT EXISTS "${AUDIT_LOG_TABLE}" (
|
|
29
103
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -37,7 +111,7 @@ const ensureAuditTable = (sql) => {
|
|
|
37
111
|
};
|
|
38
112
|
const appendAuditEntry = (sql, entry) => {
|
|
39
113
|
ensureAuditTable(sql);
|
|
40
|
-
runSql$
|
|
114
|
+
runSql$3(
|
|
41
115
|
sql,
|
|
42
116
|
`INSERT INTO "${AUDIT_LOG_TABLE}" (ts, op, "table", id, detail) VALUES (?, ?, ?, ?, ?)`,
|
|
43
117
|
entry.ts,
|
|
@@ -49,13 +123,13 @@ const appendAuditEntry = (sql, entry) => {
|
|
|
49
123
|
// eslint-disable-next-line unicorn/no-null -- SQL NULL is the correct value for an op with no associated table/id/detail.
|
|
50
124
|
entry.detail === void 0 ? null : JSON.stringify(entry.detail)
|
|
51
125
|
);
|
|
52
|
-
runSql$
|
|
126
|
+
runSql$3(sql, `DELETE FROM "${AUDIT_LOG_TABLE}" WHERE seq <= (SELECT MAX(seq) - ? FROM "${AUDIT_LOG_TABLE}")`, AUDIT_LOG_RETENTION);
|
|
53
127
|
};
|
|
54
128
|
const readAuditLog = (sql, options = {}) => {
|
|
55
129
|
ensureAuditTable(sql);
|
|
56
130
|
const sinceSeq = options.sinceSeq ?? 0;
|
|
57
131
|
const limit = Math.max(1, Math.min(options.limit ?? AUDIT_LOG_RETENTION, 1e4));
|
|
58
|
-
const rows = runSql$
|
|
132
|
+
const rows = runSql$3(
|
|
59
133
|
sql,
|
|
60
134
|
`SELECT seq, ts, op, "table", id, detail FROM "${AUDIT_LOG_TABLE}" WHERE seq > ? ORDER BY seq DESC LIMIT ?`,
|
|
61
135
|
sinceSeq,
|
|
@@ -76,10 +150,42 @@ const readAuditLog = (sql, options = {}) => {
|
|
|
76
150
|
});
|
|
77
151
|
};
|
|
78
152
|
|
|
153
|
+
const SHARED_BATCH_HEADERS = [
|
|
154
|
+
"x-lunora-userid",
|
|
155
|
+
"x-lunora-identity",
|
|
156
|
+
"x-d1-bookmark",
|
|
157
|
+
"x-lunora-client-ip",
|
|
158
|
+
"x-lunora-system",
|
|
159
|
+
"x-lunora-shard-binding"
|
|
160
|
+
];
|
|
161
|
+
const buildBatchEntryRequest = (batchRequest, entry) => {
|
|
162
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
163
|
+
for (const name of SHARED_BATCH_HEADERS) {
|
|
164
|
+
const value = batchRequest.headers.get(name);
|
|
165
|
+
if (value !== null) {
|
|
166
|
+
headers.set(name, value);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (entry.mutationId !== void 0) {
|
|
170
|
+
headers.set("x-lunora-mutation-id", entry.mutationId);
|
|
171
|
+
}
|
|
172
|
+
if (entry.clientId !== void 0) {
|
|
173
|
+
headers.set("x-lunora-client-id", entry.clientId);
|
|
174
|
+
}
|
|
175
|
+
if (entry.clientSeq !== void 0) {
|
|
176
|
+
headers.set("x-lunora-client-seq", String(entry.clientSeq));
|
|
177
|
+
}
|
|
178
|
+
return new Request("https://shard.internal/rpc", {
|
|
179
|
+
body: JSON.stringify({ args: entry.args ?? {}, functionPath: entry.functionPath }),
|
|
180
|
+
headers,
|
|
181
|
+
method: "POST"
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
|
|
79
185
|
const QUERY_METRICS_TABLE = "__lunora_metrics_queries";
|
|
80
186
|
const QUERY_METRICS_MAX_SQL_LEN = 512;
|
|
81
187
|
const QUERY_METRICS_MAX_STATEMENTS = 500;
|
|
82
|
-
const runSql$
|
|
188
|
+
const runSql$2 = (sql, query, ...params) => {
|
|
83
189
|
const runner = sql.exec;
|
|
84
190
|
return runner.call(sql, query, ...params);
|
|
85
191
|
};
|
|
@@ -91,7 +197,7 @@ const normalizeSql = (sql) => {
|
|
|
91
197
|
return normalized;
|
|
92
198
|
};
|
|
93
199
|
const ensureQueryMetricsTable = (sql) => {
|
|
94
|
-
runSql$
|
|
200
|
+
runSql$2(
|
|
95
201
|
sql,
|
|
96
202
|
`CREATE TABLE IF NOT EXISTS "${QUERY_METRICS_TABLE}" (
|
|
97
203
|
normalized_sql TEXT PRIMARY KEY,
|
|
@@ -108,10 +214,10 @@ const recordQueryMetric = (sql, rawSql, durationMs, rowsRead, rowsWritten) => {
|
|
|
108
214
|
return;
|
|
109
215
|
}
|
|
110
216
|
ensureQueryMetricsTable(sql);
|
|
111
|
-
const countRow = runSql$
|
|
217
|
+
const countRow = runSql$2(sql, `SELECT COUNT(*) AS n FROM "${QUERY_METRICS_TABLE}"`).one();
|
|
112
218
|
const count = countRow.n;
|
|
113
219
|
if (count >= QUERY_METRICS_MAX_STATEMENTS) {
|
|
114
|
-
const existing = runSql$
|
|
220
|
+
const existing = runSql$2(sql, `SELECT COUNT(*) AS c FROM "${QUERY_METRICS_TABLE}" WHERE normalized_sql = ?`, normalized).one();
|
|
115
221
|
if (existing.c === 0) {
|
|
116
222
|
return;
|
|
117
223
|
}
|
|
@@ -123,11 +229,11 @@ const recordQueryMetric = (sql, rawSql, durationMs, rowsRead, rowsWritten) => {
|
|
|
123
229
|
total_duration_ms = total_duration_ms + excluded.total_duration_ms,
|
|
124
230
|
rows_read = rows_read + excluded.rows_read,
|
|
125
231
|
rows_written = rows_written + excluded.rows_written`;
|
|
126
|
-
runSql$
|
|
232
|
+
runSql$2(sql, upsertSql, normalized, durationMs, rowsRead, rowsWritten);
|
|
127
233
|
};
|
|
128
234
|
const readQueryMetrics = (sql) => {
|
|
129
235
|
ensureQueryMetricsTable(sql);
|
|
130
|
-
const rows = runSql$
|
|
236
|
+
const rows = runSql$2(
|
|
131
237
|
sql,
|
|
132
238
|
`SELECT normalized_sql, exec_count, total_duration_ms, rows_read, rows_written FROM "${QUERY_METRICS_TABLE}" ORDER BY total_duration_ms DESC`
|
|
133
239
|
).toArray();
|
|
@@ -142,6 +248,916 @@ const readQueryMetrics = (sql) => {
|
|
|
142
248
|
});
|
|
143
249
|
};
|
|
144
250
|
|
|
251
|
+
const QUEUE_TABLE = "__lunora_queue_messages";
|
|
252
|
+
const QUEUE_RETENTION = 500;
|
|
253
|
+
const MAX_BODY_CHARS = 128 * 1024;
|
|
254
|
+
const runSql$1 = (sql, query, ...params) => {
|
|
255
|
+
const runner = sql.exec;
|
|
256
|
+
return runner.call(sql, query, ...params);
|
|
257
|
+
};
|
|
258
|
+
const orNull = (value) => (
|
|
259
|
+
// eslint-disable-next-line unicorn/no-null -- SQL NULL is the correct value for an absent column.
|
|
260
|
+
value ?? null
|
|
261
|
+
);
|
|
262
|
+
const TRUNCATION_SUFFIX = "… [truncated by the dev queue catcher]";
|
|
263
|
+
const UNSERIALIZABLE_MARKER = "[unserializable message body]";
|
|
264
|
+
const encodeBody = (value) => {
|
|
265
|
+
if (value === void 0) {
|
|
266
|
+
return "null";
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const encoded = JSON.stringify(value);
|
|
270
|
+
if (encoded.length > MAX_BODY_CHARS) {
|
|
271
|
+
return JSON.stringify(`${encoded.slice(0, MAX_BODY_CHARS)}${TRUNCATION_SUFFIX}`);
|
|
272
|
+
}
|
|
273
|
+
return encoded;
|
|
274
|
+
} catch {
|
|
275
|
+
return JSON.stringify(UNSERIALIZABLE_MARKER);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const isLossyBody = (body) => typeof body === "string" && (body === UNSERIALIZABLE_MARKER || body.endsWith(TRUNCATION_SUFFIX));
|
|
279
|
+
const decodeBody = (value) => {
|
|
280
|
+
if (value === null || value === void 0 || value === "") {
|
|
281
|
+
return void 0;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
return JSON.parse(value);
|
|
285
|
+
} catch {
|
|
286
|
+
return void 0;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const ensureQueueTable = (sql) => {
|
|
290
|
+
runSql$1(
|
|
291
|
+
sql,
|
|
292
|
+
`CREATE TABLE IF NOT EXISTS "${QUEUE_TABLE}" (
|
|
293
|
+
id TEXT PRIMARY KEY,
|
|
294
|
+
captured_at INTEGER NOT NULL,
|
|
295
|
+
message_id TEXT NOT NULL,
|
|
296
|
+
queue TEXT NOT NULL,
|
|
297
|
+
export_name TEXT,
|
|
298
|
+
body TEXT NOT NULL,
|
|
299
|
+
attempts INTEGER NOT NULL,
|
|
300
|
+
outcome TEXT NOT NULL,
|
|
301
|
+
error TEXT,
|
|
302
|
+
dead_lettered INTEGER NOT NULL,
|
|
303
|
+
message_ts INTEGER NOT NULL
|
|
304
|
+
)`
|
|
305
|
+
);
|
|
306
|
+
};
|
|
307
|
+
const recordQueueMessages = (sql, inputs, capturedAt) => {
|
|
308
|
+
ensureQueueTable(sql);
|
|
309
|
+
for (const input of inputs) {
|
|
310
|
+
runSql$1(
|
|
311
|
+
sql,
|
|
312
|
+
`INSERT INTO "${QUEUE_TABLE}" (id, captured_at, message_id, queue, export_name, body, attempts, outcome, error, dead_lettered, message_ts)
|
|
313
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
314
|
+
crypto.randomUUID(),
|
|
315
|
+
capturedAt,
|
|
316
|
+
input.messageId,
|
|
317
|
+
input.queue,
|
|
318
|
+
orNull(input.exportName),
|
|
319
|
+
encodeBody(input.body),
|
|
320
|
+
input.attempts,
|
|
321
|
+
input.outcome,
|
|
322
|
+
orNull(input.error),
|
|
323
|
+
input.deadLettered === true ? 1 : 0,
|
|
324
|
+
input.timestamp
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
runSql$1(
|
|
328
|
+
sql,
|
|
329
|
+
`DELETE FROM "${QUEUE_TABLE}"
|
|
330
|
+
WHERE id NOT IN (
|
|
331
|
+
SELECT id FROM "${QUEUE_TABLE}" ORDER BY captured_at DESC, id DESC LIMIT ?
|
|
332
|
+
)`,
|
|
333
|
+
QUEUE_RETENTION
|
|
334
|
+
);
|
|
335
|
+
return { recorded: inputs.length };
|
|
336
|
+
};
|
|
337
|
+
const rowToEntry = (row) => {
|
|
338
|
+
return {
|
|
339
|
+
attempts: row.attempts,
|
|
340
|
+
body: decodeBody(row.body),
|
|
341
|
+
capturedAt: row.captured_at,
|
|
342
|
+
deadLettered: row.dead_lettered === 1,
|
|
343
|
+
error: row.error ?? void 0,
|
|
344
|
+
exportName: row.export_name ?? void 0,
|
|
345
|
+
id: row.id,
|
|
346
|
+
messageId: row.message_id,
|
|
347
|
+
outcome: row.outcome,
|
|
348
|
+
queue: row.queue,
|
|
349
|
+
timestamp: row.message_ts
|
|
350
|
+
};
|
|
351
|
+
};
|
|
352
|
+
const readQueueMessages = (sql, options = {}) => {
|
|
353
|
+
ensureQueueTable(sql);
|
|
354
|
+
const limit = Math.min(Math.max(options.limit ?? 100, 1), QUEUE_RETENTION);
|
|
355
|
+
const filterQueue = typeof options.queue === "string" && options.queue.length > 0 ? options.queue : void 0;
|
|
356
|
+
const where = filterQueue === void 0 ? "" : `WHERE queue = ?`;
|
|
357
|
+
const params = filterQueue === void 0 ? [limit] : [filterQueue, limit];
|
|
358
|
+
const rows = runSql$1(sql, `SELECT * FROM "${QUEUE_TABLE}" ${where} ORDER BY captured_at DESC, id DESC LIMIT ?`, ...params).toArray();
|
|
359
|
+
return { entries: rows.map((row) => rowToEntry(row)) };
|
|
360
|
+
};
|
|
361
|
+
const readQueueMessageById = (sql, id) => {
|
|
362
|
+
ensureQueueTable(sql);
|
|
363
|
+
const rows = runSql$1(sql, `SELECT * FROM "${QUEUE_TABLE}" WHERE id = ? LIMIT 1`, id).toArray();
|
|
364
|
+
const row = rows[0];
|
|
365
|
+
return row === void 0 ? void 0 : rowToEntry(row);
|
|
366
|
+
};
|
|
367
|
+
const clearQueueMessages = (sql) => {
|
|
368
|
+
ensureQueueTable(sql);
|
|
369
|
+
runSql$1(sql, `DELETE FROM "${QUEUE_TABLE}"`);
|
|
370
|
+
return { cleared: true };
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const RELAY_NAME_INFIX = "::relay::";
|
|
374
|
+
const relayName = (ownerKey, index) => `${ownerKey}${RELAY_NAME_INFIX}${String(index)}`;
|
|
375
|
+
const parseRelayName = (name) => {
|
|
376
|
+
const at = name.lastIndexOf(RELAY_NAME_INFIX);
|
|
377
|
+
if (at === -1) {
|
|
378
|
+
return void 0;
|
|
379
|
+
}
|
|
380
|
+
const ownerKey = name.slice(0, at);
|
|
381
|
+
const indexText = name.slice(at + RELAY_NAME_INFIX.length);
|
|
382
|
+
const relayIndex = Number(indexText);
|
|
383
|
+
if (ownerKey.length === 0 || !Number.isInteger(relayIndex) || relayIndex < 0 || String(relayIndex) !== indexText) {
|
|
384
|
+
return void 0;
|
|
385
|
+
}
|
|
386
|
+
return { ownerKey, relayIndex };
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const shapeRoutingKey = (name, args) => stableWireKey({ args: args ?? {}, name });
|
|
390
|
+
const DEFAULT_PROMOTION_THRESHOLDS = { tDown: 4e3, tUp: 8e3 };
|
|
391
|
+
const nextPromotionState = (current, subscribers, thresholds = DEFAULT_PROMOTION_THRESHOLDS) => {
|
|
392
|
+
if (thresholds.tDown >= thresholds.tUp) {
|
|
393
|
+
throw new LunoraError("INTERNAL", `invalid promotion thresholds: tDown (${String(thresholds.tDown)}) must be < tUp (${String(thresholds.tUp)})`);
|
|
394
|
+
}
|
|
395
|
+
if (current === "owned") {
|
|
396
|
+
return subscribers >= thresholds.tUp ? "promoted" : "owned";
|
|
397
|
+
}
|
|
398
|
+
return subscribers < thresholds.tDown ? "owned" : "promoted";
|
|
399
|
+
};
|
|
400
|
+
const clampPromotionThresholds = (tUp, tDownRaw) => {
|
|
401
|
+
if (tDownRaw < tUp) {
|
|
402
|
+
return { tDown: tDownRaw, tUp };
|
|
403
|
+
}
|
|
404
|
+
return { tDown: Math.min(Math.max(1, Math.floor(tUp / 2)), tUp - 1), tUp };
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const projectColumns = (document_, columns) => {
|
|
408
|
+
if (!columns) {
|
|
409
|
+
return document_;
|
|
410
|
+
}
|
|
411
|
+
const projected = /* @__PURE__ */ Object.create(null);
|
|
412
|
+
for (const key of ["_id", "_creationTime", ...columns]) {
|
|
413
|
+
if (Object.hasOwn(document_, key)) {
|
|
414
|
+
projected[key] = document_[key];
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return projected;
|
|
418
|
+
};
|
|
419
|
+
const diffGlobalMembership = (rows, previous, options) => {
|
|
420
|
+
const { columns, table } = options;
|
|
421
|
+
const next = /* @__PURE__ */ new Map();
|
|
422
|
+
const rowsPatch = [];
|
|
423
|
+
for (const { doc, id } of rows) {
|
|
424
|
+
const value = projectColumns(doc, columns);
|
|
425
|
+
const json = JSON.stringify(encodeWire(value));
|
|
426
|
+
next.set(id, json);
|
|
427
|
+
const before = previous.get(id);
|
|
428
|
+
if (before === void 0) {
|
|
429
|
+
rowsPatch.push({ key: id, op: "insert", table, value });
|
|
430
|
+
} else if (before !== json) {
|
|
431
|
+
rowsPatch.push({ key: id, op: "update", table, value });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
for (const id of previous.keys()) {
|
|
435
|
+
if (!next.has(id)) {
|
|
436
|
+
rowsPatch.push({ key: id, op: "delete", table });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return { next, rowsPatch };
|
|
440
|
+
};
|
|
441
|
+
const encodeRowsPatch = (rowsPatch) => rowsPatch.map((op) => op.value === void 0 ? op : { ...op, value: encodeWire(op.value) });
|
|
442
|
+
const buildPokeFrames = (parts, meta, options = {}) => {
|
|
443
|
+
const { baseCheckpoint, checkpoint, epoch, lastMutationId, pokeId } = meta;
|
|
444
|
+
const frames = [JSON.stringify({ baseCheckpoint, epoch, pokeId, type: "pokeStart" })];
|
|
445
|
+
for (const part of parts) {
|
|
446
|
+
const rowsPatch = options.preEncoded ? part.rowsPatch : encodeRowsPatch(part.rowsPatch);
|
|
447
|
+
frames.push(
|
|
448
|
+
JSON.stringify({
|
|
449
|
+
pokeId,
|
|
450
|
+
rowsPatch,
|
|
451
|
+
shapeId: part.shapeId,
|
|
452
|
+
type: "pokePart",
|
|
453
|
+
...lastMutationId === void 0 ? {} : { lastMutationId }
|
|
454
|
+
})
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
frames.push(JSON.stringify({ checkpoint, epoch, pokeId, type: "pokeEnd" }));
|
|
458
|
+
return frames;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const DEFAULT_RELAY_FAN = 2;
|
|
462
|
+
const DEFAULT_MAX_RELAYS = 8;
|
|
463
|
+
const RELAY_SECRET_KEY = "LUNORA_RELAY_SECRET";
|
|
464
|
+
const RELAY_SIGNATURE_HEADER = "x-lunora-relay-sig";
|
|
465
|
+
const relaySecretOf = (env) => {
|
|
466
|
+
const value = env?.[RELAY_SECRET_KEY];
|
|
467
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
468
|
+
};
|
|
469
|
+
const signRelayBody = async (secret, body) => {
|
|
470
|
+
const encoder = new TextEncoder();
|
|
471
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { hash: "SHA-256", name: "HMAC" }, false, ["sign"]);
|
|
472
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
|
473
|
+
return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
474
|
+
};
|
|
475
|
+
const envPositiveInt = (env, key, fallback) => {
|
|
476
|
+
const raw = env?.[key];
|
|
477
|
+
let parsed = Number.NaN;
|
|
478
|
+
if (typeof raw === "string") {
|
|
479
|
+
parsed = Number.parseInt(raw, 10);
|
|
480
|
+
} else if (typeof raw === "number") {
|
|
481
|
+
parsed = raw;
|
|
482
|
+
}
|
|
483
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
484
|
+
};
|
|
485
|
+
const RELAY_MULTICAST_IDENTITY = {};
|
|
486
|
+
const assertNeverFrame = (frame) => {
|
|
487
|
+
throw new LunoraError("INTERNAL", `unhandled relay frame: ${JSON.stringify(frame)}`);
|
|
488
|
+
};
|
|
489
|
+
const asRelayNamespace = (value) => {
|
|
490
|
+
if (value === null || typeof value !== "object") {
|
|
491
|
+
return void 0;
|
|
492
|
+
}
|
|
493
|
+
const candidate = value;
|
|
494
|
+
return typeof candidate.idFromName === "function" && typeof candidate.get === "function" ? candidate : void 0;
|
|
495
|
+
};
|
|
496
|
+
const jsonRelayResponse = (body) => Response.json(body, { headers: { "content-type": "application/json" } });
|
|
497
|
+
const noContent = () => new Response(null, { status: 204 });
|
|
498
|
+
class RelayLink {
|
|
499
|
+
constructor(host, roleId) {
|
|
500
|
+
this.host = host;
|
|
501
|
+
this.roleId = roleId;
|
|
502
|
+
}
|
|
503
|
+
host;
|
|
504
|
+
roleId;
|
|
505
|
+
/**
|
|
506
|
+
* Serve the internal `/_lunora/relay` control channel: parse the frame, then
|
|
507
|
+
* dispatch to the role hooks. One exhaustive switch means a new frame type added
|
|
508
|
+
* to `relay.ts` without a case here is a COMPILE error, not a runtime mis-route.
|
|
509
|
+
*/
|
|
510
|
+
async handleControl(request) {
|
|
511
|
+
let raw;
|
|
512
|
+
try {
|
|
513
|
+
raw = await request.text();
|
|
514
|
+
} catch {
|
|
515
|
+
return new Response("bad request", { status: 400 });
|
|
516
|
+
}
|
|
517
|
+
const secret = relaySecretOf(this.host.env());
|
|
518
|
+
if (secret !== void 0) {
|
|
519
|
+
const supplied = request.headers.get(RELAY_SIGNATURE_HEADER);
|
|
520
|
+
const expected = await signRelayBody(secret, raw);
|
|
521
|
+
if (supplied === null || !constantTimeEqual(supplied, expected)) {
|
|
522
|
+
return new Response("forbidden", { status: 403 });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
let message;
|
|
526
|
+
try {
|
|
527
|
+
message = JSON.parse(raw);
|
|
528
|
+
} catch {
|
|
529
|
+
return new Response("bad request", { status: 400 });
|
|
530
|
+
}
|
|
531
|
+
switch (message.type) {
|
|
532
|
+
case "relay_attach": {
|
|
533
|
+
this.onAttach(message.relayIndex);
|
|
534
|
+
return noContent();
|
|
535
|
+
}
|
|
536
|
+
case "relay_detach": {
|
|
537
|
+
this.onDetach(message.relayIndex);
|
|
538
|
+
return noContent();
|
|
539
|
+
}
|
|
540
|
+
case "relay_frame": {
|
|
541
|
+
this.host.deliverWhisperLocal(message.topic, message.frame, void 0);
|
|
542
|
+
await this.onWhisperFrame(message);
|
|
543
|
+
return noContent();
|
|
544
|
+
}
|
|
545
|
+
case "relay_shape_poke": {
|
|
546
|
+
const iterated = this.host.getWebSockets().length;
|
|
547
|
+
const startMs = Date.now();
|
|
548
|
+
const delivered = this.onShapePoke({ ...message, args: decodeWire(message.args) });
|
|
549
|
+
this.host.recordShapePokeFanout(iterated, delivered, Date.now() - startMs);
|
|
550
|
+
return noContent();
|
|
551
|
+
}
|
|
552
|
+
case "relay_shape_subscribe": {
|
|
553
|
+
return jsonRelayResponse(this.onShapeSubscribe({ ...message, args: decodeWire(message.args) }));
|
|
554
|
+
}
|
|
555
|
+
default: {
|
|
556
|
+
return assertNeverFrame(message);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/** The hard cap on relays per shard (`LUNORA_MAX_RELAYS`) — a deployment constant the runtime surfaces in Studio as the cost ceiling. */
|
|
561
|
+
maxRelays() {
|
|
562
|
+
return envPositiveInt(this.host.env(), "LUNORA_MAX_RELAYS", DEFAULT_MAX_RELAYS);
|
|
563
|
+
}
|
|
564
|
+
/** Whether this DO can currently address its siblings (a namespace binding has been learned) — the relay tier is inert in single-DO mode. */
|
|
565
|
+
canAddressSiblings() {
|
|
566
|
+
return this.relayNamespace() !== void 0;
|
|
567
|
+
}
|
|
568
|
+
/** Resolve this DO's own namespace binding so it can address sibling owners/relays, or `undefined` when unknown. */
|
|
569
|
+
relayNamespace() {
|
|
570
|
+
const binding = this.host.shardBinding();
|
|
571
|
+
if (binding === void 0) {
|
|
572
|
+
return void 0;
|
|
573
|
+
}
|
|
574
|
+
return asRelayNamespace(this.host.env()?.[binding]);
|
|
575
|
+
}
|
|
576
|
+
/** 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. */
|
|
577
|
+
async postRelayMessage(targetName, message) {
|
|
578
|
+
await this.requestRelayMessage(targetName, message);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* POST a control frame to a sibling by name and return its response (the shape-seed
|
|
582
|
+
* path needs the owner's frames back). Best-effort: a transient cross-DO failure
|
|
583
|
+
* returns `undefined` rather than throwing.
|
|
584
|
+
* @returns the sibling's response, or `undefined` when it can't be reached
|
|
585
|
+
*/
|
|
586
|
+
async requestRelayMessage(targetName, message) {
|
|
587
|
+
const namespace = this.relayNamespace();
|
|
588
|
+
if (namespace === void 0) {
|
|
589
|
+
return void 0;
|
|
590
|
+
}
|
|
591
|
+
const stub = typeof namespace.getByName === "function" ? namespace.getByName(targetName) : namespace.get(namespace.idFromName(targetName));
|
|
592
|
+
const body = JSON.stringify(message);
|
|
593
|
+
const headers = { "content-type": "application/json", "x-lunora-shard-binding": this.host.shardBinding() ?? "" };
|
|
594
|
+
const secret = relaySecretOf(this.host.env());
|
|
595
|
+
if (secret !== void 0) {
|
|
596
|
+
headers[RELAY_SIGNATURE_HEADER] = await signRelayBody(secret, body);
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
return await stub.fetch("https://relay.internal/_lunora/relay", {
|
|
600
|
+
body,
|
|
601
|
+
headers,
|
|
602
|
+
method: "POST"
|
|
603
|
+
});
|
|
604
|
+
} catch {
|
|
605
|
+
return void 0;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
class OwnerRelay extends RelayLink {
|
|
610
|
+
/** Memoized RLS-uniform verdict per `(name, args)` shape — uniformity is stable, so the gate probe runs at most once per distinct shape. */
|
|
611
|
+
shapeUniformCache = /* @__PURE__ */ new Map();
|
|
612
|
+
/** Active relay indices, hydrated once from `__lunora_relays` and cached for the synchronous forward path. */
|
|
613
|
+
relaySetCache;
|
|
614
|
+
/** Relay-uniform shapes a relay has subscribers for, keyed by `(name, args)`. `cursor` is the cohort frontier the owner has multicast deltas up to. */
|
|
615
|
+
relayShapeRegistry = /* @__PURE__ */ new Map();
|
|
616
|
+
/** 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. */
|
|
617
|
+
relayShapeProxies = /* @__PURE__ */ new Map();
|
|
618
|
+
/** The owner's current promotion state (plan 075 Phase 4), carried across `relayCount()` calls so hysteresis has memory — a shard hovering near the threshold can't flap. */
|
|
619
|
+
promotionState = "owned";
|
|
620
|
+
constructor(host, ownerKey) {
|
|
621
|
+
super(host, { ownerKey });
|
|
622
|
+
}
|
|
623
|
+
async forwardWhisper(topic, frame) {
|
|
624
|
+
if (!this.canAddressSiblings()) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const relays = this.ownerRelaySet();
|
|
628
|
+
if (relays.size === 0) {
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
await Promise.all([...relays].map((index) => this.postRelayMessage(relayName(this.roleId.ownerKey, index), { frame, topic, type: "relay_frame" })));
|
|
632
|
+
}
|
|
633
|
+
async onFlush(changed, frameCursor) {
|
|
634
|
+
await Promise.all([this.multicastShapePokes(changed, frameCursor), this.proxyShapePokes(changed, frameCursor)]);
|
|
635
|
+
}
|
|
636
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: an owner serves its own shape subscribers locally, never through a relay
|
|
637
|
+
seedRelayShape() {
|
|
638
|
+
return Promise.resolve(void 0);
|
|
639
|
+
}
|
|
640
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only a relay announces
|
|
641
|
+
announce() {
|
|
642
|
+
return Promise.resolve();
|
|
643
|
+
}
|
|
644
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only a relay drains
|
|
645
|
+
announceDrain() {
|
|
646
|
+
return Promise.resolve();
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* How many relays the runtime should spread new connections across for this shard
|
|
650
|
+
* (plan 075 Phase 2, hysteresis added in Phase 4). `0` keeps every connection on
|
|
651
|
+
* the owner. The owner promotes once its live socket count reaches
|
|
652
|
+
* `LUNORA_RELAY_THRESHOLD` (`tUp`), fanning to a fixed `LUNORA_RELAY_FAN` (capped
|
|
653
|
+
* by `LUNORA_MAX_RELAYS`, the cost ceiling), and only collapses back to
|
|
654
|
+
* owner-served once subscribers drain below `LUNORA_RELAY_COLLAPSE_THRESHOLD`
|
|
655
|
+
* (`tDown`) — the band between the two holds the current state so a shard
|
|
656
|
+
* hovering near the threshold can't flap. {@link clampPromotionThresholds}
|
|
657
|
+
* guarantees a valid `tDown < tUp` band even under a misconfigured collapse
|
|
658
|
+
* threshold. Advances the promotion latch, so it is not a pure read.
|
|
659
|
+
*/
|
|
660
|
+
relayCount() {
|
|
661
|
+
const subscribers = this.host.getWebSockets().length;
|
|
662
|
+
const tUp = envPositiveInt(this.host.env(), "LUNORA_RELAY_THRESHOLD", DEFAULT_PROMOTION_THRESHOLDS.tUp);
|
|
663
|
+
const tDownRaw = envPositiveInt(this.host.env(), "LUNORA_RELAY_COLLAPSE_THRESHOLD", DEFAULT_PROMOTION_THRESHOLDS.tDown);
|
|
664
|
+
this.promotionState = nextPromotionState(this.promotionState, subscribers, clampPromotionThresholds(tUp, tDownRaw));
|
|
665
|
+
if (this.promotionState === "owned") {
|
|
666
|
+
return 0;
|
|
667
|
+
}
|
|
668
|
+
const maxRelays = envPositiveInt(this.host.env(), "LUNORA_MAX_RELAYS", DEFAULT_MAX_RELAYS);
|
|
669
|
+
const fan = envPositiveInt(this.host.env(), "LUNORA_RELAY_FAN", DEFAULT_RELAY_FAN);
|
|
670
|
+
return Math.min(maxRelays, Math.max(1, fan));
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* The RLS-uniform gate (plan 075 Phase 3, review-hardened): whether a reactive
|
|
674
|
+
* shape may be relay-multicast — i.e. one delta is correct for **every**
|
|
675
|
+
* subscriber. Fail-closed on four grounds — a static RLS read-policy guard, the
|
|
676
|
+
* anonymous multicast identity as the probe base, two `Proxy`-backed probes that
|
|
677
|
+
* yield a distinct value for ANY accessed claim (so any claim a `where` reads —
|
|
678
|
+
* even a custom one outside `rls()` — diverges), and a wholesale-copy backstop.
|
|
679
|
+
* Cached per `(name, args)`, whose uniformity is stable.
|
|
680
|
+
*/
|
|
681
|
+
isShapeRelayUniform(name, args) {
|
|
682
|
+
const cacheKey = shapeRoutingKey(name, args);
|
|
683
|
+
const cached = this.shapeUniformCache.get(cacheKey);
|
|
684
|
+
if (cached !== void 0) {
|
|
685
|
+
return cached;
|
|
686
|
+
}
|
|
687
|
+
const uniform = this.probeShapeRelayUniform(name, args);
|
|
688
|
+
this.shapeUniformCache.set(cacheKey, uniform);
|
|
689
|
+
return uniform;
|
|
690
|
+
}
|
|
691
|
+
onAttach(index) {
|
|
692
|
+
this.addRelayToSet(index);
|
|
693
|
+
}
|
|
694
|
+
onDetach(index) {
|
|
695
|
+
this.removeRelayFromSet(index);
|
|
696
|
+
}
|
|
697
|
+
async onWhisperFrame(message) {
|
|
698
|
+
await Promise.all(
|
|
699
|
+
[...this.ownerRelaySet()].filter((index) => index !== message.originRelay).map(
|
|
700
|
+
(index) => this.postRelayMessage(relayName(this.roleId.ownerKey, index), { frame: message.frame, topic: message.topic, type: "relay_frame" })
|
|
701
|
+
)
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
onShapeSubscribe(message) {
|
|
705
|
+
return this.buildShapeSeedFrames(message);
|
|
706
|
+
}
|
|
707
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: an owner doesn't receive multicast pokes (it sends them)
|
|
708
|
+
onShapePoke() {
|
|
709
|
+
return 0;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Owner side (slice B.2): for every registered relay-uniform shape whose table
|
|
713
|
+
* changed this flush, compute the membership diff ONCE over `(cohort cursor,
|
|
714
|
+
* frameCursor]` and multicast the `rowsPatch` to every relay. Advances the cohort
|
|
715
|
+
* cursor synchronously (before any await) so a seed interleaving during the
|
|
716
|
+
* multicast registers at `frameCursor` and is skipped by this in-flight poke.
|
|
717
|
+
*/
|
|
718
|
+
async multicastShapePokes(changed, frameCursor) {
|
|
719
|
+
if (this.relayShapeRegistry.size === 0) {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const relays = this.ownerRelaySet();
|
|
723
|
+
if (relays.size === 0) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const epoch = this.host.currentCdcEpoch();
|
|
727
|
+
const sends = [];
|
|
728
|
+
for (const entry of this.relayShapeRegistry.values()) {
|
|
729
|
+
let resolved;
|
|
730
|
+
try {
|
|
731
|
+
resolved = this.host.resolveShape(entry.name, entry.args, RELAY_MULTICAST_IDENTITY);
|
|
732
|
+
} catch {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (resolved === void 0 || resolved.global === true || !changed.has(resolved.table)) {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
const fromCursor = entry.cursor;
|
|
739
|
+
const rowsPatch = this.host.buildShapeDiff(resolved, fromCursor, frameCursor);
|
|
740
|
+
if (rowsPatch.length === 0) {
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
entry.cursor = frameCursor;
|
|
744
|
+
const poke = {
|
|
745
|
+
// `args` wire-encoded for the same reason as `rowsPatch` below; the
|
|
746
|
+
// relay decodes them at `handleControl` before the routing match.
|
|
747
|
+
args: encodeWire(entry.args),
|
|
748
|
+
checkpoint: frameCursor,
|
|
749
|
+
epoch,
|
|
750
|
+
fromCursor,
|
|
751
|
+
name: entry.name,
|
|
752
|
+
// Wire-encode before the poke crosses the owner→relay `JSON.stringify`
|
|
753
|
+
// hop (`requestRelayMessage`); the relay re-frames it with
|
|
754
|
+
// `preEncoded` so a `bytes`/`bigint` shape column isn't dropped/truncated.
|
|
755
|
+
rowsPatch: encodeRowsPatch(rowsPatch),
|
|
756
|
+
type: "relay_shape_poke"
|
|
757
|
+
};
|
|
758
|
+
for (const index of relays) {
|
|
759
|
+
sends.push(this.postRelayMessage(relayName(this.roleId.ownerKey, index), poke));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
await Promise.all(sends);
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Owner side (review MEDIUM-3): for every NON-uniform relay-shape proxy whose
|
|
766
|
+
* table changed this flush, compute that one subscriber's diff over `(entry.cursor,
|
|
767
|
+
* frameCursor]` UNDER ITS OWN forwarded identity (RLS-correct) and deliver a
|
|
768
|
+
* `targetConnectionId`-addressed poke to just that socket's relay. Each entry
|
|
769
|
+
* tracks its own cursor — the diffs are identity-specific, no cohort sharing.
|
|
770
|
+
*/
|
|
771
|
+
async proxyShapePokes(changed, frameCursor) {
|
|
772
|
+
if (this.relayShapeProxies.size === 0) {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const epoch = this.host.currentCdcEpoch();
|
|
776
|
+
const sends = [];
|
|
777
|
+
for (const entry of this.relayShapeProxies.values()) {
|
|
778
|
+
let resolved;
|
|
779
|
+
try {
|
|
780
|
+
resolved = this.host.resolveShape(entry.name, entry.args, entry.identity);
|
|
781
|
+
} catch {
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
if (resolved === void 0 || resolved.global === true || !changed.has(resolved.table)) {
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
const fromCursor = entry.cursor;
|
|
788
|
+
const rowsPatch = this.host.buildShapeDiff(resolved, fromCursor, frameCursor);
|
|
789
|
+
if (rowsPatch.length === 0) {
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
entry.cursor = frameCursor;
|
|
793
|
+
const poke = {
|
|
794
|
+
// `args` wire-encoded like `rowsPatch`; decoded relay-side at
|
|
795
|
+
// `handleControl` before the routing match.
|
|
796
|
+
args: encodeWire(entry.args),
|
|
797
|
+
checkpoint: frameCursor,
|
|
798
|
+
epoch,
|
|
799
|
+
fromCursor,
|
|
800
|
+
name: entry.name,
|
|
801
|
+
// Wire-encode before the owner→relay `JSON.stringify` hop (see the
|
|
802
|
+
// cohort-multicast path); the relay re-frames it with `preEncoded`.
|
|
803
|
+
rowsPatch: encodeRowsPatch(rowsPatch),
|
|
804
|
+
targetConnectionId: entry.connectionId,
|
|
805
|
+
type: "relay_shape_poke"
|
|
806
|
+
};
|
|
807
|
+
sends.push(this.postRelayMessage(relayName(this.roleId.ownerKey, entry.relayIndex), poke));
|
|
808
|
+
}
|
|
809
|
+
await Promise.all(sends);
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Serialize a shape's seed poke frames for a relay to deliver verbatim. Resolves
|
|
813
|
+
* under the forwarded socket identity (so RLS applies exactly as for a local
|
|
814
|
+
* subscribe), self-heals the relay set, registers the shape for live updates
|
|
815
|
+
* (cohort multicast when uniform, per-socket proxy when not), and stamps the
|
|
816
|
+
* relay's cohort memo at the registry FRONTIER (not the global cursor) so a late
|
|
817
|
+
* joiner is never stranded. `lastMutationId` is omitted (relayed sockets are
|
|
818
|
+
* owner-served for custom mutators).
|
|
819
|
+
* @returns the serialized frames + the cohort-memo cursor, or an error
|
|
820
|
+
*/
|
|
821
|
+
buildShapeSeedFrames(request) {
|
|
822
|
+
const identity = { identity: request.identity, userId: request.userId };
|
|
823
|
+
let resolved;
|
|
824
|
+
try {
|
|
825
|
+
resolved = this.host.resolveShape(request.name, request.args, identity);
|
|
826
|
+
} catch (error) {
|
|
827
|
+
const { body } = toErrorBody(error, { fallbackCode: "SHAPE_RESOLVE_FAILED", redactedMessage: "shape resolution failed" });
|
|
828
|
+
return { error: { code: body.code, message: body.message } };
|
|
829
|
+
}
|
|
830
|
+
if (resolved === void 0 || resolved.global === true) {
|
|
831
|
+
return { error: { code: "SHAPE_NOT_FOUND", message: `shape not relayable: ${request.name}` } };
|
|
832
|
+
}
|
|
833
|
+
if (request.relayIndex !== void 0) {
|
|
834
|
+
this.addRelayToSet(request.relayIndex);
|
|
835
|
+
}
|
|
836
|
+
const { baseCheckpoint, cursor, epoch, rowsPatch } = this.host.computeOpLogShapeSeed(
|
|
837
|
+
{ args: request.args, name: request.name, sinceEpoch: request.sinceEpoch, sinceSeq: request.sinceSeq },
|
|
838
|
+
resolved
|
|
839
|
+
);
|
|
840
|
+
let cohortCursor = cursor;
|
|
841
|
+
if (this.isShapeRelayUniform(request.name, request.args)) {
|
|
842
|
+
const routingKey = shapeRoutingKey(request.name, request.args);
|
|
843
|
+
let entry = this.relayShapeRegistry.get(routingKey);
|
|
844
|
+
if (entry === void 0) {
|
|
845
|
+
entry = { args: request.args, cursor, name: request.name };
|
|
846
|
+
this.relayShapeRegistry.set(routingKey, entry);
|
|
847
|
+
}
|
|
848
|
+
cohortCursor = entry.cursor;
|
|
849
|
+
} else if (request.relayIndex !== void 0 && request.connectionId !== void 0) {
|
|
850
|
+
this.relayShapeProxies.set(`${String(request.relayIndex)}:${request.connectionId}:${request.subId}`, {
|
|
851
|
+
args: request.args,
|
|
852
|
+
connectionId: request.connectionId,
|
|
853
|
+
cursor,
|
|
854
|
+
epoch,
|
|
855
|
+
identity,
|
|
856
|
+
name: request.name,
|
|
857
|
+
relayIndex: request.relayIndex,
|
|
858
|
+
subId: request.subId
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
const frames = buildPokeFrames([{ rowsPatch, shapeId: request.subId }], {
|
|
862
|
+
baseCheckpoint,
|
|
863
|
+
checkpoint: cursor,
|
|
864
|
+
epoch,
|
|
865
|
+
lastMutationId: void 0,
|
|
866
|
+
pokeId: this.host.nextPokeId()
|
|
867
|
+
});
|
|
868
|
+
return { cursor: cohortCursor, epoch, frames };
|
|
869
|
+
}
|
|
870
|
+
/** Ensure the reserved owner-side relay-set table exists (auto-hidden from the data browser by the `__lunora` prefix). */
|
|
871
|
+
ensureRelayTable() {
|
|
872
|
+
this.host.sql().exec("CREATE TABLE IF NOT EXISTS __lunora_relays (idx INTEGER PRIMARY KEY)");
|
|
873
|
+
}
|
|
874
|
+
/** The owner's active relay indices, hydrated once from `__lunora_relays` and cached for the synchronous forward path. */
|
|
875
|
+
ownerRelaySet() {
|
|
876
|
+
if (this.relaySetCache === void 0) {
|
|
877
|
+
this.ensureRelayTable();
|
|
878
|
+
const rows = this.host.sql().exec("SELECT idx FROM __lunora_relays").toArray();
|
|
879
|
+
this.relaySetCache = new Set(rows.map((row) => Number(row.idx)));
|
|
880
|
+
}
|
|
881
|
+
return this.relaySetCache;
|
|
882
|
+
}
|
|
883
|
+
/** Record a relay as active (idempotent), persisting it so the set survives the owner's hibernation. */
|
|
884
|
+
addRelayToSet(index) {
|
|
885
|
+
this.ensureRelayTable();
|
|
886
|
+
this.host.sql().exec("INSERT OR IGNORE INTO __lunora_relays (idx) VALUES (?)", index);
|
|
887
|
+
this.ownerRelaySet().add(index);
|
|
888
|
+
}
|
|
889
|
+
/** 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. */
|
|
890
|
+
removeRelayFromSet(index) {
|
|
891
|
+
this.ensureRelayTable();
|
|
892
|
+
this.host.sql().exec("DELETE FROM __lunora_relays WHERE idx = ?", index);
|
|
893
|
+
const set = this.ownerRelaySet();
|
|
894
|
+
set.delete(index);
|
|
895
|
+
for (const [key, entry] of this.relayShapeProxies) {
|
|
896
|
+
if (entry.relayIndex === index) {
|
|
897
|
+
this.relayShapeProxies.delete(key);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (set.size === 0) {
|
|
901
|
+
this.relayShapeRegistry.clear();
|
|
902
|
+
this.shapeUniformCache.clear();
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* The one-shot computation behind {@link OwnerRelay.isShapeRelayUniform}, made
|
|
907
|
+
* sound against the cross-identity row-leak (review). Resolves under the anonymous
|
|
908
|
+
* multicast identity (the base) plus two `Proxy`-backed identities that return a
|
|
909
|
+
* distinct value for ANY accessed claim, requires all to agree on table + where +
|
|
910
|
+
* columns, rejects any table with an RLS read policy or ANY masked column
|
|
911
|
+
* defined (even if unprojected — see {@link OwnerRelay.tableHasAnyMask}), and
|
|
912
|
+
* fails closed if the claims are enumerated (a wholesale copy the proxy can't
|
|
913
|
+
* differentiate).
|
|
914
|
+
*/
|
|
915
|
+
probeShapeRelayUniform(name, args) {
|
|
916
|
+
let base;
|
|
917
|
+
try {
|
|
918
|
+
base = this.host.resolveShape(name, args, RELAY_MULTICAST_IDENTITY);
|
|
919
|
+
} catch {
|
|
920
|
+
return false;
|
|
921
|
+
}
|
|
922
|
+
if (base === void 0 || base.global === true) {
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
if (this.host.rlsMetadata().policies.some((policy) => policy.on === "read" && policy.table === base.table)) {
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
928
|
+
if (this.tableHasAnyMask(base.table)) {
|
|
929
|
+
return false;
|
|
930
|
+
}
|
|
931
|
+
const baseWhere = stableWireKey(base.effectiveWhere);
|
|
932
|
+
const baseColumns = stableWireKey(base.columns);
|
|
933
|
+
let enumerated = false;
|
|
934
|
+
const populate = (side) => {
|
|
935
|
+
const backing = { groups: [`grp_${side}`], roles: [side], sub: `__lunora_probe_${side}__` };
|
|
936
|
+
const claims = /* @__PURE__ */ new Proxy(backing, {
|
|
937
|
+
get: (target, key) => {
|
|
938
|
+
if (typeof key === "symbol" || key in target) {
|
|
939
|
+
return Reflect.get(target, key);
|
|
940
|
+
}
|
|
941
|
+
return `${side}:${key}`;
|
|
942
|
+
},
|
|
943
|
+
getOwnPropertyDescriptor: (target, key) => {
|
|
944
|
+
enumerated = true;
|
|
945
|
+
return Reflect.getOwnPropertyDescriptor(target, key);
|
|
946
|
+
},
|
|
947
|
+
has: (target, key) => typeof key === "symbol" ? Reflect.has(target, key) : true,
|
|
948
|
+
ownKeys: (target) => {
|
|
949
|
+
enumerated = true;
|
|
950
|
+
return Reflect.ownKeys(target);
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
return { identity: claims, userId: `__lunora_probe_${side}__` };
|
|
954
|
+
};
|
|
955
|
+
const matches = [RELAY_MULTICAST_IDENTITY, populate("a"), populate("b")].every((probe) => {
|
|
956
|
+
let resolved;
|
|
957
|
+
try {
|
|
958
|
+
resolved = this.host.resolveShape(name, args, probe);
|
|
959
|
+
} catch {
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
return resolved !== void 0 && resolved.global !== true && resolved.table === base.table && stableWireKey(resolved.effectiveWhere) === baseWhere && stableWireKey(resolved.columns) === baseColumns;
|
|
963
|
+
});
|
|
964
|
+
return matches && !enumerated;
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Whether `table` has ANY masked column defined (L7). Deliberately conservative:
|
|
968
|
+
* we disqualify a shape from relay-multicast if its table declares any mask at
|
|
969
|
+
* all, even when the current query doesn't project the masked column. A masked
|
|
970
|
+
* value is identity-dependent, and keying uniformity off the *projected* set
|
|
971
|
+
* meant a later column addition (or a `select` change) could silently widen a
|
|
972
|
+
* cohort to include an identity-dependent value. Refusing on any table-level
|
|
973
|
+
* mask removes that footgun; the cost is a few extra shapes falling back to the
|
|
974
|
+
* per-identity path, which is always correct.
|
|
975
|
+
*/
|
|
976
|
+
tableHasAnyMask(table) {
|
|
977
|
+
return this.host.maskMetadata().columns.some((entry) => entry.table === table);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
class RelayMember extends RelayLink {
|
|
981
|
+
/** `true` once this relay has announced itself to its owner this wake, so a hot socket churn doesn't re-attach on every subscribe. */
|
|
982
|
+
relayAnnounced = false;
|
|
983
|
+
/** 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`. */
|
|
984
|
+
shapeRelayMemos = /* @__PURE__ */ new WeakMap();
|
|
985
|
+
constructor(host, ownerKey, relayIndex) {
|
|
986
|
+
super(host, { ownerKey, relayIndex });
|
|
987
|
+
}
|
|
988
|
+
async forwardWhisper(topic, frame) {
|
|
989
|
+
if (!this.canAddressSiblings()) {
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
await this.postRelayMessage(this.roleId.ownerKey, { frame, originRelay: this.roleId.relayIndex, topic, type: "relay_frame" });
|
|
993
|
+
}
|
|
994
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: a relay receives no writes, so it never flushes its own CDC
|
|
995
|
+
onFlush() {
|
|
996
|
+
return Promise.resolve();
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Seed a shape held by a socket on this relay by forwarding the request to the
|
|
1000
|
+
* owner: the owner resolves under this socket's verified identity and computes the
|
|
1001
|
+
* seed frames, which the relay delivers verbatim. Returns a structured error
|
|
1002
|
+
* (surfaced as a `shape_subscribe` error) when the owner can't be reached.
|
|
1003
|
+
*/
|
|
1004
|
+
async seedRelayShape(ws, subId, shape, identity) {
|
|
1005
|
+
if (!this.canAddressSiblings()) {
|
|
1006
|
+
return { code: "RELAY_MISCONFIGURED", message: "relay cannot address its owner" };
|
|
1007
|
+
}
|
|
1008
|
+
await this.announce();
|
|
1009
|
+
const request = {
|
|
1010
|
+
// Wire-encode before the relay->owner `JSON.stringify` hop: the shard
|
|
1011
|
+
// decoded these args at its `shape_subscribe` entry point, so a
|
|
1012
|
+
// `bigint`/`Date`/bytes arg would otherwise throw (or corrupt) here.
|
|
1013
|
+
args: encodeWire(shape.args ?? {}),
|
|
1014
|
+
connectionId: this.host.readAttachment(ws).connectionId,
|
|
1015
|
+
identity: identity.identity,
|
|
1016
|
+
name: shape.name,
|
|
1017
|
+
relayIndex: this.roleId.relayIndex,
|
|
1018
|
+
sinceEpoch: shape.sinceEpoch,
|
|
1019
|
+
sinceSeq: shape.sinceSeq,
|
|
1020
|
+
subId,
|
|
1021
|
+
type: "relay_shape_subscribe",
|
|
1022
|
+
userId: identity.userId
|
|
1023
|
+
};
|
|
1024
|
+
const response = await this.requestRelayMessage(this.roleId.ownerKey, request);
|
|
1025
|
+
if (response === void 0) {
|
|
1026
|
+
return { code: "RELAY_SEED_FAILED", message: "owner did not answer the shape seed" };
|
|
1027
|
+
}
|
|
1028
|
+
let seed;
|
|
1029
|
+
try {
|
|
1030
|
+
seed = await response.json();
|
|
1031
|
+
} catch {
|
|
1032
|
+
return { code: "RELAY_SEED_FAILED", message: "malformed shape seed from owner" };
|
|
1033
|
+
}
|
|
1034
|
+
if (seed.error !== void 0) {
|
|
1035
|
+
return seed.error;
|
|
1036
|
+
}
|
|
1037
|
+
if (seed.frames === void 0) {
|
|
1038
|
+
return { code: "RELAY_SEED_FAILED", message: "owner returned no shape frames" };
|
|
1039
|
+
}
|
|
1040
|
+
await awaitWsDrain(ws);
|
|
1041
|
+
for (const frame of seed.frames) {
|
|
1042
|
+
trySendFrame(ws, frame);
|
|
1043
|
+
}
|
|
1044
|
+
this.recordRelayShapeMemo(ws, subId, seed.cursor ?? 0, seed.epoch);
|
|
1045
|
+
return "ok";
|
|
1046
|
+
}
|
|
1047
|
+
/** 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). */
|
|
1048
|
+
async announce() {
|
|
1049
|
+
if (this.relayAnnounced || !this.canAddressSiblings()) {
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
this.relayAnnounced = true;
|
|
1053
|
+
const response = await this.requestRelayMessage(this.roleId.ownerKey, { relayIndex: this.roleId.relayIndex, type: "relay_attach" });
|
|
1054
|
+
if (!response?.ok) {
|
|
1055
|
+
this.relayAnnounced = false;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/** 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. */
|
|
1059
|
+
async announceDrain(closing) {
|
|
1060
|
+
if (!this.canAddressSiblings()) {
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
if (this.host.getWebSockets().some((ws) => ws !== closing)) {
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
this.relayAnnounced = false;
|
|
1067
|
+
await this.postRelayMessage(this.roleId.ownerKey, { relayIndex: this.roleId.relayIndex, type: "relay_detach" });
|
|
1068
|
+
}
|
|
1069
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: a relay never spreads connections (flat single tier)
|
|
1070
|
+
relayCount() {
|
|
1071
|
+
return 0;
|
|
1072
|
+
}
|
|
1073
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: the RLS-uniform gate is an owner concern
|
|
1074
|
+
isShapeRelayUniform() {
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only an owner tracks a relay set
|
|
1078
|
+
onAttach() {
|
|
1079
|
+
}
|
|
1080
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only an owner tracks a relay set
|
|
1081
|
+
onDetach() {
|
|
1082
|
+
}
|
|
1083
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: only an owner re-distributes a forwarded whisper
|
|
1084
|
+
onWhisperFrame() {
|
|
1085
|
+
return Promise.resolve();
|
|
1086
|
+
}
|
|
1087
|
+
// eslint-disable-next-line class-methods-use-this -- role hook: a relay can't seed (no op-log) — the owner does
|
|
1088
|
+
onShapeSubscribe() {
|
|
1089
|
+
return { error: { code: "RELAY_CANNOT_SEED", message: "a relay has no op-log to seed from" } };
|
|
1090
|
+
}
|
|
1091
|
+
onShapePoke(poke) {
|
|
1092
|
+
return this.deliverShapePoke(poke);
|
|
1093
|
+
}
|
|
1094
|
+
/** Record a relay socket's cohort cursor + epoch for `subId` (creating the per-socket map lazily). */
|
|
1095
|
+
recordRelayShapeMemo(ws, subId, cursor, epoch) {
|
|
1096
|
+
let memos = this.shapeRelayMemos.get(ws);
|
|
1097
|
+
if (memos === void 0) {
|
|
1098
|
+
memos = /* @__PURE__ */ new Map();
|
|
1099
|
+
this.shapeRelayMemos.set(ws, memos);
|
|
1100
|
+
}
|
|
1101
|
+
memos.set(subId, { cursor, epoch });
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Deliver an owner-multicast shape delta to this relay's cohort sockets. A socket
|
|
1105
|
+
* receives it only while its memo matches the poke's `fromCursor` AND `epoch` (so a
|
|
1106
|
+
* socket that seeded at a different cursor/epoch never double-applies), then
|
|
1107
|
+
* advances to `checkpoint`. A targeted (per-socket proxy) poke goes ONLY to its one
|
|
1108
|
+
* connection; a cohort multicast goes to every matching socket.
|
|
1109
|
+
* @returns the number of sockets delivered to
|
|
1110
|
+
*/
|
|
1111
|
+
deliverShapePoke(poke) {
|
|
1112
|
+
const routingKey = shapeRoutingKey(poke.name, poke.args);
|
|
1113
|
+
let delivered = 0;
|
|
1114
|
+
for (const ws of this.host.getWebSockets()) {
|
|
1115
|
+
const attachment = this.host.readAttachment(ws);
|
|
1116
|
+
const { shapes } = attachment;
|
|
1117
|
+
const memos = this.shapeRelayMemos.get(ws);
|
|
1118
|
+
if (shapes === void 0 || memos === void 0) {
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
if (poke.targetConnectionId !== void 0 && attachment.connectionId !== poke.targetConnectionId) {
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
for (const [subId, sub] of Object.entries(shapes)) {
|
|
1125
|
+
const memo = memos.get(subId);
|
|
1126
|
+
if (memo?.cursor !== poke.fromCursor || memo.epoch !== poke.epoch || shapeRoutingKey(sub.name, sub.args) !== routingKey) {
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
const frames = buildPokeFrames(
|
|
1130
|
+
[{ rowsPatch: poke.rowsPatch, shapeId: subId }],
|
|
1131
|
+
{
|
|
1132
|
+
baseCheckpoint: void 0,
|
|
1133
|
+
checkpoint: poke.checkpoint,
|
|
1134
|
+
epoch: poke.epoch,
|
|
1135
|
+
lastMutationId: void 0,
|
|
1136
|
+
pokeId: this.host.nextPokeId()
|
|
1137
|
+
},
|
|
1138
|
+
// `poke.rowsPatch` was wire-encoded by the owner before it crossed
|
|
1139
|
+
// the hub — don't double-encode it here.
|
|
1140
|
+
{ preEncoded: true }
|
|
1141
|
+
);
|
|
1142
|
+
for (const frame of frames) {
|
|
1143
|
+
trySendFrame(ws, frame);
|
|
1144
|
+
}
|
|
1145
|
+
memos.set(subId, { cursor: poke.checkpoint, epoch: poke.epoch });
|
|
1146
|
+
delivered += 1;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
return delivered;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
const createRelayLink = (host) => {
|
|
1153
|
+
const name = host.doName();
|
|
1154
|
+
if (name === void 0) {
|
|
1155
|
+
return void 0;
|
|
1156
|
+
}
|
|
1157
|
+
const parsed = parseRelayName(name);
|
|
1158
|
+
return parsed === void 0 ? new OwnerRelay(host, name) : new RelayMember(host, parsed.ownerKey, parsed.relayIndex);
|
|
1159
|
+
};
|
|
1160
|
+
|
|
145
1161
|
const REQUEST_LOG_TABLE = "__lunora_reqlog__";
|
|
146
1162
|
const REQUEST_LOG_RETENTION = 1e3;
|
|
147
1163
|
const REQUEST_LOG_EVENT_SOURCE = "lunora";
|
|
@@ -348,6 +1364,69 @@ const readRequestLog = (sql, options = {}) => {
|
|
|
348
1364
|
return base;
|
|
349
1365
|
});
|
|
350
1366
|
};
|
|
1367
|
+
const readErrorIssues = (sql, options = {}) => {
|
|
1368
|
+
ensureRequestLogTable(sql);
|
|
1369
|
+
const limit = Math.max(1, Math.min(options.limit ?? REQUEST_LOG_RETENTION, 1e4));
|
|
1370
|
+
const conjuncts = ["outcome = 'error'"];
|
|
1371
|
+
const parameters = [];
|
|
1372
|
+
if (options.functionPathPrefix !== void 0 && options.functionPathPrefix !== "") {
|
|
1373
|
+
conjuncts.push(String.raw`function_path LIKE ? ESCAPE '\'`);
|
|
1374
|
+
parameters.push(`${escapeLike(options.functionPathPrefix)}%`);
|
|
1375
|
+
}
|
|
1376
|
+
if (options.userId !== void 0 && options.userId !== "") {
|
|
1377
|
+
conjuncts.push("user_id = ?");
|
|
1378
|
+
parameters.push(options.userId);
|
|
1379
|
+
}
|
|
1380
|
+
if (options.shardKey !== void 0 && options.shardKey !== "") {
|
|
1381
|
+
conjuncts.push("shard_key = ?");
|
|
1382
|
+
parameters.push(options.shardKey);
|
|
1383
|
+
}
|
|
1384
|
+
parameters.push(limit);
|
|
1385
|
+
const rows = runSql(
|
|
1386
|
+
sql,
|
|
1387
|
+
`SELECT function_path, error_message, ts
|
|
1388
|
+
FROM "${REQUEST_LOG_TABLE}" WHERE ${conjuncts.join(" AND ")} ORDER BY seq DESC LIMIT ?`,
|
|
1389
|
+
...parameters
|
|
1390
|
+
).toArray();
|
|
1391
|
+
const issues = /* @__PURE__ */ new Map();
|
|
1392
|
+
const sampleTs = /* @__PURE__ */ new Map();
|
|
1393
|
+
for (const row of rows) {
|
|
1394
|
+
const message = row.error_message ?? "";
|
|
1395
|
+
const { culprit, hash, title } = fingerprintError({ functionPath: row.function_path, message });
|
|
1396
|
+
const existing = issues.get(hash);
|
|
1397
|
+
if (existing === void 0) {
|
|
1398
|
+
issues.set(hash, { count: 1, culprit, firstSeen: row.ts, hash, lastSeen: row.ts, sampleMessage: message, title });
|
|
1399
|
+
sampleTs.set(hash, row.ts);
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
existing.count += 1;
|
|
1403
|
+
existing.firstSeen = Math.min(existing.firstSeen, row.ts);
|
|
1404
|
+
existing.lastSeen = Math.max(existing.lastSeen, row.ts);
|
|
1405
|
+
if (row.ts > (sampleTs.get(hash) ?? Number.NEGATIVE_INFINITY)) {
|
|
1406
|
+
sampleTs.set(hash, row.ts);
|
|
1407
|
+
existing.sampleMessage = message;
|
|
1408
|
+
existing.title = title;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return [...issues.values()].toSorted((a, b) => b.lastSeen - a.lastSeen);
|
|
1412
|
+
};
|
|
1413
|
+
|
|
1414
|
+
const runSocketPool = async (items, processOne, concurrency = 8) => {
|
|
1415
|
+
let cursor = 0;
|
|
1416
|
+
const worker = async () => {
|
|
1417
|
+
let item = items[cursor];
|
|
1418
|
+
cursor += 1;
|
|
1419
|
+
while (item !== void 0) {
|
|
1420
|
+
try {
|
|
1421
|
+
await processOne(item);
|
|
1422
|
+
} catch {
|
|
1423
|
+
}
|
|
1424
|
+
item = items[cursor];
|
|
1425
|
+
cursor += 1;
|
|
1426
|
+
}
|
|
1427
|
+
};
|
|
1428
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
|
|
1429
|
+
};
|
|
351
1430
|
|
|
352
1431
|
const DANGLING_SCAN_CAP = 5e3;
|
|
353
1432
|
const DANGLING_RESULT_CAP = 500;
|
|
@@ -413,116 +1492,14 @@ const findDanglingReferences = (sql, storageColumns, liveKeys) => {
|
|
|
413
1492
|
|
|
414
1493
|
const WS_KEEPALIVE_PING = "lunora-ping";
|
|
415
1494
|
const WS_KEEPALIVE_PONG = "lunora-pong";
|
|
416
|
-
const
|
|
417
|
-
const
|
|
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
|
-
};
|
|
1495
|
+
const REQUIRE_EPHEMERAL_ENV_VALUES = /* @__PURE__ */ new Set(["1", "enabled", "on", "true", "yes"]);
|
|
1496
|
+
const UNDELIVERED_BASELINE = "<undelivered>";
|
|
507
1497
|
const ROOT_DO_SIZE_WARN_BYTES = 1073741824;
|
|
508
1498
|
const CDC_RESUME_SCAN_LIMIT = 1e4;
|
|
509
1499
|
const IDEMPOTENCY_RETENTION_MS = 864e5;
|
|
510
1500
|
const IDEMPOTENCY_GC_INTERVAL_MS = 36e5;
|
|
511
1501
|
const ROOT_SHARD_NAME = "__root__";
|
|
512
1502
|
const ADMIN_WILDCARD = "*";
|
|
513
|
-
const awaitWsDrain = async (ws) => {
|
|
514
|
-
let attempts = 0;
|
|
515
|
-
while (attempts < 100) {
|
|
516
|
-
attempts += 1;
|
|
517
|
-
const buffered = ws.bufferedAmount;
|
|
518
|
-
if (typeof buffered !== "number" || buffered < 1048576) {
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
await new Promise((resolve) => {
|
|
522
|
-
setTimeout(resolve, 20);
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
};
|
|
526
1503
|
const cdcSuffix = (cursor, epoch) => (cursor === void 0 ? "" : `,"cursor":${String(cursor)}`) + (epoch === void 0 ? "" : `,"epoch":${JSON.stringify(epoch)}`);
|
|
527
1504
|
const setsIntersect = (a, b) => {
|
|
528
1505
|
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
|
|
@@ -536,7 +1513,7 @@ const setsIntersect = (a, b) => {
|
|
|
536
1513
|
const parseRunMigrationArgs = (args) => {
|
|
537
1514
|
const id = typeof args["id"] === "string" ? args["id"] : "";
|
|
538
1515
|
if (id.trim() === "") {
|
|
539
|
-
throw
|
|
1516
|
+
throw new LunoraError("MIGRATION_ID_REQUIRED", "runMigration: `id` is required", { status: 400 });
|
|
540
1517
|
}
|
|
541
1518
|
return {
|
|
542
1519
|
batchSize: typeof args["batchSize"] === "number" ? args["batchSize"] : void 0,
|
|
@@ -551,25 +1528,25 @@ const parseWriteRowArgs = (args) => {
|
|
|
551
1528
|
const { op } = args;
|
|
552
1529
|
const table = typeof args["table"] === "string" ? args["table"] : "";
|
|
553
1530
|
if (op !== "insert" && op !== "patch" && op !== "replace" && op !== "delete") {
|
|
554
|
-
throw
|
|
1531
|
+
throw new LunoraError("BAD_REQUEST", "writeRow: `op` must be insert|patch|replace|delete");
|
|
555
1532
|
}
|
|
556
1533
|
if (table.trim() === "") {
|
|
557
|
-
throw
|
|
1534
|
+
throw new LunoraError("BAD_REQUEST", "writeRow: `table` is required");
|
|
558
1535
|
}
|
|
559
1536
|
const id = typeof args["id"] === "string" ? args["id"] : void 0;
|
|
560
1537
|
const record = typeof args["doc"] === "object" && args["doc"] !== null && !Array.isArray(args["doc"]) ? args["doc"] : void 0;
|
|
561
1538
|
if (op !== "insert" && (id === void 0 || id === "")) {
|
|
562
|
-
throw
|
|
1539
|
+
throw new LunoraError("BAD_REQUEST", `writeRow: \`id\` is required for op "${op}"`);
|
|
563
1540
|
}
|
|
564
1541
|
if (op !== "delete" && record === void 0) {
|
|
565
|
-
throw
|
|
1542
|
+
throw new LunoraError("BAD_REQUEST", `writeRow: \`doc\` is required for op "${op}"`);
|
|
566
1543
|
}
|
|
567
1544
|
return { doc: record, id, op, table };
|
|
568
1545
|
};
|
|
569
1546
|
const parseCreateWorkflowInstanceArgs = (args) => {
|
|
570
1547
|
const exportName = typeof args["exportName"] === "string" ? args["exportName"].trim() : "";
|
|
571
1548
|
if (exportName === "") {
|
|
572
|
-
throw
|
|
1549
|
+
throw new LunoraError("BAD_REQUEST", "createWorkflowInstance: `exportName` is required");
|
|
573
1550
|
}
|
|
574
1551
|
const id = typeof args["id"] === "string" && args["id"] !== "" ? args["id"] : void 0;
|
|
575
1552
|
return { exportName, id, params: args["params"] };
|
|
@@ -578,10 +1555,10 @@ const parseGetWorkflowInstanceStatusArgs = (args) => {
|
|
|
578
1555
|
const exportName = typeof args["exportName"] === "string" ? args["exportName"].trim() : "";
|
|
579
1556
|
const id = typeof args["id"] === "string" ? args["id"].trim() : "";
|
|
580
1557
|
if (exportName === "") {
|
|
581
|
-
throw
|
|
1558
|
+
throw new LunoraError("BAD_REQUEST", "getWorkflowInstanceStatus: `exportName` is required");
|
|
582
1559
|
}
|
|
583
1560
|
if (id === "") {
|
|
584
|
-
throw
|
|
1561
|
+
throw new LunoraError("BAD_REQUEST", "getWorkflowInstanceStatus: `id` is required");
|
|
585
1562
|
}
|
|
586
1563
|
return { exportName, id };
|
|
587
1564
|
};
|
|
@@ -636,7 +1613,7 @@ const parseTablePageOrderBy = (raw) => {
|
|
|
636
1613
|
const parseBulkDeleteArgs = (args) => {
|
|
637
1614
|
const table = typeof args["table"] === "string" ? args["table"] : "";
|
|
638
1615
|
if (table.trim() === "") {
|
|
639
|
-
throw
|
|
1616
|
+
throw new LunoraError("BAD_REQUEST", "deleteRows: `table` is required");
|
|
640
1617
|
}
|
|
641
1618
|
return {
|
|
642
1619
|
filters: parseTablePageFilters(args["filters"]),
|
|
@@ -648,37 +1625,39 @@ const parseBulkDeleteArgs = (args) => {
|
|
|
648
1625
|
const parseClearTableArgs = (args) => {
|
|
649
1626
|
const table = typeof args["table"] === "string" ? args["table"] : "";
|
|
650
1627
|
if (table.trim() === "") {
|
|
651
|
-
throw
|
|
1628
|
+
throw new LunoraError("BAD_REQUEST", "clearTable: `table` is required");
|
|
652
1629
|
}
|
|
653
1630
|
return { limit: typeof args["limit"] === "number" ? args["limit"] : void 0, table };
|
|
654
1631
|
};
|
|
655
1632
|
const parseRecordAuthEventArgs = (args) => {
|
|
656
1633
|
const { outcome } = args;
|
|
657
1634
|
if (outcome !== "ok" && outcome !== "fail") {
|
|
658
|
-
throw
|
|
1635
|
+
throw new LunoraError("BAD_REQUEST", 'recordAuthEvent: `outcome` must be "ok" or "fail"');
|
|
659
1636
|
}
|
|
660
1637
|
return { outcome };
|
|
661
1638
|
};
|
|
1639
|
+
const CONTAINER_EXIT_CODE_PATTERN = /\(exit (\d+)\)/;
|
|
662
1640
|
const parseRecordContainerEventArgs = (args) => {
|
|
663
1641
|
const raw = args["event"];
|
|
664
1642
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
665
|
-
throw
|
|
1643
|
+
throw new LunoraError("BAD_REQUEST", "recordContainerEvent: `event` must be an object");
|
|
666
1644
|
}
|
|
667
1645
|
const envelope = raw;
|
|
668
1646
|
const container = typeof envelope["container"] === "string" ? envelope["container"] : "";
|
|
669
1647
|
const event = typeof envelope["event"] === "string" ? envelope["event"] : "";
|
|
670
1648
|
if (container.trim() === "" || event.trim() === "") {
|
|
671
|
-
throw
|
|
672
|
-
code: "BAD_REQUEST",
|
|
673
|
-
name: "LunoraError",
|
|
674
|
-
status: 400
|
|
675
|
-
});
|
|
1649
|
+
throw new LunoraError("BAD_REQUEST", "recordContainerEvent: `event.container` and `event.event` are required");
|
|
676
1650
|
}
|
|
677
1651
|
const level = envelope["level"] === "error" ? "error" : "info";
|
|
678
1652
|
const detail = typeof envelope["message"] === "string" ? envelope["message"] : void 0;
|
|
679
1653
|
const timestamp = typeof envelope["ts"] === "number" ? envelope["ts"] : Date.now();
|
|
1654
|
+
const instance = typeof envelope["instance"] === "string" && envelope["instance"] !== "" ? envelope["instance"] : void 0;
|
|
1655
|
+
const exitRaw = detail === void 0 ? void 0 : CONTAINER_EXIT_CODE_PATTERN.exec(detail)?.[1];
|
|
1656
|
+
const exitCode = exitRaw === void 0 ? void 0 : Number.parseInt(exitRaw, 10);
|
|
680
1657
|
return {
|
|
1658
|
+
exitCode,
|
|
681
1659
|
functionPath: `container:${container}`,
|
|
1660
|
+
instance,
|
|
682
1661
|
level,
|
|
683
1662
|
message: detail === void 0 || detail === "" ? event : `${event}: ${detail}`,
|
|
684
1663
|
timestamp
|
|
@@ -688,21 +1667,21 @@ const parseRunAsArgs = (args) => {
|
|
|
688
1667
|
const functionPath = typeof args["functionPath"] === "string" ? args["functionPath"] : "";
|
|
689
1668
|
const userId = typeof args["userId"] === "string" ? args["userId"] : "";
|
|
690
1669
|
if (functionPath.trim() === "") {
|
|
691
|
-
throw
|
|
1670
|
+
throw new LunoraError("BAD_REQUEST", "runAs: `functionPath` is required");
|
|
692
1671
|
}
|
|
693
1672
|
if (functionPath.startsWith(ADMIN_FUNCTION_PREFIX)) {
|
|
694
|
-
throw
|
|
1673
|
+
throw new LunoraError("BAD_REQUEST", "runAs: cannot target a reserved admin function");
|
|
695
1674
|
}
|
|
696
1675
|
if (userId.trim() === "") {
|
|
697
|
-
throw
|
|
1676
|
+
throw new LunoraError("BAD_REQUEST", "runAs: `userId` is required");
|
|
698
1677
|
}
|
|
699
1678
|
const rawArgs = args["args"];
|
|
700
1679
|
if (rawArgs !== void 0 && (typeof rawArgs !== "object" || rawArgs === null || Array.isArray(rawArgs))) {
|
|
701
|
-
throw
|
|
1680
|
+
throw new LunoraError("BAD_REQUEST", "runAs: `args` must be an object");
|
|
702
1681
|
}
|
|
703
1682
|
const rawIdentity = args["identity"];
|
|
704
1683
|
if (rawIdentity !== void 0 && (typeof rawIdentity !== "object" || rawIdentity === null || Array.isArray(rawIdentity))) {
|
|
705
|
-
throw
|
|
1684
|
+
throw new LunoraError("BAD_REQUEST", "runAs: `identity` must be an object");
|
|
706
1685
|
}
|
|
707
1686
|
return {
|
|
708
1687
|
args: rawArgs === void 0 ? {} : rawArgs,
|
|
@@ -713,7 +1692,7 @@ const parseRunAsArgs = (args) => {
|
|
|
713
1692
|
};
|
|
714
1693
|
const parseRecordMailArgs = (args) => {
|
|
715
1694
|
const bad = (message) => {
|
|
716
|
-
throw
|
|
1695
|
+
throw new LunoraError("BAD_REQUEST", `recordMail: ${message}`);
|
|
717
1696
|
};
|
|
718
1697
|
const { bcc, cc, from, headers, html, replyTo, subject, text, to } = args;
|
|
719
1698
|
if (typeof subject !== "string") {
|
|
@@ -754,7 +1733,7 @@ const TEST_MAIL_DEFAULT_TO = "test@lunora.sh";
|
|
|
754
1733
|
const buildTestMailInput = (args) => {
|
|
755
1734
|
const { to } = args;
|
|
756
1735
|
if (to !== void 0 && typeof to !== "string") {
|
|
757
|
-
throw
|
|
1736
|
+
throw new LunoraError("BAD_REQUEST", "sendTestMail: `to` must be a string");
|
|
758
1737
|
}
|
|
759
1738
|
const recipient = to ?? TEST_MAIL_DEFAULT_TO;
|
|
760
1739
|
const link = "https://example.test/verify?token=demo";
|
|
@@ -768,29 +1747,99 @@ Verify your email: ${link}`,
|
|
|
768
1747
|
to: recipient
|
|
769
1748
|
};
|
|
770
1749
|
};
|
|
1750
|
+
const parseRecordQueueMessageArgs = (args) => {
|
|
1751
|
+
const bad = (message) => {
|
|
1752
|
+
throw new LunoraError("BAD_REQUEST", `recordQueueMessage: ${message}`);
|
|
1753
|
+
};
|
|
1754
|
+
const raw = args["messages"];
|
|
1755
|
+
if (!Array.isArray(raw)) {
|
|
1756
|
+
bad("`messages` must be an array");
|
|
1757
|
+
}
|
|
1758
|
+
const outcomes = /* @__PURE__ */ new Set(["ack", "error", "retry"]);
|
|
1759
|
+
return raw.map((entry, index) => {
|
|
1760
|
+
if (typeof entry !== "object" || entry === null) {
|
|
1761
|
+
bad(`\`messages[${String(index)}]\` must be an object`);
|
|
1762
|
+
}
|
|
1763
|
+
const record = entry;
|
|
1764
|
+
const messageId = typeof record["messageId"] === "string" ? record["messageId"] : "";
|
|
1765
|
+
const queue = typeof record["queue"] === "string" ? record["queue"] : "";
|
|
1766
|
+
const outcome = typeof record["outcome"] === "string" ? record["outcome"] : "";
|
|
1767
|
+
if (messageId === "") {
|
|
1768
|
+
bad(`\`messages[${String(index)}].messageId\` is required`);
|
|
1769
|
+
}
|
|
1770
|
+
if (queue === "") {
|
|
1771
|
+
bad(`\`messages[${String(index)}].queue\` is required`);
|
|
1772
|
+
}
|
|
1773
|
+
if (!outcomes.has(outcome)) {
|
|
1774
|
+
bad(`\`messages[${String(index)}].outcome\` must be one of ack | error | retry`);
|
|
1775
|
+
}
|
|
1776
|
+
const { attempts, timestamp } = record;
|
|
1777
|
+
return {
|
|
1778
|
+
attempts: typeof attempts === "number" && Number.isFinite(attempts) ? attempts : 1,
|
|
1779
|
+
body: record["body"],
|
|
1780
|
+
deadLettered: record["deadLettered"] === true,
|
|
1781
|
+
error: typeof record["error"] === "string" ? record["error"] : void 0,
|
|
1782
|
+
exportName: typeof record["exportName"] === "string" ? record["exportName"] : void 0,
|
|
1783
|
+
messageId,
|
|
1784
|
+
outcome,
|
|
1785
|
+
queue,
|
|
1786
|
+
timestamp: typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : 0
|
|
1787
|
+
};
|
|
1788
|
+
});
|
|
1789
|
+
};
|
|
1790
|
+
const MAX_QUEUE_SEND_BATCH = 100;
|
|
1791
|
+
const parseSendQueueMessageArgs = (args) => {
|
|
1792
|
+
const exportName = typeof args["exportName"] === "string" ? args["exportName"].trim() : "";
|
|
1793
|
+
if (exportName === "") {
|
|
1794
|
+
throw new LunoraError("BAD_REQUEST", "sendQueueMessage: `exportName` is required");
|
|
1795
|
+
}
|
|
1796
|
+
const delayRaw = args["delaySeconds"];
|
|
1797
|
+
if (delayRaw !== void 0 && (typeof delayRaw !== "number" || !Number.isFinite(delayRaw) || delayRaw < 0)) {
|
|
1798
|
+
throw new LunoraError("BAD_REQUEST", "sendQueueMessage: `delaySeconds` must be a non-negative number");
|
|
1799
|
+
}
|
|
1800
|
+
const batch = Array.isArray(args["batch"]) ? args["batch"] : void 0;
|
|
1801
|
+
if (batch !== void 0 && (batch.length === 0 || batch.length > MAX_QUEUE_SEND_BATCH)) {
|
|
1802
|
+
throw new LunoraError("BAD_REQUEST", `sendQueueMessage: \`batch\` must contain between 1 and ${String(MAX_QUEUE_SEND_BATCH)} messages`);
|
|
1803
|
+
}
|
|
1804
|
+
return {
|
|
1805
|
+
batch,
|
|
1806
|
+
body: args["body"],
|
|
1807
|
+
contentType: typeof args["contentType"] === "string" ? args["contentType"] : void 0,
|
|
1808
|
+
delaySeconds: delayRaw,
|
|
1809
|
+
exportName
|
|
1810
|
+
};
|
|
1811
|
+
};
|
|
1812
|
+
const parseReplayQueueMessageArgs = (args) => {
|
|
1813
|
+
const id = typeof args["id"] === "string" ? args["id"].trim() : "";
|
|
1814
|
+
if (id === "") {
|
|
1815
|
+
throw new LunoraError("BAD_REQUEST", "replayQueueMessage: `id` is required");
|
|
1816
|
+
}
|
|
1817
|
+
const target = typeof args["target"] === "string" && args["target"].trim() !== "" ? args["target"].trim() : void 0;
|
|
1818
|
+
return { id, target };
|
|
1819
|
+
};
|
|
771
1820
|
const parseRankBeforeArgs = (args) => {
|
|
772
1821
|
const table = typeof args["table"] === "string" ? args["table"] : "";
|
|
773
1822
|
const index = typeof args["index"] === "string" ? args["index"] : "";
|
|
774
1823
|
const rowId = typeof args["rowId"] === "string" ? args["rowId"] : "";
|
|
775
1824
|
if (table.trim() === "") {
|
|
776
|
-
throw
|
|
1825
|
+
throw new LunoraError("BAD_REQUEST", "rankBefore: `table` is required");
|
|
777
1826
|
}
|
|
778
1827
|
if (index.trim() === "") {
|
|
779
|
-
throw
|
|
1828
|
+
throw new LunoraError("BAD_REQUEST", "rankBefore: `index` is required");
|
|
780
1829
|
}
|
|
781
1830
|
if (typeof args["partitionKey"] !== "string") {
|
|
782
|
-
throw
|
|
1831
|
+
throw new LunoraError("BAD_REQUEST", "rankBefore: `partitionKey` must be a string");
|
|
783
1832
|
}
|
|
784
1833
|
if (rowId.trim() === "") {
|
|
785
|
-
throw
|
|
1834
|
+
throw new LunoraError("BAD_REQUEST", "rankBefore: `rowId` is required");
|
|
786
1835
|
}
|
|
787
1836
|
if (!Array.isArray(args["sortValues"])) {
|
|
788
|
-
throw
|
|
1837
|
+
throw new LunoraError("BAD_REQUEST", "rankBefore: `sortValues` must be an array");
|
|
789
1838
|
}
|
|
790
1839
|
return { index, partitionKey: args["partitionKey"], rowId, sortValues: args["sortValues"], table };
|
|
791
1840
|
};
|
|
792
1841
|
const badRequest = (message) => {
|
|
793
|
-
throw
|
|
1842
|
+
throw new LunoraError("BAD_REQUEST", message);
|
|
794
1843
|
};
|
|
795
1844
|
const requireNonEmptyString = (value, field) => {
|
|
796
1845
|
if (typeof value !== "string" || value.trim() === "") {
|
|
@@ -850,7 +1899,7 @@ const decodeIndexHitKey = (key) => {
|
|
|
850
1899
|
const parseApplyCdcArgs = (args) => {
|
|
851
1900
|
const raw = args["changes"];
|
|
852
1901
|
if (!Array.isArray(raw)) {
|
|
853
|
-
throw
|
|
1902
|
+
throw new LunoraError("BAD_REQUEST", "applyCdc: `changes` must be an array");
|
|
854
1903
|
}
|
|
855
1904
|
const changes = raw.map((entry, index) => {
|
|
856
1905
|
const record = entry;
|
|
@@ -858,27 +1907,15 @@ const parseApplyCdcArgs = (args) => {
|
|
|
858
1907
|
const table = typeof record["table"] === "string" ? record["table"] : "";
|
|
859
1908
|
const id = typeof record["id"] === "string" ? record["id"] : "";
|
|
860
1909
|
if (table === "" || id === "" || op !== "insert" && op !== "update" && op !== "delete") {
|
|
861
|
-
throw
|
|
862
|
-
code: "BAD_REQUEST",
|
|
863
|
-
name: "LunoraError",
|
|
864
|
-
status: 400
|
|
865
|
-
});
|
|
1910
|
+
throw new LunoraError("BAD_REQUEST", `applyCdc: changes[${String(index)}] must have a table, id, and op of insert|update|delete`);
|
|
866
1911
|
}
|
|
867
1912
|
const rawDocument = record["doc"];
|
|
868
1913
|
if (rawDocument !== void 0 && (typeof rawDocument !== "object" || rawDocument === null || Array.isArray(rawDocument))) {
|
|
869
|
-
throw
|
|
870
|
-
code: "BAD_REQUEST",
|
|
871
|
-
name: "LunoraError",
|
|
872
|
-
status: 400
|
|
873
|
-
});
|
|
1914
|
+
throw new LunoraError("BAD_REQUEST", `applyCdc: changes[${String(index)}].doc must be an object`);
|
|
874
1915
|
}
|
|
875
1916
|
const document = rawDocument;
|
|
876
1917
|
if (document !== void 0 && typeof document["_id"] === "string" && document["_id"] !== id) {
|
|
877
|
-
throw
|
|
878
|
-
code: "BAD_REQUEST",
|
|
879
|
-
name: "LunoraError",
|
|
880
|
-
status: 400
|
|
881
|
-
});
|
|
1918
|
+
throw new LunoraError("BAD_REQUEST", `applyCdc: changes[${String(index)}].doc._id must match the entry id`);
|
|
882
1919
|
}
|
|
883
1920
|
return {
|
|
884
1921
|
doc: document,
|
|
@@ -898,13 +1935,7 @@ const parseCdcSyncArgs = (args) => {
|
|
|
898
1935
|
};
|
|
899
1936
|
return { limit: toCount(args["limit"]), sinceSeq: toCount(args["sinceSeq"]) ?? 0 };
|
|
900
1937
|
};
|
|
901
|
-
const
|
|
902
|
-
const headers = { "content-type": "application/json" };
|
|
903
|
-
if (bookmark) {
|
|
904
|
-
headers["x-d1-bookmark"] = bookmark;
|
|
905
|
-
}
|
|
906
|
-
return Response.json(body, { headers, status });
|
|
907
|
-
};
|
|
1938
|
+
const bookmarkHeaders = (bookmark) => bookmark ? { "x-d1-bookmark": bookmark } : void 0;
|
|
908
1939
|
const parseIdentityHeader = (raw) => {
|
|
909
1940
|
if (!raw) {
|
|
910
1941
|
return void 0;
|
|
@@ -918,6 +1949,13 @@ const parseIdentityHeader = (raw) => {
|
|
|
918
1949
|
}
|
|
919
1950
|
return void 0;
|
|
920
1951
|
};
|
|
1952
|
+
const parseClientSeqHeader = (raw) => {
|
|
1953
|
+
if (!raw) {
|
|
1954
|
+
return void 0;
|
|
1955
|
+
}
|
|
1956
|
+
const seq = Number(raw);
|
|
1957
|
+
return Number.isInteger(seq) && seq > 0 ? seq : void 0;
|
|
1958
|
+
};
|
|
921
1959
|
const tablesFromDeps = (deps) => {
|
|
922
1960
|
const tables = /* @__PURE__ */ new Set();
|
|
923
1961
|
for (const dep of deps) {
|
|
@@ -971,16 +2009,6 @@ const extractBearerToken = (authorization) => {
|
|
|
971
2009
|
const value = rest.join(" ").trim();
|
|
972
2010
|
return value.length > 0 ? value : void 0;
|
|
973
2011
|
};
|
|
974
|
-
const constantTimeEqual = (a, b) => {
|
|
975
|
-
const max = Math.max(a.length, b.length);
|
|
976
|
-
let diff = a.length ^ b.length;
|
|
977
|
-
for (let index = 0; index < max; index += 1) {
|
|
978
|
-
const charA = index < a.length ? a.charCodeAt(index) : 0;
|
|
979
|
-
const charB = index < b.length ? b.charCodeAt(index) : 0;
|
|
980
|
-
diff |= charA ^ charB;
|
|
981
|
-
}
|
|
982
|
-
return diff === 0;
|
|
983
|
-
};
|
|
984
2012
|
class ShardDO {
|
|
985
2013
|
/**
|
|
986
2014
|
* Per-socket cap on concurrent stream iterators. Each in-flight stream
|
|
@@ -1002,6 +2030,28 @@ class ShardDO {
|
|
|
1002
2030
|
* failure stays unlikely.
|
|
1003
2031
|
*/
|
|
1004
2032
|
static MAX_SUBSCRIPTIONS_PER_SOCKET = 32;
|
|
2033
|
+
/**
|
|
2034
|
+
* Poll interval (ms) for `.global()`-table shapes. A global table lives in
|
|
2035
|
+
* D1 with no per-DO op-log, so its shapes can't be poke-live; the DO re-reads
|
|
2036
|
+
* each subscribed global shape's membership from D1 on an alarm every
|
|
2037
|
+
* `GLOBAL_SHAPE_POLL_INTERVAL_MS` and pokes only the diff. This is the
|
|
2038
|
+
* latency floor for a global-shape update — deliberately coarse (seconds, not
|
|
2039
|
+
* the sub-millisecond poke-live path) since the D1 read fans out per tick.
|
|
2040
|
+
*/
|
|
2041
|
+
static GLOBAL_SHAPE_POLL_INTERVAL_MS = 2e3;
|
|
2042
|
+
/**
|
|
2043
|
+
* Upper bound on a `.global()`-shape's materialized membership. Each global
|
|
2044
|
+
* shape keeps its ENTIRE current membership as a per-socket snapshot
|
|
2045
|
+
* (`Map<rowKey, hash>`) so the poll loop can diff it; that snapshot — and the
|
|
2046
|
+
* read buffer feeding it — scale with the membership size, multiplied by every
|
|
2047
|
+
* subscribed socket. An unbounded membership (a global table with no narrowing
|
|
2048
|
+
* shape predicate or RLS read scope) would grow them without limit and evict
|
|
2049
|
+
* the DO. A shape whose membership exceeds this cap is failed closed (left
|
|
2050
|
+
* empty, logged) rather than retained — the developer must narrow it. Sized
|
|
2051
|
+
* well above any reasonable per-identity replicated set so legitimate shapes
|
|
2052
|
+
* never trip it.
|
|
2053
|
+
*/
|
|
2054
|
+
static GLOBAL_SHAPE_MAX_ROWS = 5e4;
|
|
1005
2055
|
/**
|
|
1006
2056
|
* Per-socket whisper-topic cap. Topic membership rides the same hibernation
|
|
1007
2057
|
* attachment as `subs`, so bound it for the same reason — a runaway
|
|
@@ -1093,6 +2143,8 @@ class ShardDO {
|
|
|
1093
2143
|
* cleared in the `finally` block of `fetch` like the other per-request fields.
|
|
1094
2144
|
*/
|
|
1095
2145
|
currentRequestIp;
|
|
2146
|
+
/** W3C `traceparent` of the inbound RPC; forwarded onto outbound container fetches. */
|
|
2147
|
+
currentRequestTraceparent;
|
|
1096
2148
|
/**
|
|
1097
2149
|
* Client-issued idempotency key for the in-flight mutation, forwarded via the
|
|
1098
2150
|
* `x-lunora-mutation-id` header. When set, the dispatch path dedups the call
|
|
@@ -1103,6 +2155,39 @@ class ShardDO {
|
|
|
1103
2155
|
* `finally` block.
|
|
1104
2156
|
*/
|
|
1105
2157
|
currentRequestMutationId;
|
|
2158
|
+
/**
|
|
2159
|
+
* Stable per-device client id for the in-flight custom-mutator push,
|
|
2160
|
+
* forwarded via the `x-lunora-client-id` header. Backs the
|
|
2161
|
+
* `__client_watermark` table: the dispatch path classifies the paired
|
|
2162
|
+
* `currentRequestClientSeq` against the stored high-watermark (already
|
|
2163
|
+
* processed / next / out-of-order gap). Absent on legacy mutations and
|
|
2164
|
+
* queries (those keep the `__idempotency` path). Cleared in `fetch`'s
|
|
2165
|
+
* `finally`.
|
|
2166
|
+
*/
|
|
2167
|
+
currentRequestClientId;
|
|
2168
|
+
/**
|
|
2169
|
+
* Monotonic per-client mutation sequence for the in-flight custom-mutator
|
|
2170
|
+
* push, forwarded via the `x-lunora-client-seq` header (numeric). Paired
|
|
2171
|
+
* with `currentRequestClientId` to drive the watermark classification.
|
|
2172
|
+
* `undefined` when absent or non-numeric.
|
|
2173
|
+
*/
|
|
2174
|
+
currentRequestClientSeq;
|
|
2175
|
+
/**
|
|
2176
|
+
* The in-flight push's custom-mutator classification, stashed by `fetch`
|
|
2177
|
+
* before `handleRpc` so the in-transaction bookkeeping ({@link
|
|
2178
|
+
* ShardDO.commitMutationBookkeeping}) can advance the `__client_watermark` for
|
|
2179
|
+
* a `"next"` push inside the same commit as the writes. `undefined` for an
|
|
2180
|
+
* ordinary mutation / non-mutator push. Cleared per request.
|
|
2181
|
+
*/
|
|
2182
|
+
currentMutatorClass;
|
|
2183
|
+
/**
|
|
2184
|
+
* Set once a mutation's replay bookkeeping (idempotency row + watermark
|
|
2185
|
+
* advance) has committed INSIDE the handler transaction, so the post-dispatch
|
|
2186
|
+
* path skips the now-redundant best-effort writes. Cleared per request; stays
|
|
2187
|
+
* `false` for actions/queries (no transaction wrapper) so their dispatch-level
|
|
2188
|
+
* idempotency persist still runs.
|
|
2189
|
+
*/
|
|
2190
|
+
mutationBookkeepingCommitted = false;
|
|
1106
2191
|
/**
|
|
1107
2192
|
* Wall-clock millis of the last `__idempotency` GC sweep on this warm
|
|
1108
2193
|
* instance. The dedup write throttles `trimIdempotent` to at most once an
|
|
@@ -1133,6 +2218,18 @@ class ShardDO {
|
|
|
1133
2218
|
* the common read-only path allocates nothing.
|
|
1134
2219
|
*/
|
|
1135
2220
|
pendingChangedTables = void 0;
|
|
2221
|
+
/**
|
|
2222
|
+
* Coalesced set of tables awaiting a subscription-refresh pass, merged
|
|
2223
|
+
* across every {@link ShardDO.flushChangedTables} call that lands while a
|
|
2224
|
+
* pass is already draining. The single drain loop
|
|
2225
|
+
* ({@link ShardDO.drainSubscriptionRefreshes}) owns this set; a burst of N
|
|
2226
|
+
* writes to the same table therefore collapses into one (or two) refresh
|
|
2227
|
+
* passes instead of N, so each affected subscription's handler re-runs once
|
|
2228
|
+
* per burst rather than once per write. `undefined` when nothing is pending.
|
|
2229
|
+
*/
|
|
2230
|
+
pendingRefreshTables = void 0;
|
|
2231
|
+
/** True while {@link ShardDO.drainSubscriptionRefreshes} is running; the single-waiter gate that coalesces concurrent flushes. */
|
|
2232
|
+
refreshInFlight = false;
|
|
1136
2233
|
/**
|
|
1137
2234
|
* Last pushed result per `(socket, subId)`, keyed by socket. Lets
|
|
1138
2235
|
* `refreshSubscriptions` skip re-running queries whose tables were
|
|
@@ -1141,6 +2238,39 @@ class ShardDO {
|
|
|
1141
2238
|
* memo simply forces one re-run and (at most) one redundant push.
|
|
1142
2239
|
*/
|
|
1143
2240
|
subMemos = /* @__PURE__ */ new WeakMap();
|
|
2241
|
+
/**
|
|
2242
|
+
* Per-socket poke baseline for shape subscriptions: maps each shape's
|
|
2243
|
+
* subscription id to the `__cdc_log` cursor it has been poked through.
|
|
2244
|
+
* `pokeShapeSubscribers` reads each op page since this cursor and advances
|
|
2245
|
+
* it to the flush watermark. In-memory only (like {@link ShardDO.subMemos});
|
|
2246
|
+
* a cold memo on a reconnected/hibernated socket re-seeds from the client's
|
|
2247
|
+
* `sinceCheckpoint`.
|
|
2248
|
+
*/
|
|
2249
|
+
shapeMemos = /* @__PURE__ */ new WeakMap();
|
|
2250
|
+
/**
|
|
2251
|
+
* Per-socket, per-**global**-shape membership snapshot: maps each global
|
|
2252
|
+
* shape's subscription id to a `key → projected-value JSON` map of the rows
|
|
2253
|
+
* last poked to that socket. A `.global()` (D1) table has no op-log to diff,
|
|
2254
|
+
* so {@link ShardDO.refreshGlobalShape} re-reads the full membership on each
|
|
2255
|
+
* alarm tick and diffs it against this snapshot to compute the poke. Parallel
|
|
2256
|
+
* to {@link ShardDO.shapeMemos} (the cursor baseline for poke-live shapes).
|
|
2257
|
+
*
|
|
2258
|
+
* This is a hot in-memory **cache** over the durable `__global_shape_snapshot`
|
|
2259
|
+
* table (keyed by the socket's `connectionId` + subId): a hibernation eviction
|
|
2260
|
+
* clears the WeakMap, so on the next alarm wake {@link ShardDO.readGlobalSnapshot}
|
|
2261
|
+
* misses and re-loads the baseline from SQLite — without it, the diff would run
|
|
2262
|
+
* against an empty baseline and a row deleted from D1 while the DO slept would
|
|
2263
|
+
* never be poked as a `delete`, lingering on the client as a phantom row.
|
|
2264
|
+
*/
|
|
2265
|
+
globalShapeSnapshots = /* @__PURE__ */ new WeakMap();
|
|
2266
|
+
/**
|
|
2267
|
+
* Whether a global-shape poll alarm is currently armed. Guards
|
|
2268
|
+
* {@link ShardDO.scheduleGlobalPoll} from re-arming on every seed; reset in
|
|
2269
|
+
* {@link ShardDO.alarm} before the poll so a still-subscribed shape re-arms.
|
|
2270
|
+
*/
|
|
2271
|
+
globalPollScheduled = false;
|
|
2272
|
+
/** Monotonic per-DO poke id source; correlates a poke's `pokeStart`/`pokePart`/`pokeEnd` frames. */
|
|
2273
|
+
pokeSequence = 0;
|
|
1144
2274
|
/** Per-socket whisper-rate token bucket (see {@link ShardDO.WHISPER_RATE_BURST}). In-memory; resets on hibernation. */
|
|
1145
2275
|
whisperBuckets = /* @__PURE__ */ new WeakMap();
|
|
1146
2276
|
/**
|
|
@@ -1158,6 +2288,35 @@ class ShardDO {
|
|
|
1158
2288
|
* (durable aggregation would be a separate, heavier feature).
|
|
1159
2289
|
*/
|
|
1160
2290
|
metrics = { errors: 0, requests: 0, sinceMs: Date.now() };
|
|
2291
|
+
/**
|
|
2292
|
+
* Running fan-out cost counters surfaced by the
|
|
2293
|
+
* `__lunora_admin__:getFanoutMetrics` RPC — one tally for the reactive
|
|
2294
|
+
* shape-poke path (`pokeShapeSubscribers`) and one for the whisper broadcast
|
|
2295
|
+
* path (`broadcastWhisper`). Each pass records the sockets it iterated (the
|
|
2296
|
+
* O(subscribers) cost) and delivered to. In-memory and reset on
|
|
2297
|
+
* hibernation/restart, sharing `metrics.sinceMs` as the "since this instance
|
|
2298
|
+
* woke" epoch. This is the observability half of plan 075's auto-elastic
|
|
2299
|
+
* relay tier (Phase 1): measure the per-flush fan-out cost so the promotion
|
|
2300
|
+
* threshold is grounded in real numbers, with no behavior change.
|
|
2301
|
+
*/
|
|
2302
|
+
fanout = { shapePoke: createFanoutCounters(), whisper: createFanoutCounters() };
|
|
2303
|
+
/**
|
|
2304
|
+
* The runtime's Durable Object namespace binding name (e.g. `"SHARD"`),
|
|
2305
|
+
* forwarded as `x-lunora-shard-binding` on every request so a DO can address
|
|
2306
|
+
* its siblings (`this.env[binding].getByName(...)`) for the relay hub. Absent
|
|
2307
|
+
* in single-DO mode / the unit harness — when absent, the relay tier is inert
|
|
2308
|
+
* and whispers stay shard-local (no behavior change). In-memory; re-learned per
|
|
2309
|
+
* request.
|
|
2310
|
+
*/
|
|
2311
|
+
shardBinding;
|
|
2312
|
+
/**
|
|
2313
|
+
* The auto-elastic fan-out relay collaborator (plan 075) — an {@link OwnerRelay}
|
|
2314
|
+
* or {@link RelayMember} chosen ONCE from this DO's name, or `undefined` for an
|
|
2315
|
+
* unnamed (single-DO) DO where the relay tier is inert. All relay state +
|
|
2316
|
+
* transport lives on it, reached back through the {@link RelayHost} adapter, so
|
|
2317
|
+
* owner-only state can never sit next to relay-only state on this class.
|
|
2318
|
+
*/
|
|
2319
|
+
relay;
|
|
1161
2320
|
/**
|
|
1162
2321
|
* Declared indexes (`table:index`) a query has exercised since this instance
|
|
1163
2322
|
* woke, stamped by `getCtxDbIndexUseHook`. In-memory and reset on
|
|
@@ -1244,6 +2403,29 @@ class ShardDO {
|
|
|
1244
2403
|
if (options.reactiveCache) {
|
|
1245
2404
|
this.reactiveCache = new ReactiveCache(options.reactiveCache);
|
|
1246
2405
|
}
|
|
2406
|
+
const host = {
|
|
2407
|
+
buildShapeDiff: (resolved, fromCursor, toCursor) => this.buildShapeDiff(this.sql, resolved, fromCursor, toCursor),
|
|
2408
|
+
computeOpLogShapeSeed: (shape, resolved) => this.computeOpLogShapeSeed(shape, resolved),
|
|
2409
|
+
currentCdcEpoch: () => this.currentCdcEpoch(),
|
|
2410
|
+
deliverWhisperLocal: (topic, frame, exclude) => this.deliverWhisperLocal(topic, frame, exclude),
|
|
2411
|
+
doName: () => this.state.id?.name,
|
|
2412
|
+
env: () => this.env,
|
|
2413
|
+
getWebSockets: () => this.state.getWebSockets(),
|
|
2414
|
+
maskMetadata: () => this.maskMetadata(),
|
|
2415
|
+
nextPokeId: () => {
|
|
2416
|
+
this.pokeSequence += 1;
|
|
2417
|
+
return `poke-${String(this.pokeSequence)}`;
|
|
2418
|
+
},
|
|
2419
|
+
readAttachment: (ws) => this.readAttachment(ws),
|
|
2420
|
+
recordShapePokeFanout: (iterated, delivered, elapsedMs) => {
|
|
2421
|
+
this.fanout.shapePoke = recordFanoutPass(this.fanout.shapePoke, iterated, delivered, elapsedMs);
|
|
2422
|
+
},
|
|
2423
|
+
resolveShape: (name, args, identity) => this.resolveShape(name, args, identity),
|
|
2424
|
+
rlsMetadata: () => this.rlsMetadata(),
|
|
2425
|
+
shardBinding: () => this.shardBinding,
|
|
2426
|
+
sql: () => this.sql
|
|
2427
|
+
};
|
|
2428
|
+
this.relay = createRelayLink(host);
|
|
1247
2429
|
this.armWebSocketKeepalive();
|
|
1248
2430
|
}
|
|
1249
2431
|
/** SQLite handle scoped to this Durable Object. */
|
|
@@ -1253,8 +2435,10 @@ class ShardDO {
|
|
|
1253
2435
|
*/
|
|
1254
2436
|
async fetch(request) {
|
|
1255
2437
|
const url = new URL(request.url);
|
|
1256
|
-
|
|
1257
|
-
|
|
2438
|
+
this.shardBinding = request.headers.get("x-lunora-shard-binding") ?? this.shardBinding;
|
|
2439
|
+
const early = await this.routeNonRpc(url, request);
|
|
2440
|
+
if (early !== void 0) {
|
|
2441
|
+
return early;
|
|
1258
2442
|
}
|
|
1259
2443
|
if (url.pathname !== "/rpc" || request.method !== "POST") {
|
|
1260
2444
|
return new Response("Not found", { status: 404 });
|
|
@@ -1272,9 +2456,14 @@ class ShardDO {
|
|
|
1272
2456
|
this.currentResponseBookmark = void 0;
|
|
1273
2457
|
this.currentRequestUserId = request.headers.get("x-lunora-userid") ?? void 0;
|
|
1274
2458
|
this.currentRequestMutationId = request.headers.get("x-lunora-mutation-id") ?? void 0;
|
|
2459
|
+
this.currentRequestClientId = request.headers.get("x-lunora-client-id") ?? void 0;
|
|
2460
|
+
this.currentRequestClientSeq = parseClientSeqHeader(request.headers.get("x-lunora-client-seq"));
|
|
2461
|
+
this.currentMutatorClass = void 0;
|
|
2462
|
+
this.mutationBookkeepingCommitted = false;
|
|
1275
2463
|
this.currentRequestIdentity = parseIdentityHeader(request.headers.get("x-lunora-identity"));
|
|
1276
2464
|
this.currentRequestIp = request.headers.get("x-lunora-client-ip") ?? void 0;
|
|
1277
2465
|
this.currentRequestSystem = request.headers.get("x-lunora-system") === "1";
|
|
2466
|
+
this.currentRequestTraceparent = request.headers.get("traceparent") ?? void 0;
|
|
1278
2467
|
this.currentRequestReadTables = void 0;
|
|
1279
2468
|
this.currentRequestCacheHit = void 0;
|
|
1280
2469
|
this.metrics.requests += 1;
|
|
@@ -1285,22 +2474,30 @@ class ShardDO {
|
|
|
1285
2474
|
try {
|
|
1286
2475
|
if (payload.functionPath.startsWith(RELATION_FUNCTION_PREFIX)) {
|
|
1287
2476
|
const value = await this.runRelationFanoutRead(payload.functionPath, payload.args ?? {});
|
|
1288
|
-
return jsonResponse(value, 200, this.currentResponseBookmark);
|
|
2477
|
+
return jsonResponse(value, 200, bookmarkHeaders(this.currentResponseBookmark));
|
|
2478
|
+
}
|
|
2479
|
+
const mutatorClass = this.isCustomMutator(payload.functionPath) ? this.classifyClientMutation() : void 0;
|
|
2480
|
+
this.currentMutatorClass = mutatorClass;
|
|
2481
|
+
const watermarkShortCircuit = this.rejectNonNextMutation(payload.functionPath, mutatorClass, dispatchStartedAt);
|
|
2482
|
+
if (watermarkShortCircuit !== void 0) {
|
|
2483
|
+
return watermarkShortCircuit;
|
|
1289
2484
|
}
|
|
1290
2485
|
const cached = this.readIdempotentResult(this.currentRequestMutationId);
|
|
1291
2486
|
if (cached !== void 0) {
|
|
1292
|
-
this.
|
|
1293
|
-
|
|
2487
|
+
return this.respondFromIdempotencyCache(payload.functionPath, dispatchStartedAt, mutatorClass, cached.value);
|
|
2488
|
+
}
|
|
2489
|
+
const result = await this.handleRpc(payload.functionPath, decodeWire(payload.args ?? {}));
|
|
2490
|
+
this.recordPostDispatchBookkeeping(result, mutatorClass);
|
|
2491
|
+
if (mutatorClass?.kind === "next") {
|
|
2492
|
+
this.advanceClientMutationWatermark();
|
|
1294
2493
|
}
|
|
1295
|
-
const result = await this.handleRpc(payload.functionPath, payload.args ?? {});
|
|
1296
|
-
this.persistIdempotentResult(result);
|
|
1297
2494
|
const durationMs = Date.now() - dispatchStartedAt;
|
|
1298
2495
|
this.recordFunctionCall(payload.functionPath, durationMs, void 0, this.currentScannedTables, this.currentIndexHits);
|
|
1299
2496
|
this.flushStmtSamples();
|
|
1300
2497
|
const tablesWritten = [...this.pendingChangedTables ?? []];
|
|
1301
2498
|
this.recordRequestLog(payload.functionPath, payload.args ?? {}, durationMs, "ok", tablesWritten);
|
|
1302
2499
|
this.maybeWarnRootSize();
|
|
1303
|
-
const response =
|
|
2500
|
+
const response = this.buildDispatchResponse(mutatorClass, encodeWire(result));
|
|
1304
2501
|
await this.flushChangedTables();
|
|
1305
2502
|
return response;
|
|
1306
2503
|
} catch (error) {
|
|
@@ -1320,15 +2517,22 @@ class ShardDO {
|
|
|
1320
2517
|
message,
|
|
1321
2518
|
timestamp: Date.now()
|
|
1322
2519
|
});
|
|
2520
|
+
this.recordChangedTable(REQUEST_LOG_TABLE);
|
|
2521
|
+
await this.flushChangedTables();
|
|
1323
2522
|
return this.errorToResponse(error);
|
|
1324
2523
|
} finally {
|
|
1325
2524
|
this.currentRequestBookmark = void 0;
|
|
1326
2525
|
this.currentResponseBookmark = void 0;
|
|
1327
2526
|
this.currentRequestUserId = void 0;
|
|
1328
2527
|
this.currentRequestMutationId = void 0;
|
|
2528
|
+
this.currentRequestClientId = void 0;
|
|
2529
|
+
this.currentRequestClientSeq = void 0;
|
|
2530
|
+
this.currentMutatorClass = void 0;
|
|
2531
|
+
this.mutationBookkeepingCommitted = false;
|
|
1329
2532
|
this.currentRequestIdentity = void 0;
|
|
1330
2533
|
this.currentRequestIp = void 0;
|
|
1331
2534
|
this.currentRequestSystem = false;
|
|
2535
|
+
this.currentRequestTraceparent = void 0;
|
|
1332
2536
|
this.currentScannedTables = void 0;
|
|
1333
2537
|
this.currentIndexHits = void 0;
|
|
1334
2538
|
this.currentRequestReadTables = void 0;
|
|
@@ -1363,6 +2567,9 @@ class ShardDO {
|
|
|
1363
2567
|
if (envelope.context !== void 0) {
|
|
1364
2568
|
attachment.context = envelope.context;
|
|
1365
2569
|
}
|
|
2570
|
+
if (envelope.clientId !== void 0) {
|
|
2571
|
+
attachment.clientId = envelope.clientId;
|
|
2572
|
+
}
|
|
1366
2573
|
attachment.connected = true;
|
|
1367
2574
|
try {
|
|
1368
2575
|
ws.serializeAttachment?.(attachment);
|
|
@@ -1378,7 +2585,24 @@ class ShardDO {
|
|
|
1378
2585
|
ws.send(JSON.stringify({ id: envelope.id, message: "admin subscription requires admin authorization", type: "error" }));
|
|
1379
2586
|
return;
|
|
1380
2587
|
}
|
|
1381
|
-
|
|
2588
|
+
let query;
|
|
2589
|
+
try {
|
|
2590
|
+
query = envelope.query.args === void 0 ? envelope.query : { ...envelope.query, args: decodeWire(envelope.query.args) };
|
|
2591
|
+
} catch {
|
|
2592
|
+
try {
|
|
2593
|
+
ws.send(
|
|
2594
|
+
JSON.stringify({
|
|
2595
|
+
code: "BAD_SUBSCRIPTION_ARGS",
|
|
2596
|
+
error: { code: "BAD_SUBSCRIPTION_ARGS", message: "subscription args failed wire decoding" },
|
|
2597
|
+
id: envelope.id,
|
|
2598
|
+
type: "error"
|
|
2599
|
+
})
|
|
2600
|
+
);
|
|
2601
|
+
} catch {
|
|
2602
|
+
}
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
const status = this.subscribe(ws, envelope.id, query);
|
|
1382
2606
|
if (status !== "ok") {
|
|
1383
2607
|
const code = status === "too_many" ? "TOO_MANY_SUBSCRIPTIONS" : "SUBSCRIPTION_PERSIST_FAILED";
|
|
1384
2608
|
const errorMessage = status === "too_many" ? `subscription cap of ${String(ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET)} reached on this socket` : "failed to persist subscription attachment";
|
|
@@ -1389,9 +2613,30 @@ class ShardDO {
|
|
|
1389
2613
|
return;
|
|
1390
2614
|
}
|
|
1391
2615
|
ws.send(JSON.stringify({ id: envelope.id, type: "ack" }));
|
|
1392
|
-
if (functionPath) {
|
|
1393
|
-
await this.seedSubscription(ws, envelope.id,
|
|
1394
|
-
}
|
|
2616
|
+
if (functionPath) {
|
|
2617
|
+
await this.seedSubscription(ws, envelope.id, query, functionPath, isAdmin);
|
|
2618
|
+
}
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
if (envelope.type === "shape_subscribe" && envelope.shape) {
|
|
2622
|
+
let shapeArgs;
|
|
2623
|
+
try {
|
|
2624
|
+
shapeArgs = envelope.shape.args === void 0 ? void 0 : decodeWire(envelope.shape.args);
|
|
2625
|
+
} catch {
|
|
2626
|
+
this.sendShapeSubscribeError(ws, envelope.id, "BAD_SUBSCRIPTION_ARGS", "shape args failed wire decoding");
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
await this.handleShapeSubscribe(ws, envelope.id, {
|
|
2630
|
+
args: shapeArgs,
|
|
2631
|
+
name: envelope.shape.name,
|
|
2632
|
+
sinceEpoch: envelope.sinceEpoch,
|
|
2633
|
+
sinceSeq: envelope.sinceCheckpoint
|
|
2634
|
+
});
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
if (envelope.type === "shape_unsubscribe") {
|
|
2638
|
+
this.shapeUnsubscribe(ws, envelope.id);
|
|
2639
|
+
ws.send(JSON.stringify({ id: envelope.id, type: "ack" }));
|
|
1395
2640
|
return;
|
|
1396
2641
|
}
|
|
1397
2642
|
if (envelope.type === "stream" && envelope.query?.functionPath) {
|
|
@@ -1399,19 +2644,23 @@ class ShardDO {
|
|
|
1399
2644
|
ws.send(JSON.stringify({ id: envelope.id, message: "streams must be public", type: "error" }));
|
|
1400
2645
|
return;
|
|
1401
2646
|
}
|
|
1402
|
-
this.handleStream(ws, envelope.id, envelope.query.functionPath, envelope.query.args ?? {}).catch(() => {
|
|
2647
|
+
this.handleStream(ws, envelope.id, envelope.query.functionPath, decodeWire(envelope.query.args ?? {})).catch(() => {
|
|
1403
2648
|
});
|
|
1404
2649
|
return;
|
|
1405
2650
|
}
|
|
1406
2651
|
if (envelope.type === "whisper_subscribe" || envelope.type === "whisper_unsubscribe") {
|
|
1407
2652
|
if (typeof envelope.topic === "string" && envelope.topic.length > 0) {
|
|
1408
|
-
|
|
2653
|
+
const join = envelope.type === "whisper_subscribe";
|
|
2654
|
+
this.setWhisperMembership(ws, envelope.topic, join);
|
|
2655
|
+
if (join) {
|
|
2656
|
+
await this.relay?.announce();
|
|
2657
|
+
}
|
|
1409
2658
|
}
|
|
1410
2659
|
return;
|
|
1411
2660
|
}
|
|
1412
2661
|
if (envelope.type === "whisper") {
|
|
1413
2662
|
if (typeof envelope.topic === "string" && envelope.topic.length > 0) {
|
|
1414
|
-
this.broadcastWhisper(ws, envelope.topic, envelope.data);
|
|
2663
|
+
await this.broadcastWhisper(ws, envelope.topic, envelope.data);
|
|
1415
2664
|
}
|
|
1416
2665
|
return;
|
|
1417
2666
|
}
|
|
@@ -1444,12 +2693,49 @@ class ShardDO {
|
|
|
1444
2693
|
this.streamCancellers.delete(ws);
|
|
1445
2694
|
}
|
|
1446
2695
|
this.subMemos.delete(ws);
|
|
2696
|
+
this.shapeMemos.delete(ws);
|
|
2697
|
+
this.globalShapeSnapshots.delete(ws);
|
|
2698
|
+
if (attachment.connectionId !== void 0) {
|
|
2699
|
+
try {
|
|
2700
|
+
deleteGlobalShapeSnapshotsForConnection(this.sql, attachment.connectionId);
|
|
2701
|
+
} catch {
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
1447
2704
|
ws.serializeAttachment?.(void 0);
|
|
2705
|
+
await this.relay?.announceDrain(ws);
|
|
1448
2706
|
}
|
|
1449
2707
|
/** Hibernation API: invoked on socket error. */
|
|
1450
2708
|
// 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
2709
|
webSocketError(_ws, _error) {
|
|
1452
2710
|
}
|
|
2711
|
+
/**
|
|
2712
|
+
* Durable Object alarm handler — the heartbeat for `.global()`-table shapes.
|
|
2713
|
+
* The runtime wakes this when the poll alarm armed by `scheduleGlobalPoll`
|
|
2714
|
+
* fires; it refreshes every subscribed global shape (diff-poke from the global
|
|
2715
|
+
* backend) and re-arms while any remain. With no global subscribers left, the
|
|
2716
|
+
* alarm is not re-armed and the DO goes idle. A base-only / global-free DO
|
|
2717
|
+
* never arms it, so this stays dormant there.
|
|
2718
|
+
*/
|
|
2719
|
+
async alarm() {
|
|
2720
|
+
this.globalPollScheduled = false;
|
|
2721
|
+
let remaining;
|
|
2722
|
+
try {
|
|
2723
|
+
remaining = await this.pollGlobalShapes();
|
|
2724
|
+
} catch (error) {
|
|
2725
|
+
this.recordShapeError("shape:poll", error);
|
|
2726
|
+
remaining = 1;
|
|
2727
|
+
}
|
|
2728
|
+
try {
|
|
2729
|
+
remaining += await this.pollExternalSources();
|
|
2730
|
+
} catch (error) {
|
|
2731
|
+
this.recordShapeError("source:poll", error);
|
|
2732
|
+
remaining += 1;
|
|
2733
|
+
}
|
|
2734
|
+
await this.flushChangedTables();
|
|
2735
|
+
if (remaining > 0) {
|
|
2736
|
+
await this.scheduleGlobalPoll();
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
1453
2739
|
/**
|
|
1454
2740
|
* The registered function paths to dispatch when a socket connects/disconnects.
|
|
1455
2741
|
* Base default is empty; the codegen subclass overrides it to return the
|
|
@@ -1505,9 +2791,7 @@ class ShardDO {
|
|
|
1505
2791
|
*/
|
|
1506
2792
|
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with a schema-aware reader that uses `this`
|
|
1507
2793
|
runRelationFanoutRead(_functionPath, _args) {
|
|
1508
|
-
throw
|
|
1509
|
-
code: "NOT_IMPLEMENTED",
|
|
1510
|
-
name: "LunoraError",
|
|
2794
|
+
throw new LunoraError("NOT_IMPLEMENTED", "__lunora_relation__: no schema bound — the base ShardDO cannot serve cross-shard relation reads", {
|
|
1511
2795
|
status: 500
|
|
1512
2796
|
});
|
|
1513
2797
|
}
|
|
@@ -1634,34 +2918,20 @@ class ShardDO {
|
|
|
1634
2918
|
*/
|
|
1635
2919
|
async runInTransaction(handler) {
|
|
1636
2920
|
if (this.transactionDepth > 0) {
|
|
1637
|
-
throw
|
|
1638
|
-
code: "NESTED_TRANSACTION",
|
|
1639
|
-
name: "LunoraError",
|
|
1640
|
-
status: 500
|
|
1641
|
-
});
|
|
2921
|
+
throw new LunoraError("NESTED_TRANSACTION", "nested transactions are not supported in SQLite-in-DO", { status: 500 });
|
|
1642
2922
|
}
|
|
1643
2923
|
const sqlHandle = this.state.storage.sql;
|
|
1644
2924
|
if (!sqlHandle || typeof sqlHandle.exec !== "function") {
|
|
1645
|
-
throw
|
|
1646
|
-
code: "SQL_UNAVAILABLE",
|
|
1647
|
-
name: "LunoraError",
|
|
1648
|
-
status: 500
|
|
1649
|
-
});
|
|
2925
|
+
throw new LunoraError("SQL_UNAVAILABLE", "storage.sql is not available on this ShardDO state", { status: 500 });
|
|
1650
2926
|
}
|
|
1651
|
-
const
|
|
2927
|
+
const transactionalStorage = this.state.storage;
|
|
1652
2928
|
const run = async () => {
|
|
1653
2929
|
this.transactionDepth = 1;
|
|
1654
|
-
sqlExec("BEGIN");
|
|
1655
2930
|
try {
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
return value;
|
|
1659
|
-
} catch (error) {
|
|
1660
|
-
try {
|
|
1661
|
-
sqlExec("ROLLBACK");
|
|
1662
|
-
} catch {
|
|
2931
|
+
if (typeof transactionalStorage?.transaction === "function") {
|
|
2932
|
+
return await transactionalStorage.transaction(async () => handler());
|
|
1663
2933
|
}
|
|
1664
|
-
|
|
2934
|
+
return await handler();
|
|
1665
2935
|
} finally {
|
|
1666
2936
|
this.transactionDepth = 0;
|
|
1667
2937
|
}
|
|
@@ -1705,6 +2975,14 @@ class ShardDO {
|
|
|
1705
2975
|
getCurrentIp() {
|
|
1706
2976
|
return this.currentRequestIp;
|
|
1707
2977
|
}
|
|
2978
|
+
/**
|
|
2979
|
+
* W3C `traceparent` of the inbound RPC (forwarded by the runtime), or
|
|
2980
|
+
* `undefined`. `buildCtx` passes it to `createContainerContext` so outbound
|
|
2981
|
+
* container fetches carry it and the container's spans join the same trace.
|
|
2982
|
+
*/
|
|
2983
|
+
getCurrentTraceparent() {
|
|
2984
|
+
return this.currentRequestTraceparent;
|
|
2985
|
+
}
|
|
1708
2986
|
/**
|
|
1709
2987
|
* Identity claims (email, name, roles, …) forwarded by the runtime's
|
|
1710
2988
|
* `resolveIdentity` hook. Returns `undefined` for anonymous requests
|
|
@@ -1732,9 +3010,7 @@ class ShardDO {
|
|
|
1732
3010
|
*/
|
|
1733
3011
|
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to reach the generated migration registry
|
|
1734
3012
|
runShardDataMigration(args) {
|
|
1735
|
-
return Promise.reject(
|
|
1736
|
-
Object.assign(new Error(`data migration "${args.id}" is not registered`), { code: "MIGRATION_NOT_FOUND", name: "LunoraError", status: 404 })
|
|
1737
|
-
);
|
|
3013
|
+
return Promise.reject(new LunoraError("MIGRATION_NOT_FOUND", `data migration "${args.id}" is not registered`, { status: 404 }));
|
|
1738
3014
|
}
|
|
1739
3015
|
/**
|
|
1740
3016
|
* Lazily provision the shard's physical tables before an operation that
|
|
@@ -1859,7 +3135,58 @@ class ShardDO {
|
|
|
1859
3135
|
*/
|
|
1860
3136
|
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the statically-discovered feature flags
|
|
1861
3137
|
studioFeatures() {
|
|
1862
|
-
return {
|
|
3138
|
+
return {
|
|
3139
|
+
analytics: false,
|
|
3140
|
+
auth: false,
|
|
3141
|
+
containers: false,
|
|
3142
|
+
flags: false,
|
|
3143
|
+
kv: false,
|
|
3144
|
+
mail: false,
|
|
3145
|
+
payments: false,
|
|
3146
|
+
queues: false,
|
|
3147
|
+
scheduler: false,
|
|
3148
|
+
storage: false,
|
|
3149
|
+
vectors: false,
|
|
3150
|
+
workflows: false
|
|
3151
|
+
};
|
|
3152
|
+
}
|
|
3153
|
+
/**
|
|
3154
|
+
* Evaluate every statically-discovered feature flag under `context` for the
|
|
3155
|
+
* studio's read-only Flags page (`__lunora_admin__:listFlags`). The flag keys
|
|
3156
|
+
* + value types are discovered by `@lunora/codegen` from the app's
|
|
3157
|
+
* `ctx.flags.<type>("key", …)` reads and evaluated through the configured
|
|
3158
|
+
* `@lunora/flags` provider — work only the codegen subclass can do, so it
|
|
3159
|
+
* overrides this. The base class wires no provider and reports
|
|
3160
|
+
* `configured: false` with zero flags (an un-generated `ShardDO` has none).
|
|
3161
|
+
*/
|
|
3162
|
+
// 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
|
|
3163
|
+
evaluateFlags(_context) {
|
|
3164
|
+
return Promise.resolve({ configured: false, flags: [] });
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* Serve one reserved {@link FLAGS_FUNCTION_PREFIX} live flag read for the
|
|
3168
|
+
* React client's `useFlag`/`useFlags`. `functionPath` carries the flag key +
|
|
3169
|
+
* type and `args` the per-subscriber targeting context; the codegen subclass
|
|
3170
|
+
* overrides this to evaluate the flag through the app's `@lunora/flags`
|
|
3171
|
+
* provider under `identity` and return the resolved value. The base class
|
|
3172
|
+
* wires no provider, so it returns `null` — `resolveReactiveOutcome` reads
|
|
3173
|
+
* `null` as "nothing to deliver" and the subscriber keeps its default.
|
|
3174
|
+
*/
|
|
3175
|
+
// 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
|
|
3176
|
+
runFlagSubscriptionRead(_functionPath, _arguments, _identity) {
|
|
3177
|
+
return Promise.resolve(null);
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3180
|
+
* The Cloudflare Queues declared by this app, surfaced via
|
|
3181
|
+
* `__lunora_admin__:listQueues` for the studio's Queues page. Queues are NOT
|
|
3182
|
+
* Durable Objects and hold no shard state, so this is pure declaration
|
|
3183
|
+
* metadata statically discovered by `@lunora/codegen` from `lunora/queues.ts`
|
|
3184
|
+
* and emitted into the generated subclass, which overrides this. The base
|
|
3185
|
+
* class can't see the user's project, so it reports none.
|
|
3186
|
+
*/
|
|
3187
|
+
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the statically-discovered queue metadata
|
|
3188
|
+
queuesMetadata() {
|
|
3189
|
+
return { queues: [] };
|
|
1863
3190
|
}
|
|
1864
3191
|
/**
|
|
1865
3192
|
* The Cloudflare Workflows declared by this app, surfaced via
|
|
@@ -1941,7 +3268,7 @@ class ShardDO {
|
|
|
1941
3268
|
*/
|
|
1942
3269
|
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to build a schema-aware writer
|
|
1943
3270
|
runShardWrite(args) {
|
|
1944
|
-
return Promise.reject(
|
|
3271
|
+
return Promise.reject(new LunoraError("UNKNOWN_TABLE", `unknown table: ${args.table}`, { status: 404 }));
|
|
1945
3272
|
}
|
|
1946
3273
|
/**
|
|
1947
3274
|
* Delete one row by primary key THROUGH the schema-aware writer — the
|
|
@@ -1956,7 +3283,7 @@ class ShardDO {
|
|
|
1956
3283
|
*/
|
|
1957
3284
|
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to build a schema-aware writer
|
|
1958
3285
|
deleteRowThroughWriter(_table, _id) {
|
|
1959
|
-
return Promise.reject(
|
|
3286
|
+
return Promise.reject(new LunoraError("UNKNOWN_TABLE", `unknown table: ${_table}`, { status: 404 }));
|
|
1960
3287
|
}
|
|
1961
3288
|
/**
|
|
1962
3289
|
* Bulk-delete the rows of `table` matching the active `filters`/`search`
|
|
@@ -1998,9 +3325,7 @@ class ShardDO {
|
|
|
1998
3325
|
*/
|
|
1999
3326
|
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to build a schema-aware writer
|
|
2000
3327
|
runShardRankBefore(_args) {
|
|
2001
|
-
return Promise.reject(
|
|
2002
|
-
Object.assign(new Error("rankBefore is not implemented in base ShardDO"), { code: "NOT_IMPLEMENTED", name: "LunoraError", status: 500 })
|
|
2003
|
-
);
|
|
3328
|
+
return Promise.reject(new LunoraError("NOT_IMPLEMENTED", "rankBefore is not implemented in base ShardDO", { status: 500 }));
|
|
2004
3329
|
}
|
|
2005
3330
|
/**
|
|
2006
3331
|
* Page this shard's local ranked slice under `index`, each row tagged with
|
|
@@ -2014,9 +3339,7 @@ class ShardDO {
|
|
|
2014
3339
|
*/
|
|
2015
3340
|
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to build a schema-aware writer
|
|
2016
3341
|
runShardRankPage(_args) {
|
|
2017
|
-
return Promise.reject(
|
|
2018
|
-
Object.assign(new Error("rankPage is not implemented in base ShardDO"), { code: "NOT_IMPLEMENTED", name: "LunoraError", status: 500 })
|
|
2019
|
-
);
|
|
3342
|
+
return Promise.reject(new LunoraError("NOT_IMPLEMENTED", "rankPage is not implemented in base ShardDO", { status: 500 }));
|
|
2020
3343
|
}
|
|
2021
3344
|
/**
|
|
2022
3345
|
* Page this shard's change-data-capture log past `sinceSeq`. Read-only and
|
|
@@ -2124,13 +3447,15 @@ class ShardDO {
|
|
|
2124
3447
|
* unless the request carried an `x-lunora-mutation-id` header (queries and
|
|
2125
3448
|
* legacy clients leave `currentRequestMutationId` undefined).
|
|
2126
3449
|
*
|
|
2127
|
-
*
|
|
2128
|
-
*
|
|
2129
|
-
*
|
|
2130
|
-
*
|
|
2131
|
-
*
|
|
2132
|
-
*
|
|
2133
|
-
*
|
|
3450
|
+
* For a mutation this runs INSIDE the handler's transaction (via
|
|
3451
|
+
* {@link ShardDO.commitMutationBookkeeping}, which `handleRpc` invokes before
|
|
3452
|
+
* the transaction commits), so the dedup row is durable iff the writes are —
|
|
3453
|
+
* closing the crash window where the writes commit but the replay guard does
|
|
3454
|
+
* not. Actions/queries aren't transaction-wrapped, so they call this on the
|
|
3455
|
+
* live dispatch path right after the handler resolves, through the same
|
|
3456
|
+
* `this.sql` handle. `INSERT OR IGNORE` keeps a concurrent double-dispatch (or
|
|
3457
|
+
* the now-skipped post-dispatch call) of the same id idempotent. Also runs the
|
|
3458
|
+
* throttled dedup-table GC.
|
|
2134
3459
|
*/
|
|
2135
3460
|
persistIdempotentResult(result) {
|
|
2136
3461
|
if (this.currentRequestMutationId === void 0) {
|
|
@@ -2138,7 +3463,7 @@ class ShardDO {
|
|
|
2138
3463
|
}
|
|
2139
3464
|
const now = Date.now();
|
|
2140
3465
|
try {
|
|
2141
|
-
writeIdempotent(this.sql, this.currentRequestUserId ?? "", this.currentRequestMutationId, JSON.stringify(result)
|
|
3466
|
+
writeIdempotent(this.sql, this.currentRequestUserId ?? "", this.currentRequestMutationId, JSON.stringify(encodeWire(result)), now);
|
|
2142
3467
|
if (now - this.lastIdempotencyTrimAt > IDEMPOTENCY_GC_INTERVAL_MS) {
|
|
2143
3468
|
trimIdempotent(this.sql, now - IDEMPOTENCY_RETENTION_MS);
|
|
2144
3469
|
this.lastIdempotencyTrimAt = now;
|
|
@@ -2146,6 +3471,195 @@ class ShardDO {
|
|
|
2146
3471
|
} catch {
|
|
2147
3472
|
}
|
|
2148
3473
|
}
|
|
3474
|
+
/**
|
|
3475
|
+
* Whether `functionPath` names a registered custom mutator (a `defineMutator`
|
|
3476
|
+
* declaration) rather than an ordinary `mutation`. The base class knows of no
|
|
3477
|
+
* mutators, so the default is `false`; the codegen-generated subclass
|
|
3478
|
+
* overrides this to consult its mutator registry. When `true` (and the push
|
|
3479
|
+
* carries a `clientId`/`clientSeq`), the dispatch path applies the
|
|
3480
|
+
* `__client_watermark` ordering semantics instead of the legacy idempotency
|
|
3481
|
+
* dedup.
|
|
3482
|
+
*/
|
|
3483
|
+
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this to consult its mutator registry
|
|
3484
|
+
isCustomMutator(_functionPath) {
|
|
3485
|
+
return false;
|
|
3486
|
+
}
|
|
3487
|
+
/**
|
|
3488
|
+
* Classify an in-flight custom-mutator push against the shard's stored
|
|
3489
|
+
* high-watermark for `currentRequestClientId`. The watermark is the highest
|
|
3490
|
+
* per-client sequence the DO has applied, so the push is exactly one of:
|
|
3491
|
+
*
|
|
3492
|
+
* - `"already"` — `seq <= watermark`: a replay of a confirmed (or in-flight,
|
|
3493
|
+
* now-resent) mutation. The handler must NOT re-run; the dispatch path returns
|
|
3494
|
+
* a benign ack so the client drops the pending overlay.
|
|
3495
|
+
* - `"next"` — `seq == watermark + 1`: the next mutation in order. Run the
|
|
3496
|
+
* authoritative `server` impl and advance the watermark in the same commit.
|
|
3497
|
+
* - `"gap"` — `seq > watermark + 1`: an out-of-order arrival (an earlier push
|
|
3498
|
+
* was lost). Halt: the client must resend from `watermark + 1`.
|
|
3499
|
+
*
|
|
3500
|
+
* Returns `undefined` when the push is not a watermarked custom mutator
|
|
3501
|
+
* (missing client id/seq, or a stub `sql` handle without the table) so the
|
|
3502
|
+
* caller falls through to the legacy idempotency path.
|
|
3503
|
+
*/
|
|
3504
|
+
classifyClientMutation() {
|
|
3505
|
+
const clientId = this.currentRequestClientId;
|
|
3506
|
+
const seq = this.currentRequestClientSeq;
|
|
3507
|
+
if (clientId === void 0 || seq === void 0) {
|
|
3508
|
+
return void 0;
|
|
3509
|
+
}
|
|
3510
|
+
const identity = this.currentRequestUserId ?? "";
|
|
3511
|
+
let watermark;
|
|
3512
|
+
try {
|
|
3513
|
+
watermark = readClientWatermark(this.sql, identity, clientId);
|
|
3514
|
+
} catch {
|
|
3515
|
+
try {
|
|
3516
|
+
migrateClientWatermark(this.sql);
|
|
3517
|
+
watermark = readClientWatermark(this.sql, identity, clientId);
|
|
3518
|
+
} catch {
|
|
3519
|
+
return void 0;
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
const expected = watermark + 1;
|
|
3523
|
+
if (seq <= watermark) {
|
|
3524
|
+
return { expected, kind: "already" };
|
|
3525
|
+
}
|
|
3526
|
+
return seq === expected ? { expected, kind: "next" } : { expected, kind: "gap" };
|
|
3527
|
+
}
|
|
3528
|
+
/**
|
|
3529
|
+
* Terminal response for a watermarked custom-mutator push that is NOT the
|
|
3530
|
+
* next-in-order mutation — an idempotent replay ack (`"already"`) or an
|
|
3531
|
+
* out-of-order halt (`"gap"`). Returns `undefined` for an ordinary mutation
|
|
3532
|
+
* or a `"next"` push so `fetch` proceeds to the authoritative handler. Records
|
|
3533
|
+
* the function call on the short-circuit paths so metrics stay attributed.
|
|
3534
|
+
*/
|
|
3535
|
+
rejectNonNextMutation(functionPath, mutatorClass, dispatchStartedAt) {
|
|
3536
|
+
if (mutatorClass === void 0 || mutatorClass.kind === "next") {
|
|
3537
|
+
return void 0;
|
|
3538
|
+
}
|
|
3539
|
+
this.recordFunctionCall(functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
|
|
3540
|
+
if (mutatorClass.kind === "already") {
|
|
3541
|
+
return jsonResponse({ lastMutationId: mutatorClass.expected - 1, result: null }, 200, bookmarkHeaders(this.currentResponseBookmark));
|
|
3542
|
+
}
|
|
3543
|
+
return jsonResponse(
|
|
3544
|
+
{
|
|
3545
|
+
error: {
|
|
3546
|
+
code: "OUT_OF_ORDER",
|
|
3547
|
+
expectedMutationId: mutatorClass.expected,
|
|
3548
|
+
message: `out-of-order mutation; expected sequence ${String(mutatorClass.expected)}`
|
|
3549
|
+
}
|
|
3550
|
+
},
|
|
3551
|
+
409,
|
|
3552
|
+
bookmarkHeaders(this.currentResponseBookmark)
|
|
3553
|
+
);
|
|
3554
|
+
}
|
|
3555
|
+
/**
|
|
3556
|
+
* Respond to a dispatch that hit the `(identity, mutationId)` idempotency
|
|
3557
|
+
* cache. Records the (zero-work) function call, then: for a `"next"` custom
|
|
3558
|
+
* mutator whose handler already committed but whose watermark advance was
|
|
3559
|
+
* lost to a crash in between, re-advance and echo `lastMutationId` exactly as
|
|
3560
|
+
* the post-commit path does (otherwise the cached branch returns a bare
|
|
3561
|
+
* result with a stale watermark and the client reports every later seq as a
|
|
3562
|
+
* gap forever); for everything else, return the bare cached `{ result }`.
|
|
3563
|
+
*/
|
|
3564
|
+
respondFromIdempotencyCache(functionPath, dispatchStartedAt, mutatorClass, cachedValue) {
|
|
3565
|
+
this.recordFunctionCall(functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
|
|
3566
|
+
if (mutatorClass?.kind === "next") {
|
|
3567
|
+
this.advanceClientMutationWatermark();
|
|
3568
|
+
return this.buildDispatchResponse(mutatorClass, cachedValue);
|
|
3569
|
+
}
|
|
3570
|
+
const commitCursor = this.mutationCommitCursor();
|
|
3571
|
+
return jsonResponse(
|
|
3572
|
+
commitCursor === void 0 ? { result: cachedValue } : { commitCursor, result: cachedValue },
|
|
3573
|
+
200,
|
|
3574
|
+
bookmarkHeaders(this.currentResponseBookmark)
|
|
3575
|
+
);
|
|
3576
|
+
}
|
|
3577
|
+
/**
|
|
3578
|
+
* The CDC cursor a just-committed plain mutation landed at — the post-write
|
|
3579
|
+
* high-watermark, on the same scale as the `cursor` on `data`/`delta` frames.
|
|
3580
|
+
* The client drops a pending per-call optimistic overlay once it sees a frame
|
|
3581
|
+
* with `cursor >= commitCursor` (gapless reconciliation that keeps mutations
|
|
3582
|
+
* concurrent, without the serialized custom-mutator watermark). Scoped to a
|
|
3583
|
+
* mutation (carries an `x-lunora-mutation-id`) on a CDC-enabled shard;
|
|
3584
|
+
* `undefined` otherwise, leaving the wire byte-identical for queries/actions
|
|
3585
|
+
* and CDC-off shards.
|
|
3586
|
+
*/
|
|
3587
|
+
mutationCommitCursor() {
|
|
3588
|
+
return this.currentRequestMutationId === void 0 ? void 0 : this.currentCdcCursor();
|
|
3589
|
+
}
|
|
3590
|
+
/**
|
|
3591
|
+
* Build the success response for a dispatched RPC. A `"next"` custom-mutator
|
|
3592
|
+
* push echoes the applied `lastMutationId` so the client drops the pending
|
|
3593
|
+
* optimistic overlay as soon as the ack lands; a plain mutation echoes
|
|
3594
|
+
* `commitCursor` (see {@link mutationCommitCursor}); other calls return the
|
|
3595
|
+
* bare `{ result }` envelope unchanged.
|
|
3596
|
+
*/
|
|
3597
|
+
buildDispatchResponse(mutatorClass, result) {
|
|
3598
|
+
if (mutatorClass?.kind === "next") {
|
|
3599
|
+
return jsonResponse({ lastMutationId: this.currentRequestClientSeq, result }, 200, bookmarkHeaders(this.currentResponseBookmark));
|
|
3600
|
+
}
|
|
3601
|
+
const commitCursor = this.mutationCommitCursor();
|
|
3602
|
+
return jsonResponse(commitCursor === void 0 ? { result } : { commitCursor, result }, 200, bookmarkHeaders(this.currentResponseBookmark));
|
|
3603
|
+
}
|
|
3604
|
+
/**
|
|
3605
|
+
* Commit a mutation's replay bookkeeping — the `(identity, mutationId)`
|
|
3606
|
+
* idempotency dedup row and, for a `"next"` custom-mutator push, the
|
|
3607
|
+
* `__client_watermark` advance — INSIDE the handler's transaction. Called by
|
|
3608
|
+
* the generated `handleRpc` mutation branch after the user handler resolves
|
|
3609
|
+
* but before the transaction commits, so the writes, the dedup row, and the
|
|
3610
|
+
* watermark land in one atomic commit: a crash can't leave the writes durable
|
|
3611
|
+
* without the replay guard (which a re-dispatch would otherwise re-run) nor
|
|
3612
|
+
* without the watermark. Sets {@link ShardDO.mutationBookkeepingCommitted} so
|
|
3613
|
+
* `fetch` skips the redundant post-dispatch persist.
|
|
3614
|
+
*/
|
|
3615
|
+
commitMutationBookkeeping(result) {
|
|
3616
|
+
this.persistIdempotentResult(result);
|
|
3617
|
+
if (this.currentMutatorClass?.kind === "next") {
|
|
3618
|
+
this.advanceClientMutationWatermark({ strict: true });
|
|
3619
|
+
}
|
|
3620
|
+
this.mutationBookkeepingCommitted = true;
|
|
3621
|
+
}
|
|
3622
|
+
/**
|
|
3623
|
+
* Best-effort replay bookkeeping for the live dispatch path, run after
|
|
3624
|
+
* `handleRpc` returns. A generated mutation already committed it atomically
|
|
3625
|
+
* inside its transaction (via {@link ShardDO.commitMutationBookkeeping}, which
|
|
3626
|
+
* sets the flag), so this skips. Actions/queries aren't transaction-wrapped,
|
|
3627
|
+
* so they record their dedup row here (a no-op without an `x-lunora-mutation-id`),
|
|
3628
|
+
* and a `"next"` push advances its watermark (the gap self-heals on replay).
|
|
3629
|
+
*/
|
|
3630
|
+
recordPostDispatchBookkeeping(result, mutatorClass) {
|
|
3631
|
+
if (this.mutationBookkeepingCommitted) {
|
|
3632
|
+
return;
|
|
3633
|
+
}
|
|
3634
|
+
this.persistIdempotentResult(result);
|
|
3635
|
+
if (mutatorClass?.kind === "next") {
|
|
3636
|
+
this.advanceClientMutationWatermark();
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
/**
|
|
3640
|
+
* Advance the stored high-watermark for the in-flight custom mutator to
|
|
3641
|
+
* `currentRequestClientSeq` through the same `this.sql` handle. On the
|
|
3642
|
+
* transactional path ({@link ShardDO.commitMutationBookkeeping}, `strict`) it
|
|
3643
|
+
* runs inside the handler's commit, so the watermark is durable iff the writes
|
|
3644
|
+
* are; a failure rethrows to roll the mutation back. On the best-effort
|
|
3645
|
+
* cache-hit recovery path (`strict` omitted) a missing table is swallowed —
|
|
3646
|
+
* the replay re-runs and re-advances (the read side treats a missing row as
|
|
3647
|
+
* watermark 0), so the gap self-heals.
|
|
3648
|
+
*/
|
|
3649
|
+
advanceClientMutationWatermark(options) {
|
|
3650
|
+
const clientId = this.currentRequestClientId;
|
|
3651
|
+
const seq = this.currentRequestClientSeq;
|
|
3652
|
+
if (clientId === void 0 || seq === void 0) {
|
|
3653
|
+
return;
|
|
3654
|
+
}
|
|
3655
|
+
try {
|
|
3656
|
+
advanceClientWatermark(this.sql, this.currentRequestUserId ?? "", clientId, seq);
|
|
3657
|
+
} catch (error) {
|
|
3658
|
+
if (options?.strict) {
|
|
3659
|
+
throw error;
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
2149
3663
|
/**
|
|
2150
3664
|
* Replay a batch of CDC changes into this shard (point-in-time recovery).
|
|
2151
3665
|
* Schema-aware — it builds a `createShardCtxDb` writer — so the base class
|
|
@@ -2154,9 +3668,7 @@ class ShardDO {
|
|
|
2154
3668
|
*/
|
|
2155
3669
|
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to build a schema-aware writer
|
|
2156
3670
|
runShardApplyCdc(_args) {
|
|
2157
|
-
return Promise.reject(
|
|
2158
|
-
Object.assign(new Error("applyCdc is not implemented in base ShardDO"), { code: "NOT_IMPLEMENTED", name: "LunoraError", status: 500 })
|
|
2159
|
-
);
|
|
3671
|
+
return Promise.reject(new LunoraError("NOT_IMPLEMENTED", "applyCdc is not implemented in base ShardDO", { status: 500 }));
|
|
2160
3672
|
}
|
|
2161
3673
|
/**
|
|
2162
3674
|
* Register a subscription on the given socket. Stored via
|
|
@@ -2195,6 +3707,56 @@ class ShardDO {
|
|
|
2195
3707
|
}
|
|
2196
3708
|
this.subMemos.get(ws)?.delete(subId);
|
|
2197
3709
|
}
|
|
3710
|
+
/**
|
|
3711
|
+
* Register a live shape subscription on a socket — the partial-replication
|
|
3712
|
+
* parallel to {@link ShardDO.subscribe}. Stores the descriptor in the
|
|
3713
|
+
* attachment's `shapes` registry (created lazily) so it survives
|
|
3714
|
+
* hibernation, sharing the per-socket cap with `subs`. Returns a status the
|
|
3715
|
+
* caller surfaces as a structured error frame; never throws (a thrown
|
|
3716
|
+
* `webSocketMessage` is a fatal-channel error under the hibernation API).
|
|
3717
|
+
*/
|
|
3718
|
+
shapeSubscribe(ws, subId, shape) {
|
|
3719
|
+
const attachment = this.readAttachment(ws);
|
|
3720
|
+
const shapes = attachment.shapes ?? {};
|
|
3721
|
+
if (Object.keys(attachment.subs).length + Object.keys(shapes).length >= ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET) {
|
|
3722
|
+
return "too_many";
|
|
3723
|
+
}
|
|
3724
|
+
shapes[subId] = shape;
|
|
3725
|
+
attachment.shapes = shapes;
|
|
3726
|
+
try {
|
|
3727
|
+
ws.serializeAttachment?.(attachment);
|
|
3728
|
+
} catch {
|
|
3729
|
+
delete attachment.shapes[subId];
|
|
3730
|
+
return "serialize_failed";
|
|
3731
|
+
}
|
|
3732
|
+
return "ok";
|
|
3733
|
+
}
|
|
3734
|
+
/** Remove a shape subscription and its poke baseline. Mirrors {@link ShardDO.unsubscribe}'s rollback-on-serialize-failure contract. */
|
|
3735
|
+
shapeUnsubscribe(ws, subId) {
|
|
3736
|
+
const attachment = this.readAttachment(ws);
|
|
3737
|
+
const { shapes } = attachment;
|
|
3738
|
+
if (!shapes) {
|
|
3739
|
+
return;
|
|
3740
|
+
}
|
|
3741
|
+
const captured = shapes[subId];
|
|
3742
|
+
delete shapes[subId];
|
|
3743
|
+
try {
|
|
3744
|
+
ws.serializeAttachment?.(attachment);
|
|
3745
|
+
} catch {
|
|
3746
|
+
if (captured !== void 0) {
|
|
3747
|
+
shapes[subId] = captured;
|
|
3748
|
+
}
|
|
3749
|
+
return;
|
|
3750
|
+
}
|
|
3751
|
+
this.shapeMemos.get(ws)?.delete(subId);
|
|
3752
|
+
this.globalShapeSnapshots.get(ws)?.delete(subId);
|
|
3753
|
+
if (attachment.connectionId !== void 0) {
|
|
3754
|
+
try {
|
|
3755
|
+
deleteGlobalShapeSnapshot(this.sql, attachment.connectionId, subId);
|
|
3756
|
+
} catch {
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
2198
3760
|
/**
|
|
2199
3761
|
* Decide whether a single subscription is interested in a mutation
|
|
2200
3762
|
* delta. The default implementation checks the table name, then runs a
|
|
@@ -2243,10 +3805,7 @@ class ShardDO {
|
|
|
2243
3805
|
if (!this.matchesSubscription(query, delta)) {
|
|
2244
3806
|
continue;
|
|
2245
3807
|
}
|
|
2246
|
-
|
|
2247
|
-
ws.send(`{"type":"delta","id":${JSON.stringify(subId)},"delta":${deltaJson}}`);
|
|
2248
|
-
} catch {
|
|
2249
|
-
}
|
|
3808
|
+
trySendFrame(ws, `{"type":"delta","id":${JSON.stringify(subId)},"delta":${deltaJson}}`);
|
|
2250
3809
|
}
|
|
2251
3810
|
}
|
|
2252
3811
|
}
|
|
@@ -2269,6 +3828,91 @@ class ShardDO {
|
|
|
2269
3828
|
executeSubscription(_functionPath, _args, _identity) {
|
|
2270
3829
|
return Promise.resolve(null);
|
|
2271
3830
|
}
|
|
3831
|
+
/**
|
|
3832
|
+
* Resolve a named shape to its concrete query plan for `identity`. The base
|
|
3833
|
+
* class has no shape registry, so it returns `undefined` — partial
|
|
3834
|
+
* replication is disabled and a `shape_subscribe` is rejected. The
|
|
3835
|
+
* codegen-generated subclass overrides this to look the shape up in the
|
|
3836
|
+
* project's `defineShape` registry, evaluate its `where(ctx, args)` under the
|
|
3837
|
+
* subscriber's verified identity, and AND-compose it with the table's RLS
|
|
3838
|
+
* read base-where into {@link ResolvedShape.effectiveWhere}.
|
|
3839
|
+
*
|
|
3840
|
+
* `identity` is the socket's OWN verified identity (the same unforgeable
|
|
3841
|
+
* value `refreshSubscriptions` threads), passed by value so this never reads
|
|
3842
|
+
* the mutable per-request identity fields. Returning `undefined` is the
|
|
3843
|
+
* fail-closed signal — an unknown shape, or an RLS-required table with no
|
|
3844
|
+
* policy resolving for this identity, yields no subscription rather than
|
|
3845
|
+
* leaking rows.
|
|
3846
|
+
*/
|
|
3847
|
+
// 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
|
|
3848
|
+
resolveShape(_name, _args, _identity) {
|
|
3849
|
+
return void 0;
|
|
3850
|
+
}
|
|
3851
|
+
/**
|
|
3852
|
+
* The RLS-uniform gate (plan 075 Phase 3): whether a reactive shape may be
|
|
3853
|
+
* relay-multicast — i.e. one delta is correct for **every** subscriber. The owner
|
|
3854
|
+
* decides it (see {@link OwnerRelay.isShapeRelayUniform} — a static RLS read-policy
|
|
3855
|
+
* guard plus claim-exhaustive `Proxy` probes, fail-closed); this thin delegation
|
|
3856
|
+
* is the seam the gate test exercises. A non-owner DO is never relay-uniform.
|
|
3857
|
+
*/
|
|
3858
|
+
isShapeRelayUniform(name, args) {
|
|
3859
|
+
return this.relay?.isShapeRelayUniform(name, args) ?? false;
|
|
3860
|
+
}
|
|
3861
|
+
/**
|
|
3862
|
+
* Read the FULL current membership of a `.global()`-table shape from its D1
|
|
3863
|
+
* (or Hyperdrive) backend — the seed/poll source for the latency-tiered
|
|
3864
|
+
* global shape path. A `.global()` table lives in another store with no
|
|
3865
|
+
* per-DO op-log, so this is the only way to learn its rows from inside the
|
|
3866
|
+
* shard DO; {@link ShardDO.seedGlobalShape} calls it once on subscribe and
|
|
3867
|
+
* {@link ShardDO.refreshGlobalShape} on every alarm tick, diffing the result
|
|
3868
|
+
* against the per-socket snapshot to compute the poke.
|
|
3869
|
+
*
|
|
3870
|
+
* The base class has no global backend, so it returns `[]` (a base-only DO,
|
|
3871
|
+
* or a project with no global tables, never resolves a global shape). The
|
|
3872
|
+
* codegen subclass overrides it to drain `globalDb.findMany(table, { where:
|
|
3873
|
+
* effectiveWhere })` under the socket's verified `identity` — the same
|
|
3874
|
+
* unforgeable value `resolveShape` composed the RLS predicate with, so the
|
|
3875
|
+
* D1 read is identity-scoped exactly like the poke-live path.
|
|
3876
|
+
*/
|
|
3877
|
+
// 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
|
|
3878
|
+
readGlobalShapeRows(_resolved, _identity) {
|
|
3879
|
+
return Promise.resolve([]);
|
|
3880
|
+
}
|
|
3881
|
+
/**
|
|
3882
|
+
* Poll external-source (`.source(...)`) tables once (plan 077): materialize
|
|
3883
|
+
* each sourced table's freshly-pulled tenant slice into this DO's SQLite. The
|
|
3884
|
+
* base `ShardDO` has no sourced tables, so it returns `0` and the ingest tier
|
|
3885
|
+
* stays dormant — zero behavior change for every existing DO. The codegen
|
|
3886
|
+
* subclass overrides it to, per sourced table, build a `createShardCtxDb`
|
|
3887
|
+
* writer, read the tenant slice from Hyperdrive under this DO's shard key, and
|
|
3888
|
+
* run `runExternalSourceTick` (read local baseline → diff → apply via the
|
|
3889
|
+
* validated CDC writer). Returns the number of sourced tables still being
|
|
3890
|
+
* polled, so the shared poll alarm ({@link ShardDO.alarm}) re-arms while ingest
|
|
3891
|
+
* is active.
|
|
3892
|
+
*/
|
|
3893
|
+
// eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass implements the real Hyperdrive-backed poll
|
|
3894
|
+
pollExternalSources() {
|
|
3895
|
+
return Promise.resolve(0);
|
|
3896
|
+
}
|
|
3897
|
+
/**
|
|
3898
|
+
* Arm the shared poll alarm for external-source ingest (plan 077). The alarm is
|
|
3899
|
+
* shared with the global-shape poll tier; the codegen subclass calls this once
|
|
3900
|
+
* (on construction / first sourced write) so a sourced DO starts its ingest
|
|
3901
|
+
* loop, after which {@link ShardDO.alarm} re-arms itself while
|
|
3902
|
+
* {@link ShardDO.pollExternalSources} reports remaining work. Idempotent; a
|
|
3903
|
+
* no-op when the runtime exposes no `setAlarm` (unit harness).
|
|
3904
|
+
*/
|
|
3905
|
+
scheduleSourcePoll() {
|
|
3906
|
+
return this.scheduleGlobalPoll();
|
|
3907
|
+
}
|
|
3908
|
+
/** This DO's shard key (its DO name), or `__root__` for the single-DO default. The `tenantBy` mapper binds it into the source query. */
|
|
3909
|
+
currentShardKey() {
|
|
3910
|
+
return this.state.id?.name ?? ROOT_SHARD_NAME;
|
|
3911
|
+
}
|
|
3912
|
+
/** Record a contained external-source ingest failure (one sourced table's poll) into the log ring without aborting the others. */
|
|
3913
|
+
recordExternalSourceError(table, error) {
|
|
3914
|
+
this.recordShapeError(`source:${table}`, error);
|
|
3915
|
+
}
|
|
2272
3916
|
/**
|
|
2273
3917
|
* Look up a streaming-query function and return a thunk that produces the
|
|
2274
3918
|
* `AsyncIterable<unknown>` when handed an {@link AbortSignal}. The codegen
|
|
@@ -2308,8 +3952,17 @@ class ShardDO {
|
|
|
2308
3952
|
const tracker = createDependencyTracker();
|
|
2309
3953
|
this.currentTracker = tracker;
|
|
2310
3954
|
const hitsBefore = this.reactiveCache.stats().hits;
|
|
3955
|
+
const userId = this.getCurrentUserId();
|
|
3956
|
+
const claims = this.getCurrentIdentity();
|
|
3957
|
+
const identity = userId === void 0 && claims === void 0 ? (
|
|
3958
|
+
// eslint-disable-next-line unicorn/no-null -- reactiveCacheKey's identity arg is `null | string`; null is the documented "anonymous caller" discriminator
|
|
3959
|
+
null
|
|
3960
|
+
) : (
|
|
3961
|
+
// eslint-disable-next-line unicorn/no-null -- fold userId/claims into the discriminator; missing fields serialize as null so the shape stays canonical
|
|
3962
|
+
stableStringify({ claims: claims ?? null, userId: userId ?? null })
|
|
3963
|
+
);
|
|
2311
3964
|
try {
|
|
2312
|
-
const result = await this.reactiveCache.run(reactiveCacheKey(functionPath, args,
|
|
3965
|
+
const result = await this.reactiveCache.run(reactiveCacheKey(functionPath, args, identity), tracker.collect(), run);
|
|
2313
3966
|
this.currentRequestCacheHit = this.reactiveCache.stats().hits > hitsBefore;
|
|
2314
3967
|
this.currentRequestReadTables = tablesFromDeps(tracker.collect());
|
|
2315
3968
|
return result;
|
|
@@ -2667,20 +4320,67 @@ class ShardDO {
|
|
|
2667
4320
|
*/
|
|
2668
4321
|
// eslint-disable-next-line class-methods-use-this -- cohesive DO instance method (groups with the request handlers); kept non-static so subclasses can override the error mapping
|
|
2669
4322
|
errorToResponse(error) {
|
|
2670
|
-
|
|
2671
|
-
|
|
4323
|
+
const { body, redacted, status } = toErrorBody(error, { encodeData: encodeWire, fallbackCode: "RPC_FAILED", redactedMessage: "internal error" });
|
|
4324
|
+
if (redacted) {
|
|
4325
|
+
console.error("[@lunora/do] internal error:", error);
|
|
4326
|
+
}
|
|
4327
|
+
return jsonResponse({ error: body }, status);
|
|
4328
|
+
}
|
|
4329
|
+
/**
|
|
4330
|
+
* Batch dispatch (plan 088). Applies each `calls[]` entry through the SAME
|
|
4331
|
+
* single-call `/rpc` path (via a nested `this.fetch`), **sequentially**, so
|
|
4332
|
+
* the per-`(identity, mutationId)` idempotency dedup and the per-client
|
|
4333
|
+
* `__client_watermark` ordering are enforced entry-by-entry exactly as for an
|
|
4334
|
+
* individual call — no duplication of the dispatch core, no reordering.
|
|
4335
|
+
*
|
|
4336
|
+
* Failures are **per-slot, not fail-fast**: an entry that throws (or a
|
|
4337
|
+
* custom-mutator `OUT_OF_ORDER` gap) is captured in its own result slot and
|
|
4338
|
+
* later entries still run. Ordering is still safe — a later same-client
|
|
4339
|
+
* mutator after a gap re-classifies as a gap too (the watermark never
|
|
4340
|
+
* advanced), so it cannot apply out of order; unrelated entries/queries are
|
|
4341
|
+
* independent. The response is `{ results: [{ id, status, body }] }` in
|
|
4342
|
+
* request order; each `body` is the untouched single-call envelope (its
|
|
4343
|
+
* `result` already wire-encoded), so the client demuxes + decodes each
|
|
4344
|
+
* exactly as one call.
|
|
4345
|
+
*/
|
|
4346
|
+
async handleBatchRpc(request) {
|
|
4347
|
+
let payload;
|
|
4348
|
+
try {
|
|
4349
|
+
payload = await request.json();
|
|
4350
|
+
} catch {
|
|
4351
|
+
return jsonResponse({ error: { code: "BAD_REQUEST", message: "invalid JSON body" } }, 400);
|
|
4352
|
+
}
|
|
4353
|
+
if (!Array.isArray(payload.calls)) {
|
|
4354
|
+
return jsonResponse({ error: { code: "BAD_REQUEST", message: "batch `calls` must be an array" } }, 400);
|
|
2672
4355
|
}
|
|
2673
|
-
if (
|
|
2674
|
-
|
|
2675
|
-
|
|
4356
|
+
if (payload.calls.length > MAX_BATCH_ENTRIES) {
|
|
4357
|
+
return jsonResponse({ error: { code: "BAD_REQUEST", message: `batch exceeds the ${String(MAX_BATCH_ENTRIES)}-call limit` } }, 400);
|
|
4358
|
+
}
|
|
4359
|
+
const results = [];
|
|
4360
|
+
let latestBookmark;
|
|
4361
|
+
for (const raw of payload.calls) {
|
|
4362
|
+
const outcome = await this.dispatchBatchEntry(request, raw);
|
|
4363
|
+
if (outcome.bookmark !== void 0) {
|
|
4364
|
+
latestBookmark = outcome.bookmark;
|
|
4365
|
+
}
|
|
4366
|
+
results.push({ body: outcome.body, id: outcome.id, status: outcome.status });
|
|
2676
4367
|
}
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
4368
|
+
return jsonResponse({ results }, 200, bookmarkHeaders(latestBookmark));
|
|
4369
|
+
}
|
|
4370
|
+
/** Dispatch one batch entry through the single-call `/rpc` path and capture its envelope (plan 088). */
|
|
4371
|
+
async dispatchBatchEntry(batchRequest, entry) {
|
|
4372
|
+
try {
|
|
4373
|
+
const response = await this.fetch(buildBatchEntryRequest(batchRequest, entry));
|
|
4374
|
+
return { body: await response.json(), bookmark: response.headers.get("x-d1-bookmark") ?? void 0, id: entry.id, status: response.status };
|
|
4375
|
+
} catch (error) {
|
|
4376
|
+
const { body, status } = toErrorBody(error, { fallbackCode: "BATCH_ENTRY_FAILED" });
|
|
4377
|
+
return {
|
|
4378
|
+
body: { error: body },
|
|
4379
|
+
bookmark: void 0,
|
|
4380
|
+
id: entry?.id,
|
|
4381
|
+
status
|
|
4382
|
+
};
|
|
2681
4383
|
}
|
|
2682
|
-
const message = error instanceof Error ? error.message : "unknown error";
|
|
2683
|
-
return jsonResponse({ error: { code: "RPC_FAILED", message } }, 500);
|
|
2684
4384
|
}
|
|
2685
4385
|
/**
|
|
2686
4386
|
* Serve a reserved admin-introspection RPC (`__lunora_admin__:*`) for the
|
|
@@ -2796,12 +4496,27 @@ class ShardDO {
|
|
|
2796
4496
|
if (functionPath === ADMIN_FUNCTIONS.sendTestMail) {
|
|
2797
4497
|
return this.handleSendTestMail(args);
|
|
2798
4498
|
}
|
|
4499
|
+
if (functionPath === ADMIN_FUNCTIONS.recordQueueMessage) {
|
|
4500
|
+
return this.handleRecordQueueMessage(args);
|
|
4501
|
+
}
|
|
4502
|
+
if (functionPath === ADMIN_FUNCTIONS.clearQueueMessages) {
|
|
4503
|
+
return this.handleClearQueueMessages();
|
|
4504
|
+
}
|
|
4505
|
+
if (functionPath === ADMIN_FUNCTIONS.sendQueueMessage) {
|
|
4506
|
+
return this.handleSendQueueMessage(args);
|
|
4507
|
+
}
|
|
4508
|
+
if (functionPath === ADMIN_FUNCTIONS.replayQueueMessage) {
|
|
4509
|
+
return this.handleReplayQueueMessage(args);
|
|
4510
|
+
}
|
|
2799
4511
|
if (functionPath === ADMIN_FUNCTIONS.createWorkflowInstance) {
|
|
2800
4512
|
return this.handleCreateWorkflowInstance(args);
|
|
2801
4513
|
}
|
|
2802
4514
|
if (functionPath === ADMIN_FUNCTIONS.getWorkflowInstanceStatus) {
|
|
2803
4515
|
return this.handleGetWorkflowInstanceStatus(args);
|
|
2804
4516
|
}
|
|
4517
|
+
if (functionPath === ADMIN_FUNCTIONS.listFlags) {
|
|
4518
|
+
return this.handleListFlags(args);
|
|
4519
|
+
}
|
|
2805
4520
|
return this.handlePitrAdminOp(functionPath, args);
|
|
2806
4521
|
}
|
|
2807
4522
|
/**
|
|
@@ -2831,10 +4546,32 @@ class ShardDO {
|
|
|
2831
4546
|
* the panel renders it alongside `ctx.log` lines. Admin-gated by
|
|
2832
4547
|
* `handleAdminRpc`'s caller (the same `LUNORA_ADMIN_TOKEN` bearer as every
|
|
2833
4548
|
* other admin write).
|
|
2834
|
-
|
|
2835
|
-
|
|
4549
|
+
*
|
|
4550
|
+
* An `error`-level lifecycle event (a crash/`onError`, or a non-zero-exit
|
|
4551
|
+
* `stop`) is ALSO appended as an `error`-outcome row to the durable
|
|
4552
|
+
* `__lunora_reqlog__` — the same readout `getIssues` groups over — so a
|
|
4553
|
+
* crash-looping container folds into the Issues list right beside Worker
|
|
4554
|
+
* errors (they share the `fingerprintError` hash over `functionPath ::
|
|
4555
|
+
* bucket(message)`). The in-memory buffer stays the live Logs feed; the
|
|
4556
|
+
* durable row is what survives hibernation for triage.
|
|
4557
|
+
*/
|
|
4558
|
+
async handleRecordContainerEvent(args) {
|
|
2836
4559
|
const entry = parseRecordContainerEventArgs(args);
|
|
2837
4560
|
this.logs.push(entry);
|
|
4561
|
+
const crashed = entry.level === "error" || entry.exitCode !== void 0 && entry.exitCode !== 0;
|
|
4562
|
+
if (crashed) {
|
|
4563
|
+
const logEntry = {
|
|
4564
|
+
durationMs: 0,
|
|
4565
|
+
errorMessage: entry.message,
|
|
4566
|
+
functionPath: entry.functionPath,
|
|
4567
|
+
outcome: "error",
|
|
4568
|
+
shardKey: this.state.id?.name,
|
|
4569
|
+
ts: entry.timestamp
|
|
4570
|
+
};
|
|
4571
|
+
this.persistRequestLog(logEntry, this.requestLogConfig());
|
|
4572
|
+
this.recordChangedTable(REQUEST_LOG_TABLE);
|
|
4573
|
+
await this.flushChangedTables();
|
|
4574
|
+
}
|
|
2838
4575
|
return jsonResponse({ result: { recorded: true } }, 200);
|
|
2839
4576
|
}
|
|
2840
4577
|
/**
|
|
@@ -2872,15 +4609,11 @@ class ShardDO {
|
|
|
2872
4609
|
resolveWorkflowBinding(exportName) {
|
|
2873
4610
|
const metadata = this.workflowsMetadata().workflows.find((workflow) => workflow.exportName === exportName);
|
|
2874
4611
|
if (!metadata) {
|
|
2875
|
-
throw
|
|
4612
|
+
throw new LunoraError("BAD_REQUEST", `workflow "${exportName}" is not declared`);
|
|
2876
4613
|
}
|
|
2877
4614
|
const binding = this.env?.[metadata.binding];
|
|
2878
4615
|
if (typeof binding !== "object" || binding === null || typeof binding.create !== "function" || typeof binding.get !== "function") {
|
|
2879
|
-
throw
|
|
2880
|
-
code: "BAD_REQUEST",
|
|
2881
|
-
name: "LunoraError",
|
|
2882
|
-
status: 400
|
|
2883
|
-
});
|
|
4616
|
+
throw new LunoraError("BAD_REQUEST", `workflow binding "${metadata.binding}" is not available on this deployment`);
|
|
2884
4617
|
}
|
|
2885
4618
|
return binding;
|
|
2886
4619
|
}
|
|
@@ -2922,6 +4655,21 @@ class ShardDO {
|
|
|
2922
4655
|
return jsonResponse({ result }, 200);
|
|
2923
4656
|
}
|
|
2924
4657
|
/* eslint-enable no-secrets/no-secrets */
|
|
4658
|
+
/**
|
|
4659
|
+
* Serve `__lunora_admin__:listFlags` — the studio's read-only Flags page.
|
|
4660
|
+
* Evaluates every statically-discovered feature flag under an optional
|
|
4661
|
+
* `args.context` targeting context (the studio's editable context editor)
|
|
4662
|
+
* via the {@link evaluateFlags} hook, which the codegen subclass overrides
|
|
4663
|
+
* with live OpenFeature evaluation. Read-only: a flag lookup mutates no shard
|
|
4664
|
+
* state, so nothing is flushed or audited. Admin-gated by `handleAdminRpc`'s
|
|
4665
|
+
* caller.
|
|
4666
|
+
*/
|
|
4667
|
+
async handleListFlags(args) {
|
|
4668
|
+
const rawContext = args.context;
|
|
4669
|
+
const context = typeof rawContext === "object" && rawContext !== null && !Array.isArray(rawContext) ? rawContext : void 0;
|
|
4670
|
+
const result = await this.evaluateFlags(context);
|
|
4671
|
+
return jsonResponse({ result }, 200);
|
|
4672
|
+
}
|
|
2925
4673
|
/**
|
|
2926
4674
|
* Run `run()` with the per-request identity pinned to (`userId`, `identity`),
|
|
2927
4675
|
* then restore the prior values in a `finally` (even if `run()` throws), so the
|
|
@@ -2987,6 +4735,119 @@ class ShardDO {
|
|
|
2987
4735
|
const result = recordCapturedMail(this.state.storage.sql, input, Date.now());
|
|
2988
4736
|
return jsonResponse({ result }, 200);
|
|
2989
4737
|
}
|
|
4738
|
+
/**
|
|
4739
|
+
* Serve `__lunora_admin__:recordQueueMessage` — the capture sink the generated
|
|
4740
|
+
* worker `queue()` handler (via `@lunora/queue`'s `dispatchQueueBatch`) posts
|
|
4741
|
+
* every consumed message batch to. Records it into the reserved
|
|
4742
|
+
* `__lunora_queue_messages` table (bounded, auto-trimmed) so the studio Queues
|
|
4743
|
+
* panel shows one unified consumed-message log across every push consumer. Like
|
|
4744
|
+
* the mail catcher, the gate is the admin token alone — a token holder can
|
|
4745
|
+
* already mutate the shard, so a token-gated capture insert adds no privilege.
|
|
4746
|
+
*/
|
|
4747
|
+
handleRecordQueueMessage(args) {
|
|
4748
|
+
const messages = parseRecordQueueMessageArgs(args);
|
|
4749
|
+
const result = recordQueueMessages(this.state.storage.sql, messages, Date.now());
|
|
4750
|
+
return jsonResponse({ result }, 200);
|
|
4751
|
+
}
|
|
4752
|
+
/** Empty the dev queue consumed-message log (studio "clear log" action). Admin-gated by the caller. */
|
|
4753
|
+
handleClearQueueMessages() {
|
|
4754
|
+
const result = clearQueueMessages(this.state.storage.sql);
|
|
4755
|
+
return jsonResponse({ result }, 200);
|
|
4756
|
+
}
|
|
4757
|
+
/**
|
|
4758
|
+
* Serve `__lunora_admin__:sendQueueMessage` — the studio's "Send test message"
|
|
4759
|
+
* button. Resolves the declared queue's `QUEUE_*` producer binding and calls
|
|
4760
|
+
* `.send(body, { delaySeconds?, contentType? })`, or `.sendBatch(...)` when a
|
|
4761
|
+
* `batch` array is supplied. No SQLite write happens here (the message is only
|
|
4762
|
+
* captured once a consumer processes it), so this only records an audit entry.
|
|
4763
|
+
* Admin-gated by `handleAdminRpc`'s caller.
|
|
4764
|
+
*/
|
|
4765
|
+
async handleSendQueueMessage(args) {
|
|
4766
|
+
const parsed = parseSendQueueMessageArgs(args);
|
|
4767
|
+
const { binding } = this.resolveQueueBinding(parsed.exportName);
|
|
4768
|
+
let sent;
|
|
4769
|
+
if (parsed.batch === void 0) {
|
|
4770
|
+
await binding.send(parsed.body, { contentType: parsed.contentType, delaySeconds: parsed.delaySeconds });
|
|
4771
|
+
sent = 1;
|
|
4772
|
+
} else {
|
|
4773
|
+
await binding.sendBatch(
|
|
4774
|
+
parsed.batch.map((body) => {
|
|
4775
|
+
return { body, contentType: parsed.contentType, delaySeconds: parsed.delaySeconds };
|
|
4776
|
+
})
|
|
4777
|
+
);
|
|
4778
|
+
sent = parsed.batch.length;
|
|
4779
|
+
}
|
|
4780
|
+
this.recordAudit("sendQueueMessage", { detail: { count: sent, exportName: parsed.exportName } });
|
|
4781
|
+
return jsonResponse({ result: { sent } }, 200);
|
|
4782
|
+
}
|
|
4783
|
+
/**
|
|
4784
|
+
* Serve `__lunora_admin__:replayQueueMessage` — the studio's one-click replay /
|
|
4785
|
+
* DLQ redrive. Looks the captured row up by id, resolves the destination export
|
|
4786
|
+
* (explicit `target` → the parent queue when the message was captured off a
|
|
4787
|
+
* dead-letter queue → the queue it was consumed from), and re-enqueues the
|
|
4788
|
+
* stored body onto that producer. Records an audit entry; no SQLite write beyond
|
|
4789
|
+
* that (the replayed message is re-captured when a consumer processes it).
|
|
4790
|
+
* Admin-gated by `handleAdminRpc`'s caller.
|
|
4791
|
+
*/
|
|
4792
|
+
async handleReplayQueueMessage(args) {
|
|
4793
|
+
const parsed = parseReplayQueueMessageArgs(args);
|
|
4794
|
+
const row = readQueueMessageById(this.state.storage.sql, parsed.id);
|
|
4795
|
+
if (row === void 0) {
|
|
4796
|
+
throw new LunoraError("BAD_REQUEST", `replayQueueMessage: captured message "${parsed.id}" was not found`, { status: 404 });
|
|
4797
|
+
}
|
|
4798
|
+
if (isLossyBody(row.body)) {
|
|
4799
|
+
throw new LunoraError(
|
|
4800
|
+
"BAD_REQUEST",
|
|
4801
|
+
`replayQueueMessage: captured message "${parsed.id}" has a truncated or unserializable body and can't be replayed faithfully`,
|
|
4802
|
+
{ status: 422 }
|
|
4803
|
+
);
|
|
4804
|
+
}
|
|
4805
|
+
const target = parsed.target ?? this.resolveReplayTarget(row.queue) ?? row.exportName;
|
|
4806
|
+
if (typeof target !== "string" || target === "") {
|
|
4807
|
+
throw new LunoraError(
|
|
4808
|
+
"BAD_REQUEST",
|
|
4809
|
+
`replayQueueMessage: captured message "${parsed.id}" has no declared producer to replay onto (pass \`target\`)`
|
|
4810
|
+
);
|
|
4811
|
+
}
|
|
4812
|
+
const { binding } = this.resolveQueueBinding(target);
|
|
4813
|
+
await binding.send(row.body);
|
|
4814
|
+
this.recordAudit("replayQueueMessage", { detail: { messageId: row.messageId, target }, id: parsed.id });
|
|
4815
|
+
return jsonResponse({ result: { sent: 1, target } }, 200);
|
|
4816
|
+
}
|
|
4817
|
+
/**
|
|
4818
|
+
* Resolve a declared queue's runtime producer binding from this shard's `env`.
|
|
4819
|
+
* Looks the `exportName` up in {@link queuesMetadata} (the codegen subclass's
|
|
4820
|
+
* statically-discovered list) to find its generated `QUEUE_*` binding, then
|
|
4821
|
+
* reads `env[binding]` and validates it carries `send`/`sendBatch`. A bad export
|
|
4822
|
+
* name or a missing/malformed binding throws a 400 `LunoraError` so the studio
|
|
4823
|
+
* surfaces an actionable message. Mirrors {@link resolveWorkflowBinding}.
|
|
4824
|
+
*/
|
|
4825
|
+
resolveQueueBinding(exportName) {
|
|
4826
|
+
const metadata = this.queuesMetadata().queues.find((queue) => queue.exportName === exportName);
|
|
4827
|
+
if (!metadata) {
|
|
4828
|
+
throw new LunoraError("BAD_REQUEST", `queue "${exportName}" is not declared`);
|
|
4829
|
+
}
|
|
4830
|
+
const binding = this.env?.[metadata.binding];
|
|
4831
|
+
if (typeof binding !== "object" || binding === null || typeof binding.send !== "function") {
|
|
4832
|
+
throw new LunoraError("BAD_REQUEST", `queue binding "${metadata.binding}" is not available on this deployment`);
|
|
4833
|
+
}
|
|
4834
|
+
return { binding, metadata };
|
|
4835
|
+
}
|
|
4836
|
+
/**
|
|
4837
|
+
* Pick the replay destination export for a captured message's origin queue.
|
|
4838
|
+
* When the message was consumed off a queue that is another queue's dead-letter
|
|
4839
|
+
* queue, prefer that PARENT queue's producer (a DLQ usually has no producer of
|
|
4840
|
+
* its own) so replay redrives onto the original; otherwise re-enqueue onto the
|
|
4841
|
+
* queue the message came from. Returns `undefined` when neither is declared.
|
|
4842
|
+
*/
|
|
4843
|
+
resolveReplayTarget(queueName) {
|
|
4844
|
+
const { queues } = this.queuesMetadata();
|
|
4845
|
+
const parent = queues.find((queue) => queue.deadLetterQueue === queueName);
|
|
4846
|
+
if (parent !== void 0) {
|
|
4847
|
+
return parent.exportName;
|
|
4848
|
+
}
|
|
4849
|
+
return queues.find((queue) => queue.name === queueName)?.exportName;
|
|
4850
|
+
}
|
|
2990
4851
|
/**
|
|
2991
4852
|
* Append one durable audit entry for a state-changing admin op that just
|
|
2992
4853
|
* succeeded, folding the acting user (from `getCurrentUserId`) into `detail`.
|
|
@@ -3051,12 +4912,33 @@ class ShardDO {
|
|
|
3051
4912
|
ts: Date.now(),
|
|
3052
4913
|
userId: this.getCurrentUserId()
|
|
3053
4914
|
};
|
|
4915
|
+
this.persistRequestLog(entry, config);
|
|
4916
|
+
}
|
|
4917
|
+
/**
|
|
4918
|
+
* The single durable-row + Logpush write seam for `__lunora_reqlog__`. Both
|
|
4919
|
+
* the per-dispatch {@link recordRequestLog} and the container-crash path
|
|
4920
|
+
* ({@link handleRecordContainerEvent}) funnel through here so they can't
|
|
4921
|
+
* drift — before this was extracted the container writer persisted the row
|
|
4922
|
+
* but silently skipped the Logpush emit every other error got.
|
|
4923
|
+
*
|
|
4924
|
+
* Best-effort by contract: a SQL failure (e.g. a test double with no `sql`
|
|
4925
|
+
* handle) or a serialization hiccup in the emit must NEVER turn a served
|
|
4926
|
+
* request — or a container event push — into a failed one, so each half is
|
|
4927
|
+
* swallowed independently.
|
|
4928
|
+
*
|
|
4929
|
+
* Errors are always streamed to `console` (rare, high-value — they ride CF
|
|
4930
|
+
* Workers Logs at error level and the dev-server formats them in the
|
|
4931
|
+
* terminal), redacted in prod like any other event. The full per-dispatch
|
|
4932
|
+
* summary stream (successful OKs too) stays opt-in behind
|
|
4933
|
+
* `LUNORA_REQUEST_LOG_EMIT` so a hot shard doesn't emit a line per call.
|
|
4934
|
+
*/
|
|
4935
|
+
persistRequestLog(entry, config) {
|
|
3054
4936
|
const writeOptions = { captureRaw: config.captureRaw, retention: config.retention };
|
|
3055
4937
|
try {
|
|
3056
4938
|
appendRequestLogEntry(this.state.storage.sql, entry, writeOptions);
|
|
3057
4939
|
} catch {
|
|
3058
4940
|
}
|
|
3059
|
-
if (config.emit || outcome === "error") {
|
|
4941
|
+
if (config.emit || entry.outcome === "error") {
|
|
3060
4942
|
try {
|
|
3061
4943
|
emitRequestLogEvent(entry, writeOptions);
|
|
3062
4944
|
} catch {
|
|
@@ -3149,6 +5031,9 @@ class ShardDO {
|
|
|
3149
5031
|
if (functionPath === ADMIN_FUNCTIONS.getRequestLog) {
|
|
3150
5032
|
return this.readAdminRequestLog(sql, args);
|
|
3151
5033
|
}
|
|
5034
|
+
if (functionPath === ADMIN_FUNCTIONS.getIssues) {
|
|
5035
|
+
return this.readAdminIssues(sql, args);
|
|
5036
|
+
}
|
|
3152
5037
|
const durable = this.readAdminDurableSignal(functionPath, sql, args);
|
|
3153
5038
|
if (durable) {
|
|
3154
5039
|
return durable;
|
|
@@ -3274,6 +5159,9 @@ class ShardDO {
|
|
|
3274
5159
|
if (functionPath === ADMIN_FUNCTIONS.listSubscriptions) {
|
|
3275
5160
|
return this.collectSubscriptions();
|
|
3276
5161
|
}
|
|
5162
|
+
if (functionPath === ADMIN_FUNCTIONS.getFanoutMetrics) {
|
|
5163
|
+
return this.collectFanoutMetrics();
|
|
5164
|
+
}
|
|
3277
5165
|
if (functionPath === ADMIN_FUNCTIONS.getLogs) {
|
|
3278
5166
|
return { entries: this.logs.entries() };
|
|
3279
5167
|
}
|
|
@@ -3301,6 +5189,9 @@ class ShardDO {
|
|
|
3301
5189
|
if (functionPath === ADMIN_FUNCTIONS.listWorkflows) {
|
|
3302
5190
|
return this.workflowsMetadata();
|
|
3303
5191
|
}
|
|
5192
|
+
if (functionPath === ADMIN_FUNCTIONS.listQueues) {
|
|
5193
|
+
return this.queuesMetadata();
|
|
5194
|
+
}
|
|
3304
5195
|
return void 0;
|
|
3305
5196
|
}
|
|
3306
5197
|
/**
|
|
@@ -3313,6 +5204,32 @@ class ShardDO {
|
|
|
3313
5204
|
collectSubscriptions() {
|
|
3314
5205
|
return summarizeSubscriptions(this.state.getWebSockets().map((ws) => this.readAttachment(ws)));
|
|
3315
5206
|
}
|
|
5207
|
+
/**
|
|
5208
|
+
* Assemble the `__lunora_admin__:getFanoutMetrics` payload for the Studio
|
|
5209
|
+
* fan-out observability panel (plan 075 Phase 1). The point-in-time topic
|
|
5210
|
+
* subscriber counts are folded live from each socket's attachment via
|
|
5211
|
+
* {@link summarizeFanoutTopics}; the running per-path cost counters are the
|
|
5212
|
+
* in-memory {@link ShardDO.fanout} tallies, sharing `metrics.sinceMs` as the
|
|
5213
|
+
* "since this instance woke" epoch. Touches no SQLite and mutates no socket
|
|
5214
|
+
* state; it does call `relay.relayCount()`, which advances the promotion
|
|
5215
|
+
* latch — safe here because that transition is a pure, monotonic function of
|
|
5216
|
+
* the live socket count (DOs are single-threaded), so a metrics poll only ever
|
|
5217
|
+
* drives the latch to the same state the routing path would compute for the
|
|
5218
|
+
* same count, never a divergent one.
|
|
5219
|
+
*/
|
|
5220
|
+
collectFanoutMetrics() {
|
|
5221
|
+
const summary = summarizeFanoutTopics(this.state.getWebSockets().map((ws) => this.readAttachment(ws)));
|
|
5222
|
+
const relayCount = this.relay?.relayCount() ?? 0;
|
|
5223
|
+
return {
|
|
5224
|
+
...summary,
|
|
5225
|
+
maxRelays: this.relay?.maxRelays() ?? DEFAULT_MAX_RELAYS,
|
|
5226
|
+
promoted: relayCount > 0,
|
|
5227
|
+
relayCount,
|
|
5228
|
+
shapePoke: this.fanout.shapePoke,
|
|
5229
|
+
sinceMs: this.metrics.sinceMs,
|
|
5230
|
+
whisper: this.fanout.whisper
|
|
5231
|
+
};
|
|
5232
|
+
}
|
|
3316
5233
|
/** Resolve a `getAuditLog` admin read, parsing the optional `limit`/`sinceSeq` cursor args and ensuring the reserved table first. */
|
|
3317
5234
|
// eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers and future per-instance state
|
|
3318
5235
|
readAdminAuditLog(sql, args) {
|
|
@@ -3347,6 +5264,30 @@ class ShardDO {
|
|
|
3347
5264
|
};
|
|
3348
5265
|
return { result, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
|
|
3349
5266
|
}
|
|
5267
|
+
/**
|
|
5268
|
+
* Resolve a `getIssues` admin read: fold the recent `error`-outcome
|
|
5269
|
+
* request-log rows into grouped {@link readErrorIssues Issues} by fingerprint,
|
|
5270
|
+
* accepting the same optional correlation filters as `getRequestLog`
|
|
5271
|
+
* (function-path prefix, exact shardKey/userId) plus a `limit` on rows
|
|
5272
|
+
* scanned. This is a read over the bounded reqlog readout — no new store —
|
|
5273
|
+
* so a self-hosted worker gets grouped error triage for free. Carries the
|
|
5274
|
+
* {@link ADMIN_WILDCARD} like the other log reads so a live Issues
|
|
5275
|
+
* subscription re-runs on every write-flush (the per-socket JSON memo still
|
|
5276
|
+
* suppresses byte-identical pushes).
|
|
5277
|
+
*/
|
|
5278
|
+
// eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers
|
|
5279
|
+
readAdminIssues(sql, args) {
|
|
5280
|
+
ensureRequestLogTable(sql);
|
|
5281
|
+
const result = {
|
|
5282
|
+
issues: readErrorIssues(sql, {
|
|
5283
|
+
functionPathPrefix: typeof args["functionPathPrefix"] === "string" ? args["functionPathPrefix"] : void 0,
|
|
5284
|
+
limit: typeof args["limit"] === "number" ? args["limit"] : void 0,
|
|
5285
|
+
shardKey: typeof args["shardKey"] === "string" ? args["shardKey"] : void 0,
|
|
5286
|
+
userId: typeof args["userId"] === "string" ? args["userId"] : void 0
|
|
5287
|
+
})
|
|
5288
|
+
};
|
|
5289
|
+
return { result, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
|
|
5290
|
+
}
|
|
3350
5291
|
/**
|
|
3351
5292
|
* Resolve a `getAuthMetrics` admin read: the durable app-level auth
|
|
3352
5293
|
* attempt/failure counters + minute-bucketed history the studio SLO panel
|
|
@@ -3376,6 +5317,9 @@ class ShardDO {
|
|
|
3376
5317
|
if (functionPath === ADMIN_FUNCTIONS.getCapturedMail) {
|
|
3377
5318
|
return this.readAdminCapturedMail(sql, args);
|
|
3378
5319
|
}
|
|
5320
|
+
if (functionPath === ADMIN_FUNCTIONS.getQueueMessages) {
|
|
5321
|
+
return this.readAdminQueueMessages(sql, args);
|
|
5322
|
+
}
|
|
3379
5323
|
return void 0;
|
|
3380
5324
|
}
|
|
3381
5325
|
// eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers
|
|
@@ -3396,15 +5340,37 @@ class ShardDO {
|
|
|
3396
5340
|
* JSON memo still suppresses byte-identical pushes).
|
|
3397
5341
|
*/
|
|
3398
5342
|
// eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers
|
|
3399
|
-
readAdminCapturedMail(sql, args) {
|
|
5343
|
+
readAdminCapturedMail(sql, args) {
|
|
5344
|
+
const limit = typeof args["limit"] === "number" ? args["limit"] : void 0;
|
|
5345
|
+
let result;
|
|
5346
|
+
try {
|
|
5347
|
+
result = readCapturedMail(sql, { limit });
|
|
5348
|
+
} catch {
|
|
5349
|
+
result = { entries: [] };
|
|
5350
|
+
}
|
|
5351
|
+
return { result, tables: /* @__PURE__ */ new Set([MAIL_TABLE]) };
|
|
5352
|
+
}
|
|
5353
|
+
/**
|
|
5354
|
+
* Resolve a `getQueueMessages` admin read — the dev queue catcher's consumed
|
|
5355
|
+
* message log (`queue-catcher.ts`), newest-first, optionally filtered to one
|
|
5356
|
+
* queue. Best-effort: a SQL failure returns an empty log rather than throwing.
|
|
5357
|
+
* Reported against the {@link QUEUE_TABLE} so this read participates in
|
|
5358
|
+
* table-scoped subscription invalidation, but new captures arrive via the
|
|
5359
|
+
* worker→root-shard `recordQueueMessage` write, which (like the mail catcher)
|
|
5360
|
+
* inserts directly without a `flushChangedTables` — so the panel refreshes on
|
|
5361
|
+
* its poll (`useAutoRefresh`) rather than a live push.
|
|
5362
|
+
*/
|
|
5363
|
+
// eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers
|
|
5364
|
+
readAdminQueueMessages(sql, args) {
|
|
3400
5365
|
const limit = typeof args["limit"] === "number" ? args["limit"] : void 0;
|
|
5366
|
+
const queue = typeof args["queue"] === "string" ? args["queue"] : void 0;
|
|
3401
5367
|
let result;
|
|
3402
5368
|
try {
|
|
3403
|
-
result =
|
|
5369
|
+
result = readQueueMessages(sql, { limit, queue });
|
|
3404
5370
|
} catch {
|
|
3405
5371
|
result = { entries: [] };
|
|
3406
5372
|
}
|
|
3407
|
-
return { result, tables: /* @__PURE__ */ new Set([
|
|
5373
|
+
return { result, tables: /* @__PURE__ */ new Set([QUEUE_TABLE]) };
|
|
3408
5374
|
}
|
|
3409
5375
|
/** Resolve a `readTablePage` admin read, parsing the loosely-typed args into the reader's options. */
|
|
3410
5376
|
readAdminTablePage(sql, args) {
|
|
@@ -3416,6 +5382,7 @@ class ShardDO {
|
|
|
3416
5382
|
orderBy: parseTablePageOrderBy(args["orderBy"]),
|
|
3417
5383
|
refs: this.tableRefs(table),
|
|
3418
5384
|
search: typeof args["search"] === "string" ? args["search"] : void 0,
|
|
5385
|
+
skipCount: typeof args["skipCount"] === "boolean" ? args["skipCount"] : void 0,
|
|
3419
5386
|
table
|
|
3420
5387
|
});
|
|
3421
5388
|
return { result: page, tables: /* @__PURE__ */ new Set([table === "" ? ADMIN_WILDCARD : table]) };
|
|
@@ -3460,6 +5427,73 @@ class ShardDO {
|
|
|
3460
5427
|
const read = this.readAdminOp(functionPath, args);
|
|
3461
5428
|
return read ? { result: read.result, tables: read.tables } : null;
|
|
3462
5429
|
}
|
|
5430
|
+
/**
|
|
5431
|
+
* Resolve one subscription (seed or refresh) to its {@link SubscriptionOutcome}
|
|
5432
|
+
* by routing the `functionPath` to the right read path — shared by
|
|
5433
|
+
* {@link seedSubscription} and {@link refreshSubscriptions} so both branch
|
|
5434
|
+
* identically:
|
|
5435
|
+
* - `__lunora_admin__:*` → {@link executeAdminSubscription} (raw SQLite read).
|
|
5436
|
+
* - {@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`).
|
|
5437
|
+
* - everything else → {@link executeSubscription} (the user query, under the socket's own by-value identity).
|
|
5438
|
+
*/
|
|
5439
|
+
async resolveReactiveOutcome(functionPath, args, isAdmin, identity) {
|
|
5440
|
+
if (isAdmin) {
|
|
5441
|
+
return this.executeAdminSubscription(functionPath, args);
|
|
5442
|
+
}
|
|
5443
|
+
if (functionPath.startsWith(FLAGS_FUNCTION_PREFIX)) {
|
|
5444
|
+
const result = await this.runFlagSubscriptionRead(functionPath, args, identity);
|
|
5445
|
+
return result === null ? null : { result, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
|
|
5446
|
+
}
|
|
5447
|
+
return this.executeSubscription(functionPath, args, identity);
|
|
5448
|
+
}
|
|
5449
|
+
/**
|
|
5450
|
+
* SECURITY BOUNDARY for cross-socket reactive dedup. A read is
|
|
5451
|
+
* identity-INDEPENDENT only when its result cannot vary by the caller's
|
|
5452
|
+
* verified identity — i.e. the admin/reserved introspection reads, which
|
|
5453
|
+
* route to {@link executeAdminSubscription} and ignore the
|
|
5454
|
+
* {@link SubscriptionIdentity} entirely.
|
|
5455
|
+
*
|
|
5456
|
+
* Everything else is identity-DEPENDENT and must NEVER be shared across
|
|
5457
|
+
* sockets: a user query may be `rls()` / `ctx.auth`-scoped (different rows
|
|
5458
|
+
* per identity), and a flag read ({@link FLAGS_FUNCTION_PREFIX}) evaluates
|
|
5459
|
+
* the provider with the subscriber's identity (per-user targeting). Sharing
|
|
5460
|
+
* one socket's result with another would leak one identity's rows/flags to a
|
|
5461
|
+
* different identity, so this predicate gates {@link resolveReactiveOutcomeDeduped}
|
|
5462
|
+
* shut for them.
|
|
5463
|
+
*/
|
|
5464
|
+
// eslint-disable-next-line class-methods-use-this, @typescript-eslint/member-ordering -- pure predicate over the function path; a protected method so the security boundary lives in one named place (and tests can probe it), co-located with the reactive dedup it gates rather than hoisted away from its only caller
|
|
5465
|
+
isIdentityIndependent(functionPath) {
|
|
5466
|
+
return functionPath.startsWith(ADMIN_FUNCTION_PREFIX);
|
|
5467
|
+
}
|
|
5468
|
+
/**
|
|
5469
|
+
* Memoizing wrapper over {@link resolveReactiveOutcome}: flush-local sharing across sockets.
|
|
5470
|
+
* Within a single {@link refreshSubscriptions} pass, N sockets subscribed to
|
|
5471
|
+
* the SAME identity-independent `(functionPath, args)` re-run the query N
|
|
5472
|
+
* times today (see the Case-6 fan-out characterization). When the read is
|
|
5473
|
+
* identity-independent (admin/reserved — see {@link isIdentityIndependent})
|
|
5474
|
+
* its result is the same for every socket, so the first run is cached (by its
|
|
5475
|
+
* in-flight Promise, since the bounded worker pool runs sockets in parallel)
|
|
5476
|
+
* and shared with the rest — collapsing N runs to ONE.
|
|
5477
|
+
*
|
|
5478
|
+
* Identity-DEPENDENT reads are passed straight through, UNCACHED: each socket
|
|
5479
|
+
* must evaluate under its own by-value identity (RLS / `ctx.auth` / per-user
|
|
5480
|
+
* flags), so they never share a result. The `cache` is created fresh per
|
|
5481
|
+
* flush by the caller, so a result is never reused across passes (it would go
|
|
5482
|
+
* stale after the next write).
|
|
5483
|
+
*/
|
|
5484
|
+
resolveReactiveOutcomeDeduped(functionPath, args, isAdmin, identity, cache) {
|
|
5485
|
+
if (!this.isIdentityIndependent(functionPath)) {
|
|
5486
|
+
return this.resolveReactiveOutcome(functionPath, args, isAdmin, identity);
|
|
5487
|
+
}
|
|
5488
|
+
const key = reactiveCacheKey(functionPath, args, null);
|
|
5489
|
+
const cached = cache.get(key);
|
|
5490
|
+
if (cached !== void 0) {
|
|
5491
|
+
return cached;
|
|
5492
|
+
}
|
|
5493
|
+
const pending = this.resolveReactiveOutcome(functionPath, args, isAdmin, identity);
|
|
5494
|
+
cache.set(key, pending);
|
|
5495
|
+
return pending;
|
|
5496
|
+
}
|
|
3463
5497
|
/**
|
|
3464
5498
|
* Constant-time bearer check against `env.LUNORA_ADMIN_TOKEN`. Returns
|
|
3465
5499
|
* `false` (closed) when the token is unset so admin introspection is
|
|
@@ -3517,17 +5551,19 @@ class ShardDO {
|
|
|
3517
5551
|
break;
|
|
3518
5552
|
}
|
|
3519
5553
|
await awaitWsDrain(ws);
|
|
3520
|
-
ws.send(JSON.stringify({ data: chunk, id, type: "chunk" }));
|
|
5554
|
+
ws.send(JSON.stringify({ data: encodeWire(chunk), id, type: "chunk" }));
|
|
3521
5555
|
}
|
|
3522
5556
|
if (!controller.signal.aborted) {
|
|
3523
5557
|
ws.send(JSON.stringify({ id, type: "complete" }));
|
|
3524
5558
|
}
|
|
3525
5559
|
} catch (error) {
|
|
3526
|
-
const {
|
|
3527
|
-
|
|
5560
|
+
const { body, redacted } = toErrorBody(error, { fallbackCode: "INTERNAL_SERVER_ERROR", redactedMessage: "internal error" });
|
|
5561
|
+
if (redacted) {
|
|
5562
|
+
console.error("[@lunora/do] unhandled stream error:", error);
|
|
5563
|
+
}
|
|
3528
5564
|
ws.send(
|
|
3529
5565
|
JSON.stringify({
|
|
3530
|
-
error: { code:
|
|
5566
|
+
error: { code: body.code, message: body.message },
|
|
3531
5567
|
id,
|
|
3532
5568
|
type: "error"
|
|
3533
5569
|
})
|
|
@@ -3558,11 +5594,53 @@ class ShardDO {
|
|
|
3558
5594
|
if (!changed || changed.size === 0) {
|
|
3559
5595
|
return;
|
|
3560
5596
|
}
|
|
5597
|
+
if (this.pendingRefreshTables) {
|
|
5598
|
+
for (const table of changed) {
|
|
5599
|
+
this.pendingRefreshTables.add(table);
|
|
5600
|
+
}
|
|
5601
|
+
} else {
|
|
5602
|
+
this.pendingRefreshTables = changed;
|
|
5603
|
+
}
|
|
5604
|
+
if (this.refreshInFlight) {
|
|
5605
|
+
return;
|
|
5606
|
+
}
|
|
3561
5607
|
if (typeof this.state.waitUntil === "function") {
|
|
3562
|
-
this.state.waitUntil(this.
|
|
5608
|
+
this.state.waitUntil(this.drainSubscriptionRefreshes());
|
|
5609
|
+
return;
|
|
5610
|
+
}
|
|
5611
|
+
await this.drainSubscriptionRefreshes();
|
|
5612
|
+
}
|
|
5613
|
+
/**
|
|
5614
|
+
* Drain {@link ShardDO.pendingRefreshTables} one coalesced batch at a time
|
|
5615
|
+
* until it is empty, then release the {@link ShardDO.refreshInFlight} gate.
|
|
5616
|
+
* Tables merged by a `flushChangedTables` that lands mid-pass are picked up
|
|
5617
|
+
* by the next loop iteration, so every committed write is observed by a
|
|
5618
|
+
* refresh that runs after it — bursts simply share a pass. The post-write
|
|
5619
|
+
* high-watermark and live-socket set are re-read inside each
|
|
5620
|
+
* `refreshSubscriptions` / `pokeShapeSubscribers` call, so a later batch
|
|
5621
|
+
* always reflects the latest committed state.
|
|
5622
|
+
*/
|
|
5623
|
+
async drainSubscriptionRefreshes() {
|
|
5624
|
+
if (this.refreshInFlight) {
|
|
3563
5625
|
return;
|
|
3564
5626
|
}
|
|
3565
|
-
|
|
5627
|
+
this.refreshInFlight = true;
|
|
5628
|
+
try {
|
|
5629
|
+
let batch = this.pendingRefreshTables;
|
|
5630
|
+
while (batch && batch.size > 0) {
|
|
5631
|
+
this.pendingRefreshTables = void 0;
|
|
5632
|
+
const frameCursor = this.currentCdcCursor();
|
|
5633
|
+
const frameEpoch = this.currentCdcEpoch();
|
|
5634
|
+
await Promise.all([
|
|
5635
|
+
this.refreshSubscriptions(batch),
|
|
5636
|
+
this.pokeShapeSubscribers(batch, frameCursor, frameEpoch),
|
|
5637
|
+
this.relay?.onFlush(batch, frameCursor ?? 0)
|
|
5638
|
+
]);
|
|
5639
|
+
batch = this.pendingRefreshTables;
|
|
5640
|
+
}
|
|
5641
|
+
} finally {
|
|
5642
|
+
this.refreshInFlight = false;
|
|
5643
|
+
}
|
|
3566
5644
|
}
|
|
3567
5645
|
/**
|
|
3568
5646
|
* For every live subscription whose query reads one of `changed`, re-run
|
|
@@ -3623,6 +5701,7 @@ class ShardDO {
|
|
|
3623
5701
|
const sockets = [...this.state.getWebSockets()];
|
|
3624
5702
|
const frameCursor = this.currentCdcCursor();
|
|
3625
5703
|
const frameEpoch = this.currentCdcEpoch();
|
|
5704
|
+
const reactiveRunCache = /* @__PURE__ */ new Map();
|
|
3626
5705
|
const refreshOne = async (ws) => {
|
|
3627
5706
|
if (this.isSocketExpired(ws)) {
|
|
3628
5707
|
this.dropExpiredSocket(ws);
|
|
@@ -3640,14 +5719,12 @@ class ShardDO {
|
|
|
3640
5719
|
continue;
|
|
3641
5720
|
}
|
|
3642
5721
|
try {
|
|
3643
|
-
const outcome =
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
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 })
|
|
5722
|
+
const outcome = await this.resolveReactiveOutcomeDeduped(
|
|
5723
|
+
functionPath,
|
|
5724
|
+
query.args ?? {},
|
|
5725
|
+
isAdmin,
|
|
5726
|
+
{ identity: attachment.identity, userId: attachment.userId },
|
|
5727
|
+
reactiveRunCache
|
|
3651
5728
|
);
|
|
3652
5729
|
if (!outcome) {
|
|
3653
5730
|
continue;
|
|
@@ -3659,18 +5736,7 @@ class ShardDO {
|
|
|
3659
5736
|
}
|
|
3660
5737
|
}
|
|
3661
5738
|
};
|
|
3662
|
-
|
|
3663
|
-
let cursor = 0;
|
|
3664
|
-
const worker = async () => {
|
|
3665
|
-
let socket = sockets[cursor];
|
|
3666
|
-
cursor += 1;
|
|
3667
|
-
while (socket !== void 0) {
|
|
3668
|
-
await refreshOne(socket);
|
|
3669
|
-
socket = sockets[cursor];
|
|
3670
|
-
cursor += 1;
|
|
3671
|
-
}
|
|
3672
|
-
};
|
|
3673
|
-
await Promise.all(Array.from({ length: Math.min(concurrency, sockets.length) }, () => worker()));
|
|
5739
|
+
await runSocketPool(sockets, refreshOne);
|
|
3674
5740
|
}
|
|
3675
5741
|
/**
|
|
3676
5742
|
* Seed a freshly-registered subscription with its first value. Runs the
|
|
@@ -3690,7 +5756,10 @@ class ShardDO {
|
|
|
3690
5756
|
async seedSubscription(ws, subId, query, functionPath, isAdmin) {
|
|
3691
5757
|
const seedArgs = query.args ?? {};
|
|
3692
5758
|
const attachment = this.readAttachment(ws);
|
|
3693
|
-
const outcome =
|
|
5759
|
+
const outcome = await this.resolveReactiveOutcome(functionPath, seedArgs, isAdmin, {
|
|
5760
|
+
identity: attachment.identity,
|
|
5761
|
+
userId: attachment.userId
|
|
5762
|
+
});
|
|
3694
5763
|
if (!outcome) {
|
|
3695
5764
|
return;
|
|
3696
5765
|
}
|
|
@@ -3707,6 +5776,559 @@ class ShardDO {
|
|
|
3707
5776
|
}
|
|
3708
5777
|
this.pushSubscriptionData(ws, subId, outcome, resume?.cursor ?? this.currentCdcCursor(), epoch);
|
|
3709
5778
|
}
|
|
5779
|
+
/**
|
|
5780
|
+
* Drive the full `shape_subscribe` flow as one failure-aware unit: persist the
|
|
5781
|
+
* attachment, seed the shape, and ack ONLY once both succeed. A persist
|
|
5782
|
+
* rejection (`too_many`/`serialize_failed`) or a seed that can't resolve the
|
|
5783
|
+
* shape (unknown / RLS-denied / cross-shard-invalid) rolls the attachment back
|
|
5784
|
+
* and sends an `error` frame instead of acking — so a client is never left
|
|
5785
|
+
* acked but subscribed to a shape that will never deliver. Never throws (a
|
|
5786
|
+
* thrown `webSocketMessage` is fatal to the hibernating socket).
|
|
5787
|
+
*/
|
|
5788
|
+
async handleShapeSubscribe(ws, subId, shape) {
|
|
5789
|
+
const status = this.shapeSubscribe(ws, subId, shape);
|
|
5790
|
+
if (status !== "ok") {
|
|
5791
|
+
const code = status === "too_many" ? "TOO_MANY_SUBSCRIPTIONS" : "SUBSCRIPTION_PERSIST_FAILED";
|
|
5792
|
+
const message = status === "too_many" ? `subscription cap of ${String(ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET)} reached on this socket` : "failed to persist shape subscription attachment";
|
|
5793
|
+
this.sendShapeSubscribeError(ws, subId, code, message);
|
|
5794
|
+
return;
|
|
5795
|
+
}
|
|
5796
|
+
const seed = await this.seedShapeSubscription(ws, subId, shape);
|
|
5797
|
+
if (seed !== "ok") {
|
|
5798
|
+
this.shapeUnsubscribe(ws, subId);
|
|
5799
|
+
this.sendShapeSubscribeError(ws, subId, seed.code, seed.message);
|
|
5800
|
+
return;
|
|
5801
|
+
}
|
|
5802
|
+
try {
|
|
5803
|
+
ws.send(JSON.stringify({ id: subId, type: "ack" }));
|
|
5804
|
+
} catch {
|
|
5805
|
+
}
|
|
5806
|
+
}
|
|
5807
|
+
/** Send a structured `error` frame for a failed `shape_subscribe`, swallowing a send on an already-closed socket. */
|
|
5808
|
+
// eslint-disable-next-line class-methods-use-this -- groups with the shape-subscribe flow; uses only its args + the socket
|
|
5809
|
+
sendShapeSubscribeError(ws, subId, code, message) {
|
|
5810
|
+
try {
|
|
5811
|
+
ws.send(JSON.stringify({ code, error: { code, message }, id: subId, type: "error" }));
|
|
5812
|
+
} catch {
|
|
5813
|
+
}
|
|
5814
|
+
}
|
|
5815
|
+
/**
|
|
5816
|
+
* Seed a freshly-registered shape subscription. Resolves the shape under the
|
|
5817
|
+
* socket's verified identity, then ships either:
|
|
5818
|
+
*
|
|
5819
|
+
* - a **catch-up** poke (the membership diff in `(sinceCheckpoint, cursor]`)
|
|
5820
|
+
* when the client supplied a still-current checkpoint within the CDC retention
|
|
5821
|
+
* window and on this epoch — the cheap reconnect path; or
|
|
5822
|
+
* - a **full** insert-poke of the shape's entire current membership — a
|
|
5823
|
+
* first-time subscribe, or a reconnect that fell outside retention / forked
|
|
5824
|
+
* epoch.
|
|
5825
|
+
*
|
|
5826
|
+
* Either way the per-socket shape memo advances to the flush watermark so
|
|
5827
|
+
* later `pokeShapeSubscribers` passes diff from the right point.
|
|
5828
|
+
*
|
|
5829
|
+
* Returns `"ok"` once the shape resolved and its seed poke was attempted, or a
|
|
5830
|
+
* `{ code, message }` failure when the shape can't be resolved — an unknown /
|
|
5831
|
+
* RLS-denied shape (a base class with no registry resolves nothing), or a
|
|
5832
|
+
* `resolveShape` that threw (e.g. a cross-shard-join guard). The caller rolls
|
|
5833
|
+
* back the persisted attachment and errors instead of acking, so a client is
|
|
5834
|
+
* never left subscribed to a shape that will never deliver.
|
|
5835
|
+
*/
|
|
5836
|
+
async seedShapeSubscription(ws, subId, shape) {
|
|
5837
|
+
const attachment = this.readAttachment(ws);
|
|
5838
|
+
const identity = { identity: attachment.identity, userId: attachment.userId };
|
|
5839
|
+
const relayed = await this.relay?.seedRelayShape(ws, subId, shape, identity);
|
|
5840
|
+
if (relayed !== void 0) {
|
|
5841
|
+
return relayed;
|
|
5842
|
+
}
|
|
5843
|
+
let resolved;
|
|
5844
|
+
try {
|
|
5845
|
+
resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
|
|
5846
|
+
} catch (error) {
|
|
5847
|
+
this.recordShapeError(`shape:seed:${subId}`, error);
|
|
5848
|
+
const { body } = toErrorBody(error, { fallbackCode: "SHAPE_RESOLVE_FAILED", redactedMessage: "shape resolution failed" });
|
|
5849
|
+
return { code: body.code, message: body.message };
|
|
5850
|
+
}
|
|
5851
|
+
if (!resolved) {
|
|
5852
|
+
return { code: "SHAPE_NOT_FOUND", message: `shape "${shape.name}" not found or not permitted` };
|
|
5853
|
+
}
|
|
5854
|
+
try {
|
|
5855
|
+
if (resolved.global) {
|
|
5856
|
+
return await this.seedGlobalShape(ws, subId, resolved, identity, attachment.connectionId ?? "");
|
|
5857
|
+
}
|
|
5858
|
+
return await this.seedOpLogShape(ws, subId, shape, resolved);
|
|
5859
|
+
} catch (error) {
|
|
5860
|
+
this.recordShapeError(`shape:seed:${subId}`, error);
|
|
5861
|
+
const { body } = toErrorBody(error, { fallbackCode: "SHAPE_SEED_FAILED", redactedMessage: "shape seed failed" });
|
|
5862
|
+
return { code: body.code, message: body.message };
|
|
5863
|
+
}
|
|
5864
|
+
}
|
|
5865
|
+
/**
|
|
5866
|
+
* Seed a non-`.global()` (op-log-backed) shape: either a catch-up diff over
|
|
5867
|
+
* `(sinceSeq, cursor]` when the client supplied a still-current checkpoint on
|
|
5868
|
+
* this epoch within the CDC retention window, or a full membership insert-poke
|
|
5869
|
+
* otherwise. The memo advances to `cursor` only once the poke is delivered, so
|
|
5870
|
+
* a failed send re-diffs from the prior point rather than skipping rows. May
|
|
5871
|
+
* throw (a stub `sql` handle, a membership probe failure); the caller converts
|
|
5872
|
+
* it to a structured `shape_subscribe` error.
|
|
5873
|
+
*/
|
|
5874
|
+
async seedOpLogShape(ws, subId, shape, resolved) {
|
|
5875
|
+
const { baseCheckpoint, cursor, epoch, rowsPatch } = this.computeOpLogShapeSeed(shape, resolved);
|
|
5876
|
+
await awaitWsDrain(ws);
|
|
5877
|
+
if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], cursor, epoch, baseCheckpoint)) {
|
|
5878
|
+
this.recordShapeMemo(ws, subId, cursor);
|
|
5879
|
+
}
|
|
5880
|
+
return "ok";
|
|
5881
|
+
}
|
|
5882
|
+
/**
|
|
5883
|
+
* Compute an op-log shape seed (cursor, epoch, the resume base, and the
|
|
5884
|
+
* membership `rowsPatch`) WITHOUT sending — the shared core of
|
|
5885
|
+
* {@link ShardDO.seedOpLogShape} (sends to a local socket) and the owner relay's
|
|
5886
|
+
* `buildShapeSeedFrames` (serializes the frames for a relay to deliver, plan 075
|
|
5887
|
+
* Phase 3, via the {@link RelayHost} seam). Resume only when CDC is on, the client is on this
|
|
5888
|
+
* epoch, its checkpoint doesn't run ahead of ours, and the log still covers it;
|
|
5889
|
+
* else a full re-seed. A fully-compacted log only proves "nothing missed" when
|
|
5890
|
+
* the client is already at `cursor`.
|
|
5891
|
+
* @returns the cursor/epoch, the resume base (`baseCheckpoint`), and the membership patch
|
|
5892
|
+
*/
|
|
5893
|
+
computeOpLogShapeSeed(shape, resolved) {
|
|
5894
|
+
const sql = this.sql;
|
|
5895
|
+
const cursor = this.currentCdcCursor() ?? 0;
|
|
5896
|
+
const epoch = this.currentCdcEpoch();
|
|
5897
|
+
const floor = this.cdcEnabled() ? minCdcSeq(sql) : void 0;
|
|
5898
|
+
const canResume = this.cdcEnabled() && shape.sinceSeq !== void 0 && shape.sinceEpoch === epoch && shape.sinceSeq <= cursor && (shape.sinceSeq === cursor || floor !== void 0 && floor <= shape.sinceSeq + 1);
|
|
5899
|
+
const rowsPatch = canResume && shape.sinceSeq !== void 0 ? this.buildShapeDiff(sql, resolved, shape.sinceSeq, cursor) : this.buildShapeSeed(sql, resolved);
|
|
5900
|
+
return { baseCheckpoint: canResume ? shape.sinceSeq : void 0, cursor, epoch, rowsPatch };
|
|
5901
|
+
}
|
|
5902
|
+
/**
|
|
5903
|
+
* Fan the membership diff of every shape affected by this flush to its
|
|
5904
|
+
* subscribers — the partial-replication parallel to
|
|
5905
|
+
* {@link ShardDO.refreshSubscriptions}, called alongside it from
|
|
5906
|
+
* {@link ShardDO.flushChangedTables}. For each socket (bounded fan-out, same
|
|
5907
|
+
* concurrency + `awaitWsDrain` backpressure as the subscription path) it
|
|
5908
|
+
* resolves each shape under the socket's identity, diffs only the shapes
|
|
5909
|
+
* whose table changed in `(memoCursor, frameCursor]`, and emits one poke
|
|
5910
|
+
* carrying a part per changed shape. No-op when no socket holds a shape.
|
|
5911
|
+
*/
|
|
5912
|
+
async pokeShapeSubscribers(changed, frameCursor, frameEpoch) {
|
|
5913
|
+
const sockets = [...this.state.getWebSockets()];
|
|
5914
|
+
const checkpoint = frameCursor ?? this.currentCdcCursor() ?? 0;
|
|
5915
|
+
const sql = this.sql;
|
|
5916
|
+
const opRangeCache = /* @__PURE__ */ new Map();
|
|
5917
|
+
let delivered = 0;
|
|
5918
|
+
const pokeOne = async (ws) => {
|
|
5919
|
+
if (this.isSocketExpired(ws)) {
|
|
5920
|
+
this.dropExpiredSocket(ws);
|
|
5921
|
+
return;
|
|
5922
|
+
}
|
|
5923
|
+
const attachment = this.readAttachment(ws);
|
|
5924
|
+
const { shapes } = attachment;
|
|
5925
|
+
if (!shapes) {
|
|
5926
|
+
return;
|
|
5927
|
+
}
|
|
5928
|
+
try {
|
|
5929
|
+
const identity = { identity: attachment.identity, userId: attachment.userId };
|
|
5930
|
+
const { emptyAdvanced, partAdvanced, parts } = this.collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql, opRangeCache);
|
|
5931
|
+
for (const subId of emptyAdvanced) {
|
|
5932
|
+
this.recordShapeMemo(ws, subId, checkpoint);
|
|
5933
|
+
}
|
|
5934
|
+
if (parts.length > 0) {
|
|
5935
|
+
await awaitWsDrain(ws);
|
|
5936
|
+
if (this.sendPoke(ws, parts, checkpoint, frameEpoch, void 0)) {
|
|
5937
|
+
delivered += 1;
|
|
5938
|
+
for (const subId of partAdvanced) {
|
|
5939
|
+
this.recordShapeMemo(ws, subId, checkpoint);
|
|
5940
|
+
}
|
|
5941
|
+
}
|
|
5942
|
+
}
|
|
5943
|
+
} catch {
|
|
5944
|
+
}
|
|
5945
|
+
};
|
|
5946
|
+
const startMs = Date.now();
|
|
5947
|
+
await runSocketPool(sockets, pokeOne);
|
|
5948
|
+
this.fanout.shapePoke = recordFanoutPass(this.fanout.shapePoke, sockets.length, delivered, Date.now() - startMs);
|
|
5949
|
+
}
|
|
5950
|
+
/**
|
|
5951
|
+
* Diff every op-log-backed shape a socket holds against this flush, splitting
|
|
5952
|
+
* the results into the poke parts to send and the per-shape memo advances. A
|
|
5953
|
+
* `.global()` shape (driven by the alarm poll loop, not this flush) and a shape
|
|
5954
|
+
* whose table didn't change are skipped; a shape whose resolve/diff throws is
|
|
5955
|
+
* logged and skipped with its memo unadvanced so a later flush retries. Empty
|
|
5956
|
+
* diffs advance unconditionally; part-bearing shapes advance only once the
|
|
5957
|
+
* caller confirms the poke was delivered.
|
|
5958
|
+
*/
|
|
5959
|
+
collectShapePokeParts(ws, shapes, identity, changed, checkpoint, sql, opRangeCache) {
|
|
5960
|
+
const parts = [];
|
|
5961
|
+
const emptyAdvanced = [];
|
|
5962
|
+
const partAdvanced = [];
|
|
5963
|
+
for (const [subId, shape] of Object.entries(shapes)) {
|
|
5964
|
+
try {
|
|
5965
|
+
const resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
|
|
5966
|
+
if (!resolved || resolved.global || !changed.has(resolved.table)) {
|
|
5967
|
+
continue;
|
|
5968
|
+
}
|
|
5969
|
+
const memoCursor = this.shapeMemos.get(ws)?.get(subId)?.cursor ?? 0;
|
|
5970
|
+
const rowsPatch = this.buildShapeDiff(sql, resolved, memoCursor, checkpoint, opRangeCache);
|
|
5971
|
+
if (rowsPatch.length > 0) {
|
|
5972
|
+
parts.push({ rowsPatch, shapeId: subId });
|
|
5973
|
+
partAdvanced.push(subId);
|
|
5974
|
+
} else {
|
|
5975
|
+
emptyAdvanced.push(subId);
|
|
5976
|
+
}
|
|
5977
|
+
} catch (error) {
|
|
5978
|
+
this.recordShapeError(`shape:poke:${subId}`, error);
|
|
5979
|
+
}
|
|
5980
|
+
}
|
|
5981
|
+
return { emptyAdvanced, partAdvanced, parts };
|
|
5982
|
+
}
|
|
5983
|
+
/**
|
|
5984
|
+
* Drain the op-log range `(sinceSeq, upTo]` for `table` into the latest op per
|
|
5985
|
+
* row id (collapsing multiple ops on the same row to the newest). Within one
|
|
5986
|
+
* flush, every shape over the SAME `(table, sinceSeq, upTo)` reads the
|
|
5987
|
+
* identical changelog slice, so the drained map is memoized in the
|
|
5988
|
+
* caller-supplied `cache` (created fresh per flush) — N shapes on a table
|
|
5989
|
+
* share ONE changelog drain instead of re-scanning it per shape. The
|
|
5990
|
+
* per-shape membership probe still runs per shape (its predicate is
|
|
5991
|
+
* identity/args-specific), so only the shared op read is collapsed.
|
|
5992
|
+
*/
|
|
5993
|
+
readShapeOpRange(sql, table, sinceSeq, upTo, cache) {
|
|
5994
|
+
const key = `${table}\0${String(sinceSeq)}\0${String(upTo)}`;
|
|
5995
|
+
const cached = cache?.get(key);
|
|
5996
|
+
if (cached !== void 0) {
|
|
5997
|
+
return cached;
|
|
5998
|
+
}
|
|
5999
|
+
const latest = /* @__PURE__ */ new Map();
|
|
6000
|
+
const tables = /* @__PURE__ */ new Set([table]);
|
|
6001
|
+
let from = sinceSeq;
|
|
6002
|
+
for (; ; ) {
|
|
6003
|
+
const { changes, cursor } = this.readShapeCdcPage(sql, from, tables);
|
|
6004
|
+
for (const change of changes) {
|
|
6005
|
+
latest.set(change.id, change);
|
|
6006
|
+
}
|
|
6007
|
+
if (changes.length === 0 || cursor === from || cursor >= upTo) {
|
|
6008
|
+
break;
|
|
6009
|
+
}
|
|
6010
|
+
from = cursor;
|
|
6011
|
+
}
|
|
6012
|
+
cache?.set(key, latest);
|
|
6013
|
+
return latest;
|
|
6014
|
+
}
|
|
6015
|
+
/**
|
|
6016
|
+
* Read one page of the `__cdc_log` for a shape diff (table-scoped). A thin
|
|
6017
|
+
* protected seam over {@link readCdcChanges}: it isolates the single
|
|
6018
|
+
* changelog read that {@link readShapeOpRange} memoizes per flush, and gives
|
|
6019
|
+
* tests a point to count the reads the op-range cache collapses.
|
|
6020
|
+
*/
|
|
6021
|
+
// eslint-disable-next-line class-methods-use-this, @typescript-eslint/member-ordering -- thin pass-through seam over the module-level reader; a protected method so the op-range cache + tests share one read point, co-located with the poke path it serves rather than hoisted away from its only caller
|
|
6022
|
+
readShapeCdcPage(sql, sinceSeq, tables) {
|
|
6023
|
+
return readCdcChanges(sql, { sinceSeq, tables });
|
|
6024
|
+
}
|
|
6025
|
+
/**
|
|
6026
|
+
* Build the row-ops for a shape over the op range `(sinceSeq, upTo]`. Reads
|
|
6027
|
+
* the changelog (drained across pages via {@link readShapeOpRange}, shared
|
|
6028
|
+
* across same-range shapes in a flush), collapses to the latest op per row,
|
|
6029
|
+
* then runs ONE membership probe ({@link selectShapeMemberIds}) over the
|
|
6030
|
+
* changed ids: a row still in the set → upsert with its post-image doc
|
|
6031
|
+
* (projected to the shape's columns); a row that left the set, or any delete,
|
|
6032
|
+
* → `delete(key)` (a delete carries no post-image, so membership is
|
|
6033
|
+
* unknowable from the op alone — the client no-ops an unknown key).
|
|
6034
|
+
*/
|
|
6035
|
+
buildShapeDiff(sql, resolved, sinceSeq, upTo, opRangeCache) {
|
|
6036
|
+
const latest = this.readShapeOpRange(sql, resolved.table, sinceSeq, upTo, opRangeCache);
|
|
6037
|
+
if (latest.size === 0) {
|
|
6038
|
+
return [];
|
|
6039
|
+
}
|
|
6040
|
+
const ids = [...latest.keys()];
|
|
6041
|
+
const members = selectShapeMemberIds(sql, resolved.table, resolved.effectiveWhere, ids);
|
|
6042
|
+
const ops = [];
|
|
6043
|
+
for (const [id, change] of latest) {
|
|
6044
|
+
if (members.has(id)) {
|
|
6045
|
+
if (change.doc !== void 0) {
|
|
6046
|
+
ops.push({ key: id, op: change.op, table: resolved.table, value: projectColumns(change.doc, resolved.columns) });
|
|
6047
|
+
}
|
|
6048
|
+
continue;
|
|
6049
|
+
}
|
|
6050
|
+
if (change.op !== "insert") {
|
|
6051
|
+
ops.push({ key: id, op: "delete", table: resolved.table });
|
|
6052
|
+
}
|
|
6053
|
+
}
|
|
6054
|
+
return ops;
|
|
6055
|
+
}
|
|
6056
|
+
/** Build the full insert-poke of a shape's current membership — the first-seed/full-reseed rowset. */
|
|
6057
|
+
// eslint-disable-next-line class-methods-use-this -- instance method for symmetry with `buildShapeDiff`; reads via the passed `sql` handle
|
|
6058
|
+
buildShapeSeed(sql, resolved) {
|
|
6059
|
+
return selectShapeRows(sql, resolved.table, resolved.effectiveWhere).map((row) => {
|
|
6060
|
+
return {
|
|
6061
|
+
key: row.id,
|
|
6062
|
+
op: "insert",
|
|
6063
|
+
table: resolved.table,
|
|
6064
|
+
value: projectColumns(row.doc, resolved.columns)
|
|
6065
|
+
};
|
|
6066
|
+
});
|
|
6067
|
+
}
|
|
6068
|
+
/**
|
|
6069
|
+
* Seed a `.global()`-table shape: read its full membership from D1, ship it
|
|
6070
|
+
* as one insert-poke, record the membership snapshot the alarm poll loop will
|
|
6071
|
+
* diff against, and arm the poll alarm. A global shape has no op-log cursor,
|
|
6072
|
+
* so the poke is stamped at this DO's current cursor (informational only) and
|
|
6073
|
+
* carries no resume base — a reconnect always re-seeds full.
|
|
6074
|
+
*/
|
|
6075
|
+
async seedGlobalShape(ws, subId, resolved, identity, connectionId) {
|
|
6076
|
+
const rows = await this.readGlobalShapeRows(resolved, identity);
|
|
6077
|
+
if (!this.withinGlobalShapeBound(rows.length, `shape:seed:${subId}`, resolved.table)) {
|
|
6078
|
+
return {
|
|
6079
|
+
code: "SHAPE_GLOBAL_TOO_LARGE",
|
|
6080
|
+
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`
|
|
6081
|
+
};
|
|
6082
|
+
}
|
|
6083
|
+
const { next: snapshot, rowsPatch } = diffGlobalMembership(rows, /* @__PURE__ */ new Map(), { columns: resolved.columns, table: resolved.table });
|
|
6084
|
+
await awaitWsDrain(ws);
|
|
6085
|
+
if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], this.currentCdcCursor() ?? 0, this.currentCdcEpoch(), void 0)) {
|
|
6086
|
+
this.recordGlobalSnapshot(ws, subId, snapshot);
|
|
6087
|
+
this.saveGlobalSnapshot(connectionId, subId, snapshot);
|
|
6088
|
+
}
|
|
6089
|
+
await this.scheduleGlobalPoll();
|
|
6090
|
+
return "ok";
|
|
6091
|
+
}
|
|
6092
|
+
/**
|
|
6093
|
+
* Re-read a global shape's membership from D1 and poke only the diff against
|
|
6094
|
+
* the socket's last snapshot: a new key → `insert`, a changed projected value
|
|
6095
|
+
* → `update`, a vanished key → `delete`. The snapshot advances to the fresh
|
|
6096
|
+
* membership even when the diff is empty, so the next tick compares from here.
|
|
6097
|
+
* No frame is sent when nothing changed (the common steady-state tick).
|
|
6098
|
+
*/
|
|
6099
|
+
async refreshGlobalShape(ws, subId, resolved, identity, connectionId) {
|
|
6100
|
+
const rows = await this.readGlobalShapeRows(resolved, identity);
|
|
6101
|
+
if (!this.withinGlobalShapeBound(rows.length, `shape:poll:${subId}`, resolved.table)) {
|
|
6102
|
+
return;
|
|
6103
|
+
}
|
|
6104
|
+
const previous = this.readGlobalSnapshot(ws, subId, connectionId);
|
|
6105
|
+
const { next, rowsPatch } = diffGlobalMembership(rows, previous, { columns: resolved.columns, table: resolved.table });
|
|
6106
|
+
if (rowsPatch.length === 0) {
|
|
6107
|
+
this.recordGlobalSnapshot(ws, subId, next);
|
|
6108
|
+
return;
|
|
6109
|
+
}
|
|
6110
|
+
await awaitWsDrain(ws);
|
|
6111
|
+
if (this.sendPoke(ws, [{ rowsPatch, shapeId: subId }], this.currentCdcCursor() ?? 0, this.currentCdcEpoch(), void 0)) {
|
|
6112
|
+
this.recordGlobalSnapshot(ws, subId, next);
|
|
6113
|
+
this.saveGlobalSnapshot(connectionId, subId, next);
|
|
6114
|
+
}
|
|
6115
|
+
}
|
|
6116
|
+
/**
|
|
6117
|
+
* Read a socket's global-shape baseline, preferring the hot in-memory cache
|
|
6118
|
+
* and falling back to the durable `__global_shape_snapshot` table on a miss (a
|
|
6119
|
+
* cold socket after a hibernation eviction). The loaded baseline repopulates
|
|
6120
|
+
* the cache so subsequent ticks in this wake hit memory. An empty
|
|
6121
|
+
* `connectionId` (a socket that never went through the lifecycle-aware upgrade,
|
|
6122
|
+
* e.g. a unit harness) skips the durable read and behaves as in-memory-only.
|
|
6123
|
+
*/
|
|
6124
|
+
readGlobalSnapshot(ws, subId, connectionId) {
|
|
6125
|
+
const cached = this.globalShapeSnapshots.get(ws)?.get(subId);
|
|
6126
|
+
if (cached) {
|
|
6127
|
+
return cached;
|
|
6128
|
+
}
|
|
6129
|
+
const stored = this.loadGlobalSnapshot(connectionId, subId);
|
|
6130
|
+
this.recordGlobalSnapshot(ws, subId, stored);
|
|
6131
|
+
return stored;
|
|
6132
|
+
}
|
|
6133
|
+
/** Record a socket's latest global-shape membership snapshot in the in-memory cache (creating the per-socket map lazily). */
|
|
6134
|
+
recordGlobalSnapshot(ws, subId, snapshot) {
|
|
6135
|
+
let snapshots = this.globalShapeSnapshots.get(ws);
|
|
6136
|
+
if (!snapshots) {
|
|
6137
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
6138
|
+
this.globalShapeSnapshots.set(ws, snapshots);
|
|
6139
|
+
}
|
|
6140
|
+
snapshots.set(subId, snapshot);
|
|
6141
|
+
}
|
|
6142
|
+
/**
|
|
6143
|
+
* Load a durable global-shape baseline from SQLite, or an empty map when none
|
|
6144
|
+
* is stored / the durable path is unavailable. A stub `sql` handle (unit
|
|
6145
|
+
* harness) or a missing table degrades to in-memory-only behavior rather than
|
|
6146
|
+
* failing the poll tick.
|
|
6147
|
+
*/
|
|
6148
|
+
loadGlobalSnapshot(connectionId, subId) {
|
|
6149
|
+
if (connectionId === "") {
|
|
6150
|
+
return /* @__PURE__ */ new Map();
|
|
6151
|
+
}
|
|
6152
|
+
try {
|
|
6153
|
+
return readGlobalShapeSnapshot(this.sql, connectionId, subId);
|
|
6154
|
+
} catch {
|
|
6155
|
+
return /* @__PURE__ */ new Map();
|
|
6156
|
+
}
|
|
6157
|
+
}
|
|
6158
|
+
/**
|
|
6159
|
+
* Persist a socket's global-shape baseline to SQLite so the poll-loop diff
|
|
6160
|
+
* survives hibernation. A no-op for a connection-id-less socket or a stub
|
|
6161
|
+
* `sql` handle (the in-memory cache then carries the baseline for the DO's
|
|
6162
|
+
* lifetime, matching the pre-durable behavior).
|
|
6163
|
+
*/
|
|
6164
|
+
saveGlobalSnapshot(connectionId, subId, snapshot) {
|
|
6165
|
+
if (connectionId === "") {
|
|
6166
|
+
return;
|
|
6167
|
+
}
|
|
6168
|
+
try {
|
|
6169
|
+
writeGlobalShapeSnapshot(this.sql, connectionId, subId, snapshot);
|
|
6170
|
+
} catch {
|
|
6171
|
+
}
|
|
6172
|
+
}
|
|
6173
|
+
/**
|
|
6174
|
+
* Arm the poll alarm for `.global()` shapes if one isn't already pending.
|
|
6175
|
+
* Idempotent — every global-shape seed calls it, but only the first arms the
|
|
6176
|
+
* alarm. Degrades to a no-op when the runtime exposes no `setAlarm` (the unit
|
|
6177
|
+
* harness): a global shape is then seed-only, which the poll-loop tests assert
|
|
6178
|
+
* by driving {@link ShardDO.alarm} directly.
|
|
6179
|
+
*/
|
|
6180
|
+
async scheduleGlobalPoll() {
|
|
6181
|
+
if (this.globalPollScheduled) {
|
|
6182
|
+
return;
|
|
6183
|
+
}
|
|
6184
|
+
const { setAlarm } = this.state.storage;
|
|
6185
|
+
if (!setAlarm) {
|
|
6186
|
+
return;
|
|
6187
|
+
}
|
|
6188
|
+
this.globalPollScheduled = true;
|
|
6189
|
+
try {
|
|
6190
|
+
await setAlarm.call(this.state.storage, Date.now() + ShardDO.GLOBAL_SHAPE_POLL_INTERVAL_MS);
|
|
6191
|
+
} catch {
|
|
6192
|
+
this.globalPollScheduled = false;
|
|
6193
|
+
}
|
|
6194
|
+
}
|
|
6195
|
+
/**
|
|
6196
|
+
* Record a contained shape-tier error (poll / poke / seed) into the DO's log
|
|
6197
|
+
* ring without aborting the rest of the pass. The shape pipeline is a
|
|
6198
|
+
* best-effort fan-out: one socket's read or one shape's resolve failing must
|
|
6199
|
+
* never take down the others — so callers swallow the throw and surface it
|
|
6200
|
+
* here for diagnosis. `context` is a synthetic `shape:phase:subId` path.
|
|
6201
|
+
*/
|
|
6202
|
+
recordShapeError(context, error) {
|
|
6203
|
+
this.logs.push({
|
|
6204
|
+
functionPath: context,
|
|
6205
|
+
level: "error",
|
|
6206
|
+
message: error instanceof Error ? error.message : String(error),
|
|
6207
|
+
timestamp: Date.now()
|
|
6208
|
+
});
|
|
6209
|
+
}
|
|
6210
|
+
/**
|
|
6211
|
+
* Guard a global shape's materialized membership against {@link
|
|
6212
|
+
* ShardDO.GLOBAL_SHAPE_MAX_ROWS}. Returns `true` when the row count is within
|
|
6213
|
+
* the cap; otherwise records a diagnosable error and returns `false` so the
|
|
6214
|
+
* caller fails the shape closed (no snapshot retained, no poke sent) rather
|
|
6215
|
+
* than risking a DO eviction on an unbounded global table. The transient read
|
|
6216
|
+
* buffer is bounded by the same gate — an over-cap membership is dropped, not
|
|
6217
|
+
* snapshotted per socket.
|
|
6218
|
+
*/
|
|
6219
|
+
withinGlobalShapeBound(rowCount, context, table) {
|
|
6220
|
+
if (rowCount <= ShardDO.GLOBAL_SHAPE_MAX_ROWS) {
|
|
6221
|
+
return true;
|
|
6222
|
+
}
|
|
6223
|
+
this.recordShapeError(
|
|
6224
|
+
context,
|
|
6225
|
+
new Error(
|
|
6226
|
+
`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`
|
|
6227
|
+
)
|
|
6228
|
+
);
|
|
6229
|
+
return false;
|
|
6230
|
+
}
|
|
6231
|
+
/**
|
|
6232
|
+
* Refresh every `.global()`-table shape held across all live sockets, one
|
|
6233
|
+
* diff-poke per (socket, shape). Returns the number of global shapes still
|
|
6234
|
+
* subscribed so {@link ShardDO.alarm} knows whether to re-arm. Expired sockets
|
|
6235
|
+
* are dropped in passing (mirrors {@link ShardDO.pokeShapeSubscribers}).
|
|
6236
|
+
*/
|
|
6237
|
+
async pollGlobalShapes() {
|
|
6238
|
+
const sockets = [...this.state.getWebSockets()];
|
|
6239
|
+
let remaining = 0;
|
|
6240
|
+
for (const ws of sockets) {
|
|
6241
|
+
if (this.isSocketExpired(ws)) {
|
|
6242
|
+
this.dropExpiredSocket(ws);
|
|
6243
|
+
continue;
|
|
6244
|
+
}
|
|
6245
|
+
const attachment = this.readAttachment(ws);
|
|
6246
|
+
const { shapes } = attachment;
|
|
6247
|
+
if (!shapes) {
|
|
6248
|
+
continue;
|
|
6249
|
+
}
|
|
6250
|
+
const identity = { identity: attachment.identity, userId: attachment.userId };
|
|
6251
|
+
remaining += await this.pollSocketGlobalShapes(ws, shapes, identity, attachment.connectionId ?? "");
|
|
6252
|
+
}
|
|
6253
|
+
return remaining;
|
|
6254
|
+
}
|
|
6255
|
+
/**
|
|
6256
|
+
* Refresh one socket's `.global()`-table shapes, containing per-shape
|
|
6257
|
+
* failures so a single throw never aborts the poll tick (and with it the
|
|
6258
|
+
* re-arm). Returns the count of global shapes still subscribed on this socket
|
|
6259
|
+
* — a failed `resolveShape`/read keeps its shape counted so the alarm keeps
|
|
6260
|
+
* polling and retries next tick.
|
|
6261
|
+
*/
|
|
6262
|
+
async pollSocketGlobalShapes(ws, shapes, identity, connectionId) {
|
|
6263
|
+
let count = 0;
|
|
6264
|
+
for (const [subId, shape] of Object.entries(shapes)) {
|
|
6265
|
+
let resolved;
|
|
6266
|
+
try {
|
|
6267
|
+
resolved = this.resolveShape(shape.name, shape.args ?? {}, identity);
|
|
6268
|
+
} catch (error) {
|
|
6269
|
+
count += 1;
|
|
6270
|
+
this.recordShapeError(`shape:poll:${subId}`, error);
|
|
6271
|
+
continue;
|
|
6272
|
+
}
|
|
6273
|
+
if (!resolved?.global) {
|
|
6274
|
+
continue;
|
|
6275
|
+
}
|
|
6276
|
+
count += 1;
|
|
6277
|
+
try {
|
|
6278
|
+
await this.refreshGlobalShape(ws, subId, resolved, identity, connectionId);
|
|
6279
|
+
} catch (error) {
|
|
6280
|
+
this.recordShapeError(`shape:poll:${subId}`, error);
|
|
6281
|
+
}
|
|
6282
|
+
}
|
|
6283
|
+
return count;
|
|
6284
|
+
}
|
|
6285
|
+
/**
|
|
6286
|
+
* Send one poke (`pokeStart` → `pokePart` per shape → `pokeEnd`) to a socket.
|
|
6287
|
+
* All parts apply atomically at `pokeEnd`. Returns `true` when every frame was
|
|
6288
|
+
* handed to the socket, `false` when a send threw mid-poke (the socket closed)
|
|
6289
|
+
* — callers must NOT advance their shape baselines on a `false` so the client
|
|
6290
|
+
* re-receives the rows on its next flush/reconnect instead of losing them.
|
|
6291
|
+
*/
|
|
6292
|
+
sendPoke(ws, parts, checkpoint, epoch, baseCheckpoint) {
|
|
6293
|
+
this.pokeSequence += 1;
|
|
6294
|
+
const pokeId = `poke-${String(this.pokeSequence)}`;
|
|
6295
|
+
const frames = buildPokeFrames(parts, { baseCheckpoint, checkpoint, epoch, lastMutationId: this.socketClientWatermark(ws), pokeId });
|
|
6296
|
+
try {
|
|
6297
|
+
for (const frame of frames) {
|
|
6298
|
+
ws.send(frame);
|
|
6299
|
+
}
|
|
6300
|
+
return true;
|
|
6301
|
+
} catch {
|
|
6302
|
+
return false;
|
|
6303
|
+
}
|
|
6304
|
+
}
|
|
6305
|
+
/**
|
|
6306
|
+
* The recipient client's `__client_watermark` for stamping a poke's
|
|
6307
|
+
* `lastMutationId`, or `undefined` when the socket announced no `clientId`
|
|
6308
|
+
* (a client that doesn't use custom mutators — nothing to drop an overlay
|
|
6309
|
+
* for). Read off the attachment so it survives hibernation.
|
|
6310
|
+
*/
|
|
6311
|
+
socketClientWatermark(ws) {
|
|
6312
|
+
const attachment = this.readAttachment(ws);
|
|
6313
|
+
const { clientId } = attachment;
|
|
6314
|
+
if (clientId === void 0) {
|
|
6315
|
+
return void 0;
|
|
6316
|
+
}
|
|
6317
|
+
try {
|
|
6318
|
+
return readClientWatermark(this.sql, attachment.userId ?? "", clientId);
|
|
6319
|
+
} catch {
|
|
6320
|
+
return void 0;
|
|
6321
|
+
}
|
|
6322
|
+
}
|
|
6323
|
+
/** Record a shape's poke baseline cursor on a socket (creating the per-socket map lazily). */
|
|
6324
|
+
recordShapeMemo(ws, subId, cursor) {
|
|
6325
|
+
let memos = this.shapeMemos.get(ws);
|
|
6326
|
+
if (!memos) {
|
|
6327
|
+
memos = /* @__PURE__ */ new Map();
|
|
6328
|
+
this.shapeMemos.set(ws, memos);
|
|
6329
|
+
}
|
|
6330
|
+
memos.set(subId, { cursor });
|
|
6331
|
+
}
|
|
3710
6332
|
/**
|
|
3711
6333
|
* Record `outcome` as this socket's diff baseline for `subId` without
|
|
3712
6334
|
* sending a frame. Used by the resume fast-path, where the client keeps its
|
|
@@ -3719,7 +6341,7 @@ class ShardDO {
|
|
|
3719
6341
|
memos = /* @__PURE__ */ new Map();
|
|
3720
6342
|
this.subMemos.set(ws, memos);
|
|
3721
6343
|
}
|
|
3722
|
-
memos.set(subId, { lastJson: JSON.stringify(outcome.result ?? null), tables: outcome.tables });
|
|
6344
|
+
memos.set(subId, { lastJson: JSON.stringify(encodeWire(outcome.result ?? null)), tables: outcome.tables });
|
|
3723
6345
|
}
|
|
3724
6346
|
/**
|
|
3725
6347
|
* Memoise `outcome` for `(ws, subId)` and push it to the socket, unless an
|
|
@@ -3746,29 +6368,19 @@ class ShardDO {
|
|
|
3746
6368
|
this.subMemos.set(ws, memos);
|
|
3747
6369
|
}
|
|
3748
6370
|
const cursorSuffix = cdcSuffix(cursor, epoch);
|
|
3749
|
-
const json = JSON.stringify(outcome.result ?? null);
|
|
6371
|
+
const json = JSON.stringify(encodeWire(outcome.result ?? null));
|
|
3750
6372
|
const existing = memos.get(subId);
|
|
3751
6373
|
if (existing?.lastJson === json) {
|
|
3752
6374
|
existing.tables = outcome.tables;
|
|
6375
|
+
const settledWatermark = this.socketClientWatermark(ws);
|
|
6376
|
+
const watermarkField = settledWatermark === void 0 ? "" : `,"lastMutationId":${String(settledWatermark)}`;
|
|
6377
|
+
trySendFrame(ws, `{"type":"settled","id":${JSON.stringify(subId)}${watermarkField}${cursorSuffix}}`);
|
|
3753
6378
|
return;
|
|
3754
6379
|
}
|
|
3755
6380
|
const deltaFrames = [];
|
|
3756
6381
|
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
|
-
}
|
|
6382
|
+
const delivered = deltas === void 0 ? trySendFrame(ws, `{"type":"data","id":${JSON.stringify(subId)},"data":${json}${cursorSuffix}}`) : sendDeltaFrames(ws, subId, deltaFrames, cursorSuffix);
|
|
6383
|
+
memos.set(subId, { lastJson: delivered ? json : existing?.lastJson ?? UNDELIVERED_BASELINE, tables: outcome.tables });
|
|
3772
6384
|
}
|
|
3773
6385
|
/**
|
|
3774
6386
|
* Gate the upgrade request against two complementary controls:
|
|
@@ -3788,9 +6400,14 @@ class ShardDO {
|
|
|
3788
6400
|
* server logs, browser history, and `Referer` headers on any
|
|
3789
6401
|
* subresource the upgrade page loads after the handshake. Use a
|
|
3790
6402
|
* short-lived rotating token in production rather than a long-lived
|
|
3791
|
-
* secret
|
|
6403
|
+
* secret — for the ADMIN credential specifically, the worker mints one
|
|
6404
|
+
* (`POST /_lunora/admin/ws-token`) and {@link isAdminSocket} accepts it, so
|
|
6405
|
+
* the master `LUNORA_ADMIN_TOKEN` never rides the URL.
|
|
6406
|
+
*
|
|
6407
|
+
* Async because the admin fallback ({@link isAdminSocket}) verifies the
|
|
6408
|
+
* ephemeral sub-token with WebCrypto HMAC.
|
|
3792
6409
|
*/
|
|
3793
|
-
isUpgradeAllowed(request) {
|
|
6410
|
+
async isUpgradeAllowed(request) {
|
|
3794
6411
|
const env = this.env ?? {};
|
|
3795
6412
|
const allowedOrigins = env.LUNORA_ALLOWED_ORIGINS;
|
|
3796
6413
|
if (allowedOrigins && allowedOrigins.trim() !== "") {
|
|
@@ -3806,7 +6423,7 @@ class ShardDO {
|
|
|
3806
6423
|
const expectedBearer = env.LUNORA_WS_BEARER;
|
|
3807
6424
|
if (expectedBearer && expectedBearer.length > 0) {
|
|
3808
6425
|
const supplied = this.suppliedWsToken(request);
|
|
3809
|
-
if (!supplied || !constantTimeEqual(supplied, expectedBearer) && !this.isAdminSocket(request)) {
|
|
6426
|
+
if (!supplied || !constantTimeEqual(supplied, expectedBearer) && !await this.isAdminSocket(request)) {
|
|
3810
6427
|
return false;
|
|
3811
6428
|
}
|
|
3812
6429
|
}
|
|
@@ -3827,19 +6444,40 @@ class ShardDO {
|
|
|
3827
6444
|
return new URL(request.url).searchParams.get("token") ?? void 0;
|
|
3828
6445
|
}
|
|
3829
6446
|
/**
|
|
3830
|
-
* Whether the upgrade presented
|
|
3831
|
-
* constant-time compared
|
|
3832
|
-
*
|
|
3833
|
-
*
|
|
6447
|
+
* Whether the upgrade presented an admin credential: the master
|
|
6448
|
+
* `LUNORA_ADMIN_TOKEN` (constant-time compared) or a short-lived sub-token
|
|
6449
|
+
* the worker minted with it (`POST /_lunora/admin/ws-token` —
|
|
6450
|
+
* HMAC-verified statelessly here, since both isolates hold the master token
|
|
6451
|
+
* in `env`). The ephemeral token is what the studio sends in `?token=`, so
|
|
6452
|
+
* the master credential stays out of URLs/logs. Closed (resolves `false`)
|
|
6453
|
+
* when the admin token is unset, mirroring `isAdminAuthorized` for the HTTP
|
|
6454
|
+
* path so admin streaming is opt-in rather than exposed by default.
|
|
6455
|
+
*
|
|
6456
|
+
* Enforcement: with `LUNORA_REQUIRE_EPHEMERAL_WS_TOKEN` set
|
|
6457
|
+
* (`1`/`true`/`on`/`yes`/`enabled`), a raw master token in the
|
|
6458
|
+
* `?token=` query parameter is rejected — the query string is exactly
|
|
6459
|
+
* where it leaks. The `Authorization` header path still takes the master
|
|
6460
|
+
* token: browsers can't set it on a WS upgrade, so it never rides a URL.
|
|
3834
6461
|
*/
|
|
3835
|
-
isAdminSocket(request) {
|
|
6462
|
+
async isAdminSocket(request) {
|
|
3836
6463
|
const env = this.env ?? {};
|
|
3837
6464
|
const adminToken = env.LUNORA_ADMIN_TOKEN;
|
|
3838
6465
|
if (!adminToken || adminToken.length === 0) {
|
|
3839
6466
|
return false;
|
|
3840
6467
|
}
|
|
3841
6468
|
const supplied = this.suppliedWsToken(request);
|
|
3842
|
-
|
|
6469
|
+
if (supplied === void 0) {
|
|
6470
|
+
return false;
|
|
6471
|
+
}
|
|
6472
|
+
if (await verifyWsAdminToken(adminToken, supplied)) {
|
|
6473
|
+
return true;
|
|
6474
|
+
}
|
|
6475
|
+
const fromQuery = extractBearerToken(request.headers.get("authorization")) === void 0;
|
|
6476
|
+
const requireEphemeral = REQUIRE_EPHEMERAL_ENV_VALUES.has((env.LUNORA_REQUIRE_EPHEMERAL_WS_TOKEN ?? "").trim().toLowerCase());
|
|
6477
|
+
if (fromQuery && requireEphemeral) {
|
|
6478
|
+
return false;
|
|
6479
|
+
}
|
|
6480
|
+
return constantTimeEqual(supplied, adminToken);
|
|
3843
6481
|
}
|
|
3844
6482
|
/**
|
|
3845
6483
|
* Register the hibernation-safe ping/pong keepalive. The runtime answers a
|
|
@@ -3858,10 +6496,34 @@ class ShardDO {
|
|
|
3858
6496
|
}
|
|
3859
6497
|
setter.call(this.state, new WebSocketRequestResponsePair(WS_KEEPALIVE_PING, WS_KEEPALIVE_PONG));
|
|
3860
6498
|
}
|
|
3861
|
-
|
|
3862
|
-
|
|
6499
|
+
/**
|
|
6500
|
+
* Route the non-RPC requests `fetch` handles before the shard-local RPC
|
|
6501
|
+
* endpoint: a WebSocket upgrade, and the internal `/_lunora/relay` owner↔relay
|
|
6502
|
+
* control channel (never reachable by a client — the runtime forwards only
|
|
6503
|
+
* worker-internal traffic there). Returns `undefined` for an RPC request, which
|
|
6504
|
+
* `fetch` then dispatches.
|
|
6505
|
+
* @returns the routed response, or `undefined` when this is an RPC request
|
|
6506
|
+
*/
|
|
6507
|
+
async routeNonRpc(url, request) {
|
|
6508
|
+
if (url.pathname === "/_lunora/relay" && request.method === "POST") {
|
|
6509
|
+
return this.relay ? await this.relay.handleControl(request) : new Response("relay tier inactive", { status: 404 });
|
|
6510
|
+
}
|
|
6511
|
+
if (url.pathname === "/_lunora/route" && request.method === "GET") {
|
|
6512
|
+
return jsonResponse({ relayCount: this.relay?.relayCount() ?? 0 });
|
|
6513
|
+
}
|
|
6514
|
+
if (url.pathname === "/rpc-batch" && request.method === "POST") {
|
|
6515
|
+
return this.handleBatchRpc(request);
|
|
6516
|
+
}
|
|
6517
|
+
if (request.headers.get("Upgrade") === "websocket") {
|
|
6518
|
+
return this.handleWebSocketUpgrade(request);
|
|
6519
|
+
}
|
|
6520
|
+
return void 0;
|
|
6521
|
+
}
|
|
6522
|
+
async handleWebSocketUpgrade(request) {
|
|
6523
|
+
if (!await this.isUpgradeAllowed(request)) {
|
|
3863
6524
|
return new Response("Forbidden", { status: 403 });
|
|
3864
6525
|
}
|
|
6526
|
+
const admin = await this.isAdminSocket(request);
|
|
3865
6527
|
const pair = new WebSocketPair();
|
|
3866
6528
|
const client = pair[0];
|
|
3867
6529
|
const server = pair[1];
|
|
@@ -3871,7 +6533,7 @@ class ShardDO {
|
|
|
3871
6533
|
const expiresAtRaw = Number(request.headers.get("x-lunora-identity-exp"));
|
|
3872
6534
|
const expiresAt = Number.isFinite(expiresAtRaw) && expiresAtRaw > 0 ? expiresAtRaw : void 0;
|
|
3873
6535
|
server.serializeAttachment?.({
|
|
3874
|
-
admin
|
|
6536
|
+
admin,
|
|
3875
6537
|
connectionId: crypto.randomUUID(),
|
|
3876
6538
|
subs: {},
|
|
3877
6539
|
...expiresAt === void 0 ? {} : { expiresAt },
|
|
@@ -3975,7 +6637,7 @@ class ShardDO {
|
|
|
3975
6637
|
* topic name. That matches the AnyCable model (and `from` is unforgeable),
|
|
3976
6638
|
* but per-topic auth does not exist here; see `whisperSubscribe` on the client.
|
|
3977
6639
|
*/
|
|
3978
|
-
broadcastWhisper(sender, topic, data) {
|
|
6640
|
+
async broadcastWhisper(sender, topic, data) {
|
|
3979
6641
|
if (!this.allowWhisper(sender)) {
|
|
3980
6642
|
return;
|
|
3981
6643
|
}
|
|
@@ -3986,15 +6648,29 @@ class ShardDO {
|
|
|
3986
6648
|
const from = this.readAttachment(sender).userId;
|
|
3987
6649
|
const fromSuffix = from === void 0 ? "" : `,"from":${JSON.stringify(from)}`;
|
|
3988
6650
|
const frame = `{"type":"whisper","topic":${JSON.stringify(topic)},"data":${dataJson}${fromSuffix}}`;
|
|
6651
|
+
this.deliverWhisperLocal(topic, frame, sender);
|
|
6652
|
+
await this.relay?.forwardWhisper(topic, frame);
|
|
6653
|
+
}
|
|
6654
|
+
/**
|
|
6655
|
+
* Deliver an already-serialized whisper `frame` to every local socket joined to
|
|
6656
|
+
* `topic`, excluding `exclude` (the sender, or `undefined` for a frame the relay
|
|
6657
|
+
* hub forwarded in — its sender lives on another DO). Records the fan-out pass
|
|
6658
|
+
* for `getFanoutMetrics` (plan 075 Phase 1). Pure delivery — no SQLite, no CDC.
|
|
6659
|
+
* @returns the number of sockets the frame was sent to
|
|
6660
|
+
*/
|
|
6661
|
+
deliverWhisperLocal(topic, frame, exclude) {
|
|
6662
|
+
let scanned = 0;
|
|
6663
|
+
let delivered = 0;
|
|
3989
6664
|
for (const ws of this.state.getWebSockets()) {
|
|
3990
|
-
|
|
6665
|
+
scanned += 1;
|
|
6666
|
+
if (ws === exclude || this.readAttachment(ws).whispers?.includes(topic) !== true) {
|
|
3991
6667
|
continue;
|
|
3992
6668
|
}
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
} catch {
|
|
3996
|
-
}
|
|
6669
|
+
trySendFrame(ws, frame);
|
|
6670
|
+
delivered += 1;
|
|
3997
6671
|
}
|
|
6672
|
+
this.fanout.whisper = recordFanoutPass(this.fanout.whisper, scanned, delivered, 0);
|
|
6673
|
+
return delivered;
|
|
3998
6674
|
}
|
|
3999
6675
|
// eslint-disable-next-line class-methods-use-this -- cohesive DO instance method grouped with the hibernation/attachment helpers; reads only the socket
|
|
4000
6676
|
readAttachment(ws) {
|