Skip to content

fix(monitoring): one refused read cost the whole dashboard, and three more - #477

Merged
cevheri merged 1 commit into
mainfrom
chore/backlog-round8
Aug 24, 2026
Merged

cevheri merged 1 commit into
mainfrom
chore/backlog-round8

Conversation

@cevheri

@cevheri cevheri commented Aug 24, 2026

Copy link
Copy Markdown
Member

Four BACKLOG entries close. Every claim here was measured against a live engine and then verified in Chrome on a production build, not through the provider alone.

D22 — one refused read discarded the whole monitoring dashboard

BaseDatabaseProvider.getMonitoringData composed its seven reads with Promise.all, so the first rejection threw away every panel that had already answered.

Measured on StarRocks 3.3 (3.3.22-753696f), which has no information_schema.PROCESSLIST:

BEFORE  RESOLVED getOverview / getPerformanceMetrics / getSlowQueries / getTableStats / getIndexStats / getStorageStats
        REJECTED getActiveSessions: Getting analyzing error. Detail message: Unknown table 'information_schema.PROCESSLIST'.
        getMonitoringData THREW that sentence, and the page rendered nothing else.

AFTER   KEYS: timestamp,overview,performance,slowQueries,tables,indexes,storage,errors
        errors={"activeSessions":"Getting analyzing error. Detail message: Unknown table 'information_schema.PROCESSLIST'."}

The four core panels are now optional, and each rejection is recorded under MonitoringData.errors carrying the engine's own sentence — the only text that says what the database actually refused. A panel that was not requested (includeTables: false) stays absent with no error entry, so "not asked for" remains distinguishable from "asked and refused".

It still throws when all four core reads reject, which is what a dead connection looks like, so /api/db/monitoring keeps its error path untouched. When all four say the same thing the first rejection is rethrown verbatim, so an AuthenticationError is not re-classified into a generic one by its text and the route answers the status code it answered before.

Browser-verified on StarRocks: the Overview, Performance, Queries, Tables, Storage and Pool tabs all render — Tables reads 2 tables / 5 rows with real sizes — and only Sessions carries "This database could not answer this panel. Getting analyzing error. Detail message: Unknown table 'information_schema.PROCESSLIST'."

Two fabricated zeros found in the browser while verifying it, both the class removed from the connection count on 2026-08-24:

  • Overview's Quick Stats read Active 0 / Idle 0 for an activeSessions read the engine had refused.
  • The Sessions tab's four summary cards read four zeros under a Sessions (0) title, above the panel that said the engine could not answer.

Both now read N/A, and the same for the admin Operations card titles. An answered empty list still reads 0, because zero sessions is a measurement — verified side by side: StarRocks N/A, PostgreSQL 18 a real 4 / 2, and Slow Queries 0 on StarRocks because that read genuinely answered [].

D19 — Oracle intervals arrived as objects nothing could read

INTERVAL YEAR TO MONTH came back as {"months":7,"years":3} and INTERVAL DAY TO SECOND as {"fseconds":900000000,"seconds":8,...}. Lossless, and unreadable by every consumer and unreplayable by the export.

They are now normalised at the driver boundary to the literal Oracle itself accepts back, following the CQL Duration precedent already documented for Cassandra:

Type Before After
INTERVAL YEAR TO MONTH {"months":7,"years":3} +03-07
INTERVAL DAY TO SECOND {"fseconds":900000000,...} +05 06:07:08.9

The signed form and not the keyword form, because that is what the server accepts — 'INTERVAL ''3-7'' YEAR TO MONTH' as a bound value is ORA-01867: the interval is invalid, while '+03-07', '-03-07', '+00-00' and '+123456789-11' are all accepted. Proven end to end: exported through the real export path, replayed into live Oracle Free 23ai, and compared by the server (s.iym = r.iym AND s.ids = r.ids → EQUAL for every row). Browser-verified in the grid: +03-07 and +05 06:07:08.9 under column types INTERVAL YEAR TO MONTH / INTERVAL DAY TO SECOND.

TIMESTAMP WITH TIME ZONE keeps its Date, and the doc now says why rather than promising a fix. The offset is gone before any code here runs: the driver hands over a bare Date, fetchAsString refuses the type (NJS-021) and a fetchTypeHandler to STRING refuses it too (NJS-119). Measured with three stored offsets, the string form is strictly worse — it reports the reader's zone for all three and drops .345. docs/providers/oracle.md §5.5 names the TO_CHAR(col,'YYYY-MM-DD HH24:MI:SS.FF6 TZR') that recovers both the zone and the sub-millisecond digits. No other date path changed — DATE, TIMESTAMP and both zoned forms serialise byte-identically to before.

D20 — the Oracle provider was compiled against any

Measured: oracledb 6.10.0 ships no .d.ts and there is no @types/oracledb in this tree, so the entry's first branch is impossible. The blanket declare module "oracledb" { const oracledb: any } is replaced by a declaration of exactly the surface this repo uses (DbType, Metadata, FetchTypeHandler, Result, IntervalYM/IntervalDS, Connection, Pool, and the DB_TYPE_* members). The provider's local stand-ins for Result and the fetch-type-handler contract are gone, and the test mock's identities are typed. Only oracle.ts imports the module, so the tightening is contained.

D21 — the Cassandra degradation keyed on the wording of a refusal

It now keys on a structural fact asked once per connection. The entry's own premise was refuted by measurement, which is the main finding here:

cassandra-5.0.9  SELECT keyspace_name FROM system_schema.keyspaces
  -> probe, system, system_auth, system_distributed, system_schema, system_traces   -- NO system_views
scylla-2026.2.4  -> 9 rows, no system_views either

A virtual keyspace is not in system_schema on either engine, so implementing D21 as written would have emptied all five monitoring panels on the engine that answers them. The catalog that carries the fact is system_virtual_schema.keyspaces — system_views, system_virtual_schema on Cassandra 5.0.9 in 1.5 ms, and Keyspace system_virtual_schema does not exist on ScyllaDB.

readServerFacts never throws and cannot fail a connect(): the catalog answering decides by name, a refusal decides by category/code, and anything else (timeout, unreachable) claims no absence so the reads go out exactly as before. A read that needs system_views on a build that measurably has none is not sent — three fewer round trips per monitoring refresh, asserted by test. Cost stated in the code and the doc: one extra statement per successful connect.

The four measured error spellings stay pinned as a regression test, as the entry asked. A fifth capitalisation turned up while re-measuring (ScyllaDB writes Keyspace ... does not exist where Cassandra writes keyspace ...) — exactly the drift the text match was exposed to.

Browser-verified: Cassandra 5.0.9 answers 6 sessions with no failure panel; ScyllaDB 2026.2.4 degrades to an empty list with no error, and its Overview still reads Connections N/A / not published.

Recorded rather than fixed

  • D23 — every Oracle DATE and TIMESTAMP cell exports as a literal Oracle refuses. Found while verifying the interval replay: the intervals replay, and '2026-08-24T07:11:12.345Z' in the same file is ORA-01843: An invalid month was specified. It affects every date column on the engine, and the fix belongs in result-export.ts rather than the provider.
  • D24 — Cassandra's degradation still answers an empty panel where absence now has a shape. On ScyllaDB the Sessions tab reads four zeros and "No active sessions found." for a question the engine cannot answer, while StarRocks, which also cannot answer it, says so in the engine's own words. The provider already knows which it is.

Gates

Gate Result
bun run format clean (925 files)
bun run lint 0 errors
bun run typecheck clean
bun run knip clean
tests 336 core files + 33 component groups, 0 fail
coverage 100.00% (41690/41690 lines)
bun run build exit 0
build:lib + attw exit 0, node16/bundler green
gitleaks 1 commit scanned, no leaks

One thing worth flagging for review: the six new component test blocks initially failed inside tests/run-components.sh's group 15 and passed standalone. Cause was scoping, not flake — bun:test registers afterEach on the enclosing describe only, so the file's existing cleanup() hook did not reach a new sibling block and the control arm queried the previous test's DOM. Each new block now registers its own.

… more

BACKLOG D22, D19, D20, D21 close. Every claim below was measured against a live
engine and verified in Chrome on a production build.

D22 - getMonitoringData composed its seven reads with Promise.all, so one
rejection threw away every panel that had answered. Measured on StarRocks 3.3,
which has no information_schema.PROCESSLIST: the provider answered overview,
performance, slowQueries, tables, indexes and storage, and the user saw nothing
but the engine's sentence about the one read that failed. The four core panels
are now optional and each rejection is recorded under MonitoringData.errors with
the ENGINE's own sentence, which the panel renders in place of its content. It
still throws when all four core reads reject - a dead connection - and rethrows
the first rejection verbatim when they all say the same thing, so
/api/db/monitoring keeps the status code it answered before. Browser-verified:
StarRocks now renders six panels, Tables reads 2 tables / 5 rows, and only
Sessions carries "Unknown table 'information_schema.PROCESSLIST'".

Two fabricated zeros found in the browser while verifying it, both the class D17
removed from the connection count: Quick Stats read "Active 0 / Idle 0" and the
session summary read four zeros with a "Sessions (0)" title for a read the engine
had refused. Both now read N/A, and an answered empty list still reads 0 -
verified side by side, StarRocks N/A against PostgreSQL 18's real 4 / 2.

D19 - Oracle INTERVAL YEAR TO MONTH and INTERVAL DAY TO SECOND arrived as
oracledb objects ({"months":7,"years":3}) that no consumer could read and no
export could replay. They are now normalised at the driver boundary to the
literal Oracle itself accepts back, following the CQL Duration precedent:
'+03-07' and '+05 06:07:08.9', proven by exporting and replaying into the live
engine and comparing server-side. TIMESTAMP WITH TIME ZONE keeps its Date: the
offset is gone before any code here runs (fetchAsString refuses the type,
NJS-119/NJS-021), so docs/providers/oracle.md says so plainly and names the
TO_CHAR that recovers it. No other date path changed.

D20 - oracledb ships no types and there is no @types/oracledb in this tree, so
the blanket `declare module "oracledb" { const oracledb: any }` is replaced by a
declaration of exactly the surface this repo uses. The provider's local
stand-ins for Result and FetchTypeHandler are gone.

D21 - the Cassandra degradation keyed on the WORDING of a refusal. It now keys on
a structural fact asked once per connection. The entry's own premise was refuted
by measurement: system_views is not listed in system_schema.keyspaces on either
engine, so keying there would have emptied all five panels on Cassandra 5.0.9.
The catalog that carries the fact is system_virtual_schema.keyspaces (1.5 ms on
5.0.9; absent on ScyllaDB). The four measured spellings stay as a regression pin.
Browser-verified: Cassandra answers 6 sessions, ScyllaDB degrades without error.

Recorded rather than fixed: D23 (every Oracle DATE and TIMESTAMP cell exports as
a literal Oracle refuses with ORA-01843) and D24 (the Cassandra degradation still
answers an empty panel where absence now has a shape).
@sonarqubecloud

Copy link
Copy Markdown

@cevheri
cevheri merged commit b55c8b9 into main Aug 24, 2026
19 checks passed
@cevheri
cevheri deleted the chore/backlog-round8 branch August 24, 2026 13:29
cevheri added a commit that referenced this pull request Aug 24, 2026
…to it

#465 landed as one commit (0c89963), so main now carries its content with no ancestry
in common with the ~90 commits this branch has it through. Git cannot see the two as
the same work, which made this merge report more than it should and — the part worth
recording — report LESS than it should in one place.

NINE TEXT CONFLICTS, all the same shape: main's side is #465 as squashed, ours is that
plus the review changes. #477 (the other commit this merge brings) touches none of the
nine, so ours is the resolution everywhere, and the result was checked against `main`
file by file afterwards to confirm the delta is exactly the reviewed changes and
nothing of main's was dropped.

AND TEN FILES CAME BACK FROM THE DEAD, WITHOUT CONFLICTING. The per-model modules this
PR replaced with a document — `models/qwen3-8b.ts` and its nine siblings — are absent
on our side and, against a merge base that predates them, ADDED on main's. Git resolves
"added on one side only" by taking the addition, so a merge that reported nine
conflicts and no problem had quietly restored the directory the refactor exists to
remove.

`typecheck` caught it, and only by luck: the restored modules assign `notices`, a field
the refactor deleted from `AgentModelProfile`, so they stopped compiling. Had the
refactor been purely subtractive they would have compiled, passed every test, and
shipped as dead code nobody wrote.

The check that finds this class directly is `git diff --name-status HEAD
--diff-filter=A` — what the merge ADDS relative to where you were. Here it listed
eleven files: ten resurrections and one genuinely new (`PanelUnavailable.tsx`, #477).
Anyone merging a squashed base under a stacked branch should run it before trusting a
clean merge.

Verified on the result: format, lint (0 errors), typecheck, knip, `bun run test`
(33/33 groups), build, chart:check (0.1.48 / 0.13.3), and coverage:check at
42537/42537 lines.
cevheri added a commit that referenced this pull request Aug 25, 2026
…489)

D24 - a panel a build cannot answer is now ABSENT with the engine's own
sentence rather than empty, on the Cassandra family, on Trino and on the
embedded LibreDB. The mechanism landed in #477; this is the audit it asked
for. Cassandra's three permanently unanswerable panels (table, index and
storage statistics) and the two that need system_views carry their reason;
Trino separates a refused table list and a catalog whose connector publishes
no statistics from a catalog that genuinely holds no table; LibreDB reports
real per-namespace key counts where it used to answer [] on a database with
tables, and refuses sessions and indexes with what the engine actually is.
Measured against Cassandra 5.0.9, ScyllaDB 2026.2.4 and Trino 476, and
verified in Chrome on a production build.

The Tables tab's Size card summed a required-field placeholder into "0 B"
over a database with bytes on disk, which the LibreDB rows exposed. It now
gates the way StorageTab has since #469, and a table with no size of its own
reads "-" rather than an empty cell.

D23 - an Oracle DATE or TIMESTAMP cell exported as an ISO string Oracle
refuses (ORA-01843 / ORA-01861), so a DDL+INSERT export of any ordinary
Oracle table could not be replayed. The export now writes TO_DATE /
TO_TIMESTAMP / FROM_TZ chosen by the declared type, proven by replaying into
live Oracle Free 23ai and comparing by the server.

D16 - a pasted connection string's TLS parameter reached the form as
"disable", so postgresql://...?sslmode=verify-full said plaintext. The
documented spellings now map (postgres sslmode/ssl, MySQL ssl-mode/ssl/useSSL,
ADO.NET Encrypt/TrustServerCertificate). The opportunistic values are not
guessed onto either end - measured, prefer to require refuses a connection
that works and PREFERRED to disable downgrades one that is encrypted - and
the refusal is visible, not silent.

DOC3 - four marketplace listings still advertised NL2SQL, and two more
carried the old engine count. The copy now matches the product, and
tests/unit/marketplace-copy.test.ts binds each claim to the code: AI
explanation is offered where the engine returns a plan, not on every
connection, and "read-only" replaces "never executes what it recommends",
which the consented hand-over does.

Recorded rather than fixed: D25 (Couchbase turns an RBAC denial into a zero,
unmeasurable here with no live cluster), D26 (?ssl=true maps onto the one
mode that does not verify) and U19 (a caution rendered as a green success
tick).
cevheri added a commit that referenced this pull request Aug 27, 2026
…ence, and four more (#512)

* fix(db,agent,security,docs): the MySQL health panel fabricated an absence, and four more

D32 - MySQL's health slow-query read asked for `LEFT(sql_text, 100)` from
`performance_schema.events_statements_summary_by_digest`, a column that table does not have,
and a bare catch turned the resulting `ERROR 1054` into the row
`{ query: "Performance schema not available", calls: 0, avgTime: "N/A" }`. Measured on four
live servers (MySQL 26.7.0 on and off, Percona 8.4.11-11, MariaDB 12.3.2): the sentence
appeared on every one of them, including the two where `@@performance_schema` is 1 and the
Queries panel beside it returned real rows. The health read now uses the statement the panel
uses, one mapper feeds both, and an unreadable source empties the line instead of describing
itself - a fabricated absence is exactly what the absence rule (#477) exists to prevent.
`getSlowQueries()` no longer swallows either, so a denied grant (1142) or an absent tenant
schema (1049) reaches the Queries panel as the server's own sentence.

AU3 - `Cross-Origin-Opener-Policy: same-origin` is delivered from `securityHeaders()`, and
`Cross-Origin-Resource-Policy` is refused with the reason recorded beside the header rule:
CORP acts only on a subresource, the only delivery path that reaches a subresource is
`next.config.ts`'s build-time `headers()`, and CORP's correct value is the one in the set
that depends on deployment topology. What that refusal gives up is now tracked as AU4.
`next.config.ts` derives its baked set by probing `securityHeaders()` rather than restating a
list of names, so a header that varies with an option cannot be baked by accident.

B76 - `declined: "repointed"` is split out of the collapsed `"unavailable"` code, and the five
that stay collapsed each carry the reason they have no distinct remedy. The first draft of the
rail's sentence claimed the decline persists until the connection is pointed back; it does not -
the route writes the new identity onto the run it opens, so exactly one question is declined.
Both halves of the corrected sentence are pinned, and a route test pins the behaviour.

B12 - a statement that failed at the database records the span the tracker charged it, so the
rail's database-time meter no longer counts completed reads only. The charge is a delta, pinned
by a two-execution harness run; a ledger entry written before this change folds as a statement
with no duration rather than a measured zero.

B38 - a run whose workflow sends statements is refused on an engine with no read-only execution
path at the point it is opened: 400 with the posture's own sentence, before a run id, a capability
probe or a model turn is spent. The `operations` workflow still opens on the same connection,
because it sends no statement at all. The drift guard behind the refusal now measures every
provider in the `DatabaseType` union rather than restating the list.

Also restores two backlog entries #510 overwrote when it branched before #509 merged, renumbered
D34/D35, and repoints the citation that named the wrong entry.

Verified in Chrome against the built app: COOP present on the document and absent on
`/logo.svg` and every `/monaco/vs/*` response, editor, five Monaco workers, a query and the ERD
export all unaffected; the B38 refusal answering 400 with no run opened, and an `operations` run
succeeding on the same connection; the fabricated MySQL sentence gone from the monitoring panel.
B79 and B80 record what that drive found and could not reach.

* docs(comments): cite the change that landed, not the backlog entry it closed

Deleting an entry when the work lands is this repo's convention, and T4 records what it
costs: nothing checks a citation from a source comment, so every comment naming a deleted
id goes silently wrong. Closing D32, B12, B38, B76 and AU3 would have left fifty-odd of
them across `src/` and `tests/`.

Each one was read rather than substituted. Where the sentence explains why the code is the
way it is, it now cites #512, the change that made it so. Where the id was the NAME of a
defect class rather than a pointer - "a marker keyed on the throw would be emitted for
something other than off-ness, D32 one level up" - the class is named instead, because a PR
number does not carry that meaning. Where a comment described the defect in the present
tense although the same round had fixed it, the sentence was wrong and not just its id:
`digestTableMockExecute`'s docblock claimed the shared fixture still invents digest rows.
And where what survives is a residual rather than the closed entry, the citation moved to
the entry that still exists - T6 for the shared fixture nothing pins, B78 for the delta that
rests on one frozen concurrency constant.

`grep -rnE '\b(D32|B12|B38|B76|AU3)\b' src tests next.config.ts e2e scripts` is now empty.

Comment-only: `bun run test` 13062 pass / 0 fail, typecheck and format clean.

* docs(api,agent,tests): the new refusal was missing from its own contract, and a ledger claim had gone stale

Two external reviews of #512 were checked finding by finding. What held:

- `docs/API_DOCS.md` documented `POST /api/agent/runs` refusals without the 400
  this branch added, so the contract still promised `202` for `investigation` on
  MySQL followed by an `engine-unsupported` run. The refusal is now listed with
  the message it actually sends, and a note says what changed for a client that
  read the old shape.
- `docs/AGENT.md` said in its opening claims that any other engine "fails
  profiled acquisition and the run ends `engine-unsupported`", which now
  describes only the fallback: the route refuses before a run id exists. Both
  gates are named in the order they run, and the endpoint table row that omitted
  the 400 carries it too.
- Four citations named ids this branch deleted. `docs/AGENT_GUIDE.md` was the
  worst of them, because the sentence itself had gone stale: it still told the
  reader a failed statement records no duration, which is exactly what stopped
  being true. It now says what a failed statement records and what an older
  refusal does not. `docs/providers/README.md` (two rows) and the provenance
  prose in `docs/BACKLOG.md` name the change instead of the closed id; the one
  surviving mention says outright that the id was reused, which is why citing it
  is unsafe.

What did not hold, and was refused rather than adopted:

- A finding placed `elapsedMs: 0` in a file that does not exist. The line number
  was right for `src/components/agent/timeline.ts`, and the observation under it
  was real: the fold asks `=== undefined`, the two tests around it separate
  `undefined` from `30`, and a truthiness rewrite passes both while moving a
  statement the tracker charged 0 ms into the count of statements that measured
  nothing. A test now pins the measured zero, and reverting the comparison fails
  that test alone.
- SonarCloud's three `act()` reports are dated before this branch and their rule
  is wrong here: `fireEvent` inside `await act` is what flushes the pending
  start, not a redundant wrapper. The `toHaveLength` nit on this branch's own
  test is taken.
- The nested-ternary smell in `AgentRail.tsx` is declined, and the reason is the
  finding underneath it. Three of the four decline sentences are asserted;
  `connectionDropped` appears in no test in the repository, and all four arms
  being one expression is why 100% line coverage cannot see that. Extracting the
  selection would fail the coverage gate until the fourth arm is driven, which is
  the honest order. Filed as T7.
- B77 gains the half a review pointed at: an unreadable digest source leaves the
  health list empty on purpose, and the projection then hands the model
  `slowQueryCount: 0` — the absence rule one level up from the row #512 removed.

Gates: format, lint, typecheck, knip clean; 13063 pass / 0 fail; build exit 0;
coverage 43652/43652 lines (100.00%).
cevheri added a commit that referenced this pull request Aug 27, 2026
Five backlog entries close. Every one is the same defect wearing different clothes: a
reading that could not be taken, or a list that was cut, reaching a reader as a confident
figure. The absence rule (#477) is house law here, and these are the places it was not
applied.

D40 - the Overview panel's connection count. `DatabaseOverview.activeConnections` is
optional for the reason its docblock gives, and SQL Server, Oracle and MongoDB all
initialised a local to 0 and swallowed the read's failure into it. So a denied
VIEW SERVER PERFORMANCE STATE, an unprivileged V$SESSION or a serverStatus without a
connections section drew a confident "0 connections" on a server with plenty. All three now
omit the key, and a real zero still reads as zero - both arms pinned per provider.

The existing SQL Server test was asserting the fabrication rather than a measurement:
`expect(typeof overview.activeConnections).toBe("number")` passed because the fabricated 0
is a number. Its purpose-built fixture was dead code, shadowed by a generic branch that
matched the same statement, so the assertion had never seen the value it was written for.

D41 - two surfaces reading a cap as a count. The Queries tab's *Queries* card summed the
calls of a list every provider caps at ten and labelled it the database's query count;
*Slow* was bounded by ten however many slow statements the server held. Measured against a
MySQL server holding 59 digests for one schema, both cards were the ceiling wearing a
measurement's label. On the provider side TRINO_MAX_STATS_TABLES cut the table-stats read
inside the provider, where no marker above it could see the cut; it now refuses rather than
truncating, and its refusal names the real count.

D36 - the migration generator emitted `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY` for
SQLite, which has no ALTER that adds a constraint, so the file was a syntax error wherever
it was run. There were two emission sites, not the one the entry described: the ALTER path
and, undocumented, `generateCreateTable`, which writes a new table's foreign keys as a
separate ALTER. libSQL had the same defect there, so its earlier fix was incomplete. The
two sites want different answers - in the CREATE path the key belongs inside the CREATE
TABLE, where SQLite accepts it and nothing is lost; in the ALTER path it cannot be
expressed and is declined with a comment naming table recreation.

D38 - `getConnectionInfo` masked `:secret@` in an authority and nothing else, so a libSQL
connection string carrying its whole token as `?authToken=` was returned in the clear. The
mask now covers the authority and the value of every credential-shaped parameter, in any
position. Two rounds of review found two more shapes in the fix itself: a credential
LEADING an ADO-style string was returned verbatim, and narrowing the authority pattern had
stopped masking `postgres://user:p/w@host/db`, which the old one masked.

What that second one cannot do is covered rather than hidden. `postgres://host:5432/tenant@acme`
has the identical shape and carries no secret, so masking one masks the other, and drawing
`***` over a port asserts a credential the string never carried - a fabricated reading, which
this rule forbids more strongly than it forbids the miss. The docblock says so and the test
file pins the gap, so it is visible rather than assumed closed. D42 carries the parse-based
fix.

U23 - the Storage tab labelled the remainder of its breakdown "Other (TOAST, FSM)" on every
engine. TOAST and the free space map are PostgreSQL structures; SQLite, libSQL, MySQL,
Oracle, SQL Server, ClickHouse and the HTTP engines have neither. On libSQL it read 4.00 KB
under that label against a real 64 KB database: the number right, the words another
engine's.

Two defects found in the same files while fixing those, both pre-existing:

- The Storage tab was the third reader of `errors.tables` and the only one that did not
  read it. A refused table read arrived as an empty list, `every()` was vacuously true, and
  the tab drew a measured 0 B for Tables and Indexes with the remainder absorbing the whole
  database at 100%. It now carries the engine's own sentence, the way its two sibling panels
  already do, while a genuinely empty database still reads 0 B.
- Quick Stats published three cap-bounded figures as counts: "Slow Queries" off a list
  capped at 10, and "Active" and "Idle" off one list capped at 50, which the two badges
  split while reading as the server's totals. All three now name the rows they measure.

MongoDB's `getOverview` had a second fabricated zero beside the first: `databaseSizeBytes`
is optional for the same stated reason, and the Storage tab keys its entire breakdown off
whether that key is present. So a denied serverStatus did not hide a number, it replaced an
honest refusal with a breakdown over a zero-byte database - and with the table read still
answering, the remainder went negative, which `formatBytes` renders as the literal string
"NaN undefined". The success path had the same shape through `|| 0`, and so did `getHealth`,
whose `databaseSize` string is the one the agent forwards to the model verbatim.

Provider docs now cite code by name rather than by line, enforced by a new guard. A line
number hand-copied into prose has nothing measuring it. The eight seam rows in both search
docs were stale at birth: they entered in one commit as 751/811/831/844/860/873/884/898
while that same commit's declarations sat at 808/868/888/901/917/930/941/955 - a uniform
+57, which is exactly why nobody noticed. The rows stayed in ascending order and read as a
consistent, plausible list. Today the offset is +65. The guard bans the form, so a correct
line number fails it too, and its scope statement names what is not measured yet rather than
implying coverage.
cevheri added a commit that referenced this pull request Aug 27, 2026
* fix(db,ui,docs): a refusal drawn as a measurement, in five places

Five backlog entries close. Every one is the same defect wearing different clothes: a
reading that could not be taken, or a list that was cut, reaching a reader as a confident
figure. The absence rule (#477) is house law here, and these are the places it was not
applied.

D40 - the Overview panel's connection count. `DatabaseOverview.activeConnections` is
optional for the reason its docblock gives, and SQL Server, Oracle and MongoDB all
initialised a local to 0 and swallowed the read's failure into it. So a denied
VIEW SERVER PERFORMANCE STATE, an unprivileged V$SESSION or a serverStatus without a
connections section drew a confident "0 connections" on a server with plenty. All three now
omit the key, and a real zero still reads as zero - both arms pinned per provider.

The existing SQL Server test was asserting the fabrication rather than a measurement:
`expect(typeof overview.activeConnections).toBe("number")` passed because the fabricated 0
is a number. Its purpose-built fixture was dead code, shadowed by a generic branch that
matched the same statement, so the assertion had never seen the value it was written for.

D41 - two surfaces reading a cap as a count. The Queries tab's *Queries* card summed the
calls of a list every provider caps at ten and labelled it the database's query count;
*Slow* was bounded by ten however many slow statements the server held. Measured against a
MySQL server holding 59 digests for one schema, both cards were the ceiling wearing a
measurement's label. On the provider side TRINO_MAX_STATS_TABLES cut the table-stats read
inside the provider, where no marker above it could see the cut; it now refuses rather than
truncating, and its refusal names the real count.

D36 - the migration generator emitted `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY` for
SQLite, which has no ALTER that adds a constraint, so the file was a syntax error wherever
it was run. There were two emission sites, not the one the entry described: the ALTER path
and, undocumented, `generateCreateTable`, which writes a new table's foreign keys as a
separate ALTER. libSQL had the same defect there, so its earlier fix was incomplete. The
two sites want different answers - in the CREATE path the key belongs inside the CREATE
TABLE, where SQLite accepts it and nothing is lost; in the ALTER path it cannot be
expressed and is declined with a comment naming table recreation.

D38 - `getConnectionInfo` masked `:secret@` in an authority and nothing else, so a libSQL
connection string carrying its whole token as `?authToken=` was returned in the clear. The
mask now covers the authority and the value of every credential-shaped parameter, in any
position. Two rounds of review found two more shapes in the fix itself: a credential
LEADING an ADO-style string was returned verbatim, and narrowing the authority pattern had
stopped masking `postgres://user:p/w@host/db`, which the old one masked.

What that second one cannot do is covered rather than hidden. `postgres://host:5432/tenant@acme`
has the identical shape and carries no secret, so masking one masks the other, and drawing
`***` over a port asserts a credential the string never carried - a fabricated reading, which
this rule forbids more strongly than it forbids the miss. The docblock says so and the test
file pins the gap, so it is visible rather than assumed closed. D42 carries the parse-based
fix.

U23 - the Storage tab labelled the remainder of its breakdown "Other (TOAST, FSM)" on every
engine. TOAST and the free space map are PostgreSQL structures; SQLite, libSQL, MySQL,
Oracle, SQL Server, ClickHouse and the HTTP engines have neither. On libSQL it read 4.00 KB
under that label against a real 64 KB database: the number right, the words another
engine's.

Two defects found in the same files while fixing those, both pre-existing:

- The Storage tab was the third reader of `errors.tables` and the only one that did not
  read it. A refused table read arrived as an empty list, `every()` was vacuously true, and
  the tab drew a measured 0 B for Tables and Indexes with the remainder absorbing the whole
  database at 100%. It now carries the engine's own sentence, the way its two sibling panels
  already do, while a genuinely empty database still reads 0 B.
- Quick Stats published three cap-bounded figures as counts: "Slow Queries" off a list
  capped at 10, and "Active" and "Idle" off one list capped at 50, which the two badges
  split while reading as the server's totals. All three now name the rows they measure.

MongoDB's `getOverview` had a second fabricated zero beside the first: `databaseSizeBytes`
is optional for the same stated reason, and the Storage tab keys its entire breakdown off
whether that key is present. So a denied serverStatus did not hide a number, it replaced an
honest refusal with a breakdown over a zero-byte database - and with the table read still
answering, the remainder went negative, which `formatBytes` renders as the literal string
"NaN undefined". The success path had the same shape through `|| 0`, and so did `getHealth`,
whose `databaseSize` string is the one the agent forwards to the model verbatim.

Provider docs now cite code by name rather than by line, enforced by a new guard. A line
number hand-copied into prose has nothing measuring it. The eight seam rows in both search
docs were stale at birth: they entered in one commit as 751/811/831/844/860/873/884/898
while that same commit's declarations sat at 808/868/888/901/917/930/941/955 - a uniform
+57, which is exactly why nobody noticed. The rows stayed in ascending order and read as a
consistent, plausible list. Today the offset is +65. The guard bans the form, so a correct
line number fails it too, and its scope statement names what is not measured yet rather than
implying coverage.

* docs(backlog): five entries close, seven open in their place

Deleted: D36 (the migration generator's invalid SQLite DDL), D38 (the connection-string
mask), D40 (the Overview panel's fabricated connection zero), D41 (a cap read as a count),
U23 (PostgreSQL storage vocabulary on every engine). The three section index lines are
recomputed from the surviving headings rather than edited by hand, which the structure guard
added last round now checks in both directions.

The seven that open were all measured while closing those five, and each one is here rather
than in the PR for a stated reason:

D42 - the mask cannot cover an authority password holding a character RFC 3986 reserves,
because `postgres://host:5432/tenant@acme` has the identical shape and holds no secret. The
fix is parsing, not a better pattern.

D43 - SQLite gained `ALTER COLUMN ... SET/DROP NOT NULL` in 3.53.0, measured on 3.53.0:
it rewrites the stored schema and is enforced on insert. The generator declines it on both
ids. It cannot simply stop: sqld ships 3.47.0, and the `sqlite` provider runs on whichever
SQLite its runtime bundles, so a migration file handed to a human would run on one
deployment and fail on another.

D44 - `databaseSizeBytes` is fabricated as 0 wherever the size is unknown, in 14 of 16
type-ids, and the Storage tab keys its whole breakdown off whether the key is present.
Cassandra's own comment is the precedent and states the argument. 14 provider files, 14
docs, 14 test files, which is why it is an entry.

D45 - on SQL Server 2019 and earlier the connection count is under-reported rather than
refused. Microsoft documents `sys.dm_exec_sessions` as row-filtered, not refused, and
`sys.configurations` as needing only `public` below 2022, so the statement SUCCEEDS and
returns the caller's own session. Distinguishing that needs a permission probe and a live
instance with an ungranted login.

D46 - one fabricated connection zero survives the sweep, in the shared search provider, and
three of the five comments citing the old encoding are now false while two remain true: for
`maxConnections` zero and absence really are one fact, which is what Druid and Trino cite.
The three false ones are corrected here; moving the encoding is the entry.

U24 - two absences the Storage tab still renders as measurements: a measured zero total
draws the remainder bar full, and the same zero puts the literal string "NaN undefined" in
the cell beside it, because `formatBytes` takes `Math.log` of a negative. A refused overview
also drops the engine's sentence in a file that carries it for two other panels.

DOC4 - 62 line citations remain in the provider docs and every checkable one is stale: of
69 machine-checkable before this round, 68 were wrong; after 18 fixes, 62 remain and all 62
are wrong, over 12 docs. The other 209 point at expressions and table rows, so no heuristic
can judge them and no figure is claimed for them.

* docs(src,tests,docs): the five closed entries left 19 dangling citations

Deleting an entry leaves every bare id that cited it pointing at nothing. This is the third
round in a row where that happened, and T4 in the backlog is the standing record of the
class, so the repointing is part of the same PR rather than a follow-up.

Nineteen citations across 13 files now name #515, the PR that closed the work, which a
reader can actually open. They were spread across code comments (the migration generator,
the Trino introspection pair, three monitoring tabs), one provider doc and seven test files.

One is deliberately left alone. `tests/unit/backlog-structure.test.ts` says "main carried
D36, D37 and D38 twice", which is a statement about what happened in #511 and #513, not a
citation of an entry. It is still true, and D37 is still an open entry, so repointing it
would have made a correct sentence wrong - the same trap as blanket-repointing the five
comments about the "0 means not published" encoding, two of which describe `maxConnections`
and remain correct.
cevheri added a commit that referenced this pull request Sep 6, 2026
…connect (#573, #574) (#599)

* fix(mysql): read SHOW STATUS without LIKE and report absence

getHealth(), getOverview() and getPerformanceMetrics() asked for one status
variable at a time with SHOW STATUS LIKE '<name>'. Measured 2026-09-06 over
mysql2 3.24.2's text protocol against apache/doris:all-in-one-4.1.3, that
clause is a parse error there: errno 1105, ER_UNKNOWN_ERROR, HY000,
"mismatched input 'LIKE' expecting {<EOF>, ';'}(line 1, pos 12)", while a bare
SHOW STATUS is accepted and answers 0 rows. So the Overview and Health panels
failed outright on Doris for a filter the statement does not need (#573).

Each method now sends one bare SHOW STATUS and picks the rows it wants by
Variable_name, matched case-insensitively the way LIKE matched. getOverview()
takes Uptime and Threads_connected out of the same result, one round trip
fewer than before. SHOW VARIABLES LIKE 'max_connections' stays: Doris rejects
the clause on SHOW STATUS only, so the change touches only what a grammar
refuses.

The fabricated fallbacks go with it. Measured the same day, the status lists
differ: MySQL 26.7.0 528 rows, MariaDB 12.3.2 571, SingleStore 75, TiDB 8.5.1
13, StarRocks 3.3.22 0, Doris 4.1.3 0. TiDB publishes Uptime and not
Threads_connected; StarRocks and Doris publish neither. An unpublished
variable is now an absent reading: activeConnections is omitted from both
HealthInfo and DatabaseOverview, and startTime is omitted with uptime "N/A".
maxConnections stays a required number because 0 there means "no limit
published", but its || "151" default is gone, which reported MySQL's
compiled-in ceiling for every server that publishes none.

Docs and tests move with the code: mysql.md section 8 states the absence
contract and the measured row counts, and the provider test file drives the
Doris shape from probe-doris.json.

* docs(mysql): move the SHOW STATUS portability note below the method table

The paragraph was inserted between two rows of the section 8 method table.
In GFM a blank line and a paragraph end a table, so the five rows after it,
from getSlowQueries() to getStorageStats(), rendered as literal pipe text.
It now sits after the last row, before the graceful-degradation list, and its
two em-dashes are commas so the new prose matches the house style.

* feat(explain): add the mysql-text strategy for plain EXPLAIN output

The MySQL-wire relatives that refuse EXPLAIN FORMAT=JSON accept a plain
EXPLAIN and answer their own text or tabular plan. Measured 2026-09-06 over
mysql2 3.24.2 on the text protocol: TiDB v8.5.1 answers errno 1105 "explain
format 'json' is not supported now", StarRocks 3.3.22 and SingleStore 0.2.82
answer errno 1064, Apache Doris 4.1.3 answers errno 1105 "mismatched input
'=' expecting {<EOF>, ';'}". All four accept the bare EXPLAIN.

The new strategy is shape-driven, never engine-driven: the first column is
the node text, the remaining non-empty columns are the detail, and the
leading run of whitespace and box glyphs on the first column is the nesting.
Doris names its single column "Explain String(Nereids Planner)" and StarRocks
names it "Explain String", which is why nothing here keys on a column name.
An estRows column that parses as a finite number becomes the only metric; no
other number is invented. Blank rows are dropped, StarRocks pads its plan
with two and Doris with six.

The render model's raw is the plain text of the plan, so VisualExplain's raw
tab prints a string verbatim instead of stringifying it into one quoted line
with escaped newlines. plan-summary keeps no reading for the format: a text
plan carries no field the agent layer can read, and unknown is the honest
answer.

* feat(explain): build the EXPLAIN statement on the server (#574)

POST /api/db/query gains an optional "explain": { "mode": "estimate" |
"analyze" }. When it is present the route resolves the CONNECTED provider's
capabilities, builds the statement with that format's strategy, runs that
statement and names the format back in "explainFormat". The raw sql is never
run for an explain request: no strategy or supportsExplain false is a 400
"This server does not support EXPLAIN", a statement the strategy declines is a
400 "Only SELECT statements can be explained", and an explain request beside a
non-empty params array is a 400, since an explain run describes a statement
rather than running one with values bound into it.

The browser stops building EXPLAIN text. Both explain fetches in
use-query-execution now post the user's own statement plus the mode they use
today (analyze for the direct run, estimate for the background pre-warm), and
store the plan under the format the response names, falling back to the static
strategy when the response names none or names one this build does not
register. The pre-flight refusal stays client side, so a non-SELECT still shows
its toast and makes no request; a parameterized run now makes no plan request
at all, because the route refuses that combination.

Measured 2026-09-06 over the MySQL wire protocol (mysql2 3.24.2, text
protocol): EXPLAIN FORMAT=JSON SELECT 1 is errno 1105 on TiDB v8.5.1 ("explain
format 'json' is not supported now") and Apache Doris 4.1.3 ("mismatched input
'='"), errno 1064 on StarRocks 3.3.22 and SingleStore, while plain EXPLAIN
SELECT 1 is accepted on all of them. POST /api/db/provider-meta never connects
(#457), so the client cannot be told which form to build; the connected
provider is the only place that knows.

* style(explain): drop an em-dash from the explain refusal comment

* fix(explain): bind an explain request's parameters to the built statement

The route refused an explain request that carried bound params. That is the
opposite of the contract PR #304 relies on: every strategy's buildSql only
prefixes the statement, so the built statement carries the same placeholders in
the same order and the same values bind them. The refusal took the pre-warmed
plan away from every generated statement that sends its values separately
(#290), so it is gone and the hook sends params with the background plan request
again, exactly as before.

Also guards the format the response names. getExplainStrategy indexes an object
literal, so an inherited key such as "constructor" resolved to a truthy value
that is not a strategy and reading .format off it threw. A registered strategy
names itself, so that identity is now the guard, with a test for both the
unregistered format and the inherited key.

* fix(explain): treat a blank estRows cell as absent and refuse an unregistered format with a 400

Two review findings on the mysql-text strategy and the query route.

readEstRows() ran Number() over the raw cell, and Number("") is 0, so a row
whose estRows the engine left blank rendered a "~0 rows" badge nobody measured.
A blank cell is now an absent reading, per the absence rule (#477). The indent
is now the length the prefix strip removes, with no unreachable null arm.

The route compared the resolved strategy with null, but getExplainStrategy()
indexes a Record and answers undefined for a format outside the union, so an
external provider declaring one reached strategy.buildSql and surfaced as a
TypeError 500 instead of the 400 sentence the user can act on.

* feat(mysql): measure the EXPLAIN grammar at connect and declare it as a capability (#574)

The provider declared explainFormat: "mysql-json" for every engine on this
type id, so the Explain panel always sent EXPLAIN FORMAT=JSON. That is MySQL's
own grammar and the wire family does not share it. Measured 2026-09-06 through
mysql2 3.24.2 over the text protocol: TiDB 8.5.1 and Apache Doris 4.1.3 refuse
it with errno 1105, StarRocks 3.3.22 and SingleStore with errno 1064, Databend
1.2.925 with errno 1105 SyntaxException, and every one of them accepts a plain
EXPLAIN SELECT 1. MySQL 26.7.0, MariaDB 12.3.2, Vitess 24.0.2 and OceanBase CE
4.4.2 accept both.

connect() now probes on the pooled connection it already holds: EXPLAIN
FORMAT=JSON SELECT 1 first, then EXPLAIN SELECT 1, and the first that succeeds
names the format getCapabilities() declares. The family shares no errno for a
grammar refusal, so the probe reads success or failure and never the code,
which also keeps src/lib/db free of a branch on the product name. A refusal is
a fact about the Explain panel, not the connection, so it never becomes a
ConnectionError. Before connect() the provider still answers mysql-json, which
is what the type-driven provider-meta route reads and what it read before.

The marketplace copy gate derived its explain-capable engine set by grepping
provider sources for a literal explainFormat line. It now reads the measured
form too, so MySQL stays nameable in a listing's explanation claim.

* docs(providers): close D30 and X14, re-tier Doris, and record the 2026-09-06 re-measurement

The nine MySQL-wire relatives were re-measured on 2026-09-06, at the wire with
mysql2 3.24.2 and then in a browser against the built app. This sweep makes the
registry and the docs say what those runs found.

Apache Doris moves from partial to full. Both surfaces that used to fail were
ours: the overview and health panels sent SHOW STATUS LIKE, which the Doris
grammar rejects, and the Explain panel sent EXPLAIN FORMAT=JSON, which it
rejects too. All fifteen answer now. The overview publishes uptime N/A and
Connections N/A, not published, because Doris answers a bare SHOW STATUS and
SHOW VARIABLES LIKE 'max_connections' with zero rows each.

StarRocks stays partial: health, sessions and the monitoring dashboard have no
information_schema.PROCESSLIST, which is the engine's own. Its overview no
longer fabricates 0/151, and its Explain panel renders StarRocks's own text
plan, 14 rows drawn as a 13-node tree.

TiDB stays full: its Explain panel renders TiDB's own operator tree with
estRows as the row estimate, and the connection count reads not published
because its SHOW STATUS has 13 rows and no Threads_connected among them.

SingleStore stays partial (row counts and sizes are zeros in
information_schema.TABLES) and its Explain panel renders the engine's own text
plan; EXPLAIN FORMAT=JSON is still a parse error there and is no longer sent.

Databend stays query-only: the Explain panel renders its own text plan, and the
overview, health and session panels stay unavailable because Databend has no
SHOW STATUS statement at all.

Vitess and OceanBase are unchanged; both accept EXPLAIN FORMAT=JSON.

D30 and X14 are deleted from docs/BACKLOG.md with their index counts corrected,
and no reference to either id remains in the repository.

* docs(providers): correct three stale registry caveats found in review

The Phase C sweep left three sentences in the registry that contradicted
what the 2026-08-24 and 2026-09-06 measurements recorded elsewhere.

StarRocks: the caveat said the whole monitoring dashboard was unavailable.
Since 2026-08-24 each panel is read independently and getMonitoringData()
returns overview, performance, slowQueries, tables, indexes and storage,
with only errors.activeSessions carrying the engine's sentence. The caveat
now names the health request and the active-session panel, which are the
two surfaces the missing information_schema.PROCESSLIST actually costs.

SingleStore: the caveat still claimed in the present tense that Test
Connection, health, the overview and the monitoring dashboard all fail on
the prepared-statement message. Those four failures were ours and were
fixed on 2026-08-24 when every parameterless statement moved to MySQL's
text protocol; the sentence is now past tense and names that fix, keeping
the ten-of-fifteen count from the first probe. Its companion sentence
said no version is displayed anywhere, which followed from the overview
being unavailable; the overview renders, so both the registry and the
docs/providers/README.md row now record the version it shows, MySQL
5.7.32, the wire version SingleStore advertises.

The registry docblock pointed both #573 and #574 at mysql.md section 5.5.
Section 5.5 carries the EXPLAIN grammar probe, section 8 the SHOW STATUS
reads, so the pointer names both.

The e2e comment above the Databend assertion cited Doris as the precedent
for asserting a tier suffix; Doris is full since 2026-09-06 and its suffix
is now asserted absent, so the comment cites StarRocks instead.

* docs(providers): finish the re-measurement wording in the README, the registry and the Docker Hub listing

Review findings on the Phase C sweep. The SingleStore README row opened in the
present tense with a count from its first probe; it now says every surface answers
since 2026-09-06 and why the row stays Partial (zeros, not measurements). Two
2026-08-24 StarRocks sentences that the 2026-09-06 Overview re-measurement
supersedes now say so. The StarRocks registry caveat claimed the rest of the
dashboard renders, which was a provider-level reading, and now says exactly that.
The Doris registry test comment counted StarRocks at eleven surfaces; it now names
the two it still loses. DOCKERHUB.md carried the pre-fix tier table: Doris is Full,
StarRocks's overview renders, and SingleStore's Explain shows the engine's own plan.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant