@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.
- package/LICENSE.md +105 -0
- package/README.md +115 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/index.d.mts +5599 -0
- package/dist/index.d.ts +5599 -0
- package/dist/index.mjs +35 -0
- package/dist/packem_shared/ADMIN_FUNCTION_PREFIX-Dzdqq5J2.mjs +313 -0
- package/dist/packem_shared/AUTH_METRICS_BUCKET_MS-CiHHYeJi.mjs +84 -0
- package/dist/packem_shared/ConflictError-C0STs6bU.mjs +13 -0
- package/dist/packem_shared/CountRlsUnsupportedError-28ZvvwKS.mjs +133 -0
- package/dist/packem_shared/DATA_MIGRATION_STATE_TABLE-PTtTiQ7U.mjs +237 -0
- package/dist/packem_shared/LogBuffer-B_Ezju_N.mjs +37 -0
- package/dist/packem_shared/NotFoundError-CMuMZt81.mjs +10 -0
- package/dist/packem_shared/ROOT_DO_SIZE_WARN_BYTES-DQkmGiCS.mjs +4009 -0
- package/dist/packem_shared/ReactiveCache-ByVzgH3d.mjs +259 -0
- package/dist/packem_shared/SCAN_DEP-DLJF8dsj.mjs +19 -0
- package/dist/packem_shared/SESSION_DO_TTL_DEFAULT-ilPZsVwu.mjs +180 -0
- package/dist/packem_shared/SHARD_REGISTRY_DO_NAME-BsAbi5Mn.mjs +146 -0
- package/dist/packem_shared/aggregateTableName-CxNqY1Sl.mjs +64 -0
- package/dist/packem_shared/applyCdcChanges-Ctdmxmrv.mjs +103 -0
- package/dist/packem_shared/applyOnDelete-CMif2RKw.mjs +165 -0
- package/dist/packem_shared/armRestore-BJk53Ro8.mjs +55 -0
- package/dist/packem_shared/assertFlatPredicate-DyVYReuT.mjs +160 -0
- package/dist/packem_shared/assertReadonly-dDcFE1YZ.mjs +29 -0
- package/dist/packem_shared/assertValidClientId-CBZ1zC96.mjs +1745 -0
- package/dist/packem_shared/backfillAggregateIndexes-BF5eL7kW.mjs +80 -0
- package/dist/packem_shared/buildSecurityAudit-CCAvoFlr.mjs +1 -0
- package/dist/packem_shared/buildSeekWhere-lVsNXSLy.mjs +84 -0
- package/dist/packem_shared/clearCapturedMail-CPpgl-dX.mjs +104 -0
- package/dist/packem_shared/compileWhereSql-CXrhFA3G.mjs +127 -0
- package/dist/packem_shared/createSystemReader-8CzSZP9V.mjs +80 -0
- package/dist/packem_shared/ctx-db-idempotency-DkC9rP91.mjs +35 -0
- package/dist/packem_shared/do-exec-5eQy5cEi.mjs +12 -0
- package/dist/packem_shared/do-sql-BCHCWtrD.mjs +87 -0
- package/dist/packem_shared/encodePartitionKey-C6blLR5K.mjs +1 -0
- package/dist/packem_shared/ensureFunctionMetricsTables-UDNVD7FS.mjs +248 -0
- package/dist/packem_shared/exportShardRows-DZEhUeyI.mjs +156 -0
- package/dist/packem_shared/ftsTableName-BLEMawrp.mjs +38 -0
- package/dist/packem_shared/guardWriter-u3UlnCH5.mjs +128 -0
- package/dist/packem_shared/matchesStaticWhere-CFk6adSu.mjs +54 -0
- package/dist/packem_shared/rank-CrkEIpF4.mjs +102 -0
- package/dist/packem_shared/renderSql-D6eUcn2N.mjs +16 -0
- package/dist/packem_shared/runShardMigrations-C3bn5r93.mjs +103 -0
- package/dist/packem_shared/runTriggers-5N6_Fx0A.mjs +20 -0
- package/dist/packem_shared/security-audit-CucgBice.mjs +158 -0
- package/dist/packem_shared/serveRelationFanout-Clr1a05L.mjs +24 -0
- 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 `<file>:<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<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<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<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:<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
|
+
* (`<file>:<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 <token>` header (preferred) or a
|
|
3782
|
+
* `?token=<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 };
|