@lunora/do 0.0.0 → 1.0.0-alpha.1

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