feat: add rqlite database support

This commit is contained in:
t8y2 2026-06-05 01:02:07 +08:00
parent a799958328
commit 0887b96236
34 changed files with 804 additions and 12 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -229,6 +229,7 @@ const driverProfiles: Record<
},
redis: { type: "redis", port: 6379, user: "", label: "Redis", icon: "redis" },
sqlite: { type: "sqlite", port: 0, user: "", label: "SQLite", icon: "sqlite" },
rqlite: { type: "rqlite", port: 4001, user: "", label: "RQLite", icon: "rqlite" },
duckdb: { type: "duckdb", port: 0, user: "", label: "DuckDB", icon: "duckdb" },
access: { type: "access", port: 0, user: "", label: "Microsoft Access", icon: "access" },
mongodb: { type: "mongodb", port: 27017, user: "", label: "MongoDB", icon: "mongodb" },
@ -551,6 +552,7 @@ const iconTypeMap: Record<string, string> = {
mysql: "mysql",
postgres: "postgres",
sqlite: "sqlite",
rqlite: "rqlite",
access: "access",
redis: "redis",
mongodb: "mongodb",
@ -611,6 +613,7 @@ const dbOptions = [
{ value: "mysql", label: "MySQL" },
{ value: "postgres", label: "PostgreSQL" },
{ value: "sqlite", label: "SQLite" },
{ value: "rqlite", label: "RQLite" },
{ value: "access", label: "Microsoft Access" },
{ value: "redis", label: "Redis" },
{ value: "mongodb", label: "MongoDB" },

View File

@ -11,6 +11,7 @@ const assetIcons: Record<string, string> = {
postgres: "postgres",
postgresql: "postgres",
sqlite: "sqlite",
rqlite: "rqlite.png",
redis: "redis",
mongodb: "mongodb",
clickhouse: "clickhouse",

View File

@ -158,6 +158,7 @@ const activeSqlFormatDialect = computed<SqlFormatDialect>(() => {
case "postgres":
return "postgres";
case "sqlite":
case "rqlite":
return "sqlite";
case "sqlserver":
return "sqlserver";

View File

@ -192,6 +192,7 @@ const sourceFormatDialect = computed<SqlFormatDialect>(() => {
case "mysql":
case "postgres":
case "sqlite":
case "rqlite":
case "sqlserver":
return effectiveDatabaseType.value;
case "gaussdb":

View File

@ -102,6 +102,9 @@ export function connectionUrlPlaceholder(dbType: DatabaseType): string {
case "sqlite":
return "sqlite:///absolute/path/to/database.db";
case "rqlite":
return "http://user:password@host:4001";
case "duckdb":
return "duckdb:///absolute/path/to/database.duckdb";

View File

@ -37,6 +37,7 @@ export const DIAGRAM_SUPPORTED_TYPES = new Set<DatabaseType>([
"mysql",
"postgres",
"sqlite",
"rqlite",
"sqlserver",
"oracle",
"redshift",
@ -66,6 +67,7 @@ export const DATABASE_SEARCH_SUPPORTED_TYPES = new Set<DatabaseType>([
"mysql",
"postgres",
"sqlite",
"rqlite",
"sqlserver",
"oracle",
"redshift",
@ -108,6 +110,7 @@ export const TABLE_IMPORT_SUPPORTED_TYPES = new Set<DatabaseType>([
"mysql",
"postgres",
"sqlite",
"rqlite",
"duckdb",
"clickhouse",
"sqlserver",
@ -129,6 +132,7 @@ export const TABLE_STRUCTURE_SUPPORTED_TYPES = new Set<DatabaseType>([
"mysql",
"postgres",
"sqlite",
"rqlite",
"duckdb",
"clickhouse",
"sqlserver",
@ -169,6 +173,7 @@ export const FIELD_LINEAGE_SUPPORTED_TYPES = new Set<DatabaseType>([
"mysql",
"postgres",
"sqlite",
"rqlite",
"sqlserver",
"oracle",
"redshift",
@ -258,6 +263,7 @@ export const TRANSFER_SQL_TYPES = new Set<DatabaseType>([
"mysql",
"postgres",
"sqlite",
"rqlite",
"sqlserver",
"oracle",
"clickhouse",
@ -272,6 +278,7 @@ export const DIAGRAM_SQL_TYPES = new Set<DatabaseType>([
"mysql",
"postgres",
"sqlite",
"rqlite",
"sqlserver",
"oracle",
"redshift",

View File

@ -104,7 +104,7 @@ export function supportsObjectBrowserTreeNode(dbType: DatabaseType | undefined,
}
export function supportsTableTruncate(dbType?: DatabaseType): boolean {
return !!dbType && dbType !== "sqlite" && dbType !== "duckdb";
return !!dbType && dbType !== "sqlite" && dbType !== "rqlite" && dbType !== "duckdb";
}
export function usesPostgresLikeStructureCopy(dbType?: DatabaseType): boolean {

View File

@ -49,6 +49,7 @@ const NAVICAT_STYLE_TABLE_DATA_TYPES = new Set<DatabaseType>([
"mysql",
"postgres",
"sqlite",
"rqlite",
"duckdb",
"sqlserver",
"oracle",

View File

@ -31,7 +31,7 @@ export function supportsObjectRename(
if (objectType === "PROCEDURE" || objectType === "FUNCTION") {
return false;
}
if (databaseType === "sqlite") return objectType === "TABLE";
if (databaseType === "sqlite" || databaseType === "rqlite") return objectType === "TABLE";
if (databaseType === "mysql" || databaseType === "goldendb") return objectType === "TABLE" || objectType === "VIEW";
if (postgresLikeRenameTypes.has(databaseType)) return objectType === "TABLE" || objectType === "VIEW";
if (oracleLikeRenameTypes.has(databaseType)) return objectType === "TABLE" || objectType === "VIEW";

View File

@ -451,6 +451,7 @@ const DATABASE_SQL_KEYWORDS: Partial<Record<DatabaseType, string[]>> = {
mysql: MYSQL_SQL_KEYWORDS,
postgres: POSTGRES_SQL_KEYWORDS,
sqlite: SQLITE_SQL_KEYWORDS,
rqlite: SQLITE_SQL_KEYWORDS,
sqlserver: SQLSERVER_SQL_KEYWORDS,
};
@ -848,6 +849,7 @@ const DATABASE_FUNCTION_SIGNATURES: Partial<Record<DatabaseType, Map<string, str
mysql: MYSQL_FUNCTION_SIGNATURES,
postgres: POSTGRES_FUNCTION_SIGNATURES,
sqlite: SQLITE_FUNCTION_SIGNATURES,
rqlite: SQLITE_FUNCTION_SIGNATURES,
sqlserver: SQLSERVER_FUNCTION_SIGNATURES,
};

View File

@ -222,6 +222,7 @@ const capabilityByType: Partial<Record<DatabaseType, TableStructureCapabilities>
vastbase: postgresCapabilities,
kingbase: postgresCapabilities,
sqlite: sqliteCapabilities,
rqlite: sqliteCapabilities,
duckdb: duckdbCapabilities,
sqlserver: sqlserverCapabilities,
oracle: oracleCapabilities,

View File

@ -114,6 +114,7 @@ export const DATA_TYPE_OPTIONS: Record<string, string[]> = {
"oid",
],
sqlite: ["integer", "real", "text", "blob", "numeric"],
rqlite: ["integer", "real", "text", "blob", "numeric"],
sqlserver: [
"bit",
"tinyint",

View File

@ -2,6 +2,7 @@ export type DatabaseType =
| "mysql"
| "postgres"
| "sqlite"
| "rqlite"
| "redis"
| "duckdb"
| "clickhouse"

View File

@ -30,6 +30,16 @@
"metadataConnectionScoped": false,
"skipTcpProbe": true
},
{
"dbType": "rqlite",
"label": "RQLite",
"runtimeMode": "native",
"mcpMode": "direct",
"singleConnectionPool": true,
"metadataConnectionScoped": false,
"skipTcpProbe": false,
"defaultPort": 4001
},
{
"dbType": "redis",
"label": "Redis",

View File

@ -47,6 +47,7 @@ pub enum PoolKind {
Mysql(db::mysql::MySqlPool, MysqlMode),
Postgres(deadpool_postgres::Pool),
Sqlite(db::sqlite::SqliteHandle),
Rqlite(db::rqlite_driver::RqliteClient),
Redis(db::redis_driver::RedisConnection),
DuckDb(Arc<std::sync::Mutex<duckdb::Connection>>),
MongoDb(mongodb::Client),
@ -343,6 +344,18 @@ impl AppState {
db::sqlite::connect_path_with_extensions(&expand_tilde(&db_config.host), extensions).await?,
)
}
DatabaseType::Rqlite => {
let client = db::rqlite_driver::RqliteClient::new(
&url,
db_config.url_params.as_deref(),
&db_config.username,
&db_config.password,
db_config.ssl,
connect_timeout,
)?;
db::rqlite_driver::test_connection(&client, connect_timeout).await?;
PoolKind::Rqlite(client)
}
DatabaseType::Redis => {
let con = if db_config.uses_redis_cluster() {
db::redis_driver::RedisConnection::Cluster(db::redis_driver::connect_cluster(&db_config).await?)
@ -919,6 +932,7 @@ pub async fn close_pool_kind(pool: PoolKind) {
}
PoolKind::Postgres(p) => p.close(),
PoolKind::Sqlite(_) => {}
PoolKind::Rqlite(_) => {}
PoolKind::Redis(_) => {}
PoolKind::DuckDb(con) => {
crate::db::duckdb_driver::close_connection(con);

View File

@ -14,6 +14,7 @@ pub fn is_single_connection_pool(db_type: &DatabaseType) -> bool {
db_type,
DatabaseType::Sqlite
| DatabaseType::DuckDb
| DatabaseType::Rqlite
| DatabaseType::MongoDb
| DatabaseType::Oracle
| DatabaseType::Dameng

View File

@ -9,6 +9,7 @@ pub mod ob_oracle;
pub mod postgres;
pub mod proxy_tunnel;
pub mod redis_driver;
pub mod rqlite_driver;
pub mod sqlite;
pub mod sqlserver;
pub mod ssh_tunnel;

View File

@ -0,0 +1,455 @@
use reqwest::Client as HttpClient;
use serde::Deserialize;
use std::time::{Duration, Instant};
use super::with_connection_timeout;
use crate::sql::starts_with_executable_sql_keyword;
use crate::types::{
ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, ObjectSource, ObjectSourceKind, QueryResult, TableInfo,
TriggerInfo,
};
#[derive(Clone)]
pub struct RqliteClient {
http: HttpClient,
base_url: String,
query_params: String,
auth: Option<(String, String)>,
}
impl RqliteClient {
pub fn new(
url: &str,
url_params: Option<&str>,
username: &str,
password: &str,
tls_enabled: bool,
timeout: Duration,
) -> Result<Self, String> {
let mut builder = HttpClient::builder().connect_timeout(timeout);
if rqlite_accept_invalid_certs(tls_enabled, url_params) {
builder = builder.danger_accept_invalid_certs(true);
}
if rqlite_should_bypass_system_proxy(url) {
builder = builder.no_proxy();
}
let http = builder.build().map_err(|e| format!("Failed to configure rqlite HTTP client: {e}"))?;
let auth = if username.trim().is_empty() { None } else { Some((username.to_string(), password.to_string())) };
Ok(Self {
http,
base_url: url.trim_end_matches('/').split('?').next().unwrap_or(url).to_string(),
query_params: normalize_rqlite_url_params(url_params),
auth,
})
}
fn post_json(&self, path: &str, sql: &str) -> reqwest::RequestBuilder {
let req = self.http.post(self.endpoint(path)).json(&[sql]);
self.with_auth(req)
}
fn with_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some((ref user, ref pass)) = self.auth {
req.basic_auth(user, Some(pass))
} else {
req
}
}
fn endpoint(&self, path: &str) -> String {
if self.query_params.is_empty() {
format!("{}{}", self.base_url, path)
} else {
format!("{}{}?{}", self.base_url, path, self.query_params)
}
}
}
#[derive(Debug, Deserialize)]
struct RqliteResponse {
results: Vec<RqliteResult>,
}
#[derive(Debug, Deserialize)]
struct RqliteResult {
#[serde(default)]
columns: Vec<String>,
#[serde(default)]
values: Vec<Vec<serde_json::Value>>,
#[serde(default)]
rows_affected: Option<u64>,
#[serde(default)]
error: Option<String>,
}
enum RqliteEndpoint {
Query,
Execute,
}
impl RqliteEndpoint {
fn path(&self) -> &'static str {
match self {
Self::Query => "/db/query",
Self::Execute => "/db/execute",
}
}
}
pub async fn test_connection(client: &RqliteClient, timeout: Duration) -> Result<(), String> {
with_connection_timeout("rqlite", timeout, async { query_one(client, "SELECT 1").await.map(|_| ()) }).await
}
pub async fn list_databases(_client: &RqliteClient) -> Result<Vec<DatabaseInfo>, String> {
Ok(vec![DatabaseInfo { name: "main".to_string() }])
}
pub async fn list_tables(client: &RqliteClient, _schema: &str) -> Result<Vec<TableInfo>, String> {
let result = query_one(
client,
"SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name",
)
.await?;
Ok(result
.values
.into_iter()
.map(|row| {
let table_type = value_as_string(row.get(1)).unwrap_or_else(|| "table".to_string());
TableInfo {
name: value_as_string(row.first()).unwrap_or_default(),
table_type: if table_type.eq_ignore_ascii_case("view") { "VIEW" } else { "BASE TABLE" }.to_string(),
comment: None,
parent_schema: None,
parent_name: None,
}
})
.collect())
}
pub async fn get_columns(client: &RqliteClient, _schema: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let result = query_one(client, &format!("PRAGMA table_info({})", sqlite_ident(table))).await?;
Ok(result
.values
.into_iter()
.map(|row| ColumnInfo {
name: value_by_column(&result.columns, &row, "name").unwrap_or_default(),
data_type: value_by_column(&result.columns, &row, "type").unwrap_or_default(),
is_nullable: value_by_column(&result.columns, &row, "notnull")
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(0)
== 0,
column_default: value_by_column(&result.columns, &row, "dflt_value"),
is_primary_key: value_by_column(&result.columns, &row, "pk")
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(0)
> 0,
extra: None,
comment: None,
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
})
.collect())
}
pub async fn list_indexes(client: &RqliteClient, _schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
let result = query_one(client, &format!("PRAGMA index_list({})", sqlite_ident(table))).await?;
let mut indexes = Vec::new();
for row in result.values {
let name = value_by_column(&result.columns, &row, "name").unwrap_or_default();
if name.is_empty() {
continue;
}
let is_unique =
value_by_column(&result.columns, &row, "unique").and_then(|value| value.parse::<i64>().ok()).unwrap_or(0)
!= 0;
let origin = value_by_column(&result.columns, &row, "origin").unwrap_or_default();
let column_result = query_one(client, &format!("PRAGMA index_info({})", sqlite_ident(&name))).await?;
let columns = column_result
.values
.iter()
.filter_map(|row| value_by_column(&column_result.columns, row, "name"))
.collect();
indexes.push(IndexInfo {
name,
columns,
is_unique,
is_primary: origin == "pk",
filter: None,
index_type: None,
included_columns: None,
comment: None,
});
}
Ok(indexes)
}
pub async fn list_foreign_keys(
client: &RqliteClient,
_schema: &str,
table: &str,
) -> Result<Vec<ForeignKeyInfo>, String> {
let result = query_one(client, &format!("PRAGMA foreign_key_list({})", sqlite_ident(table))).await?;
Ok(result
.values
.into_iter()
.map(|row| ForeignKeyInfo {
name: format!("fk_{}", value_by_column(&result.columns, &row, "id").unwrap_or_else(|| "0".to_string())),
column: value_by_column(&result.columns, &row, "from").unwrap_or_default(),
ref_schema: None,
ref_table: value_by_column(&result.columns, &row, "table").unwrap_or_default(),
ref_column: value_by_column(&result.columns, &row, "to").unwrap_or_default(),
})
.collect())
}
pub async fn list_triggers(client: &RqliteClient, _schema: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
let result = query_one(
client,
&format!(
"SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND tbl_name = {} ORDER BY name",
sqlite_string(table)
),
)
.await?;
Ok(result
.values
.into_iter()
.map(|row| {
let sql_text = value_as_string(row.get(1)).unwrap_or_default().to_uppercase();
let timing = if sql_text.contains("BEFORE") {
"BEFORE"
} else if sql_text.contains("AFTER") {
"AFTER"
} else {
"INSTEAD OF"
};
let event = if sql_text.contains("INSERT") {
"INSERT"
} else if sql_text.contains("UPDATE") {
"UPDATE"
} else {
"DELETE"
};
TriggerInfo {
name: value_as_string(row.first()).unwrap_or_default(),
event: event.to_string(),
timing: timing.to_string(),
}
})
.collect())
}
pub async fn table_ddl(client: &RqliteClient, table: &str) -> Result<String, String> {
first_string_cell(
query_one(
client,
&format!("SELECT sql FROM sqlite_master WHERE type='table' AND name={}", sqlite_string(table)),
)
.await?,
)
}
pub async fn object_source(
client: &RqliteClient,
name: &str,
object_type: &ObjectSourceKind,
) -> Result<ObjectSource, String> {
let kind = match object_type {
ObjectSourceKind::View => "view",
_ => return Err("Object source is not supported for this rqlite object type".to_string()),
};
let source = first_string_cell(
query_one(
client,
&format!(
"SELECT sql FROM sqlite_master WHERE type={} AND name={}",
sqlite_string(kind),
sqlite_string(name)
),
)
.await?,
)?;
Ok(ObjectSource { name: name.to_string(), object_type: object_type.clone(), schema: None, source })
}
pub async fn execute_query(client: &RqliteClient, sql: &str) -> Result<QueryResult, String> {
execute_query_with_max_rows(client, sql, None).await
}
pub async fn execute_query_with_max_rows(
client: &RqliteClient,
sql: &str,
max_rows: Option<usize>,
) -> Result<QueryResult, String> {
let start = Instant::now();
if starts_with_executable_sql_keyword(sql, &["SELECT", "PRAGMA", "EXPLAIN", "WITH"]) {
let result = query_one(client, sql).await?;
Ok(query_result_from_rqlite_result(result, start.elapsed().as_millis(), max_rows))
} else {
let result = execute_one(client, sql).await?;
let affected_rows = result.rows_affected.unwrap_or(0);
Ok(QueryResult {
columns: vec![],
rows: vec![],
affected_rows,
execution_time_ms: start.elapsed().as_millis(),
truncated: false,
session_id: None,
has_more: false,
})
}
}
async fn query_one(client: &RqliteClient, sql: &str) -> Result<RqliteResult, String> {
request_one(client, RqliteEndpoint::Query, sql).await
}
async fn execute_one(client: &RqliteClient, sql: &str) -> Result<RqliteResult, String> {
request_one(client, RqliteEndpoint::Execute, sql).await
}
async fn request_one(client: &RqliteClient, endpoint: RqliteEndpoint, sql: &str) -> Result<RqliteResult, String> {
let resp =
client.post_json(endpoint.path(), sql).send().await.map_err(|e| format!("rqlite request failed: {e}"))?;
let status = resp.status();
let body = resp.text().await.map_err(|e| format!("rqlite response read failed: {e}"))?;
if !status.is_success() {
return Err(format!("rqlite error ({status}): {body}"));
}
let response: RqliteResponse =
serde_json::from_str(&body).map_err(|e| format!("rqlite parse error: {e}; body: {body}"))?;
let result = response.results.into_iter().next().ok_or_else(|| "rqlite returned no result".to_string())?;
if let Some(error) = result.error.as_ref().filter(|error| !error.is_empty()) {
return Err(format!("rqlite error: {error}"));
}
Ok(result)
}
fn query_result_from_rqlite_result(
mut result: RqliteResult,
execution_time_ms: u128,
max_rows: Option<usize>,
) -> QueryResult {
let row_limit = max_rows.unwrap_or(crate::query::MAX_ROWS).max(1);
let truncated = result.values.len() > row_limit;
if truncated {
result.values.truncate(row_limit);
}
QueryResult {
columns: result.columns,
rows: result.values,
affected_rows: 0,
execution_time_ms,
truncated,
session_id: None,
has_more: false,
}
}
fn first_string_cell(result: RqliteResult) -> Result<String, String> {
result
.values
.first()
.and_then(|row| row.first())
.and_then(|value| value_as_string(Some(value)))
.ok_or_else(|| "Object not found".to_string())
}
fn value_by_column(columns: &[String], row: &[serde_json::Value], name: &str) -> Option<String> {
columns
.iter()
.position(|column| column.eq_ignore_ascii_case(name))
.and_then(|index| row.get(index))
.and_then(|value| value_as_string(Some(value)))
}
fn value_as_string(value: Option<&serde_json::Value>) -> Option<String> {
match value? {
serde_json::Value::Null => None,
serde_json::Value::String(value) => Some(value.clone()),
serde_json::Value::Number(value) => Some(value.to_string()),
serde_json::Value::Bool(value) => Some(value.to_string()),
other => Some(other.to_string()),
}
}
fn sqlite_ident(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\""))
}
fn sqlite_string(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn normalize_rqlite_url_params(params: Option<&str>) -> String {
params
.unwrap_or("")
.trim()
.trim_start_matches('?')
.split('&')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("&")
}
fn rqlite_accept_invalid_certs(tls_enabled: bool, url_params: Option<&str>) -> bool {
tls_enabled
&& url_params
.unwrap_or("")
.trim()
.trim_start_matches('?')
.split('&')
.filter_map(|pair| pair.split_once('='))
.any(|(key, value)| {
matches!(key.trim().to_ascii_lowercase().as_str(), "insecure" | "tls_insecure" | "accept_invalid_certs")
&& matches!(value.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on")
})
}
fn rqlite_should_bypass_system_proxy(base_url: &str) -> bool {
let Ok(parsed) = reqwest::Url::parse(base_url) else {
return false;
};
let Some(host) = parsed.host_str() else {
return false;
};
let host = host.trim_matches(['[', ']']);
host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_query_result_and_truncates_probe_rows() {
let result = RqliteResult {
columns: vec!["id".to_string(), "name".to_string()],
values: vec![
vec![serde_json::json!(1), serde_json::json!("Ada")],
vec![serde_json::json!(2), serde_json::json!("Linus")],
],
rows_affected: None,
error: None,
};
let result = query_result_from_rqlite_result(result, 8, Some(1));
assert_eq!(result.columns, vec!["id", "name"]);
assert_eq!(result.rows, vec![vec![serde_json::json!(1), serde_json::json!("Ada")]]);
assert_eq!(result.execution_time_ms, 8);
assert!(result.truncated);
}
#[test]
fn maps_column_values_by_name() {
let columns = vec!["name".to_string(), "notnull".to_string(), "pk".to_string()];
let row = vec![serde_json::json!("id"), serde_json::json!(1), serde_json::json!(1)];
assert_eq!(value_by_column(&columns, &row, "NAME").as_deref(), Some("id"));
assert_eq!(value_by_column(&columns, &row, "notnull").as_deref(), Some("1"));
}
}

View File

@ -169,6 +169,7 @@ pub enum DatabaseType {
Mysql,
Postgres,
Sqlite,
Rqlite,
Redis,
#[serde(rename = "duckdb")]
DuckDb,
@ -301,6 +302,7 @@ impl ConnectionConfig {
},
DatabaseType::Redshift => Some("dev"),
DatabaseType::ClickHouse => Some("default"),
DatabaseType::Rqlite => Some("main"),
DatabaseType::Gaussdb | DatabaseType::OpenGauss => Some("postgres"),
DatabaseType::Kingbase | DatabaseType::Vastbase => Some("postgres"),
DatabaseType::Highgo => Some("highgo"),
@ -386,6 +388,7 @@ impl ConnectionConfig {
format!("postgres://{host}:{port}{db_part}{suffix}")
}
DatabaseType::ClickHouse => clickhouse_http_url(self, raw_host, port),
DatabaseType::Rqlite => rqlite_http_url(self, raw_host, port),
DatabaseType::SqlServer => {
format!("server=tcp:{host},{port};database={}", self.database.as_deref().unwrap_or("master"))
}
@ -489,6 +492,7 @@ impl ConnectionConfig {
format!("postgres://{}:{}@{host}:{port}{db_part}{suffix}", username, password)
}
DatabaseType::ClickHouse => clickhouse_http_url(self, raw_host, port),
DatabaseType::Rqlite => rqlite_http_url(self, raw_host, port),
DatabaseType::SqlServer => format!(
"server=tcp:{host},{port};user={};password={};database={}",
self.username,
@ -825,6 +829,31 @@ fn clickhouse_http_url(config: &ConnectionConfig, host: &str, port: u16) -> Stri
format!("{scheme}://{}:{port}", bracket_ipv6(trimmed))
}
fn rqlite_http_url(config: &ConnectionConfig, host: &str, port: u16) -> String {
let trimmed = host.trim();
if let Some(rest) = trimmed.strip_prefix("https://") {
return format!("https://{}", trim_http_host_port(rest, port));
}
if let Some(rest) = trimmed.strip_prefix("http://") {
let scheme = if config.ssl { "https" } else { "http" };
return format!("{scheme}://{}", trim_http_host_port(rest, port));
}
let scheme = if config.ssl { "https" } else { "http" };
format!("{scheme}://{}:{port}", bracket_ipv6(trimmed))
}
fn trim_http_host_port(value: &str, default_port: u16) -> String {
let authority = value.trim_end_matches('/').split('/').next().unwrap_or(value).split('?').next().unwrap_or(value);
if authority.starts_with('[') && !authority.contains("]:") {
return format!("{authority}:{default_port}");
}
if authority.rsplit_once(':').is_some() {
authority.to_string()
} else {
format!("{authority}:{default_port}")
}
}
fn trim_clickhouse_host_port(value: &str, default_port: u16) -> String {
let authority = value.trim_end_matches('/').split('/').next().unwrap_or(value).split('?').next().unwrap_or(value);
if authority.starts_with('[') && !authority.contains("]:") {

View File

@ -564,6 +564,17 @@ pub async fn do_execute(
wait_for_query_opt(cancel_token, query_timeout, db::sqlite::execute_query_with_max_rows(&p, sql, max_rows))
.await
}
PoolKind::Rqlite(client) => {
let client = client.clone();
let max_rows = options.max_rows;
drop(connections);
wait_for_query_opt(
cancel_token,
query_timeout,
db::rqlite_driver::execute_query_with_max_rows(&client, sql, max_rows),
)
.await
}
PoolKind::ClickHouse(client) => {
let client = client.clone();
let database = pool_key.split(':').nth(1).unwrap_or("default").to_string();
@ -1107,7 +1118,9 @@ pub async fn execute_statements_in_transaction(
PoolKind::Postgres(pg) => TxPath::Pg(pg.clone()),
PoolKind::Mysql(mp, _mode) => TxPath::Mysql(mp.clone(), false),
PoolKind::Sqlite(sq) => TxPath::Sqlite(sq.clone()),
PoolKind::ClickHouse(_) | PoolKind::SqlServer(_) | PoolKind::Agent(_) => TxPath::Explicit,
PoolKind::ClickHouse(_) | PoolKind::Rqlite(_) | PoolKind::SqlServer(_) | PoolKind::Agent(_) => {
TxPath::Explicit
}
PoolKind::DuckDb(_)
| PoolKind::Redis(_)
| PoolKind::MongoDb(_)

View File

@ -300,6 +300,7 @@ async fn list_databases_once(state: &AppState, connection_id: &str) -> Result<Ve
PoolKind::Mysql(p, mode) => dispatch_mysql!(p, mode, db::mysql::list_databases, db::ob_oracle::list_databases),
PoolKind::Postgres(p) => db::postgres::list_databases(p).await,
PoolKind::Sqlite(p) => db::sqlite::list_databases(p).await,
PoolKind::Rqlite(client) => db::rqlite_driver::list_databases(client).await,
PoolKind::DuckDb(con) => {
let con = con.lock().map_err(|e| e.to_string())?;
duckdb_list_databases_with_attached(&con, &duckdb_attached_names)
@ -428,6 +429,9 @@ async fn list_tables_once(
PoolKind::Sqlite(p) => {
db::sqlite::list_tables(p, schema).await.map(|tables| filter_table_infos(tables, filter, limit))
}
PoolKind::Rqlite(client) => {
db::rqlite_driver::list_tables(client, schema).await.map(|tables| filter_table_infos(tables, filter, limit))
}
PoolKind::MongoDb(client) => db::mongo_driver::list_collections(client, database)
.await
.map(|names| collection_names_to_tables(names, "COLLECTION"))
@ -825,6 +829,9 @@ pub async fn get_columns_core(
}
PoolKind::Postgres(p) => db::postgres::get_columns(p, schema, table).await.map(deduplicate_column_infos),
PoolKind::Sqlite(p) => db::sqlite::get_columns(p, schema, table).await.map(deduplicate_column_infos),
PoolKind::Rqlite(client) => {
db::rqlite_driver::get_columns(client, schema, table).await.map(deduplicate_column_infos)
}
_ => Ok(vec![]),
}
}
@ -896,6 +903,7 @@ pub async fn list_indexes_core(
}
PoolKind::Postgres(p) => db::postgres::list_indexes(p, schema, table).await,
PoolKind::Sqlite(p) => db::sqlite::list_indexes(p, schema, table).await,
PoolKind::Rqlite(client) => db::rqlite_driver::list_indexes(client, schema, table).await,
_ => Ok(vec![]),
}
}
@ -924,6 +932,7 @@ pub async fn list_foreign_keys_core(
}
PoolKind::Postgres(p) => db::postgres::list_foreign_keys(p, schema, table).await,
PoolKind::Sqlite(p) => db::sqlite::list_foreign_keys(p, schema, table).await,
PoolKind::Rqlite(client) => db::rqlite_driver::list_foreign_keys(client, schema, table).await,
_ => Ok(vec![]),
}
}
@ -952,6 +961,7 @@ pub async fn list_triggers_core(
}
PoolKind::Postgres(p) => db::postgres::list_triggers(p, schema, table).await,
PoolKind::Sqlite(p) => db::sqlite::list_triggers(p, schema, table).await,
PoolKind::Rqlite(client) => db::rqlite_driver::list_triggers(client, schema, table).await,
_ => Ok(vec![]),
}
}
@ -1019,6 +1029,7 @@ pub async fn get_table_ddl_core(
}
PoolKind::Postgres(p) => pg_ddl(p, schema, table).await,
PoolKind::Sqlite(p) => sqlite_ddl(p, table).await,
PoolKind::Rqlite(client) => db::rqlite_driver::table_ddl(client, table).await,
_ => Err("DDL not supported for this database type".to_string()),
}
}
@ -1244,6 +1255,9 @@ pub async fn get_object_source_core(
PoolKind::Sqlite(pool) => first_string_cell(
db::sqlite::execute_query(pool, &sqlite_object_source_sql(name, &object_type)).await?,
)?,
PoolKind::Rqlite(client) => {
return db::rqlite_driver::object_source(client, name, &object_type).await;
}
PoolKind::ClickHouse(client) if matches!(object_type, db::ObjectSourceKind::View) => {
let result = db::clickhouse_driver::execute_query(
client,

View File

@ -241,7 +241,7 @@ fn capabilities_for(database_type: Option<DatabaseType>) -> TableStructureCapabi
comment: true,
..base
},
Some(DatabaseType::Sqlite) => TableStructureCapabilities {
Some(DatabaseType::Sqlite | DatabaseType::Rqlite) => TableStructureCapabilities {
dialect: StructureDialect::Sqlite,
add_column: true,
drop_column: true,
@ -1966,6 +1966,34 @@ mod tests {
);
}
#[test]
fn builds_rqlite_changes_with_sqlite_dialect() {
let mut email = column("email");
email.data_type = "text".to_string();
email.is_nullable = false;
let mut email_index = index("idx_users_email", &["email"]);
email_index.filter = "email IS NOT NULL".to_string();
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Rqlite),
schema: None,
table_name: "users".to_string(),
columns: vec![email],
indexes: vec![email_index],
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"ALTER TABLE \"users\" ADD COLUMN \"email\" text NOT NULL;",
"CREATE INDEX \"idx_users_email\" ON \"users\" (\"email\") WHERE email IS NOT NULL;",
]
);
}
#[test]
fn builds_mysql_column_reorder_statements() {
let mut id = column("id");

View File

@ -11,6 +11,7 @@ const expected: Record<string, string> = {
redshift: "postgresql://user:password@host:port/database",
redis: "redis://:password@host:port/0",
sqlite: "sqlite:///absolute/path/to/database.db",
rqlite: "http://user:password@host:4001",
duckdb: "duckdb:///absolute/path/to/database.duckdb",
access: "jdbc:ucanaccess:///absolute/path/to/database.accdb",
mongodb: "mongodb://user:password@host:port/database",

View File

@ -175,6 +175,14 @@ test("uses Navicat-style table editing defaults for updateable SQL table engines
requiresTransactionalTableForExistingRows: false,
transaction: true,
});
assert.deepEqual(getDatabaseCapability("rqlite").tableData, {
insert: true,
updateRequiresPrimaryKey: false,
deleteRequiresPrimaryKey: false,
keylessRowPredicate: true,
requiresTransactionalTableForExistingRows: false,
transaction: true,
});
});
test("keeps conservative table editing defaults for unknown database types", () => {
@ -205,6 +213,7 @@ test("describes feature support through capability helpers", () => {
assert.equal(supportsTableStructureEditing("opengauss"), true);
assert.equal(supportsTableStructureEditing("redshift"), true);
assert.equal(supportsTableStructureEditing("clickhouse"), true);
assert.equal(supportsTableStructureEditing("rqlite"), true);
assert.equal(supportsTableStructureEditing("mongodb"), false);
assert.equal(supportsDatabaseCreation("clickhouse"), true);
assert.equal(supportsDatabaseCreation("sqlite"), false);
@ -220,6 +229,7 @@ test("describes feature support through capability helpers", () => {
assert.equal(supportsObjectBrowser("mongodb"), false);
assert.equal(supportsTableTruncate("mysql"), true);
assert.equal(supportsTableTruncate("duckdb"), false);
assert.equal(supportsTableTruncate("rqlite"), false);
});
test("object browser entry follows database tree shape", () => {

View File

@ -10,6 +10,8 @@ test("recognizes object rename support for UI affordances", () => {
assert.equal(supportsObjectRename("sqlserver", "PROCEDURE"), true);
assert.equal(supportsObjectRename("sqlite", "TABLE"), true);
assert.equal(supportsObjectRename("sqlite", "VIEW"), false);
assert.equal(supportsObjectRename("rqlite", "TABLE"), true);
assert.equal(supportsObjectRename("rqlite", "VIEW"), false);
assert.equal(supportsObjectRename("oracle", "FUNCTION"), false);
assert.equal(supportsObjectRename("dameng", "PROCEDURE"), false);
assert.equal(supportsObjectRename("mysql", "PROCEDURE"), false);

View File

@ -5,8 +5,8 @@ import {
getTableStructureCapabilities,
} from "../../apps/desktop/src/lib/tableStructureCapabilities.ts";
test("sqlite and duckdb do not support table comments", () => {
for (const dbType of ["sqlite", "duckdb"] as const) {
test("sqlite-family and duckdb do not support table comments", () => {
for (const dbType of ["sqlite", "rqlite", "duckdb"] as const) {
const caps = getTableStructureCapabilities(dbType);
assert.equal(caps.comment, false, `${dbType} should not support comments`);
assert.equal(caps.createTable, true, `${dbType} should still support creating tables`);

View File

@ -30,7 +30,8 @@ test("prints capabilities when invoked through an npm-style symlink", async () =
assert.equal(result.status, 0);
assert.equal(result.stderr, "");
const payload = JSON.parse(result.stdout) as { directQueryTypes: string[]; bridgeRequiredTypes: string[] };
assert.ok(payload.directQueryTypes.includes("postgres"));
assert.ok(payload.directQueryTypes.includes("postgres"));
assert.ok(payload.directQueryTypes.includes("rqlite"));
assert.ok(payload.bridgeRequiredTypes.includes("oracle"));
} finally {
await rm(bin.dir, { recursive: true, force: true });

View File

@ -51,7 +51,7 @@ const diagnostics: DbxDiagnostics = {
loadedConnectionCount: 2,
bridgePortFile: "/tmp/dbx/mcp-bridge-port",
bridgePortFileExists: false,
directQueryTypes: ["postgres", "mysql", "sqlite"],
directQueryTypes: ["postgres", "mysql", "sqlite", "rqlite"],
bridgeRequiredTypes: ["oracle", "mongodb"],
};
@ -154,6 +154,7 @@ test("prints capabilities as json", async () => {
const payload = JSON.parse(result.stdout) as { directQueryTypes: string[]; bridgeRequiredTypes: string[] };
assert.ok(payload.directQueryTypes.includes("postgres"));
assert.ok(payload.directQueryTypes.includes("sqlite"));
assert.ok(payload.directQueryTypes.includes("rqlite"));
assert.ok(payload.bridgeRequiredTypes.includes("oracle"));
});

View File

@ -46,7 +46,7 @@ function formatQueryToolResult(result: QueryResult, title?: string) {
}
export const DBX_CONNECTION_TYPE_DESCRIPTION =
"Database type: postgres, mysql, sqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, doris, starrocks, redshift, dameng, kingbase, highgo, vastbase, goldendb, gaussdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, gbase, h2, snowflake, trino, hive, db2, informix, iris, neo4j, cassandra, bigquery, kylin, sundb, tdengine, xugu, jdbc, access";
"Database type: postgres, mysql, sqlite, rqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, doris, starrocks, redshift, dameng, kingbase, highgo, vastbase, goldendb, gaussdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, gbase, h2, snowflake, trino, hive, db2, informix, iris, neo4j, cassandra, bigquery, kylin, sundb, tdengine, xugu, jdbc, access";
export function createDbxMcpServer(backend: Backend, options: { isWebMode?: boolean } = {}): McpServer {
const isWebMode = options.isWebMode ?? !!process.env.DBX_WEB_URL;
@ -181,7 +181,7 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
const existing = await backend.findConnection(name);
if (existing) return text(`Connection "${name}" already exists.`);
const FILE_BASED_TYPES = new Set(["sqlite", "duckdb", "access"]);
const DEFAULT_PORTS: Record<string, number> = { tdengine: 6041, xugu: 5138 };
const DEFAULT_PORTS: Record<string, number> = { rqlite: 4001, tdengine: 6041, xugu: 5138 };
const resolvedPort = port ?? DEFAULT_PORTS[db_type] ?? (FILE_BASED_TYPES.has(db_type) ? 0 : undefined);
if (resolvedPort === undefined) return text("Port is required for this database type.");
const config = await backend.addConnection({

View File

@ -42,6 +42,17 @@ interface PoolEntry {
timer: ReturnType<typeof setTimeout>;
}
interface RqliteResult {
columns?: string[];
values?: unknown[][];
rows_affected?: number;
error?: string;
}
interface RqliteResponse {
results?: RqliteResult[];
}
const pools = new Map<string, PoolEntry>();
const proxyTunnels = new Map<string, { server: Server; port: number }>();
@ -365,6 +376,7 @@ async function mysqlQuery(config: ConnectionConfig, sql: string, params?: unknow
async function query(config: ConnectionConfig, sql: string, params?: unknown[], options?: QueryOptions): Promise<QueryResult> {
if (config.db_type === "sqlite") return sqliteQuery(config, sql, options);
if (config.db_type === "rqlite") return rqliteQuery(config, sql, options);
if (isMysqlType(config.db_type)) return mysqlQuery(config, sql, params, options);
return pgQuery(config, sql, params, options);
}
@ -398,6 +410,47 @@ function sqliteQuery(config: ConnectionConfig, sql: string, options?: QueryOptio
}
}
async function rqliteQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise<QueryResult> {
const isReader = /^\s*(?:--[^\n]*\n|\s|\/\*[\s\S]*?\*\/)*(select|pragma|explain|with)\b/i.test(sql);
const endpoint = isReader ? "/db/query" : "/db/execute";
const result = await rqliteRequest(config, endpoint, sql);
if (isReader) {
const columns = result.columns ?? [];
const rows = (result.values ?? []).slice(0, resolveMaxRows(options)).map((row) => {
const record: Record<string, unknown> = {};
columns.forEach((column, index) => {
record[column] = row[index];
});
return record;
});
return { columns, rows, row_count: rows.length };
}
return { columns: [], rows: [], row_count: result.rows_affected ?? 0 };
}
async function rqliteRequest(config: ConnectionConfig, endpoint: "/db/query" | "/db/execute", sql: string): Promise<RqliteResult> {
const { host, port } = await connectionEndpoint(config);
const scheme = config.ssl ? "https" : "http";
const params = (config.url_params || "").trim().replace(/^\?/, "");
const url = `${scheme}://${host}:${port}${endpoint}${params ? `?${params}` : ""}`;
const headers: Record<string, string> = { "content-type": "application/json" };
if (config.username) {
headers.authorization = `Basic ${Buffer.from(`${config.username}:${config.password || ""}`).toString("base64")}`;
}
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify([sql]),
});
const text = await response.text();
if (!response.ok) throw new Error(`rqlite error (${response.status}): ${text}`);
const payload = JSON.parse(text) as RqliteResponse;
const result = payload.results?.[0];
if (!result) throw new Error("rqlite returned no result");
if (result.error) throw new Error(`rqlite error: ${result.error}`);
return result;
}
export async function executeQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise<QueryResult> {
if (config.db_type === "mongodb") {
const find = parseMongoFindCommand(sql);
@ -451,7 +504,7 @@ export async function listTables(config: ConnectionConfig, schema?: string): Pro
});
return collections.map((name) => ({ name, type: "COLLECTION" }));
}
if (config.db_type === "sqlite") {
if (config.db_type === "sqlite" || config.db_type === "rqlite") {
const result = await query(
config,
`SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name`,
@ -484,7 +537,7 @@ export async function describeTable(config: ConnectionConfig, table: string, sch
const result = await mongoFindDocuments(config, table, 0, 20, "{}");
return inferMongoColumns(result.documents);
}
if (config.db_type === "sqlite") {
if (config.db_type === "sqlite" || config.db_type === "rqlite") {
const result = await query(config, `PRAGMA table_info(${quoteSqliteIdentifier(table)})`);
return result.rows.map((r) => ({
name: String(r.name || ""),

View File

@ -9,6 +9,7 @@ export const DIRECT_QUERY_TYPES = [
"doris",
"starrocks",
"sqlite",
"rqlite",
"gaussdb",
"opengauss",
] as const;

View File

@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import test from "node:test";
import type { ConnectionConfig } from "../src/connections.js";
import { describeTable, executeQuery, listTables } from "../src/database.js";
function rqliteConfig(port: number): ConnectionConfig {
return {
id: "rqlite-test",
name: "local-rqlite",
db_type: "rqlite",
host: "127.0.0.1",
port,
username: "dbx",
password: "secret",
database: "main",
ssh_enabled: false,
ssl: false,
};
}
async function withRqliteServer(handler: (req: IncomingMessage, res: ServerResponse, body: string) => void) {
const server = createServer((req, res) => {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => handler(req, res, body));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert.ok(address && typeof address === "object");
return {
port: address.port,
close: () => new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
};
}
function json(res: ServerResponse, body: unknown) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
test("executes rqlite query through the HTTP API", async () => {
const server = await withRqliteServer((req, res, body) => {
assert.equal(req.url, "/db/query");
assert.equal(req.headers.authorization, "Basic ZGJ4OnNlY3JldA==");
assert.deepEqual(JSON.parse(body), ["select id, name from users"]);
json(res, { results: [{ columns: ["id", "name"], values: [[1, "Ada"], [2, "Linus"]] }] });
});
try {
const result = await executeQuery(rqliteConfig(server.port), "select id, name from users", { maxRows: 1 });
assert.deepEqual(result, { columns: ["id", "name"], rows: [{ id: 1, name: "Ada" }], row_count: 1 });
} finally {
await server.close();
}
});
test("lists rqlite tables and describes columns", async () => {
const server = await withRqliteServer((_req, res, body) => {
const sql = JSON.parse(body)[0] as string;
if (sql.includes("sqlite_master")) {
json(res, { results: [{ columns: ["name", "type"], values: [["users", "table"], ["active_users", "view"]] }] });
return;
}
if (sql.includes("PRAGMA table_info")) {
json(res, {
results: [
{
columns: ["cid", "name", "type", "notnull", "dflt_value", "pk"],
values: [
[0, "id", "INTEGER", 1, null, 1],
[1, "name", "TEXT", 0, "'unknown'", 0],
],
},
],
});
return;
}
json(res, { results: [{ columns: [], values: [] }] });
});
try {
assert.deepEqual(await listTables(rqliteConfig(server.port)), [
{ name: "users", type: "table" },
{ name: "active_users", type: "view" },
]);
assert.deepEqual(await describeTable(rqliteConfig(server.port), "users"), [
{
name: "id",
data_type: "INTEGER",
is_nullable: false,
column_default: null,
is_primary_key: true,
comment: null,
},
{
name: "name",
data_type: "TEXT",
is_nullable: true,
column_default: "'unknown'",
is_primary_key: false,
comment: null,
},
]);
} finally {
await server.close();
}
});

View File

@ -395,6 +395,19 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
.await
.map(|_| "Connection successful".to_string())
}
DatabaseType::Rqlite => {
let client = db::rqlite_driver::RqliteClient::new(
&url,
config.url_params.as_deref(),
&config.username,
&config.password,
config.ssl,
connect_timeout,
)?;
db::rqlite_driver::test_connection(&client, connect_timeout)
.await
.map(|_| "Connection successful".to_string())
}
db_type if database_capabilities::is_agent_type(&db_type) => {
test_agent_connection(state.inner(), &config, &host, port).await
}
@ -549,6 +562,18 @@ pub async fn connect_db(state: State<'_, Arc<AppState>>, config: ConnectionConfi
db::elasticsearch_driver::test_connection(&mut client, connect_timeout).await?;
PoolKind::Elasticsearch(client)
}
DatabaseType::Rqlite => {
let client = db::rqlite_driver::RqliteClient::new(
&url,
db_config.url_params.as_deref(),
&db_config.username,
&db_config.password,
db_config.ssl,
connect_timeout,
)?;
db::rqlite_driver::test_connection(&client, connect_timeout).await?;
PoolKind::Rqlite(client)
}
db_type if database_capabilities::is_agent_type(&db_type) => {
connect_agent_pool(state.inner(), &db_config, &host, port).await?
}