Skip to content

MySQL Provider

MySQL support for dbportal, built on the mysql2 driver. This document is the single reference point for the MySQL provider: design, architecture, usage, and tests. MySQL is a SQL-family provider; it shares SQLBaseProvider with 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

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.



Same Strategy-Pattern hierarchy as the other SQL providers:

DatabaseProvider (interface) → BaseDatabaseProvider → SQLBaseProvider → MySQLProvider

MySQLProvider 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() injects LIMIT into bare SELECTs; the underlying analyzeQuery() also understands MySQL’s LIMIT offset, count syntax (see §5.2).

Loaded on demand by createDatabaseProvider() (factory.ts):

case 'mysql': {
const { MySQLProvider } = await import('./providers/sql/mysql');
return new MySQLProvider(connection, options);
}

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.

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 typeof and by JSON.stringify — including the Buffer for BLOB and both BIT widths (§3.3), the Date for the three temporal types, the string for DECIMAL and TIME, the parsed object for JSON, and the same 9007199254740992 for a BIGINT written as 9007199254740993;
  • every FieldPacket identical in columnType, flags, characterSet, columnLength and decimals, so columnTypes (§5.4) names the same types either way;
  • a statement with no result set answers the same ResultSetHeader object, 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.

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.


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 string
const b = { id: 'my-1', name: 'App DB', type: 'mysql',
connectionString: 'mysql://root:secret@localhost:3306/app', createdAt: new Date() };

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 max from DEFAULT_POOL_CONFIG is honored. min, idleTimeout, and acquireTimeout are not mapped (the mysql2 pool model differs from pg), and queryTimeout is not applied (see §3.5).

⚠️ When a connectionString is supplied, buildPoolConfig() returns { ...baseConfig, uri } and takes the discrete-fields branch not at all — so timezone, 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().

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:

  1. 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-system passes no ca, so mysql2 hands tls.connect Node’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, so verify-ca and verify-full build the same object.
  2. options.ssl === true or cloud auto-detect — shouldEnableSSL() (options.ssl === true or a known managed host) enables { rejectUnauthorized: false }.
  3. Otherwise undefined.

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.


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.

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.

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.

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 for TEXT as well as for BLOB and 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.


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.

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.

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 VIEW is what information_schema’s own tables are, and that schema is not a container here.
  • TEMPORARY is SESSION-SCOPED, which a pooled provider cannot address at all. Measured on MariaDB 12.3.2: a CREATE TEMPORARY TABLE is listed in information_schema.TABLES by the session that created it and by no other (session A saw tmp_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 answered countObjects happened 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:

Terminal window
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.ts

Point 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 what information_schema’s own tables are on both servers.
  • PACKAGE BODY, which is a second ROUTINES row for one tree node exactly as on Oracle, so counting it would double the Packages badge. The body cannot exist alone: measured, CREATE PACKAGE BODY with no specification answers ERROR 1305 PACKAGE app.orphan_pkg does not exist, so the PACKAGE row 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_CONCAT for the index columns. group_concat_max_len is 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_SCHEMA is 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();

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.


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.slowQueries is a SlowQuery[] 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 the getSlowQueries() 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 the performance_schema database 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 on mysql:latest as ERROR 1049 (42000): Unknown database '...'. That one throw used to abort the whole of getHealth(), 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 to return [] on any throw, which made a source that cannot be read look like a source that measured nothing. What throws here is never the performance_schema-is-off path — an off server answers 0 rows without raising — it is the source being unreadable: no performance_schema database (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 with Promise.allSettled and records a rejected one under errors.slowQueries, and QueriesTab renders that through PanelUnavailable carrying 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 with performance_schema OFF answers the global_status sub-selects with NULL instead of failing, so each reading is taken through measuredNumber() and a field with nothing behind it is left out of the object entirely. deadlocks comes from SHOW STATUS, which answers either way, so a 0 there is a real measurement and is reported where the server publishes one. If the performance_schema database is absent outright — the OceanBase case above, where every one of these queries raises ERROR 1049 — the whole method returns {} rather than the cacheHitRatio: 99 it 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.
  • deadlocks reads the Innodb_deadlocks row of SHOW 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 survives performance_schema being off.
  • An unpublished status variable is an ABSENT reading, never a zero. A bare SHOW STATUS is 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 publishes Uptime and not Threads_connected; StarRocks and Doris publish neither. So activeConnections is omitted from both HealthInfo and DatabaseOverview when the row is missing, and startTime is omitted with uptime: "N/A" when Uptime is. The panels render N/A / not published rather than a confident 0, which is what they showed before (#477 and the docblocks in types.ts). maxConnections is the documented exception and stays a required number: 0 there 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, whose SHOW VARIABLES LIKE 'max_connections' answers 0 rows, while TiDB publishes a real 0.

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=42000
SELECT 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 summed information_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_ERROR on both), so every index reported 0 B on 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.STATISTICS with the physical shard database — vt_probe_0, not the keyspace probe — so the size lookup uses the TABLE_SCHEMA value just returned. With that, a per-index size on Vitess reads the same 16 KB as the MySQL control; the old LIKE 'probe/%' matched nothing.
  • A missing row means unavailable, not empty. Reading mysql.innodb_index_stats needs SELECT on the mysql schema (ER_TABLEACCESS_DENIED_ERROR for a user granted only its own database), and MyISAM tables have no row there at all. In both cases indexSizeBytes is omitted and indexSize is "N/A", rather than a 0 B the 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.


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 sends Error, 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).


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)

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.


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 generic QueryError/DatabaseError with the driver’s message preserved. Note the nuance on timeouts: a driver error whose message contains timeout/timed out does map to TimeoutError (the mapping is provider-agnostic). What MySQL lacks is a server-side query timeout derived from queryTimeout — the provider never configures one (§3.5), so it won’t auto-kill a long-running query on its own.


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’s mock.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 full bun run test script runs the core group in one process and is load-order flaky, so CI does not use it — the deterministic runner is bun run test:ci (per-file isolation via tests/run-core.sh); the coverage workflow uses bun run test:coverage. See CLAUDE.md.

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.

Terminal window
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 + components

12.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:

Terminal window
docker compose -f database-compose.yml up -d mysql # localhost:3306, db=app, user=root
docker compose -f database-compose.yml --profile compat up -d mariadb # localhost:3307, db=app, user=root

Point 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:

Terminal window
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.ts

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 statements
await 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.


  • No server-side query timeout. The pool ignores queryTimeout; a runaway query is not auto-killed (only explicit cancelQuery()/KILL QUERY). Future: derive a per-statement MAX_EXECUTION_TIME (the SELECT execution limit) from queryTimeout. (Note wait_timeout is unrelated — it bounds idle connections, not query execution.)
  • Pool tuning is limited to max (connectionLimit); min/idleTimeout/acquireTimeout are ignored.
  • Index scans is CARDINALITY, an estimate of distinct values — not a real index-usage/scan counter (MySQL has no pg_stat_user_indexes.idx_scan equivalent).
  • 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 when performance_schema is 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 with performance_schema=ON.
  • cancelQuery() returns true on KILL QUERY success 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 explicit connection.ssl with mode verify-system (nothing to paste) or verify-ca/verify-full and a caCert.