# Database Provider Reference This folder holds the **prime (canonical) reference for each database provider** in LibreDB Studio. 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`](../../CLAUDE.md)). | Provider | type-id | Family | Driver | Query language | Reference | |----------|---------|--------|--------|----------------|-----------| | PostgreSQL | `postgres` | SQL | `pg` | SQL | [postgres.md](./postgres.md) | | MySQL | `mysql` | SQL | `mysql2` | SQL | [mysql.md](./mysql.md) | | Oracle | `oracle` | SQL | `oracledb` (Thin) | SQL | [oracle.md](./oracle.md) | | Microsoft SQL Server | `mssql` | SQL | `mssql` | SQL (T-SQL) | [mssql.md](./mssql.md) | | SQLite | `sqlite` | SQL (embedded) | `bun:sqlite` (Bun) / `node:sqlite` (Node) | SQL | [sqlite.md](./sqlite.md) | | libSQL | `libsql` | SQL (SQLite over a network) | none (HTTP: the Hrana protocol, `POST /v2/pipeline`) | SQL (SQLite) | [libsql.md](./libsql.md) | | DuckDB | `duckdb` | SQL (embedded, analytical) | `@duckdb/node-api` (native N-API addon) | SQL (DuckDB) | [duckdb.md](./duckdb.md) | | Redis | `redis` | Key-Value | `ioredis` | JSON | [redis.md](./redis.md) | | MongoDB | `mongodb` | Document | `mongodb` | JSON (MQL) | [mongodb.md](./mongodb.md) | | Couchbase | `couchbase` | Document | none (HTTP: Query + management REST) | SQL (SQL++) | [couchbase.md](./couchbase.md) | | ClickHouse | `clickhouse` | SQL | none (HTTP interface) | SQL | [clickhouse.md](./clickhouse.md) | | Apache Druid | `druid` | SQL (analytics) | none (HTTP: SQL endpoint) | SQL (Calcite) | [druid.md](./druid.md) | | Elasticsearch | `elasticsearch` | SQL (search) | none (HTTP: `_sql` + REST) | SQL (Elasticsearch SQL) | [elasticsearch.md](./elasticsearch.md) | | OpenSearch | `opensearch` | SQL (search) | none (HTTP: `_plugins/_sql` + REST) | SQL (OpenSearch SQL plugin) | [opensearch.md](./opensearch.md) | | Apache Trino | `trino` | SQL (federated query engine) | none (HTTP: the client protocol, `POST /v1/statement`) | SQL (Trino) | [trino.md](./trino.md) | | Apache Cassandra | `cassandra` | SQL (wide-column) | `cassandra-driver` (pure JS) | SQL-shaped (CQL) | [cassandra.md](./cassandra.md) | | LibreDB | `libredb` | Embedded (Key-Value) | `@libredb/libredb` | JSON (command grammar) | [libredb.md](./libredb.md) | ## Conventions - **Filename = canonical type-id** (`postgres.md`, `mssql.md`, …), mirroring the source file (`src/lib/db/providers//.ts`, or a `/` 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/` is `elasticsearch` and `opensearch` — 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:line` citation is verified, and the per-provider triad — code, this doc, and `tests/integration/db/-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 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`](../../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](https://github.com/libredb/libredb-studio/issues/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](mysql.md#the-slow-query-line-asked-for-a-column-the-digest-table-does-not-have). | | 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](../BACKLOG.md). 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 read 0 for a freshly loaded table until StarRocks' own background statistics collector catches up - **re-measured 2026-09-16**, a 5-row table read 0 for 45s on 4.1.4 and for roughly 4.5 minutes on 3.3.22 before both answered 5, so this is a delay rather than a gap. Sizes used to read 0 regardless of that wait: StarRocks answers `INDEX_LENGTH` NULL for every `BASE TABLE`, never a real number the way Doris's 0 is, and `DATA_LENGTH + INDEX_LENGTH` in SQL is NULL-poisoned by that alone even once `DATA_LENGTH` itself is real - fixed 2026-09-16 to `DATA_LENGTH + COALESCE(INDEX_LENGTH, 0)` everywhere this provider sums the two, so a table's size now tracks its row count on the same collector delay instead of staying absent. No index is reported at all, which is a real gap: `information_schema.STATISTICS` stays empty even for a table with a `BITMAP` index created and confirmed live. **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](./cassandra.md#reproducing-the-live-pass) 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](../BACKLOG.md) moved the *parameterless* statements to the text protocol; these carry placeholders and still prepare. Recorded as [D33](../BACKLOG.md). 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` | Object browser and query editor | RisingWave 3.0.4 | **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 now lists tables and materialized views. It was unavailable until the listing learned to drop `pg_class.reltuples`, which RisingWave's pg_class does not have - measured on 3.0.4, where the columns are oid, relname, relnamespace, relowner, relpersistence, relkind, relpages, relam, reltablespace, reloptions, relispartition and relpartbound. The earlier entry here blamed the schema query's `LEFT JOIN pg_class ON (...)::regclass`, which was wrong: the engine reports an unbindable column as `missing FROM-clause entry for table "c"`, so a column defect reads as a join defect. Row counts and sizes are blank rather than zero, since neither `reltuples` nor `pg_total_relation_size()` exists to answer. | **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](./cassandra.md#11-scylladb-is-a-partial-relative-one-absent-keyspace-cost-five-surfaces-until-2026-08-24) 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: ```bash docker compose -f database-compose.yml --profile compat up -d ``` **ScyllaDB 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 `postgres` driver against `questdb/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_orders` returned three rows. **In the product it does not.** The editor always attaches a `queryId`, the provider then issues `SELECT pg_backend_pid()` first, and QuestDB has no such function, so pressing Run answers `500 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 a `queryId` fails 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 the `queryId`, 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's `AS MATERIALIZED` CTEs are a syntax error there, and with the keyword removed the same query dies on `pg_total_relation_size()`, which QuestDB does not have - so it is not the Materialize/RisingWave case of an absent catalog. `information_schema.tables` lists the table and `pg_catalog.pg_class` answers with 36 columns. There is no `pg_stat_activity`, `pg_statio_user_tables`, `pg_stat_user_tables` or `pg_tablespace`, so health, performance metrics, table statistics and storage cannot run; the slow-query and session reads fail earlier still, on `NULLS LAST`. A plain `EXPLAIN` works through the provider and shows QuestDB's own plan (`PageFrame`, `Row forward scan`) while the editor's background `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` is rejected outright. Neither `ANALYZE` nor `VACUUM` is 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 in `build()`, which the provider does not call. The compose service is kept under the `compat` profile 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 `postgres` driver against `gcr.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 a `queryId` and the provider then issues `SELECT pg_backend_pid()` first, which Spanner rejects as an unsupported function. Test Connection fails on a connection that works, since it reads `pg_stat_activity`. The object browser is empty (`regclass` is an unsupported type), no monitoring panel answers at all, no row count or size is obtainable anywhere - the emulated `pg_class` reports 0 rows for a 2000-row table and even ground truth is unreadable - Explain fails on `BUFFERS`, 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 in `connect()` 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: `numeric` takes no precision or scale (so `decimal(10,2)` is rejected), there is no `ANALYZE`, the emulator authenticates nobody and has no role vocabulary at all (`CREATE ROLE` answers "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](https://github.com/libredb/libredb-studio/issues/424). ## Cross-cutting docs - **Provider architecture:** [`../DATABASE_PROVIDERS.md`](../DATABASE_PROVIDERS.md) — the Strategy-Pattern architecture, the provider hierarchy, and the shared interface/base classes. - **Adding a new provider:** [`../ADDING_A_PROVIDER.md`](../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`](../API_DOCS.md). ## 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`](../../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: 1. **A connection carrying a pin** (`sshTunnel.hostKeyFingerprint`) is verified against it. Durable, per connection, and it always wins. 2. **No pin, first contact with that bastion** - the key is accepted and remembered for the bastion's address (`host:port`), the same thing `known_hosts` keys on. The accepted fingerprint is reported back on the tunnel. 3. **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 -> connects wrong 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 | 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 | 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 model `sshTunnel`, 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 Studio, 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 What to type into the connection dialog for **every shipped provider**, against [`database-compose.yml`](../../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) | — | | SQLite | *no service* | — | — | — | — | a file path on the Studio host | — | | LibreDB | *no service* | — | — | — | — | a directory on the Studio 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](./trino.md#43-tls-and-the-password-rule)). **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](./cassandra.md#34-localdatacenter-is-a-required-connection-field-and-nothing-else-here-has-one)). 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 Studio 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](./duckdb.md) describes. See [sqlite.md](./sqlite.md), [duckdb.md](./duckdb.md) and [libredb.md](./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`, and `gvenzl/oracle-xe` does not read that variable — it takes `ORACLE_DATABASE` for an application PDB. So the only PDB on the node is the image default, `XEPDB1` (verified: `SELECT name FROM v$pdbs`). Connect to `XEPDB1`, or the listener refuses the service name. - **SQL Server** sets `MSSQL_DATABASE: mssql`, which the official image ignores; it creates no database. `master` is what the fixture guarantees (verified: `SELECT name FROM sys.databases`), and a `shop` database 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](./elasticsearch.md) and [opensearch.md](./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](./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: ```bash docker compose -f database-compose.yml --profile druid up -d ``` For the engines that have no provider of their own, use the `compat` profile and connect as the driver named in the [Wire-compatible engines](#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: ```sql 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. ```bash docker compose -f database-compose.yml --profile compat up -d doris docker 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 land ``` Then connect on port **19035** as `root` / `Probe#2026`, database `probe`. ### If Studio itself runs in a container, `localhost` is the wrong host Every `Host` in the table above is written for a Studio 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. ```bash docker run -p 3000:3000 --network libredb-studio_default libredb/libredb-studio ``` Then 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 | > **OpenSearch is 9200 here, not 9201.** The 9201 in the table above is a *host* port published to > dodge a collision with the `elasticsearch` service. Inside the network there is no collision, so the > port is the product's own. Verified: `http://opensearch:9200/` reports distribution `opensearch`, > number `3.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 Studio; the name is > `_default`, which is `libredb-studio_default` when compose is run from this repository and > `_default` otherwise (`docker network ls` says which). **2. Reach the published host ports through the host gateway.** Use this when Studio 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: ```bash docker run -p 3000:3000 --add-host=host.docker.internal:host-gateway libredb/libredb-studio ``` Now 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: ```bash docker run --network host ghcr.io/libredb/libredb-studio ``` Last 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`](../../docker-compose.yml) in the repository root is the shape to copy for a real deployment: Studio 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 Studio's `STORAGE_*` variables at it.