# Engine Database Schema Vayu uses SQLite via `sqlite_orm`. The schema is defined in `engine/src/db/database.cpp` and the struct definitions live in `engine/include/vayu/types.hpp`. `sync_schema()` adds new columns automatically on startup - no migration scripts are needed for additive changes. > **Breaking changes**: because Vayu is pre-release, destructive schema changes (column removal, > type changes) wipe the database rather than migrating it. The `PRAGMA user_version` is > not currently managed; wipe is done by deleting the `.db` file. --- ## Tables **ID format, and who owns it.** Every row ID in the database is generated by the engine, with `vayu::utils::generate_id("col_")` (see `engine/src/utils/id.cpp`) - `_` + a random UUIDv4 (lowercase `8-4-4-4-12` hex, e.g. `col_3f2b1c9a-...`). No client can supply one: `POST /collections`, `/requests` and `/environments` reject a body carrying `id` with a `400`, and `POST /import/apply` rejects a per-item `id` the same way, taking opaque `tempId`s and returning the map of the real IDs it generated (issues #96, #97). So the format is a property of the data rather than a convention clients are trusted to follow. The engine formerly built IDs from a millisecond timestamp, so two rows created in the same millisecond collided and - because persistence upserts (`storage.replace()`) - silently merged; the random UUID removes that. `globals.id` is the one exception: a fixed literal `"globals"`. ### `collections` Stores folder/group hierarchy for requests. | Column | Type | Notes | |----------------------|---------|----------------------------------------------| | `id` | TEXT PK | `col_` + UUID | | `parent_id` | TEXT | NULL for root collections | | `name` | TEXT | | | `description` | TEXT | Default `""` | | `variables` | TEXT | JSON: `Record` | | `auth` | TEXT | JSON: `RequestAuth` (never `inherit`) | | `pre_request_script` | TEXT | Default `""` | | `post_request_script`| TEXT | Default `""` | | `order` | INTEGER | Sort order within parent; default 0 | | `created_at` | INTEGER | Unix ms | | `updated_at` | INTEGER | Unix ms | **auth** is a JSON discriminated union: `{"mode":"none"}` | `{"mode":"bearer","token":"..."}` | `{"mode":"basic","username":"...","password":"..."}` | `{"mode":"apikey","key":"...","value":"...","in":"header"|"query"}` | `{"mode":"oauth2","config":{…}}` (see [`requests.auth`](#requests) and [`oauth_tokens`](#oauth_tokens)). Collections are always auth sources - they never store `{"mode":"inherit"}`. They may store `{"mode":"noauth"}`, the inheritance terminator meaning "descendants inherit no credentials" (distinct from `none`, which means nothing is set at that level); `POST /compose` resolves both during the inherit walk (`request_composer.cpp`), so neither reaches the engine's `parse_auth` - which would treat them as no auth anyway. **Cascade delete**: deleting a collection performs BFS to collect all descendant IDs, then deletes all their requests before deleting the collections deepest-first, wrapped in a single transaction so a crash mid-cascade cannot leave a half-deleted subtree. See `Database::delete_collection()`. `parent_id` forms a tree, but SQLite enforces no such constraint, so the BFS carries a visited set and is **cycle-safe**: a self-parent (`parent_id == id`) or an `A -> B -> A` loop terminates instead of growing the work list forever under the global DB mutex (which would hang every endpoint, including `/health`). `POST /collections` rejects the writes that would create such a cycle (see [api-reference.md](api-reference.md) - POST /collections), so cycles only arise from data written before that validation existed; the visited set is what lets deletion recover from it. --- ### `requests` Stores individual HTTP request definitions. | Column | Type | Notes | |-----------------------|---------|------------------------------------------------------| | `id` | TEXT PK | `req_` + UUID | | `collection_id` | TEXT | FK → `collections.id` (not enforced by SQLite FK) | | `name` | TEXT | | | `description` | TEXT | Default `""` | | `method` | TEXT | `GET` / `POST` / `PUT` / `PATCH` / `DELETE` / etc. | | `url` | TEXT | | | `params` | TEXT | JSON array of `KeyValueEntry[]` | | `headers` | TEXT | JSON array of `KeyValueEntry[]` | | `body` | TEXT | JSON discriminated union (see below) | | `body_type` | TEXT | Denormalized mirror of `body.mode`; kept for queries | | `auth` | TEXT | JSON discriminated union (see below) | | `pre_request_script` | TEXT | Default `""` | | `post_request_script` | TEXT | Default `""` | | `order` | INTEGER | Sort order within collection; default 0 | | `follow_redirects` | INTEGER | Boolean; default 1 (follow) | | `max_redirects` | INTEGER | Hops allowed while following; default 10 | | `http_version` | TEXT | `'auto'` \| `'http1.1'` \| `'http2'`; default `'auto'` | | `created_at` | INTEGER | Unix ms | | `updated_at` | INTEGER | Unix ms | **params / headers** - stored as a JSON array of objects: ```json [{"key":"Content-Type","value":"application/json","enabled":true,"description":""}] ``` Disabled rows (`"enabled":false`) are preserved in storage and filtered at HTTP-execution time only. Duplicate keys are allowed. **body** - discriminated union: ```json {"mode":"none"} {"mode":"json"|"text"|"graphql","content":"..."} {"mode":"form-data"|"x-www-form-urlencoded","fields":[{"key":"...","value":"...","enabled":true}]} ``` **auth** - discriminated union (same shape as collection auth, plus `inherit`): ```json {"mode":"none"} {"mode":"inherit"} // resolved at execution time {"mode":"bearer","token":"..."} {"mode":"basic","username":"...","password":"..."} {"mode":"apikey","key":"...","value":"...","in":"header"|"query"} {"mode":"oauth2","config":{ /* OAuth2Config */ }} ``` The `oauth2` `config` holds the grant type, endpoints, client id/secret, placement options, etc. Secret fields (`clientSecret`, `password`) are stored **in plaintext** here, same as bearer/basic credentials - the v1 posture. The resolved access tokens live separately in [`oauth_tokens`](#oauth_tokens). **follow_redirects / max_redirects / http_version** - the request's execution options, surfaced in the request builder's **Settings** tab and serialized as `followRedirects` / `maxRedirects` / `httpVersion`. They mirror the executable `vayu::Request` fields of the same name, so the saved options are what `POST /execute` and `POST /runs` apply - `http_version` governs both Send and load test alike; there is no separate per-run protocol control. All three columns are `NOT NULL` with a `DEFAULT`, which is what lets `sync_schema()` add them to an existing, non-empty `requests` table - a `NOT NULL` column with no default cannot be added by `ALTER TABLE ADD COLUMN`. Rows written before the columns existed backfill to `1` / `10` / `'auto'`, i.e. the behaviour they already had (a row predating this column could only ever have run HTTP/1.1, since nghttp2 was not yet linked). `max_redirects` is clamped to `0..100` on write. **http_version** stores `Request::http_version` (the *requested* protocol, an enum member spelled as text) - a different value and a different value space from `Response::http_version`, the *negotiated* protocol string (`"HTTP/1.1"` / `"HTTP/2"` / `""`) that lands in a design run's `trace_data` (see [`results`](#results)) and the live `/execute` response. Conflating the two would show a user a protocol they asked for but were not actually granted. On create or on an explicit `null` reset, this column seeds from the `defaultHttpVersion` [`config_entries`](#config_entries) row (`'auto'` unless changed) - a write-time default only, never consulted at execution. Changing the global afterward does not alter a request already saved. --- ### `environments` Stores named variable sets. | Column | Type | Notes | |--------------|---------|---------------------------------------| | `id` | TEXT PK | `env_` + UUID | | `name` | TEXT | | | `description`| TEXT | Default `""` | | `variables` | TEXT | JSON: `Record` | | `is_active` | INTEGER | Boolean; 0 or 1. At most one row is 1 - see below | | `created_at` | INTEGER | Unix ms | | `updated_at` | INTEGER | Unix ms | **`is_active` marks the environment clients resolve against by default, and at most one row carries it.** The invariant is enforced in the DB layer, not the routes: every write path that can store an active environment calls `Database::deactivate_other_environments_locked` first, inside the same transaction, so activating one environment deactivates the previous one atomically - no reader can observe two actives, and none can observe an intermediate zero. Three paths reach this table (`POST /environments`, `PUT /environments/:id`, and `POST /import/apply`); putting the rule in the handlers would mean repeating it in each, which is how this column previously ended up honoured on create but not on update. Writing `isActive: true` **is** the switch - there is no separate endpoint and no companion request to clear the old one. Clearing entirely is spelled as writing `isActive: false` to the environment that holds the flag, since there is no "no environment" row to write `true` to. Selecting is still a client action: the engine never *applies* an active environment to a request, which must always name its own `environmentId`. What changed is where the choice lives. Storing it here rather than in client-local state is what makes it survive a restart and a reinstall, and what lets two clients on the same database agree - the app mirrors it into `session-store.ts` for synchronous reads and reconciles on launch (`useActiveEnvironmentRestore`), treating the engine's value as the truth. --- ### `globals` Singleton table; always has exactly one row with `id = "globals"`. | Column | Type | Notes | |--------------|---------|---------------------------------------| | `id` | TEXT PK | Always `"globals"` | | `variables` | TEXT | JSON: `Record` | | `updated_at` | INTEGER | Unix ms | --- ### `runs` Stores design-mode and load-test run records. Defined in `database.cpp` (`make_table("runs", …)`); struct is `db::Run` in `engine/include/vayu/types.hpp`. | Column | Type | Notes | |-------------------|---------|-------------------------------------------------------------| | `id` | TEXT PK | `run_` + UUID | | `request_id` | TEXT | FK → `requests.id` (optional; set in design mode) | | `environment_id` | TEXT | FK → `environments.id` (optional) | | `type` | TEXT | `"design"` or `"load"` | | `status` | TEXT | `"pending"` / `"running"` / `"completed"` / `"failed"` / `"stopped"` | | `config_snapshot` | TEXT | JSON snapshot of the request/env at run time | | `start_time` | INTEGER | Unix ms | | `end_time` | INTEGER | Unix ms; `0` = no end recorded (readers guard on `> 0`) | | `summary` | TEXT | JSON: whole-run results, written once at terminal status (`""` = not written) | **`end_time`** is stamped on every terminal status write (`update_run_status`), and refined mid-run by `update_run_end_time` when a load run finishes generating. Both inserts also *seed* it to `start_time` up front (`seed_run_times`, `http/routes/execution.cpp`), because a run killed by a daemon crash never reaches a terminal status: `reconcile_orphaned_runs` marks it failed and leaves `end_time` as recorded, so an unseeded row would report a duration spanning however long the daemon was down. `db::Run::end_time` defaults to `0` as the backstop for a future insert site that forgets to seed - `0` is the "no end recorded" sentinel, and readers (`GET /runs/:runId/report`, the app's dashboard) guard on `> 0`. **`summary`** holds the aggregates `GET /runs/:runId/report` used to rebuild by scanning every metric row of the run: totals, the cumulative latency percentiles, the status-code distribution, bytes, and the script-validation tallies. It is written once - by `run_manager.cpp` when the run reaches `completed`/`stopped`, and best-effort (minus `setup_overhead`, with a wall-clock `test_duration`) when it fails. `""` means the engine died before the run reached a terminal status, and the report then stands on the run's sampled `results` alone. NOT NULL with a `""` default, so `sync_schema()` can `ALTER TABLE ADD COLUMN` it onto an existing table (same pattern as `requests.follow_redirects`). ```json { "total_requests": 100, "rps": 50.0, "send_rate": 51.0, "throughput": 49.5, "test_duration": 2.0, "setup_overhead": 0.25, "peak_concurrency": 8, "dropped_requests": 2, "queue_wait_avg": 1.5, "bytes_sent": 1024, "bytes_received": 8192, "status_codes": { "200": 90, "500": 7, "0": 3 }, "latency": { "min": 1.0, "max": 90.0, "avg": 12.5, "p50": 10.0, "p75": 15.0, "p90": 20.0, "p95": 25.0, "p99": 30.0, "p999": 35.0 }, "tests": { "sampled": 10, "passed": 9, "failed": 1 } } ``` `tests` is **omitted** when deferred script validation did not run, which is what keeps the report's `testValidation` section absent rather than reporting zero tests. The writer is `vayu::core::build_run_summary_payload` and the reader is `apply_run_summary` (`http/routes/runs.cpp`); `runs_route_test.cpp` round-trips the pair, so the key names cannot drift apart silently. Do not confuse this **results** summary with the `summary` key on a `GET /runs` list row - that one is a derived view of `config_snapshot` (url/method/mode/duration/concurrency/comment) built per request and never stored. **`config_snapshot` redaction** - the snapshot is the raw run payload, which can carry auth credentials. Before persistence, its top-level `auth` object is reduced to just `{"mode": "..."}` (via `sanitize_config_snapshot` in `utils/json.cpp`) - an allowlist, so no current or future auth field (`clientSecret`, `password`, tokens) leaks into a stored run. **Retention** - runs are append-only in normal use (every design-mode click adds a `runs` row, every load run its `metric_ticks`/`results`), so `Database::prune_runs(max_runs, max_age_days)` trims the history. A run is a victim when it falls **beyond the `maxRunsRetained` most-recent runs** (ordered by `start_time`) **or** its `start_time` is older than **`runRetentionDays`** days; either knob is disabled by `0`. Runs still `running`/`pending` are never pruned and never count toward the cap. Deletion goes through the `delete_run` cascade (runs + their `metric_ticks` + their `results`), batched inside transactions that release the DB mutex between batches so a large backlog cannot stall `/health`, SSE, or the runs poll. The cascade itself lives in one function (`remove_run_cascade_locked`), which both `delete_run` and `prune_runs` call, so a new child table is wired into both at once. `prune_runs_configured()` reads the two knobs (config, `observability`, defaults 200 / 30) and runs at **startup** (`Database::init`) and after a run reaches a **terminal** status (design mode's `store_result`, and the load-run completion/failure paths in `run_manager.cpp`). --- ### `oauth_tokens` Cached OAuth 2.0 access/refresh tokens, keyed by config identity. Written by the token client (`engine/src/http/oauth_client.cpp`); struct is `db::OAuthToken`. Auto-created by `sync_schema()`. | Column | Type | Notes | |-----------------|---------|-------------------------------------------------------------------| | `cache_key` | TEXT PK | `accessTokenUrl \x1f clientId \x1f credentialsId \x1f username?` - byte-identical to the app's `computeOAuth2CacheKey` (omits scope/audience/resource) | | `access_token` | TEXT | Bearer token (plaintext at rest) | | `token_type` | TEXT | Defaults to `"Bearer"` when the provider omits it | | `refresh_token` | TEXT | `""` when none | | `scope` | TEXT | Granted scope, if returned | | `expires_in` | INTEGER | Seconds; `0` = non-expiring | | `created_at` | INTEGER | Unix ms | | `raw_response` | TEXT | Provider JSON (truncated to 4 KB); debugging only, never logged | Expiry is `now > created_at + expires_in*1000 − 45s` (skew). On refresh the `refresh_token` rotates when the provider issues a new one; a rejected refresh token clears the row and falls back to a fresh grant. There is **no** mid-run refresh. Tokens are plaintext at rest (v1 posture); the row is cleared via `DELETE /oauth2/token`. --- ### `metric_ticks` The time series for a load test: **one wide row per persisted tick** (~1/s), written by the metrics producer thread. Struct is `db::MetricTick`. Auto-created by `sync_schema()`. | Column | Type | Notes | |-------------|------------|------------------------------------------------| | `id` | INTEGER PK | Autoincrement | | `run_id` | TEXT | FK → `runs.id` | | `timestamp` | INTEGER | Unix ms - the tick's single wall-clock sample | | `payload` | TEXT | JSON: the complete tick object (below) | `payload` **is** one `data[]` entry of `GET /runs/:runId/metrics` - the app's snake_case `LoadTestMetrics` shape, built once at write time by `vayu::core::build_metric_tick_payload` instead of being reassembled per request: ```json { "timestamp": 1730000001000, "elapsed_seconds": 1.0, "requests_completed": 120, "requests_failed": 2, "current_rps": 118.4, "current_concurrency": 10, "send_rate": 120.0, "throughput": 118.4, "backpressure": 3, "error_rate": 1.66, "dropped_requests": 0, "bytes_sent": 4096, "bytes_received": 65536, "status_codes": { "200": 118, "0": 2 }, "latency_p50_ms": 8.1, "latency_p95_ms": 20.4, "latency_p99_ms": 31.9 } ``` Two consequences of the row being the tick: - **Pagination is tick-aligned.** `GET /runs/:runId/metrics` pages rows, and a row is a whole tick, so a page boundary can no longer hand back a half-populated bucket (which row-paginating the retired EAV `metrics` table did every ~277 ticks). - **`elapsed_seconds` is measured from the run's first persisted tick**, at write time, so it keeps counting across page boundaries instead of restarting at 0 on each page. Latency percentiles in a tick are the **windowed** (rolling) values sampled from the `hdr_interval_recorder` for that interval - the whole-run cumulative ones live in [`runs.summary`](#runs), never here. **The EAV `metrics` table this replaced is gone.** It stored one row per (`run_id`, `name`, `timestamp`) sample, ~20 rows per second of a run, and was kept read-only after the switch so runs recorded by an older engine still rendered. Retention deletes those runs within `runRetentionDays`, so the read path outlived its data; it was removed in issue #177, along with the `MetricName` enum and `db::Metric`. `sync_schema()` only syncs the tables the storage still declares - it never drops one that was removed from it - so `Database::init()` issues an explicit `DROP TABLE IF EXISTS metrics`, and an upgraded database sheds the table and its rows on first start. The freed pages return to SQLite's freelist for reuse; the file itself does not shrink, because a `VACUUM` would rewrite the whole database under a write lock at startup. --- ### `results` Individual request outcomes - all errors plus sampled successes (sampling is configurable in `MetricsCollector`). Struct is `db::Result`. | Column | Type | Notes | |---------------|------------|--------------------------------------------------------------| | `id` | INTEGER PK | Autoincrement | | `run_id` | TEXT | FK → `runs.id` | | `timestamp` | INTEGER | Unix ms | | `status_code` | INTEGER | HTTP status, or **0 for transport errors** (so totals reconcile) | | `status_text` | TEXT | Wire reason phrase or canonical IANA text | | `latency_ms` | REAL | **Perceived** latency (`completion − submitted_at`), not wire time | | `error` | TEXT | Error message for failures; empty on success | | `trace_data` | TEXT | JSON (headers/body/timing breakdown) - design mode + errors + slow samples | A load run's captured response headers and bodies are **not** here - they live in [`result_bodies`](#result_bodies) / [`body_blobs`](#body_blobs). That split is load-bearing: `Database::get_results` loads every row for a run with no limit and `calculate_detailed_report` JSON-parses each `trace_data` on every report fetch, which the dashboard polls. At ~200 bytes per trace that is free; with bodies inline it would be megabytes read and parsed per poll, to compute aggregates that never look at a body. `trace_data` timing keys are all in ms and carry the `Ms` suffix: `totalMs`, `wireMs`, `queueWaitMs`, `dnsMs`, `connectMs`, `tlsMs`, `firstByteMs`, `downloadMs`. `totalMs` is perceived latency; `wireMs` is libcurl's `CURLINFO_TOTAL_TIME`; `queueWaitMs = totalMs − wireMs` is time spent queued inside the generator. **The writers store different subsets, at different nesting**, so read the one you need rather than assuming all eight are there and flat: | Writer | What lands in `trace_data` | |--------|----------------------------| | Load run, success sample (`load_strategy.cpp`) | timing only, flat, all eight keys. Written for a completion the 1-in-`success_sample_rate` sampler selects (only while `save_timing_breakdown` is on), **or** one that crossed `slow_threshold_ms` (which also adds `isSlow` / `thresholdMs`, and is stored whether or not the breakdown toggle is on). The two have separate retention budgets - `max_success_results` and `max_slow_results` - and an outlier never consumes a sampling slot. | | Load run, error (`load_strategy.cpp`) | an error envelope (`error_type`, `message`, `request_number`) with the eight keys **nested under `timing`**, present whenever `totalMs > 0` | | Design mode (`store_result` in `execution.cpp`) | all eight keys flat, unconditionally - the same set the live `/execute` response carries, so a restored response shows exactly what the live one did (a skipped phase is stored as `0`). Written on **every** single request, alongside a nested `request` object plus either `response` (success) or `error_type` / `error_message` (failure). The `response` node carries `headers`, `body`, `httpVersion` - the negotiated protocol, `""` when nothing was negotiated, same convention as the live `/execute` response (see [POST /execute](api-reference.md#post-execute)) - and `httpVersionDowngraded`, true when the request asked for HTTP/2 and got something older; a row written before either field existed simply has no such key, so `restore-response.ts` must default both. Rows written by older engines omitted zero-valued phases and all of `totalMs`/`wireMs`/`queueWaitMs`, so readers must default missing keys (perceived total also lives in the `latency_ms` column). | The design-mode `request.body` and `response.body` are **capped at `maxTraceBodyBytes`** (config, `observability`, default 5 MiB) before storage, so downloading one 50 MB response does not live in SQLite forever. When a body is cut, its node gains two keys: | Key on `request` / `response` | Type | Meaning | |-------------------------------|------|---------| | `bodyTruncated` | bool | Present and `true` only when the stored `body` is a prefix, not the whole body | | `bodyBytes` | int | The **original** body length in bytes (the stored `body` is the first `maxTraceBodyBytes` of it) | The cut is on a raw byte boundary (the body is an opaque string), so a split UTF-8 sequence is possible; `store_result` dumps the trace with `error_handler_t::replace`, turning a stray continuation byte into U+FFFD rather than throwing. The cap is applied by `vayu::json::cap_trace_bodies` (`utils/json.cpp`) to the trace `build_result_trace` (`execution.cpp`) produces. It applies **only** to the design-mode writer - the load-run writers store timing/error envelopes, not bodies. That design-mode subset is what rebuilds the request builder's response pane (Timing tab included) after a restart - see `app/src/modules/request-builder/utils/restore-response.ts`, which surfaces `bodyTruncated`/`bodyBytes` as a "body truncated for storage" notice. A design run has exactly one `results` row. `GET /runs/:runId` serves it (as `result`) alongside the run itself, in addition to `GET /runs/:runId/report`'s `results` array - the same row, read by two routes for two different callers. --- ### `result_bodies` The response captured for one sampled **load-run** result, one-to-one with a `results` row. Struct is `db::ResultBody`. Read only by [`GET /runs/:runId/samples`](api-reference.md#get-runsrunidsamples); nothing on the report path touches it. | Column | Type | Notes | |----------------|------------|--------------------------------------------------------------------| | `result_id` | INTEGER PK | The `results.id` this exchange belongs to (not autoincrement) | | `run_id` | TEXT | FK → `runs.id`; what the run cascade and the endpoint filter on | | `headers` | TEXT | JSON object of the response headers, as received | | `blob_id` | INTEGER | FK → `body_blobs.id`, or **0** when no body was stored | | `body_bytes` | INTEGER | Size of the body **as received**, before any truncation | | `truncated` | INTEGER | 1 when the stored bytes are a prefix (`maxSampleBodyBytes`) | | `binary` | INTEGER | 1 when the body was stored as a descriptor rather than as text | | `content_type` | TEXT | The response's `Content-Type`, `""` when it sent none | **Which completions get a row.** Not a uniform sample - a uniform slice of a 30M-request run is a thousand identical 200s. Three buckets, all decided before anything is copied: | Bucket | Bound | |--------|-------| | Every error | `maxStoredErrors` (the error store's own cap) | | Slow outliers | `max_slow_results`, the existing slow-request reservoir | | The first `EXEMPLARS_PER_STATUS` (3) of each distinct status code | `max_exemplar_results` (64), and unlike its neighbours **not** a reservoir - an exemplar that gets displaced is not an exemplar | The buckets overlap, and the overlap resolves toward the *other* store: an outlier that is also one of its status code's first three stays charged to the slow budget, and a sampled completion stays charged to the sampling budget. The exemplar store holds only what no other budget wanted. Claiming an exemplar is what decides that a **body** is captured, separately from which budget pays - so a completion that is both sampled and an exemplar is stored as a sample and still keeps its body. A uniformly sampled success (`success_sample_rate`) is deliberately body-less. **Budgets.** `maxSampleBodyBytes` (config, `observability`, default 32 KiB) caps a single body; `maxSampleBytes` (default 2 MiB) is the whole-run budget. Once the run budget is spent, samples keep their headers and metadata and lose only their bodies - the row then has `blob_id = 0` with `body_bytes > 0`, and `runs.summary`'s `sampling.sample_bodies_dropped` counts them, so the UI can say the set is incomplete rather than presenting a biased subset as the whole story. Both defaults are far below design mode's `maxTraceBodyBytes` (5 MiB): a design run stores one exchange the user asked for, a load run stores tens nobody asked for individually. **Binary bodies.** The engine never sets `CURLOPT_ACCEPT_ENCODING`, so a request that asks for `gzip` gets the compressed bytes in the response body; images and protobuf arrive the same way. Those are stored as a descriptor (`binary = 1`, `blob_id = 0`, with `body_bytes` and `content_type`), never as text - `error_handler_t::replace` would keep `dump()` from throwing and hand the reader a mojibake that reads like a real response. The rule is `vayu::core::looks_binary` (`core/sample_capture.cpp`): a content type that is not text-shaped, or a bounded prefix that is not valid UTF-8 / contains a NUL. **No redaction.** Captured data is stored verbatim, consistently with design-mode traces, which already store request headers as sent. A response `Set-Cookie` is captured along with everything else. The mitigation is the run's own marker - `sampling.response_bodies_captured` in `runs.summary` - which the Samples tab reads to warn, plus the run cascade below, which makes `maxRunsRetained` the expiry for anything credential-shaped a capture picked up. **Per-run request.** There is deliberately no per-sample request copy: a load run's request is constant across iterations and already lives in `runs.config_snapshot`, and the event-loop path never populates `Response::request_headers` at all (only the synchronous `client.cpp` does). --- ### `body_blobs` One row per **distinct** captured body within a run - the dedup table. Struct is `db::BodyBlob`. | Column | Type | Notes | |-----------|------------|------------------------------------------------------------------| | `id` | INTEGER PK | Autoincrement; `result_bodies.blob_id` points here | | `run_id` | TEXT | FK → `runs.id`; scopes dedup to one run | | `hash` | TEXT | Lowercase hex SHA-256 of `content` (`vayu::core::body_digest`) | | `content` | TEXT | The stored bytes, already truncated to `maxSampleBodyBytes` | Load-test responses are overwhelmingly identical, so 1000 samples of one 2 KiB body store 2 KiB, not 2 MB. The digest is taken over the **stored** (already truncated) bytes: two bodies that differ only past the truncation point are byte-identical as stored, and storing them twice would be storing the same row twice. Dedup is scoped per run rather than globally so that deleting a run deletes its blobs with no cross-run refcount to maintain. Both tables are removed by `remove_run_cascade_locked` - bodies before the results they hang off, so a delete interrupted between the two leaves results without bodies rather than body rows pointing at nothing. Both tables are new in 0.15.0. `sync_schema()` creates new tables outright, so there is no migration: an existing database picks them up on the next startup and older runs simply have no rows in them. --- ### `config_entries` Engine configuration registry - each tunable setting with UI metadata. Read by `GET /config`, written by `POST /config`. Struct is `db::ConfigEntry`. | Column | Type | Notes | |-----------------|---------|--------------------------------------------------------| | `key` | TEXT PK | e.g. `workers`, `maxConnections`, `liveTickIntervalMs` | | `value` | TEXT | Current value (parsed per `type`) | | `type` | TEXT | `"integer"` / `"string"` / `"boolean"` / `"number"` / `"enum"` | | `label` | TEXT | Display label | | `description` | TEXT | Help text | | `category` | TEXT | Grouping (e.g. `server`, `network_performance`) | | `default_value` | TEXT | Default as string | | `min_value` | TEXT | Optional minimum (numbers) | | `max_value` | TEXT | Optional maximum (numbers) | | `options` | TEXT | JSON array of `{value, label}`; `"enum"` entries only | | `updated_at` | INTEGER | Unix ms | **options** is nullable and populated only for `type: "enum"` entries. It is JSON-in-TEXT, the same convention as every other structured column in this schema (`variables`, `auth`, `config_snapshot`, ...), never a delimited string. Labels travel with values so the Settings UI never holds its own value-to-label map that could drift from the engine's. `min_value` / `max_value` were considered and rejected as a place to carry the option list - they are engine-side validation only and unread by the app, whereas `options` is part of the client contract: the renderer cannot draw the dropdown without it. The one seeded `enum` entry today is **`defaultHttpVersion`** (`upsert_config` in `database.cpp`), whose `options` is derived from the same `HttpVersion` enumeration that validates `requests.http_version` - see [`requests`](#requests) above - so the two cannot drift: ```json [ {"value": "auto", "label": "Auto"}, {"value": "http1.1", "label": "HTTP/1.x"}, {"value": "http2", "label": "HTTP/2"} ] ``` Its `value` is this instance's current global, read fresh (not cached) on every request create; changing it applies to the next request created, never retroactively. --- ## Indexes Declared alongside the tables in `make_storage()` (`engine/src/db/database.cpp`). `sqlite_orm` requires index arguments to precede the table arguments. `sync_schema()` creates them on startup for fresh **and** pre-existing databases, so adding an index is additive and needs no migration. | Index | Column | Query paths that rely on it | |------------------------------|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| | `idx_metric_ticks_run_id` | `metric_ticks.run_id` | `get_metric_ticks_paginated` / `count_metric_ticks` (every `GET /runs/:id/metrics`), `get_metric_ticks_since` (the legacy SSE poll), and the `remove_all` in the run cascade | | `idx_results_run_id` | `results.run_id` | `get_results` and the `remove_all` in `delete_run` | | `idx_result_bodies_run_id` | `result_bodies.run_id` | `get_result_bodies_paginated` / `count_result_bodies` (every `GET /runs/:id/samples`) and the `remove_all` in the run cascade | | `idx_body_blobs_run_id` | `body_blobs.run_id` | The `remove_all` in the run cascade | | `idx_requests_collection_id` | `requests.collection_id`| `get_requests_in_collection` (every sidebar load) and cascade delete | | `idx_collections_parent_id` | `collections.parent_id` | The cascade-delete BFS in `Database::delete_collection`, which does one lookup per node in the subtree | | `idx_runs_start_time` | `runs.start_time` | `get_all_runs` and `get_runs_paginated`, which sort `start_time DESC` on every `GET /runs` | | `idx_runs_request_id` | `runs.request_id` | `GET /runs?requestId=` and `useLastDesignRunQuery`'s single-run lookup (`get_runs_paginated` with a `request_id` filter) | `metric_ticks` and `results` are the unbounded-growth tables - a load run writes one tick row per second (the retired EAV `metrics` table cost roughly 20 rows for the same second) - so without `run_id` indexes a lookup slows down with every run ever recorded, not just the current one. `collections.parent_id` is a nullable column; `sqlite_orm` indexes it without special handling. Guarded by `DatabaseTest.CreatesIndexesOnFreshDatabase` and `DatabaseTest.RecreatesIndexesOnExistingDatabase` in `engine/tests/db_test.cpp`, which read `sqlite_master` directly rather than trusting what `sqlite_orm` reports about itself. --- ## VariableValue shape Used in `collections.variables`, `environments.variables`, and `globals.variables`: ```json { "value": "https://api.example.com", "enabled": true, "secret": false, "type": "string", "createdAt": 1784967810149 } ``` `secret` is a UI masking hint only - values are not encrypted at rest. `type` is a UI/script conversion hint, one of `"string"` (default), `"number"`, `"boolean"`, `"json"` - it controls how scripts read the variable via `pm.*.get(...)`. `createdAt` (ms epoch) is the app's row-ordering key: the variables editor lists a scope oldest-first. It is **optional** - a row written before the field existed, or stripped by an engine older than the fix for issue #135, simply has none, and the app sorts an absent value as older than everything. Neither side may backfill it on an existing variable: stamping a legacy row at save time is what made it leapfrog the row the user had just added. Only the two places that genuinely create a variable stamp it - the app when the user types a new row, and the engine's `pm.*.set()` when a script introduces a key that did not exist. The engine round-trips the whole shape through `vayu::json::parse_variables` / `serialize_variables` (`engine/src/utils/json.cpp`) when `POST /execute` persists script-set variables. **A field added here must be added to both**, or a design run erases it from disk; `engine/tests/script_variables_test.cpp` pins the round trip field by field. `POST /execute` also skips the write entirely for a scope no script changed, so sending a request no longer touches a collection's / environment's `updated_at`.