diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts index 96281e82..2f4a0f4a 100644 --- a/packages/cli/src/connection.ts +++ b/packages/cli/src/connection.ts @@ -74,6 +74,12 @@ async function testNativeConnection( } const result = await connector.testConnection(); if (!result.success) { + // Re-throw the driver's original error so connection_test telemetry records + // its real class (e.g. ConnectionError) and code (e.g. ELOGIN) instead of + // collapsing every native failure to a generic Error with no code. + if (result.cause instanceof Error) { + throw result.cause; + } throw new Error(result.error ?? 'connection test failed'); } return { driver: connector.driver }; diff --git a/packages/cli/src/connectors/bigquery/connector.ts b/packages/cli/src/connectors/bigquery/connector.ts index edebe284..eae0f2ed 100644 --- a/packages/cli/src/connectors/bigquery/connector.ts +++ b/packages/cli/src/connectors/bigquery/connector.ts @@ -5,7 +5,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; import { + connectorTestFailure, createKtxConnectorCapabilities, + type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, @@ -320,7 +322,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector { this.id = `bigquery:${options.connectionId}`; } - async testConnection(): Promise<{ success: boolean; error?: string }> { + async testConnection(): Promise { try { const client = this.getClient(); await client.getDatasets({ maxResults: 1 }); @@ -329,7 +331,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector { } return { success: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return connectorTestFailure(error); } } diff --git a/packages/cli/src/connectors/clickhouse/connector.ts b/packages/cli/src/connectors/clickhouse/connector.ts index 74ef7a77..23622701 100644 --- a/packages/cli/src/connectors/clickhouse/connector.ts +++ b/packages/cli/src/connectors/clickhouse/connector.ts @@ -1,7 +1,7 @@ import { createClient } from '@clickhouse/client'; import { getDialectForDriver } from '../../context/connections/dialects.js'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; -import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableListEntry, type KtxTableSampleResult } from '../../context/scan/types.js'; +import { connectorTestFailure, createKtxConnectorCapabilities, type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaColumn, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableRef, type KtxTableSampleInput, type KtxTableListEntry, type KtxTableSampleResult } from '../../context/scan/types.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; import { readFileSync } from 'node:fs'; import { Agent as HttpsAgent } from 'node:https'; @@ -317,12 +317,12 @@ export class KtxClickHouseScanConnector implements KtxScanConnector { this.id = `clickhouse:${options.connectionId}`; } - async testConnection(): Promise<{ success: boolean; error?: string }> { + async testConnection(): Promise { try { await this.query('SELECT 1'); return { success: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return connectorTestFailure(error); } } diff --git a/packages/cli/src/connectors/mysql/connector.ts b/packages/cli/src/connectors/mysql/connector.ts index 29dacc26..c147c7dd 100644 --- a/packages/cli/src/connectors/mysql/connector.ts +++ b/packages/cli/src/connectors/mysql/connector.ts @@ -11,7 +11,9 @@ import { } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; import { + connectorTestFailure, createKtxConnectorCapabilities, + type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, @@ -413,12 +415,12 @@ export class KtxMysqlScanConnector implements KtxScanConnector { this.id = `mysql:${options.connectionId}`; } - async testConnection(): Promise<{ success: boolean; error?: string }> { + async testConnection(): Promise { try { await this.query('SELECT 1'); return { success: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return connectorTestFailure(error); } } diff --git a/packages/cli/src/connectors/postgres/connector.ts b/packages/cli/src/connectors/postgres/connector.ts index f206fa6a..1a956a3d 100644 --- a/packages/cli/src/connectors/postgres/connector.ts +++ b/packages/cli/src/connectors/postgres/connector.ts @@ -6,7 +6,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; import { + connectorTestFailure, createKtxConnectorCapabilities, + type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, @@ -442,12 +444,12 @@ export class KtxPostgresScanConnector implements KtxScanConnector { this.id = `postgres:${options.connectionId}`; } - async testConnection(): Promise<{ success: boolean; error?: string }> { + async testConnection(): Promise { try { await this.query('SELECT 1'); return { success: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return connectorTestFailure(error); } } diff --git a/packages/cli/src/connectors/snowflake/connector.ts b/packages/cli/src/connectors/snowflake/connector.ts index 86d7ebe7..51a91e52 100644 --- a/packages/cli/src/connectors/snowflake/connector.ts +++ b/packages/cli/src/connectors/snowflake/connector.ts @@ -7,7 +7,9 @@ import { assertReadOnlySql, limitSqlForExecution } from '../../context/connectio import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; import { + connectorTestFailure, createKtxConnectorCapabilities, + type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, @@ -464,7 +466,7 @@ class SnowflakeSdkDriver implements KtxSnowflakeDriver { await this.query('SELECT 1'); return { success: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return connectorTestFailure(error); } } @@ -573,7 +575,7 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector { } } - async testConnection(): Promise<{ success: boolean; error?: string }> { + async testConnection(): Promise { return this.getDriver().test(); } diff --git a/packages/cli/src/connectors/sqlite/connector.ts b/packages/cli/src/connectors/sqlite/connector.ts index e996bc25..f5ba2a55 100644 --- a/packages/cli/src/connectors/sqlite/connector.ts +++ b/packages/cli/src/connectors/sqlite/connector.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import { getDialectForDriver } from '../../context/connections/dialects.js'; import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js'; import { normalizeQueryRows } from '../../context/connections/query-executor.js'; -import { createKtxConnectorCapabilities, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js'; +import { connectorTestFailure, createKtxConnectorCapabilities, type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, type KtxColumnStatsResult, type KtxQueryResult, type KtxReadOnlyQueryInput, type KtxScanConnector, type KtxScanContext, type KtxScanInput, type KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; export interface KtxSqliteConnectionConfig { @@ -167,7 +167,7 @@ export class KtxSqliteScanConnector implements KtxScanConnector { this.id = `sqlite:${options.connectionId}`; } - async testConnection(): Promise<{ success: boolean; error?: string }> { + async testConnection(): Promise { try { if (!existsSync(this.dbPath) || !statSync(this.dbPath).isFile()) { return { success: false, error: `File not found: ${this.dbPath}` }; @@ -175,7 +175,7 @@ export class KtxSqliteScanConnector implements KtxScanConnector { this.database().prepare('SELECT 1').get(); return { success: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return connectorTestFailure(error); } } diff --git a/packages/cli/src/connectors/sqlserver/connector.ts b/packages/cli/src/connectors/sqlserver/connector.ts index 0115781d..5dd9969b 100644 --- a/packages/cli/src/connectors/sqlserver/connector.ts +++ b/packages/cli/src/connectors/sqlserver/connector.ts @@ -3,7 +3,9 @@ import { getDialectForDriver } from '../../context/connections/dialects.js'; import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js'; import { scopedTableNames } from '../../context/scan/table-ref.js'; import { + connectorTestFailure, createKtxConnectorCapabilities, + type KtxConnectorTestResult, type KtxColumnSampleInput, type KtxColumnSampleResult, type KtxColumnStatsInput, @@ -384,12 +386,12 @@ export class KtxSqlServerScanConnector implements KtxScanConnector { this.id = `sqlserver:${options.connectionId}`; } - async testConnection(): Promise<{ success: boolean; error?: string }> { + async testConnection(): Promise { try { await this.query('SELECT 1'); return { success: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return connectorTestFailure(error); } } diff --git a/packages/cli/src/context/scan/types.ts b/packages/cli/src/context/scan/types.ts index 1d9e6d6a..fc445b5e 100644 --- a/packages/cli/src/context/scan/types.ts +++ b/packages/cli/src/context/scan/types.ts @@ -303,9 +303,29 @@ export interface KtxTableListEntry { kind: 'table' | 'view'; } -interface KtxConnectorTestResult { +export interface KtxConnectorTestResult { success: boolean; error?: string; + /** + * The original error thrown by the driver, preserved unflattened so the + * connection-test path can re-throw it. Keeping the real error object lets + * telemetry record the driver's actual error class (e.g. `ConnectionError`) + * and `.code` (e.g. `ELOGIN`) instead of collapsing every failure to `Error`. + */ + cause?: unknown; +} + +/** + * Single source of truth for a failed connector test result. Captures the + * driver's message for display while preserving the original error as `cause` + * so callers can surface its real class and code. + */ +export function connectorTestFailure(error: unknown): KtxConnectorTestResult { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + cause: error, + }; } export interface KtxScanConnector { diff --git a/packages/cli/test/connection.test.ts b/packages/cli/test/connection.test.ts index 67e55af8..da650b05 100644 --- a/packages/cli/test/connection.test.ts +++ b/packages/cli/test/connection.test.ts @@ -38,7 +38,7 @@ function makeIo() { function nativeConnector( driver: KtxConnectionDriver, - testResult: { success: true } | { success: false; error: string } = { success: true }, + testResult: { success: true } | { success: false; error: string; cause?: unknown } = { success: true }, ) { const testConnection = vi.fn(async () => testResult); const cleanup = vi.fn(async () => undefined); @@ -183,6 +183,34 @@ describe('runKtxConnection', () => { expect(io.stderr()).toContain('"errorDetail":"database file is unreadable"'); }); + it('preserves the driver error class and code in connection_test telemetry', async () => { + vi.stubEnv('KTX_TELEMETRY_DEBUG', '1'); + vi.stubEnv('CI', ''); + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir }); + await writeConnections(projectDir, { + warehouse: { driver: 'sqlserver', host: 'db.example.test', database: 'analytics', username: 'svc_ro' }, + }); + class ConnectionError extends Error { + readonly code = 'ELOGIN'; + } + const driverError = new ConnectionError("Login failed for user 'svc_ro'."); + const { connector } = nativeConnector('sqlserver', { + success: false, + error: driverError.message, + cause: driverError, + }); + const io = makeIo(); + + const code = await runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, { + createScanConnector: vi.fn(async () => connector), + }); + + expect(code).toBe(1); + expect(io.stderr()).toContain('"errorClass":"ConnectionError"'); + expect(io.stderr()).toContain('"errorDetail":"ELOGIN: Login failed for user \'svc_ro\'."'); + }); + it('reports the connector error and still cleans up when native testConnection fails', async () => { const projectDir = join(tempDir, 'project'); await initKtxProject({ projectDir });