Database Provider Reference
This folder holds the prime (canonical) reference for each database provider in dbportal.
There is exactly one document per provider, named by the provider’s canonical type-id and kept
in lockstep with the code (see the tri-sync rule in ../../CLAUDE.md).
| Provider | type-id | Family | Driver | Query language | Reference |
|---|---|---|---|---|---|
| PostgreSQL | postgres |
SQL | pg |
SQL | postgres.md |
| MySQL | mysql |
SQL | mysql2 |
SQL | mysql.md |
| Oracle | oracle |
SQL | oracledb (Thin) |
SQL | oracle.md |
| Microsoft SQL Server | mssql |
SQL | mssql |
SQL (T-SQL) | mssql.md |
| SQLite | sqlite |
SQL (embedded) | bun:sqlite (Bun) / node:sqlite (Node) |
SQL | sqlite.md |
| libSQL | libsql |
SQL (SQLite over a network) | none (HTTP: the Hrana protocol, POST /v2/pipeline) |
SQL (SQLite) | libsql.md |
| DuckDB | duckdb |
SQL (embedded, analytical) | @duckdb/node-api (native N-API addon) |
SQL (DuckDB) | duckdb.md |
| Virtual | virtual |
PostgreSQL and MySQL members opened as one | the DuckDB driver above, its postgres and mysql extensions |
SQL (DuckDB) | virtual.md |
| Redis | redis |
Key-Value | ioredis |
JSON | redis.md |
| MongoDB | mongodb |
Document | mongodb |
JSON (MQL) | mongodb.md |
| Couchbase | couchbase |
Document | none (HTTP: Query + management REST) | SQL (SQL++) | couchbase.md |
| ClickHouse | clickhouse |
SQL | none (HTTP interface) | SQL | clickhouse.md |
| Apache Druid | druid |
SQL (analytics) | none (HTTP: SQL endpoint) | SQL (Calcite) | druid.md |
| Elasticsearch | elasticsearch |
SQL (search) | none (HTTP: _sql + REST) |
SQL (Elasticsearch SQL) | elasticsearch.md |
| OpenSearch | opensearch |
SQL (search) | none (HTTP: _plugins/_sql + REST) |
SQL (OpenSearch SQL plugin) | opensearch.md |
| Apache Trino | trino |
SQL (federated query engine) | none (HTTP: the client protocol, POST /v1/statement) |
SQL (Trino) | trino.md |
| Amazon Athena | athena |
SQL (serverless query service) | @aws-sdk/client-athena (pure JS) |
SQL (Trino’s dialect) | athena.md |
| Apache Cassandra | cassandra |
SQL (wide-column) | cassandra-driver (pure JS) |
SQL-shaped (CQL) | cassandra.md |
| LibreDB | libredb |
Embedded (Key-Value) | @libredb/libredb |
JSON (command grammar) | libredb.md |
Conventions
Section titled “Conventions”- Filename = canonical type-id (
postgres.md,mssql.md, …), mirroring the source file (src/lib/db/providers/<family>/<type-id>.ts, or a<type-id>/directory when a provider is split across modules, as Couchbase, ClickHouse, Druid, Trino, Cassandra, libSQL and DuckDB are). The official product name (e.g. “SQL Server”) is used only in each doc’s title and prose. One directory may serve two type-ids —providers/sql/search/iselasticsearchandopensearch— and each type-id still gets its own document, because the tri-sync invariant is per type-id and each doc is the prime reference for its own product’s measured behaviour. - Each doc mirrors the code. Every
file:linecitation is verified, and the per-provider triad — code, this doc, andtests/integration/db/<type-id>-provider.test.ts— must stay in sync in the same PR (the provider tri-sync invariant). - Each doc follows the same ~15-section shape: Overview → Architecture → Design decisions → Connection → Query interface → Schema → Monitoring → Maintenance → Capabilities & labels → Error handling → Testing → Usage → Known limitations → References.
Wire-compatible engines
Section titled “Wire-compatible engines”These engines have no provider of their own. Each one speaks the wire protocol of a driver
above, so it connects through that driver unchanged — pick the driver’s button in the connection
dialog and the engine works. The connection dialog says so too, from the same data
(src/lib/db/compatibility.ts).
A name appears here only after a live probe ran against a real instance of it through the real provider (issue #424, Phase 0). The version column is what that server reported; it is not a supported range. “Connects” is not “supported”, so the support column records how much of the product actually worked:
- Full — every introspection surface answered. Caveats may still note data that is present but inaccurate.
- Partial — the editor works; parts of the object browser or the monitoring dashboard are blank.
- Query editor only — SQL runs and nothing else does. Usable, but not as a managed database.
| Engine | Connect as | Support | Probed version | What to expect |
|---|---|---|---|---|
| Percona Server for MySQL | mysql |
Full | Percona Server for MySQL 8.4.11-11 | Probed 2026-08-26 against percona/percona-server:8.4. Behaves as MySQL throughout: all fifteen surfaces answer, the numbers are correct (2000 rows read as 2000, 114688 bytes as 114688, index 65536), indexes and a foreign key are read back, EXPLAIN FORMAT=JSON parses, and all three maintenance actions succeed - Analyze, Optimize (Table does not support optimize, doing recreate + analyze instead; OK, InnoDB’s own answer) and Check. Nothing on screen says Percona: version() answers a bare 8.4.11-11 and the product name is only in @@version_comment (Percona Server (GPL), Release 11, Revision 57878ff8), which the provider does not read, so the overview cannot be told apart from a stock MySQL 8.4 - the same shape as Garnet’s row above. One reading that looked like a Percona defect and was NOT: the health panel’s slow-query line said Performance schema not available while @@performance_schema was 1 and the slow-query panel itself returned rows. Measured identically on a stock MySQL 26.7.0 in the same pass, so it was ours, not Percona’s - since fixed in #512: the health line now carries the same digest rows the panel does (both read one statement), and where the digest table cannot be read at all the line is empty and the panel names the server’s reason. See providers/mysql.md. |
| MariaDB | mysql |
Full | 12.3.2-MariaDB-ubu2404 | Behaves as MySQL throughout. The version shown is MariaDB’s full build string, passed through as the server gave it. performance_schema ships OFF (@@performance_schema = 0), so the cache-hit, queries-per-second and buffer-pool figures are absent and the slow-query list is empty until the server is started with performance_schema=ON; the tables exist either way, so nothing fails, it simply measures nothing. The schema tree, sizes, row counts, sessions and EXPLAIN FORMAT=JSON come from information_schema and are unaffected. Only 12.3 was probed; the 10.x information_schema surface was not. |
| TiDB | mysql |
Full | 8.0.11-TiDB-v8.5.1 | All surfaces answer. A freshly loaded table reads 0 rows and 0 B until TiDB’s background statistics catch up — they correct themselves, with no ANALYZE. Max connections reads 0 (TiDB’s default, meaning unlimited) and the connection count beside it reads not published, because TiDB answers SHOW STATUS with 13 rows and none of them is Threads_connected (wire, 2026-09-06). The slow-query panel is always empty (TiDB’s slow log lives in information_schema.SLOW_QUERY), and storage stats list a phantom InnoDB entry. The Explain panel renders TiDB’s own operator tree since 2026-09-06: TiDB rejects EXPLAIN FORMAT='json', so the provider sends a plain EXPLAIN instead, and the panel drew Projection_3 over TableDual_4 with estRows as the row estimate for a constant SELECT, measured in the browser on 2026-09-06. Probed on a standalone --store=unistore server only; PD + TiKV was not probed. |
| Vitess | mysql |
Full | 8.0.43-Vitess (Vitess 24.0.2) | All fifteen surfaces answer and the browser is clean: 2 objects for 2 user tables. Row counts and total size are exact, checked against the engine (2000 rows read as 2000; 163840 bytes as 163840), and a foreign key is both read back and enforced. Two things do not work. A running query cannot be cancelled: vtgate refuses KILL QUERY with VT07001 and the statement runs to completion (a 5 second SLEEP took its full 5003 ms). And per-index sizes always read 0 bytes, because Vitess names the InnoDB table after the physical shard database (vt_probe_0), which is also the name the table and index statistics show instead of the keyspace you connected to. Setting a session variable can fail where reading it works: SET @@cte_max_recursion_depth is rejected as an unknown system variable, while SELECT @@cte_max_recursion_depth answers 1000. Probed on an unsharded single-shard keyspace only; a sharded keyspace was not probed, and no permission error could be measured because vttestserver grants every login full rights. |
| ParadeDB | postgres |
Full | ParadeDB 0.25.4 on PostgreSQL 18.6 | Probed 2026-08-27 against paradedb/paradedb:0.25.4. All fifteen surfaces answer and the numbers are correct (2000 rows read as 2000, 131072 bytes as 131072), with a foreign key and its indexes read back. What it costs is width, not accuracy: the object browser lists 41 objects for 2 user tables and the index panel 74 rows, because ParadeDB ships nine extensions - pg_search, vector, postgis with its tiger geocoder, pg_ivm, pg_stat_statements and paradedb’s own tables. spatial_ref_sys (8500 rows) sits in public beside your tables. Agent plan mode WORKS here, and that result refutes what the width predicted - worth recording, because the expectation was that a wide catalog would refuse the grounding read: the grounding capture reads what the ROLE can see, and a superuser sees 539 non-system columns where an unprivileged role sees 168, the tiger geocoder’s 425 not being readable. Under the 200-row ceiling, so a plan run grounded on 21 tables (ctx_c35a) and drafted a LEFT JOIN over the real objects. Connect as a least-privilege role, not as a superuser: as postgres the run fails with the agent cannot run on this database engine: it offers no read-only execution profile, which is the profile refusing a superuser on any PostgreSQL rather than anything about ParadeDB. So the real trigger is a role that CAN read a wide catalog, not an extension that installs one. Slow queries do work here, unlike most PostgreSQL relatives, because pg_stat_statements ships enabled. version() names PostgreSQL only, so the version panel cannot tell this apart from a stock PostgreSQL 18.6 - the release is in the image tag and nowhere the provider reads. |
| Percona Distribution for PostgreSQL | postgres |
Full | Percona Server for PostgreSQL 18.6.1 on PostgreSQL 18.6 | Probed 2026-08-26 against percona/percona-distribution-postgresql:18.6. Behaves as PostgreSQL throughout: all fifteen surfaces answer, the numbers are correct (2000 rows read as 2000 and 131072 bytes as 131072, index 65536), a foreign key is read back with its indexes, and Analyze and Vacuum both work. The version panel names Percona - version() answers PostgreSQL 18.6 - Percona Server for PostgreSQL 18.6.1 - which is the opposite of the MySQL distribution below and worth knowing when you are trying to tell a fork from stock. Slow queries are empty until pg_stat_statements is enabled, which is stock PostgreSQL behaviour rather than a Percona property. Nothing else deviates from the PostgreSQL baseline. |
| Citus | postgres |
Full | citus 14.1-1 on PostgreSQL 18.4 | All surfaces answer. Row counts and sizes for a distributed table are wrong, not missing — PostgreSQL statistics describe the empty coordinator parent, not the shards. citus_tables and citus_schemas show up in the browser. |
| OrioleDB | postgres |
Full | OrioleDB beta 16 on PostgreSQL 18.4 (nightly of 2026-08-24) | Probed 2026-08-27 against orioledb/orioledb:pg18-nightly-20260824-cc35a80-ubuntu. Read this row against ParadeDB above: both are Full and their costs are opposites. Here the object browser is clean - 2 objects for 2 user tables - row counts are exact (2000 read as 2000, 122880 bytes), and a foreign key and its indexes are read back. What is missing is what PostgreSQL cannot see of OrioleDB’s own storage: every index reads 0 bytes in the browser and the table statistics, because pg_indexes_size() returns 0 for an OrioleDB table (measured directly: 114688 bytes of table, 0 of indexes) - the YugabyteDB DocDB shape; and the cache hit ratio reads N/A, because OrioleDB has its own buffer manager and pg_statio_user_tables stays at 0 hits / 0 reads. That second one is an absence honestly rendered, not a wrong number. Gate 7 passes under a least-privilege role: 27 visible columns, a plan run grounded on 6 tables (ctx_6b5e) and drafted a LEFT JOIN over the real objects - and as a superuser it fails on the same profile refusal ParadeDB’s row describes. Verified before trusting the row that the fixture actually measures OrioleDB: default_table_access_method is orioledb and both probe tables report amname = orioledb, so a heap-table probe measuring plain PostgreSQL was ruled out. version() does name OrioleDB - the one thing ParadeDB’s does not - and carries the build hash and date, which is also the caveat: the project publishes nightly images only, so there is no release tag to pin and this row describes one dated build. |
| TimescaleDB | postgres |
Full | TimescaleDB 2.29.2 on PostgreSQL 17.11 | All surfaces answer. A hypertable’s row count and size are wrong, not missing — the statistics describe the empty parent table, not the chunks. The object browser now lists only user tables — TimescaleDB’s own seven schemas are excluded, so a database whose sole hypertable had 31 chunks went from 61 objects to the 2 the user created (34 from _timescaledb_internal, 22 from _timescaledb_catalog, 3 from _timescaledb_cache). Measured against the extension’s own hypertable_size(), the parent-table size reads 24576 bytes where the true figure is 2498560. The overview shows PostgreSQL’s version, not the extension’s. The agent grounds a stock install — the grounding read aggregates columns per table instead of refusing past the 200-row budget, and every chunk still appears as its own table in the inventory. |
| YugabyteDB | postgres |
Full | YugabyteDB 2.25.2.0-b0 (advertises PostgreSQL 15.12) | All surfaces answer, foreign keys included. Row counts and sizes read 0 until you run ANALYZE — nothing collects statistics automatically, so a full database looks empty. Index sizes always read 0 bytes (index storage lives in DocDB) and the overview’s database size reads 0 bytes. Index types read lsm, which is the real storage rather than a misreading. |
| AlloyDB Omni | postgres |
Full | PostgreSQL 17.9 (AlloyDB Omni 17.9.0) | All fifteen surfaces answer, and the numbers are exact: 2000 rows read as 2000 and 270336 bytes as 270336 (180224 table plus 90112 index), with a foreign key both read back and enforced by the engine. The version panel cannot be told apart from a stock PostgreSQL 17 - version() reports PostgreSQL 17.9 on x86_64-pc-linux-gnu and names AlloyDB nowhere; the product is identifiable only from the alloydb.* settings and the image tag. The object browser now lists only user tables, excluded by ownership rather than by name: pg_depend is asked which schemas an extension created, which catches google_ml and ai where a name list had caught only the first, and can never hide a schema a user made themselves. The count went from 10 objects to 2. That count still understates what is installed: outside the system schemas there are 70 objects, because 49 extension views live in public itself (g_columnar_*, google_db_advisor_*, hypopg_list_indexes and more), which the browser hides only because its schema query filters table_type = 'BASE TABLE'. A role with no grants at all - LOGIN plus CONNECT, ALL revoked on public - still lists those google_ml tables and reads them (SELECT count(*) FROM google_ml.supported_vertex_models answered 15 to it). The slow-query panel is empty because pg_stat_statements ships with the image but is not installed in it, which health reports honestly. The columnar engine is off by default and needs ALTER SYSTEM plus a restart; with it on the on-disk sizes stay exact but do not count the columnar copy. The agent needs a least-privilege role to ground a run — the image’s own postgres superuser is refused as too broad; with a least-privilege role the capture succeeds (the grounding read aggregates columns per table), and the inventory includes the 49 extension views installed into public. Probed on the 17.9.0 image only; the 15.x and 16.x lines were not. |
| Valkey | redis |
Full | Valkey 9.1.1 | Behaves as Redis. The overview shows Valkey 9.1.1 (Redis 7.2.4), its own version labeled ahead of the Redis compat level. |
| DragonflyDB | redis |
Full | DragonflyDB df-v1.40.1 | Overview shows Dragonfly df-v1.40.1 (Redis 7.4.0). Max connections reads the limit Dragonfly publishes under the underscored max_clients; every active session’s user reads default, whatever ACL user authenticated (CLIENT LIST publishes no user= field), and every session reads idle/N (no cmd= or flags= field). |
| KeyDB | redis |
Full | KeyDB 6.3.4 | Publishes no version field of its own, so the overview is indistinguishable from a Redis 6 server. A session’s command can appear without its subcommand. |
| Garnet | redis |
Full | Garnet 2.1.5 (advertises Redis 7.4.3) | Probed 2026-08-26 against ghcr.io/microsoft/garnet:2.1.5. Every Redis surface answers - the key browser grouped 71 keys into user:*, session:* and queue:*, sessions list the connection, and Key Info (the Analyze action) returned 129 metrics. The overview shows Garnet 2.1.5 (Redis 7.4.3): INFO carries garnet_version:2.1.5 and server_name:garnet beside redis_version:7.4.3, and the provider labels the version with the former, the same as it does for Valkey and DragonflyDB. Two numbers are absences wearing a value, both from INFO fields Garnet does not publish: there is no used_memory, so the database size and the memory storage panel read 0 B; and there are no keyspace_hits / keyspace_misses, so the cache hit ratio reads 100% - the : 100 fallback recorded as D14. Measured side by side with Redis 8.10.0, which publishes all three. Max connections reads 0: unlike Dragonfly, Garnet’s INFO carries no client-limit field under either name at all. connected_clients stays 0 even with a client attached, though the session list itself is right. Browser-verified 2026-08-26 on the built app: the tree read user:* 50, session:* 20, queue:* 1, a {"command":"GET","args":["user:1"]} run returned the value, and Monitoring showed 7.4.3, Connections 0 / no limit published, Tables 71 / 0 indexes, DB Size 0B and Cache Hit 100.0% rated Excellent - the D14 fallback being graded, which is the sharpest form of this row’s warning. Fixture note: Garnet keeps everything in memory and publishes no persistence, so a docker stop empties it - measured, dbsize 71 before and 0 after a restart. |
| FerretDB | mongodb |
Full | FerretDB 2.7.0 (MongoDB 7.0.77 wire) | Every monitoring surface answers. Sign in with the backend PostgreSQL credentials — authMechanism=PLAIN is rejected. The version shown is the advertised MongoDB wire version. Needs its own backend, so it is two containers. |
| StarRocks | mysql |
Partial | StarRocks 3.3.22-753696f | The editor, the table list, column metadata, table and storage stats, performance metrics and slow queries all work. The version reads MySQL 5.1 — version() returns a fictitious 5.1.0 and the real build is only in current_version(). Re-probed 2026-08-24 after every parameterless statement moved to MySQL’s text protocol: getOverview() and getPerformanceMetrics() now succeed through the provider, and until 2026-09-06 the user still saw none of it (the re-measurement below). The active-session read is an engine gap of its own - Unknown table information_schema.PROCESSLIST - and it used to discard the whole dashboard with it. Since 2026-08-24 each panel is read independently: re-measured 2026-08-24 through the provider, getMonitoringData() returns overview, performance, slowQueries, tables, indexes and storage, with errors.activeSessions carrying StarRocks’ own sentence, and the sessions panel alone shows it. Health fails on the same missing table and the badge reads Slow, measured in the browser. What the protocol change recovered on 2026-08-24 was the provider layer, not a panel; the Overview panel followed on 2026-09-06 (below). Row counts and sizes are hard zeros, checked directly, and no index is reported at all. Re-measured in the browser on 2026-09-06: the Overview panel no longer fabricates a reading, it shows Connections N/A, not published where it used to show 0/151, because StarRocks answers a bare SHOW STATUS and SHOW VARIABLES LIKE 'max_connections' with zero rows each; health still fails on the missing PROCESSLIST, which is the engine’s own. The Explain panel renders StarRocks’s own text plan on the same date: EXPLAIN FORMAT='json' still does not parse, so the provider sends a plain EXPLAIN, which answered 14 rows and drew a 13-node tree for a constant SELECT. |
| Apache Doris | mysql |
Full | Apache Doris 4.1.3-rc02-7126cf65d96 (version() reports 5.7.99) |
Probed 2026-08-26, re-measured 2026-09-06. All fifteen surfaces answer since 2026-09-06, which is what moved this row from Partial to Full. The two that used to fail shared one cause that was ours: the overview and health panels sent SHOW STATUS LIKE '...', a parse error in the Doris grammar (mismatched input 'LIKE') while a bare SHOW STATUS is accepted and answers zero rows. The panels read the bare form since 2026-09-06, so both render, and the Overview publishes an absence rather than a number: uptime N/A and Connections N/A, not published, because Doris publishes no Uptime and no Threads_connected row and no max_connections variable (browser, 2026-09-06). Read this row against StarRocks above, which is a fork of Doris, because the numbers are where they part company. Doris reports the truth: the object surface read 3 rows / 2.65 KB and 2000 rows / 9.95 KB against a ground truth of 3 and 2000 (SELECT count(*)), and getTableStats() read 10187 bytes against SHOW DATA’s 9.948 KB, where StarRocks reports hard zeros. The lag is the catch: immediately after the 2000-row insert every surface read 0 rows and 0 B, an ANALYZE TABLE ... WITH SYNC in between changed nothing, and the true numbers appeared about a minute later on their own - the TiDB shape, self-correcting, so a freshly loaded table looks empty for a while. What it shares with the fork: version() itself still answers the fictitious 5.7.99, and unlike StarRocks there is no current_version() to fall back to either - the real build lives only in @@version_comment (doris version doris-4.1.3-rc02-7126cf65d96), which the provider now reads for the overview’s version field, so the panel correctly shows Apache Doris 4.1.3-rc02-7126cf65d96 instead of the fictitious number; no index is ever reported, information_schema.statistics being empty; and EXPLAIN FORMAT='json' does not parse, so the provider sends a plain EXPLAIN here instead and the Explain panel renders Doris’s own Nereids plan: 18 rows and a 13-node tree for a constant SELECT, with the raw tab showing the plan text verbatim (browser, 2026-09-06). A foreign key is invisible AND unenforced: ADD CONSTRAINT ... FOREIGN KEY is accepted and SHOW CONSTRAINTS lists it, but information_schema.KEY_COLUMN_USAGE is empty (0 rows), so the ER diagram draws nothing - and an order row referencing customer 424242 inserted successfully, because the constraint is a planner hint there. A UNIQUE KEY table also declares no primary key to the product: COLUMN_KEY reads UNI, not PRI. What works that a reader would not assume: cancellation genuinely cancels (an 8-second SELECT sleep(8) died at 1513 ms with Doris’s own cancel query by user message, where Vitess cannot cancel at all), active sessions and the monitoring dashboard answer through information_schema.processlist, storage stats answer, permission errors arrive as the right class with the server’s sentence intact and a SELECT_PRIV role saw only its granted table, and Analyze works - Optimize and Check do not exist in the grammar at all. getPerformanceMetrics() answers {}: the object is empty rather than absent, because Doris’s SHOW STATUS publishes no counters. What the panels actually show, verified in Chrome against the built app on 2026-08-26 (the provider’s boundary is not the product’s, which is what U14 cost us on Cassandra): the header badge read Slow and the Monitoring Overview tab carried This database could not answer this panel with Doris’s own mismatched input 'LIKE' sentence beneath it - one panel, not the dashboard. Re-measured in the browser on 2026-09-06 the badge reads Online and that tab renders, showing Apache Doris 4.1.3-rc02-7126cf65d96, uptime N/A and Connections N/A, not published. Every other tab answered on 2026-08-26 already: Tables reads 2 tables / 2.0K rows / 12.60 KB with probe_orders at 2.0K / 9.95 KB and probe_customers at 3 / 2.65 KB, Storage shows DB Size N/A above a populated tablespace and largest-table list, Sessions lists 7, and Performance, Queries and Pool read N/A with Not measured / not available rather than a fabricated zero. The object browser shows the row count beside the table (probe_customers 3). One reading to treat with suspicion: the Tables card summarises Vacuum 0 / OK on an engine that has no vacuum-like operation at all - the numbers below it are correct, but that card is the U14 shape and is recorded here rather than claimed as healthy. Creating the connection took two clicks of Establish Connection, by design: Test Connection answered Connected, but this server answered no health data: …, and the first Establish reported that degraded reading and stored nothing, the second stored it (the D9 contract - before that fix an engine whose health failed could not be saved at all). Since health answers, one click is enough: Test Connection reads Connection successful and the first Establish stores the connection (browser, 2026-09-06). Gate 7 passes: a plan run captured 2 tables (ctx_adf7) and drafted a LEFT JOIN over the real probe_customers / probe_orders, which then ran unmodified and answered TR 667, DE 667, US 666. |
| OceanBase | mysql |
Partial | 5.7.25-OceanBase_CE-v4.4.2.1 | Fourteen of the fifteen surfaces return without throwing, but only twelve of them do their job, and that gap is what makes this partial rather than full: performance metrics and storage stats are answered-but-useless, and the overview, table statistics and index statistics carry useless zeros in their size fields while their row and structure data is real. The slow-query panel read 0 rows here, an honest empty rather than a fabricated number; since #512 it shows the tenant’s own ERROR 1049 instead, because getSlowQueries() no longer swallows an unreadable source. Health is the one hard failure - the tenant has no performance_schema database at all (ERROR 1049 Unknown database 'performance_schema'), and health passes on the MySQL 26.7.0 baseline probed in the same pass, so this is the engine’s. One consequence is visible before any panel is opened: the header badge reads Slow rather than Online, which is not latency - the badge is set from whether the health request succeeded, and health is exactly what this engine refuses. Every size reads 0 B - table, index, database and storage - because information_schema.TABLES reports DATA_LENGTH 0 and INDEX_LENGTH 0; storage stats also list a phantom InnoDB row at ibdata1:12M:autoextend whose size reads N/A, OceanBase faking innodb_data_file_path for compatibility with no such file behind it. Row counts are correct once ANALYZE TABLE has run (2000 for 2000), and it must be the MySQL-mode statement - the Oracle-mode ANALYZE TABLE t COMPUTE STATISTICS is rejected with ERROR 1235. The object browser is clean, 2 objects for 2 user tables: the schema query scopes to TABLE_SCHEMA and to TABLE_TYPE = 'BASE TABLE', and OceanBase’s own 860 + 70 + 18 catalog objects are all SYSTEM TABLE or SYSTEM VIEW, so neither filter admits them. A foreign key is both read back and enforced (an orphan insert is refused with ERROR 1452), but no backing index is created for one, so index counts will not match an equivalent MySQL schema. Cancelling genuinely cancels, and Explain genuinely describes without executing (an 11-row ASCII plan with an EST.ROWS column). Sign in to the business tenant, not sys: MODE=mini creates a user tenant named test, so the login is root@test; the sys tenant shows nine databases including Oracle-mode artifacts (LBACSYS, ORAAUDITOR, SYS, ocs, sys_external_tbs) that a user tenant does not have. Plan mode grounds a run here, through the provider-inventory path. Probed on the 4.4.2-lts image with MODE=mini only. |
| SingleStore | mysql |
Partial | SingleStoreDB 9.1.1 (advertises MySQL 5.7.32) | Ten of the fifteen surfaces answered when this engine was first probed, and every one of them answers since 2026-09-06 (the Explain panel was the last, below); the row stays Partial because the numbers the statistics panels show are zeros rather than measurements. Four of the five failures were ours, not SingleStore’s, and were fixed first - Test Connection, health, the overview and the monitoring dashboard all failed with the same engine message, This command is not supported in the prepared statement protocol yet, because the provider sent every statement through mysql2’s binary prepared protocol. Every parameterless statement moved to the text protocol on 2026-08-24, and all four were re-probed in the browser on the built app: the header badge reads Online, the fleet card reads 152ms / 4 conn, and Monitoring renders MySQL 5.7.32, an uptime, Connections 7/100000 and Tables 2 / 2 indexes. The Storage panel renders too, all zeros, which is this engine’s own zeroed information_schema.TABLES below and not a failure of ours. The Explain panel was not among them, and is fixed since 2026-09-06: EXPLAIN FORMAT=JSON is ER_PARSE_ERROR here on BOTH protocols, because SingleStore’s grammar is EXPLAIN JSON - a statement problem wearing a protocol problem’s message. The provider now measures which EXPLAIN grammar the server accepts when it connects and sends a plain EXPLAIN here, so the panel renders SingleStore’s own text plan; measured in the browser on 2026-09-06, one row for a constant SELECT. Row counts and sizes are missing rather than wrong: a 2000-row table reads rowCount 0 and 0 B in the object browser, the table statistics and the storage panel, against a ground truth of 2000 rows and 77046 bytes measured four independent ways (SELECT count(*), SHOW TABLE STATUS, information_schema.OPTIMIZER_STATISTICS.ROW_COUNT, and the EXPLAIN plan’s est_table_rows). SingleStore leaves information_schema.TABLES zeroed and keeps the real numbers elsewhere, and running ANALYZE does not change what the panels read, so a populated table looks empty. The index panel lists 4 rows for 2 tables against the baseline’s 2, because SingleStore auto-creates a shard key on every table and reports it as an index named __SHARDKEY with index type SHARD, beside PRIMARY. Foreign keys do not exist: ALTER TABLE ... ADD FOREIGN KEY fails with ERROR 2752, and with SET GLOBAL ignore_foreign_keys = ON a CREATE TABLE carrying an inline foreign key is accepted and the constraint silently stripped - the one shape where you could believe you have a key you do not. Of the maintenance actions Analyze always worked; Optimize and Check failed with the same prepared-statement error and both succeed on the text protocol, measured through the provider (the app offers only Analyze as a global action here, so that pair was not re-probed from the UI). Permission errors are identical to MySQL, with the server’s own text intact, and under a SELECT-only role the table list correctly showed only the granted table. The version the overview displays reads MySQL 5.7.32, the wire version SingleStore advertises, not SingleStoreDB 9.1.1, which is what that panel showed once it rendered (browser, 2026-08-24). No licence key is needed - the dev image self-licenses with ROOT_PASSWORD as the only variable set, which is what issue #424 recorded as the reason this engine had gone unprobed. Plan mode grounds a run here, through the provider-inventory path. Probed on the 0.2.82 dev image only. |
| CockroachDB | postgres |
Partial | CockroachDB CCL v26.2.6 | Editor, error handling, performance metrics, slow queries and sessions all work. The object browser now lists real tables (re-probed after #38680; previously blank): the schema query recovers from the missing pg_total_relation_size() builtin by falling back to an unmeasured (0-byte) size rather than failing outright, and foreign keys/indexes were never affected by that gap. The overview/health size fields stay unavailable rather than blank-crashing: pg_size_pretty(), pg_postmaster_start_time() and pg_tablespace_location() do not exist there. Its table and index counts are the user’s own — crdb_internal objects reach pg_tables although information_schema’s BASE TABLE filter never shows them, so before those two schemas were excluded the overview counted 98 tables (93 crdb_internal, 3 pg_extension) for the 2 the browser listed. |
| Apache Cloudberry (incubating) | postgres |
Partial | PostgreSQL 14.4 (Apache Cloudberry 2.1.0-incubating) | Thirteen of the fifteen surfaces answer. Table statistics and index statistics both fail with one engine error, query plan with multiple segworker groups is not supported, which is Cloudberry’s MPP planner restriction rather than a version gap. The monitoring dashboard itself is not one of them: its overview and performance tabs read connections, database size, table count, deadlocks and checkpoint stats normally, and it is specifically the Tables tab’s per-table breakdown and the Storage tab’s largest-tables list that fail. Row counts and sizes after ANALYZE are correct (2000 rows for 2000; 576 KB for 589824 bytes), so this is not the kind of engine whose statistics mislead; what they read before ANALYZE was not probed. The object browser now lists only user tables — pg_ext_aux (the PAX auxiliary tables), gp_toolkit, pg_aoseg and pg_bitmapindex are excluded, so the count went from 4 objects to 2; Cloudberry’s schema documentation lists the last three but not pg_ext_aux, which is excluded on measurement rather than on the doc’s authority. The overview’s database size reads 62 MB against roughly 900 KB of user tables. A foreign key is read back as if enforced and is not: Cloudberry accepts the constraint with a warning that it will not enforce it, and an orphan insert then succeeds. The agent needs a least-privilege role to ground a run — the usual gpadmin login is refused because the execution profile reads a superuser as too broad; with a least-privilege role the capture succeeds (the grounding read aggregates columns per table). What the run reports for the first of those is that the engine offers no read-only execution profile, which describes the role rather than the engine (B47). The two failing panels each say “This database could not answer this panel” and print the engine’s error under it, so the planner restriction is attributed to the statement rather than to the connection. Apache publishes build images only, so the probe ran on a third-party image. |
| ScyllaDB | cassandra |
Partial | ScyllaDB 2026.2.4-0.20260810.e54224b8cebb (advertises Cassandra 3.0.8) | All thirteen surfaces this provider offers now answer - thirteen rather than the fifteen this column counts elsewhere, because the Cassandra provider offers neither cancellation nor EXPLAIN on either engine - and the row is still Partial because five of them answer empty. Re-probed 2026-08-24 through createDatabaseProvider({type:"cassandra"}), surface by surface, after the 2026-08-24 change: the overview, health, performance metrics, active sessions and the monitoring dashboard read Cassandra’s system_views virtual tables, ScyllaDB has no system_views keyspace at all, and all five used to come back with the same verbatim Keyspace system_views does not exist. They now degrade to empty the way a denied grant does, so Test Connection passes and the connection dialog can create a ScyllaDB connection - before that change it could not, because Establish Connection is gated on that same health request, and one had to arrive seeded or admin-managed. What the degradation costs: no cache hit ratio and no active-session list (both N/A). Connections now reads N/A with “not published” beneath it, not a fabricated 0 - DatabaseOverview.activeConnections and HealthInfo.activeConnections are both optional as of 2026-08-24, the provider omits the key rather than send a zero nobody measured, and the same absence reaches the fleet card (which drops the connection chip entirely) and the agent’s curated health reading (null). Verified in the browser on the built app: Connections N/A / not published on ScyllaDB against 1 / no limit published on Apache Cassandra 5.0.9 in the same pass. The version, uptime, table count and index count are real: measured Apache Cassandra 3.0.8, 23.76m, 3 tables, 1 index. Apache Cassandra 5.0.9, probed in the same pass, answers all thirteen with data in every one. The SQL editor and the object browser work in full: statements run, and every one of 18 CQL types read back byte-identically to that Cassandra baseline, bigint 9007199254740993, decimal 1.25, duration 3h20m, varint, blob, inet, date, time and the collections included. The version panel reads Apache Cassandra 3.0.8 - the compatibility number system.local publishes - and not ScyllaDB 2026.2.4, which lives in system.versions where the provider does not look. The object browser lists one extra object per secondary index, and the tree and the overview disagree about it: ScyllaDB backs an index with a materialized view, so customers_country_idx_index is in system_schema.views - which the tree reads - and not in system_schema.tables, which the Tables count reads (measured: 4 objects in the tree against tableCount 3). Cassandra lists neither. Error classes are identical to Cassandra even though the server’s wording is not - a missing table is unconfigured table no_such_table rather than table no_such_table does not exist, a missing column Unrecognized name nope rather than Undefined column name nope in table probe.customers - because the provider classifies on the driver’s error code and not on the message text. Row counts and sizes are blank for the same reason as on Cassandra, and the panels read N/A rather than a fabricated zero. Creating a keyspace needs NetworkTopologyStrategy on the 2026.2 line: SimpleStrategy is refused outright with SimpleStrategy doesn't support tablet replication, so the setup recipe in cassandra.md §10 does not run unchanged; 2025.1 still accepts it, with a warning. ScyllaDB 2025.1.14-0.20260612.103b84070f3b was probed in the 2026-08-21/22 pass and behaved identically on every surface, including the same verbatim refusal the fix keys on, so this row describes both the 2025.1 and the 2026.2 line - but only those two builds, only a single-node container, and only 2026.2.4 was re-probed after the fix. |
| Databend | mysql |
Query editor only | Databend v1.2.925-patch-11 (advertises MySQL 8.0.90) | Probed 2026-08-27 against datafuselabs/databend:v1.2.925-patch-11. SQL runs - a 2000-row count(*), a GROUP BY and a plain EXPLAIN all answer, the last with Databend’s own TableScan plan. Everything else is unavailable for a reason that is ours, and the catalogs prove it: asked with literal SQL, information_schema.tables reports the true 3 and 2000 rows with data_length 124 and 49000, and information_schema.columns answers in full. The provider gets none of it, because every parameterised read goes through mysql2’s prepared protocol and Databend replies Prepare is not support in Databend - so the table list, the schema, active sessions and the table, index and storage statistics all fail. D8 moved the parameterless statements to the text protocol; these carry placeholders and still prepare. Recorded as D33. Two gaps are the engine’s own and would survive that fix: there is no SHOW STATUS statement at all (unexpected STATUS. Did you mean SHOW STAGES...), so the overview and health panels have no source, and there is no information_schema.processlist, so sessions have none either. Also its own: EXPLAIN FORMAT='json' does not parse, OPTIMIZE and CHECK do not exist, foreign keys are absent (key_column_usage is empty) and no index is ever reported (statistics is empty). ANALYZE crashes the provider rather than failing: it returns an object where the reader expects an array, so runMaintenance throws rows.filter is not a function - filed with D33. Re-measured in the browser on 2026-09-06: the Explain panel now renders Databend’s own text plan, 5 rows, because the provider asks the server which EXPLAIN grammar it accepts and falls back to a plain EXPLAIN here. The overview, health and session panels are unchanged and stay unavailable, which is the engine’s own: Databend has no SHOW STATUS statement at all. One authoring trap worth knowing: Databend follows the SQL standard on quoting, so "TR" is an identifier and only 'TR' is a string. Browser-verified 2026-08-27: the editor really does run - SELECT country, count(*) ... GROUP BY country returned DE 1, TR 1, US 1 from the grid - which is the check QuestDB failed, and the background EXPLAIN FORMAT=JSON beside it returned 500 as expected. What plan mode does here is the honest downstream of the same cause and is worth reading: the run succeeds and drafts NOTHING, saying this run was given no inventory of this database and asking for the table and column names - the schema read failed, so it refuses to invent a schema rather than guessing one. |
| Materialize | postgres |
Partial | Materialize 26.40.0 | Re-probed after #38680: the object browser now lists tables and columns (previously nothing but the editor worked). The schema query retries around four gaps in turn - the reserved MATERIALIZED keyword, the missing pg_total_relation_size() builtin, json_agg()/json_build_object() (only the jsonb_ forms exist), and information_schema.constraint_column_usage - recovering real data instead of failing outright. Foreign keys and primary keys are then empty because Materialize has neither - CREATE TABLE refuses both, and its table_constraints, key_column_usage and referential_constraints shims all answer zero rows. The fallback exists because constraint_column_usage is not implemented at all (fourteen information_schema views ship, and that is not one of them, at HEAD as well as at the probed release), so the query would fail before reaching that empty answer. The '[]'::json casts in the same statements are left alone - the cast was measured working on a live instance even though Materialize documents no json type. Materialized views are listed beside tables, with their columns: Materialize reports them as table_type = 'MATERIALIZED VIEW' and they are its central object, so listing only BASE TABLE hid the product (no other engine emits that type - measured on all six). All seven monitoring tabs render: three panels are absent rather than empty and show Materialize’s own sentence under “This engine does not publish this” instead of an error, because the message names a pg_ object that is simply not there. Row counts are blank rather than zero - pg_class.reltuples answers -1 there for every relation, so there is no estimate to show; it used to read 0, which claimed a measurement nobody made. The monitoring dashboard now loads too, every statistic reading unavailable rather than the whole page erroring: no pg statistics catalog, no size functions. |
| RisingWave | postgres |
Query editor only | RisingWave 3.0.3 | Re-probed after #38680: the monitoring dashboard now loads with every statistic marked unavailable rather than erroring the whole page (it has no pg statistics catalog at all, and a parameterised LIMIT is rejected, so slow-query/session panels stay empty specifically). The object browser remains unavailable, and re-probing found a different cause than Materialize’s: the schema query’s LEFT JOIN pg_class ON (...)::regclass fails to bind (missing FROM-clause entry for table c), not the MATERIALIZED keyword - not yet worked around. |
ScyllaDB was the standing illustration of the rule above, and its probe has now run, which is why
it has a row rather than a paragraph. It speaks the CQL wire protocol and cassandra-driver connects
to it, which is exactly the kind of “connects, therefore supported” claim this table exists to refuse
— and what the probe measured is that the parts which differ are the parts that are not the wire.
Of the three doubts cassandra.md §11 raised,
two held and one was refuted: system_views is absent, which is still the whole of what makes the
row above Partial - the five surfaces that read it answered an error until 2026-08-24 and answer empty since
(re-probed 2026-08-24) - and the version string is not release_version-shaped, while gossip_generation does
exist on ScyllaDB and answers. Nothing about how a name gets in has changed: a probe against a real
instance through the real provider is still the only way, and until one runs the honest state is
untested, not unsupported.
Reproduce any row with the compat profile of the container fixture, then connect as the driver in
the second column:
docker compose -f database-compose.yml --profile compat up -dScyllaDB needs one connection field the other rows here do not, the same one Apache Cassandra
needs: Local Data Center must be datacenter1, and the driver refuses to open a session while it is
empty. The fixture’s service is scylla, reachable on localhost port 9142 with no user and no
password, and the probe keyspace is probe.
Absent from the table for two different reasons, which are worth keeping apart: an engine we could not reach is not the same as an engine we reached and refused.
Measured, and refused a row. The probe ran and the result did not earn an entry, so there is no tier and no version column for it - the number is the finding:
- QuestDB 10.0.1 - it fails the one surface a query-editor-only row exists to claim, probed
on 2026-08-26 through the
postgresdriver againstquestdb/questdb:10.0.1. QuestDB does speak the PostgreSQL wire protocol on 8812, and through the provider a statement answers:SELECT country, count() c FROM probe_ordersreturned three rows. In the product it does not. The editor always attaches aqueryId, the provider then issuesSELECT pg_backend_pid()first, and QuestDB has no such function, so pressing Run answers500 unknown function name: pg_backend_pid()- measured in Chrome, and measured both ways at the provider boundary to be sure of the cause: the identical call with aqueryIdfails and without one returns the three rows. This is the Cloud Spanner shape below, and it is the reason a provider-level probe cannot award a tier. Two statements do not rescue it either: the multi-statement path drops thequeryId, but the run still failed in the browser. The rest, for whoever revisits this: the object browser fails on TWO independent causes of ours - the schema query’sAS MATERIALIZEDCTEs are a syntax error there, and with the keyword removed the same query dies onpg_total_relation_size(), which QuestDB does not have - so it is not the Materialize/RisingWave case of an absent catalog.information_schema.tableslists the table andpg_catalog.pg_classanswers with 36 columns. There is nopg_stat_activity,pg_statio_user_tables,pg_stat_user_tablesorpg_tablespace, so health, performance metrics, table statistics and storage cannot run; the slow-query and session reads fail earlier still, onNULLS LAST. A plainEXPLAINworks through the provider and shows QuestDB’s own plan (PageFrame,Row forward scan) while the editor’s backgroundEXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)is rejected outright. NeitherANALYZEnorVACUUMis a QuestDB statement. Errors are classified correctly with QuestDB’s own text. The version panel would read PostgreSQL 12.3 -version()names QuestDB only at the end of its string, and the real build is inbuild(), which the provider does not call. The compose service is kept under thecompatprofile deliberately, so the refusal can be reproduced rather than taken on trust. - Google Cloud Spanner, PostgreSQL dialect - 1 of 15 surfaces, probed on 2026-08-20 through
the
postgresdriver againstgcr.io/cloud-spanner-pg-adapter/pgadapter-emulator:v0.55.2(the Cloud Spanner emulator behind PGAdapter 0.55.2; the managed service was not probed and inherits nothing from this). It does not reach even query-editor-only: pressing Run fails, because the editor always attaches aqueryIdand the provider then issuesSELECT pg_backend_pid()first, which Spanner rejects as an unsupported function. Test Connection fails on a connection that works, since it readspg_stat_activity. The object browser is empty (regclassis an unsupported type), no monitoring panel answers at all, no row count or size is obtainable anywhere - the emulatedpg_classreports 0 rows for a 2000-row table and even ground truth is unreadable - Explain fails onBUFFERS, and Cancel can never work because the backend PID it needs is unreadable. Agent and plan mode fail closed, which is correct: the role-privilege verification inconnect()cannot run (current_setting(text)is unsupported), so acquisition is refused and the user is told the connection failed. Foreign keys are enforced by the engine, but the app can never display them because the schema read fails. One result is worse than a failure: Vacuum reports “VACUUM completed successfully” when Spanner has no vacuum and nothing happened - PGAdapter swallows the statement. Analyze, by contrast, correctly reports itself unsupported. Fixture notes for anyone repeating this:numerictakes no precision or scale (sodecimal(10,2)is rejected), there is noANALYZE, the emulator authenticates nobody and has no role vocabulary at all (CREATE ROLEanswers “Statement is not supported.”), and nothing survives a restart because the database is in memory.
No instance was reachable, so these are deliberately absent rather than assumed to work: every managed-only service - Amazon Redshift, Aurora, AlloyDB (the managed service; the downloadable AlloyDB Omni is in the table above), Neon, Supabase, Cloud SQL, Azure SQL Database, Microsoft Fabric, Azure Synapse, Azure SQL Managed Instance, Amazon ElastiCache, Upstash, PlanetScale, Azure Cosmos DB and Amazon DocumentDB. Their status is tracked in #424.
Cross-cutting docs
Section titled “Cross-cutting docs”- Provider architecture:
../DATABASE_PROVIDERS.md— the Strategy-Pattern architecture, the provider hierarchy, and the shared interface/base classes. - Adding a new provider:
../ADDING_A_PROVIDER.md— the step-by-step guide, plus the rubric for deciding whether a database needs a driver at all, the transport seam, and the traps of talking to one over HTTP. Couchbase is the worked example. - HTTP API contract (request/response for
/api/db/query, schema, maintenance, …):../API_DOCS.md.
SSH tunnels: bastion host key verification
Section titled “SSH tunnels: bastion host key verification”Any connection may reach its database through an SSH tunnel (sshTunnel on the connection,
src/lib/ssh/tunnel.ts). This is the only layer that can verify who
terminated the SSH hop: past it the database driver is handed 127.0.0.1 and a local port, so the
connection’s own SSL settings say nothing about the bastion.
The policy is trust-on-first-use (TOFU), pinned per connection. Not “refuse anything unknown”:
requiring a pasted fingerprint before the first connection makes tunnelling unusable for someone
reaching their own bastion, TOFU is what every SSH client does on first contact, and it is strictly
better than what it replaced - ssh2 has no default host verifier, so with the callback absent the
library accepted whatever key it was offered and the tunnel completed against whatever answered on
that address.
What happens, in order:
- A connection carrying a pin (
sshTunnel.hostKeyFingerprint) is verified against it. Durable, per connection, and it always wins. - No pin, first contact with that bastion - the key is accepted and remembered for the bastion’s
address (
host:port), the same thingknown_hostskeys on. The accepted fingerprint is reported back on the tunnel. - No pin, a later contact with that bastion - verified against what was remembered.
A key that does not match fails the connection outright, naming both fingerprints. Measured
2026-08-26 against a real openssh-server container, all three arms:
no pin -> accepted, reported SHA256:JWLQciyX8RYEeuK+bwRLW/YSpStz0/L7BlbVcejL1/U (byte-identical to `ssh-keyscan -p 2226 127.0.0.1 | ssh-keygen -lf -`)correct pin -> connectswrong pin -> SSH host key verification failed for 127.0.0.1:2226: offered SHA256:JWLQ..., expected SHA256:AAAA... Confirm the bastion's key with `ssh-keyscan <host> | ssh-keygen -lf -`; if it changed legitimately, clear the pinned fingerprint for this connection.Fingerprints are printed the way OpenSSH prints them - SHA256: followed by unpadded base64 of the
SHA-256 of the server’s public key blob - so what an error shows can be compared directly against
ssh-keyscan <bastion> | ssh-keygen -lf -, against ssh-keygen -lf on a .pub file, or against an
existing known_hosts entry.
Three deliberate limits, so nobody reads more into this than it does:
- There is no “accept the new key?” prompt. A mismatch fails; the remedy is to clear the pin once you have confirmed out of band that the key legitimately changed (a rebuilt bastion). Whether to offer an in-product accept flow is a separate decision.
- First-contact memory lasts as long as the server process. A restart re-enters first contact.
- Nothing writes the durable pin yet. The verifier honours
sshTunnel.hostKeyFingerprint, but no surface sets it: there is no input for it, seed configs do not modelsshTunnel, and an accepted fingerprint is not written back. So today’s protection is the process-scoped memory above - a key that changes within a server’s lifetime is refused, and a restart trusts afresh. Tracked as D34.
The fingerprint is stored and displayed in the clear, on purpose. It is public key material: it
authenticates the bastion to dbportal, grants no access, unlocks nothing, and it has to be readable for
the comparison above to be possible at all. src/lib/storage/connection-secrets.ts classifies it
public alongside the certificates in ssl; the tunnel’s password, private key and passphrase stay
encrypted at rest.
Connecting to the container fixture
Section titled “Connecting to the container fixture”What to type into the connection dialog for every shipped provider, against
database-compose.yml. One row per type-id, so the table covers the
same set as the table at the top of this file and a new provider is a new row rather than a re-drawn
grid. Each row was verified against the running container on 2026-08-19, and the Trino row on
2026-08-20 — the credentials are the ones the fixture actually accepts, not the ones its environment
block asks for (twice those differ; see the notes).
Start the fourteen always-on services with a plain docker compose -f database-compose.yml up -d -
thirteen engine containers plus the one-shot couchbase-init seed sidecar; the Profile column names
the ones that need asking for. The count is derived, not written: a service in this file carries no
profiles: key precisely when it backs a SHIPPED provider, so a plain up -d can reproduce that
provider’s integration pass.
| Provider | Compose service | Host | Port | User | Password | Database / service | Profile |
|---|---|---|---|---|---|---|---|
| PostgreSQL | postgres |
localhost | 5432 | postgres |
postgres |
postgres |
— |
| MySQL | mysql |
localhost | 3306 | root |
root |
mysql |
— |
| Oracle | oracle |
localhost | 1521 | system |
Password123! |
XEPDB1 (service name) |
— |
| SQL Server | mssql |
localhost | 1433 | sa |
Password123! |
master |
— |
| MongoDB | mongodb |
localhost | 27017 | admin |
admin |
any; auth source admin |
— |
| Redis | redis |
localhost | 6379 | none | none | none (db index 0) | — |
| Couchbase | couchbase |
localhost | 8091 | Administrator |
password123 |
travel (bucket) |
— |
| ClickHouse | clickhouse |
localhost | 8123 | libredb |
password123 |
demo |
— |
| Apache Druid | druid-router |
localhost | 8888 | none | none | none | druid |
| Elasticsearch | elasticsearch |
localhost | 9200 | none | none | none | — |
| OpenSearch | opensearch |
localhost | 9201 | none | none | none | — |
| Apache Trino | trino |
localhost | 8080 | none | none | tpch (catalog) |
— |
| Apache Cassandra | cassandra |
localhost | 9042 | none | none | probe (keyspace) |
— |
| Amazon Athena | no service (a managed AWS service) | — | — | an access key id, or none for the runtime’s role | the secret access key | an Athena database, plus a region and a result location | — |
| SQLite | no service | — | — | — | — | a file path on the dbportal host | — |
| LibreDB | no service | — | — | — | — | a directory on the dbportal host | — |
none means leave the field empty. It is never a default that happens to be blank: Druid loads no
security extension in a default install, both search services run with their security plugin off, the
trino service runs with authentication disabled, and the redis service sets no requirepass
(verified: CONFIG GET requirepass answers empty). Never put a password on a plain-HTTP Trino
connection: the coordinator answers 401 Password not allowed for insecure authentication even
with authentication off, so a password breaks a connection that works without one
(trino.md §4.3).
Cassandra needs one field this table has no column for, and will not connect without it. Local Data Center must be datacenter1 - a stock single node names its own data centre that, and the
driver refuses to open a session when the field is empty rather than defaulting to the only data
centre it can see (cassandra.md §3.4).
The port needs nothing: the compose service publishes the native protocol as 9042:9042, which is
also what the connection dialog prefills, so the field can be left untouched. docker port libredb-cassandra prints the mapping.
The three embedded providers have no container, and that is the whole point of them. SQLite and DuckDB each take a path resolved in the dbportal process and LibreDB a directory; none of them reaches a network. SQLite and LibreDB also ship a ready-made sample connection, “Sample (Employees)” and “Sample (LibreDB)”, which appear in the sidebar with no configuration at all, so the fastest way to exercise those two is to click one rather than to fill this dialog in. DuckDB ships no sample: build a local file instead, as duckdb.md describes. See sqlite.md, duckdb.md and libredb.md.
Two rows differ from what the compose file’s environment asks for, which is why they are stated from the running container instead:
- Oracle sets
ORACLE_PDB: ORCLPDB1, andgvenzl/oracle-xedoes not read that variable — it takesORACLE_DATABASEfor an application PDB. So the only PDB on the node is the image default,XEPDB1(verified:SELECT name FROM v$pdbs). Connect toXEPDB1, or the listener refuses the service name. - SQL Server sets
MSSQL_DATABASE: mssql, which the official image ignores; it creates no database.masteris what the fixture guarantees (verified:SELECT name FROM sys.databases), and ashopdatabase appears only once an E2E seed has run.
The two search services share a port inside the container. OpenSearch publishes 9201 on the
host because both products ship on 9200 and the elasticsearch service claims it; the provider’s own
default port stays 9200, so this is a collision on this machine and not a fact about the product.
Security is off on both, which is what makes their fixtures reproducible and the limit of what they
can prove: a bogus Basic header is ignored there (HTTP 200 on both, measured), so no 401/403 body
can ever be captured from these containers. Neither offers a Database field at all — an index has no
namespace above it. Details in elasticsearch.md and
opensearch.md.
Trino’s Database field is a CATALOG, not a database. The fixture ships tpch, tpcds,
memory, system and jmx configured, and tpch is the one to type: it generates its rows on
read, so tpch.tiny.nation (25 rows) answers immediately with no seed step. The tree shows the
schemas of that one catalog; every other catalog stays reachable from the editor by qualifying names
in full. Leave jmx configured — it is the only SQL-reachable source for the overview’s uptime.
Details in trino.md.
Druid is seven containers, not one. It has no single-container mode, so the whole block carries
profiles: ["druid"] and a plain up -d leaves it out. The dialog only ever needs the Router:
docker compose -f database-compose.yml --profile druid up -dFor the engines that have no provider of their own, use the compat profile and connect as the driver
named in the Wire-compatible engines table above.
Garnet needs no setup at all - docker compose -f database-compose.yml --profile compat up -d garnet
and it answers RESP on 16380 with no password.
ParadeDB (15441), OrioleDB (15442) and Databend (13308) need no setup either, beyond the usual
probe fixture. Two notes that decide whether a probe there measures anything: on OrioleDB,
check default_table_access_method is orioledb and that your tables report amname = orioledb
(SELECT relname, amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam) - a heap table there
measures PostgreSQL, not OrioleDB. On ParadeDB and OrioleDB both, create a least-privilege role
before testing agent plan mode, because the execution profile refuses a superuser on any PostgreSQL:
CREATE ROLE probe_ro LOGIN PASSWORD 'Probe123pass!';GRANT CONNECT ON DATABASE probe TO probe_ro;GRANT USAGE ON SCHEMA public TO probe_ro;GRANT SELECT ON ALL TABLES IN SCHEMA public TO probe_ro;Databend answers on 3307, not 3306, and its strings must be single-quoted ('TR', never
"TR" - that is an identifier there).
The questdb service exists to reproduce a refusal, not a connection. It answers the PostgreSQL wire on
18812 (user admin, password quest, database qdb) and publishes its own HTTP API on 19000, which is
how the probe seeded 2000 rows (INSERT INTO ... FROM long_sequence(2000) over GET /exec) - there is no
psql-friendly bulk insert there. A connection to it can be created and the tree stays empty, and pressing
Run fails; see QuestDB under Measured, and refused a row above for what that costs and why.
Two other services need a step before the dialog will work. ScyllaDB needs Local Data Center
filled in, the same field Cassandra needs. Apache Doris starts with no password on root, and an
empty password is not a credential the dialog treats as filled, so set one and create the fixture
objects before connecting - this is the recipe the 2026-08-26 probe ran, and the sleep is not
optional: information_schema reports 0 rows and 0 B until Doris’s background statistics catch up.
docker compose -f database-compose.yml --profile compat up -d dorisdocker exec libredb-doris mysql -uroot -P9030 -h127.0.0.1 -e "SET PASSWORD FOR 'root' = PASSWORD('Probe#2026');"docker exec libredb-doris mysql -uroot -pProbe#2026 -P9030 -h127.0.0.1 -e " CREATE DATABASE IF NOT EXISTS probe; CREATE TABLE probe.probe_orders (id INT NOT NULL, customer_id INT, amount DECIMAL(10,2), created DATETIME) DUPLICATE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 3 PROPERTIES('replication_num'='1'); INSERT INTO probe.probe_orders SELECT number, number % 3 + 1, number * 1.25, '2026-08-26 10:00:00' FROM numbers('number'='2000');"sleep 60 # information_schema reads 0 rows / 0 B until the background statistics landThen connect on port 19035 as root / Probe#2026, database probe.
If dbportal itself runs in a container, localhost is the wrong host
Section titled “If dbportal itself runs in a container, localhost is the wrong host”Every Host in the table above is written for a dbportal that runs on the host — bun dev,
bun run start, or the npm package. Inside a container, localhost is that container’s own loopback
and nothing is listening on it: a plain docker run -p 3000:3000 libredb/libredb-studio reaches
none of these services (measured — curl localhost:9200 from an unrelated container answers no HTTP
status at all, not a refusal you could mistake for a credential problem).
Pick one of three, in this order of preference.
1. Join the fixture’s network and address services by name. The best answer, and the only one that
needs no host ports at all: compose puts every service on libredb-studio_default with its service
name as a DNS name.
docker run -p 3000:3000 --network libredb-studio_default libredb/libredb-studioThen use the service name as the host and the in-container port — which is not always the port in the table above:
| Provider | Host inside the network | Port inside the network |
|---|---|---|
| PostgreSQL | postgres |
5432 |
| MySQL | mysql |
3306 |
| Oracle | oracle |
1521 |
| SQL Server | mssql |
1433 |
| MongoDB | mongodb |
27017 |
| Redis | redis |
6379 |
| Couchbase | couchbase |
8091 |
| ClickHouse | clickhouse |
8123 |
| Apache Druid | druid-router |
8888 |
| Elasticsearch | elasticsearch |
9200 |
| OpenSearch | opensearch |
9200 |
| Apache Trino | trino |
8080 |
| Amazon Athena | no service | — (the SDK reaches athena.<region>.amazonaws.com) |
OpenSearch is 9200 here, not 9201. The 9201 in the table above is a host port published to dodge a collision with the
elasticsearchservice. Inside the network there is no collision, so the port is the product’s own. Verified:http://opensearch:9200/reports distributionopensearch, number3.8.0.Credentials and database names do not change — only host and port do. And the network exists only once compose has created it, so start the fixture before dbportal; the name is
<project>_default, which islibredb-studio_defaultwhen compose is run from this repository and<your-directory>_defaultotherwise (docker network lssays which).
2. Reach the published host ports through the host gateway. Use this when dbportal must stay off the
fixture’s network — a container you did not start, or services split across several compose projects.
On Docker Desktop host.docker.internal already resolves; on Linux it does not, and the flag below is
what creates it:
docker run -p 3000:3000 --add-host=host.docker.internal:host-gateway libredb/libredb-studioNow the table at the top of this section is correct as written, with host.docker.internal in place of
localhost — including OpenSearch on 9201, because these are the published host ports. Verified on
Linux: 9200 and 9201 both answer HTTP 200 through that name.
3. --network host. Makes localhost mean the host’s loopback, so the table applies unchanged:
docker run --network host ghcr.io/klinux/dbportalLast because of what it costs: Linux only (on Docker Desktop the “host” is the VM, not your
machine), no -p mapping (the app binds the host’s port 3000 directly), and the container shares the
host’s whole network namespace, which is a far wider grant than reaching one database.
Whichever you pick, docker-compose.yml in the repository root is the
shape to copy for a real deployment: dbportal and its Postgres sit on one compose network and address
each other by service name, so nothing depends on a published host port existing.
One collision to know about if you run both files: that root compose file and the fixture both name a
container libredb-postgres, so the second one to start fails with “container name is already in
use”. Rename one, or run only the fixture and point dbportal’s STORAGE_* variables at it.