MySQL Provider
MySQL support for dbportal, built on the
mysql2driver. This document is the single reference point for the MySQL provider: design, architecture, usage, and tests. MySQL is a SQL-family provider; it sharesSQLBaseProviderwith PostgreSQL — read the PostgreSQL doc first if you want the canonical SQL walkthrough, then this doc for the MySQL-specific deltas.
| Status | ✅ Implemented & shipped |
| Database type id | mysql |
| Family | SQL (relational) |
| Driver | mysql2/promise |
| Query language | sql |
| Default port | 3306 |
| Connection pooling | Yes — mysql2 pool (connectionLimit = pool max, default 10) |
| Connection string | Supported (mysql://, via the pool uri option) |
| Transactions | Yes — explicit begin/commit/rollback with auto-rollback timeout |
| Query cancellation | Yes — thread-id tracking + KILL QUERY |
| Source | src/lib/db/providers/sql/mysql.ts |
| Base | src/lib/db/providers/sql/sql-base.ts |
| Tests | tests/integration/db/mysql-provider.test.ts |
1. Overview
Section titled “1. Overview”MySQL is a relational database and maps onto the DatabaseProvider interface much like PostgreSQL.
It extends the shared SQLBaseProvider (identifier quoting with backticks, automatic LIMIT
injection, cloud SSL auto-detection) and layers MySQL-specific introspection and
monitoring on top of mysql2.
The most useful way to read this doc is as a diff against the PostgreSQL provider, which is the SQL reference implementation. The headline differences:
| Aspect | PostgreSQL | MySQL |
|---|---|---|
| Schema introspection | One set of shared MATERIALIZED CTEs behind the object surface |
information_schema behind the object surface; the bulk column read is four statements per folder (§7.1) |
| Schema scope | All non-system schemas, cross-schema FKs | Single database (TABLE_SCHEMA = <db>), bare table names, lifted by the object surface (§7.1) |
| Maintenance ops | vacuum, analyze, reindex, kill |
analyze, optimize, check, kill |
| Query timeout | statement_timeout from queryTimeout |
Not wired — no server-side query timeout |
| Pool config honored | min/max/idleTimeout/acquireTimeout |
max only (connectionLimit) |
| Queries-per-second metric | undefined (needs sampling) |
Reported (Queries/Uptime) |
| BLOB/binary values | driver-native | driver-native (§3.3) |
1.1 MariaDB and the other MySQL-protocol engines
Section titled “1.1 MariaDB and the other MySQL-protocol engines”This provider is what a MariaDB connection uses: there is no mariadb type id, and choosing MySQL in
the connection dialog is the documented way to reach it. mysql2 speaks the protocol both servers
share, and the connection dialog’s WireCompatibilityHint names the engines this driver has been
measured against. The full per-engine table is in README.md;
two behaviours belong here because they are this provider’s code, not the engine’s.
The overview does not rename the server. VERSION() is the only thing that says which engine
answered. MySQL returns a bare number (8.0.35), so labelServerVersion() supplies the vendor;
MariaDB, TiDB, Vitess and OceanBase return a build string that already names themselves
(12.3.2-MariaDB-ubu2404), and that string is passed through unchanged. Prefixing it would assert a
vendor the server never claimed. StarRocks and SingleStore are deliberately not in that list: both
answer with a plain MySQL number and give nothing to key on. Apache Doris is the same on VERSION()
(a fixed 5.7.99), but it does put its real build in @@version_comment
(doris version doris-4.1.3-rc02-7126cf65d96), which the same query now reads alongside VERSION(),
so labelServerVersion() shows Apache Doris 4.1.3-rc02-7126cf65d96 there instead of the fictitious
number.
performance_schema is OFF by default on MariaDB. Measured on mariadb:12.3
(@@performance_schema = 0, build 12.3.2-MariaDB-ubu2404): the performance_schema tables exist,
so the metric queries do not fail — they return a row of NULLs. Cache-hit ratio, queries/sec and
buffer-pool usage are therefore absent rather than zero. The digest table behaves the same way: it is
selectable and answers 0 rows, so the slow-query list is empty rather than an error — re-measured
2026-08-27, and true of the health line only since the fix below, which is what made the OFF state
distinguishable from a broken read at all. information_schema, PROCESSLIST, EXPLAIN FORMAT=JSON, schema introspection, sizes and row counts are unaffected. Start the server with
performance_schema=ON to get the monitoring figures.
MariaDB declares two object kinds MySQL does not have. package and sequence reach the object
browser through objectKinds, which on this provider is resolved from the VERSION() string rather
than being a constant, see §7.1. That is the third of the three
behaviours on this page that are this provider’s code and not the engine’s.
The one metric that goes the other way is deadlocks: it comes from the Innodb_deadlocks row of
SHOW STATUS, which MariaDB publishes and MySQL does not, so it is the single performance figure a
default MariaDB reports and a MySQL server does not.
2. Architecture
Section titled “2. Architecture”Same Strategy-Pattern hierarchy as the other SQL providers:
DatabaseProvider (interface) → BaseDatabaseProvider → SQLBaseProvider → MySQLProviderMySQLProvider inherits the shared SQL helpers from
sql-base.ts — see the
PostgreSQL doc §2.2 for the full table. The two
that matter most here:
escapeIdentifier()quotes MySQL identifiers with backticks (`ident`), doubling any embedded backtick.prepareQuery()injectsLIMITinto bareSELECTs; the underlyinganalyzeQuery()also understands MySQL’sLIMIT offset, countsyntax (see §5.2).
Registration
Section titled “Registration”Loaded on demand by createDatabaseProvider() (factory.ts):
case 'mysql': { const { MySQLProvider } = await import('./providers/sql/mysql'); return new MySQLProvider(connection, options);}3. Design decisions
Section titled “3. Design decisions”3.1 No MATERIALIZED CTEs
Section titled “3.1 No MATERIALIZED CTEs”Unlike PostgreSQL, this provider composes no MATERIALIZED CTEs: MySQL has no such hint, and
information_schema is a set of views over the data dictionary rather than the materialised copies it
was through 5.7. The object surface’s bulk column read is four statements per folder
(§7.1) whatever the folder holds.
3.2 Single-database scope, and how the object surface lifts it
Section titled “3.2 Single-database scope, and how the object surface lifts it”The deleted flat reading parameterized every query with TABLE_SCHEMA = ? bound to config.database. MySQL
“schemas” are databases, so that surface only ever sees the connected database, and table display
names are bare (no schema.table prefixing). There is no cross-schema FK resolution to worry about.
This is a property of the FLAT surface and not of the engine: MySQL resolves a qualified name across
databases on one connection, so the object surface’s listContainers() is bound to nothing and every
database the server holds is browsable from one session (§7.1). Both
surfaces are live through Phase 1 of #789.
3.3 BLOB / binary values reach every surface AS BYTES
Section titled “3.3 BLOB / binary values reach every surface AS BYTES”The spelling changed on 2026-08-24. A BLOB/BINARY value used to reach the grid as the text
0x0102ab; it now reads \x0102ab, on every surface — the cell, the row detail sheet and the CSV —
and that is the whole point of the change: one value must not be spelled two ways depending on which
engine it came from.
sanitizeRow() walked every result row and turned each Buffer into a 0x<hex> string (an empty
one into ''). Its reason was real when it was written — the JSON a Buffer serializes to,
{"type":"Buffer","data":[…]}, is unreadable — and it expired when src/lib/export/binary.ts
(#469) started reading that exact shape. From then on the string was the only thing standing between
a MySQL BLOB and the treatment a Postgres bytea already got: the binary cell renderer, the detail
sheet, the CSV, and the per-dialect binary literal the SQL export writes. So the driver’s rows are
now handed on unchanged, Buffer values included, on both query() and queryInTransaction().
Measured on MySQL 26.7.0 (2026-08-24), replaying this export’s own INSERT for the three bytes
0102AB in r5.types.b back into a BLOB column and reading HEX():
| grid / CSV | exported INSERT | replayed, HEX(b) / LENGTH(b) |
|
|---|---|---|---|
| before | 0x0102ab |
VALUES (1, '0x0102ab') |
3078303130326162 / 8 — the ASCII of 0x0102ab |
| after | \x0102ab |
VALUES (1, X'0102ab') |
0102AB / 3 |
The before row is the defect: it stored eight characters of text where three bytes belonged, and it
stored them successfully. An empty BLOB reads \x rather than the empty string it used to
report — an empty byte string and a zero-length VARCHAR are different values and were spelled the
same — and it exports as X'', which replays to LENGTH(b) 0. A NULL stays NULL and exports as
NULL.
One consequence worth stating: the JSON response now carries about four characters per byte instead of two, exactly as Postgres’s does, so a very large single cell is no cheaper here than it is there.
3.4 Which wire protocol a statement takes
Section titled “3.4 Which wire protocol a statement takes”mysql2 speaks two protocols and this provider uses both. Every statement it issues goes through one
module-local helper, runStatement(queryable, sql, params?)
(mysql.ts), which picks by a single fact:
| Statement | Method | Protocol |
|---|---|---|
| carries parameters | conn.execute(sql, params) |
binary, server-side prepared |
| carries none (or an empty array) | conn.query(sql) |
text |
Parameterised statements are unchanged: the placeholders are what the prepared protocol is for, and
binding is what keeps a value out of the SQL text. So every information_schema read that names the
database stays prepared, while SHOW STATUS, SHOW VARIABLES, SELECT VERSION(),
SHOW BINARY LOGS, the maintenance statement, KILL, and a parameterless statement from the editor —
EXPLAIN FORMAT=JSON … among them — go over the text protocol.
Why. Everything used to call execute, parameterless statements included, and three engines
refuse whole statement classes on the prepared protocol with This command is not supported in the prepared statement protocol yet. Measured 2026-08-20 against a live SingleStore 9.1.1
(ghcr.io/singlestore-labs/singlestoredb-dev:0.2.82), both ways over one connection:
| Statement | conn.execute |
conn.query |
|---|---|---|
SHOW STATUS LIKE 'Uptime' |
ER_UNSUPPORTED_PS |
succeeds |
SHOW VARIABLES LIKE 'max_connections' |
ER_UNSUPPORTED_PS |
succeeds |
EXPLAIN <select> |
ER_UNSUPPORTED_PS |
succeeds |
EXPLAIN JSON <select> |
ER_UNSUPPORTED_PS |
succeeds |
EXPLAIN FORMAT=JSON <select> |
ER_PARSE_ERROR |
ER_PARSE_ERROR |
OPTIMIZE TABLE customers |
ER_UNSUPPORTED_PS |
succeeds |
CHECK TABLE customers |
ER_UNSUPPORTED_PS |
succeeds |
ANALYZE TABLE customers |
succeeds | succeeds |
SELECT VERSION() |
succeeds | succeeds |
That cost SingleStore its Test Connection, health, overview, monitoring dashboard and two of its three
maintenance actions; the registered StarRocks 3.3 row in README.md records the same
failure for its overview. It is not only those two: MySQL 26.7.0 itself refuses CHECK TABLE on the
prepared protocol — measured 2026-08-24 on mysql:latest, ER_UNSUPPORTED_PS on execute and OK on
query, while the other statements above worked either way. So one of this provider’s own three
maintenance actions was unavailable on the engine it is named for.
The Explain panel was NOT one of the recovered surfaces, and the row above is why. EXPLAIN FORMAT=JSON is a parse error on SingleStore on BOTH protocols — re-measured 2026-08-24 on the same
image — because SingleStore’s grammar is EXPLAIN JSON <select>. The protocol was never what stopped
it there. (An earlier note recorded EXPLAIN FORMAT=JSON as succeeding on the text protocol; the
statement that succeeds is plain EXPLAIN.) Reaching a plan on that engine needs a different
STATEMENT, not a different protocol, and that is what
§5.5 now sends: the provider measures which EXPLAIN
grammar the server accepts when it connects, and an engine that refuses EXPLAIN FORMAT=JSON gets the
plain EXPLAIN of mysql-text.ts instead of a failing panel.
The probe statement carries no parameters, so it takes the text protocol like every other statement of
that shape.
The read path is safe to move because the two protocols decode to the same JS shapes. mysql2
decodes text and binary rows on different code paths, so this was measured rather than assumed:
2026-08-24 on MySQL 26.7.0, the same SELECT * over one connection both ways, across a probe table
covering TINYINT(1), INT, BIGINT past 2^53, BIGINT UNSIGNED, DECIMAL(20,4), FLOAT,
DOUBLE, DATE, DATETIME, TIMESTAMP, TIME, YEAR, CHAR, VARCHAR, TEXT, BLOB,
BIT(1), BIT(8), JSON, ENUM, SET and NULLs:
- every value identical by
typeofand byJSON.stringify— including theBufferforBLOBand bothBITwidths (§3.3), theDatefor the three temporal types, the string forDECIMALandTIME, the parsed object forJSON, and the same9007199254740992for aBIGINTwritten as9007199254740993; - every
FieldPacketidentical incolumnType,flags,characterSet,columnLengthanddecimals, socolumnTypes(§5.4) names the same types either way; - a statement with no result set answers the same
ResultSetHeaderobject, which is what the envelope below reads.
Measured after the change, through this provider. 2026-08-24, MySQLProvider driven directly
against three live servers:
| Surface | MySQL 26.7.0 | SingleStore 9.1.1 | StarRocks 3.3 |
|---|---|---|---|
getHealth (Test Connection, header badge) |
ok | recovered | still fails — Unknown table 'information_schema.PROCESSLIST', the engine’s own gap, not the protocol |
getOverview |
ok | recovered (reads MySQL 5.7.32, the wire version) | recovered (reads MySQL 5.1.0, the fictitious version()) |
getPerformanceMetrics |
ok | answers {} — nothing measured rather than a fabricated number |
answers {} |
getStorageStats |
ok | recovered | recovered |
| object reads, table/index stats, editor query, transactions | ok | ok | ok |
maintenance analyze / optimize / check |
all three ok (check recovered) |
all three ok (optimize, check recovered) |
n/a |
| Explain | ok, EXPLAIN FORMAT=JSON (the connect probe measures mysql-json) |
Explain tab renders the text plan, browser, 2026-09-06: the probe measures mysql-text, the panel sends plain EXPLAIN, and one row came back for a constant SELECT (§5.5) |
Explain tab renders the text plan, browser, 2026-09-06: the same probe and statement, 14 rows drawn as a 13-node tree |
One behaviour does differ, and only for a connection that opted into multipleStatements=true in its
connection string: a ;-separated statement is rejected by the prepared protocol and accepted by the
text one, which then answers an array of result sets. That is the shape CALL <procedure>() already
answers today on both protocols, so nothing new reaches the envelope; the app splits multi-statement
input itself (POST /api/db/multi-query) and issues one statement per call.
rowCount is rows.length only when the driver returns a row array (i.e. SELECT); for a
non-SELECT statement mysql2 returns a ResultSetHeader rather than an array, and the provider
reports its affectedRows — see §5.1 for the full envelope.
This section used to say affected-rows was not surfaced and rowCount was reported as 0. That
described the intent of one line; the line beside it called .map on the same header and threw, so
what the user actually got for every DDL and DML statement was an error for work the server had
already done.
3.5 No server-side query timeout
Section titled “3.5 No server-side query timeout”The pool config, built by buildPoolConfig() (mysql.ts), intentionally sets only
mysql2-specific options and does not translate ProviderOptions.queryTimeout into a server-side
timeout (MySQL has no direct statement_timeout pool option like Postgres). A runaway query is not
auto-killed by the provider; cancellation is explicit via cancelQuery().
3.6 Maintenance over all tables when no target
Section titled “3.6 Maintenance over all tables when no target”analyze/optimize/check without a target run against all base tables in the database
(getAllTablesForMaintenance(), capped at 50 tables, mysql.ts),
each name quoted via escapeIdentifier(). With a target, the single quoted table is used.
4. Connection
Section titled “4. Connection”4.1 Configuration
Section titled “4.1 Configuration”Two forms (validate(), mysql.ts). validate()
requires host and database only when no connectionString is given — it does not reject
supplying both; if both are present the connection string is used (passed to the pool as uri).
// Discrete fields (host + database required when no connection string)const a = { id: 'my-1', name: 'App DB', type: 'mysql', host: 'localhost', port: 3306, database: 'app', user: 'root', password: 'secret', createdAt: new Date() };
// Connection stringconst b = { id: 'my-1', name: 'App DB', type: 'mysql', connectionString: 'mysql://root:secret@localhost:3306/app', createdAt: new Date() };4.2 Connection pooling
Section titled “4.2 Connection pooling”connect() builds a mysql2 pool and validates it by acquiring/releasing one connection. The pool
options set by buildPoolConfig() (mysql.ts):
| mysql2 option | Value | Source |
|---|---|---|
connectionLimit |
pool max (default 10) |
ProviderOptions.pool.max |
waitForConnections |
true |
fixed |
queueLimit |
0 (unbounded queue) |
fixed |
enableKeepAlive |
true |
fixed |
keepAliveInitialDelay |
10000 ms |
fixed |
timezone |
'Z' |
ProviderOptions.timezone ?? 'Z' (discrete form only — see below) |
⚠️ Only
maxfromDEFAULT_POOL_CONFIGis honored.min,idleTimeout, andacquireTimeoutare not mapped (the mysql2 pool model differs frompg), andqueryTimeoutis not applied (see §3.5).⚠️ When a
connectionStringis supplied,buildPoolConfig()returns{ ...baseConfig, uri }and takes the discrete-fields branch not at all — sotimezone,ssl/connection.ssl, and cloud SSL auto-detect are ignored; those settings must be encoded in the URI itself.
connect() is idempotent. Unlike the PostgreSQL provider, MySQL exposes no getPoolStats().
4.3 SSL
Section titled “4.3 SSL”buildSSLConfig() (mysql.ts) — applied only in the
discrete-fields form (the connectionString path bypasses it entirely). Note disable returns
undefined (mysql2’s “off”), not false:
- Explicit
connection.ssl(SSLConfig):disable→undefined;require→rejectUnauthorized: false(the one mode that encrypts without verifying);verify-system/verify-ca/verify-full→rejectUnauthorized: true;caCert/clientCert/clientKey→ca/cert/key.verify-systempasses noca, so mysql2 handstls.connectNode’s own trust store — that mode exists precisely so a managed endpoint can be verified with no PEM to paste. mysql2 exposes no separate host-name check, soverify-caandverify-fullbuild the same object. options.ssl === trueor cloud auto-detect —shouldEnableSSL()(options.ssl === trueor a known managed host) enables{ rejectUnauthorized: false }.- Otherwise
undefined.
ssl-mode in a pasted URL
Section titled “ssl-mode in a pasted URL”The paste box (connection-string-parser.ts) reads the
query string, so mysql://host/db?ssl-mode=REQUIRED arrives with SSL Mode already set. The values are
matched case-insensitively (MySQL writes them upper-case) and sslmode is accepted as an alias:
DISABLED → disable, REQUIRED → require, VERIFY_CA → verify-ca, VERIFY_IDENTITY →
verify-full (it checks the hostname as well as the chain).
The boolean spellings are read too, and mapped at both ends because a boolean has no opportunistic
value: ?ssl=true, ?ssl=1, ?useSSL=true → verify-system; ?ssl=false, ?ssl=0,
?useSSL=false → disable. An explicit ssl-mode wins when a string carries both.
The rule (D26, stated in readBooleanTLS) is that a boolean maps onto the mode matching what the
engine’s own driver does with it, never onto a weaker one — and the driver this provider uses is
mysql2, which defaults rejectUnauthorized to true for any ssl object it is handed
(node_modules/mysql2/lib/connection_config.js:171). verify-system is that behaviour exactly:
verified, with no CA certificate to find. The mapping was require until verify-system existed —
rejectUnauthorized: false (mysql.ts), encrypted with the
chain unchecked — because the only verifying modes on the form were the two that demand a PEM.
One spelling is now mapped stronger than its writer meant: Connector/J’s useSSL=true leaves
verifyServerCertificate off unless sslMode is VERIFY_CA/VERIFY_IDENTITY. That direction is the
deliberate one — a connection refused for an unverifiable certificate says so on screen, while a
silent downgrade to unverified TLS says nothing at all, and the SSL / TLS panel is one click away for
a server presenting a self-signed certificate. mysql2’s
object form (?ssl={"rejectUnauthorized":true}) is not a boolean and is reported in the banner rather
than guessed at.
PREFERRED is not mapped, and neither is any spelling the map does not know. It means “encrypt if
the server offers it”, and mapping it onto disable would downgrade a connection that was in fact
encrypted: measured over TCP against MySQL with its default self-signed certificate,
--ssl-mode=PREFERRED negotiated TLS_AES_128_GCM_SHA256 while --ssl-mode=DISABLED left
Ssl_cipher empty. Mapping it onto require is the mirror-image guess. So the mode the form already
holds is left alone and the paste banner names the parameter it declined to act on; choose SSL Mode
yourself in the SSL / TLS panel.
5. Query interface
Section titled “5. Query interface”5.1 Execution
Section titled “5.1 Execution”query(sql, params?, queryId?) (mysql.ts) acquires a
pooled connection, optionally records its threadId for cancellation, runs the statement over the
protocol its parameters imply (§3.4), and returns the
standard envelope with the driver’s own values
(§3.3):
{ rows, fields: string[], rowCount: rows.length, executionTime, columnTypes? }Native mysql2 errors are normalised via mapDatabaseError() into the shared
errors.ts classes.
A statement that returns no result set — every DDL statement, and INSERT/UPDATE/DELETE —
answers a different envelope, because mysql2 hands back a different thing. The driver’s first return
value is an array of rows only when the statement produced a result set; otherwise it is a
ResultSetHeader OBJECT and the field packets arrive as undefined — the same both ways, measured on
either protocol (§3.4). Printed verbatim out of mysql2
against mysql 26.7.0 for INSERT INTO r5_hdr (note) VALUES ('a'),('b'):
{ fieldCount: 0, affectedRows: 2, insertId: 1, info: "Records: 2 Duplicates: 0 Warnings: 0", serverStatus: 2, warningStatus: 0, changedRows: 0 }So the answer is no rows, no fields, no columnTypes, and the affected-row count in rowCount:
{ rows: [], fields: [], rowCount: header.affectedRows, executionTime }That matches what the other SQL providers here already report for the same statements — SQL Server
uses rowsAffected[0], SQLite changes, PostgreSQL pg’s own rowCount — and rowCount is the
number the results footer renders. insertId, changedRows and warningStatus are dropped:
QueryResult models none of them. affectedRows is the matched count, so a no-op
UPDATE … SET note = note reports 1 while changedRows is 0 — the same way SQL Server’s
rowsAffected counts.
Until this was fixed, both this path and queryInTransaction() called .map on that header and threw
result.rows.map is not a function — after the server had already applied the statement. Measured
through createDatabaseProvider({type:"mysql"}) on 2026-08-23: DROP TABLE, CREATE TABLE, INSERT,
UPDATE and DELETE each failed, and a following SELECT returned the row the failed INSERT had
written. Reporting a failure for work that landed is the answer that makes a user retry and
double-apply it.
5.2 Automatic LIMIT injection
Section titled “5.2 Automatic LIMIT injection”Inherited from SQLBaseProvider.prepareQuery() (see PostgreSQL doc §5.2).
The shared analyzeQuery() recognises both standard LIMIT n [OFFSET m] and MySQL’s
LIMIT offset, count form, so an already-limited MySQL query is respected rather than double-limited.
Default page size DEFAULT_QUERY_LIMIT = 500; unlimited caps at MAX_UNLIMITED_ROWS = 100000.
MySQL’s # line comment is skipped when the statement type is read, alongside -- and /* … */
(leading-keyword.ts). This is the dialect that marker exists
for: a # note-led SELECT used to classify as an unknown statement type and reach the server with
no LIMIT at all (#275).
Every # is a comment marker here, and this provider now says so: prepareQuery() passes its own
type to the shared readers, which resolve # under MySQL’s grammar instead of the dialect-less
compromise they used to apply to everyone (see
Which dialect the readers are reading).
Three readings change on this provider, and each was wrong in a way only MySQL sees:
| Statement | Before | Now |
|---|---|---|
SELECT … # note |
not bounded at all — the bound has to go before the comment, and the reader could not rule out #tmp/ID#/XOR |
bounded, with the clause before the comment |
SELECT * FROM t # LIMIT 10 |
the commented-out bound read as a real one, so the statement ran unbounded | the comment is a comment; a real bound is added before it |
WITH t AS ( + #- drop the ) SELECT here + …) DELETE FROM users |
the #- read as a PostgreSQL jsonb operator, so the ) inside the comment closed the CTE body, the statement typed SELECT and a LIMIT was appended to a DELETE — which MySQL 8 accepts and commits |
typed DELETE, not bounded |
The third row is the one that cost more than rows: a bound on a DELETE commits part of it while the
UI reports a truncated result set. A trailing -- note was always bounded normally and is unchanged.
5.3 Query cancellation
Section titled “5.3 Query cancellation”A query issued with a queryId records its connection threadId. cancelQuery(queryId)
(mysql.ts) issues KILL QUERY <threadId> and returns
true on success (it does not verify the target was actually mid-query). The killed query surfaces
to its caller as a QueryCancelledError (MySQL emits “Query execution was interrupted”, which
mapDatabaseError() classifies as cancellation). Exposed via POST /api/db/cancel.
5.4 Declared column types
Section titled “5.4 Declared column types”mysql2 reports a column’s type as a protocol type CODE plus flags, a charset number and a length -
never a name. The codes are not one type each, so QueryResult.columnTypes
(column-types.ts) resolves them from all four
pieces. Measured on MySQL 26.7.0 over a 40-column probe table, printed straight out of the driver:
| declared | code | length | charset | flags | reported as |
|---|---|---|---|---|---|
BIGINT |
8 | 20 | 63 | 0 | bigint |
DECIMAL(10,2) |
246 | 12 | 63 | 0 | decimal |
TINYINT / TINYINT(1) / BOOLEAN |
1 | 4 / 1 / 1 | 63 | 0 | tinyint |
VARCHAR(40) |
253 | 160 | 224 | 0 | varchar |
VARBINARY(9) |
253 | 9 | 63 | 128 | varbinary |
CHAR(10) / BINARY(8) |
254 | 40 / 8 | 224 / 63 | 0 / 128 | char / binary |
ENUM('a','b') / SET('x','y') |
254 | 4 / 12 | 224 | 256 / 2048 | enum / set |
TEXT / BLOB |
252 | 262140 / 65535 | 224 / 63 | 16 / 144 | text / blob |
TINYTEXT … LONGTEXT |
252 | 1020 / 262140 / 67108860 / 4294967295 | 224 | 16 | tinytext … longtext |
JSON |
245 | 4294967295 | 63 | 144 | json |
GEOMETRY / POINT |
255 | 4294967295 | 63 | 144 | geometry (both) |
Two things that table settles, neither of which is guessable from the code alone:
- Charset 63 (
binary) separates a character type from a byte type. The BLOB flag is set forTEXTas well as forBLOBand cannot do it. - All four text tiers and all four blob tiers arrive as one code, 252. Only the length tells them apart, so the tier is read from its ceiling (a tier’s byte capacity times the charset’s maximum bytes per character - 4 for utf8mb4 - and the tiers are 256x apart, so the ranges never overlap).
What the names deliberately leave out: the length, precision or display width (decimal, not
decimal(10,2)), the unsigned suffix, and the point subtype the protocol does not carry.
Checked column by column against information_schema.COLUMNS.DATA_TYPE for the same 40-column
table - the same source the schema tree shows - 38 of 39 match exactly; the one difference is
POINT, which arrives as code 255 with nothing to distinguish it from GEOMETRY.
columnTypes is filled by query() and queryInTransaction(), and is absent entirely when no
column declared a type. Its consumers are the results grid’s column labels, the SQL-DDL export
(which prefers a declared type over its own value-shaped guess) and the agent’s state summary. This
matters most for the types whose values arrive as strings: a DECIMAL reaches the browser as
"19.99", so before this the DDL export wrote it as TEXT.
5.5 The EXPLAIN grammar is measured at connect
Section titled “5.5 The EXPLAIN grammar is measured at connect”EXPLAIN FORMAT=JSON is MySQL’s own grammar, and this provider serves every MySQL-wire relative.
Several of them reject it, so the provider asks the server rather than assuming. On connect(), on
the connection the pool check already holds, probeExplainFormat()
(mysql.ts) runs EXPLAIN FORMAT=JSON SELECT 1, and only if
that is refused, EXPLAIN SELECT 1. The first statement that succeeds names the format
getCapabilities() then declares.
Measured 2026-09-06 through mysql2 3.24.2 over the text protocol, one connection per engine:
| Engine (image) | EXPLAIN FORMAT=JSON SELECT 1 |
plain EXPLAIN SELECT 1 |
resulting explainFormat |
|---|---|---|---|
MySQL 26.7.0 (mysql:latest) |
ok, one column EXPLAIN carrying the JSON plan |
ok | mysql-json |
MariaDB 12.3.2 (mariadb:latest) |
ok, one column EXPLAIN |
ok, 10 tabular columns | mysql-json |
TiDB 8.5.1 (pingcap/tidb:v8.5.1) |
errno 1105 explain format 'json' is not supported now |
ok, columns id, estRows, task, access object, operator info |
mysql-text |
StarRocks 3.3.22 (starrocks/allin1-ubuntu:3.3.22) |
errno 1064, syntax error at column 8 | ok, one column Explain String |
mysql-text |
SingleStore (ghcr.io/singlestore-labs/singlestoredb-dev:0.2.82) |
errno 1064 | ok, one column EXPLAIN |
mysql-text |
Apache Doris 4.1.3 (apache/doris:all-in-one-4.1.3) |
errno 1105 mismatched input '=' expecting {<EOF>, ';'}(line 1, pos 14) |
ok, one column Explain String(Nereids Planner) |
mysql-text |
Vitess 24.0.2 (vitess/vttestserver:v24.0.2-mysql80) |
ok, one column EXPLAIN (the QUOTED EXPLAIN FORMAT='json' is errno 1105 there; the unquoted form the probe sends is accepted) |
ok, 12 tabular columns | mysql-json |
OceanBase CE 4.4.2 (oceanbase/oceanbase-ce:4.4.2-lts, tenant test) |
ok, 8 rows in one column Query Plan, an ASCII plan |
ok, 9 rows in the same column | mysql-json |
Databend 1.2.925 (datafuselabs/databend:v1.2.925-patch-11) |
errno 1105, SyntaxException | ok, one column explain, 5 rows |
mysql-text |
The probe reads success or failure, never the error code. The family shares no errno for a grammar
refusal: Doris and TiDB answer 1105 where StarRocks and SingleStore answer 1064, as the table shows. A
code list would have to enumerate engines, and nothing in src/lib/db branches on which product
answered. Asking the server what its grammar accepts gives the same answer without the enumeration.
For the same reason a refusal is never a connection failure: it is a fact about the Explain panel, so
connect() resolves normally and the capability carries the result.
The statement is built on the server, not in the browser. POST /api/db/provider-meta never
connects: it constructs the provider and reads getCapabilities() off it, by design (#457, no socket,
no SSH tunnel, no SQLite lock contention). A capability that is only knowable once connected therefore
cannot reach the client that way, so POST /api/db/query builds the EXPLAIN statement from the
CONNECTED provider’s explainFormat and names that format in its response
(API_DOCS.md). Before connect() the provider still answers the static
mysql-json, which is exactly what it answered before the probe existed, so the client’s pre-flight
refusal for a non-SELECT statement is unchanged.
6. Transactions
Section titled “6. Transactions”Identical lifecycle to PostgreSQL, on a dedicated connection checked out from the pool and held
for the transaction’s duration (so every statement runs on the same connection; it is not returned
to the pool until commit/rollback). Surfaced via POST /api/db/transaction.
| Method | Behaviour |
|---|---|
beginTransaction() |
pool.getConnection() + beginTransaction(), arms a 5-minute auto-rollback timer (TX_TIMEOUT_MS, mysql.ts). Throws if one is active. |
queryInTransaction(sql, params?) |
Runs on the transaction’s connection (with the same non-SELECT envelope as §5.1). Throws if none active. |
commitTransaction() / rollbackTransaction() |
Ends it, clears the timer, releases the connection. Throws if none active. |
expireTransaction() |
Timeout callback — auto-rollback() to prevent leaked locks. |
isInTransaction() |
Current state. |
7. Schema introspection
Section titled “7. Schema introspection”Every reading of this engine’s objects goes through the object surface. The flat reading that came
before it was one query for the table list plus three per table, and on MySQL it was also a cage:
every one of its reads bound TABLE_SCHEMA = config.database (§3.2),
so the app showed exactly one database with no way to reach another. It is deleted.
7.1 The object surface (#789)
Section titled “7.1 The object surface (#789)”Five container-aware methods (listContainers, countObjects, listObjects, describeObject,
describeObjects) declared in types.ts and implemented in
mysql.ts.
information_schema answers for every kind here, which is the OPPOSITE of
PostgreSQL’s “read the native catalog” reasoning and is deliberate: MySQL 8’s data
dictionary made information_schema a set of views over the dictionary tables rather than the
materialised copies it was through 5.7, so there is no cheaper native catalog to prefer, and the
SQL-standard names are the portable ones across the wire-compatible family.
objectKinds is a function of the SERVER, and this is the only provider where it is
Section titled “objectKinds is a function of the SERVER, and this is the only provider where it is”Six kinds on MySQL and eight on MariaDB. There is no mariadb type id and choosing MySQL in the
connection dialog is the documented way to reach a MariaDB server (§1.1),
so the declaration is resolved from the server’s own VERSION() string, measured once per
connect() beside the EXPLAIN grammar probe (§5.5).
The type id could not answer this question even if src/lib/db were allowed to ask it.
| Kind | role |
Catalog | Type column value | Note |
|---|---|---|---|---|
table |
relation |
information_schema.TABLES |
BASE TABLE, SYSTEM VERSIONED |
acceptsRowWrites: true; two spellings, see below |
view |
relation |
information_schema.TABLES |
VIEW |
not a row-write target |
procedure |
routine |
information_schema.ROUTINES |
PROCEDURE |
|
function |
routine |
information_schema.ROUTINES |
FUNCTION |
|
trigger |
attached |
information_schema.TRIGGERS |
n/a | attachedTo: 'table' |
event |
config |
information_schema.EVENTS |
n/a | |
package |
group |
information_schema.ROUTINES |
PACKAGE |
MariaDB only, childKinds: ['procedure', 'function'] |
sequence |
config |
information_schema.TABLES |
SEQUENCE |
MariaDB only |
An unconnected provider declares the MySQL six, which is a real surface rather than internal state:
POST /api/db/provider-meta reads capabilities off a provider it never connects (#457). The MySQL
set is the safe default of the two, because declaring a kind the server does not have draws a folder
that can never fill, while missing one costs two folders a MariaDB user regains the moment the
connection is live.
containerLevels is one level, schema, labelled Database: on MySQL the two words name the same
object. No catalog level is declared, because MySQL has exactly one and
information_schema.SCHEMATA is what a catalog would contain.
No index kind, on the same line PostgreSQL and Oracle are read against. MySQL’s own dictionary
models an index as an attribute of the table it is on: information_schema.STATISTICS is keyed by
TABLE_SCHEMA and TABLE_NAME, and an index cannot exist without them, so an index stays in
describeObject()’s output beside that object’s columns rather than becoming a container-level
folder.
A MariaDB package’s members are declared but not browsable yet. childKinds is a true statement
about the engine and Phase 2 renders it, but Phase 1’s provider surface is container-scoped end to
end, so a Procedures folder under a package would render, never badge, and expand to nothing.
The TABLE_TYPE vocabulary enumerates the ENGINE, not the fixture
Section titled “The TABLE_TYPE vocabulary enumerates the ENGINE, not the fixture”Worth stating explicitly, because the first version of this provider got it wrong and the defect was
invisible. The vocabulary came from SELECT DISTINCT TABLE_TYPE run against the seeded fixture,
which enumerates the fixture; a type neither the CASE arms nor the listing bind names falls out of
BOTH the count and the listing, so the two still agree and the object is simply absent from the tree
with every gate passing.
Enumerated by building a table for each case rather than by reading a fixture. Measured 2026-09-11 on MySQL 26.7.0 and MariaDB 12.3.2:
TABLE_TYPE |
MySQL | MariaDB | Mapped to |
|---|---|---|---|
BASE TABLE |
yes | yes | table |
VIEW |
yes | yes | view |
SYSTEM VIEW |
yes | yes | nothing, deliberately |
SEQUENCE |
no | yes | sequence |
SYSTEM VERSIONED |
no | yes | table |
TEMPORARY |
no | yes | nothing, deliberately |
A PARTITIONED table is BASE TABLE on both, so partitioning adds no spelling. ROUTINE_TYPE is
PROCEDURE and FUNCTION on MySQL plus PACKAGE and PACKAGE BODY on MariaDB, and MariaDB’s
grammar has no other routine form.
SYSTEM VERSIONED is a table and not a kind of its own. MariaDB’s system versioning is a
property of a table you still SELECT from, INSERT into and address by name, so giving it a folder
would split one concept across two. Because table therefore has two spellings, the listing binds
TABLE_TYPE IN (?, ?) with the placeholder count sized from the same vocabulary table the CASE
arms are built from, so the two cannot disagree about how many spellings a kind has.
The two exclusions are each wrong in a different way if reversed:
SYSTEM VIEWis whatinformation_schema’s own tables are, and that schema is not a container here.TEMPORARYis SESSION-SCOPED, which a pooled provider cannot address at all. Measured on MariaDB 12.3.2: aCREATE TEMPORARY TABLEis listed ininformation_schema.TABLESby the session that created it and by no other (session A sawtmp_cross, session B saw none). This provider hands out a different pooled connection per method call, so a Temporary folder would badge whatever the connection that answeredcountObjectshappened to hold, list whatever a different connection held, and hand out addresses that resolve on one connection and not the next.
A live guard, because “the vocabulary enumerates the engine” is a claim that decays
Section titled “A live guard, because “the vocabulary enumerates the engine” is a claim that decays”The table above is right today and a future MariaDB release can add to it, and the failure mode is
SILENCE: a spelling no CASE arm names is dropped from the count and from the listing alike, so the
two agree, every gate passes, and the object is absent from the tree. That is exactly how
SYSTEM VERSIONED hid here. Prose and an absence from a table do not detect anything.
So the rules are a value, CATALOG_TYPE_RULES in
mysql.ts: the modelled half is derived from
MYSQL_OBJECT_TYPES so it cannot drift, and the excluded half is a MAP of spelling to reason, so an
exclusion cannot be added without saying why. tests/live/mysql-object-vocabulary.ts then asks a
real server for its own SELECT DISTINCT TABLE_TYPE and SELECT DISTINCT ROUTINE_TYPE and exits
non-zero NAMING any value outside modelled-plus-excluded.
Where it runs. It is a live check, so it is not in bun run test or bun run test:ci:
tests/run-core.sh globs tests/unit tests/api tests/integration tests/hooks tests/security tests/evals, and nothing under tests/live/ is collected, the same arrangement
tests/live/schema-diff-dialects.ts has. It runs by hand against a disposable server, and belongs
permanently in #789’s live acceptance run:
DBPORTAL_LIVE_MYSQL_URLS="mysql://root:root@127.0.0.1:3306/app,mysql://root:root@127.0.0.1:3307/app" \ bun tests/live/mysql-object-vocabulary.tsPoint it at a MySQL and a MariaDB; a run against one server is half a measurement.
What it cannot see, stated so the guard can be calibrated. SELECT DISTINCT reports the
spellings a server’s DATA exhibits, not the spellings its grammar can produce, so both fixtures hold
one object of every spelling this provider knows about, WITH SYSTEM VERSIONING included. The one
spelling a fixture can never carry is TEMPORARY, since a temporary table dies with the session that
made it; it stays recognised through its entry in CATALOG_TYPE_RULES.tables.excluded.
The subset assertion has two ways to be vacuous and neither is visible from a live run, so both are pinned in the unit suite instead: that neither half of the rules is empty, that no spelling is in both halves, and that every exclusion carries a reason.
A table and a stored routine can share a name, and this is measured
Section titled “A table and a stored routine can share a name, and this is measured”#789 reasoned that they could and named MySQL as the engine that proves it. Measured 2026-09-11 on MySQL 26.7.0 and MariaDB 12.3.2, in one database, each statement run after the one above it:
| Statement | Answer |
|---|---|
CREATE TABLE app.foo (id INT PRIMARY KEY) |
accepted |
CREATE PROCEDURE app.foo() SELECT 1 |
accepted |
CREATE FUNCTION app.foo() RETURNS INT DETERMINISTIC RETURN 1 |
accepted |
CREATE TRIGGER app.foo BEFORE INSERT ON app.foo FOR EACH ROW ... |
accepted |
CREATE EVENT app.foo ON SCHEDULE EVERY 1 DAY DO SELECT 1 |
accepted |
CREATE VIEW app.foo AS SELECT 1 AS n |
ERROR 1050 (42S01) Table 'foo' already exists |
CREATE SEQUENCE app.foo START WITH 1 (MariaDB) |
ERROR 1050 (42S01) Table 'foo' already exists |
So a table, a procedure, a function, a trigger and an event of one name coexist in one database, and
the ONE namespace that is shared is the table/view/sequence one, and a MariaDB sequence is a table
underneath, which is why it collides. A path is therefore unique WITHIN a kind and deliberately not
across kinds, which is what assertObjectSurface in
tests/helpers/object-surface-conformance.ts
asserts and what makes the tree’s row identity path PLUS kind id. The fixture ships the pair as
app.order_archive, a table and a stored procedure, so the answer cannot quietly stop being true.
Two consequences follow for describeObject(). It takes the KIND as its second argument, and it has
to: the detail reads key the last path segment against TABLE_NAME, so a name-driven describe would
have handed app.order_archive the procedure its namesake table’s five columns. And a path segment
needs no disambiguator, unlike PostgreSQL’s: MySQL does not overload routines, measured: a second
CREATE PROCEDURE app.foo(a INT) over an existing app.foo() answers ER_SP_ALREADY_EXISTS.
A trigger nests as [database, table, trigger] because attachedTo: 'table' says it hangs off its
table, NOT because the name needs the table to be unique: measured, a trigger name is unique per
DATABASE and not per table, and a second CREATE TRIGGER app.foo on a different table answers
ER_TRG_ALREADY_EXISTS.
listContainers() reads information_schema.SCHEMATA, bound to nothing
Section titled “listContainers() reads information_schema.SCHEMATA, bound to nothing”That is what ends the single-database confinement. MySQL resolves a qualified name across databases
on one connection, unlike PostgreSQL where a pg pool is pinned to one database, so every
database the server holds is genuinely browsable from one session.
Four schemas are hidden: information_schema, mysql, performance_schema, sys. It is a
hand-written name list, unlike Oracle’s ORACLE_MAINTAINED and PostgreSQL’s pg_depend ownership
test, because neither server publishes the fact: nothing in SCHEMATA says whether a schema is the
server’s own. What makes the list safe is that all four names are RESERVED, so hiding them can never
hide a database a person created; measured 2026-09-11, SCHEMATA holds exactly these four plus the
user’s own on both servers. They are hidden from the BROWSER and stay fully reachable from the SQL
editor, the same treatment pg_catalog gets on PostgreSQL, and this provider itself reads two of
them.
Container.isSessionDefault comes from SCHEMA_NAME = DATABASE(), the server’s own answer for which
database the session is in, rather than from config.database, because the configured value is what a
person typed into a form. It is SQL NULL rather than 0 when no database was selected, which reads as
false.
countObjects() is one statement over four views
Section titled “countObjects() is one statement over four views”One UNION ALL arm per view, one GROUP BY, one round trip for a whole folder row, and it reads no
column of any table. The SAME statement text goes to both servers and nothing in it branches on the
flavour: MySQL holds no SEQUENCE row and no PACKAGE row, so those CASE arms never fire there.
The data decides, which is one fewer place the two branches can disagree.
WHERE kind IS NOT NULL drops what the CASE has no name for rather than counting it under a folder
that does not exist. Two things fall out that way:
SYSTEM VIEW, which is whatinformation_schema’s own tables are on both servers.PACKAGE BODY, which is a secondROUTINESrow for one tree node exactly as on Oracle, so counting it would double the Packages badge. The body cannot exist alone: measured,CREATE PACKAGE BODYwith no specification answersERROR 1305 PACKAGE app.orphan_pkg does not exist, so thePACKAGErow is present for every package and counting that row alone is complete.
A catalog row cannot create a folder. A kind that was not declared is skipped rather than
answered for, and on this provider that is a live case rather than defensive programming: a MariaDB
server whose version probe came back empty answers sequence and package rows against a MySQL
declaration. The DECLARATION decides which folders exist.
A refused read is { unavailable }, never 0, carrying the server’s own sentence unmapped, and
every declared kind is seeded at { count: 0 } before the read so a folder the database holds none
of renders a zero rather than disappearing. There is no partial outcome to report and no retry that
could produce one, because the four views are one statement. Worth knowing when reading a small
number: information_schema FILTERS by privilege rather than refusing, so a role that can see only
part of a database gets a real count of the part it can see.
describeObject() reads three narrow statements, and only for the TABLES kinds
Section titled “describeObject() reads three narrow statements, and only for the TABLES kinds”Columns, foreign keys and indexes, each bound to ONE database and ONE object. Only the kinds
information_schema.TABLES resolves have any of the three, so a procedure, a function, a trigger, an
event and a MariaDB package answer three empty arrays without a round trip, which is a true fact about those
kinds rather than a failed read.
A MariaDB sequence DOES describe, and that is why the rule is keyed on the CATALOG rather than on
role === 'relation': measured on 12.3.2, information_schema.COLUMNS answers eight real columns for
one (next_not_cached_value, minimum_value, maximum_value, start_value, increment,
cache_size, cycle_option, cycle_count), because a sequence is a table underneath. Its role is
config rather than relation because nobody selects rows from it.
Three differences from the deleted flat reads over the same views, all deliberate:
- No
LIMIT. The flat column read stopped at 100 columns, which a flat tree could live with and a detail panel cannot: nothing downstream can tell a cap from a count, so a 140-column table would report 100 columns as a fact. - No
GROUP_CONCATfor the index columns.group_concat_max_lenis 1024 by default on both servers (measured) and the function truncates silently at it, so a wide composite index would report a column list short by an unknowable amount. The object read takes one row per column and groups them in code, which has no cap at all. REFERENCED_TABLE_SCHEMAis read. InnoDB accepts a foreign key into another database, and the object browser is no longer confined to one, so a reference that leaves the container is qualified; a bare name there addresses a table in the wrong database.
A VIEW carries neither a row count nor a size: measured, information_schema.TABLES answers NULL in
TABLE_ROWS, DATA_LENGTH and INDEX_LENGTH for one, and 0 rows of 0 bytes would be a measurement
nobody took. TABLE_ROWS on a base table is the engine’s own estimate, the same nature as
PostgreSQL’s reltuples.
One known defect this surface inherits rather than repairs: MariaDB reports
COLUMN_DEFAULT as the DEFAULT EXPRESSION AS WRITTEN where MySQL reports the VALUE, and the two
disagree in both directions. A nullable MariaDB column with no default reads as having the default
NULL, and the string NULL means opposite things on the two servers; less visibly, MariaDB keeps
the quotes, so DEFAULT 'abc' reads back as 'abc' there and abc on MySQL. A repair that
special-cases only NULL therefore leaves every string default wrong by two characters.
Measured both ways and filed as #795, whose comment carries the
full measurement table and what “done” looks like.
describeObjects() describes a whole folder in four statements (#789)
Section titled “describeObjects() describes a whole folder in four statements (#789)”describeObjects(container, kind, limit?) answers columns, indexes and foreign keys for EVERY object of
one kind in one database, in FOUR round trips whatever the folder holds.
The single read is three statements per object, so a folder of 200 tables cost 600.
Measured on MySQL 26.7.0 against a 200-table database built by the commands below: 13 ms for one
describeObjects() against 118 ms for 200 describeObject() calls, the same 600 columns and 400 indexes.
The five decisions this engine had to make for itself, each measured rather than reasoned:
Which catalog.
The same three information_schema views describeObject() reads, and that is a real answer rather than an
assumption carried over: PostgreSQL had to leave information_schema.columns because it holds no row at all
for a materialized view or a sequence, and on this family it holds a row for every kind that has columns,
a MariaDB SEQUENCE included.
What the bulk read does NOT take from that view is the MEMBERSHIP of its answer.
The target set is read from information_schema.TABLES, the same view listObjects reads, because a view
whose base table has been dropped keeps its TABLES row and has NO COLUMNS row at all.
Measured on both servers: CREATE VIEW broken AS SELECT id FROM base; DROP TABLE base; leaves one TABLES
row and zero COLUMNS rows.
A target derived from the column read would therefore drop an object the folder lists, which is the class of
absence standing ruling 5a is about.
It is also what lets an object the three detail reads answered nothing for come back with three empty lists
rather than missing.
Which kinds have no columns.
The kinds read from information_schema.ROUTINES, plus trigger and event: a procedure, a function, a
MariaDB package, a trigger and an event answer { details: [] } with no round trip at all.
A MariaDB sequence is NOT one of them, for the reason the single read gives above, and the rule is the one
function hasColumns() so the two reads cannot disagree about it.
What bounds the read on the wire.
LIMIT ? inside the target subquery, the placeholder BOUND rather than interpolated: measured on MySQL
26.7.0 and MariaDB 12.3.2 through the binary prepared protocol, a placeholder in a derived table’s LIMIT
is accepted.
The provider binds limit + 1, so a saturated read is told from an exact one with no second count, drops the
extra object, and reports the CALLER’s limit in truncated.
An unbounded call runs a statement with no LIMIT clause and can never report truncation.
Neither the columns nor the indexes are capped: the deleted flat reading’s LIMIT 100 was an
unreported bound and is the defect truncated exists to prevent.
What orders the cut, and under whose collation.
ORDER BY TABLE_NAME in the target statement, which runs under the SERVER’s collation, and the two servers
in this family do not use the same one.
Measured: information_schema.TABLES.TABLE_NAME collates utf8mb3_bin on MySQL 26.7.0 and
utf8mb3_general_ci on MariaDB 12.3.2, and the fixture makes the difference visible rather than theoretical.
The table rows of app come back as customers, order_archive, orders on MySQL and as
customers, orders, order_archive, order_audit on MariaDB, because general_ci folds s to the weight of
S (0x53), which sorts below _ (0x5F).
The two lists differ in length as well as in order: order_audit is the WITH SYSTEM VERSIONING table, which
only the MariaDB fixture creates because MySQL has no system versioning, so MySQL holds three table rows and
MariaDB holds four (re-measured live on MySQL 26.7.0 and MariaDB 12.3.2 for #789 Task 27).
So describeObjects(["app"], "table", 2) keeps customers, order_archive on one server and
customers, orders on the other.
That order decides WHICH objects a bound keeps and nothing else: the answer is re-sorted by path in code,
which is one rule on every server, and a caller joins the two answers on path rather than on position.
A bounded read’s membership is therefore the server’s, and it is not promised to be the same on two servers
of this family.
Mixed path depth (ruling 5f).
Not in this engine’s relation set.
The kinds that have columns are all addressed [database, name]; trigger is the one kind here with a
second possible depth, and it has no columns, so the target set is one shape.
The three detail reads repeat the target subquery rather than joining a temporary of it, and that is safe for
one measured reason: a table name is unique within a database, so ORDER BY TABLE_NAME is a TOTAL order and
all four statements cut the same set.
Rows for the extra limit + 1 object are dropped in code, since the target list is what says which objects
the answer is about.
One statement for all four is not reachable here: mysql2 sends one statement per call, and JSON_ARRAYAGG
has no ordering guarantee at all, so the column order a person reads would become the order the optimizer
happened to produce.
An empty container costs ONE round trip rather than four.
Rebuilding the 200-table database the timing above was measured on, so the number is re-runnable rather than asserted:
DROP DATABASE IF EXISTS bulk26a1; CREATE DATABASE bulk26a1;DELIMITER //CREATE PROCEDURE bulk26a1.seed()BEGIN DECLARE i INT DEFAULT 0; WHILE i < 200 DO SET @s = CONCAT('CREATE TABLE bulk26a1.t', LPAD(i,3,'0'), ' (id INT PRIMARY KEY, a VARCHAR(20), b DECIMAL(10,2), KEY ix_a (a))'); PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st; SET i = i + 1; END WHILE;END //DELIMITER ;CALL bulk26a1.seed();The fixture, and running it
Section titled “The fixture, and running it”docker/mysql-init/01-object-fixture.sql and
docker/mariadb-init/01-object-fixture.sql are
mounted at /docker-entrypoint-initdb.d by the mysql and mariadb services in
database-compose.yml. Each image runs its file once, on a FRESH data
directory only, so an already-initialized container has to be recreated before an edit takes effect.
They build one object of every kind the respective server declares, in a database called app, plus a
second database reporting so the container list has something to show that the connection did not
open against, and a cross-database foreign key from app.orders into reporting.regions.
DELIMITER in those files is a CLIENT command and is correct there because the mysql client is
what runs them. It must never be sent through mysql2, which takes one statement per call and has no
notion of it. SET sql_mode = 'ORACLE' is REQUIRED for CREATE PACKAGE and rewrites the grammar of
everything after it, which is why it is the last thing in the MariaDB file.
8. Monitoring & health
Section titled “8. Monitoring & health”All monitoring reads from SHOW STATUS/SHOW VARIABLES, information_schema, and
performance_schema. getMonitoringData() (inherited) fans these out in parallel.
| Method | Primary source | Notes |
|---|---|---|
getHealth() |
one bare SHOW STATUS (Threads_connected picked client-side), information_schema.TABLES/PROCESSLIST, performance_schema |
connections, size (MB), InnoDB buffer hit %, top-5 slow queries, 10 sessions |
getOverview() |
VERSION(), one bare SHOW STATUS (Uptime and Threads_connected out of the same result), SHOW VARIABLES LIKE 'max_connections', information_schema |
version, uptime, conns, max_conns, size, table/index counts |
getPerformanceMetrics() |
performance_schema.global_status, one bare SHOW STATUS (Innodb_deadlocks picked client-side) |
cache-hit %, queries/sec (Queries/Uptime), buffer-pool %, deadlocks. Every field optional — see the degradation note below |
getSlowQueries() |
performance_schema.events_statements_summary_by_digest |
per-digest stats |
getActiveSessions() |
information_schema.PROCESSLIST |
pid, user, db, host, command, duration |
getTableStats() |
information_schema.TABLES |
sizes; bloat estimated from DATA_FREE (no live/dead tuples, no last-vacuum/analyze) |
getIndexStats() |
information_schema.STATISTICS + mysql.innodb_index_stats |
columns, unique/primary; scans = CARDINALITY (a proxy, not a real scan counter); per-index size, or absent — see the index-size note below |
getStorageStats() |
information_schema.TABLES, SHOW BINARY LOGS |
Data size, Binary Logs (if enabled), InnoDB data file (size N/A) |
No monitoring read sends SHOW STATUS LIKE '…', and that is a portability fix, not a style
choice. The whole list is read once per method and the wanted variables are picked out of it by
Variable_name, matched case-insensitively, which is how the server-side LIKE matched. Measured
2026-09-06 over mysql2 3.24.2’s text protocol against apache/doris:all-in-one-4.1.3: SHOW STATUS LIKE 'Uptime' answers errno=1105 code=ER_UNKNOWN_ERROR sqlState=HY000, mismatched input ‘LIKE’
expecting {<EOF>, ‘;’}(line 1, pos 12), because the Doris grammar has no LIKE clause on this
statement, while a bare SHOW STATUS is accepted there. So the Overview and Health panels failed
outright on Doris for a filter the statement does not need (#573).
SHOW VARIABLES LIKE 'max_connections' stays: Doris rejects the clause on SHOW STATUS only,
and the narrowest fix changes only what a grammar refuses. getOverview() also costs one round trip
fewer than before, reading uptime and connections out of the same result set.
Database size is absent, never zeroed, when it is not measured. getOverview() sizes the
database with SUM(DATA_LENGTH + INDEX_LENGTH) over information_schema.tables. A missing result
row, or a row without the size_bytes column, is no measurement at all: databaseSizeBytes is
omitted and databaseSize stays "N/A". Only a returned SQL NULL — an empty database — is a
measured zero, and that reading is published as 0/"0 B".
Graceful degradation — note the different failure modes:
getHealth()slow-queries: the digest rows, or an empty list — never a placeholder row, and on this path the reason is dropped. It used to answer a single fabricated row (“Performance schema not available”,calls: 0) whenever its statement threw, and its statement threw on every server: see the slow-query line asked for a column the digest table does not have below.HealthInfo.slowQueriesis aSlowQuery[]with no error field and no sibling carrying one, so an unreadable source is indistinguishable here from a source that measured nothing. Empty is the least-wrong shape, not a shape that carries the reason — the operator gets the reason from thegetSlowQueries()path below, which does have a channel for it.getHealth()cache-hit ratio:formatCacheHitRatio()→"N/A"when nothing was measured, and"N/A"again — rather than a failed health read — when the ratio query THROWS. A tenant can be missing theperformance_schemadatabase instead of merely having the schema off, and then the query does not answer NULLs: measured 2026-08-20 on a live OceanBase Community Edition 4.4.2.1 tenant through this provider, and reproduced onmysql:latestasERROR 1049 (42000): Unknown database '...'. That one throw used to abort the whole ofgetHealth(), so the panel showed nothing where one unavailable metric was the honest answer.getSlowQueries(): the digests, or the server’s refusal — this one does not swallow. It used toreturn []on any throw, which made a source that cannot be read look like a source that measured nothing. What throws here is never theperformance_schema-is-off path — an off server answers 0 rows without raising — it is the source being unreadable: noperformance_schemadatabase (ERROR 1049), or the grant denied on it (ERROR 1142). Letting that reject is what puts the reason on screen:getMonitoringData()(base-provider.ts) reads every panel withPromise.allSettledand records a rejected one undererrors.slowQueries, andQueriesTabrenders that throughPanelUnavailablecarrying the server’s own sentence. One refused panel costs only itself; that method throws only when all four core reads reject.getPerformanceMetrics(): every field is omitted rather than defaulted. A server withperformance_schemaOFF answers theglobal_statussub-selects with NULL instead of failing, so each reading is taken throughmeasuredNumber()and a field with nothing behind it is left out of the object entirely.deadlockscomes fromSHOW STATUS, which answers either way, so a0there is a real measurement and is reported where the server publishes one. If theperformance_schemadatabase is absent outright — the OceanBase case above, where every one of these queries raisesERROR 1049— the whole method returns{}rather than thecacheHitRatio: 99it once did. This is the rule #448 and #452 settled: ABSENCE and ZERO are different inputs, and only the first is invisible to the panels.deadlocksreads theInnodb_deadlocksrow ofSHOW STATUS, which is MariaDB’s status variable. MySQL does not publish it: re-measured 2026-09-06, MySQL 26.7.0’s 528 status rows carry no such name where MariaDB 12.3.2’s 571 do, so the field is absent on MySQL and present on MariaDB. It is the one metric that survivesperformance_schemabeing off.- An unpublished status variable is an ABSENT reading, never a zero. A bare
SHOW STATUSis accepted on every MySQL-wire engine measured 2026-09-06 but the lists differ wildly: MySQL 26.7.0 528 rows, MariaDB 12.3.2 571, SingleStore 9.1.1 75, TiDB 8.5.1 13, StarRocks 3.3.22 0 and Apache Doris 4.1.3 0. TiDB publishesUptimeand notThreads_connected; StarRocks and Doris publish neither. SoactiveConnectionsis omitted from bothHealthInfoandDatabaseOverviewwhen the row is missing, andstartTimeis omitted withuptime: "N/A"whenUptimeis. The panels render N/A / not published rather than a confident0, which is what they showed before (#477 and the docblocks intypes.ts).maxConnectionsis the documented exception and stays a required number:0there means “no limit published”, so absence and zero are the same fact. Its old|| "151"default reported MySQL’s compiled-in ceiling for every server that published none, including StarRocks and Doris, whoseSHOW VARIABLES LIKE 'max_connections'answers 0 rows, while TiDB publishes a real0.
The slow-query line asked for a column the digest table does not have
Section titled “The slow-query line asked for a column the digest table does not have”getHealth()’s slow-query line had its own statement, LEFT(sql_text, 100) over
performance_schema.events_statements_summary_by_digest, wrapped in a bare try/catch that reported
[{ query: "Performance schema not available", calls: 0, avgTime: "N/A" }]. That table has no
sql_text column. SQL_TEXT belongs to events_statements_current/_history; the digest table
carries the normalised DIGEST_TEXT — MySQL 9.4 manual, Statement Summary
Tables,
and each server’s own information_schema.columns confirms it. So the statement never returned a
row on any server, and the catch reported an engine capability as absent while the panel beside it
listed real statements from the same table.
Measured 2026-08-27 through this provider, one container per arm (--innodb-use-native-aio=0;
readiness gated on a real SELECT 1, not mysqladmin ping). The raw statement’s answer on all four:
errno=1054 code=ER_BAD_FIELD_ERROR sqlState=42S22 Unknown column 'sql_text' in 'field list'(MariaDB words the same error Unknown column 'sql_text' in 'SELECT'.)
| Server | @@performance_schema |
getHealth().slowQueries before |
after | getSlowQueries() |
|---|---|---|---|---|
MySQL 26.7.0 (mysql:latest) |
1 | “Performance schema not available” | 5 real digests — SELECT COUNT ( * ) FROM `t` at calls: 4, 1.09ms |
5 rows |
| Percona Server 8.4.11-11 | 1 | “Performance schema not available” | 5 real digests | 5 rows |
MySQL 26.7.0, --performance-schema=OFF |
0 | “Performance schema not available” | [] |
[] |
| MariaDB 12.3.2 (ships it off) | 0 | “Performance schema not available” | [] |
[] |
Three decisions came out of those measurements, and one property of the reading that none of them changes.
One statement, not two. The health line and getSlowQueries() now share
SLOW_QUERIES_BODY_SQL, differing only in the interpolated LIMIT (5 for the health line, the
caller’s for the panel), and both map the row through the same toSlowQueryStats(). Two statements
for one fact is what drifted, and the copy the health panel used was the one no test ever put in
front of a server — the mysql2 mock invented a query column for any statement over this table, so
a broken read looked like a working one for as long as it was only mocked. The mysql2 mock in
mysql-provider.test.ts now refuses sql_text the way a server does, one test asserts the provider
never asks for it, and a construction in the mock’s single call funnel records any fixture that
answers such a statement instead of refusing it (§12.1).
An empty list, not an “unavailable” marker, and the reason is that OFF does not raise. A server
with @@performance_schema = 0 keeps the digest table selectable and answers 0 rows — measured
on both arms above, and it is why MariaDB’s default has always shown an empty Queries panel rather
than an error. There is therefore no exception that means “the capability is off”, and a marker
keyed on the throw would be emitted for something other than what it says: the same defect one level
up. What does reach the catch is the source being unreadable — no performance_schema database at
all (ERROR 1049, the OceanBase tenant above) or the grant denied on it: measured on MySQL 26.7.0
with a user granted only SELECT ON d32.* plus PROCESS,
errno=1142 code=ER_TABLEACCESS_DENIED_ERROR sqlState=42000SELECT command denied to user 'nops'@'172.17.0.1' for table 'events_statements_summary_by_digest'and on that connection getHealth() still answered in full (activeConnections: 1, databaseSize: "0.02 MB", cacheHitRatio: "94.3", one active session) with slowQueries: [].
Why the refusal is not carried in this field: on the health path it is dropped. A refusal must stay
representable as a refusal (#477), and a row is the one shape it must not take: calls: 0 is a
figure nobody took, and the list was counted — the agent’s curated health reading then forwarded
health.slowQueries.length as slowQueryCount
(src/lib/agent/tools.ts), so the invented row told the model
“1 slow query” about every MySQL-family server. That projection no longer carries any length, so a
row invented here would now be silent rather than counted — which is a reason to keep it out, not a
reason it could come back.
Dropped is the honest word for it, and this paragraph says so rather than naming a carrier the
reading does not have. HealthInfo.slowQueries is a SlowQuery[]: no error field, no sibling that carries one, so a
refusal cannot be represented in this reading at all. Nothing renders it either — no component reads
HealthInfo.slowQueries (the monitoring Queries and Overview tabs read MonitoringData.slowQueries,
a different reading), and the one caller of POST /api/db/health, the 60s connection pulse in
use-connection-manager.ts, reads res.ok and
discards the body. ProviderLabels.slowQueriesEmptyState is not a carrier for it: QueriesTab
renders that one fixed sentence for every empty list whatever produced it, which is why the sentence
had to stop naming a cause (it used to end “enable the Performance Schema to see them” — the one
cause that never reaches the failure path).
The operator is not left without the reason, because the panel path has a channel:
getSlowQueries() lets the refusal reject (it used to return []), getMonitoringData()
(base-provider.ts) records it under errors.slowQueries, and
QueriesTab renders it through PanelUnavailable with the server’s own sentence. The
grant-denied and ERROR 1049 fixtures in mysql-provider.test.ts assert exactly that division: the
health line empties, getSlowQueries() rejects, and errors.slowQueries names the table.
One property of the reading the repair does not change: the list is a cap, not a count.
SLOW_QUERIES_BODY_SQL has no slowness predicate anywhere — its only WHERE term is the connected
schema, “slow” is the ordering (SUM_TIMER_WAIT DESC), and the health line’s LIMIT is 5. So on
any server with five or more digests for the schema, health.slowQueries.length is 5 permanently,
and any consumer counting it reads the limit rather than a number of slow statements. Measured 2026-08-27
on MySQL 26.7.0 (libredb-mysql): the digest table held 59 rows for one connected schema, and
the five this statement returns for it were all dbportal’s own introspection statements — the
slow-query read itself first (avg 79.11ms, calls 3), then the database-size read, SHOW STATUS LIKE ? and two PREPAREs, between 1.15 ms and 7.78 ms. Nothing in that list is slow and none of it
is the user’s workload. Raising the limit would move the saturation point without making the figure a
count; only a slowness threshold, or a differently named projection, would. The agent’s curated
health reading has since stopped projecting the length at all — it declares
activeConnections, databaseSize and cacheHitRatio only, and the slow-query facts travel on the
slow-queries reading, where the rows are visible
(src/lib/agent/tools.ts) — so this provider’s part is that the cap
and the missing threshold are stated at
HEALTH_SLOW_QUERY_LIMIT and pinned by a test that reads
the statement the health call actually issued.
Sibling engines. All nine MySQL-protocol engines in
compatibility.ts — MariaDB, Percona Server for MySQL, TiDB,
StarRocks, Apache Doris, Databend, Vitess, OceanBase, SingleStore — reach this exact code, so every
one of them showed the sentence and none of them shows it now. MariaDB and Percona are the two
measured above; on the other seven the health line now carries whatever their own
performance_schema.events_statements_summary_by_digest publishes for the connected schema, and an
empty list where it publishes nothing or the table cannot be read. OceanBase is the one whose reading
changes shape without changing meaning: its tenants have no performance_schema database at all
(ERROR 1049, measured 2026-08-20), so its health line goes from the fabricated row to [] — and
its Queries panel, which took the same [] before, now shows the tenant’s own ERROR 1049 through
PanelUnavailable, because getSlowQueries() no longer swallows it.
Index sizes: mysql.innodb_index_stats, and indexSizeBytes may be absent. The per-index byte
figure is stat_value * @@innodb_page_size for the stat_name = 'size' row of the InnoDB
persistent-statistics table, matched to the information_schema.STATISTICS row on
database/table/index name. Three consequences, all measured on 2026-08-23:
- No
INNODB_*view publishes it. The former statement summedinformation_schema.INNODB_TABLESPACES.INDEX_SIZE, a column that exists on neither MySQL 26.7.0 nor the MySQL 8.0 inside Vitess 24.0.2 (ER_BAD_FIELD_ERRORon both), so every index reported0 Bon every server. It also grouped by(t.NAME, i.NAME)while selecting a tablespace total, which made the figure per-table even when the query worked. - The schema is the one the server reports, not the one you connected to. Vitess answers
information_schema.STATISTICSwith the physical shard database —vt_probe_0, not the keyspaceprobe— so the size lookup uses theTABLE_SCHEMAvalue just returned. With that, a per-index size on Vitess reads the same 16 KB as the MySQL control; the oldLIKE 'probe/%'matched nothing. - A missing row means unavailable, not empty. Reading
mysql.innodb_index_statsneedsSELECTon themysqlschema (ER_TABLEACCESS_DENIED_ERRORfor a user granted only its own database), and MyISAM tables have no row there at all. In both casesindexSizeBytesis omitted andindexSizeis"N/A", rather than a0 Bthe server never reported.
The storage panel’s index total is the per-TABLE figure, not the sum of those per-index rows.
InnoDB has no separate primary-key index: the clustered index IS the table, so
mysql.innodb_index_stats reports the PRIMARY row’s size as the row data and summing every index
row counts that data twice. Measured on MySQL 26.7.0 against a 144 KB database, the sum read
147,456 B — 49,152 of data plus 98,304 of indexes — which drew Indexes as 100% of the database and
a remainder of -49152 B. getTableStats() carries INDEX_LENGTH as indexSizeBytes (it computed
that number and dropped it before 2026-08-23, which is why the panel had nothing to add up), and
that is what MySQL itself calls index bytes.
9. Maintenance
Section titled “9. Maintenance”runMaintenance(type, target?) (mysql.ts); targets
are backtick-quoted via escapeIdentifier():
| Type | With target | Without target |
|---|---|---|
analyze |
ANALYZE TABLE <t> |
ANALYZE TABLE <all base tables, ≤50> |
optimize |
OPTIMIZE TABLE <t> |
OPTIMIZE TABLE <all base tables, ≤50> |
check |
CHECK TABLE <t> |
CHECK TABLE <all base tables, ≤50> |
kill |
KILL <connection-id> |
throws (id required) |
getCapabilities().maintenanceOperations = ['analyze', 'optimize', 'check', 'kill']. kill
validates that the target parses as an integer connection id.
The verdict is in the result set, not in the absence of an exception
Section titled “The verdict is in the result set, not in the absence of an exception”ANALYZE, OPTIMIZE and CHECK TABLE answer a result set — one row per (table, message)
with Table / Op / Msg_type / Msg_text — and a statement the server refuses resolves
normally. Measured through the driver against MySQL 26.7.0 (libredb-mysql) on 2026-08-25:
| Statement | Rows MySQL answers |
|---|---|
OPTIMIZE TABLE \real1`` |
note “Table does not support optimize, doing recreate + analyze instead”, then status “OK” |
OPTIMIZE TABLE \missing`` |
Error “Table ‘u9t.missing’ doesn’t exist”, then status “Operation failed” |
CHECK TABLE \real1`` |
status “OK” |
So await runStatement(conn, sql); return { success: true } reported a completed operation for
a statement the server had rejected — optimize u9t answered
{"success":true,"message":"OPTIMIZE completed successfully"} while the server’s own answer was
Error / “Table ‘u9t.missing’ doesn’t exist” / “Operation failed” — and it discarded the
Msg_text that is the entire point of CHECK TABLE, whose OK-or-corruption-report is the only
thing the user asked for. readMaintenanceReport() reads those rows:
- any row with
Msg_type=error(matched case-insensitively; the server sendsError, the manual documents the set in lower case) →success: false, and the message quotes those rows with their table names, because the whole-database form names every table in one statement and a per-table Error row is the only place the failure appears; - otherwise →
success: true, and the message quotes the engine’s own texts, deduplicated: over forty tables the OK and InnoDB’s “doing recreate + analyze instead” note repeat once per table and say the same thing forty times.
After the fix, through the provider: check real1 → “CHECK: OK”, optimize missing →
success: false “OPTIMIZE failed: u9t.missing: Table ‘u9t.missing’ doesn’t exist”. This is the
same read SQLite’s check already did with PRAGMA integrity_check.
A database with no tables runs no statement. OPTIMIZE TABLE ${getAllTablesForMaintenance()}
string-joined an empty list, and MySQL answered “You have an error in your SQL syntax … near
‘’” — measured through the provider against an empty database on 2026-08-25. Nothing to do is
not a failure and it is not a syntax error either, so the whole-database form now answers
success: true with “OPTIMIZE: no tables in u9empty to run it on.” without sending anything.
Where each operation may be offered (maintenanceOperationSpecs)
Section titled “Where each operation may be offered (maintenanceOperationSpecs)”Declaring that an operation EXISTS is not enough to put a button on it: two engines that
declare the same MaintenanceType take different kinds of target, so each provider also
declares what its own operations may be pointed at. The monitoring Tables tab renders a
per-row control only where perEntity is true, the admin Operations tab a whole-database
card only where global is true, and both take the wording from label (#496).
POST /api/db/maintenance reads the same declaration since #U20, and it is the one reader that
REFUSES rather than hides: it takes the placement from whether the request carries a target
(absent or empty means whole-database) and answers 400 when this provider marks that
placement unavailable while the other one is available. On MySQL it never speaks: every
declaration above is either both placements or neither, so no request can name a placement this
provider offers in one place only.
| Operation | Control label | Per-row | Global | Why |
|---|---|---|---|---|
analyze |
Analyze Table | yes | yes | ANALYZE TABLE <t>, or every table via getAllTablesForMaintenance() |
optimize |
Optimize Table | yes | yes | OPTIMIZE TABLE <t>, same loop without a target |
check |
Check Table | yes | yes | CHECK TABLE <t>, same loop without a target |
kill |
Kill Connection | no | no | the target is a connection id from the Sessions panel |
MySQL has no VACUUM, and the base labels put “Vacuum Table” in the explorer’s row menu
and “Run Vacuum” / “Reclaim Space” on the Operations tab anyway. The labels now say
“Optimize Table” / “Run Optimize” / “Optimize Tables”, and vacuumActionOperation: 'optimize' is what makes the surfaces send optimize for them - the global card used to be
gated on the literal vacuum, so MySQL’s own wording was written and never shown (#496).
10. Capabilities & labels
Section titled “10. Capabilities & labels”getCapabilities() (mysql.ts)
Section titled “getCapabilities() (mysql.ts)”| Capability | Value |
|---|---|
queryLanguage |
sql |
supportsExplain |
true unless the server refuses both EXPLAIN grammars, measured at connect (§5.5) |
explainFormat |
mysql-json before connect() and on a server that accepts EXPLAIN FORMAT=JSON; mysql-text on one that accepts only plain EXPLAIN (TiDB, StarRocks, SingleStore, Doris); the key is ABSENT when both are refused, and supportsExplain is then false |
supportsExternalQueryLimiting |
true (from base) |
supportsCreateTable |
true (from base) |
supportsInlineRowEdit |
true — UPDATE t SET c = v WHERE pk = v is core MySQL DML |
supportsTransactions |
true — the transaction runs on one held connection through the driver’s own beginTransaction(), so the trio and the SANDBOX toggle are offered (#464) |
declaresForeignKeys |
true — inherited from the base capabilities; InnoDB declares them, so an empty list means this schema (or this role) has none, not the engine |
supportsMaintenance |
true |
maintenanceOperations |
['analyze', 'optimize', 'check', 'kill'] |
supportsConnectionString |
true |
defaultPort |
3306 |
schemaRefreshPattern |
(CREATE|DROP|ALTER|TRUNCATE)\b (from base) |
containerLevels |
one level, { id: 'schema', label: 'Database', labelPlural: 'Databases' } (§7.1) |
objectKinds |
six on MySQL, eight on MariaDB, resolved from the server’s own VERSION() string at connect and never from the type id (§7.1) |
Labels
Section titled “Labels”MySQL keeps the default SQL getLabels() from BaseDatabaseProvider (entity → Table, Select Top
50, etc.) for everything a person clicks. analyzeAction is one of them and is correct: MySQL runs
ANALYZE TABLE. The vacuum slot is not, and is overridden.
The vacuum slot (mysql.ts): vacuumAction →
“Optimize Table”, vacuumGlobalLabel → “Run Optimize”, vacuumGlobalTitle → “Optimize
Tables”, vacuumGlobalDesc → the OPTIMIZE TABLE sentence, and vacuumActionOperation →
optimize. MySQL has no VACUUM, so the base default put “Vacuum Table” in the explorer’s row
menu and “Run Vacuum” / “Reclaim Space” on the admin Operations tab for an engine whose operations
are analyze/optimize/check/kill — and because that card was gated on the literal vacuum, no
wording MySQL could have declared would have been shown (#U9,
§9).
And one monitoring field, slowQueriesEmptyState, returned by getLabels() (mysql.ts):
slowQueriesEmptyState → “Query stats come from
performance_schema.events_statements_summary_by_digest for this database. An empty list means it
recorded nothing - the Performance Schema is off, or nothing has run against this database yet.”
The monitoring Queries panel’s empty state was hardcoded to PostgreSQL’s pg_stat_statements advice
on every engine (#463) — an extension MySQL does not have under any name, while the
digest table this provider actually reads (§8) is a server switch a DBA can
act on.
11. Error handling
Section titled “11. Error handling”Native mysql2 errors are mapped by the shared mapDatabaseError()
(errors.ts). What reliably maps for MySQL:
| Situation | Error |
|---|---|
Missing host/database (no connection string) |
DatabaseConfigError |
Operation before connect() |
DatabaseConfigError (via ensureConnected()) |
connect() fails |
ConnectionError (carries host/port) |
Access denied (ER_ACCESS_DENIED, message contains access denied) |
AuthenticationError |
Connection refused / DNS (ECONNREFUSED, getaddrinfo) |
ConnectionError |
| Killed query (“Query execution was interrupted”) | QueryCancelledError |
Driver message contains timeout / timed out (e.g. Lock wait timeout exceeded, connection-acquire timeout) |
TimeoutError |
Other server errors (most ER_* codes) |
QueryError / DatabaseError carrying the original message |
The mapper is text-heuristic, so MySQL
ER_*codes that don’t match a known phrase fall through to a genericQueryError/DatabaseErrorwith the driver’s message preserved. Note the nuance on timeouts: a driver error whose message contains timeout/timed out does map toTimeoutError(the mapping is provider-agnostic). What MySQL lacks is a server-side query timeout derived fromqueryTimeout— the provider never configures one (§3.5), so it won’t auto-kill a long-running query on its own.
12. Testing
Section titled “12. Testing”12.1 How the tests work
Section titled “12.1 How the tests work”Integration tests live in
tests/integration/db/mysql-provider.test.ts.
The mysql2/promise module is replaced with an in-process mock via mock.module('mysql2/promise', …)
before the provider is imported — there is no live MySQL in the suite. The mock’s pool/connection
returns canned [rows, fields] tuples, exercising the same provider code paths as a real server.
Three mock shapes are load-bearing. A non-SELECT must be mocked as a ResultSetHeader object with
undefined fields, not as an array. An array-shaped mock is exactly what hid the
result.rows.map is not a function defect described in §5.1 — the whole suite was
green while every DDL and DML statement failed against a real server.
A mock must also refuse what a server refuses, and every fixture must, not just the one written
for the defect. The default mock used to answer ANY statement over
performance_schema.events_statements_summary_by_digest with an invented query/calls/avgTime
row, which is how the broken health statement in
§8 stayed green for as long
as it existed: it asked for a column no MySQL-family server has, and only the mock ever answered it.
About a hundred of the tests in the file run against that fixture, so the unfaithfulness was the
default condition of the suite rather than a gap in one test.
sqlTextRefusal() is the corrective, and all three digest fixtures answer with it —
defaultMockExecute, perfSchemaDisabledMockExecute and digestTableMockExecute — rejecting any
statement that names sql_text with the real ER_BAD_FIELD_ERROR (1054, 42S22, re-verified
2026-08-27 against libredb-mysql) and otherwise returning the digest columns a server returns. The
OFF fixture is the one that shows why it has to be all three: while it answered [[], []] to the
broken statement too, both readings produced [], so “the health line is empty on a server whose
Performance Schema is off” passed with the fix reverted — it modelled a server that does not exist.
On top of that, one test asserts independently of any fixture’s kindness that no getHealth() read
names sql_text at all, and one reads the statement the health call issued to pin its LIMIT 5 and
the absence of a slowness predicate.
That corrective was still unpinned, though, and said so: reverting defaultMockExecute to its
unfaithful shape left the suite at 111 pass / 0 fail, because every test that needs the refusal
installs a dedicated fixture. So the rule now lives where no fixture can opt out of it.
UNANSWERABLE_STATEMENTS is a list of statements no MySQL-family server accepts — one entry today,
the sql_text digest read, and each entry has to be a measured refusal, because a rule that
refuses what a server answers is the same defect with its sign flipped. It is evaluated in
recordCall, the one funnel every fixture in the file goes through (named, delegating and inline
alike). The match is a co-occurrence over the whole statement, which is wider than the measurement:
a join of the digest table against events_statements_current — which does have SQL_TEXT —
would trip it too. Nothing mysql.ts emits has that shape, so the over-match is recorded next to
the rule rather than paid for; the day a statement does, the rule narrows rather than gaining an
exception. A fixture that answers a listed statement is recorded rather than thrown at: a
throw from there would arrive inside getHealth()’s per-panel catch as a panel error the test under
way might legitimately be asserting, which is exactly where the original unfaithfulness did its
damage. A file-scope afterEach, file scope because the file has eight top-level describes and a
hook inside one would leave seven unguarded, drains the recorded violations and fails the single
test that produced one.
Because mysql.ts no longer emits any statement naming sql_text, nothing else in the suite can
make that rule fire, so a describe at the end of the file drives it directly: one test installs an
unfaithful fixture and asserts the violation is recorded, and one installs the shared fixture and
asserts it rejects — which pins defaultMockExecute’s fidelity (deleting its refusal branch now
fails that test by name and the file-scope hook) and simultaneously proves the guard’s .then
wrapper leaves a rejection a rejection.
Its reach is exactly the rules it carries, and only over statements a test actually sends. Deleting
perfSchemaDisabledMockExecute’s refusal branch, for instance, is not caught today: no test
installs that fixture and sends a sql_text statement, so nothing asks it the question. The guard
closes the door on a fixture that lies when asked; it does not interrogate fixtures nobody asks.
The list stays in this file rather than in tests/helpers/ until a second engine has a measured
refusal of its own. The other sixteen provider test files would receive an empty rule list, which
proves nothing about their fixtures and reads as coverage. That condition is recorded in the list’s
own docblock, where a second engine’s implementer will meet it.
And the mock connection answers both query and execute, recording which one each statement
went through. A mock that only answered execute could not tell a statement routed to the text
protocol from one left on the prepared protocol, which is what
§3.4 turns on: the MySQLProvider wire protocol block
pins the method for getHealth, getOverview, getPerformanceMetrics, the object reads,
getStorageStats, each maintenance statement, cancelQuery, the editor’s own path (with and without
parameters), the Explain statement mysqlJsonStrategy builds, and the transaction path.
⚠️ Mock isolation:
bun’smock.module()is process-wide, so files mocking different drivers cross-contaminate when they share a process. A single file is safe (one file = one process). The fullbun run testscript runs the core group in one process and is load-order flaky, so CI does not use it — the deterministic runner isbun run test:ci(per-file isolation viatests/run-core.sh); the coverage workflow usesbun run test:coverage. SeeCLAUDE.md.
12.2 Coverage
Section titled “12.2 Coverage”20+ describe blocks cover: validation (incl. connection-string bypass), connect/disconnect,
capabilities, the object surface (columns/FKs/indexes, primary-key detection), health, maintenance (all
types + kill validation), the full transaction lifecycle, queryInTransaction, query cancellation,
overview, performance metrics, slow queries, active sessions, table/index/storage stats, every SSL
branch, prepareQuery, error mapping (ER_ACCESS_DENIED, ECONNREFUSED), the non-SELECT envelope
(DDL, INSERT, UPDATE, DELETE, and the transaction path) driven from real ResultSetHeader
literals, and the wire protocol each statement takes.
It also covers the object surface (§7.1) in two blocks. object surface holds the seven conformance tests: the declared kinds and roles on each server, the
pre-connect declaration, the container list, and assertObjectSurface against the fixture’s counts
on MySQL and again on MariaDB. MySQL object listing and detail holds the rest: the catalog read
behind each kind derived from the declaration, the counting statement’s arms and exclusions, the
seeded zeros, a refused count as { unavailable }, trigger nesting at both depths, the path sort,
the detail rows, the path-shape refusals, the three version-probe outcomes, and the namespace pair
that settles whether one path can carry two kinds.
The MariaDB branch is driven by a MariaDB fixture and not by a flag. objectSurfaceFixture({ mariadb: true }) changes the string VERSION() answers and adds the two MariaDB-only row sets, and
nothing else, so a declaration that ignored the version string cannot produce two answers. Both
declarations were then re-measured end to end against live containers, mysql:latest on 33106 and
mariadb:latest on 33107, through the real mysql2 driver.
12.3 Run it
Section titled “12.3 Run it”bun test tests/integration/db/mysql-provider.test.ts # just this file (single process — safe)bun run test:ci # CI publish gate — per-file isolation (tests/run-core.sh)bun run test:coverage # CI coverage workflow — per-file core + components12.4 Optional: verifying against a live MySQL, and a live MariaDB
Section titled “12.4 Optional: verifying against a live MySQL, and a live MariaDB”The compose services carry the object-browser fixture (§7.1), so this is the way to get a server with one object of every declared kind on it:
docker compose -f database-compose.yml up -d mysql # localhost:3306, db=app, user=rootdocker compose -f database-compose.yml --profile compat up -d mariadb # localhost:3307, db=app, user=rootPoint a connection at either one with type MySQL. MariaDB is the branch worth checking by hand, because it is the one that declares Packages and Sequences. Either image runs its fixture once, on a fresh data directory only, so a container that already exists has to be recreated first.
With both up, run the catalog-vocabulary guard against them
(§7.1), which is the check that a future server has not grown a
TABLE_TYPE this provider silently drops:
DBPORTAL_LIVE_MYSQL_URLS="mysql://root:root@127.0.0.1:3306/app,mysql://root:root@127.0.0.1:3307/app" \ bun tests/live/mysql-object-vocabulary.ts13. Usage examples
Section titled “13. Usage examples”import { createDatabaseProvider } from '@/lib/db/factory';
const provider = await createDatabaseProvider({ id: 'my1', name: 'App', type: 'mysql', host: 'localhost', port: 3306, database: 'app', user: 'root', password: 'secret', createdAt: new Date(),});
await provider.connect();const res = await provider.query('SELECT id, email FROM users WHERE active = ?', [1]);const tables = await provider.listObjects(['app'], 'table');const { details } = await provider.describeObjects(['app'], 'table'); // 4 statementsawait provider.disconnect();Over the API: POST /api/db/query, POST /api/db/transaction, POST /api/db/cancel,
POST /api/db/maintenance (admin), and POST /api/db/objects/inventory.
14. Known limitations & future work
Section titled “14. Known limitations & future work”- No server-side query timeout. The pool ignores
queryTimeout; a runaway query is not auto-killed (only explicitcancelQuery()/KILL QUERY). Future: derive a per-statementMAX_EXECUTION_TIME(the SELECT execution limit) fromqueryTimeout. (Notewait_timeoutis unrelated — it bounds idle connections, not query execution.) - Pool tuning is limited to
max(connectionLimit);min/idleTimeout/acquireTimeoutare ignored. - Index
scansisCARDINALITY, an estimate of distinct values — not a real index-usage/scan counter (MySQL has nopg_stat_user_indexes.idx_scanequivalent). - Row counts (
TABLE_ROWS) are engine estimates for InnoDB, not exact counts. - Table bloat is estimated from
DATA_FREE(free space), an approximation. getPerformanceMetrics()reports nothing whenperformance_schemais off. The panels show the metrics as unmeasured rather than inventing values for them, which is correct but means a MariaDB server (see below) has no cache-hit, QPS or buffer-pool reading until it is started withperformance_schema=ON.cancelQuery()returnstrueonKILL QUERYsuccess without confirming the target was actually executing.- Cloud SSL auto-detect uses
rejectUnauthorized: false— encrypted but not authenticated (MITM-exposed). For verified TLS, set an explicitconnection.sslwith modeverify-system(nothing to paste) orverify-ca/verify-fulland acaCert.
15. References
Section titled “15. References”- Driver:
mysql2 - Source:
src/lib/db/providers/sql/mysql.ts - SQL base:
src/lib/db/providers/sql/sql-base.ts - Query limiter:
src/lib/db/utils/query-limiter.ts - Interface & DTOs:
src/lib/db/types.ts - Errors:
src/lib/db/errors.ts - Tests:
tests/integration/db/mysql-provider.test.ts - API contract:
docs/API_DOCS.md - Sibling provider docs: PostgreSQL · Apache Trino · Redis