mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
feat(cli): extend ktx connection test to every supported driver (#92)
* feat(cli): extend `ktx connection test` to every supported driver Dispatch by driver: native DBs now call `connector.testConnection()` (was `introspect(dryRun)`), looker/notion/metabase hit their auth endpoints, and dbt/metricflow/lookml run `git ls-remote` via the existing `testRepoConnection` helper. Unknown drivers exit 1 with a listing of supported ones. * feat(cli): add `ktx connection test --all` summary list Tests every configured connection in parallel and renders a single Clack-style list (◇/│/◆/└, green ✓ / red ✗) consistent with sl list, with per-row detail and a passed/failed footer. Exits non-zero if any connection fails. Single-id `ktx connection test` output is preserved. * fix(cli): read metabase status url from api_url `ktx status` was probing `url` / `base_url` on metabase connections, but ktx.yaml stores it as `api_url`, so the field always reported "url not set". Read `api_url` directly and align the warning text with the actual key.
This commit is contained in:
parent
b3be54e3fa
commit
c7c5f63a66
12 changed files with 683 additions and 127 deletions
|
|
@ -33,12 +33,24 @@ export function registerConnectionCommands(program: Command, context: KtxCliComm
|
|||
connection
|
||||
.command('test')
|
||||
.description('Test a configured connection')
|
||||
.argument('<connectionId>', 'KTX connection id')
|
||||
.action(async (connectionId: string, _options: unknown, command) => {
|
||||
.argument('[connectionId]', 'KTX connection id (omit when --all is set)')
|
||||
.option('--all', 'Test every configured connection and print a summary list')
|
||||
.action(async (connectionId: string | undefined, options: { all?: boolean }, command) => {
|
||||
const all = options.all === true;
|
||||
if (all && connectionId !== undefined) {
|
||||
command.error('error: --all cannot be combined with a connection id argument');
|
||||
}
|
||||
if (!all && connectionId === undefined) {
|
||||
command.error('error: missing required argument <connectionId> (or pass --all)');
|
||||
}
|
||||
if (all) {
|
||||
await runConnectionArgs(context, { command: 'test-all', projectDir: resolveCommandProjectDir(command) });
|
||||
return;
|
||||
}
|
||||
await runConnectionArgs(context, {
|
||||
command: 'test',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId,
|
||||
connectionId: connectionId as string,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { MetabaseRuntimeClient } from '@ktx/context/ingest';
|
||||
import type { LookerClient, MetabaseRuntimeClient, NotionClient } from '@ktx/context/ingest';
|
||||
import { initKtxProject, parseKtxProjectConfig, serializeKtxProjectConfig } from '@ktx/context/project';
|
||||
import type { KtxConnectionDriver, KtxScanConnector, KtxSchemaSnapshot } from '@ktx/context/scan';
|
||||
import type { KtxConnectionDriver, KtxScanConnector } from '@ktx/context/scan';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runKtxConnection } from './connection.js';
|
||||
|
||||
function stripAnsi(s: string): string {
|
||||
return s.replace(/\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
|
@ -28,28 +32,11 @@ function makeIo() {
|
|||
};
|
||||
}
|
||||
|
||||
function snapshotFor(driver: KtxConnectionDriver, tableNames: string[]): KtxSchemaSnapshot {
|
||||
return {
|
||||
connectionId: 'warehouse',
|
||||
driver,
|
||||
extractedAt: '2026-04-29T00:00:00.000Z',
|
||||
scope: {},
|
||||
metadata: {},
|
||||
tables: tableNames.map((name) => ({
|
||||
catalog: null,
|
||||
db: null,
|
||||
name,
|
||||
kind: 'table',
|
||||
comment: null,
|
||||
estimatedRows: null,
|
||||
columns: [],
|
||||
foreignKeys: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function nativeConnector(driver: KtxConnectionDriver, tableNames: string[]) {
|
||||
const introspect = vi.fn(async () => snapshotFor(driver, tableNames));
|
||||
function nativeConnector(
|
||||
driver: KtxConnectionDriver,
|
||||
testResult: { success: true } | { success: false; error: string } = { success: true },
|
||||
) {
|
||||
const testConnection = vi.fn(async () => testResult);
|
||||
const cleanup = vi.fn(async () => undefined);
|
||||
const connector: KtxScanConnector = {
|
||||
id: `${driver}:warehouse`,
|
||||
|
|
@ -65,10 +52,13 @@ function nativeConnector(driver: KtxConnectionDriver, tableNames: string[]) {
|
|||
formalForeignKeys: false,
|
||||
estimatedRowCounts: false,
|
||||
},
|
||||
introspect,
|
||||
introspect: vi.fn(async () => {
|
||||
throw new Error('introspect should not be called from connection test');
|
||||
}),
|
||||
testConnection,
|
||||
cleanup,
|
||||
};
|
||||
return { connector, introspect, cleanup };
|
||||
return { connector, testConnection, cleanup };
|
||||
}
|
||||
|
||||
describe('runKtxConnection', () => {
|
||||
|
|
@ -119,13 +109,13 @@ describe('runKtxConnection', () => {
|
|||
expect(io.stdout()).not.toContain('ktx connection add');
|
||||
});
|
||||
|
||||
it('tests a configured connection through the native scan connector', async () => {
|
||||
it('tests a native connection by calling connector.testConnection (not introspect)', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeConnections(projectDir, {
|
||||
warehouse: { driver: 'sqlite' },
|
||||
});
|
||||
const { connector, introspect, cleanup } = nativeConnector('sqlite', ['customers', 'orders']);
|
||||
const { connector, testConnection, cleanup } = nativeConnector('sqlite');
|
||||
const createScanConnector = vi.fn(async () => connector);
|
||||
const io = makeIo();
|
||||
|
||||
|
|
@ -136,20 +126,31 @@ describe('runKtxConnection', () => {
|
|||
).resolves.toBe(0);
|
||||
|
||||
expect(createScanConnector).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'warehouse');
|
||||
expect(introspect).toHaveBeenCalledWith(
|
||||
{
|
||||
connectionId: 'warehouse',
|
||||
driver: 'sqlite',
|
||||
mode: 'structural',
|
||||
dryRun: true,
|
||||
detectRelationships: false,
|
||||
},
|
||||
{ runId: 'connection-test-warehouse' },
|
||||
);
|
||||
expect(testConnection).toHaveBeenCalledTimes(1);
|
||||
expect(connector.introspect).not.toHaveBeenCalled();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(io.stdout()).toContain('Connection test passed: warehouse');
|
||||
expect(io.stdout()).toContain('Driver: sqlite');
|
||||
expect(io.stdout()).toContain('Tables: 2');
|
||||
expect(io.stdout()).toContain('Status: ok');
|
||||
});
|
||||
|
||||
it('reports the connector error and still cleans up when native testConnection fails', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeConnections(projectDir, {
|
||||
warehouse: { driver: 'sqlite' },
|
||||
});
|
||||
const { connector, cleanup } = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' });
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
|
||||
createScanConnector: vi.fn(async () => connector),
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(io.stderr()).toContain('database file is unreadable');
|
||||
});
|
||||
|
||||
it('tests a configured Metabase connection through the Metabase runtime client', async () => {
|
||||
|
|
@ -198,41 +199,305 @@ describe('runKtxConnection', () => {
|
|||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('cleans up the native scan connector when connection testing fails', async () => {
|
||||
it('tests a Looker connection through the Looker client', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeConnections(projectDir, {
|
||||
bi_looker: {
|
||||
driver: 'looker',
|
||||
base_url: 'https://looker.example.test',
|
||||
client_id: 'cid',
|
||||
client_secret: 'csecret', // pragma: allowlist secret
|
||||
},
|
||||
});
|
||||
const testConnection = vi.fn(async () => ({
|
||||
success: true as const,
|
||||
metadata: { displayName: 'Alice Analyst', userId: '42' },
|
||||
}));
|
||||
const createLookerClient = vi.fn(async (): Promise<Pick<LookerClient, 'testConnection'>> => ({ testConnection }));
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test', projectDir, connectionId: 'bi_looker' }, io.io, { createLookerClient }),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(createLookerClient).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'bi_looker');
|
||||
expect(testConnection).toHaveBeenCalledTimes(1);
|
||||
expect(io.stdout()).toContain('Connection test passed: bi_looker');
|
||||
expect(io.stdout()).toContain('Driver: looker');
|
||||
expect(io.stdout()).toContain('User: Alice Analyst');
|
||||
});
|
||||
|
||||
it('falls back to userId when Looker metadata has no display name', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeConnections(projectDir, {
|
||||
bi_looker: {
|
||||
driver: 'looker',
|
||||
base_url: 'https://looker.example.test',
|
||||
client_id: 'cid',
|
||||
client_secret: 'csecret', // pragma: allowlist secret
|
||||
},
|
||||
});
|
||||
const createLookerClient = vi.fn(async (): Promise<Pick<LookerClient, 'testConnection'>> => ({
|
||||
testConnection: vi.fn(async () => ({
|
||||
success: true as const,
|
||||
metadata: { displayName: null, userId: '42' },
|
||||
})),
|
||||
}));
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test', projectDir, connectionId: 'bi_looker' }, io.io, { createLookerClient }),
|
||||
).resolves.toBe(0);
|
||||
expect(io.stdout()).toContain('User: 42');
|
||||
});
|
||||
|
||||
it('reports the Looker error when testConnection fails', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeConnections(projectDir, {
|
||||
bi_looker: {
|
||||
driver: 'looker',
|
||||
base_url: 'https://looker.example.test',
|
||||
client_id: 'cid',
|
||||
client_secret: 'csecret', // pragma: allowlist secret
|
||||
},
|
||||
});
|
||||
const createLookerClient = vi.fn(async (): Promise<Pick<LookerClient, 'testConnection'>> => ({
|
||||
testConnection: vi.fn(async () => ({ success: false as const, error: 'invalid client_id' })),
|
||||
}));
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test', projectDir, connectionId: 'bi_looker' }, io.io, { createLookerClient }),
|
||||
).resolves.toBe(1);
|
||||
expect(io.stderr()).toContain('Looker connection test failed: invalid client_id');
|
||||
});
|
||||
|
||||
it('tests a Notion connection by retrieving the bot user', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeConnections(projectDir, {
|
||||
docs: {
|
||||
driver: 'notion',
|
||||
auth_token: 'secret_token', // pragma: allowlist secret
|
||||
crawl_mode: 'all_accessible',
|
||||
},
|
||||
});
|
||||
const retrieveBotUser = vi.fn(async () => ({ id: 'bot-1', name: 'Analytics Bot' }));
|
||||
const createNotionClient = vi.fn(async (): Promise<Pick<NotionClient, 'retrieveBotUser'>> => ({ retrieveBotUser }));
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test', projectDir, connectionId: 'docs' }, io.io, { createNotionClient }),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(createNotionClient).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'docs');
|
||||
expect(retrieveBotUser).toHaveBeenCalledTimes(1);
|
||||
expect(io.stdout()).toContain('Connection test passed: docs');
|
||||
expect(io.stdout()).toContain('Driver: notion');
|
||||
expect(io.stdout()).toContain('Bot: Analytics Bot');
|
||||
});
|
||||
|
||||
it('falls back to bot id when Notion bot has no name', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeConnections(projectDir, {
|
||||
docs: {
|
||||
driver: 'notion',
|
||||
auth_token: 'secret_token', // pragma: allowlist secret
|
||||
crawl_mode: 'all_accessible',
|
||||
},
|
||||
});
|
||||
const createNotionClient = vi.fn(async (): Promise<Pick<NotionClient, 'retrieveBotUser'>> => ({
|
||||
retrieveBotUser: vi.fn(async () => ({ id: 'bot-1', name: null })),
|
||||
}));
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test', projectDir, connectionId: 'docs' }, io.io, { createNotionClient }),
|
||||
).resolves.toBe(0);
|
||||
expect(io.stdout()).toContain('Bot: bot-1');
|
||||
});
|
||||
|
||||
it('tests a dbt connection via testRepoConnection (success)', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
process.env.DBT_TOKEN = 'gh_token_abc'; // pragma: allowlist secret
|
||||
await writeConnections(projectDir, {
|
||||
'dbt-main': {
|
||||
driver: 'dbt',
|
||||
repo_url: 'https://github.com/example/dbt-project',
|
||||
auth_token_ref: 'env:DBT_TOKEN',
|
||||
},
|
||||
});
|
||||
const testRepoConnection = vi.fn(async () => ({ ok: true as const }));
|
||||
const io = makeIo();
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test', projectDir, connectionId: 'dbt-main' }, io.io, { testRepoConnection }),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(testRepoConnection).toHaveBeenCalledWith({
|
||||
repoUrl: 'https://github.com/example/dbt-project',
|
||||
authToken: 'gh_token_abc',
|
||||
});
|
||||
expect(io.stdout()).toContain('Connection test passed: dbt-main');
|
||||
expect(io.stdout()).toContain('Driver: dbt');
|
||||
expect(io.stdout()).toContain('Repo: https://github.com/example/dbt-project');
|
||||
} finally {
|
||||
delete process.env.DBT_TOKEN;
|
||||
}
|
||||
});
|
||||
|
||||
it('reports the git error when testRepoConnection fails for dbt', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeConnections(projectDir, {
|
||||
'dbt-main': {
|
||||
driver: 'dbt',
|
||||
repo_url: 'https://github.com/example/dbt-project',
|
||||
},
|
||||
});
|
||||
const testRepoConnection = vi.fn(async () => ({ ok: false as const, error: 'fatal: auth failed' }));
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test', projectDir, connectionId: 'dbt-main' }, io.io, { testRepoConnection }),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(testRepoConnection).toHaveBeenCalledWith({
|
||||
repoUrl: 'https://github.com/example/dbt-project',
|
||||
authToken: null,
|
||||
});
|
||||
expect(io.stderr()).toContain('dbt repository check failed: fatal: auth failed');
|
||||
});
|
||||
|
||||
it('tests a LookML connection via testRepoConnection with camelCase repoUrl', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeConnections(projectDir, {
|
||||
lookml_main: {
|
||||
driver: 'lookml',
|
||||
repoUrl: 'https://github.com/example/lookml',
|
||||
},
|
||||
});
|
||||
const testRepoConnection = vi.fn(async () => ({ ok: true as const }));
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test', projectDir, connectionId: 'lookml_main' }, io.io, { testRepoConnection }),
|
||||
).resolves.toBe(0);
|
||||
expect(testRepoConnection).toHaveBeenCalledWith({
|
||||
repoUrl: 'https://github.com/example/lookml',
|
||||
authToken: null,
|
||||
});
|
||||
expect(io.stdout()).toContain('Driver: lookml');
|
||||
expect(io.stdout()).toContain('Repo: https://github.com/example/lookml');
|
||||
});
|
||||
|
||||
it('tests a MetricFlow connection via the nested metricflow block', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeConnections(projectDir, {
|
||||
mf_main: {
|
||||
driver: 'metricflow',
|
||||
metricflow: { repoUrl: 'https://github.com/example/metricflow' },
|
||||
},
|
||||
});
|
||||
const testRepoConnection = vi.fn(async () => ({ ok: true as const }));
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test', projectDir, connectionId: 'mf_main' }, io.io, { testRepoConnection }),
|
||||
).resolves.toBe(0);
|
||||
expect(testRepoConnection).toHaveBeenCalledWith({
|
||||
repoUrl: 'https://github.com/example/metricflow',
|
||||
authToken: null,
|
||||
});
|
||||
expect(io.stdout()).toContain('Driver: metricflow');
|
||||
});
|
||||
|
||||
it('--all: prints a single coherent list with one row per connection', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeConnections(projectDir, {
|
||||
warehouse: { driver: 'sqlite' },
|
||||
docs: { driver: 'notion', auth_token: 'secret_token', crawl_mode: 'all_accessible' }, // pragma: allowlist secret
|
||||
});
|
||||
const cleanup = vi.fn(async () => undefined);
|
||||
const connector: KtxScanConnector = {
|
||||
id: 'sqlite:warehouse',
|
||||
driver: 'sqlite',
|
||||
capabilities: {
|
||||
structuralIntrospection: true,
|
||||
tableSampling: false,
|
||||
columnSampling: false,
|
||||
columnStats: false,
|
||||
readOnlySql: false,
|
||||
nestedAnalysis: false,
|
||||
eventStreamDiscovery: false,
|
||||
formalForeignKeys: false,
|
||||
estimatedRowCounts: false,
|
||||
},
|
||||
introspect: vi.fn(async () => {
|
||||
throw new Error('database file is unreadable');
|
||||
}),
|
||||
cleanup,
|
||||
};
|
||||
const { connector } = nativeConnector('sqlite');
|
||||
const createScanConnector = vi.fn(async () => connector);
|
||||
const createNotionClient = vi.fn(async (): Promise<Pick<NotionClient, 'retrieveBotUser'>> => ({
|
||||
retrieveBotUser: vi.fn(async () => ({ id: 'bot-1', name: 'Docs Bot' })),
|
||||
}));
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, {
|
||||
createScanConnector: vi.fn(async () => connector),
|
||||
}),
|
||||
runKtxConnection({ command: 'test-all', projectDir }, io.io, { createScanConnector, createNotionClient }),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const out = stripAnsi(io.stdout());
|
||||
expect(out).toContain('connection test --all');
|
||||
expect(out).toMatch(/docs\s+notion\s+✓ ok\s+Bot: Docs Bot/);
|
||||
expect(out).toMatch(/warehouse\s+sqlite\s+✓ ok\s+Status: ok/);
|
||||
expect(out).toContain('2 tested');
|
||||
expect(out).toContain('2 passed');
|
||||
expect(out).not.toContain('failed');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('--all: marks failing connections, keeps passing ones, and returns non-zero', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeConnections(projectDir, {
|
||||
warehouse: { driver: 'sqlite' },
|
||||
broken: { driver: 'sqlite' },
|
||||
});
|
||||
const okConnector = nativeConnector('sqlite').connector;
|
||||
const failConnector = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' }).connector;
|
||||
const createScanConnector = vi.fn(async (_p, connectionId: string) =>
|
||||
connectionId === 'broken' ? failConnector : okConnector,
|
||||
);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test-all', projectDir }, io.io, { createScanConnector }),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(io.stderr()).toContain('database file is unreadable');
|
||||
const out = stripAnsi(io.stdout());
|
||||
expect(out).toMatch(/broken\s+sqlite\s+✗ failed\s+database file is unreadable/);
|
||||
expect(out).toMatch(/warehouse\s+sqlite\s+✓ ok\s+Status: ok/);
|
||||
expect(out).toContain('1 passed');
|
||||
expect(out).toContain('1 failed');
|
||||
expect(io.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('--all: shows an empty-state message when no connections are configured', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
const io = makeIo();
|
||||
|
||||
await expect(runKtxConnection({ command: 'test-all', projectDir }, io.io)).resolves.toBe(0);
|
||||
|
||||
const out = stripAnsi(io.stdout());
|
||||
expect(out).toContain('connection test --all');
|
||||
expect(out).toContain('No connections configured. Run `ktx setup` to add one.');
|
||||
});
|
||||
|
||||
it('rejects unknown drivers with a helpful error', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeConnections(projectDir, {
|
||||
mystery: { driver: 'duckdb' },
|
||||
});
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test', projectDir, connectionId: 'mystery' }, io.io),
|
||||
).resolves.toBe(1);
|
||||
expect(io.stderr()).toContain('uses driver "duckdb"');
|
||||
expect(io.stderr()).toContain('Supported:');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import {
|
||||
DEFAULT_METABASE_CLIENT_CONFIG,
|
||||
DefaultLookerConnectionClientFactory,
|
||||
DefaultMetabaseConnectionClientFactory,
|
||||
type LookerClient,
|
||||
type MetabaseRuntimeClient,
|
||||
type NotionBotInfo,
|
||||
NotionClient,
|
||||
createLocalLookerCredentialResolver,
|
||||
metabaseRuntimeConfigFromLocalConnection,
|
||||
testRepoConnection,
|
||||
} from '@ktx/context/ingest';
|
||||
import { parseNotionConnectionConfig, resolveNotionConnectionAuthToken } from '@ktx/context/connections';
|
||||
import { resolveKtxConfigReference } from '@ktx/context/core';
|
||||
import { type KtxLocalProject, loadKtxProject } from '@ktx/context/project';
|
||||
import type { KtxScanConnector } from '@ktx/context/scan';
|
||||
import type { KtxCliIo } from './index.js';
|
||||
import { bold, dim, green, red, SYMBOLS } from './io/symbols.js';
|
||||
import { createKtxCliScanConnector } from './local-scan-connectors.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
|
||||
|
|
@ -14,18 +23,37 @@ profileMark('module:connection');
|
|||
|
||||
export type KtxConnectionArgs =
|
||||
| { command: 'list'; projectDir: string }
|
||||
| { command: 'test'; projectDir: string; connectionId: string };
|
||||
| { command: 'test'; projectDir: string; connectionId: string }
|
||||
| { command: 'test-all'; projectDir: string };
|
||||
|
||||
interface KtxConnectionDeps {
|
||||
type MetabaseTestPort = Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'>;
|
||||
type LookerTestPort = Pick<LookerClient, 'testConnection'>;
|
||||
type NotionTestPort = Pick<NotionClient, 'retrieveBotUser'>;
|
||||
type TestRepoConnection = typeof testRepoConnection;
|
||||
|
||||
export interface KtxConnectionDeps {
|
||||
createScanConnector?: typeof createKtxCliScanConnector;
|
||||
createMetabaseClient?: typeof createDefaultMetabaseClient;
|
||||
createMetabaseClient?: (project: KtxLocalProject, connectionId: string) => Promise<MetabaseTestPort>;
|
||||
createLookerClient?: (project: KtxLocalProject, connectionId: string) => Promise<LookerTestPort>;
|
||||
createNotionClient?: (project: KtxLocalProject, connectionId: string) => Promise<NotionTestPort>;
|
||||
testRepoConnection?: TestRepoConnection;
|
||||
}
|
||||
|
||||
async function cleanupConnector(connector: KtxScanConnector | null): Promise<void> {
|
||||
if (connector?.cleanup) {
|
||||
await connector.cleanup();
|
||||
}
|
||||
}
|
||||
const SUPPORTED_TEST_DRIVERS = [
|
||||
'sqlite',
|
||||
'postgres',
|
||||
'mysql',
|
||||
'clickhouse',
|
||||
'sqlserver',
|
||||
'bigquery',
|
||||
'snowflake',
|
||||
'metabase',
|
||||
'looker',
|
||||
'notion',
|
||||
'dbt',
|
||||
'metricflow',
|
||||
'lookml',
|
||||
];
|
||||
|
||||
function normalizedConnectionDriver(project: KtxLocalProject, connectionId: string): string {
|
||||
return String(project.config.connections[connectionId]?.driver ?? '')
|
||||
|
|
@ -37,33 +65,29 @@ async function testNativeConnection(
|
|||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
createScanConnector: typeof createKtxCliScanConnector,
|
||||
): Promise<{ driver: string; tableCount: number }> {
|
||||
): Promise<{ driver: string }> {
|
||||
let connector: KtxScanConnector | null = null;
|
||||
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,
|
||||
};
|
||||
if (!connector.testConnection) {
|
||||
throw new Error(`Connector for "${connectionId}" does not implement testConnection`);
|
||||
}
|
||||
const result = await connector.testConnection();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? 'connection test failed');
|
||||
}
|
||||
return { driver: connector.driver };
|
||||
} finally {
|
||||
await cleanupConnector(connector);
|
||||
if (connector?.cleanup) {
|
||||
await connector.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createDefaultMetabaseClient(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
): Promise<Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'>> {
|
||||
): Promise<MetabaseTestPort> {
|
||||
const factory = new DefaultMetabaseConnectionClientFactory(
|
||||
(metabaseConnectionId) =>
|
||||
metabaseRuntimeConfigFromLocalConnection(
|
||||
|
|
@ -78,30 +102,282 @@ async function createDefaultMetabaseClient(
|
|||
async function testMetabaseConnection(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
createMetabaseClient: typeof createDefaultMetabaseClient,
|
||||
): Promise<{ driver: 'metabase'; databaseCount: number }> {
|
||||
let client: Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'> | null = null;
|
||||
createClient: (project: KtxLocalProject, connectionId: string) => Promise<MetabaseTestPort>,
|
||||
): Promise<{ databaseCount: number }> {
|
||||
let client: MetabaseTestPort | null = null;
|
||||
try {
|
||||
client = await createMetabaseClient(project, connectionId);
|
||||
client = await createClient(project, connectionId);
|
||||
const testResult = await client.testConnection();
|
||||
if (!testResult.success) {
|
||||
throw new Error(
|
||||
`Metabase connection test failed: ${testResult.error ?? testResult.message ?? 'unknown error'}`,
|
||||
);
|
||||
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 };
|
||||
return { databaseCount };
|
||||
} finally {
|
||||
await client?.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function createDefaultLookerClient(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
): Promise<LookerTestPort> {
|
||||
const factory = new DefaultLookerConnectionClientFactory(createLocalLookerCredentialResolver(project));
|
||||
return (await factory.createClient(connectionId)) as unknown as LookerTestPort;
|
||||
}
|
||||
|
||||
async function testLookerConnection(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
createClient: (project: KtxLocalProject, connectionId: string) => Promise<LookerTestPort>,
|
||||
): Promise<{ user: string }> {
|
||||
const client = await createClient(project, connectionId);
|
||||
const result = await client.testConnection();
|
||||
if (!result.success) {
|
||||
throw new Error(`Looker connection test failed: ${result.error ?? 'unknown error'}`);
|
||||
}
|
||||
const metadata = (result.metadata ?? {}) as { displayName?: string | null; userId?: string };
|
||||
const user = (metadata.displayName ?? metadata.userId ?? 'unknown').trim() || 'unknown';
|
||||
return { user };
|
||||
}
|
||||
|
||||
async function createDefaultNotionClient(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
): Promise<NotionTestPort> {
|
||||
const connection = project.config.connections[connectionId];
|
||||
if (!connection) {
|
||||
throw new Error(`Connection "${connectionId}" is not configured in ktx.yaml`);
|
||||
}
|
||||
const parsed = parseNotionConnectionConfig(connection);
|
||||
const token = await resolveNotionConnectionAuthToken(parsed);
|
||||
return new NotionClient(token);
|
||||
}
|
||||
|
||||
function describeNotionBot(bot: NotionBotInfo): string {
|
||||
const name = typeof bot.name === 'string' ? bot.name.trim() : '';
|
||||
if (name) return name;
|
||||
const id = typeof bot.id === 'string' ? bot.id.trim() : '';
|
||||
return id || 'unknown';
|
||||
}
|
||||
|
||||
async function testNotionConnection(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
createClient: (project: KtxLocalProject, connectionId: string) => Promise<NotionTestPort>,
|
||||
): Promise<{ bot: string }> {
|
||||
const client = await createClient(project, connectionId);
|
||||
const bot = await client.retrieveBotUser();
|
||||
return { bot: describeNotionBot(bot) };
|
||||
}
|
||||
|
||||
interface GitConnectionFields {
|
||||
repoUrl: string;
|
||||
authToken: string | null;
|
||||
}
|
||||
|
||||
function extractGitConnectionFields(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
driver: string,
|
||||
): GitConnectionFields {
|
||||
const connection = project.config.connections[connectionId];
|
||||
if (!connection) {
|
||||
throw new Error(`Connection "${connectionId}" is not configured in ktx.yaml`);
|
||||
}
|
||||
const stringField = (value: unknown): string | null =>
|
||||
typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
const record =
|
||||
driver === 'metricflow' && typeof connection.metricflow === 'object' && connection.metricflow !== null
|
||||
? (connection.metricflow as Record<string, unknown>)
|
||||
: (connection as Record<string, unknown>);
|
||||
const repoUrl = driver === 'dbt' ? stringField(record.repo_url) : stringField(record.repoUrl);
|
||||
if (!repoUrl) {
|
||||
const field = driver === 'dbt' ? 'repo_url' : 'repoUrl';
|
||||
throw new Error(`Connection "${connectionId}" (driver: ${driver}) is missing ${field}`);
|
||||
}
|
||||
const literalToken = stringField(record.auth_token);
|
||||
const ref = stringField(record.auth_token_ref);
|
||||
const resolvedRef = ref ? resolveKtxConfigReference(ref, process.env) : null;
|
||||
return { repoUrl, authToken: literalToken ?? resolvedRef ?? null };
|
||||
}
|
||||
|
||||
async function testGitRepoConnection(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
driver: string,
|
||||
runTest: TestRepoConnection,
|
||||
): Promise<{ repoUrl: string }> {
|
||||
const { repoUrl, authToken } = extractGitConnectionFields(project, connectionId, driver);
|
||||
const result = await runTest({ repoUrl, authToken });
|
||||
if (!result.ok) {
|
||||
throw new Error(`${driver} repository check failed: ${result.error}`);
|
||||
}
|
||||
return { repoUrl };
|
||||
}
|
||||
|
||||
interface DriverTestOutcome {
|
||||
driver: string;
|
||||
detailKey: string;
|
||||
detailValue: string;
|
||||
}
|
||||
|
||||
async function testConnectionByDriver(
|
||||
project: KtxLocalProject,
|
||||
connectionId: string,
|
||||
deps: KtxConnectionDeps,
|
||||
): Promise<DriverTestOutcome> {
|
||||
const driver = normalizedConnectionDriver(project, connectionId);
|
||||
if (!driver) {
|
||||
throw new Error(`Connection "${connectionId}" has no \`driver\` field in ktx.yaml`);
|
||||
}
|
||||
|
||||
if (driver === 'metabase') {
|
||||
const result = await testMetabaseConnection(
|
||||
project,
|
||||
connectionId,
|
||||
deps.createMetabaseClient ?? createDefaultMetabaseClient,
|
||||
);
|
||||
return { driver, detailKey: 'Databases', detailValue: String(result.databaseCount) };
|
||||
}
|
||||
|
||||
if (driver === 'looker') {
|
||||
const result = await testLookerConnection(
|
||||
project,
|
||||
connectionId,
|
||||
deps.createLookerClient ?? createDefaultLookerClient,
|
||||
);
|
||||
return { driver, detailKey: 'User', detailValue: result.user };
|
||||
}
|
||||
|
||||
if (driver === 'notion') {
|
||||
const result = await testNotionConnection(
|
||||
project,
|
||||
connectionId,
|
||||
deps.createNotionClient ?? createDefaultNotionClient,
|
||||
);
|
||||
return { driver, detailKey: 'Bot', detailValue: result.bot };
|
||||
}
|
||||
|
||||
if (driver === 'dbt' || driver === 'metricflow' || driver === 'lookml') {
|
||||
const result = await testGitRepoConnection(
|
||||
project,
|
||||
connectionId,
|
||||
driver,
|
||||
deps.testRepoConnection ?? testRepoConnection,
|
||||
);
|
||||
return { driver, detailKey: 'Repo', detailValue: result.repoUrl };
|
||||
}
|
||||
|
||||
if (
|
||||
driver === 'sqlite' ||
|
||||
driver === 'sqlite3' ||
|
||||
driver === 'postgres' ||
|
||||
driver === 'postgresql' ||
|
||||
driver === 'mysql' ||
|
||||
driver === 'clickhouse' ||
|
||||
driver === 'sqlserver' ||
|
||||
driver === 'bigquery' ||
|
||||
driver === 'snowflake'
|
||||
) {
|
||||
const result = await testNativeConnection(
|
||||
project,
|
||||
connectionId,
|
||||
deps.createScanConnector ?? createKtxCliScanConnector,
|
||||
);
|
||||
return { driver: result.driver, detailKey: 'Status', detailValue: 'ok' };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Connection "${connectionId}" uses driver "${driver}", which has no test implementation in ktx. Supported: ${SUPPORTED_TEST_DRIVERS.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectionTestRow {
|
||||
connectionId: string;
|
||||
driver: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
function visualWidth(text: string): number {
|
||||
// styleText wraps content in ANSI escape sequences; strip them before measuring.
|
||||
return text.replace(/\[[0-9;]*m/g, '').length;
|
||||
}
|
||||
|
||||
function padVisual(text: string, width: number): string {
|
||||
const pad = width - visualWidth(text);
|
||||
return pad > 0 ? `${text}${' '.repeat(pad)}` : text;
|
||||
}
|
||||
|
||||
function renderTestAll(io: KtxCliIo, rows: ReadonlyArray<ConnectionTestRow>): void {
|
||||
io.stdout.write(`${SYMBOLS.barStart} connection test --all\n`);
|
||||
io.stdout.write(`${SYMBOLS.bar}\n`);
|
||||
|
||||
if (rows.length === 0) {
|
||||
io.stdout.write(`${SYMBOLS.barEnd} No connections configured. Run \`ktx setup\` to add one.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const okLabel = green('✓ ok');
|
||||
const failLabel = red('✗ failed');
|
||||
const idWidth = Math.max(...rows.map((r) => r.connectionId.length));
|
||||
const driverWidth = Math.max(...rows.map((r) => r.driver.length));
|
||||
const statusWidth = Math.max(visualWidth(okLabel), visualWidth(failLabel));
|
||||
|
||||
for (const row of rows) {
|
||||
const id = bold(padVisual(row.connectionId, idWidth));
|
||||
const driver = dim(padVisual(row.driver, driverWidth));
|
||||
const status = padVisual(row.ok ? okLabel : failLabel, statusWidth);
|
||||
const detail = dim(row.detail);
|
||||
io.stdout.write(`${SYMBOLS.bar} ${SYMBOLS.item} ${id} ${driver} ${status} ${detail}\n`);
|
||||
}
|
||||
|
||||
const failed = rows.filter((r) => !r.ok).length;
|
||||
const passed = rows.length - failed;
|
||||
io.stdout.write(`${SYMBOLS.bar}\n`);
|
||||
const summary =
|
||||
failed === 0
|
||||
? `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)}`
|
||||
: `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)} ${dim(SYMBOLS.middot)} ${red(`${failed} failed`)}`;
|
||||
io.stdout.write(`${SYMBOLS.barEnd} ${summary}\n`);
|
||||
}
|
||||
|
||||
async function runTestAll(
|
||||
project: KtxLocalProject,
|
||||
io: KtxCliIo,
|
||||
deps: KtxConnectionDeps,
|
||||
): Promise<number> {
|
||||
const entries = Object.entries(project.config.connections).sort(([a], [b]) => a.localeCompare(b));
|
||||
const rows = await Promise.all(
|
||||
entries.map(async ([connectionId, connection]): Promise<ConnectionTestRow> => {
|
||||
const declaredDriver = String(connection.driver ?? '').trim().toLowerCase() || 'unknown';
|
||||
try {
|
||||
const outcome = await testConnectionByDriver(project, connectionId, deps);
|
||||
return {
|
||||
connectionId,
|
||||
driver: outcome.driver || declaredDriver,
|
||||
ok: true,
|
||||
detail: `${outcome.detailKey}: ${outcome.detailValue}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
connectionId,
|
||||
driver: declaredDriver,
|
||||
ok: false,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
renderTestAll(io, rows);
|
||||
return rows.some((row) => !row.ok) ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function runKtxConnection(
|
||||
args: KtxConnectionArgs,
|
||||
io: KtxCliIo = process,
|
||||
|
|
@ -127,26 +403,14 @@ export async function runKtxConnection(
|
|||
return 0;
|
||||
}
|
||||
|
||||
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;
|
||||
if (args.command === 'test-all') {
|
||||
return await runTestAll(project, io, deps);
|
||||
}
|
||||
|
||||
const result = await testNativeConnection(
|
||||
project,
|
||||
args.connectionId,
|
||||
deps.createScanConnector ?? createKtxCliScanConnector,
|
||||
);
|
||||
const { driver, detailKey, detailValue } = await testConnectionByDriver(project, args.connectionId, deps);
|
||||
io.stdout.write(`Connection test passed: ${args.connectionId}\n`);
|
||||
io.stdout.write(`Driver: ${result.driver}\n`);
|
||||
io.stdout.write(`Tables: ${result.tableCount}\n`);
|
||||
io.stdout.write(`Driver: ${driver}\n`);
|
||||
io.stdout.write(`${detailKey}: ${detailValue}\n`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
|
|
|
|||
|
|
@ -1517,7 +1517,7 @@ describe('runKtxCli', () => {
|
|||
|
||||
expect(helpIo.stdout()).toContain('Usage: ktx connection');
|
||||
expect(helpIo.stdout()).toContain('list');
|
||||
expect(helpIo.stdout()).toContain('test <connectionId>');
|
||||
expect(helpIo.stdout()).toContain('test [options] [connectionId]');
|
||||
for (const removed of ['add', 'remove', 'map', 'mapping', 'metabase', 'notion']) {
|
||||
expect(helpIo.stdout()).not.toMatch(new RegExp(`\\b${removed}\\b`));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@ describe('runKtxIngest', () => {
|
|||
{
|
||||
databasesDeps: {
|
||||
testConnection: async (_projectDir, _connectionId, io) => {
|
||||
io.stdout.write('Driver: postgres\nTables: 1\n');
|
||||
io.stdout.write('Driver: postgres\nStatus: ok\n');
|
||||
return 0;
|
||||
},
|
||||
scanConnection: async () => 0,
|
||||
|
|
|
|||
|
|
@ -35,3 +35,11 @@ export function bold(text: string): string {
|
|||
export function gray(text: string): string {
|
||||
return styleText('gray', text);
|
||||
}
|
||||
|
||||
export function green(text: string): string {
|
||||
return styleText('green', text);
|
||||
}
|
||||
|
||||
export function red(text: string): string {
|
||||
return styleText('red', text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ describe('renderKtxCommandTree', () => {
|
|||
expect(topLevel).toContain(expected);
|
||||
}
|
||||
|
||||
expect(output).toContain('│ └── test <connectionId>');
|
||||
expect(output).toContain('│ └── test [connectionId]');
|
||||
expect(output).not.toContain('│ ├── add');
|
||||
expect(output).not.toContain('│ ├── remove');
|
||||
expect(output).not.toContain('│ ├── map');
|
||||
|
|
|
|||
|
|
@ -1361,7 +1361,7 @@ describe('setup databases step', () => {
|
|||
const testConnection = vi.fn(async (_projectDir: string, _connectionId: string, commandIo: KtxCliIo) => {
|
||||
commandIo.stdout.write('Connection test passed: postgres-warehouse\n');
|
||||
commandIo.stdout.write('Driver: postgres\n');
|
||||
commandIo.stdout.write('Tables: 2\n');
|
||||
commandIo.stdout.write('Status: ok\n');
|
||||
return 0;
|
||||
});
|
||||
const scanConnection = vi.fn(async (_projectDir: string, _connectionId: string, commandIo: KtxCliIo) => {
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
expectProjectStderr(connectionTest, projectDir);
|
||||
expect(connectionTest.stdout).toContain('Connection test passed: warehouse');
|
||||
expect(connectionTest.stdout).toContain('Driver: sqlite');
|
||||
expect(connectionTest.stdout).toContain('Tables: 2');
|
||||
expect(connectionTest.stdout).toContain('Status: ok');
|
||||
|
||||
const ingest = await runBuiltCli(['ingest', 'warehouse', '--project-dir', projectDir, '--fast', '--no-input']);
|
||||
expectProjectStderr(ingest, projectDir);
|
||||
|
|
|
|||
|
|
@ -289,9 +289,9 @@ function buildConnectionStatus(
|
|||
return warn('repoUrl not set', 'Rerun `ktx setup`');
|
||||
}
|
||||
case 'metabase': {
|
||||
const url = (conn as Record<string, unknown>).url ?? (conn as Record<string, unknown>).base_url;
|
||||
const url = (conn as Record<string, unknown>).api_url;
|
||||
if (typeof url === 'string' && url.length > 0) return ok(`url: ${url}`);
|
||||
return warn('url not set', 'Rerun `ktx setup`');
|
||||
return warn('api_url not set', 'Rerun `ktx setup`');
|
||||
}
|
||||
case 'looker':
|
||||
case 'lookml': {
|
||||
|
|
|
|||
|
|
@ -369,6 +369,7 @@ export type {
|
|||
KtxQueryResult,
|
||||
KtxReadOnlyQueryInput,
|
||||
KtxResolvedCredentialEnvelope,
|
||||
KtxConnectorTestResult,
|
||||
KtxScanArtifactPaths,
|
||||
KtxScanConnector,
|
||||
KtxScanContext,
|
||||
|
|
|
|||
|
|
@ -283,12 +283,18 @@ export interface KtxTableListEntry {
|
|||
kind: 'table' | 'view';
|
||||
}
|
||||
|
||||
export interface KtxConnectorTestResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface KtxScanConnector {
|
||||
id: string;
|
||||
driver: KtxConnectionDriver;
|
||||
capabilities: KtxConnectorCapabilities;
|
||||
eventStreamDiscovery?: KtxEventStreamDiscoveryPort;
|
||||
introspect(input: KtxScanInput, ctx: KtxScanContext): Promise<KtxSchemaSnapshot>;
|
||||
testConnection?(): Promise<KtxConnectorTestResult>;
|
||||
sampleColumn?(input: KtxColumnSampleInput, ctx: KtxScanContext): Promise<KtxColumnSampleResult>;
|
||||
sampleTable?(input: KtxTableSampleInput, ctx: KtxScanContext): Promise<KtxTableSampleResult>;
|
||||
columnStats?(input: KtxColumnStatsInput, ctx: KtxScanContext): Promise<KtxColumnStatsResult | null>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue