@apisurf/wire-db 0.1.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 +15 -0
- package/README.md +133 -0
- package/dist/index.d.ts +547 -0
- package/dist/index.js +1237 -0
- package/package.json +61 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1237 @@
|
|
|
1
|
+
// src/open.ts
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
|
|
6
|
+
// src/schema.ts
|
|
7
|
+
var MIGRATIONS = [
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// 001 — initial schema
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
`
|
|
12
|
+
-- One row per \`wire execute\`.
|
|
13
|
+
CREATE TABLE runs (
|
|
14
|
+
id INTEGER PRIMARY KEY,
|
|
15
|
+
uid TEXT NOT NULL UNIQUE, -- stable external id
|
|
16
|
+
label TEXT, -- --label, else the entry basename
|
|
17
|
+
entry TEXT NOT NULL, -- absolute path of the executed file
|
|
18
|
+
cwd TEXT NOT NULL,
|
|
19
|
+
node_version TEXT NOT NULL,
|
|
20
|
+
started_at INTEGER NOT NULL, -- epoch ms
|
|
21
|
+
finished_at INTEGER, -- NULL while in flight / on hard crash
|
|
22
|
+
exit_status TEXT, -- 'ok' | 'error'
|
|
23
|
+
error_name TEXT,
|
|
24
|
+
error_message TEXT
|
|
25
|
+
);
|
|
26
|
+
CREATE INDEX idx_runs_started ON runs (started_at DESC);
|
|
27
|
+
|
|
28
|
+
-- Interned dimensions. Every one is a small, hot, fully-cached table.
|
|
29
|
+
CREATE TABLE methods (id INTEGER PRIMARY KEY, method TEXT NOT NULL UNIQUE);
|
|
30
|
+
CREATE TABLE hosts (id INTEGER PRIMARY KEY, host TEXT NOT NULL UNIQUE);
|
|
31
|
+
CREATE TABLE content_types (id INTEGER PRIMARY KEY, content_type TEXT NOT NULL UNIQUE);
|
|
32
|
+
CREATE TABLE header_names (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
|
|
33
|
+
|
|
34
|
+
-- Content-addressed payloads. \`sha256\` is over the bytes AS STORED (i.e. after
|
|
35
|
+
-- redaction and truncation), which is exactly the dedupe key we want: two
|
|
36
|
+
-- captures that would render identically share one row.
|
|
37
|
+
CREATE TABLE bodies (
|
|
38
|
+
id INTEGER PRIMARY KEY,
|
|
39
|
+
sha256 BLOB NOT NULL UNIQUE, -- 32 raw bytes, not hex
|
|
40
|
+
size INTEGER NOT NULL, -- ORIGINAL length before truncation
|
|
41
|
+
stored_size INTEGER NOT NULL, -- length(content)
|
|
42
|
+
truncated INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
is_text INTEGER NOT NULL DEFAULT 1, -- 0 => binary, content is raw bytes
|
|
44
|
+
content BLOB NOT NULL
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
-- Full-text index over text payloads. Populated once per *unique* body.
|
|
48
|
+
-- Query it directly: SELECT body_id FROM body_fts WHERE body_fts MATCH 'foo'
|
|
49
|
+
CREATE VIRTUAL TABLE body_fts USING fts5(
|
|
50
|
+
content,
|
|
51
|
+
body_id UNINDEXED,
|
|
52
|
+
tokenize = 'unicode61'
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE TABLE requests (
|
|
56
|
+
id INTEGER PRIMARY KEY,
|
|
57
|
+
run_id INTEGER NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
|
58
|
+
seq INTEGER NOT NULL, -- capture order within the run
|
|
59
|
+
ts INTEGER NOT NULL, -- epoch ms at request start
|
|
60
|
+
method_id INTEGER NOT NULL REFERENCES methods (id),
|
|
61
|
+
host_id INTEGER NOT NULL REFERENCES hosts (id),
|
|
62
|
+
path TEXT NOT NULL,
|
|
63
|
+
query TEXT, -- search string without the '?'
|
|
64
|
+
url TEXT NOT NULL,
|
|
65
|
+
status INTEGER, -- NULL when the request never got a response
|
|
66
|
+
ok INTEGER,
|
|
67
|
+
error_name TEXT,
|
|
68
|
+
error_message TEXT,
|
|
69
|
+
duration_ms REAL NOT NULL,
|
|
70
|
+
dns_ms REAL,
|
|
71
|
+
connect_ms REAL,
|
|
72
|
+
tls_ms REAL,
|
|
73
|
+
ttfb_ms REAL,
|
|
74
|
+
download_ms REAL,
|
|
75
|
+
request_bytes INTEGER NOT NULL DEFAULT 0,
|
|
76
|
+
response_bytes INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
request_content_type_id INTEGER REFERENCES content_types (id),
|
|
78
|
+
response_content_type_id INTEGER REFERENCES content_types (id),
|
|
79
|
+
request_body_id INTEGER REFERENCES bodies (id),
|
|
80
|
+
response_body_id INTEGER REFERENCES bodies (id)
|
|
81
|
+
);
|
|
82
|
+
CREATE UNIQUE INDEX idx_requests_run_seq ON requests (run_id, seq);
|
|
83
|
+
CREATE INDEX idx_requests_ts ON requests (ts DESC);
|
|
84
|
+
CREATE INDEX idx_requests_host ON requests (host_id, ts DESC);
|
|
85
|
+
CREATE INDEX idx_requests_status ON requests (status);
|
|
86
|
+
CREATE INDEX idx_requests_slow ON requests (duration_ms DESC);
|
|
87
|
+
|
|
88
|
+
-- kind: 0 = request, 1 = response. \`ord\` preserves wire order and keeps
|
|
89
|
+
-- repeated headers (Set-Cookie) as distinct rows.
|
|
90
|
+
CREATE TABLE headers (
|
|
91
|
+
request_id INTEGER NOT NULL REFERENCES requests (id) ON DELETE CASCADE,
|
|
92
|
+
kind INTEGER NOT NULL,
|
|
93
|
+
ord INTEGER NOT NULL,
|
|
94
|
+
name_id INTEGER NOT NULL REFERENCES header_names (id),
|
|
95
|
+
value TEXT NOT NULL,
|
|
96
|
+
PRIMARY KEY (request_id, kind, ord)
|
|
97
|
+
) WITHOUT ROWID;
|
|
98
|
+
-- Reverse lookup: find requests by header name, or by name+value prefix.
|
|
99
|
+
CREATE INDEX idx_headers_name ON headers (name_id, value);
|
|
100
|
+
|
|
101
|
+
CREATE TABLE tags (
|
|
102
|
+
request_id INTEGER NOT NULL REFERENCES requests (id) ON DELETE CASCADE,
|
|
103
|
+
key TEXT NOT NULL,
|
|
104
|
+
value TEXT NOT NULL,
|
|
105
|
+
PRIMARY KEY (request_id, key)
|
|
106
|
+
) WITHOUT ROWID;
|
|
107
|
+
CREATE INDEX idx_tags_kv ON tags (key, value);
|
|
108
|
+
|
|
109
|
+
-- ---------------------------------------------------------------------------
|
|
110
|
+
-- Views: the denormalized surface. Normalization is for the query planner;
|
|
111
|
+
-- these are for whoever is typing \`wire sql\`.
|
|
112
|
+
-- ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
CREATE VIEW v_requests AS
|
|
115
|
+
SELECT
|
|
116
|
+
r.id,
|
|
117
|
+
r.run_id,
|
|
118
|
+
r.seq,
|
|
119
|
+
r.ts,
|
|
120
|
+
m.method AS method,
|
|
121
|
+
h.host AS host,
|
|
122
|
+
r.path,
|
|
123
|
+
r.query,
|
|
124
|
+
r.url,
|
|
125
|
+
r.status,
|
|
126
|
+
r.ok,
|
|
127
|
+
r.error_name,
|
|
128
|
+
r.error_message,
|
|
129
|
+
r.duration_ms,
|
|
130
|
+
r.dns_ms,
|
|
131
|
+
r.connect_ms,
|
|
132
|
+
r.tls_ms,
|
|
133
|
+
r.ttfb_ms,
|
|
134
|
+
r.download_ms,
|
|
135
|
+
r.request_bytes,
|
|
136
|
+
r.response_bytes,
|
|
137
|
+
rct.content_type AS request_content_type,
|
|
138
|
+
sct.content_type AS response_content_type,
|
|
139
|
+
r.request_body_id,
|
|
140
|
+
r.response_body_id
|
|
141
|
+
FROM requests r
|
|
142
|
+
JOIN methods m ON m.id = r.method_id
|
|
143
|
+
JOIN hosts h ON h.id = r.host_id
|
|
144
|
+
LEFT JOIN content_types rct ON rct.id = r.request_content_type_id
|
|
145
|
+
LEFT JOIN content_types sct ON sct.id = r.response_content_type_id;
|
|
146
|
+
|
|
147
|
+
CREATE VIEW v_headers AS
|
|
148
|
+
SELECT
|
|
149
|
+
hd.request_id,
|
|
150
|
+
CASE hd.kind WHEN 0 THEN 'request' ELSE 'response' END AS kind,
|
|
151
|
+
hd.ord,
|
|
152
|
+
hn.name AS name,
|
|
153
|
+
hd.value AS value
|
|
154
|
+
FROM headers hd
|
|
155
|
+
JOIN header_names hn ON hn.id = hd.name_id;
|
|
156
|
+
|
|
157
|
+
-- Text bodies decoded to TEXT so they are greppable straight out of a query;
|
|
158
|
+
-- binary bodies surface as NULL text with their size intact.
|
|
159
|
+
CREATE VIEW v_bodies AS
|
|
160
|
+
SELECT
|
|
161
|
+
b.id,
|
|
162
|
+
b.size,
|
|
163
|
+
b.stored_size,
|
|
164
|
+
b.truncated,
|
|
165
|
+
b.is_text,
|
|
166
|
+
CASE WHEN b.is_text = 1 THEN CAST(b.content AS TEXT) END AS text
|
|
167
|
+
FROM bodies b;
|
|
168
|
+
|
|
169
|
+
-- Run summary with counters computed on read, so they are always correct even
|
|
170
|
+
-- if a run is killed mid-flight and never gets to finalize.
|
|
171
|
+
CREATE VIEW v_runs AS
|
|
172
|
+
SELECT
|
|
173
|
+
r.id,
|
|
174
|
+
r.uid,
|
|
175
|
+
r.label,
|
|
176
|
+
r.entry,
|
|
177
|
+
r.cwd,
|
|
178
|
+
r.node_version,
|
|
179
|
+
r.started_at,
|
|
180
|
+
r.finished_at,
|
|
181
|
+
r.exit_status,
|
|
182
|
+
r.error_name,
|
|
183
|
+
r.error_message,
|
|
184
|
+
(SELECT COUNT(*) FROM requests q WHERE q.run_id = r.id) AS request_count,
|
|
185
|
+
(SELECT COUNT(*) FROM requests q WHERE q.run_id = r.id
|
|
186
|
+
AND (q.error_name IS NOT NULL OR q.status >= 400)) AS error_count,
|
|
187
|
+
(SELECT SUM(q.duration_ms) FROM requests q WHERE q.run_id = r.id) AS total_duration_ms,
|
|
188
|
+
(SELECT SUM(q.request_bytes + q.response_bytes) FROM requests q
|
|
189
|
+
WHERE q.run_id = r.id) AS total_bytes
|
|
190
|
+
FROM runs r;
|
|
191
|
+
`,
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// 002 — WebSocket and SSE
|
|
194
|
+
//
|
|
195
|
+
// A connection is a request. It has a url, a host, a handshake, a status and
|
|
196
|
+
// a duration, and modelling it as anything else would have meant a second of
|
|
197
|
+
// everything: headers, tags, byte counts, run totals, views, UI. So `requests`
|
|
198
|
+
// gains a `kind`, and the frames and events hang off it in one new table.
|
|
199
|
+
//
|
|
200
|
+
// For a connection, the request columns read slightly differently:
|
|
201
|
+
// status 101 for a WebSocket upgrade, the response status for SSE
|
|
202
|
+
// duration_ms how long it stayed open, not how slow it was
|
|
203
|
+
// connect_ms the handshake
|
|
204
|
+
// ttfb_ms time to the first message in
|
|
205
|
+
// request_bytes total sent / response_bytes total received
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
`
|
|
208
|
+
ALTER TABLE requests ADD COLUMN kind TEXT NOT NULL DEFAULT 'http';
|
|
209
|
+
CREATE INDEX idx_requests_kind ON requests (kind);
|
|
210
|
+
|
|
211
|
+
-- Interned like every other repeated low-cardinality string. For WebSockets
|
|
212
|
+
-- that is 'text' | 'binary' | 'close'; for SSE it is the event name, which is
|
|
213
|
+
-- the same handful of values over and over.
|
|
214
|
+
CREATE TABLE message_names (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
|
|
215
|
+
|
|
216
|
+
-- One row per WebSocket frame or SSE event. Payloads go through the same
|
|
217
|
+
-- content-addressed \`bodies\` table as HTTP ones, so a socket that echoes the
|
|
218
|
+
-- same heartbeat 10,000 times stores it once and is searchable in body_fts.
|
|
219
|
+
CREATE TABLE messages (
|
|
220
|
+
id INTEGER PRIMARY KEY,
|
|
221
|
+
request_id INTEGER NOT NULL REFERENCES requests (id) ON DELETE CASCADE,
|
|
222
|
+
seq INTEGER NOT NULL, -- order within the connection
|
|
223
|
+
ts INTEGER NOT NULL, -- epoch ms
|
|
224
|
+
dir INTEGER NOT NULL, -- 0 = sent, 1 = received
|
|
225
|
+
name_id INTEGER NOT NULL REFERENCES message_names (id),
|
|
226
|
+
code INTEGER, -- WebSocket close code
|
|
227
|
+
event_id TEXT, -- SSE event id
|
|
228
|
+
bytes INTEGER NOT NULL DEFAULT 0, -- payload size before truncation
|
|
229
|
+
body_id INTEGER REFERENCES bodies (id)
|
|
230
|
+
);
|
|
231
|
+
CREATE UNIQUE INDEX idx_messages_request_seq ON messages (request_id, seq);
|
|
232
|
+
CREATE INDEX idx_messages_ts ON messages (ts);
|
|
233
|
+
|
|
234
|
+
-- v_requests gains \`kind\` and a message count. Views cannot be altered, so it
|
|
235
|
+
-- is dropped and rebuilt.
|
|
236
|
+
DROP VIEW v_requests;
|
|
237
|
+
CREATE VIEW v_requests AS
|
|
238
|
+
SELECT
|
|
239
|
+
r.id,
|
|
240
|
+
r.run_id,
|
|
241
|
+
r.seq,
|
|
242
|
+
r.ts,
|
|
243
|
+
r.kind,
|
|
244
|
+
m.method AS method,
|
|
245
|
+
h.host AS host,
|
|
246
|
+
r.path,
|
|
247
|
+
r.query,
|
|
248
|
+
r.url,
|
|
249
|
+
r.status,
|
|
250
|
+
r.ok,
|
|
251
|
+
r.error_name,
|
|
252
|
+
r.error_message,
|
|
253
|
+
r.duration_ms,
|
|
254
|
+
r.dns_ms,
|
|
255
|
+
r.connect_ms,
|
|
256
|
+
r.tls_ms,
|
|
257
|
+
r.ttfb_ms,
|
|
258
|
+
r.download_ms,
|
|
259
|
+
r.request_bytes,
|
|
260
|
+
r.response_bytes,
|
|
261
|
+
rct.content_type AS request_content_type,
|
|
262
|
+
sct.content_type AS response_content_type,
|
|
263
|
+
r.request_body_id,
|
|
264
|
+
r.response_body_id,
|
|
265
|
+
(SELECT COUNT(*) FROM messages g WHERE g.request_id = r.id) AS message_count
|
|
266
|
+
FROM requests r
|
|
267
|
+
JOIN methods m ON m.id = r.method_id
|
|
268
|
+
JOIN hosts h ON h.id = r.host_id
|
|
269
|
+
LEFT JOIN content_types rct ON rct.id = r.request_content_type_id
|
|
270
|
+
LEFT JOIN content_types sct ON sct.id = r.response_content_type_id;
|
|
271
|
+
|
|
272
|
+
-- Messages with their payload decoded, the way v_bodies does it: text is
|
|
273
|
+
-- greppable straight out of a query, binary surfaces as NULL with its size.
|
|
274
|
+
CREATE VIEW v_messages AS
|
|
275
|
+
SELECT
|
|
276
|
+
g.id,
|
|
277
|
+
g.request_id,
|
|
278
|
+
r.run_id,
|
|
279
|
+
r.kind,
|
|
280
|
+
hh.host || r.path AS endpoint,
|
|
281
|
+
g.seq,
|
|
282
|
+
g.ts,
|
|
283
|
+
CASE g.dir WHEN 0 THEN 'sent' ELSE 'received' END AS direction,
|
|
284
|
+
n.name,
|
|
285
|
+
g.code,
|
|
286
|
+
g.event_id,
|
|
287
|
+
g.bytes,
|
|
288
|
+
g.body_id,
|
|
289
|
+
b.truncated,
|
|
290
|
+
CASE WHEN b.is_text = 1 THEN CAST(b.content AS TEXT) END AS text
|
|
291
|
+
FROM messages g
|
|
292
|
+
JOIN message_names n ON n.id = g.name_id
|
|
293
|
+
JOIN requests r ON r.id = g.request_id
|
|
294
|
+
JOIN hosts hh ON hh.id = r.host_id
|
|
295
|
+
LEFT JOIN bodies b ON b.id = g.body_id;
|
|
296
|
+
|
|
297
|
+
-- v_runs gains the message count. Byte totals already include socket traffic:
|
|
298
|
+
-- a connection's request_bytes/response_bytes are the sums of its messages.
|
|
299
|
+
DROP VIEW v_runs;
|
|
300
|
+
CREATE VIEW v_runs AS
|
|
301
|
+
SELECT
|
|
302
|
+
r.id,
|
|
303
|
+
r.uid,
|
|
304
|
+
r.label,
|
|
305
|
+
r.entry,
|
|
306
|
+
r.cwd,
|
|
307
|
+
r.node_version,
|
|
308
|
+
r.started_at,
|
|
309
|
+
r.finished_at,
|
|
310
|
+
r.exit_status,
|
|
311
|
+
r.error_name,
|
|
312
|
+
r.error_message,
|
|
313
|
+
(SELECT COUNT(*) FROM requests q WHERE q.run_id = r.id) AS request_count,
|
|
314
|
+
(SELECT COUNT(*) FROM requests q WHERE q.run_id = r.id
|
|
315
|
+
AND (q.error_name IS NOT NULL OR q.status >= 400)) AS error_count,
|
|
316
|
+
(SELECT COUNT(*) FROM requests q WHERE q.run_id = r.id
|
|
317
|
+
AND q.kind <> 'http') AS connection_count,
|
|
318
|
+
(SELECT COUNT(*) FROM messages g JOIN requests q ON q.id = g.request_id
|
|
319
|
+
WHERE q.run_id = r.id) AS message_count,
|
|
320
|
+
(SELECT SUM(q.duration_ms) FROM requests q WHERE q.run_id = r.id) AS total_duration_ms,
|
|
321
|
+
(SELECT SUM(q.request_bytes + q.response_bytes) FROM requests q
|
|
322
|
+
WHERE q.run_id = r.id) AS total_bytes
|
|
323
|
+
FROM runs r;
|
|
324
|
+
`,
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// 003 — where a run came from
|
|
327
|
+
//
|
|
328
|
+
// Two facts a recording could not previously carry.
|
|
329
|
+
//
|
|
330
|
+
// env which environment it ran against. Without it, two runs of
|
|
331
|
+
// the same script against staging and prod are
|
|
332
|
+
// indistinguishable after the fact, which undercuts the whole
|
|
333
|
+
// point of keeping the history.
|
|
334
|
+
// entry_source the snippet, for runs started with `execute -e` or stdin.
|
|
335
|
+
// An inline run has no file to go back to, so without this it
|
|
336
|
+
// is the one kind of run that cannot be reproduced from the
|
|
337
|
+
// database.
|
|
338
|
+
//
|
|
339
|
+
// Both are nullable: every run recorded before this migration predates the
|
|
340
|
+
// question, and NULL is the honest answer rather than a fabricated default.
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
`
|
|
343
|
+
ALTER TABLE runs ADD COLUMN env TEXT;
|
|
344
|
+
ALTER TABLE runs ADD COLUMN entry_source TEXT;
|
|
345
|
+
CREATE INDEX idx_runs_env ON runs (env);
|
|
346
|
+
|
|
347
|
+
DROP VIEW v_runs;
|
|
348
|
+
CREATE VIEW v_runs AS
|
|
349
|
+
SELECT
|
|
350
|
+
r.id,
|
|
351
|
+
r.uid,
|
|
352
|
+
r.label,
|
|
353
|
+
r.entry,
|
|
354
|
+
r.entry_source,
|
|
355
|
+
r.cwd,
|
|
356
|
+
r.env,
|
|
357
|
+
r.node_version,
|
|
358
|
+
r.started_at,
|
|
359
|
+
r.finished_at,
|
|
360
|
+
r.exit_status,
|
|
361
|
+
r.error_name,
|
|
362
|
+
r.error_message,
|
|
363
|
+
(SELECT COUNT(*) FROM requests q WHERE q.run_id = r.id) AS request_count,
|
|
364
|
+
(SELECT COUNT(*) FROM requests q WHERE q.run_id = r.id
|
|
365
|
+
AND (q.error_name IS NOT NULL OR q.status >= 400)) AS error_count,
|
|
366
|
+
(SELECT COUNT(*) FROM requests q WHERE q.run_id = r.id
|
|
367
|
+
AND q.kind <> 'http') AS connection_count,
|
|
368
|
+
(SELECT COUNT(*) FROM messages g JOIN requests q ON q.id = g.request_id
|
|
369
|
+
WHERE q.run_id = r.id) AS message_count,
|
|
370
|
+
(SELECT SUM(q.duration_ms) FROM requests q WHERE q.run_id = r.id) AS total_duration_ms,
|
|
371
|
+
(SELECT SUM(q.request_bytes + q.response_bytes) FROM requests q
|
|
372
|
+
WHERE q.run_id = r.id) AS total_bytes
|
|
373
|
+
FROM runs r;
|
|
374
|
+
`,
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// 004 — checks
|
|
377
|
+
//
|
|
378
|
+
// What a run ASSERTED, next to what it observed. A recording says a request
|
|
379
|
+
// returned 200 in 40 ms; only the script's author knows whether that was the
|
|
380
|
+
// right answer. `check()` and `assert()` write that judgement down at the
|
|
381
|
+
// moment it was made, so a run carries its own verdict instead of leaving one
|
|
382
|
+
// to be re-derived from the traffic later.
|
|
383
|
+
//
|
|
384
|
+
// A check hangs off the run rather than off a request on purpose. Plenty of
|
|
385
|
+
// them are not about a single exchange — "the cart total matches the sum of
|
|
386
|
+
// its lines" spans three — and pinning every check to one request would have
|
|
387
|
+
// meant either guessing which, or refusing the ones that fit none.
|
|
388
|
+
//
|
|
389
|
+
// mode 'check' recorded, execution continues
|
|
390
|
+
// 'assert' recorded, then thrown — this is the last check in the run
|
|
391
|
+
// status 'pass' | 'fail'
|
|
392
|
+
// message the author's line, stored only on a failure, which is the only
|
|
393
|
+
// time it describes anything. NULL on a pass, and NULL when the
|
|
394
|
+
// author passed no message at all.
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
`
|
|
397
|
+
CREATE TABLE checks (
|
|
398
|
+
id INTEGER PRIMARY KEY,
|
|
399
|
+
run_id INTEGER NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
|
400
|
+
seq INTEGER NOT NULL, -- order within the run, from 0
|
|
401
|
+
ts INTEGER NOT NULL, -- epoch ms, when the check ran
|
|
402
|
+
name TEXT NOT NULL, -- what was being checked
|
|
403
|
+
mode TEXT NOT NULL, -- 'check' | 'assert'
|
|
404
|
+
status TEXT NOT NULL, -- 'pass' | 'fail'
|
|
405
|
+
message TEXT -- the author's message, on failure
|
|
406
|
+
);
|
|
407
|
+
CREATE UNIQUE INDEX idx_checks_run_seq ON checks (run_id, seq);
|
|
408
|
+
-- "every failure across every run", the query this table exists to answer.
|
|
409
|
+
CREATE INDEX idx_checks_status ON checks (status, run_id);
|
|
410
|
+
-- One check's history: has "login returns 200" always passed?
|
|
411
|
+
CREATE INDEX idx_checks_name ON checks (name, ts DESC);
|
|
412
|
+
|
|
413
|
+
-- A straight projection today. It exists so checks are part of the documented
|
|
414
|
+
-- view surface \u2014 what \`wire schema\` lists, what \`get\` and \`ls\` read \u2014 rather
|
|
415
|
+
-- than a raw table callers are expected to know the shape of.
|
|
416
|
+
CREATE VIEW v_checks AS
|
|
417
|
+
SELECT c.id, c.run_id, c.seq, c.ts, c.name, c.mode, c.status, c.message
|
|
418
|
+
FROM checks c;
|
|
419
|
+
|
|
420
|
+
-- v_runs gains the two counters that make a run's verdict readable without a
|
|
421
|
+
-- join: how many checks it made, and how many of them failed.
|
|
422
|
+
DROP VIEW v_runs;
|
|
423
|
+
CREATE VIEW v_runs AS
|
|
424
|
+
SELECT
|
|
425
|
+
r.id,
|
|
426
|
+
r.uid,
|
|
427
|
+
r.label,
|
|
428
|
+
r.entry,
|
|
429
|
+
r.entry_source,
|
|
430
|
+
r.cwd,
|
|
431
|
+
r.env,
|
|
432
|
+
r.node_version,
|
|
433
|
+
r.started_at,
|
|
434
|
+
r.finished_at,
|
|
435
|
+
r.exit_status,
|
|
436
|
+
r.error_name,
|
|
437
|
+
r.error_message,
|
|
438
|
+
(SELECT COUNT(*) FROM requests q WHERE q.run_id = r.id) AS request_count,
|
|
439
|
+
(SELECT COUNT(*) FROM requests q WHERE q.run_id = r.id
|
|
440
|
+
AND (q.error_name IS NOT NULL OR q.status >= 400)) AS error_count,
|
|
441
|
+
(SELECT COUNT(*) FROM requests q WHERE q.run_id = r.id
|
|
442
|
+
AND q.kind <> 'http') AS connection_count,
|
|
443
|
+
(SELECT COUNT(*) FROM messages g JOIN requests q ON q.id = g.request_id
|
|
444
|
+
WHERE q.run_id = r.id) AS message_count,
|
|
445
|
+
(SELECT COUNT(*) FROM checks c WHERE c.run_id = r.id) AS check_count,
|
|
446
|
+
(SELECT COUNT(*) FROM checks c WHERE c.run_id = r.id
|
|
447
|
+
AND c.status = 'fail') AS failed_check_count,
|
|
448
|
+
(SELECT SUM(q.duration_ms) FROM requests q WHERE q.run_id = r.id) AS total_duration_ms,
|
|
449
|
+
(SELECT SUM(q.request_bytes + q.response_bytes) FROM requests q
|
|
450
|
+
WHERE q.run_id = r.id) AS total_bytes
|
|
451
|
+
FROM runs r;
|
|
452
|
+
`
|
|
453
|
+
];
|
|
454
|
+
var SCHEMA_VERSION = MIGRATIONS.length;
|
|
455
|
+
|
|
456
|
+
// src/open.ts
|
|
457
|
+
var DEFAULT_DB_FILENAME = "wire.sqlite";
|
|
458
|
+
function resolveDbPath(options = {}) {
|
|
459
|
+
const cwd = options.cwd ?? process.cwd();
|
|
460
|
+
const raw = options.path ?? DEFAULT_DB_FILENAME;
|
|
461
|
+
return isAbsolute(raw) ? raw : resolve(cwd, raw);
|
|
462
|
+
}
|
|
463
|
+
function openDb(options = {}) {
|
|
464
|
+
const file = resolveDbPath(options);
|
|
465
|
+
if (options.mustExist && !existsSync(file)) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`No database at ${file}. Run \`wire execute <file>\` first, or pass --db <path>.`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
if (!options.mustExist) mkdirSync(dirname(file), { recursive: true });
|
|
471
|
+
const db = new Database(file);
|
|
472
|
+
db.pragma("journal_mode = WAL");
|
|
473
|
+
db.pragma("synchronous = NORMAL");
|
|
474
|
+
db.pragma("temp_store = MEMORY");
|
|
475
|
+
db.pragma("cache_size = -65536");
|
|
476
|
+
db.pragma("foreign_keys = ON");
|
|
477
|
+
if (!options.skipMigrations) applyMigrations(db);
|
|
478
|
+
return db;
|
|
479
|
+
}
|
|
480
|
+
function applyMigrations(db) {
|
|
481
|
+
const current = db.pragma("user_version", { simple: true });
|
|
482
|
+
if (current > MIGRATIONS.length) {
|
|
483
|
+
throw new Error(
|
|
484
|
+
`Database schema version ${current} is newer than this build of wire understands (${MIGRATIONS.length}). Upgrade the CLI.`
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
for (let version = current; version < MIGRATIONS.length; version++) {
|
|
488
|
+
const sql = MIGRATIONS[version];
|
|
489
|
+
if (!sql) continue;
|
|
490
|
+
db.exec(`BEGIN; ${sql} PRAGMA user_version = ${version + 1}; COMMIT;`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// src/entities.ts
|
|
495
|
+
var COMPUTED_TYPES = {
|
|
496
|
+
"v_runs.request_count": "INTEGER",
|
|
497
|
+
"v_runs.error_count": "INTEGER",
|
|
498
|
+
"v_runs.connection_count": "INTEGER",
|
|
499
|
+
"v_runs.message_count": "INTEGER",
|
|
500
|
+
"v_runs.check_count": "INTEGER",
|
|
501
|
+
"v_runs.failed_check_count": "INTEGER",
|
|
502
|
+
"v_runs.total_duration_ms": "REAL",
|
|
503
|
+
"v_runs.total_bytes": "INTEGER",
|
|
504
|
+
"v_requests.message_count": "INTEGER",
|
|
505
|
+
"v_messages.endpoint": "TEXT",
|
|
506
|
+
"v_messages.direction": "TEXT",
|
|
507
|
+
"v_messages.text": "TEXT",
|
|
508
|
+
"v_headers.kind": "TEXT",
|
|
509
|
+
"v_bodies.text": "TEXT"
|
|
510
|
+
};
|
|
511
|
+
var RELATION_DESCRIPTIONS = {
|
|
512
|
+
v_runs: "One row per execute, counts computed on read.",
|
|
513
|
+
v_requests: "One HTTP request, or one WebSocket/SSE connection.",
|
|
514
|
+
v_headers: "Request and response headers, in wire order.",
|
|
515
|
+
v_bodies: "Deduped payloads, content-addressed by SHA-256.",
|
|
516
|
+
v_messages: "WebSocket frames and SSE events, with payloads.",
|
|
517
|
+
v_checks: "Assertions a run made, from check() and assert().",
|
|
518
|
+
tags: "Per-request key/value tags, from execute --tag.",
|
|
519
|
+
body_fts: "FTS5 index over text payloads. Join on body_id."
|
|
520
|
+
};
|
|
521
|
+
function listRelations(db, options = {}) {
|
|
522
|
+
const types = options.all ? ["view", "table"] : ["view"];
|
|
523
|
+
const rows = db.prepare(
|
|
524
|
+
`SELECT name, type FROM sqlite_master
|
|
525
|
+
WHERE type IN (${types.map(() => "?").join(", ")})
|
|
526
|
+
AND name NOT LIKE 'sqlite_%'
|
|
527
|
+
AND name NOT LIKE 'body_fts_%'
|
|
528
|
+
ORDER BY type DESC, name`
|
|
529
|
+
).all(...types);
|
|
530
|
+
return rows.map((r) => ({
|
|
531
|
+
name: r.name,
|
|
532
|
+
type: r.type,
|
|
533
|
+
description: RELATION_DESCRIPTIONS[r.name] ?? null
|
|
534
|
+
}));
|
|
535
|
+
}
|
|
536
|
+
function relationExists(db, name) {
|
|
537
|
+
const row = db.prepare(`SELECT 1 FROM sqlite_master WHERE name = ? AND type IN ('view', 'table')`).get(name);
|
|
538
|
+
return row !== void 0;
|
|
539
|
+
}
|
|
540
|
+
function describeRelation(db, name) {
|
|
541
|
+
const rows = db.prepare(`SELECT name, type FROM pragma_table_info(?) ORDER BY cid`).all(name);
|
|
542
|
+
return rows.map((r) => {
|
|
543
|
+
const declared = r.type.trim();
|
|
544
|
+
if (declared) return { name: r.name, type: declared, computed: false };
|
|
545
|
+
return {
|
|
546
|
+
name: r.name,
|
|
547
|
+
type: COMPUTED_TYPES[`${name}.${r.name}`] ?? "",
|
|
548
|
+
computed: true
|
|
549
|
+
};
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
function untypedColumns(db) {
|
|
553
|
+
const missing = [];
|
|
554
|
+
for (const rel of listRelations(db)) {
|
|
555
|
+
for (const col of describeRelation(db, rel.name)) {
|
|
556
|
+
if (col.computed && !col.type) missing.push(`${rel.name}.${col.name}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return missing;
|
|
560
|
+
}
|
|
561
|
+
var ENTITIES = [
|
|
562
|
+
{
|
|
563
|
+
name: "run",
|
|
564
|
+
plural: "runs",
|
|
565
|
+
view: "v_runs",
|
|
566
|
+
key: "id",
|
|
567
|
+
defaultFields: ["id", "uid", "label", "started_at", "exit_status", "request_count"],
|
|
568
|
+
description: "One execute.",
|
|
569
|
+
filters: {},
|
|
570
|
+
order: "started_at DESC",
|
|
571
|
+
derived: {}
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
name: "request",
|
|
575
|
+
plural: "requests",
|
|
576
|
+
view: "v_requests",
|
|
577
|
+
key: "id",
|
|
578
|
+
defaultFields: ["id", "seq", "method", "host", "path", "status", "duration_ms"],
|
|
579
|
+
description: "One HTTP request, or one WebSocket/SSE connection.",
|
|
580
|
+
filters: { run: "run_id" },
|
|
581
|
+
order: "id DESC",
|
|
582
|
+
derived: {}
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
name: "check",
|
|
586
|
+
plural: "checks",
|
|
587
|
+
view: "v_checks",
|
|
588
|
+
key: "id",
|
|
589
|
+
// `message` earns its place in a default set the way no other payload-ish
|
|
590
|
+
// column does: it is at most a line, and it is the answer to the only
|
|
591
|
+
// question anyone asks a failing check.
|
|
592
|
+
defaultFields: ["id", "run_id", "seq", "name", "status", "message"],
|
|
593
|
+
description: "One assertion, from check() or assert().",
|
|
594
|
+
filters: { run: "run_id" },
|
|
595
|
+
order: "id DESC",
|
|
596
|
+
derived: {}
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
name: "body",
|
|
600
|
+
plural: "bodies",
|
|
601
|
+
view: "v_bodies",
|
|
602
|
+
key: "id",
|
|
603
|
+
defaultFields: ["id", "size", "stored_size", "truncated", "is_text", "preview"],
|
|
604
|
+
description: "One deduped payload.",
|
|
605
|
+
filters: {},
|
|
606
|
+
order: "id DESC",
|
|
607
|
+
derived: { preview: "text" }
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
name: "message",
|
|
611
|
+
plural: "messages",
|
|
612
|
+
view: "v_messages",
|
|
613
|
+
key: "id",
|
|
614
|
+
defaultFields: ["id", "request_id", "seq", "direction", "name", "bytes"],
|
|
615
|
+
description: "One WebSocket frame or SSE event.",
|
|
616
|
+
filters: { run: "run_id", request: "request_id" },
|
|
617
|
+
order: "id DESC",
|
|
618
|
+
derived: { preview: "text" }
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
name: "header",
|
|
622
|
+
plural: "headers",
|
|
623
|
+
view: "v_headers",
|
|
624
|
+
// Headers have no id of their own: many rows share one request_id, so
|
|
625
|
+
// `get header 42` reads as "the headers of request 42".
|
|
626
|
+
key: "request_id",
|
|
627
|
+
defaultFields: ["request_id", "kind", "name", "value"],
|
|
628
|
+
description: "Request and response headers. Keyed by request id, many per id.",
|
|
629
|
+
filters: { request: "request_id" },
|
|
630
|
+
order: "request_id DESC, kind, ord",
|
|
631
|
+
derived: {}
|
|
632
|
+
}
|
|
633
|
+
];
|
|
634
|
+
function findEntity(name) {
|
|
635
|
+
const wanted = name.toLowerCase();
|
|
636
|
+
return ENTITIES.find((e) => e.name === wanted || e.plural === wanted || e.view === wanted) ?? null;
|
|
637
|
+
}
|
|
638
|
+
function entityFields(db, entity) {
|
|
639
|
+
const columns = describeRelation(db, entity.view).map((c) => c.name);
|
|
640
|
+
return [...columns, ...Object.keys(entity.derived)];
|
|
641
|
+
}
|
|
642
|
+
var UnknownFieldError = class extends Error {
|
|
643
|
+
constructor(entity, field, available) {
|
|
644
|
+
super(`unknown field "${field}" on ${entity.name}`);
|
|
645
|
+
this.entity = entity;
|
|
646
|
+
this.field = field;
|
|
647
|
+
this.available = available;
|
|
648
|
+
this.name = "UnknownFieldError";
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
var PREVIEW_CHARS = 120;
|
|
652
|
+
function assertFieldsExist(entity, fields, available) {
|
|
653
|
+
for (const field of fields) {
|
|
654
|
+
if (!available.includes(field)) {
|
|
655
|
+
throw new UnknownFieldError(entity, field, available);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
function columnsFor(entity, fields) {
|
|
660
|
+
const columns = /* @__PURE__ */ new Set();
|
|
661
|
+
for (const field of fields) {
|
|
662
|
+
columns.add(entity.derived[field] ?? field);
|
|
663
|
+
}
|
|
664
|
+
if (columns.size === 0) columns.add(entity.key);
|
|
665
|
+
return [...columns];
|
|
666
|
+
}
|
|
667
|
+
function whereClause(entity, options) {
|
|
668
|
+
const where = [];
|
|
669
|
+
const params = [];
|
|
670
|
+
if (options.ids && options.ids.length > 0) {
|
|
671
|
+
where.push(`"${entity.key}" IN (${options.ids.map(() => "?").join(", ")})`);
|
|
672
|
+
params.push(...options.ids);
|
|
673
|
+
}
|
|
674
|
+
for (const [name, value] of Object.entries(options.filters ?? {})) {
|
|
675
|
+
const column = entity.filters[name];
|
|
676
|
+
if (!column) continue;
|
|
677
|
+
where.push(`"${column}" = ?`);
|
|
678
|
+
params.push(value);
|
|
679
|
+
}
|
|
680
|
+
return { where, params };
|
|
681
|
+
}
|
|
682
|
+
function selectEntity(db, entity, options) {
|
|
683
|
+
assertFieldsExist(entity, options.fields, entityFields(db, entity));
|
|
684
|
+
const columns = columnsFor(entity, options.fields);
|
|
685
|
+
const { where, params } = whereClause(entity, options);
|
|
686
|
+
const order = (entity.name === "request" || entity.name === "check") && options.filters?.run !== void 0 ? "seq" : entity.order;
|
|
687
|
+
const sql = `SELECT ${columns.map((c) => `"${c}"`).join(", ")} FROM "${entity.view}"` + (where.length ? ` WHERE ${where.join(" AND ")}` : "") + ` ORDER BY ${order}` + (options.limit !== void 0 && options.limit > 0 ? ` LIMIT ${Number(options.limit)}` : "");
|
|
688
|
+
const rows = db.prepare(sql).all(...params);
|
|
689
|
+
return rows.map((row) => {
|
|
690
|
+
const out = {};
|
|
691
|
+
for (const field of options.fields) {
|
|
692
|
+
const source = entity.derived[field];
|
|
693
|
+
out[field] = source === void 0 ? row[field] : preview(row[source]);
|
|
694
|
+
}
|
|
695
|
+
return out;
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
function preview(value) {
|
|
699
|
+
if (value === null || value === void 0) return null;
|
|
700
|
+
const text = typeof value === "string" ? value : String(value);
|
|
701
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
702
|
+
return flat.length <= PREVIEW_CHARS ? flat : `${flat.slice(0, PREVIEW_CHARS)}\u2026`;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/writer.ts
|
|
706
|
+
import { createHash } from "node:crypto";
|
|
707
|
+
var RunWriter = class {
|
|
708
|
+
runId;
|
|
709
|
+
db;
|
|
710
|
+
seq = 0;
|
|
711
|
+
/** Check ordering is its own sequence: checks and requests interleave freely. */
|
|
712
|
+
checkSeq = 0;
|
|
713
|
+
// Interning caches: string -> row id. Bounded by the traffic's real
|
|
714
|
+
// cardinality (a handful of hosts and methods, a few dozen header names).
|
|
715
|
+
methodIds = /* @__PURE__ */ new Map();
|
|
716
|
+
hostIds = /* @__PURE__ */ new Map();
|
|
717
|
+
contentTypeIds = /* @__PURE__ */ new Map();
|
|
718
|
+
headerNameIds = /* @__PURE__ */ new Map();
|
|
719
|
+
messageNameIds = /* @__PURE__ */ new Map();
|
|
720
|
+
bodyIds = /* @__PURE__ */ new Map();
|
|
721
|
+
// uid -> requests.id, for connections. A WebSocket or SSE stream is written
|
|
722
|
+
// once when it opens and updated when it closes; this is how the second
|
|
723
|
+
// record finds the row the first one created.
|
|
724
|
+
connectionIds = /* @__PURE__ */ new Map();
|
|
725
|
+
stmt;
|
|
726
|
+
writeBatch;
|
|
727
|
+
writeCheckBatch;
|
|
728
|
+
constructor(db, run) {
|
|
729
|
+
this.db = db;
|
|
730
|
+
const internSql = (table, column) => db.prepare(
|
|
731
|
+
`INSERT INTO ${table} (${column}) VALUES (?)
|
|
732
|
+
ON CONFLICT(${column}) DO UPDATE SET ${column} = excluded.${column}
|
|
733
|
+
RETURNING id`
|
|
734
|
+
);
|
|
735
|
+
this.stmt = {
|
|
736
|
+
intern: {
|
|
737
|
+
method: internSql("methods", "method"),
|
|
738
|
+
host: internSql("hosts", "host"),
|
|
739
|
+
contentType: internSql("content_types", "content_type"),
|
|
740
|
+
headerName: internSql("header_names", "name"),
|
|
741
|
+
messageName: internSql("message_names", "name")
|
|
742
|
+
},
|
|
743
|
+
// DO NOTHING (not DO UPDATE) so a duplicate payload doesn't rewrite the
|
|
744
|
+
// blob. It returns no row on conflict; selectBody covers that path.
|
|
745
|
+
insertBody: db.prepare(
|
|
746
|
+
`INSERT INTO bodies (sha256, size, stored_size, truncated, is_text, content)
|
|
747
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
748
|
+
ON CONFLICT(sha256) DO NOTHING
|
|
749
|
+
RETURNING id`
|
|
750
|
+
),
|
|
751
|
+
selectBody: db.prepare(`SELECT id FROM bodies WHERE sha256 = ?`),
|
|
752
|
+
insertFts: db.prepare(`INSERT INTO body_fts (body_id, content) VALUES (?, ?)`),
|
|
753
|
+
insertRequest: db.prepare(
|
|
754
|
+
`INSERT INTO requests (
|
|
755
|
+
run_id, seq, ts, kind, method_id, host_id, path, query, url,
|
|
756
|
+
status, ok, error_name, error_message,
|
|
757
|
+
duration_ms, dns_ms, connect_ms, tls_ms, ttfb_ms, download_ms,
|
|
758
|
+
request_bytes, response_bytes,
|
|
759
|
+
request_content_type_id, response_content_type_id,
|
|
760
|
+
request_body_id, response_body_id
|
|
761
|
+
) VALUES (
|
|
762
|
+
@run_id, @seq, @ts, @kind, @method_id, @host_id, @path, @query, @url,
|
|
763
|
+
@status, @ok, @error_name, @error_message,
|
|
764
|
+
@duration_ms, @dns_ms, @connect_ms, @tls_ms, @ttfb_ms, @download_ms,
|
|
765
|
+
@request_bytes, @response_bytes,
|
|
766
|
+
@request_content_type_id, @response_content_type_id,
|
|
767
|
+
@request_body_id, @response_body_id
|
|
768
|
+
)`
|
|
769
|
+
),
|
|
770
|
+
// A connection's closing record: everything that was unknown while it
|
|
771
|
+
// was open. Headers and tags are not touched — they were written on the
|
|
772
|
+
// way in and have not changed.
|
|
773
|
+
updateConnection: db.prepare(
|
|
774
|
+
`UPDATE requests SET
|
|
775
|
+
status = @status, ok = @ok,
|
|
776
|
+
error_name = @error_name, error_message = @error_message,
|
|
777
|
+
duration_ms = @duration_ms, connect_ms = @connect_ms, ttfb_ms = @ttfb_ms,
|
|
778
|
+
request_bytes = @request_bytes, response_bytes = @response_bytes
|
|
779
|
+
WHERE id = @id`
|
|
780
|
+
),
|
|
781
|
+
insertMessage: db.prepare(
|
|
782
|
+
`INSERT INTO messages (request_id, seq, ts, dir, name_id, code, event_id, bytes, body_id)
|
|
783
|
+
VALUES (@request_id, @seq, @ts, @dir, @name_id, @code, @event_id, @bytes, @body_id)`
|
|
784
|
+
),
|
|
785
|
+
insertHeader: db.prepare(
|
|
786
|
+
`INSERT INTO headers (request_id, kind, ord, name_id, value) VALUES (?, ?, ?, ?, ?)`
|
|
787
|
+
),
|
|
788
|
+
insertTag: db.prepare(
|
|
789
|
+
`INSERT INTO tags (request_id, key, value) VALUES (?, ?, ?)
|
|
790
|
+
ON CONFLICT(request_id, key) DO UPDATE SET value = excluded.value`
|
|
791
|
+
),
|
|
792
|
+
insertCheck: db.prepare(
|
|
793
|
+
`INSERT INTO checks (run_id, seq, ts, name, mode, status, message)
|
|
794
|
+
VALUES (@run_id, @seq, @ts, @name, @mode, @status, @message)`
|
|
795
|
+
),
|
|
796
|
+
finishRun: db.prepare(
|
|
797
|
+
`UPDATE runs SET finished_at = ?, exit_status = ?, error_name = ?, error_message = ?
|
|
798
|
+
WHERE id = ?`
|
|
799
|
+
)
|
|
800
|
+
};
|
|
801
|
+
const inserted = db.prepare(
|
|
802
|
+
`INSERT INTO runs (uid, label, entry, entry_source, cwd, env, node_version, started_at)
|
|
803
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
804
|
+
).run(
|
|
805
|
+
run.uid,
|
|
806
|
+
run.label,
|
|
807
|
+
run.entry,
|
|
808
|
+
run.entrySource ?? null,
|
|
809
|
+
run.cwd,
|
|
810
|
+
run.env ?? null,
|
|
811
|
+
run.nodeVersion,
|
|
812
|
+
run.startedAt
|
|
813
|
+
);
|
|
814
|
+
this.runId = Number(inserted.lastInsertRowid);
|
|
815
|
+
this.writeBatch = db.transaction((records) => {
|
|
816
|
+
for (const record of records) {
|
|
817
|
+
if (record.type === "message") this.insertMessage(record);
|
|
818
|
+
else this.insertRequest(record);
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
this.writeCheckBatch = db.transaction((records) => {
|
|
822
|
+
for (const record of records) {
|
|
823
|
+
this.stmt.insertCheck.run({
|
|
824
|
+
run_id: this.runId,
|
|
825
|
+
seq: this.checkSeq++,
|
|
826
|
+
ts: record.ts,
|
|
827
|
+
name: record.name,
|
|
828
|
+
mode: record.mode,
|
|
829
|
+
status: record.status,
|
|
830
|
+
// A message describes a failure. Keeping it off the passing rows is
|
|
831
|
+
// what makes `SELECT name, message FROM v_checks WHERE status='fail'`
|
|
832
|
+
// read as a list of reasons rather than a column half full of lines
|
|
833
|
+
// that never applied.
|
|
834
|
+
message: record.status === "fail" ? record.message ?? null : null
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
/** Persist a batch of captures in a single transaction. */
|
|
840
|
+
write(records) {
|
|
841
|
+
if (records.length === 0) return;
|
|
842
|
+
this.writeBatch(records);
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Persist a batch of assertions in a single transaction.
|
|
846
|
+
*
|
|
847
|
+
* Separate from {@link RunWriter.write} because checks are not traffic: they
|
|
848
|
+
* arrive from the script itself rather than from a capture, and they carry no
|
|
849
|
+
* bodies, headers or interning. `seq` is assigned here, in arrival order, so
|
|
850
|
+
* a run's checks read back in the order the script made them.
|
|
851
|
+
*/
|
|
852
|
+
writeChecks(records) {
|
|
853
|
+
if (records.length === 0) return;
|
|
854
|
+
this.writeCheckBatch(records);
|
|
855
|
+
}
|
|
856
|
+
/** Record how the run ended. */
|
|
857
|
+
finish(end) {
|
|
858
|
+
this.stmt.finishRun.run(
|
|
859
|
+
end.finishedAt,
|
|
860
|
+
end.exitStatus,
|
|
861
|
+
end.errorName ?? null,
|
|
862
|
+
end.errorMessage ?? null,
|
|
863
|
+
this.runId
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
/**
|
|
868
|
+
* Write one exchange. For a connection this runs twice — once at open, once
|
|
869
|
+
* at close — and the second pass updates the row rather than adding one.
|
|
870
|
+
*/
|
|
871
|
+
insertRequest(r) {
|
|
872
|
+
const existing = r.uid === void 0 ? void 0 : this.connectionIds.get(r.uid);
|
|
873
|
+
if (existing !== void 0) {
|
|
874
|
+
this.updateConnection(existing, r);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const result = this.stmt.insertRequest.run(this.requestRow(r));
|
|
878
|
+
const requestId = Number(result.lastInsertRowid);
|
|
879
|
+
if (r.uid !== void 0) this.connectionIds.set(r.uid, requestId);
|
|
880
|
+
this.insertHeaders(requestId, 0, r.requestHeaders);
|
|
881
|
+
this.insertHeaders(requestId, 1, r.responseHeaders);
|
|
882
|
+
if (r.tags) {
|
|
883
|
+
for (const [key, value] of Object.entries(r.tags)) {
|
|
884
|
+
this.stmt.insertTag.run(requestId, key, value);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
/** The closing pass over a connection: outcome and timings, onto the open row. */
|
|
889
|
+
updateConnection(id, r) {
|
|
890
|
+
this.stmt.updateConnection.run({
|
|
891
|
+
id,
|
|
892
|
+
status: r.status ?? null,
|
|
893
|
+
ok: r.ok === void 0 ? null : r.ok ? 1 : 0,
|
|
894
|
+
error_name: r.error?.name ?? null,
|
|
895
|
+
error_message: r.error?.message ?? null,
|
|
896
|
+
duration_ms: r.timings.duration,
|
|
897
|
+
connect_ms: r.timings.connect ?? null,
|
|
898
|
+
ttfb_ms: r.timings.ttfb ?? null,
|
|
899
|
+
request_bytes: r.requestBytes,
|
|
900
|
+
response_bytes: r.responseBytes
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
requestRow(r) {
|
|
904
|
+
return {
|
|
905
|
+
run_id: this.runId,
|
|
906
|
+
seq: this.seq++,
|
|
907
|
+
ts: r.ts,
|
|
908
|
+
kind: r.kind,
|
|
909
|
+
method_id: this.intern("method", this.methodIds, r.method),
|
|
910
|
+
host_id: this.intern("host", this.hostIds, r.host),
|
|
911
|
+
path: r.path,
|
|
912
|
+
query: r.query,
|
|
913
|
+
url: r.url,
|
|
914
|
+
status: r.status ?? null,
|
|
915
|
+
ok: r.ok === void 0 ? null : r.ok ? 1 : 0,
|
|
916
|
+
error_name: r.error?.name ?? null,
|
|
917
|
+
error_message: r.error?.message ?? null,
|
|
918
|
+
duration_ms: r.timings.duration,
|
|
919
|
+
dns_ms: r.timings.dns ?? null,
|
|
920
|
+
connect_ms: r.timings.connect ?? null,
|
|
921
|
+
tls_ms: r.timings.tls ?? null,
|
|
922
|
+
ttfb_ms: r.timings.ttfb ?? null,
|
|
923
|
+
download_ms: r.timings.download ?? null,
|
|
924
|
+
request_bytes: r.requestBytes,
|
|
925
|
+
response_bytes: r.responseBytes,
|
|
926
|
+
request_content_type_id: this.internContentType(r.request?.contentType),
|
|
927
|
+
response_content_type_id: this.internContentType(r.response?.contentType),
|
|
928
|
+
request_body_id: this.internBody(r.request),
|
|
929
|
+
response_body_id: this.internBody(r.response)
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Write one frame or event against the connection that carried it.
|
|
934
|
+
*
|
|
935
|
+
* A message whose connection is unknown is dropped: records reach a sink in
|
|
936
|
+
* order, so the only way here is a sink that reordered them, and half a
|
|
937
|
+
* connection is worse than none.
|
|
938
|
+
*/
|
|
939
|
+
insertMessage(m) {
|
|
940
|
+
const requestId = this.connectionIds.get(m.connection);
|
|
941
|
+
if (requestId === void 0) return;
|
|
942
|
+
this.stmt.insertMessage.run({
|
|
943
|
+
request_id: requestId,
|
|
944
|
+
seq: m.seq,
|
|
945
|
+
ts: m.ts,
|
|
946
|
+
dir: m.direction === "sent" ? 0 : 1,
|
|
947
|
+
name_id: this.intern("messageName", this.messageNameIds, m.name),
|
|
948
|
+
code: m.code ?? null,
|
|
949
|
+
event_id: m.eventId ?? null,
|
|
950
|
+
bytes: m.bytes,
|
|
951
|
+
body_id: this.internBody(m.body)
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
insertHeaders(requestId, kind, headers) {
|
|
955
|
+
for (let ord = 0; ord < headers.length; ord++) {
|
|
956
|
+
const header = headers[ord];
|
|
957
|
+
if (!header) continue;
|
|
958
|
+
const nameId = this.intern("headerName", this.headerNameIds, header[0].toLowerCase());
|
|
959
|
+
this.stmt.insertHeader.run(requestId, kind, ord, nameId, header[1]);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
intern(kind, cache, value) {
|
|
963
|
+
const hit = cache.get(value);
|
|
964
|
+
if (hit !== void 0) return hit;
|
|
965
|
+
const row = this.stmt.intern[kind].get(value);
|
|
966
|
+
cache.set(value, row.id);
|
|
967
|
+
return row.id;
|
|
968
|
+
}
|
|
969
|
+
internContentType(contentType) {
|
|
970
|
+
if (!contentType) return null;
|
|
971
|
+
return this.intern("contentType", this.contentTypeIds, contentType);
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Store a payload once per distinct content. The SHA-256 is over the bytes as
|
|
975
|
+
* stored, so a run that polls the same unchanged endpoint 500 times keeps one
|
|
976
|
+
* copy of the response and 500 integer references to it.
|
|
977
|
+
*/
|
|
978
|
+
internBody(body) {
|
|
979
|
+
if (!body) return null;
|
|
980
|
+
const digest = createHash("sha256").update(body.bytes).digest();
|
|
981
|
+
const key = digest.toString("hex");
|
|
982
|
+
const cached = this.bodyIds.get(key);
|
|
983
|
+
if (cached !== void 0) return cached;
|
|
984
|
+
const created = this.stmt.insertBody.get(
|
|
985
|
+
digest,
|
|
986
|
+
body.size,
|
|
987
|
+
body.bytes.byteLength,
|
|
988
|
+
body.truncated ? 1 : 0,
|
|
989
|
+
body.isText ? 1 : 0,
|
|
990
|
+
body.bytes
|
|
991
|
+
);
|
|
992
|
+
let id;
|
|
993
|
+
if (created) {
|
|
994
|
+
id = created.id;
|
|
995
|
+
if (body.isText) {
|
|
996
|
+
this.stmt.insertFts.run(id, Buffer.from(body.bytes).toString("utf8"));
|
|
997
|
+
}
|
|
998
|
+
} else {
|
|
999
|
+
id = this.stmt.selectBody.get(digest).id;
|
|
1000
|
+
}
|
|
1001
|
+
this.bodyIds.set(key, id);
|
|
1002
|
+
return id;
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// src/queries.ts
|
|
1007
|
+
function listRuns(db, limit = 200) {
|
|
1008
|
+
return db.prepare(`SELECT * FROM v_runs ORDER BY started_at DESC LIMIT ?`).all(limit);
|
|
1009
|
+
}
|
|
1010
|
+
function getRun(db, id) {
|
|
1011
|
+
return db.prepare(`SELECT * FROM v_runs WHERE id = ?`).get(id) ?? null;
|
|
1012
|
+
}
|
|
1013
|
+
function listScripts(db) {
|
|
1014
|
+
return db.prepare(
|
|
1015
|
+
`SELECT entry,
|
|
1016
|
+
cwd,
|
|
1017
|
+
label,
|
|
1018
|
+
id AS last_run_id,
|
|
1019
|
+
MAX(started_at) AS last_started_at,
|
|
1020
|
+
exit_status AS last_exit_status,
|
|
1021
|
+
COUNT(*) AS run_count,
|
|
1022
|
+
SUM(exit_status = 'error') AS failed_run_count,
|
|
1023
|
+
SUM(request_count) AS request_count,
|
|
1024
|
+
SUM(connection_count) AS connection_count,
|
|
1025
|
+
SUM(message_count) AS message_count,
|
|
1026
|
+
SUM(error_count) AS error_count,
|
|
1027
|
+
SUM(check_count) AS check_count,
|
|
1028
|
+
SUM(failed_check_count) AS failed_check_count,
|
|
1029
|
+
SUM(total_bytes) AS total_bytes
|
|
1030
|
+
FROM v_runs
|
|
1031
|
+
GROUP BY entry
|
|
1032
|
+
ORDER BY last_started_at DESC`
|
|
1033
|
+
).all();
|
|
1034
|
+
}
|
|
1035
|
+
function listRunsForEntry(db, entry, limit = 200) {
|
|
1036
|
+
return db.prepare(`SELECT * FROM v_runs WHERE entry = ? ORDER BY started_at DESC LIMIT ?`).all(entry, limit);
|
|
1037
|
+
}
|
|
1038
|
+
function listRequests(db, runId) {
|
|
1039
|
+
return db.prepare(
|
|
1040
|
+
`SELECT id, seq, ts, kind, message_count, method, host, path, status, ok, error_name,
|
|
1041
|
+
duration_ms, request_bytes, response_bytes
|
|
1042
|
+
FROM v_requests WHERE run_id = ? ORDER BY seq ASC`
|
|
1043
|
+
).all(runId);
|
|
1044
|
+
}
|
|
1045
|
+
function getRequestDetail(db, id) {
|
|
1046
|
+
const request = db.prepare(`SELECT * FROM v_requests WHERE id = ?`).get(id);
|
|
1047
|
+
if (!request) return null;
|
|
1048
|
+
const headers = db.prepare(`SELECT kind, name, value FROM v_headers WHERE request_id = ? ORDER BY kind, ord`).all(id);
|
|
1049
|
+
const tags = db.prepare(`SELECT key, value FROM tags WHERE request_id = ? ORDER BY key`).all(id);
|
|
1050
|
+
const split = (kind) => headers.filter((h) => h.kind === kind).map((h) => [h.name, h.value]);
|
|
1051
|
+
return {
|
|
1052
|
+
request,
|
|
1053
|
+
requestHeaders: split("request"),
|
|
1054
|
+
responseHeaders: split("response"),
|
|
1055
|
+
requestBody: loadBody(db, request.request_body_id, request.request_content_type),
|
|
1056
|
+
responseBody: loadBody(db, request.response_body_id, request.response_content_type),
|
|
1057
|
+
tags,
|
|
1058
|
+
// Only a connection has any, and then the whole point is seeing them in order.
|
|
1059
|
+
messages: request.kind === "http" ? [] : listMessages(db, id)
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
function listRunTags(db, runId) {
|
|
1063
|
+
return db.prepare(
|
|
1064
|
+
`SELECT DISTINCT t.key, t.value
|
|
1065
|
+
FROM tags t JOIN requests r ON r.id = t.request_id
|
|
1066
|
+
WHERE r.run_id = ?
|
|
1067
|
+
ORDER BY t.key, t.value`
|
|
1068
|
+
).all(runId);
|
|
1069
|
+
}
|
|
1070
|
+
function listChecks(db, runId) {
|
|
1071
|
+
return db.prepare(
|
|
1072
|
+
`SELECT id, run_id, seq, ts, name, mode, status, message
|
|
1073
|
+
FROM v_checks WHERE run_id = ? ORDER BY seq ASC`
|
|
1074
|
+
).all(runId);
|
|
1075
|
+
}
|
|
1076
|
+
function listMessages(db, requestId) {
|
|
1077
|
+
return db.prepare(
|
|
1078
|
+
`SELECT id, request_id, seq, ts, direction, name, code, event_id,
|
|
1079
|
+
bytes, body_id, truncated, text
|
|
1080
|
+
FROM v_messages WHERE request_id = ? ORDER BY seq ASC`
|
|
1081
|
+
).all(requestId);
|
|
1082
|
+
}
|
|
1083
|
+
var IMAGE_MIME = /* @__PURE__ */ new Set([
|
|
1084
|
+
"image/apng",
|
|
1085
|
+
"image/avif",
|
|
1086
|
+
"image/bmp",
|
|
1087
|
+
"image/gif",
|
|
1088
|
+
"image/jpeg",
|
|
1089
|
+
"image/png",
|
|
1090
|
+
"image/svg+xml",
|
|
1091
|
+
"image/webp",
|
|
1092
|
+
"image/x-icon",
|
|
1093
|
+
"image/vnd.microsoft.icon"
|
|
1094
|
+
]);
|
|
1095
|
+
var MAX_INLINE_IMAGE_BYTES = 2e6;
|
|
1096
|
+
function imageMime(contentType) {
|
|
1097
|
+
if (!contentType) return null;
|
|
1098
|
+
const mime = contentType.split(";")[0].trim().toLowerCase();
|
|
1099
|
+
return IMAGE_MIME.has(mime) ? mime : null;
|
|
1100
|
+
}
|
|
1101
|
+
function loadBody(db, bodyId, contentType) {
|
|
1102
|
+
const base = {
|
|
1103
|
+
present: false,
|
|
1104
|
+
text: null,
|
|
1105
|
+
json: null,
|
|
1106
|
+
dataUrl: null,
|
|
1107
|
+
contentType,
|
|
1108
|
+
size: null,
|
|
1109
|
+
truncated: false
|
|
1110
|
+
};
|
|
1111
|
+
const empty = (note) => ({ ...base, kind: "empty", note });
|
|
1112
|
+
if (bodyId == null) return empty("No body captured (absent or redacted).");
|
|
1113
|
+
const row = db.prepare(`SELECT size, stored_size, truncated, is_text, content FROM bodies WHERE id = ?`).get(bodyId);
|
|
1114
|
+
if (!row) return empty("Body reference is set but the payload row is missing.");
|
|
1115
|
+
const truncated = row.truncated === 1;
|
|
1116
|
+
const stored = { ...base, present: true, size: row.size, truncated };
|
|
1117
|
+
const binary = (note) => ({ ...stored, kind: "binary", note });
|
|
1118
|
+
const mime = imageMime(contentType);
|
|
1119
|
+
if (mime) {
|
|
1120
|
+
if (truncated) {
|
|
1121
|
+
return binary(
|
|
1122
|
+
`Image truncated at ${row.stored_size} of ${row.size} bytes \u2014 too incomplete to draw.`
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
if (row.stored_size > MAX_INLINE_IMAGE_BYTES) {
|
|
1126
|
+
return binary(`Image is ${row.stored_size} bytes \u2014 too large to inline.`);
|
|
1127
|
+
}
|
|
1128
|
+
return {
|
|
1129
|
+
...stored,
|
|
1130
|
+
kind: "image",
|
|
1131
|
+
// The source of an SVG is readable, and worth offering alongside it.
|
|
1132
|
+
text: row.is_text === 1 ? row.content.toString("utf8") : null,
|
|
1133
|
+
dataUrl: `data:${mime};base64,${row.content.toString("base64")}`,
|
|
1134
|
+
note: null
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
if (row.is_text !== 1) return binary(`Binary body \u2014 ${row.stored_size} bytes stored, not shown.`);
|
|
1138
|
+
const text = row.content.toString("utf8");
|
|
1139
|
+
if (contentType && /json/i.test(contentType)) {
|
|
1140
|
+
try {
|
|
1141
|
+
const json = JSON.parse(text);
|
|
1142
|
+
if (json !== null && typeof json === "object") {
|
|
1143
|
+
return { ...stored, kind: "json", json, note: null };
|
|
1144
|
+
}
|
|
1145
|
+
} catch {
|
|
1146
|
+
return {
|
|
1147
|
+
...stored,
|
|
1148
|
+
kind: "text",
|
|
1149
|
+
text,
|
|
1150
|
+
note: "Content-Type is JSON but the body did not parse; shown as-is."
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return { ...stored, kind: "text", text, note: null };
|
|
1155
|
+
}
|
|
1156
|
+
function getRunAnalytics(db, runId) {
|
|
1157
|
+
const quantiles = db.prepare(
|
|
1158
|
+
`WITH d AS (
|
|
1159
|
+
SELECT duration_ms, ROW_NUMBER() OVER (ORDER BY duration_ms) AS rn
|
|
1160
|
+
FROM requests WHERE run_id = @run AND kind = 'http'
|
|
1161
|
+
), c AS (SELECT COUNT(*) AS n FROM d)
|
|
1162
|
+
SELECT
|
|
1163
|
+
(SELECT COUNT(*) FROM requests WHERE run_id = @run) AS total,
|
|
1164
|
+
(SELECT n FROM c) AS latency_samples,
|
|
1165
|
+
(SELECT duration_ms FROM d
|
|
1166
|
+
WHERE rn = MAX(1, CAST((SELECT n FROM c) * 0.50 AS INTEGER))) AS p50,
|
|
1167
|
+
(SELECT duration_ms FROM d
|
|
1168
|
+
WHERE rn = MAX(1, CAST((SELECT n FROM c) * 0.95 AS INTEGER))) AS p95,
|
|
1169
|
+
(SELECT duration_ms FROM d
|
|
1170
|
+
WHERE rn = MAX(1, CAST((SELECT n FROM c) * 0.99 AS INTEGER))) AS p99`
|
|
1171
|
+
).get({ run: runId });
|
|
1172
|
+
const statusMix = db.prepare(
|
|
1173
|
+
`SELECT CASE
|
|
1174
|
+
WHEN status IS NULL THEN 'error'
|
|
1175
|
+
ELSE CAST(status / 100 AS TEXT) || 'xx'
|
|
1176
|
+
END AS status_class,
|
|
1177
|
+
COUNT(*) AS count
|
|
1178
|
+
FROM requests WHERE run_id = ?
|
|
1179
|
+
GROUP BY status_class ORDER BY status_class`
|
|
1180
|
+
).all(runId);
|
|
1181
|
+
const slowest = db.prepare(
|
|
1182
|
+
`SELECT id, method, host, path, duration_ms FROM v_requests
|
|
1183
|
+
WHERE run_id = ? AND kind = 'http' ORDER BY duration_ms DESC LIMIT 5`
|
|
1184
|
+
).all(runId);
|
|
1185
|
+
const byHost = db.prepare(
|
|
1186
|
+
`WITH d AS (
|
|
1187
|
+
SELECT host_id, duration_ms, status, error_name,
|
|
1188
|
+
ROW_NUMBER() OVER (PARTITION BY host_id ORDER BY duration_ms) AS rn,
|
|
1189
|
+
COUNT(*) OVER (PARTITION BY host_id) AS n
|
|
1190
|
+
FROM requests WHERE run_id = @run AND kind = 'http'
|
|
1191
|
+
)
|
|
1192
|
+
SELECT h.host AS host,
|
|
1193
|
+
MAX(d.n) AS count,
|
|
1194
|
+
MAX(CASE WHEN d.rn = MAX(1, CAST(d.n * 0.95 AS INTEGER))
|
|
1195
|
+
THEN d.duration_ms END) AS p95,
|
|
1196
|
+
SUM(CASE WHEN d.error_name IS NOT NULL OR d.status >= 400
|
|
1197
|
+
THEN 1 ELSE 0 END) AS errors
|
|
1198
|
+
FROM d JOIN hosts h ON h.id = d.host_id
|
|
1199
|
+
GROUP BY d.host_id ORDER BY count DESC`
|
|
1200
|
+
).all({ run: runId });
|
|
1201
|
+
return {
|
|
1202
|
+
total: quantiles.total,
|
|
1203
|
+
latencySamples: quantiles.latency_samples,
|
|
1204
|
+
statusMix,
|
|
1205
|
+
p50: quantiles.latency_samples > 0 ? quantiles.p50 : null,
|
|
1206
|
+
p95: quantiles.latency_samples > 0 ? quantiles.p95 : null,
|
|
1207
|
+
p99: quantiles.latency_samples > 0 ? quantiles.p99 : null,
|
|
1208
|
+
slowest,
|
|
1209
|
+
byHost
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
export {
|
|
1213
|
+
ENTITIES,
|
|
1214
|
+
PREVIEW_CHARS,
|
|
1215
|
+
RunWriter,
|
|
1216
|
+
SCHEMA_VERSION,
|
|
1217
|
+
UnknownFieldError,
|
|
1218
|
+
describeRelation,
|
|
1219
|
+
entityFields,
|
|
1220
|
+
findEntity,
|
|
1221
|
+
getRequestDetail,
|
|
1222
|
+
getRun,
|
|
1223
|
+
getRunAnalytics,
|
|
1224
|
+
listChecks,
|
|
1225
|
+
listMessages,
|
|
1226
|
+
listRelations,
|
|
1227
|
+
listRequests,
|
|
1228
|
+
listRunTags,
|
|
1229
|
+
listRuns,
|
|
1230
|
+
listRunsForEntry,
|
|
1231
|
+
listScripts,
|
|
1232
|
+
openDb,
|
|
1233
|
+
relationExists,
|
|
1234
|
+
resolveDbPath,
|
|
1235
|
+
selectEntity,
|
|
1236
|
+
untypedColumns
|
|
1237
|
+
};
|