diff --git a/README.md b/README.md
index 0f4c87d2..3eb03cd8 100644
--- a/README.md
+++ b/README.md
@@ -79,7 +79,7 @@ The test instance comes with a pre-configured PostgreSQL database via [Seed Conn
- Connect to PostgreSQL, MySQL, Oracle, SQL Server, MongoDB, Couchbase, ClickHouse, Redis, or SQLite with SSL/TLS and SSH Tunnel support.
+ Connect to PostgreSQL, MySQL, Oracle, SQL Server, MongoDB, Couchbase, ClickHouse, Apache Druid, Redis, or SQLite with SSL/TLS and SSH Tunnel support.
---
@@ -185,9 +185,10 @@ The test instance comes with a pre-configured PostgreSQL database via [Seed Conn
| **MongoDB** | `mongodb` | JSON query editor, collection operations (find, aggregate, insert, update, delete) |
| **Couchbase** | none — HTTP (Query + management REST) | Full SQL++ IDE, EXPLAIN plans, bucket/scope/collection explorer, `INFER` column inference, read-your-writes consistency, `UPDATE STATISTICS` / `BUILD INDEX` / request kill |
| **ClickHouse** | none — HTTP (SQL interface, port 8123) | Full SQL IDE, JSON EXPLAIN plan trees, system-table schema introspection, `OPTIMIZE TABLE` / table statistics / query kill maintenance |
+| **Apache Druid** | none — HTTP (`POST /druid/v2/sql`, Router port 8888 or Broker 8082) | Read-only SQL IDE, native-query EXPLAIN plan trees, `INFORMATION_SCHEMA` datasource introspection, `sys.*` monitoring (segments, servers, ingestion tasks). Druid SQL has no `UPDATE`, no `DELETE` and no `CREATE TABLE`, and nothing it can do counts as a maintenance operation — a datasource changes through ingestion, not from the editor |
| **Redis** | `ioredis` | Command editor, key browser, INFO-based monitoring |
-> All SQL databases share: schema explorer, ER diagrams, schema diff & migration, display masking (preview), monitoring dashboard, and connection string import.
+> All SQL databases share: schema explorer, ER diagrams, schema diff & migration, display masking (preview), monitoring dashboard, and connection string import. Druid is the exception twice over: its HTTP SQL API has no URI convention to paste, so it is configured by host and port only, and generated migration SQL has nothing to apply against an engine whose SQL contains no DDL.
> **Provider reference docs:** each database has an in-depth reference (design, connection, query format, monitoring, limitations) under [`docs/providers/`](docs/providers/README.md). For the provider architecture see [`docs/DATABASE_PROVIDERS.md`](docs/DATABASE_PROVIDERS.md), and to add a new database see [`docs/ADDING_A_PROVIDER.md`](docs/ADDING_A_PROVIDER.md).
@@ -203,7 +204,7 @@ The test instance comes with a pre-configured PostgreSQL database via [Seed Conn
| **Editor** | Monaco Editor (VS Code Engine) | Web |
| **AI** | Multi-Model (Gemini, OpenAI, Ollama, Custom) | Web, Mobile |
| **Auth** | JWT (`jose`) + OIDC (`openid-client`), PKCE, Role Mapping | Web, Mobile |
-| **Database** | PostgreSQL, MySQL, Oracle, SQL Server, SQLite, MongoDB, Couchbase, ClickHouse, Redis | Web, Mobile |
+| **Database** | PostgreSQL, MySQL, Oracle, SQL Server, SQLite, MongoDB, Couchbase, ClickHouse, Apache Druid, Redis | Web, Mobile |
| **Charts** | Recharts (Bar, Line, Pie, Area, Scatter, Histogram, Stacked) | Web, Mobile |
| **ERD** | React Flow, ELK.js (auto-layout) | Web |
| **State/Grid** | TanStack Table & Virtual | Web, Mobile |
@@ -294,7 +295,7 @@ journalctl -u libredb-studio
### Prerequisites
- [Bun](https://bun.sh/) (Recommended) or Node.js 24+
- - A target database to query (PostgreSQL, MySQL, Oracle, SQL Server, SQLite, MongoDB, Couchbase, ClickHouse, or Redis)
+ - A target database to query (PostgreSQL, MySQL, Oracle, SQL Server, SQLite, MongoDB, Couchbase, ClickHouse, Apache Druid, or Redis)
### Quick Start (Local)
1. **Clone & Install**
@@ -340,7 +341,7 @@ journalctl -u libredb-studio
Need databases to test with? We provide ready-to-use containers for all supported engines:
```bash
-# Start all development databases (PostgreSQL, MySQL, MongoDB, SQL Server, Oracle)
+# Start every default-profile database (PostgreSQL, MySQL, MongoDB, SQL Server, Oracle, ...)
docker compose -f database-compose.yml up -d
# Or start a specific database
@@ -348,6 +349,13 @@ docker compose -f database-compose.yml up -d postgres
docker compose -f database-compose.yml up -d mssql
docker compose -f database-compose.yml up -d oracle
+# Apache Druid: profile-gated, so a bare `up -d` does NOT start it. Druid is a distributed
+# system with no single-container mode - five Druid processes plus ZooKeeper plus its own
+# metadata database is the minimum that can answer a SQL query, so all seven services carry
+# `profiles: [druid]` rather than doubling the default stack. Connect to the Router on 8888
+# (or the Broker on 8082 - the same endpoint, no different configuration).
+docker compose -f database-compose.yml --profile druid up -d
+
# Start PostgreSQL with sample e-commerce data
docker compose -f docker/postgres.yml up -d
@@ -356,6 +364,9 @@ docker compose -f database-compose.yml down
# Stop and remove all data
docker compose -f database-compose.yml down -v
+
+# The Druid containers need the profile flag here too - without it `down` leaves them running
+docker compose -f database-compose.yml --profile druid down -v
```
### Connection Details
@@ -367,6 +378,7 @@ docker compose -f database-compose.yml down -v
| **SQL Server** | localhost | 1433 | sa | Password123! | master |
| **Oracle** | localhost | 1521 | system | Password123! | freepdb1 |
| **MongoDB** | localhost | 27017 | admin | admin | — |
+| **Apache Druid** | localhost | 8888 (Router) or 8082 (Broker) | — | — | — (one catalog, always `druid`) |
### PostgreSQL Sample Data
@@ -416,7 +428,7 @@ bun run test:coverage
|-------|-----------|--------|-------|----------------|
| **Unit** | `tests/unit/` | `bun:test` | ~1,609 | Pure functions: SQL parser, connection strings, data masking, query limiter, schema diff, error classes, DB icons, showcase queries |
| **API** | `tests/api/` | `bun:test` | ~279 | Route handlers: auth, query, transaction, maintenance, AI endpoints, middleware |
-| **Integration** | `tests/integration/` | `bun:test` | ~346 | Database providers: PG, MySQL, SQLite, MongoDB, Couchbase, Redis, Oracle, MSSQL, ClickHouse|
+| **Integration** | `tests/integration/` | `bun:test` | ~346 | Database providers: PG, MySQL, SQLite, MongoDB, Couchbase, Redis, Oracle, MSSQL, ClickHouse, Druid|
| **Hooks** | `tests/hooks/` | `bun:test` | ~251 | React hooks: auth, connections, tabs, query execution, transactions, inline editing, AI chat, monitoring |
| **Components** | `tests/components/` | `bun:test` + happy-dom | ~570 | UI components: Studio, Sidebar, QueryEditor, ResultsGrid, Admin Dashboard, Charts, ERD |
| **E2E** | `e2e/` | Playwright | ~32 | Full browser flows: login, connections, query execution, tabs, export, admin |
@@ -707,7 +719,7 @@ extraEnvFrom:
| `defaults` | No | Default values merged into all connections |
| `connections[].id` | Yes | Unique slug (`[a-z0-9-]+`, max 64 chars) |
| `connections[].name` | Yes | Display name in UI |
-| `connections[].type` | Yes | `postgres`, `mysql`, `sqlite`, `mongodb`, `redis`, `oracle`, `mssql`, `libredb`, `couchbase`, `clickhouse` |
+| `connections[].type` | Yes | `postgres`, `mysql`, `sqlite`, `mongodb`, `redis`, `oracle`, `mssql`, `libredb`, `couchbase`, `clickhouse`, `druid` |
| `connections[].roles` | Yes | `["*"]` (everyone), `["admin"]`, `["user"]`, or `["admin", "user"]` |
| `connections[].managed` | No | `true` = read-only (default), `false` = editable copy for user |
| `connections[].password` | No | Use `${ENV_VAR}` syntax for secrets |
@@ -745,7 +757,7 @@ extraEnvFrom:
- [ ] **Phase 17**: Enterprise Collaboration (User Identity, Shared Workspaces, SAML 2.0).
- [ ] **Phase 18**: Server-Enforced Data Masking (SQL output-lineage, deployment-global policy, fail-closed API masking, alias/aggregate coverage).
- [x] **Phase 19**: Driver-Free Providers — Couchbase (SQL++ over the Query REST API), the first provider that adds no runtime dependency. Pattern documented in [Adding a Provider](docs/ADDING_A_PROVIDER.md).
-- [ ] **Phase 20**: Analytics Databases — ClickHouse ([#264](https://github.com/libredb/libredb-studio/issues/264)) and Apache Druid ([#265](https://github.com/libredb/libredb-studio/issues/265)), both driver-free over HTTP.
+- [x] **Phase 20**: Analytics Databases — ClickHouse ([#264](https://github.com/libredb/libredb-studio/issues/264)) and Apache Druid ([#265](https://github.com/libredb/libredb-studio/issues/265)), both driver-free over HTTP. Druid is read-only by nature — no `UPDATE`, no `DELETE`, no `CREATE TABLE` — so it also demonstrates a provider that reports absent capabilities honestly instead of offering controls that can only fail.
- [ ] **Phase 21**: Federated Query — Trino/Starburst. Deliberately unscheduled: a Trino catalog is another *system*, so what a connection pins is a product question that has to be answered before the work can be specified.
---
diff --git a/database-compose.yml b/database-compose.yml
index d351b01d..53b7ac85 100644
--- a/database-compose.yml
+++ b/database-compose.yml
@@ -1,3 +1,39 @@
+# Shared configuration for every Druid process. Druid is configured entirely through
+# `druid_*` environment variables (the image's entrypoint translates them into
+# runtime.properties), so one anchor keeps the five processes provably identical - a cluster
+# whose processes disagree about ZooKeeper or the metadata store fails in confusing ways.
+x-druid-env: &druid-env
+ # Sizes every process for a laptop. Without it each one claims cluster-scale heap and
+ # direct memory, and the five together will not fit.
+ DRUID_SINGLE_NODE_CONF: micro-quickstart
+ # druid-multi-stage-query is included on purpose: it is what makes `INSERT INTO ... SELECT`
+ # (MSQ) available on /druid/v2/sql/task. The provider does not use that endpoint - the point
+ # is that the live pass can prove the *synchronous* endpoint rejects INSERT even when the
+ # engine is fully capable of it, which is the honest reason supportsCreateTable is false.
+ druid_extensions_loadList: '["druid-histogram", "druid-datasketches", "druid-lookups-cached-global", "postgresql-metadata-storage", "druid-multi-stage-query"]'
+ druid_zk_service_host: druid-zookeeper
+ druid_metadata_storage_host: ""
+ druid_metadata_storage_type: postgresql
+ druid_metadata_storage_connector_connectURI: jdbc:postgresql://druid-metadata:5432/druid
+ druid_metadata_storage_connector_user: druid
+ druid_metadata_storage_connector_password: druid
+ # Deep storage on a shared local volume. Real deployments use S3/HDFS; local is what makes
+ # this a single-machine fixture, and the historical and middlemanager must see the same files.
+ druid_storage_type: local
+ druid_storage_storageDirectory: /opt/shared/segments
+ druid_indexer_logs_type: file
+ druid_indexer_logs_directory: /opt/shared/indexing-logs
+ druid_indexer_runner_javaOptsArray: '["-server", "-Xmx1g", "-Xms1g", "-XX:MaxDirectMemorySize=1g", "-Duser.timezone=UTC", "-Dfile.encoding=UTF-8", "-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager"]'
+ druid_indexer_fork_property_druid_processing_buffer_sizeBytes: 100MiB
+ druid_processing_numThreads: "2"
+ druid_processing_numMergeBuffers: "2"
+ # Lets the Router at 8888 proxy Coordinator and Overlord APIs, so the whole cluster is
+ # reachable through the one port the provider targets.
+ druid_router_managementProxy_enabled: "true"
+ # info, not the upstream compose's debug: the debug logger emits one line per internal
+ # request and buries the errors a live pass is looking for.
+ druid_emitter_logging_logLevel: info
+
services:
postgres:
image: postgres:18
@@ -163,3 +199,172 @@ services:
# No seed sidecar: CLICKHOUSE_DB above creates `demo` on first boot, and unlike Couchbase
# there is no index prerequisite - a SELECT against a freshly created table works
# immediately. Do not add one.
+
+ # ---------------------------------------------------------------------------------------
+ # Apache Druid. Unlike every other service here, Druid is a distributed system and has no
+ # single-container mode: five Druid processes plus ZooKeeper plus a metadata database is
+ # the minimum that can answer a SQL query. That is why all seven carry `profiles: [druid]`
+ # - a bare `docker compose -f database-compose.yml up -d` would otherwise grow from 7
+ # containers to 14 and from roughly 6 GB of RAM to 12. Start it explicitly:
+ #
+ # docker compose -f database-compose.yml --profile druid up -d
+ #
+ # The provider only ever talks to the Router (8888) or the Broker (8082); the other five
+ # processes are cluster internals and deliberately publish no host port. 8091, the
+ # MiddleManager's usual port, is already taken by couchbase above - another reason not to.
+ # ---------------------------------------------------------------------------------------
+ druid-metadata:
+ # Druid's metadata store (segment table, task history, supervisors, config). Distinct
+ # from the `postgres` service above on purpose: sharing it would put Druid's ~20 internal
+ # tables into the demo database the studio's own postgres connection browses.
+ image: postgres:17.6
+ container_name: libredb-druid-metadata
+ profiles: ["druid"]
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: druid
+ POSTGRES_PASSWORD: druid
+ POSTGRES_DB: druid
+ # No published port: only the Druid Coordinator/Overlord reads it, over the compose
+ # network. Publishing it would collide with the `postgres` service on 5432.
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U druid -d druid"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+ druid-zookeeper:
+ # Druid's cluster coordination: process discovery, segment load queues, task assignment.
+ image: zookeeper:3.9.3
+ container_name: libredb-druid-zookeeper
+ profiles: ["druid"]
+ restart: unless-stopped
+ environment:
+ ZOO_MY_ID: "1"
+ # zkServer.sh's own status check uses the `srvr` four-letter word, which 3.9 refuses
+ # unless it is whitelisted.
+ ZOO_4LW_COMMANDS_WHITELIST: "srvr,ruok,conf"
+ healthcheck:
+ test: ["CMD-SHELL", "echo ruok | nc -w 2 localhost 2181 | grep -q imok"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+ druid-coordinator:
+ # Coordinator + Overlord in one process (the image's coordinator config sets
+ # druid.coordinator.asOverlord.enabled), so segment balancing and task management both
+ # live here. This is what accepts an ingestion task, which is how a datasource is
+ # created at all - Druid has no CREATE TABLE.
+ image: apache/druid:37.0.0
+ container_name: libredb-druid-coordinator
+ profiles: ["druid"]
+ restart: unless-stopped
+ command: ["coordinator"]
+ environment: *druid-env
+ volumes:
+ - druid_shared:/opt/shared
+ - druid_coordinator_var:/opt/druid/var
+ depends_on:
+ druid-metadata:
+ condition: service_healthy
+ druid-zookeeper:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:8081/status/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 40
+ druid-historical:
+ # Serves published segments. Without it a datasource exists in the metadata store but
+ # returns no rows, which is the "datasource with no segments" case the provider has to
+ # render honestly.
+ image: apache/druid:37.0.0
+ container_name: libredb-druid-historical
+ profiles: ["druid"]
+ restart: unless-stopped
+ command: ["historical"]
+ environment: *druid-env
+ volumes:
+ - druid_shared:/opt/shared
+ - druid_historical_var:/opt/druid/var
+ depends_on:
+ druid-coordinator:
+ condition: service_started
+ druid-zookeeper:
+ condition: service_healthy
+ druid-middlemanager:
+ # Runs ingestion tasks as forked peon processes. Needed for the live pass to load data.
+ image: apache/druid:37.0.0
+ container_name: libredb-druid-middlemanager
+ profiles: ["druid"]
+ restart: unless-stopped
+ command: ["middleManager"]
+ environment: *druid-env
+ volumes:
+ - druid_shared:/opt/shared
+ - druid_middlemanager_var:/opt/druid/var
+ depends_on:
+ druid-coordinator:
+ condition: service_started
+ druid-zookeeper:
+ condition: service_healthy
+ druid-broker:
+ # Plans and merges queries. POST /druid/v2/sql here is the same API the Router proxies,
+ # so the provider works against 8082 directly too - published so that can be proven
+ # rather than assumed.
+ image: apache/druid:37.0.0
+ container_name: libredb-druid-broker
+ profiles: ["druid"]
+ restart: unless-stopped
+ command: ["broker"]
+ environment: *druid-env
+ volumes:
+ - druid_broker_var:/opt/druid/var
+ ports:
+ - "8082:8082"
+ depends_on:
+ druid-coordinator:
+ condition: service_started
+ druid-zookeeper:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:8082/status/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 40
+ druid-router:
+ # The provider's default target. It fronts the SQL API, the web console and - because
+ # druid_router_managementProxy_enabled is set - the Coordinator and Overlord APIs, so a
+ # single port is enough for both querying and loading data.
+ image: apache/druid:37.0.0
+ container_name: libredb-druid-router
+ profiles: ["druid"]
+ restart: unless-stopped
+ command: ["router"]
+ environment: *druid-env
+ volumes:
+ - druid_router_var:/opt/druid/var
+ ports:
+ - "8888:8888"
+ depends_on:
+ druid-broker:
+ condition: service_started
+ druid-coordinator:
+ condition: service_started
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:8888/status/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 40
+
+volumes:
+ # Named volumes only for Druid: it is the one service here whose processes must share
+ # state (deep storage) and whose task history is worth surviving a restart. Everything
+ # above uses the image's own anonymous volumes.
+ #
+ # druid_shared is the deep-storage + indexing-log volume, mounted by the three processes
+ # that write or read segments. A `down -v` resets the cluster to empty.
+ druid_shared: {}
+ druid_coordinator_var: {}
+ druid_historical_var: {}
+ druid_middlemanager_var: {}
+ druid_broker_var: {}
+ druid_router_var: {}
diff --git a/docs/ADDING_A_PROVIDER.md b/docs/ADDING_A_PROVIDER.md
index 3ae132e3..196e5fe4 100644
--- a/docs/ADDING_A_PROVIDER.md
+++ b/docs/ADDING_A_PROVIDER.md
@@ -19,11 +19,12 @@ Three decisions. The first is the consequential one, which is why it is first.
1. **Does it need a driver at all?** Score the engine against the rubric below. A database with a
first-class HTTP API can be supported with no dependency at all, and that is worth real effort to
- establish before you start. Three shipped providers need no driver: SQLite uses the built-in
- `bun:sqlite`/`node:sqlite` via `sqlite-driver.ts`, Couchbase talks to the cluster over
- documented REST endpoints with `fetch`/`node:https` ([couchbase.md](./providers/couchbase.md)),
- and ClickHouse speaks SQL over its HTTP interface the same way
- ([clickhouse.md](./providers/clickhouse.md)). If it does need one, it will be something like `pg`,
+ establish before you start. Four shipped providers need no driver: SQLite uses the built-in
+ `bun:sqlite`/`node:sqlite` via `sqlite-driver.ts`, and three reach the engine over HTTP with
+ nothing but `fetch`/`node:https` — Couchbase over the documented REST endpoints
+ ([couchbase.md](./providers/couchbase.md)), ClickHouse over its HTTP interface
+ ([clickhouse.md](./providers/clickhouse.md)), and Apache Druid over `POST /druid/v2/sql`
+ ([druid.md](./providers/druid.md)). If it does need one, it will be something like `pg`,
`mysql2`, `mongodb`, `ioredis`, `oracledb` or `mssql`.
2. **Which base class?**
@@ -32,8 +33,11 @@ Three decisions. The first is the consequential one, which is why it is first.
`this.type` — identifier and string escaping, `LIMIT` clause building, placeholder style,
read-only and DDL detection — plus a `prepareQuery()` that applies the shared query limiter.
None of it touches a pool, a driver or a connection, so **an HTTP transport is no reason to
- avoid it.** A standard-SQL engine reached over HTTP, such as ClickHouse, should extend it and
- get all of that for free.
+ avoid it.** A standard-SQL engine reached over HTTP, such as ClickHouse or Apache Druid, should
+ extend it and get all of that for free. Druid is the clearest case of how little is left over:
+ double-quoted identifiers and `LIMIT n OFFSET m` are both correct Druid SQL, so
+ `escapeIdentifier()`, `buildLimitClause()` and `getPlaceholder()` are inherited unchanged and
+ `prepareQuery()` is the only override — for a single dialect trap, not for the transport.
- **Non-SQL databases → extend `BaseDatabaseProvider`** directly, like MongoDB and Redis.
- The one reason a SQL-speaking provider extends `BaseDatabaseProvider` anyway is a dialect the
shared helpers cannot express. Couchbase is that case: SQL++ quotes identifiers with doubled
@@ -151,10 +155,10 @@ directly; reshaping rows inside the transport would have broken schema loading.
```typescript
// Before:
-export type DatabaseType = 'postgres' | 'mysql' | 'sqlite' | 'mongodb' | 'redis' | 'oracle' | 'mssql' | 'libredb' | 'couchbase';
+export type DatabaseType = 'postgres' | 'mysql' | 'sqlite' | 'mongodb' | 'redis' | 'oracle' | 'mssql' | 'libredb' | 'couchbase' | 'clickhouse' | 'druid';
// After (example: adding CockroachDB):
-export type DatabaseType = 'postgres' | 'mysql' | 'sqlite' | 'mongodb' | 'redis' | 'oracle' | 'mssql' | 'libredb' | 'couchbase' | 'cockroachdb';
+export type DatabaseType = 'postgres' | 'mysql' | 'sqlite' | 'mongodb' | 'redis' | 'oracle' | 'mssql' | 'libredb' | 'couchbase' | 'clickhouse' | 'druid' | 'cockroachdb';
```
### 1.2 — Add to `QueryTab.type` if needed
@@ -185,7 +189,7 @@ is kept in sync with its per-provider doc). Don't copy a skeleton from this guid
|-------------------|--------|------------------|-----------|
| Pooled SQL (wire-protocol DB) | `SQLBaseProvider` | `postgres.ts` / `mysql.ts` | [postgres.md](./providers/postgres.md) · [mysql.md](./providers/mysql.md) |
| Embedded / file SQL | `SQLBaseProvider` | `sqlite.ts` | [sqlite.md](./providers/sqlite.md) |
-| SQL database reached over HTTP (no driver) | `SQLBaseProvider` | `sql/clickhouse/` | [clickhouse.md](./providers/clickhouse.md) |
+| SQL database reached over HTTP (no driver) | `SQLBaseProvider` | `sql/clickhouse/` or `sql/druid/` | [clickhouse.md](./providers/clickhouse.md) · [druid.md](./providers/druid.md) |
| Document store | `BaseDatabaseProvider` | `mongodb.ts` | [mongodb.md](./providers/mongodb.md) |
| Document store reached over HTTP/REST (no driver) | `BaseDatabaseProvider` | `document/couchbase/` | [couchbase.md](./providers/couchbase.md) |
| Key-value store | `BaseDatabaseProvider` | `redis.ts` | [redis.md](./providers/redis.md) |
@@ -294,6 +298,7 @@ Then add the type to the selectable list that drives the ConnectionModal picker:
// Append to the existing list - do not retype it, or you will drop a provider from the picker.
const selectableTypes: DatabaseType[] = [
'postgres', 'mysql', 'sqlite', 'oracle', 'mssql', 'mongodb', 'couchbase', 'redis', 'libredb',
+ 'clickhouse', 'druid',
'cockroachdb',
];
```
@@ -312,6 +317,8 @@ bun add
# bun add ioredis (Redis)
# SQLite needs no driver — bun:sqlite / node:sqlite are runtime built-ins (see sqlite-driver.ts)
# Couchbase needs no driver — it speaks the Query and management REST APIs over fetch/node:https
+# ClickHouse needs no driver — plain SQL over its HTTP interface (port 8123)
+# Apache Druid needs no driver — plain SQL over POST /druid/v2/sql (Router 8888 or Broker 8082)
```
If your engine exposes a documented HTTP API, weigh it against the native driver before adding a
@@ -326,8 +333,32 @@ rather than a mock.
**HTTP 200 does not mean success.** Couchbase returns syntax and semantic errors inside a 200
response with `status: "errors"`, and Trino does the same with a `QueryError` field. Check the
payload before the HTTP status, or a failed statement reads as "0 rows". This is not universal —
-Apache Druid uses real 400/500 codes — so establish which behaviour applies before writing the error
-path.
+Apache Druid does use real 400 / 500 / 504 codes — so establish which behaviour applies before
+writing the error path.
+
+**Real status codes still misclassify.** Druid answers `SELECT 1/0` with **HTTP 500**,
+`persona: "ADMIN"` and `category: "UNCATEGORIZED"`, message "/ by zero" — an ordinary user mistake
+reported as an admin-facing server failure, so reading 5xx as "the cluster is broken" would tell the
+user something false. ClickHouse has the same hazard: a denied grant is a 500 rather than a 403, and
+its message says "Not enough privileges" while containing neither "access denied" nor "permission
+denied". **Classify on the engine's own error category or code, never on the status and never by
+sniffing message text.** Druid's `category` is present in both of the envelopes it uses (the
+structured `druidException` and the legacy wrapper) and is a closed enum; ClickHouse's numeric
+exception code is in its plain-text error body. Each provider branches on that one field and on
+nothing else.
+
+**64-bit integers can arrive as unquoted JSON numbers, and `JSON.parse` rounds them silently.**
+ClickHouse turns `18446744073709551615` into `...552000`; Druid turns `9007199254740993` into
+`9007199254740992`. No error is raised in either case, so the wrong number reaches the grid looking
+exactly like the right one. Ask the server to quote them if it can — ClickHouse takes
+`output_format_json_quote_64bit_integers=1` — and if it cannot, own the fix: Druid has no such
+setting, so its transport runs a string-aware pass over the **raw body** before parsing and quotes
+every integer literal outside `Number.MIN_SAFE_INTEGER … Number.MAX_SAFE_INTEGER`. String-aware is
+the load-bearing part; a naive digit-run rewrite corrupts `"id: 9007199254740993"` inside a value.
+Either way the number reaches the UI as an exact string, which is what the `pg` driver already does
+for `int8`. The generalisable lesson: check the widest integer type your engine supports against
+`Number.MAX_SAFE_INTEGER` before trusting `JSON.parse`, and expect to write the fix yourself when the
+server offers no switch.
**The response envelope does not always describe the rows.** Couchbase's `signature` is `"*"` for
`SELECT *`, and `{ id, "*" }` for a wildcard mixed with named projections. Taking those keys
@@ -373,6 +404,14 @@ control that only emits invalid input. That is the defect class
strategy declines, so the button is dead while only the background pre-warm works. When the engine
has no analyze equivalent, return the estimate for both modes — `sqlite-queryplan.ts` and
`couchbase-json.ts` both do exactly that.
+- A capability can be absent because the **grammar** lacks it rather than because nobody implemented
+ it, and the flag reads the same either way — so check, and then say so. Druid answers
+ `CREATE TABLE t (id BIGINT)` with a syntax error, because `CREATE` is not one of its statements at
+ all (a datasource comes into existence by being ingested into), so `supportsCreateTable` is
+ `false`. Nothing in `MaintenanceType` has a SQL-reachable Druid analogue either — compaction and
+ retention are Coordinator and task concerns, and `kill` has nowhere to get a query id from because
+ Druid publishes no catalog of running queries — so `supportsMaintenance` is `false` with an empty
+ operation list, rather than true with nothing behind it.
The same honesty rule governs monitoring: **a source the connected user cannot read returns empty,
it never throws.** Monitoring catalogs are frequently permission-gated, so a denial is the normal
@@ -385,12 +424,16 @@ case for a restricted user and must not break an otherwise working connection.
Mock-based tests are the repo standard and they are **not sufficient on their own**. On the
Couchbase provider a live pass against a real cluster disproved a design decision — un-indexed
collections turned out to be queryable on Server 7.6+ through a sequential scan — and found three
-defects the mocks had accepted without complaint.
+defects the mocks had accepted without complaint. On Druid it overturned a verdict recorded in **this
+guide** (see [Driver-free candidates](#driver-free-candidates)): the EXPLAIN output was predicted not
+to fit the tree render model, and the real plan turned out to be a genuine nested tree.
Before opening the PR, drive the provider through the running application against a real server:
- full `INSERT` / `UPDATE` / `SELECT` / `DELETE`, including a `SELECT` immediately after a write, to
- catch read-your-writes problems
+ catch read-your-writes problems — or establish that the engine has no write statement to test.
+ Druid SQL has neither `UPDATE` nor `DELETE` in its grammar and rejects `INSERT`/`REPLACE` on the
+ native engine, and each of those is a claim only an actual attempt can settle
- both error paths — a syntax error and a missing object — confirming each surfaces as an error
rather than as zero rows
- schema introspection, checking column types and the object-naming rule
@@ -398,7 +441,11 @@ Before opening the PR, drive the provider through the running application agains
a broken direct action
- every monitoring panel, and each maintenance operation
-Add a service to `database-compose.yml` so the next person can repeat this.
+Add a service to `database-compose.yml` so the next person can repeat this — or a profile-gated set of
+them, which is what a distributed engine needs. Druid has no single-container mode, so its seven
+services all carry `profiles: ["druid"]`: a default `docker compose up -d` must not double for
+everyone who is not working on Druid, and `docker compose --profile druid down` is then needed to
+remove them again.
---
@@ -516,27 +563,43 @@ For the authoritative, code-verified reference for each shipped provider (extend
driver, pooling, capabilities, labels, `prepareQuery` behaviour, and limitations), see the prime
docs — they are the single source of truth and are kept in sync with the code:
-**[docs/providers/](./providers/README.md)** → postgres · mysql · oracle · mssql · sqlite · redis · mongodb · couchbase · clickhouse · libredb
+**[docs/providers/](./providers/README.md)** → postgres · mysql · oracle · mssql · sqlite · redis · mongodb · couchbase · clickhouse · druid · libredb
When implementing a new provider, the closest existing analogue is the best template: a pooled SQL
provider (postgres/mysql), an embedded SQL provider (sqlite), a non-SQL provider (mongodb/redis), or
-a driverless provider reached over HTTP (couchbase).
+a driverless provider reached over HTTP (clickhouse or druid for SQL, couchbase for a document
+store).
## Driver-free candidates
Assessed against the rubric in [Prerequisites](#prerequisites). Anything not listed almost certainly needs a driver.
+**Shipped since this list was written:** Couchbase
+([#263](https://github.com/libredb/libredb-studio/issues/263)), ClickHouse
+([#264](https://github.com/libredb/libredb-studio/issues/264)) and Apache Druid
+([#265](https://github.com/libredb/libredb-studio/issues/265)).
+
+Druid is worth a paragraph, because it **corrected this table's own verdict**. The entry that stood
+here rated it strong but predicted that `EXPLAIN PLAN FOR` "returns a native-query translation rather
+than an operator tree, so it does not fit the existing tree render model". The live plan disproved
+that: `query.dataSource` recurses — `join` carries `left` and `right`, `query` carries one child,
+`union` carries a list — so the native query **is** a nested tree, and it renders as
+`{ kind: "tree" }` with nothing forced. What keeps that honest is the omission: Druid's planner emits
+no cost and no row estimate, so no node carries `metrics`, and node labels name Druid's own query
+types (`groupBy`, `scan`, `timeseries`, `topN`) rather than borrowing a relational-plan vocabulary.
+The lesson for the next candidate is to read the engine's real EXPLAIN output before predicting the
+render model from its documentation. See [druid.md](./providers/druid.md).
+
| Candidate | Verdict |
|---|---|
-| **Apache Druid** ([#265](https://github.com/libredb/libredb-studio/issues/265)) | Strong, and it returns errors with real HTTP status codes. `EXPLAIN PLAN FOR` returns a native-query translation rather than an operator tree, so it does not fit the existing tree render model |
| **Trino / Starburst** | Highest strategic value — one provider fronts S3, Iceberg, Delta and Hive. Unscheduled on purpose: a catalog is another *system*, so what a connection pins is a product question. Also a `nextUri` polling protocol and a fragmented auth matrix |
| **OpenSearch / Elasticsearch** | HTTP is the only protocol. Needs a dialect decision first: the SQL endpoint is a subset, the native DSL is JSON. OpenSearch is Apache 2.0 and the cleaner primary target |
| **Snowflake / BigQuery / Databricks SQL** | REST SQL APIs exist and the data model fits; auth is the wall (key-pair JWT, service-account signing, OAuth) and that is where the no-dependency promise ends |
| **CouchDB, ArangoDB, SurrealDB, Qdrant, Weaviate** | All HTTP, all non-SQL or only partially SQL. Feasible, but each needs its own query grammar the way MongoDB and LibreDB do |
Contributions are welcome for any of these. Open an issue with the rubric score first, so the design
-decisions are settled before code exists — that is what let the Couchbase provider land as a single
-reviewable PR.
+decisions are settled before code exists — that is what let the Couchbase, ClickHouse and Druid
+providers each land as a single reviewable PR.
---
@@ -554,8 +617,11 @@ The integration points, all of which need an entry. This is the list the Strateg
- [ ] `src/hooks/use-connection-form.ts` — **append** to `selectableTypes` (do not retype the array)
- [ ] `src/components/icons/db-icons.tsx` — the engine's mark (`strokeWidth={1.5}`, no HTML size attrs)
- [ ] `src/lib/seed/types.ts` — the seed-config `type` enum, or seeded connections fail validation
-- [ ] `package.json` — the driver, **if** it needs one. A driver-free provider leaves it untouched
-- [ ] `database-compose.yml` — a service, so the next person can repeat the live pass
+- [ ] `package.json` — the driver, **if** it needs one. A driver-free provider leaves it untouched, and
+ three shipped ones do: `couchbase`, `clickhouse` and `druid` each add nothing here
+- [ ] `database-compose.yml` — a service, so the next person can repeat the live pass. A distributed
+ engine contributes a `profiles: [...]` set instead, as Druid's seven services do, so the default
+ stack does not grow for everyone
**Conditionally, and each one is easy to miss because the code still compiles without it:**
@@ -578,7 +644,7 @@ connection-form test), so the compiler and those tests refuse to pass until each
> **`git grep -l -- src/ tests/` is the authoritative checklist.**
> This list is maintained by hand and has been wrong before: it long claimed "no other files should
> need changes", while Couchbase (#263) and ClickHouse (#264) each touched 27 files under `src/` and
-> `tests/`. Trust the grep over this list.
+> `tests/`, and Druid (#265) roughly two dozen of its own. Trust the grep over this list.
What the Strategy Pattern *does* spare you is **provider logic**: no route, no shared component and no
existing provider needs to know your engine exists. If you find yourself adding a `=== ''`
diff --git a/docs/API_DOCS.md b/docs/API_DOCS.md
index 1c5217f6..6b55c88b 100644
--- a/docs/API_DOCS.md
+++ b/docs/API_DOCS.md
@@ -24,12 +24,12 @@
## Overview
-LibreDB Studio provides a RESTful API for database management operations. The API supports PostgreSQL, MySQL, SQLite, Oracle, SQL Server, MongoDB, Couchbase, ClickHouse, and Redis.
+LibreDB Studio provides a RESTful API for database management operations. The API supports PostgreSQL, MySQL, SQLite, Oracle, SQL Server, MongoDB, Couchbase, ClickHouse, Apache Druid, and Redis.
### Key Features
- **JWT Authentication** - Secure token-based authentication stored in HTTP-only cookies
-- **Multi-Database Support** - PostgreSQL, MySQL, SQLite, Oracle, SQL Server, MongoDB, Couchbase, ClickHouse, Redis
+- **Multi-Database Support** - PostgreSQL, MySQL, SQLite, Oracle, SQL Server, MongoDB, Couchbase, ClickHouse, Apache Druid, Redis
- **AI-Powered Queries** - Natural language to SQL with streaming responses
- **Real-time Health Monitoring** - Database metrics and performance insights
@@ -417,6 +417,50 @@ providers:
---
+##### Apache Druid Query Format
+
+Druid speaks SQL over `POST /druid/v2/sql`, so the `sql` field carries a plain statement. Three
+things differ from the other SQL providers:
+
+- **There is no `database` and no `connectionString`.** `INFORMATION_SCHEMA.SCHEMATA` reports exactly
+ one catalog, always `druid`, so a connection is `host` + `port` alone. `user`/`password` are
+ optional and are sent as HTTP basic auth for a cluster running `druid-basic-security`; a default
+ install ignores the `Authorization` header entirely. `port` is the Router's `8888`; the Broker's
+ `8082` serves the identical endpoint and needs no other change.
+- **Druid SQL cannot write.** `UPDATE` and `DELETE` are not in the grammar, `CREATE TABLE` is a
+ syntax error, and `INSERT` / `REPLACE` are refused by the native engine ("consider using MSQ").
+ Each comes back as `400` / `QUERY_ERROR` carrying Druid's own message, which names the reason and
+ the alternative. `POST /api/db/maintenance` accepts no operation at all for a `druid` connection.
+- **A statement ending in `OFFSET n` with no `LIMIT` is sent unchanged**, so `wasLimited` is `false`:
+ Druid rejects `OFFSET n LIMIT m` ("'OFFSET start LIMIT count' is not allowed under the current SQL
+ conformance level"), so the auto-limiter must not append one there. Every other statement is
+ limited normally.
+
+```json
+{
+ "connection": {
+ "type": "druid",
+ "host": "localhost",
+ "port": 8888
+ },
+ "sql": "SELECT * FROM \"libredb_demo\" LIMIT 50"
+}
+```
+
+**Notes:**
+- A duplicate output name (a join projecting two `id`s, say) is disambiguated rather than dropped:
+ `fields` carries `id` and `id (2)`, and both columns reach the grid.
+- `ORDER BY` on a non-`__time` column of a plain table scan is refused by the planner ("SQL query
+ requires ordering a table by non-time column"). Order by `__time`, or aggregate with `GROUP BY`.
+- Druid uses Calcite's reserved-word list, which is large and surprising — `SELECT 1 AS one` is a
+ syntax error — so every generated identifier is double-quoted.
+- Integers wider than 2^53 are returned as exact strings rather than as JSON numbers, so no value is
+ silently rounded on the way to the grid. `ARRAY` columns arrive as JSON strings (`"[1,2]"`), which
+ is what Druid's own clients show.
+- Full reference: [`docs/providers/druid.md`](providers/druid.md).
+
+---
+
##### Redis Query Format
Redis is a key-value store, so the `sql` field carries a Redis command instead of SQL. Two interchangeable formats are accepted.
@@ -615,6 +659,8 @@ Run database maintenance operations.
The handler validates against the target provider's capabilities: `type` is required (`{ "error": "Maintenance type is required" }`), the provider must support maintenance at all, and the requested operation must be in that provider's supported set (see the matrix above) — otherwise a `400` is returned listing what the provider does support.
+A `druid` connection fails the second check whatever the `type` is, with `{ "error": "Maintenance operations not supported for this database" }`: no maintenance operation is reachable from Druid SQL, so its supported set is empty by design. Compaction and retention are Coordinator and task concerns, and Druid publishes no catalog of running queries, so there is no id for `kill` to name.
+
---
### AI API
@@ -792,12 +838,12 @@ interface DatabaseConnection {
port?: number; // Port number
user?: string; // Username
password?: string; // Password
- database?: string; // Database name (Couchbase: the bucket)
- connectionString?: string; // Full connection string (alternative)
+ database?: string; // Database name (Couchbase: the bucket; Druid: unused, it has one catalog)
+ connectionString?: string; // Full connection string (alternative; Druid has no URI form, host + port only)
createdAt: Date; // Creation timestamp
}
-type DatabaseType = 'postgres' | 'mysql' | 'sqlite' | 'mongodb' | 'redis' | 'oracle' | 'mssql' | 'libredb' | 'couchbase' | 'clickhouse';
+type DatabaseType = 'postgres' | 'mysql' | 'sqlite' | 'mongodb' | 'redis' | 'oracle' | 'mssql' | 'libredb' | 'couchbase' | 'clickhouse' | 'druid';
```
### TableSchema
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index a67a368b..747406ff 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -4,7 +4,7 @@ This document outlines the architectural patterns, tech stack, and system design
## System Overview
-LibreDB Studio is a hybrid, cloud-native database management tool that provides an IDE-like experience in the browser. It supports **10 database backends** via a Strategy Pattern abstraction: PostgreSQL, MySQL, SQLite, Oracle, SQL Server, MongoDB, Couchbase, ClickHouse, Redis, LibreDB.
+LibreDB Studio is a hybrid, cloud-native database management tool that provides an IDE-like experience in the browser. It supports **11 database backends** via a Strategy Pattern abstraction: PostgreSQL, MySQL, SQLite, Oracle, SQL Server, MongoDB, Couchbase, ClickHouse, Apache Druid, Redis, LibreDB.
It runs in two modes: as a **standalone Next.js app** and as an **embedded npm package** (`@libredb/studio`) consumed by libredb-platform. See [§4.6](#46-workspace-abstraction-npm-package-embedding).
@@ -48,6 +48,7 @@ graph TD
SQL --> Oracle[(Oracle)]
SQL --> MSSQL[(SQL Server)]
SQL --> ClickHouse[(ClickHouse)]
+ SQL --> Druid[(Apache Druid)]
Document --> MongoDB[(MongoDB)]
Document --> Couchbase[(Couchbase)]
KeyValue --> Redis[(Redis)]
@@ -101,6 +102,7 @@ classDiagram
SQLBaseProvider <|-- OracleProvider
SQLBaseProvider <|-- MSSQLProvider
SQLBaseProvider <|-- ClickHouseProvider
+ SQLBaseProvider <|-- DruidProvider
```
Each provider implements:
@@ -110,7 +112,7 @@ Each provider implements:
Adding a new database type requires: **1 provider class** + **1 entry in `db-ui-config.ts`**.
-`CouchbaseProvider` extends `BaseDatabaseProvider` even though SQL++ is a SQL dialect: `SQLBaseProvider` owns pooled-driver mechanics (per-dialect escaping, placeholders, transactions, `cancelQuery`) that its stateless HTTP transport does not have, so the SQL-ness is declared through `queryLanguage: 'sql'` instead. It is also the only provider with no driver dependency — the cluster is reached over the documented Query and management REST APIs. See [`docs/providers/couchbase.md`](providers/couchbase.md).
+`CouchbaseProvider` extends `BaseDatabaseProvider` even though SQL++ is a SQL dialect: SQL++ quotes identifiers with doubled backticks, which `escapeIdentifier()` produces for no existing type, so it owns its quoting and declares its SQL-ness through `queryLanguage: 'sql'` instead. Being reached over HTTP is **not** the reason — `ClickHouseProvider` and `DruidProvider` add no driver either, and both extend `SQLBaseProvider`, because double-quoted identifiers and `LIMIT n OFFSET m` are correct in both dialects. Each of the three is a directory rather than a single file, with its wire format behind a transport seam that provider logic never bypasses. See [`docs/providers/couchbase.md`](providers/couchbase.md), [`clickhouse.md`](providers/clickhouse.md) and [`druid.md`](providers/druid.md).
## 4. Key Architectural Patterns
@@ -237,7 +239,7 @@ src/
└── lib/
├── db/ # Database provider module
│ ├── providers/
- │ │ ├── sql/ # postgres, mysql, sqlite (+ sqlite-driver runtime adapter), oracle, mssql, clickhouse/ (transport seam + SQL over HTTP)
+ │ │ ├── sql/ # postgres, mysql, sqlite (+ sqlite-driver runtime adapter), oracle, mssql, clickhouse/ (transport seam + SQL over HTTP), druid/ (transport seam + SQL over POST /druid/v2/sql)
│ │ ├── document/ # mongodb, couchbase/ (transport seam + SQL++ over REST)
│ │ ├── keyvalue/ # redis
│ │ └── embedded/ # libredb (built-in embedded provider for the sample connection)
diff --git a/docs/DATABASE_PROVIDERS.md b/docs/DATABASE_PROVIDERS.md
index c366fa27..6b1ebec7 100644
--- a/docs/DATABASE_PROVIDERS.md
+++ b/docs/DATABASE_PROVIDERS.md
@@ -29,11 +29,16 @@ src/lib/db/
│ │ ├── sqlite-driver.ts # SQLite runtime driver adapter (bun:sqlite | node:sqlite)
│ │ ├── oracle.ts # Oracle Strategy
│ │ ├── mssql.ts # SQL Server Strategy
-│ │ └── clickhouse/ # ClickHouse Strategy (SQL over HTTP, no driver)
-│ │ ├── index.ts # ClickHouseProvider
-│ │ ├── transport.ts # ClickHouseTransport seam + neutral result types
+│ │ ├── clickhouse/ # ClickHouse Strategy (SQL over HTTP, no driver)
+│ │ │ ├── index.ts # ClickHouseProvider
+│ │ │ ├── transport.ts # ClickHouseTransport seam + neutral result types
+│ │ │ ├── http-transport.ts # The one HTTP implementation (fetch)
+│ │ │ └── introspect.ts # system.* catalogs (databases/tables/columns/data_skipping_indices)
+│ │ └── druid/ # Apache Druid Strategy (SQL over POST /druid/v2/sql, no driver)
+│ │ ├── index.ts # DruidProvider
+│ │ ├── transport.ts # DruidTransport seam + neutral result types + error categories
│ │ ├── http-transport.ts # The one HTTP implementation (fetch)
-│ │ └── introspect.ts # system.* catalogs (databases/tables/columns/data_skipping_indices)
+│ │ └── introspect.ts # INFORMATION_SCHEMA datasources + sys.servers/segments/tasks
│ ├── document/ # Document Database Providers
│ │ ├── mongodb.ts # MongoDB Strategy
│ │ └── couchbase/ # Couchbase Strategy (SQL++ over REST, no driver)
@@ -61,7 +66,8 @@ BaseDatabaseProvider (abstract)
│ ├── SQLiteProvider │ (shared SQL utilities)
│ ├── OracleProvider │
│ ├── MSSQLProvider │
-│ └── ClickHouseProvider │
+│ ├── ClickHouseProvider │
+│ └── DruidProvider │
├── MongoDBProvider ────────────────────────┤ Document Database
├── CouchbaseProvider ──────────────────────┤ Document Database (SQL++ over REST)
├── RedisProvider ──────────────────────────┤ Key-Value Store
@@ -70,7 +76,9 @@ BaseDatabaseProvider (abstract)
`SQLBaseProvider` provides SQL-specific helpers (LIMIT injection, identifier escaping, placeholder generation). Non-SQL databases like MongoDB, Redis, and LibreDB extend `BaseDatabaseProvider` directly. LibreDB is embedded (opened in-process from a file, like SQLite) but, having no SQL, it is a key-value-style provider rather than a SQL one.
-Couchbase is the one provider that speaks a SQL dialect (SQL++) without extending `SQLBaseProvider`: that base owns pooled-driver mechanics — per-dialect escaping, placeholder generation, transactions, `cancelQuery` — that a stateless HTTP transport does not have. Its SQL-ness is expressed through `queryLanguage: 'sql'` in the capabilities instead. See [providers/couchbase.md](./providers/couchbase.md).
+Couchbase is the one provider that speaks a SQL dialect (SQL++) without extending `SQLBaseProvider`: SQL++ quotes identifiers with doubled backticks, which `escapeIdentifier()` produces for no existing type, so it owns its quoting and expresses its SQL-ness through `queryLanguage: 'sql'` in the capabilities instead. See [providers/couchbase.md](./providers/couchbase.md).
+
+Being driver-free and reached over HTTP is not what decides the base class. `ClickHouseProvider` and `DruidProvider` add no driver either, and both extend `SQLBaseProvider`: double-quoted identifiers and `LIMIT n OFFSET m` are correct in both dialects, so identifier escaping, the `LIMIT` builder and the placeholder style are inherited rather than rewritten. Druid overrides only `prepareQuery()`, and only because it rejects `OFFSET n LIMIT m` — a statement that already ends in an `OFFSET` is therefore sent unlimited instead of being rewritten into a syntax error. See [providers/clickhouse.md](./providers/clickhouse.md) and [providers/druid.md](./providers/druid.md).
The `SQLiteProvider` loads its embedded driver at runtime through `sqlite-driver.ts`: `bun:sqlite` under Bun, `node:sqlite` under plain Node (Node >= 24 built-in). Set `LIBREDB_SQLITE_DRIVER=bun|node` to force a driver. `better-sqlite3` is **not** used by the DB provider — it is only the SQLite driver for the storage layer (`src/lib/storage/`).
@@ -111,7 +119,7 @@ QueryEditor /api/db/query
## Supported Databases
-Ten providers are supported. For the per-provider reference (driver, pooling, query format,
+Eleven providers are supported. For the per-provider reference (driver, pooling, query format,
monitoring, limitations, …) see the prime docs in **[`docs/providers/`](./providers/README.md)**:
| Provider | type-id | Family | Reference |
@@ -125,6 +133,7 @@ monitoring, limitations, …) see the prime docs in **[`docs/providers/`](./prov
| MongoDB | `mongodb` | Document | [providers/mongodb.md](./providers/mongodb.md) |
| Couchbase | `couchbase` | Document (SQL++) | [providers/couchbase.md](./providers/couchbase.md) |
| ClickHouse | `clickhouse` | SQL | [providers/clickhouse.md](./providers/clickhouse.md) |
+| Apache Druid | `druid` | SQL (read-only) | [providers/druid.md](./providers/druid.md) |
| LibreDB | `libredb` | Embedded (key-value) | [providers/libredb.md](./providers/libredb.md) |
## Core Interface
@@ -297,7 +306,14 @@ DatabaseError (base)
Provider-specific behaviour — pooling model, SSL/encryption, pagination, monitoring sources,
maintenance operations, and known limitations — is documented per provider under
[`docs/providers/`](./providers/README.md). Start there for anything specific to PostgreSQL, MySQL,
-Oracle, SQL Server, SQLite, Redis, MongoDB, Couchbase, ClickHouse, or LibreDB.
+Oracle, SQL Server, SQLite, Redis, MongoDB, Couchbase, ClickHouse, Apache Druid, or LibreDB.
+
+Not every provider has every feature, and the docs record the absences rather than glossing over
+them. Druid is the sharpest case: its SQL has no `UPDATE`, no `DELETE` and no `CREATE TABLE`, no
+maintenance operation is reachable from SQL, it has no user-defined indexes and no foreign keys, and
+it keeps no query log — so `supportsCreateTable` and `supportsMaintenance` are `false`, and
+`getIndexStats()`, `getSlowQueries()` and `getPerformanceMetrics()` return empty or zeroed values
+that are the truth about the engine rather than a fallback.
## Security Considerations
diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index db5740b4..0194fe78 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -27,7 +27,7 @@
### 4. Visual EXPLAIN (Query Analyzer)
* **Performance Visualization:** Visual execution plan to identify performance bottlenecks.
* **Detailed Metrics:** Graphical representation of database scan types, join operations, costs, and execution times.
-* **Multi-DB Support:** PostgreSQL and MySQL JSON plans, SQLite `EXPLAIN QUERY PLAN`, Couchbase SQL++ plan trees, and ClickHouse JSON plan trees. Providers without a real analyze mode hide the toggle instead of degrading to an estimate.
+* **Multi-DB Support:** PostgreSQL and MySQL JSON plans, SQLite `EXPLAIN QUERY PLAN`, Couchbase SQL++ plan trees, ClickHouse JSON plan trees, and Apache Druid native-query plan trees. Providers without a real analyze mode hide the toggle instead of degrading to an estimate. A plan also shows only the numbers its planner actually reports: Druid emits no cost and no row estimate, so its nodes carry structure and no metrics rather than invented ones.
### 5. AI Query Assistant (Multi-Provider LLM)
* **Natural Language to SQL:** Convert natural language requests into high-precision SQL code.
@@ -47,6 +47,7 @@
* **Oracle:** Full support with connection pooling (`oracledb`); introspection/monitoring via the `ALL_*`/`DBA_*` data-dictionary views.
* **SQL Server:** Full support with connection pooling (`mssql`); monitoring via DMVs (`sys.dm_*`).
* **ClickHouse:** Full support with **no driver dependency** — SQL over the documented HTTP interface, so the SQL editor, limiter and NL2SQL all apply. Column types read verbatim from `system.columns`, JSON EXPLAIN plan trees, and `OPTIMIZE TABLE` / table-statistics / query-kill maintenance.
+ * **Apache Druid:** Read-only support with **no driver dependency** — SQL over `POST /druid/v2/sql` on the Router (8888) or the Broker (8082), so the SQL editor, limiter and NL2SQL all apply. Datasources and column types from `INFORMATION_SCHEMA`, native-query EXPLAIN plan trees, and monitoring from `sys.segments` / `sys.servers` / `sys.tasks`. Read-only is the engine, not the integration: Druid SQL has no `UPDATE`, no `DELETE` and no `CREATE TABLE`, and no maintenance operation is reachable from SQL, so those controls are reported as unsupported instead of failing when used.
* **Document Databases:**
* **MongoDB:** Full support with official driver, JSON-based MQL queries, automatic schema inference, and aggregation pipelines.
* **Couchbase:** Full support with **no driver dependency** — SQL++ over the documented Query and management REST APIs, so the SQL editor, limiter and NL2SQL all apply. Buckets/scopes/collections flattened into the schema explorer, `INFER`-based column inference, visual EXPLAIN plans, and read-your-writes query consistency by default.
diff --git a/docs/SEED_CONNECTIONS.md b/docs/SEED_CONNECTIONS.md
index 10d056db..803241b1 100644
--- a/docs/SEED_CONNECTIONS.md
+++ b/docs/SEED_CONNECTIONS.md
@@ -58,7 +58,7 @@ defaults: # Optional — merged into every connection
connections:
- id: "analytics-pg" # Required, unique, lowercase slug [a-z0-9-]
name: "Analytics DB" # Required, display name in UI
- type: postgres # Required: postgres|mysql|sqlite|mongodb|redis|oracle|mssql|libredb
+ type: postgres # Required: postgres|mysql|sqlite|mongodb|redis|oracle|mssql|libredb|couchbase|clickhouse|druid
host: "${PG_HOST}"
port: 5432
database: analytics
@@ -86,6 +86,18 @@ connections:
roles: ["*"] # Everyone can see this
managed: false # User gets an editable copy
environment: development
+
+ - id: "events-druid"
+ name: "Druid Events"
+ type: druid
+ host: "${DRUID_HOST}"
+ port: 8888 # Router. The Broker's 8082 serves the same endpoint
+ roles: ["*"]
+ environment: production
+ # No `database`: Druid reports exactly one catalog, always `druid`, so there is
+ # nothing to select. No `connectionString` either - its HTTP SQL API has no URI
+ # convention, so host and port are the whole address.
+ # user/password are optional and only reach a cluster running druid-basic-security.
```
### Field Reference
@@ -100,13 +112,13 @@ connections:
| `connections` | Yes | — | Array of connection definitions (min 1) |
| `connections[].id` | Yes | — | Unique slug: `[a-z0-9-]+`, max 64 chars |
| `connections[].name` | Yes | — | Display name, max 128 chars |
-| `connections[].type` | Yes | — | Database type: `postgres`, `mysql`, `sqlite`, `mongodb`, `redis`, `oracle`, `mssql`, `libredb`, `couchbase`, `clickhouse` |
+| `connections[].type` | Yes | — | Database type: `postgres`, `mysql`, `sqlite`, `mongodb`, `redis`, `oracle`, `mssql`, `libredb`, `couchbase`, `clickhouse`, `druid` |
| `connections[].host` | No | — | Hostname or IP |
| `connections[].port` | No | — | Port number (1-65535) |
-| `connections[].database` | No | — | Database name |
+| `connections[].database` | No | — | Database name (Couchbase: the bucket. Druid has one catalog and ignores it) |
| `connections[].user` | No | — | Username |
| `connections[].password` | No | — | Password (use `${ENV_VAR}` syntax) |
-| `connections[].connectionString` | No | — | Full connection string (use `${ENV_VAR}`) |
+| `connections[].connectionString` | No | — | Full connection string (use `${ENV_VAR}`). Druid has no URI form — a `druid` connection needs `host` and is addressed by host and port only |
| `connections[].roles` | Yes | — | Access control: `["*"]`, `["admin"]`, `["user"]`, `["admin", "user"]` |
| `connections[].managed` | No | from defaults | `true` = read-only, `false` = editable copy |
| `connections[].environment` | No | from defaults | Environment badge |
diff --git a/docs/providers/README.md b/docs/providers/README.md
index cc94b7cf..e5fbfbf4 100644
--- a/docs/providers/README.md
+++ b/docs/providers/README.md
@@ -15,14 +15,15 @@ in lockstep with the code (see the tri-sync rule in [`../../CLAUDE.md`](../../CL
| 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) |
| 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 is). The official product name (e.g. "SQL Server") is used only
- in each doc's title and prose.
+ split across modules, as Couchbase, ClickHouse and Druid are). The official product name (e.g.
+ "SQL Server") is used only in each doc's title and prose.
- **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*).
diff --git a/docs/providers/druid.md b/docs/providers/druid.md
new file mode 100644
index 00000000..727387a4
--- /dev/null
+++ b/docs/providers/druid.md
@@ -0,0 +1,1522 @@
+# Apache Druid Provider
+
+> Apache Druid support for LibreDB Studio, built on Druid's SQL HTTP endpoint
+> (`POST /druid/v2/sql`, port `8888` on the Router or `8082` on the Broker) with **no driver
+> dependency of any kind**: every statement is a JSON body and the answer comes back through the
+> runtime's own `fetch`. This document is the single reference point for the Druid provider: design,
+> architecture, usage, and tests. If you are reading the code, extending Druid support, or authoring
+> a new provider over HTTP, start here.
+
+| | |
+|---|---|
+| **Status** | Implemented & shipped |
+| **Database type id** | `druid` |
+| **Family** | SQL (`src/lib/db/providers/sql/druid/`) |
+| **Driver** | None — HTTP only (`fetch`, a runtime built-in) |
+| **Query language** | `sql` (Apache Calcite dialect) |
+| **Default port** | `8888` (the Router). `8082` (the Broker) serves the identical endpoint and needs no different configuration — see [§3.3](#33-router-8888-or-broker-8082--both-work-identically) |
+| **Connection pooling** | None — each statement is one stateless HTTP request |
+| **Connection string** | **Not supported** — Druid has no URI convention for its SQL API ([§4.2](#42-there-is-no-connection-string-and-that-is-deliberate)) |
+| **EXPLAIN** | `druid-native` — the native query plan as a tree. Druid's `EXPLAIN` never executes the statement, so there is no separate analyze mode |
+| **Writes** | **None are possible.** Druid SQL has no `UPDATE`, no `DELETE` and no `CREATE TABLE`; `INSERT`/`REPLACE` need the MSQ task engine ([§5.5](#55-druid-sql-cannot-write-and-the-server-says-so-clearly)) |
+| **Transactions** | Not exposed (Druid has none) |
+| **Maintenance** | None — nothing in `MaintenanceType` has a SQL-reachable Druid analogue ([§8](#8-maintenance)) |
+| **Query cancellation** | No `cancelQuery`; the server-side statement deadline is what stops a runaway query ([§13](#13-known-limitations--future-work)) |
+| **Verified against** | **Apache Druid 37.0.0**, datasources `libredb_demo` (50 rows) and `libredb_rollup` (20 rows) |
+| **Source** | [`src/lib/db/providers/sql/druid/`](../../src/lib/db/providers/sql/druid/) |
+| **Tests** | [`tests/integration/db/druid-provider.test.ts`](../../tests/integration/db/druid-provider.test.ts) + [`tests/unit/db/druid/`](../../tests/unit/db/druid/) + [`tests/unit/lib/explain/druid-native.test.ts`](../../tests/unit/lib/explain/druid-native.test.ts) |
+| **Tracking issue** | [#265 — Add Apache Druid provider](https://github.com/libredb/libredb-studio/issues/265) |
+
+---
+
+## 1. Overview
+
+Apache Druid is a distributed real-time analytics database. Its **SQL layer is Apache Calcite over
+Druid's own native query engine**, exposed as a plain JSON HTTP endpoint — the same one Druid's own
+web console uses, and the one this provider speaks. That endpoint is the whole surface the provider
+needs: querying, catalog introspection and monitoring are all SQL, over one path.
+
+Three things are Druid-shaped, and nearly every decision below flows from one of them:
+
+1. **Druid SQL is read-only.** Not "writes are unimplemented here" — `UPDATE` and `DELETE` are not in
+ the grammar, `CREATE TABLE` is not in the grammar, and `INSERT`/`REPLACE` are rejected by the
+ engine that answers this endpoint. A datasource comes into existence by being **ingested into**,
+ and data is removed by marking segments unused and running a kill task
+ ([§5.5](#55-druid-sql-cannot-write-and-the-server-says-so-clearly)).
+2. **The JSON on the wire is not the JSON you would design.** The result is a *positional array
+ behind three header rows*, because the obvious object form silently drops duplicate columns
+ ([§3.4](#34-resultformat-array-because-the-object-form-loses-columns)); a 64-bit integer arrives as
+ an unquoted number that `JSON.parse` rounds, with no server-side setting to fix it
+ ([§3.6](#36-64-bit-integers-arrive-unquoted-and-druid-offers-no-server-side-fix)); and the error
+ body's `error` field is a *discriminator* rather than a message
+ ([§3.7](#37-two-error-envelopes-and-the-http-status-is-not-enough)).
+3. **The HTTP status misclassifies, in both directions.** `SELECT 1/0` — an ordinary typo — answers
+ **HTTP 500** with `persona: "ADMIN"`. Every failure in this provider is classified by the
+ `category` Druid reports, never by the status code.
+
+### Concept mapping
+
+| `DatabaseProvider` slot | Druid realisation | Mechanism |
+|---|---|---|
+| "Table" (`TableSchema`) | A **datasource**, displayed by its bare name | `INFORMATION_SCHEMA.TABLES` where `TABLE_SCHEMA = 'druid'` |
+| "Row" | One result row | One positional array element behind the header rows |
+| Columns | The datasource's column list, SQL types verbatim | `INFORMATION_SCHEMA.COLUMNS` / the query response's header rows |
+| Primary key | none — nothing in a datasource is unique | `isPrimary: false` on every column, `__time` included ([§6](#6-schema-introspection)) |
+| `query(sql)` | One SQL statement, with `?` parameters bound | `POST /druid/v2/sql` |
+| Indexes | none — every dimension is indexed inside its segment, with no index *object* | always `[]` |
+| Foreign keys | none (Druid has none) | always `[]` |
+| `getOverview()` / storage | Process identity, segment bytes, datasource count, running tasks | `sys.servers`, `sys.segments`, `INFORMATION_SCHEMA.TABLES`, `sys.tasks` |
+| `getActiveSessions()` | **Ingestion tasks** — Druid has no query sessions | `sys.tasks` where `status IN ('RUNNING','PENDING')` |
+| `getSlowQueries()` | nothing — Druid keeps no query log | always `[]` |
+| Maintenance | nothing SQL can reach | `runMaintenance()` throws with the reason |
+
+---
+
+## 2. Architecture
+
+### 2.1 Where it sits
+
+The database layer uses the **Strategy Pattern**. SQL providers add an intermediate abstract layer,
+`SQLBaseProvider`, between the generic base and each concrete provider. Druid is a *directory* rather
+than a single file, because the HTTP transport is a seam
+([§3.2](#32-the-transport-seam-one-interface-one-implementation)) — the same layout ClickHouse uses:
+
+```
+src/lib/db/providers/sql/
+├── postgres.ts
+├── sql-base.ts
+├── clickhouse/
+└── druid/
+ ├── index.ts # DruidProvider - the SQLBaseProvider subclass
+ ├── transport.ts # DruidTransport interface + neutral result/error types (no I/O)
+ ├── http-transport.ts # the one implementation: POST /druid/v2/sql
+ └── introspect.ts # INFORMATION_SCHEMA + sys.* reads
+```
+
+The explain strategy lives with the other strategies, not with the provider:
+[`src/lib/explain/druid-native.ts`](../../src/lib/explain/druid-native.ts).
+
+### 2.2 Class hierarchy
+
+```
+DatabaseProvider (interface, types.ts)
+ ^
+ | implements
+BaseDatabaseProvider (abstract, base-provider.ts)
+ ^
+ | extends
+SQLBaseProvider (abstract, sql-base.ts)
+ ^
+ | extends
+DruidProvider (druid/index.ts)
+```
+
+`DruidProvider` extends `SQLBaseProvider` — not `BaseDatabaseProvider` directly, the way Couchbase
+does — because the dialect really is standard on the points the shared helpers care about:
+double-quoted identifiers and `LIMIT n OFFSET m` are both correct Druid SQL, live-verified. This is
+the case [`docs/ADDING_A_PROVIDER.md`](../ADDING_A_PROVIDER.md) names ClickHouse for. Only
+`prepareQuery()` is overridden, for the one trap in
+[§3.9](#39-the-preparequery-override-offset-with-no-limit).
+
+### 2.3 What `SQLBaseProvider` gives for free
+
+| Member | Purpose |
+|---|---|
+| `escapeIdentifier()` | Double-quoted, since `this.type` (`druid`) falls through to the default branch — the same quoting PostgreSQL uses, and correct here: `SELECT "id" FROM "libredb_demo"` parses. Quoting is not optional in generated SQL — see the reserved-word trap in [§5.4](#54-dialect-traps-a-user-will-hit) |
+| `buildLimitClause()` | `LIMIT n` / `LIMIT n OFFSET m`, both accepted by Druid |
+| `getPlaceholder()` | Returns `?`, which is exactly what Druid's positional parameters use ([§3.10](#310-positional-parameters-really-execute)) |
+| `shouldEnableSSL()` | Inherited but **never called**, deliberately. It infers TLS from substrings in the host name, which would silently switch a self-hosted cluster whose hostname merely contains one. TLS here comes from the connection's own `ssl` config only ([§4.3](#43-tls)) |
+| `prepareQuery()` (base) | The shared query limiter; `DruidProvider` calls it first and only overrides the `OFFSET`-with-no-`LIMIT` case |
+| `getLabels()` (base) | Everything but the two entity labels ([§9](#9-capabilities--labels)) |
+
+### 2.4 Registration & lifecycle
+
+The factory wires Druid in via a dynamic import ([`factory.ts:95`](../../src/lib/db/factory.ts)):
+
+```ts
+case "druid": {
+ // The explicit /index specifier keeps this dynamic import statically
+ // analysable: a bare directory resolves only at runtime, which the bundler
+ // cannot trace into a chunk.
+ const { DruidProvider } = await import("./providers/sql/druid/index");
+ return new DruidProvider(connection, options);
+}
+```
+
+`connect()` proves the endpoint with one `SELECT 1`. That statement is live-verified as valid Druid
+SQL — the planner answers it from a one-row inline datasource and names the column `EXPR$0` — so it
+needs no datasource and succeeds on a cluster that has not ingested anything yet. Sending it at
+connect time is what makes a wrong port, a proxy in front of the Broker, a Druid process that is not
+a query endpoint, and a rejected credential surface while the user is still looking at the connection
+form. `disconnect()` has nothing to release — there is no pool and no session — so it only clears the
+cached transport reference. API routes use `getOrCreateProvider()`, which caches the connected
+provider per `connection.id` and evicts it after 30 minutes idle.
+
+---
+
+## 3. Design decisions
+
+These are the non-obvious choices. Read this section before changing the provider.
+
+### 3.1 HTTP only — no driver, and what that costs
+
+Druid ships a JDBC driver, but it addresses **Avatica**
+(`jdbc:avatica:remote:url=http://host:8888/druid/v2/sql/avatica/`), which needs a JVM client library;
+there is no Node client in Druid's own distribution. Everything this provider needs — querying,
+`INFORMATION_SCHEMA`, `sys.*` monitoring, `EXPLAIN` — is reachable over the documented SQL HTTP
+endpoint, so the provider speaks that and nothing else. `package.json` is untouched: there is no
+install step to fail, no native module in the Docker image or in any distribution channel, and no
+N-API compatibility question for the Bun runtime. This is the rubric in
+[`docs/ADDING_A_PROVIDER.md`](../ADDING_A_PROVIDER.md) applied unchanged.
+
+**What it costs, stated plainly**, because a driver is not free of charge in the other direction
+either:
+
+- **No failover and no retry.** One statement is one `fetch` to one host. A refused socket or a
+ Broker restart surfaces as an error rather than being retried against a second Broker. For an
+ interactive editor this is the right trade: the user sees the failure and presses the button again,
+ which is more honest than a silent retry that hides a degraded cluster. A Druid deployment that
+ wants failover puts the **Router** or a load balancer in front of its Brokers anyway, which is
+ exactly the host a Studio connection points at.
+- **No cursor paging.** The response body is read to the end (`await response.text()`), scanned once
+ for unsafe integers, then parsed — so a result set is materialised as text and again as objects.
+ The editor's `LIMIT 500` injection ([§5.1](#51-execution)) is what keeps that bounded; a statement
+ that deliberately asks for millions of rows will be expensive here in a way a streaming client
+ would not be.
+- **No prepared statements, no session state.** Avatica offers both; the SQL endpoint offers neither,
+ and neither is reachable from the editor's one-statement-per-execution model regardless.
+- **No cancellation.** Abandoning the request client-side does not stop the query on the cluster,
+ which is why a server-side deadline is always sent alongside the client one
+ ([§3.8](#38-both-halves-of-the-timeout)). Druid *does* expose
+ `DELETE /druid/v2/sql/{sqlQueryId}`, so a real `cancelQuery` is a concrete follow-up rather than an
+ impossibility ([§13](#13-known-limitations--future-work)).
+
+### 3.2 The transport seam: one interface, one implementation
+
+Provider logic never calls `fetch`. It goes through `DruidTransport`
+([transport.ts:156](../../src/lib/db/providers/sql/druid/transport.ts)), so adopting the Avatica
+driver later — or any client that is not this endpoint — is one new file implementing the same
+contract rather than a rewrite of the provider, the introspection and the explain strategy:
+
+```ts
+interface DruidTransport {
+ readonly kind: "http";
+ query(sql: string, opts?: DruidQueryOptions): Promise;
+ close(): Promise;
+}
+```
+
+There is no second entry point next to `query()`, unlike Couchbase's `manage()`: every Druid metric,
+task and storage statistic the provider needs is a `sys.*` table reachable by SQL
+([§7](#7-monitoring--health)), so a permanent second HTTP surface would buy nothing.
+
+The result type is deliberately **neutral** rather than the wire envelope
+([transport.ts:47](../../src/lib/db/providers/sql/druid/transport.ts)):
+
+```ts
+interface DruidQueryResult {
+ rows: Record[];
+ fieldNames: string[] | null; // declared order, made UNIQUE by the implementation
+ sqlTypes: Record | null; // BIGINT, VARCHAR, TIMESTAMP, ARRAY, ... - the trustworthy pair
+ nativeTypes: Record | null; // LONG, DOUBLE, ARRAY, COMPLEX, ...
+ executionTimeMs: number; // MEASURED here, never reported by the server
+}
+```
+
+Three things this type says by what it omits:
+
+- **No mutation count.** Druid SQL has no statement that mutates
+ ([§5.5](#55-druid-sql-cannot-write-and-the-server-says-so-clearly)), so a count here could only ever
+ be zero — and a field that is always zero reads as "nothing changed" rather than "this cannot
+ happen".
+- **No server-reported duration.** Live-verified: the endpoint answers with the rows and nothing
+ else — no timing in the body, none in the response headers, only query ids. The transport times its
+ own exchange, and the type's comment says so, so no reader mistakes it for the server's number.
+- **`fieldNames` is required to be unique**, which is the transport's obligation rather than the
+ wire's ([§3.4](#34-resultformat-array-because-the-object-form-loses-columns)).
+
+> **Seam rule.** The wire vocabulary (`resultFormat`, `typesHeader`, `sqlTypesHeader`,
+> `/druid/v2/sql`, `druidException`, `errorMessage`, `errorClass`, the `authorization` header, and
+> `fetch` itself) must appear **only** in `http-transport.ts`.
+> [`seam-guard.test.ts`](../../tests/unit/db/druid/seam-guard.test.ts) parses every source file in the
+> directory with the TypeScript compiler API — not a grep — and fails the build the moment any of that
+> vocabulary appears elsewhere, whether as a string, a property or a template. One token needs
+> narrower treatment than ClickHouse's guard needed: the **neutral** error deliberately borrows
+> Druid's own word `persona`, so `error.persona` is a legitimate read of `DruidTransportError` and is
+> flagged only when spelled as a *string* (`body["persona"]`), which is what envelope parsing looks
+> like. That hole is deliberate and is cheaper than a guard that cries wolf.
+
+### 3.3 Router 8888 or Broker 8082 — both work identically
+
+Live-verified on both ports of the same cluster: the same `POST /druid/v2/sql`, the same request
+body, the same three-header-row envelope, the same error envelopes, and `sys.servers` returns the
+same six rows from either.
+
+```
+$ curl -s -XPOST -H 'content-type: application/json' \
+ -d '{"query":"SELECT COUNT(*) AS c FROM sys.servers","resultFormat":"array",
+ "header":true,"typesHeader":true,"sqlTypesHeader":true}' \
+ http://localhost:8082/druid/v2/sql
+[["c"],["LONG"],["BIGINT"],[6]]
+```
+
+**The Router (8888) is the default only because it fronts more**: the SQL API, the web console, and —
+when `druid.router.managementProxy.enabled` is set — the Coordinator and Overlord APIs, so one port
+is enough for both querying and loading data. **A Broker-only deployment needs no different
+configuration**: point the connection at the Broker's port and everything in this document works,
+including every monitoring panel. Nothing in the provider knows which of the two it is talking to.
+
+### 3.4 `resultFormat: "array"`, because the object form loses columns
+
+**This is a correctness decision, not a preference.** Druid's `resultFormat: "object"` returns rows
+as JSON objects, and duplicate output names — legal SQL, and what a join projecting two `id`s
+produces — collide. Live-verified, the raw response text for `SELECT 1 AS c, 2 AS c`:
+
+```
+resultFormat "object" -> [{"c":null,"c":null},{"c":1,"c":2}]
+resultFormat "array" -> [["c","c"],["LONG","LONG"],["INTEGER","INTEGER"],[1,2]]
+```
+
+The object form puts the duplicate key in the text, where **every** JSON parser keeps only the last
+occurrence: the first column disappears before any code can see it. The array form is positional, so
+it keeps both, and column order becomes authoritative in a way object keys never are.
+
+The transport therefore always sends:
+
+```json
+{ "query": "...", "resultFormat": "array",
+ "header": true, "typesHeader": true, "sqlTypesHeader": true }
+```
+
+and reads back **exactly three** header rows before the data — names, native types, SQL types, in
+that order:
+
+```json
+[["__time","snowflake_id","id","name","region","qty","amount","row_count"],
+ ["LONG","LONG","LONG","STRING","STRING","LONG","DOUBLE","LONG"],
+ ["TIMESTAMP","BIGINT","BIGINT","VARCHAR","VARCHAR","BIGINT","DOUBLE","BIGINT"],
+ ["2026-08-01T00:15:00.000Z",9007199254740993,1000,"alpha","emea",0,10.5,1]]
+```
+
+**A result set with no rows still carries all three header rows** (live-verified:
+`SELECT id, name FROM libredb_demo WHERE 1=0` answers exactly
+`[["id","name"],["LONG","STRING"],["BIGINT","VARCHAR"]]`), and a bare `SET` — the only other statement
+form the grammar accepts — is rejected outright rather than answering short. So there is **no
+legitimate way to receive fewer than three rows**, and the transport treats a shorter payload as a
+failure rather than as an empty result.
+
+That distinction matters more than it looks. What actually produces a short payload is a truncated
+body or a proxy that rewrote the response, and answering `{ rows: [] }` there would render the most
+convincing possible lie: a successful query, over the right datasource, that happens to have found
+nothing. Data loss has to surface as an error, which is the same reason the mid-response truncation
+case above raises instead of returning what it managed to read.
+
+Rows are rebuilt from the declared names, and a repeat is **disambiguated rather than overwritten**:
+`SELECT 1 AS c, 2 AS c` reaches the grid as columns `c` and `c (2)`, both with their values. The
+suffix keeps climbing (`c (3)`, …) because `SELECT 1 AS c, 2 AS "c (2)", 3 AS c` is legal too. Without
+this the array format would have been chosen and then thrown away one step later, since a row is a
+`Record`.
+
+### 3.5 The SQL type labels the column, because the native type lies
+
+Druid publishes two type names per column and they disagree. Live-verified:
+
+| expression | native type | SQL type | value on the wire |
+|---|---|---|---|
+| `CURRENT_TIMESTAMP` | `LONG` | `TIMESTAMP` | `"2026-08-03T16:06:07.520Z"` |
+| `(1 = 1)` | `LONG` | `BOOLEAN` | `true` |
+| `ARRAY[1,2]` | `ARRAY` | `ARRAY` | `"[1,2]"` |
+| `ARRAY['alpha']` | `ARRAY` | `ARRAY` | `"[\"alpha\"]"` |
+
+The native type is `LONG` for a value that is an ISO timestamp string and for a value that is
+`true`, so **the SQL type is the trustworthy one of the pair**. The native type is kept alongside it
+in the neutral result rather than dropped: it is the vocabulary a Druid user reads in the web console
+and in a segment's dimension list, so discarding it would make the editor describe columns in words
+the user's other tools never use. It is *carried*, not trusted.
+
+**Where each type actually reaches the screen**, because the two halves differ and it is easy to
+assume otherwise:
+
+- **The schema tree** shows `INFORMATION_SCHEMA.COLUMNS.DATA_TYPE` — also a SQL type — which
+ introspection puts on `ColumnSchema.type`. This is the type a user sees today.
+- **The result grid shows no per-column type at all**, for any provider. `QueryResult`
+ ([src/lib/types.ts](../../src/lib/types.ts)) is `{ rows, fields, rowCount, executionTime,
+ explainPlan?, pagination? }` — there is no channel a column type could travel in — so
+ `toQueryResult` drops both maps. They are collected because every Druid client knows them and
+ because the transport is the only place that *can* know them, not because something consumes them
+ yet. Giving `QueryResult` a type channel would serve every provider and is a follow-up, not part of
+ this one.
+
+Full observed surface: native `LONG`, `DOUBLE`, `FLOAT`, `STRING`, `ARRAY`, `ARRAY`,
+`COMPLEX` against SQL `BIGINT`, `INTEGER`, `DOUBLE`, `FLOAT`, `DECIMAL`, `VARCHAR`, `CHAR`,
+`TIMESTAMP`, `BOOLEAN`, `ARRAY`, `OTHER`, `NULL`. Note that `SELECT 1` is SQL `INTEGER` while a
+`BIGINT` column is `BIGINT`: the SQL type is the *expression's* type, not a normalised family.
+
+### 3.6 64-bit integers arrive unquoted, and Druid offers no server-side fix
+
+Live-verified on real ingested data. `libredb_demo.snowflake_id` holds **9007199254740993** — that is
+253 + 1, the first integer JavaScript cannot represent — and it comes back as the
+**unquoted JSON number** `9007199254740993`:
+
+```
+[... ,["2026-08-01T00:15:00.000Z",9007199254740993,1000,"alpha","emea",0,10.5,1]]
+```
+
+`JSON.parse` turns that into `9007199254740992` with no error and no warning. A displayed id that is
+off by one is worse than an error, because nothing signals it.
+
+**Druid has no server-side "quote longs" setting.** This is the one place the provider genuinely
+diverges from ClickHouse (#264), which could push the problem to the server with
+`output_format_json_quote_64bit_integers=1`. Here the only place left to fix it is the raw body,
+before it is parsed — so `http-transport.ts` owns `quoteUnsafeIntegers()`
+([http-transport.ts:264](../../src/lib/db/providers/sql/druid/http-transport.ts)), a single-pass,
+**string-aware** scanner that wraps any integer literal outside
+`Number.MIN_SAFE_INTEGER … Number.MAX_SAFE_INTEGER` in quotes. The value then reaches the UI as an
+exact string — the same thing the `pg` driver already does for `int8`, so the grid renders it
+correctly with no further change.
+
+Properties it upholds, each with its own test:
+
+- **Never rewrites inside a string literal.** A digit run in `"id: 9007199254740993"` is data the
+ user is reading. This includes a string containing an escaped quote (`"a\"9007199254740993"`) — the
+ escape must consume the next character, or the scanner desyncs and starts rewriting *inside* a
+ string, producing invalid JSON.
+- **Leaves safe integers, floats, exponent forms and `-0` alone.** A float is a double on both sides,
+ so quoting one would turn a number the grid can sort into a string it cannot. Only integers lose
+ exactness.
+- **Handles the literal in every position** a JSON body can put it: negative, as an object value, as
+ an array element, adjacent to `,` / `]` / `}` with and without whitespace.
+- **Is a no-op on a body with no unsafe literal** — the common case returns the original string
+ without allocating a copy.
+- **The comparison is on digits, not numbers.** Converting the run to a number to find out whether
+ converting it to a number is safe is the bug. A longer digit run always exceeds the range, and for
+ two runs of equal length a lexical comparison *is* the numeric one — so the check needs no
+ arithmetic at all. The sign never matters either, because the safe range is symmetric
+ (`MIN_SAFE_INTEGER === -MAX_SAFE_INTEGER`).
+
+Both encodings then reach every reader in the provider, because a `LONG` small enough stays an
+unquoted number in the same response where a large `SUM(size)` arrives quoted.
+
+### 3.7 Two error envelopes, and the HTTP status is not enough
+
+Live-verified on 37.0.0. Both of these arrive from the same cluster, and the difference is not a
+version skew — the modern shape is planning and validation, the legacy wrapper is a runtime failure a
+data server reported.
+
+**`druidException`** — planning, validation, unsupported statements:
+
+```json
+{ "error": "druidException", "errorCode": "invalidInput", "persona": "USER",
+ "category": "INVALID_INPUT",
+ "errorMessage": "Object 'nope' not found (line [1], column [15])",
+ "context": { "sourceType": "sql", "line": "1", "column": "15",
+ "endLine": "1", "endColumn": "18" } }
+```
+
+**Legacy-wrapped** — a runtime failure from a data server (here, a 1 ms deadline):
+
+```json
+{ "error": "Query timeout",
+ "errorClass": "org.apache.druid.query.QueryTimeoutException",
+ "host": "172.18.0.5:8083", "errorCode": "legacyQueryException",
+ "persona": "OPERATOR", "category": "TIMEOUT",
+ "errorMessage": "url[http://172.18.0.5:8083/druid/v2/] timed out",
+ "context": { "host": "172.18.0.5:8083",
+ "errorClass": "org.apache.druid.query.QueryTimeoutException",
+ "legacyErrorCode": "Query timeout" } }
+```
+
+Four consequences the implementation honours:
+
+1. **`error` is a discriminator, not a message.** In the modern shape its value is the literal string
+ `"druidException"`. Showing `error` to a user prints *"druidException"* to the person who mistyped
+ a datasource name. The transport always reads **`errorMessage`**, and falls back to `error` only
+ when it is *not* that token (the legacy shape puts a real message in both).
+2. **Classification is on `category`.** It is present in both shapes and is a closed enum, exported
+ frozen as `DRUID_ERROR_CATEGORIES`
+ ([transport.ts](../../src/lib/db/providers/sql/druid/transport.ts)) so no call site spells a token:
+ `INVALID_INPUT`, `UNAUTHORIZED`, `FORBIDDEN`, `CAPACITY_EXCEEDED`, `CANCELED`, `RUNTIME_FAILURE`,
+ `TIMEOUT`, `UNSUPPORTED`, `NOT_FOUND`, `UNCATEGORIZED`, `DEFENSIVE`. `errorCode` is secondary and
+ coarser (`invalidInput`, `general`, `legacyQueryException` — the same code arrives with different
+ categories), and the `errorClass` / `host` **fields** exist only in the legacy shape and are never
+ read: a Java class name adds nothing for the person who wrote the statement.
+
+ One honest caveat, because the distinction is easy to misread: not reading the `host` field is not
+ the same as never showing the address. Druid writes it into `errorMessage` itself — the legacy
+ timeout above reads `url[http://172.18.0.5:8083/druid/v2/] timed out` — and that message is
+ surfaced verbatim. That is deliberate: which server timed out is the single most useful fact when
+ one Historical is slow, and it is an address inside a cluster the user is already connected to.
+ The alternative, preferring the legacy `error` value (`"Query timeout"`), is tidier and less
+ useful.
+3. **HTTP 500 can be a plain user error.** Live-verified:
+
+ ```
+ $ curl -s -XPOST -H 'content-type: application/json' \
+ -d '{"query":"SELECT 1/0"}' http://localhost:8888/druid/v2/sql
+ {"error":"druidException","errorCode":"general","persona":"ADMIN",
+ "category":"UNCATEGORIZED","errorMessage":"/ by zero","context":{}}
+ HTTP 500
+ ```
+
+ Dividing by zero is reported as a 500 with `persona: "ADMIN"`. Reading 5xx as "the cluster is
+ broken" would tell the user something false and send them to check their host. `persona` is carried
+ for display only and **never branched on**, for exactly this reason — Druid's own guess at who
+ should read the message is wrong in the case that matters most. Timeout is **504** /
+ `category: TIMEOUT`; everything else observed is **400**.
+4. **A body that is not JSON at all** — a proxy's HTML error page, an empty body, a body that stopped
+ arriving — still produces the seam's own error type, with a stand-in category
+ (`TRANSPORT_FAILURE`) that is deliberately **not** one of Druid's. Reusing `UNCATEGORIZED` for it,
+ tempting because that is what it means in English, would let a caller believe the server had
+ spoken and classified the failure when nothing ever answered.
+
+`DruidTransportError`
+([transport.ts:256](../../src/lib/db/providers/sql/druid/transport.ts)) carries `category`,
+`errorCode`, `persona` and the resolved message, and offers two predicates so no call site spells a
+literal:
+
+- `is(category)` — takes the closed union, so a misspelling does not compile.
+- `isMonitoringUnavailable()` — true for `UNAUTHORIZED`, `FORBIDDEN` and `NOT_FOUND` **only**. Those
+ three are the ordinary configurations of a locked-down cluster (`druid-basic-security` grants the
+ `sys` schema table by table) or of a build where the table is simply absent. Anything else must keep
+ propagating, or an empty monitoring panel hides the user's own mistake forever.
+
+`category` is typed as a plain `string` on the error rather than the closed union, so a category a
+later Druid adds arrives verbatim instead of being flattened onto `UNCATEGORIZED` — which is itself a
+real category.
+
+### 3.8 Both halves of the timeout
+
+Two deadlines, both derived from `queryTimeout` (default 60 s), because neither covers the other:
+
+- **Server side**: `context: { timeout: }`. Verified — `timeout: 1` answers 504 /
+ `category: TIMEOUT` on a statement that otherwise takes milliseconds. Asking the server to stop is
+ what actually frees the cluster's resources; abandoning the request client-side leaves the query
+ running.
+- **Client side**: an `AbortSignal.timeout(...)` on the `fetch`, which also bounds the **body read**.
+ A server-side deadline only starts counting once the server has *accepted* the statement, so it
+ cannot bound a stalled DNS lookup, TCP connect or TLS handshake, or a response body that stops
+ arriving part-way. This is the #264 lesson applied.
+
+The client deadline is deliberately the **later** of the two (`queryTimeout + 5 s`,
+`CLIENT_DEADLINE_GRACE_MS`): a client that gave up first would abandon a query that is still running
+and report a bare abort instead of the 504 Druid was about to send, with its category and its
+message. Catalog and `sys` reads use a shorter 15 s bound on both halves
+([`DRUID_SYSTEM_READ_TIMEOUT_MS`](../../src/lib/db/providers/sql/druid/introspect.ts)) — a hanging
+panel is worse than an empty one.
+
+### 3.9 The `prepareQuery()` override: `OFFSET` with no `LIMIT`
+
+The inherited limiter is otherwise correct for Druid: `LIMIT 500` appends cleanly, an existing `LIMIT`
+is left alone, and the shared `applyQueryLimit` already strips and re-appends a trailing semicolon —
+which matters, because Druid accepts `SELECT 1 AS c1;` but rejects `SELECT 1 AS c1; LIMIT 2`, and the
+shared limiter never produces the latter.
+
+The one failure is a statement ending in **`OFFSET n` with no `LIMIT`**. The limiter appends `LIMIT`
+after it, and Druid rejects the result outright (live-verified):
+
+```
+SELECT id FROM libredb_demo OFFSET 2 LIMIT 3
+-> 400 "'OFFSET start LIMIT count' is not allowed under the current SQL conformance level"
+```
+
+`DruidProvider.prepareQuery()`
+([index.ts:218](../../src/lib/db/providers/sql/druid/index.ts)) asks `analyzeQuery()` — the shared
+analyzer, not a regex of its own, because it already handles the trailing semicolon and already
+distinguishes an `OFFSET` that follows a `LIMIT` (the ordinary paginated form, which the limiter
+leaves alone anyway) from one that stands alone. When the statement has an `OFFSET` and no `LIMIT`, it
+returns the query **untouched** with `wasLimited: false`.
+
+The bias is the same one ClickHouse's trailing-clause case takes, for the same reason: **rewriting
+wrongly turns a working statement into a syntax error, while leaving it alone at worst returns more
+rows than the page size.**
+
+### 3.10 Positional parameters really execute
+
+Unlike ClickHouse — whose HTTP interface binds only named `{name:Type}` parameters, so its provider
+throws on positional ones — Druid takes `?` placeholders with a typed parameter list, live-verified:
+
+```
+{"query":"SELECT COUNT(*) AS c FROM libredb_demo WHERE region = ?",
+ "parameters":[{"type":"VARCHAR","value":"emea"}]}
+-> [["c"],[20]]
+```
+
+So `query(sql, params)` binds rather than refuses, and `getPlaceholder()` needs no override. The
+mapping:
+
+| JS value | Druid parameter type | Note |
+|---|---|---|
+| `string` | `VARCHAR` | |
+| `number`, integral and safe | `BIGINT` | refused outside `Number.MAX_SAFE_INTEGER` — see below |
+| `number`, non-integral | `DOUBLE` | |
+| `bigint` | `BIGINT` | sent as a **raw unquoted literal** — see below |
+| `boolean` | `BOOLEAN` | |
+| `Date` | `TIMESTAMP` | epoch millis; verified, `{"type":"TIMESTAMP","value":0}` against `__time > ?` matches every row |
+| `null` / `undefined` | `VARCHAR` with a `null` value | verified to execute and match the rows a null comparison should |
+
+Anything else (a plain object, an array, a symbol) raises an error naming the unsupported type, and
+`Infinity` / `NaN` are refused explicitly because `JSON.stringify` turns both into `null`, which the
+server would read as a null comparison. Refusing beats sending a value the server will misread.
+
+**The `bigint` case contradicted the plan, and the cluster won.** A `bigint` cannot be
+`JSON.stringify`d, and the obvious encoding — send the digits as a string — is rejected:
+
+```
+{"type":"BIGINT","value":"9007199254740993"} -> 500 RUNTIME_FAILURE "Cannot handle query"
+{"type":"BIGINT","value":9007199254740993} -> matches the row exactly
+```
+
+So the literal has to reach the body unquoted. `JSON.rawJSON` would do it, but it is the ES2025 JSON
+source-text proposal — V8 12.4 / Node 22.2 — while `package.json` declares
+`engines.node: ">=20.9.0"`, so depending on it would throw a bare `TypeError` on a runtime the
+package claims to support. Instead the **parameters array is serialized by hand** and spliced into the
+envelope at its closing brace, whose position is known because `JSON.stringify` just produced it.
+Everything that is not a `bigint` still goes through `JSON.stringify`, so user strings are escaped by
+the runtime rather than by us.
+
+That is deliberately structural rather than a marker-and-substitute pass. An earlier revision wrapped
+the digits in a NUL sentinel and unquoted it with a regex over the finished body, which is unsound: a
+sentinel is only as private as the values flowing through it, so a caller whose `VARCHAR` parameter
+happened to contain that sentinel would have had their string silently unquoted into a number.
+Emitting the literal in the first place cannot collide with anything, because no marker ever exists.
+
+**An integral `number` outside the safe range is refused,** which looks strict until you notice the
+value is already wrong. A caller writing `9007199254740993` as a number literal handed the transport
+`9007199254740992` — JavaScript rounded it before any of this code ran — and nothing here can recover
+the digit. Sending it would filter on a value the user never wrote and return a plausible wrong row
+set, so the error names the fix: pass a `bigint`, which binds exactly. The same check catches an
+integral double past Druid's own `BIGINT` range.
+
+The design plan for this provider said a bigint travels "as a string value"; that is the one line of
+it the live cluster disproved, and this is the record of why the code differs.
+
+### 3.11 The three `false` capabilities are each impossible, not merely unimplemented
+
+- **`supportsCreateTable: false`.** `CREATE TABLE t (id BIGINT)` is not unsupported — it is not in the
+ grammar. Live: `400`, *"Incorrect syntax near the keyword 'CREATE' at line 1, column 1."*, and the
+ parser then lists what it expected: `"INSERT"`, `"UPSERT"`, `"EXPLAIN"`, `"SET"`, `"RESET"`, … with
+ no form of `CREATE` among them. A datasource comes into existence by being ingested into. Per the
+ capability-honesty rule in [`docs/ADDING_A_PROVIDER.md`](../ADDING_A_PROVIDER.md), a flag that is
+ `true` but produces a control that can only emit invalid input is a defect, so it stays `false` and
+ the Create Table modal never appears.
+- **`supportsMaintenance: false`, `maintenanceOperations: []`.** Nothing in `MaintenanceType`
+ (`vacuum`, `analyze`, `reindex`, `optimize`, `check`, `kill`) has a Druid analogue reachable from
+ SQL. Compaction and retention are **Coordinator and task** concerns, out of scope for #265. `kill`
+ is impossible for a second, independent reason: there is no `sys.queries` catalog, so there is
+ nowhere honest for a user to read a cancellable query id from.
+- **`supportsConnectionString: false`.** See
+ [§4.2](#42-there-is-no-connection-string-and-that-is-deliberate).
+
+`schemaRefreshPattern` is `\b(INSERT|REPLACE)\b` — the only two statements that could change a
+datasource. The native engine rejects both, so in practice a query never refreshes the schema, which
+is *correct*: a Druid schema changes through ingestion, not through the editor.
+
+### 3.12 EXPLAIN: the native plan is genuinely a tree
+
+`ExplainFormat` gains `"druid-native"`, with the strategy in
+[`src/lib/explain/druid-native.ts`](../../src/lib/explain/druid-native.ts).
+
+**What the plan is.** `EXPLAIN PLAN FOR ` returns **one row with three columns — `PLAN`,
+`RESOURCES`, `ATTRIBUTES` — each a JSON *string***, so the envelope parse leaves three escaped blobs
+behind and each needs a second parse. `PLAN` is an array of
+`{ query, signature, columnMappings }` entries, where `query` is the **native** Druid query the
+cluster will actually run. Live, for a join:
+
+```json
+{ "type": "join",
+ "left": { "type": "table", "name": "libredb_demo" },
+ "right": { "type": "query", "query": { "queryType": "groupBy",
+ "dataSource": { "type": "table", "name": "libredb_rollup" }, ... } },
+ "rightPrefix": "j0.",
+ "condition": "(\"region\" == \"j0.d0\")",
+ "joinType": "INNER" }
+```
+
+**Why it renders as a tree honestly.** The recursion through `dataSource` **is** the operator tree —
+a `query` dataSource wraps another native query, a `join` has a `left` and a `right`, a `union` has
+`dataSources[]`, and `table` / `lookup` / `inline` / `external` are leaves. Rendering that as
+`{ kind: "tree" }` describes what Druid will run; it is not a flat list forced into a tree shape.
+Node labels name the **native query type** (`groupBy`, `scan`, `timeseries`, `topN`) and the
+datasource, so a reader sees Druid's own vocabulary rather than a borrowed relational-plan one.
+
+**What keeps it honest is what is left out: no `metrics` on any node.** Druid's planner emits **no
+cost and no row estimate anywhere** in this payload — verified across scan, groupBy and join plans.
+`ExplainTreeNode.metrics` is optional, so the tree carries structure and nothing it cannot support. A
+fabricated cost would be worse than none: the render model shows metrics as measured facts.
+`filter`, `dimensions`, `aggregations` and `granularity` are surfaced as **child rows** (the tree
+renderer shows only labels, so what a reader needs to see has to be a label) — never as metrics:
+
+```
+groupBy
+├── table libredb_demo
+├── granularity: all
+├── filter: range on qty
+├── dimensions: region AS d0
+└── aggregations: count AS a0
+```
+
+**What was rejected.** `useNativeQueryExplain: false` also works and returns an indented Calcite
+`RelNode` text plan (`DruidJoinQueryRel` over two `DruidQueryRel`s). It was **not** chosen: it depends
+on a non-default context flag, and it is indentation-parsed text where the default is structured
+JSON.
+
+Strategy details:
+
+- `buildSql(sql, mode)` returns `EXPLAIN PLAN FOR ${sql}` for `SELECT` statements in **both** modes,
+ and `null` for anything else. Druid's `EXPLAIN` never executes the statement, so the estimate is the
+ only plan available either way — and returning `null` for `analyze` would not narrow the feature, it
+ would **disable** it: the direct Explain action always builds with mode `analyze` and refuses to run
+ when the strategy declines, so the button would go dead while only the background pre-warm worked.
+ This is the defect found in review on #263, and `sqlite-queryplan.ts` and `couchbase-json.ts` make
+ the same call for the same reason. A trailing semicolon survives —
+ `EXPLAIN PLAN FOR SELECT 1 AS c1;` is live-verified as accepted.
+- `extractPlan()` parses all three columns and stores `{ plan, resources, attributes }`, so the
+ raw-JSON tab and the AI tab get a structure rather than three escaped blobs. Any column that is
+ absent or will not parse is tolerated — the unparsed text is kept rather than dropped — and if none
+ of the three is recognisable the rows are handed through unchanged rather than replaced by an object
+ of three `undefined`s.
+- `toRenderModel()` walks each entry's `query`, unwrapping up to four wrapper layers (stored JSON
+ text, the `{ plan }` member, the entry array) so it accepts the plan at whichever depth the storage
+ layer hands it back. Recursion depth is bounded at 32 and a truncation is **labelled** rather than
+ silently cut. An unknown `dataSource` type renders as a leaf named by that type instead of being
+ dropped — Druid adds dataSource types between releases, and a dropped node would make the tree
+ quietly lie about what runs.
+- **`PLAN` is an array and is not always length 1**: two aggregating branches of a `UNION ALL` come
+ back as two independent native queries (live-verified). A synthetic root (`2 native queries`) is the
+ only way to show both without pretending one is the parent of the other; a single query is its own
+ root.
+
+---
+
+## 4. Connection
+
+### 4.1 Configuration fields
+
+The form offers exactly four fields
+([`db-ui-config.ts:115`](../../src/lib/db-ui-config.ts)): `host`, `port`, `user`, `password`.
+
+| Field | Required | Notes |
+|---|---|---|
+| `host` | **Yes** | `validate()` throws `DatabaseConfigError` ("Druid requires a host") when it is missing. There is no connection string to substitute for it |
+| `port` | No | Defaults to `8888` (the Router). Use `8082` for a Broker ([§3.3](#33-router-8888-or-broker-8082--both-work-identically)). One default for both schemes on purpose: a TLS Druid serves on whatever `druid.tlsPort` the deployment chose, so there is no well-known HTTPS port to fall back to, and inventing one would send credentials to a port nothing is listening on |
+| `user` / `password` | No | Sent as HTTP Basic **only when `user` is set**, for the `druid-basic-security` extension. A default install loads no security extension and **ignores the header entirely** — live-verified, a bogus `Basic` header still answers `200` — so credentials are genuinely optional |
+| `ssl` | No | Any mode but `disable` switches the transport to `https` ([§4.3](#43-tls)) |
+| `database` | — | **Not offered, and ignored if set** — see below |
+
+**There is no `database` field, and that is not an omission.** Druid has no database or catalog to
+select. `INFORMATION_SCHEMA.SCHEMATA` reports exactly **one catalog**, always named `druid`:
+
+```
+[["CATALOG_NAME","SCHEMA_NAME", ...],
+ ["druid","druid", ...], ["druid","INFORMATION_SCHEMA", ...], ["druid","lookup", ...],
+ ["druid","sys", ...], ["druid","view", ...]]
+```
+
+Five *schemas* exist under that one catalog, but only `druid` holds datasources, it is the **default**
+schema, and the other four are fixed. So a database selector would be a control with no effect —
+and, worse, a control that implies a scoping decision the user does not have. Because `druid` is the
+default schema, `SELECT * FROM "libredb_demo"` resolves with no qualification at all, which is why
+none of the Couchbase `query_context` problem arises here.
+
+```ts
+const connection = {
+ id: 'druid-1',
+ name: 'Druid',
+ type: 'druid',
+ host: '127.0.0.1',
+ port: 8888,
+ createdAt: new Date(),
+};
+```
+
+### 4.2 There is no connection string, and that is deliberate
+
+`supportsConnectionString` is `false` and `showConnectionStringToggle` is `false`, so the form has no
+paste tab. Two independent reasons:
+
+- **Druid has no URI convention for its HTTP SQL API.** Its own JDBC driver addresses Avatica
+ (`jdbc:avatica:remote:url=http://host:8888/druid/v2/sql/avatica/`), which is not a URL the shared
+ parser could round-trip into host/port/user/password. Inventing `druid://` would add a parser branch
+ for a string no Druid user has ever typed.
+- **`http://` and `https://` are already claimed by ClickHouse** in
+ [`connection-string-parser.ts`](../../src/lib/connection-string-parser.ts) (#264), where an HTTP URL
+ *is* the canonical connection target.
+
+`connection-string-parser.ts` is therefore **not touched by this provider**, and
+[`tests/unit/lib/connection-string-parser.test.ts`](../../tests/unit/lib/connection-string-parser.test.ts)
+pins both halves of the absence so a future reader does not read it as a gap: `druid://` parses to
+`null`, and `http://localhost:8888` still detects as `clickhouse`. The consequence is recorded rather
+than hidden — pasting a Druid Router URL selects ClickHouse; a Druid connection is made through the
+form fields instead, which is why its form has no paste toggle at all.
+
+### 4.3 TLS
+
+`config.ssl` with any `mode` but `disable` switches the transport from `http` to `https`. `ssl` is a
+first-class `DatabaseConnection` field and is independent of the form's `connectionFields`, so it
+applies even though the Druid form shows no TLS row of its own. An explicit `disable` turns TLS
+**off** as firmly as an explicit mode turns it on (the #264 lesson).
+
+The port is **not** changed by TLS, unlike ClickHouse's `8123` → `8443`: a TLS Druid serves on
+whatever `druid.tlsPort` the deployment configured, and there is no well-known value to guess.
+
+As with ClickHouse, `ssl.caCert`, `ssl.clientCert` and `ssl.rejectUnauthorized` are **not honoured**:
+global `fetch` cannot carry a custom CA or relax verification without an undici `Agent` as its
+`dispatcher`, and undici is not a dependency. A cluster behind a **self-signed** certificate therefore
+fails verification; one with a publicly-trusted certificate works. Honouring them needs the
+`node:https` path Couchbase already has, which is a follow-up rather than a limitation of the scheme.
+
+---
+
+## 5. Query interface
+
+### 5.1 Execution
+
+`query(sql, params?)` ([index.ts:328](../../src/lib/db/providers/sql/druid/index.ts)) sends one
+statement under both deadlines from [§3.8](#38-both-halves-of-the-timeout), with its `?` parameters
+bound ([§3.10](#310-positional-parameters-really-execute)):
+
+```ts
+await provider.query('SELECT id, name FROM "libredb_demo" LIMIT 50');
+await provider.query('SELECT COUNT(*) AS c FROM "libredb_demo" WHERE region = ?', ['emea']);
+```
+
+`prepareQuery()` injects `LIMIT`/`OFFSET` through the shared limiter
+(`supportsExternalQueryLimiting: true`) unless the statement ends in an `OFFSET` with no `LIMIT`
+([§3.9](#39-the-preparequery-override-offset-with-no-limit)).
+
+A write is **not special-cased**. Every write form Druid rejects, it rejects with a message that names
+both the reason and the alternative, which is more useful than anything this provider would substitute
+([§5.5](#55-druid-sql-cannot-write-and-the-server-says-so-clearly)).
+
+### 5.2 Result shaping
+
+| Source | `QueryResult` field | Notes |
+|---|---|---|
+| the data rows | `rows` | Rebuilt from the positional arrays, keyed by the disambiguated column names |
+| header row 0 | `fields` | Declared column order, made unique (`c`, `c (2)`); `[]` when the payload carried no header |
+| — | `rowCount` | `rows.length`. There is no second number: no Druid statement mutates, so a mutation count could only ever be zero |
+| the measured exchange | `executionTime` | Rounded milliseconds, **measured by the transport**. The endpoint reports no timing whatsoever, so there is no server-side number this could be preferred over ([§3.2](#32-the-transport-seam-one-interface-one-implementation)) |
+
+### 5.3 `ARRAY` cells arrive as JSON strings
+
+Druid's `sqlStringifyArrays` query context defaults to **true**, so an array column comes back as
+text, live-verified:
+
+```
+SELECT ARRAY[1,2] AS a, ARRAY['alpha'] AS b
+-> [["a","b"], ["ARRAY","ARRAY"], ["ARRAY","ARRAY"], ["[1,2]","[\"alpha\"]"]]
+```
+
+Setting `sqlStringifyArrays: false` genuinely returns real JSON arrays (`[["a"],[[1,2]]]`), and the
+provider deliberately **keeps the default** and does **not** parse the strings back into arrays:
+that is what every Druid client shows, so the grid matches the web console and any other tool the
+user has open. The type is honest about it — the SQL type says `ARRAY` while the value is a string —
+which is better than a silent re-parse that would disagree with the same query run anywhere else.
+
+### 5.4 Dialect traps a user will hit
+
+These are Druid's, not the provider's, and each one is a real 400 a user can produce in the editor.
+None of them needs a code change; all three need to be documented, which is what this section is for.
+
+**`ORDER BY` on a non-`__time` column of a plain table scan is rejected.** Live:
+
+```
+SELECT id FROM libredb_demo ORDER BY id LIMIT 2
+-> 400 "Query could not be planned. A possible reason is [SQL query requires ordering
+ a table by non-time column [[id]], which is not supported.]"
+```
+
+Ordering by `__time` works, and ordering *anything* works once there is a `GROUP BY`, because that is
+an aggregation rather than a scan. Sort in the results grid, add a `GROUP BY`, or order by `__time`.
+The provider's own generated SQL is safe by construction: `generateTableQuery` emits
+`SELECT * FROM libredb_demo LIMIT 50;` with **no** `ORDER BY`, and no provider-generated statement may
+ever add one to a scan — ordering by the primary key, the obvious thing for a generator to do, would
+break every datasource browse.
+
+**Calcite's reserved-word list is large and surprising.** `SELECT 1 AS one` is a syntax error:
+
+```
+-> 400 "Incorrect syntax near the keyword 'AS' at line 1, column 10."
+ (the parser then lists everything it expected, including "AS" )
+```
+
+`one` is reserved. So are `rows`, `count`, `value`, `user`, `start`, `end` and `year` — every one of
+those verified as a 400 in the form `SELECT 1 AS ` — and the full Calcite list is far longer.
+That is why **every generated identifier and alias in this provider is double-quoted** as a blanket
+habit rather than word by word (`SUM("size") AS "sizeBytes"`, `"type" AS "taskType"`): auditing a
+projection against Calcite's list on every change is not a maintainable rule, and quoting is free.
+Quoting is what makes the word an identifier — `SELECT 1 AS "one"` is accepted. If you hand-write a
+statement and get an unexplained syntax error near a perfectly ordinary word, quote it.
+
+**`LIMIT 5 LIMIT 2` is a syntax error**, and so is `SELECT 1 AS c1; LIMIT 2`. The shared limiter never
+produces either: it preserves an existing `LIMIT`, and it strips and re-appends a trailing semicolon.
+
+### 5.5 Druid SQL cannot write, and the server says so clearly
+
+Every one of these is **HTTP 400** and every message below is quoted verbatim from Apache Druid
+37.0.0:
+
+| statement | `errorMessage` |
+|---|---|
+| `INSERT INTO t SELECT ...` | `INSERT operations are not supported by requested SQL engine [native], consider using MSQ.` |
+| `REPLACE INTO t OVERWRITE ALL SELECT ...` | `REPLACE operations are not supported by the requested SQL engine [native]. Consider using MSQ.` |
+| `UPDATE t SET ...` | `Unsupported SQL statement [UPDATE]` |
+| `DELETE FROM t WHERE ...` | `Unsupported SQL statement [DELETE]` |
+| `CREATE TABLE t (id BIGINT)` | `Incorrect syntax near the keyword 'CREATE' at line 1, column 1.` — not in the grammar at all |
+
+**The provider does not special-case any of them.** Druid's own message already names the reason and
+the alternative, which is more useful than a substitute, and `mapDruidError()` surfaces it verbatim as
+a `QueryError`. Note that `UPDATE` and `DELETE` are not "unimplemented on this endpoint" — they are
+not in Druid SQL *anywhere*, on any engine.
+
+**`INSERT` and `REPLACE` do exist, but only through the MSQ task engine on
+`POST /druid/v2/sql/task`**, which is out of scope for #265: it is a submit/poll protocol that returns
+a task id instead of rows, so it does not fit the `query()` contract at all. The synchronous endpoint
+this provider uses rejects both even on a cluster where `druid-multi-stage-query` is loaded and fully
+capable of running them — which is exactly the case the live fixture is configured to prove.
+
+**How data is actually removed from Druid**, since no SQL statement can do it. Two steps, both through
+the Coordinator (reachable on `8888` when the Router's management proxy is enabled):
+
+1. **Mark the segments unused** — `POST /druid/coordinator/v1/datasources/{datasource}/markUnused`
+ with an `interval` or a list of `segmentIds`. This makes the data invisible to queries immediately;
+ it does not delete anything. Live, over an interval covering a two-segment datasource:
+ `{"numChangedSegments":2,"segmentStateChanged":true}`.
+2. **Submit a `kill` task** to the Overlord (`POST /druid/indexer/v1/task`) — that is what deletes the
+ segment files from deep storage and the rows from the metadata store:
+
+ ```json
+ { "type": "kill", "dataSource": "libredb_docprobe",
+ "interval": "1000-01-01T00:00:00Z/3000-01-01T00:00:00Z" }
+ ```
+
+Both steps were run end to end against the live cluster while writing this document, and step 1 alone
+is enough to make the datasource **disappear from `INFORMATION_SCHEMA.TABLES` entirely** — which is
+what the schema tree reflects, and why there is no empty-datasource case
+([§6](#6-schema-introspection)).
+
+### 5.6 EXPLAIN
+
+The EXPLAIN button is available (`supportsExplain: true`) and renders the native plan tree described
+in [§3.12](#312-explain-the-native-plan-is-genuinely-a-tree). Druid has no analyze mode, so the direct
+action and the background pre-warm show the same plan — with no cost and no row estimates, because
+Druid's planner publishes none.
+
+---
+
+## 6. Schema introspection
+
+`getSchema()` ([introspect.ts:493](../../src/lib/db/providers/sql/druid/introspect.ts)) makes **two**
+`INFORMATION_SCHEMA` reads in parallel with `Promise.all`, both through the transport seam:
+
+| Data | Source |
+|---|---|
+| Datasources | `INFORMATION_SCHEMA.TABLES` where `TABLE_SCHEMA = 'druid'`, ordered by name |
+| Columns | `INFORMATION_SCHEMA.COLUMNS` where `TABLE_SCHEMA = 'druid'`, ordered by `TABLE_NAME, ORDINAL_POSITION` |
+| Indexes | always `[]` — Druid has no user-defined indexes |
+| Foreign keys | always `[]` — Druid has no foreign keys anywhere |
+
+**Only the `druid` schema is listed.** The same catalog also carries the four `INFORMATION_SCHEMA`
+views and the six `sys` tables as `TABLE_TYPE = 'SYSTEM_TABLE'`, and a cluster with lookups or views
+carries rows under a `lookup` / `view` schema besides. Live, on the fixture cluster:
+
+```
+["INFORMATION_SCHEMA","COLUMNS","SYSTEM_TABLE","NO","NO"] ... 4 rows
+["druid","libredb_demo","TABLE","NO","NO"]
+["druid","libredb_rollup","TABLE","NO","NO"]
+["sys","segments","SYSTEM_TABLE","NO","NO"] ... 6 rows
+```
+
+The schema predicate is the entire mechanism that keeps all of that out of the sidebar. Everything
+excluded stays **queryable by typing SQL** — the monitoring panels read `sys` themselves — so nothing
+is lost, only unlisted.
+
+**`TableSchema.name` is the bare datasource name.** `druid` is the default schema, so
+`SELECT * FROM "libredb_demo"` resolves without qualification. No prefix is added and none is needed.
+
+**No column is primary — `__time` included.** It is mandatory in every datasource, it is the
+partitioning key and the sort key within a segment, and it is the only column Druid reports as
+`IS_NULLABLE = 'NO'`. Live, for `libredb_demo`:
+
+```
+["__time",1,"TIMESTAMP","NO",93,""]
+["snowflake_id",2,"BIGINT","YES",-5,""]
+["id",3,"BIGINT","YES",-5,""]
+...
+```
+
+All of which makes `__time` tempting to mark `isPrimary`, and an earlier revision did. It is wrong,
+because **a primary key is unique and `__time` is not**:
+
+```
+$ SELECT COUNT(*) AS total, COUNT(DISTINCT __time) AS distinct_times FROM libredb_demo
+{"total":50,"distinct_times":30}
+```
+
+Nothing in a Druid datasource is unique, and `isPrimary` is not a hint — three consumers state it as
+fact. `sql-completions.ts` appends `(PK)` in autocomplete, `use-ai-chat.ts` puts `, PK` into the
+schema context the model reasons from, and `schema-diff/diff-engine.ts` reports
+`Primary key changed` — so two datasources differing only in this would diff as a key change. What
+`__time` actually is would need a partition/time-key concept distinct from a primary key, and
+`ColumnSchema` has no such field. It stays identifiable the honest way: by name, and by being the one
+column with `nullable: false`.
+`COLUMN_DEFAULT` is `""` for every column of every datasource — a Druid column has no default (an
+absent dimension is null) — so it is not read at all. `ORDINAL_POSITION` orders the read rather than
+appearing in it: it *is* the declared column order, so it has no separate value to carry.
+
+**`indexes: []` and `foreignKeys: []` are by construction, not by omission.** Druid indexes every
+dimension inside its segment, but those indexes have no name, no size and no usage counter of their
+own — there is no index *object* a row could describe — and no Druid DDL declares a foreign key.
+
+### The catalog is a view of what is *servable*, not of what exists
+
+This is the single most surprising thing about Druid introspection, and the one most likely to be
+mistaken for a bug in the editor. Verified two independent ways on 37.0.0:
+
+**1. Marking every segment unused removes the datasource from the catalog.**
+
+```
+$ curl -s -XPOST -H 'content-type: application/json' \
+ -d '{"interval":"1000-01-01/3000-01-01"}' \
+ http://localhost:8888/druid/coordinator/v1/datasources/libredb_rollup/markUnused
+{"numChangedSegments":3,"segmentStateChanged":true}
+```
+
+`libredb_rollup` then vanishes from `INFORMATION_SCHEMA.TABLES` and from `sys.segments`, and
+`markUsed` brings it back. So an empty result means "no *servable* datasources", and **there is no
+empty-datasource case to render** — the exact opposite of Couchbase's empty-collection case. Do not
+go looking for one.
+
+**2. Stopping the Historical makes an existing datasource report as a typo.** With the process that
+serves the segments down, and nothing else advertising them:
+
+```
+$ docker stop libredb-druid-historical
+$ curl -s -XPOST -H 'content-type: application/json' \
+ -d '{"query":"SELECT COUNT(*) FROM libredb_demo"}' http://localhost:8888/druid/v2/sql
+HTTP 400
+{"error":"druidException","errorCode":"invalidInput","persona":"USER",
+ "category":"INVALID_INPUT",
+ "errorMessage":"Object 'libredb_demo' not found (line [1], column [27])"}
+```
+
+The datasource still exists in the metadata store. The Broker simply has no server advertising its
+segments, so it is not in the catalog — and the failure is classified **`INVALID_INPUT`, blaming the
+statement**. It is indistinguishable, in both status and category, from genuinely mistyping the name
+(§3.7 shows the same envelope for `SELECT * FROM nope`).
+
+**What this means in practice:** if a datasource you know exists reports *"Object '<name>' not
+found"* and disappears from the schema tree, suspect availability before suspecting your SQL. Check
+`SELECT * FROM sys.servers` for a missing `historical` row, and the Coordinator for unassigned
+segments. Nothing in this provider can improve the message — Druid owns both the classification and
+the wording — so this paragraph is the mitigation.
+
+`getSchemaList()` and `getSchemaRelations()` are deliberately **not implemented**. Both are optional
+and the client falls back to `getSchema()`; the split exists so a slow relationship read cannot block
+the table list, and Druid has neither half of that problem — a list would be byte-identical to
+`getSchema()`, and a relations read would spend a round trip to answer two empty arrays per
+datasource.
+
+---
+
+## 7. Monitoring & health
+
+Every read below degrades to empty/zero when the failure `isMonitoringUnavailable()` —
+`UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND` and **only** those three
+([§3.7](#37-two-error-envelopes-and-the-http-status-is-not-enough)). Anything else propagates and
+becomes a message the user sees.
+
+| Method | Source | Mapping |
+|---|---|---|
+| `getOverview()` ([introspect.ts:524](../../src/lib/db/providers/sql/druid/introspect.ts)) | `sys.servers`, `sys.segments`, `INFORMATION_SCHEMA.TABLES`, `sys.tasks` — **four separate reads** | `version` and `startTime` from the Coordinator's `sys.servers` row (Broker as fallback); `uptime` from `CURRENT_TIMESTAMP - start_time`; `databaseSizeBytes` = `SUM(size)` over active segments; `tableCount` = datasource count; `activeConnections` = count of `RUNNING` tasks; `maxConnections` = **0**; `indexCount` = **0** |
+| `getPerformanceMetrics()` | — | **Zeroed, and sends no statement.** Druid's cache, query and ingestion metrics all reach a metrics *emitter* (statsd, Kafka, an HTTP endpoint, the log) and none reaches a SQL-readable table |
+| `getSlowQueries()` | — | **`[]`**, and sends no statement. Druid keeps no query log at all |
+| `getActiveSessions()` ([introspect.ts:609](../../src/lib/db/providers/sql/druid/introspect.ts)) | `sys.tasks` where `status IN ('RUNNING','PENDING')`, newest first | **Ingestion tasks, not query sessions** — see below |
+| `getTableStats()` ([introspect.ts:649](../../src/lib/db/providers/sql/druid/introspect.ts)) | `sys.segments` where `is_active = 1`, grouped by `datasource` | `rowCount` = `SUM(num_rows)`; `tableSizeBytes` and `totalSizeBytes` both = `SUM(size)`; `schemaName` = `"druid"`. A `{ schema }` filter naming anything but `druid` returns `[]` without a round trip |
+| `getIndexStats()` | — | **`[]`**, and sends no statement. No index objects exist |
+| `getStorageStats()` ([introspect.ts:678](../../src/lib/db/providers/sql/druid/introspect.ts)) | `sys.servers` where `server_type = 'historical'` | one row per historical: `name` = `server`, `location` = `host`, `sizeBytes` = `curr_size`, `usagePercent` = `curr_size / max_size` |
+| `getHealth()` ([introspect.ts:700](../../src/lib/db/providers/sql/druid/introspect.ts)) | the above, composed | `activeConnections`, `databaseSize`, up to 10 sessions; `cacheHitRatio` = **`"N/A"`**; `slowQueries` = `[]` |
+
+**The Active Sessions panel shows ingestion TASKS, and this is deliberate.** Druid has **no query
+sessions** — no `sys.queries`, no connection catalog, nothing that describes a client. Its tasks are
+the only activity it can describe, and returning `[]` while a multi-hour ingestion saturates the
+MiddleManagers would report a quiet cluster that is anything but. Each row is therefore made
+self-describing rather than disguised as a connection:
+
+| `ActiveSessionDetails` field | Druid value |
+|---|---|
+| `applicationName` | the constant **`"Druid ingestion task"`** — this is what stops the row being read as a client connection |
+| `pid` | `task_id` |
+| `database` | `datasource` (live-verified: a task with none, such as `noop`, reports the literal string `"none"`) |
+| `state` | `status` — `RUNNING` or `PENDING` |
+| `query` | the task **type** — `index_parallel`, `compact`, `kill` — the closest thing a task has to a statement |
+| `user` | `"unknown"` — `sys.tasks` records no submitter identity (a `druid-basic-security` cluster puts it in the audit log), and borrowing the connection's user would credit it with a task it did not submit |
+| `durationMs` | `CURRENT_TIMESTAMP - created_time`, **not** `sys.tasks.duration` |
+
+The panel asks for 50 rows when the caller names no limit, and the health summary asks for 10. A
+non-positive or fractional limit falls back to the default rather than being inlined into the
+statement, so the row cap can only ever be a positive integer in the generated SQL.
+
+The honest empties, each with its reason:
+
+- **`getPerformanceMetrics()` is zeroed** because Druid's metrics do not reach SQL. `cacheHitRatio` is
+ required by the type so it carries a neutral `0`; every other metric in that type is *optional*, so
+ absence is expressible and they are left out entirely — a zero would read as a measurement of zero,
+ which is a different and false claim.
+- **`getHealth().cacheHitRatio` is the string `"N/A"`.** That field is a `string`, so it can say
+ "not measured" — which is the truth. A fabricated low number would trip the cache-ratio threshold
+ alert into reporting a fault that does not exist. `sqlite.ts` and `oracle.ts` already spell an
+ unavailable ratio this way.
+- **`getSlowQueries()` is `[]`** because there is nothing to ask. This is not a switched-off feature
+ and not a permission gate, unlike ClickHouse's `system.query_log`: no `sys` table, no endpoint and
+ no file holds finished queries. No statement is sent to discover that.
+- **`getIndexStats()` is `[]`** and **`indexCount` is `0`** because no index object exists
+ ([§6](#6-schema-introspection)).
+- **`maxConnections` is `0`** because Druid publishes no connection limit anywhere in SQL — it has no
+ pool. A number here would be invented.
+- **`uptime` says `"unknown"`, not `"0ms"`**, when either clock reading is missing: an uptime of zero
+ claims the cluster booted this instant, which is a statement the server never made. The branch is on
+ the two *readings* rather than on their difference, so a cluster that genuinely came up this
+ millisecond still reports a measured 0.
+
+Five load-bearing live findings behind the code, each of which silently produces wrong output if
+forgotten:
+
+1. **A grouping-less aggregate over zero matching rows returns NO DATA ROW**, not a row of zeros:
+
+ ```
+ SELECT COUNT(*) AS c FROM sys.supervisors -> [["c"]]
+ ```
+
+ So every scalar read has to survive an *absent row*, not merely a null. (`sys.supervisors` is
+ genuinely empty on a batch-only cluster, and is not read: streaming supervisors are out of scope.)
+2. **`sys.tasks.duration` is `-1` for a task that has not finished** — which is every task the
+ sessions read selects — so reporting that column would print `-1ms` on every row. It is not even
+ projected, which is what stops someone reaching for it later; the age comes from two readings of
+ the **server's own** clock instead.
+3. **`sys.servers` reports `max_size = 0` for every process that is not a historical**, so the usage
+ division meets a zero denominator in ordinary operation. It yields `0`, not a flattering `100` and
+ not `NaN`. Live:
+
+ ```
+ [["server","server_type","version","curr_size","max_size"],
+ ["172.18.0.7:8082","broker","37.0.0",0,0],
+ ["172.18.0.4:8081","coordinator","37.0.0",0,0],
+ ["172.18.0.5:8083","historical","37.0.0",19617,300000000000],
+ ["172.18.0.6:8091","middle_manager","37.0.0",0,0],
+ ["172.18.0.4:8081","overlord","37.0.0",0,0],
+ ["172.18.0.8:8888","router","37.0.0",0,0]]
+ ```
+
+ The Coordinator and Overlord share one address (one process, `asOverlord` enabled), which is why
+ the identity read orders by `server_type` and takes one row rather than assuming a row count.
+4. **`is_active = 1` on every `sys.segments` read is not an optimisation.** That table describes every
+ segment the metadata store knows about, including ones superseded by a compaction or a
+ re-ingestion of the same interval. Summing those would count the same rows and bytes twice, so a
+ re-ingested datasource would appear to double in size.
+5. **A large `SUM(size)` arrives as a quoted decimal string**, because the transport quotes unsafe
+ integer literals before parsing ([§3.6](#36-64-bit-integers-arrive-unquoted-and-druid-offers-no-server-side-fix)).
+ Both encodings reach these mappers, and both are handled.
+
+**Why four reads in `getOverview()` and not one joined statement**: `sys` permissions are granted per
+table on a `druid-basic-security` cluster, so a role that declines `sys.tasks` must still get the
+datasource count `INFORMATION_SCHEMA` answers happily. Combining them would throw away every panel a
+restricted user *can* see. The schema tree goes further and touches **no `sys` table at all**, so a
+cluster that merely declines to describe its servers still renders a full sidebar; the per-datasource
+counts live in `getTableStats()`, where a denial costs one panel instead of the whole tree.
+
+---
+
+## 8. Maintenance
+
+**There is none.** `supportsMaintenance` is `false` and `maintenanceOperations` is `[]`, so the
+Maintenance panel offers no operation for a Druid connection.
+
+**One control does still appear, and it cannot work.** The monitoring **Tables** tab renders
+`Analyze` / `Vacuum` / `Reindex` per row unconditionally — `TablesTab.tsx` never reads
+`getCapabilities()` — so those three buttons are present for Druid and every click answers
+`HTTP 400 {"error":"Maintenance operations not supported for this database"}`. Verified live in the
+running application. This is **not** specific to Druid: `libredb.ts` also sets
+`supportsMaintenance: false` and has exactly the same dead buttons today, so it is a pre-existing gap
+in shared UI rather than something this provider introduced, and gating that tab on capabilities is a
+change for every provider at once. Filed as a follow-up; recorded here because a doc that claimed
+"no control offers any operation" would be describing the intent instead of the software.
+
+`runMaintenance(type)` ([index.ts:471](../../src/lib/db/providers/sql/druid/index.ts)) exists because
+the `DatabaseProvider` interface obliges every provider to implement it, and **not** because any
+request reaches it: `/api/db/maintenance`
+([route.ts](../../src/app/api/db/maintenance/route.ts)) checks `supportsMaintenance` and returns
+`{ "error": "Maintenance operations not supported for this database" }` with status 400 before it
+would ever call the provider. So the message below is what a *programmatic* caller of the
+`@libredb/studio` package sees, not what the HTTP API returns — `docs/API_DOCS.md` documents the
+route's own wording. It throws a `QueryError` naming the reason:
+
+> Druid has no SQL-reachable maintenance operation, so "\" cannot run here. Compaction and
+> retention are Coordinator and task concerns, and Druid publishes no catalog of running queries to
+> cancel one from.
+
+Both halves of that are real constraints, not scope cuts made lightly:
+
+- `vacuum` / `optimize` / `reindex` / `check` — the nearest Druid analogue is **compaction**, which is
+ a Coordinator auto-compaction config or a `compact` **task**, not SQL. Retention is a load rule on
+ the Coordinator. Both are out of scope for #265 and would need a task-management surface that does
+ not exist yet ([§13](#13-known-limitations--future-work)).
+- `kill` — impossible for a second, independent reason: there is no `sys.queries` catalog, so there is
+ nowhere honest for a user to read a cancellable query id from. (Druid *can* cancel by
+ `sqlQueryId`, which is a different feature — see [§13](#13-known-limitations--future-work).)
+- `analyze` — Druid needs none. A segment's statistics *are* its structure, current by construction,
+ and unlike ClickHouse there is no per-table parts summary worth substituting: `getTableStats()`
+ already reports rows and bytes per datasource in the monitoring panel.
+
+---
+
+## 9. Capabilities & labels
+
+### `getCapabilities()` ([index.ts:151](../../src/lib/db/providers/sql/druid/index.ts))
+
+| Capability | Value | Why |
+|---|---|---|
+| `queryLanguage` | `sql` | Calcite SQL over the native engine |
+| `supportsExplain` | `true` | `EXPLAIN PLAN FOR` returns a structured native plan ([§3.12](#312-explain-the-native-plan-is-genuinely-a-tree)) |
+| `explainFormat` | `druid-native` | The strategy id in `src/lib/explain/index.ts` |
+| `supportsExternalQueryLimiting` | `true` | `LIMIT n` / `LIMIT n OFFSET m` are both correct Druid SQL |
+| `supportsCreateTable` | **`false`** | `CREATE` is not in the grammar; a datasource is created by ingestion ([§3.11](#311-the-three-false-capabilities-are-each-impossible-not-merely-unimplemented)) |
+| `supportsMaintenance` | **`false`** | Nothing in `MaintenanceType` is reachable from Druid SQL ([§8](#8-maintenance)) |
+| `maintenanceOperations` | `[]` | Consequence of the above |
+| `supportsConnectionString` | **`false`** | Druid has no URI convention, and `http(s)://` is ClickHouse's ([§4.2](#42-there-is-no-connection-string-and-that-is-deliberate)) |
+| `defaultPort` | `8888` | The Router. `8082` (Broker) is equally valid ([§3.3](#33-router-8888-or-broker-8082--both-work-identically)) |
+| `schemaRefreshPattern` | `\b(INSERT\|REPLACE)\b` | The only statements that could change a datasource — and the native engine rejects both, so in practice a query never refreshes the schema, which is correct |
+
+### `getLabels()` ([index.ts:194](../../src/lib/db/providers/sql/druid/index.ts))
+
+Exactly two overrides:
+
+- `entityName` → **"Datasource"**, `entityNamePlural` → **"Datasources"**. Datasource is the Druid
+ word for a table, and the sidebar is where a user meets it.
+
+Everything else is inherited on purpose. **A Druid row is a row**, so renaming it would only make the
+grid speak a dialect the cluster does not. The maintenance labels are irrelevant here
+(`supportsMaintenance` is `false`, so no control ever shows them) and must still be strings, so they
+stay as inherited rather than naming operations that do not exist. `selectAction` ("Select Top 50")
+and the generate action are inherited unchanged and are correct for Druid.
+
+---
+
+## 10. Error handling
+
+The transport normalizes every failure into `DruidTransportError { message, category, errorCode,
+persona }`; `mapDruidError()`
+([index.ts:358](../../src/lib/db/providers/sql/druid/index.ts)) maps that onto the shared classes from
+[`src/lib/db/errors.ts`](../../src/lib/db/errors.ts) — **keyed on `category`, never on the HTTP
+status**:
+
+| Category | Meaning | Error raised |
+|---|---|---|
+| `UNAUTHORIZED`, `FORBIDDEN` | Bad or missing credentials on a secured cluster | `AuthenticationError` |
+| `TIMEOUT` | The statement deadline was hit (HTTP 504) | `TimeoutError` |
+| `CANCELED` | The query was cancelled | `QueryCancelledError` |
+| `INVALID_INPUT`, `UNSUPPORTED`, `NOT_FOUND`, `UNCATEGORIZED`, `RUNTIME_FAILURE`, `CAPACITY_EXCEEDED`, `DEFENSIVE` | A statement the cluster understood and rejected — including `SELECT 1/0`, which arrives as HTTP 500 | `QueryError` carrying Druid's own `errorMessage` |
+| `TRANSPORT_FAILURE` (the stand-in — no server classified anything) | A refused socket, an abort, a proxy's HTML page, a truncated body, a parameter refused before the request left | Falls through to the shared message-based `mapError()`, exactly as `clickhouse/index.ts` does when the server named no exception code |
+
+The stand-in is deliberately **not** read as "the cluster is unreachable": several of those causes are
+the user's own doing, so the shared mapping decides.
+
+| Situation | Error |
+|---|---|
+| Missing `host` | `DatabaseConfigError` — "Druid requires a host" |
+| Operation before `connect()` | `DatabaseConfigError` (via `ensureConnected()`) |
+| `connect()` fails on credentials | `AuthenticationError` — a rejected credential is not a connectivity problem, and saying so would send the user to check their host |
+| `connect()` fails otherwise | `ConnectionError` carrying host and port |
+| A parameter value with no Druid type | An error naming the unsupported type, raised **before** anything leaves the process |
+
+One shape worth knowing because it looks like a bug and is not: **a cancelled streamed query answers
+HTTP 200 and then simply stops.** Live-reproduced on 37.0.0 — a large result cancelled through
+`DELETE /druid/v2/sql/{sqlQueryId}` streamed 3.6 MB and cut off mid-value. The status line was
+committed long before the failure, a large result is served `Transfer-Encoding: chunked` (verified),
+and nothing in the headers can be revised after the fact, so **the truncated body is the only
+evidence a client has** — and an HTTP trailer, the one place a chunked response could still say
+something, is unreachable through `fetch` in any case. The transport reports it as *"Druid ended the
+response before it was complete, so the result is incomplete"* rather than a JSON parse complaint
+(which tells the person who ran the query nothing) or an empty success (which would be worse).
+
+The mirror-image case was **verified not to happen** and is therefore deliberately not handled: a
+failure the Broker learns of *before* it commits the status — `1/(id-1005)` after 35 MB of rows had
+already crossed the cluster — still answers a clean 500 whose body is the error envelope **alone**,
+with no partial result in front of it. That is the opposite of ClickHouse's buffered case (#264), so
+there is nothing to trim.
+
+---
+
+## 11. Testing
+
+### 11.1 How the tests work
+
+There is **no `mock.module()` anywhere in the Druid suite**, so none of these files carries
+process-wide contamination risk:
+
+- [`tests/integration/db/druid-provider.test.ts`](../../tests/integration/db/druid-provider.test.ts)
+ replaces `globalThis.fetch` per test and restores it in `afterEach`, so the real transport, the real
+ introspection, the real explain strategy and the real provider all run — only the cluster is fake.
+ Every payload in it was captured from a live Apache Druid 37.0.0 cluster (datasources
+ `libredb_demo`, 50 rows, and `libredb_rollup`, 20 rows), so the fake speaks exactly what the server
+ speaks. It also pins the exact statement each introspection read sends, by importing the exported
+ SQL constants: a test that matched a substring would keep passing after a projection changed shape,
+ which is precisely the change that breaks a mapper.
+- [`tests/unit/db/druid/http-transport.test.ts`](../../tests/unit/db/druid/http-transport.test.ts)
+ drives `DruidHttpTransport` against a faked `fetch`: request shape, endpoint construction
+ (host/port/TLS/IPv6 bracketing), Basic auth presence and absence, the three-header-row result path,
+ the empty-result path, duplicate-column disambiguation, `quoteUnsafeIntegers` as its own unit
+ (string literals, escaped quotes, floats, exponents, negatives, every adjacency, and the no-op
+ case), every parameter type including the `bigint` raw literal and the refusals, both error
+ envelopes, the HTTP-500-that-is-a-user-error, a non-JSON body, and the truncated-body case.
+- [`tests/unit/db/druid/introspect.test.ts`](../../tests/unit/db/druid/introspect.test.ts) drives the
+ introspection and monitoring module through a hand-built query runner — the payoff of the seam in
+ [§3.2](#32-the-transport-seam-one-interface-one-implementation): no fetch mocking, no server. It
+ covers the absent-row aggregate, the `duration = -1` task, the `max_size = 0` denominator, the
+ quoted-vs-unquoted `SUM`, the `__time` nullability flag, a malformed row costing one column instead of
+ the tree, and every degradation path.
+- [`tests/unit/db/druid/transport.test.ts`](../../tests/unit/db/druid/transport.test.ts) pins the
+ frozen category table and the normalized error — including `is()` and
+ `isMonitoringUnavailable()` — ahead of the transport and the provider, because a wrong category
+ silently turns a degradation path into a thrown error or the reverse.
+- [`tests/unit/db/druid/seam-guard.test.ts`](../../tests/unit/db/druid/seam-guard.test.ts) is a
+ parser, not a grep, and proves itself in both directions: it must fire on `http-transport.ts`
+ (which is *supposed* to speak HTTP) and stay silent on a compliant sample, before it asserts the
+ real provider directory is clean.
+- [`tests/unit/lib/explain/druid-native.test.ts`](../../tests/unit/lib/explain/druid-native.test.ts)
+ covers the plan walker against a captured join plan: wrapper-depth unwrapping, every `dataSource`
+ type including an unknown one, the depth bound and its visible truncation label, the attribute rows,
+ and the multi-entry `UNION ALL` synthetic root.
+
+### 11.2 Coverage
+
+Validation (host required), capabilities and labels, connect/disconnect including the `SELECT 1` probe
+surfacing a bad endpoint or a rejected credential, query execution and result shaping (declared column
+order, duplicate-column disambiguation, the measured duration, parameter binding), the full
+category-to-error map including the HTTP-500 user error and the transport stand-in, the
+`prepareQuery()` override in both directions (statements that must be limited and the
+`OFFSET`-with-no-`LIMIT` statement that must not be), schema introspection, every monitoring method
+and its degraded path, `runMaintenance()` refusing with its reason, and the explain strategy end to
+end through the registry.
+
+### 11.3 Run it
+
+```bash
+# Just this provider
+bun test tests/integration/db/druid-provider.test.ts
+bun test tests/unit/db/druid
+bun test tests/unit/lib/explain/druid-native.test.ts
+
+# Full isolated suite (CI-equivalent)
+bun run test
+```
+
+### 11.4 Optional: reproducing the live pass
+
+The committed tests are mock-based by design and never touch a cluster. To reproduce the live
+verification behind this document, `database-compose.yml` carries **seven services pinned to
+`apache/druid:37.0.0`**, all gated behind a `druid` profile:
+
+```bash
+docker compose -f database-compose.yml --profile druid up -d
+```
+
+**Why a profile rather than the default `up -d`:** Druid is a distributed system with **no
+single-container mode**. Five Druid processes (Coordinator+Overlord, Broker, Historical,
+MiddleManager, Router) plus ZooKeeper plus its own metadata database is the minimum that can answer a
+SQL query — and the metadata store is deliberately *not* the `postgres` service in the same file,
+because sharing it would put Druid's internal tables into the demo database that connection browses.
+Ungated, this would take the everyday fixture from 8 services to 15 and add about **4 GB of resident
+memory** (measured with `docker stats` on the idle cluster: 1.4 GB Historical, 1.1 GB Broker, and the
+rest between the other five). The profile makes that opt-in.
+
+**Only two ports are published**: `8888` (Router) and `8082` (Broker) — the two the provider can talk
+to, published so that the Broker-equivalence claim in
+[§3.3](#33-router-8888-or-broker-8082--both-work-identically) can be *proven* rather than assumed.
+The other five processes are cluster internals and publish nothing; `8091`, the MiddleManager's usual
+port, is already taken by the `couchbase` service in the same file, which is a second reason not to.
+
+Wait for the Router to become healthy (`docker compose ... ps`), then point a Studio connection at
+`127.0.0.1:8888` with no credentials — a default install loads no security extension. Repeat with port
+`8082` to exercise the Broker path.
+
+**A datasource can only be created by ingestion.** There is no `CREATE TABLE` and no seed sidecar that
+could substitute for one, so load data by submitting a **native batch task with an inline input
+source** to the Overlord (through the Router's management proxy):
+
+```bash
+curl -s -XPOST -H 'content-type: application/json' \
+ http://localhost:8888/druid/indexer/v1/task -d '{
+ "type": "index_parallel",
+ "spec": {
+ "dataSchema": {
+ "dataSource": "libredb_demo",
+ "timestampSpec": { "column": "ts", "format": "iso" },
+ "dimensionsSpec": {
+ "dimensions": [
+ { "type": "long", "name": "snowflake_id" },
+ { "type": "long", "name": "id" },
+ { "type": "string", "name": "name" },
+ { "type": "string", "name": "region" },
+ { "type": "long", "name": "qty" },
+ { "type": "double", "name": "amount" }
+ ]
+ },
+ "granularitySpec": { "type": "uniform", "segmentGranularity": "DAY", "rollup": false }
+ },
+ "ioConfig": {
+ "type": "index_parallel",
+ "inputSource": {
+ "type": "inline",
+ "data": "{\"ts\":\"2026-08-01T00:15:00Z\",\"snowflake_id\":9007199254740993,\"id\":1000,\"name\":\"alpha\",\"region\":\"emea\",\"qty\":0,\"amount\":10.5}\n{\"ts\":\"2026-08-02T01:15:00Z\",\"snowflake_id\":9007199254740994,\"id\":1001,\"name\":\"beta\",\"region\":\"apac\",\"qty\":3,\"amount\":11.75}"
+ },
+ "inputFormat": { "type": "json" }
+ },
+ "tuningConfig": { "type": "index_parallel" }
+ }
+}'
+```
+
+That payload was run against the live cluster while writing this document — byte-identical except for
+the `dataSource` name, which was `libredb_docprobe` so the two fixtures stayed untouched, and which was
+then removed again with the two steps in
+[§5.5](#55-druid-sql-cannot-write-and-the-server-says-so-clearly). It answers
+`{"task":"index_parallel_libredb_demo_"}`; follow it with
+`GET /druid/indexer/v1/task/{id}/status` until `statusCode` is `SUCCESS` — a two-row inline task takes
+a few seconds — and the datasource then appears in `INFORMATION_SCHEMA.TABLES` and is queryable. Two
+details in that payload are load-bearing for reproducing this document's findings:
+
+- **`snowflake_id` holds 9007199254740993** (253 + 1), which is the value that reproduces
+ the `JSON.parse` rounding in
+ [§3.6](#36-64-bit-integers-arrive-unquoted-and-druid-offers-no-server-side-fix). Any smaller id
+ makes that bug invisible.
+- **`rollup: false`** keeps the datasource a plain row store. The companion fixture,
+ `libredb_rollup`, uses `"rollup": true` with a `metricsSpec` of `count` and `doubleSum`, which is
+ what a Druid user's aggregating datasource looks like and what the join plan in
+ [§3.12](#312-explain-the-native-plan-is-genuinely-a-tree) was captured against.
+
+`docker compose -f database-compose.yml --profile druid down -v` resets the cluster to empty — the
+named volumes hold deep storage and task history, so without `-v` a restart keeps both.
+
+---
+
+## 12. Usage examples
+
+### 12.1 Programmatic (via the factory)
+
+```ts
+import { createDatabaseProvider } from '@/lib/db/factory';
+
+const provider = await createDatabaseProvider({
+ id: 'druid1', name: 'Druid', type: 'druid',
+ host: '127.0.0.1', port: 8888,
+ createdAt: new Date(),
+});
+
+await provider.connect();
+
+const rows = await provider.query('SELECT * FROM "libredb_demo" LIMIT 50');
+const one = await provider.query(
+ 'SELECT COUNT(*) AS "c" FROM "libredb_demo" WHERE region = ?', ['emea'],
+);
+const schema = await provider.getSchema(); // datasources + columns, indexes always []
+const tasks = await provider.getActiveSessions(); // RUNNING/PENDING ingestion tasks
+
+await provider.disconnect();
+```
+
+Note the double quotes on the alias in the parameterized statement: `AS c` happens to be safe, but
+`AS one` would be a syntax error ([§5.4](#54-dialect-traps-a-user-will-hit)), so quoting every
+generated alias is the habit worth keeping.
+
+### 12.2 Over the API
+
+`POST /api/db/query` with the SQL statement in the `sql` field — the same contract every SQL provider
+uses. `POST /api/db/maintenance` has nothing to accept for Druid and any call throws with the reason
+([§8](#8-maintenance)). The transaction and cancel routes do not apply: `/api/db/cancel` reports
+cancellation as unsupported because the provider exposes no `cancelQuery`
+([§13](#13-known-limitations--future-work)).
+
+---
+
+## 13. Known limitations & future work
+
+- **A partially-unavailable result is not flagged.** Every successful response carries
+ `X-Druid-Response-Context: {"missingSegments":[]}`, and a non-empty array there means the row set is
+ **incomplete** while the status is still 200. The provider does not surface it, for a structural
+ reason rather than an oversight: `QueryResult` has no warnings channel at all — the Couchbase
+ transport collects `warnings` at its own seam and its provider discards them for exactly the same
+ reason — so there is nowhere to put the fact without changing a shared type and the result UI. A
+ query-warnings channel would serve both providers and is the follow-up. Note that the fixture
+ cluster cannot reproduce a non-empty array: with a single Historical, losing it removes the
+ datasource from the catalog instead ([§6](#the-catalog-is-a-view-of-what-is-servable-not-of-what-exists)),
+ so the partial case needs a multi-server cluster where only *some* segments are unavailable.
+- **No writes at all through this endpoint.** No `UPDATE`, no `DELETE`, no `CREATE TABLE`; `INSERT`
+ and `REPLACE` need the MSQ task engine. This is Druid, not the provider — see
+ [§5.5](#55-druid-sql-cannot-write-and-the-server-says-so-clearly), which also documents how data is
+ actually removed (mark segments unused, then a kill task, both through the Coordinator).
+- **MSQ ingestion is out of scope.** `POST /druid/v2/sql/task` would make `INSERT`/`REPLACE` work, but
+ it returns a task id rather than rows and needs a submit/poll/status surface the `query()` contract
+ does not model. A follow-up, not a small one: it implies task management in the UI.
+- **The async statements endpoint is out of scope, and the answer to #265's question about it is no.**
+ `POST /druid/v2/sql/statements` requires `executionMode: ASYNC` in the query context — verified, a
+ plain POST answers `400` *"Execution mode is not provided to the sql statement api. Please set
+ [executionMode] to [ASYNC] in the query context"* — it runs on the MSQ task engine rather than the
+ native one, and it replaces one-shot execution with a submit/poll/paginate protocol. It is the right
+ answer for a long analytical query and the wrong shape for the current provider interface.
+- **No `cancelQuery`.** A client-side abort does **not** stop the query on the cluster; the server-side
+ statement deadline is what does ([§3.8](#38-both-halves-of-the-timeout)). The follow-up is concrete
+ rather than speculative: Druid echoes a caller-supplied `sqlQueryId` back in the
+ `X-Druid-SQL-Query-Id` response header (verified), and `DELETE /druid/v2/sql/{sqlQueryId}` cancels
+ by it — so setting our own id in the query context would give a real `cancelQuery`.
+- **No supervisor or task management.** `sys.supervisors` is empty on a batch-only cluster (verified)
+ and is not read; streaming ingestion supervisors and task submission/suspension/termination have no
+ surface here. `sys.tasks` is read for the sessions panel only ([§7](#7-monitoring--health)).
+- **Lookups are not listed in the sidebar.** The `lookup` schema is real — it is one of the five
+ `INFORMATION_SCHEMA.SCHEMATA` rows on every cluster (verified) — and a lookup is addressable as
+ `lookup.` in typed SQL, but only the `druid` schema is listed, so a cluster using
+ `druid-lookups-cached-global` shows no lookup entries in the tree. The same applies to the `view`
+ schema. Listing either would need a second schema section in the explorer, which is a UI change
+ rather than a provider one. (The fixture cluster defines no lookups and no views, so nothing here
+ demonstrates a query against one.)
+- **No compaction, retention or segment management.** All Coordinator and task concerns; see
+ [§8](#8-maintenance).
+- **Performance metrics and slow queries are structurally unavailable**, not merely unimplemented:
+ Druid's metrics reach an emitter and it keeps no query log ([§7](#7-monitoring--health)).
+- **`ORDER BY` on a non-`__time` column of a table scan fails**, and the provider does not work around
+ it — no rewrite could preserve the user's intent ([§5.4](#54-dialect-traps-a-user-will-hit)).
+- **`ARRAY` cells are JSON strings**, by Druid's default and on purpose
+ ([§5.3](#53-array-cells-arrive-as-json-strings)).
+- **`ssl.caCert` / `ssl.clientCert` / `ssl.rejectUnauthorized` are not honoured**, so a self-signed
+ certificate fails verification ([§4.3](#43-tls)).
+- **No connection string**, deliberately ([§4.2](#42-there-is-no-connection-string-and-that-is-deliberate)).
+- **The whole result body is buffered** before it is parsed, so a deliberately huge result set is
+ expensive in a way a streaming client would not be ([§3.1](#31-http-only--no-driver-and-what-that-costs)).
+- **Two shared SQL-generating features are not dialect-aware**, both pre-existing and both tracked in
+ [#269](https://github.com/libredb/libredb-studio/issues/269): the results grid's **inline row
+ editing** emits `UPDATE ... SET`, which Druid rejects as `Unsupported SQL statement [UPDATE]` — there
+ is no Druid equivalent to substitute, so editing a Druid row is not possible at all — and the
+ **schema-diff migration generator** has no Druid branch, which is moot here since Druid has no DDL
+ to migrate.
+
+---
+
+## 14. References
+
+- Source: [`src/lib/db/providers/sql/druid/`](../../src/lib/db/providers/sql/druid/)
+- Explain strategy: [`src/lib/explain/druid-native.ts`](../../src/lib/explain/druid-native.ts)
+- SQL base: [`src/lib/db/providers/sql/sql-base.ts`](../../src/lib/db/providers/sql/sql-base.ts)
+- Base class: [`src/lib/db/base-provider.ts`](../../src/lib/db/base-provider.ts)
+- Interface & DTOs: [`src/lib/db/types.ts`](../../src/lib/db/types.ts)
+- Errors: [`src/lib/db/errors.ts`](../../src/lib/db/errors.ts)
+- Connection form config: [`src/lib/db-ui-config.ts`](../../src/lib/db-ui-config.ts)
+- Local cluster: [`database-compose.yml`](../../database-compose.yml) (`--profile druid`)
+- Tests: [`tests/integration/db/druid-provider.test.ts`](../../tests/integration/db/druid-provider.test.ts) · [`tests/unit/db/druid/`](../../tests/unit/db/druid/) · [`tests/unit/lib/explain/druid-native.test.ts`](../../tests/unit/lib/explain/druid-native.test.ts)
+- API contract: [`docs/API_DOCS.md`](../API_DOCS.md)
+- Tracking issue: [#265 — Add Apache Druid provider](https://github.com/libredb/libredb-studio/issues/265)
+- Druid SQL:
+- SQL HTTP endpoint and result formats:
+- Metadata tables (`INFORMATION_SCHEMA`, `sys`):
+- `EXPLAIN PLAN FOR`:
+- Native batch ingestion:
+- Sibling provider docs: [PostgreSQL](./postgres.md) · [MySQL](./mysql.md) · [Oracle](./oracle.md) · [SQL Server](./mssql.md) · [SQLite](./sqlite.md) · [MongoDB](./mongodb.md) · [Couchbase](./couchbase.md) · [ClickHouse](./clickhouse.md) · [Redis](./redis.md) · [LibreDB](./libredb.md)
diff --git a/src/components/icons/db-icons.tsx b/src/components/icons/db-icons.tsx
index 858314ff..2742a1b2 100644
--- a/src/components/icons/db-icons.tsx
+++ b/src/components/icons/db-icons.tsx
@@ -195,6 +195,27 @@ export const ClickHouseIcon: React.FC = ({ className, ...props }) =>
);
+/**
+ * Apache Druid five-pointed angular mark, reduced to its outline. The brand mark's
+ * interior is segmented; those segments collapse into mush at the 14px (`w-3.5`)
+ * size the sidebar renders a DB icon at, so only the silhouette survives — the
+ * five points are what makes it identifiable at that size.
+ */
+export const DruidIcon: React.FC = ({ className, ...props }) => (
+
+
+
+);
+
/** LibreDB database cylinder with L marker */
export const LibreDBIcon: React.FC = ({ className, ...props }) => (
0 ? Math.round((overview.activeConnections / connectionLimit) * 100) : null;
+
+ // Evaluate thresholds. No published limit cannot be near a limit, so it scores as
+ // healthy rather than as the 0 that a missing reading would once have implied.
const connThreshold = evaluateThreshold(
- connectionPercent,
+ connectionPercent ?? 0,
DEFAULT_THRESHOLDS.find((t) => t.metric === "connectionPercent")!,
);
const cacheThreshold = evaluateThreshold(
- performance?.cacheHitRatio ?? 100,
+ cacheHitRatio ?? 100,
DEFAULT_THRESHOLDS.find((t) => t.metric === "cacheHitRatio")!,
);
@@ -73,12 +85,18 @@ export function OverviewTab({ data, loading, history = [] }: OverviewTabProps) {
{overview?.activeConnections ?? 0}
-
- /{overview?.maxConnections ?? 0}
-
+ {connectionLimit > 0 && (
+ /{connectionLimit}
+ )}
-
- {connectionPercent}% used
+ {connectionPercent === null ? (
+ no limit published
+ ) : (
+ <>
+
+ {connectionPercent}% used
+ >
+ )}
@@ -101,15 +119,22 @@ export function OverviewTab({ data, loading, history = [] }: OverviewTabProps) {
- {performance?.cacheHitRatio?.toFixed(1) ?? 0}%
-
-
- {(performance?.cacheHitRatio ?? 0) >= 90
- ? "Excellent"
- : (performance?.cacheHitRatio ?? 0) >= 80
- ? "Good"
- : "Needs tuning"}
-
+ {cacheHitRatio === undefined ? (
+ <>
+
+ {CACHE_HIT_RATIO_UNAVAILABLE}
+
+ Not measured
+ >
+ ) : (
+ <>
+ {cacheHitRatio.toFixed(1)}%
+
+
+ {cacheHitRatio >= 90 ? "Excellent" : cacheHitRatio >= 80 ? "Good" : "Needs tuning"}
+
+ >
+ )}
diff --git a/src/components/monitoring/tabs/PerformanceTab.tsx b/src/components/monitoring/tabs/PerformanceTab.tsx
index fc4e67b0..3c623ccb 100644
--- a/src/components/monitoring/tabs/PerformanceTab.tsx
+++ b/src/components/monitoring/tabs/PerformanceTab.tsx
@@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge";
import type { MonitoringData } from "@/lib/db/types";
import type { TimeSeriesPoint } from "@/lib/time-series-buffer";
import { evaluateThreshold, getThresholdColor, DEFAULT_THRESHOLDS } from "@/lib/monitoring-thresholds";
+import { CACHE_HIT_RATIO_UNAVAILABLE } from "@/lib/monitoring-cache-ratio";
import { MetricChart } from "./MetricChart";
interface PerformanceTabProps {
@@ -31,12 +32,17 @@ export function PerformanceTab({ data, loading, history = [] }: PerformanceTabPr
return { label: "Poor", color: "text-red-500", bg: "bg-red-500" };
};
- const cacheStatus = getHealthStatus(performance?.cacheHitRatio ?? 0);
+ // Optional on purpose: an engine that cannot measure its cache (Druid) reports
+ // nothing, and a rating - or a red icon - for a number that does not exist would
+ // be an invented verdict. No ratio, no status.
+ const cacheHitRatio = performance?.cacheHitRatio;
+ const cacheStatus =
+ cacheHitRatio === undefined ? undefined : { ratio: cacheHitRatio, ...getHealthStatus(cacheHitRatio) };
const bufferStatus = getHealthStatus(performance?.bufferPoolUsage ?? 0);
// Threshold evaluations
const cacheThreshold = evaluateThreshold(
- performance?.cacheHitRatio ?? 100,
+ cacheHitRatio ?? 100,
DEFAULT_THRESHOLDS.find((t) => t.metric === "cacheHitRatio")!,
);
const bufferThreshold = evaluateThreshold(
@@ -49,7 +55,15 @@ export function PerformanceTab({ data, loading, history = [] }: PerformanceTabPr
);
// Build trend data from history
- const cacheHistory = history.map((h) => ({ timestamp: h.timestamp, value: h.data.performance?.cacheHitRatio ?? 0 }));
+ // Missing samples are DROPPED, not zeroed. An engine that cannot measure a cache hit
+ // ratio (Druid) reports none, and mapping that to 0 would plot a measured 0% trend -
+ // exactly the fabricated metric the current-value card above withholds. Dropping
+ // leaves an empty series, which the chart renders as no data rather than as a floor.
+ const cacheHistory = history.flatMap((h) =>
+ h.data.performance?.cacheHitRatio === undefined
+ ? []
+ : [{ timestamp: h.timestamp, value: h.data.performance.cacheHitRatio }],
+ );
const bufferHistory = history.map((h) => ({
timestamp: h.timestamp,
value: h.data.performance?.bufferPoolUsage ?? 0,
@@ -64,20 +78,36 @@ export function PerformanceTab({ data, loading, history = [] }: PerformanceTabPr
Cache Hit
-
+
-
- {performance?.cacheHitRatio?.toFixed(1) ?? 0}
- %
-
-
-
-
- {cacheStatus.label}
-
- 95%+
-
+ {cacheStatus === undefined ? (
+ <>
+
+
+ {CACHE_HIT_RATIO_UNAVAILABLE}
+
+
+ Not measured
+ >
+ ) : (
+ <>
+
+ {cacheStatus.ratio.toFixed(1)}
+ %
+
+
+
+
+ {cacheStatus.label}
+
+ 95%+
+
+ >
+ )}
@@ -135,7 +165,11 @@ export function PerformanceTab({ data, loading, history = [] }: PerformanceTabPr
Cache Hit Trend
-
+ {cacheHistory.length === 0 ? (
+ Not measured
+ ) : (
+
+ )}
@@ -187,7 +221,9 @@ export function PerformanceTab({ data, loading, history = [] }: PerformanceTabPr
- {(performance?.cacheHitRatio ?? 100) < 90 && (
+ {/* Guarded on the ratio existing, not on a stand-in value: there is
+ nothing to advise about a cache nobody measured. */}
+ {cacheHitRatio !== undefined && cacheHitRatio < 90 && (
)}
- {(performance?.cacheHitRatio ?? 0) >= 90 && !performance?.deadlocks && (
+ {cacheHitRatio !== undefined && cacheHitRatio >= 90 && !performance?.deadlocks && (
Performing well!
diff --git a/src/hooks/use-connection-form.ts b/src/hooks/use-connection-form.ts
index 41b38275..c76c4196 100644
--- a/src/hooks/use-connection-form.ts
+++ b/src/hooks/use-connection-form.ts
@@ -351,6 +351,7 @@ export function useConnectionForm({ isOpen, onConnect, editConnection, onTestCon
"redis",
"libredb",
"clickhouse",
+ "druid",
];
const dbTypes = selectableTypes.map((t) => {
const cfg = getDBConfig(t);
diff --git a/src/lib/db-ui-config.ts b/src/lib/db-ui-config.ts
index c445d251..5a0c8c0a 100644
--- a/src/lib/db-ui-config.ts
+++ b/src/lib/db-ui-config.ts
@@ -10,6 +10,7 @@ import {
LibreDBIcon,
CouchbaseIcon,
ClickHouseIcon,
+ DruidIcon,
} from "@/components/icons/db-icons";
import type { DatabaseType } from "@/lib/types";
@@ -111,6 +112,27 @@ const DB_UI_CONFIG: Record
= {
showConnectionStringToggle: true,
connectionFields: ["host", "port", "user", "password", "database", "connectionString"],
},
+ druid: {
+ icon: DruidIcon,
+ // Issue #265 specified text-sky-400, which mssql already owns; the distinct-colour
+ // assertion in tests/unit/lib/db-ui-config.test.ts rules a duplicate out. teal-400
+ // is the nearest free shade and is closer to Druid's own petrol-teal mark anyway.
+ color: "text-teal-400",
+ label: "Apache Druid",
+ // The Router port. The Broker on 8082 serves the identical POST /druid/v2/sql and
+ // needs no different configuration (live-verified, issue #265); the Router is the
+ // default only because it also fronts the console and the management-proxied APIs.
+ defaultPort: "8888",
+ // No URI convention exists for Druid's HTTP SQL API - its JDBC driver addresses
+ // Avatica (jdbc:avatica:remote:url=...), and http:// / https:// already resolve to
+ // ClickHouse in connection-string-parser.ts. There is nothing to paste.
+ showConnectionStringToggle: false,
+ // Deliberately no "database": INFORMATION_SCHEMA.SCHEMATA reports exactly one
+ // catalog, always named `druid`, so a database selector would be a control with no
+ // effect. Credentials stay offered because a cluster running druid-basic-security
+ // needs them; a default install ignores the Authorization header entirely.
+ connectionFields: ["host", "port", "user", "password"],
+ },
libredb: {
icon: LibreDBIcon,
color: "text-violet-400",
diff --git a/src/lib/db/factory.ts b/src/lib/db/factory.ts
index f422960c..d98c25fd 100644
--- a/src/lib/db/factory.ts
+++ b/src/lib/db/factory.ts
@@ -92,6 +92,14 @@ export async function createDatabaseProvider(
return new ClickHouseProvider(connection, options);
}
+ case "druid": {
+ // The explicit /index specifier keeps this dynamic import statically
+ // analysable: a bare directory resolves only at runtime, which the bundler
+ // cannot trace into a chunk.
+ const { DruidProvider } = await import("./providers/sql/druid/index");
+ return new DruidProvider(connection, options);
+ }
+
// Document Databases - dynamically imported
case "mongodb": {
const { MongoDBProvider } = await import("./providers/document/mongodb");
@@ -120,7 +128,7 @@ export async function createDatabaseProvider(
default:
throw new DatabaseConfigError(
- `Unknown database type: ${connection.type}. Supported types: postgres, mysql, sqlite, oracle, mssql, clickhouse, mongodb, couchbase, redis, libredb`,
+ `Unknown database type: ${connection.type}. Supported types: postgres, mysql, sqlite, oracle, mssql, clickhouse, druid, mongodb, couchbase, redis, libredb`,
connection.type,
);
}
diff --git a/src/lib/db/providers/document/couchbase/index.ts b/src/lib/db/providers/document/couchbase/index.ts
index d4b33452..95881720 100644
--- a/src/lib/db/providers/document/couchbase/index.ts
+++ b/src/lib/db/providers/document/couchbase/index.ts
@@ -46,6 +46,7 @@ import {
type TableSchema,
type TableStats,
} from "@/lib/db/types";
+import { formatCacheHitRatio } from "@/lib/monitoring-cache-ratio";
import { formatBytes } from "@/lib/db/utils/pool-manager";
import { applyQueryLimit, DEFAULT_QUERY_LIMIT, MAX_UNLIMITED_ROWS } from "@/lib/db/utils/query-limiter";
import { CouchbaseHttpTransport } from "./http-transport";
@@ -739,7 +740,7 @@ export class CouchbaseProvider extends BaseDatabaseProvider {
return {
activeConnections: overview.activeConnections,
databaseSize: overview.databaseSize,
- cacheHitRatio: performance.cacheHitRatio.toFixed(1),
+ cacheHitRatio: formatCacheHitRatio(performance.cacheHitRatio),
slowQueries: slow,
activeSessions: active,
};
diff --git a/src/lib/db/providers/sql/clickhouse/index.ts b/src/lib/db/providers/sql/clickhouse/index.ts
index 0364f3ca..dca78938 100644
--- a/src/lib/db/providers/sql/clickhouse/index.ts
+++ b/src/lib/db/providers/sql/clickhouse/index.ts
@@ -64,6 +64,7 @@ import {
type TableSchema,
type TableStats,
} from "@/lib/db/types";
+import { formatCacheHitRatio } from "@/lib/monitoring-cache-ratio";
import { formatBytes } from "@/lib/db/utils/pool-manager";
import { ClickHouseHttpTransport } from "./http-transport";
import {
@@ -937,7 +938,7 @@ export class ClickHouseProvider extends SQLBaseProvider {
return {
activeConnections: overview.activeConnections,
databaseSize: overview.databaseSize,
- cacheHitRatio: performance.cacheHitRatio.toFixed(1),
+ cacheHitRatio: formatCacheHitRatio(performance.cacheHitRatio),
slowQueries: slow,
activeSessions: active,
};
diff --git a/src/lib/db/providers/sql/druid/http-transport.ts b/src/lib/db/providers/sql/druid/http-transport.ts
new file mode 100644
index 00000000..70690719
--- /dev/null
+++ b/src/lib/db/providers/sql/druid/http-transport.ts
@@ -0,0 +1,542 @@
+/**
+ * Druid HTTP transport (issue #265, design spec sections 2, 3, 5, 6, 11 and 13)
+ *
+ * The only implementation of the DruidTransport seam, and the only file in the
+ * provider allowed to know how Druid's SQL endpoint encodes a request and a
+ * result: its `resultFormat`, its three header flags, the header ROWS it prepends,
+ * its query context, its two error envelopes and its parameter type names.
+ * `seam-guard.test.ts` fails the build the moment any of that vocabulary appears
+ * elsewhere in the directory, which is what keeps "an Avatica JDBC client is one
+ * new file" true rather than aspirational.
+ *
+ * Zero runtime dependency: the statement is one JSON POST and the answer comes
+ * back through the runtime's own `fetch`.
+ *
+ * Four live-verified shapes drive nearly every decision below, and each is the
+ * opposite of what a JSON API teaches (all on Apache Druid 37.0.0):
+ *
+ * - `resultFormat: "object"` LOSES data - `SELECT 1 AS c, 2 AS c` keeps only the
+ * last `c` - so the wire form is the positional `array` one, and the rows are
+ * rebuilt here from the declared names (spec section 2).
+ * - A 64-bit integer arrives as an UNQUOTED JSON number, and there is no
+ * server-side setting to quote it, so the raw body is rewritten before it is
+ * parsed (spec section 3, `quoteUnsafeIntegers`).
+ * - The error body's `error` field is a DISCRIMINATOR whose value is the literal
+ * string "druidException"; the message lives in `errorMessage`, and the HTTP
+ * status misclassifies - `SELECT 1/0` is a 500 for a user's typo (spec 5).
+ * - Druid can fail AFTER committing a 200: a cancelled streamed query simply
+ * stops mid-value. It signals that by withholding a response TRAILER, which
+ * `fetch` cannot read, so a truncated body is the only evidence there is.
+ */
+
+import type { DatabaseConnection } from "@/lib/db/types";
+// Shared with `lib/explain/druid-native.ts`, which parses the EXPLAIN plan columns:
+// those arrive as JSON *text* inside this body, so the pass below correctly leaves
+// their digits alone and the inner parse is a second chance to round the same value.
+// An explain strategy may not import from a provider directory, which is why this
+// lives in db/utils rather than here.
+import { quoteUnsafeIntegers } from "@/lib/db/utils/json-integers";
+import {
+ DRUID_TRANSPORT_FAILURE,
+ type DruidQueryOptions,
+ type DruidQueryResult,
+ type DruidRow,
+ type DruidTransport,
+ DruidTransportError,
+} from "./transport";
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+const DEFAULT_HOST = "localhost";
+
+/**
+ * The Router's port. One default for both schemes on purpose: a TLS Druid serves
+ * on whatever `druid.tlsPort` the deployment chose, so there is no well-known
+ * HTTPS port to fall back to, and inventing one would send credentials to a port
+ * nothing is listening on. The connection form prefills this, so it is a floor
+ * rather than a guess.
+ */
+const DEFAULT_PORT = 8888;
+
+/** Spec section 11: the Broker serves this same path, and so does the Router. */
+const SQL_PATH = "/druid/v2/sql";
+
+/** Live-verified: without this header the endpoint answers 400 before parsing the SQL. */
+const JSON_CONTENT_TYPE = "application/json";
+
+/**
+ * Spec section 2, a correctness decision rather than a preference. Live-verified:
+ * `SELECT 1 AS c, 2 AS c` with `resultFormat: "object"` answers
+ * `[{"c":{...}},{"c":2}]` - the object form silently drops every duplicate column
+ * but the last, and duplicate output names are legal SQL that real joins produce.
+ * The array form is positional, so it keeps both, and column order becomes
+ * authoritative.
+ */
+const RESULT_FORMAT = "array";
+
+/**
+ * Asked for on every statement. Without them the positional rows carry no names
+ * and no types at all, so there would be nothing to rebuild a row object from.
+ */
+const HEADER_FLAGS = Object.freeze({ header: true, typesHeader: true, sqlTypesHeader: true });
+
+/**
+ * Exactly three rows precede the data when all three flags above are set - one
+ * per flag, in this order. Live-verified, including for a result set with NO rows
+ * (`WHERE id = -1` still answers `[["id"],["LONG"],["BIGINT"]]`), which is why a
+ * shorter payload cannot be data.
+ */
+const NAME_ROW = 0;
+const NATIVE_TYPE_ROW = 1;
+const SQL_TYPE_ROW = 2;
+const HEADER_ROW_COUNT = 3;
+
+/**
+ * The fields the two envelopes carry (spec section 5), read back from the live
+ * cluster. Frozen and named so no call site spells one twice, and so the seam
+ * guard can prove that only this file reads them.
+ */
+const ERROR_FIELDS = Object.freeze({
+ /**
+ * A DISCRIMINATOR in the modern shape, a real message in the legacy one - which
+ * is why it is only ever the fallback, and never when it holds the token below.
+ */
+ DISCRIMINATOR: "error",
+ MESSAGE: "errorMessage",
+ /** The classifier: present in BOTH shapes, and a closed enum. */
+ CATEGORY: "category",
+ CODE: "errorCode",
+ /** Druid's guess at who should read the message. Carried for display only. */
+ PERSONA: "persona",
+ /**
+ * Legacy-shape-only and deliberately NOT surfaced: the Java exception class and
+ * the data server's address. Recorded here because this file is the record of
+ * what the wire contains, but a `org.apache.druid.query.QueryTimeoutException`
+ * and a container IP tell the person who wrote the statement nothing.
+ */
+ CLASS: "errorClass",
+ HOST: "host",
+} as const);
+
+/**
+ * The value `error` holds in the modern envelope. Spec section 5, point 1: it is a
+ * discriminator, so falling back to it would print "druidException" to the person
+ * who mistyped a datasource name.
+ */
+const ERROR_DISCRIMINATOR = "druidException";
+
+/** The Druid SQL type names a positional parameter may declare (spec section 13). */
+const PARAMETER_TYPES = Object.freeze({
+ VARCHAR: "VARCHAR",
+ BIGINT: "BIGINT",
+ DOUBLE: "DOUBLE",
+ BOOLEAN: "BOOLEAN",
+ TIMESTAMP: "TIMESTAMP",
+} as const);
+
+const UNREADABLE_PAYLOAD = "Druid ended the response before it was complete, so the result is incomplete";
+const NOT_AN_ARRAY = "Druid answered a SQL result that is not the array it was asked for";
+
+// ============================================================================
+// Wire shapes
+// ============================================================================
+
+/** One positional parameter as the endpoint takes it. */
+interface DruidParameter {
+ type: string;
+ /** `unknown` because a bigint reaches the body as a raw JSON literal. */
+ value: unknown;
+}
+
+/** One HTTP response, already drained so the body can be inspected twice. */
+interface HttpOutcome {
+ ok: boolean;
+ status: number;
+ text: string;
+}
+
+/**
+ * Serialize the parameters array by hand, so a bigint reaches Druid as a bare
+ * literal.
+ *
+ * `JSON.stringify` cannot help: it throws outright on a bigint, and it has no way to
+ * emit an unquoted literal wider than a double. `JSON.rawJSON` can, but it is the
+ * ES2025 JSON source-text proposal - V8 12.4 / Node 22.2 - while this package declares
+ * `engines.node: ">=20.9.0"`, so depending on it would throw a bare TypeError on a
+ * runtime the package claims to support.
+ *
+ * Building the array as text is what remains, and it is deliberately STRUCTURAL rather
+ * than a marker-and-substitute pass. An earlier version wrapped the digits in a
+ * sentinel and unquoted it with a regex over the finished body; that is unsound,
+ * because the sentinel is only as private as the values flowing through it - a caller
+ * whose VARCHAR parameter happened to contain the sentinel would have had that string
+ * silently unquoted into a number. Emitting the literal in the first place cannot
+ * collide with anything, because no marker ever exists.
+ *
+ * Everything that is not a bigint still goes through `JSON.stringify`, so the escaping
+ * of user strings is the runtime's, not ours.
+ */
+function serializeParameters(parameters: readonly DruidParameter[]): string {
+ const encoded = parameters.map((parameter) =>
+ typeof parameter.value === "bigint"
+ ? `{"type":${JSON.stringify(parameter.type)},"value":${parameter.value.toString()}}`
+ : JSON.stringify(parameter),
+ );
+
+ return `[${encoded.join(",")}]`;
+}
+
+// ============================================================================
+// Pure helpers
+// ============================================================================
+
+function asRecord(value: unknown): Record | null {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+function parseJson(text: string): unknown {
+ try {
+ return JSON.parse(text) as unknown;
+ } catch {
+ return null;
+ }
+}
+
+/** Bracket a bare IPv6 literal, which is otherwise not a legal URL authority. */
+function formatHost(host: string): string {
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
+}
+
+/** A field the envelope reported as usable text, or null when it reported none. */
+function textField(envelope: Record, field: string): string | null {
+ const value = envelope[field];
+ return typeof value === "string" && value !== "" ? value : null;
+}
+
+// ============================================================================
+// The result (spec section 2)
+// ============================================================================
+
+/** What a payload with no header row can honestly say about its columns. */
+const UNDESCRIBED = Object.freeze({ fieldNames: null, sqlTypes: null, nativeTypes: null });
+
+/**
+ * The declared names, made unique.
+ *
+ * `SELECT 1 AS c, 2 AS c` really declares `["c","c"]` (live-verified), and a row
+ * is a record, so the repeat has to be disambiguated as the row is built or the
+ * second column disappears BEFORE the seam rather than after it. The suffix keeps
+ * climbing because `SELECT 1 AS c, 2 AS "c (2)", 3 AS c` is legal too, and
+ * uniqueness is the invariant the seam states.
+ */
+function disambiguate(declared: readonly string[]): string[] {
+ const taken = new Set();
+
+ return declared.map((name) => {
+ let unique = name;
+ for (let repeat = 2; taken.has(unique); repeat += 1) unique = `${name} (${repeat})`;
+ taken.add(unique);
+ return unique;
+ });
+}
+
+/**
+ * One type per column, keyed by the disambiguated name.
+ *
+ * A column the header row did not reach is left OUT rather than given a
+ * placeholder: an invented type name would be indistinguishable from one the
+ * server sent.
+ */
+function typesByName(fieldNames: readonly string[], row: unknown): Record {
+ const declared = Array.isArray(row) ? (row as unknown[]) : [];
+
+ return Object.fromEntries(
+ fieldNames.flatMap((name, column) => (column < declared.length ? [[name, String(declared[column])]] : [])),
+ );
+}
+
+/** One positional row, rebuilt as the record the seam promises. */
+function toRow(fieldNames: readonly string[], row: unknown): DruidRow {
+ const values = Array.isArray(row) ? (row as unknown[]) : [];
+
+ return Object.fromEntries(fieldNames.map((name, column) => [name, values[column] ?? null]));
+}
+
+function toQueryResult(payload: unknown[], executionTimeMs: number): DruidQueryResult {
+ const names = payload[NAME_ROW];
+ // A payload shorter than the header, or one whose first row is not the name array,
+ // CANNOT be a healthy answer to the request this transport sends - and it must not
+ // be reported as an empty one.
+ //
+ // Live-verified: with all three header flags set, even a result set with no rows
+ // answers exactly `[["id"],["LONG"],["BIGINT"]]`, and a bare `SET` (the only other
+ // statement form Druid's grammar accepts) is rejected outright rather than answering
+ // short. So there is no legitimate way to receive fewer than three rows.
+ //
+ // What can produce one is a truncated body, or a proxy that rewrote the response.
+ // Returning `{ rows: [] }` there would render the most convincing possible lie: a
+ // successful query over the right datasource that happens to have found nothing.
+ // Data loss has to surface as a failure, which is the same reason the streamed
+ // mid-response case above raises rather than returns.
+ if (payload.length < HEADER_ROW_COUNT || !Array.isArray(names)) {
+ throw new DruidTransportError(UNREADABLE_PAYLOAD);
+ }
+
+ const fieldNames = disambiguate((names as unknown[]).map(String));
+ return {
+ rows: payload.slice(HEADER_ROW_COUNT).map((row) => toRow(fieldNames, row)),
+ fieldNames,
+ sqlTypes: typesByName(fieldNames, payload[SQL_TYPE_ROW]),
+ nativeTypes: typesByName(fieldNames, payload[NATIVE_TYPE_ROW]),
+ executionTimeMs,
+ };
+}
+
+// ============================================================================
+// Failures (spec section 5)
+// ============================================================================
+
+/**
+ * The failure an envelope describes, or `fallback` when it described none.
+ *
+ * Nothing here reads the HTTP status except to name it in that fallback. Spec
+ * section 5, point 3, live-verified: `SELECT 1/0` answers HTTP 500 with
+ * `persona: "ADMIN"` and `category: "UNCATEGORIZED"` for what is an ordinary user
+ * mistake, so classifying on the status would tell the user the cluster is broken
+ * when they divided by zero. `category` is the classifier because it is present in
+ * BOTH envelopes and is a closed enum; the stand-in below means "nothing
+ * classified this", which is not the same as Druid's own `UNCATEGORIZED`.
+ */
+function envelopeError(payload: unknown, fallback: string): DruidTransportError {
+ const envelope = asRecord(payload) ?? {};
+ const discriminated = textField(envelope, ERROR_FIELDS.DISCRIMINATOR);
+
+ return new DruidTransportError(
+ textField(envelope, ERROR_FIELDS.MESSAGE) ??
+ (discriminated === ERROR_DISCRIMINATOR ? null : discriminated) ??
+ fallback,
+ textField(envelope, ERROR_FIELDS.CATEGORY) ?? DRUID_TRANSPORT_FAILURE,
+ textField(envelope, ERROR_FIELDS.CODE) ?? DRUID_TRANSPORT_FAILURE,
+ textField(envelope, ERROR_FIELDS.PERSONA),
+ );
+}
+
+/** A failure that never reached the cluster, or never came back from it. */
+function transportError(cause: unknown): DruidTransportError {
+ const reason = cause instanceof Error ? cause.message : String(cause);
+ return new DruidTransportError(`Druid request failed: ${reason}`);
+}
+
+/**
+ * The rows, or the reason the body could not be read as rows.
+ *
+ * The unparseable case is not defensive padding: live-reproduced on 37.0.0, a
+ * large streamed result cancelled through `DELETE /druid/v2/sql/{sqlQueryId}`
+ * answers HTTP **200**, streams 3.6 MB and then simply stops, its body cut
+ * mid-value. Druid signals that by WITHHOLDING the `X-Druid-Response-Complete`
+ * trailer it otherwise sends, and an HTTP trailer is unreachable through `fetch`,
+ * so the truncated body is the only evidence a client has. Reporting a JSON parse
+ * complaint would tell the person who ran the query nothing; reporting an empty
+ * success would be worse.
+ *
+ * Verified NOT to happen, and therefore deliberately not handled: a failure the
+ * Broker learns of before it commits the status - `1/(id-1005)` after 35 MB of
+ * rows had already crossed the cluster - still answers a clean 500 whose body is
+ * the error envelope ALONE, with no partial result in front of it. That is the
+ * opposite of ClickHouse's buffered case (#264), so there is nothing to cut off.
+ */
+function parseRows(text: string): unknown[] {
+ let payload: unknown;
+ try {
+ payload = JSON.parse(quoteUnsafeIntegers(text)) as unknown;
+ } catch {
+ throw new DruidTransportError(UNREADABLE_PAYLOAD);
+ }
+
+ // An object where an array was promised is either an error Druid committed after
+ // the status or a proxy rewriting the body; reading it as an error beats
+ // reporting no rows.
+ if (!Array.isArray(payload)) throw envelopeError(payload, NOT_AN_ARRAY);
+ return payload;
+}
+
+// ============================================================================
+// Parameters (spec section 13)
+// ============================================================================
+
+/** The most specific name available for a value the mapping refuses. */
+function typeName(value: unknown): string {
+ if (typeof value !== "object" || value === null) return typeof value;
+ return value.constructor?.name ?? "object";
+}
+
+function unmappable(detail: string): DruidTransportError {
+ return new DruidTransportError(`Druid has no parameter type for ${detail}`);
+}
+
+/**
+ * `Infinity` and `NaN` have no JSON form: `JSON.stringify` turns both into `null`,
+ * which the server would read as a null comparison. Refusing beats sending a value
+ * it will misread.
+ *
+ * An integral `number` outside the safe range is refused for a subtler reason: by the
+ * time it arrives here it is ALREADY wrong. A caller writing `9007199254740993`
+ * as a number literal handed us `9007199254740992` - JavaScript rounded it before the
+ * transport existed - and there is nothing here that can recover the digit. Sending it
+ * would filter on a value the user never wrote and return a plausible wrong row set,
+ * so the refusal names the fix: pass a `bigint`, which this transport binds exactly.
+ * The same check catches an integral double far past Druid's own BIGINT range.
+ */
+function numberParameter(value: number): DruidParameter {
+ if (!Number.isFinite(value)) throw unmappable(`the non-finite number ${value}`);
+ if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
+ throw unmappable(`the integer ${value}, which JavaScript has already rounded - pass a bigint instead`);
+ }
+
+ return Number.isInteger(value) ? { type: PARAMETER_TYPES.BIGINT, value } : { type: PARAMETER_TYPES.DOUBLE, value };
+}
+
+/** Live-verified: `{"type":"TIMESTAMP","value":0}` against `__time > ?` matches every row. */
+function timestampParameter(value: Date): DruidParameter {
+ const millis = value.getTime();
+ if (Number.isNaN(millis)) throw unmappable("an invalid Date");
+
+ return { type: PARAMETER_TYPES.TIMESTAMP, value: millis };
+}
+
+/**
+ * A bigint reaches the body as a RAW, UNQUOTED literal, because the obvious
+ * encoding is refused by the server. Live-verified on 37.0.0:
+ *
+ * {"type":"BIGINT","value":"9007199254740993"} -> RUNTIME_FAILURE, "Cannot handle query"
+ * {"type":"BIGINT","value":9007199254740993} -> matches the row exactly
+ *
+ * Design spec section 13 says a bigint goes over "as a string value"; that is the
+ * one line of the spec the live cluster contradicts, so the unquoted form is what
+ * is implemented and this is the record of why.
+ *
+ * The bigint is carried through as a bigint and emitted as a literal by
+ * `serializeParameters`, which is where the reason `JSON.stringify` and
+ * `JSON.rawJSON` are both unusable is recorded.
+ */
+function bigintParameter(value: bigint): DruidParameter {
+ return { type: PARAMETER_TYPES.BIGINT, value };
+}
+
+function toParameter(value: unknown): DruidParameter {
+ if (typeof value === "string") return { type: PARAMETER_TYPES.VARCHAR, value };
+ if (typeof value === "boolean") return { type: PARAMETER_TYPES.BOOLEAN, value };
+ if (typeof value === "number") return numberParameter(value);
+ if (typeof value === "bigint") return bigintParameter(value);
+ if (value instanceof Date) return timestampParameter(value);
+ // Live-verified: a VARCHAR parameter with a null value executes and matches the
+ // rows a null comparison should, which is the honest encoding for "no value".
+ if (value === null || value === undefined) return { type: PARAMETER_TYPES.VARCHAR, value: null };
+
+ throw unmappable(`a value of type ${typeName(value)}`);
+}
+
+// ============================================================================
+// Transport
+// ============================================================================
+
+export class DruidHttpTransport implements DruidTransport {
+ public readonly kind = "http" as const;
+
+ private readonly endpoint: string;
+ private readonly authorization: string | undefined;
+
+ constructor(config: DatabaseConnection) {
+ // `ssl` is a first-class connection field and independent of the form's
+ // `connectionFields`, and an explicit `disable` has to turn TLS OFF as well as
+ // an explicit mode turns it on (the #264 lesson).
+ const secure = config.ssl !== undefined && config.ssl.mode !== "disable";
+ const host = formatHost(config.host ?? DEFAULT_HOST);
+ this.endpoint = `${secure ? "https" : "http"}://${host}:${config.port ?? DEFAULT_PORT}${SQL_PATH}`;
+ // Spec section 1, live-verified: a default install loads no security extension
+ // and IGNORES this header entirely - a bogus Basic header still answers 200 -
+ // so credentials are optional, and sending none is the normal case. When they
+ // are configured they are for the `druid-basic-security` extension.
+ this.authorization = config.user
+ ? `Basic ${Buffer.from(`${config.user}:${config.password ?? ""}`).toString("base64")}`
+ : undefined;
+ }
+
+ public async query(sql: string, opts: DruidQueryOptions = {}): Promise {
+ // Built before the clock starts: an unmappable parameter must be refused
+ // without anything leaving the process.
+ const body = this.requestBody(sql, opts);
+
+ const startedAt = performance.now();
+ const outcome = await this.send(body, opts.clientDeadlineMs);
+ // Measured, never reported: live-verified, the endpoint answers with the rows
+ // and nothing else - no timing in the body and none in the response metadata,
+ // only query ids - so timing the exchange here is the only honest number
+ // available, and it must not pretend to have come from the server.
+ const executionTimeMs = performance.now() - startedAt;
+
+ if (!outcome.ok) throw envelopeError(parseJson(outcome.text), `Druid request failed with HTTP ${outcome.status}`);
+
+ return toQueryResult(parseRows(outcome.text), executionTimeMs);
+ }
+
+ /**
+ * Nothing to release: one HTTP request per statement and no session pinned, so
+ * this exists only because every implementation of the seam has to be closeable.
+ */
+ public close(): Promise {
+ return Promise.resolve();
+ }
+
+ private requestBody(sql: string, opts: DruidQueryOptions): string {
+ const parameters = (opts.parameters ?? []).map(toParameter);
+ const envelope = JSON.stringify({
+ query: sql,
+ resultFormat: RESULT_FORMAT,
+ ...HEADER_FLAGS,
+ // Spec section 6, first half: verified, a `timeout` of 1 ms answers 504 with
+ // `category: TIMEOUT` on a statement that otherwise takes milliseconds.
+ // Asking the server to stop is what actually frees the cluster's resources;
+ // abandoning the request client-side leaves the query running.
+ ...(opts.timeoutMs === undefined ? {} : { context: { timeout: opts.timeoutMs } }),
+ });
+
+ if (parameters.length === 0) return envelope;
+
+ // Spliced structurally - at the envelope's closing brace, whose position is known
+ // because `JSON.stringify` just produced it - rather than by matching anything in
+ // the text. `parameters` is the only member that can contain a bigint, and
+ // `serializeParameters` records why that cannot go through `JSON.stringify`.
+ return `${envelope.slice(0, -1)},"parameters":${serializeParameters(parameters)}}`;
+ }
+
+ private async send(body: string, clientDeadlineMs?: number): Promise {
+ // Spec section 6, second half: one signal for the request AND the body read. A
+ // response whose headers arrive promptly can still stall mid-body, and awaiting
+ // text() below is otherwise unbounded - which a server-side deadline cannot
+ // help with, since it only starts counting once the statement was accepted.
+ const signal = clientDeadlineMs === undefined ? undefined : AbortSignal.timeout(clientDeadlineMs);
+
+ try {
+ const response = await fetch(this.endpoint, {
+ method: "POST",
+ headers: {
+ "content-type": JSON_CONTENT_TYPE,
+ ...(this.authorization === undefined ? {} : { authorization: this.authorization }),
+ },
+ body,
+ ...(signal ? { signal } : {}),
+ });
+
+ return { ok: response.ok, status: response.status, text: await response.text() };
+ } catch (error) {
+ // A refused socket, an abort and a truncated body all arrive here, and all
+ // have to leave as the seam's own error type.
+ throw transportError(error);
+ }
+ }
+}
diff --git a/src/lib/db/providers/sql/druid/index.ts b/src/lib/db/providers/sql/druid/index.ts
new file mode 100644
index 00000000..c22c5939
--- /dev/null
+++ b/src/lib/db/providers/sql/druid/index.ts
@@ -0,0 +1,494 @@
+/**
+ * Apache Druid Database Provider (issue #265)
+ *
+ * SQL over Druid's HTTP query endpoint with no runtime dependency: every
+ * statement, catalog read and metric goes through the DruidTransport seam, so
+ * this file never names a request field, a header row or an envelope field, and
+ * `seam-guard.test.ts` fails the build if it starts to. The wire lives in
+ * `http-transport.ts`; the catalog and `sys` reads live in `introspect.ts`.
+ *
+ * It extends `SQLBaseProvider` rather than `BaseDatabaseProvider` because the
+ * dialect really is standard on the points the shared helpers care about -
+ * double-quoted identifiers and `LIMIT n OFFSET m` are both correct Druid SQL
+ * (live-verified on 37.0.0) - which is the case `docs/ADDING_A_PROVIDER.md` names
+ * ClickHouse for. Only `prepareQuery()` is overridden, for the one trap below.
+ *
+ * Five live-verified behaviours shape almost everything here, and each one
+ * produces a wrong answer or a hard failure if forgotten:
+ *
+ * - `OFFSET n` with no `LIMIT` is the one statement the shared limiter breaks:
+ * `... OFFSET 2 LIMIT 3` is a 400, "'OFFSET start LIMIT count' is not allowed
+ * under the current SQL conformance level". Such a statement is left alone.
+ * - The HTTP status misclassifies, in BOTH directions. `SELECT 1/0` is a 500 for
+ * an ordinary typo, so failures are classified by the CATEGORY Druid reports
+ * and never by the status.
+ * - Druid SQL has no statement that mutates: `UPDATE` and `DELETE` are not in the
+ * grammar and `INSERT`/`REPLACE` need the MSQ task engine. None of them is
+ * special-cased - the server's own message already names the alternative.
+ * - There is no maintenance operation SQL can reach, and no `sys.queries` catalog
+ * to read a cancellable query id from, so `supportsMaintenance` is false.
+ * - Positional parameters really execute, unlike ClickHouse (#264), so
+ * `query(sql, params)` binds them rather than refusing them.
+ */
+
+import { SQLBaseProvider } from "../sql-base";
+import {
+ AuthenticationError,
+ ConnectionError,
+ DatabaseConfigError,
+ QueryCancelledError,
+ QueryError,
+ TimeoutError,
+} from "@/lib/db/errors";
+import {
+ type ActiveSessionDetails,
+ type DatabaseConnection,
+ type DatabaseOverview,
+ type HealthInfo,
+ type IndexStats,
+ type MaintenanceResult,
+ type MaintenanceType,
+ type PerformanceMetrics,
+ type PreparedQuery,
+ type ProviderCapabilities,
+ type ProviderLabels,
+ type ProviderOptions,
+ type QueryPrepareOptions,
+ type QueryResult,
+ type SlowQueryStats,
+ type StorageStats,
+ type TableSchema,
+ type TableStats,
+} from "@/lib/db/types";
+import { analyzeQuery } from "@/lib/db/utils/query-limiter";
+import { DruidHttpTransport } from "./http-transport";
+import {
+ getActiveSessions as readActiveSessions,
+ getHealth as readHealth,
+ getIndexStats as readIndexStats,
+ getOverview as readOverview,
+ getPerformanceMetrics as readPerformanceMetrics,
+ getSchema as readSchema,
+ getSlowQueries as readSlowQueries,
+ getStorageStats as readStorageStats,
+ getTableStats as readTableStats,
+} from "./introspect";
+import {
+ DRUID_CLIENT_DEADLINE_GRACE_MS,
+ DRUID_TRANSPORT_FAILURE,
+ type DruidQueryResult,
+ type DruidTransport,
+ DruidTransportError,
+} from "./transport";
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/**
+ * The cheapest statement Druid will answer, used to prove the cluster at connect
+ * time rather than at the user's first query.
+ *
+ * Live-verified as valid: Druid plans it against a one-row inline datasource and
+ * names the column `EXPR$0`. It needs no datasource, so it also succeeds on a
+ * cluster that has not ingested anything yet.
+ */
+const CONNECT_PROBE_SQL = "SELECT 1";
+
+/**
+ * How much longer the client waits than the deadline it asked the server to
+ * honour.
+ *
+ * The two deadlines are not duplicates (the #264 lesson): the server-side one is
+ * what actually frees the cluster's resources, but it only starts counting once
+ * the statement was accepted, so it cannot bound a stalled connect, a TLS
+ * handshake, or a response body that stops arriving part-way. The client one is
+ * therefore deliberately the LATER of the two - a client that gave up first would
+ * abandon a query that is still running and report a bare abort instead of the
+ * 504 Druid was about to send, with its category and its message.
+ *
+ * Re-exported from the seam rather than declared here: the introspection reads set
+ * the same pair of deadlines and once used the same value for both, which lost that
+ * race on every slow catalog read. One definition is what keeps them in step.
+ */
+const CLIENT_DEADLINE_GRACE_MS = DRUID_CLIENT_DEADLINE_GRACE_MS;
+
+// ============================================================================
+// Pure helpers
+// ============================================================================
+
+/**
+ * The neutral transport result as the grid's row contract.
+ *
+ * Three things this does NOT do, each deliberate:
+ *
+ * - No mutation count to fall back on. Druid SQL has no statement that mutates,
+ * so the row count is the number of rows returned and a second number would
+ * always be zero - which reads as "nothing changed" rather than "impossible".
+ * - No fallback duration. The endpoint reports no timing whatsoever (no field in
+ * the body, nothing in the response metadata), so the transport's measurement
+ * of the exchange is the only number in existence; there is no server-reported
+ * value it could be preferred over.
+ * - No renaming of the declared columns. They arrive already unique, so a
+ * duplicated output name reaches the grid as `name` and `name (2)` instead of
+ * overwriting - which is exactly what the wire format was chosen for.
+ */
+function toQueryResult(result: DruidQueryResult): QueryResult {
+ return {
+ rows: result.rows,
+ fields: result.fieldNames ?? [],
+ rowCount: result.rows.length,
+ executionTime: Math.round(result.executionTimeMs),
+ };
+}
+
+// ============================================================================
+// Druid Provider
+// ============================================================================
+
+export class DruidProvider extends SQLBaseProvider {
+ private transport: DruidTransport | null = null;
+
+ constructor(config: DatabaseConnection, options: ProviderOptions = {}) {
+ super(config, options);
+ this.validate();
+ }
+
+ // ==========================================================================
+ // Provider metadata
+ // ==========================================================================
+
+ public override getCapabilities(): ProviderCapabilities {
+ return {
+ queryLanguage: "sql",
+ supportsExplain: true,
+ explainFormat: "druid-native",
+ supportsExternalQueryLimiting: true,
+ // Not merely unimplemented: CREATE is not in Druid's grammar at all.
+ // Live-verified, `CREATE TABLE t (id BIGINT)` answers 400 "Incorrect syntax
+ // near the keyword 'CREATE' at line 1, column 1" and the parser lists the
+ // statements it expected, with no form of CREATE among them. A datasource
+ // comes into existence by being ingested into.
+ supportsCreateTable: false,
+ // Nothing in MaintenanceType has a Druid analogue reachable from SQL:
+ // compaction and retention are Coordinator and task concerns, and `kill` is
+ // impossible for a second reason - there is no `sys.queries` catalog, so
+ // there is nowhere honest for a user to read a cancellable query id from.
+ supportsMaintenance: false,
+ maintenanceOperations: [],
+ // Druid's SQL endpoint has no URI convention (its JDBC driver addresses
+ // Avatica instead), and `http://` / `https://` are already claimed by
+ // ClickHouse in the shared parser. There is nothing for a user to paste.
+ supportsConnectionString: false,
+ // The Router's port. The Broker on 8082 serves the identical endpoint and
+ // needs no different configuration (live-verified); the Router is the
+ // default only because it also fronts the console.
+ defaultPort: 8888,
+ // The only two statements that can change a datasource. The native engine
+ // rejects both, so in practice a query never refreshes the schema - which is
+ // correct: a Druid schema changes through ingestion, not through the editor.
+ schemaRefreshPattern: "\\b(INSERT|REPLACE)\\b",
+ };
+ }
+
+ /**
+ * Datasource is the Druid word for a table, and the sidebar is where a user
+ * meets it.
+ *
+ * Everything else is inherited on purpose. A Druid row IS a row, so renaming it
+ * would only make the grid speak a dialect the cluster does not. The maintenance
+ * labels barely matter here - `supportsMaintenance` is false, so the Maintenance
+ * panel offers nothing - and they must still be strings, so they stay as they are
+ * rather than naming operations that do not exist.
+ */
+ public override getLabels(): ProviderLabels {
+ return {
+ ...super.getLabels(),
+ entityName: "Datasource",
+ entityNamePlural: "Datasources",
+ };
+ }
+
+ /**
+ * The inherited limiter is right for every statement but one.
+ *
+ * It appends `LIMIT n` at the very END, and Druid rejects `OFFSET n LIMIT n`
+ * outright: live-verified, `SELECT id FROM libredb_demo OFFSET 2 LIMIT 3`
+ * answers 400 "'OFFSET start LIMIT count' is not allowed under the current SQL
+ * conformance level". So a statement that ends in an OFFSET with no LIMIT is
+ * returned untouched, and the bias has to be "do not rewrite" for the same
+ * reason as ClickHouse's trailing-clause case: rewriting wrongly fails the
+ * query outright, while leaving it alone only returns more rows than asked for.
+ *
+ * `analyzeQuery` rather than a regex of this file's own: it already strips a
+ * trailing semicolon, and it already distinguishes the OFFSET that follows a
+ * LIMIT (which is the ordinary paginated form, and which the limiter leaves
+ * alone anyway) from the OFFSET that stands alone.
+ */
+ public override prepareQuery(query: string, options: QueryPrepareOptions = {}): PreparedQuery {
+ const prepared = super.prepareQuery(query, options);
+ const parsed = analyzeQuery(query);
+ if (!parsed.hasOffset || parsed.hasLimit) return prepared;
+
+ return { ...prepared, query, wasLimited: false };
+ }
+
+ // ==========================================================================
+ // Validation and lifecycle
+ // ==========================================================================
+
+ /**
+ * A host is the only requirement.
+ *
+ * No database is asked for, and the field is ignored even when the connection
+ * form sets one: `INFORMATION_SCHEMA.SCHEMATA` reports exactly one catalog,
+ * always named `druid`, so there is nothing to select. No connection string is
+ * accepted either - see `supportsConnectionString`.
+ */
+ public override validate(): void {
+ super.validate();
+ if (!this.config.host) {
+ throw new DatabaseConfigError("Druid requires a host", this.type);
+ }
+ }
+
+ public async connect(): Promise {
+ const transport = new DruidHttpTransport(this.config);
+
+ try {
+ // The cheapest statement there is, sent here so a wrong port, a proxy in
+ // front of the Broker, a Druid process that is not the query endpoint and a
+ // rejected credential all surface while the user is still looking at the
+ // connection form rather than at their first query.
+ await transport.query(CONNECT_PROBE_SQL, this.deadlines());
+ } catch (error) {
+ await transport.close();
+ const failure = this.describeConnectFailure(error);
+ this.setError(failure);
+ throw failure;
+ }
+
+ this.transport = transport;
+ this.setConnected(true);
+ }
+
+ public async disconnect(): Promise {
+ if (this.transport) {
+ await this.transport.close();
+ this.transport = null;
+ }
+ this.setConnected(false);
+ }
+
+ private describeConnectFailure(error: unknown): Error {
+ const mapped = this.mapDruidError(error);
+ // A rejected credential is not a connectivity problem, and saying so would
+ // send the user to check their host.
+ if (mapped instanceof AuthenticationError) return mapped;
+
+ return new ConnectionError(
+ `Failed to connect to Druid: ${mapped.message}`,
+ this.type,
+ this.config.host,
+ this.config.port,
+ );
+ }
+
+ private requireTransport(): DruidTransport {
+ this.ensureConnected();
+ // Assigned before setConnected(true) and cleared after setConnected(false),
+ // so a connected provider always has one.
+ return this.transport!;
+ }
+
+ /**
+ * Both halves of the deadline the provider advertises, for one statement.
+ *
+ * Set together everywhere, because either alone leaves a real hang unbounded:
+ * without the server's own deadline an abandoned query keeps burning cluster
+ * resources, and without the client's the body read is unbounded no matter what
+ * the server promised.
+ */
+ private deadlines(): { timeoutMs: number; clientDeadlineMs: number } {
+ return {
+ timeoutMs: this.queryTimeout,
+ clientDeadlineMs: this.queryTimeout + CLIENT_DEADLINE_GRACE_MS,
+ };
+ }
+
+ // ==========================================================================
+ // Query execution
+ // ==========================================================================
+
+ /**
+ * One statement, with its parameters bound.
+ *
+ * Unlike ClickHouse (#264), whose HTTP interface binds named parameters only
+ * and whose provider therefore refuses positional ones, `?` placeholders really
+ * execute on Druid (live-verified), so a parameterized statement is a
+ * first-class case here. An unmappable value is refused by the transport before
+ * anything leaves the process - sending something the server would misread is
+ * worse than failing.
+ *
+ * A write is not special-cased. `UPDATE` and `DELETE` are not in Druid's
+ * grammar and `INSERT`/`REPLACE` are rejected by the native engine, and in each
+ * case the server's own message names both the reason and the alternative
+ * ("consider using MSQ"), which is more useful than anything substituted here.
+ */
+ public async query(sql: string, params?: unknown[]): Promise {
+ const transport = this.requireTransport();
+
+ return this.trackQuery(async () => {
+ try {
+ const result = await transport.query(sql, { ...this.deadlines(), parameters: params ?? [] });
+ return toQueryResult(result);
+ } catch (error) {
+ throw this.mapDruidError(error, sql);
+ }
+ });
+ }
+
+ /**
+ * Normalized transport failure -> the provider error vocabulary, keyed on the
+ * CATEGORY Druid reported.
+ *
+ * The category rather than the HTTP status, because the status misclassifies in
+ * both directions (live-verified): `SELECT 1/0` is a 500 for a user's own typo,
+ * so reading 5xx as "the cluster is broken" would tell them something false. The
+ * category is present in both envelopes Druid uses and is a closed enum, which
+ * makes it the only discrete thing there is to branch on.
+ *
+ * The stand-in category means "nothing classified this" - a refused socket, an
+ * abort, a proxy's HTML page, a body that stopped arriving, a parameter refused
+ * before the request left. It is deliberately NOT read as "the cluster is
+ * unreachable": several of those are the user's own doing, so the shared
+ * message-based mapping decides, exactly as `clickhouse/index.ts` does when the
+ * server named no exception code.
+ */
+ private mapDruidError(error: unknown, sql?: string): Error {
+ if (!(error instanceof DruidTransportError) || error.category === DRUID_TRANSPORT_FAILURE) {
+ return this.mapError(error, sql);
+ }
+
+ if (error.is("UNAUTHORIZED") || error.is("FORBIDDEN")) {
+ return new AuthenticationError(error.message, this.type);
+ }
+ if (error.is("TIMEOUT")) {
+ return new TimeoutError(error.message, this.type, this.queryTimeout, sql);
+ }
+ if (error.is("CANCELED")) {
+ return new QueryCancelledError(error.message, this.type, sql);
+ }
+
+ // Every remaining category describes a statement the cluster rejected -
+ // INVALID_INPUT, UNSUPPORTED, NOT_FOUND, UNCATEGORIZED, RUNTIME_FAILURE - and
+ // Druid's own message is the most useful thing that can be shown for it.
+ return new QueryError(error.message, this.type, sql);
+ }
+
+ /** Run a catalog or monitoring read whose failures should surface as provider errors. */
+ private async guarded(operation: () => Promise): Promise {
+ try {
+ return await operation();
+ } catch (error) {
+ throw this.mapDruidError(error);
+ }
+ }
+
+ // ==========================================================================
+ // Schema
+ // ==========================================================================
+
+ /**
+ * The datasources and their columns, from `INFORMATION_SCHEMA` alone.
+ *
+ * `getSchemaList` and `getSchemaRelations` are deliberately NOT implemented.
+ * Both are optional and the client falls back to this method, and the split
+ * exists to keep a slow relationship read from blocking the table list - which
+ * Druid has neither half of: there are no user-defined indexes and no foreign
+ * keys, so a list would be byte-identical to this and a relations read would
+ * spend a round trip to answer two empty arrays per datasource.
+ */
+ public async getSchema(): Promise {
+ const transport = this.requireTransport();
+ return this.guarded(() => readSchema(transport));
+ }
+
+ // ==========================================================================
+ // Monitoring
+ // ==========================================================================
+
+ public async getOverview(): Promise {
+ const transport = this.requireTransport();
+ return this.guarded(() => readOverview(transport));
+ }
+
+ /**
+ * Zeroed, and it asks the cluster nothing.
+ *
+ * Druid's cache and query metrics reach a metrics emitter - statsd, Kafka, an
+ * HTTP endpoint, the log - and none of them reaches a SQL-readable table, so
+ * there is no statement to send and no connection to require: the answer cannot
+ * vary with either. The same is true of the two reads below.
+ */
+ public getPerformanceMetrics(): Promise {
+ return Promise.resolve(readPerformanceMetrics());
+ }
+
+ /** Empty: Druid keeps no query log anywhere, so a row cap has nothing to cap. */
+ public getSlowQueries(): Promise {
+ return Promise.resolve(readSlowQueries());
+ }
+
+ /** Empty: every dimension is indexed inside its segment, so no index OBJECT exists. */
+ public getIndexStats(): Promise {
+ return Promise.resolve(readIndexStats());
+ }
+
+ public async getActiveSessions(options: { limit?: number } = {}): Promise {
+ const transport = this.requireTransport();
+ return this.guarded(() => readActiveSessions(transport, options));
+ }
+
+ public async getTableStats(options: { schema?: string } = {}): Promise {
+ const transport = this.requireTransport();
+ return this.guarded(() => readTableStats(transport, options));
+ }
+
+ public async getStorageStats(): Promise {
+ const transport = this.requireTransport();
+ return this.guarded(() => readStorageStats(transport));
+ }
+
+ public async getHealth(): Promise {
+ const transport = this.requireTransport();
+ return this.guarded(() => readHealth(transport));
+ }
+
+ // ==========================================================================
+ // Maintenance
+ // ==========================================================================
+
+ /**
+ * Refused, with the reason.
+ *
+ * This exists because the interface obliges every provider to implement it, and
+ * it is reached only by a programmatic caller of the package: `/api/db/maintenance`
+ * checks `supportsMaintenance` and answers 400 before it would call this, so no
+ * HTTP request gets here. (The monitoring Tables tab does still render Analyze /
+ * Vacuum / Reindex per row for every provider - it never reads capabilities - so
+ * those buttons hit that 400. Pre-existing and shared with `libredb.ts`; see
+ * docs/providers/druid.md section 8.)
+ *
+ * Compaction and retention are Coordinator and task concerns, out of scope for
+ * #265, and a query cannot be killed through SQL at all because there is no
+ * catalog listing running queries to name one from.
+ */
+ public async runMaintenance(type: MaintenanceType): Promise {
+ throw new QueryError(
+ `Druid has no SQL-reachable maintenance operation, so "${type}" cannot run here. ` +
+ "Compaction and retention are Coordinator and task concerns, and Druid publishes no catalog of running queries to cancel one from.",
+ this.type,
+ );
+ }
+}
diff --git a/src/lib/db/providers/sql/druid/introspect.ts b/src/lib/db/providers/sql/druid/introspect.ts
new file mode 100644
index 00000000..9a01c2ab
--- /dev/null
+++ b/src/lib/db/providers/sql/druid/introspect.ts
@@ -0,0 +1,756 @@
+/**
+ * Druid schema introspection and monitoring (issue #265, design spec sections 9 and 10)
+ *
+ * Every read the provider makes that is not a user's own statement lives here,
+ * and all of them go through the transport seam, so this file names nothing from
+ * the wire - not a request parameter, not a response field. It owns no transport
+ * either: each function takes one, which is what lets the provider hand it a live
+ * transport and a test hand it nine rows.
+ *
+ * Druid publishes two catalogs and one set of `sys` tables, and the split
+ * between them is the load-bearing decision here:
+ *
+ * - `INFORMATION_SCHEMA.TABLES` / `.COLUMNS` -> the schema tree. Nothing else.
+ * - `sys.servers`, `sys.segments`, `sys.tasks` -> the monitoring panels.
+ *
+ * The schema tree deliberately does NOT touch `sys`. A cluster running
+ * `druid-basic-security` grants the `sys` schema separately from the catalogs, so
+ * a datasource row count read from `sys.segments` would make the whole sidebar
+ * fail on a cluster that merely declines to describe its servers. The
+ * per-datasource counts are in `getTableStats()`, where a denial costs one panel.
+ *
+ * Four live-verified shapes on 37.0.0 drive most of the code below, and each one
+ * silently produces wrong output if forgotten:
+ *
+ * 1. A grouping-less aggregate over zero matching rows returns ZERO ROWS, not a
+ * row of zeros: `SELECT COUNT(*) FROM sys.tasks WHERE status = 'RUNNING'`
+ * describes the column and returns no row at all when nothing is running. Every
+ * scalar read therefore has to survive an absent row, not just a null.
+ * 2. `sys.tasks.duration` is **-1** for a task that has not finished - which is
+ * every task the session read selects - so the elapsed time comes from
+ * `CURRENT_TIMESTAMP` minus `created_time` instead.
+ * 3. `sys.servers` reports `max_size = 0` for every process that is not a
+ * historical, so the usage division meets a zero in ordinary operation.
+ * 4. A large `SUM(size)` arrives as a decimal STRING, because the transport
+ * quotes integer literals outside the safe range before parsing (spec section
+ * 3). Both encodings reach these mappers.
+ *
+ * Druid's own dialect traps apply to every statement here: Calcite's reserved-word
+ * list is large and surprising (`SELECT 1 AS one` is a syntax error, and so is
+ * `SUM(size) AS rows`), so every alias is double-quoted, and so is every column
+ * whose name is a keyword. And no statement over a datasource may ORDER BY a
+ * non-time column - the reads below are over `sys` and `INFORMATION_SCHEMA`,
+ * where ordering is unrestricted, or over an aggregation, where it is allowed.
+ */
+
+import type {
+ ActiveSession,
+ ActiveSessionDetails,
+ DatabaseOverview,
+ HealthInfo,
+ IndexStats,
+ PerformanceMetrics,
+ SlowQueryStats,
+ StorageStats,
+ TableStats,
+} from "@/lib/db/types";
+import { formatBytes, formatDuration } from "@/lib/db/utils/pool-manager";
+import type { ColumnSchema, TableSchema } from "@/lib/types";
+import { DRUID_CLIENT_DEADLINE_GRACE_MS, type DruidRow, type DruidTransport, DruidTransportError } from "./transport";
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/**
+ * The one schema that holds datasources, and Druid's default schema.
+ *
+ * Because it IS the default, a `TableSchema.name` is the BARE datasource name and
+ * `SELECT * FROM "libredb_demo"` resolves - none of the qualification the other
+ * multi-schema providers need. `INFORMATION_SCHEMA.SCHEMATA` reports exactly one
+ * catalog, always `druid`, so there is nothing else to pin either.
+ */
+export const DRUID_SCHEMA_NAME = "druid";
+
+/**
+ * The mandatory primary timestamp of every datasource.
+ *
+ * It is the partitioning key, the sort key within a segment, and the only column
+ * Druid reports as `IS_NULLABLE = 'NO'` - which is why it, and only it, is the
+ * primary column of the schema tree.
+ */
+export const DRUID_TIME_COLUMN = "__time";
+
+/**
+ * Druid's own SQL type for a column its SQL layer cannot name.
+ *
+ * Never observed empty in `DATA_TYPE`, so this is the defensive branch - but when
+ * it fires, `OTHER` keeps the column list speaking Druid's vocabulary (spec
+ * section 2 observed it alongside `BIGINT`, `VARCHAR`, `TIMESTAMP` and `ARRAY`)
+ * instead of showing a blank type.
+ */
+const DRUID_UNKNOWN_COLUMN_TYPE = "OTHER";
+
+/** The value `IS_NULLABLE` carries for `__time`, and for nothing else today. */
+const NOT_NULLABLE = "NO";
+
+/**
+ * What a panel prints for something the cluster did not tell us.
+ *
+ * Used for the server version, the uptime and a task's submitter, all of which
+ * are genuinely unknown rather than empty: Druid records no submitter identity in
+ * `sys.tasks` (a `druid-basic-security` cluster puts it in the audit log), and a
+ * cluster that declines to describe its servers reports no version at all.
+ */
+export const DRUID_UNKNOWN_TEXT = "unknown";
+
+/**
+ * What `HealthInfo.cacheHitRatio` says on Druid.
+ *
+ * That field is a STRING, so it can say "not measured" - which is the truth.
+ * Druid's cache statistics reach a metrics emitter (statsd, Kafka, the log) and
+ * never a SQL-readable table, so any number here would be invented, and a low
+ * one would trip the cache-ratio threshold alert into reporting a fault that does
+ * not exist. `sqlite.ts` and `oracle.ts` already spell an unavailable ratio this
+ * way, so this is the repo's existing word for it rather than a new one.
+ */
+export const DRUID_CACHE_HIT_RATIO_UNAVAILABLE = "N/A";
+
+/**
+ * What an ingestion task calls itself in the sessions panel.
+ *
+ * Druid has no query sessions - no `sys.queries`, no connection catalog - so its
+ * tasks are the only activity it can describe, and returning nothing while a
+ * multi-hour ingestion runs would hide the one thing happening on the cluster.
+ * This is what stops the row being read as a client connection.
+ */
+export const DRUID_TASK_APPLICATION_NAME = "Druid ingestion task";
+
+/** Row cap for the sessions panel when the caller names none. */
+export const DRUID_DEFAULT_SESSION_LIMIT = 50;
+
+/** Row cap for the sessions the health summary embeds. */
+const DRUID_HEALTH_SESSION_LIMIT = 10;
+
+/**
+ * Server-side deadline for one catalog or `sys` read, in milliseconds.
+ *
+ * Set on BOTH halves of the exchange, which are not duplicates of each other: the
+ * server-side deadline is what actually frees the cluster's resources, while the
+ * client-side one also bounds a stalled connect and a response body that stops
+ * arriving part-way.
+ *
+ * The client half is this plus `DRUID_CLIENT_DEADLINE_GRACE_MS`, never this value
+ * itself. Equal deadlines are a race the client wins - the server's 504 still has
+ * to travel back - and winning it throws away Druid's classified `TIMEOUT` envelope
+ * in favour of a bare abort, which is the whole reason the provider distinguishes
+ * the two halves in the first place.
+ */
+export const DRUID_SYSTEM_READ_TIMEOUT_MS = 15_000;
+
+// ============================================================================
+// Catalog and sys SQL
+// ----------------------------------------------------------------------------
+// Hoisted to module scope and joined from single lines rather than written as
+// multi-line template literals inside the functions: bun's coverage instruments
+// the interior lines of a template literal in a function body as 0-hit in any
+// process that imports this file without calling that function, which the merged
+// lcov then reports as uncovered SQL. `clickhouse/introspect.ts` and
+// `postgres.ts` hoist their own for the same reason.
+//
+// Exported so the tests pin the exact statement each read sends. A test that
+// matched a substring would keep passing after the projection changed shape,
+// which is precisely the change that breaks a mapper.
+// ============================================================================
+
+/** The schema name is a compile-time constant, so inlining it as a literal is safe. */
+const DATASOURCE_SCHEMA_FILTER = `WHERE TABLE_SCHEMA = '${DRUID_SCHEMA_NAME}'`;
+
+/**
+ * The datasources, and only the datasources.
+ *
+ * `INFORMATION_SCHEMA.TABLES` also lists the four `INFORMATION_SCHEMA` views and
+ * the six `sys` tables as `SYSTEM_TABLE`, and a cluster with lookups or views
+ * carries a `lookup` / `view` schema besides. The schema predicate is the entire
+ * mechanism that keeps all of those out of the sidebar; they stay reachable by
+ * typing SQL, and the monitoring reads below query `sys` directly.
+ */
+export const DRUID_TABLE_LIST_SQL = [
+ 'SELECT TABLE_NAME AS "tableName"',
+ "FROM INFORMATION_SCHEMA.TABLES",
+ DATASOURCE_SCHEMA_FILTER,
+ "ORDER BY TABLE_NAME",
+].join(" ");
+
+/**
+ * The columns of every datasource, in declared order.
+ *
+ * `ORDINAL_POSITION` orders the read rather than appearing in it: it IS the
+ * declared column order, so it has no separate value to carry. `COLUMN_DEFAULT`
+ * is left out for a stronger reason - live-verified, it is the empty string for
+ * every column of every datasource, because a Druid column has no default: a
+ * dimension absent from an ingested row is null, and that is not a default the
+ * user could have chosen.
+ */
+export const DRUID_COLUMN_LIST_SQL = [
+ 'SELECT TABLE_NAME AS "tableName", COLUMN_NAME AS "columnName",',
+ 'DATA_TYPE AS "dataType", IS_NULLABLE AS "isNullable"',
+ "FROM INFORMATION_SCHEMA.COLUMNS",
+ DATASOURCE_SCHEMA_FILTER,
+ "ORDER BY TABLE_NAME, ORDINAL_POSITION",
+].join(" ");
+
+/**
+ * Who the cluster is and when it came up.
+ *
+ * Live `sys.servers` returns one row per process - the Coordinator and Overlord
+ * (which share an address), a Broker, a Router, a MiddleManager and each
+ * Historical - all reporting the same `version` but different start times. The
+ * Coordinator is the cluster's brain, so its start time is the one that reads as
+ * "the cluster came up"; the Broker is the fallback because a Broker-only
+ * deployment is a supported way to reach Druid (spec section 11) and would have
+ * no Coordinator row to offer.
+ *
+ * `CURRENT_TIMESTAMP` rides along so the uptime is a difference of two readings
+ * of the SAME clock. The editor's clock may be skewed from the cluster's, and
+ * `TIME_PARSE` cannot help here: live-verified, any expression over a `sys`
+ * column fails with "cannot translate call TIME_PARSE", so the subtraction has to
+ * happen in the mapper - from two values the server produced.
+ */
+export const DRUID_SERVER_IDENTITY_SQL = [
+ 'SELECT "version" AS "version", start_time AS "startTime", CURRENT_TIMESTAMP AS "serverNow"',
+ "FROM sys.servers",
+ "ORDER BY CASE server_type WHEN 'coordinator' THEN 0 WHEN 'broker' THEN 1 ELSE 2 END, server",
+ "LIMIT 1",
+].join(" ");
+
+/**
+ * Bytes held by the cluster's active segments.
+ *
+ * `is_active = 1` is not an optimisation. `sys.segments` describes every segment
+ * the metadata store knows about, and its own `is_overshadowed` and `is_published`
+ * columns exist because a segment can be superseded - by a compaction or a
+ * re-ingestion of the same interval - while its row is still there. Summing those
+ * would count the same rows and the same bytes twice, so a re-ingested datasource
+ * would appear to double in size.
+ */
+export const DRUID_SEGMENT_TOTALS_SQL = [
+ 'SELECT SUM("size") AS "sizeBytes"',
+ "FROM sys.segments",
+ "WHERE is_active = 1",
+].join(" ");
+
+/** How many datasources there are, which is the overview's table count. */
+export const DRUID_DATASOURCE_COUNT_SQL = [
+ 'SELECT COUNT(*) AS "datasourceCount"',
+ "FROM INFORMATION_SCHEMA.TABLES",
+ DATASOURCE_SCHEMA_FILTER,
+].join(" ");
+
+/**
+ * How many ingestion tasks are running, which is the nearest thing Druid has to
+ * an active connection count.
+ *
+ * Live-verified: this answers with the column-name row and NO data row when
+ * nothing is running, rather than a row holding 0 - so the mapper has to read an
+ * absent row as zero.
+ */
+export const DRUID_RUNNING_TASK_COUNT_SQL = [
+ 'SELECT COUNT(*) AS "runningTasks"',
+ "FROM sys.tasks",
+ "WHERE status = 'RUNNING'",
+].join(" ");
+
+/**
+ * The unfinished tasks, newest first.
+ *
+ * `duration` is deliberately NOT projected. Live-verified against a `noop` task
+ * submitted to the running cluster: `sys.tasks` reports `duration = -1` for a
+ * task that has not finished, which is every task this filter selects, so
+ * reporting that column would print "-1ms" on every row. Leaving it out of the
+ * projection is what stops someone reaching for it later; `CURRENT_TIMESTAMP`
+ * minus `created_time` is the age, and both values come from the server.
+ *
+ * `"type"` is quoted because it is a Calcite keyword.
+ */
+export const DRUID_ACTIVE_TASK_SQL = [
+ 'SELECT task_id AS "taskId", "type" AS "taskType", datasource AS "datasource",',
+ 'status AS "status", created_time AS "createdTime", CURRENT_TIMESTAMP AS "serverNow"',
+ "FROM sys.tasks",
+ "WHERE status IN ('RUNNING', 'PENDING')",
+ "ORDER BY created_time DESC",
+].join(" ");
+
+/** Rows and bytes per datasource. Active segments only, for the reason above. */
+export const DRUID_DATASOURCE_STATS_SQL = [
+ 'SELECT datasource AS "datasource", SUM(num_rows) AS "rowCount", SUM("size") AS "sizeBytes"',
+ "FROM sys.segments",
+ "WHERE is_active = 1",
+ "GROUP BY datasource",
+ 'ORDER BY SUM("size") DESC',
+].join(" ");
+
+/**
+ * Each historical's segment cache.
+ *
+ * The historicals are the only processes that hold segments: live-verified, the
+ * Coordinator, Overlord, Broker, Router and MiddleManager rows of this same table
+ * all report `curr_size` 0 and `max_size` 0, so listing them would fill the panel
+ * with rows describing no storage - and would divide by their zero capacity.
+ */
+export const DRUID_HISTORICAL_STORAGE_SQL = [
+ 'SELECT server AS "server", host AS "host", curr_size AS "currSize", max_size AS "maxSize"',
+ "FROM sys.servers",
+ "WHERE server_type = 'historical'",
+ "ORDER BY server",
+].join(" ");
+
+// ============================================================================
+// Types
+// ============================================================================
+
+/**
+ * The part of the seam these reads use.
+ *
+ * Narrower than `DruidTransport` on purpose: this module never opens or closes
+ * anything, so taking the whole transport would claim a lifecycle it does not
+ * have - and a test would have to supply a `close()` that means nothing.
+ */
+export type DruidQueryRunner = Pick;
+
+/** A column row placed against the datasource that owns it. */
+interface OwnedColumn {
+ table: string;
+ column: ColumnSchema;
+}
+
+// ============================================================================
+// Value readers
+// ============================================================================
+
+/** An identifier, or null for a row that cannot be placed and must be skipped. */
+function readIdentifier(value: unknown): string | null {
+ return typeof value === "string" && value !== "" ? value : null;
+}
+
+function readText(value: unknown): string {
+ return typeof value === "string" ? value : "";
+}
+
+/**
+ * A number the server reported, or 0 when it reported nothing usable.
+ *
+ * Both encodings are real and both can arrive in the same panel: a `LONG` is an
+ * unquoted JSON number, until it leaves the safe-integer range and the transport
+ * quotes it so `JSON.parse` cannot round it (spec section 3). A null - or an
+ * absent row, which is what a grouping-less aggregate over no matching rows
+ * produces here - is neither, and zero is the honest reading of both.
+ */
+function asNumber(value: unknown): number {
+ if (typeof value === "number") return value;
+ const parsed = Number(value);
+ return typeof value === "string" && value !== "" && Number.isFinite(parsed) ? parsed : 0;
+}
+
+/**
+ * An instant the server reported, or undefined when it reported nothing readable.
+ *
+ * Every timestamp Druid puts in a `sys` table or returns from
+ * `CURRENT_TIMESTAMP` is an ISO-8601 string in UTC (`2026-08-03T14:29:00.534Z`),
+ * live-verified - including the ones whose native type claims to be `LONG`, which
+ * is why the string is parsed rather than trusted as millis.
+ */
+function asInstant(value: unknown): Date | undefined {
+ if (typeof value !== "string" || value === "") return undefined;
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
+}
+
+function round2(value: number): number {
+ return Math.round(value * 100) / 100;
+}
+
+/**
+ * A percentage, or 0 when there is nothing to divide by. Zero rather than a
+ * flattering 100: a historical with no configured capacity must not look full,
+ * and it must not show NaN either.
+ */
+function percentOf(part: number, whole: number): number {
+ return whole > 0 ? round2((part / whole) * 100) : 0;
+}
+
+/** A row cap that is always a positive integer, so it can be inlined into SQL. */
+function rowLimit(limit: number | undefined, fallback: number): number {
+ const requested = Math.trunc(limit ?? fallback);
+ return requested > 0 ? requested : fallback;
+}
+
+/**
+ * How long ago something happened, according to the server's own clock.
+ *
+ * Zero when either reading is unusable - the honest answer, because the two
+ * timestamps are the only clock available and there is no other source to fall
+ * back on. Never negative: the row is one snapshot, but a cluster whose metadata
+ * store disagrees with its Coordinator can still order the pair backwards, and an
+ * age of "-2s" is worse than an age of 0.
+ */
+function elapsedMs(from: Date | undefined, until: Date | undefined): number {
+ if (from === undefined || until === undefined) return 0;
+ return Math.max(0, until.getTime() - from.getTime());
+}
+
+// ============================================================================
+// Reads
+// ============================================================================
+
+/**
+ * One catalog or `sys` read, degrading to no rows when the surface is not
+ * available here.
+ *
+ * `UNAUTHORIZED`, `FORBIDDEN` and `NOT_FOUND` are the three categories that mean
+ * "this surface does not exist for this user or this deployment", and all three
+ * are ordinary: a cluster running `druid-basic-security` grants the `sys` schema
+ * table by table, and a role may hold `INFORMATION_SCHEMA` and nothing else.
+ * Every OTHER failure propagates - an unplannable statement or a timeout hidden
+ * behind an empty panel is hidden forever, and the provider is the place that
+ * turns a propagated failure into a message the user sees.
+ */
+async function readRows(runner: DruidQueryRunner, sql: string): Promise {
+ try {
+ const result = await runner.query(sql, {
+ timeoutMs: DRUID_SYSTEM_READ_TIMEOUT_MS,
+ // Deliberately LATER than the server deadline, by the seam's own grace: equal
+ // deadlines are a race the client wins, and winning it replaces Druid's
+ // classified TIMEOUT envelope with a bare abort that says nothing useful.
+ clientDeadlineMs: DRUID_SYSTEM_READ_TIMEOUT_MS + DRUID_CLIENT_DEADLINE_GRACE_MS,
+ });
+ return result.rows;
+ } catch (error) {
+ if (error instanceof DruidTransportError && error.isMonitoringUnavailable()) return [];
+ throw error;
+ }
+}
+
+/** The single row of a scalar read, or null when the server returned none. */
+async function readRow(runner: DruidQueryRunner, sql: string): Promise {
+ const rows = await readRows(runner, sql);
+ return rows[0] ?? null;
+}
+
+// ============================================================================
+// Schema
+// ============================================================================
+
+function readColumn(row: DruidRow): OwnedColumn | null {
+ const table = readIdentifier(row.tableName);
+ const name = readIdentifier(row.columnName);
+ if (table === null || name === null) return null;
+
+ return {
+ table,
+ column: {
+ name,
+ // DATA_TYPE is the SQL type, which is the accurate one of the two Druid
+ // publishes: spec section 2 records the native type lying about
+ // CURRENT_TIMESTAMP (native LONG, actually an ISO timestamp) and about a
+ // boolean expression (native LONG, actually true).
+ type: readText(row.dataType) || DRUID_UNKNOWN_COLUMN_TYPE,
+ // Nullable is the safe reading of an unreadable flag: Druid marks every
+ // column but `__time` as YES, and a wrongly-mandatory marker on a column
+ // that accepts nulls is the more misleading of the two mistakes.
+ nullable: readText(row.isNullable) !== NOT_NULLABLE,
+ // NEVER primary, `__time` included.
+ //
+ // `__time` is tempting: it is mandatory, it is the partition and sort key, and
+ // it is the only column Druid reports as NOT NULL. But `isPrimary` means
+ // PRIMARY KEY to every consumer of this field, and a primary key is UNIQUE,
+ // which `__time` is not - live-verified on the fixture datasource, 50 rows
+ // carry 30 distinct `__time` values. Nothing in a Druid datasource is unique.
+ //
+ // Claiming otherwise is not cosmetic, because three places state it as fact:
+ // `sql-completions.ts` appends "(PK)" in autocomplete, `use-ai-chat.ts` puts
+ // ", PK" in the schema context the model reasons from, and
+ // `schema-diff/diff-engine.ts` reports "Primary key changed" - so two Druid
+ // datasources that differ only in this would diff as a key change. A
+ // partition/time-key concept distinct from a primary key is what this would
+ // need, and `ColumnSchema` has no such field.
+ isPrimary: false,
+ },
+ };
+}
+
+/**
+ * Bucket the column rows by the datasource that owns them. A row the decoder
+ * cannot place is dropped rather than fatal, so one malformed row costs one
+ * column instead of the whole tree.
+ */
+function groupColumns(rows: DruidRow[]): Map {
+ const grouped = new Map();
+ for (const row of rows) {
+ const owned = readColumn(row);
+ if (owned === null) continue;
+ const columns = grouped.get(owned.table) ?? [];
+ columns.push(owned.column);
+ grouped.set(owned.table, columns);
+ }
+ return grouped;
+}
+
+/**
+ * Every datasource, with its columns.
+ *
+ * `indexes` and `foreignKeys` are empty by construction, not by omission: Druid
+ * has no user-defined indexes - every dimension is indexed inside the segment -
+ * and no foreign keys anywhere, so there is no DDL that could declare either.
+ *
+ * A datasource whose segments have all been marked unused disappears from
+ * `INFORMATION_SCHEMA.TABLES` entirely (live-verified through the Coordinator's
+ * `markUnused`), so an empty result means "no datasources" and there is no
+ * empty-datasource row to render - the opposite of Couchbase's empty-collection
+ * case, and worth knowing before looking for one.
+ */
+export async function getSchema(runner: DruidQueryRunner): Promise {
+ const [tableRows, columnRows] = await Promise.all([
+ readRows(runner, DRUID_TABLE_LIST_SQL),
+ readRows(runner, DRUID_COLUMN_LIST_SQL),
+ ]);
+
+ const columns = groupColumns(columnRows);
+
+ return tableRows
+ .map((row) => readIdentifier(row.tableName))
+ .filter((name): name is string => name !== null)
+ .map((name) => ({
+ name,
+ columns: columns.get(name) ?? [],
+ indexes: [],
+ foreignKeys: [],
+ }));
+}
+
+// ============================================================================
+// Monitoring
+// ============================================================================
+
+/**
+ * What the cluster is and how much it holds.
+ *
+ * Four separate reads on purpose, not one joined statement: `sys` permissions are
+ * granted per table, so a cluster that declines `sys.tasks` must still report the
+ * datasource count `INFORMATION_SCHEMA` answers happily. Combining them would
+ * throw away every panel a restricted user CAN see.
+ */
+export async function getOverview(runner: DruidQueryRunner): Promise {
+ const [identity, segments, datasources, tasks] = await Promise.all([
+ readRow(runner, DRUID_SERVER_IDENTITY_SQL),
+ readRow(runner, DRUID_SEGMENT_TOTALS_SQL),
+ readRow(runner, DRUID_DATASOURCE_COUNT_SQL),
+ readRow(runner, DRUID_RUNNING_TASK_COUNT_SQL),
+ ]);
+
+ const startTime = asInstant(identity?.startTime);
+ const serverNow = asInstant(identity?.serverNow);
+ const sizeBytes = asNumber(segments?.sizeBytes);
+ const clockRead = startTime !== undefined && serverNow !== undefined;
+
+ return {
+ version: readIdentifier(identity?.version) ?? DRUID_UNKNOWN_TEXT,
+ // Unknown rather than "0ms" when either reading is missing: an uptime of zero
+ // claims the cluster booted this instant, which is a statement the server
+ // never made. The branch is on the two readings rather than on their
+ // difference, so a cluster that genuinely came up this millisecond still
+ // reports a measured 0.
+ uptime: clockRead ? formatDuration(elapsedMs(startTime, serverNow)) : DRUID_UNKNOWN_TEXT,
+ startTime,
+ // Druid has no query sessions to count, so a running ingestion task is the
+ // only activity it can report as an occupied slot.
+ activeConnections: asNumber(tasks?.runningTasks),
+ // Zero means "no limit published", which is the truth: Druid has no connection
+ // pool, and its task-slot capacity (`druid.worker.capacity`) is an Overlord API
+ // reading rather than anything in the `sys` schema, so this provider cannot see
+ // it. `mssql.ts` uses the same encoding and comments it the same way. The
+ // Connections card treats a zero limit as "no limit published" rather than
+ // dividing by it - it used to render the literal "NaN% used" - so no invented
+ // ceiling is needed to keep the panel sane. There are likewise no index objects.
+ maxConnections: 0,
+ databaseSize: formatBytes(sizeBytes),
+ databaseSizeBytes: sizeBytes,
+ tableCount: asNumber(datasources?.datasourceCount),
+ indexCount: 0,
+ };
+}
+
+/**
+ * Empty, because there is nothing to read - and empty specifically rather than
+ * zeroed.
+ *
+ * Druid's cache, query and ingestion metrics all reach a metrics emitter -
+ * statsd, Kafka, an HTTP endpoint, the log - and none of them reaches a
+ * SQL-readable table, so every field here would be a number the editor made up.
+ *
+ * `cacheHitRatio` used to carry a "neutral" 0, which was not neutral at all:
+ * `DEFAULT_THRESHOLDS` scores that metric `direction: "below"` with
+ * `critical: 80`, so a 0 made every healthy Druid cluster render a red critical
+ * cache fault - the exact alert-for-a-fault-that-does-not-exist that
+ * `DRUID_CACHE_HIT_RATIO_UNAVAILABLE` above was written to avoid. The field is now
+ * optional in `PerformanceMetrics`, and the monitoring tabs already default the
+ * THRESHOLD to a healthy 100 when it is absent, so omission is both honest and
+ * the one value that raises no alarm.
+ *
+ * Every other metric was already optional. A zero would read as a measurement of
+ * zero, which is a different and false claim, so none of them are invented either.
+ */
+export function getPerformanceMetrics(): PerformanceMetrics {
+ return {};
+}
+
+/**
+ * Empty, because Druid has no query log.
+ *
+ * Not a switched-off feature and not a permission gate: there is no `sys` table,
+ * no endpoint and no file holding finished queries, so unlike ClickHouse's
+ * `system.query_log` or Postgres's `pg_stat_statements` there is nothing to ask.
+ * No statement is sent to discover that.
+ */
+export function getSlowQueries(): SlowQueryStats[] {
+ return [];
+}
+
+/**
+ * Empty, because no user-defined indexes exist.
+ *
+ * Druid indexes every dimension by construction, but those indexes live inside a
+ * segment with no name, no size and no usage counter of their own, so there is
+ * nothing an index row could describe. The schema tree reports the same thing
+ * from the other side, with `indexes: []`.
+ */
+export function getIndexStats(): IndexStats[] {
+ return [];
+}
+
+/**
+ * The unfinished ingestion tasks, described as sessions.
+ *
+ * Druid has no query sessions at all - no `sys.queries`, no connection catalog -
+ * so this is the one honest thing the panel can show. The alternative, an empty
+ * list, would report a quiet cluster while a multi-hour ingestion saturates the
+ * MiddleManagers, and `applicationName` is what keeps the row from being read as
+ * a client connection.
+ */
+export async function getActiveSessions(
+ runner: DruidQueryRunner,
+ options: { limit?: number } = {},
+): Promise {
+ const limit = rowLimit(options.limit, DRUID_DEFAULT_SESSION_LIMIT);
+ const rows = await readRows(runner, `${DRUID_ACTIVE_TASK_SQL} LIMIT ${limit}`);
+
+ return rows.map((row) => {
+ const queryStart = asInstant(row.createdTime);
+ const durationMs = elapsedMs(queryStart, asInstant(row.serverNow));
+
+ return {
+ pid: readText(row.taskId),
+ // `sys.tasks` records no submitter identity - a druid-basic-security
+ // cluster puts it in the audit log - and borrowing the connection's user
+ // would credit it with a task it did not submit.
+ user: DRUID_UNKNOWN_TEXT,
+ // Live-verified: a task with no datasource, such as a `noop` task, reports
+ // the literal string "none" rather than null.
+ database: readText(row.datasource),
+ applicationName: DRUID_TASK_APPLICATION_NAME,
+ state: readText(row.status),
+ // The task TYPE - `index_parallel`, `compact`, `kill` - which is the
+ // closest thing a task has to a statement.
+ query: readText(row.taskType),
+ queryStart,
+ duration: formatDuration(durationMs),
+ durationMs,
+ };
+ });
+}
+
+/**
+ * Rows and bytes per datasource, from the active segments.
+ *
+ * The schema filter is answered without a round trip when it names anything but
+ * `druid`: that is the only schema holding datasources, so any other value
+ * selects nothing, and a predicate that can never match is slower and less
+ * obviously right than not asking.
+ */
+export async function getTableStats(
+ runner: DruidQueryRunner,
+ options: { schema?: string } = {},
+): Promise {
+ if (options.schema !== undefined && options.schema !== DRUID_SCHEMA_NAME) return [];
+
+ const rows = await readRows(runner, DRUID_DATASOURCE_STATS_SQL);
+
+ return rows.map((row) => {
+ const sizeBytes = asNumber(row.sizeBytes);
+
+ return {
+ schemaName: DRUID_SCHEMA_NAME,
+ tableName: readText(row.datasource),
+ rowCount: asNumber(row.rowCount),
+ // Segment bytes are all the bytes a datasource has: the dimension indexes
+ // are inside the segment, so the table size and the total size are the same
+ // number rather than one being the other plus an index total. The optional
+ // index size stays absent for the same reason - a zero would be a
+ // measurement of something that does not exist.
+ tableSize: formatBytes(sizeBytes),
+ tableSizeBytes: sizeBytes,
+ totalSize: formatBytes(sizeBytes),
+ totalSizeBytes: sizeBytes,
+ };
+ });
+}
+
+/** Each historical's segment cache, and how full it is. */
+export async function getStorageStats(runner: DruidQueryRunner): Promise {
+ const rows = await readRows(runner, DRUID_HISTORICAL_STORAGE_SQL);
+
+ return rows.map((row) => {
+ const sizeBytes = asNumber(row.currSize);
+
+ return {
+ name: readText(row.server),
+ location: readText(row.host),
+ size: formatBytes(sizeBytes),
+ sizeBytes,
+ // `max_size` is 0 for every process that is not a historical (live-verified
+ // on the Coordinator, Overlord, Broker, Router and MiddleManager rows of
+ // this same table), and a historical with no configured segment cache
+ // reports it too, so the zero denominator is real data rather than a
+ // defensive guess.
+ usagePercent: percentOf(sizeBytes, asNumber(row.maxSize)),
+ };
+ });
+}
+
+/** The health summary, composed from the reads that have a source. */
+export async function getHealth(runner: DruidQueryRunner): Promise {
+ const [overview, sessions] = await Promise.all([
+ getOverview(runner),
+ getActiveSessions(runner, { limit: DRUID_HEALTH_SESSION_LIMIT }),
+ ]);
+
+ const activeSessions: ActiveSession[] = sessions.map((session) => ({
+ pid: session.pid,
+ user: session.user,
+ database: session.database,
+ state: session.state,
+ query: session.query,
+ duration: session.duration,
+ }));
+
+ return {
+ activeConnections: overview.activeConnections,
+ databaseSize: overview.databaseSize,
+ cacheHitRatio: DRUID_CACHE_HIT_RATIO_UNAVAILABLE,
+ // Written out rather than mapped from `getSlowQueries()`: the summary needs
+ // the narrower `SlowQuery` shape, and a mapper over a list that is always
+ // empty would be a body no test can reach. Same reason, stated once there.
+ slowQueries: [],
+ activeSessions,
+ };
+}
diff --git a/src/lib/db/providers/sql/druid/transport.ts b/src/lib/db/providers/sql/druid/transport.ts
new file mode 100644
index 00000000..6a30ef79
--- /dev/null
+++ b/src/lib/db/providers/sql/druid/transport.ts
@@ -0,0 +1,292 @@
+/**
+ * Druid transport seam (issue #265, design spec section 0)
+ *
+ * Provider logic never talks to the cluster directly. It goes through this
+ * interface, so adopting Druid's Avatica JDBC driver later - or any client that
+ * is not the SQL HTTP endpoint - is an additive change (one new file
+ * implementing the same contract) rather than a rewrite of the provider, the
+ * introspection and the explain strategy. This is the sibling of the ClickHouse
+ * seam in `providers/sql/clickhouse/transport.ts`.
+ *
+ * The types below are deliberately NEUTRAL: they describe what a caller needs,
+ * not how one source encodes it. Everything Druid's HTTP endpoint invented - the
+ * result-format flags, the header rows it prepends, its two error envelopes, its
+ * query context - stays inside `http-transport.ts`, and `seam-guard.test.ts`
+ * fails the build when that vocabulary appears anywhere else in the provider
+ * directory. That is what keeps the "one new file" estimate for a second
+ * implementation true.
+ *
+ * Apart from the error type this file is purely structural: no I/O.
+ */
+
+/**
+ * One result row.
+ *
+ * Unlike Couchbase SQL++ - where `SELECT RAW` yields bare scalars and the
+ * equivalent declaration is a known unsoundness - Druid SQL has no projection
+ * that produces a non-object row, so this type is honest rather than a cast.
+ *
+ * The rows are nonetheless REBUILT by the implementation rather than parsed as
+ * objects: spec section 2 requires the wire's array form, whose rows are
+ * positional, because the object form silently drops duplicate columns. That is
+ * an encoding detail, so it does not reach this type - but it is the reason the
+ * invariant on `fieldNames` below is the implementation's obligation.
+ */
+export type DruidRow = Record;
+
+/**
+ * Normalized outcome of one statement.
+ *
+ * There is deliberately NO mutation count. Spec section 8, all live-verified on
+ * 37.0.0: Druid SQL has no statement that mutates. `UPDATE` and `DELETE` are not
+ * in the grammar at all, and `INSERT`/`REPLACE` are rejected by the native
+ * engine ("not supported by requested SQL engine [native], consider using
+ * MSQ"). A count here could therefore only ever be zero, and a field that is
+ * always zero reads as "nothing changed" rather than "this cannot happen".
+ */
+export interface DruidQueryResult {
+ rows: DruidRow[];
+
+ /**
+ * Column order as the server declared it, or null when the source could not
+ * describe the rows. Declared order matters and is authoritative here, which
+ * it is not for object keys: an all-null first row cannot be trusted to carry
+ * every key.
+ *
+ * INVARIANT the implementation must uphold: these names are UNIQUE and are
+ * exactly the key set of every row. Live-verified (spec section 2),
+ * `SELECT 1 AS c, 2 AS c` really declares `["c","c"]`, and a duplicate cannot
+ * survive into a `DruidRow` - so the loss would happen BEFORE this seam unless
+ * the implementation disambiguates the repeat while it rebuilds the row. The
+ * seam requires uniqueness and leaves the spelling to the implementation.
+ *
+ * The originally declared names are not carried alongside: a column can only
+ * be labelled with the key its value is looked up by (`QueryResult.fields` is
+ * a `string[]` of row keys), so a second list would be a field with no
+ * consumer that every future implementation would still have to produce.
+ */
+ fieldNames: string[] | null;
+
+ /**
+ * The SQL type per column - `BIGINT`, `VARCHAR`, `TIMESTAMP`, `BOOLEAN`,
+ * `ARRAY`, `OTHER`, ... - keyed by the name in `fieldNames`.
+ *
+ * This is the type the UI labels a column with. Spec section 2, live-verified:
+ * it is the accurate one of the two, because the native type LIES for
+ * `CURRENT_TIMESTAMP` (native `LONG`, actually an ISO timestamp string) and for
+ * `(1 = 1)` (native `LONG`, actually `true`).
+ *
+ * A `Record` is lossless only because `fieldNames` is unique; that invariant is
+ * what keeps a duplicated output column's type from being overwritten too.
+ * Null when the source could not describe the rows.
+ */
+ sqlTypes: Record | null;
+
+ /**
+ * Druid's own type name per column - `LONG`, `DOUBLE`, `FLOAT`, `STRING`,
+ * `ARRAY`, `COMPLEX` - keyed the same way.
+ *
+ * Kept alongside the SQL type rather than instead of it: it is the vocabulary a
+ * Druid user reads in the web console and in a segment's dimension list, so
+ * dropping it would make the editor describe columns in words the user's other
+ * tools never use. It is carried, not trusted - see `sqlTypes`. Null when the
+ * source could not describe the rows.
+ */
+ nativeTypes: Record | null;
+
+ /**
+ * How long the exchange took, in milliseconds.
+ *
+ * Measured by the implementation rather than reported: live-verified on 37.0.0,
+ * the SQL endpoint answers with the rows and nothing else - no timing anywhere
+ * in the body or in the response metadata, only query ids. Any implementation
+ * can time its own exchange, so the field stays neutral; what it must not do is
+ * pretend the number came from the server.
+ */
+ executionTimeMs: number;
+}
+
+/** Per-statement options. */
+export interface DruidQueryOptions {
+ /**
+ * Server-side deadline for this one statement, in milliseconds.
+ *
+ * Druid takes a per-query deadline of its own (spec section 6, verified: a
+ * 1 ms deadline answers 504 with `category: TIMEOUT` on a statement that
+ * otherwise takes milliseconds). Asking the server to stop is what actually
+ * frees the cluster's resources - abandoning the request client-side leaves the
+ * query running - which is why this is a distinct knob and not a duplicate of
+ * the one below. Neutral: every Druid client can set a query deadline.
+ */
+ timeoutMs?: number;
+
+ /**
+ * Wall-clock deadline for the whole exchange, client side.
+ *
+ * Not a duplicate of `timeoutMs`: a server-side deadline only starts counting
+ * once the server has accepted the statement, so it cannot bound a stalled
+ * connect, a TLS handshake, or a response body that stops arriving part-way
+ * (the #264 lesson - headers can arrive promptly and the stream stall
+ * afterwards). The provider advertises a query timeout, and this is what makes
+ * that promise cover the transport rather than only the query.
+ */
+ clientDeadlineMs?: number;
+
+ /**
+ * Values for the `?` placeholders in the statement, in order.
+ *
+ * Spec section 13, live-verified: positional parameters really execute on
+ * Druid, so unlike ClickHouse (#264, where the endpoint has no equivalent and
+ * the provider throws) a parameterized statement is a first-class case here.
+ * Values stay raw JS: mapping each one onto the type name the server expects is
+ * an encoding concern, and an unmappable value is the implementation's error to
+ * raise - sending something the server would misread is worse than refusing it.
+ */
+ parameters?: readonly unknown[];
+}
+
+/**
+ * How much later than `timeoutMs` a caller should set `clientDeadlineMs`.
+ *
+ * The two deadlines above are a race unless the client one is deliberately the
+ * later: if both fire at the same instant the client abort wins, because the
+ * server's own 504 still has to travel back over the network. Losing that race
+ * costs real information - the abort surfaces a bare transport failure instead of
+ * Druid's classified `TIMEOUT` envelope with its message - so every caller that
+ * sets both must add this grace to the client half. Exported so the provider and
+ * the introspection reads cannot drift apart on it.
+ */
+export const DRUID_CLIENT_DEADLINE_GRACE_MS = 5_000;
+
+/**
+ * The seam itself.
+ *
+ * There is no management method next to `query()`, unlike Couchbase: every Druid
+ * metric, task and storage statistic the provider needs is a `sys.*` table
+ * reachable by SQL (spec section 10), so a second entry point would be a
+ * permanent HTTP dependency for nothing.
+ */
+export interface DruidTransport {
+ /** Widen when a non-HTTP implementation appears. */
+ readonly kind: "http";
+ query(sql: string, opts?: DruidQueryOptions): Promise;
+ close(): Promise;
+}
+
+/**
+ * The categories Druid classifies a failure into, and the only thing the
+ * provider branches on.
+ *
+ * Read back from the live cluster (37.0.0) rather than transcribed from
+ * documentation: spec section 5 records both envelopes it observed, and
+ * `category` is the one field present in both - the modern `druidException`
+ * shape and the legacy wrapper a data server produces. Exported frozen so the
+ * provider, the transport and the tests share one definition instead of
+ * repeating literals that drift apart.
+ *
+ * Key and value coincide because the category IS the token the server sends;
+ * the table exists so a call site indexes it instead of spelling the token, and
+ * so a category that Druid ever renames stays a one-line change here.
+ */
+export const DRUID_ERROR_CATEGORIES = Object.freeze({
+ /** The statement was wrong: unknown datasource, syntax, an unplannable query. */
+ INVALID_INPUT: "INVALID_INPUT",
+ UNAUTHORIZED: "UNAUTHORIZED",
+ FORBIDDEN: "FORBIDDEN",
+ CAPACITY_EXCEEDED: "CAPACITY_EXCEEDED",
+ CANCELED: "CANCELED",
+ RUNTIME_FAILURE: "RUNTIME_FAILURE",
+ TIMEOUT: "TIMEOUT",
+ /** A statement Druid's native engine does not implement, such as `UPDATE`. */
+ UNSUPPORTED: "UNSUPPORTED",
+ NOT_FOUND: "NOT_FOUND",
+ /**
+ * Druid's own bucket for a failure it did not classify. Live-verified and the
+ * reason the HTTP status must never be trusted on its own: `SELECT 1/0`
+ * answers HTTP 500 with `persona: "ADMIN"` and this category for what is an
+ * ordinary user mistake (spec section 5, point 3).
+ */
+ UNCATEGORIZED: "UNCATEGORIZED",
+ /** An internal invariant Druid checks defensively; a bug, not a user error. */
+ DEFENSIVE: "DEFENSIVE",
+} as const);
+
+export type DruidErrorCategory = keyof typeof DRUID_ERROR_CATEGORIES;
+
+/**
+ * Stand-in for a failure that never reached the server, or reached it and came
+ * back with nothing to classify - a refused socket, an aborted request, a
+ * proxy's HTML error page, an empty body (spec section 5, point 4).
+ *
+ * It is deliberately NOT a member of `DRUID_ERROR_CATEGORIES`: reusing one of
+ * Druid's own categories - `UNCATEGORIZED` most temptingly, since that is what
+ * it means in English - would let a caller believe the server had spoken and
+ * classified the failure when nothing ever answered. It stands in for the
+ * `errorCode` as well, and its shouty spelling cannot be mistaken for one of
+ * Druid's camelCase codes (`invalidInput`, `general`, `legacyQueryException`).
+ */
+export const DRUID_TRANSPORT_FAILURE = "TRANSPORT_FAILURE";
+
+/**
+ * Categories that mean "this surface is not available here", as opposed to
+ * "this request was wrong".
+ *
+ * Spec section 5: a cluster running `druid-basic-security`, a role without the
+ * permissions the `sys` schema needs, and a build where the table is simply
+ * absent are all ordinary, expected configurations, so every monitoring read
+ * degrades to an empty panel on these three categories and only these three.
+ * Anything else is the user's own mistake and must keep propagating - hiding an
+ * unplannable query behind an empty panel is the failure mode this list exists
+ * to prevent.
+ */
+const MONITORING_UNAVAILABLE_CATEGORIES: readonly string[] = [
+ DRUID_ERROR_CATEGORIES.UNAUTHORIZED,
+ DRUID_ERROR_CATEGORIES.FORBIDDEN,
+ DRUID_ERROR_CATEGORIES.NOT_FOUND,
+];
+
+/**
+ * Normalized transport failure.
+ *
+ * `category` is the only field callers should branch on, and it is typed as a
+ * plain string rather than the closed union so a category a later Druid adds
+ * arrives verbatim instead of being flattened onto `UNCATEGORIZED` - which is
+ * itself a real category. `is()` takes the union, so a call site still cannot
+ * misspell one.
+ *
+ * `errorCode` is secondary (`invalidInput`, `general`, `legacyQueryException`):
+ * it is coarser than the category, and the same code arrives with different
+ * categories. `persona` is Druid's guess at WHO should read the message
+ * (`USER`, `OPERATOR`, `ADMIN`) and is carried for display only - never
+ * branched on, because live evidence shows it is wrong in exactly the case that
+ * matters: `SELECT 1/0` is reported as `ADMIN`.
+ *
+ * The message is always the envelope's `errorMessage`, resolved by the
+ * implementation. Spec section 5, point 1: the envelope's `error` field is a
+ * discriminator whose value is the literal string `druidException`, so showing
+ * it would print that to the user.
+ */
+export class DruidTransportError extends Error {
+ constructor(
+ message: string,
+ public readonly category: string = DRUID_TRANSPORT_FAILURE,
+ public readonly errorCode: string = DRUID_TRANSPORT_FAILURE,
+ public readonly persona: string | null = null,
+ ) {
+ super(message);
+ this.name = "DruidTransportError";
+ // Subclassing a builtin loses the prototype under a downlevel emit, which
+ // would make every instanceof check in the provider quietly fall through.
+ Object.setPrototypeOf(this, DruidTransportError.prototype);
+ }
+
+ /** True when this failure is the named one. Keyed by name so no call site spells a token. */
+ is(category: DruidErrorCategory): boolean {
+ return this.category === DRUID_ERROR_CATEGORIES[category];
+ }
+
+ /** True when a monitoring read should degrade to empty instead of surfacing this. */
+ isMonitoringUnavailable(): boolean {
+ return MONITORING_UNAVAILABLE_CATEGORIES.includes(this.category);
+ }
+}
diff --git a/src/lib/db/types.ts b/src/lib/db/types.ts
index 13d88213..879599db 100644
--- a/src/lib/db/types.ts
+++ b/src/lib/db/types.ts
@@ -90,7 +90,13 @@ export interface MaintenanceResult {
* Each id selects one strategy module in `src/lib/explain`. Extended per
* provider as explain support lands.
*/
-export type ExplainFormat = "postgres-json" | "mysql-json" | "sqlite-queryplan" | "couchbase-json" | "clickhouse-json";
+export type ExplainFormat =
+ | "postgres-json"
+ | "mysql-json"
+ | "sqlite-queryplan"
+ | "couchbase-json"
+ | "clickhouse-json"
+ | "druid-native";
export interface ProviderCapabilities {
queryLanguage: "sql" | "json";
@@ -334,8 +340,20 @@ export interface DatabaseOverview {
* Performance metrics for the database
*/
export interface PerformanceMetrics {
- /** Cache hit ratio as percentage (0-100) */
- cacheHitRatio: number;
+ /**
+ * Cache hit ratio as percentage (0-100), or absent when the engine does not
+ * measure one.
+ *
+ * Optional because "not measured" and "measured as zero" are different facts
+ * and only one of them should raise an alarm. `DEFAULT_THRESHOLDS` treats this
+ * metric as `direction: "below"` with `critical: 80`, so a provider that has no
+ * ratio to report and substitutes a neutral-looking `0` makes every healthy
+ * cluster show a red critical cache fault. Apache Druid is that case - its cache
+ * statistics reach a metrics emitter and never a SQL-readable table - and the
+ * monitoring tabs already read this field as optional, defaulting the THRESHOLD
+ * to a healthy 100 when it is absent.
+ */
+ cacheHitRatio?: number;
/** Transactions per second */
transactionsPerSecond?: number;
/** Queries per second */
diff --git a/src/lib/db/utils/json-integers.ts b/src/lib/db/utils/json-integers.ts
new file mode 100644
index 00000000..9de7a460
--- /dev/null
+++ b/src/lib/db/utils/json-integers.ts
@@ -0,0 +1,147 @@
+/**
+ * 64-bit integers in JSON text (issue #265, design spec section 3)
+ *
+ * `JSON.parse` has no exact form for an integer wider than 2^53: it rounds one
+ * silently, with no error at all. A server that sends a 64-bit column as an
+ * UNQUOTED JSON number therefore cannot be parsed by the runtime alone, and the
+ * only place left to fix it is the raw text, before it is parsed.
+ *
+ * This lives under `db/utils` rather than in the provider that discovered it
+ * because TWO parsers need it and one of them may not import from a provider
+ * directory:
+ *
+ * - `providers/sql/druid/http-transport.ts` runs it over the response body.
+ * - `lib/explain/druid-native.ts` runs it over the EXPLAIN plan columns, which
+ * arrive as JSON *text* inside that body. The outer pass correctly leaves their
+ * digits alone - they sit inside a string literal - so the INNER parse is a
+ * second, independent chance to round the same value. An explain strategy
+ * importing from a provider directory would tie the registry to that provider
+ * (the rule `clickhouse-json.ts` records), which is what makes a neutral home
+ * the requirement rather than the tidier option.
+ *
+ * Nothing here is Druid-specific: it is a property of JSON text and of
+ * `JSON.parse`. ClickHouse avoids needing it only because it has a server-side
+ * setting (`output_format_json_quote_64bit_integers`, #264) and Druid has none.
+ */
+
+/** `Number.MAX_SAFE_INTEGER` as digits: the widest integer JSON.parse keeps exact. */
+const SAFE_DIGITS = String(Number.MAX_SAFE_INTEGER);
+
+function isDigit(char: string | undefined): boolean {
+ return char !== undefined && char >= "0" && char <= "9";
+}
+
+/**
+ * True when a digit run cannot survive `JSON.parse`.
+ *
+ * Compared as DIGITS rather than as numbers, because converting it to a number to
+ * find out whether converting it to a number is safe is the bug. Equal-length
+ * digit strings compare numerically, and the safe range is symmetric
+ * (`MIN_SAFE_INTEGER` is `-MAX_SAFE_INTEGER`), so the sign never matters.
+ */
+function exceedsSafeRange(digits: string): boolean {
+ if (digits.length !== SAFE_DIGITS.length) return digits.length > SAFE_DIGITS.length;
+ return digits > SAFE_DIGITS;
+}
+
+/** Index just past the string literal that starts at `start`, or the end of the text. */
+function endOfString(text: string, start: number): number {
+ let index = start + 1;
+ while (index < text.length) {
+ const char = text[index];
+ // An escape consumes the next character whatever it is. This is what keeps
+ // `\"` from being read as the end of the string - the desync that would put
+ // the scanner OUTSIDE a string it is still inside, and rewrite the digits
+ // that follow into invalid JSON.
+ if (char === "\\") {
+ index += 2;
+ continue;
+ }
+ index += 1;
+ if (char === '"') break;
+ }
+ // An unterminated string means a truncated body (a cancelled Druid query really
+ // does cut its own body mid-value): the rest is treated as string content, so
+ // nothing is rewritten and JSON.parse is left to report the real problem.
+ return index;
+}
+
+interface NumberSpan {
+ end: number;
+ /** The digits before any fraction or exponent, without the sign. */
+ digits: string;
+ /** False as soon as a fraction or an exponent appears: a double either way. */
+ integral: boolean;
+}
+
+/** The JSON number starting at `start`, or null when nothing there starts one. */
+function numberAt(text: string, start: number): NumberSpan | null {
+ let index = text[start] === "-" ? start + 1 : start;
+
+ const digitsStart = index;
+ while (isDigit(text[index])) index += 1;
+ if (index === digitsStart) return null;
+ const digits = text.slice(digitsStart, index);
+
+ let integral = true;
+ if (text[index] === ".") {
+ integral = false;
+ index += 1;
+ while (isDigit(text[index])) index += 1;
+ }
+ if (text[index] === "e" || text[index] === "E") {
+ integral = false;
+ index += 1;
+ if (text[index] === "+" || text[index] === "-") index += 1;
+ while (isDigit(text[index])) index += 1;
+ }
+
+ return { end: index, digits, integral };
+}
+
+/**
+ * Quote every integer literal `JSON.parse` would round, leaving everything else
+ * byte-for-byte alone.
+ *
+ * Live-verified on real ingested data: a Druid BIGINT column holding
+ * 9007199254740993 (2^53 + 1) comes back as the UNQUOTED JSON number
+ * 9007199254740993, and `JSON.parse` turns it into 9007199254740992 with no error
+ * whatsoever. The quoted value then reaches the UI as an exact string, which is
+ * what the `pg` driver already does for `int8`.
+ *
+ * A single pass, and STRING-AWARE, which is the whole difficulty: a digit run
+ * inside `"id: 9007199254740993"` is a value the user is reading and must not be
+ * touched. Only integers are rewritten - a float is a double on both sides, so
+ * quoting one would turn a number the grid can sort into a string it cannot.
+ */
+export function quoteUnsafeIntegers(jsonText: string): string {
+ /** Non-null only once something needs rewriting, so the common body is returned as is. */
+ let rewritten: string[] | null = null;
+ let copiedTo = 0;
+ let index = 0;
+
+ while (index < jsonText.length) {
+ if (jsonText[index] === '"') {
+ index = endOfString(jsonText, index);
+ continue;
+ }
+
+ const number = numberAt(jsonText, index);
+ if (number === null) {
+ index += 1;
+ continue;
+ }
+
+ if (number.integral && exceedsSafeRange(number.digits)) {
+ rewritten ??= [];
+ rewritten.push(jsonText.slice(copiedTo, index), '"', jsonText.slice(index, number.end), '"');
+ copiedTo = number.end;
+ }
+ index = number.end;
+ }
+
+ if (rewritten === null) return jsonText;
+
+ rewritten.push(jsonText.slice(copiedTo));
+ return rewritten.join("");
+}
diff --git a/src/lib/explain/druid-native.ts b/src/lib/explain/druid-native.ts
new file mode 100644
index 00000000..9418ed73
--- /dev/null
+++ b/src/lib/explain/druid-native.ts
@@ -0,0 +1,373 @@
+// db/utils, not the Druid provider directory: an explain strategy that imported from
+// a provider would tie the registry to it (the rule clickhouse-json.ts records).
+import { quoteUnsafeIntegers } from "@/lib/db/utils/json-integers";
+import type { ExplainStrategy, ExplainTreeNode } from "./types";
+
+/**
+ * Does this statement lead to a SELECT, so that `EXPLAIN PLAN FOR` can wrap it?
+ *
+ * Broader than the bare `/^\s*SELECT\b/` the other strategies still use, because two
+ * ordinary things a user types are genuinely explainable on Druid and were being
+ * refused - which left the Explain button dead rather than merely narrow:
+ *
+ * - a CTE: `EXPLAIN PLAN FOR WITH t AS (...) SELECT * FROM t` is live-verified as
+ * accepted, and the shared `analyzeQuery` already classifies `WITH ... SELECT` as a
+ * SELECT (it injects a LIMIT into one), so refusing it here contradicted the rest of
+ * the pipeline;
+ * - a leading comment: `-- note`, or a `/* ... *\/` licence header, before the SELECT.
+ * Live-verified as accepted too, both as a prefix and combined with a CTE.
+ *
+ * The SHAPE of this pattern matters as much as what it accepts, and it is deliberately
+ * not the obvious `^\s*(--...|\/*...*\/|\s)*` spelling:
+ *
+ * Every one of the three alternatives had to be made unambiguous, because each is
+ * inside a `*` quantifier and any way of matching the same text twice is a way for a
+ * non-matching input to backtrack. All three were measured, with a tail that never
+ * reaches SELECT:
+ *
+ * - there is no leading `\s*`, because whitespace is already one alternative below.
+ * Having both gives two ways to match the same run of spaces: quadratic, 958ms on
+ * 20k leading spaces.
+ * - the line comment must end at a newline OR at end-of-input. Without that tail,
+ * `[^\n]*` can give characters back and let a later iteration match `--` again, so a
+ * run of bare dashes partitions exponentially - 634ms on a FORTY-NINE character
+ * input, which is by far the cheapest of the three to trigger. Requiring the tail
+ * forces the branch to run to the newline or to the end, so there is nothing to give
+ * back. Found by CodeQL after the first two were fixed.
+ * - the block-comment body is TEMPERED (`[^*]|\*(?!\/)`) rather than a lazy
+ * `[\s\S]*?\*\/`, which inside a `*` quantifier can extend past the first `*\/` and
+ * let one iteration swallow several comments: 852ms on a 4 KB run of `/**\/`.
+ *
+ * All three now answer in well under a millisecond.
+ *
+ * Both forms accept and reject exactly the same statements - the difference is only
+ * how much backtracking a non-matching input costs. `buildSql` runs on whatever is in
+ * the editor when a query is executed, so a buffer that opens with a large commented
+ * block and then a non-SELECT is a reachable input, and this file follows the same
+ * anti-backtracking care that made `query-limiter.ts` hand-write its semicolon strip
+ * "without regex to avoid ReDoS".
+ */
+const SELECT_ONLY = /^(?:\s|--[^\n]*(?:\n|$)|\/\*(?:[^*]|\*(?!\/))*\*\/)*(?:SELECT|WITH)\b/i;
+
+/**
+ * Druid's EXPLAIN is a statement prefix, not a modifier with options, and it never
+ * executes the statement. A trailing semicolon survives untouched:
+ * `EXPLAIN PLAN FOR SELECT 1 AS c1;` is live-verified as accepted.
+ */
+const EXPLAIN_PREFIX = "EXPLAIN PLAN FOR ";
+
+/**
+ * The single result row carries three columns and every one of them is JSON *text*,
+ * not JSON: the envelope parse leaves three escaped blobs behind and each needs a
+ * second parse. Names are upper case on the wire.
+ */
+const EXPLAIN_COLUMNS = { plan: "PLAN", resources: "RESOURCES", attributes: "ATTRIBUTES" } as const;
+
+/**
+ * How many wrapper layers toPlanEntries peels before giving up. The deepest
+ * legitimate chain is three hops - stored JSON text, the { plan } member, then the
+ * entry array - so the extra layer only keeps a pathological chain from looping.
+ */
+const MAX_UNWRAP_DEPTH = 4;
+
+/**
+ * How deep the query -> dataSource -> query recursion may go. The deepest live plan
+ * is three dataSource hops (a join whose right leg is a subquery over a table), so
+ * this is far past anything real and fires only on a shape that nests without end.
+ */
+const MAX_PLAN_DEPTH = 32;
+
+/** What the tree says where MAX_PLAN_DEPTH stopped it, so truncation is visible rather than silent. */
+const TRUNCATED_LABEL = "plan truncated: nesting limit reached";
+
+/** Stands in for a `type` Druid did not send at all; an unrecognised type labels itself. */
+const UNKNOWN_TYPE = "unknown";
+
+/**
+ * The dataSource discriminators Druid emits. Anything absent from this list renders
+ * as a leaf named by its own type rather than being dropped - Druid adds dataSource
+ * types between releases, and a dropped node would make the tree quietly lie about
+ * what runs.
+ */
+const DATA_SOURCE = {
+ table: "table",
+ query: "query",
+ join: "join",
+ union: "union",
+ lookup: "lookup",
+ inline: "inline",
+ external: "external",
+} as const;
+
+/**
+ * One entry of the PLAN array: `query` is the native query Druid will run, and
+ * `signature`/`columnMappings` describe the output shape rather than the operator
+ * tree, so only `query` is walked.
+ */
+interface DruidNativeQuery {
+ queryType: string;
+ [key: string]: unknown;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function isNonEmptyString(value: unknown): value is string {
+ return typeof value === "string" && value.length > 0;
+}
+
+function isNativeQuery(value: unknown): value is DruidNativeQuery {
+ return isRecord(value) && typeof value.queryType === "string";
+}
+
+/**
+ * The plan columns are JSON TEXT inside an already-parsed body, so this is a second,
+ * independent parse - and a second, independent chance to round a 64-bit integer.
+ *
+ * The transport's pass over the outer body cannot help here: relative to that body
+ * the plan is a string literal, so its digits are correctly left alone. Without the
+ * scanner a native filter on a BIGINT arrives rounded, live-verified:
+ * `... "matchValue": 9007199254740993` in the raw text became `...992` in the stored
+ * plan, which is what the raw-JSON tab and the AI analyzer then read.
+ */
+function parseJsonText(text: string): unknown {
+ try {
+ return JSON.parse(quoteUnsafeIntegers(text));
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Accepts what extractPlan stores ({ plan, resources, attributes }), the bare PLAN
+ * array, and - because an older tab may hold the column text unparsed - JSON text of
+ * either, in any nesting up to MAX_UNWRAP_DEPTH.
+ */
+function toPlanEntries(raw: unknown): unknown[] | null {
+ let current = raw;
+ for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) {
+ if (Array.isArray(current)) return current.length > 0 ? current : null;
+ if (typeof current === "string") {
+ current = parseJsonText(current);
+ } else if (isRecord(current) && "plan" in current) {
+ current = current.plan;
+ } else {
+ return null;
+ }
+ }
+ return null;
+}
+
+/** Each column holds JSON text; the text is kept when it will not parse so nothing is lost. */
+function readColumn(row: Record | undefined, column: string): unknown {
+ const cell = row?.[column];
+ if (typeof cell !== "string") return undefined;
+ return parseJsonText(cell) ?? cell;
+}
+
+/**
+ * `granularity` is `{ type: "all" }` on almost every plan, but TIME_FLOOR turns it
+ * into a bare string ("DAY", "SIX_HOUR") and a period spec puts the ISO period in
+ * `period` while `type` stays the uninformative "period" - so `period` wins.
+ */
+function readGranularity(value: unknown): string | undefined {
+ if (isNonEmptyString(value)) return value;
+ if (!isRecord(value)) return undefined;
+ if (isNonEmptyString(value.period)) return value.period;
+ return isNonEmptyString(value.type) ? value.type : undefined;
+}
+
+/**
+ * A native filter is itself a tree (`and` over `equals` and `range`, live). Rendering
+ * it as a second tree would compete with the operator tree for the reader's
+ * attention, so one row names the filter type plus either the column it restricts or
+ * how many clauses it combines.
+ */
+function buildFilterLabel(filter: Record): string {
+ const type = isNonEmptyString(filter.type) ? filter.type : UNKNOWN_TYPE;
+ if (isNonEmptyString(filter.column)) return `filter: ${type} on ${filter.column}`;
+ const fields = filter.fields;
+ if (Array.isArray(fields)) return `filter: ${type} (${fields.length} clauses)`;
+ return `filter: ${type}`;
+}
+
+/** `region AS d0`: the source column and the generated output name Druid gave it. */
+function describeDimension(dimension: unknown): string | undefined {
+ if (!isRecord(dimension)) return undefined;
+ const source = isNonEmptyString(dimension.dimension) ? dimension.dimension : undefined;
+ const output = isNonEmptyString(dimension.outputName) ? dimension.outputName : undefined;
+ if (source === undefined) return output;
+ return output === undefined || output === source ? source : `${source} AS ${output}`;
+}
+
+/** `longMax(qty) AS a0`; a count has no `fieldName` because it aggregates no column. */
+function describeAggregation(aggregation: unknown): string | undefined {
+ if (!isRecord(aggregation)) return undefined;
+ const type = aggregation.type;
+ if (!isNonEmptyString(type)) return undefined;
+ const call = isNonEmptyString(aggregation.fieldName) ? `${type}(${aggregation.fieldName})` : type;
+ return isNonEmptyString(aggregation.name) ? `${call} AS ${aggregation.name}` : call;
+}
+
+function describeAll(value: unknown, describe: (item: unknown) => string | undefined): string[] {
+ return Array.isArray(value) ? value.map(describe).filter(isNonEmptyString) : [];
+}
+
+/** topN names its single grouping key `dimension`; groupBy uses the `dimensions` array. */
+function readDimensions(query: DruidNativeQuery): string[] {
+ return describeAll(Array.isArray(query.dimensions) ? query.dimensions : [query.dimension], describeDimension);
+}
+
+/**
+ * VisualExplain's tree renderer shows the label and nothing else, so what a reader
+ * needs to see has to be a label - hence child rows rather than one detail string.
+ * These are attributes of the query, never metrics: Druid's planner emits no cost and
+ * no row estimate anywhere in this payload.
+ */
+function collectAttributeRows(query: DruidNativeQuery): ExplainTreeNode[] {
+ const rows: string[] = [];
+ const granularity = readGranularity(query.granularity);
+ if (granularity !== undefined) rows.push(`granularity: ${granularity}`);
+ if (isRecord(query.filter)) rows.push(buildFilterLabel(query.filter));
+ const dimensions = readDimensions(query);
+ if (dimensions.length > 0) rows.push(`dimensions: ${dimensions.join(", ")}`);
+ const aggregations = describeAll(query.aggregations, describeAggregation);
+ if (aggregations.length > 0) rows.push(`aggregations: ${aggregations.join(", ")}`);
+ return rows.map((label) => ({ label, children: [] }));
+}
+
+function buildJoinLabel(dataSource: Record): string {
+ const head = [DATA_SOURCE.join, dataSource.joinType].filter(isNonEmptyString).join(" ");
+ return isNonEmptyString(dataSource.condition) ? `${head} on ${dataSource.condition}` : head;
+}
+
+function buildNamedLabel(type: string, name: unknown): string {
+ return isNonEmptyString(name) ? `${type} ${name}` : type;
+}
+
+/**
+ * "1 row" / "2 rows". The count is part of the label, which is the only thing the
+ * tree renderer shows, so it has to read as English - live `SELECT 1 AS c1` plans a
+ * one-row inline dataSource and reached the reader as "1 rows".
+ */
+function countLabel(value: unknown, noun: string): string {
+ const count = Array.isArray(value) ? value.length : 0;
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
+}
+
+function toDataSourceNodes(value: unknown, depth: number): ExplainTreeNode[] {
+ const node = toDataSourceNode(value, depth);
+ return node === null ? [] : [node];
+}
+
+/**
+ * The recursion that IS the operator tree: a query's `dataSource` is either a leaf
+ * (table, lookup, inline, external) or another layer of queries (query, join, union).
+ */
+function toDataSourceNode(value: unknown, depth: number): ExplainTreeNode | null {
+ if (!isRecord(value)) return null;
+ if (depth >= MAX_PLAN_DEPTH) return { label: TRUNCATED_LABEL, children: [] };
+ const type = isNonEmptyString(value.type) ? value.type : UNKNOWN_TYPE;
+ switch (type) {
+ case DATA_SOURCE.table:
+ return { label: buildNamedLabel(DATA_SOURCE.table, value.name), children: [] };
+ case DATA_SOURCE.query:
+ return {
+ label: DATA_SOURCE.query,
+ children: isNativeQuery(value.query) ? [toQueryNode(value.query, depth + 1)] : [],
+ };
+ case DATA_SOURCE.join: {
+ // rightPrefix is what disambiguates the right leg's columns inside the join
+ // condition ("j0." live), so it belongs beside the condition, not instead of it.
+ const node: ExplainTreeNode = {
+ label: buildJoinLabel(value),
+ children: [...toDataSourceNodes(value.left, depth + 1), ...toDataSourceNodes(value.right, depth + 1)],
+ };
+ if (isNonEmptyString(value.rightPrefix)) node.detail = `rightPrefix: ${value.rightPrefix}`;
+ return node;
+ }
+ case DATA_SOURCE.union: {
+ const sources = Array.isArray(value.dataSources) ? value.dataSources : [];
+ return {
+ label: `${DATA_SOURCE.union} (${countLabel(sources, "source")})`,
+ children: sources.flatMap((source) => toDataSourceNodes(source, depth + 1)),
+ };
+ }
+ case DATA_SOURCE.lookup:
+ return { label: buildNamedLabel(DATA_SOURCE.lookup, value.lookup), children: [] };
+ case DATA_SOURCE.inline: {
+ const node: ExplainTreeNode = {
+ label: `${DATA_SOURCE.inline} (${countLabel(value.rows, "row")})`,
+ children: [],
+ };
+ const columns = Array.isArray(value.columnNames) ? value.columnNames.filter(isNonEmptyString) : [];
+ if (columns.length > 0) node.detail = `columns: ${columns.join(", ")}`;
+ return node;
+ }
+ case DATA_SOURCE.external: {
+ // The native engine rejects EXTERN outright ("Cannot use [EXTERN] with SQL
+ // engine [native]"), so this only arrives from the MSQ task engine. Naming the
+ // input source is what makes the node readable when it does.
+ const inputSource = value.inputSource;
+ const name = isRecord(inputSource) ? inputSource.type : undefined;
+ return { label: buildNamedLabel(DATA_SOURCE.external, name), children: [] };
+ }
+ default:
+ return { label: type, children: [] };
+ }
+}
+
+/**
+ * The dataSource subtree comes first and the attribute rows after, so following first
+ * children down the tree follows the data - the same ordering ClickHouse uses for its
+ * plan children ahead of its index rows.
+ */
+function toQueryNode(query: DruidNativeQuery, depth: number): ExplainTreeNode {
+ return {
+ label: query.queryType,
+ children: [...toDataSourceNodes(query.dataSource, depth), ...collectAttributeRows(query)],
+ };
+}
+
+function readEntryQuery(entry: unknown): unknown {
+ return isRecord(entry) ? entry.query : undefined;
+}
+
+export const druidNativeStrategy: ExplainStrategy = {
+ format: "druid-native",
+ // Druid's EXPLAIN never executes the statement, so there is nothing different to
+ // build for analyze. Declining that mode would disable the feature instead of
+ // narrowing it - the direct Explain action always builds with mode "analyze"
+ // (use-query-execution.ts:165) and refuses the run when the strategy returns null -
+ // so both modes return the same plan, as the SQLite and Couchbase strategies do.
+ buildSql(sql) {
+ if (!SELECT_ONLY.test(sql.trim())) return null;
+ return `${EXPLAIN_PREFIX}${sql}`;
+ },
+ // Parsing all three columns here rather than at render time is what gives the raw
+ // JSON tab and the AI tab a structure to read instead of three escaped blobs.
+ extractPlan(result) {
+ const row = result.rows?.[0];
+ const plan = readColumn(row, EXPLAIN_COLUMNS.plan);
+ const resources = readColumn(row, EXPLAIN_COLUMNS.resources);
+ const attributes = readColumn(row, EXPLAIN_COLUMNS.attributes);
+ // Nothing recognisable: hand the rows through so the raw tab still shows what the
+ // server sent rather than an object of three undefineds.
+ if (plan === undefined && resources === undefined && attributes === undefined) return result.rows;
+ return { plan, resources, attributes };
+ },
+ toRenderModel(raw) {
+ const entries = toPlanEntries(raw);
+ if (entries === null) return null;
+ const queries = entries.map(readEntryQuery).filter(isNativeQuery);
+ if (queries.length === 0) return null;
+ const roots = queries.map((query) => toQueryNode(query, 0));
+ // PLAN is an array and it is not always length 1: two aggregating branches of a
+ // UNION ALL come back as two independent native queries (live-verified). A
+ // synthetic root is the only way to show both without pretending one is the
+ // parent of the other; a single query is its own root.
+ const root = roots.length === 1 ? roots[0] : { label: `${roots.length} native queries`, children: roots };
+ return { kind: "tree", root, raw };
+ },
+};
diff --git a/src/lib/explain/index.ts b/src/lib/explain/index.ts
index 3d27ca95..8ba1d703 100644
--- a/src/lib/explain/index.ts
+++ b/src/lib/explain/index.ts
@@ -5,6 +5,7 @@ import { mysqlJsonStrategy } from "./mysql-json";
import { sqliteQueryplanStrategy } from "./sqlite-queryplan";
import { couchbaseJsonStrategy } from "./couchbase-json";
import { clickhouseJsonStrategy } from "./clickhouse-json";
+import { druidNativeStrategy } from "./druid-native";
export type { ExplainMode, ExplainStrategy } from "./types";
export type { ExplainPlanInput, ExplainTreeNode, StoredExplainPlan } from "./types";
@@ -17,6 +18,7 @@ const registry: Record = {
"sqlite-queryplan": sqliteQueryplanStrategy,
"couchbase-json": couchbaseJsonStrategy,
"clickhouse-json": clickhouseJsonStrategy,
+ "druid-native": druidNativeStrategy,
};
export function getExplainStrategy(format: ExplainFormat | undefined): ExplainStrategy | null {
diff --git a/src/lib/monitoring-cache-ratio.ts b/src/lib/monitoring-cache-ratio.ts
new file mode 100644
index 00000000..da549348
--- /dev/null
+++ b/src/lib/monitoring-cache-ratio.ts
@@ -0,0 +1,33 @@
+/**
+ * The one place that turns an optional cache hit ratio into display text.
+ *
+ * `PerformanceMetrics.cacheHitRatio` is optional because "not measured" and
+ * "measured as zero" are different facts and only one of them is an alarm: Apache
+ * Druid cannot measure the ratio at all (its cache statistics reach a metrics
+ * emitter and never a SQL-readable table), while a genuine 0 on another engine is
+ * a real, actionable measurement.
+ *
+ * `HealthInfo.cacheHitRatio` is a STRING, so it can carry the absence honestly -
+ * but the providers that always produce a number (Couchbase, ClickHouse) would
+ * each need an unreachable fallback branch to say so, and the repo's coverage gate
+ * fails on unreachable lines. Hence one shared formatter whose absent path is
+ * covered once, here.
+ */
+
+/**
+ * What an unavailable ratio is called on screen.
+ *
+ * "N/A" is the spelling `sqlite.ts`, `oracle.ts`, `mssql.ts` and `mongodb.ts`
+ * already use for a ratio they cannot read, so this is the repo's existing word
+ * for it rather than a new one.
+ */
+export const CACHE_HIT_RATIO_UNAVAILABLE = "N/A";
+
+/**
+ * One decimal place for a measured ratio, {@link CACHE_HIT_RATIO_UNAVAILABLE}
+ * when there is nothing to report. A measured `0` formats as "0.0": it is a
+ * number the engine actually produced.
+ */
+export function formatCacheHitRatio(ratio: number | undefined): string {
+ return ratio === undefined ? CACHE_HIT_RATIO_UNAVAILABLE : ratio.toFixed(1);
+}
diff --git a/src/lib/query-generators.ts b/src/lib/query-generators.ts
index 7b5d6262..3ca5331e 100644
--- a/src/lib/query-generators.ts
+++ b/src/lib/query-generators.ts
@@ -4,6 +4,9 @@ import type { ColumnSchema } from "@/lib/types";
/** Couchbase management port, the capability signal for the SQL++ dialect. */
const COUCHBASE_PORT = 8091;
+/** Apache Druid Router port, the capability signal for the Druid SQL dialect. */
+const DRUID_PORT = 8888;
+
/**
* Alias every generated Couchbase statement binds its keyspace to. SQL++ needs a
* name to hang `META()` and field references off, and the generator has only the
@@ -45,6 +48,7 @@ const COUCHBASE_KEY_PROJECTION = `META(${COUCHBASE_ALIAS}).id AS ${COUCHBASE_DOC
* - SQL Server (1433): case-insensitive → bracket-quote only specials
* - MySQL (3306): case-preserving → backtick-quote only specials
* - Couchbase (8091): SQL++ → always backtick-quote
+ * - Druid (8888): Calcite SQL → always double-quote
* - PostgreSQL (5432) / SQLite / ClickHouse (8123) / default: unquoted folds to
* lowercase (pg) → quote unless plain lower
*
@@ -63,6 +67,16 @@ export function quoteIdentifier(name: string, capabilities: ProviderCapabilities
// anything at all, so there is no safe unquoted subset worth detecting.
return couchbaseQuote(name);
}
+ if (capabilities.defaultPort === DRUID_PORT) {
+ // Druid (Calcite SQL): quote unconditionally, same reasoning as Couchbase above.
+ // A bare reserved word is a SYNTAX error, not a column-not-found: `SELECT count
+ // FROM libredb_demo` fails with "Received an unexpected token [count FROM]",
+ // while `SELECT "count" FROM libredb_demo` parses (issue #265). `count` is
+ // Druid's conventional rollup metric name, so the standard rollup ingestion
+ // produces a datasource that has one. Calcite's reserved list is large and
+ // version-dependent, so no safe unquoted subset is worth detecting.
+ return `"${name.replaceAll('"', '""')}"`;
+ }
if (capabilities.defaultPort === 1521) {
// Oracle
return /^[A-Z_][A-Z0-9_$#]*$/.test(name) ? name : `"${name.replaceAll('"', '""')}"`;
diff --git a/src/lib/seed/types.ts b/src/lib/seed/types.ts
index bb5b8610..e5ddb001 100644
--- a/src/lib/seed/types.ts
+++ b/src/lib/seed/types.ts
@@ -30,6 +30,7 @@ const SeedDatabaseType = z.enum([
"libredb",
"couchbase",
"clickhouse",
+ "druid",
]);
export const SeedDefaultsSchema = z.object({
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 441e1d15..0e78b64a 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -8,7 +8,8 @@ export type DatabaseType =
| "mssql"
| "libredb"
| "couchbase"
- | "clickhouse";
+ | "clickhouse"
+ | "druid";
export type ConnectionEnvironment = "production" | "staging" | "development" | "local" | "other";
diff --git a/tests/components/monitoring/OverviewTab.test.tsx b/tests/components/monitoring/OverviewTab.test.tsx
index d52f9e72..58a70575 100644
--- a/tests/components/monitoring/OverviewTab.test.tsx
+++ b/tests/components/monitoring/OverviewTab.test.tsx
@@ -71,6 +71,41 @@ describe("OverviewTab", () => {
expect(queryByText("Quick Stats")).not.toBeNull();
});
+ // An engine with no connection pool reports maxConnections 0, which means "no
+ // limit published" rather than "no capacity" - Apache Druid and, per its own
+ // comment, SQL Server both do. Dividing by it used to render the literal
+ // "NaN% used" and an NaN-width progress bar.
+ test("reports no limit instead of NaN when the engine publishes no connection ceiling", () => {
+ const base = makeData();
+ const { queryByText, container } = render(
+ ,
+ );
+
+ expect(container.textContent).not.toContain("NaN");
+ expect(queryByText("no limit published")).not.toBeNull();
+ // The count is still shown; only the meaningless share is withheld.
+ expect(container.textContent).toContain("3");
+ expect(container.textContent).not.toContain("3/0");
+ });
+
+ // Absence must not be displayed as a measured 0%, and must not be scored as the
+ // critical cache fault that a real 0 would be.
+ test("withholds the cache ratio card's percentage and rating when the engine cannot measure one", () => {
+ const base = makeData();
+ const { queryByText, container } = render(
+ ,
+ );
+
+ expect(container.textContent).not.toContain("0.0%");
+ expect(queryByText("Poor")).toBeNull();
+ });
+
test("renders connection trend when history has enough points", () => {
const { queryByText, queryByTestId } = render(
,
@@ -90,4 +125,34 @@ describe("OverviewTab", () => {
const { queryByText } = render( );
expect(queryByText("Needs tuning")).not.toBeNull();
});
+
+ test("renders the measured cache hit ratio with a bar and a rating", () => {
+ const { queryByText } = render( );
+ const card = queryByText("Cache Hit")!.closest('[data-slot="card"]')!;
+ expect(card.textContent).toContain("95.7%");
+ expect(card.querySelectorAll('[data-slot="progress"]').length).toBe(1);
+ expect(queryByText("Excellent")).not.toBeNull();
+ });
+
+ test("reports an unmeasured cache hit ratio as unavailable instead of 0.0%", () => {
+ const data = { ...makeData(), performance: { bufferPoolUsage: 62, deadlocks: 0 } } as MonitoringData;
+ const { queryByText, container } = render( );
+
+ // The card, its title and the 4-card grid all stay put.
+ expect(queryByText("Cache Hit")).not.toBeNull();
+ expect(container.querySelectorAll('[data-slot="card"]').length).toBe(6);
+
+ const card = queryByText("Cache Hit")!.closest('[data-slot="card"]')!;
+ expect(card.textContent).toContain("N/A");
+ expect(card.textContent).toContain("Not measured");
+ // No fabricated measurement: no percentage, no bar, no rating.
+ expect(card.textContent).not.toContain("%");
+ expect(card.querySelectorAll('[data-slot="progress"]').length).toBe(0);
+ expect(queryByText("Excellent")).toBeNull();
+ expect(queryByText("Good")).toBeNull();
+ expect(queryByText("Needs tuning")).toBeNull();
+ // Absence is not a fault: nothing red or yellow on the card.
+ expect(card.className).not.toContain("red");
+ expect(card.className).not.toContain("yellow");
+ });
});
diff --git a/tests/components/monitoring/PerformanceTab.test.tsx b/tests/components/monitoring/PerformanceTab.test.tsx
index 54b4aa61..b0af5096 100644
--- a/tests/components/monitoring/PerformanceTab.test.tsx
+++ b/tests/components/monitoring/PerformanceTab.test.tsx
@@ -75,6 +75,44 @@ describe("PerformanceTab", () => {
expect(queryByText("Attention")).not.toBeNull();
});
+ test("renders the measured cache hit ratio with a bar and a rating", () => {
+ const { queryByText } = render( );
+ const card = queryByText("Cache Hit")!.closest('[data-slot="card"]')!;
+ expect(card.textContent).toContain("98.2");
+ expect(card.textContent).toContain("%");
+ expect(card.querySelectorAll('[data-slot="progress"]').length).toBe(1);
+ expect(card.textContent).toContain("Excellent");
+ expect(card.querySelector("svg")?.getAttribute("class")).toContain("text-green-500");
+ });
+
+ test("reports an unmeasured cache hit ratio as unavailable instead of 0%", () => {
+ const data = {
+ ...makeData(),
+ performance: { bufferPoolUsage: 65, deadlocks: 0, checkpointWriteTime: "12ms" },
+ } as MonitoringData;
+ const { queryByText, container } = render( );
+
+ // The card, its title and the 3-card grid all stay put.
+ expect(queryByText("Cache Hit")).not.toBeNull();
+ expect(container.querySelectorAll('[data-slot="card"]').length).toBe(5);
+
+ const card = queryByText("Cache Hit")!.closest('[data-slot="card"]')!;
+ expect(card.textContent).toContain("N/A");
+ expect(card.textContent).toContain("Not measured");
+ // No fabricated measurement: no percentage, no bar, no rating.
+ expect(card.textContent).not.toContain("%");
+ expect(card.querySelectorAll('[data-slot="progress"]').length).toBe(0);
+ for (const rating of ["Excellent", "Good", "Fair", "Poor"]) {
+ expect(card.textContent).not.toContain(rating);
+ }
+ // Absence is not a fault: no red icon, no red or yellow border, no advice.
+ expect(card.querySelector("svg")?.getAttribute("class")).toContain("text-muted-foreground");
+ expect(card.className).not.toContain("red");
+ expect(card.className).not.toContain("yellow");
+ expect(queryByText("Low Cache Hit")).toBeNull();
+ expect(queryByText("Performing well!")).toBeNull();
+ });
+
test("shows trend charts when history has at least 2 points", () => {
const { queryByText, queryAllByTestId } = render(
,
@@ -84,4 +122,38 @@ describe("PerformanceTab", () => {
expect(queryByText("Deadlock Trend")).not.toBeNull();
expect(queryAllByTestId("metric-chart").length).toBeGreaterThanOrEqual(3);
});
+
+ // An engine that cannot measure a cache hit ratio (Druid) reports none on every
+ // sample. Mapping those to 0 plotted a measured 0% trend - the same fabricated
+ // metric the current-value card withholds - so missing samples are dropped and the
+ // card says so instead of drawing a floor.
+ test("renders the cache trend as not measured when no history sample carries a ratio", () => {
+ const history = [
+ { timestamp: new Date("2026-02-15T12:00:00Z"), data: makeData({ cacheHitRatio: undefined }) },
+ { timestamp: new Date("2026-02-15T12:01:00Z"), data: makeData({ cacheHitRatio: undefined }) },
+ ] as unknown as TimeSeriesPoint[];
+
+ const { queryByText, queryAllByText, queryAllByTestId, container } = render(
+ ,
+ );
+
+ expect(queryByText("Cache Hit Trend")).not.toBeNull();
+ // Twice: the current-value card and the trend card both decline to show a number.
+ expect(queryAllByText("Not measured")).toHaveLength(2);
+ // The other two trends still draw, so only the unmeasurable one is withheld.
+ expect(queryAllByTestId("metric-chart").length).toBe(2);
+ // And nothing anywhere claims a measured zero.
+ expect(container.textContent).not.toContain("0.0%");
+ });
+
+ test("still plots the cache trend from the samples that do carry a ratio", () => {
+ const history = [
+ { timestamp: new Date("2026-02-15T12:00:00Z"), data: makeData({ cacheHitRatio: undefined }) },
+ { timestamp: new Date("2026-02-15T12:01:00Z"), data: makeData({ cacheHitRatio: 96.1 }) },
+ ] as unknown as TimeSeriesPoint[];
+
+ const { queryAllByTestId } = render( );
+
+ expect(queryAllByTestId("metric-chart").length).toBe(3);
+ });
});
diff --git a/tests/hooks/use-connection-form.test.ts b/tests/hooks/use-connection-form.test.ts
index b587839e..3669bbe3 100644
--- a/tests/hooks/use-connection-form.test.ts
+++ b/tests/hooks/use-connection-form.test.ts
@@ -414,6 +414,7 @@ describe("useConnectionForm", () => {
libredb: true,
couchbase: true,
clickhouse: true,
+ druid: true,
};
test("dbTypes offers every database type a connection can carry", () => {
diff --git a/tests/integration/db/druid-provider.test.ts b/tests/integration/db/druid-provider.test.ts
new file mode 100644
index 00000000..a963f372
--- /dev/null
+++ b/tests/integration/db/druid-provider.test.ts
@@ -0,0 +1,1586 @@
+/**
+ * Apache Druid Provider Integration Tests (issue #265)
+ *
+ * globalThis.fetch is replaced per test and restored in afterEach, so the real
+ * transport, the real introspection, the real explain strategy and the real
+ * provider all run - only the cluster is fake. mock.module() is deliberately not
+ * used: it is process-wide in bun and would poison sibling test files.
+ *
+ * Every payload below was captured from a live Apache Druid 37.0.0 cluster
+ * (datasources `libredb_demo`, 50 rows, and `libredb_rollup`, 20 rows), so the
+ * fake speaks exactly what the server speaks. That matters more here than in a
+ * typical mock, because six behaviours the provider depends on are the opposite
+ * of what a JSON API teaches:
+ *
+ * - A result is a POSITIONAL array behind THREE header rows (names, native types,
+ * SQL types), and a result set with no rows still carries all three. The object
+ * format was rejected because it silently drops duplicate columns.
+ * - A 64-bit integer arrives as an UNQUOTED JSON number that `JSON.parse` rounds,
+ * so the transport quotes it and the value reaches the grid as an exact string.
+ * - The error body's `error` field is a DISCRIMINATOR whose value is the literal
+ * string "druidException"; the message lives elsewhere in the envelope, and the
+ * HTTP status misclassifies - `SELECT 1/0` is a 500 for a user's own typo.
+ * - `sys.tasks.duration` is -1 for every task that has not finished, so a
+ * session's age is `CURRENT_TIMESTAMP` minus `created_time` instead.
+ * - A grouping-less aggregate over zero matching rows returns NO DATA ROW at all,
+ * so every scalar read has to survive an absent row rather than a null.
+ * - Appending `LIMIT n` to a statement that ends in `OFFSET n` is a hard 400, so
+ * the shared limiter must not run on one.
+ */
+import { describe, test, expect, beforeEach, afterEach } from "bun:test";
+import type { DatabaseConnection, DatabaseType } from "@/lib/types";
+import type { DatabaseProvider } from "@/lib/db/types";
+import { DruidProvider } from "@/lib/db/providers/sql/druid";
+import {
+ DRUID_ACTIVE_TASK_SQL,
+ DRUID_COLUMN_LIST_SQL,
+ DRUID_DATASOURCE_COUNT_SQL,
+ DRUID_DATASOURCE_STATS_SQL,
+ DRUID_HISTORICAL_STORAGE_SQL,
+ DRUID_RUNNING_TASK_COUNT_SQL,
+ DRUID_SEGMENT_TOTALS_SQL,
+ DRUID_SERVER_IDENTITY_SQL,
+ DRUID_TABLE_LIST_SQL,
+} from "@/lib/db/providers/sql/druid/introspect";
+import {
+ AuthenticationError,
+ ConnectionError,
+ DatabaseConfigError,
+ DatabaseError,
+ QueryCancelledError,
+ QueryError,
+ TimeoutError,
+} from "@/lib/db/errors";
+import { getExplainStrategy } from "@/lib/explain";
+import type { ExplainTreeNode } from "@/lib/explain/types";
+
+// ============================================================================
+// Connection
+// ============================================================================
+
+const DRUID: DatabaseType = "druid";
+
+/** The statement `connect()` proves the cluster with, live-verified as valid. */
+const CONNECT_PROBE = "SELECT 1";
+
+function makeConnection(overrides: Partial = {}): DatabaseConnection {
+ return {
+ id: "druid-1",
+ name: "Druid",
+ type: DRUID,
+ host: "127.0.0.1",
+ port: 8888,
+ createdAt: new Date(),
+ ...overrides,
+ };
+}
+
+// ============================================================================
+// Wire payloads (captured from Apache Druid 37.0.0 over POST /druid/v2/sql)
+// ----------------------------------------------------------------------------
+// Written as raw text rather than built from objects, for one reason that only
+// applies to Druid: the BIGINT below is 2^53 + 1, and a JavaScript number literal
+// would already have rounded it before any code under test ran.
+// ============================================================================
+
+/** `SELECT 1` - Druid names an unaliased expression EXPR$0. */
+const PROBE_BODY = '[["EXPR$0"],["LONG"],["INTEGER"],[1]]';
+
+/** `SELECT id, region, qty FROM "libredb_demo" WHERE region = ? LIMIT 2`. */
+const DEMO_BODY =
+ '[["id","region","qty"],["LONG","STRING","LONG"],["BIGINT","VARCHAR","BIGINT"],[1000,"emea",0],[1030,"emea",90]]';
+
+/**
+ * `SELECT id, name, snowflake_id FROM "libredb_demo" WHERE region = ? LIMIT 1`.
+ * `snowflake_id` really holds 9007199254740993 and really arrives unquoted.
+ */
+const BIGINT_BODY =
+ '[["id","name","snowflake_id"],["LONG","STRING","LONG"],["BIGINT","VARCHAR","BIGINT"],[1000,"alpha",9007199254740993]]';
+
+/** `SELECT 1 AS c, 2 AS c` - two columns, one declared name, both preserved. */
+const DUPLICATE_COLUMN_BODY = '[["c","c"],["LONG","LONG"],["INTEGER","INTEGER"],[1,2]]';
+
+/** `SELECT id FROM libredb_demo WHERE id = -1` - all three headers, no data. */
+const NO_ROWS_BODY = '[["id"],["LONG"],["BIGINT"]]';
+
+/** `INFORMATION_SCHEMA.TABLES` filtered to the `druid` schema. */
+const TABLE_LIST_BODY = '[["tableName"],["STRING"],["VARCHAR"],["libredb_demo"],["libredb_rollup"]]';
+
+/**
+ * `INFORMATION_SCHEMA.COLUMNS` for both datasources, in ORDINAL_POSITION order.
+ * `libredb_rollup` is trimmed to its first three columns for length; nothing the
+ * schema tree reads is affected. `__time` is the only column reported NOT NULL.
+ */
+const COLUMN_LIST_BODY = JSON.stringify([
+ ["tableName", "columnName", "dataType", "isNullable"],
+ ["STRING", "STRING", "STRING", "STRING"],
+ ["VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR"],
+ ["libredb_demo", "__time", "TIMESTAMP", "NO"],
+ ["libredb_demo", "snowflake_id", "BIGINT", "YES"],
+ ["libredb_demo", "id", "BIGINT", "YES"],
+ ["libredb_demo", "name", "VARCHAR", "YES"],
+ ["libredb_demo", "region", "VARCHAR", "YES"],
+ ["libredb_demo", "qty", "BIGINT", "YES"],
+ ["libredb_demo", "amount", "DOUBLE", "YES"],
+ ["libredb_demo", "row_count", "BIGINT", "YES"],
+ ["libredb_rollup", "__time", "TIMESTAMP", "NO"],
+ ["libredb_rollup", "id", "BIGINT", "YES"],
+ ["libredb_rollup", "qty", "BIGINT", "YES"],
+]);
+
+/**
+ * `sys.servers`, Coordinator first. `serverNow` rides along so the uptime is a
+ * difference of two readings of the SAME clock: 1h 8m 34.559s here.
+ */
+const IDENTITY_BODY =
+ '[["version","startTime","serverNow"],["STRING","STRING","LONG"],["VARCHAR","VARCHAR","TIMESTAMP"],' +
+ '["37.0.0","2026-08-03T14:29:00.534Z","2026-08-03T15:37:35.093Z"]]';
+
+/** `SUM("size")` over the active segments of the whole cluster. */
+const SEGMENT_TOTALS_BODY = '[["sizeBytes"],["LONG"],["BIGINT"],[19617]]';
+
+const DATASOURCE_COUNT_BODY = '[["datasourceCount"],["LONG"],["BIGINT"],[2]]';
+
+/**
+ * The absent-row case, and it is the ORDINARY one: live-verified, a quiet cluster
+ * answers `SELECT COUNT(*) FROM sys.tasks WHERE status = 'RUNNING'` with the
+ * column-name rows and no data row whatsoever, not with a row holding 0.
+ */
+const NO_RUNNING_TASKS_BODY = '[["runningTasks"],["LONG"],["BIGINT"]]';
+
+const ONE_RUNNING_TASK_BODY = '[["runningTasks"],["LONG"],["BIGINT"],[1]]';
+
+/** The unfinished-task read on a quiet cluster: three header rows, no data. */
+const NO_ACTIVE_TASKS_BODY = JSON.stringify([
+ ["taskId", "taskType", "datasource", "status", "createdTime", "serverNow"],
+ ["STRING", "STRING", "STRING", "STRING", "STRING", "LONG"],
+ ["VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "TIMESTAMP"],
+]);
+
+/**
+ * Two real rows of this cluster's `sys.tasks`, read while the first was RUNNING.
+ *
+ * The noop task is the live snapshot verbatim - and it is what disproved the
+ * design spec's `durationMs = duration` mapping: the same row reports
+ * `duration = -1` because the task has not finished, so the age can only come
+ * from `serverNow` minus `createdTime` (22.86s here). Its `datasource` is the
+ * literal string "none", not null.
+ *
+ * The ingestion row is this cluster's real `index_parallel` task for
+ * `libredb_demo`, shown with the RUNNING status it carried while it ran.
+ */
+const ACTIVE_TASKS_BODY = JSON.stringify([
+ ["taskId", "taskType", "datasource", "status", "createdTime", "serverNow"],
+ ["STRING", "STRING", "STRING", "STRING", "STRING", "LONG"],
+ ["VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "VARCHAR", "TIMESTAMP"],
+ [
+ "noop_2026-08-03T15:41:40.345Z_166088c6-0e19-4ba0-8b75-873392f4ce34",
+ "noop",
+ "none",
+ "RUNNING",
+ "2026-08-03T15:41:40.346Z",
+ "2026-08-03T15:42:03.210Z",
+ ],
+ [
+ "index_parallel_libredb_demo_onmdflbc_2026-08-03T14:33:59.465Z",
+ "index_parallel",
+ "libredb_demo",
+ "RUNNING",
+ "2026-08-03T14:33:59.480Z",
+ "2026-08-03T15:42:03.210Z",
+ ],
+]);
+
+/** `sys.segments` grouped by datasource, active segments only. */
+const DATASOURCE_STATS_BODY =
+ '[["datasource","rowCount","sizeBytes"],["STRING","LONG","LONG"],["VARCHAR","BIGINT","BIGINT"],' +
+ '["libredb_demo",50,10203],["libredb_rollup",20,9414]]';
+
+/**
+ * The one historical of this cluster: a 300 GB segment cache holding 19 KB, so
+ * the honest rounded usage really is 0%.
+ */
+const HISTORICAL_STORAGE_BODY =
+ '[["server","host","currSize","maxSize"],["STRING","STRING","LONG","LONG"],["VARCHAR","VARCHAR","BIGINT","BIGINT"],' +
+ '["172.18.0.5:8083","172.18.0.5",19617,300000000000]]';
+
+// ============================================================================
+// EXPLAIN payload
+// ============================================================================
+
+/** Druid stamps every plan with the same all-of-time interval. */
+const ETERNITY = "-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z";
+
+/**
+ * `EXPLAIN PLAN FOR SELECT * FROM libredb_demo LIMIT 500` verbatim, minus the
+ * per-request `context` (a fresh UUID pair that describes the request rather than
+ * the plan) and with `signature`/`columnMappings` trimmed to one entry each -
+ * neither is walked by the tree.
+ */
+const EXPLAIN_PLAN = [
+ {
+ query: {
+ queryType: "scan",
+ dataSource: { type: "table", name: "libredb_demo" },
+ intervals: { type: "intervals", intervals: [ETERNITY] },
+ resultFormat: "compactedList",
+ limit: 500,
+ columns: ["__time", "snowflake_id", "id", "name", "region", "qty", "amount", "row_count"],
+ columnTypes: ["LONG", "LONG", "LONG", "STRING", "STRING", "LONG", "DOUBLE", "LONG"],
+ granularity: { type: "all" },
+ legacy: false,
+ },
+ signature: [{ name: "id", type: "LONG" }],
+ columnMappings: [{ queryColumn: "id", outputColumn: "id" }],
+ },
+];
+
+const EXPLAIN_RESOURCES = [{ name: "libredb_demo", type: "DATASOURCE" }];
+const EXPLAIN_ATTRIBUTES = { statementType: "SELECT" };
+
+/**
+ * The wire shape of an EXPLAIN answer: one row, three columns, and every cell is
+ * JSON TEXT rather than JSON. The column names really are upper case.
+ */
+const EXPLAIN_BODY = JSON.stringify([
+ ["PLAN", "RESOURCES", "ATTRIBUTES"],
+ ["STRING", "STRING", "STRING"],
+ ["VARCHAR", "VARCHAR", "VARCHAR"],
+ [JSON.stringify(EXPLAIN_PLAN), JSON.stringify(EXPLAIN_RESOURCES), JSON.stringify(EXPLAIN_ATTRIBUTES)],
+]);
+
+// ============================================================================
+// Error envelopes
+// ----------------------------------------------------------------------------
+// Both shapes are live. `error` is a DISCRIMINATOR in the modern one - its value
+// is the literal string "druidException" - which is why a provider that shows it
+// prints that word to the person who mistyped a datasource name.
+// ============================================================================
+
+/** `SELECT * FROM nope` - HTTP 400. */
+const UNKNOWN_DATASOURCE =
+ '{"error":"druidException","errorCode":"invalidInput","persona":"USER","category":"INVALID_INPUT",' +
+ '"errorMessage":"Object \'nope\' not found (line [1], column [15])",' +
+ '"context":{"sourceType":"sql","line":"1","column":"15","endLine":"1","endColumn":"18"}}';
+
+/** `SELECT 1/0 AS z` - HTTP **500**, `persona: ADMIN`, for an ordinary user mistake. */
+const DIVIDE_BY_ZERO =
+ '{"error":"druidException","errorCode":"general","persona":"ADMIN","category":"UNCATEGORIZED",' +
+ '"errorMessage":"/ by zero","context":{}}';
+
+/** The legacy wrapper a data server produces, here from `context.timeout` of 1 ms - HTTP 504. */
+const LEGACY_TIMEOUT =
+ '{"error":"Query timeout","errorClass":"org.apache.druid.query.QueryTimeoutException",' +
+ '"host":"172.18.0.5:8083","errorCode":"legacyQueryException","persona":"OPERATOR","category":"TIMEOUT",' +
+ '"errorMessage":"url[http://172.18.0.5:8083/druid/v2/] timed out",' +
+ '"context":{"host":"172.18.0.5:8083","errorClass":"org.apache.druid.query.QueryTimeoutException",' +
+ '"legacyErrorCode":"Query timeout"}}';
+
+/** `INSERT INTO libredb_demo SELECT * FROM libredb_rollup` - HTTP 400. */
+const UNSUPPORTED_INSERT =
+ '{"error":"druidException","errorCode":"invalidInput","persona":"USER","category":"INVALID_INPUT",' +
+ '"errorMessage":"INSERT operations are not supported by requested SQL engine [native], consider using MSQ.",' +
+ '"context":{"sourceType":"sql"}}';
+
+/** `UPDATE libredb_demo SET qty = 1 WHERE id = 1` - HTTP 400. UPDATE is not in the grammar. */
+const UNSUPPORTED_UPDATE =
+ '{"error":"druidException","errorCode":"invalidInput","persona":"USER","category":"INVALID_INPUT",' +
+ '"errorMessage":"Unsupported SQL statement [UPDATE]","context":{"sourceType":"sql"}}';
+
+/**
+ * A denial, CONSTRUCTED - and the one envelope here that is not a live capture,
+ * because it cannot be: this cluster loads no security extension and ignores
+ * credentials entirely (live-verified, a bogus header still answers 200). The
+ * `category` values are Druid's own (transport.ts records the closed enum) and
+ * the envelope shape is the live one; only the denial itself is synthesized.
+ */
+function deniedBody(category: "FORBIDDEN" | "UNAUTHORIZED" | "NOT_FOUND"): string {
+ return JSON.stringify({
+ error: "druidException",
+ errorCode: "forbidden",
+ persona: "USER",
+ category,
+ errorMessage: "Unauthorized",
+ context: {},
+ });
+}
+
+/**
+ * A cancellation, CONSTRUCTED for the same reason in reverse: a cancel that lands
+ * mid-stream answers 200 and truncates the body instead of sending an envelope
+ * (proven in the transport's own tests), so an envelope-shaped CANCELED is the
+ * shape a cancel BEFORE streaming would take. The category is Druid's own.
+ */
+const CANCELED_BODY = JSON.stringify({
+ error: "druidException",
+ errorCode: "general",
+ persona: "USER",
+ category: "CANCELED",
+ errorMessage: "Query cancelled",
+});
+
+// ============================================================================
+// fetch harness
+// ============================================================================
+
+interface Reply {
+ status?: number;
+ body: string;
+}
+
+function ok(body: string): Reply {
+ return { body };
+}
+
+function fail(status: number, body: string): Reply {
+ return { status, body };
+}
+
+const originalFetch = globalThis.fetch;
+const originalAbortTimeout = AbortSignal.timeout;
+
+let sentSql: string[] = [];
+let sentBodies: Record[] = [];
+let sentUrls: string[] = [];
+let sentAuth: (string | null)[] = [];
+/** Every client-side deadline the transport armed, in the order it armed them. */
+let armedDeadlines: number[] = [];
+let networkFailure: Error | null = null;
+let replyFor: (sql: string) => Reply;
+
+/**
+ * Which canned body each catalog or `sys` read gets, keyed on the exported
+ * statement the read actually sends. Keying on the constant rather than on a
+ * substring means a routing miss is impossible: a renamed projection cannot
+ * silently serve another surface's rows.
+ */
+const SURFACE_BODIES: [statement: string, body: string][] = [
+ [DRUID_TABLE_LIST_SQL, TABLE_LIST_BODY],
+ [DRUID_COLUMN_LIST_SQL, COLUMN_LIST_BODY],
+ [DRUID_SERVER_IDENTITY_SQL, IDENTITY_BODY],
+ [DRUID_SEGMENT_TOTALS_SQL, SEGMENT_TOTALS_BODY],
+ [DRUID_DATASOURCE_COUNT_SQL, DATASOURCE_COUNT_BODY],
+ [DRUID_RUNNING_TASK_COUNT_SQL, NO_RUNNING_TASKS_BODY],
+ [DRUID_ACTIVE_TASK_SQL, NO_ACTIVE_TASKS_BODY],
+ [DRUID_DATASOURCE_STATS_SQL, DATASOURCE_STATS_BODY],
+ [DRUID_HISTORICAL_STORAGE_SQL, HISTORICAL_STORAGE_BODY],
+];
+
+function defaultReply(sql: string): Reply {
+ if (sql === CONNECT_PROBE) return ok(PROBE_BODY);
+
+ // `startsWith` because the session read appends its own row cap.
+ const surface = SURFACE_BODIES.find(([statement]) => sql.startsWith(statement));
+ if (surface) return ok(surface[1]);
+
+ if (sql.startsWith("EXPLAIN")) return ok(EXPLAIN_BODY);
+ return ok(DEMO_BODY);
+}
+
+/** Every read fails the way a locked-down cluster's ordinary user sees it. */
+function denyEverything(): void {
+ replyFor = () => fail(403, deniedBody("FORBIDDEN"));
+}
+
+/** Serve one surface differently and leave every other read alone. */
+function overrideSurface(statement: string, reply: Reply): void {
+ replyFor = (sql) => (sql.startsWith(statement) ? reply : defaultReply(sql));
+}
+
+function installFetch(): void {
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
+ if (networkFailure) throw networkFailure;
+ const body = JSON.parse(String(init?.body)) as Record;
+ const sql = String(body.query);
+ sentUrls.push(String(input));
+ sentBodies.push(body);
+ sentSql.push(sql);
+ sentAuth.push(new Headers(init?.headers).get("authorization"));
+
+ const reply = replyFor(sql);
+ return new Response(reply.body, {
+ status: reply.status ?? 200,
+ // Live-verified: every answer, success and failure alike, is JSON.
+ headers: { "content-type": "application/json" },
+ });
+ }) as typeof fetch;
+}
+
+/**
+ * Record every client-side deadline instead of only proving a signal was
+ * attached. The grace above the server's own deadline is the whole point of
+ * having two (the #264 lesson), and it is otherwise unobservable.
+ */
+function installAbortRecorder(): void {
+ AbortSignal.timeout = ((ms: number) => {
+ armedDeadlines.push(ms);
+ return originalAbortTimeout.call(AbortSignal, ms);
+ }) as typeof AbortSignal.timeout;
+}
+
+function indexOfStatement(match: string): number {
+ const index = sentSql.findIndex((statement) => statement.includes(match));
+ if (index === -1) throw new Error(`no statement matching "${match}" was sent`);
+ return index;
+}
+
+/** The statement the provider sent that mentions `match`, or a failure naming it. */
+function sqlWith(match: string): string {
+ return sentSql[indexOfStatement(match)];
+}
+
+/** The whole request the provider sent for that statement. */
+function bodyWith(match: string): Record {
+ return sentBodies[indexOfStatement(match)];
+}
+
+function urlWith(match: string): string {
+ return sentUrls[indexOfStatement(match)];
+}
+
+function sentAnything(match: string): boolean {
+ return sentSql.some((statement) => statement.includes(match));
+}
+
+async function connectProvider(overrides: Partial = {}): Promise {
+ const provider = new DruidProvider(makeConnection(overrides));
+ await provider.connect();
+ return provider;
+}
+
+beforeEach(() => {
+ sentSql = [];
+ sentBodies = [];
+ sentUrls = [];
+ sentAuth = [];
+ armedDeadlines = [];
+ networkFailure = null;
+ replyFor = defaultReply;
+ installFetch();
+ installAbortRecorder();
+});
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ AbortSignal.timeout = originalAbortTimeout;
+});
+
+// ============================================================================
+// Metadata
+// ============================================================================
+
+describe("DruidProvider metadata", () => {
+ test("declares the capabilities the design spec settled on", () => {
+ const capabilities = new DruidProvider(makeConnection()).getCapabilities();
+
+ expect(capabilities).toEqual({
+ queryLanguage: "sql",
+ supportsExplain: true,
+ explainFormat: "druid-native",
+ supportsExternalQueryLimiting: true,
+ supportsCreateTable: false,
+ supportsMaintenance: false,
+ maintenanceOperations: [],
+ supportsConnectionString: false,
+ defaultPort: 8888,
+ schemaRefreshPattern: "\\b(INSERT|REPLACE)\\b",
+ });
+ });
+
+ test("keeps supportsCreateTable false because CREATE is not in Druid's grammar", () => {
+ // Live-verified, and stronger than "unimplemented": `CREATE TABLE t (id
+ // BIGINT)` answers HTTP 400 "Incorrect syntax near the keyword 'CREATE' at
+ // line 1, column 1" - the parser lists the statements it expected, and no
+ // form of CREATE is among them. Datasources are created by ingestion.
+ expect(new DruidProvider(makeConnection()).getCapabilities().supportsCreateTable).toBe(false);
+ });
+
+ test("offers no maintenance operation, because SQL reaches none of them", () => {
+ // Compaction and retention are Coordinator and task concerns, and `kill` has
+ // a second problem: there is no `sys.queries` catalog, so there is nowhere
+ // honest for a user to read a cancellable query id from.
+ const capabilities = new DruidProvider(makeConnection()).getCapabilities();
+
+ expect(capabilities.supportsMaintenance).toBe(false);
+ expect(capabilities.maintenanceOperations).toEqual([]);
+ });
+
+ test("declares no connection string, because Druid's SQL endpoint has no URI form", () => {
+ // Its JDBC driver addresses Avatica (jdbc:avatica:remote:url=...), and
+ // http:// / https:// already resolve to ClickHouse in the shared parser.
+ expect(new DruidProvider(makeConnection()).getCapabilities().supportsConnectionString).toBe(false);
+ });
+
+ test("calls a table a Datasource, and a row a row", () => {
+ // Datasource is the Druid word and the sidebar is where a user meets it. The
+ // rest is inherited on purpose: a Druid row IS a row, and the maintenance
+ // labels name work this provider does not offer.
+ const labels = new DruidProvider(makeConnection()).getLabels();
+
+ expect(labels.entityName).toBe("Datasource");
+ expect(labels.entityNamePlural).toBe("Datasources");
+ expect(labels.rowName).toBe("row");
+ expect(labels.rowNamePlural).toBe("rows");
+ expect(labels.selectAction).toBe("Select Top 50");
+ expect(labels.generateAction).toBe("Generate Query");
+ });
+});
+
+// ============================================================================
+// Validation and the connection model
+// ============================================================================
+
+describe("DruidProvider validation", () => {
+ test("requires a host", () => {
+ expect(() => new DruidProvider(makeConnection({ host: undefined }))).toThrow(DatabaseConfigError);
+ });
+
+ test("ignores the connection's database field entirely", async () => {
+ // The connection form still renders a Database Name input for every
+ // non-file-based type, so a Druid connection CAN carry one. Druid reports
+ // exactly one catalog, always `druid`, so the field can only ever be noise -
+ // and the request has nowhere to put it.
+ const provider = await connectProvider({ database: "nope" });
+
+ expect(urlWith(CONNECT_PROBE)).toBe("http://127.0.0.1:8888/druid/v2/sql");
+ expect(sqlWith(CONNECT_PROBE)).toBe(CONNECT_PROBE);
+ await provider.disconnect();
+ });
+
+ test("falls back to the Router's port when the connection names none", async () => {
+ const provider = await connectProvider({ port: undefined });
+
+ expect(urlWith(CONNECT_PROBE)).toBe("http://127.0.0.1:8888/druid/v2/sql");
+ await provider.disconnect();
+ });
+
+ test("speaks TLS when the connection asks for it, on the port it was given", async () => {
+ // One default port for both schemes on purpose: a TLS Druid serves on
+ // whatever `druid.tlsPort` the deployment chose, so there is no well-known
+ // HTTPS port to guess at.
+ const provider = await connectProvider({ ssl: { mode: "require" }, port: 9088 });
+
+ expect(urlWith(CONNECT_PROBE)).toBe("https://127.0.0.1:9088/druid/v2/sql");
+ await provider.disconnect();
+ });
+
+ test("sends no credentials when the connection carries none", async () => {
+ // A default install loads no security extension and ignores the header
+ // entirely (live-verified: a bogus one still answers 200), so credentials are
+ // optional and sending none is the normal case.
+ const provider = await connectProvider();
+
+ expect(sentAuth[0]).toBeNull();
+ await provider.disconnect();
+ });
+
+ test("sends configured credentials as HTTP basic auth", async () => {
+ const provider = await connectProvider({ user: "reader", password: "s3cret" });
+
+ const header = sentAuth[0] ?? "";
+ expect(Buffer.from(header.replace("Basic ", ""), "base64").toString()).toBe("reader:s3cret");
+ await provider.disconnect();
+ });
+});
+
+// ============================================================================
+// Lifecycle
+// ============================================================================
+
+describe("DruidProvider lifecycle", () => {
+ test("connect proves the cluster with the cheapest statement there is", async () => {
+ const provider = await connectProvider();
+
+ expect(provider.isConnected()).toBe(true);
+ expect(sentSql).toEqual([CONNECT_PROBE]);
+ });
+
+ test("connect bounds both halves of the exchange", async () => {
+ // Two deadlines, not one duplicated: the server-side one is what actually
+ // frees the cluster's resources, while the client-side one also bounds a
+ // stalled connect, a TLS handshake and a body that stops arriving part-way.
+ const provider = await connectProvider();
+
+ expect(bodyWith(CONNECT_PROBE).context).toEqual({ timeout: 60_000 });
+ expect(armedDeadlines).toEqual([65_000]);
+ await provider.disconnect();
+ });
+
+ test("connect maps a denial to an AuthenticationError", async () => {
+ replyFor = () => fail(403, deniedBody("FORBIDDEN"));
+ const provider = new DruidProvider(makeConnection({ user: "reader", password: "wrong" }));
+
+ await expect(provider.connect()).rejects.toBeInstanceOf(AuthenticationError);
+ expect(provider.isConnected()).toBe(false);
+ });
+
+ test("connect maps a rejected credential to an AuthenticationError too", async () => {
+ replyFor = () => fail(401, deniedBody("UNAUTHORIZED"));
+ const provider = new DruidProvider(makeConnection({ user: "reader", password: "wrong" }));
+
+ await expect(provider.connect()).rejects.toBeInstanceOf(AuthenticationError);
+ });
+
+ test("connect maps an unreachable cluster to a ConnectionError naming the target", async () => {
+ networkFailure = new Error("connect ECONNREFUSED 127.0.0.1:8888");
+ const provider = new DruidProvider(makeConnection());
+
+ const failure = provider.connect();
+
+ await expect(failure).rejects.toBeInstanceOf(ConnectionError);
+ await expect(failure).rejects.toThrow(/ECONNREFUSED/);
+ });
+
+ test("connect reports a cluster that answers something else as a ConnectionError", async () => {
+ // A proxy in front of the Broker, a wrong port, a Druid process that is not
+ // the SQL endpoint: the probe is what turns all of those into one failure at
+ // the moment the user is looking at the connection form.
+ replyFor = () => fail(404, "404 Not Found ");
+ const provider = new DruidProvider(makeConnection({ port: 8081 }));
+
+ await expect(provider.connect()).rejects.toBeInstanceOf(ConnectionError);
+ expect(provider.isConnected()).toBe(false);
+ });
+
+ test("a failed connect leaves nothing open behind it", async () => {
+ replyFor = () => fail(400, UNKNOWN_DATASOURCE);
+ const provider = new DruidProvider(makeConnection());
+
+ await expect(provider.connect()).rejects.toBeInstanceOf(ConnectionError);
+ await expect(provider.query(CONNECT_PROBE)).rejects.toBeInstanceOf(DatabaseConfigError);
+ });
+
+ test("disconnect releases the transport and is safe to call twice", async () => {
+ const provider = await connectProvider();
+
+ await provider.disconnect();
+ await provider.disconnect();
+
+ expect(provider.isConnected()).toBe(false);
+ });
+
+ test("every read before connect is refused rather than answered", async () => {
+ const provider = new DruidProvider(makeConnection());
+
+ await expect(provider.query(CONNECT_PROBE)).rejects.toBeInstanceOf(DatabaseConfigError);
+ await expect(provider.getSchema()).rejects.toBeInstanceOf(DatabaseConfigError);
+ await expect(provider.getOverview()).rejects.toBeInstanceOf(DatabaseConfigError);
+ await expect(provider.getActiveSessions()).rejects.toBeInstanceOf(DatabaseConfigError);
+ await expect(provider.getTableStats()).rejects.toBeInstanceOf(DatabaseConfigError);
+ await expect(provider.getStorageStats()).rejects.toBeInstanceOf(DatabaseConfigError);
+ await expect(provider.getHealth()).rejects.toBeInstanceOf(DatabaseConfigError);
+ expect(sentSql).toEqual([]);
+ });
+
+ test("the three constant reads answer without a connection, because they read nothing", async () => {
+ // Deliberately not guarded by the connection check above: Druid publishes no
+ // cache metrics, no query log and no index objects anywhere in SQL, so there
+ // is no statement to send and no answer a socket could change. Requiring one
+ // would only turn an honest empty into an error.
+ const provider = new DruidProvider(makeConnection());
+
+ expect(await provider.getPerformanceMetrics()).toEqual({});
+ expect(await provider.getSlowQueries()).toEqual([]);
+ expect(await provider.getIndexStats()).toEqual([]);
+ expect(sentSql).toEqual([]);
+ });
+});
+
+// ============================================================================
+// Query execution
+// ============================================================================
+
+describe("DruidProvider query", () => {
+ test("returns the rows, the declared column order and a measured duration", async () => {
+ const provider = await connectProvider();
+
+ const result = await provider.query('SELECT id, region, qty FROM "libredb_demo" LIMIT 2');
+
+ expect(result.rows).toEqual([
+ { id: 1000, region: "emea", qty: 0 },
+ { id: 1030, region: "emea", qty: 90 },
+ ]);
+ expect(result.fields).toEqual(["id", "region", "qty"]);
+ expect(result.rowCount).toBe(2);
+ expect(result.executionTime).toBeGreaterThanOrEqual(0);
+ });
+
+ test("gives the server a deadline and keeps a client-side one slightly above it", async () => {
+ const provider = await connectProvider();
+ armedDeadlines = [];
+
+ await provider.query("SELECT COUNT(*) FROM libredb_demo");
+
+ expect(bodyWith("COUNT(*)").context).toEqual({ timeout: 60_000 });
+ expect(armedDeadlines).toEqual([65_000]);
+ });
+
+ test("honours a configured query timeout on both halves", async () => {
+ const provider = new DruidProvider(makeConnection(), { queryTimeout: 5_000 });
+ await provider.connect();
+ armedDeadlines = [];
+
+ await provider.query("SELECT COUNT(*) FROM libredb_demo");
+
+ expect(bodyWith("COUNT(*)").context).toEqual({ timeout: 5_000 });
+ expect(armedDeadlines).toEqual([10_000]);
+ });
+
+ test("binds positional parameters, which Druid genuinely supports", async () => {
+ // Unlike ClickHouse (#264), whose HTTP interface binds only named parameters
+ // and whose provider therefore throws, `?` placeholders really execute here.
+ const provider = await connectProvider();
+
+ const result = await provider.query('SELECT id, region, qty FROM "libredb_demo" WHERE region = ?', ["emea"]);
+
+ expect(bodyWith("region = ?").parameters).toEqual([{ type: "VARCHAR", value: "emea" }]);
+ expect(result.rows).toHaveLength(2);
+ });
+
+ test("maps each parameter onto the type Druid expects, in order", async () => {
+ const provider = await connectProvider();
+
+ await provider.query("SELECT 1 WHERE a = ? AND b = ? AND c = ? AND d = ?", ["emea", 5, 1.5, true]);
+
+ expect(bodyWith("a = ?").parameters).toEqual([
+ { type: "VARCHAR", value: "emea" },
+ { type: "BIGINT", value: 5 },
+ { type: "DOUBLE", value: 1.5 },
+ { type: "BOOLEAN", value: true },
+ ]);
+ });
+
+ test("sends no parameters for a statement that has none", async () => {
+ const provider = await connectProvider();
+
+ await provider.query("SELECT COUNT(*) FROM libredb_demo");
+
+ expect(bodyWith("COUNT(*)")).not.toHaveProperty("parameters");
+ });
+
+ test("accepts an empty parameter array, which is how the app calls every provider", async () => {
+ const provider = await connectProvider();
+
+ await expect(provider.query("SELECT COUNT(*) FROM libredb_demo", [])).resolves.toBeDefined();
+ expect(bodyWith("COUNT(*)")).not.toHaveProperty("parameters");
+ });
+
+ test("keeps both columns of a duplicated output name", async () => {
+ // Live-verified and the reason the array result format was chosen: with the
+ // object format `SELECT 1 AS c, 2 AS c` answers [{"c":...},{"c":2}] and the
+ // first column is simply gone. A record cannot hold the repeat either, so the
+ // second one is disambiguated as the row is built.
+ const provider = await connectProvider();
+ replyFor = () => ok(DUPLICATE_COLUMN_BODY);
+
+ const result = await provider.query("SELECT 1 AS c, 2 AS c");
+
+ expect(result.fields).toEqual(["c", "c (2)"]);
+ expect(result.rows).toEqual([{ c: 1, "c (2)": 2 }]);
+ expect(result.rowCount).toBe(1);
+ });
+
+ test("delivers a 64-bit id exactly, as the string it has to become", async () => {
+ // The value on the wire is the unquoted number 9007199254740993, which
+ // JSON.parse turns into ...992 with no error at all. Druid has no
+ // server-side quoting setting, so the raw body is rewritten before parsing
+ // and the value reaches the grid as an exact string - the same thing the `pg`
+ // driver already does for int8.
+ const provider = await connectProvider();
+ replyFor = () => ok(BIGINT_BODY);
+
+ const result = await provider.query('SELECT id, name, snowflake_id FROM "libredb_demo" LIMIT 1');
+
+ expect(result.rows[0].snowflake_id).toBe("9007199254740993");
+ expect(result.rows[0].id).toBe(1000);
+ });
+
+ test("describes the columns of a result set with no rows", async () => {
+ // Live-verified: `WHERE id = -1` still answers all three header rows, so an
+ // empty grid still knows what it would have shown.
+ const provider = await connectProvider();
+ replyFor = () => ok(NO_ROWS_BODY);
+
+ const result = await provider.query("SELECT id FROM libredb_demo WHERE id = -1");
+
+ expect(result.rows).toEqual([]);
+ expect(result.fields).toEqual(["id"]);
+ expect(result.rowCount).toBe(0);
+ });
+
+ test("counts the rows it returned, because no Druid statement mutates", async () => {
+ // There is no written-row count to fall back on: UPDATE and DELETE are not in
+ // the grammar and INSERT/REPLACE need the MSQ task engine, so the row count
+ // is the number of rows returned and nothing else.
+ const provider = await connectProvider();
+
+ const result = await provider.query('SELECT id FROM "libredb_demo"');
+
+ expect(result.rowCount).toBe(result.rows.length);
+ });
+});
+
+// ============================================================================
+// Error mapping
+// ============================================================================
+
+describe("DruidProvider error mapping", () => {
+ test("a mistyped datasource becomes a QueryError carrying Druid's own message", async () => {
+ const provider = await connectProvider();
+ replyFor = () => fail(400, UNKNOWN_DATASOURCE);
+
+ const failure = provider.query("SELECT * FROM nope");
+
+ await expect(failure).rejects.toBeInstanceOf(QueryError);
+ await expect(failure).rejects.toThrow("Object 'nope' not found (line [1], column [15])");
+ });
+
+ test("the message never degrades to the envelope's discriminator", async () => {
+ // `error` holds the literal string "druidException" in the modern envelope,
+ // so showing it would print that word to the person who mistyped a name.
+ const provider = await connectProvider();
+ replyFor = () => fail(400, UNKNOWN_DATASOURCE);
+
+ await expect(provider.query("SELECT * FROM nope")).rejects.not.toThrow(/druidException/);
+ });
+
+ test("an HTTP 500 for a divide by zero is still the user's own error", async () => {
+ // Live-verified and the reason nothing here classifies on the status:
+ // `SELECT 1/0` answers 500 with `persona: ADMIN` and `category:
+ // UNCATEGORIZED` for an ordinary mistake. Reading 5xx as "the cluster is
+ // broken" would tell the user something false.
+ const provider = await connectProvider();
+ replyFor = () => fail(500, DIVIDE_BY_ZERO);
+
+ const failure = provider.query("SELECT 1/0 AS z");
+
+ await expect(failure).rejects.toBeInstanceOf(QueryError);
+ await expect(failure).rejects.not.toBeInstanceOf(ConnectionError);
+ await expect(failure).rejects.toThrow("/ by zero");
+ });
+
+ test("an exceeded deadline becomes a TimeoutError, from the legacy envelope", async () => {
+ const provider = await connectProvider();
+ replyFor = () => fail(504, LEGACY_TIMEOUT);
+
+ const failure = provider.query("SELECT COUNT(*) FROM libredb_demo");
+
+ await expect(failure).rejects.toBeInstanceOf(TimeoutError);
+ await expect(failure).rejects.toThrow(/timed out/);
+ });
+
+ test("a cancelled query becomes a QueryCancelledError", async () => {
+ const provider = await connectProvider();
+ replyFor = () => fail(500, CANCELED_BODY);
+
+ await expect(provider.query("SELECT COUNT(*) FROM libredb_demo")).rejects.toBeInstanceOf(QueryCancelledError);
+ });
+
+ test("a denial becomes an AuthenticationError", async () => {
+ const provider = await connectProvider();
+ denyEverything();
+
+ await expect(provider.query("SELECT * FROM sys.segments")).rejects.toBeInstanceOf(AuthenticationError);
+ });
+
+ test("a socket that never reached the cluster becomes a ConnectionError", async () => {
+ const provider = await connectProvider();
+ networkFailure = new Error("connect ECONNREFUSED 127.0.0.1:8888");
+
+ await expect(provider.query(CONNECT_PROBE)).rejects.toBeInstanceOf(ConnectionError);
+ });
+
+ test("a client-side stall becomes a TimeoutError", async () => {
+ const provider = await connectProvider();
+ networkFailure = new DOMException("The operation timed out.", "TimeoutError");
+
+ await expect(provider.query(CONNECT_PROBE)).rejects.toBeInstanceOf(TimeoutError);
+ });
+
+ test("a truncated response reports the incomplete answer it is", async () => {
+ // Live-reproduced: a large streamed result cancelled mid-flight answers 200,
+ // streams megabytes and then simply stops. Druid signals it by withholding a
+ // response TRAILER, which fetch cannot read, so the cut body is the only
+ // evidence there is - and reporting an empty success would be far worse.
+ const provider = await connectProvider();
+ replyFor = () => ok('[["pad"],["STRING"],["VARCHAR"],["gammagammagam');
+
+ const failure = provider.query("SELECT REPEAT(name, 200000) FROM libredb_demo");
+
+ await expect(failure).rejects.toBeInstanceOf(DatabaseError);
+ await expect(failure).rejects.toThrow(/incomplete/);
+ });
+
+ test("a proxy's HTML error page still surfaces as a database error naming the status", async () => {
+ const provider = await connectProvider();
+ replyFor = () => fail(502, "502 Bad Gateway ");
+
+ await expect(provider.query(CONNECT_PROBE)).rejects.toThrow(/HTTP 502/);
+ });
+
+ test("an unmappable parameter is refused before anything leaves the process", async () => {
+ // Sending a value the server would misread is worse than refusing it:
+ // JSON.stringify turns NaN and Infinity into `null`, which Druid would
+ // compare against as a null.
+ const provider = await connectProvider();
+ sentSql = [];
+
+ const failure = provider.query("SELECT 1 WHERE x = ?", [Symbol("s")]);
+
+ await expect(failure).rejects.toBeInstanceOf(DatabaseError);
+ await expect(failure).rejects.toThrow("Druid has no parameter type for a value of type symbol");
+ expect(sentSql).toEqual([]);
+ });
+
+ test.each<[string, string, string]>([
+ ["INSERT", "INSERT INTO libredb_demo SELECT * FROM libredb_rollup", UNSUPPORTED_INSERT],
+ ["UPDATE", "UPDATE libredb_demo SET qty = 1 WHERE id = 1", UNSUPPORTED_UPDATE],
+ ])("surfaces Druid's own explanation of why %s is unsupported", async (_label, sql, envelope) => {
+ // Deliberately NOT special-cased: the server's message already names both the
+ // reason and the alternative ("consider using MSQ"), which is more useful
+ // than anything the provider could substitute.
+ const provider = await connectProvider();
+ replyFor = () => fail(400, envelope);
+
+ const failure = provider.query(sql);
+
+ await expect(failure).rejects.toBeInstanceOf(QueryError);
+ await expect(failure).rejects.toThrow(JSON.parse(envelope).errorMessage as string);
+ });
+});
+
+// ============================================================================
+// Query preparation (the OFFSET override)
+// ============================================================================
+
+describe("DruidProvider query preparation", () => {
+ const provider = () => new DruidProvider(makeConnection());
+
+ test("applies the external row limit to a plain SELECT", () => {
+ const prepared = provider().prepareQuery('SELECT * FROM "libredb_demo"', { limit: 25 });
+
+ expect(prepared.query).toBe('SELECT * FROM "libredb_demo" LIMIT 25');
+ expect(prepared.wasLimited).toBe(true);
+ expect(prepared.limit).toBe(25);
+ });
+
+ test("keeps a trailing semicolon, which Druid accepts", () => {
+ const prepared = provider().prepareQuery('SELECT * FROM "libredb_demo";', { limit: 25 });
+
+ expect(prepared.query).toBe('SELECT * FROM "libredb_demo" LIMIT 25;');
+ });
+
+ test.each([
+ ["OFFSET as the last clause", "SELECT id FROM libredb_demo ORDER BY __time OFFSET 2"],
+ ["OFFSET followed by a semicolon", "SELECT id FROM libredb_demo ORDER BY __time OFFSET 2;"],
+ ["OFFSET padded with whitespace", "SELECT id FROM libredb_demo OFFSET 2 "],
+ ])("leaves a statement ending in %s untouched", (_label, sql) => {
+ // Live-verified: `SELECT id FROM libredb_demo OFFSET 2 LIMIT 3` answers 400
+ // "'OFFSET start LIMIT count' is not allowed under the current SQL
+ // conformance level". Same bias as ClickHouse's trailing-clause case -
+ // rewriting wrongly fails the query outright, while leaving it alone only
+ // returns more rows.
+ const prepared = provider().prepareQuery(sql, { limit: 25 });
+
+ expect(prepared.query).toBe(sql);
+ expect(prepared.wasLimited).toBe(false);
+ expect(prepared.limit).toBe(25);
+ });
+
+ test("still limits a statement that merely mentions an offset column", () => {
+ const sql = "SELECT offset_minutes FROM libredb_demo WHERE region = 'emea'";
+
+ const prepared = provider().prepareQuery(sql, { limit: 25 });
+
+ expect(prepared.query).toBe(`${sql} LIMIT 25`);
+ expect(prepared.wasLimited).toBe(true);
+ });
+
+ test("preserves a LIMIT the user wrote, with or without an OFFSET after it", () => {
+ // `LIMIT 5 LIMIT 25` is a syntax error; the shared limiter never produces it
+ // because it leaves an existing LIMIT alone.
+ const withOffset = provider().prepareQuery("SELECT id FROM libredb_demo LIMIT 3 OFFSET 2", { limit: 25 });
+ const withoutOffset = provider().prepareQuery("SELECT id FROM libredb_demo LIMIT 3", { limit: 25 });
+
+ expect(withOffset.query).toBe("SELECT id FROM libredb_demo LIMIT 3 OFFSET 2");
+ expect(withOffset.wasLimited).toBe(false);
+ expect(withoutOffset.query).toBe("SELECT id FROM libredb_demo LIMIT 3");
+ expect(withoutOffset.wasLimited).toBe(false);
+ });
+
+ test("paginates with LIMIT n OFFSET m, which is correct Druid SQL in that order", () => {
+ const prepared = provider().prepareQuery('SELECT * FROM "libredb_demo"', { limit: 25, offset: 50 });
+
+ expect(prepared.query).toBe('SELECT * FROM "libredb_demo" LIMIT 25 OFFSET 50');
+ expect(prepared.wasLimited).toBe(true);
+ });
+
+ test("lifts the ceiling for an unlimited export", () => {
+ const prepared = provider().prepareQuery('SELECT * FROM "libredb_demo"', { unlimited: true });
+
+ expect(prepared.query).toBe('SELECT * FROM "libredb_demo" LIMIT 100000');
+ expect(prepared.limit).toBe(100000);
+ });
+
+ test("leaves a statement that is not a SELECT alone", () => {
+ const prepared = provider().prepareQuery("INSERT INTO libredb_demo SELECT * FROM libredb_rollup");
+
+ expect(prepared.query).toBe("INSERT INTO libredb_demo SELECT * FROM libredb_rollup");
+ expect(prepared.wasLimited).toBe(false);
+ });
+
+ test("never appends a limit to an EXPLAIN, so the double-limit syntax error cannot happen", () => {
+ // `EXPLAIN PLAN FOR SELECT * FROM libredb_demo LIMIT 500` plans fine, but
+ // `... LIMIT 5 LIMIT 500` is a 400. The explain statement is not a SELECT, so
+ // the limiter never touches it.
+ const prepared = provider().prepareQuery("EXPLAIN PLAN FOR SELECT * FROM libredb_demo LIMIT 5");
+
+ expect(prepared.query).toBe("EXPLAIN PLAN FOR SELECT * FROM libredb_demo LIMIT 5");
+ expect(prepared.wasLimited).toBe(false);
+ });
+});
+
+// ============================================================================
+// Schema
+// ============================================================================
+
+describe("DruidProvider schema", () => {
+ test("getSchema lists the datasources by their bare names, with their columns", async () => {
+ // `druid` is the default schema, so `SELECT * FROM "libredb_demo"` resolves
+ // and no qualification is needed anywhere.
+ const provider = await connectProvider();
+
+ const schema = await provider.getSchema();
+
+ expect(schema.map((table) => table.name)).toEqual(["libredb_demo", "libredb_rollup"]);
+ expect(schema[1].columns).toEqual([
+ { name: "__time", type: "TIMESTAMP", nullable: false, isPrimary: false },
+ { name: "id", type: "BIGINT", nullable: true, isPrimary: false },
+ { name: "qty", type: "BIGINT", nullable: true, isPrimary: false },
+ ]);
+ });
+
+ test("getSchema marks no column primary, and __time is the one NOT NULL column", async () => {
+ // `__time` is mandatory, it is the partitioning and sort key, and it is the only
+ // column Druid reports as IS_NULLABLE = 'NO' - but it is not UNIQUE (50 rows, 30
+ // distinct values live), and `isPrimary` is read as PRIMARY KEY by autocomplete,
+ // by the AI schema context and by the schema differ. Nullability is how the time
+ // column is identified instead.
+ const provider = await connectProvider();
+
+ const schema = await provider.getSchema();
+
+ expect(schema[0].columns.filter((column) => column.isPrimary)).toEqual([]);
+ expect(schema[0].columns.filter((column) => !column.nullable).map((column) => column.name)).toEqual(["__time"]);
+ expect(schema[0].columns.map((column) => column.name)).toEqual([
+ "__time",
+ "snowflake_id",
+ "id",
+ "name",
+ "region",
+ "qty",
+ "amount",
+ "row_count",
+ ]);
+ });
+
+ test("getSchema reports no indexes and no foreign keys, because Druid has neither", async () => {
+ // Every dimension is indexed inside the segment, with no name, no size and no
+ // usage counter of its own, and there is no DDL that could declare a key.
+ const provider = await connectProvider();
+
+ const schema = await provider.getSchema();
+
+ expect(schema.every((table) => table.indexes.length === 0)).toBe(true);
+ expect(schema.every((table) => table.foreignKeys?.length === 0)).toBe(true);
+ });
+
+ test("getSchema leaves row counts and sizes unset rather than reading them from sys", async () => {
+ // Deliberate: `sys.segments` is permission-gated separately from the
+ // catalogs, so reading counts there would make the whole sidebar fail on a
+ // cluster that merely declines to describe its servers. The counts live in
+ // getTableStats(), where a denial costs one panel.
+ const provider = await connectProvider();
+
+ const schema = await provider.getSchema();
+
+ expect(schema.every((table) => table.rowCount === undefined)).toBe(true);
+ expect(schema.every((table) => table.size === undefined)).toBe(true);
+ expect(sentAnything("sys.segments")).toBe(false);
+ });
+
+ test("getSchema reads the catalogs and nothing else", async () => {
+ const provider = await connectProvider();
+
+ await provider.getSchema();
+
+ expect(sqlWith("INFORMATION_SCHEMA.TABLES")).toBe(DRUID_TABLE_LIST_SQL);
+ expect(sqlWith("INFORMATION_SCHEMA.COLUMNS")).toBe(DRUID_COLUMN_LIST_SQL);
+ expect(sentSql).toHaveLength(3);
+ });
+
+ test("getTables lists the datasource names", async () => {
+ const provider = await connectProvider();
+
+ expect(await provider.getTables()).toEqual(["libredb_demo", "libredb_rollup"]);
+ });
+
+ test("a datasource with no segments left is simply absent", async () => {
+ // Live-verified through the Coordinator's markUnused: a datasource whose
+ // segments are all unused disappears from INFORMATION_SCHEMA.TABLES
+ // entirely, so there is no empty-datasource row to render.
+ const provider = await connectProvider();
+ overrideSurface(DRUID_TABLE_LIST_SQL, ok('[["tableName"],["STRING"],["VARCHAR"]]'));
+
+ expect(await provider.getSchema()).toEqual([]);
+ });
+
+ test("a denied catalog yields an empty tree instead of an error page", async () => {
+ const provider = await connectProvider();
+ denyEverything();
+
+ expect(await provider.getSchema()).toEqual([]);
+ });
+
+ test("a catalog failure that is not a denial propagates", async () => {
+ // An empty sidebar in place of a real error hides it forever.
+ const provider = await connectProvider();
+ replyFor = () => fail(400, UNKNOWN_DATASOURCE);
+
+ await expect(provider.getSchema()).rejects.toBeInstanceOf(QueryError);
+ });
+
+ test("declares neither getSchemaList nor getSchemaRelations", async () => {
+ // Both are optional, and the split does not fit Druid: with no user-defined
+ // indexes and no foreign keys, getSchemaList would be byte-identical to
+ // getSchema and getSchemaRelations would spend a round trip to answer
+ // `{ indexes: [], foreignKeys: [] }` per datasource. The client falls back to
+ // getSchema(), so declaring them would only add two network calls.
+ const provider = await connectProvider();
+ const surface = provider as unknown as Record;
+
+ expect(surface.getSchemaList).toBeUndefined();
+ expect(surface.getSchemaRelations).toBeUndefined();
+ });
+});
+
+// ============================================================================
+// Monitoring
+// ============================================================================
+
+describe("DruidProvider monitoring", () => {
+ test("getOverview describes the cluster from four separate reads", async () => {
+ // Four rather than one joined statement: `sys` permissions are granted per
+ // table, so a cluster that declines sys.tasks must still report the
+ // datasource count INFORMATION_SCHEMA answers happily.
+ const provider = await connectProvider();
+
+ const overview = await provider.getOverview();
+
+ expect(overview.version).toBe("37.0.0");
+ expect(overview.uptime).toBe("1.14h");
+ expect(overview.startTime).toEqual(new Date("2026-08-03T14:29:00.534Z"));
+ expect(overview.databaseSizeBytes).toBe(19617);
+ expect(overview.databaseSize).toBe("19.16 KB");
+ expect(overview.tableCount).toBe(2);
+ // No index objects exist, and Druid publishes no connection limit anywhere in
+ // SQL - it has no pool - so both would be numbers the editor made up.
+ expect(overview.indexCount).toBe(0);
+ expect(overview.maxConnections).toBe(0);
+ });
+
+ test("getOverview reads an absent row as zero running tasks", async () => {
+ // The ordinary case on a quiet cluster: the aggregate answers with the column
+ // rows and NO data row, which is not the same as a row holding 0.
+ const provider = await connectProvider();
+
+ expect((await provider.getOverview()).activeConnections).toBe(0);
+ });
+
+ test("getOverview counts a running ingestion task as the occupied slot it is", async () => {
+ // Druid has no query sessions to count, so a running task is the only
+ // activity it can report.
+ const provider = await connectProvider();
+ overrideSurface(DRUID_RUNNING_TASK_COUNT_SQL, ok(ONE_RUNNING_TASK_BODY));
+
+ expect((await provider.getOverview()).activeConnections).toBe(1);
+ });
+
+ test("getOverview degrades to unknown rather than claiming the cluster just booted", async () => {
+ const provider = await connectProvider();
+ denyEverything();
+
+ const overview = await provider.getOverview();
+
+ expect(overview.version).toBe("unknown");
+ expect(overview.uptime).toBe("unknown");
+ expect(overview.startTime).toBeUndefined();
+ expect(overview.activeConnections).toBe(0);
+ expect(overview.databaseSizeBytes).toBe(0);
+ expect(overview.tableCount).toBe(0);
+ });
+
+ test("a monitoring failure that is not a denial propagates", async () => {
+ const provider = await connectProvider();
+ replyFor = () => fail(500, DIVIDE_BY_ZERO);
+
+ await expect(provider.getOverview()).rejects.toBeInstanceOf(QueryError);
+ });
+
+ test("getPerformanceMetrics reports only the ratio the type demands, and asks nothing", async () => {
+ // Druid's cache and query metrics reach a metrics emitter - statsd, Kafka, the
+ // log - and never a SQL-readable table. Every other field is optional, so
+ // absence expresses "not reported"; a 0 would read as a measurement of zero.
+ const provider = await connectProvider();
+ sentSql = [];
+
+ const performance = await provider.getPerformanceMetrics();
+
+ expect(performance).toEqual({});
+ expect(sentSql).toEqual([]);
+ });
+
+ test("getSlowQueries is empty and sends no statement, because there is no query log", async () => {
+ // Not a switched-off feature and not a permission gate: there is no sys
+ // table, no endpoint and no file holding finished queries, so unlike
+ // ClickHouse's system.query_log there is nothing to ask.
+ const provider = await connectProvider();
+ sentSql = [];
+ // Also called the way the monitoring panel calls it - through the interface,
+ // with a row cap. The provider declares no parameter at all, because a list
+ // that is always empty has nothing to cap, and the narrower signature still
+ // satisfies every caller.
+ const monitored: DatabaseProvider = provider;
+
+ expect(await provider.getSlowQueries()).toEqual([]);
+ expect(await monitored.getSlowQueries({ limit: 5 })).toEqual([]);
+ expect(sentSql).toEqual([]);
+ });
+
+ test("getIndexStats is empty and sends no statement, because no index objects exist", async () => {
+ const provider = await connectProvider();
+ sentSql = [];
+ const monitored: DatabaseProvider = provider;
+
+ expect(await provider.getIndexStats()).toEqual([]);
+ expect(await monitored.getIndexStats({ schema: "druid" })).toEqual([]);
+ expect(sentSql).toEqual([]);
+ });
+
+ test("getActiveSessions describes the unfinished tasks, timed against the server's own clock", async () => {
+ // `sys.tasks.duration` is -1 for every task this read selects (live-verified
+ // against a noop task submitted to the running cluster), so the age is
+ // CURRENT_TIMESTAMP minus created_time - both values from the server, since
+ // the editor's clock may be skewed and no expression over a sys column plans.
+ const provider = await connectProvider();
+ overrideSurface(DRUID_ACTIVE_TASK_SQL, ok(ACTIVE_TASKS_BODY));
+
+ const sessions = await provider.getActiveSessions();
+
+ expect(sessions[0]).toEqual({
+ pid: "noop_2026-08-03T15:41:40.345Z_166088c6-0e19-4ba0-8b75-873392f4ce34",
+ // sys.tasks records no submitter identity - a druid-basic-security cluster
+ // puts it in the audit log - and borrowing the connection's user would
+ // credit it with a task it did not submit.
+ user: "unknown",
+ // Live-verified: a task with no datasource reports the literal "none".
+ database: "none",
+ applicationName: "Druid ingestion task",
+ state: "RUNNING",
+ // The task TYPE, which is the closest thing a task has to a statement.
+ query: "noop",
+ queryStart: new Date("2026-08-03T15:41:40.346Z"),
+ duration: "22.86s",
+ durationMs: 22864,
+ });
+ expect(sessions[1].database).toBe("libredb_demo");
+ expect(sessions[1].query).toBe("index_parallel");
+ expect(sessions[1].durationMs).toBe(4083730);
+ });
+
+ test("getActiveSessions never prints the -1 the duration column carries", async () => {
+ const provider = await connectProvider();
+ overrideSurface(DRUID_ACTIVE_TASK_SQL, ok(ACTIVE_TASKS_BODY));
+
+ const sessions = await provider.getActiveSessions();
+
+ expect(sessions.every((session) => session.durationMs > 0)).toBe(true);
+ expect(sessions.map((session) => session.duration)).not.toContain("-1ms");
+ });
+
+ test("getActiveSessions caps the rows, defaulting to 50", async () => {
+ const provider = await connectProvider();
+
+ await provider.getActiveSessions({ limit: 7 });
+ await provider.getActiveSessions();
+ await provider.getActiveSessions({ limit: 0 });
+
+ const reads = sentSql.filter((sql) => sql.startsWith(DRUID_ACTIVE_TASK_SQL));
+ expect(reads[0]).toBe(`${DRUID_ACTIVE_TASK_SQL} LIMIT 7`);
+ expect(reads[1]).toBe(`${DRUID_ACTIVE_TASK_SQL} LIMIT 50`);
+ expect(reads[2]).toBe(`${DRUID_ACTIVE_TASK_SQL} LIMIT 50`);
+ });
+
+ test("getActiveSessions is empty on a quiet cluster and on a denied one", async () => {
+ const provider = await connectProvider();
+
+ expect(await provider.getActiveSessions()).toEqual([]);
+ denyEverything();
+ expect(await provider.getActiveSessions()).toEqual([]);
+ });
+
+ test("getTableStats reports rows and bytes per datasource, from the active segments", async () => {
+ // Active only: `sys.segments` still lists a segment that a compaction or a
+ // re-ingestion superseded, so summing everything would double-count both the
+ // rows and the bytes.
+ const provider = await connectProvider();
+
+ const stats = await provider.getTableStats();
+
+ expect(stats).toEqual([
+ {
+ schemaName: "druid",
+ tableName: "libredb_demo",
+ rowCount: 50,
+ // The dimension indexes are inside the segment, so the table size and the
+ // total size are the same number rather than one being the other plus an
+ // index total - and the optional index size stays absent.
+ tableSize: "9.96 KB",
+ tableSizeBytes: 10203,
+ totalSize: "9.96 KB",
+ totalSizeBytes: 10203,
+ },
+ {
+ schemaName: "druid",
+ tableName: "libredb_rollup",
+ rowCount: 20,
+ tableSize: "9.19 KB",
+ tableSizeBytes: 9414,
+ totalSize: "9.19 KB",
+ totalSizeBytes: 9414,
+ },
+ ]);
+ });
+
+ test("getTableStats answers a foreign schema without a round trip", async () => {
+ // `druid` is the only schema holding datasources, so any other value selects
+ // nothing, and a predicate that can never match is slower and less obviously
+ // right than not asking.
+ const provider = await connectProvider();
+ sentSql = [];
+
+ expect(await provider.getTableStats({ schema: "sys" })).toEqual([]);
+ expect(sentSql).toEqual([]);
+ expect(await provider.getTableStats({ schema: "druid" })).toHaveLength(2);
+ expect(sentSql).toHaveLength(1);
+ });
+
+ test("getTableStats returns empty when sys.segments is denied", async () => {
+ const provider = await connectProvider();
+ denyEverything();
+
+ expect(await provider.getTableStats()).toEqual([]);
+ });
+
+ test("getStorageStats reports each historical's segment cache", async () => {
+ // The historicals are the only processes that hold segments: live-verified,
+ // the Coordinator, Overlord, Broker, Router and MiddleManager rows of this
+ // same table all report curr_size 0 and max_size 0.
+ const provider = await connectProvider();
+
+ const storage = await provider.getStorageStats();
+
+ expect(storage).toEqual([
+ {
+ name: "172.18.0.5:8083",
+ location: "172.18.0.5",
+ size: "19.16 KB",
+ sizeBytes: 19617,
+ // 19 KB in a 300 GB cache: the honest rounded percentage really is 0.
+ usagePercent: 0,
+ },
+ ]);
+ });
+
+ test("getStorageStats divides by the configured capacity when there is some in use", async () => {
+ // Constructed rather than captured: this cluster's historical is nearly
+ // empty, so a meaningful percentage needs a fuller cache than it has.
+ const provider = await connectProvider();
+ overrideSurface(
+ DRUID_HISTORICAL_STORAGE_SQL,
+ ok(
+ '[["server","host","currSize","maxSize"],["STRING","STRING","LONG","LONG"],' +
+ '["VARCHAR","VARCHAR","BIGINT","BIGINT"],["172.18.0.5:8083","172.18.0.5",150000000000,300000000000]]',
+ ),
+ );
+
+ expect((await provider.getStorageStats())[0].usagePercent).toBe(50);
+ });
+
+ test("getStorageStats survives a historical with no configured capacity", async () => {
+ // A zero denominator is real data here rather than a defensive guess, and a
+ // flattering 100% would report a fault that does not exist.
+ const provider = await connectProvider();
+ overrideSurface(
+ DRUID_HISTORICAL_STORAGE_SQL,
+ ok(
+ '[["server","host","currSize","maxSize"],["STRING","STRING","LONG","LONG"],' +
+ '["VARCHAR","VARCHAR","BIGINT","BIGINT"],["172.18.0.5:8083","172.18.0.5",0,0]]',
+ ),
+ );
+
+ expect((await provider.getStorageStats())[0].usagePercent).toBe(0);
+ });
+
+ test("getStorageStats returns empty when sys.servers is denied", async () => {
+ const provider = await connectProvider();
+ denyEverything();
+
+ expect(await provider.getStorageStats()).toEqual([]);
+ });
+
+ test("getHealth says the cache ratio is unavailable rather than inventing one", async () => {
+ // The field is a STRING, so it can say so - and a fabricated low number would
+ // trip the cache-ratio threshold into reporting a fault that does not exist.
+ // sqlite.ts and oracle.ts already spell an unavailable ratio this way.
+ const provider = await connectProvider();
+ overrideSurface(DRUID_ACTIVE_TASK_SQL, ok(ACTIVE_TASKS_BODY));
+
+ const health = await provider.getHealth();
+
+ expect(health.cacheHitRatio).toBe("N/A");
+ expect(health.activeConnections).toBe(0);
+ expect(health.databaseSize).toBe("19.16 KB");
+ expect(health.slowQueries).toEqual([]);
+ expect(health.activeSessions[0]).toEqual({
+ pid: "noop_2026-08-03T15:41:40.345Z_166088c6-0e19-4ba0-8b75-873392f4ce34",
+ user: "unknown",
+ database: "none",
+ state: "RUNNING",
+ query: "noop",
+ duration: "22.86s",
+ });
+ });
+
+ test("getHealth reads at most ten sessions", async () => {
+ const provider = await connectProvider();
+
+ await provider.getHealth();
+
+ expect(sqlWith(DRUID_ACTIVE_TASK_SQL)).toBe(`${DRUID_ACTIVE_TASK_SQL} LIMIT 10`);
+ });
+
+ test("getMonitoringData survives a user who may read nothing", async () => {
+ const provider = await connectProvider();
+ denyEverything();
+
+ const data = await provider.getMonitoringData();
+
+ expect(data.overview.version).toBe("unknown");
+ expect(data.performance).toEqual({});
+ expect(data.slowQueries).toEqual([]);
+ expect(data.activeSessions).toEqual([]);
+ expect(data.tables).toEqual([]);
+ expect(data.indexes).toEqual([]);
+ expect(data.storage).toEqual([]);
+ });
+
+ test("getMonitoringData fills every panel on a healthy cluster", async () => {
+ const provider = await connectProvider();
+ overrideSurface(DRUID_ACTIVE_TASK_SQL, ok(ACTIVE_TASKS_BODY));
+
+ const data = await provider.getMonitoringData();
+
+ expect(data.overview.version).toBe("37.0.0");
+ expect(data.activeSessions).toHaveLength(2);
+ expect(data.tables).toHaveLength(2);
+ expect(data.storage).toHaveLength(1);
+ });
+});
+
+// ============================================================================
+// Maintenance
+// ============================================================================
+
+describe("DruidProvider maintenance", () => {
+ test.each<[string]>([
+ ["vacuum"],
+ ["analyze"],
+ ["reindex"],
+ ["kill"],
+ ["optimize"],
+ ["check"],
+ ])("refuses %s, because SQL reaches no Druid equivalent", async (operation) => {
+ // Absent from maintenanceOperations, so the UI never offers any of these;
+ // the refusal exists so a direct API call gets an explanation rather than a
+ // statement the server would reject.
+ const provider = await connectProvider();
+
+ const failure = provider.runMaintenance(operation as "vacuum");
+
+ await expect(failure).rejects.toBeInstanceOf(QueryError);
+ await expect(failure).rejects.toThrow(operation);
+ expect(sentSql).toEqual([CONNECT_PROBE]);
+ });
+});
+
+// ============================================================================
+// EXPLAIN (the round trip the provider's capability promises)
+// ============================================================================
+
+function walk(node: ExplainTreeNode, seen: ExplainTreeNode[] = []): ExplainTreeNode[] {
+ seen.push(node);
+ for (const child of node.children) walk(child, seen);
+ return seen;
+}
+
+describe("DruidProvider explain", () => {
+ test("the declared format resolves to a registered strategy", () => {
+ const capabilities = new DruidProvider(makeConnection()).getCapabilities();
+
+ expect(getExplainStrategy(capabilities.explainFormat)?.format).toBe("druid-native");
+ });
+
+ test("runs the strategy's statement and renders the plan Druid answered with", async () => {
+ const provider = await connectProvider();
+ const strategy = getExplainStrategy(provider.getCapabilities().explainFormat);
+ const sql = strategy?.buildSql('SELECT * FROM "libredb_demo" LIMIT 500', "analyze");
+
+ const result = await provider.query(sql ?? "");
+ const stored = strategy?.extractPlan({ rows: result.rows });
+ const model = strategy?.toRenderModel(stored);
+
+ expect(sql).toBe('EXPLAIN PLAN FOR SELECT * FROM "libredb_demo" LIMIT 500');
+ // The three columns arrive as JSON TEXT, so the envelope parse leaves three
+ // escaped blobs behind; parsing them here is what gives the raw-JSON and AI
+ // tabs a structure rather than one long escaped string.
+ expect(stored).toEqual({ plan: EXPLAIN_PLAN, resources: EXPLAIN_RESOURCES, attributes: EXPLAIN_ATTRIBUTES });
+ expect(model?.kind).toBe("tree");
+
+ const root = (model as { root: ExplainTreeNode }).root;
+ expect(root.label).toBe("scan");
+ expect(root.children.map((child) => child.label)).toEqual(["table libredb_demo", "granularity: all"]);
+ // No cost and no row estimate on any node: Druid's planner emits neither, and
+ // an empty metrics column is the honest render rather than a fabricated zero.
+ expect(walk(root).every((node) => node.metrics === undefined)).toBe(true);
+ });
+
+ test("the upper-case column names the Broker sends are what the strategy reads", async () => {
+ // If the transport ever lower-cased or renamed the header row, extractPlan
+ // would fall back to the raw rows and no tree would ever render.
+ const provider = await connectProvider();
+
+ const result = await provider.query("EXPLAIN PLAN FOR SELECT 1");
+
+ expect(result.fields).toEqual(["PLAN", "RESOURCES", "ATTRIBUTES"]);
+ });
+
+ test("an EXPLAIN of a statement Druid cannot run is never built", async () => {
+ // UPDATE and DELETE are not in the grammar and INSERT/REPLACE need the MSQ
+ // task engine, so none of them is explainable through this endpoint.
+ const provider = await connectProvider();
+ const strategy = getExplainStrategy(provider.getCapabilities().explainFormat);
+
+ expect(strategy?.buildSql("UPDATE libredb_demo SET qty = 1", "analyze")).toBeNull();
+ });
+});
diff --git a/tests/unit/db/druid/http-transport.test.ts b/tests/unit/db/druid/http-transport.test.ts
new file mode 100644
index 00000000..8b223396
--- /dev/null
+++ b/tests/unit/db/druid/http-transport.test.ts
@@ -0,0 +1,953 @@
+/**
+ * Druid HTTP transport (issue #265, design spec sections 2, 3, 5, 6, 11 and 13)
+ *
+ * globalThis.fetch is replaced per test and restored in afterEach. mock.module()
+ * is deliberately not used: it is process-wide in bun, so mocking a module here
+ * would poison every sibling test file sharing the process.
+ *
+ * Every response replayed below was captured verbatim from Apache Druid 37.0.0
+ * over `POST /druid/v2/sql`, including the shapes that look like bugs and are not:
+ * a 64-bit integer sent as an UNQUOTED JSON number that JSON.parse silently
+ * rounds, an error whose `error` field is the literal string "druidException"
+ * rather than a message, an HTTP 500 for `SELECT 1/0` that is an ordinary user
+ * mistake, and a cancelled query that answers 200 and then truncates its own
+ * body. Those are the cases a hand-written envelope parser gets wrong, so they
+ * are pinned here rather than assumed.
+ */
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { DruidHttpTransport } from "@/lib/db/providers/sql/druid/http-transport";
+import { DRUID_TRANSPORT_FAILURE, DruidTransportError } from "@/lib/db/providers/sql/druid/transport";
+import type { DatabaseConnection, DatabaseType } from "@/lib/db/types";
+
+// ============================================================================
+// Harness
+// ============================================================================
+
+// The DatabaseType union gains "druid" in the registration commit; the double
+// assertion keeps this file compiling on either side of that change.
+const DRUID: DatabaseType = "druid" as unknown as DatabaseType;
+
+interface FetchCall {
+ url: string;
+ init: RequestInit | undefined;
+}
+
+const originalFetch = globalThis.fetch;
+let calls: FetchCall[] = [];
+let handler: (url: string, init?: RequestInit) => Response | Promise;
+
+function respond(body: string, init: { status?: number; headers?: Record } = {}): Response {
+ return new Response(body, {
+ status: init.status ?? 200,
+ // Live-verified: every answer, success and failure alike, is
+ // `Content-Type: application/json`.
+ headers: { "content-type": "application/json", ...init.headers },
+ });
+}
+
+/**
+ * `SELECT id, name, snowflake_id FROM libredb_demo WHERE region = ? LIMIT 1` as
+ * the server returned it: three header rows (names, NATIVE types, SQL types) and
+ * then the data, with the BIGINT unquoted - the exact 2^53+1 value that
+ * JSON.parse rounds to ...992 (spec section 3).
+ */
+const SELECT_BODY =
+ '[["id","name","snowflake_id"],["LONG","STRING","LONG"],["BIGINT","VARCHAR","BIGINT"],[1030,"alpha",9007199254740993]]';
+
+/**
+ * `SELECT CURRENT_TIMESTAMP AS t, (1 = 1) AS b, ARRAY[1,2] AS nums,
+ * ARRAY['alpha'] AS words, 1.5 AS d, CAST(NULL AS VARCHAR) AS n` verbatim.
+ *
+ * The two rows spec section 2 calls out are both here: the native type LIES for
+ * `t` (LONG for an ISO string) and for `b` (LONG for `true`), and an ARRAY cell
+ * arrives as a JSON STRING because `sqlStringifyArrays` defaults to true.
+ */
+const TYPED_BODY =
+ '[["t","b","nums","words","d","n"],["LONG","LONG","ARRAY","ARRAY","DOUBLE","STRING"],' +
+ '["TIMESTAMP","BOOLEAN","ARRAY","ARRAY","DECIMAL","VARCHAR"],' +
+ '["2026-08-03T15:17:00.549Z",true,"[1,2]","[\\"alpha\\"]",1.5,null]]';
+
+function makeConnection(overrides: Partial = {}): DatabaseConnection {
+ return {
+ id: "druid-1",
+ name: "Druid",
+ type: DRUID,
+ host: "127.0.0.1",
+ port: 8888,
+ createdAt: new Date(),
+ ...overrides,
+ };
+}
+
+function makeTransport(overrides: Partial = {}): DruidHttpTransport {
+ return new DruidHttpTransport(makeConnection(overrides));
+}
+
+function lastCall(): FetchCall {
+ const call = calls.at(-1);
+ if (!call) throw new Error("no request was made");
+ return call;
+}
+
+/** The request body as text, which is what the 64-bit assertions have to read. */
+function lastBodyText(): string {
+ return String(lastCall().init?.body);
+}
+
+function lastBody(): Record {
+ return JSON.parse(lastBodyText()) as Record;
+}
+
+function lastHeader(name: string): string | undefined {
+ return (lastCall().init?.headers as Record | undefined)?.[name];
+}
+
+/** The thrown DruidTransportError, or a failed expectation if none was thrown. */
+async function captureError(run: () => Promise): Promise {
+ try {
+ await run();
+ } catch (caught) {
+ expect(caught).toBeInstanceOf(DruidTransportError);
+ return caught as DruidTransportError;
+ }
+ throw new Error("the transport resolved where it should have thrown");
+}
+
+beforeEach(() => {
+ calls = [];
+ handler = () => respond(SELECT_BODY);
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input.toString();
+ calls.push({ url, init });
+ return await handler(url, init);
+ }) as unknown as typeof fetch;
+});
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+});
+
+// ============================================================================
+// The request
+// ============================================================================
+
+describe("DruidHttpTransport request", () => {
+ test("posts the statement verbatim to the SQL endpoint", async () => {
+ await makeTransport().query('SELECT id FROM "libredb_demo"');
+
+ expect(lastCall().url).toBe("http://127.0.0.1:8888/druid/v2/sql");
+ expect(lastCall().init?.method).toBe("POST");
+ expect(lastBody().query).toBe('SELECT id FROM "libredb_demo"');
+ });
+
+ // Live-verified: without this header the endpoint answers HTTP 400 before it
+ // ever looks at the statement.
+ test("declares the body as JSON, which the endpoint requires", async () => {
+ await makeTransport().query("SELECT 1");
+
+ expect(lastHeader("content-type")).toBe("application/json");
+ });
+
+ /**
+ * Spec section 2, and a correctness decision rather than a preference.
+ * Live-verified on 37.0.0:
+ * SELECT 1 AS c, 2 AS c with resultFormat "object" -> [{"c":{...}},{"c":2}]
+ * The object form silently drops every duplicate column but the last, and
+ * duplicate output names are legal SQL that real joins produce.
+ */
+ test('asks for the array result format, never "object"', async () => {
+ await makeTransport().query("SELECT 1 AS c, 2 AS c");
+
+ expect(lastBody().resultFormat).toBe("array");
+ });
+
+ // All three, because the array form is positional: without them the response
+ // carries no names and no types at all.
+ test("asks for all three header rows", async () => {
+ await makeTransport().query("SELECT 1");
+
+ expect(lastBody()).toMatchObject({ header: true, typesHeader: true, sqlTypesHeader: true });
+ });
+
+ // Spec section 6, first half: verified, `timeout: 1` answers 504 with
+ // `category: TIMEOUT` on a statement that otherwise takes milliseconds. Asking
+ // the server to stop is what frees the cluster; abandoning the request
+ // client-side leaves the query running.
+ test("asks the server to stop at the deadline it was given", async () => {
+ await makeTransport().query("SELECT 1", { timeoutMs: 30_000 });
+
+ expect(lastBody().context).toEqual({ timeout: 30_000 });
+ });
+
+ test("sends no query context when no server deadline was given", async () => {
+ await makeTransport().query("SELECT 1");
+
+ expect(lastBody()).not.toHaveProperty("context");
+ });
+
+ test("sends no parameters array for a statement that has no placeholders", async () => {
+ await makeTransport().query("SELECT 1");
+
+ expect(lastBody()).not.toHaveProperty("parameters");
+ await makeTransport().query("SELECT 1", { parameters: [] });
+
+ expect(lastBody()).not.toHaveProperty("parameters");
+ });
+
+ // Spec section 6, second half (the #264 lesson): a server-side deadline only
+ // starts counting once the server accepts the statement, so it cannot bound a
+ // stalled connect, a TLS handshake, or a body that stops arriving part-way.
+ test("arms a client-side deadline when one is given", async () => {
+ await makeTransport().query("SELECT 1", { clientDeadlineMs: 5000 });
+
+ const signal = lastCall().init?.signal;
+ expect(signal).toBeInstanceOf(AbortSignal);
+ expect(signal?.aborted).toBe(false);
+ });
+
+ test("sends no signal when no client deadline is given", async () => {
+ await makeTransport().query("SELECT 1", { timeoutMs: 1000 });
+
+ expect(lastCall().init?.signal).toBeUndefined();
+ });
+
+ test("aborts the in-flight request once the client deadline passes", async () => {
+ // Proves the signal is live rather than merely attached: the handler waits on
+ // it instead of answering, which is the stalled-body case a server-side
+ // deadline cannot reach.
+ handler = (_url, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => reject(init.signal?.reason));
+ });
+
+ const error = await captureError(() => makeTransport().query("SELECT 1", { clientDeadlineMs: 10 }));
+
+ expect(error.message.toLowerCase()).toContain("timed out");
+ expect(error.category).toBe(DRUID_TRANSPORT_FAILURE);
+ });
+
+ // Spec section 1, live-verified: a default install loads no security extension
+ // and ignores an Authorization header entirely - a bogus Basic header still
+ // answers 200 - so credentials are optional and only sent when configured, for
+ // the druid-basic-security extension.
+ test("authenticates with HTTP Basic when the connection carries credentials", async () => {
+ await makeTransport({ user: "admin", password: "password1" }).query("SELECT 1");
+
+ expect(lastHeader("authorization")).toBe(`Basic ${Buffer.from("admin:password1").toString("base64")}`);
+ });
+
+ test("sends an empty password rather than dropping the header", async () => {
+ await makeTransport({ user: "admin" }).query("SELECT 1");
+
+ expect(lastHeader("authorization")).toBe(`Basic ${Buffer.from("admin:").toString("base64")}`);
+ });
+
+ test("sends no authorization header when the connection names no user", async () => {
+ await makeTransport().query("SELECT 1");
+
+ expect(lastHeader("authorization")).toBeUndefined();
+ });
+});
+
+// ============================================================================
+// The endpoint
+// ============================================================================
+
+describe("DruidHttpTransport endpoint", () => {
+ test("defaults to the Router's port on localhost", async () => {
+ await new DruidHttpTransport({ id: "druid-2", name: "Druid", type: DRUID, createdAt: new Date() }).query(
+ "SELECT 1",
+ );
+
+ expect(lastCall().url).toBe("http://localhost:8888/druid/v2/sql");
+ });
+
+ // Spec section 11: the Broker on 8082 serves the same endpoint with the same
+ // envelope, so a Broker-only deployment needs nothing but its port.
+ test("talks to a Broker exactly as it talks to a Router", async () => {
+ await makeTransport({ port: 8082 }).query("SELECT 1");
+
+ expect(lastCall().url).toBe("http://127.0.0.1:8082/druid/v2/sql");
+ });
+
+ test("uses TLS when the connection asks for it", async () => {
+ await makeTransport({ ssl: { mode: "require" } }).query("SELECT 1");
+
+ expect(lastCall().url).toBe("https://127.0.0.1:8888/druid/v2/sql");
+ });
+
+ // The #264 lesson: the scheme must be able to turn TLS OFF as well as on, or a
+ // connection that explicitly disables it still tries to handshake.
+ test("keeps plain HTTP when SSL is explicitly disabled", async () => {
+ await makeTransport({ ssl: { mode: "disable" } }).query("SELECT 1");
+
+ expect(lastCall().url).toBe("http://127.0.0.1:8888/druid/v2/sql");
+ });
+
+ // A bare IPv6 literal is not a legal URL authority, so it has to be bracketed
+ // or the request never leaves the process.
+ test("brackets a bare IPv6 host", async () => {
+ await makeTransport({ host: "::1" }).query("SELECT 1");
+
+ expect(lastCall().url).toBe("http://[::1]:8888/druid/v2/sql");
+ });
+
+ test("leaves an already-bracketed IPv6 host alone", async () => {
+ await makeTransport({ host: "[::1]" }).query("SELECT 1");
+
+ expect(lastCall().url).toBe("http://[::1]:8888/druid/v2/sql");
+ });
+});
+
+// ============================================================================
+// A tabular result
+// ============================================================================
+
+describe("DruidHttpTransport results", () => {
+ test("drops the three header rows and rebuilds the rows from the declared names", async () => {
+ const result = await makeTransport().query("SELECT id, name, snowflake_id FROM libredb_demo");
+
+ expect(result.rows).toEqual([{ id: 1030, name: "alpha", snowflake_id: "9007199254740993" }]);
+ expect(result.fieldNames).toEqual(["id", "name", "snowflake_id"]);
+ });
+
+ test("keeps both type vocabularies, keyed by the same names", async () => {
+ const result = await makeTransport().query("SELECT id, name, snowflake_id FROM libredb_demo");
+
+ expect(result.sqlTypes).toEqual({ id: "BIGINT", name: "VARCHAR", snowflake_id: "BIGINT" });
+ expect(result.nativeTypes).toEqual({ id: "LONG", name: "STRING", snowflake_id: "LONG" });
+ });
+
+ // Spec section 2: the native type is the one that lies, which is why both are
+ // carried and why the SQL type is the one the grid labels a column with.
+ test("carries the SQL type that disagrees with the native one", async () => {
+ handler = () => respond(TYPED_BODY);
+
+ const result = await makeTransport().query("SELECT CURRENT_TIMESTAMP AS t, (1 = 1) AS b");
+
+ expect(result.sqlTypes).toMatchObject({ t: "TIMESTAMP", b: "BOOLEAN", nums: "ARRAY", d: "DECIMAL" });
+ expect(result.nativeTypes).toMatchObject({ t: "LONG", b: "LONG", nums: "ARRAY", d: "DOUBLE" });
+ });
+
+ // `sqlStringifyArrays` defaults to true, so an ARRAY cell really is a JSON
+ // string on the wire. Parsing it back would invent a shape no Druid client
+ // shows, so the value is carried verbatim.
+ test("carries an ARRAY cell as the JSON string Druid sent", async () => {
+ handler = () => respond(TYPED_BODY);
+
+ const result = await makeTransport().query("SELECT ARRAY[1,2] AS nums");
+
+ expect(result.rows[0].nums).toBe("[1,2]");
+ expect(result.rows[0].words).toBe('["alpha"]');
+ expect(result.rows[0].b).toBe(true);
+ expect(result.rows[0].n).toBeNull();
+ });
+
+ /**
+ * Spec section 2, live-verified:
+ * SELECT 1 AS c, 2 AS c -> [["c","c"],["LONG","LONG"],["INTEGER","INTEGER"],[1,2]]
+ * Rows are records, so the repeat has to be disambiguated as the row is built
+ * or the second column is lost before the seam rather than after it.
+ */
+ test("disambiguates a duplicated output name so both columns survive", async () => {
+ handler = () => respond('[["c","c"],["LONG","LONG"],["INTEGER","INTEGER"],[1,2]]');
+
+ const result = await makeTransport().query("SELECT 1 AS c, 2 AS c");
+
+ expect(result.fieldNames).toEqual(["c", "c (2)"]);
+ expect(result.rows).toEqual([{ c: 1, "c (2)": 2 }]);
+ expect(result.sqlTypes).toEqual({ c: "INTEGER", "c (2)": "INTEGER" });
+ expect(result.nativeTypes).toEqual({ c: "LONG", "c (2)": "LONG" });
+ });
+
+ test("disambiguates a third repeat too", async () => {
+ handler = () => respond('[["c","c","c"],["LONG","LONG","LONG"],["INTEGER","INTEGER","INTEGER"],[1,2,3]]');
+
+ const result = await makeTransport().query("SELECT 1 AS c, 2 AS c, 3 AS c");
+
+ expect(result.fieldNames).toEqual(["c", "c (2)", "c (3)"]);
+ expect(result.rows).toEqual([{ c: 1, "c (2)": 2, "c (3)": 3 }]);
+ });
+
+ // `SELECT 1 AS c, 2 AS "c (2)", 3 AS c` is legal, and the obvious spelling for
+ // the third column is already taken by the second. Uniqueness is the seam's
+ // invariant, so the suffix keeps climbing until it is free.
+ test("keeps climbing when the disambiguated name is itself declared", async () => {
+ handler = () => respond('[["c","c (2)","c"],["LONG","LONG","LONG"],["INTEGER","INTEGER","INTEGER"],[1,2,3]]');
+
+ const result = await makeTransport().query('SELECT 1 AS c, 2 AS "c (2)", 3 AS c');
+
+ expect(result.fieldNames).toEqual(["c", "c (2)", "c (3)"]);
+ expect(result.rows).toEqual([{ c: 1, "c (2)": 2, "c (3)": 3 }]);
+ });
+
+ // Live-verified: `SELECT id FROM libredb_demo WHERE id = -1` answers
+ // `[["id"],["LONG"],["BIGINT"]]` - all three header rows, no data.
+ test("describes the columns of a result set with no rows", async () => {
+ handler = () => respond('[["id"],["LONG"],["BIGINT"]]');
+
+ const result = await makeTransport().query("SELECT id FROM libredb_demo WHERE id = -1");
+
+ expect(result.rows).toEqual([]);
+ expect(result.fieldNames).toEqual(["id"]);
+ expect(result.sqlTypes).toEqual({ id: "BIGINT" });
+ });
+
+ // An empty array carries no header at all, and with all three flags set even a
+ // zero-row result answers `[["id"],["LONG"],["BIGINT"]]` (live-verified). So this
+ // cannot be a healthy answer - it is a truncated body or a proxy rewrite - and
+ // reporting it as `{ rows: [] }` would render the most convincing possible lie: a
+ // successful query over the right datasource that simply found nothing.
+ test("raises on an empty array rather than reporting an empty result", async () => {
+ handler = () => respond("[]");
+
+ await expect(makeTransport().query("SELECT 1")).rejects.toThrow(/incomplete/i);
+ });
+
+ // Same reasoning as the empty array: a result set with no rows still carries all
+ // three header rows, and a bare `SET` - the only other statement form Druid's
+ // grammar accepts - is rejected outright rather than answering short. There is no
+ // legitimate way to receive fewer than three, so data loss surfaces as a failure.
+ test.each<[string, string]>([
+ ["one row", '[["id"]]'],
+ ["two rows", '[["id"],["LONG"]]'],
+ ["a header that is not a row", '["id",["LONG"],["BIGINT"],[1]]'],
+ ])("raises when the header is %s", async (_label, body) => {
+ handler = () => respond(body);
+
+ await expect(makeTransport().query("SELECT id FROM libredb_demo")).rejects.toThrow(/incomplete/i);
+ });
+
+ test("fills a short data row with nulls rather than dropping the row", async () => {
+ handler = () => respond('[["a","b"],["LONG","LONG"],["BIGINT","BIGINT"],[1]]');
+
+ const result = await makeTransport().query("SELECT a, b FROM t");
+
+ expect(result.rows).toEqual([{ a: 1, b: null }]);
+ });
+
+ test("reads a row that is not an array as one with no values", async () => {
+ handler = () => respond('[["a"],["LONG"],["BIGINT"],7]');
+
+ const result = await makeTransport().query("SELECT a FROM t");
+
+ expect(result.rows).toEqual([{ a: null }]);
+ });
+
+ // Never fabricate a type: a types row something rewrote describes fewer columns
+ // rather than more, and the names still describe the rows.
+ test("omits a type it was not told rather than inventing one", async () => {
+ handler = () => respond('[["a","b"],["LONG"],"nope",[1,2]]');
+
+ const result = await makeTransport().query("SELECT a, b FROM t");
+
+ expect(result.nativeTypes).toEqual({ a: "LONG" });
+ expect(result.sqlTypes).toEqual({});
+ expect(result.rows).toEqual([{ a: 1, b: 2 }]);
+ });
+
+ // Spec section 0 and the seam: live-verified on 37.0.0, the endpoint answers
+ // with the rows and nothing else - no timing anywhere in the body or the
+ // response metadata - so the transport times its own exchange and never
+ // pretends the number came from the server.
+ test("times the exchange itself", async () => {
+ handler = async () => {
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ return respond(SELECT_BODY);
+ };
+
+ const result = await makeTransport().query("SELECT 1");
+
+ expect(Number.isFinite(result.executionTimeMs)).toBe(true);
+ expect(result.executionTimeMs).toBeGreaterThan(0);
+ });
+
+ test("describes exactly the neutral result and nothing else", async () => {
+ const result = await makeTransport().query("SELECT 1");
+
+ expect(Object.keys(result).sort()).toEqual(["executionTimeMs", "fieldNames", "nativeTypes", "rows", "sqlTypes"]);
+ });
+});
+
+describe("DruidHttpTransport 64-bit integers", () => {
+ test("hands a BIGINT beyond the safe range to the caller as an exact string", async () => {
+ const result = await makeTransport().query("SELECT snowflake_id FROM libredb_demo");
+
+ expect(result.rows[0].snowflake_id).toBe("9007199254740993");
+ // The same thing the `pg` driver already does for int8: a value the UI can
+ // display and copy exactly, rather than a number rounded on arrival.
+ expect(typeof result.rows[0].snowflake_id).toBe("string");
+ });
+
+ test("leaves a safe integer a number, so the grid can still sort it", async () => {
+ const result = await makeTransport().query("SELECT id FROM libredb_demo");
+
+ expect(result.rows[0].id).toBe(1030);
+ });
+});
+
+// ============================================================================
+// Failures (spec section 5)
+// ============================================================================
+
+describe("DruidHttpTransport failures", () => {
+ function failWith(status: number, body: string): void {
+ handler = () => respond(body, { status });
+ }
+
+ /**
+ * The modern envelope, captured from `SELECT * FROM nope`. `error` is a
+ * DISCRIMINATOR here - its value is the literal string "druidException" - so a
+ * transport that shows `error` prints that to the person who mistyped the name.
+ */
+ const DRUID_EXCEPTION =
+ '{"error":"druidException","errorCode":"invalidInput","persona":"USER","category":"INVALID_INPUT",' +
+ '"errorMessage":"Object \'nope\' not found (line [1], column [15])",' +
+ '"context":{"sourceType":"sql","line":"1","column":"15","endLine":"1","endColumn":"18"}}';
+
+ /** The legacy wrapper, captured from a `context.timeout` of 1 ms. */
+ const LEGACY_TIMEOUT =
+ '{"error":"Query timeout","errorClass":"org.apache.druid.query.QueryTimeoutException",' +
+ '"host":"172.18.0.5:8083","errorCode":"legacyQueryException","persona":"OPERATOR","category":"TIMEOUT",' +
+ '"errorMessage":"url[http://172.18.0.5:8083/druid/v2/] timed out",' +
+ '"context":{"host":"172.18.0.5:8083","errorClass":"org.apache.druid.query.QueryTimeoutException",' +
+ '"legacyErrorCode":"Query timeout"}}';
+
+ test("reads the message out of errorMessage, never out of the discriminator", async () => {
+ failWith(400, DRUID_EXCEPTION);
+
+ const error = await captureError(() => makeTransport().query("SELECT * FROM nope"));
+
+ expect(error.message).toBe("Object 'nope' not found (line [1], column [15])");
+ expect(error.message).not.toContain("druidException");
+ expect(error.is("INVALID_INPUT")).toBe(true);
+ expect(error.errorCode).toBe("invalidInput");
+ expect(error.persona).toBe("USER");
+ });
+
+ test("classifies the legacy wrapper the same way", async () => {
+ failWith(504, LEGACY_TIMEOUT);
+
+ const error = await captureError(() => makeTransport().query("SELECT COUNT(*) FROM libredb_demo"));
+
+ // The specific message, not the legacy `error` field's "Query timeout".
+ expect(error.message).toBe("url[http://172.18.0.5:8083/druid/v2/] timed out");
+ expect(error.is("TIMEOUT")).toBe(true);
+ expect(error.errorCode).toBe("legacyQueryException");
+ expect(error.persona).toBe("OPERATOR");
+ });
+
+ /**
+ * Spec section 5, point 3, and the reason nothing here branches on the status:
+ * `SELECT 1/0` answers HTTP 500 with `persona: "ADMIN"` and
+ * `category: "UNCATEGORIZED"` for what is an ordinary user mistake. Reading 5xx
+ * as "the cluster is broken" would tell the user the wrong thing entirely.
+ */
+ test("does not classify on the HTTP status", async () => {
+ failWith(
+ 500,
+ '{"error":"druidException","errorCode":"general","persona":"ADMIN","category":"UNCATEGORIZED","errorMessage":"/ by zero","context":{}}',
+ );
+
+ const error = await captureError(() => makeTransport().query("SELECT 1/0 AS z"));
+
+ expect(error.message).toBe("/ by zero");
+ expect(error.is("UNCATEGORIZED")).toBe(true);
+ expect(error.isMonitoringUnavailable()).toBe(false);
+ expect(error.persona).toBe("ADMIN");
+ });
+
+ // Live-verified: an unsupported statement is a 400 whose message already names
+ // both the reason and the alternative, which is more useful than anything the
+ // provider could substitute (spec section 8).
+ test("passes an unsupported statement's own explanation through", async () => {
+ failWith(
+ 400,
+ '{"error":"druidException","errorCode":"general","persona":"USER","category":"INVALID_INPUT",' +
+ '"errorMessage":"INSERT operations are not supported by requested SQL engine [native], consider using MSQ.",' +
+ '"context":{}}',
+ );
+
+ const error = await captureError(() => makeTransport().query("INSERT INTO t SELECT 1"));
+
+ expect(error.message).toContain("consider using MSQ");
+ });
+
+ // The legacy shape puts a real message in `error` as well as in `errorMessage`,
+ // so it is the right fallback - but only when errorMessage is missing.
+ test("falls back to the error field when the envelope carries no errorMessage", async () => {
+ failWith(500, '{"error":"Unknown exception","errorCode":"legacyQueryException","category":"RUNTIME_FAILURE"}');
+
+ const error = await captureError(() => makeTransport().query("SELECT 1"));
+
+ expect(error.message).toBe("Unknown exception");
+ expect(error.is("RUNTIME_FAILURE")).toBe(true);
+ });
+
+ // The one thing that must never reach a user: the discriminator as a message.
+ test("never falls back to the discriminator itself", async () => {
+ failWith(400, '{"error":"druidException","errorCode":"invalidInput","category":"INVALID_INPUT"}');
+
+ const error = await captureError(() => makeTransport().query("SELECT 1"));
+
+ expect(error.message).toBe("Druid request failed with HTTP 400");
+ expect(error.is("INVALID_INPUT")).toBe(true);
+ });
+
+ test("describes the status when the envelope classified nothing", async () => {
+ failWith(503, "{}");
+
+ const error = await captureError(() => makeTransport().query("SELECT 1"));
+
+ expect(error.message).toBe("Druid request failed with HTTP 503");
+ expect(error.category).toBe(DRUID_TRANSPORT_FAILURE);
+ expect(error.errorCode).toBe(DRUID_TRANSPORT_FAILURE);
+ expect(error.persona).toBeNull();
+ });
+
+ // Spec section 5, point 4: a proxy's HTML page and an empty body carry nothing
+ // to classify, and they must still leave as the seam's own error type - a raw
+ // SyntaxError would slip past every instanceof branch in the provider.
+ test.each<[string, string]>([
+ ["a proxy's HTML error page", "502 Bad Gateway "],
+ ["an empty body", ""],
+ ["a JSON array", "[]"],
+ ["a JSON scalar", '"nope"'],
+ ["JSON null", "null"],
+ ])("normalizes %s", async (_label, body) => {
+ failWith(502, body);
+
+ const error = await captureError(() => makeTransport().query("SELECT 1"));
+
+ expect(error).toBeInstanceOf(DruidTransportError);
+ expect(error).not.toBeInstanceOf(SyntaxError);
+ expect(error.message).toBe("Druid request failed with HTTP 502");
+ expect(error.category).toBe(DRUID_TRANSPORT_FAILURE);
+ });
+
+ test("ignores an envelope field that is not a string", async () => {
+ failWith(400, '{"errorMessage":42,"category":["INVALID_INPUT"],"errorCode":null,"persona":false}');
+
+ const error = await captureError(() => makeTransport().query("SELECT 1"));
+
+ expect(error.message).toBe("Druid request failed with HTTP 400");
+ expect(error.category).toBe(DRUID_TRANSPORT_FAILURE);
+ expect(error.persona).toBeNull();
+ });
+
+ test("ignores an empty message rather than showing a blank error", async () => {
+ failWith(400, '{"errorMessage":"","error":"","category":"INVALID_INPUT"}');
+
+ expect((await captureError(() => makeTransport().query("SELECT 1"))).message).toBe(
+ "Druid request failed with HTTP 400",
+ );
+ });
+});
+
+// ============================================================================
+// Mid-stream failures (spec section 5, and the #264 lesson)
+// ============================================================================
+
+/**
+ * Druid CAN fail after it has started answering, and this is what it looks like.
+ *
+ * Live-reproduced on 37.0.0: a large streamed result (`SELECT REPEAT(name,
+ * 200000) FROM libredb_demo` read slowly) cancelled through
+ * `DELETE /druid/v2/sql/{sqlQueryId}` mid-flight answers HTTP **200**, streams
+ * 3.6 MB, and then simply stops - the body is cut mid-value with no closing
+ * bracket. Druid signals it by WITHHOLDING the `X-Druid-Response-Complete: true`
+ * trailer it otherwise sends, and an HTTP trailer is not reachable through
+ * `fetch` at all, so the truncated body is the only evidence the client has.
+ *
+ * The failure the ClickHouse work warned about is therefore real here too, only
+ * with a different shape: no fence to cut on, just an unparseable tail. Reporting
+ * a JSON complaint would tell the person who ran the query nothing; reporting an
+ * empty success would be worse.
+ *
+ * Verified NOT to happen, so it is deliberately not handled: a failure that the
+ * Broker learns about before it commits the status - `SELECT 1/(id-1005)` over 35
+ * MB of already-transferred rows - still answers a clean 500 whose body is the
+ * error envelope ALONE, with no partial result in front of it (the opposite of
+ * ClickHouse's buffered case).
+ */
+describe("DruidHttpTransport mid-stream failures", () => {
+ test("reports a truncated body as the incomplete response it is", async () => {
+ handler = () => respond('[["pad"],["STRING"],["VARCHAR"],["gammagammagam');
+
+ const error = await captureError(() => makeTransport().query("SELECT REPEAT(name, 200000) FROM libredb_demo"));
+
+ expect(error).not.toBeInstanceOf(SyntaxError);
+ expect(error.message).toContain("incomplete");
+ expect(error.category).toBe(DRUID_TRANSPORT_FAILURE);
+ // A parse complaint would bury the only useful part of the failure.
+ expect(error.message).not.toContain("JSON.parse");
+ });
+
+ // Reporting no rows would be the worst outcome of the three: the person who ran
+ // the query would read a truncated answer as the whole answer.
+ test.each<[string, string]>([
+ ["cut at a row boundary", '[["pad"],["STRING"],["VARCHAR"],['],
+ ["cut inside the header", '[["pad"],["STRING"'],
+ ["a 200 with no body at all", ""],
+ ])("does not report an empty success for a body %s", async (_label, body) => {
+ handler = () => respond(body);
+
+ const error = await captureError(() => makeTransport().query("SELECT 1"));
+
+ expect(error.message).toContain("incomplete");
+ });
+
+ // An envelope where an array was promised is either an error Druid committed
+ // after the status or a proxy rewriting the body. Reading it as an error beats
+ // reporting a parse failure, and beats reporting no rows.
+ test("classifies an error envelope that arrives with a 200", async () => {
+ handler = () =>
+ respond(
+ '{"error":"druidException","errorCode":"invalidInput","persona":"USER","category":"CANCELED","errorMessage":"Query cancelled"}',
+ );
+
+ const error = await captureError(() => makeTransport().query("SELECT 1"));
+
+ expect(error.message).toBe("Query cancelled");
+ expect(error.is("CANCELED")).toBe(true);
+ });
+
+ test("normalizes a 200 body that is neither an array nor an envelope", async () => {
+ handler = () => respond('"unexpected"');
+
+ const error = await captureError(() => makeTransport().query("SELECT 1"));
+
+ expect(error.message).toContain("array");
+ expect(error.category).toBe(DRUID_TRANSPORT_FAILURE);
+ });
+});
+
+// ============================================================================
+// Failures that are not the server's
+// ============================================================================
+
+describe("DruidHttpTransport transport failures", () => {
+ // Every throw out of the seam has to be a DruidTransportError or the provider's
+ // instanceof branches fall through to a generic message.
+ test("normalizes a refused connection", async () => {
+ handler = () => {
+ throw new Error("fetch failed");
+ };
+
+ const error = await captureError(() => makeTransport().query("SELECT 1"));
+
+ expect(error.message).toBe("Druid request failed: fetch failed");
+ expect(error.category).toBe(DRUID_TRANSPORT_FAILURE);
+ expect(error.errorCode).toBe(DRUID_TRANSPORT_FAILURE);
+ });
+
+ test("normalizes an aborted request", async () => {
+ handler = () => {
+ const abort = new Error("The operation was aborted.");
+ abort.name = "AbortError";
+ throw abort;
+ };
+
+ expect((await captureError(() => makeTransport().query("SELECT 1"))).message).toBe(
+ "Druid request failed: The operation was aborted.",
+ );
+ });
+
+ test("normalizes a rejection that is not an Error at all", async () => {
+ handler = () => {
+ throw "socket hang up";
+ };
+
+ expect((await captureError(() => makeTransport().query("SELECT 1"))).message).toBe(
+ "Druid request failed: socket hang up",
+ );
+ });
+
+ // src/lib/db/errors.ts keys on "timeout"/"timed out" in the message, and a
+ // transport-level stall has no Druid category to key on instead.
+ test("reports a client-side timeout as a timeout, so the shared mapping classifies it", async () => {
+ handler = () => {
+ throw new DOMException("The operation timed out.", "TimeoutError");
+ };
+
+ expect(
+ (await captureError(() => makeTransport().query("SELECT 1", { clientDeadlineMs: 5 }))).message.toLowerCase(),
+ ).toContain("timed out");
+ });
+});
+
+// ============================================================================
+// Parameters (spec section 13)
+// ============================================================================
+
+/**
+ * Live-verified: `?` placeholders with `parameters: [{type, value}]` really
+ * execute on Druid, so unlike ClickHouse (#264, whose endpoint has no
+ * equivalent) a parameterized statement is a first-class case here.
+ */
+describe("DruidHttpTransport parameters", () => {
+ async function sendParameters(...parameters: unknown[]): Promise<{ type: string; value: unknown }[]> {
+ await makeTransport().query("SELECT 1 WHERE x = ?", { parameters });
+ return lastBody().parameters as { type: string; value: unknown }[];
+ }
+
+ test.each<[string, unknown, { type: string; value: unknown }]>([
+ ["a string", "emea", { type: "VARCHAR", value: "emea" }],
+ ["an integral number", 5, { type: "BIGINT", value: 5 }],
+ ["an integral float", 5.0, { type: "BIGINT", value: 5 }],
+ ["a fractional number", 1.5, { type: "DOUBLE", value: 1.5 }],
+ ["a negative fraction", -0.25, { type: "DOUBLE", value: -0.25 }],
+ ["true", true, { type: "BOOLEAN", value: true }],
+ ["false", false, { type: "BOOLEAN", value: false }],
+ ["null", null, { type: "VARCHAR", value: null }],
+ ["undefined", undefined, { type: "VARCHAR", value: null }],
+ ])("maps %s onto the type Druid expects", async (_label, value, expected) => {
+ expect(await sendParameters(value)).toEqual([expected]);
+ });
+
+ // Live-verified: `{"type":"TIMESTAMP","value":0}` against `__time > ?` matches
+ // all 50 rows, so epoch millis is the encoding.
+ test("maps a Date onto epoch millis", async () => {
+ expect(await sendParameters(new Date("2026-08-03T15:17:00.549Z"))).toEqual([
+ { type: "TIMESTAMP", value: 1785770220549 },
+ ]);
+ });
+
+ test("keeps the parameters in the order the placeholders appear", async () => {
+ await makeTransport().query("SELECT 1 WHERE a = ? AND b = ?", { parameters: ["emea", 5] });
+
+ expect(lastBody().parameters).toEqual([
+ { type: "VARCHAR", value: "emea" },
+ { type: "BIGINT", value: 5 },
+ ]);
+ });
+
+ /**
+ * A bigint has no JSON form, and the obvious workaround is REFUSED by the
+ * server. Live-verified on 37.0.0:
+ * {"type":"BIGINT","value":"1"} (a string) -> 500 {"errorCode":"general",
+ * "category":"RUNTIME_FAILURE","errorMessage":"Cannot handle query"}
+ * {"type":"BIGINT","value":9007199254740993} (unquoted) -> matches the row
+ * So the literal has to reach the body unquoted, which is the whole reason
+ * JSON.rawJSON is used. Design spec section 13 says "as a string value"; that
+ * is the one line of the spec the live cluster contradicts.
+ */
+ // Built with BigInt() rather than a `9007199254740993n` literal: tsconfig targets
+ // ES2017, where the literal syntax is a compile error and the global is not.
+ test("sends a bigint as an unquoted JSON literal, which is what the server accepts", async () => {
+ await makeTransport().query("SELECT 1 WHERE snowflake_id = ?", { parameters: [BigInt("9007199254740993")] });
+
+ expect(lastBodyText()).toContain('{"type":"BIGINT","value":9007199254740993}');
+ expect(lastBodyText()).not.toContain('"9007199254740993"');
+ });
+
+ test("sends a negative bigint the same way", async () => {
+ await makeTransport().query("SELECT 1 WHERE n = ?", { parameters: [BigInt("-9007199254740993")] });
+
+ expect(lastBodyText()).toContain('{"type":"BIGINT","value":-9007199254740993}');
+ });
+
+ /**
+ * The parameters array is serialized structurally, not by marking the digits and
+ * substituting them back over the finished body. A marker is only as private as the
+ * values flowing through it: a caller whose VARCHAR parameter happened to contain the
+ * sentinel would have had that string silently unquoted into a number, changing its
+ * JSON type. These are the exact strings the previous NUL-marker version would have
+ * corrupted, and they must now survive as strings.
+ */
+ test.each<[string, string]>([
+ ["a NUL-wrapped digit run", "\u0000123\u0000"],
+ ["a NUL-wrapped negative digit run", "\u0000-9007199254740993\u0000"],
+ ["the literal escape text", "\\u0000123\\u0000"],
+ ])("keeps %s as a VARCHAR string rather than unquoting it into a number", async (_label, value) => {
+ const sent = await sendParameters(value);
+
+ expect(sent).toEqual([{ type: "VARCHAR", value }]);
+ // Round-trips as JSON, so nothing downstream sees a malformed body.
+ expect(JSON.parse(lastBodyText())).toBeTruthy();
+ });
+
+ test("still emits a bigint literal when a marker-looking string travels beside it", async () => {
+ await makeTransport().query("SELECT 1 WHERE a = ? AND b = ?", {
+ parameters: ["\u0000123\u0000", BigInt("9007199254740993")],
+ });
+
+ expect(lastBodyText()).toContain('{"type":"BIGINT","value":9007199254740993}');
+ // Number("...") rather than a literal: oxlint's no-loss-of-precision is right that
+ // a literal this wide cannot be held exactly, and JSON.parse produces the same
+ // rounded double, so this compares like with like. The EXACT value is asserted
+ // against the wire TEXT above, which is the only place it survives.
+ expect(lastBody().parameters).toEqual([
+ { type: "VARCHAR", value: "\u0000123\u0000" },
+ { type: "BIGINT", value: Number("9007199254740993") },
+ ]);
+ });
+
+ /**
+ * An integral `number` outside the safe range is ALREADY wrong by the time it
+ * arrives: `9007199254740993` written as a number literal is `...992` before the
+ * transport sees it, and no code here can recover the digit. Sending it would filter
+ * on a value the user never wrote and return a plausible wrong row set, so the
+ * refusal names the fix. `bigint` is the exact path and is unaffected.
+ */
+ // Built with Number("...") rather than literals: oxlint's no-loss-of-precision flags
+ // a literal this wide, and it is correct - which is precisely the point being tested.
+ // The parse yields the same already-rounded double a caller's literal would have.
+ test.each<[string, number]>([
+ ["2^53 + 1 rounded down by the parser", Number("9007199254740993")],
+ ["a negative unsafe integer", Number("-9007199254740993")],
+ ["an integral double far past Druid's BIGINT range", 1e21],
+ ])("refuses %s rather than filtering on a value the caller never wrote", async (_label, value) => {
+ const error = await captureError(() => makeTransport().query("SELECT 1 WHERE x = ?", { parameters: [value] }));
+
+ expect(error.message).toContain("already rounded");
+ expect(error.message).toContain("pass a bigint instead");
+ expect(calls).toEqual([]);
+ });
+
+ // The boundary itself is exact, so it must still go through.
+ test("accepts the largest safe integer", async () => {
+ expect(await sendParameters(Number.MAX_SAFE_INTEGER)).toEqual([{ type: "BIGINT", value: 9007199254740991 }]);
+ });
+
+ // Refusing beats sending a value the server would misread: JSON.stringify turns
+ // NaN and Infinity into `null`, which Druid would compare against as a null.
+ test.each<[string, unknown, string]>([
+ ["NaN", Number.NaN, "the non-finite number NaN"],
+ ["Infinity", Number.POSITIVE_INFINITY, "the non-finite number Infinity"],
+ ["an invalid Date", new Date("nope"), "an invalid Date"],
+ ["a symbol", Symbol("s"), "a value of type symbol"],
+ ["a function", () => 1, "a value of type function"],
+ ["an array", [1, 2], "a value of type Array"],
+ ["a plain object", { a: 1 }, "a value of type Object"],
+ ["a Map", new Map(), "a value of type Map"],
+ ["a prototype-less object", Object.create(null), "a value of type object"],
+ ])("refuses %s rather than sending something the server would misread", async (_label, value, detail) => {
+ const error = await captureError(() => makeTransport().query("SELECT 1 WHERE x = ?", { parameters: [value] }));
+
+ expect(error.message).toBe(`Druid has no parameter type for ${detail}`);
+ expect(error.category).toBe(DRUID_TRANSPORT_FAILURE);
+ // Refused before anything left the process.
+ expect(calls).toEqual([]);
+ });
+});
+
+// ============================================================================
+// The seam
+// ============================================================================
+
+describe("DruidHttpTransport seam", () => {
+ test("announces itself as the HTTP implementation", () => {
+ expect(makeTransport().kind).toBe("http");
+ });
+
+ // One request per statement and no session pinned, so there is nothing to
+ // release. close() exists because every implementation of the seam has it.
+ test("closes without holding anything open", async () => {
+ const transport = makeTransport();
+
+ await transport.close();
+
+ await expect(transport.query("SELECT 1")).resolves.toBeDefined();
+ });
+});
diff --git a/tests/unit/db/druid/introspect.test.ts b/tests/unit/db/druid/introspect.test.ts
new file mode 100644
index 00000000..99333bfc
--- /dev/null
+++ b/tests/unit/db/druid/introspect.test.ts
@@ -0,0 +1,1201 @@
+/**
+ * Druid schema introspection and monitoring (issue #265, design spec sections 9 and 10)
+ *
+ * Driven entirely through a hand-built query runner - the point of the seam: no
+ * fetch mocking, no `mock.module()` (process-wide in bun) and no server. Every
+ * row shape below was captured from a live Apache Druid 37.0.0 cluster, so the
+ * fake speaks exactly what the server speaks, including the four shapes that
+ * break a naive mapper:
+ *
+ * 1. A grouping-less aggregate over zero matching rows returns ZERO ROWS, not a
+ * row of zeros - live-verified, `SELECT COUNT(*) FROM sys.tasks WHERE status
+ * = 'RUNNING'` answers `[["runningTasks"]]` with no data row when nothing is
+ * running. Every scalar read therefore has to survive an absent row.
+ * 2. A RUNNING task reports `duration = -1`, so the elapsed time has to come
+ * from `CURRENT_TIMESTAMP` minus `created_time` instead.
+ * 3. `sys.servers` reports `max_size = 0` for every process that is not a
+ * historical, so the usage division meets a zero denominator in ordinary
+ * operation rather than only in a contrived one.
+ * 4. A `SUM(size)` big enough to leave the safe-integer range arrives QUOTED,
+ * because the transport rewrites unsafe integer literals before parsing
+ * (spec section 3). Both encodings reach these mappers.
+ */
+import { describe, expect, test } from "bun:test";
+import { DEFAULT_THRESHOLDS, evaluateThreshold } from "@/lib/monitoring-thresholds";
+import {
+ DRUID_ACTIVE_TASK_SQL,
+ DRUID_CACHE_HIT_RATIO_UNAVAILABLE,
+ DRUID_COLUMN_LIST_SQL,
+ DRUID_DATASOURCE_COUNT_SQL,
+ DRUID_DATASOURCE_STATS_SQL,
+ DRUID_DEFAULT_SESSION_LIMIT,
+ DRUID_HISTORICAL_STORAGE_SQL,
+ DRUID_RUNNING_TASK_COUNT_SQL,
+ DRUID_SCHEMA_NAME,
+ DRUID_SEGMENT_TOTALS_SQL,
+ DRUID_SERVER_IDENTITY_SQL,
+ DRUID_SYSTEM_READ_TIMEOUT_MS,
+ DRUID_TABLE_LIST_SQL,
+ DRUID_TASK_APPLICATION_NAME,
+ DRUID_TIME_COLUMN,
+ DRUID_UNKNOWN_TEXT,
+ type DruidQueryRunner,
+ getActiveSessions,
+ getHealth,
+ getIndexStats,
+ getOverview,
+ getPerformanceMetrics,
+ getSchema,
+ getSlowQueries,
+ getStorageStats,
+ getTableStats,
+} from "@/lib/db/providers/sql/druid/introspect";
+import {
+ DRUID_CLIENT_DEADLINE_GRACE_MS,
+ DRUID_ERROR_CATEGORIES,
+ type DruidErrorCategory,
+ type DruidQueryOptions,
+ type DruidRow,
+ DruidTransportError,
+} from "@/lib/db/providers/sql/druid/transport";
+
+// ============================================================================
+// Fake query runner
+// ============================================================================
+
+/** Which catalog or `sys` read a recorded statement is. */
+type Surface =
+ | "tableList"
+ | "columnList"
+ | "identity"
+ | "segmentTotals"
+ | "datasourceCount"
+ | "runningTasks"
+ | "activeTasks"
+ | "datasourceStats"
+ | "historicalStorage";
+
+/**
+ * The statement each surface must send, so a read is identified by the constant
+ * it came from rather than by a substring. A statement the module invents that
+ * matches none of these fails the test loudly instead of silently returning the
+ * rows of a different surface.
+ */
+const SURFACE_SQL: Record = {
+ tableList: DRUID_TABLE_LIST_SQL,
+ columnList: DRUID_COLUMN_LIST_SQL,
+ identity: DRUID_SERVER_IDENTITY_SQL,
+ segmentTotals: DRUID_SEGMENT_TOTALS_SQL,
+ datasourceCount: DRUID_DATASOURCE_COUNT_SQL,
+ runningTasks: DRUID_RUNNING_TASK_COUNT_SQL,
+ activeTasks: DRUID_ACTIVE_TASK_SQL,
+ datasourceStats: DRUID_DATASOURCE_STATS_SQL,
+ historicalStorage: DRUID_HISTORICAL_STORAGE_SQL,
+};
+
+const SURFACES = Object.keys(SURFACE_SQL) as Surface[];
+
+interface RecordedCall {
+ sql: string;
+ opts: DruidQueryOptions | undefined;
+}
+
+interface FakeOptions {
+ rows?: Partial>;
+ /** Raised instead of returning rows, per surface. */
+ failures?: Partial>;
+}
+
+/** `startsWith` because a row cap is appended to the session read. */
+function surfaceOf(sql: string): Surface {
+ const surface = SURFACES.find((candidate) => sql.startsWith(SURFACE_SQL[candidate]));
+ if (surface === undefined) throw new Error(`unexpected statement: ${sql}`);
+ return surface;
+}
+
+function createRunner(options: FakeOptions = {}) {
+ const calls: RecordedCall[] = [];
+
+ const runner = {
+ query: async (sql: string, opts?: DruidQueryOptions) => {
+ calls.push({ sql, opts });
+ const surface = surfaceOf(sql);
+ const failure = options.failures?.[surface];
+ if (failure) throw failure;
+ return {
+ rows: options.rows?.[surface] ?? [],
+ fieldNames: null,
+ sqlTypes: null,
+ nativeTypes: null,
+ executionTimeMs: 1,
+ };
+ },
+ };
+
+ return { runner, calls };
+}
+
+function sqlFor(calls: RecordedCall[], surface: Surface): string {
+ const call = calls.find((entry) => surfaceOf(entry.sql) === surface);
+ if (!call) throw new Error(`no ${surface} statement was sent`);
+ return call.sql;
+}
+
+// ============================================================================
+// Row builders (shapes captured from Druid 37.0.0)
+// ============================================================================
+
+function tableRow(overrides: Partial = {}): DruidRow {
+ return { tableName: "libredb_demo", ...overrides };
+}
+
+function columnRow(overrides: Partial = {}): DruidRow {
+ return {
+ tableName: "libredb_demo",
+ columnName: "id",
+ dataType: "BIGINT",
+ isNullable: "YES",
+ ...overrides,
+ };
+}
+
+/** The mandatory primary timestamp - the one column Druid reports as NOT NULL. */
+function timeColumnRow(overrides: Partial = {}): DruidRow {
+ return columnRow({ columnName: DRUID_TIME_COLUMN, dataType: "TIMESTAMP", isNullable: "NO", ...overrides });
+}
+
+/** The coordinator row, which is what the identity read's ordering puts first. */
+function identityRow(overrides: Partial = {}): DruidRow {
+ return {
+ version: "37.0.0",
+ startTime: "2026-08-03T14:29:00.534Z",
+ serverNow: "2026-08-03T15:09:26.292Z",
+ ...overrides,
+ };
+}
+
+/** `2026-08-03T15:09:26.292Z` minus `2026-08-03T14:29:00.534Z`. */
+const IDENTITY_UPTIME_MS = 2_425_758;
+const IDENTITY_UPTIME_TEXT = "40.43m";
+
+/**
+ * A live `noop` task, submitted to the running cluster to capture what an
+ * unfinished task actually reports: `duration` came back as -1, which is why the
+ * elapsed time is computed from these two timestamps instead.
+ */
+function taskRow(overrides: Partial = {}): DruidRow {
+ return {
+ taskId: "noop_2026-08-03T15:09:03.006Z_406ac936",
+ taskType: "index_parallel",
+ datasource: "libredb_demo",
+ status: "RUNNING",
+ createdTime: "2026-08-03T15:09:03.007Z",
+ serverNow: "2026-08-03T15:09:26.268Z",
+ ...overrides,
+ };
+}
+
+/** `2026-08-03T15:09:26.268Z` minus `2026-08-03T15:09:03.007Z`. */
+const TASK_ELAPSED_MS = 23_261;
+const TASK_ELAPSED_TEXT = "23.26s";
+
+function statsRow(overrides: Partial = {}): DruidRow {
+ return { datasource: "libredb_demo", rowCount: 50, sizeBytes: 10203, ...overrides };
+}
+
+function storageRow(overrides: Partial = {}): DruidRow {
+ return {
+ server: "172.18.0.5:8083",
+ host: "172.18.0.5",
+ currSize: 19617,
+ maxSize: 300000000000,
+ ...overrides,
+ };
+}
+
+// ============================================================================
+// The datasource filter
+// ============================================================================
+
+describe("the datasource filter", () => {
+ test.each<[Surface]>([
+ ["tableList"],
+ ["columnList"],
+ ])("restricts the %s read to the druid schema", async (surface) => {
+ const { runner, calls } = createRunner();
+
+ await getSchema(runner);
+
+ expect(sqlFor(calls, surface)).toContain(`TABLE_SCHEMA = '${DRUID_SCHEMA_NAME}'`);
+ });
+
+ // Live-verified: INFORMATION_SCHEMA.TABLES also lists the four
+ // INFORMATION_SCHEMA views and the six sys tables, all as SYSTEM_TABLE, and a
+ // cluster with lookups or views carries a `lookup` / `view` schema too. The
+ // schema predicate is the whole mechanism that keeps them out of the sidebar,
+ // so nothing else may be named.
+ test("never names another schema", () => {
+ for (const sql of [DRUID_TABLE_LIST_SQL, DRUID_COLUMN_LIST_SQL]) {
+ expect(sql).not.toContain("sys");
+ expect(sql).not.toContain("lookup");
+ expect(sql).not.toContain("SYSTEM_TABLE");
+ }
+ });
+
+ test("reads INFORMATION_SCHEMA rather than a sys table", () => {
+ expect(DRUID_TABLE_LIST_SQL).toContain("INFORMATION_SCHEMA.TABLES");
+ expect(DRUID_COLUMN_LIST_SQL).toContain("INFORMATION_SCHEMA.COLUMNS");
+ });
+
+ test("bounds both catalog reads with a deadline on each half of the exchange", async () => {
+ const { runner, calls } = createRunner();
+
+ await getSchema(runner);
+
+ expect(calls).toHaveLength(2);
+ for (const call of calls) {
+ expect(call.opts).toEqual({
+ timeoutMs: DRUID_SYSTEM_READ_TIMEOUT_MS,
+ // Strictly LATER than the server deadline. Equal deadlines are a race the
+ // client wins - the server's 504 still has to travel back - and winning it
+ // replaces Druid's classified TIMEOUT envelope with a bare abort that says
+ // nothing useful. The provider follows the same rule for user queries.
+ clientDeadlineMs: DRUID_SYSTEM_READ_TIMEOUT_MS + DRUID_CLIENT_DEADLINE_GRACE_MS,
+ });
+ expect(call.opts?.clientDeadlineMs).toBeGreaterThan(call.opts?.timeoutMs as number);
+ }
+ });
+});
+
+// ============================================================================
+// getSchema
+// ============================================================================
+
+describe("getSchema", () => {
+ test("names a datasource by its bare name", async () => {
+ const { runner } = createRunner({ rows: { tableList: [tableRow(), tableRow({ tableName: "libredb_rollup" })] } });
+
+ const schema = await getSchema(runner);
+
+ expect(schema.map((table) => table.name)).toEqual(["libredb_demo", "libredb_rollup"]);
+ });
+
+ test("carries the columns of each datasource in the order the server declared", async () => {
+ const { runner } = createRunner({
+ rows: {
+ tableList: [tableRow()],
+ columnList: [
+ timeColumnRow(),
+ columnRow({ columnName: "snowflake_id" }),
+ columnRow({ columnName: "region", dataType: "VARCHAR" }),
+ ],
+ },
+ });
+
+ const [demo] = await getSchema(runner);
+
+ expect(demo.columns.map((column) => column.name)).toEqual([DRUID_TIME_COLUMN, "snowflake_id", "region"]);
+ });
+
+ // The projection leaves ORDINAL_POSITION out and orders by it instead: it IS
+ // the declared column order, so it has no separate value to carry.
+ test("orders the column read by ordinal position", () => {
+ expect(DRUID_COLUMN_LIST_SQL).toContain("ORDER BY TABLE_NAME, ORDINAL_POSITION");
+ expect(DRUID_COLUMN_LIST_SQL).not.toContain('AS "ordinalPosition"');
+ });
+
+ test("takes the column type from DATA_TYPE, which is the SQL type", async () => {
+ const { runner } = createRunner({
+ rows: { tableList: [tableRow()], columnList: [columnRow({ dataType: "DOUBLE" })] },
+ });
+
+ const [demo] = await getSchema(runner);
+
+ expect(demo.columns[0]?.type).toBe("DOUBLE");
+ expect(DRUID_COLUMN_LIST_SQL).toContain("DATA_TYPE");
+ });
+
+ // Never observed empty, so this is the defensive branch - and OTHER is Druid's
+ // own token for a type its SQL layer cannot name, so the fallback stays inside
+ // the vocabulary the rest of the column list uses.
+ test("falls back to Druid's own OTHER type when DATA_TYPE says nothing", async () => {
+ const { runner } = createRunner({
+ rows: { tableList: [tableRow()], columnList: [columnRow({ dataType: "" }), columnRow({ dataType: null })] },
+ });
+
+ const [demo] = await getSchema(runner);
+
+ expect(demo.columns.map((column) => column.type)).toEqual(["OTHER", "OTHER"]);
+ });
+
+ // Nothing in a Druid datasource is a primary key, `__time` included. It is
+ // mandatory, it is the partition and sort key, and it is the only column Druid
+ // reports NOT NULL - but it is not UNIQUE, and `isPrimary` is read as PRIMARY KEY by
+ // autocomplete ("(PK)"), by the AI schema context (", PK") and by the schema differ
+ // ("Primary key changed"). Live-verified on the fixture datasource: 50 rows carry 30
+ // distinct `__time` values.
+ test("marks no column as primary, not even __time", async () => {
+ const { runner } = createRunner({
+ rows: {
+ tableList: [tableRow()],
+ columnList: [timeColumnRow(), columnRow(), columnRow({ columnName: "region" })],
+ },
+ });
+
+ const [demo] = await getSchema(runner);
+
+ expect(demo.columns.filter((column) => column.isPrimary)).toEqual([]);
+ // The time column is still recognisable by name and by being the one NOT NULL
+ // column, which is the honest way to find it.
+ expect(demo.columns.find((column) => column.name === DRUID_TIME_COLUMN)?.nullable).toBe(false);
+ });
+
+ // isPrimary is keyed on the NAME, not on IS_NULLABLE = 'NO'. Today __time is
+ // the only column Druid reports as NOT NULL, but that is a consequence of it
+ // being mandatory rather than the definition of the key - so a Druid that ever
+ // marks a second column NOT NULL must not grow a second primary column.
+ test("does not promote a NOT NULL column to primary either", async () => {
+ const { runner } = createRunner({
+ rows: {
+ tableList: [tableRow()],
+ columnList: [timeColumnRow(), columnRow({ columnName: "id", isNullable: "NO" })],
+ },
+ });
+
+ const [demo] = await getSchema(runner);
+
+ expect(demo.columns.map((column) => [column.name, column.isPrimary, column.nullable])).toEqual([
+ [DRUID_TIME_COLUMN, false, false],
+ ["id", false, false],
+ ]);
+ });
+
+ test.each<[string, unknown, boolean]>([
+ ["YES", "YES", true],
+ ["NO", "NO", false],
+ ])("reads IS_NULLABLE %s as nullable=%p", async (_label, value, expected) => {
+ const { runner } = createRunner({
+ rows: { tableList: [tableRow()], columnList: [columnRow({ isNullable: value })] },
+ });
+
+ const [demo] = await getSchema(runner);
+
+ expect(demo.columns[0]?.nullable).toBe(expected);
+ });
+
+ // Nullable is the safe reading of an unreadable flag: claiming NOT NULL would
+ // put a mandatory marker on a column that may well accept nulls, and Druid
+ // marks all but one column YES.
+ test.each<[string, unknown]>([
+ ["an absent flag", undefined],
+ ["a null flag", null],
+ ["an unexpected word", "MAYBE"],
+ ])("treats %s as nullable", async (_label, value) => {
+ const { runner } = createRunner({
+ rows: { tableList: [tableRow()], columnList: [columnRow({ isNullable: value })] },
+ });
+
+ const [demo] = await getSchema(runner);
+
+ expect(demo.columns[0]?.nullable).toBe(true);
+ });
+
+ // Druid has no user-defined indexes - every dimension is indexed by
+ // construction - and no foreign keys anywhere. Both lists are a fact about the
+ // engine, not a load that failed.
+ test("reports no indexes and no foreign keys", async () => {
+ const { runner } = createRunner({ rows: { tableList: [tableRow()], columnList: [timeColumnRow()] } });
+
+ const [demo] = await getSchema(runner);
+
+ expect(demo.indexes).toEqual([]);
+ expect(demo.foreignKeys).toEqual([]);
+ });
+
+ // getSchema reads INFORMATION_SCHEMA only. A row count would have to come from
+ // sys.segments, which is separately permission-gated, so asking for it would
+ // make the whole sidebar fail on a cluster that only denies `sys` - and the
+ // per-datasource counts are already in getTableStats.
+ test("leaves the row count and size unset rather than reading sys.segments", async () => {
+ const { runner, calls } = createRunner({ rows: { tableList: [tableRow()] } });
+
+ const [demo] = await getSchema(runner);
+
+ expect(demo.rowCount).toBeUndefined();
+ expect(demo.size).toBeUndefined();
+ expect(calls.map((call) => surfaceOf(call.sql)).sort()).toEqual(["columnList", "tableList"]);
+ });
+
+ test("gives a datasource with no column rows an empty column list", async () => {
+ const { runner } = createRunner({
+ rows: { tableList: [tableRow(), tableRow({ tableName: "libredb_rollup" })], columnList: [columnRow()] },
+ });
+
+ const [, rollup] = await getSchema(runner);
+
+ expect(rollup.columns).toEqual([]);
+ });
+
+ test("drops a column row belonging to no listed datasource", async () => {
+ const { runner } = createRunner({
+ rows: { tableList: [tableRow()], columnList: [columnRow({ tableName: "gone" }), columnRow()] },
+ });
+
+ const schema = await getSchema(runner);
+
+ expect(schema).toHaveLength(1);
+ expect(schema[0]?.columns.map((column) => column.name)).toEqual(["id"]);
+ });
+
+ test.each<[string, unknown]>([
+ ["an absent name", undefined],
+ ["a null name", null],
+ ["an empty name", ""],
+ ["a non-string name", 7],
+ ])("drops a datasource row carrying %s", async (_label, value) => {
+ const { runner } = createRunner({ rows: { tableList: [tableRow({ tableName: value }), tableRow()] } });
+
+ const schema = await getSchema(runner);
+
+ expect(schema.map((table) => table.name)).toEqual(["libredb_demo"]);
+ });
+
+ test.each<[string, "tableName" | "columnName"]>([
+ ["an unusable table name", "tableName"],
+ ["an unusable column name", "columnName"],
+ ])("drops a column row carrying %s", async (_label, field) => {
+ const { runner } = createRunner({
+ rows: { tableList: [tableRow()], columnList: [columnRow({ [field]: "" }), columnRow()] },
+ });
+
+ const [demo] = await getSchema(runner);
+
+ expect(demo.columns.map((column) => column.name)).toEqual(["id"]);
+ });
+
+ // A datasource whose segments are all unused disappears from
+ // INFORMATION_SCHEMA.TABLES entirely (live-verified with the Coordinator's
+ // markUnused), so an empty catalog means "no datasources", never "a datasource
+ // with nothing in it".
+ test("returns nothing when the catalog lists no datasource", async () => {
+ const { runner } = createRunner();
+
+ expect(await getSchema(runner)).toEqual([]);
+ });
+});
+
+// ============================================================================
+// Degradation
+// ============================================================================
+
+/**
+ * The three categories that mean "this surface is not available here" - a
+ * locked-down cluster's ordinary configurations. Every monitoring and catalog
+ * read degrades to empty or zero on these, and on nothing else: an empty panel
+ * standing in for a real error hides it forever.
+ */
+const DEGRADING: DruidErrorCategory[] = ["UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND"];
+
+/** Driven off the frozen table, so a category Druid adds cannot escape the matrix. */
+const PROPAGATING = (Object.keys(DRUID_ERROR_CATEGORIES) as DruidErrorCategory[]).filter(
+ (category) => !DEGRADING.includes(category),
+);
+
+function transportError(category: DruidErrorCategory): DruidTransportError {
+ return new DruidTransportError("probe", DRUID_ERROR_CATEGORIES[category], "general", "OPERATOR");
+}
+
+/** Each read, the surface it depends on, and what it must answer with that surface gone. */
+const READS: [name: string, surface: Surface, run: (runner: DruidQueryRunner) => Promise][] = [
+ ["getSchema", "tableList", (runner) => getSchema(runner)],
+ ["getActiveSessions", "activeTasks", (runner) => getActiveSessions(runner)],
+ ["getTableStats", "datasourceStats", (runner) => getTableStats(runner)],
+ ["getStorageStats", "historicalStorage", (runner) => getStorageStats(runner)],
+];
+
+describe("degradation", () => {
+ test.each(READS)("%s degrades to empty when the surface is unavailable", async (_name, surface, run) => {
+ const answers = await Promise.all(
+ DEGRADING.map((category) => run(createRunner({ failures: { [surface]: transportError(category) } }).runner)),
+ );
+
+ expect(answers).toEqual(DEGRADING.map(() => []));
+ });
+
+ test.each(READS)("%s propagates every other category", async (_name, surface, run) => {
+ await Promise.all(
+ PROPAGATING.map(async (category) => {
+ const { runner } = createRunner({ failures: { [surface]: transportError(category) } });
+
+ await expect(run(runner)).rejects.toThrow("probe");
+ }),
+ );
+ });
+
+ // A failure that never reached the server - a refused socket, an aborted
+ // request - is not a DruidTransportError at all, and must not be mistaken for
+ // an absent surface either.
+ test.each(READS)("%s propagates a failure that is not a transport error", async (_name, surface, run) => {
+ const { runner } = createRunner({ failures: { [surface]: new Error("socket hang up") } });
+
+ await expect(run(runner)).rejects.toThrow("socket hang up");
+ });
+
+ test("keeps the columns of a schema whose column read is denied", async () => {
+ const { runner } = createRunner({
+ rows: { tableList: [tableRow()] },
+ failures: { columnList: transportError("FORBIDDEN") },
+ });
+
+ const [demo] = await getSchema(runner);
+
+ expect(demo.name).toBe("libredb_demo");
+ expect(demo.columns).toEqual([]);
+ });
+
+ // Each overview read is separate for exactly this reason: `sys` permissions are
+ // granted per table, so a cluster that denies sys.tasks must still report the
+ // datasource count that INFORMATION_SCHEMA answers happily.
+ test("zeroes only the overview halves whose surface is unavailable", async () => {
+ const { runner } = createRunner({
+ rows: { identity: [identityRow()], datasourceCount: [{ datasourceCount: 2 }] },
+ failures: {
+ segmentTotals: transportError("FORBIDDEN"),
+ runningTasks: transportError("UNAUTHORIZED"),
+ },
+ });
+
+ const overview = await getOverview(runner);
+
+ expect(overview.version).toBe("37.0.0");
+ expect(overview.tableCount).toBe(2);
+ expect(overview.databaseSizeBytes).toBe(0);
+ expect(overview.activeConnections).toBe(0);
+ });
+
+ test("propagates an overview failure that is not a missing surface", async () => {
+ const { runner } = createRunner({ failures: { identity: transportError("INVALID_INPUT") } });
+
+ await expect(getOverview(runner)).rejects.toThrow("probe");
+ });
+});
+
+// ============================================================================
+// getOverview
+// ============================================================================
+
+describe("getOverview", () => {
+ function overviewRunner(rows: FakeOptions["rows"] = {}) {
+ return createRunner({
+ rows: {
+ identity: [identityRow()],
+ segmentTotals: [{ sizeBytes: 19617 }],
+ datasourceCount: [{ datasourceCount: 2 }],
+ runningTasks: [{ runningTasks: 1 }],
+ ...rows,
+ },
+ });
+ }
+
+ test("reports the cluster as the live cluster describes itself", async () => {
+ const { runner } = overviewRunner();
+
+ expect(await getOverview(runner)).toEqual({
+ version: "37.0.0",
+ uptime: IDENTITY_UPTIME_TEXT,
+ startTime: new Date("2026-08-03T14:29:00.534Z"),
+ activeConnections: 1,
+ maxConnections: 0,
+ databaseSize: "19.16 KB",
+ databaseSizeBytes: 19617,
+ tableCount: 2,
+ indexCount: 0,
+ });
+ });
+
+ // Both timestamps come from the server, in one statement: the editor's own
+ // clock may be skewed from the cluster's, and an uptime is a difference of two
+ // readings of the SAME clock or it is nothing.
+ test("computes the uptime from the server's own clock, not the editor's", async () => {
+ expect(DRUID_SERVER_IDENTITY_SQL).toContain("CURRENT_TIMESTAMP");
+ const { runner } = overviewRunner();
+
+ const overview = await getOverview(runner);
+
+ expect(overview.uptime).toBe(IDENTITY_UPTIME_TEXT);
+ expect(IDENTITY_UPTIME_MS).toBe(
+ new Date("2026-08-03T15:09:26.292Z").getTime() - new Date("2026-08-03T14:29:00.534Z").getTime(),
+ );
+ });
+
+ // Live `sys.servers` reports the Coordinator/Overlord pair, a Broker, a Router,
+ // a MiddleManager and a Historical, all with the same version but different
+ // start times. The Coordinator is the cluster's brain, so its start time is the
+ // one that reads as "the cluster came up"; the Broker is next because a
+ // Broker-only deployment is a supported way to reach Druid (spec section 11).
+ test("prefers the coordinator, then the broker, for the identity read", () => {
+ expect(DRUID_SERVER_IDENTITY_SQL).toContain("CASE server_type WHEN 'coordinator' THEN 0 WHEN 'broker' THEN 1");
+ expect(DRUID_SERVER_IDENTITY_SQL).toContain("LIMIT 1");
+ });
+
+ test("reports an unknown version and uptime when no server row came back", async () => {
+ const { runner } = overviewRunner({ identity: [] });
+
+ const overview = await getOverview(runner);
+
+ expect(overview.version).toBe(DRUID_UNKNOWN_TEXT);
+ expect(overview.uptime).toBe(DRUID_UNKNOWN_TEXT);
+ expect(overview.startTime).toBeUndefined();
+ });
+
+ test.each<[string, unknown]>([
+ ["an absent start time", undefined],
+ ["a null start time", null],
+ ["an unparseable start time", "not-a-timestamp"],
+ ["a non-string start time", 1_754_231_340_534],
+ ])("reports an unknown uptime for %s rather than inventing one", async (_label, value) => {
+ const { runner } = overviewRunner({ identity: [identityRow({ startTime: value })] });
+
+ const overview = await getOverview(runner);
+
+ expect(overview.startTime).toBeUndefined();
+ expect(overview.uptime).toBe(DRUID_UNKNOWN_TEXT);
+ });
+
+ test("reports an unknown uptime when the server's clock is unreadable", async () => {
+ const { runner } = overviewRunner({ identity: [identityRow({ serverNow: "" })] });
+
+ const overview = await getOverview(runner);
+
+ expect(overview.startTime).toEqual(new Date("2026-08-03T14:29:00.534Z"));
+ expect(overview.uptime).toBe(DRUID_UNKNOWN_TEXT);
+ });
+
+ // Spec section 3: the transport wraps any integer literal outside the safe
+ // range in quotes before parsing, so a large SUM(size) reaches this mapper as a
+ // decimal STRING while a small one stays a number. Both encodings are real, and
+ // a string that fell through as 0 would report an empty cluster.
+ test.each<[string, unknown, number]>([
+ ["a quoted size", "1099511627776", 1099511627776],
+ ["an unquoted size", 1099511627776, 1099511627776],
+ ])("parses %s", async (_label, value, expected) => {
+ const { runner } = overviewRunner({ segmentTotals: [{ sizeBytes: value }] });
+
+ expect((await getOverview(runner)).databaseSizeBytes).toBe(expected);
+ });
+
+ // Live-verified: a grouping-less aggregate over no matching rows returns zero
+ // ROWS, so `SUM(size)` over an empty cluster is an absent row rather than a
+ // null, and every scalar read has to survive that.
+ test.each<[string, DruidRow[]]>([
+ ["no row at all", []],
+ ["a row with a null total", [{ sizeBytes: null }]],
+ ["a row with a non-numeric total", [{ sizeBytes: "" }]],
+ ])("reports a zero size for %s", async (_label, rows) => {
+ const { runner } = overviewRunner({ segmentTotals: rows });
+
+ const overview = await getOverview(runner);
+
+ expect(overview.databaseSizeBytes).toBe(0);
+ expect(overview.databaseSize).toBe("0 B");
+ });
+
+ test("counts a RUNNING ingestion task as an active connection", async () => {
+ const { runner } = overviewRunner({ runningTasks: [{ runningTasks: 4 }] });
+
+ expect((await getOverview(runner)).activeConnections).toBe(4);
+ });
+
+ // Druid has no connection pool and publishes no connection limit anywhere in
+ // SQL, so a maximum would be a number the editor made up. Same for the index
+ // count: there are no index objects to count.
+ test("reports no connection limit and no indexes rather than guessing", async () => {
+ const { runner } = overviewRunner();
+
+ const overview = await getOverview(runner);
+
+ expect(overview.maxConnections).toBe(0);
+ expect(overview.indexCount).toBe(0);
+ });
+
+ test("reads the four sources separately so one denial cannot empty the rest", async () => {
+ const { runner, calls } = overviewRunner();
+
+ await getOverview(runner);
+
+ expect(calls.map((call) => surfaceOf(call.sql)).sort()).toEqual([
+ "datasourceCount",
+ "identity",
+ "runningTasks",
+ "segmentTotals",
+ ]);
+ });
+});
+
+// ============================================================================
+// The honest empties
+// ============================================================================
+
+/** The real threshold the monitoring UI evaluates this metric against. */
+const CACHE_HIT_RATIO_THRESHOLD = DEFAULT_THRESHOLDS.find((t) => t.metric === "cacheHitRatio")!;
+
+describe("getPerformanceMetrics", () => {
+ // Druid's cache and query metrics go to a metrics emitter (statsd, Kafka, the
+ // log), never to a SQL-readable table. There is nothing to read, so there is
+ // nothing to report.
+ test("reports nothing at all", () => {
+ expect(getPerformanceMetrics()).toEqual({});
+ });
+
+ // Regression guard. A "neutral" 0 here was not neutral: DEFAULT_THRESHOLDS scores
+ // cacheHitRatio `direction: "below"` with `critical: 80`, so a zero made every
+ // healthy Druid cluster render a red critical cache fault. Absence is the only
+ // value that raises no alarm, and the monitoring tabs default the THRESHOLD to a
+ // healthy 100 when the field is missing.
+ test("omits cacheHitRatio rather than reporting a zero the threshold reads as critical", () => {
+ const metrics = getPerformanceMetrics();
+
+ expect(metrics.cacheHitRatio).toBeUndefined();
+ expect("cacheHitRatio" in metrics).toBe(false);
+ expect(evaluateThreshold(metrics.cacheHitRatio ?? 100, CACHE_HIT_RATIO_THRESHOLD)).toBe("healthy");
+ expect(evaluateThreshold(0, CACHE_HIT_RATIO_THRESHOLD)).toBe("critical");
+ });
+
+ // Every metric is optional in the type, so absence is expressible for all of them
+ // and means "not reported"; a zero would read as a measurement of zero.
+ test("leaves every optional metric absent rather than zeroing it", () => {
+ expect(Object.keys(getPerformanceMetrics())).toEqual([]);
+ });
+
+ test("hands out a fresh object each call", () => {
+ expect(getPerformanceMetrics()).not.toBe(getPerformanceMetrics());
+ });
+});
+
+describe("getSlowQueries", () => {
+ // Druid has no query log at all: no sys table, no endpoint, nothing on disk.
+ // The panel is empty because there is nothing to read, not because a read
+ // failed - and no statement is sent to discover that.
+ test("reports no slow queries, without asking the cluster", () => {
+ expect(getSlowQueries()).toEqual([]);
+ });
+
+ test("hands out a fresh array each call", () => {
+ expect(getSlowQueries()).not.toBe(getSlowQueries());
+ });
+});
+
+describe("getIndexStats", () => {
+ // No user-defined indexes exist to have statistics about. Druid indexes every
+ // dimension by construction, and those indexes are inside a segment with no
+ // name, no size and no usage counter of their own.
+ test("reports no indexes, without asking the cluster", () => {
+ expect(getIndexStats()).toEqual([]);
+ });
+
+ test("hands out a fresh array each call", () => {
+ expect(getIndexStats()).not.toBe(getIndexStats());
+ });
+});
+
+// ============================================================================
+// getActiveSessions
+// ============================================================================
+
+describe("getActiveSessions", () => {
+ function taskRunner(rows: DruidRow[] = [taskRow()]) {
+ return createRunner({ rows: { activeTasks: rows } });
+ }
+
+ // Druid has no query sessions - no sys.queries, no connection catalog - so the
+ // only activity it can describe is its tasks. Returning [] while a multi-hour
+ // ingestion runs would hide the one thing happening on the cluster, and the
+ // application name is what stops the row being mistaken for a client session.
+ test("describes a running ingestion task as the session it is", async () => {
+ const { runner } = taskRunner();
+
+ expect(await getActiveSessions(runner)).toEqual([
+ {
+ pid: "noop_2026-08-03T15:09:03.006Z_406ac936",
+ user: DRUID_UNKNOWN_TEXT,
+ database: "libredb_demo",
+ applicationName: DRUID_TASK_APPLICATION_NAME,
+ state: "RUNNING",
+ query: "index_parallel",
+ queryStart: new Date("2026-08-03T15:09:03.007Z"),
+ duration: TASK_ELAPSED_TEXT,
+ durationMs: TASK_ELAPSED_MS,
+ },
+ ]);
+ });
+
+ test("reads the pending tasks as well as the running ones", () => {
+ expect(DRUID_ACTIVE_TASK_SQL).toContain("status IN ('RUNNING', 'PENDING')");
+ });
+
+ // THE correctness requirement of this read. Live-verified against a `noop` task
+ // submitted to the running cluster: sys.tasks reports `duration = -1` for a task
+ // that has not finished, which is every task this statement selects. Reporting
+ // that column would put "-1ms" on every row, so the elapsed time is computed
+ // from the server's clock minus the task's creation instant instead - and the
+ // column is left out of the projection so nobody reaches for it later.
+ test("never reads the duration column, which is -1 for an unfinished task", () => {
+ expect(DRUID_ACTIVE_TASK_SQL).not.toContain("duration");
+ expect(DRUID_ACTIVE_TASK_SQL).toContain("CURRENT_TIMESTAMP");
+ });
+
+ test("computes the elapsed time from the two timestamps in the row", async () => {
+ const { runner } = taskRunner([
+ taskRow({ createdTime: "2026-08-03T15:00:00.000Z", serverNow: "2026-08-03T15:00:02.500Z" }),
+ ]);
+
+ const [session] = await getActiveSessions(runner);
+
+ expect(session?.durationMs).toBe(2500);
+ expect(session?.duration).toBe("2.50s");
+ });
+
+ test.each<[string, Partial]>([
+ ["the creation time is absent", { createdTime: undefined }],
+ ["the creation time is unparseable", { createdTime: "soon" }],
+ ["the server clock is absent", { serverNow: null }],
+ ["the server clock is unparseable", { serverNow: "now" }],
+ ])("reports a zero elapsed time when %s", async (_label, overrides) => {
+ const { runner } = taskRunner([taskRow(overrides)]);
+
+ const [session] = await getActiveSessions(runner);
+
+ expect(session?.durationMs).toBe(0);
+ expect(session?.duration).toBe("0ms");
+ });
+
+ test("leaves the start unset when the creation time is unreadable", async () => {
+ const { runner } = taskRunner([taskRow({ createdTime: "" })]);
+
+ const [session] = await getActiveSessions(runner);
+
+ expect(session?.queryStart).toBeUndefined();
+ });
+
+ // A clock that ran backwards between the two readings - the row is a snapshot,
+ // but a cluster with a skewed metadata store can still produce it - must not
+ // report a negative age.
+ test("never reports a negative elapsed time", async () => {
+ const { runner } = taskRunner([
+ taskRow({ createdTime: "2026-08-03T15:00:05.000Z", serverNow: "2026-08-03T15:00:00.000Z" }),
+ ]);
+
+ expect((await getActiveSessions(runner))[0]?.durationMs).toBe(0);
+ });
+
+ // Live-verified: a task with no datasource - a `noop` task, or a compaction
+ // that has not resolved one yet - reports the literal string "none" rather
+ // than null, so the field is never empty in practice.
+ test.each<[string, unknown, string]>([
+ ["a datasource", "libredb_rollup", "libredb_rollup"],
+ ["Druid's own placeholder", "none", "none"],
+ ["an absent datasource", undefined, ""],
+ ])("passes through %s", async (_label, value, expected) => {
+ const { runner } = taskRunner([taskRow({ datasource: value })]);
+
+ expect((await getActiveSessions(runner))[0]?.database).toBe(expected);
+ });
+
+ test.each<[string, Partial, Partial>]>([
+ ["an absent task id", { taskId: undefined }, { pid: "" }],
+ ["an absent status", { status: null }, { state: "" }],
+ ["an absent task type", { taskType: undefined }, { query: "" }],
+ ])("survives %s", async (_label, overrides, expected) => {
+ const { runner } = taskRunner([taskRow(overrides)]);
+
+ expect((await getActiveSessions(runner))[0]).toMatchObject(expected);
+ });
+
+ // Druid records no submitter identity in sys.tasks - a basic-security cluster
+ // puts it in the audit log, not here - so the user is unknown rather than
+ // borrowed from the connection, which did not submit the task.
+ test("does not claim the connection's user submitted the task", async () => {
+ const { runner } = taskRunner();
+
+ expect((await getActiveSessions(runner))[0]?.user).toBe(DRUID_UNKNOWN_TEXT);
+ });
+
+ test("caps the read at the requested number of rows", async () => {
+ const { runner, calls } = taskRunner();
+
+ await getActiveSessions(runner, { limit: 3 });
+
+ expect(sqlFor(calls, "activeTasks")).toBe(`${DRUID_ACTIVE_TASK_SQL} LIMIT 3`);
+ });
+
+ test.each<[string, number | undefined]>([
+ ["no limit", undefined],
+ ["a zero limit", 0],
+ ["a negative limit", -5],
+ ])("falls back to the default cap for %s", async (_label, limit) => {
+ const { runner, calls } = taskRunner();
+
+ await getActiveSessions(runner, { limit });
+
+ expect(sqlFor(calls, "activeTasks")).toBe(`${DRUID_ACTIVE_TASK_SQL} LIMIT ${DRUID_DEFAULT_SESSION_LIMIT}`);
+ });
+
+ test("truncates a fractional cap rather than putting it in the statement", async () => {
+ const { runner, calls } = taskRunner();
+
+ await getActiveSessions(runner, { limit: 7.9 });
+
+ expect(sqlFor(calls, "activeTasks")).toBe(`${DRUID_ACTIVE_TASK_SQL} LIMIT 7`);
+ });
+
+ test("reports nothing when no task is running", async () => {
+ const { runner } = taskRunner([]);
+
+ expect(await getActiveSessions(runner)).toEqual([]);
+ });
+});
+
+// ============================================================================
+// getTableStats
+// ============================================================================
+
+describe("getTableStats", () => {
+ test("groups the active segments of each datasource", async () => {
+ const { runner } = createRunner({ rows: { datasourceStats: [statsRow()] } });
+
+ expect(await getTableStats(runner)).toEqual([
+ {
+ schemaName: DRUID_SCHEMA_NAME,
+ tableName: "libredb_demo",
+ rowCount: 50,
+ tableSize: "9.96 KB",
+ tableSizeBytes: 10203,
+ totalSize: "9.96 KB",
+ totalSizeBytes: 10203,
+ },
+ ]);
+ });
+
+ // An index size of 0 would be a measurement of something that does not exist,
+ // and the field is optional, so it stays absent.
+ test("leaves the index size absent, since there are no index objects", async () => {
+ const { runner } = createRunner({ rows: { datasourceStats: [statsRow()] } });
+
+ const [stats] = await getTableStats(runner);
+
+ expect(stats).not.toHaveProperty("indexSize");
+ expect(stats).not.toHaveProperty("indexSizeBytes");
+ });
+
+ // Only the ACTIVE segments count: sys.segments also carries overshadowed and
+ // unused rows, and summing those double-counts both rows and bytes.
+ test("counts only the active segments", () => {
+ expect(DRUID_DATASOURCE_STATS_SQL).toContain("is_active = 1");
+ expect(DRUID_DATASOURCE_STATS_SQL).toContain("GROUP BY datasource");
+ });
+
+ test("passes the read through for the one schema Druid has", async () => {
+ const { runner, calls } = createRunner({ rows: { datasourceStats: [statsRow()] } });
+
+ expect(await getTableStats(runner, { schema: DRUID_SCHEMA_NAME })).toHaveLength(1);
+ expect(calls).toHaveLength(1);
+ });
+
+ // `druid` is the only schema holding datasources, so a filter naming any other
+ // one selects nothing - and answering that without a round trip is both faster
+ // and more obviously right than a predicate that can never match.
+ test("answers a filter for any other schema with nothing, and no statement", async () => {
+ const { runner, calls } = createRunner({ rows: { datasourceStats: [statsRow()] } });
+
+ expect(await getTableStats(runner, { schema: "sys" })).toEqual([]);
+ expect(calls).toEqual([]);
+ });
+
+ test.each<[string, Partial]>([
+ ["a null row count", { rowCount: null }],
+ ["a null size", { sizeBytes: null }],
+ ["an absent datasource", { datasource: undefined }],
+ ])("survives %s", async (_label, overrides) => {
+ const { runner } = createRunner({ rows: { datasourceStats: [statsRow(overrides)] } });
+
+ const [stats] = await getTableStats(runner);
+
+ expect(stats?.rowCount).toBeGreaterThanOrEqual(0);
+ expect(stats?.totalSizeBytes).toBeGreaterThanOrEqual(0);
+ expect(typeof stats?.tableName).toBe("string");
+ });
+
+ test("parses a quoted size", async () => {
+ const { runner } = createRunner({ rows: { datasourceStats: [statsRow({ sizeBytes: "1048576" })] } });
+
+ const [stats] = await getTableStats(runner);
+
+ expect(stats?.tableSizeBytes).toBe(1048576);
+ expect(stats?.tableSize).toBe("1 MB");
+ });
+
+ test("reports nothing for a cluster with no segments", async () => {
+ const { runner } = createRunner();
+
+ expect(await getTableStats(runner)).toEqual([]);
+ });
+});
+
+// ============================================================================
+// getStorageStats
+// ============================================================================
+
+describe("getStorageStats", () => {
+ test("describes each historical's segment cache", async () => {
+ const { runner } = createRunner({
+ rows: { historicalStorage: [storageRow({ currSize: 25000, maxSize: 100000 })] },
+ });
+
+ expect(await getStorageStats(runner)).toEqual([
+ {
+ name: "172.18.0.5:8083",
+ location: "172.18.0.5",
+ size: "24.41 KB",
+ sizeBytes: 25000,
+ usagePercent: 25,
+ },
+ ]);
+ });
+
+ // The historicals are the only processes that hold segments. Every other
+ // process in sys.servers reports curr_size 0 and max_size 0 (live-verified for
+ // the Coordinator, Overlord, Broker, Router and MiddleManager), so listing them
+ // would fill the panel with rows describing no storage.
+ test("reads only the historicals", () => {
+ expect(DRUID_HISTORICAL_STORAGE_SQL).toContain("server_type = 'historical'");
+ });
+
+ // The zero really is in this column: the Coordinator and Broker rows of the
+ // same table report max_size 0 live, and a historical with no segment cache
+ // configured reports it too. Dividing by it would put NaN on the panel.
+ test("reports zero usage rather than dividing by a zero capacity", async () => {
+ const { runner } = createRunner({ rows: { historicalStorage: [storageRow({ maxSize: 0 })] } });
+
+ const [storage] = await getStorageStats(runner);
+
+ expect(storage?.usagePercent).toBe(0);
+ expect(storage?.sizeBytes).toBe(19617);
+ });
+
+ test.each<[string, unknown]>([
+ ["a null capacity", null],
+ ["an absent capacity", undefined],
+ ["a non-numeric capacity", "unbounded"],
+ ])("reports zero usage for %s", async (_label, value) => {
+ const { runner } = createRunner({ rows: { historicalStorage: [storageRow({ maxSize: value })] } });
+
+ expect((await getStorageStats(runner))[0]?.usagePercent).toBe(0);
+ });
+
+ test("rounds the usage to two decimals", async () => {
+ const { runner } = createRunner({
+ rows: { historicalStorage: [storageRow({ currSize: 1, maxSize: 3 })] },
+ });
+
+ expect((await getStorageStats(runner))[0]?.usagePercent).toBe(33.33);
+ });
+
+ test.each<[string, Partial]>([
+ ["an absent server address", { server: undefined }],
+ ["an absent host", { host: null }],
+ ["a null used size", { currSize: null }],
+ ])("survives %s", async (_label, overrides) => {
+ const { runner } = createRunner({ rows: { historicalStorage: [storageRow(overrides)] } });
+
+ const [storage] = await getStorageStats(runner);
+
+ expect(typeof storage?.name).toBe("string");
+ expect(typeof storage?.location).toBe("string");
+ expect(storage?.sizeBytes).toBeGreaterThanOrEqual(0);
+ });
+
+ test("reports nothing for a cluster with no historical", async () => {
+ const { runner } = createRunner();
+
+ expect(await getStorageStats(runner)).toEqual([]);
+ });
+});
+
+// ============================================================================
+// getHealth
+// ============================================================================
+
+describe("getHealth", () => {
+ function healthRunner() {
+ return createRunner({
+ rows: {
+ identity: [identityRow()],
+ segmentTotals: [{ sizeBytes: 19617 }],
+ datasourceCount: [{ datasourceCount: 2 }],
+ runningTasks: [{ runningTasks: 1 }],
+ activeTasks: [taskRow()],
+ },
+ });
+ }
+
+ test("composes the panel from the reads that have a source", async () => {
+ const { runner } = healthRunner();
+
+ expect(await getHealth(runner)).toEqual({
+ activeConnections: 1,
+ databaseSize: "19.16 KB",
+ cacheHitRatio: DRUID_CACHE_HIT_RATIO_UNAVAILABLE,
+ slowQueries: [],
+ activeSessions: [
+ {
+ pid: "noop_2026-08-03T15:09:03.006Z_406ac936",
+ user: DRUID_UNKNOWN_TEXT,
+ database: "libredb_demo",
+ state: "RUNNING",
+ query: "index_parallel",
+ duration: TASK_ELAPSED_TEXT,
+ },
+ ],
+ });
+ });
+
+ // The field is a string, so it can say "not available" - which is the truth,
+ // Druid publishing no cache statistics in SQL - instead of a number that would
+ // be read as a measurement and would trip the cache-ratio threshold alert.
+ test("says the cache hit ratio is unavailable rather than sending a number", async () => {
+ const { runner } = healthRunner();
+
+ const health = await getHealth(runner);
+
+ expect(health.cacheHitRatio).toBe(DRUID_CACHE_HIT_RATIO_UNAVAILABLE);
+ expect(Number.isNaN(Number(health.cacheHitRatio))).toBe(true);
+ });
+
+ test("caps the session list so the panel stays readable", async () => {
+ const { runner, calls } = healthRunner();
+
+ await getHealth(runner);
+
+ expect(sqlFor(calls, "activeTasks")).toMatch(/ LIMIT \d+$/);
+ });
+
+ test("degrades to an empty panel on a cluster that denies every sys table", async () => {
+ const { runner } = createRunner({
+ failures: {
+ identity: transportError("FORBIDDEN"),
+ segmentTotals: transportError("FORBIDDEN"),
+ runningTasks: transportError("FORBIDDEN"),
+ activeTasks: transportError("FORBIDDEN"),
+ },
+ rows: { datasourceCount: [{ datasourceCount: 2 }] },
+ });
+
+ expect(await getHealth(runner)).toEqual({
+ activeConnections: 0,
+ databaseSize: "0 B",
+ cacheHitRatio: DRUID_CACHE_HIT_RATIO_UNAVAILABLE,
+ slowQueries: [],
+ activeSessions: [],
+ });
+ });
+});
diff --git a/tests/unit/db/druid/seam-guard.test.ts b/tests/unit/db/druid/seam-guard.test.ts
new file mode 100644
index 00000000..1d8891b9
--- /dev/null
+++ b/tests/unit/db/druid/seam-guard.test.ts
@@ -0,0 +1,344 @@
+/**
+ * Druid transport seam guard (issue #265, design spec section 0)
+ *
+ * The Druid provider is worth building without a client library only while
+ * swapping the transport stays cheap, and it stays cheap only while the wire
+ * format lives in exactly one file. This test is the mechanism that keeps that
+ * true: it parses every source in the provider directory and fails the build the
+ * moment Druid's HTTP vocabulary is used outside http-transport.ts. It reads the
+ * directory from disk rather than from a list, so it keeps holding as the provider
+ * grows.
+ *
+ * The guard is a parser, not a grep, and it sorts the vocabulary into three
+ * classes because the three need different treatment. What forces that here is
+ * something the ClickHouse guard (#264) did not face: the neutral error
+ * DELIBERATELY borrows Druid's own words. `DruidTransportError` carries
+ * `category`, `errorCode` and `persona` because those are the words a Druid user
+ * reads in the console, which makes `error.persona` a legitimate read of the
+ * NEUTRAL type - indistinguishable from `envelope.persona` without type
+ * information. A guard that cries wolf is a guard the next contributor deletes, so
+ * `persona` is matched only where envelope PARSING spells it (as a string), and a
+ * bare property access is left alone. That hole is deliberate and is cheaper than
+ * a false positive.
+ *
+ * Both directions are proven below: the detector must light up on the file that is
+ * SUPPOSED to speak HTTP, and stay silent on a compliant one.
+ */
+import { describe, expect, test } from "bun:test";
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import ts from "typescript";
+
+const ROOT = join(import.meta.dir, "..", "..", "..", "..");
+const PROVIDER_DIR = join(ROOT, "src", "lib", "db", "providers", "sql", "druid");
+
+/** The single file allowed to know the wire format. */
+const TRANSPORT_FILE = "http-transport.ts";
+
+/**
+ * Vocabulary that exists nowhere else in this provider and nowhere in English:
+ * the request's format and header flags, the endpoint path, the error
+ * discriminator, the two envelope fields the neutral error does NOT carry, and
+ * the auth header. Naming any of them outside the transport - in a string, a
+ * property, a template or a variable - is a leak, so these are matched as text,
+ * case-insensitively, because a header name is case-insensitive on the wire.
+ */
+const WIRE_TOKENS = [
+ "resultFormat",
+ "typesHeader",
+ "sqlTypesHeader",
+ "/druid/v2/sql",
+ "druidException",
+ "errorMessage",
+ "errorClass",
+ "authorization",
+];
+
+/**
+ * Identifiers matched EXACTLY, because a substring match would fire on every
+ * legitimate helper: `fetchTableStats` and `prefetchSchema` are ordinary provider
+ * names, while a bare `fetch` - called, or read off `globalThis` - is the one
+ * thing spec section 15, point 4 says provider logic must never do.
+ */
+const EXACT_TOKENS = ["fetch"];
+
+/**
+ * Envelope fields whose names the NEUTRAL seam deliberately shares, so only the
+ * spelling that envelope parsing produces can be flagged: the field as a string
+ * (`body["persona"]`, `pick(body, "persona")`). `error.persona` is a legitimate
+ * read of `DruidTransportError` and is deliberately NOT flagged - see the header.
+ */
+const STRING_TOKENS = ["persona"];
+
+/** Everything the transport must speak, and nothing else may. */
+const WIRE_VOCABULARY = [...WIRE_TOKENS, ...EXACT_TOKENS, ...STRING_TOKENS];
+
+/**
+ * Why the rule exists, printed on failure. Whoever trips this needs to see the
+ * boundary they are crossing, otherwise the cheapest fix looks like deleting the
+ * test.
+ */
+const SEAM_RULE = [
+ `Druid's HTTP wire format leaked out of ${TRANSPORT_FILE}.`,
+ "",
+ "Druid's SQL endpoint asks for rows through resultFormat/header/typesHeader/sqlTypesHeader, answers with",
+ "three HEADER ROWS in front of positional data, and reports a failure in one of two envelopes whose",
+ "`error` field is a discriminator rather than a message. Issue #265 keeps all of that inside the",
+ "transport: provider logic reads the neutral DruidQueryResult (rows, fieldNames, sqlTypes, nativeTypes,",
+ "executionTimeMs) and the classified DruidTransportError through the DruidTransport seam. That is what",
+ "makes adopting Druid's Avatica JDBC driver later one new file implementing the same interface, instead",
+ "of a rewrite of the provider, the introspection and the explain strategy.",
+ "",
+ `Fix an access below by mapping the field inside ${TRANSPORT_FILE} and widening DruidQueryResult when the`,
+ "value is genuinely needed. If you tripped this on a local name rather than on the wire, rename it to the",
+ "neutral vocabulary (`message`, not `errorMessage`) so one layer keeps one vocabulary. Provider logic must",
+ "never call fetch: every request goes through the seam. Do not weaken or delete this test - it is the only",
+ "thing keeping the seam real.",
+ "",
+ "Wire vocabulary outside the transport:",
+].join("\n");
+
+interface WireLeak {
+ file: string;
+ line: number;
+ token: string;
+ snippet: string;
+}
+
+/**
+ * The text this node carries, and whether it carries it as a string.
+ *
+ * Only three kinds of node spell a name: a string literal, a template chunk, and
+ * an identifier. Comments are trivia rather than nodes, so prose naming the
+ * envelope is deliberately free - the point is that no code depends on it.
+ */
+function spelling(node: ts.Node): { text: string; isString: boolean } | null {
+ if (ts.isStringLiteral(node) || ts.isTemplateLiteralToken(node)) return { text: node.text, isString: true };
+ if (ts.isIdentifier(node)) return { text: node.text, isString: false };
+ return null;
+}
+
+/**
+ * `sqlTypesHeader` CONTAINS `typesHeader`, so a plain substring match reports one
+ * leak as two. Only the longest match on a given spelling survives: both are
+ * leaks, and naming the specific one is what tells the reader which flag they
+ * copied.
+ */
+function mostSpecific(matches: string[]): string[] {
+ // Lowered on both sides, like the match itself: `sqlTypesHeader` contains
+ // `typesHeader` only case-insensitively.
+ return matches.filter(
+ (token) => !matches.some((other) => other !== token && other.toLowerCase().includes(token.toLowerCase())),
+ );
+}
+
+function leakedTokens(node: ts.Node): string[] {
+ const spelled = spelling(node);
+ if (!spelled) return [];
+
+ const lowered = spelled.text.toLowerCase();
+ return mostSpecific([
+ ...WIRE_TOKENS.filter((token) => lowered.includes(token.toLowerCase())),
+ ...EXACT_TOKENS.filter((token) => spelled.text === token),
+ ...(spelled.isString ? STRING_TOKENS.filter((token) => spelled.text === token) : []),
+ ]);
+}
+
+function findWireLeaks(file: string, source: string): WireLeak[] {
+ const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
+ const lines = source.split("\n");
+ // One leak per line and token: an element access reports the same token as the
+ // string literal it contains, and reporting it twice reads like two problems.
+ const found = new Map();
+
+ const visit = (node: ts.Node): void => {
+ for (const token of leakedTokens(node)) {
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line;
+ found.set(`${line}:${token}`, { file, line: line + 1, token, snippet: lines[line].trim() });
+ }
+ ts.forEachChild(node, visit);
+ };
+
+ visit(sourceFile);
+ return [...found.values()].sort((a, b) => a.line - b.line);
+}
+
+/** Empty when the seam holds; the rule plus every offending line when it does not. */
+function violationReport(leaks: WireLeak[]): string {
+ if (leaks.length === 0) return "";
+
+ const offences = leaks.map((leak) => ` ${leak.file}:${leak.line} uses "${leak.token}" -> ${leak.snippet}`);
+ return [SEAM_RULE, ...offences].join("\n");
+}
+
+function providerSources(): string[] {
+ return readdirSync(PROVIDER_DIR, { recursive: true })
+ .map(String)
+ .filter((name) => name.endsWith(".ts"))
+ .sort();
+}
+
+function readProviderSource(file: string): string {
+ return readFileSync(join(PROVIDER_DIR, file), "utf8");
+}
+
+describe("Druid transport seam", () => {
+ const sources = providerSources();
+
+ test("the guard scans the whole provider directory", () => {
+ expect(sources).toContain(TRANSPORT_FILE);
+ expect(sources.length).toBeGreaterThan(1);
+ });
+
+ // A detector that finds nothing anywhere is indistinguishable from a broken one,
+ // so the file that is SUPPOSED to speak HTTP must light it up - every token,
+ // including the two envelope fields the seam deliberately drops, because the
+ // transport is also the record of what the wire contains.
+ test.each(WIRE_VOCABULARY)("the transport itself uses %s, proving the detector reads real code", (token) => {
+ const tokens = findWireLeaks(TRANSPORT_FILE, readProviderSource(TRANSPORT_FILE)).map((leak) => leak.token);
+
+ expect(tokens).toContain(token);
+ });
+
+ test(`the wire format is used only in ${TRANSPORT_FILE}`, () => {
+ const leaks = sources
+ .filter((file) => file !== TRANSPORT_FILE)
+ .flatMap((file) => findWireLeaks(file, readProviderSource(file)));
+
+ expect(violationReport(leaks)).toBe("");
+ });
+});
+
+describe("the seam guard detector", () => {
+ /**
+ * Everything a compliant provider file legitimately does: name the wire in
+ * prose, read the neutral result and the classified error, branch on a category
+ * by name, query the `sys` tables whose columns share a name with an envelope
+ * field, and call helpers whose names merely contain "fetch". None of it is a
+ * leak, and none of it may fire.
+ */
+ const COMPLIANT_SAMPLE = `
+/**
+ * Prose may name the wire: resultFormat, typesHeader and sqlTypesHeader are asked
+ * for in http-transport.ts, which POSTs to /druid/v2/sql and reads errorMessage
+ * out of a druidException envelope. Even body.persona written in a comment is prose.
+ */
+import { DRUID_ERROR_CATEGORIES, DruidTransportError } from "./transport";
+import type { DruidTransport } from "./transport";
+
+const SERVERS = 'SELECT server, host, server_type, curr_size, max_size FROM sys.servers';
+const TASKS = 'SELECT task_id, datasource, status, error_msg FROM sys.tasks';
+
+export async function storage(transport: DruidTransport, sql: string) {
+ try {
+ const result = await transport.query(sql, { timeoutMs: 30000, clientDeadlineMs: 35000, parameters: [] });
+ const { rows, fieldNames, sqlTypes, nativeTypes, executionTimeMs } = result;
+ const stats = await fetchTableStats(transport);
+ await prefetchSchema(transport);
+ return { rows, fieldNames, sqlTypes, nativeTypes, executionTimeMs, stats, SERVERS, TASKS };
+ } catch (error) {
+ if (error instanceof DruidTransportError && error.isMonitoringUnavailable()) return null;
+ if (error instanceof DruidTransportError && error.is("UNAUTHORIZED")) return null;
+ const message = error instanceof Error ? error.message : String(error);
+ const persona = error instanceof DruidTransportError ? error.persona : null;
+ const category = error instanceof DruidTransportError ? error.category : DRUID_ERROR_CATEGORIES.DEFENSIVE;
+ const code = error instanceof DruidTransportError ? error.errorCode : null;
+ return { message, persona, category, code };
+ }
+}
+`;
+
+ const VIOLATING_SAMPLE = `
+export async function readRows(origin: string, sql: string) {
+ const response = await fetch(\`\${origin}/druid/v2/sql\`, {
+ method: "POST",
+ body: JSON.stringify({ query: sql, resultFormat: "array", typesHeader: true, sqlTypesHeader: true }),
+ });
+ const body = await response.json();
+ if (body.error === "druidException") throw new Error(body.errorMessage);
+ return body;
+}
+`;
+
+ test("passes a file that stays behind the seam", () => {
+ expect(findWireLeaks("introspect.ts", COMPLIANT_SAMPLE)).toEqual([]);
+ });
+
+ test("fails a file that speaks the wire, once per line and token", () => {
+ const leaks = findWireLeaks("index.ts", VIOLATING_SAMPLE);
+
+ expect(leaks.map((leak) => leak.token)).toEqual([
+ "fetch",
+ "/druid/v2/sql",
+ "resultFormat",
+ "typesHeader",
+ "sqlTypesHeader",
+ "druidException",
+ "errorMessage",
+ ]);
+ expect(leaks[1].line).toBe(3);
+ expect(leaks[6].snippet).toBe('if (body.error === "druidException") throw new Error(body.errorMessage);');
+ });
+
+ test.each<[string, string, string]>([
+ ["a requested result format", 'const body = { resultFormat: "array" };', "resultFormat"],
+ ["a native types flag", "const flags = { typesHeader: true };", "typesHeader"],
+ ["a SQL types flag", "const flags = { sqlTypesHeader: true };", "sqlTypesHeader"],
+ ["the endpoint path", 'const url = origin + "/druid/v2/sql";', "/druid/v2/sql"],
+ ["the endpoint path built into a template", "const url = `${origin}/druid/v2/sql`;", "/druid/v2/sql"],
+ ["the error discriminator", 'if (body.error === "druidException") return;', "druidException"],
+ ["an envelope message read", "const text = body.errorMessage;", "errorMessage"],
+ ["an envelope message read by key", 'const text = body["errorMessage"];', "errorMessage"],
+ ["the legacy exception class", "const cause = payload.errorClass;", "errorClass"],
+ ["the persona field spelled as a string", 'const who = body["persona"];', "persona"],
+ ["an auth header", "const headers = { authorization: basic };", "authorization"],
+ ["an auth header spelled for HTTP", 'headers.set("Authorization", basic);', "authorization"],
+ ["a direct fetch", 'await fetch(url, { method: "POST" });', "fetch"],
+ ["a fetch off globalThis", "await globalThis.fetch(url);", "fetch"],
+ ])("flags %s", (_label, source, token) => {
+ const [leak, ...rest] = findWireLeaks("index.ts", source);
+
+ expect(rest).toEqual([]);
+ expect(leak.token).toBe(token);
+ expect(leak.line).toBe(1);
+ });
+
+ test.each([
+ // The neutral error's own fields: transport.ts names three of them after
+ // Druid's on purpose, so reading them is the seam working as designed.
+ ["the neutral error's persona", "const who = error.persona;"],
+ ["the neutral error's category", "const c = error.category;"],
+ ["the neutral error's errorCode", "const c = error.errorCode;"],
+ ["the neutral error's message", "const m = error.message;"],
+ ["a destructured neutral error", "const { message, category, persona } = error;"],
+ ["a category checked by name", 'if (error.is("UNAUTHORIZED")) return [];'],
+ ["the frozen category table", "const c = DRUID_ERROR_CATEGORIES.UNAUTHORIZED;"],
+ // A substring match on "fetch" would fire on both of these.
+ ["a helper whose name contains fetch", "const rows = await fetchTableStats(transport);"],
+ ["a prefetch helper", "await prefetchSchema(transport);"],
+ // `host` is a DatabaseConnection field and a sys.servers column, so it is
+ // deliberately not guarded even though the legacy envelope carries one.
+ ["the host column of sys.servers", 'const q = "SELECT server, host FROM sys.servers";'],
+ ["the connection's own host", "const host = this.connection.host;"],
+ // Neutral seam vocabulary that reads like the wire and is not.
+ ["the neutral result's fields", "const { rows, fieldNames, sqlTypes, nativeTypes } = result;"],
+ ["the neutral options", "await transport.query(sql, { timeoutMs: 30000, clientDeadlineMs: 35000 });"],
+ ["a local holding the neutral message", "const message = error.message;"],
+ ["a SQL header-ish column name", 'const q = "SELECT header FROM sys.segments";'],
+ ])("does not flag %s", (_label, source) => {
+ expect(findWireLeaks("index.ts", source)).toEqual([]);
+ });
+
+ test("reports nothing when the seam holds", () => {
+ expect(violationReport([])).toBe("");
+ });
+
+ test("the failure report explains the rule and points at the issue", () => {
+ const report = violationReport(findWireLeaks("index.ts", "const text = body.errorMessage;"));
+
+ expect(report).toContain("#265");
+ expect(report).toContain(TRANSPORT_FILE);
+ expect(report).toContain("DruidQueryResult");
+ expect(report).toContain('index.ts:1 uses "errorMessage" -> const text = body.errorMessage;');
+ });
+});
diff --git a/tests/unit/db/druid/transport.test.ts b/tests/unit/db/druid/transport.test.ts
new file mode 100644
index 00000000..3efb69e5
--- /dev/null
+++ b/tests/unit/db/druid/transport.test.ts
@@ -0,0 +1,335 @@
+/**
+ * Druid transport seam (issue #265, design spec section 5)
+ *
+ * Almost all of transport.ts is type declarations, which erase at build time.
+ * What survives is the vocabulary every other file in the provider switches on:
+ * the frozen category table and the normalized error. Those are pinned here,
+ * ahead of the transport and the provider, because a wrong category silently
+ * turns a degradation path into a thrown error (or the reverse) - and on Druid
+ * the category is the ONLY reliable classifier: live-verified on 37.0.0,
+ * `SELECT 1/0` answers HTTP 500 with `persona: "ADMIN"` for what is a plain user
+ * mistake, so neither the status code nor the persona may be branched on.
+ *
+ * The categories are not transcribed from documentation. Both envelope shapes in
+ * spec section 5 were read back from the live cluster, e.g.
+ * {"error":"druidException","errorCode":"invalidInput","persona":"USER",
+ * "category":"INVALID_INPUT","errorMessage":"Object 'nope' not found ..."}
+ */
+import { describe, expect, test } from "bun:test";
+import {
+ DRUID_ERROR_CATEGORIES,
+ DRUID_TRANSPORT_FAILURE,
+ type DruidErrorCategory,
+ type DruidQueryOptions,
+ type DruidQueryResult,
+ type DruidRow,
+ type DruidTransport,
+ DruidTransportError,
+} from "@/lib/db/providers/sql/druid/transport";
+
+/** Every category in the frozen table, so a new one cannot escape the matrices below. */
+const CATEGORIES = Object.keys(DRUID_ERROR_CATEGORIES) as DruidErrorCategory[];
+
+function errorIn(category: string): DruidTransportError {
+ return new DruidTransportError("probe", category, "general", "USER");
+}
+
+// ============================================================================
+// The shared category table
+// ============================================================================
+
+describe("DRUID_ERROR_CATEGORIES", () => {
+ test("carries exactly the categories Druid classifies a failure into", () => {
+ expect(DRUID_ERROR_CATEGORIES).toEqual({
+ INVALID_INPUT: "INVALID_INPUT",
+ UNAUTHORIZED: "UNAUTHORIZED",
+ FORBIDDEN: "FORBIDDEN",
+ CAPACITY_EXCEEDED: "CAPACITY_EXCEEDED",
+ CANCELED: "CANCELED",
+ RUNTIME_FAILURE: "RUNTIME_FAILURE",
+ TIMEOUT: "TIMEOUT",
+ UNSUPPORTED: "UNSUPPORTED",
+ NOT_FOUND: "NOT_FOUND",
+ UNCATEGORIZED: "UNCATEGORIZED",
+ DEFENSIVE: "DEFENSIVE",
+ });
+ });
+
+ test("maps every name to a distinct token", () => {
+ const tokens = Object.values(DRUID_ERROR_CATEGORIES);
+
+ expect(new Set(tokens).size).toBe(tokens.length);
+ });
+
+ // A consumer that can retune a category at runtime makes the table advisory,
+ // and the whole point of exporting it is that there is one definition.
+ test("is frozen, so no consumer can retune a category", () => {
+ const mutable = DRUID_ERROR_CATEGORIES as unknown as Record;
+
+ expect(Object.isFrozen(DRUID_ERROR_CATEGORIES)).toBe(true);
+ expect(() => {
+ mutable.TIMEOUT = "NOPE";
+ }).toThrow(TypeError);
+ expect(DRUID_ERROR_CATEGORIES.TIMEOUT).toBe("TIMEOUT");
+ });
+
+ // The stand-in is ours, not Druid's. Were it a member of the table, `is()`
+ // would accept it and a caller could believe the server had classified the
+ // failure when nothing ever answered.
+ test("does not contain the stand-in used when the server said nothing", () => {
+ expect(Object.values(DRUID_ERROR_CATEGORIES)).not.toContain(DRUID_TRANSPORT_FAILURE);
+ });
+});
+
+// ============================================================================
+// The normalized error
+// ============================================================================
+
+describe("DruidTransportError", () => {
+ test("is a real Error carrying everything the envelope classified", () => {
+ const error = new DruidTransportError(
+ "Object 'nope' not found (line [1], column [15])",
+ DRUID_ERROR_CATEGORIES.INVALID_INPUT,
+ "invalidInput",
+ "USER",
+ );
+
+ expect(error).toBeInstanceOf(Error);
+ expect(error).toBeInstanceOf(DruidTransportError);
+ expect(error.category).toBe("INVALID_INPUT");
+ expect(error.errorCode).toBe("invalidInput");
+ expect(error.persona).toBe("USER");
+ expect(error.message).toBe("Object 'nope' not found (line [1], column [15])");
+ expect(error.name).toBe("DruidTransportError");
+ });
+
+ // Spec section 5, point 4 and the abort case: a refused socket, an aborted
+ // request and a proxy's HTML error page all arrive with nothing to scrape.
+ test("falls back to the stand-in when nothing was reported to classify", () => {
+ const error = new DruidTransportError("Druid request failed: connect ECONNREFUSED 127.0.0.1:8888");
+
+ expect(error.category).toBe(DRUID_TRANSPORT_FAILURE);
+ expect(error.errorCode).toBe(DRUID_TRANSPORT_FAILURE);
+ expect(error.persona).toBeNull();
+ expect(CATEGORIES.some((category) => error.is(category))).toBe(false);
+ });
+
+ // Subclassing a builtin loses the prototype under some downlevel emits, which
+ // would make every `catch` in the provider fall through to the generic path.
+ test("survives being thrown and caught", () => {
+ try {
+ throw new DruidTransportError("url[...] timed out", DRUID_ERROR_CATEGORIES.TIMEOUT, "legacyQueryException");
+ } catch (caught) {
+ expect(caught).toBeInstanceOf(DruidTransportError);
+ expect((caught as DruidTransportError).category).toBe("TIMEOUT");
+ expect((caught as DruidTransportError).errorCode).toBe("legacyQueryException");
+ expect((caught as DruidTransportError).persona).toBeNull();
+ }
+ });
+
+ // The table holds the categories the provider branches on, not the only legal
+ // ones: a later Druid may add one, and it must arrive verbatim rather than be
+ // flattened onto UNCATEGORIZED, which is itself a category Druid really sends.
+ test("carries a category that is not in the named table", () => {
+ const error = errorIn("SOME_FUTURE_CATEGORY");
+
+ expect(error.category).toBe("SOME_FUTURE_CATEGORY");
+ expect(CATEGORIES.some((category) => error.is(category))).toBe(false);
+ expect(error.isMonitoringUnavailable()).toBe(false);
+ });
+});
+
+describe("DruidTransportError.is", () => {
+ test.each(CATEGORIES)("recognises %s and rejects every other category", (category) => {
+ const error = errorIn(DRUID_ERROR_CATEGORIES[category]);
+
+ expect(error.is(category)).toBe(true);
+ expect(CATEGORIES.filter((other) => other !== category).some((other) => error.is(other))).toBe(false);
+ });
+
+ // Spec section 5, point 2: `category` is the classifier and `errorCode` is
+ // secondary. The same errorCode (`general`) arrives with UNCATEGORIZED,
+ // and `legacyQueryException` with TIMEOUT as well as RUNTIME_FAILURE, so a
+ // branch keyed on the code would be wrong for one of them.
+ test("matches on the category even when the errorCode is the generic one", () => {
+ const error = new DruidTransportError("/ by zero", DRUID_ERROR_CATEGORIES.UNCATEGORIZED, "general", "ADMIN");
+
+ expect(error.is("UNCATEGORIZED")).toBe(true);
+ expect(error.is("RUNTIME_FAILURE")).toBe(false);
+ expect(error.errorCode).toBe("general");
+ });
+});
+
+describe("DruidTransportError.isMonitoringUnavailable", () => {
+ // Spec section 5: these three are the ordinary configurations of a locked-down
+ // cluster - basic security refusing the credentials, a role without the
+ // STATE/EXTERNAL permission `sys` needs, and a build where the table is absent.
+ test.each<[DruidErrorCategory]>([
+ ["UNAUTHORIZED"],
+ ["FORBIDDEN"],
+ ["NOT_FOUND"],
+ ])("treats %s as an unavailable monitoring surface", (category) => {
+ expect(errorIn(DRUID_ERROR_CATEGORIES[category]).isMonitoringUnavailable()).toBe(true);
+ });
+
+ // Everything else must keep propagating: swallowing it would hide the user's
+ // own mistake behind an empty panel, which is what this list exists to prevent.
+ // UNCATEGORIZED is the sharpest case - live-verified, `SELECT 1/0` lands there
+ // with an ADMIN persona and an HTTP 500 while being an ordinary user error.
+ test.each<[DruidErrorCategory]>([
+ ["INVALID_INPUT"],
+ ["CAPACITY_EXCEEDED"],
+ ["CANCELED"],
+ ["RUNTIME_FAILURE"],
+ ["TIMEOUT"],
+ ["UNSUPPORTED"],
+ ["UNCATEGORIZED"],
+ ["DEFENSIVE"],
+ ])("does not swallow %s", (category) => {
+ expect(errorIn(DRUID_ERROR_CATEGORIES[category]).isMonitoringUnavailable()).toBe(false);
+ });
+
+ // A cluster that never answered has told us nothing about the surface, so a
+ // monitoring read must surface the outage rather than render an empty panel.
+ test("does not swallow a failure that never reached the server", () => {
+ expect(new DruidTransportError("The operation was aborted").isMonitoringUnavailable()).toBe(false);
+ });
+});
+
+// ============================================================================
+// The seam contract
+// ============================================================================
+
+describe("the DruidTransport contract", () => {
+ /** A transport built out of nothing but the neutral types, proving they suffice. */
+ class RecordingTransport implements DruidTransport {
+ readonly kind = "http" as const;
+ readonly calls: { sql: string; opts?: DruidQueryOptions }[] = [];
+ closed = false;
+
+ constructor(private readonly result: DruidQueryResult) {}
+
+ async query(sql: string, opts?: DruidQueryOptions): Promise {
+ this.calls.push({ sql, opts });
+ return this.result;
+ }
+
+ async close(): Promise {
+ this.closed = true;
+ }
+ }
+
+ const rows: DruidRow[] = [{ __time: "2026-08-03T14:36:44.356Z", id: "9007199254740993", ok: true }];
+
+ const result: DruidQueryResult = {
+ rows,
+ fieldNames: ["__time", "id", "ok"],
+ // Spec section 2: the SQL type is what the grid labels a column with, and it
+ // is the only one of the two that is right here.
+ sqlTypes: { __time: "TIMESTAMP", id: "BIGINT", ok: "BOOLEAN" },
+ nativeTypes: { __time: "LONG", id: "LONG", ok: "LONG" },
+ executionTimeMs: 7,
+ };
+
+ test("a result describes its rows, their order and both type vocabularies", async () => {
+ const transport = new RecordingTransport(result);
+
+ const received = await transport.query('SELECT __time, id, ok FROM "libredb_demo"');
+
+ expect(transport.kind).toBe("http");
+ expect(received.rows).toEqual(rows);
+ expect(received.fieldNames).toEqual(["__time", "id", "ok"]);
+ expect(received.executionTimeMs).toBe(7);
+ });
+
+ // Spec section 2, live-verified: the native type LIES for exactly these two
+ // cases, which is why both maps exist rather than one.
+ test("keeps the native type even where it disagrees with the SQL type", () => {
+ expect(result.sqlTypes?.__time).toBe("TIMESTAMP");
+ expect(result.nativeTypes?.__time).toBe("LONG");
+ expect(result.sqlTypes?.ok).toBe("BOOLEAN");
+ expect(result.nativeTypes?.ok).toBe("LONG");
+ });
+
+ /**
+ * Spec section 2, live-verified on 37.0.0:
+ * SELECT 1 AS c, 2 AS c -> [["c","c"],["LONG","LONG"],["INTEGER","INTEGER"],[1,2]]
+ * Rows are records, so the seam requires the implementation to disambiguate
+ * before it builds them - the second column would otherwise be gone before the
+ * seam, not after it. The spelling below is illustrative; the invariant the
+ * seam states is that `fieldNames` is unique and is the key set of every row,
+ * which is also what keeps the two type maps lossless.
+ */
+ test("a duplicated output name survives as two distinct columns", async () => {
+ const names = ["c", "c (2)"];
+ const duplicated: DruidQueryResult = {
+ rows: [{ c: 1, "c (2)": 2 }],
+ fieldNames: names,
+ sqlTypes: { c: "INTEGER", "c (2)": "INTEGER" },
+ nativeTypes: { c: "LONG", "c (2)": "LONG" },
+ executionTimeMs: 2,
+ };
+ const transport = new RecordingTransport(duplicated);
+
+ const received = await transport.query("SELECT 1 AS c, 2 AS c");
+
+ expect(received.fieldNames).toEqual(names);
+ expect(new Set(names).size).toBe(2);
+ // Both columns are reachable, and each type map still describes both.
+ expect(names.map((name) => received.rows[0][name])).toEqual([1, 2]);
+ expect(Object.keys(received.sqlTypes ?? {})).toEqual(names);
+ expect(Object.keys(received.nativeTypes ?? {})).toEqual(names);
+ });
+
+ // Nothing in the endpoint's answer describes the columns of an EXPLAIN-free
+ // failure-adjacent shape, and a proxy may rewrite the body, so both maps and
+ // the order are nullable together rather than degrading to fabricated names.
+ test("a result the source could not describe carries nulls, not guesses", async () => {
+ const transport = new RecordingTransport({
+ rows: [],
+ fieldNames: null,
+ sqlTypes: null,
+ nativeTypes: null,
+ executionTimeMs: 1,
+ });
+
+ const received = await transport.query("SELECT 1");
+
+ expect(received.rows).toEqual([]);
+ expect(received.fieldNames).toBeNull();
+ expect(received.sqlTypes).toBeNull();
+ expect(received.nativeTypes).toBeNull();
+ });
+
+ // Spec sections 6 and 13: the two deadlines are independent halves, and
+ // positional parameters really execute on Druid, so unlike ClickHouse the seam
+ // carries them instead of rejecting them.
+ test("options carry both deadlines and positional parameters", async () => {
+ const transport = new RecordingTransport(result);
+
+ await transport.query('SELECT id FROM "libredb_demo" WHERE region = ?', {
+ timeoutMs: 30_000,
+ clientDeadlineMs: 35_000,
+ parameters: ["emea", 5, 1.5, true, null, new Date(0)],
+ });
+
+ expect(transport.calls).toEqual([
+ {
+ sql: 'SELECT id FROM "libredb_demo" WHERE region = ?',
+ opts: {
+ timeoutMs: 30_000,
+ clientDeadlineMs: 35_000,
+ parameters: ["emea", 5, 1.5, true, null, new Date(0)],
+ },
+ },
+ ]);
+ });
+
+ test("close is part of the contract even when a transport holds nothing open", async () => {
+ const transport = new RecordingTransport(result);
+
+ await transport.close();
+
+ expect(transport.closed).toBe(true);
+ });
+});
diff --git a/tests/unit/db/factory.test.ts b/tests/unit/db/factory.test.ts
index 8fbf3d43..12c6e9de 100644
--- a/tests/unit/db/factory.test.ts
+++ b/tests/unit/db/factory.test.ts
@@ -278,6 +278,7 @@ describe("createDatabaseProvider", () => {
const conn = makeConnection("unknown");
await expect(createDatabaseProvider(conn)).rejects.toThrow(/couchbase/);
await expect(createDatabaseProvider(conn)).rejects.toThrow(/clickhouse/);
+ await expect(createDatabaseProvider(conn)).rejects.toThrow(/druid/);
});
test('creates provider for type "postgres"', async () => {
@@ -344,6 +345,15 @@ describe("createDatabaseProvider", () => {
expect(provider.type).toBe("clickhouse");
});
+ test('creates provider for type "druid"', async () => {
+ // No `database` field: Druid reports exactly one catalog, always named
+ // `druid`, so the provider ignores the connection's database entirely.
+ const conn = makeConnection("druid", { port: 8888 });
+ const provider = await createDatabaseProvider(conn);
+ expect(provider).toBeDefined();
+ expect(provider.type).toBe("druid");
+ });
+
test('creates provider for type "libredb"', async () => {
const conn = makeConnection("libredb", { database: "/tmp/test.libredb" });
const provider = await createDatabaseProvider(conn);
diff --git a/tests/unit/db/json-integers.test.ts b/tests/unit/db/json-integers.test.ts
new file mode 100644
index 00000000..188adad1
--- /dev/null
+++ b/tests/unit/db/json-integers.test.ts
@@ -0,0 +1,148 @@
+/**
+ * 64-bit integers in JSON text (issue #265, design spec section 3)
+ *
+ * The scanner these tests cover started inside the Druid HTTP transport and moved
+ * here because a SECOND parser needs it: Druid's EXPLAIN answers with columns that
+ * are themselves JSON *text*, so the explain strategy parses one layer deeper than
+ * the transport can reach, and an explain strategy must not import from a provider
+ * directory (the rule `clickhouse-json.ts` records - it would tie the registry to
+ * one provider). Both callers now import it from `@/lib/db/utils`.
+ *
+ * Every body replayed below was captured verbatim from Apache Druid 37.0.0, and the
+ * hazard is pinned first: `JSON.parse` rounds a 64-bit literal silently, with no
+ * error whatsoever.
+ */
+import { describe, expect, test } from "bun:test";
+import { quoteUnsafeIntegers } from "@/lib/db/utils/json-integers";
+
+/**
+ * `SELECT id, name, snowflake_id FROM libredb_demo WHERE region = ? LIMIT 1` as the
+ * server returned it: three header rows and then the data, with the BIGINT unquoted
+ * - the exact 2^53+1 value that `JSON.parse` rounds to ...992.
+ */
+const DRUID_SELECT_BODY =
+ '[["id","name","snowflake_id"],["LONG","STRING","LONG"],["BIGINT","VARCHAR","BIGINT"],[1030,"alpha",9007199254740993]]';
+
+/**
+ * One EXPLAIN plan column, verbatim, for
+ * `SELECT id FROM "libredb_demo" WHERE snowflake_id = 9007199254740993`. It is the
+ * value of a JSON string, which is why the outer pass leaves its digits alone and
+ * the inner parse has to run the scanner itself.
+ */
+const DRUID_PLAN_COLUMN =
+ '[{"query":{"queryType":"scan","dataSource":{"type":"table","name":"libredb_demo"},' +
+ '"filter":{"type":"equals","column":"snowflake_id","matchValueType":"LONG","matchValue":9007199254740993},' +
+ '"columns":["id"]}}]';
+
+describe("quoteUnsafeIntegers", () => {
+ /** The exact value the live cluster returned for libredb_demo.snowflake_id. */
+ const LIVE_UNSAFE = "9007199254740993";
+
+ test("proves the hazard it exists for", () => {
+ // Not a test of our code: a test of the reason it exists. If this ever stops
+ // being true, the whole pass can go.
+ expect(JSON.parse(`[${LIVE_UNSAFE}]`)).toEqual([9007199254740992]);
+ });
+
+ test("makes the live value survive JSON.parse exactly", () => {
+ const parsed = JSON.parse(quoteUnsafeIntegers(DRUID_SELECT_BODY)) as unknown[][];
+
+ expect(parsed[3][2]).toBe(LIVE_UNSAFE);
+ });
+
+ // The second caller's shape: a plan literal nested inside what was a JSON string
+ // one layer up, which is the case the transport's own pass cannot reach.
+ test("makes a plan literal survive the inner parse exactly", () => {
+ const [entry] = JSON.parse(quoteUnsafeIntegers(DRUID_PLAN_COLUMN)) as [
+ { query: { filter: { matchValue: unknown } } },
+ ];
+
+ expect(entry.query.filter.matchValue).toBe(LIVE_UNSAFE);
+ });
+
+ test.each<[string, string, string]>([
+ ["an array element", `[${LIVE_UNSAFE}]`, `["${LIVE_UNSAFE}"]`],
+ ["an object value", `{"a":${LIVE_UNSAFE}}`, `{"a":"${LIVE_UNSAFE}"}`],
+ ["a value before a comma", `[${LIVE_UNSAFE},1]`, `["${LIVE_UNSAFE}",1]`],
+ ["a value padded with spaces", `[ ${LIVE_UNSAFE} , 1 ]`, `[ "${LIVE_UNSAFE}" , 1 ]`],
+ ["a value before a brace", `{"a":${LIVE_UNSAFE}}`, `{"a":"${LIVE_UNSAFE}"}`],
+ ["a value before a newline", `[\n ${LIVE_UNSAFE}\n]`, `[\n "${LIVE_UNSAFE}"\n]`],
+ ["a negative literal", "[-9007199254740993]", '["-9007199254740993"]'],
+ ["every literal in the body", `[${LIVE_UNSAFE},9007199254740994]`, `["${LIVE_UNSAFE}","9007199254740994"]`],
+ ["a much longer literal", "[18446744073709551615]", '["18446744073709551615"]'],
+ ])("quotes %s", (_label, body, expected) => {
+ expect(quoteUnsafeIntegers(body)).toBe(expected);
+ });
+
+ // The boundary is exactly Number.MIN_SAFE_INTEGER .. Number.MAX_SAFE_INTEGER:
+ // inside it JSON.parse is exact, so rewriting would turn a number the grid can
+ // sort into a string it cannot.
+ test.each<[string, string]>([
+ ["the largest safe integer", String(Number.MAX_SAFE_INTEGER)],
+ ["the smallest safe integer", String(Number.MIN_SAFE_INTEGER)],
+ ["a small integer", "1030"],
+ ["zero", "0"],
+ ["negative zero", "-0"],
+ ["a float", "1.5"],
+ ["a float beyond the safe range", "9007199254740993.5"],
+ ["an exponent form", "1e999"],
+ ["an integral exponent form", "9007199254740993e0"],
+ ["a negative exponent form", "-1.5e-7"],
+ ])("leaves %s untouched", (_label, literal) => {
+ expect(quoteUnsafeIntegers(`[${literal}]`)).toBe(`[${literal}]`);
+ });
+
+ test.each<[string, string]>([
+ ["the first integer outside the safe range", "9007199254740992"],
+ ["the first integer below it", "-9007199254740992"],
+ ])("quotes %s", (_label, literal) => {
+ expect(quoteUnsafeIntegers(`[${literal}]`)).toBe(`["${literal}"]`);
+ });
+
+ // A string is the one place a digit run must never be touched: rewriting inside
+ // one changes a value the user is reading, and would produce invalid JSON.
+ test("never rewrites a digit run inside a string literal", () => {
+ const body = `[["id: ${LIVE_UNSAFE}"]]`;
+
+ expect(quoteUnsafeIntegers(body)).toBe(body);
+ });
+
+ // The desync that matters: reading `\"` as the end of the string would put the
+ // scanner outside it, and the digits that follow would be rewritten inside a
+ // string - invalid JSON, and a corrupted value on screen.
+ test("an escaped quote does not desync the scanner", () => {
+ const body = `[["a\\"${LIVE_UNSAFE}"],[${LIVE_UNSAFE}]]`;
+
+ expect(quoteUnsafeIntegers(body)).toBe(`[["a\\"${LIVE_UNSAFE}"],["${LIVE_UNSAFE}"]]`);
+ });
+
+ test("an escaped backslash at the end of a string does not desync the scanner", () => {
+ const body = `[["a\\\\",${LIVE_UNSAFE}]]`;
+
+ expect(quoteUnsafeIntegers(body)).toBe(`[["a\\\\","${LIVE_UNSAFE}"]]`);
+ });
+
+ test.each<[string, string]>([
+ ["a body with no literal at all", '[["a"],["STRING"],["VARCHAR"],["x"]]'],
+ ["an empty body", ""],
+ ["a body of only safe numbers", "[1,2,3]"],
+ ])("returns %s unchanged", (_label, body) => {
+ expect(quoteUnsafeIntegers(body)).toBe(body);
+ });
+
+ // A cancelled Druid query really does truncate its own body mid-value. The pass
+ // runs before JSON.parse, so it has to walk one without throwing and leave the
+ // parser to report the real problem.
+ test.each<[string, string]>([
+ ["a truncated string", `[["a"],["STRING"],["VARCHAR"],["gamm`],
+ ["a truncated escape", `[["a\\`],
+ ["a lone minus", "[-,1]"],
+ ["a truncated unsafe literal", `[${LIVE_UNSAFE}`],
+ ])("walks %s without throwing", (_label, body) => {
+ expect(() => quoteUnsafeIntegers(body)).not.toThrow();
+ });
+
+ test("quotes a truncated literal it did reach the end of", () => {
+ expect(quoteUnsafeIntegers(`[${LIVE_UNSAFE}`)).toBe(`["${LIVE_UNSAFE}"`);
+ });
+});
diff --git a/tests/unit/lib/connection-string-parser.test.ts b/tests/unit/lib/connection-string-parser.test.ts
index a8f38d81..00b1d177 100644
--- a/tests/unit/lib/connection-string-parser.test.ts
+++ b/tests/unit/lib/connection-string-parser.test.ts
@@ -392,6 +392,30 @@ describe("parseConnectionString", () => {
});
});
+ // ── Apache Druid: deliberately no scheme (issue #265) ───────────────────
+
+ describe("Druid has no connection-string form", () => {
+ // Druid's capabilities set supportsConnectionString: false and its UI config sets
+ // showConnectionStringToggle: false, so nothing in the product ever produces or
+ // consumes a Druid URI. There is no convention to parse either: Druid's own JDBC
+ // driver addresses Avatica (jdbc:avatica:remote:url=http://host:8888/druid/v2/sql/avatica/),
+ // which is not a URL this parser could round-trip into host/port/user/password.
+ // These tests pin that absence so a future reader does not read it as an omission.
+ test("does not invent a druid:// scheme", () => {
+ expect(parseConnectionString("druid://localhost:8888")).toBeNull();
+ expect(detectConnectionStringType("druid://localhost:8888")).toBeNull();
+ });
+
+ test("http:// and https:// stay ClickHouse, even on Druid's Router port", () => {
+ // The consequence of the decision above, recorded rather than hidden: the generic
+ // HTTP schemes were claimed by ClickHouse first (issue #264), so pasting a Druid
+ // Router URL selects ClickHouse. A Druid connection is made through the form
+ // fields instead, which is why its form has no paste toggle at all.
+ expect(detectConnectionStringType("http://localhost:8888")).toBe("clickhouse");
+ expect(parseConnectionString("http://localhost:8888")!.type).toBe("clickhouse");
+ });
+ });
+
// ── ADO.NET format ──────────────────────────────────────────────────────
describe("ADO.NET format", () => {
diff --git a/tests/unit/lib/db-icons.test.tsx b/tests/unit/lib/db-icons.test.tsx
index d28887df..eabd4225 100644
--- a/tests/unit/lib/db-icons.test.tsx
+++ b/tests/unit/lib/db-icons.test.tsx
@@ -12,6 +12,7 @@ import {
LibreDBIcon,
CouchbaseIcon,
ClickHouseIcon,
+ DruidIcon,
} from "@/components/icons/db-icons";
describe("db-icons", () => {
@@ -26,6 +27,7 @@ describe("db-icons", () => {
{ name: "LibreDBIcon", Component: LibreDBIcon },
{ name: "CouchbaseIcon", Component: CouchbaseIcon },
{ name: "ClickHouseIcon", Component: ClickHouseIcon },
+ { name: "DruidIcon", Component: DruidIcon },
];
for (const { name, Component } of icons) {
diff --git a/tests/unit/lib/db-ui-config.test.ts b/tests/unit/lib/db-ui-config.test.ts
index bc59ac33..aca2185d 100644
--- a/tests/unit/lib/db-ui-config.test.ts
+++ b/tests/unit/lib/db-ui-config.test.ts
@@ -13,6 +13,7 @@ const ALL_TYPES: DatabaseType[] = [
"libredb",
"couchbase",
"clickhouse",
+ "druid",
];
describe("db-ui-config", () => {
@@ -42,7 +43,9 @@ describe("db-ui-config", () => {
test("exposes the connection string toggle only for the URI-addressed providers", () => {
// MongoDB (mongodb+srv), Couchbase (couchbase://, couchbases://) and ClickHouse
// (its HTTP endpoint is itself a URL) are the providers a user routinely has a
- // full URI for; everything else is field-based.
+ // full URI for; everything else is field-based. Druid is field-based on purpose:
+ // it has no URI convention for its HTTP SQL API (its JDBC driver uses
+ // `jdbc:avatica:remote:url=...`), so there is no string a user could paste.
const withToggle = new Set(["mongodb", "couchbase", "clickhouse"]);
for (const type of ALL_TYPES) {
expect(getDBConfig(type).showConnectionStringToggle).toBe(withToggle.has(type));
@@ -75,6 +78,19 @@ describe("db-ui-config", () => {
]);
});
+ test("druid exposes its label, Router port and connection fields", () => {
+ expect(getDBConfig("druid").label).toBe("Apache Druid");
+ expect(getDBConfig("druid").defaultPort).toBe("8888");
+ expect(getDBConfig("druid").connectionFields).toEqual(["host", "port", "user", "password"]);
+ });
+
+ test("druid offers no database field, because Druid has exactly one catalog", () => {
+ // INFORMATION_SCHEMA.SCHEMATA reports exactly one catalog, always named `druid`
+ // (issue #265, live-verified against Druid 37.0.0). A database selector would be
+ // a control with no effect, so the field is absent rather than ignored.
+ expect(getDBConfig("druid").connectionFields).not.toContain("database");
+ });
+
test("every provider carries a distinct colour class", () => {
const colors = ALL_TYPES.map((type) => getDBConfig(type).color);
expect(new Set(colors).size).toBe(colors.length);
@@ -116,6 +132,7 @@ describe("db-ui-config", () => {
expect(isFileBased("mssql")).toBe(false);
expect(isFileBased("couchbase")).toBe(false);
expect(isFileBased("clickhouse")).toBe(false);
+ expect(isFileBased("druid")).toBe(false);
});
});
});
diff --git a/tests/unit/lib/explain/druid-native.test.ts b/tests/unit/lib/explain/druid-native.test.ts
new file mode 100644
index 00000000..1c0fe5b7
--- /dev/null
+++ b/tests/unit/lib/explain/druid-native.test.ts
@@ -0,0 +1,782 @@
+import { describe, test, expect } from "bun:test";
+import { getExplainStrategy } from "@/lib/explain";
+import { druidNativeStrategy } from "@/lib/explain/druid-native";
+import type { ExplainTreeNode } from "@/lib/explain/types";
+
+/**
+ * Druid stamps every plan with the same all-of-time interval; it is noise in a
+ * fixture but it is what the server emits, so it stays.
+ */
+const ETERNITY = "-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z";
+const INTERVALS = { type: "intervals", intervals: [ETERNITY] };
+
+/**
+ * Captured from Apache Druid 37.0.0 for
+ * `EXPLAIN PLAN FOR SELECT a.region, COUNT(*) AS c FROM libredb_demo a
+ * INNER JOIN (SELECT region, MAX(qty) AS mq FROM libredb_rollup GROUP BY region) b
+ * ON a.region = b.region WHERE a.qty > 5 GROUP BY a.region`.
+ *
+ * This is the shape that settled the render model: a table under a join under a
+ * groupBy, with the right leg wrapped in a `query` dataSource. The recursion
+ * through `dataSource` is the operator tree. Only the per-request `context`
+ * ({queryId, sqlQueryId}) is dropped - it is a fresh UUID on every call and
+ * describes the request, not the plan.
+ */
+const LIVE_JOIN_PLAN = [
+ {
+ query: {
+ queryType: "groupBy",
+ dataSource: {
+ type: "join",
+ left: { type: "table", name: "libredb_demo" },
+ right: {
+ type: "query",
+ query: {
+ queryType: "groupBy",
+ dataSource: { type: "table", name: "libredb_rollup" },
+ intervals: INTERVALS,
+ granularity: { type: "all" },
+ dimensions: [{ type: "default", dimension: "region", outputName: "d0", outputType: "STRING" }],
+ limitSpec: { type: "NoopLimitSpec" },
+ },
+ },
+ rightPrefix: "j0.",
+ condition: '("region" == "j0.d0")',
+ joinType: "INNER",
+ },
+ intervals: INTERVALS,
+ filter: { type: "range", column: "qty", matchValueType: "LONG", lower: 5, lowerOpen: true },
+ granularity: { type: "all" },
+ dimensions: [{ type: "default", dimension: "region", outputName: "d0", outputType: "STRING" }],
+ aggregations: [{ type: "count", name: "a0" }],
+ limitSpec: { type: "NoopLimitSpec" },
+ },
+ signature: [
+ { name: "d0", type: "STRING" },
+ { name: "a0", type: "LONG" },
+ ],
+ columnMappings: [
+ { queryColumn: "d0", outputColumn: "region" },
+ { queryColumn: "a0", outputColumn: "c" },
+ ],
+ },
+];
+
+/** Live for the same query: RESOURCES lists what the statement reads. */
+const LIVE_RESOURCES = [
+ { name: "libredb_demo", type: "DATASOURCE" },
+ { name: "libredb_rollup", type: "DATASOURCE" },
+];
+
+/** Live: ATTRIBUTES is an object, not an array. */
+const LIVE_ATTRIBUTES = { statementType: "SELECT" };
+
+/**
+ * Live for `EXPLAIN PLAN FOR SELECT * FROM libredb_demo LIMIT 5` - the simplest
+ * single-query plan there is: a scan straight off a table, no filter and no
+ * aggregation. `columns`/`columnTypes` are trimmed for length; nothing the tree
+ * reads is affected.
+ */
+const LIVE_SCAN_PLAN = [
+ {
+ query: {
+ queryType: "scan",
+ dataSource: { type: "table", name: "libredb_demo" },
+ intervals: INTERVALS,
+ resultFormat: "compactedList",
+ limit: 5,
+ columns: ["__time", "id", "region"],
+ columnTypes: ["LONG", "LONG", "STRING"],
+ granularity: { type: "all" },
+ legacy: false,
+ },
+ signature: [{ name: "id", type: "LONG" }],
+ columnMappings: [{ queryColumn: "id", outputColumn: "id" }],
+ },
+];
+
+/**
+ * Live for `EXPLAIN PLAN FOR SELECT COUNT(*) AS n FROM (SELECT region FROM libredb_demo GROUP BY region)`.
+ * The subquery becomes a `query` dataSource, and the outer groupBy reports
+ * `dimensions: []` - an empty array, which must not produce an empty row.
+ */
+const LIVE_SUBQUERY_PLAN = [
+ {
+ query: {
+ queryType: "groupBy",
+ dataSource: {
+ type: "query",
+ query: {
+ queryType: "groupBy",
+ dataSource: { type: "table", name: "libredb_demo" },
+ intervals: INTERVALS,
+ granularity: { type: "all" },
+ dimensions: [{ type: "default", dimension: "region", outputName: "d0", outputType: "STRING" }],
+ limitSpec: { type: "NoopLimitSpec" },
+ },
+ },
+ intervals: INTERVALS,
+ granularity: { type: "all" },
+ dimensions: [],
+ aggregations: [{ type: "count", name: "a0" }],
+ limitSpec: { type: "NoopLimitSpec" },
+ },
+ },
+];
+
+/** Live for `... SELECT region FROM libredb_demo UNION ALL SELECT region FROM libredb_rollup`. */
+const LIVE_UNION_PLAN = [
+ {
+ query: {
+ queryType: "scan",
+ dataSource: {
+ type: "union",
+ dataSources: [
+ { type: "table", name: "libredb_demo" },
+ { type: "table", name: "libredb_rollup" },
+ ],
+ },
+ intervals: INTERVALS,
+ granularity: { type: "all" },
+ },
+ },
+];
+
+/**
+ * Live for `... SELECT region, COUNT(*) AS c FROM libredb_demo GROUP BY region ORDER BY 2 DESC LIMIT 3`.
+ * topN names its single grouping key `dimension` (singular), so the dimensions
+ * row has to accept both spellings.
+ */
+const LIVE_TOPN_PLAN = [
+ {
+ query: {
+ queryType: "topN",
+ dataSource: { type: "table", name: "libredb_demo" },
+ dimension: { type: "default", dimension: "region", outputName: "d0", outputType: "STRING" },
+ metric: { type: "numeric", metric: "a0" },
+ threshold: 3,
+ granularity: { type: "all" },
+ aggregations: [{ type: "count", name: "a0" }],
+ },
+ },
+];
+
+/**
+ * Live for `... SELECT TIME_FLOOR(__time, 'P1D') AS d, COUNT(*) AS c FROM libredb_demo GROUP BY 1`.
+ * Two shapes only this plan reveals: `granularity` is a bare string ("DAY", and
+ * "SIX_HOUR" for PT6H) rather than the usual {type:"all"} object, and
+ * `dimensions` is explicit null.
+ */
+const LIVE_TIMESERIES_PLAN = [
+ {
+ query: {
+ queryType: "timeseries",
+ dataSource: { type: "table", name: "libredb_demo" },
+ intervals: INTERVALS,
+ granularity: "DAY",
+ dimensions: null,
+ virtualColumns: null,
+ aggregations: [{ type: "count", name: "a0" }],
+ },
+ },
+];
+
+/**
+ * Live for
+ * `... SELECT region, SUM(qty) AS s FROM libredb_demo GROUP BY region
+ * UNION ALL SELECT name, COUNT(*) FROM libredb_rollup GROUP BY name`.
+ *
+ * PLAN is an array and it is NOT always length 1: two aggregating branches of a
+ * UNION ALL come back as two independent native queries. Rendering only the first
+ * would silently hide half the plan.
+ */
+const LIVE_TWO_QUERY_PLAN = [
+ {
+ query: {
+ queryType: "groupBy",
+ dataSource: { type: "table", name: "libredb_demo" },
+ granularity: { type: "all" },
+ dimensions: [{ type: "default", dimension: "region", outputName: "d0", outputType: "STRING" }],
+ aggregations: [{ type: "longSum", name: "a0", fieldName: "qty" }],
+ },
+ },
+ {
+ query: {
+ queryType: "groupBy",
+ dataSource: { type: "table", name: "libredb_rollup" },
+ granularity: { type: "all" },
+ dimensions: [{ type: "default", dimension: "name", outputName: "d0", outputType: "STRING" }],
+ aggregations: [{ type: "count", name: "a0" }],
+ },
+ },
+];
+
+/** Live for `... SELECT * FROM (VALUES (1),(2)) AS t(x)` - the only inline dataSource SQL can produce. */
+const LIVE_INLINE_PLAN = [
+ {
+ query: {
+ queryType: "scan",
+ dataSource: { type: "inline", columnNames: ["x"], columnTypes: ["LONG"], rows: [[1], [2]] },
+ intervals: INTERVALS,
+ granularity: { type: "all" },
+ },
+ },
+];
+
+/** The wire shape: one row, three columns, each holding JSON text. */
+function explainResult(
+ plan: unknown,
+ resources: unknown = LIVE_RESOURCES,
+ attributes: unknown = LIVE_ATTRIBUTES,
+): { rows: Array> } {
+ return {
+ rows: [
+ { PLAN: JSON.stringify(plan), RESOURCES: JSON.stringify(resources), ATTRIBUTES: JSON.stringify(attributes) },
+ ],
+ };
+}
+
+/** What extractPlan stores, without going through JSON text. */
+function stored(plan: unknown): unknown {
+ return { plan, resources: LIVE_RESOURCES, attributes: LIVE_ATTRIBUTES };
+}
+
+function treeRoot(raw: unknown): ExplainTreeNode {
+ const model = druidNativeStrategy.toRenderModel(raw);
+ expect(model?.kind).toBe("tree");
+ return (model as { root: ExplainTreeNode }).root;
+}
+
+/** Builds a one-entry plan around a single native query. */
+function planOf(query: unknown): unknown {
+ return [{ query }];
+}
+
+/** Builds a one-entry plan whose root scan reads the given dataSource. */
+function planWithDataSource(dataSource: unknown): unknown {
+ return planOf({ queryType: "scan", dataSource });
+}
+
+function labels(nodes: ExplainTreeNode[]): string[] {
+ return nodes.map((node) => node.label);
+}
+
+/** Follows first children down from the root, collecting every label on the way. */
+function spineLabels(root: ExplainTreeNode): string[] {
+ const collected: string[] = [];
+ for (let node: ExplainTreeNode | undefined = root; node !== undefined; node = node.children[0]) {
+ collected.push(node.label);
+ }
+ return collected;
+}
+
+/** The dataSource node of a plan built by planWithDataSource. */
+function dataSourceNode(dataSource: unknown): ExplainTreeNode {
+ return treeRoot(planWithDataSource(dataSource)).children[0];
+}
+
+/** A `query` dataSource chain `levels` deep, to exercise the recursion bound. */
+function nestedQueryChain(levels: number): unknown {
+ let dataSource: unknown = { type: "table", name: "libredb_demo" };
+ for (let level = 0; level < levels; level++) {
+ dataSource = { type: "query", query: { queryType: "groupBy", dataSource } };
+ }
+ return planWithDataSource(dataSource);
+}
+
+describe("druidNativeStrategy", () => {
+ test("format id", () => {
+ expect(druidNativeStrategy.format).toBe("druid-native");
+ });
+
+ test("is registered in the explain registry", () => {
+ expect(getExplainStrategy("druid-native")).toBe(druidNativeStrategy);
+ });
+
+ test("buildSql wraps a SELECT in EXPLAIN PLAN FOR", () => {
+ expect(druidNativeStrategy.buildSql("SELECT id FROM libredb_demo", "estimate")).toBe(
+ "EXPLAIN PLAN FOR SELECT id FROM libredb_demo",
+ );
+ expect(druidNativeStrategy.buildSql(" select 1", "estimate")).toBe("EXPLAIN PLAN FOR select 1");
+ });
+
+ // Druid's EXPLAIN never executes the statement, so analyze has no separate form
+ // to build. Returning null for it would kill the Explain button outright: the
+ // direct action always builds with mode "analyze" (use-query-execution.ts:165)
+ // and refuses the run when the strategy declines. sqlite-queryplan.ts and
+ // couchbase-json.ts return the estimate for both modes for the same reason.
+ test("buildSql returns the same plan in analyze mode, matching the SQLite and Couchbase strategies", () => {
+ expect(druidNativeStrategy.buildSql("SELECT id FROM libredb_demo", "analyze")).toBe(
+ "EXPLAIN PLAN FOR SELECT id FROM libredb_demo",
+ );
+ });
+
+ // Live-verified: `EXPLAIN PLAN FOR SELECT 1 AS c1;` is accepted, so the
+ // semicolon needs no special handling and must not be stripped.
+ test("buildSql leaves a trailing semicolon in place, which Druid accepts", () => {
+ expect(druidNativeStrategy.buildSql("SELECT 1 AS c1;", "analyze")).toBe("EXPLAIN PLAN FOR SELECT 1 AS c1;");
+ });
+
+ // Live-verified accepted by Druid 37.0.0, and `analyzeQuery` already treats
+ // `WITH ... SELECT` as a SELECT, so declining it here contradicted the pipeline and
+ // left the Explain button dead on any CTE.
+ test("buildSql accepts CTEs that lead to SELECT", () => {
+ expect(druidNativeStrategy.buildSql("WITH t AS (SELECT 1) SELECT * FROM t", "estimate")).toBe(
+ "EXPLAIN PLAN FOR WITH t AS (SELECT 1) SELECT * FROM t",
+ );
+ });
+
+ test("buildSql accepts SELECT preceded by SQL comments", () => {
+ expect(druidNativeStrategy.buildSql("-- note\nSELECT 1", "estimate")).toBe("EXPLAIN PLAN FOR -- note\nSELECT 1");
+ expect(druidNativeStrategy.buildSql("/* multi\nline */ SELECT 1", "analyze")).toBe(
+ "EXPLAIN PLAN FOR /* multi\nline */ SELECT 1",
+ );
+ });
+
+ test("buildSql accepts comments and whitespace interleaved, and stacked ahead of a CTE", () => {
+ expect(druidNativeStrategy.buildSql("/*a*//*b*/SELECT 1", "estimate")).toBe("EXPLAIN PLAN FOR /*a*//*b*/SELECT 1");
+ expect(druidNativeStrategy.buildSql("--\nSELECT 1", "estimate")).toBe("EXPLAIN PLAN FOR --\nSELECT 1");
+ const stacked = "-- a\n-- b\n /* c */ WITH t AS (SELECT 1) SELECT * FROM t";
+ expect(druidNativeStrategy.buildSql(stacked, "analyze")).toBe(`EXPLAIN PLAN FOR ${stacked}`);
+ });
+
+ // The near misses. Broadening the prefix must not turn it into "starts with
+ // anything": a comment is not a statement, and the word boundary is what keeps
+ // SELECTED and WITHER out.
+ test("buildSql still declines comment-only input, empty input and words that merely start with the keywords", () => {
+ expect(druidNativeStrategy.buildSql("-- only a comment", "estimate")).toBeNull();
+ expect(druidNativeStrategy.buildSql("/* only a comment */", "analyze")).toBeNull();
+ expect(druidNativeStrategy.buildSql("", "estimate")).toBeNull();
+ expect(druidNativeStrategy.buildSql(" ", "analyze")).toBeNull();
+ expect(druidNativeStrategy.buildSql("SELECTED 1", "estimate")).toBeNull();
+ expect(druidNativeStrategy.buildSql("WITHER", "analyze")).toBeNull();
+ // An unterminated block comment never closes, so nothing after it is reached.
+ expect(druidNativeStrategy.buildSql("/* unterminated SELECT 1", "estimate")).toBeNull();
+ });
+
+ /**
+ * Regression guard on the SHAPE of SELECT_ONLY, not on what it accepts.
+ *
+ * The obvious spelling of this pattern - a leading `\s*` in front of an alternation
+ * that also contains `\s`, plus a lazy `[\s\S]*?` block-comment body inside a `*`
+ * quantifier - is ambiguous twice over, and a non-matching input pays for it by
+ * backtracking. Measured on the ambiguous form: 852ms for the 4 KB input below, and
+ * 958ms for 20k leading spaces. The tempered form used here answers both in well
+ * under a millisecond.
+ *
+ * `buildSql` runs on the editor's contents every time a query is executed, so a
+ * buffer that opens with a large commented-out block and then a non-SELECT is a
+ * reachable input. The bound is deliberately loose - three orders of magnitude above
+ * what the correct pattern needs - so it cannot flake on a slow runner while still
+ * failing outright if the ambiguity comes back.
+ */
+ test("buildSql does not backtrack on a long comment or whitespace run that never reaches a SELECT", () => {
+ const BOUND_MS = 200;
+ const adversarial = [
+ `${"/**/".repeat(1000)}UPDATE t SET a = 1`,
+ `${"/*a*/".repeat(1000)}DELETE FROM t`,
+ `${" ".repeat(20000)}UPDATE t SET a = 1`,
+ `${"-- a\n".repeat(1000)}UPDATE t SET a = 1`,
+ `${"/**/ -- a\n ".repeat(1000)}DELETE FROM t`,
+ // The cheapest of the lot, and the one the first two fixes missed: a run of BARE
+ // dashes with no newline. Without the `(?:\n|$)` tail on the line-comment branch
+ // this alone cost 634ms at FORTY-NINE characters, growing about fourfold per two
+ // extra dashes. CodeQL found it; `-- a\n` above cannot, because the newline makes
+ // that branch unambiguous.
+ `${"--".repeat(24)}X`,
+ `${"--".repeat(2000)}UPDATE t SET a = 1`,
+ `${"-".repeat(20000)}X`,
+ ];
+
+ for (const sql of adversarial) {
+ const started = performance.now();
+ const result = druidNativeStrategy.buildSql(sql, "analyze");
+ const elapsed = performance.now() - started;
+
+ // Correct answer AND a bounded one: a fast wrong answer is not a pass.
+ expect(result).toBeNull();
+ expect(elapsed).toBeLessThan(BOUND_MS);
+ }
+ });
+
+ // Druid rejects UPDATE and DELETE outright and routes INSERT/REPLACE to the MSQ
+ // task engine, so none of them is explainable through this endpoint.
+ test("buildSql returns null for non-SELECT statements in both modes", () => {
+ expect(druidNativeStrategy.buildSql("INSERT INTO t SELECT 1", "estimate")).toBeNull();
+ expect(druidNativeStrategy.buildSql("REPLACE INTO t OVERWRITE ALL SELECT 1", "analyze")).toBeNull();
+ expect(druidNativeStrategy.buildSql("UPDATE t SET a = 1", "estimate")).toBeNull();
+ expect(druidNativeStrategy.buildSql("DELETE FROM t WHERE a = 1", "analyze")).toBeNull();
+ expect(druidNativeStrategy.buildSql("EXPLAIN PLAN FOR SELECT 1", "estimate")).toBeNull();
+ });
+
+ // All three columns arrive as JSON text, so the envelope parse leaves three
+ // escaped blobs behind. Parsing them here is what gives the raw JSON tab and the
+ // AI tab a structure to read instead of one long escaped string.
+ test("extractPlan parses all three JSON-string columns into one structure", () => {
+ expect(druidNativeStrategy.extractPlan(explainResult(LIVE_JOIN_PLAN))).toEqual({
+ plan: LIVE_JOIN_PLAN,
+ resources: LIVE_RESOURCES,
+ attributes: LIVE_ATTRIBUTES,
+ });
+ });
+
+ // Live: a statement reading no datasource (SELECT 1) reports RESOURCES "[]".
+ test("extractPlan keeps an empty RESOURCES array", () => {
+ const extracted = druidNativeStrategy.extractPlan(explainResult(LIVE_SCAN_PLAN, [], { statementType: "SELECT" }));
+ expect(extracted).toEqual({ plan: LIVE_SCAN_PLAN, resources: [], attributes: { statementType: "SELECT" } });
+ });
+
+ // Regression guard. The plan columns are JSON TEXT inside an already-parsed body,
+ // so the transport's pass over the outer body correctly leaves their digits alone -
+ // relative to that body they sit inside a string literal. This is a SECOND,
+ // independent parse and therefore a second chance to round the same value. Live,
+ // a native filter's `"matchValue": 9007199254740993` became ...992 in the stored
+ // plan, which is what the raw-JSON tab and the AI analyzer then read.
+ test("extractPlan keeps a 64-bit filter value exact through the inner parse", () => {
+ const exact = "9007199254740993";
+ const plan = `[{"query":{"queryType":"scan","dataSource":{"type":"table","name":"libredb_demo"},"filter":{"type":"equals","column":"snowflake_id","matchValueType":"LONG","matchValue":${exact}}}}]`;
+
+ // The premise: a plain parse of this same text really does corrupt the value.
+ const naive = JSON.stringify(JSON.parse(plan));
+ expect(naive).not.toContain(exact);
+
+ const stored = JSON.stringify(druidNativeStrategy.extractPlan({ rows: [{ PLAN: plan }] }));
+ expect(stored).toContain(exact);
+ expect(stored).not.toContain("9007199254740992");
+ });
+
+ test("extractPlan keeps a column's text when its JSON cannot be parsed", () => {
+ expect(druidNativeStrategy.extractPlan({ rows: [{ PLAN: "[{ oops", RESOURCES: "", ATTRIBUTES: "{" }] })).toEqual({
+ plan: "[{ oops",
+ resources: "",
+ attributes: "{",
+ });
+ });
+
+ test("extractPlan tolerates any of the three columns being absent", () => {
+ expect(druidNativeStrategy.extractPlan({ rows: [{ PLAN: "[]" }] })).toEqual({
+ plan: [],
+ resources: undefined,
+ attributes: undefined,
+ });
+ expect(druidNativeStrategy.extractPlan({ rows: [{ ATTRIBUTES: '{"statementType":"SELECT"}' }] })).toEqual({
+ plan: undefined,
+ resources: undefined,
+ attributes: { statementType: "SELECT" },
+ });
+ });
+
+ // A non-string cell cannot be JSON text; it is not a column this strategy owns.
+ test("extractPlan ignores a column whose cell is not text", () => {
+ expect(druidNativeStrategy.extractPlan({ rows: [{ PLAN: 7, RESOURCES: null, ATTRIBUTES: "[]" }] })).toEqual({
+ plan: undefined,
+ resources: undefined,
+ attributes: [],
+ });
+ });
+
+ // Nothing recognisable to unwrap: hand the rows through so the raw tab still
+ // shows what the server sent.
+ test("extractPlan falls back to the raw rows when none of the three columns is present", () => {
+ const rows = [{ nope: 1 }];
+ expect(druidNativeStrategy.extractPlan({ rows })).toEqual(rows);
+ expect(druidNativeStrategy.extractPlan({ rows: [] })).toEqual([]);
+ expect(druidNativeStrategy.extractPlan({})).toBeUndefined();
+ });
+
+ // The tree carries no metrics anywhere: Druid's planner emits no cost and no row
+ // estimate, and ExplainTreeNode.metrics is optional, so leaving it out is what
+ // keeps the render honest rather than showing fabricated zeros.
+ test("toRenderModel builds the join tree and puts no metrics on any node", () => {
+ const model = druidNativeStrategy.toRenderModel(stored(LIVE_JOIN_PLAN));
+ expect(model).toEqual({ kind: "tree", root: expect.anything(), raw: stored(LIVE_JOIN_PLAN) });
+ const root = (model as { root: ExplainTreeNode }).root;
+ expect(spineLabels(root)).toEqual(["groupBy", 'join INNER on ("region" == "j0.d0")', "table libredb_demo"]);
+ const nodes: ExplainTreeNode[] = [];
+ const walk = (node: ExplainTreeNode) => {
+ nodes.push(node);
+ node.children.forEach(walk);
+ };
+ walk(root);
+ expect(nodes.every((node) => node.metrics === undefined)).toBe(true);
+ });
+
+ test("toRenderModel names the query type at the root and hangs the dataSource off it", () => {
+ const root = treeRoot(stored(LIVE_JOIN_PLAN));
+ expect(labels(root.children)).toEqual([
+ 'join INNER on ("region" == "j0.d0")',
+ "granularity: all",
+ "filter: range on qty",
+ "dimensions: region AS d0",
+ "aggregations: count AS a0",
+ ]);
+ });
+
+ // rightPrefix is what disambiguates the right leg's columns in the join
+ // condition; it is secondary to the condition itself, so it goes in detail.
+ test("toRenderModel walks both legs of a join and keeps rightPrefix as detail", () => {
+ const join = treeRoot(stored(LIVE_JOIN_PLAN)).children[0];
+ expect(join.detail).toBe("rightPrefix: j0.");
+ expect(labels(join.children)).toEqual(["table libredb_demo", "query"]);
+ const subquery = join.children[1];
+ expect(labels(subquery.children)).toEqual(["groupBy"]);
+ expect(labels(subquery.children[0].children)).toEqual([
+ "table libredb_rollup",
+ "granularity: all",
+ "dimensions: region AS d0",
+ ]);
+ });
+
+ test("toRenderModel renders end to end from what extractPlan stored", () => {
+ const root = treeRoot(druidNativeStrategy.extractPlan(explainResult(LIVE_JOIN_PLAN)));
+ expect(root.label).toBe("groupBy");
+ });
+
+ test("toRenderModel renders the simplest scan plan as a query over one table", () => {
+ const root = treeRoot(stored(LIVE_SCAN_PLAN));
+ expect(root.label).toBe("scan");
+ expect(labels(root.children)).toEqual(["table libredb_demo", "granularity: all"]);
+ expect(root.children[0].children).toEqual([]);
+ });
+
+ // dimensions: [] is live on the outer groupBy here, and an empty row would say
+ // nothing, so it is omitted rather than rendered blank.
+ test("toRenderModel renders a subquery dataSource and omits an empty dimensions list", () => {
+ const root = treeRoot(stored(LIVE_SUBQUERY_PLAN));
+ expect(spineLabels(root)).toEqual(["groupBy", "query", "groupBy", "table libredb_demo"]);
+ expect(labels(root.children)).toEqual(["query", "granularity: all", "aggregations: count AS a0"]);
+ });
+
+ test("toRenderModel renders every branch of a union dataSource", () => {
+ const root = treeRoot(stored(LIVE_UNION_PLAN));
+ const union = root.children[0];
+ expect(union.label).toBe("union (2 sources)");
+ expect(labels(union.children)).toEqual(["table libredb_demo", "table libredb_rollup"]);
+ });
+
+ test("toRenderModel reads topN's singular dimension as well as groupBy's array", () => {
+ const root = treeRoot(stored(LIVE_TOPN_PLAN));
+ expect(root.label).toBe("topN");
+ expect(labels(root.children)).toEqual([
+ "table libredb_demo",
+ "granularity: all",
+ "dimensions: region AS d0",
+ "aggregations: count AS a0",
+ ]);
+ });
+
+ // Live: TIME_FLOOR turns granularity into a bare string and dimensions into
+ // explicit null, which no other plan shape shows.
+ test("toRenderModel reads a bare-string granularity and tolerates a null dimensions field", () => {
+ const root = treeRoot(stored(LIVE_TIMESERIES_PLAN));
+ expect(root.label).toBe("timeseries");
+ expect(labels(root.children)).toEqual(["table libredb_demo", "granularity: DAY", "aggregations: count AS a0"]);
+ });
+
+ // PLAN holds two independent native queries here, so a synthetic root is the
+ // only way to show both without pretending one is the parent of the other.
+ test("toRenderModel groups a multi-query plan under a synthetic root", () => {
+ const root = treeRoot(stored(LIVE_TWO_QUERY_PLAN));
+ expect(root).toMatchObject({ label: "2 native queries" });
+ expect(labels(root.children)).toEqual(["groupBy", "groupBy"]);
+ expect(labels(root.children[0].children)).toEqual([
+ "table libredb_demo",
+ "granularity: all",
+ "dimensions: region AS d0",
+ "aggregations: longSum(qty) AS a0",
+ ]);
+ expect(labels(root.children[1].children)).toEqual([
+ "table libredb_rollup",
+ "granularity: all",
+ "dimensions: name AS d0",
+ "aggregations: count AS a0",
+ ]);
+ });
+
+ test("toRenderModel counts the rows of an inline dataSource and names its columns in detail", () => {
+ const inline = treeRoot(stored(LIVE_INLINE_PLAN)).children[0];
+ expect(inline).toEqual({ label: "inline (2 rows)", detail: "columns: x", children: [] });
+ });
+
+ test("toRenderModel accepts the plan at every wrapper depth storage may present", () => {
+ expect(treeRoot(LIVE_SCAN_PLAN).label).toBe("scan");
+ expect(treeRoot({ plan: LIVE_SCAN_PLAN }).label).toBe("scan");
+ expect(treeRoot(JSON.stringify(LIVE_SCAN_PLAN)).label).toBe("scan");
+ expect(treeRoot(JSON.stringify({ plan: LIVE_SCAN_PLAN })).label).toBe("scan");
+ });
+
+ test("toRenderModel skips a plan entry that carries no native query", () => {
+ expect(treeRoot([{ signature: [] }, LIVE_SCAN_PLAN[0]]).label).toBe("scan");
+ expect(treeRoot([7, { query: { queryType: 7 } }, LIVE_SCAN_PLAN[0]]).label).toBe("scan");
+ });
+
+ describe("dataSource types", () => {
+ test("table without a name degrades to the bare type", () => {
+ expect(dataSourceNode({ type: "table" })).toEqual({ label: "table", children: [] });
+ });
+
+ test("a query dataSource whose inner query is foreign renders as a leaf", () => {
+ expect(dataSourceNode({ type: "query", query: { nope: 1 } })).toEqual({ label: "query", children: [] });
+ expect(dataSourceNode({ type: "query" })).toEqual({ label: "query", children: [] });
+ });
+
+ test("join degrades when joinType or condition is missing", () => {
+ expect(dataSourceNode({ type: "join", joinType: "LEFT" }).label).toBe("join LEFT");
+ expect(dataSourceNode({ type: "join", condition: '("a" == "b")' }).label).toBe('join on ("a" == "b")');
+ expect(dataSourceNode({ type: "join" })).toEqual({ label: "join", children: [] });
+ });
+
+ test("join drops a leg that is not a dataSource rather than rendering an empty node", () => {
+ const join = dataSourceNode({ type: "join", left: { type: "table", name: "t" }, right: "nope" });
+ expect(labels(join.children)).toEqual(["table t"]);
+ });
+
+ test("union tolerates a missing or foreign dataSources list", () => {
+ expect(dataSourceNode({ type: "union" })).toEqual({ label: "union (0 sources)", children: [] });
+ expect(dataSourceNode({ type: "union", dataSources: "nope" })).toEqual({
+ label: "union (0 sources)",
+ children: [],
+ });
+ const union = dataSourceNode({ type: "union", dataSources: [7, { type: "table", name: "t" }] });
+ expect(union.label).toBe("union (2 sources)");
+ expect(labels(union.children)).toEqual(["table t"]);
+ });
+
+ // Lookups are not listed in the sidebar but stay queryable by typing SQL, so a
+ // plan can still reach one.
+ test("lookup names the lookup it reads", () => {
+ expect(dataSourceNode({ type: "lookup", lookup: "region_names" })).toEqual({
+ label: "lookup region_names",
+ children: [],
+ });
+ expect(dataSourceNode({ type: "lookup" })).toEqual({ label: "lookup", children: [] });
+ });
+
+ test("inline tolerates a missing or foreign rows list and column names", () => {
+ expect(dataSourceNode({ type: "inline" })).toEqual({ label: "inline (0 rows)", children: [] });
+ expect(dataSourceNode({ type: "inline", rows: "nope", columnNames: "nope" })).toEqual({
+ label: "inline (0 rows)",
+ children: [],
+ });
+ expect(dataSourceNode({ type: "inline", rows: [[1]], columnNames: ["a", 7, ""] }).detail).toBe("columns: a");
+ });
+
+ // Live: `EXPLAIN PLAN FOR SELECT 1 AS c1` plans a one-row inline dataSource, and
+ // the count is part of the only text the tree renderer shows.
+ test("a count of one reads as singular", () => {
+ expect(dataSourceNode({ type: "inline", rows: [[1]] }).label).toBe("inline (1 row)");
+ expect(dataSourceNode({ type: "union", dataSources: [{ type: "table", name: "t" }] }).label).toBe(
+ "union (1 source)",
+ );
+ });
+
+ // EXTERN is rejected by the native engine ("Cannot use [EXTERN] with SQL engine
+ // [native]"), so an external dataSource can only reach here from the MSQ task
+ // engine. It is handled rather than left to the unknown-type leaf because the
+ // shape is stable and naming the input source is what makes the node readable.
+ test("external names its input source", () => {
+ expect(dataSourceNode({ type: "external", inputSource: { type: "inline" } })).toEqual({
+ label: "external inline",
+ children: [],
+ });
+ expect(dataSourceNode({ type: "external", inputSource: 7 })).toEqual({ label: "external", children: [] });
+ expect(dataSourceNode({ type: "external" })).toEqual({ label: "external", children: [] });
+ });
+
+ // Druid adds dataSource types between releases. Dropping one would make the
+ // tree quietly lie about what runs, so an unrecognised type becomes a leaf
+ // labelled with the type Druid actually sent.
+ test("an unrecognised type renders as a leaf labelled with that type", () => {
+ expect(dataSourceNode({ type: "unnest", base: { type: "table", name: "t" } })).toEqual({
+ label: "unnest",
+ children: [],
+ });
+ });
+
+ test("a dataSource with no type at all still renders a leaf", () => {
+ expect(dataSourceNode({ type: 7 })).toEqual({ label: "unknown", children: [] });
+ expect(dataSourceNode({})).toEqual({ label: "unknown", children: [] });
+ });
+
+ test("a query with no dataSource renders its attribute rows only", () => {
+ expect(labels(treeRoot(planOf({ queryType: "scan", granularity: { type: "all" } })).children)).toEqual([
+ "granularity: all",
+ ]);
+ expect(treeRoot(planWithDataSource("libredb_demo"))).toEqual({ label: "scan", children: [] });
+ });
+ });
+
+ describe("attribute rows", () => {
+ test("granularity reads a period spec, falls back to the type and skips anything else", () => {
+ const granularityRow = (granularity: unknown) =>
+ labels(treeRoot(planOf({ queryType: "scan", granularity })).children);
+ expect(granularityRow({ type: "period", period: "P1D", timeZone: "UTC" })).toEqual(["granularity: P1D"]);
+ expect(granularityRow({ type: "all" })).toEqual(["granularity: all"]);
+ expect(granularityRow({ period: 7 })).toEqual([]);
+ expect(granularityRow(7)).toEqual([]);
+ expect(granularityRow("")).toEqual([]);
+ });
+
+ test("filter names the column it restricts, or the number of clauses it combines", () => {
+ const filterRow = (filter: unknown) => labels(treeRoot(planOf({ queryType: "scan", filter })).children);
+ expect(filterRow({ type: "equals", column: "region", matchValue: "emea" })).toEqual(["filter: equals on region"]);
+ expect(filterRow({ type: "and", fields: [{ type: "equals" }, { type: "range" }] })).toEqual([
+ "filter: and (2 clauses)",
+ ]);
+ expect(filterRow({ type: "true" })).toEqual(["filter: true"]);
+ expect(filterRow({ fields: [] })).toEqual(["filter: unknown (0 clauses)"]);
+ expect(filterRow("qty > 5")).toEqual([]);
+ });
+
+ test("dimensions fall back to the output name and drop entries that name nothing", () => {
+ const dimensionRow = (dimensions: unknown) =>
+ labels(treeRoot(planOf({ queryType: "groupBy", dimensions })).children);
+ expect(dimensionRow([{ type: "default", outputName: "d0" }])).toEqual(["dimensions: d0"]);
+ expect(dimensionRow([{ type: "default", dimension: "region", outputName: "region" }])).toEqual([
+ "dimensions: region",
+ ]);
+ expect(dimensionRow([{ type: "default", dimension: "region" }])).toEqual(["dimensions: region"]);
+ expect(dimensionRow([7, {}, { dimension: "qty", outputName: "d1" }])).toEqual(["dimensions: qty AS d1"]);
+ expect(dimensionRow([7])).toEqual([]);
+ });
+
+ test("aggregations name the aggregated column and drop entries with no type", () => {
+ const aggregationRow = (aggregations: unknown) =>
+ labels(treeRoot(planOf({ queryType: "groupBy", aggregations })).children);
+ expect(aggregationRow([{ type: "longMax", name: "a0", fieldName: "qty" }])).toEqual([
+ "aggregations: longMax(qty) AS a0",
+ ]);
+ expect(aggregationRow([{ type: "count" }])).toEqual(["aggregations: count"]);
+ expect(aggregationRow([{ type: "count", name: "a0" }, 7, { name: "a1" }])).toEqual(["aggregations: count AS a0"]);
+ expect(aggregationRow("count")).toEqual([]);
+ });
+ });
+
+ // Depth is bounded so a plan that somehow nests without end cannot recurse away
+ // the stack. The bound is far past any real plan - the deepest live one is three
+ // dataSource hops - so it only ever fires on a pathological shape.
+ test("toRenderModel bounds the dataSource recursion depth", () => {
+ const deep = spineLabels(treeRoot(nestedQueryChain(40)));
+ expect(deep.at(-1)).toBe("plan truncated: nesting limit reached");
+ expect(deep.length).toBeLessThan(70);
+ // A chain that stays inside the bound still reaches its table.
+ expect(spineLabels(treeRoot(nestedQueryChain(3))).at(-1)).toBe("table libredb_demo");
+ });
+
+ test("toRenderModel rejects shapes that are not Druid plans", () => {
+ expect(druidNativeStrategy.toRenderModel(null)).toBeNull();
+ expect(druidNativeStrategy.toRenderModel(undefined)).toBeNull();
+ expect(druidNativeStrategy.toRenderModel(7)).toBeNull();
+ expect(druidNativeStrategy.toRenderModel([])).toBeNull();
+ expect(druidNativeStrategy.toRenderModel({})).toBeNull();
+ expect(druidNativeStrategy.toRenderModel({ plan: undefined })).toBeNull();
+ expect(druidNativeStrategy.toRenderModel([{ signature: [] }])).toBeNull();
+ expect(druidNativeStrategy.toRenderModel([{ id: 3, parent: 0, detail: "SCAN users" }])).toBeNull();
+ });
+
+ test("toRenderModel rejects column text that is not JSON, and a pathological wrapper chain", () => {
+ expect(druidNativeStrategy.toRenderModel("[{ oops")).toBeNull();
+ expect(druidNativeStrategy.toRenderModel("")).toBeNull();
+ expect(druidNativeStrategy.toRenderModel({ plan: { plan: { plan: { plan: LIVE_SCAN_PLAN } } } })).toBeNull();
+ });
+});
diff --git a/tests/unit/lib/monitoring-cache-ratio.test.ts b/tests/unit/lib/monitoring-cache-ratio.test.ts
new file mode 100644
index 00000000..7a2bf6ed
--- /dev/null
+++ b/tests/unit/lib/monitoring-cache-ratio.test.ts
@@ -0,0 +1,27 @@
+import { describe, test, expect } from "bun:test";
+import { CACHE_HIT_RATIO_UNAVAILABLE, formatCacheHitRatio } from "@/lib/monitoring-cache-ratio";
+
+describe("formatCacheHitRatio", () => {
+ test("formats a measured ratio to one decimal place", () => {
+ expect(formatCacheHitRatio(95.74)).toBe("95.7");
+ });
+
+ test("keeps a trailing zero so the string always carries one decimal", () => {
+ expect(formatCacheHitRatio(100)).toBe("100.0");
+ });
+
+ test("formats a measured zero as a real measurement, not as unavailable", () => {
+ expect(formatCacheHitRatio(0)).toBe("0.0");
+ expect(formatCacheHitRatio(0)).not.toBe(CACHE_HIT_RATIO_UNAVAILABLE);
+ });
+
+ test("reports an unmeasured ratio as unavailable rather than inventing a number", () => {
+ expect(formatCacheHitRatio(undefined)).toBe(CACHE_HIT_RATIO_UNAVAILABLE);
+ });
+});
+
+describe("CACHE_HIT_RATIO_UNAVAILABLE", () => {
+ test('uses the spelling the repo already uses for an unavailable ratio ("N/A")', () => {
+ expect(CACHE_HIT_RATIO_UNAVAILABLE).toBe("N/A");
+ });
+});
diff --git a/tests/unit/lib/query-generators.test.ts b/tests/unit/lib/query-generators.test.ts
index baadac86..9974e6d6 100644
--- a/tests/unit/lib/query-generators.test.ts
+++ b/tests/unit/lib/query-generators.test.ts
@@ -297,6 +297,107 @@ describe("ClickHouse (8123) generation", () => {
});
});
+// ============================================================================
+// Apache Druid (issue #265) — Druid has a dialect branch of its own that quotes
+// UNCONDITIONALLY, and that is the claim under test: every string below was run
+// against Apache Druid 37.0.0 through POST /druid/v2/sql and accepted.
+// ============================================================================
+
+describe("Druid (8888) generation", () => {
+ const druidCaps = makeCaps({ defaultPort: 8888 });
+
+ test("generateTableQuery quotes the datasource and uses the plain LIMIT form", () => {
+ expect(generateTableQuery("libredb_demo", druidCaps)).toBe('SELECT * FROM "libredb_demo" LIMIT 50;');
+ });
+
+ // The trap that makes the default branch correct for Druid rather than merely
+ // adequate: Druid rejects ORDER BY on a non-__time column of a plain table scan
+ // with 400 "SQL query requires ordering a table by non-time column [[qty]], which
+ // is not supported." A generator that ordered by the primary key - the obvious
+ // thing to do for a "top 50" - would produce a query that cannot be planned on
+ // any Druid datasource. So no provider-generated scan may ever carry ORDER BY.
+ test("no generated Druid statement carries ORDER BY", () => {
+ expect(generateTableQuery("libredb_demo", druidCaps)).not.toContain("ORDER BY");
+ expect(generateSelectQuery("libredb_demo", sampleColumns, druidCaps)).not.toContain("ORDER BY");
+ });
+
+ test("generateSelectQuery emits a double-quoted column list and LIMIT 100", () => {
+ const cols: ColumnSchema[] = [
+ { name: "id", type: "BIGINT", nullable: true, isPrimary: false },
+ { name: "region", type: "VARCHAR", nullable: true, isPrimary: false },
+ ];
+ expect(generateSelectQuery("libredb_demo", cols, druidCaps)).toBe(
+ 'SELECT\n "id",\n "region"\nFROM "libredb_demo"\nWHERE 1=1\nLIMIT 100;',
+ );
+ });
+
+ test("the __time column is quoted like every other column", () => {
+ // __time is mandatory on every datasource, so it is in almost every generated
+ // projection. It parses both bare and quoted; quoting it needs no exception.
+ const cols: ColumnSchema[] = [{ name: "__time", type: "TIMESTAMP", nullable: false, isPrimary: true }];
+ expect(generateSelectQuery("libredb_demo", cols, druidCaps)).toContain(' "__time"');
+ });
+
+ // The defect this branch exists for (issue #265 review): Calcite reserves a large
+ // set of plain lowercase words, so a bare one is a SYNTAX error, not a
+ // column-not-found. Verified against Apache Druid 37.0.0:
+ // SELECT count FROM libredb_demo LIMIT 1
+ // -> 400 "Received an unexpected token [count FROM] (line [1], column [8])"
+ // SELECT "count" FROM libredb_demo LIMIT 1
+ // -> 400 "Column 'count' not found in any table" (syntax fine, no such column)
+ // `count` matters most: it is Druid's conventional rollup metric name, so the
+ // standard rollup ingestion produces a datasource that has one.
+ test("quoteIdentifier quotes reserved words, so a rollup metric column parses", () => {
+ for (const word of [
+ "count",
+ "value",
+ "start",
+ "end",
+ "date",
+ "time",
+ "year",
+ "rows",
+ "result",
+ "system",
+ "window",
+ "position",
+ "language",
+ "period",
+ "range",
+ ]) {
+ expect(quoteIdentifier(word, druidCaps)).toBe(`"${word}"`);
+ }
+ });
+
+ test("quoteIdentifier quotes unconditionally, reserved or not", () => {
+ // No safe unquoted subset is worth detecting: Calcite's reserved list is large
+ // and version-dependent, so an ordinary-looking name gets the same treatment.
+ expect(quoteIdentifier("libredb_demo", druidCaps)).toBe('"libredb_demo"');
+ expect(quoteIdentifier("snowflake_id", druidCaps)).toBe('"snowflake_id"');
+ expect(quoteIdentifier("Region", druidCaps)).toBe('"Region"');
+ expect(quoteIdentifier("weird name", druidCaps)).toBe('"weird name"');
+ });
+
+ test("quoteIdentifier doubles an embedded double quote so it cannot terminate its quoting", () => {
+ // Verified via `SELECT 1 AS "we""ird"`, which returns the column name `we"ird`.
+ expect(quoteIdentifier('we"ird', druidCaps)).toBe('"we""ird"');
+ });
+
+ test("quoteQualifiedName quotes each segment and keeps the schema separator intact", () => {
+ // Druid's single catalog exposes one user schema, `druid`, and both the bare and
+ // the schema-qualified form resolve, so the dot must stay a separator:
+ // `SELECT * FROM "druid"."libredb_demo" LIMIT 1` -> HTTP 200.
+ expect(quoteQualifiedName("druid.libredb_demo", druidCaps)).toBe('"druid"."libredb_demo"');
+ });
+
+ test("generateSelectQuery with no columns falls back to a bare star, not a quoted one", () => {
+ // `SELECT "*"` would be a column literally named `*`; the star must stay bare.
+ expect(generateSelectQuery("libredb_demo", [], druidCaps)).toBe(
+ 'SELECT\n *\nFROM "libredb_demo"\nWHERE 1=1\nLIMIT 100;',
+ );
+ });
+});
+
// ============================================================================
// quoteIdentifier (dialect-aware, quote-only-when-needed)
// ============================================================================
diff --git a/tests/unit/seed/types.test.ts b/tests/unit/seed/types.test.ts
index a901b39d..fea78968 100644
--- a/tests/unit/seed/types.test.ts
+++ b/tests/unit/seed/types.test.ts
@@ -78,6 +78,7 @@ describe("SeedConnectionSchema", () => {
"libredb",
"couchbase",
"clickhouse",
+ "druid",
];
for (const type of allTypes) {
const result = SeedConnectionSchema.safeParse({ ...validConn, type });