Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions docs/AGENT_ANALYST_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ The sizes, read off the code rather than guessed:
| Packed schema inventory | 6 000 chars, fenced whole | `context-snapshot.ts:348` |
| Relations block | 2 000 chars | `er-diagram.ts` (`MAX_ER_CHARS`) |
| Tool declarations, resent each turn | ~3 k chars for 7 descriptions + schemas | `investigation.ts:557-566` |
| **One completed read** | **200 rows and 256 KiB, whichever binds first** | `execution-policy.ts:54-55`, enforced at `postgres.ts:917-931` and `sqlite.ts:429` |
| **One completed read** | **200 rows and 256 KiB, whichever binds first** | `execution-policy.ts:54-55`, enforced at `postgres.ts:919-933` and `sqlite.ts:431` |

So `B ≈ 16 k chars ≈ 4 k tokens` (at a conservative four characters per token; the ratio is the
model's tokeniser's, which this layer deliberately does not know — `context-snapshot.ts:340-347`
Expand Down Expand Up @@ -397,7 +397,7 @@ selected by workflow — not a variable.

**What raising the wall clock costs, stated:** the artifact TTL is derived from the deadline
(`runtime.ts:55`), so it scales with it automatically and correctly. PostgreSQL opens and rolls back
its own read-only transaction *per statement* (`postgres.ts:889,903`), so a longer run does not hold
its own read-only transaction *per statement* (`postgres.ts:891,905`), so a longer run does not hold
a longer transaction — the run's life and a transaction's life are unrelated on that engine. What a
longer deadline does cost is heap: the transcript and the artifacts stay resident for longer on a
single replica. At the numbers above that is tens of megabytes, not hundreds.
Expand Down Expand Up @@ -426,7 +426,7 @@ snapshot — anywhere in the database, not only in the tables the query touches.
cluster that is bloat, and over a long enough read it is transaction-ID wraparound pressure. It is
not a lock; a writer is not blocked. It is slower, quieter and harder to attribute, which arguably
makes it worse. The agent's own path is safe from this by construction (each statement gets its own
`BEGIN READ ONLY` … `ROLLBACK`, bounded to 10 s, `postgres.ts:889-914`). The editor path is not.
`BEGIN READ ONLY` … `ROLLBACK`, bounded to 10 s, `postgres.ts:891-916`). The editor path is not.

**On SQLite the risk is worse than the owner said.** A long read genuinely does block writers, and
the driver is synchronous, so it also **blocks the studio process itself** — `deadline.ts:20-26` and
Expand Down Expand Up @@ -570,7 +570,7 @@ means warning plus "Apply to editor", never a silent skip.
1. **The run executed this exact statement itself.** A model may compose a final statement wider than
anything it ran; that one is never auto-executed. This condition is close to free and it removes
most of the risk class on its own, because the agent path *refuses rather than truncates*
(`postgres.ts:917`, `sqlite.ts:421`): an artifact exists only for a statement that provably
(`postgres.ts:919`, `sqlite.ts:423`): an artifact exists only for a statement that provably
returned 200 rows or fewer inside the 10 s statement ceiling. Row explosion is therefore already
excluded by the artifact's existence, not by any estimate.
2. **The plan gate the owner asked for**, read per engine, with unknown resolving to risky:
Expand Down Expand Up @@ -715,8 +715,8 @@ limit and must **not** inherit a tab's `unlimited` flag (§2.1). Beyond that, no
> user "writes and DDL are refused either way". Those two sentences cannot both be true of the
> implementation they describe. The editor's execution goes to `POST /api/db/query`, a plain
> read-WRITE session whose only protection is `isDangerousQuery` — a check on the statement's TEXT —
> while the agent's own read is enforced by the ENGINE (`BEGIN READ ONLY` at `postgres.ts:889`,
> `PRAGMA query_only` at `sqlite.ts:410`). Text is not where the difference lives: a `SELECT` may
> while the agent's own read is enforced by the ENGINE (`BEGIN READ ONLY` at `postgres.ts:891`,
> `PRAGMA query_only` at `sqlite.ts:412`). Text is not where the difference lives: a `SELECT` may
> invoke a VOLATILE function that performs an `INSERT`, which the read-only transaction refuses
> (SQLSTATE 25006) and the read-write session performs. The same statement was therefore harmless
> where the run proved it and harmful where it was replayed — and this repository already treats
Expand Down
30 changes: 15 additions & 15 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,12 +369,12 @@ Found 2026-08-27 by the audit that closed the curated health projection's cap-as
removed MySQL's fabricated "Performance schema not available" row; three providers still ship the
same shape, in the same field:

- `src/lib/db/providers/sql/postgres.ts:1239` - a database without `pg_stat_statements` answers
- `src/lib/db/providers/sql/postgres.ts:1241` - a database without `pg_stat_statements` answers
`[{ query: "pg_stat_statements extension not enabled", calls: 0, avgTime: "N/A" }]`.
- `src/lib/db/providers/document/mongodb.ts:791` - a database whose profiler is off answers
- `src/lib/db/providers/document/mongodb.ts:785` - a database whose profiler is off answers
`[{ query: "Profiler not enabled. Run db.setProfilingLevel(1) to enable." }]`, and the outer catch
at `:830` answers `[{ query: "Error fetching health info" }]` for a read that failed entirely.
- `src/lib/db/providers/sql/sqlite.ts:721-731` - EVERY SQLite database answers two synthetic rows,
- `src/lib/db/providers/sql/sqlite.ts:707-717` - EVERY SQLite database answers two synthetic rows,
`Integrity: OK|FAILED` and `Journal Mode: <mode>`, about statements that were never executed.

A sentence wearing a row's clothes is the fabrication the absence rule (#477) forbids, and here it is
Expand All @@ -392,8 +392,8 @@ file, and falsifies `src/lib/db/compatibility.ts:267`, `docs/providers/postgres.
and `tests/helpers/sqlite-node-harness.ts:104`, all of which pin the current sentences.

**The other path swallows instead of fabricating, and that is not better.** On the `slow-queries`
reading the agent actually uses, `src/lib/db/providers/keyvalue/redis.ts:635-637` and
`src/lib/db/providers/document/mongodb.ts:1047-1049` `return []` from their catch where MySQL now
reading the agent actually uses, `src/lib/db/providers/keyvalue/redis.ts:622-624` and
`src/lib/db/providers/document/mongodb.ts:1041-1043` `return []` from their catch where MySQL now
rejects. So a denied grant reaches the model as an empty reading, and the run prompt tells it
`"A reading that comes back EMPTY is an answer, not a failure - no blocked session, no slow query,
no unused index is what a healthy server looks like"` (`src/lib/agent/investigation.ts:1485`). It
Expand Down Expand Up @@ -424,12 +424,12 @@ MongoDB's `getOverview()` catch now omits it too.
`databaseSize: TRINO_UNAVAILABLE_TEXT`, and `sql/search/index.ts:849` pairs `sizeBytes ?? 0` with
`databaseSize: SEARCH_UNKNOWN_TEXT` for both `elasticsearch` and `opensearch`.
- Swallowed into an initialiser the way D40's connection counts were: `sql/mssql.ts:1111`,
`sql/oracle.ts:1178`, `sql/sqlite.ts:808`.
`sql/oracle.ts:1154`, `sql/sqlite.ts:794`.
- Coerced by a helper that returns 0 for an absent row: `sql/druid/introspect.ts:578` and
`sql/clickhouse/index.ts:833` through their local `asNumber`.
- Coerced inline: `sql/postgres.ts:1397` and `sql/mysql.ts:1156` (`parseInt(... || "0")`),
- Coerced inline: `sql/postgres.ts:1360` and `sql/mysql.ts:1158` (`parseInt(... || "0")`),
`sql/libsql/introspect.ts:399` and `document/couchbase/index.ts:606` (`?? 0`),
`keyvalue/redis.ts:603`, and `embedded/libredb.ts:709`, whose `fileSizeBytes()` returns 0 when the
`keyvalue/redis.ts:590`, and `embedded/libredb.ts:709`, whose `fileSizeBytes()` returns 0 when the
`statSync` throws.

**The consumer makes it visible.** `src/components/monitoring/tabs/StorageTab.tsx` keys its entire
Expand Down Expand Up @@ -510,7 +510,7 @@ correct in isolation and only the running product puts them together.
`TablesTab.tsx:390` calls `handleMaintenance(type, table.tableName)` - the BARE table name - from a
row whose very next line (`:350`) renders `table.schemaName` beside it. Every provider's
`qualifyMaintenanceTarget` then supplies a default schema for an unqualified target:
`postgres.ts:1285` returns `"public." + escapeIdentifier(target)`, and
`postgres.ts:1287` returns `"public." + escapeIdentifier(target)`, and
`duckdb/index.ts:712` returns `"main"."<target>"`. So the statement names a table that is not there.

Measured on DuckDB v1.5.5, clicking **Analyze Table** on the `analytics.events` row:
Expand Down Expand Up @@ -1226,12 +1226,12 @@ Every other `file.ts:NNNN` in the repository is hand-copied prose, and a sample

| Citation | Cited in | Anchor actually at |
|---|---|---|
| `postgres.ts:915` (`queryReadOnly`) | `docs/AGENT_GUIDE.md:925` | 2396 |
| `postgres.ts:889` (`BEGIN READ ONLY`) | `docs/AGENT_ANALYST_DESIGN.md:400`, `:718` | 2415 |
| `postgres.ts:892` (`SET LOCAL statement_timeout`) | `src/lib/agent/tools.ts:1552` | 2418 |
| `postgres.ts:2095-2099` (`{ ...baseConfig, connectionString }`) | `src/lib/db/connection-fingerprint.ts:67`, `tests/api/db/objects/edit-apply.test.ts:91`, `tests/unit/lib/db/connection-fingerprint.test.ts` x3 | 2256-2262 |
| `postgres.ts:1239` (`pg_stat_statements extension not enabled`) | `docs/BACKLOG.md:326` | 4001 |
| `postgres.ts:1285` (`"public." + escapeIdentifier`) | `docs/BACKLOG.md:467` | 4048 |
| `postgres.ts:917` (`queryReadOnly`) | `docs/AGENT_GUIDE.md:925` | 2396 |
| `postgres.ts:891` (`BEGIN READ ONLY`) | `docs/AGENT_ANALYST_DESIGN.md:400`, `:718` | 2415 |
| `postgres.ts:894` (`SET LOCAL statement_timeout`) | `src/lib/agent/tools.ts:1552` | 2418 |
| `postgres.ts:2070-2074` (`{ ...baseConfig, connectionString }`) | `src/lib/db/connection-fingerprint.ts:67`, `tests/api/db/objects/edit-apply.test.ts:91`, `tests/unit/lib/db/connection-fingerprint.test.ts` x3 | 2256-2262 |
| `postgres.ts:1241` (`pg_stat_statements extension not enabled`) | `docs/BACKLOG.md:326` | 4001 |
| `postgres.ts:1287` (`"public." + escapeIdentifier`) | `docs/BACKLOG.md:467` | 4048 |
| `source-applier.ts:155` (the silent-status sentence) | `tests/components/object-source/ApplyPreviewDialog.test.tsx:1092` | `whenSilent`, elsewhere |
| `StudioWorkspace.tsx:494` (`<main className="flex-1 overflow-hidden relative">`) | `docs/BACKLOG.md:1360` | 823 |
| `StudioWorkspace.tsx:833` (the `ObjectSourceView` mount) | `docs/BACKLOG.md:1145` | 919 |
Expand Down
2 changes: 1 addition & 1 deletion src/lib/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1566,7 +1566,7 @@ function auditDeadlineRefusal(
* on: a credential failure happens while connecting, a grant failure while running.
* - **`QueryCancelledError`** is what a PostgreSQL statement timeout arrives as, and
* this layer is what CAUSES it: the clamped budget becomes `SET LOCAL
* statement_timeout` (`postgres.ts:892`), the engine says `canceling statement due
* statement_timeout` (`postgres.ts:894`), the engine says `canceling statement due
* to statement timeout`, and `mapDatabaseError` matches `canceling statement`
* BEFORE its timeout branch (`errors.ts:280-293`) — so the timeout never arrives as
* `TimeoutError` on this engine at all. Narrowing the read is the repair that helps.
Expand Down
8 changes: 4 additions & 4 deletions src/lib/api/object-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,9 +650,9 @@ function boundText(part: ObjectSourcePart, limit: number): ObjectSourcePart {
* was the only engine that had landed; the day-one set is now three and the count was re-measured
* rather than the digit bumped, because what it counts is what the paragraph is for.
*
* There are THREE producers of `edit`: `providers/sql/postgres.ts:3182`, gated on
* There are THREE producers of `edit`: `providers/sql/postgres.ts:3157`, gated on
* `kindAcceptsSourceEdits(capabilities, kind)`; `providers/sql/trino/index.ts:1257` and
* `providers/keyvalue/redis.ts:1780`, both gated on `spec.acceptsSourceEdits === true`, which is the
* `providers/keyvalue/redis.ts:1768`, both gated on `spec.acceptsSourceEdits === true`, which is the
* same fact read through the same declaration. All three sit on the READABLE arm, verified rather
* than assumed: no producer attaches `edit` to a part carrying `unavailable`.
*
Expand All @@ -664,7 +664,7 @@ function boundText(part: ObjectSourcePart, limit: number): ObjectSourcePart {
* Rule 2's producer set GREW and its character changed, which is the part a bumped digit would have
* hidden. On PostgreSQL it is a by-product: that site spreads `truncated` and `edit` from a single
* read, so a routine over `SOURCE_CHARACTER_LIMIT` reaches it. On Redis it is a DECIDED POSITION,
* stated at `redis.ts:1773-1779`: the affordance is offered on a truncated part deliberately, because
* stated at `redis.ts:1761-1767`: the affordance is offered on a truncated part deliberately, because
* the bound is the CALLER's and the same object read without one is whole, so a provider that withheld
* it there would be answering a property of the REQUEST as a property of the object. Rule 2 is what
* makes that position safe on the standalone path, and the pane's predicate and `buildObjectEdit`'s
Expand All @@ -684,7 +684,7 @@ function boundText(part: ObjectSourcePart, limit: number): ObjectSourcePart {
*
* THE BOUND, on both sides of the same constant. `edit-plan/route.ts:74` refuses a SUBMITTED text
* longer than `EDIT_CHARACTER_LIMIT`, and all three day-one providers refuse a READ definition longer
* than it inside `buildObjectEdit`: `providers/sql/postgres.ts:3339`, `providers/keyvalue/redis.ts:1870`
* than it inside `buildObjectEdit`: `providers/sql/postgres.ts:3314`, `providers/keyvalue/redis.ts:1858`
* and `providers/sql/trino/index.ts:1451`. The second is what closes the class rather than narrowing
* it: a plan is minted only from the build's own read, so a definition the pane could only have shown
* truncated never reaches a plan at all, whatever the client POSTs.
Expand Down
8 changes: 4 additions & 4 deletions src/lib/db/connection-fingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ export function tunnelRoute(tunnel: SSHTunnelConfig | undefined): string {
* `2dedca1a0ad45abdfb01427f0e1df3130e59617c5658058cbcf4852297f888c7` on both sides.
*
* - `connectionString` OVERRIDES the field-by-field form outright and is not merged with it.
* `src/lib/db/providers/sql/postgres.ts:2095-2099` returns `{ ...baseConfig, connectionString }`
* `src/lib/db/providers/sql/postgres.ts:2070-2074` returns `{ ...baseConfig, connectionString }`
* and never reaches the `host`/`port`/`user`/`database` branch below it. The same shape is at
* `mysql.ts:1973-1976` (`uri`), `oracle.ts:1545-1546`, `sqlite.ts:1189-1192`,
* `document/mongodb.ts:800-801`, `libsql/index.ts:116-120`, `clickhouse/index.ts:402-422` and
* `mysql.ts:1948-1951` (`uri`), `oracle.ts:1521-1522`, `sqlite.ts:1175-1178`,
* `document/mongodb.ts:794-795`, `libsql/index.ts:116-120`, `clickhouse/index.ts:402-422` and
* `document/couchbase/index.ts:478`. PostgreSQL is the LIVE population: it declares two editable
* kinds.
* - `schema` is Trino's session schema and is sent as the `X-Trino-Schema` submission header
Expand All @@ -76,7 +76,7 @@ export function tunnelRoute(tunnel: SSHTunnelConfig | undefined): string {
* The normal path is qualified: measured on Trino 476, `SHOW CREATE FUNCTION` answers
* `memory.app.plus_one`, so the case needs a user edit that drops the qualification, which the
* pane cannot stop and the seal is not entitled to assume away.
* - `serviceName` is Oracle's connect-string tail, `oracle.ts:1551-1560` building
* - `serviceName` is Oracle's connect-string tail, `oracle.ts:1527-1536` building
* `host:port/serviceName`, so it selects WHICH DATABASE on that listener.
* - `sshTunnel` is the ROUTE and not a credential. `getOrCreateProvider`
* (`src/lib/db/factory.ts:533-540`) REWRITES `host` and `port` to the tunnel's local endpoint
Expand Down
77 changes: 76 additions & 1 deletion src/lib/db/object-kinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
* `acceptsRowWrites` reads as false in every caller because there is only one caller.
*/
import { QueryError } from "@/lib/db/errors";
import type { DatabaseType, KindCount, ObjectKindSpec, ObjectSourcePart, ProviderCapabilities } from "@/lib/db/types";
import type {
ContainerLevelSpec,
DatabaseType,
KindCount,
ObjectKindSpec,
ObjectSourcePart,
ProviderCapabilities,
} from "@/lib/db/types";

/**
* How many container levels this engine declares, as the tree models them.
Expand All @@ -32,6 +39,74 @@ export function findKind(capabilities: ProviderCapabilities, id: string): Object
return declaredKinds(capabilities).find((kind) => kind.id === id);
}

/**
* The engine half of `assertObjectPathShape`: the identity its error carries, and the one
* policy the nine hoisted copies disagreed on.
*/
export type ObjectPathShapeEngine = {
/** The engine code the thrown `QueryError` is stamped with. */
code: DatabaseType;
/** The message's opening subject, article included: "A PostgreSQL", "An Oracle". */
label: string;
/**
* Whether a kind that declares `attachedTo` also admits the bare shape. MySQL and
* Oracle answer yes; every other engine requires the attached segment.
*/
attachedSegment: "required" | "optional";
};

/**
* The container levels this engine declares, sliced to the depth `containerDepth()`
* reports. The same derivation every provider kept locally; hoisted with the assert so
* the depth rule and the level list cannot be taken by two different rules.
*/
function declaredLevels(capabilities: ProviderCapabilities): readonly ContainerLevelSpec[] {
return (capabilities.containerLevels ?? []).slice(0, containerDepth(capabilities));
}

/**
* Refuses a path no shape of this kind admits, naming every shape it does admit.
*
* Hoisted from nine provider-local copies (#978) that had drifted apart in more than the
* signature. MySQL and Oracle read an attached kind by EITHER address, so they name both
* shapes in the error and carry `attachedSegment: "optional"`; PostgreSQL, SQLite,
* libSQL and Cassandra require the attached segment; ClickHouse, Redis and MongoDB
* declare no attached kind, so the policy never fires for them. The engine descriptor
* keeps those behaviours exactly as they were, because a hoist that quietly picked one
* would change what a bare-shaped path means on four engines.
*
* `kind` - not `spec.id` - stays in the message because that is what seven of the nine
* copies printed. Callers resolve the kind before this call (findKind, requireSourceKind
* or requireEditableKind), so the spec is always in hand and this function is not the
* kind-existence check: refusing an undeclared kind stays the caller's job, in the
* caller's own words.
*/
export function assertObjectPathShape(
capabilities: ProviderCapabilities,
spec: ObjectKindSpec,
kind: string,
path: readonly string[],
engine: ObjectPathShapeEngine,
): void {
const levels = declaredLevels(capabilities).map((level) => level.label.toLowerCase());
const attachedTo = spec.attachedTo;
const shapes: string[][] =
attachedTo === undefined
? [[...levels, "name"]]
: engine.attachedSegment === "optional"
? [
[...levels, attachedTo, "name"],
[...levels, "name"],
]
: [[...levels, attachedTo, "name"]];
if (shapes.some((shape) => shape.length === path.length)) return;
throw new QueryError(
`${engine.label} "${kind}" path is ${shapes.map((shape) => `[${shape.join(", ")}]`).join(" or ")}, ` +
`received ${JSON.stringify(path)}`,
engine.code,
);
}

/**
* Whether THIS KIND accepts a row write. Absent and undeclared both read as false.
*
Expand Down
Loading
Loading