Skip to content

Commit b1cda56

Browse files
authored
feat: implement connection_timeout (#124)
1 parent a1179a1 commit b1cda56

File tree

11 files changed

+310
-89
lines changed

11 files changed

+310
-89
lines changed

dbhub.toml.example

Lines changed: 70 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,87 +7,101 @@
77

88
## Example 1: PostgreSQL with DSN (recommended)
99
[[sources]]
10-
id = "prod_pg"
11-
dsn = "postgres://user:password@localhost:5432/production?sslmode=require"
12-
readonly = false
13-
max_rows = 1000
10+
id = "prod_pg" # Required: Unique identifier
11+
dsn = "postgres://user:password@localhost:5432/production?sslmode=require" # Required: Connection string
12+
readonly = false # Optional: Limit to read-only operations (default: false)
13+
max_rows = 1000 # Optional: Maximum rows to return per query (default: unlimited)
14+
connection_timeout = 30 # Optional: Connection timeout in seconds (default: driver-specific)
1415

1516
## Example 2: MySQL with individual parameters
1617
[[sources]]
17-
id = "staging_mysql"
18-
type = "mysql"
19-
host = "localhost"
20-
port = 3306
21-
database = "staging"
22-
user = "root"
23-
password = "secret"
24-
readonly = false
25-
max_rows = 500
18+
id = "staging_mysql" # Required: Unique identifier
19+
type = "mysql" # Required when using individual parameters (not DSN)
20+
host = "localhost" # Required when using individual parameters
21+
port = 3306 # Optional: Uses default if not specified (MySQL default: 3306)
22+
database = "staging" # Required: Database name
23+
user = "root" # Required: Database username
24+
password = "secret" # Required: Database password
25+
readonly = false # Optional: Limit to read-only operations (default: false)
26+
max_rows = 500 # Optional: Maximum rows to return per query (default: unlimited)
27+
connection_timeout = 60 # Optional: Connection timeout in seconds (useful for high-latency connections)
2628

2729
## Example 3: MariaDB with SSH tunnel
2830
[[sources]]
29-
id = "remote_mariadb"
30-
dsn = "mariadb://dbuser:[email protected]:3306/mydb"
31-
ssh_host = "bastion.example.com"
32-
ssh_port = 22
33-
ssh_user = "ubuntu"
34-
ssh_key = "~/.ssh/id_rsa"
35-
# ssh_passphrase = "optional_key_passphrase"
36-
# ssh_password = "optional_ssh_password" # Use instead of ssh_key for password auth
31+
id = "remote_mariadb" # Required: Unique identifier
32+
dsn = "mariadb://dbuser:[email protected]:3306/mydb" # Required: Connection string (target DB behind SSH tunnel)
33+
ssh_host = "bastion.example.com" # Optional: SSH server hostname (required if using SSH tunnel)
34+
ssh_port = 22 # Optional: SSH server port (default: 22)
35+
ssh_user = "ubuntu" # Optional: SSH username (required if using SSH tunnel)
36+
ssh_key = "~/.ssh/id_rsa" # Optional: Path to private key file (use ssh_key OR ssh_password)
37+
# ssh_passphrase = "key_passphrase" # Optional: Passphrase for encrypted private key
38+
# ssh_password = "ssh_password" # Optional: SSH password (use instead of ssh_key for password auth)
3739

38-
## Example 4: SQL Server
40+
## Example 4: SQL Server with timeouts
3941
[[sources]]
40-
id = "analytics_sqlserver"
41-
type = "sqlserver"
42-
host = "sqlserver.example.com"
43-
port = 1433
44-
database = "analytics"
45-
user = "sa"
46-
password = "YourStrong@Passw0rd"
47-
max_rows = 2000
48-
# instanceName = "optional_instance_name"
42+
id = "analytics_sqlserver" # Required: Unique identifier
43+
type = "sqlserver" # Required when using individual parameters
44+
host = "sqlserver.example.com" # Required when using individual parameters
45+
port = 1433 # Optional: Uses default if not specified (SQL Server default: 1433)
46+
database = "analytics" # Required: Database name
47+
user = "sa" # Required: Database username
48+
password = "YourStrong@Passw0rd" # Required: Database password
49+
max_rows = 2000 # Optional: Maximum rows to return per query (default: unlimited)
50+
connection_timeout = 30 # Optional: Connection establishment timeout in seconds (default: 15s)
51+
request_timeout = 120 # Optional: Query execution timeout in seconds (SQL Server only, default: 15s)
52+
# instanceName = "INSTANCE1" # Optional: SQL Server named instance (e.g., SERVER\INSTANCE1)
4953

5054
## Example 5: SQLite local file
5155
[[sources]]
52-
id = "local_sqlite"
53-
type = "sqlite"
54-
database = "/path/to/database.db"
55-
readonly = true
56+
id = "local_sqlite" # Required: Unique identifier
57+
type = "sqlite" # Required when using individual parameters
58+
database = "/path/to/database.db" # Required: Path to SQLite database file
59+
readonly = true # Optional: Limit to read-only operations (default: false)
5660

5761
## Example 6: SQLite in-memory (for testing)
5862
[[sources]]
59-
id = "test_db"
60-
dsn = "sqlite:///:memory:"
63+
id = "test_db" # Required: Unique identifier
64+
dsn = "sqlite:///:memory:" # Required: Connection string (in-memory database)
6165

6266
# Connection Parameters Reference:
6367
# -------------------------------
64-
# Required for each source:
65-
# - id: Unique identifier for this database source
6668
#
67-
# Connection options (choose one):
68-
# Option A: Full DSN string
69-
# - dsn: Complete connection string (e.g., "postgres://user:pass@host:port/db")
69+
# REQUIRED FIELDS:
70+
# ----------------
71+
# - id: Unique identifier for this database source (string)
7072
#
71-
# Option B: Individual parameters
72-
# - type: Database type (postgres, mysql, mariadb, sqlserver, sqlite)
73-
# - host: Database host (not needed for sqlite)
74-
# - port: Database port (optional, uses default if not specified)
75-
# - database: Database name or file path (for sqlite)
76-
# - user: Database username (not needed for sqlite)
77-
# - password: Database password (not needed for sqlite)
78-
# - instanceName: SQL Server named instance (optional, for sqlserver only)
73+
# AND one of:
74+
# Option A: DSN (connection string)
75+
# - dsn: Complete connection string (e.g., "postgres://user:pass@host:port/db")
7976
#
80-
# Execution options (optional):
77+
# Option B: Individual connection parameters
78+
# - type: Database type (postgres, mysql, mariadb, sqlserver, sqlite) [REQUIRED]
79+
# - database: Database name or file path [REQUIRED]
80+
# For network databases (postgres, mysql, mariadb, sqlserver):
81+
# - host: Database host [REQUIRED]
82+
# - user: Database username [REQUIRED]
83+
# - password: Database password [REQUIRED]
84+
# For SQLite: only database path is needed
85+
#
86+
# OPTIONAL FIELDS:
87+
# ----------------
88+
# Connection parameters:
89+
# - port: Database port (default: 5432 for postgres, 3306 for mysql/mariadb, 1433 for sqlserver)
90+
# - instanceName: SQL Server named instance (sqlserver only, e.g., "INSTANCE1")
91+
#
92+
# Execution options:
8193
# - readonly: Limit to read-only operations (default: false)
82-
# - max_rows: Maximum rows to return per query (default: unlimited)
94+
# - max_rows: Maximum rows to return per query (default: unlimited, e.g., 1000)
95+
# - connection_timeout: Connection timeout in seconds (default: driver-specific, e.g., 30, 60)
96+
# - request_timeout: Query execution timeout in seconds (SQL Server only, default: 15, e.g., 120)
8397
#
84-
# SSH tunnel options (optional):
98+
# SSH tunnel options (all optional, but if using SSH tunnel, ssh_host, ssh_user required):
8599
# - ssh_host: SSH server hostname or IP
86100
# - ssh_port: SSH server port (default: 22)
87101
# - ssh_user: SSH username
88-
# - ssh_password: SSH password (for password authentication)
89-
# - ssh_key: Path to private key file (for key-based authentication)
90-
# - ssh_passphrase: Passphrase for encrypted private key (optional)
102+
# - ssh_key: Path to private key file (use ssh_key OR ssh_password, not both)
103+
# - ssh_password: SSH password (use instead of ssh_key for password authentication)
104+
# - ssh_passphrase: Passphrase for encrypted private key
91105

92106
# Default Port Numbers:
93107
# - PostgreSQL: 5432

src/config/__tests__/toml-loader.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,134 @@ ssh_port = 99999
233233

234234
expect(() => loadTomlConfig()).toThrow('Configuration file specified by --config flag not found');
235235
});
236+
237+
describe('connection_timeout validation', () => {
238+
it('should accept valid connection_timeout', () => {
239+
const tomlContent = `
240+
[[sources]]
241+
id = "test_db"
242+
dsn = "postgres://user:pass@localhost:5432/testdb"
243+
connection_timeout = 60
244+
`;
245+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
246+
247+
const result = loadTomlConfig();
248+
249+
expect(result).toBeTruthy();
250+
expect(result?.sources[0].connection_timeout).toBe(60);
251+
});
252+
253+
it('should throw error for negative connection_timeout', () => {
254+
const tomlContent = `
255+
[[sources]]
256+
id = "test_db"
257+
dsn = "postgres://user:pass@localhost:5432/testdb"
258+
connection_timeout = -30
259+
`;
260+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
261+
262+
expect(() => loadTomlConfig()).toThrow('invalid connection_timeout');
263+
});
264+
265+
it('should throw error for zero connection_timeout', () => {
266+
const tomlContent = `
267+
[[sources]]
268+
id = "test_db"
269+
dsn = "postgres://user:pass@localhost:5432/testdb"
270+
connection_timeout = 0
271+
`;
272+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
273+
274+
expect(() => loadTomlConfig()).toThrow('invalid connection_timeout');
275+
});
276+
277+
it('should accept large connection_timeout values', () => {
278+
const tomlContent = `
279+
[[sources]]
280+
id = "test_db"
281+
dsn = "postgres://user:pass@localhost:5432/testdb"
282+
connection_timeout = 300
283+
`;
284+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
285+
286+
const result = loadTomlConfig();
287+
288+
expect(result).toBeTruthy();
289+
expect(result?.sources[0].connection_timeout).toBe(300);
290+
});
291+
292+
it('should work without connection_timeout (optional field)', () => {
293+
const tomlContent = `
294+
[[sources]]
295+
id = "test_db"
296+
dsn = "postgres://user:pass@localhost:5432/testdb"
297+
`;
298+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
299+
300+
const result = loadTomlConfig();
301+
302+
expect(result).toBeTruthy();
303+
expect(result?.sources[0].connection_timeout).toBeUndefined();
304+
});
305+
});
306+
307+
describe('request_timeout validation', () => {
308+
it('should accept valid request_timeout', () => {
309+
const tomlContent = `
310+
[[sources]]
311+
id = "test_db"
312+
dsn = "sqlserver://user:pass@localhost:1433/testdb"
313+
request_timeout = 120
314+
`;
315+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
316+
317+
const result = loadTomlConfig();
318+
319+
expect(result).toBeTruthy();
320+
expect(result?.sources[0].request_timeout).toBe(120);
321+
});
322+
323+
it('should throw error for negative request_timeout', () => {
324+
const tomlContent = `
325+
[[sources]]
326+
id = "test_db"
327+
dsn = "sqlserver://user:pass@localhost:1433/testdb"
328+
request_timeout = -60
329+
`;
330+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
331+
332+
expect(() => loadTomlConfig()).toThrow('invalid request_timeout');
333+
});
334+
335+
it('should throw error for zero request_timeout', () => {
336+
const tomlContent = `
337+
[[sources]]
338+
id = "test_db"
339+
dsn = "sqlserver://user:pass@localhost:1433/testdb"
340+
request_timeout = 0
341+
`;
342+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
343+
344+
expect(() => loadTomlConfig()).toThrow('invalid request_timeout');
345+
});
346+
347+
it('should accept both connection_timeout and request_timeout', () => {
348+
const tomlContent = `
349+
[[sources]]
350+
id = "test_db"
351+
dsn = "sqlserver://user:pass@localhost:1433/testdb"
352+
connection_timeout = 30
353+
request_timeout = 120
354+
`;
355+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
356+
357+
const result = loadTomlConfig();
358+
359+
expect(result).toBeTruthy();
360+
expect(result?.sources[0].connection_timeout).toBe(30);
361+
expect(result?.sources[0].request_timeout).toBe(120);
362+
});
363+
});
236364
});
237365

238366
describe('buildDSNFromSource', () => {

src/config/toml-loader.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,26 @@ function validateSourceConfig(source: SourceConfig, configPath: string): void {
161161
}
162162
}
163163

164+
// Validate connection_timeout if provided
165+
if (source.connection_timeout !== undefined) {
166+
if (typeof source.connection_timeout !== "number" || source.connection_timeout <= 0) {
167+
throw new Error(
168+
`Configuration file ${configPath}: source '${source.id}' has invalid connection_timeout. ` +
169+
`Must be a positive number (in seconds).`
170+
);
171+
}
172+
}
173+
174+
// Validate request_timeout if provided
175+
if (source.request_timeout !== undefined) {
176+
if (typeof source.request_timeout !== "number" || source.request_timeout <= 0) {
177+
throw new Error(
178+
`Configuration file ${configPath}: source '${source.id}' has invalid request_timeout. ` +
179+
`Must be a positive number (in seconds).`
180+
);
181+
}
182+
}
183+
164184
// Validate SSH port if provided
165185
if (source.ssh_port !== undefined) {
166186
if (

src/connectors/interface.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ export interface StoredProcedure {
4242
export interface ExecuteOptions {
4343
/** Maximum number of rows to return (applied via database-native LIMIT) */
4444
maxRows?: number;
45+
/** Restrict to read-only SQL operations */
46+
readonly?: boolean;
47+
}
48+
49+
/**
50+
* Configuration options for database connections
51+
* Different databases may use different subset of these options
52+
*/
53+
export interface ConnectorConfig {
54+
/** Connection timeout in seconds (PostgreSQL, MySQL, MariaDB, SQL Server) */
55+
connectionTimeoutSeconds?: number;
56+
/** Request/query timeout in seconds (SQL Server only) */
57+
requestTimeoutSeconds?: number;
58+
// Future database-specific options can be added here as optional fields
4559
}
4660

4761
/**
@@ -51,13 +65,15 @@ export interface ExecuteOptions {
5165
export interface DSNParser {
5266
/**
5367
* Parse a connection string into connector-specific configuration
68+
* @param dsn - Database connection string
69+
* @param config - Optional database-specific configuration options
5470
* Example DSN formats:
5571
* - PostgreSQL: "postgres://user:password@localhost:5432/dbname?sslmode=disable"
5672
* - MariaDB: "mariadb://user:password@localhost:3306/dbname"
5773
* - MySQL: "mysql://user:password@localhost:3306/dbname"
5874
* - SQLite: "sqlite:///path/to/database.db" or "sqlite:///:memory:"
5975
*/
60-
parse(dsn: string): Promise<any>;
76+
parse(dsn: string, config?: ConnectorConfig): Promise<any>;
6177

6278
/**
6379
* Generate a sample DSN string for this connector type
@@ -83,8 +99,8 @@ export interface Connector {
8399
/** Create a new instance of this connector (for multi-source support) - optional, only implemented for tested connectors */
84100
clone?(): Connector;
85101

86-
/** Connect to the database using DSN, with optional init script */
87-
connect(dsn: string, initScript?: string): Promise<void>;
102+
/** Connect to the database using DSN, with optional init script and database-specific configuration */
103+
connect(dsn: string, initScript?: string, config?: ConnectorConfig): Promise<void>;
88104

89105
/** Close the connection */
90106
disconnect(): Promise<void>;

src/connectors/manager.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Connector, ConnectorType, ConnectorRegistry, ExecuteOptions } from "./interface.js";
1+
import { Connector, ConnectorType, ConnectorRegistry, ExecuteOptions, ConnectorConfig } from "./interface.js";
22
import { SSHTunnel } from "../utils/ssh-tunnel.js";
33
import { resolveSSHConfig, resolveMaxRows } from "../config/env.js";
44
import type { SSHTunnelConfig } from "../types/ssh.js";
@@ -192,8 +192,17 @@ export class ConnectorManager {
192192
// Other databases will reuse the singleton instance (not recommended for multi-source)
193193
const connector = connectorPrototype.clone ? connectorPrototype.clone() : connectorPrototype;
194194

195-
// Connect to the database
196-
await connector.connect(actualDSN);
195+
// Build config for database-specific options
196+
const config: ConnectorConfig = {};
197+
if (source.connection_timeout !== undefined) {
198+
config.connectionTimeoutSeconds = source.connection_timeout;
199+
}
200+
if (connector.id === 'sqlserver' && source.request_timeout !== undefined) {
201+
config.requestTimeoutSeconds = source.request_timeout;
202+
}
203+
204+
// Connect to the database with config
205+
await connector.connect(actualDSN, undefined, config);
197206

198207
// Store connector
199208
this.connectors.set(sourceId, connector);

0 commit comments

Comments
 (0)