@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/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>
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|