diff --git a/DOCKERHUB.md b/DOCKERHUB.md
index 8c1ef2d3a..30d63c233 100644
--- a/DOCKERHUB.md
+++ b/DOCKERHUB.md
@@ -107,7 +107,7 @@ The network route is the one to prefer for a real deployment: put Studio and its
## Supported databases
-Sixteen external engines share one interface, and three of them are read-only because their own SQL is. The table below has seventeen rows: the seventeenth is the embedded LibreDB store, which ships inside the image rather than being a server you connect out to.
+Seventeen external engines share one interface, and three of them are read-only because their own SQL is. The table below has eighteen rows: the eighteenth is the embedded LibreDB store, which ships inside the image rather than being a server you connect out to.
| Database | Driver | Highlights |
| :--- | :--- | :--- |
@@ -115,6 +115,7 @@ Sixteen external engines share one interface, and three of them are read-only be
| **MySQL** | `mysql2` | EXPLAIN plans, transactions, `KILL QUERY`, SSL/TLS, SSH tunnel |
| **Oracle** | `oracledb` (thin) | `FETCH FIRST` pagination, `V$` monitoring, `ANALYZE`, transactions |
| **SQL Server** | `mssql` | `OFFSET FETCH`, `sys.dm_*` DMVs, `DBCC CHECKDB`, Azure SQL auto-detect |
+| **IBM Db2 LUW** | `ibm_db` (a native addon; installs the IBM CLI driver) | SQL IDE over the DRDA protocol, `FETCH FIRST` / `OFFSET FETCH` pagination, `SYSCAT.*` catalog browser, `RUNSTATS` and `REORG TABLE` maintenance. EXPLAIN and the interactive-transaction toolbar are not wired yet |
| **SQLite** | `bun:sqlite` / `node:sqlite` | File-based or in-memory databases; the driver follows the runtime, with a `LIBREDB_SQLITE_DRIVER` override |
| **libSQL** | none — HTTP | Full SQL IDE over the Hrana protocol against a libSQL server or Turso Cloud; SQLite's dialect across a network, with real per-table bytes from `dbstat` and an auth token instead of a password |
| **DuckDB** | `@duckdb/node-api` (a native N-API addon) | Full SQL IDE against a local DuckDB file or `:memory:` on the server this image runs on; `EXPLAIN (FORMAT JSON)` plan trees, `duckdb_*` catalog introspection, real per-table bytes from `pragma_storage_info` block allocation, and cancellation through the driver's `interrupt()`. `VACUUM`, `ANALYZE` and `CHECKPOINT` only, and no slow-query or session panel, because DuckDB publishes neither. One operating-system process may hold the file, refused in read-only mode too |
@@ -133,7 +134,7 @@ Sixteen external engines share one interface, and three of them are read-only be
### Engines with no provider of their own
-Twenty-six further engines speak the wire protocol of one of the sixteen drivers above, so they connect through it unchanged: pick that driver in the connection dialog. The table has twenty-two rows rather than twenty-six because engines that behave identically share a row; all twenty-six are named in it. Every one of them was measured against a real instance rather than assumed, and how much of the product worked is recorded per engine.
+Twenty-six further engines speak the wire protocol of one of the seventeen drivers above, so they connect through it unchanged: pick that driver in the connection dialog. The table has twenty-two rows rather than twenty-six because engines that behave identically share a row; all twenty-six are named in it. Every one of them was measured against a real instance rather than assumed, and how much of the product worked is recorded per engine.
| Engine | Connect as | Support |
| :--- | :--- | :--- |
diff --git a/README.md b/README.md
index 14538be66..83c8056d6 100644
--- a/README.md
+++ b/README.md
@@ -103,13 +103,13 @@ You create a Postgres on a managed platform. It is ready in forty seconds. Then
LibreDB Studio goes the other way. It deploys next to the data: a container, a Helm chart, an operator, a one-click template on your PaaS, or `npm i @libredb/studio` inside your own product. Nothing has to face outward.
-Sixteen engines share one interface — PostgreSQL, MySQL, Oracle, SQL Server, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Druid, Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra — with the same explorer everywhere, and ER diagrams, schema diff and monitoring wherever the engine has something to report. Three of the sixteen are read-only because their own SQL is: Druid, Elasticsearch and OpenSearch have no `UPDATE` and no `CREATE TABLE` in the grammar at all, so those controls are reported as unsupported instead of failing when used. Cassandra is the newest, and the one that reports the least on purpose: it publishes no row count and no size that is true, so the object browser shows neither rather than showing a number that is wrong — the estimate it does publish counts partitions from flushed files, and it read 143 for a 500-row table. Trino is the other odd one: it is a query engine rather than a database, so it declares no keys and no indexes and reports the bytes as belonging to the systems behind its connectors.
+Seventeen engines share one interface — PostgreSQL, MySQL, Oracle, SQL Server, IBM Db2 LUW, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Druid, Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra — with the same explorer everywhere, and ER diagrams, schema diff and monitoring wherever the engine has something to report. Three of the seventeen are read-only because their own SQL is: Druid, Elasticsearch and OpenSearch have no `UPDATE` and no `CREATE TABLE` in the grammar at all, so those controls are reported as unsupported instead of failing when used. Cassandra is the newest, and the one that reports the least on purpose: it publishes no row count and no size that is true, so the object browser shows neither rather than showing a number that is wrong — the estimate it does publish counts partitions from flushed files, and it read 143 for a 500-row table. Trino is the other odd one: it is a query engine rather than a database, so it declares no keys and no indexes and reports the bytes as belonging to the systems behind its connectors.
And nothing is held back. Single sign-on, ER diagrams, the AI features and the NoSQL engines all ship in the MIT build. MIT is not generosity here, it is a requirement of the architecture: you cannot place a per-seat licensed, feature-gated tool into every environment you own.
### Why LibreDB Studio?
- **Deploys next to the data**: container, Helm chart, Rancher, OpenShift operator, one-click PaaS template, or embedded via npm.
-- **Sixteen engines, one interface**: PostgreSQL, MySQL, Oracle, SQL Server, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Druid, Elasticsearch, OpenSearch, Trino, Cassandra.
+- **Seventeen engines, one interface**: PostgreSQL, MySQL, Oracle, SQL Server, IBM Db2 LUW, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Druid, Elasticsearch, OpenSearch, Trino, Cassandra.
- **Runs where you are**: browser, phone, Windows, MacOS, Linux desktop.
- **A read-only agent, with your own model**: state a question, and the run drafts SQL, reads the results, and writes a report whose claims cite them. Gemini, OpenAI, or a local Ollama with open-source models.
- **Nothing behind a wall**: RBAC, OIDC single sign-on, query audit trail, and ER diagrams all ship under MIT.
@@ -265,6 +265,7 @@ Standalone application only: the embedded `@libredb/studio` package carries no a
| **MySQL** | `mysql2` | Full SQL IDE, EXPLAIN plans, transactions, query cancellation (`KILL QUERY`) |
| **Oracle** | `oracledb` (Thin mode) | Full SQL IDE, `FETCH FIRST N ROWS` pagination, `V$` monitoring views, `ANALYZE TABLE`, `ALTER INDEX REBUILD`, transactions |
| **SQL Server** | `mssql` (tedious) | Full SQL IDE, `TOP N` / `OFFSET FETCH` pagination, `sys.dm_*` DMVs, `UPDATE STATISTICS`, `DBCC CHECKDB`, transactions, Azure SQL auto-detect |
+| **IBM Db2 LUW** | `ibm_db` (a native addon; its install step fetches the IBM CLI driver) | SQL IDE over the DRDA protocol, `FETCH FIRST N ROWS` / `OFFSET FETCH` pagination, `SYSCAT.*` catalog schema introspection, `RUNSTATS` and `REORG TABLE` maintenance. EXPLAIN is not offered yet (Db2 populates explain tables rather than returning a plan from one statement, the same reason it is off on Oracle and SQL Server); the interactive-transaction toolbar is not wired yet either. A generated migration that drops or retypes a column leaves the table REORG-pending, so the migration carries a commented `REORG` advisory rather than running one — a REORG can be slow and lock-heavy on a large table |
| **SQLite** | `bun:sqlite` / `node:sqlite` (runtime-selected) | Full SQL IDE, file-based or in-memory databases (server-local file) |
| **libSQL** | none — HTTP (the Hrana protocol, `POST /v2/pipeline`, port 8080) | Full SQL IDE against a libSQL server or Turso Cloud — the same SQLite dialect as the row above, reached across a network instead of on disk. `EXPLAIN QUERY PLAN`, `sqlite_master` and `pragma_*` introspection, and real per-table bytes from `dbstat`, which the file-based driver above cannot read. The credential is an auth token rather than a password. Two maintenance operations only, `REINDEX` and `PRAGMA integrity_check`: the server refuses `VACUUM`, `ANALYZE`, `PRAGMA optimize` and `PRAGMA wal_checkpoint` outright, so no control is offered for them |
| **DuckDB** | `@duckdb/node-api` (a native N-API addon, ~68 MB of platform bindings) | Full SQL IDE against a local DuckDB file or `:memory:`, on the server the app runs on. `EXPLAIN (FORMAT JSON)` physical plan trees, `duckdb_*` catalog introspection, real per-table bytes from `pragma_storage_info` block allocation, and query cancellation through the driver's own `interrupt()`. Three maintenance operations, `VACUUM`, `ANALYZE` and `CHECKPOINT`: `REINDEX` is a parser error here and neither `PRAGMA integrity_check` nor `PRAGMA optimize` exists, so no control is offered for them. No slow-query log and no session list — DuckDB publishes neither, so those panels say so rather than showing a zero. The file admits exactly ONE operating-system process, refused in read-only mode too, so a second Studio instance cannot open a database this one holds |
@@ -278,7 +279,7 @@ Standalone application only: the embedded `@libredb/studio` package carries no a
| **Apache Cassandra** | `cassandra-driver` (pure JS, no native module) | CQL IDE over the native protocol (port 9042), keyspace browser marking partition and clustering keys, `system_views` overview, uptime and running statements. No EXPLAIN (the keyword is not in CQL), no cancellation (the protocol has none), no maintenance (every operation is a `nodetool` action), and **no row counts or sizes**: the only figures Cassandra publishes are partition estimates from flushed files and whole mebibytes, so neither is shown rather than shown wrong |
| **Redis** | `ioredis` | Command editor, key browser, INFO-based monitoring |
-> **Twenty-six more engines have no driver of their own.** The sixteen above are the drivers this build ships. Twenty-six further engines speak one of those wire protocols and connect through an existing driver unchanged, so sixteen drivers reach forty-two named engines in all. They are MariaDB, Percona Server for MySQL, TiDB, Vitess, StarRocks, Apache Doris, OceanBase, SingleStore, Databend, Citus, Percona Distribution for PostgreSQL, ParadeDB, OrioleDB, TimescaleDB, YugabyteDB, AlloyDB Omni, Apache Cloudberry (incubating), CockroachDB, Materialize and RisingWave (as PostgreSQL or MySQL), Valkey, DragonflyDB, KeyDB and Garnet (as Redis), FerretDB (as MongoDB), and ScyllaDB (as Cassandra). Each was measured against a live instance, and how much of the product works differs per engine. MariaDB, both Percona distributions, TiDB, Vitess, AlloyDB Omni, Citus, TimescaleDB, YugabyteDB, ParadeDB, OrioleDB, Valkey, DragonflyDB, KeyDB and FerretDB behave as their driver's own engine, though three of them report statistics you should not trust: a Citus distributed table and a TimescaleDB hypertable report row counts and sizes that are wrong rather than missing, and YugabyteDB reports 0 until you run `ANALYZE`. Vitess is not one of those three, its row counts and sizes being exact to the byte, but a running query cannot be cancelled there: vtgate refuses `KILL QUERY` and the statement runs to completion. AlloyDB Omni is not one of them either, reporting 2000 rows for 2000 and 270336 bytes for 270336, but two things there surprise: `version()` names AlloyDB nowhere, so the version panel cannot be told apart from a stock PostgreSQL 17, and eight of AlloyDB's own `google_ml` tables list in the object browser, which any role that can connect at all may also read. StarRocks reports itself as MySQL 5.1 and loses its overview, health and session panels, its monitoring dashboard rendering six panels with the session one carrying the engine's own refusal; Apache Doris - the engine StarRocks is a fork of - loses only the overview and health panels, to one statement form its grammar rejects, and is the more trustworthy of the two where it counts: it reports 2000 rows and 10187 bytes for a table holding exactly that, where StarRocks reports zeros, though a freshly loaded table there reads 0 for about a minute before its background statistics land, no index is ever reported, and a foreign key is accepted, listed by `SHOW CONSTRAINTS`, invisible to the ER diagram and unenforced; Cloudberry loses the monitoring dashboard and its table and index statistics, all three to one MPP planner restriction, and reads a foreign key back as though it were enforced when it is not, though its row counts are correct; CockroachDB loses the object browser and the size panels; OceanBase answers fourteen of the fifteen surfaces but only twelve of them usefully, health failing outright because its tenant has no `performance_schema` database at all and every size reading 0 B, though its row counts are correct once `ANALYZE TABLE` has run; SingleStore lost five surfaces to a cause that was ours rather than its own - the provider sent every statement through the prepared-statement protocol, which SingleStore refuses for the `SHOW` and `EXPLAIN` statements four panels need - and four of those five are now recovered, its Explain panel being the one that is not, because there the grammar wants `EXPLAIN JSON` and the statement fails on either protocol; its numbers are still missing rather than wrong, a 2000-row table reading 0 rows and 0 B with no `ANALYZE` able to change it; ScyllaDB loses five surfaces and Test Connection with them, all six to one absent keyspace - the overview, health, performance-metrics, active-session and monitoring panels read Cassandra's `system_views` virtual tables and ScyllaDB has no `system_views` keyspace at all - those five now degrade to empty rather than throwing, so Test Connection passes and the dialog saves the connection, which it could not do at all until that change - while the editor and the object browser work in full, every one of 18 CQL types reading back byte-identically to the Cassandra 5.0.9 probed in the same pass; ParadeDB and OrioleDB are both full and their costs are opposites: ParadeDB's nine extensions put 41 objects in the object browser for 2 user tables and break agent plan mode on a stock install, while OrioleDB's browser is clean and its own storage is invisible to PostgreSQL's size functions, so every index reads 0 bytes and the cache hit ratio reads N/A. Materialize, RisingWave and Databend are query-editor-only, and Databend is the one of those three whose catalogs answer perfectly well when asked directly - the object browser is empty because our parameterised reads use a prepared protocol it does not implement. Garnet behaves as Redis and is one of three relatives here (with Valkey and DragonflyDB) whose own version `INFO` carries beside the Redis compat level and the overview now labels ahead of it - `Garnet 2.1.5 (Redis 7.4.3)` - and two of its readings are absences wearing a value, every size showing 0 B because it publishes no `used_memory` and the cache hit ratio showing 100% because it publishes no keyspace counters. The per-engine detail, with the exact version probed, is in [`docs/providers/README.md`](docs/providers/README.md#wire-compatible-engines) — we publish a name only after connecting to it, so a name absent there is untested rather than unsupported.
+> **Twenty-six more engines have no driver of their own.** The seventeen above are the drivers this build ships. Twenty-six further engines speak one of those wire protocols and connect through an existing driver unchanged, so seventeen drivers reach forty-three named engines in all. They are MariaDB, Percona Server for MySQL, TiDB, Vitess, StarRocks, Apache Doris, OceanBase, SingleStore, Databend, Citus, Percona Distribution for PostgreSQL, ParadeDB, OrioleDB, TimescaleDB, YugabyteDB, AlloyDB Omni, Apache Cloudberry (incubating), CockroachDB, Materialize and RisingWave (as PostgreSQL or MySQL), Valkey, DragonflyDB, KeyDB and Garnet (as Redis), FerretDB (as MongoDB), and ScyllaDB (as Cassandra). Each was measured against a live instance, and how much of the product works differs per engine. MariaDB, both Percona distributions, TiDB, Vitess, AlloyDB Omni, Citus, TimescaleDB, YugabyteDB, ParadeDB, OrioleDB, Valkey, DragonflyDB, KeyDB and FerretDB behave as their driver's own engine, though three of them report statistics you should not trust: a Citus distributed table and a TimescaleDB hypertable report row counts and sizes that are wrong rather than missing, and YugabyteDB reports 0 until you run `ANALYZE`. Vitess is not one of those three, its row counts and sizes being exact to the byte, but a running query cannot be cancelled there: vtgate refuses `KILL QUERY` and the statement runs to completion. AlloyDB Omni is not one of them either, reporting 2000 rows for 2000 and 270336 bytes for 270336, but two things there surprise: `version()` names AlloyDB nowhere, so the version panel cannot be told apart from a stock PostgreSQL 17, and eight of AlloyDB's own `google_ml` tables list in the object browser, which any role that can connect at all may also read. StarRocks reports itself as MySQL 5.1 and loses its overview, health and session panels, its monitoring dashboard rendering six panels with the session one carrying the engine's own refusal; Apache Doris - the engine StarRocks is a fork of - loses only the overview and health panels, to one statement form its grammar rejects, and is the more trustworthy of the two where it counts: it reports 2000 rows and 10187 bytes for a table holding exactly that, where StarRocks reports zeros, though a freshly loaded table there reads 0 for about a minute before its background statistics land, no index is ever reported, and a foreign key is accepted, listed by `SHOW CONSTRAINTS`, invisible to the ER diagram and unenforced; Cloudberry loses the monitoring dashboard and its table and index statistics, all three to one MPP planner restriction, and reads a foreign key back as though it were enforced when it is not, though its row counts are correct; CockroachDB loses the object browser and the size panels; OceanBase answers fourteen of the fifteen surfaces but only twelve of them usefully, health failing outright because its tenant has no `performance_schema` database at all and every size reading 0 B, though its row counts are correct once `ANALYZE TABLE` has run; SingleStore lost five surfaces to a cause that was ours rather than its own - the provider sent every statement through the prepared-statement protocol, which SingleStore refuses for the `SHOW` and `EXPLAIN` statements four panels need - and four of those five are now recovered, its Explain panel being the one that is not, because there the grammar wants `EXPLAIN JSON` and the statement fails on either protocol; its numbers are still missing rather than wrong, a 2000-row table reading 0 rows and 0 B with no `ANALYZE` able to change it; ScyllaDB loses five surfaces and Test Connection with them, all six to one absent keyspace - the overview, health, performance-metrics, active-session and monitoring panels read Cassandra's `system_views` virtual tables and ScyllaDB has no `system_views` keyspace at all - those five now degrade to empty rather than throwing, so Test Connection passes and the dialog saves the connection, which it could not do at all until that change - while the editor and the object browser work in full, every one of 18 CQL types reading back byte-identically to the Cassandra 5.0.9 probed in the same pass; ParadeDB and OrioleDB are both full and their costs are opposites: ParadeDB's nine extensions put 41 objects in the object browser for 2 user tables and break agent plan mode on a stock install, while OrioleDB's browser is clean and its own storage is invisible to PostgreSQL's size functions, so every index reads 0 bytes and the cache hit ratio reads N/A. Materialize, RisingWave and Databend are query-editor-only, and Databend is the one of those three whose catalogs answer perfectly well when asked directly - the object browser is empty because our parameterised reads use a prepared protocol it does not implement. Garnet behaves as Redis and is one of three relatives here (with Valkey and DragonflyDB) whose own version `INFO` carries beside the Redis compat level and the overview now labels ahead of it - `Garnet 2.1.5 (Redis 7.4.3)` - and two of its readings are absences wearing a value, every size showing 0 B because it publishes no `used_memory` and the cache hit ratio showing 100% because it publishes no keyspace counters. The per-engine detail, with the exact version probed, is in [`docs/providers/README.md`](docs/providers/README.md#wire-compatible-engines) — we publish a name only after connecting to it, so a name absent there is untested rather than unsupported.
> **Transport security is cross-cutting, not per engine.** The SSH tunnel is opened before the provider connects and the connection is rewritten to the local endpoint, so it is provider-independent: it applies to any connection configured with a host and a port. A connection entered as a connection string instead (an option for MongoDB, Couchbase, ClickHouse and libSQL) carries neither, so it is not tunnelled; SQLite and DuckDB have neither either. The SSL/TLS panel is honoured by every engine that shows it — which is every engine except the three file-based ones, SQLite, DuckDB and the embedded LibreDB, where no transport exists to secure and no panel is offered. On Trino it is load-bearing rather than optional, because the coordinator refuses a password over plain HTTP. Oracle is the one engine whose mapping carries a caveat worth stating up front: its Thin driver always verifies the certificate chain, so `require` needs the server's CA supplied when that certificate is self-signed, and a connect string pasted whole keeps whatever protocol it names.
diff --git a/README_es.md b/README_es.md
index 69edea24e..9d70a59fd 100644
--- a/README_es.md
+++ b/README_es.md
@@ -87,9 +87,9 @@ Tomada en serio, esa frase deja de ser una preferencia y se vuelve una especific
## Capacidades principales
-### Dieciséis motores, una sola interfaz
+### Diecisiete motores, una sola interfaz
-PostgreSQL · MySQL · Oracle · SQL Server · SQLite · libSQL · DuckDB · MongoDB · Redis · Couchbase · ClickHouse · Apache Druid · Elasticsearch · OpenSearch · Apache Trino · Apache Cassandra
+PostgreSQL · MySQL · Oracle · SQL Server · IBM Db2 LUW · SQLite · libSQL · DuckDB · MongoDB · Redis · Couchbase · ClickHouse · Apache Druid · Elasticsearch · OpenSearch · Apache Trino · Apache Cassandra
Todos los motores SQL comparten el mismo explorador de esquemas, los diagramas ER, la comparación de esquemas y los paneles de monitoreo. MongoDB y Redis no son motores SQL: no tienen diagrama ER ni comparación de esquemas. Druid, Elasticsearch, OpenSearch y Trino son doblemente excepcionales: sus interfaces SQL sobre HTTP no tienen una forma de URI que este build sepa interpretar, así que se configuran por host y puerto, y las migraciones que se generan explican la limitación en lugar de inventar DDL para un motor cuyo SQL no tiene sentencias de cambio de columna. Lo mismo pasa con las colecciones sin esquema de Couchbase. El diagrama ER de los clústeres de búsqueda tiene cajas pero no líneas: los índices no declaran claves foráneas, y en el modelo del motor no hay ninguna que declarar.
@@ -99,6 +99,7 @@ Todos los motores SQL comparten el mismo explorador de esquemas, los diagramas E
| **MySQL** | `mysql2` | IDE SQL completo, EXPLAIN, transacciones, cancelación de consultas (`KILL QUERY`) |
| **Oracle** | `oracledb` (modo Thin) | IDE SQL completo, paginación con `FETCH FIRST N ROWS`, vistas de monitoreo `V$`, `ANALYZE TABLE`, `ALTER INDEX REBUILD`, transacciones |
| **SQL Server** | `mssql` (tedious) | IDE SQL completo, paginación con `TOP N` / `OFFSET FETCH`, DMV `sys.dm_*`, `UPDATE STATISTICS`, `DBCC CHECKDB`, transacciones, detección automática de Azure SQL |
+| **IBM Db2 LUW** | `ibm_db` (complemento nativo; su instalación descarga el driver CLI de IBM) | IDE SQL sobre el protocolo DRDA, paginación con `FETCH FIRST` / `OFFSET FETCH`, exploración del catálogo `SYSCAT.*`, mantenimiento con `RUNSTATS` y `REORG TABLE`. EXPLAIN y la barra de transacciones interactivas aún no están conectados |
| **SQLite** | `bun:sqlite` / `node:sqlite` (según el runtime) | IDE SQL completo, sobre archivo o en memoria |
| **libSQL** | Sin driver, HTTP puro (protocolo Hrana, `POST /v2/pipeline`, puerto 8080) | IDE SQL completo. El mismo type-id conecta tanto a un servidor libSQL propio (`sqld`) como a Turso Cloud. Es el dialecto de SQLite a través de la red, y con `dbstat` da el tamaño real en bytes de tablas e índices. La credencial es un auth token, no una contraseña. Solo hay dos operaciones de mantenimiento, Reindex y verificación de integridad: `VACUUM`, `ANALYZE` y `PRAGMA optimize` los rechaza el servidor |
| **DuckDB** | `@duckdb/node-api` (complemento nativo N-API, unos 68 MB por plataforma) | IDE SQL completo sobre archivos DuckDB locales o `:memory:`, ejecutando en el mismo servidor que la aplicación. Árbol de plan físico con `EXPLAIN (FORMAT JSON)`, introspección del catálogo `duckdb_*`, tamaño real por tabla a partir de la asignación de bloques de `pragma_storage_info`, y cancelación de consultas mediante el `interrupt()` del propio driver. Tres operaciones de mantenimiento: `VACUUM`, `ANALYZE` y `CHECKPOINT`. Acá `REINDEX` es un error de sintaxis, y `PRAGMA integrity_check` y `PRAGMA optimize` no existen, así que no se ofrecen. No hay log de consultas lentas ni lista de sesiones: DuckDB no expone ninguna de las dos, así que esos paneles lo dicen en lugar de mostrar 0. Un archivo de base solo admite un proceso del sistema operativo (incluso en modo lectura), así que una segunda instancia de Studio no puede abrir el archivo que ya tiene abierto la primera |
diff --git a/README_ja.md b/README_ja.md
index 46505ea47..1c262fbbb 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -86,9 +86,9 @@ LibreDB Studioは逆向きです。**データをツールのところへ持っ
## 主な機能
-### 16のエンジン、1つのインターフェース
+### 17のエンジン、1つのインターフェース
-PostgreSQL · MySQL · Oracle · SQL Server · SQLite · libSQL · DuckDB · MongoDB · Redis · Couchbase · ClickHouse · Apache Druid · Elasticsearch · OpenSearch · Apache Trino · Apache Cassandra
+PostgreSQL · MySQL · Oracle · SQL Server · IBM Db2 LUW · SQLite · libSQL · DuckDB · MongoDB · Redis · Couchbase · ClickHouse · Apache Druid · Elasticsearch · OpenSearch · Apache Trino · Apache Cassandra
スキーマエクスプローラ、ER図、スキーマ差分、モニタリングは全SQLエンジンで共通です。MongoDBとRedisはSQLエンジンではないため、ER図とスキーマ差分はありません。Druid、Elasticsearch、OpenSearch、TrinoはこのビルドがパースできるURI形式を持たないためhostとportで設定する二重の例外で、生成されるマイグレーションもDDLを出力せず制約を明示します(Couchbaseのスキーマレスなコレクションも同様)。検索クラスタのER図は箱だけで線がありません。インデックスは外部キーを宣言せず、エンジンのモデルにも宣言できる外部キーが存在しないためです。
@@ -98,6 +98,7 @@ PostgreSQL · MySQL · Oracle · SQL Server · SQLite · libSQL · DuckDB · Mon
| **MySQL** | `mysql2` | フルSQL IDE、EXPLAIN、トランザクション、クエリキャンセル(`KILL QUERY`) |
| **Oracle** | `oracledb`(Thinモード) | フルSQL IDE、`FETCH FIRST N ROWS`、`V$`監視ビュー、`ANALYZE TABLE`、`ALTER INDEX REBUILD`、トランザクション |
| **SQL Server** | `mssql` (tedious) | フルSQL IDE、`TOP N` / `OFFSET FETCH`、`sys.dm_*` DMV、`UPDATE STATISTICS`、`DBCC CHECKDB`、トランザクション、Azure SQL自動判別 |
+| **IBM Db2 LUW** | `ibm_db`(ネイティブアドオン、インストール時にIBM CLIドライバを取得) | DRDAプロトコル上のSQL IDE、`FETCH FIRST` / `OFFSET FETCH`、`SYSCAT.*` カタログ参照、`RUNSTATS` と `REORG TABLE` のメンテナンス。EXPLAINと対話型トランザクションツールバーは未接続 |
| **SQLite** | `bun:sqlite` / `node:sqlite`(実行時選択) | フルSQL IDE、ファイル型・インメモリ型 |
| **libSQL** | ドライバなし、HTTPのみ(Hranaプロトコル、`POST /v2/pipeline`、8080) | フルSQL IDE。自前運用のlibSQLサーバー(`sqld`)とTurso Cloudの両方に同じtype-idで接続します。ネットワーク越しのSQLite方言で、`dbstat`による実測のテーブル・インデックスサイズが読めます。認証情報はパスワードではなくauthトークンです。メンテナンスはReindexと整合性チェックのみ。`VACUUM`、`ANALYZE`、`PRAGMA optimize`はサーバー側が拒否します |
| **DuckDB** | `@duckdb/node-api`(ネイティブN-APIアドオン、プラットフォームごとに約68MBのバインディング) | アプリが動作するサーバ上のローカルDuckDBファイル、または`:memory:`に対するフルSQL IDE。`EXPLAIN (FORMAT JSON)`による物理プランツリー、`duckdb_*`カタログの自省、`pragma_storage_info`のブロック割り当てから得られる実際のテーブル別バイト数、ドライバ自身の`interrupt()`によるクエリキャンセル。メンテナンス操作は`VACUUM`・`ANALYZE`・`CHECKPOINT`の3つです。`REINDEX`はこのエンジンではパースエラーであり、`PRAGMA integrity_check`も`PRAGMA optimize`も存在しないため、それらの操作は提供しません。スロークエリログもセッション一覧もありません。DuckDBはどちらも公開していないため、これらのパネルは0を表示するのではなくその旨を伝えます。データベースファイルを開けるOSプロセスは1つだけで、読み取り専用モードでも2つ目は拒否されるため、このインスタンスが保持しているファイルを別のStudioインスタンスが開くことはできません |
diff --git a/README_ur.md b/README_ur.md
index 0457bef84..fd543cd48 100644
--- a/README_ur.md
+++ b/README_ur.md
@@ -96,11 +96,11 @@ npx @libredb/studio
## بنیادی صلاحیتیں
-### سولہ engines، ایک interface
+### سترہ engines، ایک interface
-PostgreSQL · MySQL · Oracle · SQL Server · SQLite · libSQL · DuckDB · MongoDB · Redis · Couchbase · ClickHouse · Apache Druid · Elasticsearch · OpenSearch · Apache Trino · Apache Cassandra
+PostgreSQL · MySQL · Oracle · SQL Server · IBM Db2 LUW · SQLite · libSQL · DuckDB · MongoDB · Redis · Couchbase · ClickHouse · Apache Druid · Elasticsearch · OpenSearch · Apache Trino · Apache Cassandra
@@ -114,6 +114,7 @@ PostgreSQL · MySQL · Oracle · SQL Server · SQLite · libSQL · DuckDB · Mon
| **MySQL** | `mysql2` | مکمل SQL IDE، EXPLAIN، transactions، query cancellation (`KILL QUERY`) |
| **Oracle** | `oracledb` (Thin موڈ) | مکمل SQL IDE، `FETCH FIRST N ROWS` کے ساتھ pagination، `V$` monitoring views، `ANALYZE TABLE`، `ALTER INDEX REBUILD`، transactions |
| **SQL Server** | `mssql` (tedious) | مکمل SQL IDE، `TOP N` / `OFFSET FETCH` کے ساتھ pagination، `sys.dm_*` DMV، `UPDATE STATISTICS`، `DBCC CHECKDB`، transactions، Azure SQL کی خودکار شناخت |
+| **IBM Db2 LUW** | `ibm_db` (native addon؛ اس کا install مرحلہ IBM CLI driver download کرتا ہے) | DRDA protocol پر SQL IDE، `FETCH FIRST` / `OFFSET FETCH` کے ساتھ pagination، `SYSCAT.*` catalog سے object browser، `RUNSTATS` اور `REORG TABLE` maintenance۔ EXPLAIN اور interactive transactions toolbar ابھی wired نہیں |
| **SQLite** | `bun:sqlite` / `node:sqlite` (runtime کے مطابق) | file یا memory میں مکمل SQL IDE |
| **libSQL** | کوئی مخصوص driver نہیں؛ براہِ راست HTTP (Hrana protocol، `POST /v2/pipeline`، port 8080) | مکمل SQL IDE۔ یہی type-id آپ کے اپنے libSQL server (`sqld`) اور Turso Cloud، دونوں سے connect کرتا ہے۔ یہ network پر SQLite dialect ہے، اور `dbstat` کے ساتھ tables اور indexes کا اصل size bytes میں دیتا ہے۔ credential password نہیں بلکہ auth token ہے۔ صرف دو maintenance operations ہیں، Reindex اور integrity check: server `VACUUM`، `ANALYZE` اور `PRAGMA optimize` کو رد کرتا ہے |
| **DuckDB** | `@duckdb/node-api` (مقامی N-API addon، ہر platform کے لیے تقریباً 68 MB) | مقامی DuckDB files یا `:memory:` پر مکمل SQL IDE، جو application کے اسی server پر چلتا ہے۔ `EXPLAIN (FORMAT JSON)` کے ساتھ physical plan tree، `duckdb_*` catalog introspection، `pragma_storage_info` کی block allocation سے table کا اصل size، اور خود driver کے `interrupt()` سے query cancellation۔ تین maintenance operations: `VACUUM`، `ANALYZE` اور `CHECKPOINT`۔ یہاں `REINDEX` syntax error ہے، جبکہ `PRAGMA integrity_check` اور `PRAGMA optimize` موجود نہیں، اس لیے پیش نہیں کیے جاتے۔ slow-query log یا sessions list نہیں: DuckDB ان میں سے کوئی بھی ظاہر نہیں کرتا، اس لیے panels 0 دکھانے کے بجائے یہ بات بتاتے ہیں۔ ایک database file کو operating system کا صرف ایک process کھول سکتا ہے (read-only mode میں بھی)، اس لیے Studio کی دوسری instance اس file کو نہیں کھول سکتی جو پہلی instance نے کھولی ہوئی ہے |
diff --git a/README_zh.md b/README_zh.md
index 56d8988cc..87a52ae9f 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -88,9 +88,9 @@ LibreDB Studio 走另一条路:**工具去找数据,而不是把数据搬来
## 核心能力
-### 十六种引擎,一个界面
+### 十七种引擎,一个界面
-PostgreSQL · MySQL · Oracle · SQL Server · SQLite · libSQL · DuckDB · MongoDB · Redis · Couchbase · ClickHouse · Apache Druid · Elasticsearch · OpenSearch · Apache Trino · Apache Cassandra
+PostgreSQL · MySQL · Oracle · SQL Server · IBM Db2 LUW · SQLite · libSQL · DuckDB · MongoDB · Redis · Couchbase · ClickHouse · Apache Druid · Elasticsearch · OpenSearch · Apache Trino · Apache Cassandra
所有 SQL 引擎共用同一套 schema 浏览器、ER 图、schema 对比和监控面板。MongoDB 和 Redis 不属于 SQL 引擎,没有 ER 图和 schema 对比;Druid、Elasticsearch、OpenSearch 和 Trino 都是双重例外:它们的 HTTP SQL 接口没有本构建能解析的 URI 形式,只能按 host/port 配置,而且生成的迁移会直接说明限制,而不是对一个 SQL 里根本没有列变更语句的引擎硬输出 DDL;Couchbase 的 schemaless collection 同理。搜索集群的 ER 图只有方框没有连线:索引不声明外键,引擎模型里也没有外键可声明。
@@ -100,6 +100,7 @@ PostgreSQL · MySQL · Oracle · SQL Server · SQLite · libSQL · DuckDB · Mon
| **MySQL** | `mysql2` | 完整 SQL IDE、EXPLAIN、事务、查询取消(`KILL QUERY`) |
| **Oracle** | `oracledb`(Thin 模式) | 完整 SQL IDE、`FETCH FIRST N ROWS` 分页、`V$` 监控视图、`ANALYZE TABLE`、`ALTER INDEX REBUILD`、事务 |
| **SQL Server** | `mssql` (tedious) | 完整 SQL IDE、`TOP N` / `OFFSET FETCH` 分页、`sys.dm_*` DMV、`UPDATE STATISTICS`、`DBCC CHECKDB`、事务、自动识别 Azure SQL |
+| **IBM Db2 LUW** | `ibm_db`(原生插件,安装时下载 IBM CLI 驱动) | 基于 DRDA 协议的 SQL IDE、`FETCH FIRST` / `OFFSET FETCH` 分页、`SYSCAT.*` 目录浏览、`RUNSTATS` 与 `REORG TABLE` 维护。EXPLAIN 与交互式事务工具栏暂未接入 |
| **SQLite** | `bun:sqlite` / `node:sqlite`(运行时自选) | 完整 SQL IDE,文件型或内存型数据库 |
| **libSQL** | 无驱动,纯 HTTP(Hrana 协议,`POST /v2/pipeline`,8080 端口) | 完整 SQL IDE,同一个 type-id 同时连接自建 libSQL 服务器(`sqld`)与 Turso Cloud。就是跨网络的 SQLite 方言,并能通过 `dbstat` 读到真实的表与索引字节数。凭据是 auth token 而不是密码。维护操作只有 Reindex 和完整性检查:`VACUUM`、`ANALYZE`、`PRAGMA optimize` 都被服务端拒绝 |
| **DuckDB** | `@duckdb/node-api`(原生 N-API 插件,每个平台约 68 MB 绑定) | 面向本地 DuckDB 文件或 `:memory:` 的完整 SQL IDE,运行在应用所在的服务器上。`EXPLAIN (FORMAT JSON)` 物理计划树、`duckdb_*` 目录自省、来自 `pragma_storage_info` 块分配的真实单表字节数,以及通过驱动自身 `interrupt()` 实现的查询取消。三项维护操作:`VACUUM`、`ANALYZE` 和 `CHECKPOINT`——这里 `REINDEX` 是语法错误,`PRAGMA integrity_check` 与 `PRAGMA optimize` 都不存在,因此不为它们提供入口。没有慢查询日志,也没有会话列表:DuckDB 两者都不公开,所以这两个面板会如实说明,而不是显示 0。数据库文件只允许一个操作系统进程打开(只读模式下同样被拒绝),因此第二个 Studio 实例无法打开本实例已持有的文件 |
diff --git a/bun.lock b/bun.lock
index 108739c8b..5ec3aed3b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -49,6 +49,7 @@
"embla-carousel-react": "^8.6.0",
"framer-motion": "^13.2.0",
"geist": "^1.7.2",
+ "ibm_db": "4.0.1",
"input-otp": "^1.5.0",
"ioredis": "^5.11.1",
"jose": "^6.2.12",
@@ -114,6 +115,7 @@
},
},
"trustedDependencies": [
+ "ibm_db",
"esbuild",
"oracledb",
"ssh2",
@@ -1158,7 +1160,7 @@
"adm-zip": ["adm-zip@0.6.0", "", {}, "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg=="],
- "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+ "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"ai": ["ai@7.0.59", "", { "dependencies": { "@ai-sdk/gateway": "4.0.47", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-p10cqg8KvLIZi7Gk+XsQjoYoLENoDdEYvehP0rSo6Wg15nbCmP+nwIy1rOIX+WGIDWGdI+jmhjVx5fXShBVC3w=="],
@@ -1210,12 +1212,16 @@
"async-sema": ["async-sema@3.1.1", "", {}, "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg=="],
+ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
+
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
"axe-core": ["axe-core@4.13.0", "", {}, "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A=="],
+ "axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
+
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"b4a": ["b4a@1.8.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="],
@@ -1232,10 +1238,14 @@
"better-sqlite3": ["better-sqlite3@13.0.3", "", { "dependencies": { "node-addon-api": "^8.0.0" } }, "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ=="],
+ "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
+
"binary-version": ["binary-version@7.1.0", "", { "dependencies": { "execa": "^8.0.1", "find-versions": "^6.0.0" } }, "sha512-Iy//vPc3ANPNlIWd242Npqc8MK0a/i4kVcHDlDA6HNMv5zMxz4ulIFhOSYJVKw/8AbHdHy0CnGYEt1QqSXxPsw=="],
"binary-version-check": ["binary-version-check@6.1.0", "", { "dependencies": { "binary-version": "^7.1.0", "semver": "^7.6.0", "semver-truncate": "^3.0.0" } }, "sha512-REKdLKmuViV2WrtWXvNSiPX04KbIjfUV3Cy8batUeOg+FtmowavzJorfFhWq95cVJzINnL/44ixP26TrdJZACA=="],
+ "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
+
"bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="],
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
@@ -1254,8 +1264,14 @@
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
+ "buffer-alloc": ["buffer-alloc@1.2.0", "", { "dependencies": { "buffer-alloc-unsafe": "^1.1.0", "buffer-fill": "^1.0.0" } }, "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow=="],
+
+ "buffer-alloc-unsafe": ["buffer-alloc-unsafe@1.1.0", "", {}, "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg=="],
+
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
+ "buffer-fill": ["buffer-fill@1.0.0", "", {}, "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ=="],
+
"buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
@@ -1306,6 +1322,8 @@
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+ "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
+
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
@@ -1342,6 +1360,8 @@
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
+
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
@@ -1362,6 +1382,8 @@
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
+ "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
+
"cosmiconfig": ["cosmiconfig@8.3.6", "", { "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA=="],
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
@@ -1434,6 +1456,8 @@
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
+ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
+
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
@@ -1486,6 +1510,8 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
+ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
+
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
@@ -1606,6 +1632,8 @@
"file-type": ["file-type@22.0.2", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-0H8TsCUGBLx+V5adH3EY52hTAcyLKbV1D4gq5cIOJ6DnQAHeV9Z2Hhuc5CoBX4YmvB2oL+JIC84z0qO7JsCoNw=="],
+ "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
+
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"filename-reserved-regex": ["filename-reserved-regex@4.0.1", "", {}, "sha512-qUet2faQFKvtvVUsEf7wCrTURwxBOIZpspsLHGifw9QCWk55ITE2FrG8XhfQqG/uxMA1xEGFVQbL+Yfm0O94+Q=="],
@@ -1626,8 +1654,12 @@
"flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="],
+ "follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
+
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
+ "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
+
"form-data-encoder": ["form-data-encoder@4.1.0", "", {}, "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw=="],
"formatly": ["formatly@0.7.0", "", { "dependencies": { "fd-package-json": "^2.0.0", "package-manager-detector": "^1.8.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-7CXJtIIA0zy/u12StsYk25qVKxvdLA2ep2sTNxK3ov0mGNIIDqIvAXDSgTnAfDJFsPfWjuz0WjfYSdpvnLA5Tg=="],
@@ -1638,6 +1670,8 @@
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
+ "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
+
"fs-extra": ["fs-extra@11.4.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -1730,10 +1764,12 @@
"http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="],
- "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+ "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
+ "ibm_db": ["ibm_db@4.0.1", "", { "dependencies": { "adm-zip": "^0.5.16", "axios": "^1.16.1", "big-integer": "^1.6.51", "bindings": "^1.5.0", "fs-extra": "^11.1.1", "lodash": "^4.18.1", "node-addon-api": "^7.1.1", "targz": "^1.0.1" } }, "sha512-0fO1fyaDm3jqaydObwKZw1aUNh6SbfHKxtrSfadWHoFsrnmBhQfp1oMFm6cWJSRNjE9pHyQw7zUNteuLRObwSg=="],
+
"iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -1934,6 +1970,8 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
+ "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
+
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
@@ -1986,9 +2024,9 @@
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
- "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
+ "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
- "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
+ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
@@ -2002,6 +2040,8 @@
"mixpart": ["mixpart@0.0.6", "", {}, "sha512-CRdXtgfQH2jARmtNmPR0Q7jL20fiESbaYk1b0KvLD0jCdUuemepREtsbd8nbiY6BHV9OGGddAZITNXklupUPUQ=="],
+ "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
+
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
"monaco-editor": ["monaco-editor@0.56.0", "", { "dependencies": { "dompurify": "3.4.8", "marked": "14.0.0" } }, "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg=="],
@@ -2198,10 +2238,16 @@
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
+ "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
+
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
+ "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
+
+ "pump": ["pump@1.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw=="],
+
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="],
@@ -2432,7 +2478,11 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
- "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
+ "tar-fs": ["tar-fs@1.16.6", "", { "dependencies": { "chownr": "^1.0.1", "mkdirp": "^0.5.1", "pump": "^1.0.0", "tar-stream": "^1.1.2" } }, "sha512-JkOgFt3FxM/2v2CNpAVHqMW2QASjc/Hxo7IGfNd3MHaDYSW/sBFiS7YVmmhmr8x6vwN1VFQDQGdT2MWpmIuVKA=="],
+
+ "tar-stream": ["tar-stream@1.6.2", "", { "dependencies": { "bl": "^1.0.0", "buffer-alloc": "^1.2.0", "end-of-stream": "^1.0.0", "fs-constants": "^1.0.0", "readable-stream": "^2.3.0", "to-buffer": "^1.1.1", "xtend": "^4.0.0" } }, "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A=="],
+
+ "targz": ["targz@1.0.1", "", { "dependencies": { "tar-fs": "^1.8.1" } }, "sha512-6q4tP9U55mZnRuMTBqnqc3nwYQY3kv+QthCFZuMk+Tn1qYUnMPmL/JZ/mzgXINzFpSqfU+242IFmFU9VPvqaQw=="],
"tarn": ["tarn@3.1.2", "", {}, "sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg=="],
@@ -2456,6 +2506,8 @@
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+ "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="],
+
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
@@ -2540,6 +2592,8 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
+ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+
"validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@@ -2670,6 +2724,8 @@
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="],
+ "@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+
"@unrs/resolver-binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@unrs/resolver-binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
@@ -2748,6 +2804,8 @@
"@xhmikosr/decompress-tar/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
+ "@xhmikosr/decompress-tar/tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
+
"@xhmikosr/decompress-tarbz2/file-type": ["file-type@21.3.4", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g=="],
"@xhmikosr/decompress-tarbz2/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
@@ -2764,6 +2822,8 @@
"@xhmikosr/downloader/file-type": ["file-type@21.3.4", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g=="],
+ "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
+
"binary-version/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
"body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
@@ -2812,6 +2872,10 @@
"eslint-plugin-react-hooks/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
+ "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
+
+ "ext-list/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
+
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
@@ -2820,6 +2884,10 @@
"graphile-config/@types/node": ["@types/node@22.20.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw=="],
+ "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+
+ "ibm_db/node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
+
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
@@ -2872,6 +2940,8 @@
"seek-bzip/commander": ["commander@6.2.1", "", {}, "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="],
+ "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
+
"sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -2882,12 +2952,18 @@
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
+ "tar-stream/bl": ["bl@1.2.3", "", { "dependencies": { "readable-stream": "^2.3.5", "safe-buffer": "^5.1.1" } }, "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww=="],
+
+ "tar-stream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
+
"terminal-link/supports-hyperlinks": ["supports-hyperlinks@4.5.0", "", { "dependencies": { "has-flag": "^5.0.1", "supports-color": "^10.2.2" } }, "sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w=="],
"tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
+ "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
+
"unbzip2-stream/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
@@ -2910,6 +2986,8 @@
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
+ "@typespec/ts-http-runtime/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+
"@unrs/resolver-binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@vercel/cli-auth/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
@@ -3040,6 +3118,8 @@
"@xhmikosr/decompress-unzip/get-stream/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
+ "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
+
"binary-version/execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
"binary-version/execa/human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
@@ -3080,16 +3160,28 @@
"eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
+ "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
+
"filelist/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
"graphile-config/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+ "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
+
+ "tar-stream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
+
+ "tar-stream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
+
+ "tar-stream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
+
"terminal-link/supports-hyperlinks/has-flag": ["has-flag@5.0.1", "", {}, "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="],
"terminal-link/supports-hyperlinks/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
+ "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
+
"@oclif/plugin-help/@oclif/core/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"@oclif/plugin-help/@oclif/core/wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
diff --git a/charts/libredb-studio/Chart.yaml b/charts/libredb-studio/Chart.yaml
index 3bd401882..4fea8c832 100644
--- a/charts/libredb-studio/Chart.yaml
+++ b/charts/libredb-studio/Chart.yaml
@@ -1,6 +1,6 @@
apiVersion: v2
name: libredb-studio
-description: Web-based SQL IDE for cloud-native teams supporting sixteen engines - PostgreSQL, MySQL, SQLite, DuckDB, Oracle, SQL Server, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino, Apache Cassandra and libSQL
+description: Web-based SQL IDE for cloud-native teams supporting seventeen engines - PostgreSQL, MySQL, SQLite, DuckDB, Oracle, SQL Server, IBM Db2 LUW, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino, Apache Cassandra and libSQL
type: application
version: 0.1.64
appVersion: "0.16.0"
@@ -38,6 +38,9 @@ keywords:
# One keyword only, unlike the pair above: DuckDB is registered as `duckdb` and that
# is also the product name an evaluator types, so there is no second spelling to catch.
- duckdb
+ # Two spellings for IBM Db2: the type-id `db2` and the product name a user searches.
+ - db2
+ - ibm db2
- web-ide
maintainers:
- name: cevheri
diff --git a/charts/libredb-studio/README.md b/charts/libredb-studio/README.md
index b1bfe7f45..d08f36584 100644
--- a/charts/libredb-studio/README.md
+++ b/charts/libredb-studio/README.md
@@ -2,7 +2,7 @@
[](https://artifacthub.io/packages/search?repo=libredb-studio)
-Web-based SQL IDE for cloud-native teams supporting sixteen engines - PostgreSQL, MySQL, SQLite, libSQL, DuckDB, Oracle, SQL Server, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra.
+Web-based SQL IDE for cloud-native teams supporting seventeen engines - PostgreSQL, MySQL, SQLite, libSQL, DuckDB, Oracle, SQL Server, IBM Db2 LUW, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra.
## Prerequisites
diff --git a/database-compose.yml b/database-compose.yml
index 95ff424a3..312284ba7 100644
--- a/database-compose.yml
+++ b/database-compose.yml
@@ -120,6 +120,59 @@ services:
# once and ONLY on a fresh data directory, so an existing container has to be
# recreated before an edit takes effect. Connect as APP / Password123! on XEPDB1.
- ./docker/oracle-init:/container-entrypoint-initdb.d:ro
+ db2:
+ # IBM Db2 LUW (Community Edition image). One container is the whole server, like the
+ # mssql and oracle services above, so it carries no `profiles:` — it is a shipped
+ # provider and a plain `docker compose up` has to be able to reproduce its integration
+ # pass, the same rule mssql/oracle/clickhouse follow.
+ #
+ # Three things this image needs that the others do not, and each is load-bearing:
+ #
+ # * `privileged: true`. The Db2 engine sets kernel parameters (shared memory, message
+ # queues) at startup and the container's entrypoint refuses to proceed without the
+ # capability to do so. Without it the database never comes up. This is IBM's own
+ # documented requirement for this image, not a convenience.
+ # * `LICENSE=accept`. The image will not start otherwise; there is no EULA prompt to
+ # answer interactively, so the acceptance is the env var.
+ # * A long first boot. Db2 creates the instance and the SAMPLE-style database on first
+ # run, which takes minutes — far longer than mssql/oracle — so the healthcheck's
+ # retries and start_period are generous. MEASURED expectations belong in
+ # docs/providers/db2.md after the gate-4 pass; the values here are a starting point,
+ # not a claim.
+ #
+ # DB2INSTANCE/DB2INST1_PASSWORD are the instance owner the provider connects as; DBNAME
+ # is the database its `database` field should point at.
+ image: icr.io/db2_community/db2:12.1.0.0
+ container_name: libredb-db2
+ restart: unless-stopped
+ privileged: true
+ environment:
+ LICENSE: accept
+ DB2INSTANCE: db2inst1
+ DB2INST1_PASSWORD: Password123!
+ DBNAME: testdb
+ # Skips the optional text-search and audit setup the image otherwise runs on first
+ # boot, which shortens the multi-minute init the healthcheck waits through.
+ ARCHIVE_LOGS: "false"
+ AUTOCONFIG: "false"
+ ports:
+ - "50000:50000"
+ volumes:
+ # The object-browser fixture (#786, #789): two schemas, one object of every declared kind,
+ # an overloaded function, a trigger in a schema other than its table's, an external routine
+ # with no stored text and a view left invalid. The image runs /var/custom once, after
+ # first-boot setup, so an existing container has to be recreated before an edit takes effect.
+ - ./docker/db2-init:/var/custom:ro
+ healthcheck:
+ # `db2gcf -s` reports the instance state; the database is queryable once the entrypoint
+ # writes "Setup has completed" to the log, but db2gcf is the machine-readable signal.
+ # The interval and retry budget cover several minutes of first-boot setup — a fixed
+ # sleep would be wrong the way the cassandra service's comment warns.
+ test: ["CMD-SHELL", "su - db2inst1 -c 'db2gcf -s' | grep -q Available"]
+ interval: 15s
+ timeout: 10s
+ retries: 40
+ start_period: 120s
couchbase:
# The image ships an uninitialized node: the cluster, the credentials below,
# the bucket and its primary index are all applied by the couchbase-init
diff --git a/deploy/aws/listing/description.md b/deploy/aws/listing/description.md
index 6d4e8dad7..785e7ee7c 100644
--- a/deploy/aws/listing/description.md
+++ b/deploy/aws/listing/description.md
@@ -1,2 +1,2 @@
-Open-source SQL IDE for cloud-native teams. One interface for 16 engines, among them PostgreSQL, MySQL, Oracle, SQL Server, MongoDB, Redis, ClickHouse and Trino. Browse schemas, run queries with schema-aware autocomplete, compare schemas, and generate SQL with AI assistance. Runs on your own instance; no data leaves your account.
+Open-source SQL IDE for cloud-native teams. One interface for 17 engines, among them PostgreSQL, MySQL, Oracle, SQL Server, Db2, MongoDB, Redis, ClickHouse and Trino. Browse schemas, run queries with schema-aware autocomplete, compare schemas, and generate SQL with AI assistance. Runs on your own instance; no data leaves your account.
diff --git a/deploy/aws/listing/listing-fields.md b/deploy/aws/listing/listing-fields.md
index 4ce3019b1..c4d15f20e 100644
--- a/deploy/aws/listing/listing-fields.md
+++ b/deploy/aws/listing/listing-fields.md
@@ -10,7 +10,7 @@ enforces them, along with the character set the portal accepts (ASCII 0-126 plus
(R), (C), (TM) and currency symbols). The engine count is checked against the
code by the repo-wide catalog gate, not by a number kept here.
-
+
## Product title
@@ -27,7 +27,7 @@ sentence verbatim, which is what the AMI product checklist asks for.
-- Sixteen engines behind one interface, among them PostgreSQL, MySQL, Oracle, SQL Server, MongoDB, Redis, ClickHouse and Trino.
+- Seventeen engines behind one interface, among them PostgreSQL, MySQL, Oracle, SQL Server, Db2, MongoDB, Redis, ClickHouse and Trino.
- AI query assistance using your own model key, or run it with no AI at all.
- Self-hosted on a single instance: connections, query history and results stay in your account.
diff --git a/deploy/azure/listing/description.html b/deploy/azure/listing/description.html
index 16540ec53..8531a4dd8 100644
--- a/deploy/azure/listing/description.html
+++ b/deploy/azure/listing/description.html
@@ -4,8 +4,8 @@
What you get
-
One workspace for sixteen engines - PostgreSQL, MySQL/MariaDB/TiDB, Microsoft SQL Server
- (including Azure SQL), Oracle, SQLite, libSQL/Turso, DuckDB, MongoDB/FerretDB, Redis, Couchbase,
+
One workspace for seventeen engines - PostgreSQL, MySQL/MariaDB/TiDB, Microsoft SQL Server
+ (including Azure SQL), Oracle, IBM Db2 LUW, SQLite, libSQL/Turso, DuckDB, MongoDB/FerretDB, Redis, Couchbase,
ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra.
A real editor - Monaco-based SQL editing with schema-aware autocomplete,
formatting, query history and virtualized result grids that stay fast on large result sets.
diff --git a/deploy/azure/listing/listing-fields.md b/deploy/azure/listing/listing-fields.md
index c7cf13df9..916957772 100644
--- a/deploy/azure/listing/listing-fields.md
+++ b/deploy/azure/listing/listing-fields.md
@@ -31,12 +31,12 @@ LibreDB Studio
**Search results summary** (limit 100):
-Open-source SQL IDE for 16 engines: PostgreSQL, MySQL, SQL Server, Oracle, MongoDB, Redis and more
+Open-source SQL IDE for 17 engines: PostgreSQL, MySQL, SQL Server, Oracle, MongoDB, Redis and more
**Short description** (limit 256):
-Open-source, self-hosted SQL IDE. Sixteen engines - PostgreSQL, MySQL, SQL Server, Oracle, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Druid, Elasticsearch, OpenSearch, Trino, Cassandra - explore and query, with read-only AI.
+Open-source, self-hosted SQL IDE. Seventeen engines - PostgreSQL, MySQL, SQL Server, Oracle, Db2 LUW, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Druid, Elasticsearch, OpenSearch, Trino, Cassandra - explore and query, with read-only AI.
**Description** (limit 5000, HTML): see [description.html](description.html) —
the limit is asserted by the unit test.
@@ -75,7 +75,7 @@ Planned captions:
1. `hero-editor` — "Write and run SQL with schema-aware autocomplete and a virtualized result grid."
2. `agent-rail` — "Ask the read-only agent a question; every claim in its answer cites the result it came from."
3. `erd-diagram` — "Explore relationships with an automatically generated ERD."
-4. `connection-modal` — "Connect to sixteen engines, from PostgreSQL and SQL Server to Apache Cassandra."
+4. `connection-modal` — "Connect to seventeen engines, from PostgreSQL and SQL Server to Apache Cassandra."
5. `data-profiler` — "Profile table data: distributions, null ratios and outliers at a glance."
## Plan (§7.6)
diff --git a/deploy/caprover/libredb-studio.yml b/deploy/caprover/libredb-studio.yml
index ed55ca8a8..25f4bb849 100644
--- a/deploy/caprover/libredb-studio.yml
+++ b/deploy/caprover/libredb-studio.yml
@@ -69,8 +69,8 @@ caproverOneClickApp:
instructions:
start: |-
LibreDB Studio is a modern, open-source, web-based SQL IDE for the cloud era.
- Query sixteen engines - PostgreSQL, MySQL, SQLite, libSQL, DuckDB, Oracle,
- SQL Server, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid,
+ Query seventeen engines - PostgreSQL, MySQL, SQLite, libSQL, DuckDB, Oracle,
+ SQL Server, Db2 LUW, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid,
Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra - from your
browser, with an ERD viewer, schema diff and optional read-only AI on your
own model key.
@@ -112,5 +112,5 @@ caproverOneClickApp:
"App Configs" tab. See https://github.com/libredb/libredb-studio
displayName: LibreDB Studio
isOfficial: false
- description: Open-source web-based SQL IDE. Query sixteen engines - PostgreSQL, MySQL, SQLite, libSQL, DuckDB, Oracle, SQL Server, MongoDB, Redis, Couchbase, ClickHouse, Druid, Elasticsearch, OpenSearch, Apache Trino & Apache Cassandra - from your browser, with optional read-only AI on your own model key.
+ description: Open-source web-based SQL IDE. Query seventeen engines - PostgreSQL, MySQL, SQLite, libSQL, DuckDB, Oracle, SQL Server, Db2 LUW, MongoDB, Redis, Couchbase, ClickHouse, Druid, Elasticsearch, OpenSearch, Apache Trino & Apache Cassandra - from your browser, with optional read-only AI on your own model key.
documentation: Image and docs - https://github.com/libredb/libredb-studio . Uses ghcr.io/libredb/libredb-studio. Deploys with SQLite persistence; supports OIDC SSO and multiple AI providers via extra env vars.
diff --git a/deploy/digitalocean/assets/description-long.md b/deploy/digitalocean/assets/description-long.md
index 2c7501119..707559555 100644
--- a/deploy/digitalocean/assets/description-long.md
+++ b/deploy/digitalocean/assets/description-long.md
@@ -6,7 +6,7 @@ LibreDB Studio gives you a full-featured database workspace in your browser —
## Features
-- **Sixteen engines, one interface** — PostgreSQL, MySQL, Oracle, SQL Server, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra
+- **Seventeen engines, one interface** — PostgreSQL, MySQL, Oracle, SQL Server, Db2, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra
- **Read-only AI agent** — state a question and the agent investigates it, and every claim in its report cites the result it came from; it runs SQL on PostgreSQL, SQLite and DuckDB only, in a session the database enforces as read-only, so writes and DDL are refused by the engine rather than by reading the statement. On every other engine it drafts the statement and you run it, and nothing reaches your editor unless you consent to the hand-over when the run opens
- **AI query explanation** — one click turns an unfamiliar query into plain English, with your own schema as context, on PostgreSQL, MySQL, SQLite, libSQL, DuckDB, Couchbase, ClickHouse, Apache Druid and Apache Trino: the write-up is derived from the engine's own `EXPLAIN` plan, so it is offered where an engine returns one (bring your own key: Gemini, OpenAI, Ollama or any OpenAI-compatible endpoint; off unless configured)
- **Modern editor** — autocomplete, syntax highlighting, query history
diff --git a/deploy/railway/TEMPLATE_OVERVIEW.md b/deploy/railway/TEMPLATE_OVERVIEW.md
index 80ee2efa2..99388f3f8 100644
--- a/deploy/railway/TEMPLATE_OVERVIEW.md
+++ b/deploy/railway/TEMPLATE_OVERVIEW.md
@@ -16,7 +16,7 @@ Hosting LibreDB Studio means running a single stateless Next.js container that s
## Dependencies for libredb-studio Hosting
-- A database to connect to — any of the sixteen engines above (bring your own, or add a Railway database to the project).
+- A database to connect to — any of the seventeen engines above (bring your own, or add a Railway database to the project).
- A persistent volume mounted at `/app/data` for the SQLite-backed store of saved connections and settings (included in this template).
### Deployment Dependencies
diff --git a/deploy/railway/template.json b/deploy/railway/template.json
index 18d70aaec..a51ad4427 100644
--- a/deploy/railway/template.json
+++ b/deploy/railway/template.json
@@ -1,7 +1,7 @@
{
"$comment": "Reviewable serialization of the LibreDB Studio Railway template. Railway does not ingest this file directly — the marketplace template is built in Railway's template editor (Workspace -> Templates -> New Template, at https://railway.com/workspace/templates). This file is the single source of truth for what to enter there (see PUBLISH.md) and what a future API automation would consume. Mirrors deploy/caprover/libredb-studio.yml.",
"name": "LibreDB Studio",
- "description": "Open-source web-based SQL IDE. Query sixteen engines - PostgreSQL, MySQL, SQLite, libSQL, DuckDB, Oracle, SQL Server, MongoDB, Redis, Couchbase, ClickHouse, Druid, Elasticsearch, OpenSearch, Apache Trino & Apache Cassandra - from your browser, with optional read-only AI on your own model key.",
+ "description": "Open-source web-based SQL IDE. Query seventeen engines - PostgreSQL, MySQL, SQLite, libSQL, DuckDB, Oracle, SQL Server, Db2 LUW, MongoDB, Redis, Couchbase, ClickHouse, Druid, Elasticsearch, OpenSearch, Apache Trino & Apache Cassandra - from your browser, with optional read-only AI on your own model key.",
"tags": ["database", "sql", "ide", "postgres", "mysql", "mongodb", "redis"],
"services": [
{
diff --git a/deploy/rancher/CATALOG_LISTING.md b/deploy/rancher/CATALOG_LISTING.md
index ea6333ea4..231a736c9 100644
--- a/deploy/rancher/CATALOG_LISTING.md
+++ b/deploy/rancher/CATALOG_LISTING.md
@@ -14,18 +14,19 @@ published it on 2026-08-05 from an earlier revision of this file). Edits here do
propagate automatically — SUSE owns the page, so any change has to be mailed to the
partner contact.
-> **Accuracy gate — engine count.** The wording below says sixteen engines. That is true only
-> from the release that carries **DuckDB** ([#424](https://github.com/libredb/libredb-studio/issues/424)),
-> which followed libSQL; fourteen was true from **0.13.0** onwards, the release that carried
+> **Accuracy gate — engine count.** The wording below says seventeen engines. That is true only
+> from the release that carries **IBM Db2 LUW**, which followed DuckDB
+> ([#424](https://github.com/libredb/libredb-studio/issues/424)); sixteen was true from the DuckDB
+> release, and fourteen was true from **0.13.0** onwards, the release that carried
> Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra alongside the ten of 0.11.0.
> The number is the `SHIPPED` record in
> `src/lib/db/compatibility.ts` minus the embedded `libredb`, which `EXTERNAL` in the same
> file already splits out; read it from there rather than from this file. The catalog entry
-> is version-scoped, so do not publish the sixteen-engine wording against a version that
-> predates DuckDB — send the fourteen-engine variant (0.13.0 onwards), the ten-engine one
-> (0.11.0 onwards) or the eight-engine one instead.
+> is version-scoped, so do not publish the seventeen-engine wording against a version that
+> predates Db2 — send the sixteen-engine variant (DuckDB release onwards), the fourteen-engine
+> variant (0.13.0 onwards), the ten-engine one (0.11.0 onwards) or the eight-engine one instead.
>
-> **The scope goes with the count.** Browsing and querying reach all sixteen; editing data does
+> **The scope goes with the count.** Browsing and querying reach all seventeen; editing data does
> not, so "manage data across …" must never be written over the whole list. Read the split from
> the providers: `supportsInlineRowEdit` and `supportsCreateTable` default to `true` in
> `src/lib/db/base-provider.ts` and each provider that cannot turns them off, which leaves inline
@@ -77,7 +78,7 @@ partner contact.
> `operator/helm-charts/libredb-studio/` by hand, or the sync guard fails the required check.
>
> What *is* release-coupled is every marketplace description that spells the count:
-> `deploy/azure`, `deploy/railway` and `deploy/caprover` all say sixteen as of the DuckDB
+> `deploy/azure`, `deploy/railway` and `deploy/caprover` all say seventeen as of the Db2
> release - and all three were still on fourteen when it landed, a full engine behind, because
> libSQL had moved the code and not them. `deploy/railway/template.json` and
> `deploy/caprover/libredb-studio.yml` were on thirteen once for the same reason: each channel
@@ -90,7 +91,7 @@ partner contact.
> day the next engine lands (issue #445) - but their exhaustive descriptions still name every
> engine, and so does `desktop/src-tauri/tauri.conf.json`.
> `packaging/linux/nfpm.yaml` and the operator CSVs are consumed at release time from `main`,
-> so they name sixteen now and the next tag publishes it.
+> so they name seventeen now and the next tag publishes it.
## Listing facts
@@ -114,18 +115,18 @@ partner contact.
## Short description (one sentence)
LibreDB Studio is an MIT-licensed, AI-assisted open source SQL IDE that connects to
-PostgreSQL, MySQL, Oracle, SQL Server, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase,
+PostgreSQL, MySQL, Oracle, SQL Server, IBM Db2 LUW, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase,
ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra
directly from the browser.
## Long description
LibreDB Studio brings a full SQL IDE to Rancher-managed Kubernetes clusters: browse
-schemas and run queries across PostgreSQL, MySQL, Oracle, SQL Server, SQLite, libSQL, DuckDB,
+schemas and run queries across PostgreSQL, MySQL, Oracle, SQL Server, IBM Db2 LUW, SQLite, libSQL, DuckDB,
MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino
and Apache Cassandra from a single web interface, with no desktop client to install. Editing
data follows the engine rather than the IDE: inline row editing on PostgreSQL, MySQL,
-Oracle, SQL Server, SQLite, libSQL and DuckDB, table creation on those seven and Apache Trino, and
+Oracle, SQL Server, Db2, SQLite, libSQL and DuckDB, table creation on those eight and Apache Trino, and
everywhere else the controls are reported as unsupported rather than offered and then
failed — Elasticsearch SQL has no mutation in its grammar at all, OpenSearch's one
mutation (`DELETE`) is off by default, and Druid SQL has no `UPDATE`, no `DELETE` and no
@@ -147,8 +148,8 @@ versions are documented and validated for every release.
## Key features (bullet form, if the catalog template asks for them)
-- Sixteen database engines in one browser-based IDE: PostgreSQL, MySQL, Oracle,
- SQL Server, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid,
+- Seventeen database engines in one browser-based IDE: PostgreSQL, MySQL, Oracle,
+ SQL Server, Db2 LUW, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid,
Elasticsearch, OpenSearch, Apache Trino, Apache Cassandra
- One-click install from the Rancher Apps catalog — deployable with default values,
zero configuration required
diff --git a/deploy/rancher/app-readme.md b/deploy/rancher/app-readme.md
index eac07676e..043e76abd 100644
--- a/deploy/rancher/app-readme.md
+++ b/deploy/rancher/app-readme.md
@@ -1,11 +1,11 @@
# LibreDB Studio
-LibreDB Studio is an MIT-licensed, AI-assisted open source SQL IDE that connects to sixteen database engines directly from the browser.
+LibreDB Studio is an MIT-licensed, AI-assisted open source SQL IDE that connects to seventeen database engines directly from the browser.
Use cases:
* Browser-based database management
- * Browse schemas and run queries across sixteen engines: PostgreSQL, MySQL, Oracle, SQL Server, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra. Editing data follows the engine rather than the IDE: inline row editing on PostgreSQL, MySQL, Oracle, SQL Server, SQLite, libSQL and DuckDB, and everywhere else the controls are reported as unsupported rather than offered and then failed.
+ * Browse schemas and run queries across seventeen engines: PostgreSQL, MySQL, Oracle, SQL Server, Db2 LUW, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra. Editing data follows the engine rather than the IDE: inline row editing on PostgreSQL, MySQL, Oracle, SQL Server, Db2, SQLite, libSQL and DuckDB, and everywhere else the controls are reported as unsupported rather than offered and then failed.
* AI-assisted querying
* An optional AI assistant (bring your own key: Gemini, OpenAI, or a local model) explains a query in plain English from the engine's own EXPLAIN plan, on PostgreSQL, MySQL, SQLite, libSQL, DuckDB, Couchbase, ClickHouse, Apache Druid and Apache Trino, and runs a read-only investigation agent on PostgreSQL, SQLite and DuckDB where the database, not the IDE, enforces the read-only session. It stays off unless you configure a provider.
* Self-hosted data
diff --git a/docker/db2-init/01-object-fixture.sh b/docker/db2-init/01-object-fixture.sh
new file mode 100755
index 000000000..574bd6d14
--- /dev/null
+++ b/docker/db2-init/01-object-fixture.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+# The icr.io/db2_community/db2 image runs every executable in /var/custom as root, once its
+# first-boot setup has created the database named by DBNAME. The SQL runs as the instance
+# owner through the Command Line Processor, with `@` as the terminator because compound SQL
+# bodies carry `;`. An already-initialized data directory skips first boot, so an edit to the
+# fixture needs the container recreated.
+set -euo pipefail
+su - "${DB2INSTANCE}" -c "db2 connect to ${DBNAME} && db2 -td@ -vf /var/custom/01-object-fixture.sql; db2 terminate"
diff --git a/docker/db2-init/01-object-fixture.sql b/docker/db2-init/01-object-fixture.sql
new file mode 100644
index 000000000..7d3447fd6
--- /dev/null
+++ b/docker/db2-init/01-object-fixture.sql
@@ -0,0 +1,107 @@
+-- Object-browser fixture for the Db2 LUW provider (#786, #789).
+--
+-- Run by 01-object-fixture.sh, which the icr.io/db2_community/db2 image executes from
+-- /var/custom once its first-boot setup has created TESTDB. The statement terminator is `@`
+-- (db2 -td@) because a compound SQL body carries `;` inside it. The `@` is the CLP's
+-- convention and must never reach ibm_db, which sends one statement per call.
+--
+-- Two schemas, because one cannot show that the object browser reads beyond CURRENT SCHEMA.
+-- Every object is schema-qualified: CURRENT SCHEMA decides name RESOLUTION, and relying on
+-- it to decide where a CREATE lands would make the fixture depend on the session.
+
+CREATE SCHEMA APP@
+CREATE SCHEMA REPORTING@
+
+CREATE TABLE APP.CUSTOMERS (
+ ID INTEGER NOT NULL,
+ NAME VARCHAR(100),
+ CONSTRAINT CUSTOMERS_PK PRIMARY KEY (ID)
+)@
+
+CREATE TABLE APP.ORDERS (
+ ID INTEGER NOT NULL,
+ CUSTOMER_ID INTEGER,
+ TOTAL DECIMAL(12, 2) DEFAULT 0,
+ NOTE VARCHAR(200),
+ CONSTRAINT ORDERS_PK PRIMARY KEY (ID),
+ CONSTRAINT ORDERS_CUSTOMER_FK FOREIGN KEY (CUSTOMER_ID) REFERENCES APP.CUSTOMERS (ID)
+)@
+
+CREATE INDEX APP.ORDERS_CUSTOMER_IX ON APP.ORDERS (CUSTOMER_ID, TOTAL)@
+
+-- Two rows each: an empty table cannot tell a statement the server accepted from one it
+-- rejected, and a single row cannot show that a row limit left the rows alone.
+INSERT INTO APP.CUSTOMERS VALUES (1, 'Ada'), (2, 'Grace')@
+INSERT INTO APP.ORDERS VALUES (1, 1, 10.50, 'first'), (2, 2, 20.00, NULL)@
+
+-- A name only a delimited identifier can spell, so quoting is exercised end to end.
+CREATE TABLE APP."Mixed Case" (ID INTEGER NOT NULL PRIMARY KEY)@
+
+-- REPORTING references APP, so a foreign key crosses a schema boundary.
+CREATE TABLE REPORTING.DAILY (
+ DAY DATE NOT NULL,
+ CUSTOMER_ID INTEGER NOT NULL,
+ CONSTRAINT DAILY_PK PRIMARY KEY (DAY, CUSTOMER_ID),
+ CONSTRAINT DAILY_CUSTOMER_FK FOREIGN KEY (CUSTOMER_ID) REFERENCES APP.CUSTOMERS (ID)
+)@
+
+CREATE VIEW APP.ORDER_SUMMARY AS
+ SELECT C.NAME, SUM(O.TOTAL) AS TOTAL
+ FROM APP.ORDERS O JOIN APP.CUSTOMERS C ON C.ID = O.CUSTOMER_ID
+ GROUP BY C.NAME@
+
+CREATE TABLE APP.ORDER_TOTALS AS (
+ SELECT CUSTOMER_ID, SUM(TOTAL) AS TOTAL, COUNT(*) AS N FROM APP.ORDERS GROUP BY CUSTOMER_ID
+) DATA INITIALLY DEFERRED REFRESH DEFERRED@
+REFRESH TABLE APP.ORDER_TOTALS@
+
+CREATE ALIAS APP.CLIENTS FOR APP.CUSTOMERS@
+
+CREATE SEQUENCE APP.ORDER_SEQ START WITH 100 INCREMENT BY 1@
+
+CREATE PROCEDURE APP.ADD_ORDER (IN P_ID INTEGER, IN P_CUSTOMER INTEGER, IN P_TOTAL DECIMAL(12, 2))
+LANGUAGE SQL
+BEGIN
+ INSERT INTO APP.ORDERS (ID, CUSTOMER_ID, TOTAL) VALUES (P_ID, P_CUSTOMER, P_TOTAL);
+END@
+
+-- Overloaded: one routine name, two signatures, two SPECIFIC names.
+CREATE FUNCTION APP.ORDER_TOTAL (P_ID INTEGER)
+RETURNS DECIMAL(12, 2)
+LANGUAGE SQL READS SQL DATA
+SPECIFIC APP.ORDER_TOTAL_BY_ID
+RETURN SELECT TOTAL FROM APP.ORDERS WHERE ID = P_ID@
+
+CREATE FUNCTION APP.ORDER_TOTAL (P_ID INTEGER, P_TAX DECIMAL(5, 2))
+RETURNS DECIMAL(12, 2)
+LANGUAGE SQL READS SQL DATA
+RETURN SELECT TOTAL * (1 + P_TAX) FROM APP.ORDERS WHERE ID = P_ID@
+
+CREATE TRIGGER APP.ORDERS_NOTE_DEFAULT
+NO CASCADE BEFORE INSERT ON APP.ORDERS
+REFERENCING NEW AS N
+FOR EACH ROW
+WHEN (N.NOTE IS NULL)
+ SET N.NOTE = 'none'@
+
+-- A trigger whose own schema is not its table's schema, which Db2 allows.
+CREATE TRIGGER REPORTING.ORDERS_AUDIT
+AFTER UPDATE ON APP.ORDERS
+FOR EACH ROW
+ UPDATE APP.CUSTOMERS SET NAME = NAME WHERE 1 = 0@
+
+-- A module holding a routine: its procedure is not a schema-level procedure.
+CREATE MODULE APP.ORDER_MOD@
+ALTER MODULE APP.ORDER_MOD PUBLISH PROCEDURE PING () LANGUAGE SQL BEGIN END@
+
+-- A view left invalid by dropping what it reads.
+CREATE TABLE APP.SCRATCH (ID INTEGER)@
+CREATE VIEW APP.SCRATCH_VIEW AS SELECT ID FROM APP.SCRATCH@
+DROP TABLE APP.SCRATCH@
+
+-- An EXTERNAL routine. Db2 keeps no SQL text for one (SYSCAT.ROUTINES.TEXT is NULL, ORIGIN
+-- 'E'), so its source read answers a refusal part rather than an empty editor. The library
+-- does not exist and does not need to: CREATE records the routine without loading it.
+CREATE FUNCTION APP.EXT_FN (X INTEGER) RETURNS INTEGER
+LANGUAGE C PARAMETER STYLE SQL NO SQL DETERMINISTIC
+EXTERNAL NAME 'nolib!nofn'@
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 92bdaf3e7..3bed4e6b6 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 **17 database backends** via a Strategy Pattern abstraction: PostgreSQL, MySQL, SQLite, libSQL, DuckDB, Oracle, SQL Server, MongoDB, Couchbase, ClickHouse, Apache Druid, Apache Trino, Apache Cassandra, Elasticsearch, OpenSearch, Redis, LibreDB. The count is the `SHIPPED` record in [`src/lib/db/compatibility.ts`](../src/lib/db/compatibility.ts), which is exhaustive over `DatabaseType`; `elasticsearch` and `opensearch` are two ids served by one provider module.
+LibreDB Studio is a hybrid, cloud-native database management tool that provides an IDE-like experience in the browser. It supports **18 database backends** via a Strategy Pattern abstraction: PostgreSQL, MySQL, SQLite, libSQL, DuckDB, Oracle, SQL Server, IBM Db2 LUW, MongoDB, Couchbase, ClickHouse, Apache Druid, Apache Trino, Apache Cassandra, Elasticsearch, OpenSearch, Redis, LibreDB. The count is the `SHIPPED` record in [`src/lib/db/compatibility.ts`](../src/lib/db/compatibility.ts), which is exhaustive over `DatabaseType`; `elasticsearch` and `opensearch` are two ids served by one provider module.
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).
@@ -47,6 +47,7 @@ graph TD
SQL --> SQLite[(SQLite)]
SQL --> Oracle[(Oracle)]
SQL --> MSSQL[(SQL Server)]
+ SQL --> Db2[(IBM Db2 LUW)]
SQL --> ClickHouse[(ClickHouse)]
SQL --> Druid[(Apache Druid)]
SQL --> Search[(Elasticsearch / OpenSearch)]
@@ -110,6 +111,7 @@ classDiagram
SQLBaseProvider <|-- SQLiteProvider
SQLBaseProvider <|-- OracleProvider
SQLBaseProvider <|-- MSSQLProvider
+ SQLBaseProvider <|-- Db2Provider
SQLBaseProvider <|-- ClickHouseProvider
SQLBaseProvider <|-- DruidProvider
SQLBaseProvider <|-- SearchProvider
@@ -262,7 +264,7 @@ src/
└── lib/
├── db/ # Database provider module
│ ├── providers/
- │ │ ├── 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), search/ (transport seam + SQL over HTTP; elasticsearch and opensearch, two ids one module), trino/ (transport seam + SQL over the Trino client protocol), cassandra/ (transport seam + CQL over the native protocol via cassandra-driver), libsql/ (transport seam + SQLite's dialect over the Hrana protocol), duckdb/ (driver seam + an embedded analytical engine over @duckdb/node-api)
+ │ │ ├── sql/ # postgres, mysql, sqlite (+ sqlite-driver runtime adapter), oracle, mssql, db2 (DRDA over the ibm_db native driver), clickhouse/ (transport seam + SQL over HTTP), druid/ (transport seam + SQL over POST /druid/v2/sql), search/ (transport seam + SQL over HTTP; elasticsearch and opensearch, two ids one module), trino/ (transport seam + SQL over the Trino client protocol), cassandra/ (transport seam + CQL over the native protocol via cassandra-driver), libsql/ (transport seam + SQLite's dialect over the Hrana protocol), duckdb/ (driver seam + an embedded analytical engine over @duckdb/node-api)
│ │ ├── document/ # mongodb, couchbase/ (transport seam + SQL++ over REST)
│ │ ├── keyvalue/ # redis
│ │ └── embedded/ # libredb (built-in embedded provider for the sample connection)
diff --git a/docs/BRAND_MESSAGING.md b/docs/BRAND_MESSAGING.md
index b96be8e1f..382e19ebd 100644
--- a/docs/BRAND_MESSAGING.md
+++ b/docs/BRAND_MESSAGING.md
@@ -59,7 +59,7 @@ One umbrella claim, three entry doors into it, three assurance layers underneath
**The three doors** are three ways into that one claim. A campaign picks a door. It does not argue all three at once, because a piece that opens on three arguments has opened on none.
1. You created the database. The editor is already beside it.
-2. One tab, sixteen databases.
+2. One tab, seventeen databases.
3. Nothing sits behind an Enterprise wall.
**Door 3 is deliberately third, and stays third.** Leading with it would define LibreDB as another company's opponent rather than as a position of its own, and it would put our credibility at the mercy of their pricing page. Third, the same fact reads as reassurance rather than accusation.
@@ -80,12 +80,12 @@ Each door carries five parts. A promise whose proof does not resolve to a row in
This is the sharpest of the three. It is the one claim no competitor can currently make.
-### Door 2 — One tab, sixteen databases.
+### Door 2 — One tab, seventeen databases.
- **Audience:** teams running more than one kind of database, and the engineers who join them.
- **Pain:** four databases, four clients, four sets of credentials, and a connection-string hunt for anyone new.
-- **Promise:** sixteen engines in one interface, with the same exploration everywhere, and ER diagrams, schema diff and monitoring wherever the engine has something to show. (Not "across all of them": a search cluster declares no foreign keys, so its ER diagram has no edges, and item 4 below forbids the sentence that hides that.)
-- **Proof:** sixteen providers, each with its own reference document under `docs/providers/`.
+- **Promise:** seventeen engines in one interface, with the same exploration everywhere, and ER diagrams, schema diff and monitoring wherever the engine has something to show. (Not "across all of them": a search cluster declares no foreign keys, so its ER diagram has no edges, and item 4 below forbids the sentence that hides that.)
+- **Proof:** seventeen providers, each with its own reference document under `docs/providers/`.
- **Difference:** CloudBeaver Community bundles 18 driver modules and every one of them is SQL. MongoDB and Redis are not among them.
The claim here is the span, never the count. See the honesty limits.
@@ -124,7 +124,7 @@ Facts drift. Provider counts, channel counts and competitor editions all change,
| Claim | Evidence | Source | Verified |
| :--- | :--- | :--- | :--- |
-| Sixteen database engines | One reference document per engine: PostgreSQL, MySQL, Oracle, SQL Server, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Druid, Elasticsearch, OpenSearch, Apache Trino, Apache Cassandra. A seventeenth, `libredb.md`, is the embedded provider and is not an external engine. The count is derived, not written: `SHIPPED` in `src/lib/db/compatibility.ts` is an exhaustive record over `DatabaseType`, so the compiler refuses a missing id — read the count from there, minus `libredb` | `docs/providers/`, `src/lib/db/compatibility.ts` | 2026-08-20 |
+| Seventeen database engines | One reference document per engine: PostgreSQL, MySQL, Oracle, SQL Server, IBM Db2 LUW, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Druid, Elasticsearch, OpenSearch, Apache Trino, Apache Cassandra. An eighteenth, `libredb.md`, is the embedded provider and is not an external engine. The count is derived, not written: `SHIPPED` in `src/lib/db/compatibility.ts` is an exhaustive record over `DatabaseType`, so the compiler refuses a missing id — read the count from there, minus `libredb` | `docs/providers/`, `src/lib/db/compatibility.ts` | 2026-08-20 |
| Published as an embeddable npm package | `"name": "@libredb/studio"`, version 0.14.1 | `package.json` | 2026-09-07 |
| MIT licensed | "MIT License / Copyright (c) 2025 LibreDB" | `LICENSE` | 2026-08-07 |
| 36 distribution channels, 30 live | "36 channels · 30 live · 5 pending · 1 deprecated" | `docs/CHANNELS.md` | 2026-09-07 |
diff --git a/docs/DATABASE_PROVIDERS.md b/docs/DATABASE_PROVIDERS.md
index 3b0f152c1..fc328728d 100644
--- a/docs/DATABASE_PROVIDERS.md
+++ b/docs/DATABASE_PROVIDERS.md
@@ -39,6 +39,7 @@ src/lib/db/
│ │ │ └── values.ts # result -> QueryResult and DuckDB type text -> the product's own names
│ │ ├── oracle.ts # Oracle Strategy
│ │ ├── mssql.ts # SQL Server Strategy
+│ │ ├── db2.ts # IBM Db2 LUW Strategy (DRDA over the ibm_db native driver)
│ │ ├── clickhouse/ # ClickHouse Strategy (SQL over HTTP, no driver)
│ │ │ ├── index.ts # ClickHouseProvider
│ │ │ ├── transport.ts # ClickHouseTransport seam + neutral result types
@@ -93,6 +94,7 @@ BaseDatabaseProvider (abstract)
│ ├── DuckDBProvider │
│ ├── OracleProvider │
│ ├── MSSQLProvider │
+│ ├── Db2Provider │
│ ├── ClickHouseProvider │
│ ├── DruidProvider │
│ ├── ElasticsearchProvider │
@@ -159,7 +161,7 @@ QueryEditor /api/db/query
## Supported Databases
-Seventeen type-ids are supported by sixteen provider modules — `elasticsearch` and `opensearch` share
+Eighteen type-ids are supported by seventeen provider modules — `elasticsearch` and `opensearch` share
one, `providers/sql/search/`. The count is derived from the exhaustive `SHIPPED` record in
[`src/lib/db/compatibility.ts`](../src/lib/db/compatibility.ts) rather than written here twice. For
the per-provider reference (driver, pooling, query format,
@@ -171,6 +173,7 @@ monitoring, limitations, …) see the prime docs in **[`docs/providers/`](./prov
| MySQL | `mysql` | SQL | [providers/mysql.md](./providers/mysql.md) |
| Oracle | `oracle` | SQL | [providers/oracle.md](./providers/oracle.md) |
| Microsoft SQL Server | `mssql` | SQL | [providers/mssql.md](./providers/mssql.md) |
+| IBM Db2 LUW | `db2` | SQL | [providers/db2.md](./providers/db2.md) |
| SQLite | `sqlite` | SQL (embedded) | [providers/sqlite.md](./providers/sqlite.md) |
| libSQL | `libsql` | SQL (SQLite over a network) | [providers/libsql.md](./providers/libsql.md) |
| DuckDB | `duckdb` | SQL (embedded, analytical) | [providers/duckdb.md](./providers/duckdb.md) |
@@ -361,7 +364,7 @@ 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, libSQL, DuckDB, Redis, MongoDB, Couchbase, ClickHouse, Apache Druid,
+Oracle, SQL Server, IBM Db2 LUW, SQLite, libSQL, DuckDB, Redis, MongoDB, Couchbase, ClickHouse, Apache Druid,
Elasticsearch, OpenSearch, Apache Trino, Apache Cassandra, or LibreDB.
Not every provider has every feature, and the docs record the absences rather than glossing over
diff --git a/docs/providers/README.md b/docs/providers/README.md
index af0366941..988f77f15 100644
--- a/docs/providers/README.md
+++ b/docs/providers/README.md
@@ -10,6 +10,7 @@ in lockstep with the code (see the tri-sync rule in [`../../CLAUDE.md`](../../CL
| MySQL | `mysql` | SQL | `mysql2` | SQL | [mysql.md](./mysql.md) |
| Oracle | `oracle` | SQL | `oracledb` (Thin) | SQL | [oracle.md](./oracle.md) |
| Microsoft SQL Server | `mssql` | SQL | `mssql` | SQL (T-SQL) | [mssql.md](./mssql.md) |
+| IBM Db2 LUW | `db2` | SQL | `ibm_db` (native addon) | SQL | [db2.md](./db2.md) |
| SQLite | `sqlite` | SQL (embedded) | `bun:sqlite` (Bun) / `node:sqlite` (Node) | SQL | [sqlite.md](./sqlite.md) |
| libSQL | `libsql` | SQL (SQLite over a network) | none (HTTP: the Hrana protocol, `POST /v2/pipeline`) | SQL (SQLite) | [libsql.md](./libsql.md) |
| DuckDB | `duckdb` | SQL (embedded, analytical) | `@duckdb/node-api` (native N-API addon) | SQL (DuckDB) | [duckdb.md](./duckdb.md) |
@@ -258,6 +259,7 @@ provider's integration pass.
| MySQL | `mysql` | localhost | 3306 | `root` | `root` | `mysql` | — |
| Oracle | `oracle` | localhost | 1521 | `system` | `Password123!` | `XEPDB1` (service name) | — |
| SQL Server | `mssql` | localhost | 1433 | `sa` | `Password123!` | `master` | — |
+| IBM Db2 LUW | `db2` | localhost | 50000 | `db2inst1` | `Password123!` | `testdb` | privileged container, `LICENSE=accept`, slow first boot |
| MongoDB | `mongodb` | localhost | 27017 | `admin` | `admin` | any; auth source `admin` | — |
| Redis | `redis` | localhost | 6379 | *none* | *none* | *none* (db index 0) | — |
| Couchbase | `couchbase` | localhost | 8091 | `Administrator` | `password123` | `travel` (bucket) | — |
@@ -304,6 +306,10 @@ from the running container instead:
- **SQL Server** sets `MSSQL_DATABASE: mssql`, which the official image ignores; it creates no
database. `master` is what the fixture guarantees (verified: `SELECT name FROM sys.databases`), and
a `shop` database appears only once an E2E seed has run.
+- **IBM Db2 LUW** needs a privileged container and `LICENSE=accept`, and its first boot is slow
+ (minutes) while the instance and the `testdb` database are created — the healthcheck's
+ `start_period` and retry budget cover that. The exact boot time and the live-pass findings are
+ not measured yet; this row records the fixture, not a verified probe.
**The two search services share a port inside the container.** OpenSearch publishes **9201** on the
host because both products ship on 9200 and the `elasticsearch` service claims it; the provider's own
diff --git a/docs/providers/db2.md b/docs/providers/db2.md
new file mode 100644
index 000000000..1c33a9132
--- /dev/null
+++ b/docs/providers/db2.md
@@ -0,0 +1,570 @@
+# IBM Db2 for Linux, UNIX and Windows (Db2 LUW) Provider
+
+> Db2 LUW support for LibreDB Studio, built on the [`ibm_db`](https://github.com/ibmdb/node-ibm_db)
+> native driver. This is the prime reference for the `db2` type-id: the cross-cutting architecture
+> and the step-by-step authoring guide live in [`../DATABASE_PROVIDERS.md`](../DATABASE_PROVIDERS.md)
+> and [`../ADDING_A_PROVIDER.md`](../ADDING_A_PROVIDER.md).
+
+> **Status: verified against a live server (gate-4 complete).** The live pass ran against Db2
+> v11.5.9.0 and v11.5.x; items proven there are marked "measured". The
+> pass also surfaced and fixed one bug — `query()` originally dropped its bound-parameter argument,
+> which broke inline row edit (`CLI0100E Wrong number of parameters`); see §3.8 and §9. A few
+> decisions (`supportsExplain`, the standalone-safe type names in the export layer) remain
+> deliberately conservative and are called out as follow-ups rather than gaps.
+
+---
+
+## 1. Overview
+
+Db2 LUW is a proprietary relational engine reached over the **DRDA binary protocol** over TCP. Unlike
+the driver-free HTTP providers (ClickHouse, Druid, Trino, the search pair, Couchbase, libSQL), a
+browser cannot talk to it and neither can `fetch`, so it carries a native driver. It maps onto the
+`DatabaseProvider` interface like the other SQL providers, via the `ibm_db` package, and extends
+[`SQLBaseProvider`](../../src/lib/db/providers/sql/sql-base.ts).
+
+Its SQL is standard-shaped: double-quoted identifiers and `FETCH FIRST n ROWS ONLY` /
+`OFFSET n ROWS FETCH NEXT n ROWS ONLY` pagination are both correct Db2, exactly as they are on
+Oracle 12c+. So identifier escaping is **inherited unchanged** from `SQLBaseProvider`, and the only
+dialect override is `prepareQuery()` — the same shape as the Oracle provider.
+
+Type-id: `db2`. Default port: **50000** (the conventional DRDA listener port). Query language: `sql`.
+
+### Why a driver, and why `ibm_db`
+
+Db2 LUW does expose a first-class HTTP path — the **Db2 REST service**'s `/v1/services/execsql`
+endpoint runs arbitrary SQL and returns JSON. It was weighed and rejected for v1 for two reasons: the
+REST service is a **separately deployed container** that is not listening on a standard Db2 by
+default, and `execsql` (arbitrary SQL) is the endpoint security-conscious sites most often disable.
+An IDE that must reach any standard Db2 the way DBeaver or DataGrip do therefore uses the driver. The
+REST transport remains a possible future addition behind the same provider (see §8).
+
+`ibm_db` is a **native N-API addon** whose install step downloads or compiles the IBM CLI/ODBC
+driver. This is the material cost of the provider and it is stated plainly rather than hidden — it is
+the same category as DuckDB's `@duckdb/node-api` (see [`duckdb.md`](./duckdb.md)):
+
+- **It grows every distribution channel.** The addon and its CLI driver land in the Docker image, the
+ Snap/AppImage/Flatpak/deb/rpm channels, and the `@libredb/studio` npm package that
+ libredb-platform consumes.
+- **Its install can fail in an air-gapped or egress-restricted network**, because the postinstall
+ fetches the CLI driver from IBM. `ibm_db` is listed in `package.json` `trustedDependencies` so its
+ postinstall runs under Bun.
+- **It is a compiled addon that needs Node, not Bun.** Measured with Bun 1.4.2 on 2026-09-15: the
+ module loads, and the first `ibm_db.open()` panics the whole process with
+ `unsupported uv function: uv_default_loop`. Every shipped entry point runs the server under Node
+ (`next dev`/`next start`, the Docker image, and `bin/studio.js`, whose `#!/usr/bin/env node` shebang
+ is load-bearing for the same reason it is for `better-sqlite3`), so only a caller who asks for
+ `bunx --bun` reaches it. The mock-based suite runs under Bun and never loads the addon.
+
+The trade the driver-free providers make — you own the pooling, failover and retry the driver would
+otherwise write — applies here too, and this provider makes the editor's choice: it holds **one**
+connection and serialises queries onto it, with no pool, failover or retry. Acceptable for an
+interactive editor; not for a high-throughput application.
+
+---
+
+## 2. Architecture
+
+Same Strategy-Pattern hierarchy as the other SQL providers:
+
+```
+BaseDatabaseProvider (abstract)
+└── SQLBaseProvider (abstract) ← identifier escaping, LIMIT helpers, read-only detection
+ └── Db2Provider ← src/lib/db/providers/sql/db2/index.ts
+ (catalog statements and row mapping in db2/objects.ts)
+```
+
+The `ibm_db` surface the provider touches (`open` → a connection with `query`/`close`) is declared
+**locally** in the provider file rather than imported, because the package's published typings do not
+describe a promise API and are incomplete. The provider wraps each callback in a promise at that
+boundary.
+
+---
+
+## 3. Design decisions
+
+### 3.1 One connection, no pool
+
+`ibm_db` has connection-pool helpers, but this provider holds a single connection and serialises onto
+it, the same way the editor's other single-purpose reads work. A pool is future work if a Db2
+workload ever needs it; the transport seam is the connection object, so adding one is contained.
+
+### 3.2 `prepareQuery()` mirrors Oracle
+
+Db2 spells a first page `FETCH FIRST n ROWS ONLY` and a later page
+`OFFSET n ROWS FETCH NEXT n ROWS ONLY` — byte-identical to Oracle. The override therefore copies
+Oracle's: it builds on the statement's own text via `readStatementEnd()` and **declines** (reports
+`wasLimited: false`) when the tail cannot be safely cut, so a clause is never appended inside a
+trailing comment (the #280 hazard). It only rewrites a `SELECT` that does not already carry a bound.
+
+### 3.3 The object surface reads `SYSCAT.*` across every schema (#789)
+
+The object browser is not confined to `CURRENT SCHEMA`.
+See §6.1 for the containers, kinds and addresses, and for every catalog fact they rest on.
+
+### 3.4 `columnTypes` is omitted, deliberately
+
+`ibm_db`'s high-level `query()` surface hands back row objects keyed by column name and does **not**
+expose a declared per-column type name. Rather than guess one, the provider derives `fields` from the
+first row's keys and **omits** `QueryResult.columnTypes`. Absence is the signal the grid reads
+(issue #273) — an omitted field is treated correctly, an empty object would not be. Reading declared
+types through the driver's lower-level prepared-statement `describeColumns`/`getColumnMetadata`
+surface is a verified follow-up, gated on a live pass.
+
+### 3.5 REORG-pending is the one Db2-specific migration hazard
+
+Db2 places a table in **REORG-pending** state after certain structural `ALTER`s. Verified against
+IBM's documentation ("a table is placed in reorg pending mode if you alter its physical structure,
+such as add or drop a column or change the column data type or nullability"), the triggers this
+provider's migrations account for are:
+
+- **DROP COLUMN** — a removed column;
+- **ALTER COLUMN … SET DATA TYPE** — a modified column whose type changed;
+- **SET / DROP NOT NULL** — a modified column whose nullability changed.
+
+What does **not** trigger it, and is deliberately excluded so the advisory is not a false positive:
+a plain **ADD COLUMN** (immediate on Db2 LUW — this is the z/OS-vs-LUW difference: some z/OS ADD forms
+go AREOR, LUW's do not), a **DEFAULT-only change** (`SET`/`DROP DEFAULT` with no type or nullability
+change is metadata-only), and **index/foreign-key** changes.
+
+The table becomes read-restricted (`SQL0668N` reason code 7) until a `REORG TABLE` materializes the
+change. No other engine here behaves this way; the rest apply these changes synchronously.
+
+Two nuances from IBM's "Multiple ALTER TABLE operations within a single unit of work" page, both
+operational rather than generatable, so the advisory does not try to model them and the DBA owns
+them: (1) the state is **not immediate per statement** — Db2 LUW lets an unlimited number of these
+ALTERs run across up to **~31 units of work** before a `REORG TABLE` is *forced*; (2) since
+**10.5.0.5**, once a table is already reorg-pending a column's data type may be altered **only once**
+before a REORG is required. And the block is partial, not total: in reorg-pending "**many types of
+queries cannot be run**" until the table is reorganized, not that the table is entirely inaccessible.
+Because a migration this generator writes is one file a DBA applies once, a single trailing REORG
+advisory per table is the correct guidance regardless of either threshold.
+
+The schema-diff **migration generator** ([`migration-generator.ts`](../../src/lib/schema-diff/migration-generator.ts))
+handles this by emitting a **commented advisory** — not an executable statement — once per affected
+table, after the ALTERs it refers to:
+
+```sql
+-- Db2: the ALTERs above may leave "orders" in REORG-pending, where many query types are blocked until it is reorganized.
+-- Run this OUTSIDE the transaction above, and mind that it can be slow and lock-heavy on a large table:
+-- CALL SYSPROC.ADMIN_CMD('REORG TABLE "orders"');
+```
+
+It is a comment rather than a live statement for two reasons, both load-bearing: a `REORG` can be
+**very slow and lock-heavy** in proportion to table size, so a migration file must not run one
+unattended; and a `REORG` **cannot run inside the `BEGIN;`/`COMMIT;` unit of work** the generator
+wraps DDL in (`ADMIN_CMD` commits internally). This follows the generator's established pattern for
+anything it cannot emit as both correct and safe to run blindly (compare `NO_PORTABLE_INDEX_DDL`'s
+"write the index change by hand" and SQLite's "Requires table recreation"). A single `REORG` clears
+any number of changes batched in one unit of work, so one advisory per table is emitted rather than
+one per column.
+
+Note the asymmetry with the **explicit maintenance action**: the admin Operations tab's "Reorganize
+Table" runs a real `REORG` (§7), because that is a user clicking a button on one table they chose, not
+a file applied unattended — the same posture SQL Server's "Rebuild Indexes" takes for its own
+potentially-slow operation.
+
+### 3.6 Capability honesty
+
+`getCapabilities()` declares only what works, because a flag that is `true` but cannot work produces a
+control that only emits invalid input (the defect class #194/#201 were about):
+
+| Capability | Value | Why |
+|---|---|---|
+| `supportsExplain` | `false` | Db2 EXPLAIN populates the explain tables (`EXPLAIN_STATEMENT` et al.) rather than returning a plan from one statement, which the single-statement explain path cannot express — the same posture as Oracle and SQL Server (#126). Adding it later is additive: a new `ExplainFormat` union member, a strategy, and flipping this flag. |
+| `supportsCreateTable` | `true` (inherited) | Db2 has full `CREATE TABLE`. |
+| `supportsInlineRowEdit` | `true` | Db2 accepts the single-table `UPDATE SET
= WHERE = ` the results grid builds. |
+| `supportsTransactions` | `false` | This provider holds no interactive-transaction session wired to `POST /api/db/transaction` yet, so the toolbar trio and SANDBOX stay hidden rather than offer a control the route would refuse. Db2 has transactions; this is a statement about the provider's surface, exactly as SQLite's `false` is. Future work. |
+| `declaresForeignKeys` | `true` (inherited) | Db2 has referential constraints. |
+| `maintenanceOperations` | `["analyze", "optimize"]` | `analyze` → `RUNSTATS`, `optimize` → `REORG TABLE`. `check`, a SQL-reachable `kill`, and index rebuild are left off the initial set rather than claimed unverified; widen after the live pass. |
+
+### 3.7 Other Db2 table states that can block a query (provider context)
+
+REORG-pending (§3.5) is the one state the **migration generator** must account for, because the DDL
+it writes causes it. But Db2 LUW has a wider family of table/table-space states that make a table
+partially or fully inaccessible, and the **provider** can meet any of them at query time — they are
+caused by **utilities and constraints**, not by our DDL, so there is nothing to generate for them.
+They are recorded here so the behaviour is understood rather than surprising:
+
+- **Set Integrity Pending** (historically "Check Pending"): a table with constraints or a dependent
+ materialized query table can enter this after a `LOAD`, or after `SET INTEGRITY … OFF`, until
+ `SET INTEGRITY` re-validates it. Referential and check constraints are unchecked meanwhile.
+- **Load Pending / Load in Progress**: a `LOAD` that is running, or that failed/was interrupted,
+ leaves the table transiently or persistently unusable until the load is completed or terminated.
+- **Restore Pending / Rollforward Pending**: recovery states after certain backup/restore or
+ roll-forward sequences.
+- **Table-space states** generally (`SYSIBMADM.ADMINTABINFO`, `LOAD QUERY`, or
+ `db2 get snapshot`): backup-pending, restore-pending, and others.
+
+The common thread for the provider is the **`SQL0668N`** family (SQLSTATE `57016` / `57007`), whose
+reason code names the specific state (reason code 7 is REORG-pending). The provider does **not** try
+to pre-empt these — it runs the user's statement and surfaces whatever the engine returns through
+`mapDatabaseError(err, 'db2', sql)`, so a blocked table reads as an error with Db2's own message
+rather than as an empty result. A Db2-specific error branch that decodes the `SQL0668N` reason code
+into a plain-language hint ("run REORG TABLE", "run SET INTEGRITY") is a possible future refinement,
+gated on seeing the exact driver error text on a live pass (§9); it is deliberately not guessed here.
+
+### 3.8 Transaction and value semantics (autocommit, isolation, LOBs)
+
+The runtime behaviours that most often surprise on a new SQL provider, and where this one stands:
+
+- **Autocommit is ON.** `ibm_db` opens every connection in autocommit mode by default (IBM: "By
+ default, the ibm_db API opens every connection in autocommit mode"), so each editor statement is
+ its own complete transaction and nothing is left uncommitted between runs. This is the right
+ default for an interactive editor and matches the posture the Oracle provider sets explicitly
+ (`oracledb.autoCommit = true`). It is also consistent with `supportsTransactions: false` (§3.6):
+ there is no held session, so there is no half-open transaction to worry about. If an
+ interactive-transaction session is added later, it will turn autocommit off for that one held
+ connection only.
+
+- **Isolation level: Db2's default, Cursor Stability (CS).** Read-committed-like, which is the
+ correct default for browsing and querying; the provider sets no override. A future transaction
+ session could expose the level, but the editor's read path does not need to.
+
+- **CLOB → string, measured.** Verified against Db2 v11.5.9.0 (`ibm_db` 4.0.1): `query()`
+ materializes a CLOB into the row object as a string — it does not hand back the CLI LOB *locator* —
+ so a CLOB serializes cleanly to the grid, CSV and SQL export with no special handling, unlike
+ Oracle, whose driver returns a `Lob` stream this repo had to convert (see [`oracle.md`](./oracle.md)
+ and `oracle.ts`'s `lobFetchTypeHandler`). No equivalent handler is needed for Db2 CLOBs. A very
+ large CLOB could be bound to a file with the driver's `bindFileToCol` if the inline value ever
+ proves impractical, but nothing in the editor path needs that.
+
+- **BLOB → `Buffer`, measured — no normalization needed.** This was an open question (an older
+ `ibm_db` issue, [#860](https://github.com/ibmdb/node-ibm_db/issues/860) from 2022, reported a BLOB
+ coming back as a string). It is resolved in the pinned driver: verified against Db2 v11.5.9.0 with
+ `ibm_db` 4.0.1, a `BLOB(X'0102DEADBEEF')` round-trips as a real Node `Buffer`
+ (`{"type":"Buffer","data":[1,2,222,173,190,239]}`), which is exactly the shape the product's binary
+ contract (`asBytes`/`binaryText` in `src/lib/export/binary.ts`) accepts — the same representation
+ the `pg` driver produces for `bytea`. So a BLOB renders, previews and exports with no
+ provider-boundary work. The JDBC driver's clean `java.sql.Blob` handling was never evidence for
+ `ibm_db` either way; the driver itself was measured.
+
+- **DECFLOAT / INTEGER → number, and BIGINT → string, measured.** Against Db2 v11.5.x with `ibm_db`
+ 4.0.1: `DECIMAL`, `DECFLOAT`, `REAL`, `DOUBLE` and `INTEGER` come back as JS numbers, exact. The
+ integer caution the guide raises for every engine is **settled here rather than deferred**: a
+ `BIGINT` of `9223372036854775807` (2^63−1, well past `Number.MAX_SAFE_INTEGER`) came back as the JS
+ **string** `"9223372036854775807"` — so the driver preserves it losslessly the way `pg` returns
+ `int8`, and no widening or precision fix is needed. (`SYSCAT.TABLES.CARD` likewise arrives as a
+ string, which the listing reads through `Number(...)`.)
+
+- **Db2-only types, measured.** `VARGRAPHIC` → string, `XML` → its serialized text
+ (`…`), and `DATE`/`TIME`/`TIMESTAMP` → strings — all render sanely in the grid and
+ export. One expected quirk worth stating so it is not filed as a bug: a fixed-width **`CHAR(n)`
+ comes back space-padded to its width** (`CHAR(5)` of `'abc'` → `"abc "`). That is correct Db2/SQL
+ semantics — the stored value *is* padded — and it matches how other SQL clients show `CHAR`; the
+ provider does not trim it, because trimming would hide real data and break equality against a
+ padded key.
+
+---
+
+## 4. Connection
+
+### 4.1 Configuration
+
+Field-based: `host`, `port` (default 50000), `user`, `password`, `database`. The provider builds the
+DRDA connection string `ibm_db.open` expects — a semicolon-delimited `KEY=VALUE;` attribute list:
+
+```
+DATABASE=;HOSTNAME=;PORT=;PROTOCOL=TCPIP;UID=;PWD=;
+```
+
+A **pasted connection string** (the modal's connection-string toggle is on) is passed to the driver
+**unchanged**, so a user's own attributes (`SECURITY`, `Authentication`, …) decide — the same rule the
+Oracle and SQL Server providers follow. The `db2://host:port/database` URI scheme parses into the
+host/port/database fields (`connection-string-parser.ts`); it is the scheme common ORMs/tools emit,
+since Db2's own canonical form is the attribute list rather than a URI.
+
+`validate()` requires a `host` and a `database` unless a connection string is supplied.
+
+**Delimiter guard.** The attribute list has no escaping for its `;` separator, and the CLI driver
+honours no brace/quote form (measured: `UID={value}` is taken literally). A field value containing `;`
+would therefore split into extra attributes — a password `pa;ss` misparses so auth fails on `pa`, and a
+crafted value can INJECT an attribute (`PWD=x;SECURITY=NONE` was shown to connect). So `validate()`
+refuses a `;` in any field it interpolates (`host`, `database`, `user`, `password`) and points the user
+at the connection-string field, which they own end to end and which is passed through unchanged.
+
+### 4.2 TLS
+
+When the SSL mode is not `disable`, `SECURITY=SSL` is added to the attribute list — Db2's own switch
+for TLS on the wire. This is the audited shape of the attribute list, **not** a verified TLS path: the
+compose fixture (§9) speaks plaintext, so the TLS handshake is not exercised until a live pass against
+a TLS-configured server. Kerberos and client-certificate auth are out of scope for v1.
+
+---
+
+## 5. Query format
+
+Ordinary SQL in the `sql` field; the SQL editor and the shared query limiter apply. Positional
+parameters are `?` (`positionalPlaceholder('db2', …)` → `?`), the same as MySQL/SQLite/Druid. String
+literals use standard escaping (doubled single quote, backslash is data). The grammar reads under the
+compatibility default (no `#` line comment, `"..."` identifiers rather than `[...]`, non-nesting block
+comments, no `q'...'`) — see [`grammar.ts`](../../src/lib/sql/grammar.ts); confirm on the live pass.
+
+**Bound `?` parameters are forwarded to the driver, measured.** `query(sql, params)` passes the values
+array straight to `ibm_db`'s `conn.query(sql, params, cb)` so a `?`-marked statement binds
+positionally; a call with no params uses the two-arg `conn.query(sql, cb)` form, because `ibm_db`
+reads a function in the params slot as the callback and an empty array against a marker-less statement
+can raise `CLI0100E Wrong number of parameters`. This is the path inline row edit relies on: the grid
+sends `UPDATE t SET "col" = ? WHERE "id" = ?` with a separate values array (issue #290), verified
+end-to-end against Db2 v11.5.9.0 — the edited value persists on re-query. (This corrects a first-cut
+bug where `query()` ignored its params argument and every bound statement failed with `CLI0100E`.)
+
+---
+
+## 6. Schema and monitoring
+
+The object surface is real (§6.1), and so is most of the monitoring set. Db2's live data lives in the
+`MON_GET_*` table functions and `SYSIBMADM.*` administrative views. These are **permission-gated**
+(they need SYSMON authority or an explicit grant), so every read is wrapped to return empty on refusal
+rather than throw — a locked-down account degrades to blank panels while a monitoring-authorized
+account (measured on Db2 v11.5.9.0) gets real figures:
+
+- `getOverview()` reads the service level from `SYSPROC.ENV_GET_INST_INFO()` for the version, the live
+ connection count from `MON_GET_CONNECTION`, the database-wide table/index counts from `SYSCAT.TABLES`
+ and `SYSCAT.INDEXES`, uptime from the database activation time (`MON_GET_DATABASE.DB_CONN_TIME`), and
+ the database size as the sum of used tablespace bytes (`MON_GET_TABLESPACE`, the same read the Storage
+ panel uses). Each gated read is independent: a denied one leaves its own field neutral (`"Unknown"`
+ version, `"N/A"` uptime/size, absent `activeConnections`) rather than failing the whole overview.
+ `maxConnections` is the configured `maxappls` ceiling from `SYSIBMADM.DBCFG` (Db2's per-database
+ concurrent-application limit; the DBM-level `max_connections` is often `-1`/automatic), left `0` when
+ that config read is refused.
+- `getPerformanceMetrics()` / `getHealth()` report a **cache hit ratio** and **deadlocks**. The hit
+ ratio comes from `SYSIBMADM.BP_HITRATIO` (summed `(logical − physical) / logical` across buffer
+ pools). That snapshot view is used rather than `MON_GET_BUFFERPOOL` on purpose: the `MON_GET_*` read
+ counters only accumulate when the database's `mon_obj_metrics` config is on (measured `NONE` on a live
+ catalog, where every counter read 0), whereas `BP_HITRATIO` reports real reads regardless. The ratio
+ is still omitted (never zeroed) on a refused read or a database with no reads at all. Deadlocks come
+ from `MON_GET_DATABASE.DEADLOCKS` and keep a measured `0` (a real fact), omitted only on refusal.
+- Uptime (in `getOverview`) is computed **inside the database** as elapsed seconds from
+ `MON_GET_DATABASE.DB_CONN_TIME` against `CURRENT_TIMESTAMP`, not by parsing the activation timestamp
+ in Node: `DB_CONN_TIME` carries no timezone, so subtracting it from a JS `Date.now()` produced a
+ negative uptime whenever the server and app clocks differed (a UTC server read as local time). A
+ negative or unreadable value falls back to `"N/A"`.
+- `getActiveSessions()` lists live connections from `MON_GET_CONNECTION` (handle, auth id, application
+ name, client address). Db2 exposes no per-connection "current statement" or state column on this
+ surface the way PostgreSQL's `pg_stat_activity` does, so `state` is reported as `"active"` (the row
+ exists because the connection is live) and `query` is left empty rather than invented.
+- `getSlowQueries()` returns the costliest statements from the package cache
+ (`MON_GET_PKG_CACHE_STMT`): statement text, execution count, total activity time, and a per-row
+ average. It is a cache snapshot, not an exhaustive history — a statement evicted from the cache is
+ not listed. **Timings depend on the database's `mon_req_metrics`/`mon_act_metrics` config.** With
+ metrics off (the default on many installs), Db2 records execution *counts* but not *times*, so every
+ statement reports `NUM_EXEC_WITH_METRICS = 0` and a `0` time; the query filters those rows out, so the
+ panel shows real slow queries when the server collects timings and its empty state — rather than a
+ list of misleading `0.00 ms` rows — when it does not. Enabling metrics (`UPDATE DB CFG … USING
+ mon_req_metrics BASE`) is a DBA action the app does not take.
+- `getStorageStats()` returns per-tablespace sizing from `MON_GET_TABLESPACE` (used pages × page size
+ for bytes, used/total for fill percentage). This is the real, cheap storage view and populates the
+ Storage tab's tablespace list. The tab's separate "Storage Breakdown" (a Table-Data vs Indexes split)
+ stays "N/A": Db2 tablespaces are typed `LARGE`/`ANY`/`*TEMP`, not data-vs-index, so the split cannot
+ come from tablespaces, and the only per-object source, `SYSPROC.ADMIN_GET_TAB_INFO`, is a full scan
+ (measured at tens of seconds on a large schema) — far too slow for a panel read. Per-object byte
+ sizes are a documented limitation, not a quick win.
+- `getTableStats()` returns per-table rows from `SYSCAT.TABLES`: a row count (`CARD`) and the timestamp
+ of the RUNSTATS that produced it (`STATS_TIME`), so the admin Operations/Monitoring "Tables" panel
+ lists every table with its count. Two caveats are carried rather than smoothed over:
+ - **The count is only as current as the last RUNSTATS.** `CARD` is not live; it is whatever RUNSTATS
+ last wrote, which can be very old. Measured on a real catalog, table counts in a single schema
+ ranged over several years apart. `STATS_TIME` is surfaced as `TableStats.lastAnalyze` precisely so the age
+ of the number is visible rather than implied to be current.
+ - **A table that never had RUNSTATS reports `CARD = -1` and `STATS_TIME = NULL`** (a large fraction of
+ tables in a real catalog had never been RUNSTATS'd). The provider maps that to `rowCount: 0` with
+ **no** `lastAnalyze`, so it reads as "no stats yet" rather than surfacing a literal `-1`.
+ - **Size is not read per table.** Db2's only per-table size is `SYSPROC.ADMIN_GET_TAB_INFO`, a table
+ function called one table at a time; running it across a whole schema is too heavy for
+ a panel read, so the required `totalSize`/`totalSizeBytes` carry the `"N/A"`/`0` placeholder and the
+ byte fields are omitted (the same honest-absence contract the SQLite provider uses). Bulk per-table
+ size is a follow-up.
+
+- `getIndexStats()` returns per-index rows for the current schema from `SYSCAT.INDEXES` +
+ `SYSCAT.INDEXCOLUSE` (name, table, key columns, unique/primary from `UNIQUERULE`, index type), with
+ scan counts LEFT JOINed from `MON_GET_INDEX` — real where an index has been scanned since activation,
+ `0` otherwise. Index size is not derived (`indexSize` carries the `"N/A"` placeholder, byte field
+ omitted): `NLEAF` is leaf *pages* whose byte size depends on the index's tablespace page size, a
+ per-object lookup too heavy for a panel read and meaningless before RUNSTATS.
+
+Still neutral: `getHealth().databaseSize` stays `"N/A"` (the Overview card shows the real size from
+`getOverview`; a single figure on `getHealth` would need `SYSPROC.GET_DBSIZE_INFO`, which returns
+through OUT parameters rather than a result set — a follow-up). Every gated read that a restricted
+account cannot run returns empty, never throws.
+
+### 6.1 The object surface (#789)
+
+One container level, the **schema**, read from `SYSCAT.SCHEMATA`.
+Every read below binds the schema and the object name as `?` markers, so nothing a caller supplies is interpolated.
+Measured against `icr.io/db2_community/db2:12.1.0.0` with [`docker/db2-init/01-object-fixture.sql`](../../docker/db2-init/01-object-fixture.sql) on 2026-09-15.
+
+**Which schemas are listed.**
+Every schema except `SYS%`, `NULLID` and `SQLJ`.
+The rule is by name because the owner cannot separate them: the schema Db2 creates implicitly on a user's first unqualified `CREATE` is `OWNER SYSIBM`, `OWNERTYPE 'S'`, exactly like `SYSCAT`.
+An upper-case `SYS` prefix is reserved (`CREATE SCHEMA SYSX` answers `SQL0553N`), while a delimited lower-case `"sysx"` is a legal user schema, and `LIKE` is case-sensitive, so that one stays listed.
+`SCHEMANAME` comes back blank-padded to eight characters (`"APP "`), so the read trims it; object names are not padded.
+The schema equal to `CURRENT SCHEMA` is marked as the session default, and it is marked only when it exists: a fresh `db2inst1` connection has `CURRENT SCHEMA` `DB2INST1`, which is not a schema until something is created in it.
+
+**Nine kinds.**
+
+| Kind | Catalog | Address | Source |
+|---|---|---|---|
+| `table` | `SYSCAT.TABLES` `TYPE 'T'` | `[schema, name]` | Not supported |
+| `view` | `TYPE 'V'` | `[schema, name]` | `SYSCAT.VIEWS.TEXT` |
+| `materialized_query_table` | `TYPE 'S'` | `[schema, name]` | `SYSCAT.VIEWS.TEXT` |
+| `alias` | `TYPE 'A'` | `[schema, name]` | Not supported |
+| `sequence` | `SYSCAT.SEQUENCES` `SEQTYPE 'S'` | `[schema, name]` | Not supported |
+| `module` | `SYSCAT.MODULES` `MODULETYPE 'M'` or `'P'` | `[schema, name]` | Not supported |
+| `procedure` | `SYSCAT.ROUTINES` `ROUTINETYPE 'P'` | `[schema, specific name]` | `SYSCAT.ROUTINES.TEXT` |
+| `function` | `ROUTINETYPE 'F'` | `[schema, specific name]` | `SYSCAT.ROUTINES.TEXT` |
+| `trigger` | `SYSCAT.TRIGGERS` | `[schema, table, name]` or `[schema, name]` | `SYSCAT.TRIGGERS.TEXT` |
+
+A routine is addressed by its `SPECIFICNAME` and labelled by its `ROUTINENAME`.
+Db2 overloads a routine name by parameter types: the fixture's two `ORDER_TOTAL` functions carry one routine name and two specific names.
+The specific name is Db2's own unique identifier and the one `DROP SPECIFIC FUNCTION` takes.
+The cost is that a system-generated specific name (`SQL260915014426735`) changes when the routine is dropped and created again, so an address saved before that no longer resolves.
+
+A routine is listed only when it is not in a module and its `ORIGIN` is one a person wrote: `E` external, `F` federated, `Q` SQL-bodied, `U` sourced.
+A module's routines carry `ROUTINEMODULENAME` and belong to the module, which is a leaf node like an Oracle package: its members are declared through `childKinds` and not browsable.
+A sequence is listed only with `SEQTYPE 'S'`, because `'I'` is the sequence behind an identity column.
+
+A trigger nests under its table only when both are in the same schema.
+Db2 lets a trigger's schema differ from its table's (the fixture's `REPORTING.ORDERS_AUDIT` fires on `APP.ORDERS`), and `[REPORTING, ORDERS, ORDERS_AUDIT]` would address a table that does not exist, so that trigger is `[REPORTING, ORDERS_AUDIT]`.
+The source read binds the address it was given, so a trigger under the other shape is not found.
+
+**Status.**
+Only a state a reader acts on is published: `VALID 'N'` reads `INVALID` (measured: a view over a dropped table under `AUTO_REVAL DEFERRED`), `VALID 'X'` and `TABLES.STATUS 'X'` read `INOPERATIVE`, and `TABLES.STATUS 'C'` reads `SET INTEGRITY PENDING`.
+
+**Row counts.**
+`SYSCAT.TABLES.CARD` is published for a table and a materialized query table only when it is a measurement.
+It is `-1` until `RUNSTATS` runs, and that absence publishes no `rowCount` at all rather than a 0; measured, `ORDERS` read no count before `RUNSTATS` and 2 after it.
+
+**Detail.**
+Columns come from `SYSCAT.COLUMNS`, with the primary key read from `KEYSEQ`, which is the column's position in the key and `NULL` outside it.
+The type is spelled the way Db2 takes it back in DDL, because schema diff compares these strings and the migration generator writes one into `SET DATA TYPE`, where a bare `VARCHAR` is a syntax error.
+Measured: `LENGTH` is the declared length for the character, graphic, binary and LOB types; `DECIMAL` carries precision in `LENGTH` and scale in `SCALE`; `TIMESTAMP` carries its fractional precision in `SCALE` (6 when declared bare); `DECFLOAT` reports 8 bytes for `DECFLOAT(16)` and 16 for `DECFLOAT(34)`; a character column with `CODEPAGE 0` is `FOR BIT DATA`.
+
+Foreign keys join `SYSCAT.REFERENCES` to `SYSCAT.KEYCOLUSE` twice, and the referenced key is joined on its table as well as its schema and constraint name.
+A constraint name is unique per table and not per schema: measured, two tables in `APP` both carried a primary key named `PK`, so a join without the table pairs one foreign key with every key of that name.
+A reference into another schema is qualified (`APP.CUSTOMERS` from `REPORTING.DAILY`) and a reference inside the schema is bare.
+
+Indexes are filtered by the table's schema, not the index's, because a system-generated key index lives in `INDSCHEMA SYSIBM` while its table is in the user's schema.
+
+**Bulk detail.**
+`describeObjects()` answers a whole relation kind in four round trips: the target listing, then columns, foreign keys and indexes restricted to that target through a CTE.
+A caller's bound is sent as `FETCH FIRST ? ROWS ONLY` at one more than the bound, so a saturated read is told apart from an exact one, and the extra object is dropped.
+Measured, the bulk answer is identical to the single read for every relation in both fixture schemas.
+The target is ordered by `TABNAME` under the database collation (`IDENTITY` on the fixture's UTF-8 database), and the returned details are then sorted by path.
+
+#### Object source (#789)
+
+Source is declared on `view`, `materialized_query_table`, `procedure`, `function` and `trigger`, in `sql`.
+Every text is `stored` and `complete`: a view created through `ibm_db` with irregular spacing and a trailing `--` comment read back from `SYSCAT.VIEWS.TEXT` byte-identical, and every text begins with its `CREATE`.
+A table, an alias, a sequence and a module have no stored text, and Db2 offers no read-only way to generate one: `db2look` is a client tool, and `SYSPROC.DB2LK_GENERATE_DDL` writes its output into `SYSTOOLS` tables.
+
+An external or sourced routine answers a refusal part, not an empty editor and not an error.
+Measured, `SYSCAT.ROUTINES.TEXT` is `NULL` for `ORIGIN 'E'` (the fixture's `APP.EXT_FN`, a C function) and `ORIGIN 'U'`, and the part says which.
+An object the read cannot find raises a `QueryError` naming it, and so does a view read under the `materialized_query_table` kind, because the read also binds the catalog type.
+
+#### Object edit (#789)
+
+This engine is DEFERRED rather than refused, and the measurements say which half of the ruling it passes.
+The failure arm is safe: a `CREATE OR REPLACE PROCEDURE` and a `CREATE OR REPLACE TRIGGER` that do not compile answer `SQL0206N` and leave the previous object `VALID` with its previous text, measured on 12.1.
+A successful replace keeps the object's privileges (an `EXECUTE` granted to `PUBLIC` survived it), but replacing a view leaves every view that reads it `VALID 'N'` until its next use, which is a consequence the preview would have to show before it could ship.
+No kind here declares `acceptsSourceEdits`, and `tests/isolated/object-edit-declarations.test.ts` is what holds that absence and this section together.
+
+---
+
+## 7. Maintenance
+
+Two operations, both routed through `CALL SYSPROC.ADMIN_CMD(...)` (Db2's SQL interface to its
+command-line utilities), both taking a table name:
+
+| Operation | Statement |
+|---|---|
+| `analyze` | `RUNSTATS ON TABLE "" WITH DISTRIBUTION AND DETAILED INDEXES ALL` |
+| `optimize` | `REORG TABLE ""` |
+
+`optimize` (`REORG`) can be slow and lock-heavy on a large table; it is offered as an explicit,
+user-initiated action (§3.5 explains why that is appropriate where auto-emitting it into a migration
+file is not). An operation called without a target is refused rather than sent.
+
+The target is a bare table name, and Db2 resolves it against `CURRENT SCHEMA`.
+Measured: `RUNSTATS` on `ORDERS` from a `db2inst1` session answered `SQL2306N The table or index "DB2INST1.ORDERS" does not exist`, and succeeded once the connection string carried `CurrentSchema=APP`.
+That agrees with the Operations tab, whose table list is also read from `CURRENT SCHEMA`.
+
+The whole command is a SQL string literal, so the name is escaped twice: its `"` doubled for the delimited identifier, then every `'` doubled for the literal.
+Measured on a table named `O'Brien`: the single escape answered `SQL0010N` (an unterminated string), and the double escape ran both operations.
+
+---
+
+## 8. Known limitations
+
+- **No EXPLAIN yet** (§3.6). The Explain button and tab are hidden.
+- **No interactive-transaction toolbar yet** (§3.6). BEGIN/COMMIT/ROLLBACK and SANDBOX are hidden.
+- **`columnTypes` not reported** (§3.4). The grid infers display from values; declared types are a
+ follow-up.
+- **Monitoring and maintenance follow `CURRENT SCHEMA`** (§6, §7). The object browser lists every schema, but the table and index panels and the maintenance targets are the session schema's.
+- **Routine addresses are specific names** (§6.1). A system-generated one changes when the routine is created again.
+- **Module members are not browsable** (§6.1). A module is a leaf node.
+- **No object editing** (§6.1, Object edit).
+- **Node only** (§1). `bunx --bun` crashes on the first connection.
+- **Native driver, with an install-time download** (§1). Air-gapped installs must pre-provision the
+ `ibm_db` CLI driver.
+- **No pool, failover or retry** (§3.1).
+- **REST/`execsql` transport is not implemented.** It is a possible future additive transport behind
+ the same provider, gated on the operator having the Db2 REST service deployed with `execsql`
+ enabled.
+
+---
+
+## 9. Testing
+
+Mock-based unit/integration coverage lives in
+[`tests/integration/db/db2-provider.test.ts`](../../tests/integration/db/db2-provider.test.ts):
+validation, capability honesty, `prepareQuery` FETCH FIRST/OFFSET, connection-string building, the
+`columnTypes` omission, positional-parameter binding (params forwarded to the driver; the no-params
+two-arg call form), the object surface against a mirror of the fixture (including
+`assertObjectSurface`), the neutral monitoring values, and the
+`RUNSTATS`/`REORG` maintenance SQL. The migration-generator's Db2 branch (SET DATA TYPE, the REORG
+advisory, the transaction wrapper) is pinned in
+[`tests/unit/schema-diff/migration-generator.test.ts`](../../tests/unit/schema-diff/migration-generator.test.ts).
+
+**Mock-based tests are not sufficient on their own.** This provider was driven through the running
+application against a real Db2 LUW server (v11.5.9.0, via `ibm_db` 4.0.1);
+a `db2` service is also provided in [`database-compose.yml`](../../database-compose.yml) (a privileged
+container with `LICENSE=accept` and a slow first boot). Results of the gate-4 pass:
+
+- full `INSERT` / `UPDATE` / `SELECT` / `DELETE`, including a `SELECT` immediately after a write
+ (read-your-writes) — **VERIFIED** (see the autocommit line below);
+- both error paths — **VERIFIED**: a missing object throws `SQL0204N` and a syntax error throws
+ `SQL0104N` (both surface as errors, not "0 rows"); `mapDatabaseError(err, 'db2', sql)` wraps them;
+- a query against a table left in a pending state — e.g. run a `SELECT`/`INSERT` against a table you
+ put in REORG-pending, and capture the exact `SQL0668N` driver text (and its reason code) so a
+ future error branch can decode it (§3.7) — **not yet exercised** (no REORG-pending table was
+ induced on the live server); still a follow-up;
+- the object surface — **VERIFIED** on 12.1 against the fixture (§6.1): every count, listing, detail,
+ bulk read and source read, including the external routine, the cross-schema trigger and the
+ truncated bulk read;
+- **value fidelity — VERIFIED** on Db2 v11.5.x (`ibm_db` 4.0.1), via `probe-db2.mjs DB2_MATRIX=1`:
+ CLOB→string, BLOB→`Buffer` (exact bytes), `BIGINT`→lossless string, DECIMAL/DECFLOAT/REAL/DOUBLE→
+ number, VARGRAPHIC/XML/DATE/TIME/TIMESTAMP→strings, `CHAR(n)` space-padded (expected). No
+ provider-boundary normalization needed for any of them (§3.8);
+- **autocommit — VERIFIED**: an `UPDATE`/`DELETE` run on its own is durable and read-your-writes holds
+ (UPDATE→new value on immediate SELECT; DELETE→count 0), so nothing is left uncommitted between runs;
+- **pagination — VERIFIED**: an unbounded `SELECT` gets `FETCH FIRST n ROWS ONLY` (`wasLimited`),
+ paging switches to `OFFSET n ROWS FETCH NEXT n ROWS ONLY` with contiguous rows, and an
+ already-bounded statement is left untouched;
+- **capability-gated UI — VERIFIED** (Playwright): the connection modal offers "Db2 LUW", defaults the
+ port to 50000, and shows the connection-string toggle; once connected the Explain button and Explain
+ tab are absent, no transaction toolbar/SANDBOX shows, Create Table is present, and the per-table
+ context menu offers "Run Statistics" (RUNSTATS) and "Reorganize Table" (REORG);
+- **inline row edit — VERIFIED** (Playwright, end-to-end): editing a grid cell issues
+ `UPDATE t SET "col" = ? WHERE "id" = ?` with a bound values array and the change persists on
+ re-query. This is what caught the param-binding bug now fixed and regression-tested (§5);
+- each maintenance operation — **VERIFIED**: `RUNSTATS`/`REORG` succeed per table via
+ `SYSPROC.ADMIN_CMD`; a global (no-target) request and an unsupported op are refused with a clear
+ message;
+- **`ibm_db` runs under Node, and not under Bun** — **MEASURED** on Bun 1.4.2: the first `open()`
+ panics the process (§1);
+- integer fidelity — **VERIFIED**: `BIGINT` `9223372036854775807` (past `Number.MAX_SAFE_INTEGER`)
+ arrives as the exact JS string, no precision loss.
+
+Remaining live follow-ups: the `SQL0668N`/REORG-pending error text (above), a TLS handshake against a
+TLS-configured server (§4.2), and the richer monitoring/EXPLAIN/transaction surfaces called out as
+future work.
diff --git a/next.config.ts b/next.config.ts
index ffcb9be34..894545953 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -232,6 +232,15 @@ const nextConfig: NextConfig = {
// from `.next/server/chunks`. The addon itself is still a file-tracing blind
// spot; the Dockerfile runner stage and scripts/build-standalone-payload.sh
// copy the package for that.
+ // `ibm_db` is external for the same reason as `oracledb` (#538 class), and the error
+ // is the concrete one: its `bindings` package locates `odbc_bindings.node` relative to
+ // the calling module, which Turbopack rewrites into `.next/dev/...`, so a bundled build
+ // threw "Could not locate the bindings file" listing a dozen `.next/...` paths before
+ // the connection was even attempted. Externalizing leaves `require('ibm_db')` to resolve
+ // from node_modules at runtime, where `bindings` finds the addon in
+ // `node_modules/ibm_db/build/Release`. The addon (and the bundled CLI driver under
+ // `node_modules/ibm_db/installer/clidriver`) is a file-tracing blind spot the same way
+ // oracledb's is; the Dockerfile/standalone payload must copy the whole `ibm_db` package.
serverExternalPackages: [
"pg",
"mysql2",
@@ -240,6 +249,7 @@ const nextConfig: NextConfig = {
"ssh2",
"cassandra-driver",
"oracledb",
+ "ibm_db",
"@duckdb/node-api",
"@duckdb/node-bindings",
],
diff --git a/operator/bundle/manifests/libredb-studio-operator.clusterserviceversion.yaml b/operator/bundle/manifests/libredb-studio-operator.clusterserviceversion.yaml
index 514ea0d49..7b6f999e3 100644
--- a/operator/bundle/manifests/libredb-studio-operator.clusterserviceversion.yaml
+++ b/operator/bundle/manifests/libredb-studio-operator.clusterserviceversion.yaml
@@ -22,11 +22,11 @@ metadata:
capabilities: Basic Install
categories: Database, Developer Tools
containerImage: ghcr.io/libredb/libredb-studio-operator:0.16.0
- createdAt: "2026-09-12T04:39:04Z"
- description: Open-source web-based SQL IDE for sixteen engines - PostgreSQL, MySQL,
- Oracle, SQL Server, SQLite, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Apache
- Druid, Elasticsearch, OpenSearch, Apache Trino, Apache Cassandra and libSQL
- - with AI-powered query assistance.
+ createdAt: "2026-09-15T02:09:53Z"
+ description: Open-source web-based SQL IDE for seventeen engines - PostgreSQL,
+ MySQL, Oracle, SQL Server, IBM Db2 LUW, SQLite, DuckDB, MongoDB, Redis, Couchbase,
+ ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino, Apache Cassandra
+ and libSQL - with AI-powered query assistance.
olm.skipRange: '>=0.0.0 <0.16.0'
operators.operatorframework.io/builder: operator-sdk-v1.42.3
operators.operatorframework.io/project_layout: helm.sdk.operatorframework.io/v1
@@ -50,7 +50,7 @@ spec:
version: v1alpha1
description: |
LibreDB Studio is an open-source, web-based SQL IDE. Query PostgreSQL,
- MySQL, Oracle, SQL Server, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase,
+ MySQL, Oracle, SQL Server, IBM Db2 LUW, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase,
ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino and Apache
Cassandra from the browser —
with AI-powered query assistance (natural-language-to-SQL, explain, fix),
diff --git a/operator/config/manifests/bases/libredb-studio-operator.clusterserviceversion.yaml b/operator/config/manifests/bases/libredb-studio-operator.clusterserviceversion.yaml
index 461db4733..785f9fa6a 100644
--- a/operator/config/manifests/bases/libredb-studio-operator.clusterserviceversion.yaml
+++ b/operator/config/manifests/bases/libredb-studio-operator.clusterserviceversion.yaml
@@ -6,10 +6,10 @@ metadata:
capabilities: Basic Install
categories: Database, Developer Tools
containerImage: ghcr.io/libredb/libredb-studio-operator:0.0.0
- description: Open-source web-based SQL IDE for sixteen engines - PostgreSQL, MySQL,
- Oracle, SQL Server, SQLite, DuckDB, MongoDB, Redis, Couchbase, ClickHouse, Apache
- Druid, Elasticsearch, OpenSearch, Apache Trino, Apache Cassandra and libSQL
- - with AI-powered query assistance.
+ description: Open-source web-based SQL IDE for seventeen engines - PostgreSQL,
+ MySQL, Oracle, SQL Server, IBM Db2 LUW, SQLite, DuckDB, MongoDB, Redis, Couchbase,
+ ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino, Apache Cassandra
+ and libSQL - with AI-powered query assistance.
olm.skipRange: '>=0.0.0 <0.0.0'
repository: https://github.com/libredb/libredb-studio
support: LibreDB
@@ -31,7 +31,7 @@ spec:
version: v1alpha1
description: |
LibreDB Studio is an open-source, web-based SQL IDE. Query PostgreSQL,
- MySQL, Oracle, SQL Server, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase,
+ MySQL, Oracle, SQL Server, IBM Db2 LUW, SQLite, libSQL, DuckDB, MongoDB, Redis, Couchbase,
ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino and Apache
Cassandra from the browser —
with AI-powered query assistance (natural-language-to-SQL, explain, fix),
diff --git a/operator/helm-charts/libredb-studio/Chart.yaml b/operator/helm-charts/libredb-studio/Chart.yaml
index 3bd401882..4fea8c832 100644
--- a/operator/helm-charts/libredb-studio/Chart.yaml
+++ b/operator/helm-charts/libredb-studio/Chart.yaml
@@ -1,6 +1,6 @@
apiVersion: v2
name: libredb-studio
-description: Web-based SQL IDE for cloud-native teams supporting sixteen engines - PostgreSQL, MySQL, SQLite, DuckDB, Oracle, SQL Server, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino, Apache Cassandra and libSQL
+description: Web-based SQL IDE for cloud-native teams supporting seventeen engines - PostgreSQL, MySQL, SQLite, DuckDB, Oracle, SQL Server, IBM Db2 LUW, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino, Apache Cassandra and libSQL
type: application
version: 0.1.64
appVersion: "0.16.0"
@@ -38,6 +38,9 @@ keywords:
# One keyword only, unlike the pair above: DuckDB is registered as `duckdb` and that
# is also the product name an evaluator types, so there is no second spelling to catch.
- duckdb
+ # Two spellings for IBM Db2: the type-id `db2` and the product name a user searches.
+ - db2
+ - ibm db2
- web-ide
maintainers:
- name: cevheri
diff --git a/operator/helm-charts/libredb-studio/README.md b/operator/helm-charts/libredb-studio/README.md
index b1bfe7f45..d08f36584 100644
--- a/operator/helm-charts/libredb-studio/README.md
+++ b/operator/helm-charts/libredb-studio/README.md
@@ -2,7 +2,7 @@
[](https://artifacthub.io/packages/search?repo=libredb-studio)
-Web-based SQL IDE for cloud-native teams supporting sixteen engines - PostgreSQL, MySQL, SQLite, libSQL, DuckDB, Oracle, SQL Server, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra.
+Web-based SQL IDE for cloud-native teams supporting seventeen engines - PostgreSQL, MySQL, SQLite, libSQL, DuckDB, Oracle, SQL Server, IBM Db2 LUW, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid, Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra.
## Prerequisites
diff --git a/package.json b/package.json
index f96a77c3a..ac7ee9b47 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,8 @@
"elasticsearch",
"opensearch",
"trino",
- "cassandra"
+ "cassandra",
+ "db2"
],
"homepage": "https://github.com/libredb/libredb-studio",
"bugs": {
@@ -120,9 +121,10 @@
"node": ">=24.0.0"
},
"packageManager": "bun@1.4.2",
- "//trustedDependencies": "Explicit allowlist of the ONLY dependencies permitted to execute code at install time. Without this field bun applies its own default allowlist, which synthesizes a `node-gyp rebuild` for any package shipping a binding.gyp - including better-sqlite3 13, whose `gypfile: false` says not to (it is N-API and ships prebuilts, so npm honours it and bun does not). These three are exactly the packages that ran scripts before the field existed; cpu-features (optional, ssh2) and unrs-resolver stay blocked as they already were.",
+ "//trustedDependencies": "Explicit allowlist of the ONLY dependencies permitted to execute code at install time. Without this field bun applies its own default allowlist, which synthesizes a `node-gyp rebuild` for any package shipping a binding.gyp - including better-sqlite3 13, whose `gypfile: false` says not to (it is N-API and ships prebuilts, so npm honours it and bun does not). esbuild, oracledb and ssh2 are exactly the packages that ran scripts before the field existed; ibm_db joined with the Db2 provider (#786), because its install script downloads the IBM CLI driver the addon links against. cpu-features (optional, ssh2) and unrs-resolver stay blocked as they already were.",
"trustedDependencies": [
"esbuild",
+ "ibm_db",
"oracledb",
"ssh2"
],
@@ -217,6 +219,7 @@
"embla-carousel-react": "^8.6.0",
"framer-motion": "^13.2.0",
"geist": "^1.7.2",
+ "ibm_db": "4.0.1",
"input-otp": "^1.5.0",
"ioredis": "^5.11.1",
"jose": "^6.2.12",
diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml
index 631d23e04..e4fd39952 100644
--- a/packaging/linux/nfpm.yaml
+++ b/packaging/linux/nfpm.yaml
@@ -23,8 +23,8 @@ section: database
priority: optional
maintainer: cevheri
description: |
- Web-based SQL IDE for sixteen engines - PostgreSQL, MySQL, SQLite, libSQL, DuckDB,
- Oracle, SQL Server, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid,
+ Web-based SQL IDE for seventeen engines - PostgreSQL, MySQL, SQLite, libSQL, DuckDB,
+ Oracle, SQL Server, Db2 LUW, MongoDB, Redis, Couchbase, ClickHouse, Apache Druid,
Elasticsearch, OpenSearch, Apache Trino and Apache Cassandra - with AI query assistance.
Ships the standalone Next.js server with a bundled private Node.js
runtime (no external dependencies) and a systemd unit (libredb-studio)
diff --git a/src/components/icons/db-icons.tsx b/src/components/icons/db-icons.tsx
index 289d6096c..6fa64f51c 100644
--- a/src/components/icons/db-icons.tsx
+++ b/src/components/icons/db-icons.tsx
@@ -419,3 +419,30 @@ export const DuckDBIcon: React.FC = ({ className, ...props }) => (
);
+
+/**
+ * IBM Db2 LUW: a relational server the app dials over the DRDA protocol.
+ *
+ * Not a copy of IBM's wordmark. The house idiom instead — one stroke at weight 1.5 —
+ * drawing the two halves of the id: a database cylinder (the server, telling it apart
+ * from the SQLite/libSQL document outlines) with a numeral "2" beside it, which is the
+ * one glyph that distinguishes Db2 from every other cylinder on the page. It still
+ * reads at 14px.
+ */
+export const Db2Icon: React.FC = ({ className, ...props }) => (
+
+);
diff --git a/src/hooks/use-connection-form.ts b/src/hooks/use-connection-form.ts
index d62f69578..53e917718 100644
--- a/src/hooks/use-connection-form.ts
+++ b/src/hooks/use-connection-form.ts
@@ -682,6 +682,7 @@ export function useConnectionForm({ isOpen, onConnect, editConnection, onTestCon
"cassandra",
"libsql",
"duckdb",
+ "db2",
];
const dbTypes = selectableTypes.map((t) => {
const cfg = getDBConfig(t);
diff --git a/src/lib/connection-string-parser.ts b/src/lib/connection-string-parser.ts
index c9c6c6225..4cd88d925 100644
--- a/src/lib/connection-string-parser.ts
+++ b/src/lib/connection-string-parser.ts
@@ -86,6 +86,7 @@ export const ENGINE_URI_SCHEMES: Partial> = {
couchbase: "couchbase",
clickhouse: "clickhouse",
libsql: "libsql",
+ db2: "db2",
};
/**
@@ -138,6 +139,15 @@ export function parseConnectionString(input: string): ParsedConnection | null {
return parseGenericURL(trimmed, "mssql", "1433");
}
+ // Db2 LUW — `db2://host:port/database`. There is no single vendor URI convention
+ // (the driver's native form is a `KEY=VALUE;` attribute list), but `db2://` is the
+ // scheme common ORMs and tools emit, and it maps cleanly onto the host/port/database
+ // form. The provider re-reads the fields, so the pasted URL is not itself sent to the
+ // driver. Pinned by tests/unit/lib/connection-string-parser.test.ts.
+ if (trimmed.startsWith("db2://")) {
+ return parseGenericURL(trimmed, "db2", "50000");
+ }
+
// Couchbase — the TLS scheme is checked first, it is not a prefix of the plain one.
// The mode travels for the same reason as ClickHouse's: this provider talks HTTP,
// and `CouchbaseHttpTransport` picks `https` vs `http` from `config.ssl`, never from
@@ -573,6 +583,7 @@ export function detectConnectionStringType(input: string): DatabaseType | null {
if (trimmed.startsWith("redis://") || trimmed.startsWith("rediss://")) return "redis";
if (trimmed.startsWith("oracle://")) return "oracle";
if (trimmed.startsWith("mssql://") || trimmed.startsWith("sqlserver://")) return "mssql";
+ if (trimmed.startsWith("db2://")) return "db2";
if (trimmed.startsWith("couchbase://") || trimmed.startsWith("couchbases://")) return "couchbase";
if (trimmed.startsWith("libsql://")) return "libsql";
if (trimmed.startsWith("clickhouse://") || trimmed.startsWith("http://") || trimmed.startsWith("https://"))
diff --git a/src/lib/db-showcase.ts b/src/lib/db-showcase.ts
index b0c553e3d..78f442e35 100644
--- a/src/lib/db-showcase.ts
+++ b/src/lib/db-showcase.ts
@@ -28,33 +28,37 @@ export const SHOWCASE_RANK: Record = {
redis: 5,
oracle: 6,
mssql: 7,
+ // IBM Db2 sits with the other enterprise relational names it is most often compared
+ // to (Oracle, SQL Server), ahead of the search and analytical groups: it is a
+ // first-rank name for that audience.
+ db2: 8,
// The two search engines sit here, ahead of the analytical stores: Elasticsearch is
// one of the best-known names on this page, and OpenSearch reads as its sibling to
// anyone who knows it - which is also what the code says, since the two type-ids
// share one HTTP SQL transport (#424).
- elasticsearch: 8,
- opensearch: 9,
+ elasticsearch: 9,
+ opensearch: 10,
// Ahead of the analytical stores and behind the search pair: Cassandra is a
// first-rank name for anyone who has met a wide-column store, and it is the only
// one of those on this page.
- cassandra: 10,
- couchbase: 11,
- clickhouse: 12,
- druid: 13,
+ cassandra: 11,
+ couchbase: 12,
+ clickhouse: 13,
+ druid: 14,
// Ahead of the embedded store and behind the three analytical ones: Trino is the
// name an evaluator is most likely to already know out of this last group, because
// it is the engine a data platform is usually met THROUGH rather than one more
// store to choose between.
- trino: 14,
+ trino: 15,
// Behind Trino and ahead of the embedded store: libSQL is the newest name on this
// page and the one an evaluator is least likely to have met, but it is a product
// name (Turso's server) rather than our own, so it goes ahead of `libredb`.
- libsql: 15,
+ libsql: 16,
// Last on purpose: the embedded store is the least recognisable name here. It is
// still shown - it is a shipped provider with a doc (docs/providers/libredb.md), an
// icon and a slot in the connection picker, so omitting it would make the login page
// contradict the app (issue #425, step 2).
- libredb: 16,
+ libredb: 17,
};
/**
diff --git a/src/lib/db-ui-config.ts b/src/lib/db-ui-config.ts
index f0bd08b6c..686b217e5 100644
--- a/src/lib/db-ui-config.ts
+++ b/src/lib/db-ui-config.ts
@@ -17,6 +17,7 @@ import {
CassandraIcon,
LibSQLIcon,
DuckDBIcon,
+ Db2Icon,
} from "@/components/icons/db-icons";
import type { DatabaseType } from "@/lib/types";
@@ -157,6 +158,20 @@ export const DB_UI_CONFIG: Record = {
showConnectionStringToggle: false,
connectionFields: ["host", "port", "user", "password", "database", "instanceName"],
},
+ db2: {
+ icon: Db2Icon,
+ // IBM's brand blue. `hue-blue` is PostgreSQL's and `hue-sky` is SQL Server's;
+ // `hue-blue-alt` is the nearest free shade, and the distinct-colour assertion in
+ // tests/unit/lib/db-ui-config.test.ts rules a duplicate out.
+ color: "text-hue-blue-alt",
+ label: "Db2 LUW",
+ // The DRDA listener's conventional port on Db2 LUW.
+ defaultPort: "50000",
+ // `ibm_db` takes a DRDA connection string (a `KEY=VALUE;` attribute list), so a
+ // pasted string is honoured; the provider declares supportsConnectionString: true.
+ showConnectionStringToggle: true,
+ connectionFields: ["host", "port", "user", "password", "database", "connectionString"],
+ },
couchbase: {
icon: CouchbaseIcon,
color: "text-hue-orange",
diff --git a/src/lib/db/compatibility.ts b/src/lib/db/compatibility.ts
index 70b049d49..fdd1e4549 100644
--- a/src/lib/db/compatibility.ts
+++ b/src/lib/db/compatibility.ts
@@ -64,6 +64,11 @@ const SHIPPED: Readonly> = Object.freeze({
couchbase: true,
redis: true,
libredb: true,
+ // IBM Db2 LUW (#424 follow-on): its own provider, doc and integration test. A
+ // driver rather than a relative of anything here - it speaks the DRDA wire
+ // protocol, which no other shipped id speaks, so no engine is wire-compatible
+ // with it and nothing is recorded as a relative below.
+ db2: true,
});
/**
@@ -79,10 +84,10 @@ export const SHIPPED_DATABASE_TYPES: readonly DatabaseType[] = Object.freeze(Obj
/**
* Which shipped ids are databases a user already runs, and which one is not.
*
- * `libredb` is the embedded store this app carries with it; the other sixteen are
+ * `libredb` is the embedded store this app carries with it; the other seventeen are
* external engines you point the product at. Everything published as a database
- * count means the external sixteen - README.md's "sixteen drivers reach
- * forty-two named engines", the login hero's engine claim - so the split needs a
+ * count means the external seventeen - README.md's "seventeen drivers reach
+ * forty-three named engines", the login hero's engine claim - so the split needs a
* definition somewhere, and it belongs beside `SHIPPED` rather than in the UI that
* prints it. That is the same reason `SHIPPED` itself lives here.
*
@@ -113,6 +118,9 @@ const EXTERNAL: Readonly> = Object.freeze({
// external: it is the user's file, opened from a path they give us. libredb is
// ours, created by this app, so it is the only id that answers no here.
libredb: false,
+ // A server the user already runs, reached over the network like every other
+ // external engine.
+ db2: true,
});
/**
@@ -564,7 +572,7 @@ export function compatibleEnginesFor(type: DatabaseType): readonly WireCompatibl
* app at it, so the embedded store is out of both halves of the sum.
*
* Still no runtime consumer: README.md and the docs table are markdown and quote the
- * number as prose, and the login hero prints the two halves separately - sixteen in
+ * number as prose, and the login hero prints the two halves separately - seventeen in
* the proof row, twenty-six in the relatives line - rather than their sum. This exists
* so the arithmetic has one definition, and the unit test pins it.
*/
diff --git a/src/lib/db/factory.ts b/src/lib/db/factory.ts
index 36e240acd..6f0d602d7 100644
--- a/src/lib/db/factory.ts
+++ b/src/lib/db/factory.ts
@@ -109,6 +109,11 @@ export async function createDatabaseProvider(
return new MSSQLProvider(connection, options);
}
+ case "db2": {
+ const { Db2Provider } = await import("./providers/sql/db2");
+ return new Db2Provider(connection, options);
+ }
+
case "clickhouse": {
// The explicit /index specifier keeps this dynamic import statically
// analysable: a bare directory resolves only at runtime, which the bundler
@@ -186,7 +191,7 @@ export async function createDatabaseProvider(
// This list is NOT type-checked against the union - a new case above with no
// entry here is silent - so it is kept in the same order as the cases and
// tests/isolated/factory.test.ts pins individual names in it by regex.
- `Unknown database type: ${connection.type}. Supported types: postgres, mysql, sqlite, duckdb, libsql, oracle, mssql, clickhouse, druid, trino, cassandra, elasticsearch, opensearch, mongodb, couchbase, redis, libredb`,
+ `Unknown database type: ${connection.type}. Supported types: postgres, mysql, sqlite, duckdb, libsql, oracle, mssql, db2, clickhouse, druid, trino, cassandra, elasticsearch, opensearch, mongodb, couchbase, redis, libredb`,
connection.type,
);
}
diff --git a/src/lib/db/providers/sql/db2/index.ts b/src/lib/db/providers/sql/db2/index.ts
new file mode 100644
index 000000000..1b9ea90bf
--- /dev/null
+++ b/src/lib/db/providers/sql/db2/index.ts
@@ -0,0 +1,1022 @@
+/**
+ * IBM Db2 for Linux, UNIX and Windows (Db2 LUW) Database Provider
+ *
+ * Reached over the DRDA binary protocol through the native `ibm_db` driver (an N-API
+ * addon whose install step fetches the IBM CLI driver). There is a first-class HTTP
+ * path — the Db2 REST service's `/v1/services/execsql` endpoint — but it is a
+ * separately deployed container that is commonly disabled, so an IDE that must reach
+ * ANY standard Db2 uses the driver the way DBeaver/DataGrip do. See
+ * `docs/providers/db2.md` for the driver's distribution cost and the REST alternative.
+ *
+ * Standard SQL: double-quoted identifiers and `FETCH FIRST n ROWS ONLY` pagination are
+ * both correct Db2, so this extends `SQLBaseProvider` and inherits `escapeIdentifier()`
+ * unchanged, overriding only `prepareQuery()` — the same shape as Oracle.
+ *
+ * `ibm_db` exposes a callback API. This module holds ONE connection handle (the driver
+ * has no pool of its own worth the surface here for an editor), serialises queries onto
+ * it, and wraps every callback in a promise at the driver boundary. Connection pooling,
+ * failover and retry are the code the driver would otherwise own and are deliberately
+ * not written — acceptable for an editor, and stated in the doc rather than hidden.
+ */
+
+// `ibm_db`'s published typings are thin, so the driver surface this provider touches is
+// declared here and the import is typed against it. The dynamic import in factory.ts is
+// what keeps the native addon out of the initial bundle.
+import { SQLBaseProvider } from "../sql-base";
+import {
+ type DatabaseConnection,
+ type QueryResult,
+ type HealthInfo,
+ type MaintenanceType,
+ type MaintenanceResult,
+ type ProviderOptions,
+ type ProviderCapabilities,
+ type ProviderLabels,
+ type SlowQueryStats,
+ type ActiveSessionDetails,
+ type DatabaseOverview,
+ type PerformanceMetrics,
+ type TableStats,
+ type IndexStats,
+ type StorageStats,
+ type PreparedQuery,
+ type QueryPrepareOptions,
+ type Container,
+ type DatabaseObject,
+ type KindCount,
+ type ObjectDetail,
+ type ObjectDetailBatch,
+ type ObjectSourceDocument,
+} from "../../../types";
+import { DatabaseConfigError, ConnectionError, QueryError, mapDatabaseError } from "../../../errors";
+import { callerBoundTruncationReason, declaredKinds, requireSourceKind } from "../../../object-kinds";
+import { comparePaths } from "../../../object-path";
+import {
+ COUNTS_SQL,
+ CONTAINERS_SQL,
+ DB2_CONTAINER_LEVELS,
+ DB2_OBJECT_KINDS,
+ OBJECT_COLUMNS_SQL,
+ OBJECT_FOREIGN_KEYS_SQL,
+ OBJECT_INDEXES_SQL,
+ assertObjectPathShape,
+ bulkDetailSql,
+ bulkTargetSql,
+ byObjectName,
+ containerSchema,
+ containersFromRows,
+ countsFromRows,
+ listingStatement,
+ objectAddress,
+ objectDetailFromRows,
+ objectFromRow,
+ relationTableType,
+ requireKind,
+ sourcePartFromRow,
+ sourceStatement,
+ unavailableCounts,
+ type ContainerRow,
+ type KindCountRow,
+ type ObjectRow,
+} from "./objects";
+import { analyzeQuery, DEFAULT_QUERY_LIMIT, MAX_UNLIMITED_ROWS } from "../../../utils/query-limiter";
+import { formatBytes, formatDuration } from "../../../utils/pool-manager";
+import { logger } from "@/lib/logger";
+import { resolveSqlGrammar } from "@/lib/sql/grammar";
+import { readStatementEnd } from "@/lib/sql/statement-end";
+import { CACHE_HIT_RATIO_UNAVAILABLE, formatCacheHitRatio } from "@/lib/monitoring-cache-ratio";
+
+// ============================================================================
+// Driver surface (ibm_db)
+// ============================================================================
+
+/**
+ * The slice of `ibm_db`'s callback API this provider uses, declared locally because the
+ * package's own typings do not describe a promise surface and are incomplete.
+ * `ibm_db.open` hands back a `Database` connection object; `query` runs a statement and
+ * calls back with an array of row objects (keyed by column name). Errors arrive as the
+ * first callback argument, Node-style.
+ */
+interface Db2Connection {
+ query(sql: string, callback: (err: Error | null, rows: Record[]) => void): void;
+ query(sql: string, params: unknown[], callback: (err: Error | null, rows: Record[]) => void): void;
+ close(callback: (err: Error | null) => void): void;
+}
+
+interface Db2Driver {
+ open(connStr: string, callback: (err: Error | null, conn: Db2Connection) => void): void;
+}
+
+// ============================================================================
+// SQL statements
+// ============================================================================
+// Multi-line SQL is hoisted to module scope so per-line coverage attribution stays
+// stable. The object surface's statements live in ./objects.ts.
+
+const VERSION_SQL = `SELECT SERVICE_LEVEL FROM TABLE(SYSPROC.ENV_GET_INST_INFO()) AS T`;
+
+// Per-table stats for the admin Operations/Monitoring "Tables" panel. Read from the
+// catalog, not fabricated: CARD is the row count and STATS_TIME is when RUNSTATS last
+// wrote them. Both are only as current as that RUNSTATS — a table that has never had it
+// reports CARD = -1 and STATS_TIME = NULL, which the mapper turns into "no stats" (row
+// count 0, no lastAnalyze) rather than surfacing -1. Sizes are deliberately NOT read
+// here: the only per-table size Db2 publishes is SYSPROC.ADMIN_GET_TAB_INFO, a table
+// function called one table at a time, and running it across a whole schema (measured too
+// slow on a large real catalog) is too heavy for a panel read. Size is a follow-up.
+const TABLE_STATS_SQL = `SELECT TABSCHEMA, TABNAME, CARD, STATS_TIME
+ FROM SYSCAT.TABLES
+ WHERE TABSCHEMA = CURRENT SCHEMA AND TYPE = 'T'
+ ORDER BY TABNAME`;
+
+// Live monitoring reads. Every one of these is a MON_GET_* table function or a
+// SYSIBMADM.* administrative view, and every one is permission-gated (they need SYSMON
+// authority or an explicit grant). The provider runs each in its own try/catch and
+// returns an empty result on failure rather than throwing: a locked-down account that
+// cannot read them sees an empty panel, exactly as the DatabaseProvider contract requires
+// for a permission-gated source, while a monitoring-authorized account sees real data.
+// Measured readable on Db2 v11.5.9.0. Documented in docs/providers/db2.md §6.
+
+// Active connections, the Sessions panel. -2 = every member; NULL handle = all.
+const SESSIONS_SQL = `SELECT APPLICATION_HANDLE, APPLICATION_NAME, CLIENT_IPADDR,
+ SYSTEM_AUTH_ID, TOTAL_APP_COMMITS
+ FROM TABLE(MON_GET_CONNECTION(NULL, -2)) AS T
+ ORDER BY APPLICATION_HANDLE
+ FETCH FIRST 200 ROWS ONLY`;
+
+// Statement cache with timings, the slow-query panel. Db2 only accumulates per-statement
+// execution TIMES when the database's `mon_req_metrics`/`mon_act_metrics` config is on;
+// with it off (the default on many installs), `NUM_EXEC_WITH_METRICS` is 0 and every time
+// column reads 0. Filtering on `NUM_EXEC_WITH_METRICS > 0` keeps the panel honest: it shows
+// real timings when the server collects them and an empty state (not a list of 0.00 ms
+// rows) when it does not. TOTAL_ACT_TIME is milliseconds of activity across the metrics-
+// bearing executions; the average is derived per row.
+const SLOW_QUERIES_SQL = `SELECT STMT_TEXT, NUM_EXECUTIONS, NUM_EXEC_WITH_METRICS, TOTAL_ACT_TIME, ROWS_READ
+ FROM TABLE(MON_GET_PKG_CACHE_STMT(NULL, NULL, NULL, -2)) AS T
+ WHERE STMT_TEXT IS NOT NULL AND NUM_EXEC_WITH_METRICS > 0
+ ORDER BY TOTAL_ACT_TIME DESC
+ FETCH FIRST 50 ROWS ONLY`;
+
+// Buffer-pool hit ratio source. NOTE: MON_GET_BUFFERPOOL only accumulates read counters
+// when the database's mon_obj_metrics config is on (measured NONE on a real server, so
+// every counter read 0). SYSIBMADM.BP_HITRATIO is the older snapshot-monitor view and
+// reports real logical/physical reads regardless of that config, so it is the reliable
+// source for a hit ratio. Summed across pools: (logical - physical) / logical.
+const BUFFERPOOL_SQL = `SELECT SUM(TOTAL_LOGICAL_READS) AS LOGICAL_READS,
+ SUM(TOTAL_PHYSICAL_READS) AS PHYSICAL_READS
+ FROM SYSIBMADM.BP_HITRATIO`;
+
+// Tablespace sizing, the Storage panel. Bytes = used pages * page size.
+const STORAGE_SQL = `SELECT TBSP_NAME, TBSP_TYPE, TBSP_TOTAL_PAGES, TBSP_USED_PAGES, TBSP_PAGE_SIZE
+ FROM TABLE(MON_GET_TABLESPACE(NULL, -2)) AS T
+ ORDER BY TBSP_USED_PAGES DESC`;
+
+// Count of live connections, for the overview/health `activeConnections`. Same gated
+// source as SESSIONS_SQL; a refused read leaves the count absent (never a fabricated 0).
+const ACTIVE_CONNECTIONS_SQL = `SELECT COUNT(*) AS N FROM TABLE(MON_GET_CONNECTION(NULL, -2)) AS T`;
+
+// Configured connection ceiling for the overview. `maxappls` is Db2's per-database limit
+// on concurrent applications — the meaningful cap (the DBM-level `max_connections` is
+// often -1 = automatic). SYSIBMADM.DBCFG is permission-gated, so a refusal leaves the
+// overview's `maxConnections` at 0.
+const MAX_CONNECTIONS_SQL = `SELECT VALUE FROM SYSIBMADM.DBCFG WHERE NAME = 'maxappls' FETCH FIRST 1 ROW ONLY`;
+
+// Database-wide overview counts and the activation time. TABLE/INDEX counts come from the
+// catalog (whole database, TYPE='T' for base tables); DB_CONN_TIME is when the database was
+// activated, from which uptime is derived. Catalog counts are always readable; DB_CONN_TIME
+// needs MON authority and is read separately so a refusal only costs the uptime figure.
+const CATALOG_COUNTS_SQL = `SELECT
+ (SELECT COUNT(*) FROM SYSCAT.TABLES WHERE TYPE = 'T') AS TABLE_COUNT,
+ (SELECT COUNT(*) FROM SYSCAT.INDEXES) AS INDEX_COUNT
+ FROM SYSIBM.SYSDUMMY1`;
+// Uptime as elapsed SECONDS, computed by the database rather than in JS. DB_CONN_TIME is
+// the database activation time in the server's own timezone; parsing that string in Node
+// and subtracting from Date.now() goes wrong whenever the app and the server are in
+// different timezones (measured: a UTC server read as local EDT produced a NEGATIVE
+// uptime). Doing the arithmetic in Db2 against its own CURRENT_TIMESTAMP keeps both sides
+// in the server's clock. DAYS + MIDNIGHT_SECONDS is exact (TIMESTAMPDIFF is an estimate).
+const DB_UPTIME_SECONDS_SQL = `SELECT
+ (DAYS(CURRENT_TIMESTAMP) - DAYS(DB_CONN_TIME)) * 86400
+ + (MIDNIGHT_SECONDS(CURRENT_TIMESTAMP) - MIDNIGHT_SECONDS(DB_CONN_TIME)) AS UPTIME_SECONDS
+ FROM TABLE(MON_GET_DATABASE(-2)) AS T`;
+
+// Database-level counters for the Performance panel. DEADLOCKS is a real cumulative count
+// since activation; a positive value is a genuine signal, a 0 is a measured 0 (not a
+// fabricated absence). Permission-gated, so a refusal omits the field.
+const DEADLOCKS_SQL = `SELECT DEADLOCKS FROM TABLE(MON_GET_DATABASE(-2)) AS T`;
+
+// Per-index stats for the monitoring Indexes panel, scoped to CURRENT SCHEMA like the
+// Tables panel. Structural columns (name, table, uniquerule, type, leaf pages, key columns)
+// come from SYSCAT.INDEXES + SYSCAT.INDEXCOLUSE — always readable. Scan counts are LEFT
+// JOINed from MON_GET_INDEX (real where an index has been used since activation, 0
+// elsewhere); that table function is permission-gated, so the whole read goes through
+// tryRun and an index simply reports 0 scans when the join finds nothing. Index size is not
+// derived: NLEAF is leaf PAGES whose byte size depends on the index's tablespace page size,
+// a per-object lookup too heavy for a panel read and meaningless until RUNSTATS has run
+// (NLEAF = -1 before then), so the required `indexSize` carries the "N/A" placeholder and
+// `indexSizeBytes` is omitted — the same honest-absence contract getTableStats uses.
+const INDEX_STATS_SQL = `WITH SCANS AS (
+ SELECT RTRIM(TABSCHEMA) AS TS, RTRIM(TABNAME) AS TN, IID, SUM(INDEX_SCANS) AS SCANS
+ FROM TABLE(MON_GET_INDEX(NULL, NULL, -2)) AS M
+ GROUP BY RTRIM(TABSCHEMA), RTRIM(TABNAME), IID
+ )
+ SELECT i.INDSCHEMA, i.INDNAME, i.TABNAME, i.UNIQUERULE, i.INDEXTYPE,
+ (SELECT LISTAGG(RTRIM(ic.COLNAME), ',') WITHIN GROUP (ORDER BY ic.COLSEQ)
+ FROM SYSCAT.INDEXCOLUSE ic
+ WHERE ic.INDSCHEMA = i.INDSCHEMA AND ic.INDNAME = i.INDNAME) AS COLS,
+ COALESCE(s.SCANS, 0) AS SCANS
+ FROM SYSCAT.INDEXES i
+ LEFT JOIN SCANS s
+ ON s.TS = RTRIM(i.TABSCHEMA) AND s.TN = RTRIM(i.TABNAME) AND s.IID = i.IID
+ WHERE i.TABSCHEMA = CURRENT SCHEMA
+ ORDER BY i.TABNAME, i.INDNAME`;
+
+// ============================================================================
+// Db2 Provider
+// ============================================================================
+
+export class Db2Provider extends SQLBaseProvider {
+ private conn: Db2Connection | null = null;
+ private driver: Db2Driver | null = null;
+
+ constructor(config: DatabaseConnection, options: ProviderOptions = {}) {
+ super(config, options);
+ this.validate();
+ }
+
+ // ============================================================================
+ // Provider Metadata
+ // ============================================================================
+
+ public override getCapabilities(): ProviderCapabilities {
+ return {
+ ...super.getCapabilities(),
+ defaultPort: 50000,
+ // Disabled until a Db2 dialect explain flow exists (#126, same posture as Oracle
+ // and SQL Server): Db2 EXPLAIN populates the explain tables (EXPLAIN_STATEMENT et
+ // al.) rather than returning a plan from one statement, which the single-statement
+ // explain path cannot express. Adding it later is additive — a new ExplainFormat
+ // union member plus a strategy — and flips this flag with no rework here.
+ supportsExplain: false,
+ supportsConnectionString: true,
+ // Db2 accepts the single-table `UPDATE SET
= WHERE = `
+ // the results grid's inline editor builds.
+ supportsInlineRowEdit: true,
+ // Not implemented in this provider yet: no held-connection transaction session is
+ // wired to POST /api/db/transaction, so the toolbar trio and SANDBOX stay hidden
+ // rather than offer a control the route would refuse. Db2 has transactions; this
+ // is a statement about the provider's surface, exactly as SQLite's `false` is.
+ supportsTransactions: false,
+ // `analyze` is RUNSTATS; `optimize` is REORG TABLE. Both are declared per-entity
+ // and NOT global: on Db2 LUW there is no single whole-database RUNSTATS or REORG
+ // statement — each targets one table — so a global card would send the operation
+ // with no target, which `runMaintenance` correctly refuses (the broken "Update
+ // Statistics" button #… reported). Unlike Oracle, which has a real whole-schema
+ // form (`DBMS_STATS.GATHER_SCHEMA_STATS`) and so declares `global: true`, Db2's
+ // honest surface is per-table only. Doing "all tables" would mean iterating
+ // SYSCAT.TABLES and issuing one statement each, and for REORG that is a slow,
+ // lock-heavy operation across potentially thousands of tables — not something a
+ // single global button should fire. So these are offered from the schema
+ // explorer's per-table row menu, where a target exists, and no global card renders.
+ // `check`, `kill` and index rebuild stay off the list until a live pass. Widen after.
+ maintenanceOperations: ["analyze", "optimize"],
+ maintenanceOperationSpecs: {
+ analyze: { label: "Run Statistics", perEntity: true, global: false },
+ optimize: { label: "Reorganize Table", perEntity: true, global: false },
+ },
+ containerLevels: DB2_CONTAINER_LEVELS,
+ objectKinds: DB2_OBJECT_KINDS,
+ };
+ }
+
+ public override getLabels(): ProviderLabels {
+ return {
+ ...super.getLabels(),
+ analyzeAction: "Run Statistics",
+ // Db2 has no VACUUM; the reclaim/reorg operation is REORG TABLE, which this
+ // provider exposes as `optimize`. Point the vacuum slot at that operation so the
+ // schema-explorer row menu sends `optimize` rather than a `vacuum` this provider
+ // does not offer (the #496 shape).
+ vacuumAction: "Reorganize Table",
+ vacuumActionOperation: "optimize",
+ // No analyzeGlobal*/vacuumGlobal* overrides: both operations declare `global: false`
+ // (RUNSTATS/REORG are per-table on Db2), so the Operations tab renders no global card
+ // for them and those label triads would never be read. The generic inherited strings
+ // stay unused rather than stating a whole-database operation that does not exist.
+ //
+ // Db2's slow-query timings come from MON_GET_PKG_CACHE_STMT, but only once the database
+ // config `mon_req_metrics`/`mon_act_metrics` is enabled (getSlowQueries filters out the
+ // metric-less rows, §6). So the Queries panel's empty state is NOT "install
+ // pg_stat_statements" — that Postgres wording is actively false here — it is "turn on
+ // Db2's monitoring metrics". This label replaces it.
+ slowQueriesEmptyState:
+ "Db2 records per-statement timings only when the database's monitoring metrics are enabled. " +
+ "To see slow queries here, a DBA can run: UPDATE DB CFG FOR USING mon_req_metrics BASE " +
+ "(or mon_act_metrics BASE). The slowest cached statements then appear on the next refresh.",
+ };
+ }
+
+ // ============================================================================
+ // Validation
+ // ============================================================================
+
+ public validate(): void {
+ super.validate();
+
+ if (!this.config.connectionString) {
+ if (!this.config.host) {
+ throw new DatabaseConfigError("Host is required for Db2", "db2");
+ }
+ if (!this.config.database) {
+ throw new DatabaseConfigError("Database name is required for Db2", "db2");
+ }
+
+ // The DRDA connection string is a `KEY=VALUE;` attribute list with NO escaping for
+ // its delimiter. A field value containing `;` would split into extra attributes:
+ // a password `pa;ss` misparses (auth fails on `pa`), and a crafted value could
+ // INJECT an attribute (`PWD=x;SECURITY=NONE` was shown to connect). There is no
+ // brace/quote form the CLI driver honours (measured: `{value}` is taken literally),
+ // so the only safe answer for the field-built path is to refuse the delimiter and
+ // point the user at the connection-string field, which they own end to end.
+ for (const [name, value] of [
+ ["Host", this.config.host],
+ ["Database name", this.config.database],
+ ["Username", this.config.user],
+ ["Password", this.config.password],
+ ] as const) {
+ if (typeof value === "string" && value.includes(";")) {
+ throw new DatabaseConfigError(
+ `${name} contains a ';', which Db2's connection-string format cannot carry safely. ` +
+ "Use the connection-string field to pass it as a quoted DRDA attribute instead.",
+ "db2",
+ );
+ }
+ }
+ }
+ }
+
+ // ============================================================================
+ // Connection Management
+ // ============================================================================
+
+ /**
+ * The DRDA connection string `ibm_db.open` expects: a semicolon-delimited list of
+ * `KEY=VALUE` attributes. A pasted `connectionString` is returned unchanged so the
+ * user's own attributes (SECURITY, Authentication, etc.) decide, exactly as the
+ * Oracle and SQL Server providers return a pasted string untouched.
+ *
+ * SECURITY=SSL is Db2's own switch for TLS on the wire; it is added only when the SSL
+ * mode is not `disable`. This is the audited shape of the attribute list, not a claim
+ * about a verified TLS path — the live pass runs against a plaintext listener.
+ */
+ private buildConnectionString(): string {
+ if (this.config.connectionString) {
+ return this.config.connectionString;
+ }
+
+ const host = this.config.host || "localhost";
+ const port = this.config.port || 50000;
+ const parts = [
+ `DATABASE=${this.config.database}`,
+ `HOSTNAME=${host}`,
+ `PORT=${port}`,
+ "PROTOCOL=TCPIP",
+ `UID=${this.config.user ?? ""}`,
+ `PWD=${this.config.password ?? ""}`,
+ ];
+
+ if (this.config.ssl && this.config.ssl.mode !== "disable") {
+ parts.push("SECURITY=SSL");
+ }
+
+ return parts.join(";") + ";";
+ }
+
+ private open(driver: Db2Driver, connStr: string): Promise {
+ return new Promise((resolve, reject) => {
+ driver.open(connStr, (err, conn) => {
+ if (err) reject(err);
+ else resolve(conn);
+ });
+ });
+ }
+
+ public async connect(): Promise {
+ if (this.conn) {
+ return;
+ }
+
+ try {
+ // Dynamic import keeps the native addon out of the initial bundle and off the
+ // load path of every non-Db2 connection.
+ const mod = (await import("ibm_db")) as unknown as Db2Driver;
+ this.driver = mod;
+ this.conn = await this.open(this.driver, this.buildConnectionString());
+ this.setConnected(true);
+ } catch (error) {
+ this.setError(error instanceof Error ? error : new Error(String(error)));
+ throw new ConnectionError(
+ `Failed to connect to Db2: ${error instanceof Error ? error.message : error}`,
+ "db2",
+ this.config.host,
+ this.config.port,
+ );
+ }
+ }
+
+ public async disconnect(): Promise {
+ if (this.conn) {
+ const conn = this.conn;
+ this.conn = null;
+ await new Promise((resolve) => {
+ conn.close(() => resolve());
+ });
+ this.setConnected(false);
+ }
+ }
+
+ // ============================================================================
+ // Query Execution
+ // ============================================================================
+
+ // `ibm_db`'s `query(sql, params, cb)` binds `params` to the statement's `?`
+ // placeholders positionally. The driver reads a function in the params slot as
+ // the callback (`cb = params; params = null`), so a no-params call must pass the
+ // callback in the two-arg form rather than an empty array: some builds bind an
+ // empty array against a statement that has no markers and raise CLI0100E, the
+ // very "Wrong number of parameters" error a bound statement is meant to avoid.
+ private run(sql: string, params?: unknown[]): Promise[]> {
+ return new Promise((resolve, reject) => {
+ const cb = (err: Error | null, rows: Record[]) => {
+ if (err) reject(err);
+ else resolve(rows ?? []);
+ };
+ if (params !== undefined && params.length > 0) {
+ this.conn!.query(sql, params, cb);
+ } else {
+ this.conn!.query(sql, cb);
+ }
+ });
+ }
+
+ /**
+ * Run a monitoring read that may be refused, returning `[]` instead of throwing.
+ *
+ * The MON_GET_* table functions and SYSIBMADM.* views are permission-gated (SYSMON
+ * authority or an
+ * explicit grant). The DatabaseProvider contract for a gated source is to return empty
+ * rather than fail the panel, so a locked-down account degrades to a blank panel while a
+ * monitoring-authorized account gets real data. The engine's own message is logged, not
+ * surfaced, because the panel's own emptiness is the user-visible signal.
+ */
+ private async tryRun(sql: string): Promise[]> {
+ try {
+ return await this.run(sql);
+ } catch (error) {
+ logger.warn("[db2] monitoring read refused; returning empty", {
+ route: "db2/monitoring",
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return [];
+ }
+ }
+
+ public async query(sql: string, params?: unknown[]): Promise {
+ this.ensureConnected();
+
+ return this.trackQuery(async () => {
+ const { result, executionTime } = await this.measureExecution(async () => {
+ try {
+ return await this.run(sql, params);
+ } catch (error) {
+ throw mapDatabaseError(error, "db2", sql);
+ }
+ });
+
+ // `ibm_db` hands back an array of row objects keyed by column name and does not
+ // expose a declared-type name on its high-level query surface, so `fields` is
+ // derived from the first row's keys and `columnTypes` is OMITTED rather than
+ // guessed. Absence is the signal the grid reads (issue #273); adding declared
+ // types is a verified follow-up through the driver's prepared-statement
+ // describeColumns surface. Documented in docs/providers/db2.md.
+ const rows = result as Record[];
+ const fields = rows.length > 0 ? Object.keys(rows[0]) : [];
+
+ return {
+ rows,
+ fields,
+ rowCount: rows.length,
+ executionTime,
+ };
+ });
+ }
+
+ // ============================================================================
+ // Query Preparation (Db2 FETCH FIRST — same shape as Oracle)
+ // ============================================================================
+
+ public override prepareQuery(query: string, options: QueryPrepareOptions = {}): PreparedQuery {
+ const { limit = DEFAULT_QUERY_LIMIT, offset = 0, unlimited = false } = options;
+ const effectiveLimit = unlimited ? MAX_UNLIMITED_ROWS : limit;
+ const queryInfo = analyzeQuery(query, this.type);
+
+ if (queryInfo.type === "SELECT" && !queryInfo.hasLimit) {
+ // Build on the statement's own text and re-attach whatever trailed it, declining
+ // when the tail cannot be safely cut — the same guard Oracle's prepareQuery uses,
+ // so a clause is never appended inside a trailing comment (#280). Db2 spells the
+ // page `OFFSET n ROWS FETCH NEXT n ROWS ONLY` and the first page
+ // `FETCH FIRST n ROWS ONLY`, identical to Oracle 12c+.
+ const source = query.trim();
+ const { end, rewritable } = readStatementEnd(source, resolveSqlGrammar(this.type));
+ if (!rewritable) {
+ return { query, wasLimited: false, limit: effectiveLimit, offset };
+ }
+
+ const head = source.slice(0, end);
+ const trailing = source.slice(end);
+
+ const clause =
+ offset > 0
+ ? `OFFSET ${offset} ROWS FETCH NEXT ${effectiveLimit} ROWS ONLY`
+ : `FETCH FIRST ${effectiveLimit} ROWS ONLY`;
+
+ return {
+ query: `${head} ${clause}${trailing}`,
+ wasLimited: true,
+ limit: effectiveLimit,
+ offset,
+ };
+ }
+
+ return { query, wasLimited: false, limit: effectiveLimit, offset };
+ }
+
+ // ============================================================================
+ // Object surface (#786, #789)
+ // ============================================================================
+
+ /** A catalog read, with the driver's error mapped onto the shared classes. */
+ private async runCatalog(sql: string, params: unknown[]): Promise[]> {
+ try {
+ return await this.run(sql, params);
+ } catch (error) {
+ throw mapDatabaseError(error, "db2", sql);
+ }
+ }
+
+ /** The schemas this connection can see. One level, so nothing nests under a schema. */
+ public async listContainers(parent?: readonly string[]): Promise {
+ this.ensureConnected();
+ if (parent !== undefined && parent.length > 0) return [];
+ const rows = await this.runCatalog(CONTAINERS_SQL, []);
+ return containersFromRows(rows as unknown as ContainerRow[]);
+ }
+
+ /**
+ * How many objects of each declared kind one schema holds, in one statement. A refused read
+ * carries Db2's own sentence against every kind rather than a zero nobody measured.
+ */
+ public async countObjects(container: readonly string[]): Promise> {
+ this.ensureConnected();
+ const capabilities = this.getCapabilities();
+ const schema = containerSchema(capabilities, container);
+ const kinds = declaredKinds(capabilities);
+ try {
+ const rows = await this.run(COUNTS_SQL, [schema, schema, schema, schema, schema]);
+ return countsFromRows(kinds, rows as unknown as KindCountRow[]);
+ } catch (error) {
+ return unavailableCounts(kinds, error);
+ }
+ }
+
+ /** The objects of one kind in one schema, sorted by address. */
+ public async listObjects(container: readonly string[], kind: string): Promise {
+ this.ensureConnected();
+ const capabilities = this.getCapabilities();
+ const schema = containerSchema(capabilities, container);
+ requireKind(capabilities, kind);
+ const statement = listingStatement(schema, kind);
+ if (statement === undefined) {
+ throw new QueryError(`Db2 declares the kind "${kind}" but has no statement that lists it`, "db2");
+ }
+ const rows = await this.runCatalog(statement.sql, statement.params);
+ return (rows as unknown as ObjectRow[])
+ .map((row) => objectFromRow(container, kind, row))
+ .sort((left, right) => comparePaths(left.path, right.path));
+ }
+
+ /**
+ * Columns, indexes and foreign keys for one object. Only the three relation kinds have any,
+ * so an alias, a sequence, a module, a routine and a trigger answer empty with no round trip.
+ */
+ public async describeObject(path: readonly string[], kind: string): Promise {
+ this.ensureConnected();
+ const capabilities = this.getCapabilities();
+ const spec = requireKind(capabilities, kind);
+ assertObjectPathShape(capabilities, spec, path);
+ if (relationTableType(kind) === undefined) {
+ return { path: [...path], columns: [], indexes: [], foreignKeys: [] };
+ }
+ const { schema, name } = objectAddress(capabilities, path);
+ const binds = [schema, name];
+ const columns = await this.runCatalog(OBJECT_COLUMNS_SQL, binds);
+ const foreignKeys = await this.runCatalog(OBJECT_FOREIGN_KEYS_SQL, binds);
+ const indexes = await this.runCatalog(OBJECT_INDEXES_SQL, binds);
+ return objectDetailFromRows(path, schema, { columns, foreignKeys, indexes });
+ }
+
+ /**
+ * Columns, indexes and foreign keys for EVERY object of one relation kind in one schema, in
+ * four round trips. The bound is the caller's: the target read asks for one row more, so a
+ * saturated read is told apart from an exact one without a second count.
+ */
+ public async describeObjects(container: readonly string[], kind: string, limit?: number): Promise {
+ this.ensureConnected();
+ const capabilities = this.getCapabilities();
+ requireKind(capabilities, kind);
+ const schema = containerSchema(capabilities, container);
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
+ throw new QueryError(`A Db2 bulk column read limit must be a positive whole number, received ${limit}`, "db2");
+ }
+ const type = relationTableType(kind);
+ if (type === undefined) return { details: [] };
+
+ const bounded = limit !== undefined;
+ const binds = bounded ? [schema, type, limit + 1] : [schema, type];
+ const targets = await this.runCatalog(bulkTargetSql(bounded), binds);
+ const truncated = bounded && targets.length > limit;
+ const described = truncated ? targets.slice(0, limit) : targets;
+ if (described.length === 0) return { details: [] };
+
+ const statements = bulkDetailSql(bounded);
+ const detailBinds = [...binds, schema];
+ const columns = byObjectName(await this.runCatalog(statements.columns, detailBinds));
+ const foreignKeys = byObjectName(await this.runCatalog(statements.foreignKeys, detailBinds));
+ const indexes = byObjectName(await this.runCatalog(statements.indexes, detailBinds));
+
+ const details = described
+ .map((row) => {
+ const name = String(row.OBJECT_NAME);
+ return objectDetailFromRows([...container, name], schema, {
+ columns: columns.get(name) ?? [],
+ foreignKeys: foreignKeys.get(name) ?? [],
+ indexes: indexes.get(name) ?? [],
+ });
+ })
+ .sort((left, right) => comparePaths(left.path, right.path));
+ return truncated ? { details, truncated: { limit, reason: callerBoundTruncationReason(limit) } } : { details };
+ }
+
+ /**
+ * ONE object's definition, from the text SYSCAT stores for it. An object the read cannot find
+ * RAISES; a routine Db2 keeps no text for answers a refusal part saying why.
+ */
+ public async readObjectSource(path: readonly string[], kind: string, limit?: number): Promise {
+ this.ensureConnected();
+ const capabilities = this.getCapabilities();
+ const spec = requireSourceKind(capabilities, kind, { displayName: "Db2", type: "db2" });
+ assertObjectPathShape(capabilities, spec, path);
+ const address = objectAddress(capabilities, path);
+ const statement = sourceStatement(kind, address, path);
+ const [row] = await this.runCatalog(statement.sql, statement.params);
+ const where = `${path.slice(0, -1).join(".")}.${address.name}`;
+ if (row === undefined) {
+ throw new QueryError(
+ `Db2 holds no ${spec.label.toLowerCase()} called "${address.name}" in ${path.slice(0, -1).join(".")}`,
+ "db2",
+ statement.sql,
+ );
+ }
+ return { path: [...path], kind, parts: [sourcePartFromRow(row, spec.sourceLanguage, limit, where)] };
+ }
+
+ // ============================================================================
+ // Health & Monitoring
+ // ============================================================================
+
+ /**
+ * A private reader for the active-connection count, absent (not 0) on a refused read.
+ * Shared by `getHealth` and `getOverview` so the absence travels through both the way
+ * the `DatabaseOverview.activeConnections`/`HealthInfo.activeConnections` docblocks
+ * require: a denied MON_GET_CONNECTION must not reach the agent as a measured zero.
+ */
+ private async readActiveConnections(): Promise {
+ const rows = await this.tryRun(ACTIVE_CONNECTIONS_SQL);
+ if (rows.length === 0) return undefined;
+ const n = Number(rows[0].N);
+ return Number.isFinite(n) ? n : undefined;
+ }
+
+ public async getHealth(): Promise {
+ this.ensureConnected();
+
+ const cacheRatio = await this.readCacheHitRatio();
+ const activeConnections = await this.readActiveConnections();
+
+ return {
+ databaseSize: "N/A",
+ cacheHitRatio: cacheRatio === undefined ? CACHE_HIT_RATIO_UNAVAILABLE : formatCacheHitRatio(cacheRatio),
+ slowQueries: [],
+ activeSessions: [],
+ ...(activeConnections === undefined ? {} : { activeConnections }),
+ };
+ }
+
+ public async getOverview(): Promise {
+ this.ensureConnected();
+
+ let version = "Unknown";
+ try {
+ const rows = await this.run(VERSION_SQL);
+ if (rows.length > 0 && rows[0].SERVICE_LEVEL !== undefined && rows[0].SERVICE_LEVEL !== null) {
+ version = String(rows[0].SERVICE_LEVEL);
+ }
+ } catch {
+ // ENV_GET_INST_INFO is permission-gated; a denied read leaves the neutral default
+ // rather than failing the whole overview.
+ }
+
+ const activeConnections = await this.readActiveConnections();
+
+ // Catalog counts are always readable; the activation time and tablespace sizing are
+ // permission-gated, so each is read through tryRun and simply omitted (left "N/A"/0)
+ // on refusal rather than failing the overview.
+ let tableCount = 0;
+ let indexCount = 0;
+ const countRows = await this.tryRun(CATALOG_COUNTS_SQL);
+ if (countRows.length > 0) {
+ tableCount = Number(countRows[0].TABLE_COUNT) || 0;
+ indexCount = Number(countRows[0].INDEX_COUNT) || 0;
+ }
+
+ // Configured connection ceiling (maxappls). Left 0 when the config read is refused —
+ // 0 is the DatabaseOverview convention for "not known" on this required field.
+ let maxConnections = 0;
+ const maxConnRows = await this.tryRun(MAX_CONNECTIONS_SQL);
+ if (maxConnRows.length > 0) {
+ const parsed = Number(maxConnRows[0].VALUE);
+ if (Number.isFinite(parsed) && parsed > 0) maxConnections = parsed;
+ }
+
+ // Uptime from the database activation time, computed in the DB (see DB_UPTIME_SECONDS_SQL:
+ // doing it here would misfire across timezones). Negative or nonsensical values are
+ // rejected rather than shown.
+ let uptime = "N/A";
+ const uptimeRows = await this.tryRun(DB_UPTIME_SECONDS_SQL);
+ if (uptimeRows.length > 0) {
+ const seconds = Number(uptimeRows[0].UPTIME_SECONDS);
+ if (Number.isFinite(seconds) && seconds >= 0) {
+ uptime = formatDuration(seconds * 1000);
+ }
+ }
+
+ // Database size as the sum of used tablespace bytes — the same STORAGE_SQL the Storage
+ // panel reads, so no extra source. Both the formatted string and the numeric byte
+ // figure are set: fleet-health and the Overview total consume `databaseSizeBytes`
+ // (absence, not 0, means "unpublished"), while the overview card shows the string.
+ // Omitted ("N/A", bytes absent) when the read is refused.
+ let databaseSize = "N/A";
+ let databaseSizeBytes: number | undefined;
+ const tbspRows = await this.tryRun(STORAGE_SQL);
+ if (tbspRows.length > 0) {
+ const totalBytes = tbspRows.reduce((sum, r) => {
+ const usedPages = Number(r.TBSP_USED_PAGES) || 0;
+ const pageSize = Number(r.TBSP_PAGE_SIZE) || 0;
+ return sum + usedPages * pageSize;
+ }, 0);
+ if (totalBytes > 0) {
+ databaseSize = formatBytes(totalBytes);
+ databaseSizeBytes = totalBytes;
+ }
+ }
+
+ return {
+ version,
+ uptime,
+ maxConnections,
+ databaseSize,
+ ...(databaseSizeBytes === undefined ? {} : { databaseSizeBytes }),
+ tableCount,
+ indexCount,
+ ...(activeConnections === undefined ? {} : { activeConnections }),
+ };
+ }
+
+ /**
+ * Buffer-pool cache hit ratio as a percentage, or `undefined` when it cannot be read.
+ *
+ * `(logical - physical) / logical` over every buffer pool: the fraction of page reads
+ * served from memory rather than disk. `undefined` (not 0) on a refused read or a pool
+ * with no reads yet, because `DEFAULT_THRESHOLDS` scores this `direction: "below"` and a
+ * fabricated 0 would paint a critical cache fault on a healthy, idle database.
+ */
+ private async readCacheHitRatio(): Promise {
+ const rows = await this.tryRun(BUFFERPOOL_SQL);
+ if (rows.length === 0) return undefined;
+ const logical = Number(rows[0].LOGICAL_READS);
+ const physical = Number(rows[0].PHYSICAL_READS);
+ if (!Number.isFinite(logical) || logical <= 0) return undefined;
+ const ratio = ((logical - physical) / logical) * 100;
+ if (!Number.isFinite(ratio)) return undefined;
+ // Clamp: a pool can report physical > logical transiently, which would push this past
+ // 100 or below 0 — neither is a real hit ratio.
+ return Math.max(0, Math.min(100, Math.round(ratio * 10) / 10));
+ }
+
+ /**
+ * Real performance metrics from the buffer pools and database counters, not a neutral
+ * empty. The cache hit ratio is omitted (never zeroed) when the read is refused or the
+ * pools are idle. Deadlocks is a measured cumulative count since activation — a real 0
+ * is kept (it is a fact), and only a refused read omits it.
+ */
+ public async getPerformanceMetrics(): Promise {
+ this.ensureConnected();
+ const cacheHitRatio = await this.readCacheHitRatio();
+ const deadlockRows = await this.tryRun(DEADLOCKS_SQL);
+ const deadlocks = deadlockRows.length > 0 ? Number(deadlockRows[0].DEADLOCKS) : undefined;
+ return {
+ ...(cacheHitRatio === undefined ? {} : { cacheHitRatio }),
+ ...(deadlocks !== undefined && Number.isFinite(deadlocks) ? { deadlocks } : {}),
+ };
+ }
+
+ /**
+ * The costliest statements in the package cache, by activity time. Db2 only records
+ * per-statement execution times when the database's `mon_req_metrics`/`mon_act_metrics`
+ * config is enabled; with metrics off, `NUM_EXEC_WITH_METRICS` is 0 and the query filters
+ * those rows out, so the panel shows real timings when the server collects them and its
+ * empty state — not a list of misleading 0.00 ms rows — when it does not. `TOTAL_ACT_TIME`
+ * is milliseconds across the metrics-bearing executions; the average is derived per row.
+ * Refused reads return `[]` (see `tryRun`).
+ */
+ public async getSlowQueries(): Promise {
+ this.ensureConnected();
+ const rows = await this.tryRun(SLOW_QUERIES_SQL);
+ return rows.map((r) => {
+ const calls = Number(r.NUM_EXECUTIONS) || 0;
+ const totalTime = Number(r.TOTAL_ACT_TIME) || 0;
+ return {
+ query: String(r.STMT_TEXT ?? "").trim(),
+ calls,
+ totalTime,
+ avgTime: calls > 0 ? Math.round((totalTime / calls) * 10) / 10 : 0,
+ rows: Number(r.ROWS_READ) || 0,
+ };
+ });
+ }
+
+ /**
+ * Live connections from `MON_GET_CONNECTION`. Db2 has no single "current statement" or
+ * per-connection state column on this surface the way PostgreSQL's `pg_stat_activity`
+ * does, so `state` is reported as `"active"` (the row exists because the connection is
+ * live) and `query` is left empty rather than invented. Refused reads return `[]`.
+ */
+ public async getActiveSessions(): Promise {
+ this.ensureConnected();
+ const rows = await this.tryRun(SESSIONS_SQL);
+ return rows.map((r) => ({
+ pid: String(r.APPLICATION_HANDLE ?? ""),
+ user: String(r.SYSTEM_AUTH_ID ?? "").trim(),
+ database: this.config.database ?? "",
+ applicationName: r.APPLICATION_NAME ? String(r.APPLICATION_NAME).trim() : undefined,
+ clientAddr: r.CLIENT_IPADDR ? String(r.CLIENT_IPADDR).trim() : undefined,
+ state: "active",
+ query: "",
+ duration: "N/A",
+ durationMs: 0,
+ }));
+ }
+
+ /**
+ * Real per-table stats from the catalog, not neutral empties like the other monitoring
+ * surfaces: SYSCAT.TABLES publishes a row count (CARD) and the timestamp of the RUNSTATS
+ * that produced it (STATS_TIME), so this returns them rather than hiding data the engine
+ * has. The currency caveat is real and preserved rather than smoothed over: CARD is only
+ * as fresh as the last RUNSTATS, which can be years old, and a table that never had
+ * RUNSTATS reports CARD = -1 with STATS_TIME = NULL. Such a table is mapped to rowCount 0
+ * with NO `lastAnalyze`, so a reader sees "no stats" instead of a fabricated -1; a table
+ * with stats carries `lastAnalyze` so the age of the number is visible. Size fields are
+ * omitted (the required `totalSize`/`totalSizeBytes` carry the "N/A"/0 placeholder the
+ * SQLite provider established) because Db2's only per-table size is a one-table-at-a-time
+ * table function — see TABLE_STATS_SQL. Documented in docs/providers/db2.md.
+ */
+ public async getTableStats(): Promise {
+ this.ensureConnected();
+ const rows = await this.run(TABLE_STATS_SQL);
+ return rows.map((r) => {
+ const card = Number(r.CARD);
+ const rowCount = Number.isFinite(card) && card >= 0 ? card : 0;
+ const statsTime = r.STATS_TIME;
+ const lastAnalyze = statsTime ? new Date(String(statsTime)) : undefined;
+ return {
+ schemaName: String(r.TABSCHEMA).trimEnd(),
+ tableName: String(r.TABNAME).trimEnd(),
+ rowCount,
+ totalSize: "N/A",
+ totalSizeBytes: 0,
+ ...(lastAnalyze && !Number.isNaN(lastAnalyze.getTime()) ? { lastAnalyze } : {}),
+ };
+ });
+ }
+
+ /**
+ * Per-index stats for the Indexes panel, scoped to CURRENT SCHEMA. Structural fields are
+ * real (SYSCAT), scan counts are real where MON_GET_INDEX has them (0 otherwise), and size
+ * is left as the honest "N/A" placeholder — see INDEX_STATS_SQL. Refused reads return `[]`.
+ */
+ public async getIndexStats(): Promise {
+ this.ensureConnected();
+ const rows = await this.tryRun(INDEX_STATS_SQL);
+ return rows.map((r) => {
+ const uniqueRule = String(r.UNIQUERULE ?? "").trim();
+ const cols = String(r.COLS ?? "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+ const indexType = String(r.INDEXTYPE ?? "").trim();
+ return {
+ schemaName: String(r.INDSCHEMA ?? "").trim() || "",
+ tableName: String(r.TABNAME ?? "").trim(),
+ indexName: String(r.INDNAME ?? "").trim(),
+ ...(indexType ? { indexType } : {}),
+ columns: cols,
+ // Db2 UNIQUERULE: 'P' primary key, 'U' unique, 'D' duplicates allowed.
+ isUnique: uniqueRule === "P" || uniqueRule === "U",
+ isPrimary: uniqueRule === "P",
+ indexSize: "N/A",
+ scans: Number(r.SCANS) || 0,
+ };
+ });
+ }
+
+ /**
+ * Tablespace sizing from `MON_GET_TABLESPACE`: used pages × page size is the bytes on
+ * disk, and used/total is the fill percentage. This is Db2's real storage breakdown
+ * (SYSCATSPACE, USERSPACE1, temp spaces, …). Refused reads return `[]` (see `tryRun`).
+ */
+ public async getStorageStats(): Promise {
+ this.ensureConnected();
+ const rows = await this.tryRun(STORAGE_SQL);
+ return rows.map((r) => {
+ const usedPages = Number(r.TBSP_USED_PAGES) || 0;
+ const totalPages = Number(r.TBSP_TOTAL_PAGES) || 0;
+ const pageSize = Number(r.TBSP_PAGE_SIZE) || 0;
+ const sizeBytes = usedPages * pageSize;
+ return {
+ name: String(r.TBSP_NAME ?? "").trim(),
+ size: formatBytes(sizeBytes),
+ sizeBytes,
+ ...(totalPages > 0 ? { usagePercent: Math.round((usedPages / totalPages) * 1000) / 10 } : {}),
+ };
+ });
+ }
+
+ // ============================================================================
+ // Maintenance
+ // ============================================================================
+
+ /**
+ * `analyze` → RUNSTATS, `optimize` → REORG TABLE. Both take a table name and both run
+ * through the CALL ADMIN_CMD interface, which is how Db2 exposes these command-line
+ * utilities to SQL.
+ */
+ /**
+ * A table name as it sits inside ADMIN_CMD's string argument. Two escapes, in order: the
+ * identifier's own `"` doubled for the delimited name, then every `'` doubled because the whole
+ * command is a SQL string literal. With only the first, a table called `O'Brien` ended the
+ * literal early and the rest of its name was read as SQL.
+ */
+ private adminCommandTable(target: string): string {
+ return this.escapeIdentifier(target).replaceAll("'", "''");
+ }
+
+ public async runMaintenance(type: MaintenanceType, target?: string): Promise {
+ this.ensureConnected();
+
+ const start = Date.now();
+ try {
+ if (type === "analyze") {
+ if (!target) {
+ throw new DatabaseConfigError("A table name is required for RUNSTATS", "db2");
+ }
+ const table = this.adminCommandTable(target);
+ await this.run(
+ `CALL SYSPROC.ADMIN_CMD('RUNSTATS ON TABLE ${table} WITH DISTRIBUTION AND DETAILED INDEXES ALL')`,
+ );
+ return { success: true, executionTime: Date.now() - start, message: `RUNSTATS completed on ${target}` };
+ }
+
+ if (type === "optimize") {
+ if (!target) {
+ throw new DatabaseConfigError("A table name is required for REORG", "db2");
+ }
+ const table = this.adminCommandTable(target);
+ await this.run(`CALL SYSPROC.ADMIN_CMD('REORG TABLE ${table}')`);
+ return { success: true, executionTime: Date.now() - start, message: `REORG completed on ${target}` };
+ }
+
+ throw new DatabaseConfigError(`Unsupported maintenance operation for Db2: ${type}`, "db2");
+ } catch (error) {
+ throw mapDatabaseError(error, "db2");
+ }
+ }
+}
diff --git a/src/lib/db/providers/sql/db2/objects.ts b/src/lib/db/providers/sql/db2/objects.ts
new file mode 100644
index 000000000..7fc1f577b
--- /dev/null
+++ b/src/lib/db/providers/sql/db2/objects.ts
@@ -0,0 +1,647 @@
+/**
+ * The Db2 LUW object surface (#786, #789): what the engine holds, as containers, kinds and
+ * objects, read from the SYSCAT catalog views.
+ *
+ * Everything here is either a statement or a pure function over the rows a statement
+ * answered, so the provider in `index.ts` only runs statements and every rule about Db2's
+ * catalog lives in one file. The facts each rule rests on were measured against
+ * icr.io/db2_community/db2:12.1.0.0 with `docker/db2-init/01-object-fixture.sql` and are
+ * recorded in `docs/providers/db2.md`.
+ *
+ * NOTHING A CALLER SUPPLIES IS INTERPOLATED. A schema, an object name, a catalog type code
+ * and a row bound all reach Db2 as `?` markers, so no identifier escaper is involved in any
+ * read below.
+ */
+import { QueryError } from "../../../errors";
+import { applySourceBound, containerDepth, declaredKinds } from "../../../object-kinds";
+import type {
+ ColumnSchema,
+ Container,
+ ContainerLevelSpec,
+ ContainerLevels,
+ DatabaseObject,
+ ForeignKeySchema,
+ IndexSchema,
+ KindCount,
+ ObjectDetail,
+ ObjectKindSpec,
+ ObjectSourcePart,
+ ProviderCapabilities,
+} from "../../../types";
+
+const SOURCE = { hasSource: true, sourceLanguage: "sql" } as const;
+
+export const DB2_CONTAINER_LEVELS: ContainerLevels = [{ id: "schema", label: "Schema", labelPlural: "Schemas" }];
+
+/**
+ * Nine kinds, each answered by one SYSCAT view.
+ *
+ * Source is declared where SYSCAT keeps the statement the author ran: VIEWS.TEXT for a view
+ * and for a materialized query table, ROUTINES.TEXT and TRIGGERS.TEXT. A table, an alias, a
+ * sequence and a module have no stored text, and Db2 offers no read-only way to generate one
+ * (`db2look` is a client tool and `SYSPROC.DB2LK_GENERATE_DDL` writes to SYSTOOLS tables).
+ *
+ * No `index` kind: SYSCAT.INDEXES is keyed by the table an index is on, so an index belongs in
+ * `describeObject`'s answer. No `nickname` kind: nicknames exist only with federation enabled,
+ * and the fixture cannot produce one.
+ */
+export const DB2_OBJECT_KINDS: readonly ObjectKindSpec[] = [
+ { id: "table", role: "relation", label: "Table", labelPlural: "Tables", acceptsRowWrites: true },
+ { id: "view", role: "relation", label: "View", labelPlural: "Views", ...SOURCE },
+ {
+ id: "materialized_query_table",
+ role: "relation",
+ label: "Materialized Query Table",
+ labelPlural: "Materialized Query Tables",
+ ...SOURCE,
+ },
+ { id: "alias", role: "config", label: "Alias", labelPlural: "Aliases" },
+ { id: "sequence", role: "config", label: "Sequence", labelPlural: "Sequences" },
+ // A module is ONE node holding routines, the same leaf-group shape as an Oracle package: its
+ // routines carry ROUTINEMODULENAME and are left out of the schema-level routine folders.
+ { id: "module", role: "group", label: "Module", labelPlural: "Modules", childKinds: ["procedure", "function"] },
+ { id: "procedure", role: "routine", label: "Procedure", labelPlural: "Procedures", ...SOURCE },
+ { id: "function", role: "routine", label: "Function", labelPlural: "Functions", ...SOURCE },
+ { id: "trigger", role: "attached", label: "Trigger", labelPlural: "Triggers", attachedTo: "table", ...SOURCE },
+];
+
+/** SYSCAT.TABLES.TYPE per relation-shaped kind. */
+const TABLE_TYPE: Readonly> = {
+ table: "T",
+ view: "V",
+ materialized_query_table: "S",
+ alias: "A",
+};
+
+/** SYSCAT.ROUTINES.ROUTINETYPE per routine kind. */
+const ROUTINE_TYPE: Readonly> = { procedure: "P", function: "F" };
+
+/** What `COUNTS_SQL` labels each row with, and the kind that row counts. */
+const KIND_BY_COUNT_LABEL: Readonly> = {
+ ...Object.fromEntries(Object.entries(TABLE_TYPE).map(([kind, type]) => [`TABLES:${type}`, kind])),
+ ...Object.fromEntries(Object.entries(ROUTINE_TYPE).map(([kind, type]) => [`ROUTINES:${type}`, kind])),
+ SEQUENCES: "sequence",
+ MODULES: "module",
+ TRIGGERS: "trigger",
+};
+
+// ----------------------------------------------------------------------------
+// Statements
+// ----------------------------------------------------------------------------
+
+/**
+ * The schemas, minus the ones Db2 owns.
+ *
+ * By NAME and not by owner, because the owner cannot tell them apart: measured, the schema Db2
+ * creates implicitly on a user's first unqualified CREATE is OWNER SYSIBM, OWNERTYPE 'S', the
+ * same as SYSCAT's. An upper-case `SYS` prefix is reserved (CREATE SCHEMA SYSX answers
+ * SQL0553N), and NULLID and SQLJ are created with every database. A delimited lower-case
+ * `"sysx"` is a legal user schema, and `LIKE` is case-sensitive, so it stays listed.
+ *
+ * RTRIM because SCHEMANAME comes back blank-padded to eight characters.
+ */
+export const CONTAINERS_SQL = `SELECT RTRIM(SCHEMANAME) AS NAME,
+ CASE WHEN SCHEMANAME = CURRENT SCHEMA THEN 1 ELSE 0 END AS IS_SESSION_DEFAULT
+ FROM SYSCAT.SCHEMATA
+ WHERE SCHEMANAME NOT LIKE 'SYS%' AND SCHEMANAME NOT IN ('NULLID', 'SQLJ')`;
+
+/**
+ * How many of each kind one schema holds, in ONE statement that binds the schema five times.
+ *
+ * A routine counts only when it is not in a module and its ORIGIN is one a person wrote:
+ * E external, F federated, Q SQL-bodied, U sourced. B (built-in), M (template), R and S
+ * (system-generated) are Db2's own. A sequence counts only with SEQTYPE 'S': 'I' is the
+ * sequence behind an identity column and 'A' an alias of a sequence.
+ */
+export const COUNTS_SQL = `SELECT 'TABLES:' CONCAT TYPE AS KIND, COUNT(*) AS N
+ FROM SYSCAT.TABLES WHERE TABSCHEMA = ? AND TYPE IN ('T', 'V', 'S', 'A') GROUP BY TYPE
+ UNION ALL
+ SELECT 'SEQUENCES', COUNT(*) FROM SYSCAT.SEQUENCES WHERE SEQSCHEMA = ? AND SEQTYPE = 'S'
+ UNION ALL
+ SELECT 'MODULES', COUNT(*) FROM SYSCAT.MODULES WHERE MODULESCHEMA = ? AND MODULETYPE IN ('M', 'P')
+ UNION ALL
+ SELECT 'ROUTINES:' CONCAT ROUTINETYPE, COUNT(*)
+ FROM SYSCAT.ROUTINES
+ WHERE ROUTINESCHEMA = ? AND ROUTINETYPE IN ('P', 'F') AND ROUTINEMODULENAME IS NULL
+ AND ORIGIN IN ('E', 'F', 'Q', 'U')
+ GROUP BY ROUTINETYPE
+ UNION ALL
+ SELECT 'TRIGGERS', COUNT(*) FROM SYSCAT.TRIGGERS WHERE TRIGSCHEMA = ?`;
+
+/** Tables, views, materialized query tables and aliases. VIEWS.VALID answers for the two view shapes only. */
+const LIST_TABLES_SQL = `SELECT t.TABNAME AS NAME, t.STATUS, t.CARD, v.VALID
+ FROM SYSCAT.TABLES t
+ LEFT JOIN SYSCAT.VIEWS v ON v.VIEWSCHEMA = t.TABSCHEMA AND v.VIEWNAME = t.TABNAME
+ WHERE t.TABSCHEMA = ? AND t.TYPE = ?`;
+
+const LIST_SEQUENCES_SQL = `SELECT SEQNAME AS NAME FROM SYSCAT.SEQUENCES WHERE SEQSCHEMA = ? AND SEQTYPE = 'S'`;
+
+const LIST_MODULES_SQL = `SELECT MODULENAME AS NAME FROM SYSCAT.MODULES WHERE MODULESCHEMA = ? AND MODULETYPE IN ('M', 'P')`;
+
+/**
+ * Routines of one type. SPECIFICNAME is the address: Db2 overloads a routine name by its
+ * parameter types, and measured, the two ORDER_TOTAL functions in the fixture carry one
+ * ROUTINENAME and two SPECIFICNAMEs. It is also the name `DROP SPECIFIC FUNCTION` takes.
+ */
+const LIST_ROUTINES_SQL = `SELECT SPECIFICNAME AS SEGMENT, ROUTINENAME AS NAME, VALID
+ FROM SYSCAT.ROUTINES
+ WHERE ROUTINESCHEMA = ? AND ROUTINETYPE = ? AND ROUTINEMODULENAME IS NULL
+ AND ORIGIN IN ('E', 'F', 'Q', 'U')`;
+
+/**
+ * Triggers, with the table they nest under when there is one in THIS schema.
+ *
+ * A trigger's schema may differ from its table's (measured: REPORTING.ORDERS_AUDIT fires on
+ * APP.ORDERS). Nesting it under `[REPORTING, ORDERS]` would address a table that does not
+ * exist, so such a trigger hangs off its own schema instead, which is the shape an attached
+ * kind with no base object in the container already takes.
+ */
+const LIST_TRIGGERS_SQL = `SELECT TRIGNAME AS NAME,
+ CASE WHEN TABSCHEMA = TRIGSCHEMA THEN TABNAME END AS PARENT,
+ VALID
+ FROM SYSCAT.TRIGGERS
+ WHERE TRIGSCHEMA = ?`;
+
+/**
+ * Columns. KEYSEQ is the column's position in the PRIMARY KEY and NULL outside it, so no
+ * separate key read is needed. `CODEPAGE` 0 on a character type is `FOR BIT DATA`.
+ */
+const COLUMNS_SELECT = `COLNAME AS COLUMN_NAME, TYPENAME, LENGTH, SCALE, CODEPAGE, NULLS, "DEFAULT" AS DEFAULT_VALUE, KEYSEQ`;
+
+/**
+ * Foreign keys, one row per column pair.
+ *
+ * The referenced key is joined on its TABLE as well as its schema and name: a constraint name
+ * is unique per table, not per schema (measured, two tables in APP both carried a key named
+ * PK), so a join without TABNAME pairs one foreign key with every same-named key in the schema.
+ */
+const FOREIGN_KEYS_FROM = `FROM SYSCAT.REFERENCES r
+ JOIN SYSCAT.KEYCOLUSE fk
+ ON fk.CONSTNAME = r.CONSTNAME AND fk.TABSCHEMA = r.TABSCHEMA AND fk.TABNAME = r.TABNAME
+ JOIN SYSCAT.KEYCOLUSE pk
+ ON pk.CONSTNAME = r.REFKEYNAME AND pk.TABSCHEMA = r.REFTABSCHEMA AND pk.TABNAME = r.REFTABNAME
+ AND pk.COLSEQ = fk.COLSEQ`;
+const FOREIGN_KEYS_SELECT = `fk.COLNAME AS COLUMN_NAME, RTRIM(r.REFTABSCHEMA) AS REF_SCHEMA, r.REFTABNAME AS REF_TABLE, pk.COLNAME AS REF_COLUMN`;
+
+/**
+ * Indexes, one row per column. Filtered by the TABLE's schema: a system-generated key index
+ * lives in INDSCHEMA SYSIBM while its table is in the user's schema (measured on the fixture's
+ * "Mixed Case" table), so filtering by INDSCHEMA would drop it.
+ */
+const INDEXES_FROM = `FROM SYSCAT.INDEXES i
+ JOIN SYSCAT.INDEXCOLUSE ic ON ic.INDSCHEMA = i.INDSCHEMA AND ic.INDNAME = i.INDNAME`;
+const INDEXES_SELECT = `RTRIM(i.INDSCHEMA) AS INDEX_SCHEMA, i.INDNAME AS INDEX_NAME, i.UNIQUERULE, ic.COLNAME AS COLUMN_NAME`;
+
+export const OBJECT_COLUMNS_SQL = `SELECT ${COLUMNS_SELECT}
+ FROM SYSCAT.COLUMNS WHERE TABSCHEMA = ? AND TABNAME = ? ORDER BY COLNO`;
+
+export const OBJECT_FOREIGN_KEYS_SQL = `SELECT ${FOREIGN_KEYS_SELECT}
+ ${FOREIGN_KEYS_FROM}
+ WHERE r.TABSCHEMA = ? AND r.TABNAME = ?
+ ORDER BY r.CONSTNAME, fk.COLSEQ`;
+
+export const OBJECT_INDEXES_SQL = `SELECT ${INDEXES_SELECT}
+ ${INDEXES_FROM}
+ WHERE i.TABSCHEMA = ? AND i.TABNAME = ?
+ ORDER BY i.INDSCHEMA, i.INDNAME, ic.COLSEQ`;
+
+/**
+ * The objects a bulk read describes, bound `[schema, type]` or `[schema, type, limit + 1]`.
+ *
+ * The bound reads ONE row more than the caller asked for, so the read itself says whether it
+ * stopped short. `FETCH FIRST ? ROWS ONLY` binds, measured, including inside the CTE below.
+ */
+export function bulkTargetSql(bounded: boolean): string {
+ return `SELECT TABNAME AS OBJECT_NAME FROM SYSCAT.TABLES
+ WHERE TABSCHEMA = ? AND TYPE = ?
+ ORDER BY TABNAME${bounded ? " FETCH FIRST ? ROWS ONLY" : ""}`;
+}
+
+/**
+ * The three detail reads for a whole kind, each restricted to the target above and bound
+ * `[...targetBinds, schema]`. They select what the single reads select plus OBJECT_NAME, so one
+ * mapper serves both and the bulk answer for a table cannot differ from its single answer.
+ */
+export function bulkDetailSql(bounded: boolean): { columns: string; foreignKeys: string; indexes: string } {
+ const target = `WITH TARGET AS (${bulkTargetSql(bounded)})`;
+ return {
+ columns: `${target}
+ SELECT TABNAME AS OBJECT_NAME, ${COLUMNS_SELECT}
+ FROM SYSCAT.COLUMNS
+ WHERE TABSCHEMA = ? AND TABNAME IN (SELECT OBJECT_NAME FROM TARGET)
+ ORDER BY TABNAME, COLNO`,
+ foreignKeys: `${target}
+ SELECT r.TABNAME AS OBJECT_NAME, ${FOREIGN_KEYS_SELECT}
+ ${FOREIGN_KEYS_FROM}
+ WHERE r.TABSCHEMA = ? AND r.TABNAME IN (SELECT OBJECT_NAME FROM TARGET)
+ ORDER BY r.TABNAME, r.CONSTNAME, fk.COLSEQ`,
+ indexes: `${target}
+ SELECT i.TABNAME AS OBJECT_NAME, ${INDEXES_SELECT}
+ ${INDEXES_FROM}
+ WHERE i.TABSCHEMA = ? AND i.TABNAME IN (SELECT OBJECT_NAME FROM TARGET)
+ ORDER BY i.TABNAME, i.INDSCHEMA, i.INDNAME, ic.COLSEQ`,
+ };
+}
+
+/** A view's or a materialized query table's text, only when the object IS that kind. */
+const SOURCE_VIEW_SQL = `SELECT v.TEXT
+ FROM SYSCAT.VIEWS v
+ JOIN SYSCAT.TABLES t ON t.TABSCHEMA = v.VIEWSCHEMA AND t.TABNAME = v.VIEWNAME
+ WHERE v.VIEWSCHEMA = ? AND v.VIEWNAME = ? AND t.TYPE = ?`;
+
+const SOURCE_ROUTINE_SQL = `SELECT TEXT, ORIGIN
+ FROM SYSCAT.ROUTINES
+ WHERE ROUTINESCHEMA = ? AND SPECIFICNAME = ? AND ROUTINETYPE = ? AND ROUTINEMODULENAME IS NULL`;
+
+/** A trigger addressed under its table, which the listing produces only when both share a schema. */
+const SOURCE_NESTED_TRIGGER_SQL = `SELECT TEXT
+ FROM SYSCAT.TRIGGERS
+ WHERE TRIGSCHEMA = ? AND TABSCHEMA = TRIGSCHEMA AND TABNAME = ? AND TRIGNAME = ?`;
+
+/** A trigger addressed under its schema, which the listing produces only for another schema's table. */
+const SOURCE_SCHEMA_TRIGGER_SQL = `SELECT TEXT
+ FROM SYSCAT.TRIGGERS
+ WHERE TRIGSCHEMA = ? AND TRIGNAME = ? AND TABSCHEMA <> TRIGSCHEMA`;
+
+// ----------------------------------------------------------------------------
+// Addressing
+// ----------------------------------------------------------------------------
+
+function declaredLevels(capabilities: ProviderCapabilities): readonly ContainerLevelSpec[] {
+ return (capabilities.containerLevels ?? []).slice(0, containerDepth(capabilities));
+}
+
+/**
+ * The segment of `path` that belongs to the declared `schema` level, never `path[0]`: a
+ * level's position is a property of the declaration (standing ruling 5g).
+ */
+function schemaSegment(capabilities: ProviderCapabilities, path: readonly string[]): string {
+ const levels = declaredLevels(capabilities);
+ const index = levels.findIndex((level) => level.id === "schema");
+ const segment = index < 0 ? undefined : path.slice(0, levels.length)[index];
+ if (segment === undefined) {
+ throw new QueryError(
+ `A Db2 path needs a "schema" container level and a segment for it; the declaration is ` +
+ `[${levels.map((level) => level.id).join(", ")}] and the path is ${JSON.stringify(path)}`,
+ "db2",
+ );
+ }
+ return segment;
+}
+
+/** The one schema a container path names, refused by name at any other depth. */
+export function containerSchema(capabilities: ProviderCapabilities, container: readonly string[]): string {
+ const levels = declaredLevels(capabilities);
+ if (container.length !== levels.length) {
+ throw new QueryError(
+ `A Db2 container path is [${levels.map((level) => level.label.toLowerCase()).join(", ")}], ` +
+ `received ${JSON.stringify(container)}`,
+ "db2",
+ );
+ }
+ return schemaSegment(capabilities, container);
+}
+
+/** The declared kind, or a refusal naming the kind a caller asked for. */
+export function requireKind(capabilities: ProviderCapabilities, kind: string): ObjectKindSpec {
+ const spec = declaredKinds(capabilities).find((candidate) => candidate.id === kind);
+ if (spec === undefined) throw new QueryError(`Db2 declares no object kind "${kind}"`, "db2");
+ return spec;
+}
+
+/**
+ * That `path` has a shape this kind can take. An attached kind takes either depth, because a
+ * trigger on another schema's table hangs off its own schema.
+ */
+export function assertObjectPathShape(
+ capabilities: ProviderCapabilities,
+ spec: ObjectKindSpec,
+ path: readonly string[],
+): void {
+ const levels = declaredLevels(capabilities).map((level) => level.label.toLowerCase());
+ const shapes =
+ spec.attachedTo === undefined
+ ? [[...levels, "name"]]
+ : [
+ [...levels, spec.attachedTo, "name"],
+ [...levels, "name"],
+ ];
+ if (!shapes.some((shape) => shape.length === path.length)) {
+ throw new QueryError(
+ `A Db2 "${spec.id}" path is ${shapes.map((shape) => `[${shape.join(", ")}]`).join(" or ")}, ` +
+ `received ${JSON.stringify(path)}`,
+ "db2",
+ );
+ }
+}
+
+/** The schema and the object's own name for a path whose shape was already checked. */
+export function objectAddress(
+ capabilities: ProviderCapabilities,
+ path: readonly string[],
+): { schema: string; name: string } {
+ return { schema: schemaSegment(capabilities, path), name: path[path.length - 1] };
+}
+
+// ----------------------------------------------------------------------------
+// Containers and counts
+// ----------------------------------------------------------------------------
+
+export interface ContainerRow {
+ NAME: string;
+ IS_SESSION_DEFAULT: number;
+}
+
+export function containersFromRows(rows: readonly ContainerRow[]): Container[] {
+ return rows
+ .map((row) => ({
+ path: [row.NAME],
+ name: row.NAME,
+ level: 0,
+ isSessionDefault: Number(row.IS_SESSION_DEFAULT) === 1,
+ }))
+ .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
+}
+
+export interface KindCountRow {
+ KIND: string;
+ N: number | string;
+}
+
+/**
+ * Every declared kind seeded at zero, then overwritten with what the statement answered, so a
+ * kind this schema holds none of still draws a folder with a 0.
+ */
+export function countsFromRows(
+ kinds: readonly ObjectKindSpec[],
+ rows: readonly KindCountRow[],
+): Record {
+ const counts: Record = Object.fromEntries(kinds.map((kind) => [kind.id, { count: 0 }]));
+ for (const row of rows) {
+ const kind = KIND_BY_COUNT_LABEL[String(row.KIND).trimEnd()];
+ if (kind !== undefined && kind in counts) counts[kind] = { count: Number(row.N) };
+ }
+ return counts;
+}
+
+/**
+ * Db2's own sentence against every kind the refused read covered. Not `mapDatabaseError`: this
+ * is rendered as the reason a folder has no number, and a refusal is never a zero.
+ */
+export function unavailableCounts(kinds: readonly ObjectKindSpec[], error: unknown): Record {
+ const reason = error instanceof Error ? error.message : String(error);
+ return Object.fromEntries(kinds.map((kind) => [kind.id, { unavailable: reason }]));
+}
+
+// ----------------------------------------------------------------------------
+// Listings
+// ----------------------------------------------------------------------------
+
+/** The statement and binds that list one kind, or undefined for a kind with no listing. */
+export function listingStatement(schema: string, kind: string): { sql: string; params: unknown[] } | undefined {
+ if (Object.hasOwn(TABLE_TYPE, kind)) return { sql: LIST_TABLES_SQL, params: [schema, TABLE_TYPE[kind]] };
+ if (Object.hasOwn(ROUTINE_TYPE, kind)) return { sql: LIST_ROUTINES_SQL, params: [schema, ROUTINE_TYPE[kind]] };
+ if (kind === "sequence") return { sql: LIST_SEQUENCES_SQL, params: [schema] };
+ if (kind === "module") return { sql: LIST_MODULES_SQL, params: [schema] };
+ if (kind === "trigger") return { sql: LIST_TRIGGERS_SQL, params: [schema] };
+ return undefined;
+}
+
+export interface ObjectRow {
+ NAME: string;
+ SEGMENT?: string;
+ PARENT?: string | null;
+ STATUS?: string;
+ VALID?: string | null;
+ CARD?: number | string | null;
+}
+
+/**
+ * The status a reader acts on, in Db2's documented meaning of its codes, and nothing otherwise.
+ *
+ * VALID is 'Y' for nearly every object, so publishing it would badge every row. 'N' is an
+ * object Db2 will revalidate on next use (measured: a view over a dropped table, under
+ * AUTO_REVAL DEFERRED) and 'X' one that is inoperative. TABLES.STATUS 'C' is set integrity
+ * pending and 'X' inoperative; 'N' is normal.
+ */
+function notableStatus(row: ObjectRow): { status?: string } {
+ const valid = row.VALID?.trimEnd();
+ if (valid === "N") return { status: "INVALID" };
+ if (valid === "X") return { status: "INOPERATIVE" };
+ const status = row.STATUS?.trimEnd();
+ if (status === "C") return { status: "SET INTEGRITY PENDING" };
+ if (status === "X") return { status: "INOPERATIVE" };
+ return {};
+}
+
+/**
+ * CARD as a row count only when it is a measurement. -1 means RUNSTATS never ran, which is an
+ * absence, and clamping it to 0 would show an empty table.
+ */
+function measuredRowCount(row: ObjectRow): { rowCount?: number } {
+ if (row.CARD === undefined || row.CARD === null) return {};
+ const card = Number(row.CARD);
+ return Number.isFinite(card) && card >= 0 ? { rowCount: card } : {};
+}
+
+/** One listed object. The last segment is SEGMENT where the listing has one and NAME otherwise. */
+export function objectFromRow(container: readonly string[], kind: string, row: ObjectRow): DatabaseObject {
+ const segment = row.SEGMENT ?? row.NAME;
+ const parent = row.PARENT;
+ const path = parent === null || parent === undefined ? [...container, segment] : [...container, parent, segment];
+ const counted = kind === "table" || kind === "materialized_query_table" ? measuredRowCount(row) : {};
+ return { path, name: row.NAME, kind, ...notableStatus(row), ...counted };
+}
+
+// ----------------------------------------------------------------------------
+// Detail
+// ----------------------------------------------------------------------------
+
+/** The SYSCAT.TABLES.TYPE a relation kind is stored under, or undefined for a kind that is not one. */
+export function relationTableType(kind: string): string | undefined {
+ return kind === "alias" || !Object.hasOwn(TABLE_TYPE, kind) ? undefined : TABLE_TYPE[kind];
+}
+
+const LENGTH_TYPES = new Set([
+ "CHARACTER",
+ "VARCHAR",
+ "GRAPHIC",
+ "VARGRAPHIC",
+ "BINARY",
+ "VARBINARY",
+ "CLOB",
+ "BLOB",
+ "DBCLOB",
+]);
+const BIT_DATA_TYPES = new Set(["CHARACTER", "VARCHAR"]);
+
+/**
+ * The column type as Db2 would accept it back in DDL.
+ *
+ * The bare TYPENAME is not enough: schema diff compares these strings and the migration
+ * generator writes one into `SET DATA TYPE`, where `VARCHAR` with no length is a syntax error.
+ * Measured on 12.1: LENGTH is the declared length for the character, graphic, binary and LOB
+ * types; DECIMAL carries precision in LENGTH and scale in SCALE; TIMESTAMP carries its
+ * fractional precision in SCALE (6 when declared bare); DECFLOAT reports its storage in bytes,
+ * 8 for DECFLOAT(16) and 16 for DECFLOAT(34); a character column with CODEPAGE 0 is FOR BIT DATA.
+ */
+function columnType(row: Record): string {
+ const name = String(row.TYPENAME).trimEnd();
+ const length = Number(row.LENGTH);
+ const scale = Number(row.SCALE);
+ if (LENGTH_TYPES.has(name)) {
+ const bitData = BIT_DATA_TYPES.has(name) && Number(row.CODEPAGE) === 0 ? " FOR BIT DATA" : "";
+ return `${name}(${length})${bitData}`;
+ }
+ if (name === "DECIMAL") return `DECIMAL(${length},${scale})`;
+ if (name === "TIMESTAMP" && scale !== 6) return `TIMESTAMP(${scale})`;
+ if (name === "DECFLOAT") return length === 16 ? "DECFLOAT(34)" : "DECFLOAT(16)";
+ return name;
+}
+
+export interface DetailRows {
+ readonly columns: readonly Record[];
+ readonly foreignKeys: readonly Record[];
+ readonly indexes: readonly Record[];
+}
+
+/**
+ * Three row sets turned into one `ObjectDetail`, for the single and the bulk read both.
+ *
+ * `schema` is the object's own and decides only how a reference is spelled: bare within the
+ * schema and qualified outside it, because a bare name for the crossing case addresses a table
+ * in the wrong schema.
+ */
+export function objectDetailFromRows(path: readonly string[], schema: string, rows: DetailRows): ObjectDetail {
+ const columns: ColumnSchema[] = rows.columns.map((row) => {
+ const column: ColumnSchema = {
+ name: String(row.COLUMN_NAME),
+ type: columnType(row),
+ nullable: String(row.NULLS) === "Y",
+ isPrimary: row.KEYSEQ !== null && row.KEYSEQ !== undefined,
+ };
+ return row.DEFAULT_VALUE === null || row.DEFAULT_VALUE === undefined
+ ? column
+ : { ...column, defaultValue: String(row.DEFAULT_VALUE) };
+ });
+
+ // Keyed by schema AND name, because two index schemas may each hold an index of one name.
+ const byIndex = new Map();
+ for (const row of rows.indexes) {
+ const key = `${String(row.INDEX_SCHEMA)}\0${String(row.INDEX_NAME)}`;
+ const rule = String(row.UNIQUERULE);
+ const index = byIndex.get(key) ?? {
+ name: String(row.INDEX_NAME),
+ columns: [],
+ unique: rule === "U" || rule === "P",
+ };
+ index.columns.push(String(row.COLUMN_NAME));
+ byIndex.set(key, index);
+ }
+
+ const foreignKeys: ForeignKeySchema[] = rows.foreignKeys.map((row) => ({
+ columnName: String(row.COLUMN_NAME),
+ referencedTable:
+ String(row.REF_SCHEMA) === schema ? String(row.REF_TABLE) : `${String(row.REF_SCHEMA)}.${String(row.REF_TABLE)}`,
+ referencedColumn: String(row.REF_COLUMN),
+ }));
+
+ return { path: [...path], columns, indexes: [...byIndex.values()], foreignKeys };
+}
+
+/** The rows of one bulk read grouped by the object each belongs to. */
+export function byObjectName(rows: readonly Record[]): Map[]> {
+ const grouped = new Map[]>();
+ for (const row of rows) {
+ const name = String(row.OBJECT_NAME);
+ const held = grouped.get(name);
+ if (held === undefined) grouped.set(name, [row]);
+ else held.push(row);
+ }
+ return grouped;
+}
+
+// ----------------------------------------------------------------------------
+// Source
+// ----------------------------------------------------------------------------
+
+/**
+ * The statement and binds that read one object's text. The kind decides the view; the path
+ * depth decides which of a trigger's two addresses is meant, and each binds exactly the
+ * segments its own text names.
+ */
+export function sourceStatement(
+ kind: string,
+ address: { schema: string; name: string },
+ path: readonly string[],
+): { sql: string; params: unknown[] } {
+ if (kind === "view" || kind === "materialized_query_table") {
+ return { sql: SOURCE_VIEW_SQL, params: [address.schema, address.name, TABLE_TYPE[kind]] };
+ }
+ if (kind === "trigger") {
+ return path.length === 3
+ ? { sql: SOURCE_NESTED_TRIGGER_SQL, params: [address.schema, path[1], address.name] }
+ : { sql: SOURCE_SCHEMA_TRIGGER_SQL, params: [address.schema, address.name] };
+ }
+ if (Object.hasOwn(ROUTINE_TYPE, kind)) {
+ return { sql: SOURCE_ROUTINE_SQL, params: [address.schema, address.name, ROUTINE_TYPE[kind]] };
+ }
+ throw new QueryError(`Db2 declares readable source for the kind "${kind}" but has no statement that reads it`, "db2");
+}
+
+/** Why a routine row has no text, by its ORIGIN. Only a routine with SQL of its own keeps one. */
+function missingRoutineText(origin: string): string {
+ if (origin === "E") {
+ return "This is an EXTERNAL routine: its body is compiled code outside the database, so SYSCAT.ROUTINES.TEXT is NULL and Db2 keeps no SQL text for it.";
+ }
+ if (origin === "U") {
+ return "This is a SOURCED routine: it is defined as another function, so SYSCAT.ROUTINES.TEXT is NULL and Db2 keeps no SQL text for it.";
+ }
+ if (origin === "F") {
+ return "This is a FEDERATED procedure: its body lives on the remote data source, so SYSCAT.ROUTINES.TEXT is NULL here.";
+ }
+ return `SYSCAT.ROUTINES.TEXT is NULL for this routine (ORIGIN '${origin}'), so Db2 keeps no SQL text for it.`;
+}
+
+/**
+ * One source row as the single part a Db2 document carries: the stored text, or a refusal
+ * saying why there is none. The two arms are whole literals and neither spreads the other, so
+ * a part can never carry both `text` and `unavailable`.
+ *
+ * STORED and COMPLETE, measured: a view created through ibm_db with irregular spacing and a
+ * trailing `--` comment read back from SYSCAT.VIEWS.TEXT byte-identical, and every text begins
+ * with its CREATE, so it runs as given.
+ */
+export function sourcePartFromRow(
+ row: Record,
+ language: string,
+ limit: number | undefined,
+ where: string,
+): ObjectSourcePart {
+ const text = row.TEXT;
+ if (text === null || text === undefined) {
+ const reason =
+ row.ORIGIN === undefined
+ ? `SYSCAT answered no definition text for ${where}.`
+ : missingRoutineText(String(row.ORIGIN).trimEnd());
+ return { id: "definition", label: "Definition", unavailable: reason };
+ }
+ if (typeof text !== "string") {
+ // A defect in this file or the driver rather than a fact about the object, and reporting
+ // it as Db2's answer would tell a reader their definition is broken.
+ throw new QueryError(`Db2 answered the definition of ${where} as ${typeof text} rather than a string`, "db2");
+ }
+ if (text.trim() === "") {
+ return { id: "definition", label: "Definition", unavailable: `SYSCAT answered an empty definition for ${where}.` };
+ }
+ const bounded = applySourceBound(text, limit);
+ return {
+ id: "definition",
+ label: "Definition",
+ text: bounded.text,
+ language,
+ form: "complete",
+ origin: "stored",
+ ...(bounded.truncated === undefined ? {} : { truncated: bounded.truncated }),
+ };
+}
diff --git a/src/lib/export/result-export.ts b/src/lib/export/result-export.ts
index e7a60667a..ace8dfce6 100644
--- a/src/lib/export/result-export.ts
+++ b/src/lib/export/result-export.ts
@@ -515,6 +515,10 @@ const STANDS_ALONE: Record = {
redis: NOTHING_STANDS_ALONE,
libredb: NOTHING_STANDS_ALONE,
couchbase: NOTHING_STANDS_ALONE,
+ // Db2 LUW takes INSERT/CREATE TABLE, but no standalone-safe type names have been
+ // measured against a live server yet, so every bare name is re-spelled portably
+ // rather than kept as Db2's private word. Promote measured names here after gate 4.
+ db2: NOTHING_STANDS_ALONE,
};
/**
@@ -667,6 +671,10 @@ const BINARY_LITERAL: Record = {
duckdb: "unhex",
clickhouse: "unhex",
couchbase: "text",
+ // Db2 LUW spells a binary literal `X'0102deadbeef'` and `HEX()` returns uppercase hex
+ // — the same `standard-hex` shape as MySQL and SQLite. Documented Db2 behaviour, not
+ // yet live-verified; confirm on the gate-4 pass.
+ db2: "standard-hex",
};
/**
diff --git a/src/lib/query-generators.ts b/src/lib/query-generators.ts
index 5697c715d..4550e95dd 100644
--- a/src/lib/query-generators.ts
+++ b/src/lib/query-generators.ts
@@ -7,6 +7,13 @@ const COUCHBASE_PORT = 8091;
/** Apache Druid Router port, the capability signal for the Druid SQL dialect. */
const DRUID_PORT = 8888;
+/**
+ * Db2 LUW DRDA port, the capability signal for its `FETCH FIRST` pagination. Db2 has
+ * no `LIMIT` clause, so the shared PostgreSQL fallback would emit a statement it
+ * refuses; this port routes it to the same `FETCH FIRST` branch Oracle uses.
+ */
+const DB2_PORT = 50000;
+
/**
* 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
@@ -428,6 +435,10 @@ export function generateTableQuery(
if (capabilities.defaultPort === 1521) {
return `SELECT * FROM ${table} FETCH FIRST 50 ROWS ONLY${terminator(capabilities)}`;
}
+ // Db2 LUW — same FETCH FIRST clause as Oracle; it has no LIMIT.
+ if (capabilities.defaultPort === DB2_PORT) {
+ return `SELECT * FROM ${table} FETCH FIRST 50 ROWS ONLY;`;
+ }
// MSSQL
if (capabilities.defaultPort === 1433) {
return `SELECT TOP 50 * FROM ${table};`;
@@ -594,6 +605,10 @@ export function generateSelectQuery(
if (capabilities.defaultPort === 1521) {
return `SELECT\n${cols}\nFROM ${table}\nWHERE 1=1\nFETCH FIRST 100 ROWS ONLY${terminator(capabilities)}`;
}
+ // Db2 LUW — same FETCH FIRST clause as Oracle; it has no LIMIT.
+ if (capabilities.defaultPort === DB2_PORT) {
+ return `SELECT\n${cols}\nFROM ${table}\nWHERE 1=1\nFETCH FIRST 100 ROWS ONLY;`;
+ }
// MSSQL
if (capabilities.defaultPort === 1433) {
return `SELECT TOP 100\n${cols}\nFROM ${table}\nWHERE 1=1;`;
diff --git a/src/lib/schema-diff/migration-generator.ts b/src/lib/schema-diff/migration-generator.ts
index 670a7770b..7e850bfe8 100644
--- a/src/lib/schema-diff/migration-generator.ts
+++ b/src/lib/schema-diff/migration-generator.ts
@@ -543,6 +543,27 @@ function generateAlterTable(table: TableDiff, dialect: DatabaseType): string {
);
}
}
+ } else if (dialect === "db2") {
+ // Db2 LUW spells a type change `ALTER COLUMN c SET DATA TYPE t` (the PostgreSQL
+ // `else` below emits the invalid `... TYPE t`), and nullability and default use
+ // the same `SET`/`DROP` verbs PostgreSQL does. Each facet is emitted as its own
+ // ALTER, which is Db2's own grammar. Not live-probed against a server yet — the
+ // syntax is Db2's documented standard SQL; the byte-exact behaviour is confirmed
+ // on the gate-4 pass (docs/providers/db2.md).
+ const column = escapeIdentifier(col.columnName, dialect);
+ if (col.sourceType !== col.targetType) {
+ lines.push(`ALTER TABLE ${id} ALTER COLUMN ${column} SET DATA TYPE ${col.targetType};`);
+ }
+ if (col.sourceNullable !== col.targetNullable) {
+ lines.push(`ALTER TABLE ${id} ALTER COLUMN ${column} ${col.targetNullable ? "DROP" : "SET"} NOT NULL;`);
+ }
+ if (col.sourceDefault !== col.targetDefault) {
+ lines.push(
+ col.targetDefault
+ ? `ALTER TABLE ${id} ALTER COLUMN ${column} SET DEFAULT ${col.targetDefault};`
+ : `ALTER TABLE ${id} ALTER COLUMN ${column} DROP DEFAULT;`,
+ );
+ }
} else if (inexpressible) {
lines.push(
`-- ${inexpressible.label}: Cannot alter column "${commentName(col.columnName)}". ${inexpressible.reason}`,
@@ -629,6 +650,77 @@ function generateAlterTable(table: TableDiff, dialect: DatabaseType): string {
);
});
+ // Db2 LUW places a table in REORG-pending after certain structural ALTERs — dropping
+ // a column, changing a column's data type, and changing nullability all trigger it —
+ // and the table is read-restricted (SQL0668N reason code 7) until a REORG materializes
+ // the change. This is emitted as an ADVISORY COMMENT, not an executable statement, and
+ // that follows the same pattern the rest of this generator uses for anything it cannot
+ // emit as both correct AND safe to run blindly (NO_PORTABLE_INDEX_DDL's "write the
+ // index change by hand", SQLite's "Requires table recreation"). Two reasons a REORG
+ // must not be auto-run here:
+ // * It can be VERY slow and lock-heavy — proportional to table size — so a migration
+ // file that runs it unattended could block a large table for a long time. When and
+ // how to REORG (off-hours, INPLACE vs offline, resource limits) is an operational
+ // decision the person applying the migration has to make, not one this file should
+ // make for them.
+ // * REORG cannot run inside the BEGIN;/COMMIT; unit of work this file wraps DDL in
+ // (ADMIN_CMD commits internally), so emitting it as a live statement between the
+ // wrapper's bounds would be wrong regardless of size.
+ // A single REORG clears any number of changes batched into one unit of work, so one
+ // advisory per table is emitted rather than one per column. What triggers it, verified
+ // against IBM's Db2 LUW documentation ("Multiple ALTER TABLE operations within a single
+ // unit of work": "Certain ALTER TABLE operations, like dropping a column, altering a
+ // column type, or altering the nullability property of a column may put the table into
+ // a reorg pending state"):
+ // * DROP COLUMN — a removed column;
+ // * SET DATA TYPE — a modified column whose type changed;
+ // * SET/DROP NOT NULL — a modified column whose nullability changed.
+ // What does NOT trigger it, and is deliberately excluded so the advisory is not a false
+ // positive:
+ // * ADD COLUMN — immediate on Db2 LUW (this is the z/OS-vs-LUW difference; some z/OS
+ // ADD forms go AREOR, LUW's do not);
+ // * a DEFAULT-only change (SET/DROP DEFAULT with no type or nullability change) —
+ // metadata-only, so a column modified only in its default must not raise the
+ // advisory;
+ // * index and foreign-key changes.
+ // Two nuances IBM's page states that this advisory does NOT try to model, because they
+ // are operational rather than generatable, and getting them wrong in emitted SQL would
+ // be worse than leaving them to the DBA: (1) the state is not immediate per statement —
+ // LUW lets an unlimited number of these ALTERs run across up to ~31 units of work before
+ // REORG TABLE is *forced*, and (2) since 10.5.0.5 a column's data type may be altered
+ // only ONCE while the table is already reorg-pending before a REORG is required. A
+ // migration this generator writes is one file the DBA applies once, so a single trailing
+ // REORG advisory per table is the correct guidance regardless of either threshold.
+ //
+ // The advisory is a COMMENT, not an executable statement — the same pattern the rest of
+ // this generator uses for anything it cannot emit as both correct AND safe to run
+ // blindly (NO_PORTABLE_INDEX_DDL's "write the index change by hand", SQLite's "Requires
+ // table recreation"). Two reasons a REORG must not be auto-run here:
+ // * It can be VERY slow and lock-heavy — proportional to table size — so a migration
+ // file that runs it unattended could block a large table for a long time. When and
+ // how to REORG (off-hours, INPLACE vs offline, resource limits) is an operational
+ // decision the person applying the migration has to make, not one this file should
+ // make for them.
+ // * REORG cannot run inside the BEGIN;/COMMIT; unit of work this file wraps DDL in
+ // (ADMIN_CMD commits internally), so emitting it as a live statement between the
+ // wrapper's bounds would be wrong regardless of size.
+ if (dialect === "db2") {
+ const triggersReorg = table.columns.some(
+ (c) =>
+ c.action === "removed" ||
+ (c.action === "modified" && (c.sourceType !== c.targetType || c.sourceNullable !== c.targetNullable)),
+ );
+ if (triggersReorg) {
+ lines.push(
+ `-- Db2: the ALTERs above may leave "${commentName(table.tableName)}" in REORG-pending, where many query types are blocked until it is reorganized.`,
+ );
+ lines.push(
+ `-- Run this OUTSIDE the transaction above, and mind that it can be slow and lock-heavy on a large table:`,
+ );
+ lines.push(`-- CALL SYSPROC.ADMIN_CMD('REORG TABLE ${id}');`);
+ }
+ }
+
return lines.join("\n");
}
diff --git a/src/lib/seed/types.ts b/src/lib/seed/types.ts
index 53db73d5f..cd05e0394 100644
--- a/src/lib/seed/types.ts
+++ b/src/lib/seed/types.ts
@@ -42,6 +42,7 @@ const SeedDatabaseType = z.enum([
"cassandra",
"libsql",
"duckdb",
+ "db2",
]);
export const SeedDefaultsSchema = z.object({
diff --git a/src/lib/sql/fence-tags.ts b/src/lib/sql/fence-tags.ts
index 4323bb365..62ea0beea 100644
--- a/src/lib/sql/fence-tags.ts
+++ b/src/lib/sql/fence-tags.ts
@@ -71,6 +71,10 @@ const ENGINE_FENCE_TAGS: Readonly> = Object.freeze({
// is not: CQL is a language, and ScyllaDB speaks it too, so reading `cql` as
// "this was written for Cassandra" would put a claim in the model's mouth.
cassandra: true,
+ // A ```db2 block holds a statement the editor can run against a Db2 connection. No
+ // alias is registered below: `luw` is not a fence tag anyone writes, and `db2` is
+ // already the type-id.
+ db2: true,
});
/**
diff --git a/src/lib/sql/values.ts b/src/lib/sql/values.ts
index 8bf117ea7..6ed1690ef 100644
--- a/src/lib/sql/values.ts
+++ b/src/lib/sql/values.ts
@@ -77,6 +77,11 @@ const LITERAL_ESCAPE: Record = {
// '\' ( '\' | '"' | "'" | 'b' | 'f' | 'n' | 'r' | 't' | 'u' hex hex hex hex )`.
// Doubling is not in that grammar, so a doubled quote is not one literal there.
couchbase: "backslash",
+ // Db2 LUW uses standard SQL string literals: the single quote is doubled and a
+ // backslash is ordinary data (Db2 has no backslash-escape mode by default). Not yet
+ // live-verified; the standard reading is Db2's documented behaviour and matches its
+ // ANSI-SQL lineage.
+ db2: "standard",
};
/**
@@ -165,6 +170,9 @@ export function positionalPlaceholder(dialect: DatabaseType, position: number):
case "mysql":
case "sqlite":
case "druid":
+ // Db2 LUW binds a positional parameter array against `?`, the same as MySQL and
+ // SQLite. `ibm_db` uses the driver's own array binding for it.
+ case "db2":
return "?";
case "oracle":
return `:${position}`;
diff --git a/src/lib/types.ts b/src/lib/types.ts
index a6083aeb8..f7fa1ce4d 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -82,7 +82,22 @@ export type DatabaseType =
// MotherDuck (`md:`), Quack and DuckLake are NOT this id and have no row anywhere
// yet: each is a different connection story than a local path, and #424 publishes
// no name it has not connected to.
- | "duckdb";
+ | "duckdb"
+ // IBM Db2 for Linux, UNIX and Windows (Db2 LUW). A proprietary relational engine
+ // reached over the DRDA binary protocol, so - unlike the driver-free HTTP providers
+ // - it carries a NATIVE driver (`ibm_db`, an N-API addon whose install step fetches
+ // the IBM CLI driver). There is a first-class HTTP path (the Db2 REST service's
+ // `/v1/services/execsql`), but it is a separately deployed container that is
+ // commonly disabled, so an IDE that must reach any standard Db2 uses the driver the
+ // way DBeaver/DataGrip do. Standard SQL: double-quoted identifiers and
+ // `FETCH FIRST n ROWS ONLY` pagination are both correct, so this extends
+ // `SQLBaseProvider` and overrides only `prepareQuery()`, mirroring Oracle. The
+ // connection's `database` field pins one database; a Db2 SCHEMA is the namespace
+ // level, read as the connecting user's schema by default the way Oracle reads its
+ // owner. `supportsExplain` is false initially (the same posture as Oracle and SQL
+ // Server, #126): Db2 EXPLAIN populates explain tables rather than returning a plan
+ // from one statement, so the single-statement explain path cannot express it yet.
+ | "db2";
export type ConnectionEnvironment = "production" | "staging" | "development" | "local" | "other";
diff --git a/tests/helpers/object-edit-expectation.ts b/tests/helpers/object-edit-expectation.ts
index d3d6caa6b..2c4e1de6f 100644
--- a/tests/helpers/object-edit-expectation.ts
+++ b/tests/helpers/object-edit-expectation.ts
@@ -32,6 +32,7 @@ export const EXPECTED_EDIT_ABSTAINERS: readonly DatabaseType[] = Object.freeze([
"cassandra",
"clickhouse",
"couchbase",
+ "db2",
"druid",
"duckdb",
"elasticsearch",
diff --git a/tests/hooks/use-connection-form.test.ts b/tests/hooks/use-connection-form.test.ts
index b3f5861e0..6cf2d554c 100644
--- a/tests/hooks/use-connection-form.test.ts
+++ b/tests/hooks/use-connection-form.test.ts
@@ -1225,6 +1225,7 @@ describe("useConnectionForm", () => {
cassandra: true,
libsql: true,
duckdb: true,
+ db2: true,
};
test("dbTypes offers every database type a connection can carry", () => {
diff --git a/tests/integration/db/db2-provider.test.ts b/tests/integration/db/db2-provider.test.ts
new file mode 100644
index 000000000..88f3cf13e
--- /dev/null
+++ b/tests/integration/db/db2-provider.test.ts
@@ -0,0 +1,1544 @@
+import { describe, test, expect, beforeEach, mock } from "bun:test";
+import { ConnectionError, DatabaseConfigError, QueryError } from "@/lib/db/errors";
+import { maintenanceControl } from "@/lib/db/types";
+import type { DatabaseConnection } from "@/lib/types";
+import type { ProviderCapabilities } from "@/lib/db/types";
+import { CACHE_HIT_RATIO_UNAVAILABLE } from "@/lib/monitoring-cache-ratio";
+import { callerBoundTruncationReason, isSourcePartUnavailable } from "@/lib/db/object-kinds";
+import { assertObjectSurface } from "../../helpers/object-surface-conformance";
+
+// ---------------------------------------------------------------------------
+// Mock ibm_db BEFORE loading the provider.
+//
+// The driver's high-level surface is `open(connStr, cb) -> conn`, and the connection
+// answers `query(sql, cb)` with an array of row objects and `close(cb)`. The provider
+// declares that slice locally (its published typings do not describe a promise API), so
+// the mock only has to honour those three calls. `mockRowsFor` lets a test decide what a
+// given SQL string returns, which is how the object surface is exercised without a live server.
+// ---------------------------------------------------------------------------
+
+let mockRowsFor: (sql: string, params?: unknown[]) => Record[];
+let openShouldFail: boolean;
+let queryShouldThrowFor: (sql: string) => boolean;
+const capturedConnStrings: string[] = [];
+const capturedQueries: string[] = [];
+// Every query's bound-parameter array, in call order. A 2-arg `query(sql, cb)` call
+// records `undefined` (no params slot), which is how a param-binding regression is
+// told apart from an unbound call: the fix must pass the array through, and the
+// no-params path must NOT invent an empty array (ibm_db reads a function in the
+// params slot as the callback, and some builds reject an empty array against a
+// marker-less statement with CLI0100E).
+const capturedParams: Array = [];
+
+const makeMockConnection = () => ({
+ query: (
+ sql: string,
+ paramsOrCb: unknown[] | ((err: Error | null, rows: Record[]) => void),
+ maybeCb?: (err: Error | null, rows: Record[]) => void,
+ ) => {
+ // The real driver treats a function in the params slot as the callback. Mirror
+ // that so the provider's two call shapes — `query(sql, cb)` and
+ // `query(sql, params, cb)` — are both honoured and distinguishable.
+ const cb = (typeof paramsOrCb === "function" ? paramsOrCb : maybeCb)!;
+ const params = typeof paramsOrCb === "function" ? undefined : paramsOrCb;
+ capturedQueries.push(sql);
+ capturedParams.push(params);
+ if (queryShouldThrowFor(sql)) {
+ cb(new Error("SQL0551N the user does not have the required authorization"), []);
+ return;
+ }
+ cb(null, mockRowsFor(sql, params));
+ },
+ close: (cb: (err: Error | null) => void) => cb(null),
+});
+
+mock.module("ibm_db", () => {
+ const open = (connStr: string, cb: (err: Error | null, conn: ReturnType) => void) => {
+ capturedConnStrings.push(connStr);
+ if (openShouldFail) {
+ cb(new Error("SQL30081N a communication error has been detected"), makeMockConnection());
+ return;
+ }
+ cb(null, makeMockConnection());
+ };
+ return { default: { open }, open };
+});
+
+const { Db2Provider } = await import("@/lib/db/providers/sql/db2");
+
+const baseConfig: DatabaseConnection = {
+ id: "db2-test",
+ name: "Db2 Test",
+ type: "db2",
+ host: "localhost",
+ port: 50000,
+ database: "testdb",
+ user: "db2inst1",
+ password: "secret",
+ createdAt: new Date(),
+};
+
+beforeEach(() => {
+ mockRowsFor = () => [];
+ openShouldFail = false;
+ queryShouldThrowFor = () => false;
+ capturedConnStrings.length = 0;
+ capturedQueries.length = 0;
+ capturedParams.length = 0;
+});
+
+describe("Db2Provider validation", () => {
+ test("throws when host is missing and no connectionString", () => {
+ expect(() => new Db2Provider({ ...baseConfig, host: undefined })).toThrow(DatabaseConfigError);
+ });
+
+ test("throws when database is missing and no connectionString", () => {
+ expect(() => new Db2Provider({ ...baseConfig, database: undefined })).toThrow(DatabaseConfigError);
+ });
+
+ test("a connectionString bypasses host/database validation", () => {
+ expect(
+ () => new Db2Provider({ ...baseConfig, host: undefined, database: undefined, connectionString: "db2://h/db" }),
+ ).not.toThrow();
+ });
+
+ // The DRDA attribute list has no escaping for its `;` delimiter, so a field value that
+ // contains one would misparse (password `pa;ss` → auth on `pa`) or inject an attribute
+ // (`PWD=x;SECURITY=NONE` connects). The field-built path must refuse the delimiter.
+ test("rejects a ';' in the password (connection-string injection guard)", () => {
+ expect(() => new Db2Provider({ ...baseConfig, password: "pa;ss" })).toThrow(DatabaseConfigError);
+ });
+
+ test("rejects a ';' in the username", () => {
+ expect(() => new Db2Provider({ ...baseConfig, user: "u;SECURITY=NONE" })).toThrow(DatabaseConfigError);
+ });
+
+ test("rejects a ';' in the database name", () => {
+ expect(() => new Db2Provider({ ...baseConfig, database: "db;PORT=1" })).toThrow(DatabaseConfigError);
+ });
+
+ test("a pasted connection string is the user's own responsibility and is not delimiter-checked", () => {
+ // The user typed the whole attribute list, so a ';' there is theirs to get right —
+ // the guard is only for values WE interpolate into the list.
+ expect(
+ () =>
+ new Db2Provider({
+ ...baseConfig,
+ host: undefined,
+ database: undefined,
+ connectionString: "DATABASE=x;HOSTNAME=h;PORT=50000;UID=u;PWD=p;",
+ }),
+ ).not.toThrow();
+ });
+});
+
+describe("Db2Provider capabilities", () => {
+ const caps = new Db2Provider(baseConfig).getCapabilities();
+
+ test("declares the DRDA default port", () => {
+ expect(caps.defaultPort).toBe(50000);
+ });
+
+ test("explain is disabled initially (MSSQL/#126 posture)", () => {
+ expect(caps.supportsExplain).toBe(false);
+ expect(caps.explainFormat).toBeUndefined();
+ });
+
+ test("declares no transaction session yet, but inline row edit and connection strings", () => {
+ expect(caps.supportsTransactions).toBe(false);
+ expect(caps.supportsInlineRowEdit).toBe(true);
+ expect(caps.supportsConnectionString).toBe(true);
+ });
+
+ test("offers only the maintenance operations that are wired (analyze, optimize)", () => {
+ expect(caps.maintenanceOperations.sort()).toEqual(["analyze", "optimize"]);
+ });
+});
+
+describe("Db2Provider labels", () => {
+ const labels = new Db2Provider(baseConfig).getLabels();
+
+ test("relabels the maintenance verbs to Db2's own (RUNSTATS / REORG)", () => {
+ expect(labels.analyzeAction).toBe("Run Statistics");
+ // Db2 has no VACUUM; the vacuum slot points at REORG (`optimize`).
+ expect(labels.vacuumAction).toBe("Reorganize Table");
+ expect(labels.vacuumActionOperation).toBe("optimize");
+ });
+
+ test("the slow-query empty state names Db2's monitoring metrics, not pg_stat_statements", () => {
+ // The Queries panel falls back to a Postgres "install pg_stat_statements" message when a
+ // provider declares none; that is false for Db2, whose timings come from mon_req_metrics.
+ expect(labels.slowQueriesEmptyState).toBeDefined();
+ expect(labels.slowQueriesEmptyState).toMatch(/mon_req_metrics|monitoring metrics/i);
+ expect(labels.slowQueriesEmptyState).not.toMatch(/pg_stat_statements/i);
+ });
+});
+
+describe("Db2Provider maintenance is per-table only (no whole-database form)", () => {
+ const caps = new Db2Provider(baseConfig).getCapabilities();
+
+ test("analyze and optimize are perEntity but NOT global", () => {
+ // RUNSTATS and REORG each target one table on Db2 LUW; there is no whole-database
+ // statement, so a global card would fire the operation with no target and fail. Both
+ // specs declare global:false so the Operations tab renders no global card for them.
+ expect(caps.maintenanceOperationSpecs?.analyze).toEqual({
+ label: "Run Statistics",
+ perEntity: true,
+ global: false,
+ });
+ expect(caps.maintenanceOperationSpecs?.optimize).toEqual({
+ label: "Reorganize Table",
+ perEntity: true,
+ global: false,
+ });
+ });
+
+ test("the global (no-target) maintenance controls are not offered", () => {
+ // maintenanceControl gates the global placement on the spec's `global` flag.
+ expect(maintenanceControl(caps, "analyze", "global").offered).toBe(false);
+ expect(maintenanceControl(caps, "optimize", "global").offered).toBe(false);
+ // …but the per-table controls ARE offered, so the operation still has a home.
+ expect(maintenanceControl(caps, "analyze", "perEntity").offered).toBe(true);
+ expect(maintenanceControl(caps, "optimize", "perEntity").offered).toBe(true);
+ });
+});
+
+describe("Db2Provider prepareQuery (FETCH FIRST)", () => {
+ const provider = new Db2Provider(baseConfig);
+
+ test("injects FETCH FIRST for an unbounded SELECT", () => {
+ const prepared = provider.prepareQuery("SELECT * FROM users", { limit: 50 });
+ expect(prepared.wasLimited).toBe(true);
+ expect(prepared.query).toContain("FETCH FIRST 50 ROWS ONLY");
+ expect(prepared.query).not.toContain("LIMIT");
+ });
+
+ test("uses OFFSET n ROWS FETCH NEXT for a paged SELECT", () => {
+ const prepared = provider.prepareQuery("SELECT * FROM users", { limit: 25, offset: 50 });
+ expect(prepared.wasLimited).toBe(true);
+ expect(prepared.query).toContain("OFFSET 50 ROWS FETCH NEXT 25 ROWS ONLY");
+ });
+
+ test("leaves a statement that already declares FETCH FIRST untouched", () => {
+ const sql = "SELECT * FROM users FETCH FIRST 10 ROWS ONLY";
+ const prepared = provider.prepareQuery(sql, { limit: 50 });
+ expect(prepared.wasLimited).toBe(false);
+ expect(prepared.query).toBe(sql);
+ });
+
+ test("does not limit a non-SELECT", () => {
+ const prepared = provider.prepareQuery("UPDATE users SET active = 1", { limit: 50 });
+ expect(prepared.wasLimited).toBe(false);
+ });
+
+ test("splices the clause before a trailing semicolon rather than after it", () => {
+ // The clause must land between the statement and its trailing trivia; appended after a
+ // ';' it would be a syntax error, and Db2 (like Oracle) rejects a trailing ';' on a
+ // plain statement. Pins the #280 shape for Db2.
+ const prepared = provider.prepareQuery("SELECT * FROM users;", { limit: 10 });
+ expect(prepared.wasLimited).toBe(true);
+ expect(prepared.query).toContain("FETCH FIRST 10 ROWS ONLY");
+ // The FETCH clause is before the ';', not after it.
+ expect(prepared.query.indexOf("FETCH FIRST")).toBeLessThan(prepared.query.lastIndexOf(";"));
+ });
+
+ test("does not append a clause inside a trailing line comment", () => {
+ // A `-- comment` after the statement must not swallow the appended clause.
+ const prepared = provider.prepareQuery("SELECT * FROM users -- trailing", { limit: 10 });
+ if (prepared.wasLimited) {
+ // If it rewrote, the clause is NOT inside the comment (it precedes it).
+ expect(prepared.query.indexOf("FETCH FIRST")).toBeLessThan(prepared.query.indexOf("-- trailing"));
+ } else {
+ // Or it declined to rewrite, which is the safe alternative the guard allows.
+ expect(prepared.query).toBe("SELECT * FROM users -- trailing");
+ }
+ });
+});
+
+describe("Db2Provider connect", () => {
+ test("builds a DRDA attribute string from the fields", async () => {
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ expect(provider.isConnected()).toBe(true);
+ const connStr = capturedConnStrings[0];
+ expect(connStr).toContain("DATABASE=testdb");
+ expect(connStr).toContain("HOSTNAME=localhost");
+ expect(connStr).toContain("PORT=50000");
+ expect(connStr).toContain("UID=db2inst1");
+ expect(connStr).toContain("PROTOCOL=TCPIP");
+ await provider.disconnect();
+ });
+
+ test("adds SECURITY=SSL when TLS is requested", async () => {
+ const provider = new Db2Provider({ ...baseConfig, ssl: { mode: "require" } });
+ await provider.connect();
+ expect(capturedConnStrings[0]).toContain("SECURITY=SSL");
+ await provider.disconnect();
+ });
+
+ test("passes a pasted connection string through unchanged", async () => {
+ const raw = "DATABASE=x;HOSTNAME=h;PORT=50000;UID=u;PWD=p;SECURITY=SSL;";
+ const provider = new Db2Provider({ ...baseConfig, connectionString: raw });
+ await provider.connect();
+ expect(capturedConnStrings[0]).toBe(raw);
+ await provider.disconnect();
+ });
+
+ test("wraps a failed open in a ConnectionError", async () => {
+ openShouldFail = true;
+ const provider = new Db2Provider(baseConfig);
+ await expect(provider.connect()).rejects.toBeInstanceOf(ConnectionError);
+ });
+});
+
+describe("Db2Provider query", () => {
+ test("derives fields from the first row and omits columnTypes (unverified)", async () => {
+ mockRowsFor = () => [{ ID: 1, NAME: "a" }];
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const result = await provider.query("SELECT id, name FROM users");
+ expect(result.fields).toEqual(["ID", "NAME"]);
+ expect(result.rowCount).toBe(1);
+ // columnTypes is deliberately absent — the high-level ibm_db surface does not
+ // expose declared types, and absence is the signal the grid reads (#273).
+ expect(result.columnTypes).toBeUndefined();
+ await provider.disconnect();
+ });
+
+ test("an empty result yields no fields rather than throwing", async () => {
+ mockRowsFor = () => [];
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const result = await provider.query("SELECT id FROM empty_table");
+ expect(result.fields).toEqual([]);
+ expect(result.rowCount).toBe(0);
+ await provider.disconnect();
+ });
+
+ // Regression: the first cut ignored the `params` argument entirely, so a bound
+ // statement reached the driver with its `?` markers unfilled and Db2 answered
+ // `CLI0100E Wrong number of parameters` — which broke inline row edit, the very
+ // capability the provider advertises (`supportsInlineRowEdit: true`). The grid
+ // sends `UPDATE t SET "c" = ? WHERE "id" = ?` with a values array (issue #290), so
+ // the provider must forward it positionally.
+ test("binds positional params, passing the values array through to the driver", async () => {
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ await provider.query('UPDATE LIBREDB_EDIT SET "NAME" = ? WHERE "ID" = ?', ["alpha-edited", 1]);
+ await provider.disconnect();
+
+ const idx = capturedQueries.findIndex((q) => q.startsWith("UPDATE LIBREDB_EDIT"));
+ expect(idx).toBeGreaterThanOrEqual(0);
+ expect(capturedParams[idx]).toEqual(["alpha-edited", 1]);
+ });
+
+ // The other half of the same fix: a call with no params (or an empty array) must
+ // use the two-arg `query(sql, cb)` form, never `query(sql, [], cb)`. ibm_db reads a
+ // function in the params slot as the callback, and an empty array bound against a
+ // marker-less statement can itself raise CLI0100E — so passing it would reintroduce
+ // the bug on every ordinary SELECT.
+ test("omits the params slot entirely when there are none", async () => {
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ await provider.query("SELECT id FROM users");
+ await provider.disconnect();
+
+ const idx = capturedQueries.findIndex((q) => q.startsWith("SELECT id FROM users"));
+ expect(idx).toBeGreaterThanOrEqual(0);
+ expect(capturedParams[idx]).toBeUndefined();
+ });
+
+ test("treats an empty params array as no params (two-arg form)", async () => {
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ await provider.query("SELECT id FROM users", []);
+ await provider.disconnect();
+
+ const idx = capturedQueries.findIndex((q) => q.startsWith("SELECT id FROM users"));
+ expect(idx).toBeGreaterThanOrEqual(0);
+ expect(capturedParams[idx]).toBeUndefined();
+ });
+});
+
+// The object surface (#789) lives in its own block at the end of this file.
+
+describe("Db2Provider getHealth and monitoring", () => {
+ test("health reports the unavailable cache-ratio sentinel when the read is refused", async () => {
+ queryShouldThrowFor = (sql) => sql.includes("BP_HITRATIO") || sql.includes("MON_GET_CONNECTION");
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const health = await provider.getHealth();
+ expect(health.cacheHitRatio).toBe(CACHE_HIT_RATIO_UNAVAILABLE);
+ expect(health.slowQueries).toEqual([]);
+ expect(health.activeSessions).toEqual([]);
+ // A refused connection-count read leaves the field ABSENT, never a fabricated 0.
+ expect(health.activeConnections).toBeUndefined();
+ await provider.disconnect();
+ });
+
+ test("health reports a measured cache hit ratio and connection count when readable", async () => {
+ mockRowsFor = (sql) => {
+ if (sql.includes("BP_HITRATIO")) return [{ LOGICAL_READS: "1000", PHYSICAL_READS: "50" }];
+ if (sql.includes("MON_GET_CONNECTION") && sql.includes("COUNT")) return [{ N: "7" }];
+ return [];
+ };
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const health = await provider.getHealth();
+ // (1000 - 50) / 1000 = 95.0%
+ expect(health.cacheHitRatio).toBe("95.0");
+ expect(health.activeConnections).toBe(7);
+ await provider.disconnect();
+ });
+
+ test("performance metrics carry the cache ratio and deadlocks when readable", async () => {
+ mockRowsFor = (sql) => {
+ if (sql.includes("BP_HITRATIO")) return [{ LOGICAL_READS: "200", PHYSICAL_READS: "0" }];
+ if (sql.includes("DEADLOCKS")) return [{ DEADLOCKS: "3" }];
+ return [];
+ };
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ expect(await provider.getPerformanceMetrics()).toEqual({ cacheHitRatio: 100, deadlocks: 3 });
+ await provider.disconnect();
+ });
+
+ test("a buffer pool with no reads yet yields no ratio rather than a fabricated 0", async () => {
+ mockRowsFor = (sql) => (sql.includes("BP_HITRATIO") ? [{ LOGICAL_READS: "0", PHYSICAL_READS: "0" }] : []);
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ expect(await provider.getPerformanceMetrics()).toEqual({});
+ await provider.disconnect();
+ });
+
+ test("getOverview reads the Db2 service level for the version", async () => {
+ mockRowsFor = (sql) => (sql.includes("ENV_GET_INST_INFO") ? [{ SERVICE_LEVEL: "DB2 v12.1.0.0" }] : []);
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const overview = await provider.getOverview();
+ expect(overview.version).toBe("DB2 v12.1.0.0");
+ // With no catalog/activation/tablespace rows, the derived fields stay neutral.
+ expect(overview.databaseSize).toBe("N/A");
+ expect(overview.uptime).toBe("N/A");
+ expect(overview.maxConnections).toBe(0);
+ await provider.disconnect();
+ });
+
+ test("getOverview fills counts, uptime and database size when their reads succeed", async () => {
+ mockRowsFor = (sql) => {
+ if (sql.includes("ENV_GET_INST_INFO")) return [{ SERVICE_LEVEL: "DB2 v11.5.9.0" }];
+ if (sql.includes("SYSCAT.TABLES") && sql.includes("SYSCAT.INDEXES"))
+ return [{ TABLE_COUNT: "1135", INDEX_COUNT: "3300" }];
+ if (sql.includes("UPTIME_SECONDS")) return [{ UPTIME_SECONDS: "3600" }];
+ if (sql.includes("maxappls")) return [{ VALUE: "397" }];
+ if (sql.includes("BP_HITRATIO")) return [];
+ if (sql.includes("MON_GET_TABLESPACE"))
+ return [
+ { TBSP_NAME: "USERSPACE1", TBSP_USED_PAGES: "1000", TBSP_PAGE_SIZE: "4096" },
+ { TBSP_NAME: "SYSCATSPACE", TBSP_USED_PAGES: "500", TBSP_PAGE_SIZE: "4096" },
+ ];
+ return [];
+ };
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const overview = await provider.getOverview();
+ await provider.disconnect();
+
+ expect(overview.tableCount).toBe(1135);
+ expect(overview.indexCount).toBe(3300);
+ expect(overview.maxConnections).toBe(397);
+ // (1000 + 500) pages * 4096 = 6,144,000 bytes → formatted, non-"N/A", with the numeric
+ // byte figure set so fleet-health and the Overview total can sum it.
+ expect(overview.databaseSize).not.toBe("N/A");
+ expect(overview.databaseSizeBytes).toBe(6144000);
+ // Uptime is derived from DB_CONN_TIME, so it is a real duration string, not the sentinel.
+ expect(overview.uptime).not.toBe("N/A");
+ await provider.disconnect();
+ });
+
+ // Regression: uptime is computed in the database (elapsed seconds), not by parsing the
+ // activation timestamp in JS — a UTC server read as local time produced a NEGATIVE uptime.
+ // A negative/nonsensical value must fall back to "N/A", never render as "-3600000ms".
+ test("getOverview rejects a negative uptime rather than rendering it", async () => {
+ mockRowsFor = (sql) => {
+ if (sql.includes("ENV_GET_INST_INFO")) return [{ SERVICE_LEVEL: "DB2 v11.5.9.0" }];
+ if (sql.includes("UPTIME_SECONDS")) return [{ UPTIME_SECONDS: "-5" }];
+ return [];
+ };
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const overview = await provider.getOverview();
+ await provider.disconnect();
+ expect(overview.uptime).toBe("N/A");
+ });
+
+ test("getOverview surfaces the active-connection count when readable", async () => {
+ mockRowsFor = (sql) => {
+ if (sql.includes("ENV_GET_INST_INFO")) return [{ SERVICE_LEVEL: "DB2 v11.5.9.0" }];
+ if (sql.includes("MON_GET_CONNECTION") && sql.includes("COUNT")) return [{ N: "12" }];
+ return [];
+ };
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const overview = await provider.getOverview();
+ expect(overview.activeConnections).toBe(12);
+ await provider.disconnect();
+ });
+
+ test("getOverview leaves the neutral version when the permission-gated read is denied", async () => {
+ queryShouldThrowFor = (sql) => sql.includes("ENV_GET_INST_INFO");
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const overview = await provider.getOverview();
+ // The denied read is swallowed rather than failing the whole overview.
+ expect(overview.version).toBe("Unknown");
+ // A denied connection-count read leaves the field absent, never a fabricated 0.
+ expect(overview.activeConnections).toBeUndefined();
+ await provider.disconnect();
+ });
+
+ test("getIndexStats maps SYSCAT.INDEXES structural fields plus MON_GET_INDEX scans", async () => {
+ mockRowsFor = (sql) =>
+ sql.includes("SYSCAT.INDEXES")
+ ? [
+ {
+ INDSCHEMA: "APPDATA",
+ INDNAME: "PK_ORDERS",
+ TABNAME: "ORDERS",
+ UNIQUERULE: "P",
+ INDEXTYPE: "REG",
+ COLS: "ORDER_ID",
+ SCANS: "42",
+ },
+ {
+ INDSCHEMA: "APPDATA",
+ INDNAME: "NU_ORDERS_CUST",
+ TABNAME: "ORDERS",
+ UNIQUERULE: "D",
+ INDEXTYPE: "REG",
+ COLS: "CUSTOMER_ID,CREATED_AT",
+ SCANS: "0",
+ },
+ ]
+ : [];
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const indexes = await provider.getIndexStats();
+ await provider.disconnect();
+
+ expect(indexes).toHaveLength(2);
+ // 'P' → primary AND unique, single column, real scan count.
+ expect(indexes[0]).toMatchObject({
+ schemaName: "APPDATA",
+ tableName: "ORDERS",
+ indexName: "PK_ORDERS",
+ indexType: "REG",
+ columns: ["ORDER_ID"],
+ isUnique: true,
+ isPrimary: true,
+ indexSize: "N/A",
+ scans: 42,
+ });
+ expect(indexes[0].indexSizeBytes).toBeUndefined();
+ // 'D' → neither primary nor unique; multi-column split from the LISTAGG string.
+ expect(indexes[1]).toMatchObject({
+ indexName: "NU_ORDERS_CUST",
+ columns: ["CUSTOMER_ID", "CREATED_AT"],
+ isUnique: false,
+ isPrimary: false,
+ scans: 0,
+ });
+ });
+
+ // The permission-gated monitoring reads must return empty, never throw, when the
+ // connecting account cannot read the MON_GET_* functions (tryRun swallows the refusal).
+ test("slow queries / sessions / storage return empty when the monitoring reads are refused", async () => {
+ queryShouldThrowFor = (sql) => sql.includes("MON_GET_");
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ expect(await provider.getSlowQueries()).toEqual([]);
+ expect(await provider.getActiveSessions()).toEqual([]);
+ expect(await provider.getStorageStats()).toEqual([]);
+ await provider.disconnect();
+ });
+
+ test("getSlowQueries maps the package-cache statement rows with a derived average", async () => {
+ mockRowsFor = (sql) =>
+ sql.includes("MON_GET_PKG_CACHE_STMT")
+ ? [
+ {
+ STMT_TEXT: "SELECT * FROM ORDERS",
+ NUM_EXECUTIONS: "4",
+ NUM_EXEC_WITH_METRICS: "4",
+ TOTAL_ACT_TIME: "800",
+ ROWS_READ: "40",
+ },
+ ]
+ : [];
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const slow = await provider.getSlowQueries();
+ await provider.disconnect();
+ expect(slow).toHaveLength(1);
+ expect(slow[0]).toMatchObject({
+ query: "SELECT * FROM ORDERS",
+ calls: 4,
+ totalTime: 800,
+ avgTime: 200, // 800 / 4
+ rows: 40,
+ });
+ });
+
+ test("getActiveSessions maps MON_GET_CONNECTION rows, reporting no invented state/query", async () => {
+ mockRowsFor = (sql) =>
+ sql.includes("MON_GET_CONNECTION") && !sql.includes("COUNT")
+ ? [
+ {
+ APPLICATION_HANDLE: "4059",
+ APPLICATION_NAME: "sample_app",
+ CLIENT_IPADDR: "192.0.2.10",
+ SYSTEM_AUTH_ID: "APPDATA",
+ TOTAL_APP_COMMITS: "3",
+ },
+ ]
+ : [];
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const sessions = await provider.getActiveSessions();
+ await provider.disconnect();
+ expect(sessions).toHaveLength(1);
+ expect(sessions[0]).toMatchObject({
+ pid: "4059",
+ user: "APPDATA",
+ applicationName: "sample_app",
+ clientAddr: "192.0.2.10",
+ state: "active",
+ query: "",
+ });
+ });
+
+ test("getStorageStats maps tablespace pages to bytes and fill percentage", async () => {
+ mockRowsFor = (sql) =>
+ sql.includes("MON_GET_TABLESPACE")
+ ? [{ TBSP_NAME: "USERSPACE1", TBSP_TOTAL_PAGES: "1000", TBSP_USED_PAGES: "250", TBSP_PAGE_SIZE: "4096" }]
+ : [];
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const storage = await provider.getStorageStats();
+ await provider.disconnect();
+ expect(storage).toHaveLength(1);
+ expect(storage[0]).toMatchObject({
+ name: "USERSPACE1",
+ sizeBytes: 250 * 4096, // used pages * page size
+ usagePercent: 25, // 250 / 1000
+ });
+ });
+
+ // getTableStats is NOT a neutral empty: SYSCAT.TABLES publishes a real row count (CARD)
+ // and the RUNSTATS timestamp (STATS_TIME), so the provider surfaces them for the admin
+ // Tables panel. The currency caveat is the whole point of these assertions.
+ test("getTableStats maps CARD to rowCount and STATS_TIME to lastAnalyze", async () => {
+ mockRowsFor = (sql) => {
+ if (!sql.includes("SYSCAT.TABLES")) return [];
+ return [{ TABSCHEMA: "APPDATA", TABNAME: "ORDERS", CARD: "1000000", STATS_TIME: "2020-01-15 12:00:00.000000" }];
+ };
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const stats = await provider.getTableStats();
+ await provider.disconnect();
+
+ expect(stats).toHaveLength(1);
+ expect(stats[0]).toMatchObject({
+ schemaName: "APPDATA",
+ tableName: "ORDERS",
+ rowCount: 1000000,
+ // Size is not read per table (too heavy across a whole schema), so the required
+ // fields carry the honest placeholder and the byte fields are absent.
+ totalSize: "N/A",
+ totalSizeBytes: 0,
+ });
+ expect(stats[0].tableSizeBytes).toBeUndefined();
+ // STATS_TIME becomes lastAnalyze, so a reader can see how stale the count is.
+ expect(stats[0].lastAnalyze).toBeInstanceOf(Date);
+ expect(stats[0].lastAnalyze?.getFullYear()).toBe(2020);
+ });
+
+ // A table that never had RUNSTATS reports CARD = -1 and STATS_TIME = NULL. That must read
+ // as "no stats" (rowCount 0, no lastAnalyze), never as a literal -1 row count.
+ test("getTableStats treats CARD = -1 / NULL STATS_TIME as no-stats, not a -1 count", async () => {
+ mockRowsFor = (sql) => {
+ if (!sql.includes("SYSCAT.TABLES")) return [];
+ return [{ TABSCHEMA: "APPDATA", TABNAME: "FRESHLY_CREATED", CARD: "-1", STATS_TIME: null }];
+ };
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const stats = await provider.getTableStats();
+ await provider.disconnect();
+
+ expect(stats).toHaveLength(1);
+ expect(stats[0].rowCount).toBe(0);
+ expect(stats[0].lastAnalyze).toBeUndefined();
+ });
+});
+
+describe("Db2Provider runMaintenance", () => {
+ test("analyze issues RUNSTATS through ADMIN_CMD", async () => {
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const result = await provider.runMaintenance("analyze", "users");
+ expect(result.success).toBe(true);
+ const stmt = capturedQueries.find((q) => q.includes("RUNSTATS"));
+ expect(stmt).toContain("CALL SYSPROC.ADMIN_CMD");
+ expect(stmt).toContain('RUNSTATS ON TABLE "users"');
+ await provider.disconnect();
+ });
+
+ test("optimize issues REORG TABLE through ADMIN_CMD", async () => {
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ const result = await provider.runMaintenance("optimize", "users");
+ expect(result.success).toBe(true);
+ const stmt = capturedQueries.find((q) => q.includes("REORG"));
+ expect(stmt).toContain('REORG TABLE "users"');
+ await provider.disconnect();
+ });
+
+ test("analyze without a target is refused rather than sent", async () => {
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ await expect(provider.runMaintenance("analyze")).rejects.toBeInstanceOf(DatabaseConfigError);
+ await provider.disconnect();
+ });
+
+ test("optimize without a target is refused rather than sent", async () => {
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ await expect(provider.runMaintenance("optimize")).rejects.toBeInstanceOf(DatabaseConfigError);
+ await provider.disconnect();
+ });
+
+ test("an operation Db2 does not offer is refused", async () => {
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ // `vacuum` is not in maintenanceOperations, so it reaches the unsupported branch.
+ await expect(provider.runMaintenance("vacuum", "users")).rejects.toBeInstanceOf(DatabaseConfigError);
+ await provider.disconnect();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// The object surface (#786, #789)
+//
+// `catalogRows` mirrors `docker/db2-init/01-object-fixture.sql` as Db2 12.1 answered it on
+// 2026-09-15, including the two shapes a hand-typed double would get wrong: a SCHEMA column
+// is blank-padded to eight characters while an object name is not, and a trigger may live in
+// a schema other than its table's. Each answer is chosen by the catalog view the statement
+// reads and by its BOUND values, never by the statement's exact spelling.
+// ---------------------------------------------------------------------------
+
+const VIEW_TEXT = "CREATE VIEW APP.ORDER_SUMMARY AS\n SELECT C.NAME, SUM(O.TOTAL) AS TOTAL -- author comment";
+const MQT_TEXT =
+ "CREATE TABLE APP.ORDER_TOTALS AS (SELECT CUSTOMER_ID, SUM(TOTAL) AS TOTAL FROM APP.ORDERS GROUP BY CUSTOMER_ID) DATA INITIALLY DEFERRED REFRESH DEFERRED";
+const PROC_TEXT =
+ "CREATE PROCEDURE APP.ADD_ORDER (IN P_ID INTEGER)\nLANGUAGE SQL\nBEGIN\n INSERT INTO APP.ORDERS (ID) VALUES (P_ID);\nEND";
+const FN_TEXT = "CREATE FUNCTION APP.ORDER_TOTAL (P_ID INTEGER) RETURNS DECIMAL(12, 2) RETURN 1";
+const TRIGGER_TEXT = "CREATE TRIGGER APP.ORDERS_NOTE_DEFAULT\nNO CASCADE BEFORE INSERT ON APP.ORDERS";
+const AUDIT_TEXT = "CREATE TRIGGER REPORTING.ORDERS_AUDIT AFTER UPDATE ON APP.ORDERS FOR EACH ROW";
+
+const TABLES = [
+ { TABSCHEMA: "APP ", TABNAME: "CUSTOMERS", TYPE: "T", STATUS: "N", CARD: "2", VALID: null },
+ { TABSCHEMA: "APP ", TABNAME: "ORDERS", TYPE: "T", STATUS: "N", CARD: "-1", VALID: null },
+ { TABSCHEMA: "APP ", TABNAME: "Mixed Case", TYPE: "T", STATUS: "C", CARD: "-1", VALID: null },
+ { TABSCHEMA: "APP ", TABNAME: "ORDER_SUMMARY", TYPE: "V", STATUS: "N", CARD: "-1", VALID: "Y" },
+ { TABSCHEMA: "APP ", TABNAME: "SCRATCH_VIEW", TYPE: "V", STATUS: "N", CARD: "-1", VALID: "N" },
+ { TABSCHEMA: "APP ", TABNAME: "ORDER_TOTALS", TYPE: "S", STATUS: "N", CARD: "0", VALID: "Y" },
+ { TABSCHEMA: "APP ", TABNAME: "CLIENTS", TYPE: "A", STATUS: "N", CARD: "-1", VALID: null },
+ { TABSCHEMA: "REPORTING", TABNAME: "DAILY", TYPE: "T", STATUS: "N", CARD: "-1", VALID: null },
+];
+const VIEW_TEXTS: Record = {
+ ORDER_SUMMARY: VIEW_TEXT,
+ SCRATCH_VIEW: "CREATE VIEW APP.SCRATCH_VIEW AS SELECT ID FROM APP.SCRATCH",
+ ORDER_TOTALS: MQT_TEXT,
+};
+const ROUTINES = [
+ {
+ ROUTINESCHEMA: "APP",
+ SPECIFICNAME: "SQL260915014426733",
+ ROUTINENAME: "ADD_ORDER",
+ ROUTINETYPE: "P",
+ VALID: "Y",
+ ORIGIN: "Q",
+ TEXT: PROC_TEXT,
+ },
+ {
+ ROUTINESCHEMA: "APP",
+ SPECIFICNAME: "ORDER_TOTAL_BY_ID",
+ ROUTINENAME: "ORDER_TOTAL",
+ ROUTINETYPE: "F",
+ VALID: "Y",
+ ORIGIN: "Q",
+ TEXT: FN_TEXT,
+ },
+ {
+ ROUTINESCHEMA: "APP",
+ SPECIFICNAME: "SQL260915014426735",
+ ROUTINENAME: "ORDER_TOTAL",
+ ROUTINETYPE: "F",
+ VALID: "Y",
+ ORIGIN: "Q",
+ TEXT: FN_TEXT,
+ },
+ {
+ ROUTINESCHEMA: "APP",
+ SPECIFICNAME: "SQL260915014426740",
+ ROUTINENAME: "EXT_FN",
+ ROUTINETYPE: "F",
+ VALID: "Y",
+ ORIGIN: "E",
+ TEXT: null,
+ },
+];
+const TRIGGERS = [
+ {
+ TRIGSCHEMA: "APP",
+ TRIGNAME: "ORDERS_NOTE_DEFAULT",
+ TABSCHEMA: "APP",
+ TABNAME: "ORDERS",
+ VALID: "Y",
+ TEXT: TRIGGER_TEXT,
+ },
+ {
+ TRIGSCHEMA: "REPORTING",
+ TRIGNAME: "ORDERS_AUDIT",
+ TABSCHEMA: "APP",
+ TABNAME: "ORDERS",
+ VALID: "Y",
+ TEXT: AUDIT_TEXT,
+ },
+];
+const COLUMNS: Record[]> = {
+ ORDERS: [
+ {
+ COLUMN_NAME: "ID",
+ TYPENAME: "INTEGER",
+ LENGTH: 4,
+ SCALE: 0,
+ CODEPAGE: 0,
+ NULLS: "N",
+ DEFAULT_VALUE: null,
+ KEYSEQ: 1,
+ },
+ {
+ COLUMN_NAME: "CUSTOMER_ID",
+ TYPENAME: "INTEGER",
+ LENGTH: 4,
+ SCALE: 0,
+ CODEPAGE: 0,
+ NULLS: "Y",
+ DEFAULT_VALUE: null,
+ KEYSEQ: null,
+ },
+ {
+ COLUMN_NAME: "TOTAL",
+ TYPENAME: "DECIMAL",
+ LENGTH: 12,
+ SCALE: 2,
+ CODEPAGE: 0,
+ NULLS: "Y",
+ DEFAULT_VALUE: "0",
+ KEYSEQ: null,
+ },
+ {
+ COLUMN_NAME: "NOTE",
+ TYPENAME: "VARCHAR",
+ LENGTH: 200,
+ SCALE: 0,
+ CODEPAGE: 1208,
+ NULLS: "Y",
+ DEFAULT_VALUE: null,
+ KEYSEQ: null,
+ },
+ ],
+ CUSTOMERS: [
+ {
+ COLUMN_NAME: "ID",
+ TYPENAME: "INTEGER",
+ LENGTH: 4,
+ SCALE: 0,
+ CODEPAGE: 0,
+ NULLS: "N",
+ DEFAULT_VALUE: null,
+ KEYSEQ: 1,
+ },
+ {
+ COLUMN_NAME: "NAME",
+ TYPENAME: "VARCHAR",
+ LENGTH: 100,
+ SCALE: 0,
+ CODEPAGE: 1208,
+ NULLS: "Y",
+ DEFAULT_VALUE: null,
+ KEYSEQ: null,
+ },
+ ],
+ DAILY: [
+ {
+ COLUMN_NAME: "DAY",
+ TYPENAME: "DATE",
+ LENGTH: 4,
+ SCALE: 0,
+ CODEPAGE: 0,
+ NULLS: "N",
+ DEFAULT_VALUE: null,
+ KEYSEQ: 1,
+ },
+ {
+ COLUMN_NAME: "CUSTOMER_ID",
+ TYPENAME: "INTEGER",
+ LENGTH: 4,
+ SCALE: 0,
+ CODEPAGE: 0,
+ NULLS: "N",
+ DEFAULT_VALUE: null,
+ KEYSEQ: 2,
+ },
+ ],
+};
+const FOREIGN_KEYS: Record[]> = {
+ ORDERS: [{ COLUMN_NAME: "CUSTOMER_ID", REF_SCHEMA: "APP", REF_TABLE: "CUSTOMERS", REF_COLUMN: "ID" }],
+ DAILY: [{ COLUMN_NAME: "CUSTOMER_ID", REF_SCHEMA: "APP", REF_TABLE: "CUSTOMERS", REF_COLUMN: "ID" }],
+};
+const INDEXES: Record[]> = {
+ ORDERS: [
+ { INDEX_SCHEMA: "APP", INDEX_NAME: "ORDERS_CUSTOMER_IX", UNIQUERULE: "D", COLUMN_NAME: "CUSTOMER_ID" },
+ { INDEX_SCHEMA: "APP", INDEX_NAME: "ORDERS_CUSTOMER_IX", UNIQUERULE: "D", COLUMN_NAME: "TOTAL" },
+ { INDEX_SCHEMA: "APP", INDEX_NAME: "ORDERS_PK", UNIQUERULE: "P", COLUMN_NAME: "ID" },
+ ],
+};
+
+const trimmed = (value: unknown) => String(value).trimEnd();
+const withName = (name: string, rows: Record[] = []) =>
+ rows.map((row) => ({ OBJECT_NAME: name, ...row }));
+
+function catalogRows(sql: string, params: unknown[] = []): Record[] {
+ const [schema] = params;
+ if (sql.includes("SYSCAT.SCHEMATA")) {
+ return [
+ { NAME: "REPORTING", IS_SESSION_DEFAULT: 0 },
+ { NAME: "APP", IS_SESSION_DEFAULT: 1 },
+ ];
+ }
+ if (sql.includes("COUNT(*)")) {
+ const tables = TABLES.filter((row) => trimmed(row.TABSCHEMA) === schema);
+ const byType = (type: string) => tables.filter((row) => row.TYPE === type).length;
+ const routines = ROUTINES.filter((row) => row.ROUTINESCHEMA === schema);
+ return [
+ ...["T", "V", "S", "A"]
+ .filter((type) => byType(type) > 0)
+ .map((type) => ({ KIND: `TABLES:${type}`, N: byType(type) })),
+ { KIND: "SEQUENCES", N: schema === "APP" ? 1 : 0 },
+ { KIND: "MODULES", N: schema === "APP" ? 1 : 0 },
+ ...["P", "F"]
+ .map((type) => ({ KIND: `ROUTINES:${type}`, N: routines.filter((row) => row.ROUTINETYPE === type).length }))
+ .filter((row) => row.N > 0),
+ { KIND: "TRIGGERS", N: TRIGGERS.filter((row) => row.TRIGSCHEMA === schema).length },
+ ];
+ }
+ // Bulk reads: a target listing, then detail rows restricted to that target.
+ if (sql.includes("AS OBJECT_NAME")) {
+ const [, type, bound] = params;
+ const target = TABLES.filter((row) => trimmed(row.TABSCHEMA) === schema && row.TYPE === type)
+ .map((row) => row.TABNAME)
+ .sort();
+ const limited = typeof bound === "number" ? target.slice(0, bound) : target;
+ if (sql.includes("SYSCAT.COLUMNS")) return limited.flatMap((name) => withName(name, COLUMNS[name]));
+ if (sql.includes("SYSCAT.REFERENCES")) return limited.flatMap((name) => withName(name, FOREIGN_KEYS[name]));
+ if (sql.includes("SYSCAT.INDEXES")) return limited.flatMap((name) => withName(name, INDEXES[name]));
+ return limited.map((name) => ({ OBJECT_NAME: name }));
+ }
+ if (sql.includes("SYSCAT.COLUMNS")) return COLUMNS[String(params[1])] ?? [];
+ if (sql.includes("SYSCAT.REFERENCES")) return FOREIGN_KEYS[String(params[1])] ?? [];
+ if (sql.includes("SYSCAT.INDEXES")) return INDEXES[String(params[1])] ?? [];
+ if (sql.includes("SYSCAT.VIEWS") && sql.includes("TEXT")) {
+ const [, name, type] = params;
+ const row = TABLES.find((t) => trimmed(t.TABSCHEMA) === schema && t.TABNAME === name && t.TYPE === type);
+ return row ? [{ TEXT: VIEW_TEXTS[String(name)] }] : [];
+ }
+ if (sql.includes("SYSCAT.TABLES")) {
+ return TABLES.filter((row) => trimmed(row.TABSCHEMA) === schema && row.TYPE === params[1]).map((row) => ({
+ NAME: row.TABNAME,
+ STATUS: row.STATUS,
+ CARD: row.CARD,
+ VALID: row.VALID,
+ }));
+ }
+ if (sql.includes("SYSCAT.SEQUENCES")) return schema === "APP" ? [{ NAME: "ORDER_SEQ" }] : [];
+ if (sql.includes("SYSCAT.MODULES")) return schema === "APP" ? [{ NAME: "ORDER_MOD" }] : [];
+ if (sql.includes("SYSCAT.ROUTINES")) {
+ if (sql.includes("TEXT")) {
+ const [, specific, type] = params;
+ return ROUTINES.filter(
+ (r) => r.ROUTINESCHEMA === schema && r.SPECIFICNAME === specific && r.ROUTINETYPE === type,
+ );
+ }
+ return ROUTINES.filter((row) => row.ROUTINESCHEMA === schema && row.ROUTINETYPE === params[1]).map((row) => ({
+ SEGMENT: row.SPECIFICNAME,
+ NAME: row.ROUTINENAME,
+ VALID: row.VALID,
+ }));
+ }
+ if (sql.includes("SYSCAT.TRIGGERS")) {
+ if (sql.includes("TEXT")) {
+ // [schema, name] for a trigger hanging off the schema, [schema, table, name] under its table.
+ return TRIGGERS.filter((row) =>
+ params.length === 3
+ ? row.TRIGSCHEMA === schema &&
+ row.TABNAME === params[1] &&
+ row.TRIGNAME === params[2] &&
+ row.TABSCHEMA === schema
+ : row.TRIGSCHEMA === schema && row.TRIGNAME === params[1] && row.TABSCHEMA !== schema,
+ );
+ }
+ return TRIGGERS.filter((row) => row.TRIGSCHEMA === schema).map((row) => ({
+ NAME: row.TRIGNAME,
+ PARENT: row.TABSCHEMA === row.TRIGSCHEMA ? row.TABNAME : null,
+ VALID: row.VALID,
+ }));
+ }
+ return [];
+}
+
+async function connectedProvider() {
+ const provider = new Db2Provider(baseConfig);
+ await provider.connect();
+ return provider;
+}
+
+describe("Db2Provider object declarations", () => {
+ const caps = new Db2Provider(baseConfig).getCapabilities();
+
+ test("one container level, the schema", () => {
+ expect(caps.containerLevels).toEqual([{ id: "schema", label: "Schema", labelPlural: "Schemas" }]);
+ });
+
+ test("declares the nine kinds SYSCAT answers for, in tree order", () => {
+ expect(caps.objectKinds?.map((kind) => kind.id)).toEqual([
+ "table",
+ "view",
+ "materialized_query_table",
+ "alias",
+ "sequence",
+ "module",
+ "procedure",
+ "function",
+ "trigger",
+ ]);
+ });
+
+ test("source is declared exactly where SYSCAT keeps the statement text", () => {
+ const withSource = caps.objectKinds?.filter((kind) => kind.hasSource === true).map((kind) => kind.id);
+ expect(withSource).toEqual(["view", "materialized_query_table", "procedure", "function", "trigger"]);
+ for (const kind of caps.objectKinds ?? []) {
+ if (kind.hasSource === true) expect(kind.sourceLanguage).toBe("sql");
+ expect(kind.acceptsSourceEdits).toBeUndefined();
+ }
+ });
+
+ test("a trigger hangs off a table, a module groups routines, and only a table takes row writes", () => {
+ const byId = Object.fromEntries((caps.objectKinds ?? []).map((kind) => [kind.id, kind]));
+ expect(byId.trigger.attachedTo).toBe("table");
+ expect(byId.module.childKinds).toEqual(["procedure", "function"]);
+ expect((caps.objectKinds ?? []).filter((kind) => kind.acceptsRowWrites === true).map((kind) => kind.id)).toEqual([
+ "table",
+ ]);
+ });
+});
+
+describe("Db2Provider object surface", () => {
+ test("conforms against the fixture mirror", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ await assertObjectSurface(provider, {
+ containers: [["APP"], ["REPORTING"]],
+ kinds: {
+ table: 3,
+ view: 2,
+ materialized_query_table: 1,
+ alias: 1,
+ sequence: 1,
+ module: 1,
+ procedure: 1,
+ function: 3,
+ trigger: 1,
+ },
+ sampleObject: { path: ["APP", "ORDERS"], kind: "table" },
+ absentSource: { path: ["APP", "NO_SUCH_VIEW"], kind: "view" },
+ });
+ await provider.disconnect();
+ });
+
+ test("the second schema answers its own objects, including a trigger on another schema's table", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ expect(await provider.listObjects(["REPORTING"], "table")).toEqual([
+ { path: ["REPORTING", "DAILY"], name: "DAILY", kind: "table" },
+ ]);
+ // Its table is APP.ORDERS, which is not in REPORTING, so there is no row in this schema
+ // to nest it under: it hangs off the schema itself.
+ expect(await provider.listObjects(["REPORTING"], "trigger")).toEqual([
+ { path: ["REPORTING", "ORDERS_AUDIT"], name: "ORDERS_AUDIT", kind: "trigger" },
+ ]);
+ await provider.disconnect();
+ });
+});
+
+describe("Db2Provider listContainers", () => {
+ test("lists schemas sorted by name, flags the session default, and hides the reserved ones", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ expect(await provider.listContainers()).toEqual([
+ { path: ["APP"], name: "APP", level: 0, isSessionDefault: true },
+ { path: ["REPORTING"], name: "REPORTING", level: 0, isSessionDefault: false },
+ ]);
+ const statement = capturedQueries.find((sql) => sql.includes("SYSCAT.SCHEMATA"));
+ // Owner type cannot separate them: an implicitly created user schema is OWNER SYSIBM too.
+ expect(statement).toContain("NOT LIKE 'SYS%'");
+ expect(statement).toContain("RTRIM(SCHEMANAME)");
+ await provider.disconnect();
+ });
+
+ test("nothing nests under a schema", async () => {
+ const provider = await connectedProvider();
+ expect(await provider.listContainers(["APP"])).toEqual([]);
+ expect(capturedQueries).toHaveLength(0);
+ await provider.disconnect();
+ });
+});
+
+describe("Db2Provider countObjects", () => {
+ test("seeds every declared kind at zero and binds the schema", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ const counts = await provider.countObjects(["REPORTING"]);
+ expect(counts).toEqual({
+ table: { count: 1 },
+ view: { count: 0 },
+ materialized_query_table: { count: 0 },
+ alias: { count: 0 },
+ sequence: { count: 0 },
+ module: { count: 0 },
+ procedure: { count: 0 },
+ function: { count: 0 },
+ trigger: { count: 1 },
+ });
+ const index = capturedQueries.findIndex((sql) => sql.includes("COUNT(*)"));
+ expect(capturedParams[index]).toEqual(["REPORTING", "REPORTING", "REPORTING", "REPORTING", "REPORTING"]);
+ await provider.disconnect();
+ });
+
+ test("a refused read reports Db2's own sentence against every kind, never a zero", async () => {
+ queryShouldThrowFor = (sql) => sql.includes("COUNT(*)");
+ const provider = await connectedProvider();
+ const counts = await provider.countObjects(["APP"]);
+ expect(Object.keys(counts)).toHaveLength(9);
+ for (const count of Object.values(counts)) {
+ expect(count).toEqual({ unavailable: "SQL0551N the user does not have the required authorization" });
+ }
+ await provider.disconnect();
+ });
+
+ test("a container path that is not one schema is refused rather than read as empty", async () => {
+ const provider = await connectedProvider();
+ await expect(provider.countObjects([])).rejects.toThrow(/container path is \[schema\]/);
+ await expect(provider.listObjects(["DB", "APP"], "table")).rejects.toThrow(QueryError);
+ await provider.disconnect();
+ });
+});
+
+describe("Db2Provider listObjects", () => {
+ test("a table carries a measured row count and the status a reader acts on", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ expect(await provider.listObjects(["APP"], "table")).toEqual([
+ { path: ["APP", "CUSTOMERS"], name: "CUSTOMERS", kind: "table", rowCount: 2 },
+ // CARD -1 is "RUNSTATS never ran", an absence, so no rowCount at all.
+ { path: ["APP", "Mixed Case"], name: "Mixed Case", kind: "table", status: "SET INTEGRITY PENDING" },
+ { path: ["APP", "ORDERS"], name: "ORDERS", kind: "table" },
+ ]);
+ await provider.disconnect();
+ });
+
+ test("an invalid view is marked and a valid one is not", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ expect(await provider.listObjects(["APP"], "view")).toEqual([
+ { path: ["APP", "ORDER_SUMMARY"], name: "ORDER_SUMMARY", kind: "view" },
+ { path: ["APP", "SCRATCH_VIEW"], name: "SCRATCH_VIEW", kind: "view", status: "INVALID" },
+ ]);
+ await provider.disconnect();
+ });
+
+ test("an overloaded function is addressed by its specific name and labelled by its routine name", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ expect(await provider.listObjects(["APP"], "function")).toEqual([
+ { path: ["APP", "ORDER_TOTAL_BY_ID"], name: "ORDER_TOTAL", kind: "function" },
+ { path: ["APP", "SQL260915014426735"], name: "ORDER_TOTAL", kind: "function" },
+ { path: ["APP", "SQL260915014426740"], name: "EXT_FN", kind: "function" },
+ ]);
+ const statement = capturedQueries.find((sql) => sql.includes("SYSCAT.ROUTINES"));
+ // A module's routines belong to the module, and a built-in or system-generated one to nobody.
+ expect(statement).toContain("ROUTINEMODULENAME IS NULL");
+ await provider.disconnect();
+ });
+
+ test("a trigger in its table's schema nests under the table", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ expect(await provider.listObjects(["APP"], "trigger")).toEqual([
+ { path: ["APP", "ORDERS", "ORDERS_NOTE_DEFAULT"], name: "ORDERS_NOTE_DEFAULT", kind: "trigger" },
+ ]);
+ await provider.disconnect();
+ });
+
+ test("routine and trigger validity use Db2's codes", async () => {
+ mockRowsFor = (sql) => {
+ if (sql.includes("SYSCAT.ROUTINES")) return [{ SEGMENT: "P1", NAME: "P1", VALID: "X" }];
+ if (sql.includes("SYSCAT.TRIGGERS")) return [{ NAME: "T1", PARENT: null, VALID: "N" }];
+ return [];
+ };
+ const provider = await connectedProvider();
+ expect(await provider.listObjects(["APP"], "procedure")).toEqual([
+ { path: ["APP", "P1"], name: "P1", kind: "procedure", status: "INOPERATIVE" },
+ ]);
+ expect(await provider.listObjects(["APP"], "trigger")).toEqual([
+ { path: ["APP", "T1"], name: "T1", kind: "trigger", status: "INVALID" },
+ ]);
+ await provider.disconnect();
+ });
+
+ test("sequences, modules and aliases list by name", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ expect(await provider.listObjects(["APP"], "sequence")).toEqual([
+ { path: ["APP", "ORDER_SEQ"], name: "ORDER_SEQ", kind: "sequence" },
+ ]);
+ expect(await provider.listObjects(["APP"], "module")).toEqual([
+ { path: ["APP", "ORDER_MOD"], name: "ORDER_MOD", kind: "module" },
+ ]);
+ expect(await provider.listObjects(["APP"], "alias")).toEqual([
+ { path: ["APP", "CLIENTS"], name: "CLIENTS", kind: "alias" },
+ ]);
+ await provider.disconnect();
+ });
+
+ test("a kind Db2 does not declare is refused, not answered empty", async () => {
+ const provider = await connectedProvider();
+ await expect(provider.listObjects(["APP"], "package")).rejects.toThrow('Db2 declares no object kind "package"');
+ expect(capturedQueries).toHaveLength(0);
+ await provider.disconnect();
+ });
+
+ test("a failed listing is mapped rather than answered empty", async () => {
+ queryShouldThrowFor = (sql) => sql.includes("SYSCAT.SEQUENCES");
+ const provider = await connectedProvider();
+ await expect(provider.listObjects(["APP"], "sequence")).rejects.toThrow(/SQL0551N/);
+ await provider.disconnect();
+ });
+});
+
+describe("Db2Provider describeObject", () => {
+ test("columns carry the engine's type with its length, the key, the default and nullability", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ const detail = await provider.describeObject(["APP", "ORDERS"], "table");
+ expect(detail).toEqual({
+ path: ["APP", "ORDERS"],
+ columns: [
+ { name: "ID", type: "INTEGER", nullable: false, isPrimary: true },
+ { name: "CUSTOMER_ID", type: "INTEGER", nullable: true, isPrimary: false },
+ { name: "TOTAL", type: "DECIMAL(12,2)", nullable: true, isPrimary: false, defaultValue: "0" },
+ { name: "NOTE", type: "VARCHAR(200)", nullable: true, isPrimary: false },
+ ],
+ indexes: [
+ { name: "ORDERS_CUSTOMER_IX", columns: ["CUSTOMER_ID", "TOTAL"], unique: false },
+ { name: "ORDERS_PK", columns: ["ID"], unique: true },
+ ],
+ foreignKeys: [{ columnName: "CUSTOMER_ID", referencedTable: "CUSTOMERS", referencedColumn: "ID" }],
+ });
+ // Every read is bound to one schema and one object, and nothing is interpolated.
+ for (const [index, sql] of capturedQueries.entries()) {
+ if (sql.includes("SYSCAT.")) expect(capturedParams[index]).toEqual(["APP", "ORDERS"]);
+ }
+ await provider.disconnect();
+ });
+
+ test("a foreign key into another schema names that schema", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ const detail = await provider.describeObject(["REPORTING", "DAILY"], "table");
+ expect(detail.foreignKeys).toEqual([
+ { columnName: "CUSTOMER_ID", referencedTable: "APP.CUSTOMERS", referencedColumn: "ID" },
+ ]);
+ expect(detail.columns.filter((column) => column.isPrimary).map((column) => column.name)).toEqual([
+ "DAY",
+ "CUSTOMER_ID",
+ ]);
+ await provider.disconnect();
+ });
+
+ test("the referenced key is joined on its table, because a constraint name is unique per table", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ await provider.describeObject(["APP", "ORDERS"], "table");
+ const statement = capturedQueries.find((sql) => sql.includes("SYSCAT.REFERENCES"));
+ expect(statement).toContain("pk.TABNAME = r.REFTABNAME");
+ await provider.disconnect();
+ });
+
+ test("a kind that is not a relation answers empty detail without a round trip", async () => {
+ const provider = await connectedProvider();
+ expect(await provider.describeObject(["APP", "ORDER_SEQ"], "sequence")).toEqual({
+ path: ["APP", "ORDER_SEQ"],
+ columns: [],
+ indexes: [],
+ foreignKeys: [],
+ });
+ expect(capturedQueries).toHaveLength(0);
+ await provider.disconnect();
+ });
+
+ test("a path of the wrong shape is refused by name", async () => {
+ const provider = await connectedProvider();
+ await expect(provider.describeObject(["APP"], "table")).rejects.toThrow(/"table" path is \[schema, name\]/);
+ await expect(provider.describeObject(["APP", "T", "X", "Y"], "trigger")).rejects.toThrow(
+ /\[schema, table, name\] or \[schema, name\]/,
+ );
+ await expect(provider.describeObject(["APP", "X"], "nope")).rejects.toThrow('Db2 declares no object kind "nope"');
+ await provider.disconnect();
+ });
+});
+
+describe("Db2Provider describeObjects", () => {
+ test("describes a whole kind in four round trips, sorted by path", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ const batch = await provider.describeObjects(["APP"], "table");
+ expect(batch.truncated).toBeUndefined();
+ expect(batch.details.map((detail) => detail.path)).toEqual([
+ ["APP", "CUSTOMERS"],
+ ["APP", "Mixed Case"],
+ ["APP", "ORDERS"],
+ ]);
+ const single = await new Db2Provider(baseConfig).getCapabilities();
+ expect(single.objectKinds).toBeDefined();
+ const orders = batch.details.find((detail) => detail.path[1] === "ORDERS");
+ expect(orders).toEqual(await provider.describeObject(["APP", "ORDERS"], "table"));
+ await provider.disconnect();
+ });
+
+ test("a caller's bound reads one row more and reports the truncation in the caller's number", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ const batch = await provider.describeObjects(["APP"], "table", 2);
+ expect(batch.details).toHaveLength(2);
+ expect(batch.truncated).toEqual({ limit: 2, reason: callerBoundTruncationReason(2) });
+ const target = capturedQueries.findIndex((sql) => sql.includes("AS OBJECT_NAME"));
+ expect(capturedParams[target]).toEqual(["APP", "T", 3]);
+ await provider.disconnect();
+ });
+
+ test("an exact bound is not marked truncated", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ const batch = await provider.describeObjects(["APP"], "table", 3);
+ expect(batch.details).toHaveLength(3);
+ expect(batch.truncated).toBeUndefined();
+ await provider.disconnect();
+ });
+
+ test("an empty kind costs one round trip and a non-relation none", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ expect(await provider.describeObjects(["REPORTING"], "view")).toEqual({ details: [] });
+ expect(capturedQueries).toHaveLength(1);
+ expect(await provider.describeObjects(["APP"], "procedure")).toEqual({ details: [] });
+ expect(capturedQueries).toHaveLength(1);
+ await provider.disconnect();
+ });
+
+ test("a bound that is not a positive whole number is refused", async () => {
+ const provider = await connectedProvider();
+ await expect(provider.describeObjects(["APP"], "table", 0)).rejects.toThrow(/positive whole number/);
+ await expect(provider.describeObjects(["APP"], "table", 1.5)).rejects.toThrow(/positive whole number/);
+ await expect(provider.describeObjects(["APP"], "nope")).rejects.toThrow('Db2 declares no object kind "nope"');
+ await provider.disconnect();
+ });
+});
+
+describe("Db2Provider readObjectSource", () => {
+ test("a view's text is the author's stored statement, complete", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ const document = await provider.readObjectSource!(["APP", "ORDER_SUMMARY"], "view");
+ expect(document).toEqual({
+ path: ["APP", "ORDER_SUMMARY"],
+ kind: "view",
+ parts: [
+ { id: "definition", label: "Definition", text: VIEW_TEXT, language: "sql", form: "complete", origin: "stored" },
+ ],
+ });
+ const index = capturedQueries.findIndex((sql) => sql.includes("SYSCAT.VIEWS"));
+ expect(capturedParams[index]).toEqual(["APP", "ORDER_SUMMARY", "V"]);
+ await provider.disconnect();
+ });
+
+ test("a view read under the materialized query table kind is an absence", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ await expect(provider.readObjectSource!(["APP", "ORDER_SUMMARY"], "materialized_query_table")).rejects.toThrow(
+ 'Db2 holds no materialized query table called "ORDER_SUMMARY" in APP',
+ );
+ await provider.disconnect();
+ });
+
+ test("an external routine is a refusal part naming why Db2 has no text", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ const document = await provider.readObjectSource!(["APP", "SQL260915014426740"], "function");
+ expect(document.parts).toHaveLength(1);
+ const [part] = document.parts;
+ expect(isSourcePartUnavailable(part)).toBe(true);
+ expect(part).toEqual({
+ id: "definition",
+ label: "Definition",
+ unavailable: expect.stringContaining("EXTERNAL routine") as unknown as string,
+ });
+ await provider.disconnect();
+ });
+
+ test("a sourced routine and one of unknown origin each say which", async () => {
+ let origin = "U";
+ mockRowsFor = (sql) => (sql.includes("SYSCAT.ROUTINES") ? [{ TEXT: null, ORIGIN: origin }] : []);
+ const provider = await connectedProvider();
+ const sourced = await provider.readObjectSource!(["APP", "S1"], "function");
+ expect(sourced.parts[0]).toMatchObject({ unavailable: expect.stringContaining("SOURCED") });
+ origin = "F";
+ const federated = await provider.readObjectSource!(["APP", "S1"], "function");
+ expect(federated.parts[0]).toMatchObject({ unavailable: expect.stringContaining("FEDERATED") });
+ origin = "Z";
+ const unknown = await provider.readObjectSource!(["APP", "S1"], "function");
+ expect(unknown.parts[0]).toMatchObject({ unavailable: expect.stringContaining("ORIGIN 'Z'") });
+ await provider.disconnect();
+ });
+
+ test("a procedure and both trigger addresses read their stored text", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ const proc = await provider.readObjectSource!(["APP", "SQL260915014426733"], "procedure");
+ expect(proc.parts[0]).toMatchObject({ text: PROC_TEXT });
+ const nested = await provider.readObjectSource!(["APP", "ORDERS", "ORDERS_NOTE_DEFAULT"], "trigger");
+ expect(nested.parts[0]).toMatchObject({ text: TRIGGER_TEXT });
+ const loose = await provider.readObjectSource!(["REPORTING", "ORDERS_AUDIT"], "trigger");
+ expect(loose.parts[0]).toMatchObject({ text: AUDIT_TEXT });
+ // The same trigger under an address the listing never produced is not found.
+ await expect(provider.readObjectSource!(["APP", "ORDERS_NOTE_DEFAULT"], "trigger")).rejects.toThrow(QueryError);
+ await provider.disconnect();
+ });
+
+ test("a caller's bound truncates the text and says so", async () => {
+ mockRowsFor = catalogRows;
+ const provider = await connectedProvider();
+ const document = await provider.readObjectSource!(["APP", "ORDER_SUMMARY"], "view", 10);
+ expect(document.parts[0]).toMatchObject({ text: VIEW_TEXT.slice(0, 10), truncated: { limit: 10 } });
+ await provider.disconnect();
+ });
+
+ test("an empty text is a refusal and a non-string text raises", async () => {
+ let text: unknown = " ";
+ mockRowsFor = (sql) => (sql.includes("SYSCAT.VIEWS") ? [{ TEXT: text }] : []);
+ const provider = await connectedProvider();
+ const empty = await provider.readObjectSource!(["APP", "V"], "view");
+ expect(isSourcePartUnavailable(empty.parts[0])).toBe(true);
+ text = Buffer.from("CREATE VIEW");
+ await expect(provider.readObjectSource!(["APP", "V"], "view")).rejects.toThrow(/rather than a string/);
+ await provider.disconnect();
+ });
+
+ test("a kind with no stored text is refused before any round trip", async () => {
+ const provider = await connectedProvider();
+ await expect(provider.readObjectSource!(["APP", "ORDERS"], "table")).rejects.toThrow(
+ 'Db2 publishes no definition text for the kind "table"',
+ );
+ await expect(provider.readObjectSource!(["APP"], "view")).rejects.toThrow(/"view" path is \[schema, name\]/);
+ expect(capturedQueries).toHaveLength(0);
+ await provider.disconnect();
+ });
+});
+
+describe("Db2Provider maintenance target quoting", () => {
+ test("a quote in the table name cannot end the ADMIN_CMD string literal", async () => {
+ const provider = await connectedProvider();
+ await provider.runMaintenance("analyze", "O'Brien");
+ const statement = capturedQueries.find((sql) => sql.includes("RUNSTATS")) ?? "";
+ expect(statement).toContain(`RUNSTATS ON TABLE "O''Brien"`);
+ await provider.runMaintenance("optimize", "O'Brien");
+ expect(capturedQueries.find((sql) => sql.includes("REORG"))).toContain(`REORG TABLE "O''Brien"`);
+ await provider.disconnect();
+ });
+});
+
+describe("Db2Provider object surface under a changed declaration", () => {
+ // The provider reads its own declaration rather than assuming it, so each of these swaps a
+ // declaration in and drives the arm only a DECLARATION can reach.
+ function withCapabilities(change: (caps: ProviderCapabilities) => ProviderCapabilities) {
+ return class extends Db2Provider {
+ public override getCapabilities(): ProviderCapabilities {
+ return change(super.getCapabilities());
+ }
+ };
+ }
+
+ test("a declaration with no schema level is refused by name rather than bound as undefined", async () => {
+ const Provider = withCapabilities((caps) => ({
+ ...caps,
+ containerLevels: [{ id: "catalog", label: "Database", labelPlural: "Databases" }],
+ }));
+ const provider = new Provider(baseConfig);
+ await provider.connect();
+ await expect(provider.countObjects(["TESTDB"])).rejects.toThrow(/needs a "schema" container level/);
+ expect(capturedQueries).toHaveLength(0);
+ await provider.disconnect();
+ });
+
+ test("a declared kind with no listing statement says so", async () => {
+ const Provider = withCapabilities((caps) => ({
+ ...caps,
+ objectKinds: [
+ ...(caps.objectKinds ?? []),
+ { id: "nickname", role: "relation", label: "Nickname", labelPlural: "Nicknames" },
+ ],
+ }));
+ const provider = new Provider(baseConfig);
+ await provider.connect();
+ await expect(provider.listObjects(["APP"], "nickname")).rejects.toThrow(
+ 'Db2 declares the kind "nickname" but has no statement that lists it',
+ );
+ await provider.disconnect();
+ });
+
+ test("a kind given source with no statement to read it says so", async () => {
+ const Provider = withCapabilities((caps) => ({
+ ...caps,
+ objectKinds: (caps.objectKinds ?? []).map((kind) =>
+ kind.id === "sequence" ? { ...kind, hasSource: true, sourceLanguage: "sql" } : kind,
+ ),
+ }));
+ const provider = new Provider(baseConfig);
+ await provider.connect();
+ await expect(provider.readObjectSource(["APP", "ORDER_SEQ"], "sequence")).rejects.toThrow(
+ 'Db2 declares readable source for the kind "sequence" but has no statement that reads it',
+ );
+ await provider.disconnect();
+ });
+});
diff --git a/tests/isolated/factory.test.ts b/tests/isolated/factory.test.ts
index be669db5a..99dad40ac 100644
--- a/tests/isolated/factory.test.ts
+++ b/tests/isolated/factory.test.ts
@@ -196,6 +196,21 @@ mock.module("mssql", () => {
};
});
+mock.module("ibm_db", () => {
+ // The Db2 provider imports the module as its driver and calls `open`, which hands back
+ // a connection with `query`/`close`. createDatabaseProvider only constructs the
+ // provider (connect() is not called here), so a minimal open/query/close is enough for
+ // the dynamic import to resolve and the construction test below to pass.
+ const mockConn = {
+ query: (_sql: string, cb: (err: Error | null, rows: Record[]) => void) => cb(null, []),
+ close: (cb: (err: Error | null) => void) => cb(null),
+ };
+ return {
+ default: { open: (_c: string, cb: (err: Error | null, conn: typeof mockConn) => void) => cb(null, mockConn) },
+ open: (_c: string, cb: (err: Error | null, conn: typeof mockConn) => void) => cb(null, mockConn),
+ };
+});
+
mock.module("mongodb", () => {
const mockCollection = {
find: () => ({ limit: () => ({ toArray: async () => [] }), toArray: async () => [] }),
@@ -440,6 +455,14 @@ describe("createDatabaseProvider", () => {
expect(provider.type).toBe("mssql");
});
+ test('creates provider for type "db2"', async () => {
+ // Db2 requires a database name (the provider's validate() enforces it, like mssql).
+ const conn = makeConnection("db2", { port: 50000, database: "testdb" });
+ const provider = await createDatabaseProvider(conn);
+ expect(provider).toBeDefined();
+ expect(provider.type).toBe("db2");
+ });
+
test('creates provider for type "couchbase"', async () => {
// The bucket is the `database` field; the provider refuses a connection without one.
const conn = makeConnection("couchbase", { port: 8091, database: "travel" });
diff --git a/tests/isolated/monaco-language-ids.test.ts b/tests/isolated/monaco-language-ids.test.ts
index 1f736cb1f..7554acf0d 100644
--- a/tests/isolated/monaco-language-ids.test.ts
+++ b/tests/isolated/monaco-language-ids.test.ts
@@ -221,7 +221,7 @@ describe("the installed editor's language ids", () => {
// first.
expect(declared.map((entry) => entry.where)).toContain("mysql/package");
expect(declared.map((entry) => entry.where)).toContain("mysql/sequence");
- expect(declared).toHaveLength(60);
+ expect(declared).toHaveLength(65);
const unregistered = declared.filter((entry) => !registered.has(entry.language));
// Named, so a failure says which kind on which engine declared what, rather than false. This
diff --git a/tests/isolated/object-source-declarations.test.ts b/tests/isolated/object-source-declarations.test.ts
index e28ea428a..7be768ef0 100644
--- a/tests/isolated/object-source-declarations.test.ts
+++ b/tests/isolated/object-source-declarations.test.ts
@@ -5,7 +5,7 @@
* model wrote "all seventeen providers implement the method" into every task brief without
* re-measuring it, and it was FALSE: two providers never got the method, and the conformance
* guard's own early return hid the gap because a provider that answers `undefined` was simply
- * skipped. A number typed by a person is not a measurement. So the claim "58 kinds across the
+ * skipped. A number typed by a person is not a measurement. So the claim "63 kinds across the
* fleet declare a readable definition" is produced HERE, by building every provider through
* `createDatabaseProvider` and reading what each one actually declares, and compared against
* an expectation committed from the design's own table.
@@ -116,6 +116,7 @@ export const CENSUS_CONNECTION: Readonly> = O
"trigger/sql",
],
mssql: ["view/sql", "procedure/sql", "function/sql", "trigger/sql"],
+ // SYSCAT keeps the author's statement for these five and for nothing else (#786).
+ db2: ["view/sql", "materialized_query_table/sql", "procedure/sql", "function/sql", "trigger/sql"],
clickhouse: ["table/sql", "view/sql", "materialized_view/sql", "dictionary/sql", "function/sql"],
druid: [],
trino: ["table/sql", "view/sql", "materialized_view/sql", "function/sql"],
@@ -254,7 +257,7 @@ describe("the fleet census of object source declarations", () => {
// The population every assertion below iterates. If this were empty or short, each of those
// loops would certify only the engines it happened to reach, so it is asserted first.
expect([...CENSUS_TYPES].sort()).toEqual([...SHIPPED_DATABASE_TYPES].sort());
- expect(CENSUS_TYPES).toHaveLength(17);
+ expect(CENSUS_TYPES).toHaveLength(18);
expect(Object.keys(SOURCE_DECLARATIONS).sort()).toEqual([...SHIPPED_DATABASE_TYPES].sort());
});
@@ -272,10 +275,10 @@ describe("the fleet census of object source declarations", () => {
// `hasSource` moves between the two halves, so both halves must be pinned or the total alone
// would still be satisfied. Neither half may be edited to match a build: if this fails, the
// DECLARATION is wrong or the design's table is, and the repair is one of those two.
- expect(UNCONNECTED_SOURCE_KINDS).toHaveLength(58);
- expect(rows.filter((row) => row.kind.hasSource === true)).toHaveLength(58);
- expect(rows.filter((row) => row.kind.hasSource !== true)).toHaveLength(22);
- expect(rows).toHaveLength(80);
+ expect(UNCONNECTED_SOURCE_KINDS).toHaveLength(63);
+ expect(rows.filter((row) => row.kind.hasSource === true)).toHaveLength(63);
+ expect(rows.filter((row) => row.kind.hasSource !== true)).toHaveLength(26);
+ expect(rows).toHaveLength(89);
});
test("the MariaDB branch declares two more, which an unconnected provider cannot show", async () => {
@@ -301,10 +304,10 @@ describe("the fleet census of object source declarations", () => {
[],
);
expect(mariadbRows.filter((row) => row.kind.hasSource === true)).toHaveLength(8);
- // 60 on a MariaDB connection against 58 unconnected: the design states both numbers because
+ // 65 on a MariaDB connection against 63 unconnected: the design states both numbers because
// criterion 2's evidence method reads an unconnected provider and would otherwise
// structurally exclude the two riskiest declarations in the phase.
- expect(UNCONNECTED_SOURCE_KINDS.length + MARIADB_EXTRA_SOURCE_KINDS.length).toBe(60);
+ expect(UNCONNECTED_SOURCE_KINDS.length + MARIADB_EXTRA_SOURCE_KINDS.length).toBe(65);
});
/*
@@ -390,7 +393,7 @@ describe("the fleet census of object source declarations", () => {
throw new Error(`the half-declaration guard never reached ${extra}, so it does not cover the MariaDB branch`);
}
}
- expect(rows).toHaveLength(88);
+ expect(rows).toHaveLength(97);
const halfDeclared = rows
.filter((row) => row.kind.sourceLanguage !== undefined && row.kind.hasSource !== true)
diff --git a/tests/unit/db/object-edit-expectation.test.ts b/tests/unit/db/object-edit-expectation.test.ts
index c49ccdb55..615aea072 100644
--- a/tests/unit/db/object-edit-expectation.test.ts
+++ b/tests/unit/db/object-edit-expectation.test.ts
@@ -18,8 +18,8 @@ describe("the Phase 3 census expectation", () => {
test("the day-one set is four pairs on three engines", () => {
expect(EXPECTED_EDITABLE_KINDS.length).toBe(4);
expect(new Set(EXPECTED_EDITABLE_KINDS.map(([type]) => type)).size).toBe(3);
- expect(EXPECTED_EDIT_ABSTAINERS.length).toBe(14);
- expect(EDIT_CENSUS_TYPES.length).toBe(17);
+ expect(EXPECTED_EDIT_ABSTAINERS.length).toBe(15);
+ expect(EDIT_CENSUS_TYPES.length).toBe(18);
});
test("no pair is written twice", () => {
diff --git a/tests/unit/lib/agent/engine-support.test.ts b/tests/unit/lib/agent/engine-support.test.ts
index 9a0552cb3..18c38af07 100644
--- a/tests/unit/lib/agent/engine-support.test.ts
+++ b/tests/unit/lib/agent/engine-support.test.ts
@@ -49,10 +49,11 @@ const factorySource = readFileSync(join(repoRoot, "src", "lib", "db", "factory.t
function parseDatabaseTypes(): DatabaseType[] {
const declaration = /export type DatabaseType =([\s\S]*?);\n/.exec(typesSource);
if (!declaration) throw new Error("could not locate the DatabaseType declaration in src/lib/types.ts");
- const ids = [...declaration[1].matchAll(/^\s*\|\s*"([a-z]+)"/gm)].map((match) => match[1] as DatabaseType);
+ const ids = [...declaration[1].matchAll(/^\s*\|\s*"([a-z][a-z0-9]*)"/gm)].map((match) => match[1] as DatabaseType);
if (ids.length === 0) throw new Error("parsed no ids out of the DatabaseType declaration");
- // A union arm whose id the `[a-z]+` class cannot spell (a digit, a hyphen) would be
- // dropped SILENTLY, and the factory scan below would drop it in the same way, so the
+ // A union arm whose id the id class cannot spell would be dropped SILENTLY. The class
+ // admits a digit after the first letter because `db2` is a real id (#786); a hyphen is
+ // still outside it, and the factory scan below would drop it in the same way, so the
// parity test would still pass and the new engine would go unmeasured. Counting the
// arms independently of their spelling makes that a hard failure instead.
const arms = [...declaration[1].matchAll(/^\s*\|/gm)].length;
@@ -71,7 +72,7 @@ function parseFactoryDispatch(): Map {
test("an English numeral is read as well as a digit", () => {
expect(engineCountProblems("Query fourteen engines from your browser.", "fixture")).toHaveLength(1);
- expect(engineCountProblems(`Sixteen database engines in one IDE: ${fullList}`, "fixture")).toEqual([]);
+ expect(engineCountProblems(`Seventeen database engines in one IDE: ${fullList}`, "fixture")).toEqual([]);
});
test("a deliberately abridged list is checked on its numeral only", () => {
diff --git a/tests/unit/lib/connection-string-parser.test.ts b/tests/unit/lib/connection-string-parser.test.ts
index a62cbe962..601f8d2f7 100644
--- a/tests/unit/lib/connection-string-parser.test.ts
+++ b/tests/unit/lib/connection-string-parser.test.ts
@@ -235,6 +235,33 @@ describe("parseConnectionString", () => {
});
});
+ // ── Db2 LUW ─────────────────────────────────────────────────────────────
+
+ describe("db2:// URLs", () => {
+ test("parses a db2 URL into host/port/user/password/database", () => {
+ const result = parseConnectionString("db2://db2inst1:pass@db2host:50000/testdb");
+ expect(result).not.toBeNull();
+ expect(result!.type).toBe("db2");
+ expect(result!.host).toBe("db2host");
+ expect(result!.port).toBe("50000");
+ expect(result!.user).toBe("db2inst1");
+ expect(result!.database).toBe("testdb");
+ });
+
+ test("uses default port 50000 when omitted", () => {
+ const result = parseConnectionString("db2://db2inst1:pass@host/testdb");
+ expect(result!.port).toBe("50000");
+ });
+
+ test("detectConnectionStringType recognizes db2://", () => {
+ expect(detectConnectionStringType("db2://host:50000/testdb")).toBe("db2");
+ });
+
+ test("db2 is published in the scheme map", () => {
+ expect(ENGINE_URI_SCHEMES.db2).toBe("db2");
+ });
+ });
+
// ── Couchbase ───────────────────────────────────────────────────────────
describe("couchbase:// and couchbases:// URLs", () => {
diff --git a/tests/unit/lib/db-icons.test.tsx b/tests/unit/lib/db-icons.test.tsx
index f354001cd..3fce2fe2f 100644
--- a/tests/unit/lib/db-icons.test.tsx
+++ b/tests/unit/lib/db-icons.test.tsx
@@ -17,6 +17,7 @@ import {
OpenSearchIcon,
TrinoIcon,
CassandraIcon,
+ Db2Icon,
} from "@/components/icons/db-icons";
describe("db-icons", () => {
@@ -38,6 +39,7 @@ describe("db-icons", () => {
{ name: "OpenSearchIcon", Component: OpenSearchIcon },
{ name: "TrinoIcon", Component: TrinoIcon },
{ name: "CassandraIcon", Component: CassandraIcon },
+ { name: "Db2Icon", Component: Db2Icon },
];
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 3c5848d62..c57997bec 100644
--- a/tests/unit/lib/db-ui-config.test.ts
+++ b/tests/unit/lib/db-ui-config.test.ts
@@ -25,6 +25,7 @@ const ALL_TYPES: DatabaseType[] = [
"cassandra",
"libsql",
"duckdb",
+ "db2",
];
describe("db-ui-config", () => {
@@ -60,7 +61,10 @@ describe("db-ui-config", () => {
// libSQL joins them: `libsql://-.turso.io?authToken=` is the
// URL Turso's own CLI prints, so there is a real string to paste here - unlike
// Trino, whose canonical form is a JDBC URL.
- const withToggle = new Set(["mongodb", "couchbase", "clickhouse", "libsql"]);
+ // Db2 joins them too: the driver takes a `KEY=VALUE;` DRDA attribute list, and
+ // `db2://host:port/db` is the scheme common tools/ORMs emit, so there is a string
+ // to paste.
+ const withToggle = new Set(["mongodb", "couchbase", "clickhouse", "libsql", "db2"]);
for (const type of ALL_TYPES) {
expect(getDBConfig(type).showConnectionStringToggle).toBe(withToggle.has(type));
}
@@ -370,6 +374,8 @@ describe("db-showcase", () => {
"redis",
"oracle",
"mssql",
+ // IBM Db2 sits with the enterprise relational names it is compared to.
+ "db2",
"elasticsearch",
"opensearch",
"cassandra",
diff --git a/tests/unit/lib/query-generators.test.ts b/tests/unit/lib/query-generators.test.ts
index da525c0cc..d03301838 100644
--- a/tests/unit/lib/query-generators.test.ts
+++ b/tests/unit/lib/query-generators.test.ts
@@ -67,6 +67,13 @@ describe("generateTableQuery", () => {
expect(result).toBe("SELECT TOP 50 * FROM users;");
});
+ test("Db2 (port 50000) uses FETCH FIRST 50 ROWS ONLY", () => {
+ const result = generateTableQuery(["users"], makeCaps({ defaultPort: 50000 }));
+ expect(result).toContain("FETCH FIRST 50 ROWS ONLY");
+ // Db2 has no LIMIT clause, so the PostgreSQL fallback would emit invalid SQL.
+ expect(result).not.toContain("LIMIT");
+ });
+
// #424 Phase 1, measured 2026-08-19 against Elasticsearch 9.1.4 and OpenSearch
// 3.8.0. Elasticsearch SQL has no statement terminator in its grammar: the
// generator's own `SELECT * FROM orders LIMIT 50;` answered
@@ -286,6 +293,12 @@ describe("generateSelectQuery", () => {
expect(result).toContain("id");
expect(result).toContain("name");
});
+
+ test("Db2 uses FETCH FIRST 100 ROWS ONLY", () => {
+ const result = generateSelectQuery(["users"], sampleColumns, makeCaps({ defaultPort: 50000 }));
+ expect(result).toContain("FETCH FIRST 100 ROWS ONLY");
+ expect(result).not.toContain("LIMIT");
+ });
});
// ============================================================================
diff --git a/tests/unit/schema-diff/migration-dialects.test.ts b/tests/unit/schema-diff/migration-dialects.test.ts
index 33c0d96c0..bf182fcfd 100644
--- a/tests/unit/schema-diff/migration-dialects.test.ts
+++ b/tests/unit/schema-diff/migration-dialects.test.ts
@@ -24,6 +24,9 @@ const COLUMN_GRAMMAR: Record = {
cassandra: ['ADD "extra" integer;', 'DROP "old";'],
clickhouse: ['ADD COLUMN "extra" integer;', 'DROP COLUMN "old";'],
trino: ['ADD COLUMN "extra" integer;', 'DROP COLUMN "old";'],
+ // Db2 LUW uses standard `ADD COLUMN`/`DROP COLUMN` with double-quoted identifiers,
+ // the same spelling as PostgreSQL and DuckDB.
+ db2: ['ADD COLUMN "extra" integer;', 'DROP COLUMN "old";'],
couchbase: null,
druid: null,
elasticsearch: null,
diff --git a/tests/unit/schema-diff/migration-generator.test.ts b/tests/unit/schema-diff/migration-generator.test.ts
index a0a340572..5204537e2 100644
--- a/tests/unit/schema-diff/migration-generator.test.ts
+++ b/tests/unit/schema-diff/migration-generator.test.ts
@@ -940,6 +940,9 @@ describe("generateMigrationSQL: SQLite's grammar declares a foreign key only ins
mysql: "key-follows-in-an-alter",
oracle: "key-follows-in-an-alter",
mssql: "key-follows-in-an-alter",
+ // Db2 LUW accepts a trailing `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY`, so
+ // the key leaves CREATE TABLE the same way it does on PostgreSQL and the others.
+ db2: "key-follows-in-an-alter",
clickhouse: "engine-has-no-foreign-key",
couchbase: "engine-has-no-foreign-key",
druid: "engine-has-no-foreign-key",
@@ -1055,6 +1058,11 @@ const MODIFIED_COLUMN_COVERAGE: Record<
mongodb: { label: "MongoDB", reason: "schemaless" },
redis: { label: "Redis", reason: "no column definitions" },
libredb: { label: "LibreDB", reason: "JSON command grammar" },
+ // Db2 LUW has its own branch in the modified-column chain: it spells a type change
+ // `ALTER COLUMN c SET DATA TYPE t` (the PostgreSQL arm's `... TYPE t` is invalid Db2)
+ // and uses PostgreSQL's own SET/DROP verbs for nullability and default. The DDL is
+ // pinned in the "generateMigrationSQL: db2" describe block below.
+ db2: "has-own-branch",
};
/**
@@ -1210,6 +1218,133 @@ describe("generateMigrationSQL: duckdb", () => {
});
});
+/**
+ * Db2 LUW's modified-column DDL, asserted at the string level. Unlike the DuckDB block
+ * above, there is no embedded Db2 to execute against — the driver is a server client, not
+ * an in-process engine — so these pin the emitted spelling rather than a running result;
+ * the byte-exact behaviour is confirmed on the gate-4 live pass (docs/providers/db2.md).
+ */
+describe("generateMigrationSQL: db2", () => {
+ test("a modified column emits Db2's SET DATA TYPE spelling, not PostgreSQL's TYPE", () => {
+ const sql = generateMigrationSQL(makeModifiedTableDiff(), "db2");
+
+ expect(sql).toContain('ALTER TABLE "users" ALTER COLUMN "name" SET DATA TYPE varchar(255);');
+ expect(sql).toContain('ALTER TABLE "users" ALTER COLUMN "name" SET NOT NULL;');
+ expect(sql).toContain('ALTER TABLE "users" ALTER COLUMN "name" SET DEFAULT \'unknown\';');
+ // The invalid PostgreSQL spelling must not leak through the shared `else` arm.
+ expect(sql).not.toMatch(/ALTER COLUMN "name" TYPE /);
+ expect(sql).not.toContain("Cannot alter column");
+ });
+
+ test("added and dropped columns take the standard spelling, which Db2 accepts", () => {
+ const sql = generateMigrationSQL(makeModifiedTableDiff(), "db2");
+
+ expect(sql).toContain('ALTER TABLE "users" ADD COLUMN "phone" varchar(20);');
+ expect(sql).toContain('ALTER TABLE "users" DROP COLUMN "legacy_col";');
+ });
+
+ test("the migration is wrapped in a transaction, because Db2 DDL is transactional", () => {
+ const sql = generateMigrationSQL(makeModifiedTableDiff(), "db2");
+
+ expect(sql).toContain("BEGIN;");
+ expect(sql).toContain("COMMIT;");
+ });
+
+ test("a REORG advisory (commented, not executable) is emitted once per table left REORG-pending", () => {
+ const sql = generateMigrationSQL(makeModifiedTableDiff(), "db2");
+
+ // The REORG is emitted as a COMMENT, following the generator's pattern for anything
+ // it cannot emit as both correct and safe to run blindly — a REORG can be very slow
+ // and lock-heavy, and cannot run inside the BEGIN;/COMMIT; wrapper. So there must be
+ // NO executable REORG statement, only the advisory comment lines.
+ expect(sql).not.toMatch(/^\s*CALL SYSPROC\.ADMIN_CMD\('REORG TABLE/m);
+ expect(sql).toContain("REORG-pending");
+ const advisory = sql.split("\n").filter((line) => line.includes("REORG TABLE"));
+ // One advisory line (the commented CALL) per table, and it is a comment.
+ expect(advisory).toHaveLength(1);
+ expect(advisory[0].trimStart().startsWith("--")).toBe(true);
+ expect(advisory[0]).toContain('REORG TABLE "users"');
+ // The advisory appears after the ALTERs it refers to.
+ expect(sql.indexOf("REORG TABLE")).toBeGreaterThan(sql.indexOf("SET DATA TYPE"));
+ // And it warns about the cost / transaction boundary.
+ expect(sql).toMatch(/slow|lock|OUTSIDE the transaction/i);
+ });
+
+ test("no REORG advisory when only immediate operations (ADD COLUMN, index) are present", () => {
+ // An added-table diff carries no dropped or modified column, so nothing goes pending.
+ const sql = generateMigrationSQL(makeAddedTableDiff(), "db2");
+ expect(sql).not.toContain("REORG");
+ });
+
+ test("a modified column that drops its default emits Db2's DROP DEFAULT", () => {
+ // The shared fixture only ADDS a default; this covers the removal arm.
+ const diff: SchemaDiff = {
+ hasChanges: true,
+ summary: { added: 0, removed: 0, modified: 1 },
+ tables: [
+ {
+ action: "modified",
+ tableName: "users",
+ columns: [
+ {
+ action: "modified",
+ columnName: "status",
+ sourceType: "varchar(20)",
+ targetType: "varchar(20)",
+ sourceNullable: false,
+ targetNullable: true,
+ sourceDefault: "'active'",
+ targetDefault: undefined,
+ changes: ["Default removed", "Nullable changed"],
+ },
+ ],
+ indexes: [],
+ foreignKeys: [],
+ },
+ ],
+ };
+ const sql = generateMigrationSQL(diff, "db2");
+ expect(sql).toContain('ALTER TABLE "users" ALTER COLUMN "status" DROP DEFAULT;');
+ // Nullability false -> true emits DROP NOT NULL.
+ expect(sql).toContain('ALTER TABLE "users" ALTER COLUMN "status" DROP NOT NULL;');
+ });
+
+ test("a DEFAULT-only column change does NOT raise the REORG advisory (metadata-only on Db2 LUW)", () => {
+ // Only the default changes — no type change, no nullability change. On Db2 LUW this
+ // is a metadata-only ALTER that does not go REORG-pending, so the advisory would be a
+ // false positive.
+ const diff: SchemaDiff = {
+ hasChanges: true,
+ summary: { added: 0, removed: 0, modified: 1 },
+ tables: [
+ {
+ action: "modified",
+ tableName: "users",
+ columns: [
+ {
+ action: "modified",
+ columnName: "status",
+ sourceType: "varchar(20)",
+ targetType: "varchar(20)",
+ sourceNullable: true,
+ targetNullable: true,
+ sourceDefault: "'active'",
+ targetDefault: "'inactive'",
+ changes: ["Default changed"],
+ },
+ ],
+ indexes: [],
+ foreignKeys: [],
+ },
+ ],
+ };
+ const sql = generateMigrationSQL(diff, "db2");
+ expect(sql).toContain('ALTER TABLE "users" ALTER COLUMN "status" SET DEFAULT \'inactive\';');
+ expect(sql).not.toContain("REORG");
+ expect(sql).not.toContain("SET DATA TYPE");
+ });
+});
+
describe("generateMigrationSQL: dialects that cannot modify a column", () => {
for (const [dialect, expected] of Object.entries(MODIFIED_COLUMN_COVERAGE)) {
if (typeof expected === "string") continue;
@@ -1265,6 +1400,10 @@ const TRANSACTION_WRAPPER_COVERAGE: Record {
"druid",
"trino",
"cassandra",
+ "db2",
];
for (const type of allTypes) {
const result = SeedConnectionSchema.safeParse({ ...validConn, type });
diff --git a/tests/unit/sql/grammar.test.ts b/tests/unit/sql/grammar.test.ts
index 4d9bc28ee..686f317ba 100644
--- a/tests/unit/sql/grammar.test.ts
+++ b/tests/unit/sql/grammar.test.ts
@@ -415,6 +415,12 @@ const GRAMMAR_COVERAGE: Record = {
couchbase: "default",
druid: "default",
libredb: "default",
+ // Db2 LUW's four facts (no `#` line comment, `"..."` rather than `[...]` for
+ // identifiers, non-nesting block comments, no `q'...'`) match the compatibility
+ // default, so no SQL_GRAMMARS entry is added. Left at the default rather than
+ // asserted from a neighbour; a live probe on the gate-4 pass can promote it to
+ // "established" if any fact turns out to differ.
+ db2: "default",
// Not SQL at all - see SQL_TEXT_COVERAGE below.
mongodb: "default",
redis: "default",
@@ -460,6 +466,9 @@ const SQL_TEXT_COVERAGE: Record = {
// would switch the SQL checks off for text that is SQL, which is the mirror of the
// defect #297 fixed.
cassandra: true,
+ // SQL, and the editor text is what `ibm_db.query` sends. The provider extends
+ // SQLBaseProvider.
+ db2: true,
mongodb: false,
redis: false,
};
diff --git a/tests/unit/sql/values.test.ts b/tests/unit/sql/values.test.ts
index 028c41e16..aae565398 100644
--- a/tests/unit/sql/values.test.ts
+++ b/tests/unit/sql/values.test.ts
@@ -19,6 +19,9 @@ describe("quoteLiteral", () => {
expect(quoteLiteral("O'Brien", "mysql")).toBe("'O''Brien'");
expect(quoteLiteral("O'Brien", "sqlite")).toBe("'O''Brien'");
expect(quoteLiteral("O'Brien", "oracle")).toBe("'O''Brien'");
+ // Db2 uses standard escaping: doubled quote, backslash is data.
+ expect(quoteLiteral("O'Brien", "db2")).toBe("'O''Brien'");
+ expect(quoteLiteral("a\\b", "db2")).toBe("'a\\b'");
expect(quoteLiteral("O'Brien", "mssql")).toBe("'O''Brien'");
expect(quoteLiteral("O'Brien", "clickhouse")).toBe("'O''Brien'");
expect(quoteLiteral("O'Brien", "druid")).toBe("'O''Brien'");
@@ -109,6 +112,9 @@ describe("positionalPlaceholder", () => {
// because `$` also opens a dollar-quoted literal in this dialect.
expect(positionalPlaceholder("duckdb", 1)).toBe("?");
expect(positionalPlaceholder("duckdb", 2)).toBe("?");
+ // Db2 LUW binds a positional array against `?`, the same as MySQL/SQLite.
+ expect(positionalPlaceholder("db2", 1)).toBe("?");
+ expect(positionalPlaceholder("db2", 2)).toBe("?");
});
test("trino has no positional placeholder, because its provider refuses to bind one", () => {