@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/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
+ };