Repository navigation
fix(providers): every MySQL DDL and DML reported a failure for work it had done, plus five more - #469
Conversation
…t had done, plus five more
Six defects, each measured against a live server before and after. Four were BACKLOG
entries; two were found while closing them and are the most serious of the six.
Every non-SELECT statement on MySQL threw. `query()` called `.map` on mysql2's
first return value, which is a `ResultSetHeader` object rather than an array for
DDL and DML, so `CREATE TABLE`, `INSERT`, `UPDATE` and `DELETE` each answered
`result.rows.map is not a function` AFTER the server had applied the statement -
the answer that makes a user retry and double-apply it. Measured through the
provider with an interleaved SELECT proving each statement had landed, and again
in the browser: a `CREATE TABLE` from the editor now reports "0 rows" with the
table created, and a two-row `INSERT` reports "2 rows". The tests could not see
it because they mocked `execute` as returning an array; the mock type is now wide
enough to express the real shape, which is what hid it.
X7 - the DDL export typed a numeric or a timestamp column as TEXT, because the
wire hands it a string. The inference was never at fault: it never saw a type.
`pg`, `mysql2`, `oracledb` and `mssql` now fill `QueryResult.columnTypes` with the
engine's own spelling, joining the six provider families that already did. The
mappings were measured, not remembered: Postgres resolves its OIDs through a
generated `pg_type` table (149 built-ins; verified over dvdrental as 128 columns,
125 named, 0 wrong), and MySQL's shared type codes are separated by charset 63 and
the length tiers rather than by assumption (38 of 39 match
`information_schema.DATA_TYPE`; `POINT` and `GEOMETRY` are indistinguishable on the
wire). In the browser a Postgres result now reads `price numeric`,
`ts timestamp without time zone`, `b bytea` in the grid, and its exported DDL says
the same instead of TEXT.
That change had a consequence which is fixed in the same PR rather than left to be
discovered: a declared type is a BARE base name, because a length cannot be
recovered from the wire, and a bare name is not legal DDL everywhere. Measured by
replay - MySQL `ERROR 1064`, Oracle `ORA-00906`, and SQL Server accepting it while
silently narrowing `nvarchar` to length 1. The export now completes a bare name to
its target dialect's unbounded form, and re-spells a foreign engine's declaration
rather than writing a `VARCHAR2` into Postgres DDL, which the shells make reachable
by keeping the connection type beside the tab's own result. Every arm replayed into
its own engine and read back out of its catalog.
X8 - the SQL INSERT and DDL exports wrote a binary value as its JSON shape, so a
replayed INSERT stored 46 bytes of `{"type":"Buffer","data":[...]}` where six bytes
belonged, successfully and silently. Each dialect now writes a literal its own
engine accepts, every spelling measured rather than assumed: Postgres's `X'...'` is
a bit string and cannot be cast to bytea, MySQL has no `0x` spelling for an empty
value, SQL Server rejects the standard form, and Oracle parses neither so the
conversion function is the literal. Replayed into seven engines, six bytes each.
D7 - three providers answered the performance panel with a fabricated cache hit
ratio, and the sweep found five more. All eight are honest now: MySQL's health read
no longer dies on a tenant with no `performance_schema` DATABASE (measured on
OceanBase as ERROR 1049, where the entry's own three sites had already been fixed
by #458), and SQLite, LibreDB, MongoDB, Couchbase, Postgres, Oracle and SQL Server
report an unmeasurable ratio as absent, which #452's tabs already render as "Not
measured". Two of those fabrications were pinned by tests - one named "reports zero
rather than a perfect score when denied" - and a denial is the ordinary case, so the
panel was inventing a red critical cache fault. `bufferPoolUsage` went with them in
three providers: it was the cache hit ratio wearing a second name, drawn and rated
as an independent gauge. In the browser: MySQL 98.8%, Oracle 99.5%, SQLite "Not
measured" where it used to claim a fixed 95%.
D12 - a pasted `rediss://` URL landed on SSL mode `disable`, so the scheme was
silently dropped. It now arrives as `require`, and so does `couchbases://`, whose
excuse for being left out ("the provider re-reads the scheme") was untrue: that
transport picks http or https from `config.ssl` alone and was posting plaintext to
18091. `mongodb+srv://` is deliberately left unset - the driver turns TLS on itself
WITH chain verification, and carrying our `require` there would have stopped an
Atlas certificate being checked. Verified in the browser and end to end against a
TLS-only Redis, both arms.
Follow-ups recorded rather than smuggled in: D13 (Oracle answers every non-SELECT
with `rowCount: 0`, including rows it just wrote), D14 (what a real buffer-pool
gauge would cost on each engine), D15 (SQLite's table size is `rowCount * 100`),
D16 (the TLS a connection string carries in its query string is still dropped), X9
(what `columnTypes` still cannot name) and X10 (MySQL and Cassandra stringify their
bytes before the export can see them, which bounds what X8 closed).
…d only half the cases An external review of #469 approved it and read the absence of the other dialects as settled. It is not, and the module's own comment is why: it justified having no row for clickhouse/trino/sqlite/cassandra by saying those engines spell their types out in full, which is true of the names THEY declare and says nothing about a name arriving from somewhere else. Measured under each target for one Oracle result: postgres writes TEXT, mysql TEXT, mssql NVARCHAR(MAX) - and clickhouse, plus the no-connection default, write VARCHAR2, BINARY_DOUBLE and CLOB, which replay nowhere. The no-connection case is a regression: a value-shaped guess used to write portable standard SQL there. Widening the rule is not the fix - Trino's own bare varchar is legal and unbounded and would become a TEXT it does not have - so each remaining dialect needs a measured row, recorded as BACKLOG X11. Comment and entry only, no behaviour change.
|
Checked the external review's findings against the code rather than taking them as read. Every factual claim in it holds:
One correction, minor: the OID table is not extracted from And one thing the review missed, which is why "correctly deferred" was too generous in one place. The review read the absence of the other dialects from
The last two rows replay nowhere, and they are reachable exactly the way the measured rows are - both shells pass the ACTIVE connection's type beside the tab's own result, so querying Oracle and switching to a ClickHouse connection before exporting is enough. The no-connection row is a small regression rather than a pre-existing gap: before these four drivers declared anything, a value-shaped guess wrote portable standard SQL there. Widening the rule is not the fix. Re-spelling from the family whenever the target is unmeasured breaks Trino, whose own bare |
|
…nly the AppImage The chart version was written when 0.13.3 was one file-mode fix; #469 landed on top of the bump and its content is what a chart user actually experiences - a MySQL statement reported as failed while the server applied it, eight providers publishing a cache hit ratio they never measured, and a pasted rediss:// URL connecting in the clear. That last one also moves `containsSecurityUpdates` to true. No CVE and no dependency moved, but a connection the user asked to encrypt was made in the clear, and this annotation is what an operator reads when deciding whether to upgrade. 0.1.47 has not published yet (the image gate skipped its publish jobs on the bump commit), so this is the last moment the annotation can be corrected: #167 freezes it once the chart is out.
…d an Oracle LOB failed the whole query (#472) * fix(providers): a ScyllaDB connection could not be created at all, and an Oracle LOB failed the whole query Six defects, each measured against a live server before and after, and each re-measured in a browser against a production build. Four were BACKLOG entries; two were found while closing them. ScyllaDB could not be added to the product. Five monitoring reads query Cassandra's `system_views` virtual tables, ScyllaDB has no such keyspace, and the provider degraded a monitoring read on a permission denial and on nothing else - deliberately, so a typo in our own CQL would not hide behind an empty panel. So the reads threw, Test Connection with them, and `handleConnect` gates the SAVE on that same request: the dialog refused to store the connection. The discriminator the entry left open turned out not to exist structurally. Measured through cassandra-driver 4.9.0 against both servers: an absent keyspace, a table typo, a column typo and a keyspace typo all arrive as code 8704 with `keyspace` and `table` undefined. The server's sentence is all there is, so the provider now reads the NAME the server refused and degrades only for a keyspace it knows is optional - `system_views`, nothing else. `system_viewz` still throws, on the engine that has the keyspace. Measured after: all thirteen surfaces answer on ScyllaDB 2026.2.4, and the Cassandra 5.0.9 control arm is unchanged and still full. The save gate is answered rather than special-cased. A server that connects and runs statements is usable, so `POST /api/db/test-connection` now separates the two facts: a failed connect is still a failure, a failed health read after a successful connect is a degraded success carrying the server's own sentence. Establish Connection reports that sentence and saves nothing on the first click; a second one saves. Three published engines were unsaveable on that gate - ScyllaDB plus StarRocks and SingleStore, which fail health for their own reason (BACKLOG D8). Browser: a ScyllaDB connection is created through the dialog on the first click, the badge reads Online rather than Slow, and the monitoring dashboard renders panels instead of one Connection Error page. An Oracle query touching a CLOB, NCLOB or BLOB failed entirely. Not merely an unreadable cell: oracledb hands back a Lob stream, `JSON.stringify` on it throws `cannot serialize cyclic structures`, and `POST /api/db/query` answers with `NextResponse.json` - so the whole SELECT was a 500. In process the value reached the grid, the CSV, both SQL exports and the agent's summary as the stream's internals. A per-call `fetchTypeHandler` maps CLOB and NCLOB to STRING and BLOB to BUFFER; per call rather than the process-wide `fetchAsString` globals, which would also apply to `getSchema` and every monitoring read and would leak out of an embedded host. No size cap, argued from measurement: a 16.4M-character CLOB costs 66ms and 1.5MB, the runtime's own ceiling fails loudly, and a truncated CLOB looks complete in the grid while the export would write the truncation as though it were the value. D13 - Oracle answered every non-SELECT with `rowCount: 0`, including rows it had just written. `rowsAffected` was on the wire all along. Both call sites now go through one builder, matching the shape mysql got in #469. Measured: INSERT 1, INSERT-SELECT 3, UPDATE 4, unmatched DELETE 0, and the transaction path does not double-count. Browser: an INSERT reports "1 rows". D15 - SQLite's per-table size was `rowCount * 100` ("Assume 100 bytes average per row") and the storage panel summed it into the Data figure. Measured against a 1.7MB file: the guess answered 20,000 bytes for both tables where `dbstat` reports 823,296 and 4,096 - 40x under and 5x over. `dbstat` exists on `node:sqlite` and not on `bun:sqlite`, and `LIBREDB_SQLITE_DRIVER` picks between them, so both answers have to be right: the fields are optional now and absent under Bun. Browser, the node driver the server actually runs: Tables 808 KB / Indexes 916 KB / Other 4 KB against a 1.69 MB database, and the arithmetic closes. X11 - a foreign engine's type name reached a target dialect nobody had measured. Two cells of that entry's own table were mis-attributed (ClickHouse takes `VARCHAR2` and `CLOB`), and the defect was wider: `decimal` narrows to `Decimal(10, 0)` there, MySQL's `set` becomes `UInt64`, and on SQLite an `enum` column takes NUMERIC affinity so `'007'` reads back as the integer 7. Four measured `STANDS_ALONE` rows added (sqlite, clickhouse, trino, cassandra), Trino's own bare `varchar` kept because it is legal and unbounded there, and the no-dialect case now writes portable standard SQL rather than an Oracle-only spelling. 8 of 15 dialects measured; the other 7 parse no CREATE TABLE at all and get the portable form, with the reason recorded per dialect. X10 - MySQL and Cassandra stringified a Buffer to `0x<hex>` at the driver boundary, so the per-dialect binary literal never got bytes. Cassandra's stated reason had expired: it existed because the wire JSON was unreadable, and that JSON is exactly what the shared binary module reads. Measured before: MySQL stored eight characters of text into a BLOB, successfully; Cassandra refused the statement outright and lost the value. After: `X'0102ab'` replays as three bytes on both. The other three rows of Cassandra's normalisation table were re-measured and left alone, with the reason each is still valid written down. Browser: the MySQL grid reads `\x0102ab` where it used to read `0x0102ab` - one spelling everywhere. Follow-ups recorded rather than smuggled in: D17 (an unmeasured connection count still reads 0), D18 (Oracle NUMBER and SQL Server DECIMAL hand back a number whose digits are already gone), D19 (Oracle's timestamps fold their offset and its intervals arrive unreadable), D20 (oracledb is declared `any`, so nothing in that provider is checked against the driver), X12 (a declared type the export cannot map still reaches every target verbatim - `jsonb` replays into neither ClickHouse nor Trino), X13 (every Cassandra CREATE TABLE the export writes is rejected, because CQL requires a PRIMARY KEY) and B57 (the agent reads a binary cell as its wire JSON). * docs(cassandra): the degradation discriminator is a message match, and say what that risks An external review of #472 flagged the regex in `absentKeyspace()` as load-bearing text the project does not control. It is, and the consequence is worth writing next to it rather than leaving a reader to work out: a future build that rephrases the refusal stops matching and the five monitoring reads throw again. What that costs is bounded, because the same PR stopped the connection dialog gating its save on the health read - the connection stays creatable and the failure reaches the user as the server's own sentence - and the four measured spellings are pinned by tests, so a rephrase fails a test rather than going quiet. The review did not name the structural alternative and there is one: asking `system_schema.keyspaces` whether `system_views` exists at all replaces the text match with a property of the server, at the cost of one read per connection. Recorded as BACKLOG D21 with what would and would not change. Comment and entry only, no behaviour change.
…489) D24 - a panel a build cannot answer is now ABSENT with the engine's own sentence rather than empty, on the Cassandra family, on Trino and on the embedded LibreDB. The mechanism landed in #477; this is the audit it asked for. Cassandra's three permanently unanswerable panels (table, index and storage statistics) and the two that need system_views carry their reason; Trino separates a refused table list and a catalog whose connector publishes no statistics from a catalog that genuinely holds no table; LibreDB reports real per-namespace key counts where it used to answer [] on a database with tables, and refuses sessions and indexes with what the engine actually is. Measured against Cassandra 5.0.9, ScyllaDB 2026.2.4 and Trino 476, and verified in Chrome on a production build. The Tables tab's Size card summed a required-field placeholder into "0 B" over a database with bytes on disk, which the LibreDB rows exposed. It now gates the way StorageTab has since #469, and a table with no size of its own reads "-" rather than an empty cell. D23 - an Oracle DATE or TIMESTAMP cell exported as an ISO string Oracle refuses (ORA-01843 / ORA-01861), so a DDL+INSERT export of any ordinary Oracle table could not be replayed. The export now writes TO_DATE / TO_TIMESTAMP / FROM_TZ chosen by the declared type, proven by replaying into live Oracle Free 23ai and comparing by the server. D16 - a pasted connection string's TLS parameter reached the form as "disable", so postgresql://...?sslmode=verify-full said plaintext. The documented spellings now map (postgres sslmode/ssl, MySQL ssl-mode/ssl/useSSL, ADO.NET Encrypt/TrustServerCertificate). The opportunistic values are not guessed onto either end - measured, prefer to require refuses a connection that works and PREFERRED to disable downgrades one that is encrypted - and the refusal is visible, not silent. DOC3 - four marketplace listings still advertised NL2SQL, and two more carried the old engine count. The copy now matches the product, and tests/unit/marketplace-copy.test.ts binds each claim to the code: AI explanation is offered where the engine returns a plan, not on every connection, and "read-only" replaces "never executes what it recommends", which the consented hand-over does. Recorded rather than fixed: D25 (Couchbase turns an RBAC denial into a zero, unmeasurable here with no live cluster), D26 (?ssl=true maps onto the one mode that does not verify) and U19 (a caution rendered as a green success tick).



Round 5 of the BACKLOG sweep. Six defects fixed, each measured against a live server before the change and re-measured in a browser against a production build after it. Four were BACKLOG entries (D7, D12, X7, X8); two were found while closing them, and one of those is the most serious thing in the PR. BACKLOG 106 -> 108 (four removed, six follow-ups recorded).
The one a user would have hit first
Every MySQL DDL and DML statement reported a failure for work it had done
Not a backlog entry - found while filling
columnTypes.query()called.mapon mysql2's first return value, which is aResultSetHeaderobject, not an array, for anything that returns no rows. Measured through the provider against MySQL 26.7.0, with an interleavedSELECTproving the server had already applied each statement:This is a SQL IDE. Reporting an error for a statement the server ran is the worst available answer, because the user retries it. Both sites now go through one envelope builder that branches on the shape, reporting
affectedRowsinrowCount- the same answer postgres (rowCount), mssql (rowsAffected[0]) and sqlite (changes) already give.Browser, production build, MySQL (r5):
CREATE TABLE ui_probe (...)reports "0 rows - 22ms" with the table created (confirmed ininformation_schema), andINSERT ... VALUES (1,'alpha'),(2,'beta')reports "2 rows - 9ms" with both rows present.The tests could not see it: they mocked
executeas returning an array, and the mock's TYPE made the real shape unexpressible. That type is now wide enough, which is the part that keeps it fixed.X7 - the DDL export typed a numeric or a timestamp column as TEXT
Nothing was wrong with the inference; it never saw a type.
pg,mysql2,oracledbandmssqlnow fillQueryResult.columnTypeswith the engine's own spelling, joining the six families that already did. Every mapping measured against a live engine rather than remembered:pg_typetable (149 built-ins,format_typespellings). Verified over all of dvdrental: 128 result columns, 125 named, 0 wrong, 3 absent - the three being one user-defined enum. A catalog round trip is not available at three of the four call sites (query()releases its client first;queryReadOnly()promises exactly one statement insideBEGIN READ ONLY).information_schema.DATA_TYPE, andPOINTis indistinguishable fromGEOMETRYon the wire.1/3, mssql reports 65535 as a sentinel forvarchar(MAX)).Browser, Postgres (r5),
SELECT id, price, dbl, flag, name, ts, tsz, d, j, u, b FROM r5_types:and the DDL that same result exports:
priceandtswereTEXTbefore. The backlog entry's own acceptance test.The consequence of X7, fixed here rather than left to be found
A declared type is a bare base name, because a length cannot be recovered from the wire - and a bare name is not legal DDL everywhere. Measured by replaying the generated
CREATE TABLEinto its own engine:ERROR 1064 ... near ',`body` text,'name text,price doubleORA-00906: missing left parenthesisNAME VARCHAR2 4000nvarcharlength 1,varbinarylength 1,decimal(18,0)- and its own INSERT failsMsg 2628nvarchar(max),varbinary(max),floatA bare name now completes to the target dialect's unbounded form, and a FOREIGN engine's declaration is re-spelled rather than written through - both shells keep the connection type beside the tab's own result, so exporting an Oracle result as Postgres DDL is reachable, and
VARCHAR2in Postgres DDL replays nowhere. Two dialect corrections came out of the measurements: mssqlchar->NVARCHAR(MAX)(char(max)is not legal T-SQL) and oracleNVARCHAR2->VARCHAR2(4000)(NVARCHAR2 tops out at 2000).X8 - a binary value exported as its JSON shape
sqlValuehad no binary branch, so abytea/BLOBcell was written as a quoted{"type":"Buffer","data":[...]}. Replaying that did not fail - it stored 46 bytes of JSON text where six bytes belonged, because bytea's escape input accepts it. The DDL had the same hole (a binary column was recreated asTEXT).Each dialect now writes a literal its own engine accepts, every spelling measured:
'\x...'::bytea-X'...'there is a bit string (pg_typeof(X'0102')isbit) and cannot be cast to bytea; the plain form survivesstandard_conforming_strings = off.X'...'- MySQL has no0xspelling for an EMPTY value (LENGTH(0x)isERROR 1054), and an empty cell is not a case an export gets to refuse.0x...- both rejectX'...'.HEXTORAW('...')- it parses neither literal form.unhex('...'); Couchbase is the honest no-binary-type case and gets the\x...text a human can decode.Replayed into seven engines, six bytes each. Browser: the exported INSERT for the Postgres row is
... '\x0102ab'::bytea);, and replaying the browser-produced file giveshex 0102ab, bytes 3.D7 - a fabricated cache hit ratio, in eight providers rather than three
The entry named MySQL, MongoDB and LibreDB. The sweep found five more, and all eight are honest now.
getHealth()ran the ratio query uncaught, and a tenant with noperformance_schemaDATABASE (measured on OceanBase asERROR 1049) lost the whole health panel rather than one metric.PRAGMA cache_size, a configuration value that is-2000by default and therefore always truthy. Neitherbun:sqlitenornode:sqliteexposessqlite3_db_status, and no PRAGMA stands in, so it is omitted permanently and the doc says why.DEFAULT_THRESHOLDSrates 0 as critical, so the panel invented a cache fault the cluster never reported.COALESCE(..., 100)inside our own SQL,|| 100,|| 0. Reproduced both absence arms live for each: a fresh database with NULL counters, and a login holding onlyCONNECT/CREATE SESSION(VIEW SERVER PERFORMANCE STATE permission was denied,ORA-00942).Two of these were pinned by tests - one named "reports zero rather than a perfect score when denied", one commented
// Default fallback. A third assertion was unreachable behind a branch that matched first, so the suite was asserting the fabrication.bufferPoolUsagewent with the ratio in three providers, because it was the ratio wearing a second name (Oracle and SQL Server assigned it literally; Postgres computed the same quotient frompg_stat_database), so the Performance tab drew and rated one quantity as two independent gauges. What a real occupancy gauge would cost on each engine is recorded in D14 rather than guessed at.Browser: MySQL 98.8%, Oracle 99.5% measured; SQLite "Cache Hit N/A - Not measured" where it used to say 95% "Excellent"; Buffer Pool "Not measured" on Oracle and SQLite.
D12 - a pasted
rediss://URL landed on SSL modedisableIt arrives as
requirenow, and so doescouchbases://- whose excuse for being left out of the panel work ("the provider re-reads the scheme there") was simply untrue:CouchbaseHttpTransportpicks http vs https fromconfig.sslalone and was posting plain HTTP to the TLS management port 18091.requirerather than a verifying mode, in both cases, because a self-hosted--tls-portRedis and a self-hosted Couchbase node present self-signed certificates; a paste encrypts but never silently claims to have checked a chain.mongodb+srv://is the one secure scheme deliberately left unset: the driver receives the URI verbatim and turns TLS on with chain verification, so handing it ourrequire(rejectUnauthorized: false) would have stopped an Atlas certificate being verified. Pinned as a negative test.Browser, both arms: pasting
rediss://localhost:6390fills the form with SSL / TLS: REQUIRE; pastingredis://localhost:6379leaves it on the default. End to end against a TLS-only Redis (--port 0 --tls-port 6390, so no plaintext port exists):rediss://connects and answersPONGin 14ms,redis://fails withConnection is closed.Follow-ups recorded, not smuggled in
rowCount: 0, including the rows it just wrote (rows.lengthinstead ofresult.rowsAffected). Found by the same audit; the only one of the five SQL providers that under-reports DML silently.pg_buffercacheis an extension,sys.dm_os_buffer_descriptorsis a full scan), so the field is not refilled by guesswork.rowCount * 100and the storage panel sums it. Needs a required type to allow absence, anddbstatexists onnode:sqlitebut notbun:sqlite.?sslmode=,?ssl-mode=,Encrypt=True) is still dropped. Not the same one-line shape:prefer/allow/PREFERREDhave no representation inSSLMode, and mapping them is a security decision. It also corrects a false claim the old entry carried.columnTypesstill cannot name, with the numbers: the user-defined type,POINTvsGEOMETRY,bit(which needs opposite families on pg and mysql2), and the mssql zero-row path.0x0102abwhere Postgres shows\x0102ab, and its INSERT exports the eight-character string. This BOUNDS what X8 closed, which is why it is stated rather than implied.Verification
bun run formatbun run lintbun run typecheckbun run kniprun-core.sh+ components)bun run coverage:checkbun run buildbun run build:lib+attweb88fad1..HEAD)Provider tri-sync held for all nine providers touched: code,
docs/providers/<id>.mdandtests/integration/db/<id>-provider.test.tsmove together for postgres, mysql, oracle, mssql, sqlite, mongodb, couchbase, libredb and redis.Live engines used: postgres:18, MySQL 26.7.0, SQL Server 2022 CU26, Oracle Free 23ai, MongoDB, Redis (plus throwaway ClickHouse, Trino and Cassandra containers for the binary replay). One environment note for whoever repeats this: SQL Server would not start until
fs.aio-nrhad room - the two ScyllaDB containers from the previous round held all 65536 aio contexts, and the failure readsUnable to create a new asynchronous I/O context.