@lunora/do 1.0.0-alpha.7 → 1.0.0-alpha.71

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.
Files changed (52) hide show
  1. package/LICENSE.md +6 -0
  2. package/dist/index.d.mts +3394 -5259
  3. package/dist/index.d.ts +3394 -5259
  4. package/dist/index.mjs +1 -36
  5. package/dist/packem_shared/ROOT_DO_SIZE_WARN_BYTES-Bze_kj6L.mjs +13 -0
  6. package/dist/packem_shared/SESSION_DO_TTL_DEFAULT-Dan63qLN.mjs +1 -0
  7. package/dist/packem_shared/SHARD_REGISTRY_DO_NAME-tOQVzDCB.mjs +1 -0
  8. package/dist/packem_shared/constant-time-equal-BVG05Guz.mjs +1 -0
  9. package/dist/packem_shared/json-response-wrh9TBPw.mjs +1 -0
  10. package/dist/packem_shared/serveRelationFanout-Cbmlso1k.mjs +1 -0
  11. package/package.json +6 -2
  12. package/dist/packem_shared/ADMIN_FUNCTIONS-CHcC8fKV.mjs +0 -314
  13. package/dist/packem_shared/AGGREGATE_SQL_FUNCTION-CFk6adSu.mjs +0 -54
  14. package/dist/packem_shared/AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs +0 -84
  15. package/dist/packem_shared/CDC_LOG_TABLE-Ctdmxmrv.mjs +0 -103
  16. package/dist/packem_shared/ConflictError-C0STs6bU.mjs +0 -13
  17. package/dist/packem_shared/CountRlsUnsupportedError-28ZvvwKS.mjs +0 -133
  18. package/dist/packem_shared/DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs +0 -237
  19. package/dist/packem_shared/DEFAULT_MAX_RELATION_KEYS-Dou2PWdO.mjs +0 -160
  20. package/dist/packem_shared/FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs +0 -248
  21. package/dist/packem_shared/LogBuffer-B_Ezju_N.mjs +0 -37
  22. package/dist/packem_shared/MAIL_RETENTION-CPpgl-dX.mjs +0 -104
  23. package/dist/packem_shared/MAX_SQL_ROWS-dDcFE1YZ.mjs +0 -29
  24. package/dist/packem_shared/MIN_ADMIN_TOKEN_LENGTH-CCAvoFlr.mjs +0 -1
  25. package/dist/packem_shared/NotFoundError-CMuMZt81.mjs +0 -10
  26. package/dist/packem_shared/NotUniqueError-h_thNFSZ.mjs +0 -1809
  27. package/dist/packem_shared/RANK_TIEBREAK-C6blLR5K.mjs +0 -1
  28. package/dist/packem_shared/RLS_UNWRAP_SYMBOL-EtGQdC9d.mjs +0 -132
  29. package/dist/packem_shared/ROOT_DO_SIZE_WARN_BYTES-2DxWrdla.mjs +0 -3958
  30. package/dist/packem_shared/ReactiveCache-ByVzgH3d.mjs +0 -259
  31. package/dist/packem_shared/SCAN_DEP-DLJF8dsj.mjs +0 -19
  32. package/dist/packem_shared/SESSION_DO_TTL_DEFAULT-ilPZsVwu.mjs +0 -180
  33. package/dist/packem_shared/SHARD_REGISTRY_DO_NAME-BsAbi5Mn.mjs +0 -146
  34. package/dist/packem_shared/aggregateTableName-CxNqY1Sl.mjs +0 -64
  35. package/dist/packem_shared/applyOnDelete-sA7o1CqD.mjs +0 -169
  36. package/dist/packem_shared/applySelect-BvZdFUBT.mjs +0 -101
  37. package/dist/packem_shared/armRestore-BJk53Ro8.mjs +0 -55
  38. package/dist/packem_shared/backfillAggregateIndexes-BbVPvciS.mjs +0 -80
  39. package/dist/packem_shared/buildFtsMatch-BLEMawrp.mjs +0 -38
  40. package/dist/packem_shared/compileWhereSql-CXrhFA3G.mjs +0 -127
  41. package/dist/packem_shared/createSystemReader-8CzSZP9V.mjs +0 -80
  42. package/dist/packem_shared/ctx-db-idempotency-DkC9rP91.mjs +0 -35
  43. package/dist/packem_shared/do-exec-5eQy5cEi.mjs +0 -12
  44. package/dist/packem_shared/do-sql-BCHCWtrD.mjs +0 -87
  45. package/dist/packem_shared/exportShardRows-DZEhUeyI.mjs +0 -156
  46. package/dist/packem_shared/hasTrigger-5N6_Fx0A.mjs +0 -20
  47. package/dist/packem_shared/rank-CrkEIpF4.mjs +0 -102
  48. package/dist/packem_shared/renderSql-D6eUcn2N.mjs +0 -16
  49. package/dist/packem_shared/runShardMigrations-PabobOjF.mjs +0 -103
  50. package/dist/packem_shared/security-audit-CucgBice.mjs +0 -158
  51. package/dist/packem_shared/serveRelationFanout-oxaM6_WL.mjs +0 -24
  52. package/dist/packem_shared/subscriptionListDeltas-ce84gpwL.mjs +0 -111
@@ -1,3958 +0,0 @@
1
- import { drizzle } from 'drizzle-orm/durable-sqlite';
2
- import { parseExportShardArgs, parseImportShardArgs } from './exportShardRows-DZEhUeyI.mjs';
3
- import { recordAuthEvent, readAuthMetrics } from './AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs';
4
- import { DATA_MIGRATION_STATE_TABLE, readMigrationStatus } from './DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs';
5
- import { SCAN_DEP, createDependencyTracker, tableFromDepKey } from './SCAN_DEP-DLJF8dsj.mjs';
6
- import { readFunctionMetricsTotals, readFunctionMetricIndexHits, recordFunctionMetric, mergeScanAttribution, readFunctionMetrics, readFunctionMetricBuckets } from './FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs';
7
- import { ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, selectMatchingIds, ADMIN_FUNCTIONS, findStorageReferences, listTables, summarizeSubscriptions, readTablePage, facetColumn, MAX_PAGE_SIZE } from './ADMIN_FUNCTIONS-CHcC8fKV.mjs';
8
- import { LogBuffer } from './LogBuffer-B_Ezju_N.mjs';
9
- import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './MAIL_RETENTION-CPpgl-dX.mjs';
10
- import { readBookmark, armRestore } from './armRestore-BJk53Ro8.mjs';
11
- import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-ByVzgH3d.mjs';
12
- import { redact, standardRules } from '@visulima/redact';
13
- import { i as isDevEnvironment, c as buildSettings, b as buildSecurityAudit } from './security-audit-CucgBice.mjs';
14
- import { runReadonlySql } from './MAX_SQL_ROWS-dDcFE1YZ.mjs';
15
- import { trySendFrame, subscriptionListDeltas, sendDeltaFrames } from './subscriptionListDeltas-ce84gpwL.mjs';
16
- import { ConflictError } from './ConflictError-C0STs6bU.mjs';
17
- import { CDC_LOG_TABLE, readCdcChanges, readCdcCursor, readCdcEpoch, minCdcSeq, bumpCdcEpoch } from './CDC_LOG_TABLE-Ctdmxmrv.mjs';
18
- import { r as readIdempotent, w as writeIdempotent, t as trimIdempotent } from './ctx-db-idempotency-DkC9rP91.mjs';
19
-
20
- const AUDIT_LOG_TABLE = "__lunora_audit__";
21
- const AUDIT_LOG_RETENTION = 1e3;
22
- const runSql$2 = (sql, query, ...params) => {
23
- const runner = sql.exec;
24
- return runner.call(sql, query, ...params);
25
- };
26
- const ensureAuditTable = (sql) => {
27
- runSql$2(
28
- sql,
29
- `CREATE TABLE IF NOT EXISTS "${AUDIT_LOG_TABLE}" (
30
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
31
- ts REAL NOT NULL,
32
- op TEXT NOT NULL,
33
- "table" TEXT,
34
- id TEXT,
35
- detail TEXT
36
- )`
37
- );
38
- };
39
- const appendAuditEntry = (sql, entry) => {
40
- ensureAuditTable(sql);
41
- runSql$2(
42
- sql,
43
- `INSERT INTO "${AUDIT_LOG_TABLE}" (ts, op, "table", id, detail) VALUES (?, ?, ?, ?, ?)`,
44
- entry.ts,
45
- entry.op,
46
- // eslint-disable-next-line unicorn/no-null -- SQL NULL is the correct value for an op with no associated table/id/detail.
47
- entry.table ?? null,
48
- // eslint-disable-next-line unicorn/no-null -- SQL NULL is the correct value for an op with no associated table/id/detail.
49
- entry.id ?? null,
50
- // eslint-disable-next-line unicorn/no-null -- SQL NULL is the correct value for an op with no associated table/id/detail.
51
- entry.detail === void 0 ? null : JSON.stringify(entry.detail)
52
- );
53
- runSql$2(sql, `DELETE FROM "${AUDIT_LOG_TABLE}" WHERE seq <= (SELECT MAX(seq) - ? FROM "${AUDIT_LOG_TABLE}")`, AUDIT_LOG_RETENTION);
54
- };
55
- const readAuditLog = (sql, options = {}) => {
56
- ensureAuditTable(sql);
57
- const sinceSeq = options.sinceSeq ?? 0;
58
- const limit = Math.max(1, Math.min(options.limit ?? AUDIT_LOG_RETENTION, 1e4));
59
- const rows = runSql$2(
60
- sql,
61
- `SELECT seq, ts, op, "table", id, detail FROM "${AUDIT_LOG_TABLE}" WHERE seq > ? ORDER BY seq DESC LIMIT ?`,
62
- sinceSeq,
63
- limit
64
- ).toArray();
65
- return rows.map((row) => {
66
- const base = { op: row.op, seq: row.seq, ts: row.ts };
67
- if (row.table !== null) {
68
- base.table = row.table;
69
- }
70
- if (row.id !== null) {
71
- base.id = row.id;
72
- }
73
- if (row.detail !== null) {
74
- base.detail = JSON.parse(row.detail);
75
- }
76
- return base;
77
- });
78
- };
79
-
80
- const QUERY_METRICS_TABLE = "__lunora_metrics_queries";
81
- const QUERY_METRICS_MAX_SQL_LEN = 512;
82
- const QUERY_METRICS_MAX_STATEMENTS = 500;
83
- const runSql$1 = (sql, query, ...params) => {
84
- const runner = sql.exec;
85
- return runner.call(sql, query, ...params);
86
- };
87
- const normalizeSql = (sql) => {
88
- let normalized = sql.replaceAll(/'(?:[^']|'')*'/g, "?").replaceAll(/\b0x[\da-f]+\b/gi, "?").replaceAll(/(?<=[=,([\s])\d+(?:\.\d+)?/g, "?").replaceAll(/\s+/g, " ").trim();
89
- if (normalized.length > QUERY_METRICS_MAX_SQL_LEN) {
90
- normalized = `${normalized.slice(0, QUERY_METRICS_MAX_SQL_LEN - 1)}…`;
91
- }
92
- return normalized;
93
- };
94
- const ensureQueryMetricsTable = (sql) => {
95
- runSql$1(
96
- sql,
97
- `CREATE TABLE IF NOT EXISTS "${QUERY_METRICS_TABLE}" (
98
- normalized_sql TEXT PRIMARY KEY,
99
- exec_count INTEGER NOT NULL DEFAULT 0,
100
- total_duration_ms REAL NOT NULL DEFAULT 0,
101
- rows_read INTEGER NOT NULL DEFAULT 0,
102
- rows_written INTEGER NOT NULL DEFAULT 0
103
- )`
104
- );
105
- };
106
- const recordQueryMetric = (sql, rawSql, durationMs, rowsRead, rowsWritten) => {
107
- const normalized = normalizeSql(rawSql);
108
- if (normalized.length === 0) {
109
- return;
110
- }
111
- ensureQueryMetricsTable(sql);
112
- const countRow = runSql$1(sql, `SELECT COUNT(*) AS n FROM "${QUERY_METRICS_TABLE}"`).one();
113
- const count = countRow.n;
114
- if (count >= QUERY_METRICS_MAX_STATEMENTS) {
115
- const existing = runSql$1(sql, `SELECT COUNT(*) AS c FROM "${QUERY_METRICS_TABLE}" WHERE normalized_sql = ?`, normalized).one();
116
- if (existing.c === 0) {
117
- return;
118
- }
119
- }
120
- const upsertSql = `INSERT INTO "${QUERY_METRICS_TABLE}" (normalized_sql, exec_count, total_duration_ms, rows_read, rows_written)
121
- VALUES (?, 1, ?, ?, ?)
122
- ON CONFLICT(normalized_sql) DO UPDATE SET
123
- exec_count = exec_count + 1,
124
- total_duration_ms = total_duration_ms + excluded.total_duration_ms,
125
- rows_read = rows_read + excluded.rows_read,
126
- rows_written = rows_written + excluded.rows_written`;
127
- runSql$1(sql, upsertSql, normalized, durationMs, rowsRead, rowsWritten);
128
- };
129
- const readQueryMetrics = (sql) => {
130
- ensureQueryMetricsTable(sql);
131
- const rows = runSql$1(
132
- sql,
133
- `SELECT normalized_sql, exec_count, total_duration_ms, rows_read, rows_written FROM "${QUERY_METRICS_TABLE}" ORDER BY total_duration_ms DESC`
134
- ).toArray();
135
- return rows.map((row) => {
136
- return {
137
- execCount: row.exec_count,
138
- normalizedSql: row.normalized_sql,
139
- rowsRead: row.rows_read,
140
- rowsWritten: row.rows_written,
141
- totalDurationMs: row.total_duration_ms
142
- };
143
- });
144
- };
145
-
146
- const REQUEST_LOG_TABLE = "__lunora_reqlog__";
147
- const REQUEST_LOG_RETENTION = 1e3;
148
- const REQUEST_LOG_EVENT_SOURCE = "lunora";
149
- const runSql = (sql, query, ...params) => {
150
- const runner = sql.exec;
151
- return runner.call(sql, query, ...params);
152
- };
153
- const redactArgs = (value, captureRaw = false) => {
154
- if (captureRaw || value === null || value === void 0) {
155
- return value;
156
- }
157
- return redact(value, standardRules);
158
- };
159
- const ensureRequestLogTable = (sql) => {
160
- runSql(
161
- sql,
162
- `CREATE TABLE IF NOT EXISTS "${REQUEST_LOG_TABLE}" (
163
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
164
- ts REAL NOT NULL,
165
- function_path TEXT NOT NULL,
166
- shard_key TEXT,
167
- user_id TEXT,
168
- identity TEXT,
169
- args TEXT,
170
- outcome TEXT NOT NULL,
171
- error_message TEXT,
172
- duration_ms REAL NOT NULL,
173
- tables_read TEXT NOT NULL DEFAULT '[]',
174
- tables_written TEXT NOT NULL DEFAULT '[]',
175
- cache_hit INTEGER,
176
- subscriptions_rerun INTEGER NOT NULL DEFAULT 0
177
- )`
178
- );
179
- };
180
- const encodeTables = (tables) => JSON.stringify([...new Set(tables)].toSorted((a, b) => a.localeCompare(b)));
181
- const cacheHitColumn = (cacheHit) => {
182
- if (cacheHit === void 0) {
183
- return null;
184
- }
185
- return cacheHit ? 1 : 0;
186
- };
187
- const appendRequestLogEntry = (sql, entry, options = {}) => {
188
- ensureRequestLogTable(sql);
189
- const captureRaw = options.captureRaw ?? false;
190
- const retention = options.retention ?? REQUEST_LOG_RETENTION;
191
- runSql(
192
- sql,
193
- `INSERT INTO "${REQUEST_LOG_TABLE}"
194
- (ts, function_path, shard_key, user_id, identity, args, outcome, error_message, duration_ms, tables_read, tables_written, cache_hit, subscriptions_rerun)
195
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
196
- entry.ts,
197
- entry.functionPath,
198
- // eslint-disable-next-line unicorn/no-null -- SQL NULL is the correct value for a request with no shard key / anonymous caller / absent field.
199
- entry.shardKey ?? null,
200
- // eslint-disable-next-line unicorn/no-null -- anonymous request: no acting user.
201
- entry.userId ?? null,
202
- // Identity claims (email/name/roles) are PII, so they're redacted by
203
- // default exactly like args — keeping the envelope's shape for the
204
- // studio while keeping raw PII out of the durable log. The opaque
205
- // `user_id` column above stays raw; it's the non-PII correlation key the
206
- // `getRequestLog` filters key on.
207
- // eslint-disable-next-line unicorn/no-null -- anonymous request or no claims attached.
208
- entry.identity === void 0 ? null : JSON.stringify(redactArgs(entry.identity, captureRaw)),
209
- // eslint-disable-next-line unicorn/no-null -- no args were sent on this dispatch.
210
- entry.redactedArgs === void 0 ? null : JSON.stringify(redactArgs(entry.redactedArgs, captureRaw)),
211
- entry.outcome,
212
- // eslint-disable-next-line unicorn/no-null -- success path: no error message.
213
- entry.errorMessage ?? null,
214
- entry.durationMs,
215
- encodeTables(entry.tablesRead),
216
- encodeTables(entry.tablesWritten),
217
- cacheHitColumn(entry.cacheHit),
218
- entry.subscriptionsReRun ?? 0
219
- );
220
- runSql(sql, `DELETE FROM "${REQUEST_LOG_TABLE}" WHERE seq <= (SELECT MAX(seq) - ? FROM "${REQUEST_LOG_TABLE}")`, retention);
221
- };
222
- const emitRequestLogEvent = (entry, options = {}) => {
223
- const captureRaw = options.captureRaw ?? false;
224
- const event = {
225
- args: entry.redactedArgs === void 0 ? void 0 : redactArgs(entry.redactedArgs, captureRaw),
226
- cacheHit: entry.cacheHit,
227
- durationMs: entry.durationMs,
228
- error: entry.errorMessage,
229
- function: entry.functionPath,
230
- identity: entry.identity === void 0 ? void 0 : redactArgs(entry.identity, captureRaw),
231
- outcome: entry.outcome,
232
- shard: entry.shardKey,
233
- source: REQUEST_LOG_EVENT_SOURCE,
234
- tablesRead: entry.tablesRead ?? [],
235
- tablesWritten: entry.tablesWritten ?? [],
236
- ts: entry.ts,
237
- type: "request",
238
- userId: entry.userId
239
- };
240
- const line = JSON.stringify(event);
241
- if (entry.outcome === "error") {
242
- console.error(line);
243
- } else {
244
- console.log(line);
245
- }
246
- };
247
- const LOG_EVENT_TYPE = "log";
248
- const renderLogMessage = (args) => args.map((value) => {
249
- if (typeof value === "string") {
250
- return value;
251
- }
252
- try {
253
- const json = JSON.stringify(value);
254
- return json ?? String(value);
255
- } catch {
256
- return String(value);
257
- }
258
- }).join(" ");
259
- const emitLogEvent = (input) => {
260
- const line = JSON.stringify({
261
- function: input.functionPath,
262
- level: input.level,
263
- message: input.message,
264
- shard: input.shardKey,
265
- source: REQUEST_LOG_EVENT_SOURCE,
266
- ts: input.ts,
267
- type: LOG_EVENT_TYPE,
268
- userId: input.userId
269
- });
270
- if (input.level === "error") {
271
- console.error(line);
272
- } else if (input.level === "warn") {
273
- console.warn(line);
274
- } else {
275
- console.log(line);
276
- }
277
- };
278
- const escapeLike = (value) => value.replaceAll(/[\\%_]/g, (character) => `\\${character}`);
279
- const decodeTables = (text) => {
280
- try {
281
- const value = JSON.parse(text);
282
- return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
283
- } catch {
284
- return [];
285
- }
286
- };
287
- const readRequestLog = (sql, options = {}) => {
288
- ensureRequestLogTable(sql);
289
- const limit = Math.max(1, Math.min(options.limit ?? REQUEST_LOG_RETENTION, 1e4));
290
- const conjuncts = ["seq > ?"];
291
- const parameters = [options.sinceSeq ?? 0];
292
- if (options.functionPathPrefix !== void 0 && options.functionPathPrefix !== "") {
293
- conjuncts.push(String.raw`function_path LIKE ? ESCAPE '\'`);
294
- parameters.push(`${escapeLike(options.functionPathPrefix)}%`);
295
- }
296
- if (options.userId !== void 0 && options.userId !== "") {
297
- conjuncts.push("user_id = ?");
298
- parameters.push(options.userId);
299
- }
300
- if (options.shardKey !== void 0 && options.shardKey !== "") {
301
- conjuncts.push("shard_key = ?");
302
- parameters.push(options.shardKey);
303
- }
304
- if (options.outcome !== void 0) {
305
- conjuncts.push("outcome = ?");
306
- parameters.push(options.outcome);
307
- }
308
- if (options.tableTouched !== void 0 && options.tableTouched !== "") {
309
- const needle = `%${escapeLike(JSON.stringify(options.tableTouched))}%`;
310
- conjuncts.push(String.raw`(tables_read LIKE ? ESCAPE '\' OR tables_written LIKE ? ESCAPE '\')`);
311
- parameters.push(needle, needle);
312
- }
313
- parameters.push(limit);
314
- const rows = runSql(
315
- sql,
316
- `SELECT seq, ts, function_path, shard_key, user_id, identity, args, outcome, error_message, duration_ms, tables_read, tables_written, cache_hit, subscriptions_rerun
317
- FROM "${REQUEST_LOG_TABLE}" WHERE ${conjuncts.join(" AND ")} ORDER BY seq DESC LIMIT ?`,
318
- ...parameters
319
- ).toArray();
320
- return rows.map((row) => {
321
- const base = {
322
- durationMs: row.duration_ms,
323
- functionPath: row.function_path,
324
- outcome: row.outcome === "error" ? "error" : "ok",
325
- seq: row.seq,
326
- subscriptionsReRun: row.subscriptions_rerun,
327
- tablesRead: decodeTables(row.tables_read),
328
- tablesWritten: decodeTables(row.tables_written),
329
- ts: row.ts
330
- };
331
- if (row.shard_key !== null) {
332
- base.shardKey = row.shard_key;
333
- }
334
- if (row.user_id !== null) {
335
- base.userId = row.user_id;
336
- }
337
- if (row.identity !== null) {
338
- base.identity = JSON.parse(row.identity);
339
- }
340
- if (row.args !== null) {
341
- base.redactedArgs = JSON.parse(row.args);
342
- }
343
- if (row.error_message !== null) {
344
- base.errorMessage = row.error_message;
345
- }
346
- if (row.cache_hit !== null) {
347
- base.cacheHit = row.cache_hit === 1;
348
- }
349
- return base;
350
- });
351
- };
352
-
353
- const DANGLING_SCAN_CAP = 5e3;
354
- const DANGLING_RESULT_CAP = 500;
355
- const DOC_COLUMN = "__doc__";
356
- const isInternalTable = (name) => name.startsWith("sqlite_") || name.startsWith("_cf_") || name.startsWith("__miniflare") || name.startsWith("__lunora") || name.includes("__fts_");
357
- const quoteIdentifier = (name) => `"${name.replaceAll('"', '""')}"`;
358
- const tableExists = (sql, table) => {
359
- if (isInternalTable(table)) {
360
- return false;
361
- }
362
- return sql.exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", table).toArray().length > 0;
363
- };
364
- const resolveColumnExpression = (column, physicalColumns) => {
365
- const isPhysical = physicalColumns.includes(column);
366
- const isDocumentStored = physicalColumns.includes(DOC_COLUMN);
367
- if (!isPhysical && !isDocumentStored) {
368
- return void 0;
369
- }
370
- return isPhysical ? { expression: quoteIdentifier(column), params: [] } : { expression: `json_extract(${quoteIdentifier(DOC_COLUMN)}, ?)`, params: [`$."${column.replaceAll('"', '""')}"`] };
371
- };
372
- const scanColumnForDangling = (sql, quoted, table, column, physicalColumns, live, accumulator) => {
373
- const resolved = resolveColumnExpression(column, physicalColumns);
374
- if (resolved === void 0) {
375
- return;
376
- }
377
- const rows = sql.exec(
378
- `SELECT id, ${resolved.expression} AS ref FROM ${quoted} WHERE ${resolved.expression} IS NOT NULL AND ${resolved.expression} <> '' LIMIT ?`,
379
- ...resolved.params,
380
- ...resolved.params,
381
- ...resolved.params,
382
- DANGLING_SCAN_CAP + 1
383
- ).toArray();
384
- if (rows.length > DANGLING_SCAN_CAP) {
385
- accumulator.truncated = true;
386
- }
387
- for (const row of rows.slice(0, DANGLING_SCAN_CAP)) {
388
- accumulator.scanned += 1;
389
- if (live.has(row.ref)) {
390
- continue;
391
- }
392
- if (accumulator.references.length >= DANGLING_RESULT_CAP) {
393
- accumulator.truncated = true;
394
- continue;
395
- }
396
- accumulator.references.push({ column, id: row.id, key: row.ref, table });
397
- }
398
- };
399
- const findDanglingReferences = (sql, storageColumns, liveKeys) => {
400
- const live = liveKeys instanceof Set ? liveKeys : new Set(liveKeys);
401
- const accumulator = { references: [], scanned: 0, truncated: false };
402
- for (const [table, columns] of Object.entries(storageColumns)) {
403
- if (!tableExists(sql, table)) {
404
- continue;
405
- }
406
- const quoted = quoteIdentifier(table);
407
- const physicalColumns = sql.exec(`PRAGMA table_info(${quoted})`).toArray().map((column) => column.name);
408
- for (const column of columns) {
409
- scanColumnForDangling(sql, quoted, table, column, physicalColumns, live, accumulator);
410
- }
411
- }
412
- return accumulator;
413
- };
414
-
415
- const WS_KEEPALIVE_PING = "lunora-ping";
416
- const WS_KEEPALIVE_PONG = "lunora-pong";
417
- const UNDELIVERED_BASELINE = "<undelivered>";
418
- const ROOT_DO_SIZE_WARN_BYTES = 1073741824;
419
- const CDC_RESUME_SCAN_LIMIT = 1e4;
420
- const IDEMPOTENCY_RETENTION_MS = 864e5;
421
- const IDEMPOTENCY_GC_INTERVAL_MS = 36e5;
422
- const ROOT_SHARD_NAME = "__root__";
423
- const ADMIN_WILDCARD = "*";
424
- const awaitWsDrain = async (ws) => {
425
- let attempts = 0;
426
- while (attempts < 100) {
427
- attempts += 1;
428
- const buffered = ws.bufferedAmount;
429
- if (typeof buffered !== "number" || buffered < 1048576) {
430
- return;
431
- }
432
- await new Promise((resolve) => {
433
- setTimeout(resolve, 20);
434
- });
435
- }
436
- };
437
- const cdcSuffix = (cursor, epoch) => (cursor === void 0 ? "" : `,"cursor":${String(cursor)}`) + (epoch === void 0 ? "" : `,"epoch":${JSON.stringify(epoch)}`);
438
- const setsIntersect = (a, b) => {
439
- const [small, large] = a.size <= b.size ? [a, b] : [b, a];
440
- for (const value of small) {
441
- if (large.has(value)) {
442
- return true;
443
- }
444
- }
445
- return false;
446
- };
447
- const parseRunMigrationArgs = (args) => {
448
- const id = typeof args["id"] === "string" ? args["id"] : "";
449
- if (id.trim() === "") {
450
- throw Object.assign(new Error("runMigration: `id` is required"), { code: "MIGRATION_ID_REQUIRED", name: "LunoraError", status: 400 });
451
- }
452
- return {
453
- batchSize: typeof args["batchSize"] === "number" ? args["batchSize"] : void 0,
454
- direction: args["direction"] === "down" ? "down" : "up",
455
- dryRun: args["dryRun"] === true,
456
- id,
457
- maxBatches: typeof args["maxBatches"] === "number" ? args["maxBatches"] : void 0
458
- };
459
- };
460
- const SHARD_BULK_DELETE_CAP = MAX_PAGE_SIZE;
461
- const parseWriteRowArgs = (args) => {
462
- const { op } = args;
463
- const table = typeof args["table"] === "string" ? args["table"] : "";
464
- if (op !== "insert" && op !== "patch" && op !== "replace" && op !== "delete") {
465
- throw Object.assign(new Error("writeRow: `op` must be insert|patch|replace|delete"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
466
- }
467
- if (table.trim() === "") {
468
- throw Object.assign(new Error("writeRow: `table` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
469
- }
470
- const id = typeof args["id"] === "string" ? args["id"] : void 0;
471
- const record = typeof args["doc"] === "object" && args["doc"] !== null && !Array.isArray(args["doc"]) ? args["doc"] : void 0;
472
- if (op !== "insert" && (id === void 0 || id === "")) {
473
- throw Object.assign(new Error(`writeRow: \`id\` is required for op "${op}"`), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
474
- }
475
- if (op !== "delete" && record === void 0) {
476
- throw Object.assign(new Error(`writeRow: \`doc\` is required for op "${op}"`), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
477
- }
478
- return { doc: record, id, op, table };
479
- };
480
- const parseCreateWorkflowInstanceArgs = (args) => {
481
- const exportName = typeof args["exportName"] === "string" ? args["exportName"].trim() : "";
482
- if (exportName === "") {
483
- throw Object.assign(new Error("createWorkflowInstance: `exportName` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
484
- }
485
- const id = typeof args["id"] === "string" && args["id"] !== "" ? args["id"] : void 0;
486
- return { exportName, id, params: args["params"] };
487
- };
488
- const parseGetWorkflowInstanceStatusArgs = (args) => {
489
- const exportName = typeof args["exportName"] === "string" ? args["exportName"].trim() : "";
490
- const id = typeof args["id"] === "string" ? args["id"].trim() : "";
491
- if (exportName === "") {
492
- throw Object.assign(new Error("getWorkflowInstanceStatus: `exportName` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
493
- }
494
- if (id === "") {
495
- throw Object.assign(new Error("getWorkflowInstanceStatus: `id` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
496
- }
497
- return { exportName, id };
498
- };
499
- const WORKFLOW_INSTANCE_STATES = /* @__PURE__ */ new Set([
500
- "complete",
501
- "errored",
502
- "paused",
503
- "queued",
504
- "running",
505
- "terminated",
506
- "unknown",
507
- "waiting",
508
- "waitingForPause"
509
- ]);
510
- const toWorkflowInstanceState = (raw) => typeof raw === "string" && WORKFLOW_INSTANCE_STATES.has(raw) ? raw : "unknown";
511
- const toWorkflowInstanceError = (raw) => {
512
- if (typeof raw !== "object" || raw === null) {
513
- return void 0;
514
- }
515
- const { message, name } = raw;
516
- return { message: typeof message === "string" ? message : "", name: typeof name === "string" ? name : "Error" };
517
- };
518
- const FILTER_OPERATORS = /* @__PURE__ */ new Set(["contains", "eq", "gt", "gte", "lt", "lte", "ne"]);
519
- const parseTablePageFilters = (raw) => {
520
- if (!Array.isArray(raw)) {
521
- return void 0;
522
- }
523
- const clauses = [];
524
- for (const item of raw) {
525
- if (typeof item !== "object" || item === null) {
526
- continue;
527
- }
528
- const record = item;
529
- const { column, operator } = record;
530
- if (typeof column !== "string" || column === "" || typeof operator !== "string" || !FILTER_OPERATORS.has(operator)) {
531
- continue;
532
- }
533
- clauses.push({ column, operator, value: record["value"] });
534
- }
535
- return clauses.length > 0 ? clauses : void 0;
536
- };
537
- const parseTablePageOrderBy = (raw) => {
538
- if (typeof raw !== "object" || raw === null) {
539
- return void 0;
540
- }
541
- const { column, direction } = raw;
542
- if (typeof column !== "string" || column === "") {
543
- return void 0;
544
- }
545
- return { column, direction: direction === "desc" ? "desc" : "asc" };
546
- };
547
- const parseBulkDeleteArgs = (args) => {
548
- const table = typeof args["table"] === "string" ? args["table"] : "";
549
- if (table.trim() === "") {
550
- throw Object.assign(new Error("deleteRows: `table` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
551
- }
552
- return {
553
- filters: parseTablePageFilters(args["filters"]),
554
- limit: typeof args["limit"] === "number" ? args["limit"] : void 0,
555
- search: typeof args["search"] === "string" ? args["search"] : void 0,
556
- table
557
- };
558
- };
559
- const parseClearTableArgs = (args) => {
560
- const table = typeof args["table"] === "string" ? args["table"] : "";
561
- if (table.trim() === "") {
562
- throw Object.assign(new Error("clearTable: `table` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
563
- }
564
- return { limit: typeof args["limit"] === "number" ? args["limit"] : void 0, table };
565
- };
566
- const parseRecordAuthEventArgs = (args) => {
567
- const { outcome } = args;
568
- if (outcome !== "ok" && outcome !== "fail") {
569
- throw Object.assign(new Error('recordAuthEvent: `outcome` must be "ok" or "fail"'), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
570
- }
571
- return { outcome };
572
- };
573
- const parseRecordContainerEventArgs = (args) => {
574
- const raw = args["event"];
575
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
576
- throw Object.assign(new Error("recordContainerEvent: `event` must be an object"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
577
- }
578
- const envelope = raw;
579
- const container = typeof envelope["container"] === "string" ? envelope["container"] : "";
580
- const event = typeof envelope["event"] === "string" ? envelope["event"] : "";
581
- if (container.trim() === "" || event.trim() === "") {
582
- throw Object.assign(new Error("recordContainerEvent: `event.container` and `event.event` are required"), {
583
- code: "BAD_REQUEST",
584
- name: "LunoraError",
585
- status: 400
586
- });
587
- }
588
- const level = envelope["level"] === "error" ? "error" : "info";
589
- const detail = typeof envelope["message"] === "string" ? envelope["message"] : void 0;
590
- const timestamp = typeof envelope["ts"] === "number" ? envelope["ts"] : Date.now();
591
- return {
592
- functionPath: `container:${container}`,
593
- level,
594
- message: detail === void 0 || detail === "" ? event : `${event}: ${detail}`,
595
- timestamp
596
- };
597
- };
598
- const parseRunAsArgs = (args) => {
599
- const functionPath = typeof args["functionPath"] === "string" ? args["functionPath"] : "";
600
- const userId = typeof args["userId"] === "string" ? args["userId"] : "";
601
- if (functionPath.trim() === "") {
602
- throw Object.assign(new Error("runAs: `functionPath` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
603
- }
604
- if (functionPath.startsWith(ADMIN_FUNCTION_PREFIX)) {
605
- throw Object.assign(new Error("runAs: cannot target a reserved admin function"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
606
- }
607
- if (userId.trim() === "") {
608
- throw Object.assign(new Error("runAs: `userId` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
609
- }
610
- const rawArgs = args["args"];
611
- if (rawArgs !== void 0 && (typeof rawArgs !== "object" || rawArgs === null || Array.isArray(rawArgs))) {
612
- throw Object.assign(new Error("runAs: `args` must be an object"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
613
- }
614
- const rawIdentity = args["identity"];
615
- if (rawIdentity !== void 0 && (typeof rawIdentity !== "object" || rawIdentity === null || Array.isArray(rawIdentity))) {
616
- throw Object.assign(new Error("runAs: `identity` must be an object"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
617
- }
618
- return {
619
- args: rawArgs === void 0 ? {} : rawArgs,
620
- functionPath,
621
- userId,
622
- ...rawIdentity === void 0 ? {} : { identity: rawIdentity }
623
- };
624
- };
625
- const parseRecordMailArgs = (args) => {
626
- const bad = (message) => {
627
- throw Object.assign(new Error(`recordMail: ${message}`), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
628
- };
629
- const { bcc, cc, from, headers, html, replyTo, subject, text, to } = args;
630
- if (typeof subject !== "string") {
631
- bad("`subject` must be a string");
632
- }
633
- const toOk = typeof to === "string" || Array.isArray(to) && to.every((entry) => typeof entry === "string");
634
- if (!toOk) {
635
- bad("`to` must be a string or string[]");
636
- }
637
- const optionalStringList = (value, label) => {
638
- if (value === void 0) {
639
- return void 0;
640
- }
641
- if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
642
- bad(`\`${label}\` must be a string[]`);
643
- }
644
- return value;
645
- };
646
- const optionalString = (value, label) => {
647
- if (value !== void 0 && typeof value !== "string") {
648
- bad(`\`${label}\` must be a string`);
649
- }
650
- return value;
651
- };
652
- return {
653
- bcc: optionalStringList(bcc, "bcc"),
654
- cc: optionalStringList(cc, "cc"),
655
- from: optionalString(from, "from"),
656
- headers: headers !== void 0 && typeof headers === "object" && headers !== null ? headers : void 0,
657
- html: optionalString(html, "html"),
658
- replyTo: optionalString(replyTo, "replyTo"),
659
- subject,
660
- text: optionalString(text, "text"),
661
- to
662
- };
663
- };
664
- const TEST_MAIL_DEFAULT_TO = "test@lunora.sh";
665
- const buildTestMailInput = (args) => {
666
- const { to } = args;
667
- if (to !== void 0 && typeof to !== "string") {
668
- throw Object.assign(new Error("sendTestMail: `to` must be a string"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
669
- }
670
- const recipient = to ?? TEST_MAIL_DEFAULT_TO;
671
- const link = "https://example.test/verify?token=demo";
672
- return {
673
- from: "Lunora <noreply@lunora.sh>",
674
- html: `<p>This is a test email from the Lunora dev mail catcher.</p><p><a href="${link}">Verify your email</a></p>`,
675
- subject: "Lunora test email",
676
- text: `This is a test email from the Lunora dev mail catcher.
677
-
678
- Verify your email: ${link}`,
679
- to: recipient
680
- };
681
- };
682
- const parseRankBeforeArgs = (args) => {
683
- const table = typeof args["table"] === "string" ? args["table"] : "";
684
- const index = typeof args["index"] === "string" ? args["index"] : "";
685
- const rowId = typeof args["rowId"] === "string" ? args["rowId"] : "";
686
- if (table.trim() === "") {
687
- throw Object.assign(new Error("rankBefore: `table` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
688
- }
689
- if (index.trim() === "") {
690
- throw Object.assign(new Error("rankBefore: `index` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
691
- }
692
- if (typeof args["partitionKey"] !== "string") {
693
- throw Object.assign(new Error("rankBefore: `partitionKey` must be a string"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
694
- }
695
- if (rowId.trim() === "") {
696
- throw Object.assign(new Error("rankBefore: `rowId` is required"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
697
- }
698
- if (!Array.isArray(args["sortValues"])) {
699
- throw Object.assign(new Error("rankBefore: `sortValues` must be an array"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
700
- }
701
- return { index, partitionKey: args["partitionKey"], rowId, sortValues: args["sortValues"], table };
702
- };
703
- const badRequest = (message) => {
704
- throw Object.assign(new Error(message), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
705
- };
706
- const requireNonEmptyString = (value, field) => {
707
- if (typeof value !== "string" || value.trim() === "") {
708
- badRequest(`rankPage: \`${field}\` is required`);
709
- }
710
- return value;
711
- };
712
- const parseRankPageAfter = (raw) => {
713
- if (raw === void 0) {
714
- return void 0;
715
- }
716
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
717
- badRequest("rankPage: `after` must be an object");
718
- }
719
- const record = raw;
720
- if (typeof record["partitionKey"] !== "string" || typeof record["rowId"] !== "string" || !Array.isArray(record["sortValues"])) {
721
- badRequest("rankPage: `after` must have a string partitionKey, string rowId, and array sortValues");
722
- }
723
- return { partitionKey: record["partitionKey"], rowId: record["rowId"], sortValues: record["sortValues"] };
724
- };
725
- const parseRankPageArgs = (args) => {
726
- const table = requireNonEmptyString(args["table"], "table");
727
- const index = requireNonEmptyString(args["index"], "index");
728
- if (args["take"] !== void 0 && typeof args["take"] !== "number") {
729
- badRequest("rankPage: `take` must be a number");
730
- }
731
- if (args["cursor"] !== void 0 && args["cursor"] !== null && typeof args["cursor"] !== "string") {
732
- badRequest("rankPage: `cursor` must be a string or null");
733
- }
734
- if (args["partitionKey"] !== void 0 && typeof args["partitionKey"] !== "string") {
735
- badRequest("rankPage: `partitionKey` must be a string");
736
- }
737
- if (args["directions"] !== void 0 && !Array.isArray(args["directions"])) {
738
- badRequest("rankPage: `directions` must be an array");
739
- }
740
- const directions = args["directions"] === void 0 ? void 0 : args["directions"].map((d) => d === "desc" ? "desc" : "asc");
741
- return {
742
- after: parseRankPageAfter(args["after"]),
743
- cursor: typeof args["cursor"] === "string" ? args["cursor"] : void 0,
744
- directions,
745
- index,
746
- partitionKey: typeof args["partitionKey"] === "string" ? args["partitionKey"] : void 0,
747
- take: typeof args["take"] === "number" ? args["take"] : void 0,
748
- table
749
- };
750
- };
751
- const decodeIndexHitKey = (key) => {
752
- try {
753
- const parsed = JSON.parse(key);
754
- if (Array.isArray(parsed) && typeof parsed[0] === "string" && typeof parsed[1] === "string") {
755
- return { index: parsed[1], table: parsed[0] };
756
- }
757
- } catch {
758
- }
759
- return void 0;
760
- };
761
- const parseApplyCdcArgs = (args) => {
762
- const raw = args["changes"];
763
- if (!Array.isArray(raw)) {
764
- throw Object.assign(new Error("applyCdc: `changes` must be an array"), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
765
- }
766
- const changes = raw.map((entry, index) => {
767
- const record = entry;
768
- const { op } = record;
769
- const table = typeof record["table"] === "string" ? record["table"] : "";
770
- const id = typeof record["id"] === "string" ? record["id"] : "";
771
- if (table === "" || id === "" || op !== "insert" && op !== "update" && op !== "delete") {
772
- throw Object.assign(new Error(`applyCdc: changes[${String(index)}] must have a table, id, and op of insert|update|delete`), {
773
- code: "BAD_REQUEST",
774
- name: "LunoraError",
775
- status: 400
776
- });
777
- }
778
- const rawDocument = record["doc"];
779
- if (rawDocument !== void 0 && (typeof rawDocument !== "object" || rawDocument === null || Array.isArray(rawDocument))) {
780
- throw Object.assign(new Error(`applyCdc: changes[${String(index)}].doc must be an object`), {
781
- code: "BAD_REQUEST",
782
- name: "LunoraError",
783
- status: 400
784
- });
785
- }
786
- const document = rawDocument;
787
- if (document !== void 0 && typeof document["_id"] === "string" && document["_id"] !== id) {
788
- throw Object.assign(new Error(`applyCdc: changes[${String(index)}].doc._id must match the entry id`), {
789
- code: "BAD_REQUEST",
790
- name: "LunoraError",
791
- status: 400
792
- });
793
- }
794
- return {
795
- doc: document,
796
- id,
797
- op,
798
- seq: typeof record["seq"] === "number" ? record["seq"] : 0,
799
- table,
800
- ts: typeof record["ts"] === "number" ? record["ts"] : 0
801
- };
802
- });
803
- return { changes };
804
- };
805
- const parseCdcSyncArgs = (args) => {
806
- const toCount = (value) => {
807
- const n = typeof value === "number" ? value : Number(value);
808
- return Number.isFinite(n) && n >= 0 ? Math.floor(n) : void 0;
809
- };
810
- return { limit: toCount(args["limit"]), sinceSeq: toCount(args["sinceSeq"]) ?? 0 };
811
- };
812
- const jsonResponse = (body, status = 200, bookmark) => {
813
- const headers = { "content-type": "application/json" };
814
- if (bookmark) {
815
- headers["x-d1-bookmark"] = bookmark;
816
- }
817
- return Response.json(body, { headers, status });
818
- };
819
- const parseIdentityHeader = (raw) => {
820
- if (!raw) {
821
- return void 0;
822
- }
823
- try {
824
- const parsed = JSON.parse(raw);
825
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
826
- return parsed;
827
- }
828
- } catch {
829
- }
830
- return void 0;
831
- };
832
- const tablesFromDeps = (deps) => {
833
- const tables = /* @__PURE__ */ new Set();
834
- for (const dep of deps) {
835
- const table = tableFromDepKey(dep);
836
- if (table !== "") {
837
- tables.add(table);
838
- }
839
- }
840
- return tables;
841
- };
842
- const parsePositiveInt = (raw) => {
843
- if (raw === void 0) {
844
- return void 0;
845
- }
846
- const value = Number.parseInt(raw, 10);
847
- return Number.isFinite(value) && value > 0 ? value : void 0;
848
- };
849
- const parseEmit = (raw, devDefault) => {
850
- if (raw === "1" || raw === "true") {
851
- return true;
852
- }
853
- if (raw === "0" || raw === "false") {
854
- return false;
855
- }
856
- return devDefault;
857
- };
858
- const parseSampleRate = (raw) => {
859
- if (raw === void 0) {
860
- return 1;
861
- }
862
- const value = Number.parseFloat(raw);
863
- return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 1;
864
- };
865
- const sampleHit = (rate) => {
866
- if (rate >= 1) {
867
- return true;
868
- }
869
- if (rate <= 0) {
870
- return false;
871
- }
872
- return Math.random() < rate;
873
- };
874
- const extractBearerToken = (authorization) => {
875
- if (!authorization) {
876
- return void 0;
877
- }
878
- const [scheme, ...rest] = authorization.split(" ");
879
- if (scheme?.toLowerCase() !== "bearer") {
880
- return void 0;
881
- }
882
- const value = rest.join(" ").trim();
883
- return value.length > 0 ? value : void 0;
884
- };
885
- const constantTimeEqual = (a, b) => {
886
- const max = Math.max(a.length, b.length);
887
- let diff = a.length ^ b.length;
888
- for (let index = 0; index < max; index += 1) {
889
- const charA = index < a.length ? a.charCodeAt(index) : 0;
890
- const charB = index < b.length ? b.charCodeAt(index) : 0;
891
- diff |= charA ^ charB;
892
- }
893
- return diff === 0;
894
- };
895
- class ShardDO {
896
- /**
897
- * Per-socket cap on concurrent stream iterators. Each in-flight stream
898
- * pins an `AbortController` + the user's async generator + any buffered
899
- * chunks on the WS — letting a client open hundreds of streams in
900
- * parallel would let it pin DO memory without ever sending a message.
901
- * 8 is generous for legitimate clients (the studio rarely opens more
902
- * than 2-3 simultaneously) and small enough that the worst-case memory
903
- * footprint stays bounded.
904
- */
905
- static MAX_STREAMS_PER_SOCKET = 8;
906
- /**
907
- * Per-socket subscription cap. Each subscription is stored in the
908
- * hibernation attachment (which is serialized JSON), and runaway
909
- * subscribe loops would let a single client wedge the attachment past
910
- * the runtime's size budget — keep the per-socket ceiling well below
911
- * that. 32 is enough for any reasonable client (one per visible
912
- * panel/query) and small enough that an attachment serialization
913
- * failure stays unlikely.
914
- */
915
- static MAX_SUBSCRIPTIONS_PER_SOCKET = 32;
916
- /**
917
- * Per-socket whisper-topic cap. Topic membership rides the same hibernation
918
- * attachment as `subs`, so bound it for the same reason — a runaway
919
- * `whisper_subscribe` loop must not wedge the attachment past the runtime's
920
- * size budget. Over-cap joins are silently ignored (whispering is
921
- * best-effort, never acked).
922
- */
923
- static MAX_WHISPER_TOPICS_PER_SOCKET = 64;
924
- /**
925
- * Cap on the serialized size (bytes) of a whisper `data` payload. Whispers
926
- * carry small awareness blobs (cursor, typing flag); bounding the payload
927
- * stops a client from turning the fan-out into a bandwidth-amplification
928
- * vector. An over-limit whisper is dropped (best-effort, never acked).
929
- */
930
- static MAX_WHISPER_BYTES = 4096;
931
- /**
932
- * Whisper-rate token bucket: each socket may burst {@link ShardDO.WHISPER_RATE_BURST}
933
- * whispers, refilling at {@link ShardDO.WHISPER_RATE_PER_SEC}/s. Without this a single
934
- * client could loop `whisper` frames, each costing O(connections) to fan out
935
- * — an O(N) CPU + egress amplification the per-message byte cap alone doesn't
936
- * close. In-memory (resets to full burst on hibernation, which is the
937
- * conservative direction).
938
- */
939
- static WHISPER_RATE_BURST = 50;
940
- static WHISPER_RATE_PER_SEC = 25;
941
- /**
942
- * Set once the very first `__root__` warning has been emitted. Static so
943
- * a hot DO cannot spam the log on every write; the v0.1 lifetime of a DO
944
- * exceeds any reasonable cooldown so a single warning is sufficient. The
945
- * test suite resets this via `resetRootSizeWarning` for isolation.
946
- */
947
- static rootSizeWarned = false;
948
- /** Test-only: reset the static "warned once" flag. */
949
- static resetRootSizeWarning() {
950
- ShardDO.rootSizeWarned = false;
951
- }
952
- state;
953
- env;
954
- /**
955
- * Opt-in per-shard reactive query cache. When the subclass passes
956
- * `ReactiveCacheOptions` to `super(state, env, { reactiveCache: { … } })`
957
- * the cache is instantiated here and exposed to subclasses via
958
- * `runCachedQuery`; when omitted (today's default) it stays
959
- * undefined and the dispatch path runs with zero cache overhead.
960
- *
961
- * The cache is per-shard and in-memory only — it is lost on DO restart
962
- * and on workerd hibernation. That's fine: a cold shard simply re-runs
963
- * the query on the first call, just like it does today.
964
- */
965
- reactiveCache;
966
- /**
967
- * Lazily-built drizzle handle over `state.storage`. Memoised so a single
968
- * DO instance reuses the same dialect across handler calls. The drizzle
969
- * DO driver only touches `storage.sql`, so test doubles only need to
970
- * supply that field — see {@link ShardDOState}.
971
- */
972
- drizzleHandle;
973
- /**
974
- * Tracks BEGIN/COMMIT nesting so we can reject nested transactions —
975
- * SQLite-in-DO does not support them and the runtime would crash with
976
- * "cannot start a transaction within a transaction".
977
- */
978
- transactionDepth = 0;
979
- /**
980
- * Per-request D1 Sessions API bookmark, read from the inbound
981
- * `x-d1-bookmark` header at the top of `fetch` and exposed to handlers
982
- * via `getInboundBookmark`. Cleared between requests so a stale
983
- * bookmark from a previous client never leaks into the next session.
984
- */
985
- currentRequestBookmark;
986
- /**
987
- * Per-request D1 bookmark to echo on the outbound response. Handlers
988
- * call `setOutboundBookmark` after a global-table write so the
989
- * client can pin subsequent reads on the same replica.
990
- */
991
- currentResponseBookmark;
992
- /**
993
- * Per-request userId forwarded from the runtime via the
994
- * `x-lunora-userid` header. Surfaced to handlers via
995
- * `getCurrentUserId`. Cleared in the `finally` block of `fetch`
996
- * so a stale identity from a previous client never leaks into the
997
- * next request.
998
- */
999
- currentRequestUserId;
1000
- /**
1001
- * Per-request caller IP forwarded from the runtime via the
1002
- * `x-lunora-client-ip` header (sourced server-side from Cloudflare's trusted
1003
- * `CF-Connecting-IP`). Surfaced to handlers as `ctx.ip` via `getCurrentIp`;
1004
- * cleared in the `finally` block of `fetch` like the other per-request fields.
1005
- */
1006
- currentRequestIp;
1007
- /**
1008
- * Client-issued idempotency key for the in-flight mutation, forwarded via the
1009
- * `x-lunora-mutation-id` header. When set, the dispatch path dedups the call
1010
- * by `(currentRequestUserId, mutationId)`: a replay short-circuits to the
1011
- * cached result, and `persistIdempotentResult` records the result right
1012
- * after the handler's writes commit so the dedup row is durable iff the
1013
- * writes are. Absent on queries and legacy clients. Cleared in the `fetch`
1014
- * `finally` block.
1015
- */
1016
- currentRequestMutationId;
1017
- /**
1018
- * Wall-clock millis of the last `__idempotency` GC sweep on this warm
1019
- * instance. The dedup write throttles `trimIdempotent` to at most once an
1020
- * hour off this field (in-memory, so a fresh instance just sweeps on its
1021
- * first mutation) — keeping the 24h-retention cleanup off the per-mutation
1022
- * hot path without needing a separate alarm/cron.
1023
- */
1024
- lastIdempotencyTrimAt = 0;
1025
- /**
1026
- * Per-request identity envelope forwarded from the runtime via the
1027
- * `x-lunora-identity` JSON header. Stores claims like `email`,
1028
- * `name`, or custom roles populated by `resolveIdentity` on the
1029
- * worker. Surfaced to handlers via `getCurrentIdentity`.
1030
- */
1031
- currentRequestIdentity;
1032
- /**
1033
- * Whether the in-flight `/rpc` call is a trusted server-initiated dispatch
1034
- * (scheduler/cron), signalled by the `x-lunora-system` header that only the
1035
- * worker's authorized dispatch path sets. When true, `handleRpc` may invoke
1036
- * `internal` functions; client RPCs never carry it, so internals stay
1037
- * unreachable across the external boundary. Cleared in `fetch`'s `finally`.
1038
- */
1039
- currentRequestSystem = false;
1040
- /**
1041
- * Tables written during the in-flight RPC, accumulated by
1042
- * `recordChangedTable`. Drained after `handleRpc` returns to drive
1043
- * `refreshSubscriptions`. `null` when no write has happened yet so
1044
- * the common read-only path allocates nothing.
1045
- */
1046
- pendingChangedTables = void 0;
1047
- /**
1048
- * Coalesced set of tables awaiting a subscription-refresh pass, merged
1049
- * across every {@link ShardDO.flushChangedTables} call that lands while a
1050
- * pass is already draining. The single drain loop
1051
- * ({@link ShardDO.drainSubscriptionRefreshes}) owns this set; a burst of N
1052
- * writes to the same table therefore collapses into one (or two) refresh
1053
- * passes instead of N, so each affected subscription's handler re-runs once
1054
- * per burst rather than once per write. `undefined` when nothing is pending.
1055
- */
1056
- pendingRefreshTables = void 0;
1057
- /** True while {@link ShardDO.drainSubscriptionRefreshes} is running; the single-waiter gate that coalesces concurrent flushes. */
1058
- refreshInFlight = false;
1059
- /**
1060
- * Last pushed result per `(socket, subId)`, keyed by socket. Lets
1061
- * `refreshSubscriptions` skip re-running queries whose tables were
1062
- * untouched and suppress pushes when the re-run result is unchanged. Held
1063
- * in memory only — it does not survive hibernation, which is safe: a cold
1064
- * memo simply forces one re-run and (at most) one redundant push.
1065
- */
1066
- subMemos = /* @__PURE__ */ new WeakMap();
1067
- /** Per-socket whisper-rate token bucket (see {@link ShardDO.WHISPER_RATE_BURST}). In-memory; resets on hibernation. */
1068
- whisperBuckets = /* @__PURE__ */ new WeakMap();
1069
- /**
1070
- * Per-socket {@link AbortController} map keyed by stream id, used to
1071
- * propagate a client unsubscribe (or a socket close) into the user
1072
- * handler. In-memory only: a hibernation drops the controllers, which is
1073
- * fine because the corresponding socket is gone too — the iterator
1074
- * pumping into it would have nowhere to write.
1075
- */
1076
- streamCancellers = /* @__PURE__ */ new WeakMap();
1077
- /**
1078
- * Lifetime request counters surfaced by the `__lunora_admin__:getMetrics`
1079
- * RPC. In-memory only — they reset when the DO hibernates or restarts, which
1080
- * is the right granularity for a "since this instance woke" health readout
1081
- * (durable aggregation would be a separate, heavier feature).
1082
- */
1083
- metrics = { errors: 0, requests: 0, sinceMs: Date.now() };
1084
- /**
1085
- * Declared indexes (`table:index`) a query has exercised since this instance
1086
- * woke, stamped by `getCtxDbIndexUseHook`. In-memory and reset on
1087
- * hibernation/restart — drives the `unused_index` runtime advisory.
1088
- */
1089
- usedIndexes = /* @__PURE__ */ new Set();
1090
- /**
1091
- * Per-function execution counters surfaced by the
1092
- * `__lunora_admin__:getFunctionStats` RPC, keyed by `&lt;file>:&lt;function>`
1093
- * path. Shares the `metrics` lifecycle: in-memory, reset on
1094
- * hibernation/restart. The map is naturally bounded by the app's registered
1095
- * function count (a finite set), so no eviction is needed. Maintained by
1096
- * `recordFunctionCall` at the one dispatch site that also bumps the
1097
- * aggregate `metrics` counters.
1098
- */
1099
- functionStats = /* @__PURE__ */ new Map();
1100
- /**
1101
- * Recent RPC errors on this shard instance, surfaced by the
1102
- * `__lunora_admin__:getLogs` RPC. In-memory only and bounded — like
1103
- * `metrics`, it resets on hibernation/restart. We only capture RPC
1104
- * dispatch failures here (path + error message), not user `console.*` output:
1105
- * intercepting the console cheaply isn't possible, so this is honestly a
1106
- * "recent RPC errors on this instance" feed, not a general application log.
1107
- */
1108
- logs = new LogBuffer();
1109
- /**
1110
- * In-flight dependency tracker for the currently-executing query. Set by
1111
- * `runCachedQuery` so the ctx-db hooks (wired via `onRead`) can
1112
- * stamp deps without threading the tracker explicitly through every
1113
- * generated handler signature. Cleared in the `finally` of the same
1114
- * call so a leaked tracker can never bleed into a sibling RPC.
1115
- */
1116
- currentTracker;
1117
- /**
1118
- * Tables the in-flight dispatch full-scanned (read via `SCAN_DEP`, no index
1119
- * / point lookup). Allocated at the top of each `/rpc` dispatch and drained
1120
- * into `recordFunctionCall` once the handler returns, so the durable
1121
- * `__lunora_metrics_scans` attribution can pin a slow function to the
1122
- * table(s) it scanned. Independent of `currentTracker` (which only exists
1123
- * when the reactive cache is enabled), so the causal signal is collected
1124
- * even on a cache-less shard. Stamped by `getCtxDbReadHook`.
1125
- */
1126
- currentScannedTables;
1127
- /**
1128
- * Declared indexes the in-flight dispatch exercised (used to narrow a read,
1129
- * via `onIndexUse`), keyed by `JSON.stringify([table, index])`. Allocated at the top of each
1130
- * `/rpc` dispatch and drained into `recordFunctionCall` once the handler
1131
- * returns, so the durable `__lunora_metrics_index` hit counter — the producer
1132
- * behind the advisor dead-index lint — records on the same dispatch path as
1133
- * the scan attribution. Stamped by `getCtxDbIndexUseHook`.
1134
- */
1135
- currentIndexHits;
1136
- /**
1137
- * Read-tables + cache-hit captured for the current `/rpc` dispatch, so the
1138
- * dispatch site can fold them into the durable request log
1139
- * (`request-log.ts`). Populated by `runCachedQuery` — the one place that
1140
- * both holds the per-query dependency tracker AND learns whether the
1141
- * reactive cache served the result — and reset per request in `fetch`.
1142
- * `undefined`/empty when the reactive cache is disabled or the path is a
1143
- * write/action (which doesn't run through the cache), which is exactly why
1144
- * the request log treats those fields as "unknown" rather than asserting a
1145
- * read set on the hot path.
1146
- */
1147
- currentRequestReadTables;
1148
- /**
1149
- * Per-statement SQL samples collected during the current `/rpc` dispatch by
1150
- * the instrumented `sql` getter. Drained into the durable
1151
- * `__lunora_metrics_queries` table after the handler returns (same pattern as
1152
- * `currentScannedTables` / `currentIndexHits`). `undefined` when no dispatch
1153
- * is in flight; allocated fresh per dispatch so a previous request's samples
1154
- * never leak into the next one.
1155
- *
1156
- * Each entry is `[rawSql, durationMs, rowsRead, rowsWritten]`. DML rows
1157
- * written is always 0 here — the ctx-db adapter doesn't expose a
1158
- * `changes()` count through the structural `SqlExec` surface, so we
1159
- * attribute only SELECT result sizes as `rowsRead`.
1160
- */
1161
- currentStmtSamples;
1162
- /** Whether the current dispatch's cached query was served from cache; `undefined` until `runCachedQuery` resolves one. */
1163
- currentRequestCacheHit;
1164
- constructor(state, env, options = {}) {
1165
- this.state = state;
1166
- this.env = env;
1167
- if (options.reactiveCache) {
1168
- this.reactiveCache = new ReactiveCache(options.reactiveCache);
1169
- }
1170
- this.armWebSocketKeepalive();
1171
- }
1172
- /** SQLite handle scoped to this Durable Object. */
1173
- /**
1174
- * Worker-side fetch entry point. Handles WebSocket upgrades and the
1175
- * shard-local RPC endpoint forwarded by `@lunora/runtime`.
1176
- */
1177
- async fetch(request) {
1178
- const url = new URL(request.url);
1179
- if (request.headers.get("Upgrade") === "websocket") {
1180
- return this.handleWebSocketUpgrade(request);
1181
- }
1182
- if (url.pathname !== "/rpc" || request.method !== "POST") {
1183
- return new Response("Not found", { status: 404 });
1184
- }
1185
- let payload;
1186
- try {
1187
- payload = await request.json();
1188
- } catch {
1189
- return jsonResponse({ error: { code: "BAD_REQUEST", message: "invalid JSON body" } }, 400);
1190
- }
1191
- if (payload.functionPath.startsWith(ADMIN_FUNCTION_PREFIX)) {
1192
- return this.handleAdminRpc(request, payload.functionPath, payload.args ?? {});
1193
- }
1194
- this.currentRequestBookmark = request.headers.get("x-d1-bookmark") ?? void 0;
1195
- this.currentResponseBookmark = void 0;
1196
- this.currentRequestUserId = request.headers.get("x-lunora-userid") ?? void 0;
1197
- this.currentRequestMutationId = request.headers.get("x-lunora-mutation-id") ?? void 0;
1198
- this.currentRequestIdentity = parseIdentityHeader(request.headers.get("x-lunora-identity"));
1199
- this.currentRequestIp = request.headers.get("x-lunora-client-ip") ?? void 0;
1200
- this.currentRequestSystem = request.headers.get("x-lunora-system") === "1";
1201
- this.currentRequestReadTables = void 0;
1202
- this.currentRequestCacheHit = void 0;
1203
- this.metrics.requests += 1;
1204
- const dispatchStartedAt = Date.now();
1205
- this.currentScannedTables = /* @__PURE__ */ new Set();
1206
- this.currentIndexHits = /* @__PURE__ */ new Set();
1207
- this.currentStmtSamples = [];
1208
- try {
1209
- if (payload.functionPath.startsWith(RELATION_FUNCTION_PREFIX)) {
1210
- const value = await this.runRelationFanoutRead(payload.functionPath, payload.args ?? {});
1211
- return jsonResponse(value, 200, this.currentResponseBookmark);
1212
- }
1213
- const cached = this.readIdempotentResult(this.currentRequestMutationId);
1214
- if (cached !== void 0) {
1215
- this.recordFunctionCall(payload.functionPath, Date.now() - dispatchStartedAt, void 0, this.currentScannedTables, this.currentIndexHits);
1216
- return jsonResponse({ result: cached.value }, 200, this.currentResponseBookmark);
1217
- }
1218
- const result = await this.handleRpc(payload.functionPath, payload.args ?? {});
1219
- this.persistIdempotentResult(result);
1220
- const durationMs = Date.now() - dispatchStartedAt;
1221
- this.recordFunctionCall(payload.functionPath, durationMs, void 0, this.currentScannedTables, this.currentIndexHits);
1222
- this.flushStmtSamples();
1223
- const tablesWritten = [...this.pendingChangedTables ?? []];
1224
- this.recordRequestLog(payload.functionPath, payload.args ?? {}, durationMs, "ok", tablesWritten);
1225
- this.maybeWarnRootSize();
1226
- const response = jsonResponse({ result }, 200, this.currentResponseBookmark);
1227
- await this.flushChangedTables();
1228
- return response;
1229
- } catch (error) {
1230
- this.metrics.errors += 1;
1231
- const durationMs = Date.now() - dispatchStartedAt;
1232
- const message = error instanceof Error ? error.message : String(error);
1233
- const conflicted = error instanceof ConflictError && error.kind === "occ";
1234
- const code = error?.code;
1235
- if (code !== "FUNCTION_NOT_FOUND") {
1236
- this.recordFunctionCall(payload.functionPath, durationMs, message, this.currentScannedTables, this.currentIndexHits, conflicted);
1237
- }
1238
- this.flushStmtSamples();
1239
- this.recordRequestLog(payload.functionPath, payload.args ?? {}, durationMs, "error", [...this.pendingChangedTables ?? []], message);
1240
- this.logs.push({
1241
- functionPath: payload.functionPath,
1242
- level: "error",
1243
- message,
1244
- timestamp: Date.now()
1245
- });
1246
- return this.errorToResponse(error);
1247
- } finally {
1248
- this.currentRequestBookmark = void 0;
1249
- this.currentResponseBookmark = void 0;
1250
- this.currentRequestUserId = void 0;
1251
- this.currentRequestMutationId = void 0;
1252
- this.currentRequestIdentity = void 0;
1253
- this.currentRequestIp = void 0;
1254
- this.currentRequestSystem = false;
1255
- this.currentScannedTables = void 0;
1256
- this.currentIndexHits = void 0;
1257
- this.currentRequestReadTables = void 0;
1258
- this.currentRequestCacheHit = void 0;
1259
- this.currentStmtSamples = void 0;
1260
- }
1261
- }
1262
- /**
1263
- * Hibernation API: invoked by the runtime when a message arrives on a
1264
- * hibernated socket. Subclasses can override this to intercept; the
1265
- * default decodes a {@link SubscriptionEnvelope} and updates the registry.
1266
- */
1267
- // eslint-disable-next-line sonarjs/cognitive-complexity -- Workers hibernation message router: the type/credential/route branching is the wire protocol and stays clearer inline than split across helpers sharing the socket + envelope
1268
- async webSocketMessage(ws, message) {
1269
- if (this.isSocketExpired(ws)) {
1270
- this.dropExpiredSocket(ws);
1271
- return;
1272
- }
1273
- const text = typeof message === "string" ? message : new TextDecoder().decode(message);
1274
- let envelope;
1275
- try {
1276
- envelope = JSON.parse(text);
1277
- } catch {
1278
- ws.send(JSON.stringify({ message: "invalid envelope", type: "error" }));
1279
- return;
1280
- }
1281
- if (envelope.type === "connect") {
1282
- const attachment = this.readAttachment(ws);
1283
- if (attachment.connected === true) {
1284
- return;
1285
- }
1286
- if (envelope.context !== void 0) {
1287
- attachment.context = envelope.context;
1288
- }
1289
- attachment.connected = true;
1290
- try {
1291
- ws.serializeAttachment?.(attachment);
1292
- } catch {
1293
- }
1294
- await this.dispatchLifecycle("connect", this.lifecycleInfo(attachment));
1295
- return;
1296
- }
1297
- if (envelope.type === "subscribe" && envelope.query) {
1298
- const { functionPath } = envelope.query;
1299
- const isAdmin = functionPath?.startsWith(ADMIN_FUNCTION_PREFIX) === true;
1300
- if (isAdmin && this.readAttachment(ws).admin !== true) {
1301
- ws.send(JSON.stringify({ id: envelope.id, message: "admin subscription requires admin authorization", type: "error" }));
1302
- return;
1303
- }
1304
- const status = this.subscribe(ws, envelope.id, envelope.query);
1305
- if (status !== "ok") {
1306
- const code = status === "too_many" ? "TOO_MANY_SUBSCRIPTIONS" : "SUBSCRIPTION_PERSIST_FAILED";
1307
- const errorMessage = status === "too_many" ? `subscription cap of ${String(ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET)} reached on this socket` : "failed to persist subscription attachment";
1308
- try {
1309
- ws.send(JSON.stringify({ code, error: { code, message: errorMessage }, id: envelope.id, type: "error" }));
1310
- } catch {
1311
- }
1312
- return;
1313
- }
1314
- ws.send(JSON.stringify({ id: envelope.id, type: "ack" }));
1315
- if (functionPath) {
1316
- await this.seedSubscription(ws, envelope.id, envelope.query, functionPath, isAdmin);
1317
- }
1318
- return;
1319
- }
1320
- if (envelope.type === "stream" && envelope.query?.functionPath) {
1321
- if (envelope.query.functionPath.startsWith(ADMIN_FUNCTION_PREFIX)) {
1322
- ws.send(JSON.stringify({ id: envelope.id, message: "streams must be public", type: "error" }));
1323
- return;
1324
- }
1325
- this.handleStream(ws, envelope.id, envelope.query.functionPath, envelope.query.args ?? {}).catch(() => {
1326
- });
1327
- return;
1328
- }
1329
- if (envelope.type === "whisper_subscribe" || envelope.type === "whisper_unsubscribe") {
1330
- if (typeof envelope.topic === "string" && envelope.topic.length > 0) {
1331
- this.setWhisperMembership(ws, envelope.topic, envelope.type === "whisper_subscribe");
1332
- }
1333
- return;
1334
- }
1335
- if (envelope.type === "whisper") {
1336
- if (typeof envelope.topic === "string" && envelope.topic.length > 0) {
1337
- this.broadcastWhisper(ws, envelope.topic, envelope.data);
1338
- }
1339
- return;
1340
- }
1341
- if (envelope.type === "unsubscribe") {
1342
- const cancellers = this.streamCancellers.get(ws);
1343
- const controller = cancellers?.get(envelope.id);
1344
- if (controller) {
1345
- controller.abort();
1346
- cancellers?.delete(envelope.id);
1347
- }
1348
- this.unsubscribe(ws, envelope.id);
1349
- ws.send(JSON.stringify({ id: envelope.id, type: "ack" }));
1350
- }
1351
- }
1352
- /**
1353
- * Hibernation API: invoked on socket close. The runtime has already
1354
- * closed the socket by the time we're called — calling `ws.close()`
1355
- * again would throw "WebSocket has been closed" in the Workers runtime.
1356
- */
1357
- async webSocketClose(ws, _code, _reason, _wasClean) {
1358
- const attachment = this.readAttachment(ws);
1359
- if (attachment.connectionId !== void 0) {
1360
- await this.dispatchLifecycle("disconnect", this.lifecycleInfo(attachment));
1361
- }
1362
- const cancellers = this.streamCancellers.get(ws);
1363
- if (cancellers) {
1364
- for (const controller of cancellers.values()) {
1365
- controller.abort();
1366
- }
1367
- this.streamCancellers.delete(ws);
1368
- }
1369
- this.subMemos.delete(ws);
1370
- ws.serializeAttachment?.(void 0);
1371
- }
1372
- /** Hibernation API: invoked on socket error. */
1373
- // 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
1374
- webSocketError(_ws, _error) {
1375
- }
1376
- /**
1377
- * The registered function paths to dispatch when a socket connects/disconnects.
1378
- * Base default is empty; the codegen subclass overrides it to return the
1379
- * generated lifecycle manifest keyed by `event`. Kept as a data hook (like
1380
- * `tableRefs`/`rlsMetadata`) so the security-load-bearing dispatch — running
1381
- * each hook under the verified identity + system dispatch — stays here in the
1382
- * base and can't be mis-wired by generated code.
1383
- */
1384
- // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass returns the generated lifecycle manifest
1385
- lifecycleHookPaths(_event) {
1386
- return [];
1387
- }
1388
- /**
1389
- * Run every registered `connect`/`disconnect` hook for a socket, each under
1390
- * the connecting user's verified identity and a trusted system dispatch (so
1391
- * the internal hooks are permitted). A hook that throws is swallowed (logged)
1392
- * — a disconnect must never fail the hibernation close path, and one hook's
1393
- * failure must not skip the rest. Hooks run sequentially so they share the
1394
- * DO's single-threaded write snapshot deterministically.
1395
- */
1396
- async dispatchLifecycle(event, info) {
1397
- for (const functionPath of this.lifecycleHookPaths(event)) {
1398
- try {
1399
- await this.withRequestIdentity(
1400
- info.userId,
1401
- info.identity,
1402
- () => this.withSystemDispatch(() => this.handleRpc(functionPath, info.event))
1403
- );
1404
- } catch (error) {
1405
- this.logs.push({
1406
- functionPath,
1407
- level: "error",
1408
- message: error instanceof Error ? error.message : String(error),
1409
- timestamp: Date.now()
1410
- });
1411
- }
1412
- }
1413
- }
1414
- /**
1415
- * Serve a reserved {@link RELATION_FUNCTION_PREFIX} fan-out read/count for
1416
- * reverse cross-backend relations (a `.global()` parent loading a
1417
- * shard-local child that spans every shard). Returns a BARE value — the
1418
- * child-row array for `__lunora_relation__:read`, a number for
1419
- * `__lunora_relation__:count` — so the coordinator's `concat`/`sum` merge
1420
- * composes the per-shard results. Runs under the forwarded caller identity
1421
- * (the `x-lunora-userid` / `x-lunora-identity` headers stashed for the
1422
- * request), never the admin token.
1423
- *
1424
- * The base class is schema-agnostic, so it cannot build the ctx-db needed to
1425
- * read the child table; the codegen subclass overrides this with a
1426
- * schema-aware implementation. Reaching the base default means the prefix was
1427
- * dispatched against a ShardDO with no generated schema bound.
1428
- */
1429
- // 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`
1430
- runRelationFanoutRead(_functionPath, _args) {
1431
- throw Object.assign(new Error("__lunora_relation__: no schema bound — the base ShardDO cannot serve cross-shard relation reads"), {
1432
- code: "NOT_IMPLEMENTED",
1433
- name: "LunoraError",
1434
- status: 500
1435
- });
1436
- }
1437
- /**
1438
- * Instrumented SQL handle. Wraps `state.storage.sql` so that every `exec`
1439
- * call during a user RPC dispatch is timed and its result size captured into
1440
- * `currentStmtSamples`. The samples are flushed to the durable
1441
- * `__lunora_metrics_queries` table after the handler returns (same lifecycle
1442
- * as `currentScannedTables`/`currentIndexHits`).
1443
- *
1444
- * The wrapper is only active when `currentStmtSamples` is defined (i.e.
1445
- * during a live user RPC dispatch). Admin ops, subscription re-runs, and
1446
- * any other path that doesn't allocate `currentStmtSamples` pass through to
1447
- * the raw handle unchanged — recording there would skew leaderboard totals
1448
- * with internal housekeeping queries.
1449
- *
1450
- * IMPORTANT: the instrumented wrapper must NOT call any SQL itself (e.g. to
1451
- * flush metrics) — it is invoked synchronously inside an `exec` call and
1452
- * the SQLite connection is not re-entrant in workerd. Samples are flushed
1453
- * after the handler fully resolves.
1454
- */
1455
- get sql() {
1456
- const rawSql = this.state.storage.sql;
1457
- const samples = this.currentStmtSamples;
1458
- if (samples === void 0) {
1459
- return rawSql;
1460
- }
1461
- const rawExec = rawSql.exec;
1462
- if (typeof rawExec !== "function") {
1463
- return rawSql;
1464
- }
1465
- const instrumentedExec = (query, ...params) => {
1466
- const start = Date.now();
1467
- const cursor = rawExec.call(rawSql, query, ...params);
1468
- if (cursor !== null && typeof cursor === "object") {
1469
- const c = cursor;
1470
- if (typeof c["toArray"] === "function") {
1471
- const originalToArray = c["toArray"].bind(c);
1472
- c["toArray"] = () => {
1473
- const rows = originalToArray();
1474
- const durationMs = Date.now() - start;
1475
- samples.push([query, durationMs, rows.length, 0]);
1476
- return rows;
1477
- };
1478
- }
1479
- if (typeof c["one"] === "function") {
1480
- const originalOne = c["one"].bind(c);
1481
- c["one"] = () => {
1482
- const row = originalOne();
1483
- const durationMs = Date.now() - start;
1484
- samples.push([query, durationMs, 1, 0]);
1485
- return row;
1486
- };
1487
- }
1488
- if (typeof c["toArray"] !== "function" && typeof c["one"] !== "function") {
1489
- const durationMs = Date.now() - start;
1490
- samples.push([query, durationMs, 0, 0]);
1491
- }
1492
- } else {
1493
- const durationMs = Date.now() - start;
1494
- samples.push([query, durationMs, 0, 0]);
1495
- }
1496
- return cursor;
1497
- };
1498
- return /* @__PURE__ */ new Proxy(rawSql, {
1499
- get(target, prop) {
1500
- if (prop === "exec") {
1501
- return instrumentedExec;
1502
- }
1503
- return Reflect.get(target, prop, target);
1504
- }
1505
- });
1506
- }
1507
- /**
1508
- * Drizzle handle scoped to this Durable Object's SQLite storage. Use this
1509
- * for typed queries against generated `sqliteTable` schemas. The handle
1510
- * participates in `runInTransaction` via drizzle's own `transaction`
1511
- * helper — there is no need to call `db.transaction(...)` directly from
1512
- * subclasses; wrap your work in `runInTransaction` and use `this.db`
1513
- * inside the handler instead.
1514
- */
1515
- get db() {
1516
- if (this.drizzleHandle) {
1517
- return this.drizzleHandle;
1518
- }
1519
- this.drizzleHandle = drizzle(this.state.storage, { logger: false });
1520
- return this.drizzleHandle;
1521
- }
1522
- /**
1523
- * Run `handler` inside a SQLite transaction. Commits if it resolves;
1524
- * rolls back if it throws. The `ConflictError` re-throw lets the
1525
- * runtime translate optimistic-concurrency failures into a 409 response.
1526
- *
1527
- * Nested calls are refused with a `LunoraError`-shaped object — SQLite
1528
- * in Durable Objects does not support nested transactions, so we fail
1529
- * loudly rather than silently flattening them.
1530
- *
1531
- * Drizzle queries issued via `db` inside the handler participate
1532
- * in this transaction implicitly — drizzle and the BEGIN/COMMIT below
1533
- * both write through the same `state.storage.sql` handle, so the tx
1534
- * boundary is shared. Do **not** call `this.db.transaction(...)` from
1535
- * inside a handler; that would attempt a nested SQLite transaction.
1536
- *
1537
- * Why raw BEGIN/COMMIT/ROLLBACK strings instead of `this.db.transaction(handler)`?
1538
- * Two reasons, both verified against drizzle-orm 0.45.2's
1539
- * `durable-sqlite/session.js`:
1540
- *
1541
- * 1. The DO driver does NOT issue BEGIN/COMMIT/ROLLBACK SQL — it
1542
- * delegates to `state.storage.transactionSync(callback)`, the
1543
- * DO platform's native transaction primitive. Swapping in
1544
- * `db.transaction()` would silently change the wire-level
1545
- * contract observed by tests and any tooling that intercepts
1546
- * `storage.sql`.
1547
- *
1548
- * 2. `transactionSync` invokes the callback synchronously and does
1549
- * not await its return value. Drizzle's `transaction()` matches
1550
- * that — it passes the tx handle through and then returns.
1551
- * Handing it an async handler would let the transaction commit
1552
- * before the handler resolves, breaking the `() => Promise&lt;T> | T`
1553
- * contract.
1554
- *
1555
- * The raw-SQL approach below is async-safe and gives the
1556
- * connection-scoped semantics SQLite-in-DO is designed for.
1557
- */
1558
- async runInTransaction(handler) {
1559
- if (this.transactionDepth > 0) {
1560
- throw Object.assign(new Error("nested transactions are not supported in SQLite-in-DO"), {
1561
- code: "NESTED_TRANSACTION",
1562
- name: "LunoraError",
1563
- status: 500
1564
- });
1565
- }
1566
- const sqlHandle = this.state.storage.sql;
1567
- if (!sqlHandle || typeof sqlHandle.exec !== "function") {
1568
- throw Object.assign(new Error("storage.sql is not available on this ShardDO state"), {
1569
- code: "SQL_UNAVAILABLE",
1570
- name: "LunoraError",
1571
- status: 500
1572
- });
1573
- }
1574
- const transactionalStorage = this.state.storage;
1575
- const run = async () => {
1576
- this.transactionDepth = 1;
1577
- try {
1578
- if (typeof transactionalStorage?.transaction === "function") {
1579
- return await transactionalStorage.transaction(async () => handler());
1580
- }
1581
- return await handler();
1582
- } finally {
1583
- this.transactionDepth = 0;
1584
- }
1585
- };
1586
- if (typeof this.state.blockConcurrencyWhile === "function") {
1587
- return this.state.blockConcurrencyWhile(run);
1588
- }
1589
- return run();
1590
- }
1591
- /**
1592
- * Returns the D1 Sessions API bookmark forwarded by the client on this
1593
- * request, or `undefined` when none was supplied. Handlers pass this
1594
- * into `db.withSession(bookmark)` to opt into read-your-writes
1595
- * consistency across replicas.
1596
- */
1597
- getInboundBookmark() {
1598
- return this.currentRequestBookmark;
1599
- }
1600
- /**
1601
- * Record the post-write D1 bookmark that should be echoed back to the
1602
- * client on the outbound `x-d1-bookmark` header. Safe to call multiple
1603
- * times — the last value wins; only the most recent write's bookmark
1604
- * is meaningful for downstream read pinning.
1605
- */
1606
- setOutboundBookmark(bookmark) {
1607
- this.currentResponseBookmark = bookmark;
1608
- }
1609
- /**
1610
- * The userId forwarded by the runtime's `resolveIdentity` hook for the
1611
- * current request, or `undefined` when the request is anonymous. Use
1612
- * this to populate `ctx.auth.userId` inside `buildCtx`.
1613
- */
1614
- getCurrentUserId() {
1615
- return this.currentRequestUserId;
1616
- }
1617
- /**
1618
- * The caller's IP for the current request (Cloudflare's `CF-Connecting-IP`,
1619
- * forwarded server-side), or `undefined` when unknown. Use this to populate
1620
- * `ctx.ip` inside `buildCtx`.
1621
- */
1622
- getCurrentIp() {
1623
- return this.currentRequestIp;
1624
- }
1625
- /**
1626
- * Identity claims (email, name, roles, …) forwarded by the runtime's
1627
- * `resolveIdentity` hook. Returns `undefined` for anonymous requests
1628
- * or when no extra claims were attached. Use this to populate the
1629
- * value returned by `ctx.auth.getIdentity()` inside `buildCtx`.
1630
- */
1631
- getCurrentIdentity() {
1632
- return this.currentRequestIdentity;
1633
- }
1634
- /**
1635
- * Whether the in-flight `/rpc` call is a trusted server-initiated dispatch
1636
- * (scheduler/cron). A concrete `handleRpc` consults this to decide whether
1637
- * `internal` functions may run — they may for system dispatch, never for a
1638
- * client RPC (which never carries the `x-lunora-system` header).
1639
- */
1640
- isSystemDispatch() {
1641
- return this.currentRequestSystem;
1642
- }
1643
- /**
1644
- * Run a data migration by id against this shard, returning the runner's
1645
- * result. The base class can't reach the project's generated
1646
- * `LUNORA_MIGRATIONS` registry or build a schema-aware writer, so it reports
1647
- * the migration as unknown; the codegen-generated subclass overrides this to
1648
- * look the migration up and invoke `runDataMigration`.
1649
- */
1650
- // 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
1651
- runShardDataMigration(args) {
1652
- return Promise.reject(
1653
- Object.assign(new Error(`data migration "${args.id}" is not registered`), { code: "MIGRATION_NOT_FOUND", name: "LunoraError", status: 404 })
1654
- );
1655
- }
1656
- /**
1657
- * Lazily provision the shard's physical tables before an operation that
1658
- * depends on them existing. The base class has no `schema.ts`, so it does
1659
- * nothing; the codegen subclass overrides this to run `runShardMigrations`
1660
- * once (guarded by an idempotent `migrated` flag). Kept here so base-class
1661
- * paths — notably admin introspection — can materialise tables on demand
1662
- * without knowing the schema, which is what keeps the data browser from
1663
- * showing an empty shard on first load.
1664
- */
1665
- // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this to run the generated schema's migrations
1666
- ensureMigrated() {
1667
- }
1668
- /**
1669
- * Foreign-key map for `table`: doc field → target table, for every field
1670
- * declared `v.id("target")` in the schema, so the data browser can render
1671
- * those cells as links. The base class can't see the user's `schema.ts`, so
1672
- * it returns `undefined` (no links); the codegen subclass overrides this with
1673
- * the schema-derived map.
1674
- */
1675
- // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this and uses `this` to read the generated schema's foreign keys
1676
- tableRefs(_table) {
1677
- return void 0;
1678
- }
1679
- /**
1680
- * Declared indexes for `table` (secondary, search, rank, vector), surfaced by
1681
- * the schema viewer via `__lunora_admin__:listTableIndexes`. Like
1682
- * {@link tableRefs}, the base class can't see the user's `schema.ts`, so it
1683
- * reports none; the codegen subclass overrides this with the schema-derived
1684
- * list. Schema-sourced rather than read from SQLite because lunora's physical
1685
- * indexes are `json_extract` expressions whose field names PRAGMA can't recover.
1686
- */
1687
- // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this to read the generated schema's index metadata
1688
- tableIndexes(_table) {
1689
- return [];
1690
- }
1691
- /**
1692
- * Typed columns for `table` (name, validator-IR type, PK/FK/storage role),
1693
- * surfaced by the schema viewer's diagram via `__lunora_admin__:describeTable`.
1694
- * Like {@link tableRefs}/{@link tableIndexes}, the base class can't see the
1695
- * user's `schema.ts`, so it reports none; the codegen subclass overrides this
1696
- * with the schema-derived list. Schema-sourced rather than read from SQLite
1697
- * because lunora stores rows in a `__doc__` JSON blob, so PRAGMA recovers
1698
- * neither declared types nor PK/FK roles.
1699
- */
1700
- // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the generated schema's column metadata
1701
- tableColumns(_table) {
1702
- return [];
1703
- }
1704
- /**
1705
- * Storage-key columns per table (`{ table: [field, …] }`) — every field
1706
- * declared `v.storage(...)` in the schema, so the admin `storageReferences`
1707
- * read can join R2 objects back to the rows that own them (and flag orphans).
1708
- * Like {@link tableRefs}, the base class can't see the user's `schema.ts`, so
1709
- * it reports none; the codegen subclass overrides this with the schema-derived
1710
- * map.
1711
- */
1712
- // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the generated storage-column map
1713
- storageColumns() {
1714
- return {};
1715
- }
1716
- /**
1717
- * Static schema advisories for this deployment, surfaced via
1718
- * `__lunora_admin__:getAdvisories`. Computed by `@lunora/advisor` at codegen
1719
- * time (the only place the schema + query reads are both available) and
1720
- * emitted into the generated subclass, which overrides this. The base class
1721
- * can't see the user's `schema.ts`, so it reports none.
1722
- */
1723
- // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the generated advisory list
1724
- advisories() {
1725
- return [];
1726
- }
1727
- /**
1728
- * Row-level-security metadata for this deployment, surfaced via
1729
- * `__lunora_admin__:rlsPolicies` to the studio's read-only RLS inspector:
1730
- * which `definePolicy`s guard which `(table, on)` and which `defineRole`s
1731
- * are registered. Statically discovered by `@lunora/codegen` at codegen
1732
- * time (the only place every `.use(rls(...))` chain is visible) and emitted
1733
- * into the generated subclass, which overrides this. The base class can't
1734
- * see the user's `lunora/` sources, so it reports none. Never includes the
1735
- * `when` predicate — that's an opaque closure whose logic stays in code.
1736
- */
1737
- // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the generated RLS policy + role metadata
1738
- rlsMetadata() {
1739
- return { policies: [], roles: [] };
1740
- }
1741
- /**
1742
- * Masking metadata for this deployment, surfaced via
1743
- * `__lunora_admin__:maskPolicies` to the studio's data-browser mask preview:
1744
- * which `(table, column)` pairs a `.use(mask(...))` chain redacts and the
1745
- * declared strategy. Statically discovered by `@lunora/codegen` (the only
1746
- * place every `.use(mask(...))` chain is visible) and emitted into the
1747
- * generated subclass, which overrides this. The base class can't see the
1748
- * user's `lunora/` sources, so it reports none. Never includes the masking
1749
- * closure — only the column + strategy descriptor.
1750
- */
1751
- // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the generated mask column metadata
1752
- maskMetadata() {
1753
- return { columns: [] };
1754
- }
1755
- /**
1756
- * Storage access-rule metadata for this deployment, surfaced via
1757
- * `__lunora_admin__:storageRules` to the studio's read-only access-rules
1758
- * view: which `defineStorageRule`s gate which `(bucket, on, prefix)`.
1759
- * Statically discovered by `@lunora/codegen` from every
1760
- * `.use(storageRules(...))` chain and emitted into the generated subclass,
1761
- * which overrides this. The base class can't see the user's `lunora/`
1762
- * sources, so it reports none. Never includes the `when` predicate.
1763
- */
1764
- // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the generated storage-rule metadata
1765
- storageRulesMetadata() {
1766
- return { rules: [] };
1767
- }
1768
- /**
1769
- * Which optional, package-backed features this deployment wires up, surfaced
1770
- * via `__lunora_admin__:studioFeatures` so the studio hides nav pages whose
1771
- * backing package isn't enabled (mirroring how auth panels gate on
1772
- * capabilities). Statically discovered by `@lunora/codegen` from the app's
1773
- * `lunora/` sources + schema and emitted into the generated subclass, which
1774
- * overrides this. The base class can't see the user's project, so it reports
1775
- * every flag `false` — an un-generated `ShardDO` shows no optional pages.
1776
- */
1777
- // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the statically-discovered feature flags
1778
- studioFeatures() {
1779
- return { mail: false, payments: false, queues: false, scheduler: false, storage: false, vectors: false, workflows: false };
1780
- }
1781
- /**
1782
- * The Cloudflare Queues declared by this app, surfaced via
1783
- * `__lunora_admin__:listQueues` for the studio's Queues page. Queues are NOT
1784
- * Durable Objects and hold no shard state, so this is pure declaration
1785
- * metadata statically discovered by `@lunora/codegen` from `lunora/queues.ts`
1786
- * and emitted into the generated subclass, which overrides this. The base
1787
- * class can't see the user's project, so it reports none.
1788
- */
1789
- // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the statically-discovered queue metadata
1790
- queuesMetadata() {
1791
- return { queues: [] };
1792
- }
1793
- /**
1794
- * The Cloudflare Workflows declared by this app, surfaced via
1795
- * `__lunora_admin__:listWorkflows` for the studio's Workflows page. Workflows
1796
- * are NOT Durable Objects and hold no shard state, so this is pure
1797
- * declaration metadata statically discovered by `@lunora/codegen` from
1798
- * `lunora/workflows.ts` and emitted into the generated subclass, which
1799
- * overrides this. The base class can't see the user's project, so it reports
1800
- * none — an un-generated `ShardDO` lists zero workflows.
1801
- */
1802
- // eslint-disable-next-line class-methods-use-this -- base-class override hook: the codegen subclass overrides this with the statically-discovered workflow metadata
1803
- workflowsMetadata() {
1804
- return { workflows: [] };
1805
- }
1806
- /**
1807
- * Runtime advisories derived from observed signal — currently `unused_index`:
1808
- * a declared index a query has never exercised since this instance woke. To
1809
- * keep noise down it only inspects tables that have used *some* index (so a
1810
- * never-queried table never spams findings; a table queried only via full
1811
- * scan is the `filter_without_index` lint's concern, not this one). The
1812
- * "since this instance woke" caveat rides in the detail — like the other
1813
- * in-memory counters, the signal resets on hibernation.
1814
- */
1815
- runtimeAdvisories() {
1816
- const usedTables = new Set([...this.usedIndexes].map((key) => key.slice(0, key.indexOf(":"))));
1817
- const findings = [];
1818
- for (const table of usedTables) {
1819
- for (const index of this.tableIndexes(table)) {
1820
- if (index.type === "vector" || this.usedIndexes.has(`${table}:${index.name}`)) {
1821
- continue;
1822
- }
1823
- findings.push({
1824
- cacheKey: `unused_index:${table}:${index.name}`,
1825
- categories: ["PERFORMANCE"],
1826
- description: "A declared index has not been exercised by any query since this shard instance started. An unused index costs storage and is maintained on every write for no read benefit.",
1827
- detail: `Index "${index.name}" on table "${table}" has not been used since this instance woke, though other indexes on "${table}" have — it may be redundant.`,
1828
- facing: "INTERNAL",
1829
- level: "INFO",
1830
- metadata: { index: index.name, indexKind: index.type, since: "instance-woke", table },
1831
- name: "unused_index",
1832
- remediation: "Confirm over a representative window, then drop the index if no query needs it.",
1833
- title: "Unused index"
1834
- });
1835
- }
1836
- }
1837
- return findings;
1838
- }
1839
- /**
1840
- * Export every row this shard owns across the requested tables (or every
1841
- * shard-local user table when none are specified) as `{table, doc}` records.
1842
- * Globals are not the DO's concern; the worker reads those from D1.
1843
- *
1844
- * The base class can't build a schema-aware writer without seeing the user's
1845
- * `schema.ts`, so it returns an empty list; the codegen-generated subclass
1846
- * overrides this with `exportShardRows(...)` against the live writer.
1847
- */
1848
- // 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
1849
- runShardExport(_args) {
1850
- return Promise.resolve([]);
1851
- }
1852
- /**
1853
- * Re-insert a batch of `{table, doc}` rows on this shard, returning the
1854
- * per-table insert counts and a per-row error array. Schema-failed rows do
1855
- * not abort the batch — they're surfaced in `errors` and the rest land.
1856
- *
1857
- * The base class can't build a writer; the codegen subclass overrides this
1858
- * to call `importShardRows(...)` inside one transaction per batch.
1859
- */
1860
- // 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
1861
- runShardImport(_args) {
1862
- return Promise.resolve({ conflicts: 0, errors: [], inserted: {} });
1863
- }
1864
- /**
1865
- * Apply a single-row insert/patch/replace/delete through the schema-aware
1866
- * writer. The base class can't build a writer without the user's `schema.ts`,
1867
- * so it reports the table as unknown; the codegen-generated subclass overrides
1868
- * this to run the op against a live `createShardCtxDb(...)` writer (which
1869
- * maintains the FTS/aggregate/rank shadow tables and runs validators).
1870
- */
1871
- // 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
1872
- runShardWrite(args) {
1873
- return Promise.reject(Object.assign(new Error(`unknown table: ${args.table}`), { code: "UNKNOWN_TABLE", name: "LunoraError", status: 404 }));
1874
- }
1875
- /**
1876
- * Delete one row by primary key THROUGH the schema-aware writer — the
1877
- * per-row seam {@link runShardBulkDelete} loops over. Routing each delete
1878
- * through the writer (not raw SQL) is the whole point: it keeps the FTS /
1879
- * aggregate / rank shadow tables in sync and fires `onDelete` cascades,
1880
- * exactly like {@link runShardWrite}'s single-row delete.
1881
- *
1882
- * The base class can't build a writer without the user's `schema.ts`, so it
1883
- * reports the table as unknown; the codegen-generated subclass overrides
1884
- * this to call `writer.delete(id)` on a live `createShardCtxDb(...)` writer.
1885
- */
1886
- // 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
1887
- deleteRowThroughWriter(_table, _id) {
1888
- return Promise.reject(Object.assign(new Error(`unknown table: ${_table}`), { code: "UNKNOWN_TABLE", name: "LunoraError", status: 404 }));
1889
- }
1890
- /**
1891
- * Bulk-delete the rows of `table` matching the active `filters`/`search`
1892
- * (or every row, for `clearTable`), bounded to {@link SHARD_BULK_DELETE_CAP}
1893
- * per call. Concrete in the base: it collects the matching ids with the same
1894
- * predicate {@link readTablePage} previews, then deletes them ONE AT A TIME
1895
- * through {@link deleteRowThroughWriter} so the FTS / aggregate / rank shadow
1896
- * tables stay correct. Returns `{ deleted, hasMore }` so the caller loops a
1897
- * single bounded round-trip rather than deleting an unbounded set at once.
1898
- *
1899
- * Deletes are sequential by design — parallel writes to one DO would contend
1900
- * on OCC — so the per-row `await` is intentional.
1901
- */
1902
- async runShardBulkDelete(args) {
1903
- const limit = Math.min(Math.max(Math.trunc(args.limit ?? SHARD_BULK_DELETE_CAP), 1), SHARD_BULK_DELETE_CAP);
1904
- const { hasMore, ids } = selectMatchingIds(this.sql, {
1905
- filters: args.filters,
1906
- limit,
1907
- search: args.search,
1908
- table: args.table
1909
- });
1910
- let deleted = 0;
1911
- for (const id of ids) {
1912
- await this.deleteRowThroughWriter(args.table, id);
1913
- deleted += 1;
1914
- }
1915
- return { deleted, hasMore };
1916
- }
1917
- /**
1918
- * Count, for the row identified by `rowId`, how many rows precede it under
1919
- * `index` within `partitionKey` on this shard (`before`) and the partition's
1920
- * total (`total`). The cross-shard coordinator fans this out to every shard
1921
- * and sums the results into a global rank.
1922
- *
1923
- * The base class can't build a schema-aware writer without the user's
1924
- * `schema.ts`, so it has no rank shadow tables to count against; the
1925
- * codegen-generated subclass overrides this to call `rankBefore(...)` on a
1926
- * live `createShardCtxDb(...)` writer.
1927
- */
1928
- // 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
1929
- runShardRankBefore(_args) {
1930
- return Promise.reject(
1931
- Object.assign(new Error("rankBefore is not implemented in base ShardDO"), { code: "NOT_IMPLEMENTED", name: "LunoraError", status: 500 })
1932
- );
1933
- }
1934
- /**
1935
- * Page this shard's local ranked slice under `index`, each row tagged with
1936
- * its rank-key tuple (`partitionKey`, `sortValues`, `rowId`). The cross-shard
1937
- * coordinator (`orchestrateRankPage`) fans this out to every live shard and
1938
- * k-way merges the slices into one globally-ranked page.
1939
- *
1940
- * Same base/codegen split as {@link runShardRankBefore}: the base class has
1941
- * no schema-aware writer, so the codegen subclass overrides this to call
1942
- * `rankPageRows(...)` on a live `createShardCtxDb(...)` writer.
1943
- */
1944
- // 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
1945
- runShardRankPage(_args) {
1946
- return Promise.reject(
1947
- Object.assign(new Error("rankPage is not implemented in base ShardDO"), { code: "NOT_IMPLEMENTED", name: "LunoraError", status: 500 })
1948
- );
1949
- }
1950
- /**
1951
- * Page this shard's change-data-capture log past `sinceSeq`. Read-only and
1952
- * schema-free — it only touches the `__cdc_log` table — so the base class
1953
- * implements it directly (no codegen override needed). Returns an empty
1954
- * page that leaves the cursor untouched when CDC was never enabled on this
1955
- * shard, so the coordinator tolerates shards that predate CDC.
1956
- */
1957
- runShardCdcSync(args) {
1958
- const sql = this.sql;
1959
- const present = sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, CDC_LOG_TABLE).toArray().length > 0;
1960
- if (!present) {
1961
- return { changes: [], cursor: args.sinceSeq };
1962
- }
1963
- return readCdcChanges(sql, { limit: args.limit, sinceSeq: args.sinceSeq });
1964
- }
1965
- /**
1966
- * The `__cdc_log` high-watermark stamped on outbound `data`/`delta` frames
1967
- * as their `cursor`, letting a client persist its resume position (Pillar
1968
- * 1b). Returns `undefined` when CDC was never enabled on this shard — there
1969
- * is no monotonic cursor to advertise, and the frame omits the field so the
1970
- * wire stays byte-identical to the pre-cursor format for non-CDC apps.
1971
- */
1972
- currentCdcCursor() {
1973
- return this.cdcEnabled() ? readCdcCursor(this.sql) : void 0;
1974
- }
1975
- /**
1976
- * This shard's current CDC epoch, stamped on `data`/`delta`/`resume` frames
1977
- * next to the cursor so a reconnecting client can prove it is resuming the
1978
- * same changelog timeline it cached (see {@link evaluateResume}). Returns
1979
- * `undefined` when CDC was never enabled — the frame omits the field, keeping
1980
- * the wire byte-identical to the pre-epoch format for non-CDC apps.
1981
- */
1982
- currentCdcEpoch() {
1983
- return this.cdcEnabled() ? readCdcEpoch(this.sql) : void 0;
1984
- }
1985
- /**
1986
- * Decide whether a reconnecting subscription can resume from `sinceSeq`
1987
- * without a full snapshot. Returns the current high-watermark `cursor` plus
1988
- * a `resumable` verdict.
1989
- *
1990
- * `resumable: true` means `sinceSeq` is within the CDC retention window and
1991
- * no table in the query's `readSet` changed in `(sinceSeq, cursor]` — the
1992
- * client's cached value is still current, so the caller emits a lightweight
1993
- * `resume` frame instead of re-shipping the snapshot.
1994
- *
1995
- * `resumable: false` means either the log was compacted past `sinceSeq` (a
1996
- * retention gap), a read table changed (the client needs the fresh value),
1997
- * or CDC is off — the caller falls back to the full-snapshot seed.
1998
- */
1999
- evaluateResume(sinceSeq, readSet, sinceEpoch) {
2000
- const sql = this.sql;
2001
- if (!this.cdcEnabled()) {
2002
- return { cursor: void 0, epoch: void 0, resumable: false };
2003
- }
2004
- const cursor = readCdcCursor(sql);
2005
- const epoch = readCdcEpoch(sql);
2006
- if (sinceEpoch !== epoch) {
2007
- return { cursor, epoch, resumable: false };
2008
- }
2009
- if (sinceSeq > cursor) {
2010
- return { cursor, epoch, resumable: false };
2011
- }
2012
- if (sinceSeq === cursor) {
2013
- return { cursor, epoch, resumable: true };
2014
- }
2015
- const floor = minCdcSeq(sql);
2016
- if (floor === void 0 || floor > sinceSeq + 1) {
2017
- return { cursor, epoch, resumable: false };
2018
- }
2019
- if (readSet.size === 0) {
2020
- return { cursor, epoch, resumable: false };
2021
- }
2022
- const { changes } = readCdcChanges(sql, { limit: CDC_RESUME_SCAN_LIMIT, sinceSeq });
2023
- if (changes.length >= CDC_RESUME_SCAN_LIMIT) {
2024
- return { cursor, epoch, resumable: false };
2025
- }
2026
- const touchedReadSet = changes.some((change) => readSet.has(change.table));
2027
- return { cursor, epoch, resumable: !touchedReadSet };
2028
- }
2029
- /**
2030
- * Look up a previously-committed mutation for the in-flight request's
2031
- * `(identity, mutationId)`. Returns `{ value }` (the cached, JSON-decoded
2032
- * handler result) on a hit so the dispatch path can short-circuit, or
2033
- * `undefined` when `mutationId` is absent (queries / legacy clients) or the
2034
- * mutation has not run yet. Tolerates a stub `sql` handle without the dedup
2035
- * table (returns a miss) so unit harnesses that skip migrations still work.
2036
- * @returns the cached result box on a hit, or `undefined` for a miss or absent mutationId
2037
- */
2038
- readIdempotentResult(mutationId) {
2039
- if (mutationId === void 0) {
2040
- return void 0;
2041
- }
2042
- try {
2043
- const record = readIdempotent(this.sql, this.currentRequestUserId ?? "", mutationId);
2044
- return record === void 0 ? void 0 : { value: JSON.parse(record.resultJson) };
2045
- } catch {
2046
- return void 0;
2047
- }
2048
- }
2049
- /**
2050
- * Record the in-flight mutation's result against its `(identity, mutationId)`
2051
- * so a later replay of the same id short-circuits through
2052
- * {@link readIdempotentResult} instead of re-running the handler. A no-op
2053
- * unless the request carried an `x-lunora-mutation-id` header (queries and
2054
- * legacy clients leave `currentRequestMutationId` undefined).
2055
- *
2056
- * Called on the live dispatch path right after the handler's writes have
2057
- * auto-committed, through the same `this.sql` handle, so the dedup row is
2058
- * durable iff those writes are. (The DO has no ambient BEGIN/COMMIT around a
2059
- * mutation — `handleRpc` invokes the user handler directly — so this can't
2060
- * piggyback on a surrounding transaction; it commits as its own statement
2061
- * immediately after.) `INSERT OR IGNORE` keeps a concurrent double-dispatch
2062
- * of the same id idempotent. Also runs the throttled dedup-table GC.
2063
- */
2064
- persistIdempotentResult(result) {
2065
- if (this.currentRequestMutationId === void 0) {
2066
- return;
2067
- }
2068
- const now = Date.now();
2069
- try {
2070
- writeIdempotent(this.sql, this.currentRequestUserId ?? "", this.currentRequestMutationId, JSON.stringify(result) ?? "null", now);
2071
- if (now - this.lastIdempotencyTrimAt > IDEMPOTENCY_GC_INTERVAL_MS) {
2072
- trimIdempotent(this.sql, now - IDEMPOTENCY_RETENTION_MS);
2073
- this.lastIdempotencyTrimAt = now;
2074
- }
2075
- } catch {
2076
- }
2077
- }
2078
- /**
2079
- * Replay a batch of CDC changes into this shard (point-in-time recovery).
2080
- * Schema-aware — it builds a `createShardCtxDb` writer — so the base class
2081
- * can't implement it; the codegen-generated subclass overrides this to call
2082
- * `applyCdcChanges(writer, args.changes)`.
2083
- */
2084
- // 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
2085
- runShardApplyCdc(_args) {
2086
- return Promise.reject(
2087
- Object.assign(new Error("applyCdc is not implemented in base ShardDO"), { code: "NOT_IMPLEMENTED", name: "LunoraError", status: 500 })
2088
- );
2089
- }
2090
- /**
2091
- * Register a subscription on the given socket. Stored via
2092
- * `ws.serializeAttachment` so it survives hibernation.
2093
- *
2094
- * Returns a status so the caller can surface a structured error frame
2095
- * when the cap is hit or the attachment fails to serialize. We never
2096
- * throw out of this path — the WS hibernation API treats a thrown
2097
- * `webSocketMessage` as a fatal-channel error.
2098
- */
2099
- subscribe(ws, subId, query) {
2100
- const attachment = this.readAttachment(ws);
2101
- if (Object.keys(attachment.subs).length >= ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET) {
2102
- return "too_many";
2103
- }
2104
- attachment.subs[subId] = query;
2105
- try {
2106
- ws.serializeAttachment?.(attachment);
2107
- } catch {
2108
- delete attachment.subs[subId];
2109
- return "serialize_failed";
2110
- }
2111
- return "ok";
2112
- }
2113
- unsubscribe(ws, subId) {
2114
- const attachment = this.readAttachment(ws);
2115
- const captured = attachment.subs[subId];
2116
- delete attachment.subs[subId];
2117
- try {
2118
- ws.serializeAttachment?.(attachment);
2119
- } catch {
2120
- if (captured !== void 0) {
2121
- attachment.subs[subId] = captured;
2122
- }
2123
- return;
2124
- }
2125
- this.subMemos.get(ws)?.delete(subId);
2126
- }
2127
- /**
2128
- * Decide whether a single subscription is interested in a mutation
2129
- * delta. The default implementation checks the table name, then runs a
2130
- * shallow-equality predicate over `query.args` against `delta.row`. A
2131
- * subscription with no `args` matches every row in the table.
2132
- *
2133
- * Subclasses can override this to implement range queries, joins, or
2134
- * full-text matching — anything more elaborate than equality. When
2135
- * `delta.row` is undefined (delete events without row data) we fall back
2136
- * to a broadcast so subscribers know to refetch; trying to filter
2137
- * against missing data would silently drop legitimate notifications.
2138
- */
2139
- // eslint-disable-next-line class-methods-use-this -- protected matching hook kept non-static so subclasses can refine subscription/delta matching
2140
- matchesSubscription(query, delta) {
2141
- if (query.table !== delta.table) {
2142
- return false;
2143
- }
2144
- const { args } = query;
2145
- if (!args) {
2146
- return true;
2147
- }
2148
- const { row } = delta;
2149
- if (!row) {
2150
- return true;
2151
- }
2152
- for (const [key, expected] of Object.entries(args)) {
2153
- if (row[key] !== expected) {
2154
- return false;
2155
- }
2156
- }
2157
- return true;
2158
- }
2159
- /**
2160
- * Broadcast a mutation delta to every subscriber whose registered query
2161
- * targets the affected table _and_ matches its args. The wire payload
2162
- * includes the per-socket subscription id, so we serialise once per
2163
- * `(socket, sub)` pair — but the structural delta body itself is
2164
- * identical, so we build a payload keyed by `subId` lazily.
2165
- */
2166
- broadcastDelta(delta) {
2167
- const sockets = this.state.getWebSockets();
2168
- const deltaJson = JSON.stringify(delta);
2169
- for (const ws of sockets) {
2170
- const attachment = this.readAttachment(ws);
2171
- for (const [subId, query] of Object.entries(attachment.subs)) {
2172
- if (!this.matchesSubscription(query, delta)) {
2173
- continue;
2174
- }
2175
- trySendFrame(ws, `{"type":"delta","id":${JSON.stringify(subId)},"delta":${deltaJson}}`);
2176
- }
2177
- }
2178
- }
2179
- /**
2180
- * Re-run a subscription's query and return its current result alongside
2181
- * the set of tables it read. The base class can't dispatch user functions,
2182
- * so it returns `null` — the codegen-generated subclass overrides this to
2183
- * run the handler from the project's function registry. Returning `null`
2184
- * disables server re-execution and leaves the legacy `broadcastDelta`
2185
- * path as the only live-update mechanism.
2186
- *
2187
- * `identity` is the EXPLICIT subscriber identity the query runs under. It
2188
- * is passed by value (anonymous by default — see {@link SubscriptionIdentity})
2189
- * and forwarded straight into the codegen subclass's `buildCtx`, so a
2190
- * subscription re-run never reads or mutates the shared, per-request
2191
- * `currentRequestUserId`/`currentRequestIdentity` instance fields from a
2192
- * deferred (`waitUntil`) or concurrently-interleaved context.
2193
- */
2194
- // 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 function map
2195
- executeSubscription(_functionPath, _args, _identity) {
2196
- return Promise.resolve(null);
2197
- }
2198
- /**
2199
- * Look up a streaming-query function and return a thunk that produces the
2200
- * `AsyncIterable&lt;unknown>` when handed an {@link AbortSignal}. The codegen
2201
- * subclass overrides this to dispatch via `LUNORA_FUNCTIONS`; the base
2202
- * default returns `null`, which surfaces as `{type:"error", code:"NOT_FOUND"}`
2203
- * to the client.
2204
- *
2205
- * The deferred-iterator shape (`(signal) => AsyncIterable&lt;unknown>`) keeps
2206
- * the cancel signal pluggable per-call without coupling this signature to
2207
- * the wire-frame loop in `handleStream`.
2208
- */
2209
- // 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 function map
2210
- executeStream(_functionPath, _args) {
2211
- return null;
2212
- }
2213
- /**
2214
- * Wrap a query handler in the reactive cache. The subclass passes the
2215
- * function path, parsed args, and a `run` callback that resolves to the
2216
- * handler's return value. When the cache is configured we key by
2217
- * `(functionPath, stable-stringified args)`, allocate a fresh dep
2218
- * tracker, store it on `this.currentTracker` so `getCtxDbReadHook` reads
2219
- * stamp into it, and restore the prior tracker in `finally`. When the
2220
- * cache is absent we just call `run()` — same shape, zero overhead.
2221
- *
2222
- * Subclasses should ALSO pass `getCtxDbReadHook()` as the `onRead`
2223
- * option on their `createShardCtxDb(...)` call so the tracker actually
2224
- * collects deps. Without that wiring the cache will memoize results
2225
- * with empty dep sets, so writes never invalidate them and stale
2226
- * results stick around — the {@link ReactiveCache} class is contract-
2227
- * neutral about who fills `deps`.
2228
- */
2229
- async runCachedQuery(functionPath, args, run) {
2230
- if (!this.reactiveCache) {
2231
- return run();
2232
- }
2233
- const previous = this.currentTracker;
2234
- const tracker = createDependencyTracker();
2235
- this.currentTracker = tracker;
2236
- const hitsBefore = this.reactiveCache.stats().hits;
2237
- try {
2238
- const result = await this.reactiveCache.run(reactiveCacheKey(functionPath, args, this.getCurrentUserId() ?? null), tracker.collect(), run);
2239
- this.currentRequestCacheHit = this.reactiveCache.stats().hits > hitsBefore;
2240
- this.currentRequestReadTables = tablesFromDeps(tracker.collect());
2241
- return result;
2242
- } finally {
2243
- this.currentTracker = previous;
2244
- }
2245
- }
2246
- /**
2247
- * Returns an `onRead` callback suitable to hand to `createShardCtxDb`'s
2248
- * `onRead` option. The returned function stamps the in-flight tracker (set
2249
- * by `runCachedQuery`) when one exists and is a no-op otherwise — so
2250
- * subclasses can wire this hook unconditionally without checking whether
2251
- * the cache is enabled.
2252
- *
2253
- * It ALSO records the table into {@link currentScannedTables} whenever the
2254
- * read was a full-table scan (the `SCAN_DEP` sentinel). That set is drained
2255
- * into `recordFunctionCall` after dispatch to build the durable per-function
2256
- * full-scan attribution — and unlike the tracker, it's collected even when
2257
- * the reactive cache is off, since the causal signal is independent of
2258
- * caching.
2259
- */
2260
- getCtxDbReadHook() {
2261
- return (table, idOrScan) => {
2262
- this.currentTracker?.recordRead(table, idOrScan ?? SCAN_DEP);
2263
- if (idOrScan === SCAN_DEP) {
2264
- this.currentScannedTables?.add(table);
2265
- }
2266
- };
2267
- }
2268
- /**
2269
- * Read hook recording which declared indexes a query actually exercises.
2270
- * Two destinations, both stamped here so a single hook serves the live and
2271
- * durable signals. First, the in-memory `usedIndexes` set behind the
2272
- * `unused_index` runtime advisory (reset on hibernation/restart — a "since
2273
- * this instance woke" readout, like the function/scan counters), keyed
2274
- * `table:index`. Second, the per-dispatch `currentIndexHits` set, drained
2275
- * into `recordFunctionCall` so the DURABLE `__lunora_metrics_index` hit
2276
- * counter (the advisor dead-index lint's producer) records one read per
2277
- * distinct `(table, index)` this dispatch exercised. Passed as `onIndexUse`
2278
- * to `createShardCtxDb` by the generated subclass.
2279
- */
2280
- getCtxDbIndexUseHook() {
2281
- return (table, indexName) => {
2282
- this.usedIndexes.add(`${table}:${indexName}`);
2283
- this.currentIndexHits?.add(JSON.stringify([table, indexName]));
2284
- };
2285
- }
2286
- /**
2287
- * Record that `table` was written during the current RPC. Wired into the
2288
- * db adapter's `broadcast` callback by the generated subclass so that
2289
- * `flushChangedTables` can re-run only the affected subscriptions.
2290
- */
2291
- recordChangedTable(table) {
2292
- this.pendingChangedTables ??= /* @__PURE__ */ new Set();
2293
- this.pendingChangedTables.add(table);
2294
- }
2295
- /**
2296
- * Per-batch progress hook for the codegen subclass's data-migration runner
2297
- * (wired via `runDataMigration`'s `onBatch`). The runner persists progress to
2298
- * the reserved {@link DATA_MIGRATION_STATE_TABLE} through raw SQL the
2299
- * change-tracker can't observe, so record that table here and flush — that's
2300
- * what re-runs live `migrationStatus` subscribers mid-run. Centralised in the
2301
- * base class so subclasses don't have to remember the record-then-flush dance.
2302
- */
2303
- async flushMigrationProgress() {
2304
- this.recordChangedTable(DATA_MIGRATION_STATE_TABLE);
2305
- await this.flushChangedTables();
2306
- }
2307
- /**
2308
- * Record one `ctx.log.*` call from a handler. Invoked by the generated
2309
- * `buildCtx` logger closure, which supplies the executing `functionPath` and
2310
- * the sink resolved from `createShardDO({ observability })` (if any).
2311
- *
2312
- * Three destinations, each best-effort so a logging call can NEVER turn a
2313
- * served request into a failed one. First, the in-memory {@link LogBuffer}
2314
- * that powers the studio's live Logs panel (it resets on hibernation, like
2315
- * the metrics counters). Second, a structured `{ source: "lunora", type:
2316
- * "log" }` console event that rides CF Workers Logs / Logpush to prod sinks
2317
- * and is pretty-printed by the CLI / Vite dev-server formatter in the
2318
- * terminal. Third, the optional programmatic `sink.onLog` — the in-process
2319
- * hook for users who route logs themselves (webhook/Sentry/etc.), mirroring
2320
- * `onRpc`.
2321
- *
2322
- * Unlike request-log args, `ctx.log` args are NOT redacted: the developer
2323
- * chose to log them, exactly like a raw `console.log`.
2324
- */
2325
- recordUserLog(functionPath, level, args, sink) {
2326
- const event = {
2327
- args,
2328
- functionPath,
2329
- level,
2330
- message: renderLogMessage(args),
2331
- shardKey: this.state.id?.name,
2332
- ts: Date.now(),
2333
- userId: this.getCurrentUserId()
2334
- };
2335
- this.logs.push({ functionPath, level: level === "log" ? "info" : level, message: event.message, timestamp: event.ts });
2336
- try {
2337
- emitLogEvent(event);
2338
- } catch {
2339
- }
2340
- if (sink?.onLog) {
2341
- try {
2342
- sink.onLog(event);
2343
- } catch {
2344
- }
2345
- }
2346
- }
2347
- /**
2348
- * Assemble the per-socket {@link LifecycleDispatchInfo} from its attachment:
2349
- * the verified identity to replay and the {@link LifecycleEvent} the hooks
2350
- * receive as their argument. `shardKey` is this DO's shard name.
2351
- */
2352
- lifecycleInfo(attachment) {
2353
- const event = {
2354
- connectionId: attachment.connectionId ?? "",
2355
- shardKey: this.state.id?.name ?? ROOT_SHARD_NAME,
2356
- // eslint-disable-next-line unicorn/no-null -- LifecycleEvent.userId is `string | null`; null is the contractual anonymous sentinel mirrored on ctx.auth
2357
- userId: attachment.userId ?? null,
2358
- ...attachment.context === void 0 ? {} : { context: attachment.context }
2359
- };
2360
- return { event, identity: attachment.identity, userId: attachment.userId };
2361
- }
2362
- /**
2363
- * Run `fn` with the trusted-system flag set (restored afterwards), so an
2364
- * internal function dispatched through `handleRpc` is permitted. Mirrors the
2365
- * header-driven flag the worker's authorized path sets, without forging a
2366
- * header. The single toggle primitive for lifecycle-hook dispatch.
2367
- */
2368
- async withSystemDispatch(run) {
2369
- const previous = this.currentRequestSystem;
2370
- this.currentRequestSystem = true;
2371
- try {
2372
- return await run();
2373
- } finally {
2374
- this.currentRequestSystem = previous;
2375
- }
2376
- }
2377
- /**
2378
- * Emit a one-shot console warning when the `__root__` DO's SQLite file
2379
- * crosses {@link ROOT_DO_SIZE_WARN_BYTES} (1 GiB = 10% of the per-DO
2380
- * ceiling). We deliberately avoid throwing — apps should keep working;
2381
- * the warning is the migration signal.
2382
- */
2383
- /**
2384
- * Assemble the health snapshot served by `__lunora_admin__:getMetrics`:
2385
- * lifetime request/error counts, the live SQLite size, and (when an opt-in
2386
- * reactive cache is configured) its hit/miss stats.
2387
- *
2388
- * `requests`/`errors` now report the **durable** lifetime totals from the
2389
- * `__lunora_metrics` table (source of truth) so they survive
2390
- * hibernation/restart; the in-memory counters are used only as a fallback
2391
- * when the durable read throws. The response is extended additively with
2392
- * `functions` (per-function persisted rows), `history` (the coarse
2393
- * time-series buckets), and `indexHits` (the per-`(table, index)` hit
2394
- * counts) so the studio can read durable per-function metrics, chart
2395
- * history, and feed the advisor dead-index lint without breaking existing
2396
- * fields. `indexHits` is shaped exactly as the advisor's `AdvisorIndexHit`
2397
- * (`{ table, index, reads }`), so the studio passes it straight to
2398
- * `runLints({ ..., indexHits })` after summing the per-shard arrays.
2399
- */
2400
- collectMetrics() {
2401
- const size = this.state.storage.sql?.databaseSize;
2402
- let { requests } = this.metrics;
2403
- let { errors } = this.metrics;
2404
- try {
2405
- const totals = readFunctionMetricsTotals(this.state.storage.sql);
2406
- requests = totals.requests;
2407
- errors = totals.errors;
2408
- } catch {
2409
- }
2410
- let indexHits = [];
2411
- try {
2412
- indexHits = readFunctionMetricIndexHits(this.state.storage.sql);
2413
- } catch {
2414
- }
2415
- let queryStats = [];
2416
- try {
2417
- queryStats = readQueryMetrics(this.state.storage.sql);
2418
- } catch {
2419
- }
2420
- return {
2421
- // eslint-disable-next-line unicorn/no-null -- metrics wire shape: `cache` is `null | {...}`, null reported when the reactive cache is disabled
2422
- cache: this.reactiveCache ? this.reactiveCache.stats() : null,
2423
- // eslint-disable-next-line unicorn/no-null -- metrics wire shape: `databaseSize` is `null | number`, null when the runtime doesn't expose a size
2424
- databaseSize: typeof size === "number" ? size : null,
2425
- errors,
2426
- functions: this.collectFunctionStats().functions,
2427
- history: this.collectFunctionMetricBuckets(),
2428
- indexHits,
2429
- queryStats,
2430
- requests,
2431
- shard: this.state.id?.name ?? ROOT_SHARD_NAME,
2432
- sinceMs: this.metrics.sinceMs,
2433
- uptimeMs: Date.now() - this.metrics.sinceMs
2434
- };
2435
- }
2436
- /**
2437
- * Fold one dispatch into the per-function counters keyed by `functionPath`,
2438
- * creating the entry on first sight. `errorMessage` is supplied only when
2439
- * the handler threw, in which case the failure counters advance too.
2440
- * `scannedTables` carries the tables the dispatch full-scanned (collected by
2441
- * `getCtxDbReadHook`), which advance the causal scan attribution.
2442
- * `indexHits` carries the declared indexes it exercised (collected by
2443
- * `getCtxDbIndexUseHook`, NUL-free `JSON.stringify([table, index])` keys),
2444
- * which advance the durable `__lunora_metrics_index` hit counter behind the
2445
- * dead-index lint. Called once per `/rpc` dispatch alongside the aggregate
2446
- * `metrics` update.
2447
- *
2448
- * Two writes happen here. The in-memory {@link functionStats} map is kept
2449
- * for the fast warm-instance path, and the durable `__lunora_metrics`
2450
- * table is upserted so the counters survive hibernation/restart — the
2451
- * persisted table is the source of truth the admin RPCs read from. The
2452
- * persist is best-effort: a SQL failure (e.g. a test double without a
2453
- * `sql` handle) must never turn a successful dispatch into a failed one,
2454
- * so it is swallowed and the in-memory counters still advance.
2455
- */
2456
- recordFunctionCall(functionPath, durationMs, errorMessage, scannedTables, indexHits, conflicted = false) {
2457
- const now = Date.now();
2458
- const scanned = scannedTables ? [...scannedTables] : [];
2459
- const hits = indexHits ? [...indexHits].map((key) => decodeIndexHitKey(key)).filter((hit) => hit !== void 0) : [];
2460
- try {
2461
- recordFunctionMetric(this.state.storage.sql, {
2462
- conflicted,
2463
- durationMs,
2464
- errored: errorMessage !== void 0,
2465
- errorMessage,
2466
- indexHits: hits,
2467
- path: functionPath,
2468
- scannedTables: scanned,
2469
- ts: now
2470
- });
2471
- } catch {
2472
- }
2473
- const existing = this.functionStats.get(functionPath);
2474
- const stat = existing ?? {
2475
- calls: 0,
2476
- conflicts: 0,
2477
- errors: 0,
2478
- lastCalledAt: now,
2479
- // eslint-disable-next-line unicorn/no-null -- wire shape: `null` until the function first throws
2480
- lastErrorAt: null,
2481
- // eslint-disable-next-line unicorn/no-null -- wire shape: `null` until the function first throws
2482
- lastErrorMessage: null,
2483
- maxDurationMs: 0,
2484
- path: functionPath,
2485
- scannedTables: [],
2486
- scans: 0,
2487
- totalDurationMs: 0
2488
- };
2489
- stat.calls += 1;
2490
- stat.totalDurationMs += durationMs;
2491
- stat.maxDurationMs = Math.max(stat.maxDurationMs, durationMs);
2492
- stat.lastCalledAt = now;
2493
- if (scanned.length > 0) {
2494
- stat.scans += scanned.length;
2495
- mergeScanAttribution(stat.scannedTables, scanned);
2496
- }
2497
- if (errorMessage !== void 0) {
2498
- stat.errors += 1;
2499
- stat.lastErrorAt = now;
2500
- stat.lastErrorMessage = errorMessage;
2501
- }
2502
- if (conflicted) {
2503
- stat.conflicts += 1;
2504
- }
2505
- if (existing === void 0) {
2506
- this.functionStats.set(functionPath, stat);
2507
- }
2508
- }
2509
- /**
2510
- * Flush per-statement SQL samples accumulated during the current dispatch
2511
- * into the durable `__lunora_metrics_queries` table. Called after
2512
- * `recordFunctionCall` on both the success and error paths.
2513
- *
2514
- * Best-effort: a SQL failure (e.g. a test double without a usable `sql`
2515
- * handle) must never fail the response, so every call is swallowed.
2516
- * Clearing `currentStmtSamples` happens in the `finally` block of the
2517
- * dispatch path, not here, so a partial flush (partial error) still
2518
- * drains the correct slice.
2519
- */
2520
- flushStmtSamples() {
2521
- const samples = this.currentStmtSamples;
2522
- if (!samples || samples.length === 0) {
2523
- return;
2524
- }
2525
- try {
2526
- const sqlHandle = this.state.storage.sql;
2527
- for (const [rawSql, durationMs, rowsRead, rowsWritten] of samples) {
2528
- try {
2529
- recordQueryMetric(sqlHandle, rawSql, durationMs, rowsRead, rowsWritten);
2530
- } catch {
2531
- }
2532
- }
2533
- } catch {
2534
- }
2535
- }
2536
- /**
2537
- * Assemble the per-function readout served by
2538
- * `__lunora_admin__:getFunctionStats`, sorted most-recently-called first so
2539
- * the busiest functions surface at the top of the studio table.
2540
- *
2541
- * Reads from the durable `__lunora_metrics` table — the source of truth —
2542
- * so the counts reflect the function's lifetime, not just calls since this
2543
- * instance woke. Falls back to the in-memory map only if the durable read
2544
- * throws (e.g. a test double without a usable `sql` handle), keeping the
2545
- * warm-instance counters available even then. The wire shape is unchanged
2546
- * (`{ functions, sinceMs }`), so existing studio/runtime consumers keep
2547
- * working; the rows are now backed by persisted data.
2548
- */
2549
- collectFunctionStats() {
2550
- try {
2551
- const functions = readFunctionMetrics(this.state.storage.sql);
2552
- return { functions, sinceMs: this.metrics.sinceMs };
2553
- } catch {
2554
- const functions = [...this.functionStats.values()].toSorted((a, b) => b.lastCalledAt - a.lastCalledAt);
2555
- return { functions, sinceMs: this.metrics.sinceMs };
2556
- }
2557
- }
2558
- /**
2559
- * Per-function coarse time-series served additively by the metrics RPC, so
2560
- * the studio can chart call/error history. Reads the durable
2561
- * `__lunora_metrics_buckets` table; returns `[]` when persistence is
2562
- * unavailable so the response stays well-formed.
2563
- */
2564
- collectFunctionMetricBuckets() {
2565
- try {
2566
- return readFunctionMetricBuckets(this.state.storage.sql);
2567
- } catch {
2568
- return [];
2569
- }
2570
- }
2571
- maybeWarnRootSize() {
2572
- if (ShardDO.rootSizeWarned) {
2573
- return;
2574
- }
2575
- const idName = this.state.id?.name;
2576
- if (idName !== ROOT_SHARD_NAME) {
2577
- return;
2578
- }
2579
- const size = this.state.storage.sql?.databaseSize;
2580
- if (typeof size !== "number" || size < ROOT_DO_SIZE_WARN_BYTES) {
2581
- return;
2582
- }
2583
- ShardDO.rootSizeWarned = true;
2584
- console.warn(
2585
- `[@lunora/do] __root__ Durable Object SQLite size is ${String(size)} bytes (>= 1 GiB, 10% of the 10 GiB per-DO ceiling). Plan a \`.shardBy()\` migration before you hit the wall. See https://lunora.sh/docs/concepts/sharding for guidance.`
2586
- );
2587
- }
2588
- /**
2589
- * Map a thrown value to a JSON response. `ValidationError` from
2590
- * `@lunora/values` becomes a 400 with code `VALIDATION_ERROR`. A
2591
- * `LunoraError` keeps its declared status/code. Everything else becomes
2592
- * a 500 with code `RPC_FAILED`.
2593
- */
2594
- // 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
2595
- errorToResponse(error) {
2596
- if (error instanceof ConflictError) {
2597
- return jsonResponse({ error: { code: error.code, message: error.message } }, error.status);
2598
- }
2599
- if (error && typeof error === "object" && error.name === "ValidationError") {
2600
- const message2 = error instanceof Error ? error.message : "validation failed";
2601
- return jsonResponse({ error: { code: "VALIDATION_ERROR", message: message2 } }, 400);
2602
- }
2603
- if (error && typeof error === "object" && error.name === "LunoraError") {
2604
- const lunoraError = error;
2605
- const status = typeof lunoraError.status === "number" ? lunoraError.status : 500;
2606
- return jsonResponse({ error: { code: lunoraError.code ?? "INTERNAL", message: lunoraError.message ?? "internal error" } }, status);
2607
- }
2608
- const message = error instanceof Error ? error.message : "unknown error";
2609
- return jsonResponse({ error: { code: "RPC_FAILED", message } }, 500);
2610
- }
2611
- /**
2612
- * Serve a reserved admin-introspection RPC (`__lunora_admin__:*`) for the
2613
- * data browser. Gated by `env.LUNORA_ADMIN_TOKEN`: introspection is
2614
- * **disabled unless the token is configured**, and when it is, the request
2615
- * must present a matching `Authorization: Bearer` header. The blast radius
2616
- * is raw table contents, so the default is closed — unlike the WebSocket
2617
- * upgrade gate, which defaults open for local dev.
2618
- */
2619
- async handleAdminRpc(request, functionPath, args) {
2620
- if (!this.isAdminAuthorized(request)) {
2621
- return jsonResponse({ error: { code: "ADMIN_FORBIDDEN", message: "admin introspection is disabled or the bearer token is invalid" } }, 403);
2622
- }
2623
- try {
2624
- const read = this.readAdminOp(functionPath, args);
2625
- if (read) {
2626
- return jsonResponse({ result: read.result }, 200);
2627
- }
2628
- if (functionPath === ADMIN_FUNCTIONS.runMigration) {
2629
- const parsed = parseRunMigrationArgs(args);
2630
- const result = await this.runShardDataMigration(parsed);
2631
- await this.flushChangedTables();
2632
- this.recordAudit("runMigration", {
2633
- id: parsed.id,
2634
- detail: { changed: result.changed, direction: result.direction, dryRun: result.dryRun, processed: result.processed }
2635
- });
2636
- return jsonResponse({ result }, 200);
2637
- }
2638
- if (functionPath === ADMIN_FUNCTIONS.exportShard) {
2639
- const parsed = parseExportShardArgs(args);
2640
- const rows = await this.runShardExport({ batchSize: parsed.batchSize, tables: parsed.tables });
2641
- return jsonResponse({ result: { rows } }, 200);
2642
- }
2643
- if (functionPath === ADMIN_FUNCTIONS.importShard) {
2644
- const parsed = parseImportShardArgs(args);
2645
- const result = await this.runShardImport({ rows: parsed.rows, startLine: parsed.startLine });
2646
- await this.flushChangedTables();
2647
- this.recordAudit("importShard", { detail: { conflicts: result.conflicts, errors: result.errors.length, inserted: result.inserted } });
2648
- return jsonResponse({ result }, 200);
2649
- }
2650
- if (functionPath === ADMIN_FUNCTIONS.writeRow) {
2651
- const parsed = parseWriteRowArgs(args);
2652
- const result = await this.runShardWrite(parsed);
2653
- await this.flushChangedTables();
2654
- this.recordAudit("writeRow", { table: parsed.table, id: result.id ?? parsed.id, detail: { op: result.op } });
2655
- return jsonResponse({ result }, 200);
2656
- }
2657
- if (functionPath === ADMIN_FUNCTIONS.deleteRows) {
2658
- const parsed = parseBulkDeleteArgs(args);
2659
- const result = await this.runShardBulkDelete(parsed);
2660
- await this.flushChangedTables();
2661
- this.recordAudit("deleteRows", { table: parsed.table, detail: { deleted: result.deleted, hasMore: result.hasMore } });
2662
- return jsonResponse({ result }, 200);
2663
- }
2664
- if (functionPath === ADMIN_FUNCTIONS.clearTable) {
2665
- const parsed = parseClearTableArgs(args);
2666
- const result = await this.runShardBulkDelete(parsed);
2667
- await this.flushChangedTables();
2668
- this.recordAudit("clearTable", { table: parsed.table, detail: { deleted: result.deleted, hasMore: result.hasMore } });
2669
- return jsonResponse({ result }, 200);
2670
- }
2671
- if (functionPath === ADMIN_FUNCTIONS.rankBefore) {
2672
- const result = await this.runShardRankBefore(parseRankBeforeArgs(args));
2673
- return jsonResponse({ result }, 200);
2674
- }
2675
- if (functionPath === ADMIN_FUNCTIONS.rankPage) {
2676
- const result = await this.runShardRankPage(parseRankPageArgs(args));
2677
- return jsonResponse({ result }, 200);
2678
- }
2679
- if (functionPath === ADMIN_FUNCTIONS.cdcSync) {
2680
- const result = this.runShardCdcSync(parseCdcSyncArgs(args));
2681
- return jsonResponse({ result }, 200);
2682
- }
2683
- if (functionPath === ADMIN_FUNCTIONS.applyCdc) {
2684
- const result = await this.runShardApplyCdc(parseApplyCdcArgs(args));
2685
- await this.flushChangedTables();
2686
- this.recordAudit("applyCdc", { detail: { applied: result.applied } });
2687
- return jsonResponse({ result }, 200);
2688
- }
2689
- if (functionPath === ADMIN_FUNCTIONS.runAs) {
2690
- return this.handleRunAs(args);
2691
- }
2692
- const handled = await this.handleExtraAdminOp(functionPath, args);
2693
- if (handled) {
2694
- return handled;
2695
- }
2696
- return jsonResponse({ error: { code: "UNKNOWN_ADMIN_OP", message: `unknown admin op: ${functionPath}` } }, 404);
2697
- } catch (error) {
2698
- return this.errorToResponse(error);
2699
- }
2700
- }
2701
- /**
2702
- * Dispatch the side-effecting / non-read admin ops that `handleAdminRpc`
2703
- * doesn't handle inline: the auth-event + mail-capture writes and the native
2704
- * PITR ops. Returns the op's `Response`, or `undefined` when `functionPath`
2705
- * isn't one of these (so the caller answers 404). Kept out of
2706
- * `handleAdminRpc` to hold that dispatcher under the complexity budget,
2707
- * mirroring `handlePitrAdminOp`.
2708
- */
2709
- async handleExtraAdminOp(functionPath, args) {
2710
- if (functionPath === ADMIN_FUNCTIONS.recordAuthEvent) {
2711
- return this.handleRecordAuthEvent(args);
2712
- }
2713
- if (functionPath === ADMIN_FUNCTIONS.recordContainerEvent) {
2714
- return this.handleRecordContainerEvent(args);
2715
- }
2716
- if (functionPath === ADMIN_FUNCTIONS.recordMail) {
2717
- return this.handleRecordMail(args);
2718
- }
2719
- if (functionPath === ADMIN_FUNCTIONS.clearCapturedMail) {
2720
- return this.handleClearCapturedMail();
2721
- }
2722
- if (functionPath === ADMIN_FUNCTIONS.sendTestMail) {
2723
- return this.handleSendTestMail(args);
2724
- }
2725
- if (functionPath === ADMIN_FUNCTIONS.createWorkflowInstance) {
2726
- return this.handleCreateWorkflowInstance(args);
2727
- }
2728
- if (functionPath === ADMIN_FUNCTIONS.getWorkflowInstanceStatus) {
2729
- return this.handleGetWorkflowInstanceStatus(args);
2730
- }
2731
- return this.handlePitrAdminOp(functionPath, args);
2732
- }
2733
- /**
2734
- * Record one app-level auth attempt for the auth-failure SLO (PLAN3 §2.3).
2735
- * The worker calls this fire-and-forget (via `waitUntil`) after a top-level
2736
- * `/api/auth/*` ATTEMPT route returns, so it never blocks or fails the auth
2737
- * response. `outcome` is validated up front (400 `BAD_REQUEST` on a bad
2738
- * value); the durable upsert itself is best-effort — a SQL failure is
2739
- * swallowed so the SLO signal is simply absent rather than turning the
2740
- * recording call into an error. Admin-gated by `handleAdminRpc`'s caller.
2741
- */
2742
- handleRecordAuthEvent(args) {
2743
- const parsed = parseRecordAuthEventArgs(args);
2744
- try {
2745
- recordAuthEvent(this.state.storage.sql, { outcome: parsed.outcome, ts: Date.now() });
2746
- } catch {
2747
- }
2748
- return jsonResponse({ result: { recorded: true } }, 200);
2749
- }
2750
- /**
2751
- * Append one container lifecycle event to the in-memory {@link LogBuffer}
2752
- * the `getLogs` admin RPC reads, so a start/stop/error on a Container DO
2753
- * surfaces in the Studio Logs panel — not just the dev terminal. The
2754
- * Container DO pushes this best-effort (its `console` print stays the source
2755
- * of truth), so a missing/garbage envelope is rejected up front (400) rather
2756
- * than corrupting the buffer. Mapped to `functionPath: "container:&lt;name>"` so
2757
- * the panel renders it alongside `ctx.log` lines. Admin-gated by
2758
- * `handleAdminRpc`'s caller (the same `LUNORA_ADMIN_TOKEN` bearer as every
2759
- * other admin write).
2760
- */
2761
- handleRecordContainerEvent(args) {
2762
- const entry = parseRecordContainerEventArgs(args);
2763
- this.logs.push(entry);
2764
- return jsonResponse({ result: { recorded: true } }, 200);
2765
- }
2766
- /**
2767
- * Serve the `__lunora_admin__:runAs` admin RPC — the studio's "Run as
2768
- * identity" tool. Dispatches the target `functionPath` through the normal
2769
- * `handleRpc` path while the per-request identity is forged to the supplied
2770
- * `userId`/`identity`, so the function (and any RLS middleware it uses)
2771
- * observes that user instead of the admin caller.
2772
- *
2773
- * SECURITY. This op is reachable only after `handleAdminRpc`'s
2774
- * `isAdminAuthorized` bearer check (the `LUNORA_ADMIN_TOKEN` gate), so an
2775
- * unauthenticated caller can never forge an identity. The inbound
2776
- * `x-lunora-userid`/`x-lunora-identity` headers the runtime sets are
2777
- * overwritten here for the duration of the dispatch and restored after, so
2778
- * the forge can't leak into a later request. The target path is validated to
2779
- * be a non-admin function, so it can't be used to re-enter the admin plane.
2780
- * The studio only surfaces this tool behind a loopback-dev gate.
2781
- */
2782
- async handleRunAs(args) {
2783
- const parsed = parseRunAsArgs(args);
2784
- const result = await this.withRequestIdentity(parsed.userId, parsed.identity, () => this.handleRpc(parsed.functionPath, parsed.args));
2785
- await this.flushChangedTables();
2786
- this.recordAudit("runAs", { detail: { functionPath: parsed.functionPath, runAsUserId: parsed.userId } });
2787
- return jsonResponse({ result }, 200);
2788
- }
2789
- /* eslint-disable no-secrets/no-secrets -- reserved admin RPC names are framework constants, not credentials */
2790
- /**
2791
- * Resolve a declared workflow's runtime binding handle from this shard's `env`.
2792
- * Looks the `exportName` up in {@link workflowsMetadata} (the codegen subclass's
2793
- * statically-discovered list) to find its generated `WORKFLOW_*` binding, then
2794
- * reads `env[binding]` and validates it carries the `create`/`get` methods. A
2795
- * bad export name or a missing/malformed binding throws a 400 `LunoraError` so
2796
- * the studio surfaces an actionable message instead of a generic 500.
2797
- */
2798
- resolveWorkflowBinding(exportName) {
2799
- const metadata = this.workflowsMetadata().workflows.find((workflow) => workflow.exportName === exportName);
2800
- if (!metadata) {
2801
- throw Object.assign(new Error(`workflow "${exportName}" is not declared`), { code: "BAD_REQUEST", name: "LunoraError", status: 400 });
2802
- }
2803
- const binding = this.env?.[metadata.binding];
2804
- if (typeof binding !== "object" || binding === null || typeof binding.create !== "function" || typeof binding.get !== "function") {
2805
- throw Object.assign(new Error(`workflow binding "${metadata.binding}" is not available on this deployment`), {
2806
- code: "BAD_REQUEST",
2807
- name: "LunoraError",
2808
- status: 400
2809
- });
2810
- }
2811
- return binding;
2812
- }
2813
- /**
2814
- * Serve `__lunora_admin__:createWorkflowInstance` — the studio's "Start
2815
- * instance" button. Resolves the declared workflow's `WORKFLOW_*` binding and
2816
- * calls `.create({ id?, params })`, returning the new instance's id and initial
2817
- * status. No SQLite write happens (workflows are not Durable Objects and hold
2818
- * no shard state), so this only records an audit entry — there's nothing to
2819
- * flush. Admin-gated by `handleAdminRpc`'s caller.
2820
- */
2821
- async handleCreateWorkflowInstance(args) {
2822
- const parsed = parseCreateWorkflowInstanceArgs(args);
2823
- const binding = this.resolveWorkflowBinding(parsed.exportName);
2824
- const instance = await binding.create({ id: parsed.id, params: parsed.params });
2825
- const snapshot = await instance.status();
2826
- const result = { id: instance.id, status: toWorkflowInstanceState(snapshot.status) };
2827
- this.recordAudit("createWorkflowInstance", { id: instance.id, detail: { exportName: parsed.exportName } });
2828
- return jsonResponse({ result }, 200);
2829
- }
2830
- /**
2831
- * Serve `__lunora_admin__:getWorkflowInstanceStatus` — the studio's instance
2832
- * observer. Resolves the workflow binding, fetches the instance handle by id,
2833
- * and reports its current status plus output/error when present. Read-only:
2834
- * inspecting an instance mutates no shard state, so nothing is flushed or
2835
- * audited. Admin-gated by `handleAdminRpc`'s caller.
2836
- */
2837
- async handleGetWorkflowInstanceStatus(args) {
2838
- const parsed = parseGetWorkflowInstanceStatusArgs(args);
2839
- const binding = this.resolveWorkflowBinding(parsed.exportName);
2840
- const instance = await binding.get(parsed.id);
2841
- const snapshot = await instance.status();
2842
- const result = {
2843
- error: toWorkflowInstanceError(snapshot.error),
2844
- id: parsed.id,
2845
- output: snapshot.output,
2846
- status: toWorkflowInstanceState(snapshot.status)
2847
- };
2848
- return jsonResponse({ result }, 200);
2849
- }
2850
- /* eslint-enable no-secrets/no-secrets */
2851
- /**
2852
- * Run `run()` with the per-request identity pinned to (`userId`, `identity`),
2853
- * then restore the prior values in a `finally` (even if `run()` throws), so the
2854
- * forced identity can never leak into a later dispatch on this DO instance. The
2855
- * generated `buildCtx` reads identity via `getCurrentUserId`/`getCurrentIdentity`,
2856
- * so pinning the fields around the call makes the dispatched function observe the
2857
- * chosen identity without threading it through the generated signature.
2858
- *
2859
- * The single caller is {@link handleRunAs} (pins a forged user — the dev
2860
- * "Run as identity" tool), which runs synchronously on the request thread
2861
- * with no intervening concurrent dispatch. Subscriptions deliberately do NOT
2862
- * use this primitive: they run in deferred/interleaved contexts where
2863
- * mutating the shared field would race a concurrent RPC, so they thread an
2864
- * explicit {@link SubscriptionIdentity} into `executeSubscription` instead.
2865
- */
2866
- async withRequestIdentity(userId, identity, run) {
2867
- const previousUserId = this.currentRequestUserId;
2868
- const previousIdentity = this.currentRequestIdentity;
2869
- this.currentRequestUserId = userId;
2870
- this.currentRequestIdentity = identity;
2871
- try {
2872
- return await run();
2873
- } finally {
2874
- this.currentRequestUserId = previousUserId;
2875
- this.currentRequestIdentity = previousIdentity;
2876
- }
2877
- }
2878
- /**
2879
- * Capture one outbound message into the dev mail catcher (`mail-catcher.ts`).
2880
- * `@lunora/mail`'s capture transport POSTs each rendered, validated send here
2881
- * (fire-and-forget) so the studio's Mail inbox shows it. Admin-gated by
2882
- * `handleAdminRpc`'s caller, so only a request bearing `LUNORA_ADMIN_TOKEN`
2883
- * can record — and the worker only ever calls this when the capture transport
2884
- * is wired (dev). Validates the payload (400 on a bad shape) and returns the
2885
- * generated id.
2886
- *
2887
- * Note: the gate is the admin token alone — the same trust boundary that
2888
- * already protects every other admin write (`writeRow`, `clearTable`,
2889
- * `deleteRows`, `runSql`). A token holder can already mutate the shard
2890
- * arbitrarily, so a token-gated mailbox insert adds no new privilege; the DO
2891
- * has no signal for "capture is active", so an inert-unless-capture guard
2892
- * isn't enforced here (an accepted relaxation of plan 011's STOP condition).
2893
- */
2894
- handleRecordMail(args) {
2895
- const parsed = parseRecordMailArgs(args);
2896
- const result = recordCapturedMail(this.state.storage.sql, parsed, Date.now());
2897
- return jsonResponse({ result }, 200);
2898
- }
2899
- /** Empty the dev mail-catcher inbox (studio "clear inbox" action). Admin-gated by the caller. */
2900
- handleClearCapturedMail() {
2901
- const result = clearCapturedMail(this.state.storage.sql);
2902
- return jsonResponse({ result }, 200);
2903
- }
2904
- /**
2905
- * Populate the dev mail-catcher inbox with one synthetic message (studio
2906
- * "Send test" button) so the inbox can be exercised in one click. Builds the
2907
- * message via {@link buildTestMailInput} (validating the optional `to`) and
2908
- * records it through the same `recordCapturedMail` path as a real capture.
2909
- * Admin-gated by `handleAdminRpc`'s caller.
2910
- */
2911
- handleSendTestMail(args) {
2912
- const input = buildTestMailInput(args);
2913
- const result = recordCapturedMail(this.state.storage.sql, input, Date.now());
2914
- return jsonResponse({ result }, 200);
2915
- }
2916
- /**
2917
- * Append one durable audit entry for a state-changing admin op that just
2918
- * succeeded, folding the acting user (from `getCurrentUserId`) into `detail`.
2919
- * Called only on the success path, so a rejected/validated op leaves no
2920
- * trace. Best-effort: the write happens after the op's own commit, so it
2921
- * never blocks or fails the response.
2922
- */
2923
- recordAudit(op, fields = {}) {
2924
- const sql = this.state.storage.sql;
2925
- const userId = this.getCurrentUserId();
2926
- const detail = userId === void 0 ? fields.detail : { ...fields.detail, userId };
2927
- appendAuditEntry(sql, { detail, id: fields.id, op, table: fields.table, ts: Date.now() });
2928
- }
2929
- /**
2930
- * Append one structured entry to the durable request log (`request-log.ts`)
2931
- * for a `/rpc` dispatch that just completed — the per-request readout
2932
- * (`&lt;file>:&lt;function>`, shard key, acting user/identity, redacted args,
2933
- * outcome, duration, tables read/written, cache hit) that Cloudflare cannot
2934
- * attribute (PLAN3 §1.1). When `LUNORA_REQUEST_LOG_EMIT` is set, the same
2935
- * entry is ALSO emitted as a structured console event for CF Workers Logs /
2936
- * Logpush to ship to external SIEMs (PLAN3 §3.3) — see `requestLogConfig`.
2937
- *
2938
- * Volume is bounded by two knobs (`requestLogConfig`): SUCCESSFUL dispatches
2939
- * are sampled at `LUNORA_REQUEST_LOG_SAMPLE` (errors always recorded) and the
2940
- * durable rows are trimmed to `LUNORA_REQUEST_LOG_RETENTION`. Args/identity
2941
- * are redacted by default and captured raw only in a dev environment.
2942
- *
2943
- * Best-effort, exactly like `recordFunctionCall`'s durable upsert: a SQL
2944
- * failure (e.g. a test double without a `sql` handle) must NEVER turn a
2945
- * served request into a failed one, so it is swallowed. Args are redacted
2946
- * inside `appendRequestLogEntry` so a raw value never reaches the table.
2947
- *
2948
- * The correlated fields all come from data the dispatch already holds, with
2949
- * no extra hot-path bookkeeping. `tablesWritten` is snapshotted from
2950
- * `pendingChangedTables` by the caller before `flushChangedTables` drains it.
2951
- * `tablesRead` and `cacheHit` are captured by `runCachedQuery`, so they are
2952
- * present only for cached query paths — a write/action doesn't run through
2953
- * the cache, and an instance with the reactive cache disabled never captures
2954
- * them — and are left empty/`undefined` rather than recomputed here.
2955
- * `subscriptionsReRun` is left `0`: the write-driven subscription refresh
2956
- * runs off the response path via `waitUntil` (see `flushChangedTables`), so a
2957
- * per-request count isn't available synchronously at this site, and threading
2958
- * one back would add bookkeeping to the deferred fan-out for no correctness
2959
- * benefit — recorded as `0` with this note rather than faked.
2960
- */
2961
- recordRequestLog(functionPath, args, durationMs, outcome, tablesWritten, errorMessage) {
2962
- const config = this.requestLogConfig();
2963
- if (outcome === "ok" && !sampleHit(config.sampleRate)) {
2964
- return;
2965
- }
2966
- const entry = {
2967
- cacheHit: this.currentRequestCacheHit,
2968
- durationMs,
2969
- errorMessage,
2970
- functionPath,
2971
- identity: this.currentRequestIdentity,
2972
- outcome,
2973
- redactedArgs: Object.keys(args).length === 0 ? void 0 : args,
2974
- shardKey: this.state.id?.name,
2975
- tablesRead: this.currentRequestReadTables === void 0 ? [] : [...this.currentRequestReadTables],
2976
- tablesWritten,
2977
- ts: Date.now(),
2978
- userId: this.getCurrentUserId()
2979
- };
2980
- const writeOptions = { captureRaw: config.captureRaw, retention: config.retention };
2981
- try {
2982
- appendRequestLogEntry(this.state.storage.sql, entry, writeOptions);
2983
- } catch {
2984
- }
2985
- if (config.emit || outcome === "error") {
2986
- try {
2987
- emitRequestLogEvent(entry, writeOptions);
2988
- } catch {
2989
- }
2990
- }
2991
- }
2992
- /**
2993
- * Resolve the request-log knobs from the Worker `env`, all PLAN3 §3.3 decisions.
2994
- *
2995
- * `captureRaw`: raw (un-redacted) args/identity in a dev environment, redacted
2996
- * in production (`isDevEnvironment`) — default redacted.
2997
- *
2998
- * `emit`: also stream each entry as a console event for CF Workers Logs /
2999
- * Logpush (and the dev-server terminal). Explicit `LUNORA_REQUEST_LOG_EMIT`
3000
- * (`"1"`/`"true"` vs `"0"`/`"false"`) always wins; unset, it defaults to
3001
- * `isDevEnvironment` — ON in dev so a developer sees every dispatch, OFF in
3002
- * production where a line per dispatch is log volume an operator opts into.
3003
- * Errors stream regardless (see `recordRequestLog`).
3004
- *
3005
- * `retention`: durable-row cap override (`LUNORA_REQUEST_LOG_RETENTION`);
3006
- * `undefined` falls back to the module default.
3007
- *
3008
- * `sampleRate`: fraction of SUCCESSFUL dispatches recorded
3009
- * (`LUNORA_REQUEST_LOG_SAMPLE`, 0..1, default 1.0 = all); errors always record.
3010
- */
3011
- requestLogConfig() {
3012
- const env = this.env ?? {};
3013
- return {
3014
- captureRaw: isDevEnvironment(this.env),
3015
- emit: parseEmit(env.LUNORA_REQUEST_LOG_EMIT, isDevEnvironment(this.env)),
3016
- retention: parsePositiveInt(env.LUNORA_REQUEST_LOG_RETENTION),
3017
- sampleRate: parseSampleRate(env.LUNORA_REQUEST_LOG_SAMPLE)
3018
- };
3019
- }
3020
- /**
3021
- * Native Durable-Object PITR ops (the ≤30-day in-place tier). `getPitrBookmark`
3022
- * reads the current/for-time bookmark; `pitrRestore` arms a restore to a
3023
- * bookmark/time (auditing the target + undo bookmark before any restart, so the
3024
- * undo point survives even if `abort()` drops the response). Returns `null` when
3025
- * `functionPath` isn't a PITR op so the caller falls through. Kept out of
3026
- * `handleAdminRpc` to hold that dispatcher under the complexity budget.
3027
- */
3028
- async handlePitrAdminOp(functionPath, args) {
3029
- const time = typeof args.time === "number" || typeof args.time === "string" ? args.time : void 0;
3030
- if (functionPath === ADMIN_FUNCTIONS.getPitrBookmark) {
3031
- return jsonResponse({ result: await readBookmark(this.state.storage, time) }, 200);
3032
- }
3033
- if (functionPath !== ADMIN_FUNCTIONS.pitrRestore) {
3034
- return void 0;
3035
- }
3036
- const restart = args.restart === true;
3037
- const bookmark = typeof args.bookmark === "string" ? args.bookmark : void 0;
3038
- const armed = await armRestore(this.state.storage, { bookmark, time });
3039
- if (this.cdcEnabled()) {
3040
- bumpCdcEpoch(this.sql);
3041
- }
3042
- this.recordAudit("pitrRestore", { detail: { restart, restoredTo: armed.restoredTo, undoBookmark: armed.undoBookmark } });
3043
- const response = jsonResponse({ result: { ...armed, restarted: restart } }, 200);
3044
- if (restart) {
3045
- this.state.abort?.("lunora PITR restore");
3046
- }
3047
- return response;
3048
- }
3049
- /**
3050
- * Run a single read-only admin introspection op, returning its result plus
3051
- * the table-dependency set the subscription bridge uses to decide when to
3052
- * re-run it. Write/migration/export ops are NOT handled here — they stay in
3053
- * `handleAdminRpc` because they mutate state and can't be safely
3054
- * re-executed on every write-flush. Returns `null` for any non-read op.
3055
- *
3056
- * `readTablePage` depends on exactly the table it reads, so a write to an
3057
- * unrelated table never re-runs it. The counter/log ops (`getMetrics`,
3058
- * `getLogs`, `listTables`, `migrationStatus`) aren't bound to a single
3059
- * table; they carry the {@link ADMIN_WILDCARD} sentinel so
3060
- * `refreshSubscriptions` re-runs them on every write-flush. The
3061
- * per-socket JSON memo in `pushSubscriptionData` still suppresses
3062
- * pushes when the recomputed value is byte-identical.
3063
- * @returns the result and table-dependency set for a read op, or `null` for a write/migration op
3064
- */
3065
- readAdminOp(functionPath, args) {
3066
- this.ensureMigrated();
3067
- const sql = this.state.storage.sql;
3068
- const wildcardRead = this.readAdminWildcardOp(functionPath);
3069
- if (wildcardRead !== void 0) {
3070
- return { result: wildcardRead, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
3071
- }
3072
- if (functionPath === ADMIN_FUNCTIONS.getAuditLog) {
3073
- return this.readAdminAuditLog(sql, args);
3074
- }
3075
- if (functionPath === ADMIN_FUNCTIONS.getRequestLog) {
3076
- return this.readAdminRequestLog(sql, args);
3077
- }
3078
- const durable = this.readAdminDurableSignal(functionPath, sql, args);
3079
- if (durable) {
3080
- return durable;
3081
- }
3082
- if (functionPath === ADMIN_FUNCTIONS.readTablePage) {
3083
- return this.readAdminTablePage(sql, args);
3084
- }
3085
- if (functionPath === ADMIN_FUNCTIONS.facetColumn) {
3086
- return this.readAdminFacetColumn(sql, args);
3087
- }
3088
- if (functionPath === ADMIN_FUNCTIONS.runSql) {
3089
- return this.readAdminRunSql(sql, args);
3090
- }
3091
- const tableSignal = this.readAdminTableSignal(functionPath, sql, args);
3092
- if (tableSignal) {
3093
- return tableSignal;
3094
- }
3095
- const storage = this.readAdminStorageSignal(functionPath, sql, args);
3096
- if (storage) {
3097
- return storage;
3098
- }
3099
- return null;
3100
- }
3101
- /**
3102
- * Resolve the table-scoped introspection reads whose payload is a single
3103
- * `this.*()` lookup keyed by an optional `table` arg — `listTableIndexes`
3104
- * (declared indexes), `describeTable` (declared columns) and `migrationStatus`
3105
- * (the migration ledger). The first two carry their `table` (or the
3106
- * {@link ADMIN_WILDCARD} sentinel when unscoped); `migrationStatus` is
3107
- * deployment-wide, so it always carries the wildcard. Returns `undefined` for
3108
- * any other path so {@link readAdminOp} falls through; folded into one helper
3109
- * to keep that dispatcher under its complexity budget.
3110
- * @returns the read result and its table-dependency set, or `undefined` when the path is not owned by this resolver
3111
- */
3112
- readAdminTableSignal(functionPath, sql, args) {
3113
- if (functionPath === ADMIN_FUNCTIONS.listTableIndexes || functionPath === ADMIN_FUNCTIONS.describeTable) {
3114
- const table = typeof args["table"] === "string" ? args["table"] : "";
3115
- const result = functionPath === ADMIN_FUNCTIONS.describeTable ? { columns: this.tableColumns(table) } : { indexes: this.tableIndexes(table) };
3116
- return { result, tables: /* @__PURE__ */ new Set([table === "" ? ADMIN_WILDCARD : table]) };
3117
- }
3118
- if (functionPath === ADMIN_FUNCTIONS.describeTables) {
3119
- const requested = Array.isArray(args["tables"]) ? args["tables"].filter((table) => typeof table === "string") : [];
3120
- const columnsByTable = Object.fromEntries(requested.map((table) => [table, this.tableColumns(table)]));
3121
- return { result: { columnsByTable }, tables: new Set(requested.length === 0 ? [ADMIN_WILDCARD] : requested) };
3122
- }
3123
- if (functionPath === ADMIN_FUNCTIONS.migrationStatus) {
3124
- const id = typeof args["id"] === "string" ? args["id"] : void 0;
3125
- return { result: { migrations: readMigrationStatus(sql, id) }, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
3126
- }
3127
- return void 0;
3128
- }
3129
- /**
3130
- * Resolve the storage↔schema correlation admin reads — the file browser's
3131
- * records↔files join (`storageReferences`, object→owning-record + per-key
3132
- * orphans) and its inverse (`storageOrphans`, dangling references: records
3133
- * pointing at a missing object). Both scan only the schema's declared
3134
- * `v.storage()` columns. Returns `undefined` for any other path so
3135
- * {@link readAdminOp} falls through; folded into one helper to keep that
3136
- * dispatcher under its complexity budget.
3137
- * @returns the read result and its table-dependency set, or `undefined` when the path is not owned by this resolver
3138
- */
3139
- readAdminStorageSignal(functionPath, sql, args) {
3140
- if (functionPath === ADMIN_FUNCTIONS.storageReferences) {
3141
- return this.readAdminStorageReferences(sql, args);
3142
- }
3143
- if (functionPath === ADMIN_FUNCTIONS.storageOrphans) {
3144
- return this.readAdminStorageOrphans(sql, args);
3145
- }
3146
- return void 0;
3147
- }
3148
- /**
3149
- * Resolve a `storageReferences` admin read — the file browser's records↔files
3150
- * join: given the object keys on the page, return the rows that reference each
3151
- * (via a `v.storage()` column) plus the schema's declared storage columns.
3152
- * Scans only those columns through {@link findStorageReferences}. Carries the
3153
- * {@link ADMIN_WILDCARD} (it spans every storage table) so a live subscription
3154
- * re-runs on any write.
3155
- */
3156
- readAdminStorageReferences(sql, args) {
3157
- const keys = Array.isArray(args["keys"]) ? args["keys"].filter((key) => typeof key === "string") : [];
3158
- return { result: findStorageReferences(sql, this.storageColumns(), keys), tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
3159
- }
3160
- /**
3161
- * Resolve a `storageOrphans` admin read — the inverse of the records↔files
3162
- * join: given the set of object keys that actually exist in the bucket
3163
- * (`liveKeys`, the studio's enumerated listing), return every record
3164
- * `v.storage()` field whose value points at a key the bucket DOES NOT have — a
3165
- * **dangling reference**. CF's R2 browser can never make this join. Scans only
3166
- * the schema's declared storage columns through {@link findDanglingReferences},
3167
- * bounded with a `truncated` flag (logged once when set). Carries the
3168
- * {@link ADMIN_WILDCARD} (it spans every storage table) so a live subscription
3169
- * re-runs on any write.
3170
- */
3171
- readAdminStorageOrphans(sql, args) {
3172
- const liveKeys = Array.isArray(args["liveKeys"]) ? args["liveKeys"].filter((key) => typeof key === "string") : [];
3173
- const result = findDanglingReferences(sql, this.storageColumns(), liveKeys);
3174
- if (result.truncated) {
3175
- console.warn(
3176
- `[@lunora/do] storageOrphans scan truncated after checking ${String(result.scanned)} storage references; reporting the first ${String(result.references.length)} dangling reference(s).`
3177
- );
3178
- }
3179
- return { result, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
3180
- }
3181
- /**
3182
- * Resolve the read-only admin ops whose result isn't bound to a single table
3183
- * — the in-memory counters (`getMetrics`, `getFunctionStats`), the table list
3184
- * (`listTables`), the in-memory error buffer (`getLogs`) and the masked
3185
- * deployment config (`getSettings`). Returns the result value, or `undefined`
3186
- * for any path it doesn't own (so `readAdminOp` falls through). The caller
3187
- * wraps each in the {@link ADMIN_WILDCARD} sentinel, keeping that one fact in
3188
- * a single place and `readAdminOp` under its complexity budget.
3189
- */
3190
- readAdminWildcardOp(functionPath) {
3191
- if (functionPath === ADMIN_FUNCTIONS.listTables) {
3192
- return listTables(this.state.storage.sql);
3193
- }
3194
- if (functionPath === ADMIN_FUNCTIONS.getMetrics) {
3195
- return this.collectMetrics();
3196
- }
3197
- if (functionPath === ADMIN_FUNCTIONS.getFunctionStats) {
3198
- return this.collectFunctionStats();
3199
- }
3200
- if (functionPath === ADMIN_FUNCTIONS.listSubscriptions) {
3201
- return this.collectSubscriptions();
3202
- }
3203
- if (functionPath === ADMIN_FUNCTIONS.getLogs) {
3204
- return { entries: this.logs.entries() };
3205
- }
3206
- if (functionPath === ADMIN_FUNCTIONS.getSettings) {
3207
- return buildSettings(this.env);
3208
- }
3209
- if (functionPath === ADMIN_FUNCTIONS.getSecurityAudit) {
3210
- return buildSecurityAudit(this.env);
3211
- }
3212
- if (functionPath === ADMIN_FUNCTIONS.getAdvisories) {
3213
- return { advisories: [...this.advisories(), ...this.runtimeAdvisories()] };
3214
- }
3215
- if (functionPath === ADMIN_FUNCTIONS.rlsPolicies) {
3216
- return this.rlsMetadata();
3217
- }
3218
- if (functionPath === ADMIN_FUNCTIONS.maskPolicies) {
3219
- return this.maskMetadata();
3220
- }
3221
- if (functionPath === ADMIN_FUNCTIONS.storageRules) {
3222
- return this.storageRulesMetadata();
3223
- }
3224
- if (functionPath === ADMIN_FUNCTIONS.studioFeatures) {
3225
- return this.studioFeatures();
3226
- }
3227
- if (functionPath === ADMIN_FUNCTIONS.listWorkflows) {
3228
- return this.workflowsMetadata();
3229
- }
3230
- if (functionPath === ADMIN_FUNCTIONS.listQueues) {
3231
- return this.queuesMetadata();
3232
- }
3233
- return void 0;
3234
- }
3235
- /**
3236
- * Enumerate every connected WebSocket and the subscriptions it tracks for
3237
- * the `__lunora_admin__:listSubscriptions` realtime inspector. Reads each
3238
- * socket's hibernation attachment (admin flag + live `subs` map) and folds
3239
- * them into a {@link SubscriptionsResult} via {@link summarizeSubscriptions}.
3240
- * Read-only: it touches no SQLite and mutates no socket state.
3241
- */
3242
- collectSubscriptions() {
3243
- return summarizeSubscriptions(this.state.getWebSockets().map((ws) => this.readAttachment(ws)));
3244
- }
3245
- /** Resolve a `getAuditLog` admin read, parsing the optional `limit`/`sinceSeq` cursor args and ensuring the reserved table first. */
3246
- // eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers and future per-instance state
3247
- readAdminAuditLog(sql, args) {
3248
- ensureAuditTable(sql);
3249
- const limit = typeof args["limit"] === "number" ? args["limit"] : void 0;
3250
- const sinceSeq = typeof args["sinceSeq"] === "number" ? args["sinceSeq"] : void 0;
3251
- const result = { entries: readAuditLog(sql, { limit, sinceSeq }) };
3252
- return { result, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
3253
- }
3254
- /**
3255
- * Resolve a `getRequestLog` admin read, parsing the optional correlation
3256
- * filters (function-path prefix, exact userId/shardKey/outcome, table-touched)
3257
- * plus the `limit`/`sinceSeq` cursor, and ensuring the reserved table first.
3258
- * Carries the {@link ADMIN_WILDCARD} like the other log reads so a live Logs
3259
- * subscription re-runs on every write-flush (the per-socket JSON memo still
3260
- * suppresses byte-identical pushes).
3261
- */
3262
- // eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers and future per-instance state
3263
- readAdminRequestLog(sql, args) {
3264
- ensureRequestLogTable(sql);
3265
- const outcome = args["outcome"] === "ok" || args["outcome"] === "error" ? args["outcome"] : void 0;
3266
- const result = {
3267
- entries: readRequestLog(sql, {
3268
- functionPathPrefix: typeof args["functionPathPrefix"] === "string" ? args["functionPathPrefix"] : void 0,
3269
- limit: typeof args["limit"] === "number" ? args["limit"] : void 0,
3270
- outcome,
3271
- shardKey: typeof args["shardKey"] === "string" ? args["shardKey"] : void 0,
3272
- sinceSeq: typeof args["sinceSeq"] === "number" ? args["sinceSeq"] : void 0,
3273
- tableTouched: typeof args["tableTouched"] === "string" ? args["tableTouched"] : void 0,
3274
- userId: typeof args["userId"] === "string" ? args["userId"] : void 0
3275
- })
3276
- };
3277
- return { result, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
3278
- }
3279
- /**
3280
- * Resolve a `getAuthMetrics` admin read: the durable app-level auth
3281
- * attempt/failure counters + minute-bucketed history the studio SLO panel
3282
- * charts (PLAN3 §2.3). Auth runs as a top-level `/api/auth/*` worker route,
3283
- * NOT through lunora functions, so the worker records each attempt against
3284
- * the root shard via `recordAuthEvent` and this read surfaces the rollup.
3285
- *
3286
- * Best-effort: a SQL failure (e.g. a test double without a real `sql`
3287
- * handle) returns an empty all-zero {@link AuthMetrics} rather than throwing,
3288
- * so the SLO signal is simply absent instead of breaking the studio.
3289
- * Carries the {@link ADMIN_WILDCARD} like the other counter reads so a live
3290
- * subscription re-runs on every write-flush (the per-socket JSON memo still
3291
- * suppresses byte-identical pushes).
3292
- */
3293
- /**
3294
- * Resolve the durable app-signal reads that aren't bound to a user table —
3295
- * the auth-metrics rollup and the dev mail-catcher inbox. Returns the read's
3296
- * `{ result, tables }`, or `undefined` for any path it doesn't own (so
3297
- * `readAdminOp` falls through). Keeps `readAdminOp` under its complexity
3298
- * budget by holding these two in one branch.
3299
- * @returns the read result and its table-dependency set, or `undefined` when the path is not owned by this resolver
3300
- */
3301
- readAdminDurableSignal(functionPath, sql, args) {
3302
- if (functionPath === ADMIN_FUNCTIONS.getAuthMetrics) {
3303
- return this.readAdminAuthMetrics(sql);
3304
- }
3305
- if (functionPath === ADMIN_FUNCTIONS.getCapturedMail) {
3306
- return this.readAdminCapturedMail(sql, args);
3307
- }
3308
- return void 0;
3309
- }
3310
- // eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers
3311
- readAdminAuthMetrics(sql) {
3312
- let result;
3313
- try {
3314
- result = readAuthMetrics(sql);
3315
- } catch {
3316
- result = { attempts: 0, failureRate: 0, failures: 0, history: [], sinceMs: 0 };
3317
- }
3318
- return { result, tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
3319
- }
3320
- /**
3321
- * Resolve a `getCapturedMail` admin read — the dev mail catcher's inbox
3322
- * (`mail-catcher.ts`), newest-first. Best-effort: a SQL failure returns an
3323
- * empty inbox rather than throwing. Bound to the {@link MAIL_TABLE} so a live
3324
- * studio subscription re-runs when a new message is recorded (the per-socket
3325
- * JSON memo still suppresses byte-identical pushes).
3326
- */
3327
- // eslint-disable-next-line class-methods-use-this -- kept an instance method for symmetry with the other `readAdmin*` resolvers
3328
- readAdminCapturedMail(sql, args) {
3329
- const limit = typeof args["limit"] === "number" ? args["limit"] : void 0;
3330
- let result;
3331
- try {
3332
- result = readCapturedMail(sql, { limit });
3333
- } catch {
3334
- result = { entries: [] };
3335
- }
3336
- return { result, tables: /* @__PURE__ */ new Set([MAIL_TABLE]) };
3337
- }
3338
- /** Resolve a `readTablePage` admin read, parsing the loosely-typed args into the reader's options. */
3339
- readAdminTablePage(sql, args) {
3340
- const table = typeof args["table"] === "string" ? args["table"] : "";
3341
- const page = readTablePage(sql, {
3342
- filters: parseTablePageFilters(args["filters"]),
3343
- limit: typeof args["limit"] === "number" ? args["limit"] : void 0,
3344
- offset: typeof args["offset"] === "number" ? args["offset"] : void 0,
3345
- orderBy: parseTablePageOrderBy(args["orderBy"]),
3346
- refs: this.tableRefs(table),
3347
- search: typeof args["search"] === "string" ? args["search"] : void 0,
3348
- table
3349
- });
3350
- return { result: page, tables: /* @__PURE__ */ new Set([table === "" ? ADMIN_WILDCARD : table]) };
3351
- }
3352
- /**
3353
- * Resolve a `facetColumn` admin read — Datasette-style per-column value/count
3354
- * summary over the active view. Reuses {@link readTablePage}'s predicate args
3355
- * (`filters` + `search`) so the facet reflects exactly the previewed rows; the
3356
- * `column` is validated + bound inside {@link facetColumn} (never interpolated).
3357
- * Read-only `SELECT … GROUP BY`. Depends on its table like {@link readAdminTablePage}.
3358
- */
3359
- // eslint-disable-next-line class-methods-use-this -- instance method for symmetry with the other `readAdmin*` resolvers
3360
- readAdminFacetColumn(sql, args) {
3361
- const table = typeof args["table"] === "string" ? args["table"] : "";
3362
- const result = facetColumn(sql, {
3363
- column: typeof args["column"] === "string" ? args["column"] : "",
3364
- filters: parseTablePageFilters(args["filters"]),
3365
- limit: typeof args["limit"] === "number" ? args["limit"] : void 0,
3366
- search: typeof args["search"] === "string" ? args["search"] : void 0,
3367
- table
3368
- });
3369
- return { result, tables: /* @__PURE__ */ new Set([table === "" ? ADMIN_WILDCARD : table]) };
3370
- }
3371
- /**
3372
- * Resolve a `runSql` admin read: execute a read-only SQL query against the
3373
- * shard's SQLite via {@link runReadonlySql} (which rejects every mutating
3374
- * statement). Carries the {@link ADMIN_WILDCARD} since an arbitrary query can
3375
- * touch any table; it is a one-shot read, never a live subscription.
3376
- */
3377
- // eslint-disable-next-line class-methods-use-this -- instance method for symmetry with the other `readAdmin*` resolvers
3378
- readAdminRunSql(sql, args) {
3379
- const query = typeof args["sql"] === "string" ? args["sql"] : "";
3380
- return { result: runReadonlySql(sql, query), tables: /* @__PURE__ */ new Set([ADMIN_WILDCARD]) };
3381
- }
3382
- /**
3383
- * Seed/refresh hook for `__lunora_admin__:*` subscriptions, mirroring
3384
- * `executeSubscription` for user functions. Returns `null` for any
3385
- * path that isn't a read-only admin op so the caller can fall through.
3386
- * Synchronous — admin reads hit raw SQLite directly, no async dispatch.
3387
- */
3388
- executeAdminSubscription(functionPath, args) {
3389
- const read = this.readAdminOp(functionPath, args);
3390
- return read ? { result: read.result, tables: read.tables } : null;
3391
- }
3392
- /**
3393
- * Constant-time bearer check against `env.LUNORA_ADMIN_TOKEN`. Returns
3394
- * `false` (closed) when the token is unset so admin introspection is
3395
- * opt-in rather than exposed by default.
3396
- */
3397
- isAdminAuthorized(request) {
3398
- const env = this.env ?? {};
3399
- const token = env.LUNORA_ADMIN_TOKEN;
3400
- if (!token || token.length === 0) {
3401
- return false;
3402
- }
3403
- const supplied = extractBearerToken(request.headers.get("authorization"));
3404
- return supplied !== void 0 && constantTimeEqual(supplied, token);
3405
- }
3406
- /**
3407
- * Drive a streaming-query iterator end-to-end:
3408
- * 1. Allocate a per-id {@link AbortController} so a later `unsubscribe`
3409
- * (or socket close) tears the user iterator down.
3410
- * 2. Send a `{type:"ack"}` so the client knows the stream started before
3411
- * any chunks land.
3412
- * 3. Pump every yielded chunk through a `{type:"chunk"}` frame.
3413
- * 4. On normal completion send `{type:"complete"}`; on throw send
3414
- * `{type:"error"}`. Either way drop the controller.
3415
- */
3416
- async handleStream(ws, id, functionPath, args) {
3417
- const iterable = this.executeStream(functionPath, args);
3418
- if (!iterable) {
3419
- ws.send(JSON.stringify({ error: { code: "NOT_FOUND", message: `stream not registered: ${functionPath}` }, id, type: "error" }));
3420
- return;
3421
- }
3422
- let cancellers = this.streamCancellers.get(ws);
3423
- if (!cancellers) {
3424
- cancellers = /* @__PURE__ */ new Map();
3425
- this.streamCancellers.set(ws, cancellers);
3426
- }
3427
- if (cancellers.size >= ShardDO.MAX_STREAMS_PER_SOCKET) {
3428
- try {
3429
- ws.send(
3430
- JSON.stringify({
3431
- error: { code: "TOO_MANY_STREAMS", message: `stream cap of ${String(ShardDO.MAX_STREAMS_PER_SOCKET)} reached on this socket` },
3432
- id,
3433
- type: "error"
3434
- })
3435
- );
3436
- } catch {
3437
- }
3438
- return;
3439
- }
3440
- const controller = new AbortController();
3441
- cancellers.set(id, controller);
3442
- ws.send(JSON.stringify({ id, type: "ack" }));
3443
- try {
3444
- for await (const chunk of iterable.iterator(controller.signal)) {
3445
- if (controller.signal.aborted) {
3446
- break;
3447
- }
3448
- await awaitWsDrain(ws);
3449
- ws.send(JSON.stringify({ data: chunk, id, type: "chunk" }));
3450
- }
3451
- if (!controller.signal.aborted) {
3452
- ws.send(JSON.stringify({ id, type: "complete" }));
3453
- }
3454
- } catch (error) {
3455
- const { code } = error;
3456
- const message = error instanceof Error ? error.message : String(error);
3457
- ws.send(
3458
- JSON.stringify({
3459
- error: { code: typeof code === "string" ? code : "INTERNAL_SERVER_ERROR", message },
3460
- id,
3461
- type: "error"
3462
- })
3463
- );
3464
- } finally {
3465
- cancellers.delete(id);
3466
- if (cancellers.size === 0) {
3467
- this.streamCancellers.delete(ws);
3468
- }
3469
- }
3470
- }
3471
- /**
3472
- * Drain the tables written during the in-flight RPC and re-run every
3473
- * subscription that depends on one of them. Called after `handleRpc`
3474
- * resolves, and per-batch during a data migration via
3475
- * `flushMigrationProgress`. No-op when nothing was written.
3476
- *
3477
- * When the DO state exposes `waitUntil`, the refresh runs off the
3478
- * response path so the client doesn't block on subscription fan-out —
3479
- * a wide subscription set on a hot DO could otherwise add tens of ms
3480
- * to every write's tail latency. The user-facing write is already
3481
- * durable by the time we return; subscribers observe the change
3482
- * shortly after.
3483
- */
3484
- async flushChangedTables() {
3485
- const changed = this.pendingChangedTables;
3486
- this.pendingChangedTables = void 0;
3487
- if (!changed || changed.size === 0) {
3488
- return;
3489
- }
3490
- if (this.pendingRefreshTables) {
3491
- for (const table of changed) {
3492
- this.pendingRefreshTables.add(table);
3493
- }
3494
- } else {
3495
- this.pendingRefreshTables = changed;
3496
- }
3497
- if (this.refreshInFlight) {
3498
- return;
3499
- }
3500
- if (typeof this.state.waitUntil === "function") {
3501
- this.state.waitUntil(this.drainSubscriptionRefreshes());
3502
- return;
3503
- }
3504
- await this.drainSubscriptionRefreshes();
3505
- }
3506
- /**
3507
- * Drain {@link ShardDO.pendingRefreshTables} one coalesced batch at a time
3508
- * until it is empty, then release the {@link ShardDO.refreshInFlight} gate.
3509
- * Tables merged by a `flushChangedTables` that lands mid-pass are picked up
3510
- * by the next loop iteration, so every committed write is observed by a
3511
- * refresh that runs after it — bursts simply share a pass. The post-write
3512
- * high-watermark and live-socket set are re-read inside each
3513
- * `refreshSubscriptions` call, so a later batch always reflects the latest
3514
- * committed state.
3515
- */
3516
- async drainSubscriptionRefreshes() {
3517
- if (this.refreshInFlight) {
3518
- return;
3519
- }
3520
- this.refreshInFlight = true;
3521
- try {
3522
- let batch = this.pendingRefreshTables;
3523
- while (batch && batch.size > 0) {
3524
- this.pendingRefreshTables = void 0;
3525
- await this.refreshSubscriptions(batch);
3526
- batch = this.pendingRefreshTables;
3527
- }
3528
- } finally {
3529
- this.refreshInFlight = false;
3530
- }
3531
- }
3532
- /**
3533
- * For every live subscription whose query reads one of `changed`, re-run
3534
- * the query and push a fresh `{ type: "data" }` frame when the result
3535
- * differs from the last one sent. Subscriptions with no `functionPath`
3536
- * (legacy delta-only) are left to `broadcastDelta`.
3537
- *
3538
- * The per-socket loop runs in parallel across sockets, bounded so a
3539
- * shard with thousands of live subscribers doesn't spin up thousands
3540
- * of `executeSubscription` calls in lockstep and saturate the DO
3541
- * isolate. Within a single socket we stay sequential — the same
3542
- * subscription set is small (cap of {@link ShardDO.MAX_SUBSCRIPTIONS_PER_SOCKET}).
3543
- *
3544
- * ----------------------------------------------------------------------
3545
- * Audit finding #5 — N identical subscriptions ⇒ N query runs per change.
3546
- * ----------------------------------------------------------------------
3547
- * This loop executes `executeSubscription` once PER (socket, sub). When N
3548
- * sockets subscribe to the SAME `(functionPath, args)`, a single write that
3549
- * touches a read table re-runs the identical query N times. The N-runs
3550
- * fan-out is characterized by the `profile:` case in
3551
- * `subscription-refresh.integration.test.ts`.
3552
- *
3553
- * Cross-socket execution dedup (group identical `(functionPath, args)`, run
3554
- * + serialize once, fan the same frame to every sharing socket) was
3555
- * INVESTIGATED and DELIBERATELY NOT implemented here, because it would change
3556
- * observable behavior rather than being a pure optimization:
3557
- *
3558
- * (a) Per-socket memo divergence. The frame a socket receives depends on its
3559
- * OWN `subMemos` entry (`pushSubscriptionData`): one socket may need a
3560
- * `{type:"delta"}`, a freshly-subscribed socket a full `{type:"data"}`
3561
- * snapshot, and an up-to-date socket nothing at all. So only the QUERY RUN +
3562
- * its result can be shared — `pushSubscriptionData` must still run per socket.
3563
- * Dedup saves the N-1 redundant runs, not the fan-out.
3564
- *
3565
- * (b) Side-effect cardinality. The real `executeSubscription` lives in the
3566
- * codegen subclass and dispatches the user handler, which records function
3567
- * metrics, scan attribution, and `ctx.log` lines PER RUN. N subscribers today
3568
- * produce N metric samples / N log lines; collapsing to one run silently
3569
- * under-counts those in the studio. The base class can't see inside the
3570
- * override, so it can't make that trade safely.
3571
- *
3572
- * (c) Error attribution. A throwing run is contained per (socket, sub) here
3573
- * (see the catch below, and the integration test's isolation cases). A shared
3574
- * run would have to fan one failure to every sharing socket while preserving
3575
- * the "leave memo untouched ⇒ re-run next flush" contract.
3576
- *
3577
- * The framework's INTENDED answer to this fan-out already exists: the opt-in
3578
- * {@link ReactiveCache} (`ShardDOOptions.reactiveCache`). Refreshes run with
3579
- * an explicit anonymous {@link SubscriptionIdentity}, so the cache key
3580
- * `reactiveCacheKey(functionPath, args, null)` is identical across all
3581
- * sockets — N identical subscriptions collapse to ONE handler run plus N
3582
- * cache hits, with every per-run side effect honored exactly once by design.
3583
- * Recommended remediation is to document/enable ReactiveCache for
3584
- * high-fanout shards rather than bolt a second, semantically-divergent dedup
3585
- * into this loop.
3586
- */
3587
- async refreshSubscriptions(changed) {
3588
- const sockets = [...this.state.getWebSockets()];
3589
- const frameCursor = this.currentCdcCursor();
3590
- const frameEpoch = this.currentCdcEpoch();
3591
- const refreshOne = async (ws) => {
3592
- if (this.isSocketExpired(ws)) {
3593
- this.dropExpiredSocket(ws);
3594
- return;
3595
- }
3596
- const attachment = this.readAttachment(ws);
3597
- for (const [subId, query] of Object.entries(attachment.subs)) {
3598
- const { functionPath } = query;
3599
- if (!functionPath) {
3600
- continue;
3601
- }
3602
- const isAdmin = functionPath.startsWith(ADMIN_FUNCTION_PREFIX);
3603
- const memo = this.subMemos.get(ws)?.get(subId);
3604
- if (memo && !memo.tables.has(ADMIN_WILDCARD) && !setsIntersect(memo.tables, changed)) {
3605
- continue;
3606
- }
3607
- try {
3608
- const outcome = isAdmin ? this.executeAdminSubscription(functionPath, query.args ?? {}) : (
3609
- // Re-run under the socket's OWN verified identity (stamped on the
3610
- // attachment at upgrade, unforgeable by the client) — passed BY
3611
- // VALUE, so this deferred re-run never reads or mutates the shared
3612
- // per-request identity fields. Without it an `rls()` / `ctx.auth`
3613
- // scoped live query would evaluate anonymous and return zero rows.
3614
- // eslint-disable-next-line no-await-in-loop -- subscriptions on a socket re-run sequentially; each shares the single SQLite handle
3615
- await this.executeSubscription(functionPath, query.args ?? {}, { identity: attachment.identity, userId: attachment.userId })
3616
- );
3617
- if (!outcome) {
3618
- continue;
3619
- }
3620
- await awaitWsDrain(ws);
3621
- this.pushSubscriptionData(ws, subId, outcome, frameCursor, frameEpoch);
3622
- } catch {
3623
- continue;
3624
- }
3625
- }
3626
- };
3627
- const concurrency = 8;
3628
- let cursor = 0;
3629
- const worker = async () => {
3630
- let socket = sockets[cursor];
3631
- cursor += 1;
3632
- while (socket !== void 0) {
3633
- await refreshOne(socket);
3634
- socket = sockets[cursor];
3635
- cursor += 1;
3636
- }
3637
- };
3638
- await Promise.all(Array.from({ length: Math.min(concurrency, sockets.length) }, () => worker()));
3639
- }
3640
- /**
3641
- * Seed a freshly-registered subscription with its first value. Runs the
3642
- * query once, then takes one of two paths.
3643
- *
3644
- * The default path ships the full snapshot via {@link pushSubscriptionData}
3645
- * — a first-time subscribe, an admin sub, or a reconnect whose read-set
3646
- * changed (or fell outside the CDC retention window) since its cursor.
3647
- *
3648
- * The resume path sends a lightweight `resume` frame — a reconnecting client
3649
- * that supplied `sinceSeq` and is still current keeps its cached value and
3650
- * only advances its cursor, saving the full-snapshot round-trip.
3651
- *
3652
- * Either way the fresh result memoises this socket's diff baseline so later
3653
- * write-flushes ({@link refreshSubscriptions}) can emit incremental deltas.
3654
- */
3655
- async seedSubscription(ws, subId, query, functionPath, isAdmin) {
3656
- const seedArgs = query.args ?? {};
3657
- const attachment = this.readAttachment(ws);
3658
- const outcome = isAdmin ? this.executeAdminSubscription(functionPath, seedArgs) : await this.executeSubscription(functionPath, seedArgs, { identity: attachment.identity, userId: attachment.userId });
3659
- if (!outcome) {
3660
- return;
3661
- }
3662
- const { sinceEpoch, sinceSeq } = query;
3663
- const resume = isAdmin || sinceSeq === void 0 ? void 0 : this.evaluateResume(sinceSeq, outcome.tables, sinceEpoch);
3664
- const epoch = isAdmin ? void 0 : resume?.epoch ?? this.currentCdcEpoch();
3665
- if (resume?.resumable) {
3666
- this.seedSubscriptionMemo(ws, subId, outcome);
3667
- try {
3668
- ws.send(`{"type":"resume","id":${JSON.stringify(subId)}${cdcSuffix(resume.cursor ?? 0, epoch)}}`);
3669
- } catch {
3670
- }
3671
- return;
3672
- }
3673
- this.pushSubscriptionData(ws, subId, outcome, resume?.cursor ?? this.currentCdcCursor(), epoch);
3674
- }
3675
- /**
3676
- * Record `outcome` as this socket's diff baseline for `subId` without
3677
- * sending a frame. Used by the resume fast-path, where the client keeps its
3678
- * cached value but the server still needs a baseline so the next
3679
- * write-flush can diff against it.
3680
- */
3681
- seedSubscriptionMemo(ws, subId, outcome) {
3682
- let memos = this.subMemos.get(ws);
3683
- if (!memos) {
3684
- memos = /* @__PURE__ */ new Map();
3685
- this.subMemos.set(ws, memos);
3686
- }
3687
- memos.set(subId, { lastJson: JSON.stringify(outcome.result ?? null), tables: outcome.tables });
3688
- }
3689
- /**
3690
- * Memoise `outcome` for `(ws, subId)` and push it to the socket, unless an
3691
- * identical result was already sent. Always refreshes the memo's table set
3692
- * so dependency tracking stays current even when the value is unchanged.
3693
- *
3694
- * When the result is a diffable list (Convex-parity live-pagination, gap
3695
- * #20), emit one `{type:"delta"}` frame per changed row instead of a full
3696
- * `{type:"data"}` snapshot — see {@link subscriptionListDeltas} for the
3697
- * five conditions under which deltas are safe. The first send (and any
3698
- * non-list / large-change result) falls back to the snapshot. The memo is
3699
- * always advanced to the new `lastJson`/`tables` regardless of path.
3700
- *
3701
- * `cursor` (when supplied) is the `__cdc_log` high-watermark this frame
3702
- * covers; it is appended to the emitted `data`/`delta` JSON so a client can
3703
- * persist its resume position and replay it as `sinceSeq` on reconnect
3704
- * (Pillar 1b). Omitted on shards without CDC, keeping the wire byte-identical
3705
- * to the pre-cursor format.
3706
- */
3707
- pushSubscriptionData(ws, subId, outcome, cursor, epoch) {
3708
- let memos = this.subMemos.get(ws);
3709
- if (!memos) {
3710
- memos = /* @__PURE__ */ new Map();
3711
- this.subMemos.set(ws, memos);
3712
- }
3713
- const cursorSuffix = cdcSuffix(cursor, epoch);
3714
- const json = JSON.stringify(outcome.result ?? null);
3715
- const existing = memos.get(subId);
3716
- if (existing?.lastJson === json) {
3717
- existing.tables = outcome.tables;
3718
- return;
3719
- }
3720
- const deltaFrames = [];
3721
- const deltas = existing === void 0 ? void 0 : subscriptionListDeltas(existing.lastJson, outcome.result, outcome.tables.values().next().value ?? "", deltaFrames);
3722
- const delivered = deltas === void 0 ? trySendFrame(ws, `{"type":"data","id":${JSON.stringify(subId)},"data":${json}${cursorSuffix}}`) : sendDeltaFrames(ws, subId, deltaFrames, cursorSuffix);
3723
- memos.set(subId, { lastJson: delivered ? json : existing?.lastJson ?? UNDELIVERED_BASELINE, tables: outcome.tables });
3724
- }
3725
- /**
3726
- * Gate the upgrade request against two complementary controls:
3727
- *
3728
- * 1. Origin allowlist via `env.LUNORA_ALLOWED_ORIGINS` (comma-separated).
3729
- * When unset, any origin is accepted — convenient for local dev,
3730
- * not suitable for production.
3731
- * 2. Bearer token via `env.LUNORA_WS_BEARER`. When set, the upgrade
3732
- * must present a matching token. We accept either an
3733
- * `Authorization: Bearer &lt;token>` header (preferred) or a
3734
- * `?token=&lt;token>` query parameter (the only escape hatch for
3735
- * browsers, which can't customise headers on the WebSocket
3736
- * constructor). The match runs in constant time to avoid leaking
3737
- * the token via response-timing differences.
3738
- *
3739
- * The `?token=` path is a real risk surface: the token ends up in
3740
- * server logs, browser history, and `Referer` headers on any
3741
- * subresource the upgrade page loads after the handshake. Use a
3742
- * short-lived rotating token in production rather than a long-lived
3743
- * secret.
3744
- */
3745
- isUpgradeAllowed(request) {
3746
- const env = this.env ?? {};
3747
- const allowedOrigins = env.LUNORA_ALLOWED_ORIGINS;
3748
- if (allowedOrigins && allowedOrigins.trim() !== "") {
3749
- const origin = request.headers.get("origin");
3750
- if (!origin) {
3751
- return false;
3752
- }
3753
- const list = allowedOrigins.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
3754
- if (!list.includes(origin)) {
3755
- return false;
3756
- }
3757
- }
3758
- const expectedBearer = env.LUNORA_WS_BEARER;
3759
- if (expectedBearer && expectedBearer.length > 0) {
3760
- const supplied = this.suppliedWsToken(request);
3761
- if (!supplied || !constantTimeEqual(supplied, expectedBearer) && !this.isAdminSocket(request)) {
3762
- return false;
3763
- }
3764
- }
3765
- return true;
3766
- }
3767
- /**
3768
- * Token presented on a WS upgrade: the `Authorization: Bearer` header when
3769
- * present, else the `?token=` query parameter (the only channel a browser
3770
- * `WebSocket` constructor can use). Returns `undefined` when neither is set.
3771
- * @returns the bearer token string, or `undefined` when no token was supplied
3772
- */
3773
- // eslint-disable-next-line class-methods-use-this -- cohesive DO instance method grouped with the upgrade-auth helpers; reads only the request
3774
- suppliedWsToken(request) {
3775
- const fromHeader = extractBearerToken(request.headers.get("authorization"));
3776
- if (fromHeader !== void 0) {
3777
- return fromHeader;
3778
- }
3779
- return new URL(request.url).searchParams.get("token") ?? void 0;
3780
- }
3781
- /**
3782
- * Whether the upgrade presented a token matching `LUNORA_ADMIN_TOKEN`,
3783
- * constant-time compared. Closed (returns `false`) when the admin token is
3784
- * unset, mirroring `isAdminAuthorized` for the HTTP path so admin
3785
- * streaming is opt-in rather than exposed by default.
3786
- */
3787
- isAdminSocket(request) {
3788
- const env = this.env ?? {};
3789
- const adminToken = env.LUNORA_ADMIN_TOKEN;
3790
- if (!adminToken || adminToken.length === 0) {
3791
- return false;
3792
- }
3793
- const supplied = this.suppliedWsToken(request);
3794
- return supplied !== void 0 && constantTimeEqual(supplied, adminToken);
3795
- }
3796
- /**
3797
- * Register the hibernation-safe ping/pong keepalive. The runtime answers a
3798
- * {@link WS_KEEPALIVE_PING} text frame with {@link WS_KEEPALIVE_PONG}
3799
- * WITHOUT waking this Durable Object, keeping idle subscription sockets
3800
- * alive across hibernation with no billable wakeup and no dispatch. The
3801
- * auto-response is per-instance, so this re-runs on every construction
3802
- * (including a post-hibernation wake). Guarded: the API and the
3803
- * `WebSocketRequestResponsePair` global are absent in the unit harness and
3804
- * on older runtimes, where it degrades to a no-op.
3805
- */
3806
- armWebSocketKeepalive() {
3807
- const setter = this.state.setWebSocketAutoResponse;
3808
- if (typeof setter !== "function" || typeof WebSocketRequestResponsePair === "undefined") {
3809
- return;
3810
- }
3811
- setter.call(this.state, new WebSocketRequestResponsePair(WS_KEEPALIVE_PING, WS_KEEPALIVE_PONG));
3812
- }
3813
- handleWebSocketUpgrade(request) {
3814
- if (!this.isUpgradeAllowed(request)) {
3815
- return new Response("Forbidden", { status: 403 });
3816
- }
3817
- const pair = new WebSocketPair();
3818
- const client = pair[0];
3819
- const server = pair[1];
3820
- this.state.acceptWebSocket(server);
3821
- const userId = request.headers.get("x-lunora-userid") ?? void 0;
3822
- const identity = parseIdentityHeader(request.headers.get("x-lunora-identity"));
3823
- const expiresAtRaw = Number(request.headers.get("x-lunora-identity-exp"));
3824
- const expiresAt = Number.isFinite(expiresAtRaw) && expiresAtRaw > 0 ? expiresAtRaw : void 0;
3825
- server.serializeAttachment?.({
3826
- admin: this.isAdminSocket(request),
3827
- connectionId: crypto.randomUUID(),
3828
- subs: {},
3829
- ...expiresAt === void 0 ? {} : { expiresAt },
3830
- ...identity === void 0 ? {} : { identity },
3831
- ...userId === void 0 ? {} : { userId }
3832
- });
3833
- return new Response(null, { status: 101, webSocket: client });
3834
- }
3835
- /**
3836
- * Whether this shard has a `__cdc_log` table. The single source of the
3837
- * "is CDC on here?" probe shared by {@link currentCdcCursor},
3838
- * {@link currentCdcEpoch}, {@link evaluateResume}, and the PITR-restore epoch
3839
- * bump. Returns `false` (rather than throwing) on a stub `sql` handle (unit
3840
- * harness double) or a pre-CDC shard, so callers degrade to the no-CDC path.
3841
- */
3842
- cdcEnabled() {
3843
- try {
3844
- return this.sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, CDC_LOG_TABLE).toArray().length > 0;
3845
- } catch {
3846
- return false;
3847
- }
3848
- }
3849
- /** Whether `ws` carries a credential whose expiry (stamped at upgrade) is now past. */
3850
- isSocketExpired(ws) {
3851
- const { expiresAt } = this.readAttachment(ws);
3852
- return typeof expiresAt === "number" && Date.now() >= expiresAt;
3853
- }
3854
- /**
3855
- * Send the `TOKEN_EXPIRED` error frame and close the socket with code 4001 so
3856
- * the client distinguishes an expired-credential drop from an ordinary one
3857
- * and refreshes before reconnecting. Best-effort: a throw (socket already
3858
- * gone) is swallowed — this must never escape the hibernation handlers.
3859
- */
3860
- // eslint-disable-next-line class-methods-use-this -- cohesive socket helper grouped with isSocketExpired; operates only on the passed socket
3861
- dropExpiredSocket(ws) {
3862
- try {
3863
- ws.send(JSON.stringify({ code: "TOKEN_EXPIRED", error: { code: "TOKEN_EXPIRED", message: "authentication token expired" }, type: "error" }));
3864
- ws.close(4001, "token_expired");
3865
- } catch {
3866
- }
3867
- }
3868
- /**
3869
- * Join (`join = true`) or leave a whisper `topic` on this socket. Membership rides
3870
- * the hibernation attachment, bounded by
3871
- * {@link ShardDO.MAX_WHISPER_TOPICS_PER_SOCKET}. Best-effort and silent:
3872
- * whispering is never acked, and an over-cap join or a serialize failure is
3873
- * simply dropped (the join just doesn't take).
3874
- */
3875
- setWhisperMembership(ws, topic, join) {
3876
- const attachment = this.readAttachment(ws);
3877
- const topics = attachment.whispers ?? [];
3878
- const has = topics.includes(topic);
3879
- if (join) {
3880
- if (has || topics.length >= ShardDO.MAX_WHISPER_TOPICS_PER_SOCKET) {
3881
- return;
3882
- }
3883
- attachment.whispers = [...topics, topic];
3884
- } else {
3885
- if (!has) {
3886
- return;
3887
- }
3888
- const next = topics.filter((entry) => entry !== topic);
3889
- if (next.length === 0) {
3890
- delete attachment.whispers;
3891
- } else {
3892
- attachment.whispers = next;
3893
- }
3894
- }
3895
- try {
3896
- ws.serializeAttachment?.(attachment);
3897
- } catch {
3898
- }
3899
- }
3900
- /**
3901
- * Token-bucket admission for a sender's whisper. Refills lazily from elapsed
3902
- * wall-clock at {@link ShardDO.WHISPER_RATE_PER_SEC}/s up to a burst of
3903
- * {@link ShardDO.WHISPER_RATE_BURST}; returns `false` (drop the whisper) when
3904
- * the bucket is empty. Per-socket, in-memory — a hibernation resets it to a
3905
- * full burst, which is the safe direction (never under-counts into a denial).
3906
- */
3907
- allowWhisper(ws) {
3908
- const now = Date.now();
3909
- const bucket = this.whisperBuckets.get(ws) ?? { last: now, tokens: ShardDO.WHISPER_RATE_BURST };
3910
- const refilled = Math.min(ShardDO.WHISPER_RATE_BURST, bucket.tokens + (now - bucket.last) / 1e3 * ShardDO.WHISPER_RATE_PER_SEC);
3911
- if (refilled < 1) {
3912
- this.whisperBuckets.set(ws, { last: now, tokens: refilled });
3913
- return false;
3914
- }
3915
- this.whisperBuckets.set(ws, { last: now, tokens: refilled - 1 });
3916
- return true;
3917
- }
3918
- /**
3919
- * Fan an ephemeral whisper out to every OTHER socket on this shard that
3920
- * joined `topic`. No SQLite write, no CDC entry, no query re-run — the
3921
- * payload is relayed verbatim, so it never touches durable state (the
3922
- * AnyCable "whisper" primitive: typing indicators, live cursors). The sender
3923
- * is excluded; an over-limit or over-rate whisper is dropped.
3924
- *
3925
- * Authorization note: whisper topics are NOT access-controlled beyond the
3926
- * shard boundary — any socket on this shard can join and read/inject on any
3927
- * topic name. That matches the AnyCable model (and `from` is unforgeable),
3928
- * but per-topic auth does not exist here; see `whisperSubscribe` on the client.
3929
- */
3930
- broadcastWhisper(sender, topic, data) {
3931
- if (!this.allowWhisper(sender)) {
3932
- return;
3933
- }
3934
- const dataJson = JSON.stringify(data ?? null);
3935
- if (dataJson.length > ShardDO.MAX_WHISPER_BYTES) {
3936
- return;
3937
- }
3938
- const from = this.readAttachment(sender).userId;
3939
- const fromSuffix = from === void 0 ? "" : `,"from":${JSON.stringify(from)}`;
3940
- const frame = `{"type":"whisper","topic":${JSON.stringify(topic)},"data":${dataJson}${fromSuffix}}`;
3941
- for (const ws of this.state.getWebSockets()) {
3942
- if (ws === sender || this.readAttachment(ws).whispers?.includes(topic) !== true) {
3943
- continue;
3944
- }
3945
- trySendFrame(ws, frame);
3946
- }
3947
- }
3948
- // eslint-disable-next-line class-methods-use-this -- cohesive DO instance method grouped with the hibernation/attachment helpers; reads only the socket
3949
- readAttachment(ws) {
3950
- const raw = ws.deserializeAttachment?.();
3951
- if (raw && typeof raw === "object" && "subs" in raw && raw.subs) {
3952
- return raw;
3953
- }
3954
- return { subs: {} };
3955
- }
3956
- }
3957
-
3958
- export { ROOT_DO_SIZE_WARN_BYTES, ROOT_SHARD_NAME, ShardDO, subscriptionListDeltas };