@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 ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Luka Vidakovic
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # @apisurf/wire-db
2
+
3
+ The SQLite store behind [`@apisurf/wire`](https://www.npmjs.com/package/@apisurf/wire):
4
+ schema, migrations, the run writer and the read queries.
5
+
6
+ **You probably want `@apisurf/wire` instead.** That package is the `wire` CLI —
7
+ it records a script's HTTP traffic and gives you `wire ls`, `wire get` and
8
+ `wire sql` to read it back. This one is the library underneath, published so
9
+ that anything else reading a `wire.sqlite` file agrees with the CLI about what
10
+ is in it rather than re-deriving the schema.
11
+
12
+ ```bash
13
+ pnpm add @apisurf/wire-db
14
+ ```
15
+
16
+ Needs Node >= 20. `better-sqlite3` is a native addon, so installing this
17
+ compiles or downloads a prebuilt binary.
18
+
19
+ ## Opening a database
20
+
21
+ ```ts
22
+ import { openDb, resolveDbPath } from "@apisurf/wire-db";
23
+
24
+ const db = openDb({ path: "./wire.sqlite" }); // created and migrated
25
+ const reader = openDb({ path: "./wire.sqlite", mustExist: true, skipMigrations: true });
26
+ ```
27
+
28
+ `openDb` returns a `better-sqlite3` `Database`. It creates the file and brings
29
+ it up to the current schema version unless you opt out:
30
+
31
+ | Option | |
32
+ | ---------------- | -------------------------------------------------------------------------- |
33
+ | `path` | Database file. Relative paths resolve against `cwd`. Default `wire.sqlite` |
34
+ | `cwd` | Base for a relative `path`. Default `process.cwd()` |
35
+ | `mustExist` | Throw instead of creating the file |
36
+ | `skipMigrations` | Do not migrate — for readers that expect an already-migrated file |
37
+
38
+ It sets WAL, `synchronous=NORMAL`, a 64 MiB page cache, memory temp store and
39
+ foreign keys. WAL is what lets a reader open the file while a run is still
40
+ writing to it.
41
+
42
+ `resolveDbPath` applies the same path rules without opening anything.
43
+
44
+ ## Reading
45
+
46
+ Query the views, not the base tables: `v_runs`, `v_requests`, `v_headers`,
47
+ `v_bodies`, `v_messages`, `v_checks`, plus the `tags` table and the `body_fts`
48
+ full-text index.
49
+
50
+ The query helpers are the statements the CLI and the UI share:
51
+
52
+ ```ts
53
+ import { openDb, listRuns, listRequests, getRequestDetail } from "@apisurf/wire-db";
54
+
55
+ const db = openDb({ mustExist: true, skipMigrations: true });
56
+ const runs = listRuns(db, 20); // limit defaults to 200
57
+ const requests = listRequests(db, runs[0].id);
58
+ const detail = getRequestDetail(db, requests[0].id);
59
+ ```
60
+
61
+ `getRun`, `getRunAnalytics`, `getRequestDetail`, `listRuns`, `listRunsForEntry`,
62
+ `listRequests`, `listMessages`, `listChecks`, `listRunTags`, `listScripts`.
63
+
64
+ ### Projected reads
65
+
66
+ `ENTITIES` is the entity map behind `wire get` and `wire ls` — six entities
67
+ (`run`, `request`, `check`, `body`, `message`, `header`), each with the view it
68
+ reads, the column it keys on, its default field set and the filters it accepts.
69
+ `selectEntity` runs a projected read against one; `findEntity` resolves
70
+ `request`, `requests` or `v_requests` to the same definition.
71
+
72
+ ```ts
73
+ import { openDb, findEntity, selectEntity } from "@apisurf/wire-db";
74
+
75
+ const entity = findEntity("requests");
76
+ const rows = selectEntity(db, entity, {
77
+ fields: entity.defaultFields,
78
+ filters: { run: 7 }, // filter names, not columns — validated against entity.filters
79
+ limit: 20,
80
+ });
81
+ ```
82
+
83
+ A field name the entity does not have throws `UnknownFieldError`, which carries
84
+ the entity and the valid names. `PREVIEW_CHARS` is the width of the bounded
85
+ `preview` field that stands in for a payload.
86
+
87
+ ### Describing the file
88
+
89
+ `listRelations`, `relationExists`, `describeRelation`, `entityFields` and
90
+ `untypedColumns` read the schema out of the file at runtime — this is what
91
+ `wire schema` prints, so it describes the database in hand rather than the
92
+ schema this build was compiled against.
93
+
94
+ ## Writing
95
+
96
+ `RunWriter` is the write path: one instance per run, batched inserts, interned
97
+ strings and content-addressed payloads.
98
+
99
+ ```ts
100
+ import { openDb, RunWriter } from "@apisurf/wire-db";
101
+
102
+ const writer = new RunWriter(db, {
103
+ uid: randomUUID(),
104
+ label: "nightly",
105
+ entry: "/abs/path/to/flow.ts",
106
+ cwd: process.cwd(),
107
+ nodeVersion: process.versions.node,
108
+ startedAt: Date.now(),
109
+ });
110
+
111
+ writer.write(records); // captured requests, messages and headers
112
+ writer.writeChecks(checks); // check() / assert() outcomes
113
+ writer.finish({ finishedAt: Date.now(), exitStatus: "ok" });
114
+ ```
115
+
116
+ Payloads are deduped by SHA-256, so the same response body recorded a hundred
117
+ times is stored once. Text payloads are indexed into `body_fts`.
118
+
119
+ ## Schema version
120
+
121
+ ```ts
122
+ import { SCHEMA_VERSION } from "@apisurf/wire-db";
123
+ ```
124
+
125
+ `SCHEMA_VERSION` is the migration count this build writes, and it is what
126
+ `wire --version` reports. A file written by a newer build refuses to open on an
127
+ older one — comparing these two numbers is the explanation for that error.
128
+
129
+ Migrations are forward-only and applied in order on open.
130
+
131
+ ## License
132
+
133
+ ISC. Full documentation: <https://github.com/apisurf/wire#readme>
@@ -0,0 +1,547 @@
1
+ import Database from 'better-sqlite3';
2
+ import { MessageDirection, CapturedKind, HeaderPair, CapturedRecord } from './types.js';
3
+ export { CapturedBody, CapturedKind, CapturedMessage, CapturedRecord, CapturedRequest, CapturedTimings, HeaderPair, MessageDirection } from './types.js';
4
+
5
+ type Db = Database.Database;
6
+ interface OpenOptions {
7
+ /** Path to the database file. Relative paths resolve against `cwd`. */
8
+ path?: string;
9
+ cwd?: string;
10
+ /** Fail instead of creating the file when it does not exist. */
11
+ mustExist?: boolean;
12
+ /** Skip migrations (readers that expect an already-migrated file). */
13
+ skipMigrations?: boolean;
14
+ }
15
+ declare function resolveDbPath(options?: OpenOptions): string;
16
+ /**
17
+ * Open (and by default create + migrate) the local database.
18
+ *
19
+ * The pragmas below are the difference between "a SQLite file" and "a SQLite
20
+ * file that keeps up with a script hammering an API":
21
+ * - WAL lets the web UI read while a run is still writing.
22
+ * - synchronous=NORMAL skips an fsync per commit. On a crash we can lose the
23
+ * last transaction; for captured dev traffic that trade is obviously right.
24
+ * - a 64 MiB page cache and memory temp storage keep sorts and the FTS merge
25
+ * off disk.
26
+ */
27
+ declare function openDb(options?: OpenOptions): Db;
28
+
29
+ /** Schema version this build writes. Equals the number of migrations. */
30
+ declare const SCHEMA_VERSION: number;
31
+
32
+ /**
33
+ * The named surface behind `wire schema`, `wire get` and `wire ls`.
34
+ *
35
+ * `wire sql` is the complete interface and always will be, but it asks the
36
+ * caller to already know that `v_requests` exists, that the column is
37
+ * `duration_ms` and not `duration`, and that a payload lives behind
38
+ * `response_body_id`. This module is the smaller surface that answers those
39
+ * questions instead of assuming them: a fixed set of entities, each mapped to a
40
+ * view, each with a curated default field set.
41
+ *
42
+ * Two rules shape everything here.
43
+ *
44
+ * **The schema is read from the database, never from a list kept alongside it.**
45
+ * The real schema is a set of embedded migration strings, and any second
46
+ * description of it rots the first time a migration lands. Column names and
47
+ * types come from `pragma_table_info` at runtime. The only hand-maintained
48
+ * facts are one prose line per view and the type map below.
49
+ *
50
+ * **No field is ever selected that the caller did not ask for.** That is a
51
+ * context-window rule first — an agent must not be handed a 2 MB body it did
52
+ * not request — but it is also a query-cost rule. The views are not
53
+ * materialized, and `v_runs`'s counters are correlated subqueries; SQLite
54
+ * prunes the ones a projection never references, so a narrow field list is
55
+ * measurably cheaper (~160 ms vs ~1 ms across 5k runs). Projection is the
56
+ * mechanism for both.
57
+ */
58
+
59
+ type Row = Record<string, unknown>;
60
+ interface ColumnInfo {
61
+ name: string;
62
+ /** SQLite's declared type, or the one from {@link COMPUTED_TYPES}. */
63
+ type: string;
64
+ /**
65
+ * True when the type came from the map below rather than from SQLite —
66
+ * i.e. the column is an expression in the view and has no origin column.
67
+ */
68
+ computed: boolean;
69
+ }
70
+ interface RelationInfo {
71
+ name: string;
72
+ type: "view" | "table";
73
+ description: string | null;
74
+ }
75
+ /**
76
+ * Every relation worth querying.
77
+ *
78
+ * Views only by default, because that is the documented surface and the raw
79
+ * tables store interned ids rather than the strings anyone wants to read.
80
+ * `all` adds the base tables — minus FTS5's shadow tables, which are storage
81
+ * for `body_fts` and not something to query directly.
82
+ */
83
+ declare function listRelations(db: Db, options?: {
84
+ all?: boolean;
85
+ }): RelationInfo[];
86
+ /** True when a relation of that name exists. Guards every interpolation below. */
87
+ declare function relationExists(db: Db, name: string): boolean;
88
+ /**
89
+ * Columns of a view or table, with types.
90
+ *
91
+ * Uses the `pragma_table_info` table-valued function rather than the `PRAGMA`
92
+ * statement so the relation name is a bound parameter and never string
93
+ * interpolation.
94
+ */
95
+ declare function describeRelation(db: Db, name: string): ColumnInfo[];
96
+ /**
97
+ * Every computed column that has no entry in {@link COMPUTED_TYPES}.
98
+ *
99
+ * Exported for the test that keeps the map honest: a migration that adds an
100
+ * expression column to a view should fail the suite, not ship an untyped
101
+ * column into `wire schema`.
102
+ */
103
+ declare function untypedColumns(db: Db): string[];
104
+ interface EntityDef {
105
+ /** Singular name, as typed: `wire get request 42`. */
106
+ readonly name: string;
107
+ /** Plural, as typed: `wire ls requests`. */
108
+ readonly plural: string;
109
+ readonly view: string;
110
+ /** Column `get <entity> <id>` matches on. Not always unique — see `header`. */
111
+ readonly key: string;
112
+ readonly defaultFields: readonly string[];
113
+ readonly description: string;
114
+ /** `--run` / `--request` narrowing for `ls`, mapped to real columns. */
115
+ readonly filters: Readonly<Record<string, string>>;
116
+ /** Default ORDER BY for `ls`. */
117
+ readonly order: string;
118
+ /**
119
+ * Fields computed in this module rather than selected from the view, mapped
120
+ * to the column they derive from. They are not columns and `wire schema` does
121
+ * not report them as such.
122
+ */
123
+ readonly derived: Readonly<Record<string, string>>;
124
+ }
125
+ /**
126
+ * Default field sets are the whole feature: defaults are what an agent
127
+ * actually uses, so what is left out matters more than what is in.
128
+ *
129
+ * Two exclusions are deliberate. No default set contains a payload column
130
+ * (`text`) — a body is reached through `preview`, or by asking for `text` by
131
+ * name. And `run` carries only `request_count` of the six counters on `v_runs`,
132
+ * so the common listing costs one correlated subquery per row instead of six.
133
+ */
134
+ declare const ENTITIES: readonly EntityDef[];
135
+ /** Resolve `request`, `requests`, or `v_requests` to one entity. */
136
+ declare function findEntity(name: string): EntityDef | null;
137
+ /** Every field name `--fields` accepts for an entity: real columns, then derived. */
138
+ declare function entityFields(db: Db, entity: EntityDef): string[];
139
+ declare class UnknownFieldError extends Error {
140
+ readonly entity: EntityDef;
141
+ readonly field: string;
142
+ readonly available: string[];
143
+ constructor(entity: EntityDef, field: string, available: string[]);
144
+ }
145
+ /** How many characters of a payload a derived `preview` keeps. */
146
+ declare const PREVIEW_CHARS = 120;
147
+ interface SelectOptions {
148
+ fields: readonly string[];
149
+ /** `get`: the ids to match on {@link EntityDef.key}. */
150
+ ids?: readonly (number | string)[];
151
+ /** `ls`: `{run: 7}` etc., validated against {@link EntityDef.filters}. */
152
+ filters?: Readonly<Record<string, number>>;
153
+ limit?: number;
154
+ }
155
+ /**
156
+ * Read an entity, selecting only the requested fields.
157
+ *
158
+ * Every identifier reaching SQL is first matched against the live column list,
159
+ * so a field name is either a column that exists or an error — there is no path
160
+ * from user input to interpolated SQL. Values are always bound.
161
+ */
162
+ declare function selectEntity(db: Db, entity: EntityDef, options: SelectOptions): Row[];
163
+
164
+ /**
165
+ * The shapes that cross the package boundaries.
166
+ *
167
+ * The WRITE contract (`Captured*`, `HeaderPair`) belongs to @wire/capture, which
168
+ * produces it; {@link import("./writer.js").RunWriter} persists it. It is
169
+ * re-exported here so a consumer that only stores traffic never has to name the
170
+ * capture package. Everything below is the READ contract, consumed by the CLI
171
+ * and the web UI.
172
+ */
173
+
174
+ /** Everything known about a run at the moment it starts. */
175
+ interface RunStart {
176
+ uid: string;
177
+ label: string | null;
178
+ /** Absolute path of the executed file, or `<inline>` for `execute -e`. */
179
+ entry: string;
180
+ /** The snippet, when the run had no file behind it. */
181
+ entrySource?: string | null;
182
+ cwd: string;
183
+ /** Environment the run was made against, when one was selected. */
184
+ env?: string | null;
185
+ nodeVersion: string;
186
+ startedAt: number;
187
+ }
188
+ /** How a run ended. */
189
+ interface RunEnd {
190
+ finishedAt: number;
191
+ exitStatus: "ok" | "error";
192
+ errorName?: string | null;
193
+ errorMessage?: string | null;
194
+ }
195
+ /**
196
+ * Which of the two check functions made a judgement.
197
+ *
198
+ * `check` records and returns; `assert` records and throws, so an `assert` row
199
+ * with `status = 'fail'` is always the last check of its run.
200
+ */
201
+ type CheckMode = "check" | "assert";
202
+ type CheckStatus = "pass" | "fail";
203
+ /** One assertion, as handed to {@link import("./writer.js").RunWriter.writeChecks}. */
204
+ interface CheckRecord {
205
+ /** What was being checked, in the author's words. */
206
+ name: string;
207
+ mode: CheckMode;
208
+ status: CheckStatus;
209
+ /** Epoch ms at the moment the check ran. */
210
+ ts: number;
211
+ /**
212
+ * The author's failure message. Only meaningful on a failure, and only when
213
+ * one was passed — it is stored as NULL otherwise.
214
+ */
215
+ message?: string | null;
216
+ }
217
+ /** A row of `v_runs`. */
218
+ interface RunRow {
219
+ id: number;
220
+ uid: string;
221
+ label: string | null;
222
+ entry: string;
223
+ entry_source: string | null;
224
+ cwd: string;
225
+ env: string | null;
226
+ node_version: string;
227
+ started_at: number;
228
+ finished_at: number | null;
229
+ exit_status: string | null;
230
+ error_name: string | null;
231
+ error_message: string | null;
232
+ request_count: number;
233
+ error_count: number;
234
+ /** Requests that were WebSocket or SSE connections rather than plain HTTP. */
235
+ connection_count: number;
236
+ /** Frames and events across every connection in the run. */
237
+ message_count: number;
238
+ /** Assertions the run made, passing and failing. */
239
+ check_count: number;
240
+ failed_check_count: number;
241
+ total_duration_ms: number | null;
242
+ total_bytes: number | null;
243
+ }
244
+ /** A row of `v_checks`: one assertion the run made. */
245
+ interface CheckRow {
246
+ id: number;
247
+ run_id: number;
248
+ /** Order within the run, from 0. */
249
+ seq: number;
250
+ ts: number;
251
+ name: string;
252
+ mode: CheckMode;
253
+ status: CheckStatus;
254
+ /** The author's message. Null on a pass, and null when none was given. */
255
+ message: string | null;
256
+ }
257
+ /**
258
+ * One executed file, folded across every run of it.
259
+ *
260
+ * `entry` is the identity: the absolute path of the script that was executed.
261
+ * A label can change between runs (`--label`), the path cannot, so grouping on
262
+ * it is the only grouping that stays stable.
263
+ */
264
+ interface ScriptRow {
265
+ entry: string;
266
+ /** Working directory of the most recent run. */
267
+ cwd: string;
268
+ /** Label of the most recent run. */
269
+ label: string | null;
270
+ run_count: number;
271
+ /** Runs that ended with `exit_status = 'error'`. */
272
+ failed_run_count: number;
273
+ /** The most recent run, so a script row can link straight to it. */
274
+ last_run_id: number;
275
+ last_started_at: number;
276
+ last_exit_status: string | null;
277
+ /**
278
+ * Totals across every run of this script. `request_count` counts connections
279
+ * too, exactly as `v_runs` does — subtract `connection_count` for the plain
280
+ * HTTP calls, the way the CLI summary reports them.
281
+ */
282
+ request_count: number;
283
+ connection_count: number;
284
+ message_count: number;
285
+ error_count: number;
286
+ /** Assertions across every run of this script, and how many of them failed. */
287
+ check_count: number;
288
+ failed_check_count: number;
289
+ total_bytes: number | null;
290
+ }
291
+ /** The columns of `v_requests` needed for the request list. */
292
+ interface RequestListRow {
293
+ id: number;
294
+ seq: number;
295
+ ts: number;
296
+ /** `http`, `ws` or `sse`. */
297
+ kind: CapturedKind;
298
+ /** Frames or events on this connection; 0 for plain HTTP. */
299
+ message_count: number;
300
+ method: string;
301
+ host: string;
302
+ path: string;
303
+ status: number | null;
304
+ ok: number | null;
305
+ error_name: string | null;
306
+ duration_ms: number;
307
+ request_bytes: number;
308
+ response_bytes: number;
309
+ }
310
+ /** A full row of `v_requests`. */
311
+ interface RequestRow extends RequestListRow {
312
+ run_id: number;
313
+ query: string | null;
314
+ url: string;
315
+ error_message: string | null;
316
+ /** Connection phases. All null on a reused connection — nothing was set up. */
317
+ dns_ms: number | null;
318
+ connect_ms: number | null;
319
+ tls_ms: number | null;
320
+ ttfb_ms: number | null;
321
+ download_ms: number | null;
322
+ request_content_type: string | null;
323
+ response_content_type: string | null;
324
+ request_body_id: number | null;
325
+ response_body_id: number | null;
326
+ }
327
+ /**
328
+ * How a payload should be drawn.
329
+ *
330
+ * Decided here rather than in the UI, because the decision needs the raw bytes
331
+ * and the `is_text` flag — neither of which survives the trip to a component.
332
+ */
333
+ type BodyKind = "empty" | "text" | "json" | "image" | "binary";
334
+ /** Anything `JSON.parse` can return — spelled out, so a `BodyView` stays serializable. */
335
+ type JsonValue = string | number | boolean | null | JsonValue[] | {
336
+ [key: string]: JsonValue;
337
+ };
338
+ /** A payload shaped for display. */
339
+ interface BodyView {
340
+ kind: BodyKind;
341
+ present: boolean;
342
+ /**
343
+ * The payload decoded. Null for anything not readable as text — and for JSON,
344
+ * which travels parsed, in `json`, rather than as text as well.
345
+ */
346
+ text: string | null;
347
+ /** The parsed payload when `kind` is `"json"`, else null. Always an object or an array. */
348
+ json: JsonValue | null;
349
+ /** A `data:` URL when `kind` is `"image"`, else null. */
350
+ dataUrl: string | null;
351
+ contentType: string | null;
352
+ size: number | null;
353
+ truncated: boolean;
354
+ /** Set when there is nothing to show and the reason is worth stating. */
355
+ note: string | null;
356
+ }
357
+ interface RequestDetail {
358
+ request: RequestRow;
359
+ requestHeaders: HeaderPair[];
360
+ responseHeaders: HeaderPair[];
361
+ requestBody: BodyView;
362
+ responseBody: BodyView;
363
+ tags: {
364
+ key: string;
365
+ value: string;
366
+ }[];
367
+ /** Frames and events, in order. Empty for plain HTTP. */
368
+ messages: MessageRow[];
369
+ }
370
+ /**
371
+ * A row of `v_messages`: one WebSocket frame or SSE event.
372
+ *
373
+ * `text` is the payload decoded, or null when it was binary or absent —
374
+ * `bytes` still says how big it was.
375
+ */
376
+ interface MessageRow {
377
+ id: number;
378
+ request_id: number;
379
+ seq: number;
380
+ ts: number;
381
+ direction: MessageDirection;
382
+ /** `text` | `binary` | `close` for WebSockets; the event name for SSE. */
383
+ name: string;
384
+ /** WebSocket close code. */
385
+ code: number | null;
386
+ /** SSE event id. */
387
+ event_id: string | null;
388
+ bytes: number;
389
+ body_id: number | null;
390
+ truncated: number | null;
391
+ text: string | null;
392
+ }
393
+ interface StatusMixRow {
394
+ status_class: string;
395
+ count: number;
396
+ }
397
+ interface RunAnalytics {
398
+ total: number;
399
+ /** How many rows the latency figures cover — HTTP requests, not connections. */
400
+ latencySamples: number;
401
+ statusMix: StatusMixRow[];
402
+ p50: number | null;
403
+ p95: number | null;
404
+ p99: number | null;
405
+ slowest: {
406
+ id: number;
407
+ method: string;
408
+ host: string;
409
+ path: string;
410
+ duration_ms: number;
411
+ }[];
412
+ byHost: {
413
+ host: string;
414
+ count: number;
415
+ p95: number | null;
416
+ errors: number;
417
+ }[];
418
+ }
419
+
420
+ /**
421
+ * Persists one run's captures.
422
+ *
423
+ * Two things make this fast enough to sit behind a script making thousands of
424
+ * requests:
425
+ *
426
+ * 1. Every write goes through a prepared statement, and a whole batch commits
427
+ * in ONE transaction. Committing per request would fsync per request.
428
+ * 2. The dimension tables are cached in-process, so the second time a run sees
429
+ * `api.example.com` there is no SQL at all — just a Map hit.
430
+ *
431
+ * All of it is synchronous, which is a feature: capture already runs off the
432
+ * caller's request path, and a sync write means no interleaving and no
433
+ * partially-written run if the script exits abruptly.
434
+ */
435
+ declare class RunWriter {
436
+ readonly runId: number;
437
+ private readonly db;
438
+ private seq;
439
+ /** Check ordering is its own sequence: checks and requests interleave freely. */
440
+ private checkSeq;
441
+ private readonly methodIds;
442
+ private readonly hostIds;
443
+ private readonly contentTypeIds;
444
+ private readonly headerNameIds;
445
+ private readonly messageNameIds;
446
+ private readonly bodyIds;
447
+ private readonly connectionIds;
448
+ private readonly stmt;
449
+ private readonly writeBatch;
450
+ private readonly writeCheckBatch;
451
+ constructor(db: Db, run: RunStart);
452
+ /** Persist a batch of captures in a single transaction. */
453
+ write(records: CapturedRecord[]): void;
454
+ /**
455
+ * Persist a batch of assertions in a single transaction.
456
+ *
457
+ * Separate from {@link RunWriter.write} because checks are not traffic: they
458
+ * arrive from the script itself rather than from a capture, and they carry no
459
+ * bodies, headers or interning. `seq` is assigned here, in arrival order, so
460
+ * a run's checks read back in the order the script made them.
461
+ */
462
+ writeChecks(records: CheckRecord[]): void;
463
+ /** Record how the run ended. */
464
+ finish(end: RunEnd): void;
465
+ /**
466
+ * Write one exchange. For a connection this runs twice — once at open, once
467
+ * at close — and the second pass updates the row rather than adding one.
468
+ */
469
+ private insertRequest;
470
+ /** The closing pass over a connection: outcome and timings, onto the open row. */
471
+ private updateConnection;
472
+ private requestRow;
473
+ /**
474
+ * Write one frame or event against the connection that carried it.
475
+ *
476
+ * A message whose connection is unknown is dropped: records reach a sink in
477
+ * order, so the only way here is a sink that reordered them, and half a
478
+ * connection is worse than none.
479
+ */
480
+ private insertMessage;
481
+ private insertHeaders;
482
+ private intern;
483
+ private internContentType;
484
+ /**
485
+ * Store a payload once per distinct content. The SHA-256 is over the bytes as
486
+ * stored, so a run that polls the same unchanged endpoint 500 times keeps one
487
+ * copy of the response and 500 integer references to it.
488
+ */
489
+ private internBody;
490
+ }
491
+
492
+ /** Runs, newest first. */
493
+ declare function listRuns(db: Db, limit?: number): RunRow[];
494
+ declare function getRun(db: Db, id: number): RunRow | null;
495
+ /**
496
+ * Every script that has been run, one row each, most recently run first.
497
+ *
498
+ * Grouping is on `entry` — the absolute path of the executed file — because
499
+ * that is the only stable identity a script has. `--label` is per run and the
500
+ * default label is a basename, which collides the moment two folders hold a
501
+ * `sync.ts`.
502
+ *
503
+ * `label`, `id` and `exit_status` are bare columns under a GROUP BY, which
504
+ * SQLite answers from the row that produced the query's single MAX() — so they
505
+ * describe the latest run, not an arbitrary one. That is a documented SQLite
506
+ * guarantee (one min/max aggregate, bare columns come from that row), not an
507
+ * accident of the query plan.
508
+ */
509
+ declare function listScripts(db: Db): ScriptRow[];
510
+ /** Runs of one script, newest first — the sessions behind a script row. */
511
+ declare function listRunsForEntry(db: Db, entry: string, limit?: number): RunRow[];
512
+ /** Requests for a run, in capture order. */
513
+ declare function listRequests(db: Db, runId: number): RequestListRow[];
514
+ declare function getRequestDetail(db: Db, id: number): RequestDetail | null;
515
+ /**
516
+ * Every tag on a run's requests, once each.
517
+ *
518
+ * `execute --tag` attaches the same pairs to every capture in the run, so read
519
+ * back per request they are the same two or three facts repeated a hundred
520
+ * times — they describe the run, not the call. The DISTINCT is what makes this
521
+ * honest for the one case that is not uniform: a library caller can configure
522
+ * two recorders differently inside one run, and then the union is the answer.
523
+ */
524
+ declare function listRunTags(db: Db, runId: number): {
525
+ key: string;
526
+ value: string;
527
+ }[];
528
+ /** Every assertion a run made, in the order the script made them. */
529
+ declare function listChecks(db: Db, runId: number): CheckRow[];
530
+ /** Every frame or event on one connection, in the order it happened. */
531
+ declare function listMessages(db: Db, requestId: number): MessageRow[];
532
+ /**
533
+ * Per-run aggregates.
534
+ *
535
+ * SQLite has no `quantile()`, so percentiles use the nearest-rank definition:
536
+ * order the durations, take the row at ceil(q * n). One ordered CTE serves all
537
+ * three, and with `idx_requests_run_seq` narrowing to the run first the sort is
538
+ * over that run's rows only.
539
+ *
540
+ * Latency figures cover HTTP requests only. A connection's `duration_ms` is how
541
+ * long it stayed open, so letting one WebSocket sit in the same distribution
542
+ * would make "p95" mean nothing. `latencySamples` says how many rows they are
543
+ * computed over; the status mix and totals still count everything.
544
+ */
545
+ declare function getRunAnalytics(db: Db, runId: number): RunAnalytics;
546
+
547
+ export { type BodyKind, type BodyView, type CheckMode, type CheckRecord, type CheckRow, type CheckStatus, type ColumnInfo, type Db, ENTITIES, type EntityDef, type JsonValue, type MessageRow, PREVIEW_CHARS, type RelationInfo, type RequestDetail, type RequestListRow, type RequestRow, type Row, type RunAnalytics, type RunEnd, type RunRow, type RunStart, RunWriter, SCHEMA_VERSION, type ScriptRow, type SelectOptions, type StatusMixRow, UnknownFieldError, describeRelation, entityFields, findEntity, getRequestDetail, getRun, getRunAnalytics, listChecks, listMessages, listRelations, listRequests, listRunTags, listRuns, listRunsForEntry, listScripts, openDb, relationExists, resolveDbPath, selectEntity, untypedColumns };