2026-05-12 10:25:54 +02:00
|
|
|
import {
|
|
|
|
|
DEFAULT_METABASE_CLIENT_CONFIG,
|
|
|
|
|
DefaultMetabaseConnectionClientFactory,
|
|
|
|
|
type MetabaseRuntimeClient,
|
|
|
|
|
metabaseRuntimeConfigFromLocalConnection,
|
|
|
|
|
} from '@ktx/context/ingest';
|
2026-05-13 15:04:50 +02:00
|
|
|
import { type KtxLocalProject, loadKtxProject } from '@ktx/context/project';
|
2026-05-10 23:51:24 +02:00
|
|
|
import type { KtxScanConnector } from '@ktx/context/scan';
|
|
|
|
|
import type { KtxCliIo } from './index.js';
|
|
|
|
|
import { createKtxCliScanConnector } from './local-scan-connectors.js';
|
2026-05-10 23:12:26 +02:00
|
|
|
import { profileMark } from './startup-profile.js';
|
|
|
|
|
|
|
|
|
|
profileMark('module:connection');
|
|
|
|
|
|
2026-05-10 23:51:24 +02:00
|
|
|
export type KtxConnectionArgs =
|
2026-05-10 23:12:26 +02:00
|
|
|
| { command: 'list'; projectDir: string }
|
2026-05-13 15:04:50 +02:00
|
|
|
| { command: 'test'; projectDir: string; connectionId: string };
|
2026-05-10 23:12:26 +02:00
|
|
|
|
2026-05-10 23:51:24 +02:00
|
|
|
interface KtxConnectionDeps {
|
|
|
|
|
createScanConnector?: typeof createKtxCliScanConnector;
|
2026-05-12 10:25:54 +02:00
|
|
|
createMetabaseClient?: typeof createDefaultMetabaseClient;
|
2026-05-10 23:12:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:51:24 +02:00
|
|
|
async function cleanupConnector(connector: KtxScanConnector | null): Promise<void> {
|
2026-05-10 23:12:26 +02:00
|
|
|
if (connector?.cleanup) {
|
|
|
|
|
await connector.cleanup();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 10:25:54 +02:00
|
|
|
function normalizedConnectionDriver(project: KtxLocalProject, connectionId: string): string {
|
|
|
|
|
return String(project.config.connections[connectionId]?.driver ?? '')
|
|
|
|
|
.trim()
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:12:26 +02:00
|
|
|
async function testNativeConnection(
|
2026-05-10 23:51:24 +02:00
|
|
|
project: KtxLocalProject,
|
2026-05-10 23:12:26 +02:00
|
|
|
connectionId: string,
|
2026-05-10 23:51:24 +02:00
|
|
|
createScanConnector: typeof createKtxCliScanConnector,
|
2026-05-10 23:12:26 +02:00
|
|
|
): Promise<{ driver: string; tableCount: number }> {
|
2026-05-10 23:51:24 +02:00
|
|
|
let connector: KtxScanConnector | null = null;
|
2026-05-10 23:12:26 +02:00
|
|
|
try {
|
|
|
|
|
connector = await createScanConnector(project, connectionId);
|
|
|
|
|
const snapshot = await connector.introspect(
|
|
|
|
|
{
|
|
|
|
|
connectionId,
|
|
|
|
|
driver: connector.driver,
|
|
|
|
|
mode: 'structural',
|
|
|
|
|
dryRun: true,
|
|
|
|
|
detectRelationships: false,
|
|
|
|
|
},
|
|
|
|
|
{ runId: `connection-test-${connectionId}` },
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
driver: connector.driver,
|
|
|
|
|
tableCount: snapshot.tables.length,
|
|
|
|
|
};
|
|
|
|
|
} finally {
|
|
|
|
|
await cleanupConnector(connector);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 10:25:54 +02:00
|
|
|
async function createDefaultMetabaseClient(
|
|
|
|
|
project: KtxLocalProject,
|
|
|
|
|
connectionId: string,
|
|
|
|
|
): Promise<Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'>> {
|
|
|
|
|
const factory = new DefaultMetabaseConnectionClientFactory(
|
|
|
|
|
(metabaseConnectionId) =>
|
|
|
|
|
metabaseRuntimeConfigFromLocalConnection(
|
|
|
|
|
metabaseConnectionId,
|
|
|
|
|
project.config.connections[metabaseConnectionId],
|
|
|
|
|
),
|
|
|
|
|
DEFAULT_METABASE_CLIENT_CONFIG,
|
|
|
|
|
);
|
|
|
|
|
return factory.createClient(connectionId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function testMetabaseConnection(
|
|
|
|
|
project: KtxLocalProject,
|
|
|
|
|
connectionId: string,
|
|
|
|
|
createMetabaseClient: typeof createDefaultMetabaseClient,
|
|
|
|
|
): Promise<{ driver: 'metabase'; databaseCount: number }> {
|
|
|
|
|
let client: Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'> | null = null;
|
|
|
|
|
try {
|
|
|
|
|
client = await createMetabaseClient(project, connectionId);
|
|
|
|
|
const testResult = await client.testConnection();
|
|
|
|
|
if (!testResult.success) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Metabase connection test failed: ${testResult.error ?? testResult.message ?? 'unknown error'}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const databases = await client.getDatabases();
|
|
|
|
|
const databaseCount = databases.filter((database) => database.is_sample !== true).length;
|
|
|
|
|
if (databaseCount === 0) {
|
|
|
|
|
throw new Error('Metabase auth worked but no usable databases were returned');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { driver: 'metabase', databaseCount };
|
|
|
|
|
} finally {
|
|
|
|
|
await client?.cleanup();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:51:24 +02:00
|
|
|
export async function runKtxConnection(
|
|
|
|
|
args: KtxConnectionArgs,
|
2026-05-13 15:04:50 +02:00
|
|
|
io: KtxCliIo = process,
|
2026-05-10 23:51:24 +02:00
|
|
|
deps: KtxConnectionDeps = {},
|
2026-05-10 23:12:26 +02:00
|
|
|
): Promise<number> {
|
|
|
|
|
try {
|
2026-05-10 23:51:24 +02:00
|
|
|
const project = await loadKtxProject({ projectDir: args.projectDir });
|
2026-05-10 23:12:26 +02:00
|
|
|
if (args.command === 'list') {
|
|
|
|
|
const entries = Object.entries(project.config.connections).sort(([a], [b]) => a.localeCompare(b));
|
|
|
|
|
if (entries.length === 0) {
|
2026-05-13 15:04:50 +02:00
|
|
|
io.stdout.write('No connections configured. Run `ktx setup` to add one.\n');
|
2026-05-10 23:12:26 +02:00
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
const idWidth = Math.max('ID'.length, ...entries.map(([id]) => id.length));
|
|
|
|
|
const driverWidth = Math.max(
|
|
|
|
|
'DRIVER'.length,
|
|
|
|
|
...entries.map(([, c]) => (c.driver ?? 'unknown').length),
|
|
|
|
|
);
|
|
|
|
|
io.stdout.write(`${'ID'.padEnd(idWidth)} ${'DRIVER'.padEnd(driverWidth)}\n`);
|
|
|
|
|
for (const [id, connection] of entries) {
|
|
|
|
|
io.stdout.write(`${id.padEnd(idWidth)} ${(connection.driver ?? 'unknown').padEnd(driverWidth)}\n`);
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 10:25:54 +02:00
|
|
|
if (normalizedConnectionDriver(project, args.connectionId) === 'metabase') {
|
|
|
|
|
const result = await testMetabaseConnection(
|
|
|
|
|
project,
|
|
|
|
|
args.connectionId,
|
|
|
|
|
deps.createMetabaseClient ?? createDefaultMetabaseClient,
|
|
|
|
|
);
|
|
|
|
|
io.stdout.write(`Connection test passed: ${args.connectionId}\n`);
|
|
|
|
|
io.stdout.write(`Driver: ${result.driver}\n`);
|
|
|
|
|
io.stdout.write(`Databases: ${result.databaseCount}\n`);
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:12:26 +02:00
|
|
|
const result = await testNativeConnection(
|
|
|
|
|
project,
|
|
|
|
|
args.connectionId,
|
2026-05-10 23:51:24 +02:00
|
|
|
deps.createScanConnector ?? createKtxCliScanConnector,
|
2026-05-10 23:12:26 +02:00
|
|
|
);
|
|
|
|
|
io.stdout.write(`Connection test passed: ${args.connectionId}\n`);
|
|
|
|
|
io.stdout.write(`Driver: ${result.driver}\n`);
|
|
|
|
|
io.stdout.write(`Tables: ${result.tableCount}\n`);
|
|
|
|
|
return 0;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
}
|