mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
* feat(cli): define full warehouse dialect contract
* test(cli): keep dialect edge tests focused
* fix(cli): stabilize dialect contract foundation
* refactor(connectors): own read-only query preparation
* refactor(connectors): resolve dialects through registry
* refactor(connectors): keep concrete dialect classes internal
* chore(workspace): enforce dialect import boundary
* refactor(cli): resolve relationship dialect at scan boundary
* refactor(cli): use dialect display parsing for entity details
* refactor(cli): use dialect display parsing for warehouse catalog
* refactor(cli): use dialect SQL in relationship workflows
* test(cli): verify solid dialect scan workflow closure
* test: split cli tests from source tree
* refactor(cli): standardize BigQuery scope listing
* feat(sqlite): implement connector scope listing
* test(connectors): cover required table listing
* feat(cli): add warehouse driver registry
* refactor(setup): route scope discovery through driver registry
* refactor(cli): route local query execution through driver registry
* refactor(historic-sql): route dialect support through driver registry
* refactor(cli): test warehouse connections through driver registry
* fix(cli): close driver registry type export gaps
* Improve setup daemon diagnostics
* refactor(setup): centralize rail-prefixed diagnostics + query-history fallback
Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput
into clack.ts so the setup wizard, managed daemons, and embedding/agent steps
share one rail-formatted writer. setup-databases.ts also adds a
"disable query history and retry" option when the schema-context build fails
and query history is the likely culprit, surfaced via a new
failed-query-history-unavailable status.
* fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match
The setup picker's KtxTableListEntry was a 2-level { schema, name }, so
qualifiedTableId always wrote db.name into enabled_tables. When BigQuery,
Snowflake, or SQL Server later ran fast ingest, their introspect step filtered
the scope set with scopedTableNames(scope, { catalog: projectId|database, db })
— catalog was non-null on the introspect side but null in the scope refs, so
every entry was rejected, the live-database adapter staged zero table files,
and detect() failed with 'Adapter "live-database" did not recognize fetched
source output'.
Align the picker boundary with the canonical 3-level KtxTableRef:
- Add catalog: string | null to KtxTableListEntry.
- BigQuery/Snowflake/SQL Server listTables populate catalog from the
resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null.
- qualifiedTableId emits catalog.schema.name when catalog is non-null
(resolveEnabledTables already accepts the 3-part shape) and
schemasFromEnabledTables now goes through parseDottedTableEntry so it
recovers the schema correctly from both 2-part and 3-part entries.
- Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker
reuse.
Update listTables expectations in all seven connector tests and the setup /
picker test fixtures. Add a picker regression test that covers the
catalog-bearing round-trip (save + refine).
* fix(cli): allow debug telemetry under opt-out env
463 lines
16 KiB
TypeScript
463 lines
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
DEFAULT_METABASE_CLIENT_CONFIG,
|
|
DefaultMetabaseConnectionClientFactory,
|
|
getDummyValueForWidgetType,
|
|
MetabaseClient,
|
|
stripOptionalClauses,
|
|
} from '../../../../../src/context/ingest/adapters/metabase/client.js';
|
|
import type { MetabaseCard, MetabaseTemplateTag } from '../../../../../src/context/ingest/adapters/metabase/client-port.js';
|
|
|
|
const runtime = {
|
|
apiUrl: 'https://metabase.example.test/api',
|
|
apiKey: 'test-key-1234', // pragma: allowlist secret
|
|
};
|
|
|
|
const fastRetryConfig = {
|
|
maxRetries: 2,
|
|
baseDelayMs: 1,
|
|
maxDelayMs: 1,
|
|
timeoutMs: 5000,
|
|
jitter: false,
|
|
retryableStatuses: [429, 500, 502, 503, 504],
|
|
};
|
|
|
|
function nativeCard(query: string, templateTags: Record<string, MetabaseTemplateTag> = {}): MetabaseCard {
|
|
return {
|
|
id: 1,
|
|
name: 'Native card',
|
|
type: 'model',
|
|
query_type: 'native',
|
|
database_id: 6,
|
|
dataset_query: {
|
|
type: 'native',
|
|
database: 6,
|
|
stages: [{ 'lib/type': 'mbql.stage/native', native: query, 'template-tags': templateTags }],
|
|
},
|
|
};
|
|
}
|
|
|
|
function legacyNativeCard(query: string, templateTags: Record<string, MetabaseTemplateTag> = {}): MetabaseCard {
|
|
return {
|
|
id: 1,
|
|
name: 'Legacy native card',
|
|
type: 'model',
|
|
query_type: 'native',
|
|
database_id: 6,
|
|
dataset_query: {
|
|
type: 'native',
|
|
database: 6,
|
|
native: { query, 'template-tags': templateTags },
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('DefaultMetabaseConnectionClientFactory', () => {
|
|
it('resolves runtime credentials by the explicit Metabase source connection id and merges overrides', async () => {
|
|
const resolveCredentials = vi.fn().mockResolvedValue(runtime);
|
|
const factory = new DefaultMetabaseConnectionClientFactory(resolveCredentials, {
|
|
...DEFAULT_METABASE_CLIENT_CONFIG,
|
|
timeoutMs: 60000,
|
|
maxRetries: 4,
|
|
});
|
|
|
|
const client = await factory.createClient('metabase-source-1', { timeoutMs: 1000 });
|
|
|
|
expect(resolveCredentials).toHaveBeenCalledWith('metabase-source-1');
|
|
expect(client).toBeInstanceOf(MetabaseClient);
|
|
expect(Reflect.get(client, 'baseUrl')).toBe('https://metabase.example.test/api');
|
|
expect(Reflect.get(client, 'runtime').apiKey).toBe('test-key-1234');
|
|
expect(Reflect.get(client, 'config').timeoutMs).toBe(1000);
|
|
expect(Reflect.get(client, 'config').maxRetries).toBe(4);
|
|
});
|
|
});
|
|
|
|
describe('MetabaseClient retry exhaustion', () => {
|
|
let originalFetch: typeof fetch;
|
|
|
|
beforeEach(() => {
|
|
originalFetch = globalThis.fetch;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('does not warn to console when retrying by default', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
globalThis.fetch = vi
|
|
.fn<typeof fetch>()
|
|
.mockRejectedValueOnce(Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }))
|
|
.mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 }));
|
|
|
|
const client = new MetabaseClient(
|
|
{ apiUrl: 'https://metabase.example.test', apiKey: 'key' }, // pragma: allowlist secret
|
|
{
|
|
...DEFAULT_METABASE_CLIENT_CONFIG,
|
|
baseDelayMs: 0,
|
|
maxRetries: 1,
|
|
},
|
|
);
|
|
|
|
await client.getDatabases();
|
|
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('wraps an exhausted ECONNRESET retry chain with method, path, attempt count, and original cause', async () => {
|
|
const sysErr = Object.assign(new Error('read ECONNRESET'), {
|
|
code: 'ECONNRESET',
|
|
errno: -104,
|
|
syscall: 'read',
|
|
});
|
|
const fetchMock = vi.fn<typeof fetch>().mockRejectedValue(sysErr);
|
|
globalThis.fetch = fetchMock;
|
|
|
|
const client = new MetabaseClient(runtime, fastRetryConfig);
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await client.getDatabases();
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(Error);
|
|
const e = caught as Error & { cause?: unknown; code?: string };
|
|
expect(e.message).toContain('Metabase request failed (3 attempts)');
|
|
expect(e.message).toContain('GET /api/database/');
|
|
expect(e.message).toContain('ECONNRESET');
|
|
expect(e.cause).toBe(sysErr);
|
|
expect(e.code).toBe('ECONNRESET');
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('classifies undici mid-TLS-handshake error as TLS-handshake failure', async () => {
|
|
const undiciTlsErr = new Error('Client network socket disconnected before secure TLS connection was established');
|
|
const fetchMock = vi.fn<typeof fetch>().mockRejectedValue(undiciTlsErr);
|
|
globalThis.fetch = fetchMock;
|
|
|
|
const client = new MetabaseClient(runtime, { ...fastRetryConfig, maxRetries: 0 });
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await client.getDatabases();
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(Error);
|
|
const e = caught as Error & { cause?: unknown };
|
|
expect(e.message).toMatch(/^Metabase request failed:/);
|
|
expect(e.message).not.toContain('attempts');
|
|
expect(e.message).toContain('TLS handshake to metabase.example.test did not complete');
|
|
expect(e.message).toContain('before secure TLS connection was established');
|
|
expect(e.cause).toBeInstanceOf(Error);
|
|
expect(((e.cause as Error & { cause?: unknown }).cause as Error)?.message).toContain(
|
|
'before secure TLS connection was established',
|
|
);
|
|
});
|
|
|
|
it('does not wrap when a non-retryable error short-circuits the loop', async () => {
|
|
const fetchMock = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(
|
|
new Response('{"message":"unauthorized"}', { status: 401, headers: { 'content-type': 'application/json' } }),
|
|
);
|
|
globalThis.fetch = fetchMock;
|
|
|
|
const client = new MetabaseClient(runtime, fastRetryConfig);
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await client.getDatabases();
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(Error);
|
|
const e = caught as Error;
|
|
expect(e.message).not.toContain('after 3 attempts');
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('MetabaseClient admin auth helpers', () => {
|
|
let originalFetch: typeof fetch;
|
|
|
|
beforeEach(() => {
|
|
originalFetch = globalThis.fetch;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('creates a session without sending an auth header', async () => {
|
|
const sessionFixture = 'session-fixture';
|
|
const adminCredentialFixture = 'admin-fixture';
|
|
const fetchMock = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response(JSON.stringify({ id: sessionFixture }), { status: 200 }));
|
|
globalThis.fetch = fetchMock;
|
|
|
|
const client = new MetabaseClient({ apiUrl: 'https://metabase.example.test', apiKey: '' }, fastRetryConfig);
|
|
|
|
await expect(client.createSession('admin@example.test', adminCredentialFixture)).resolves.toBe(sessionFixture);
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://metabase.example.test/api/session',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username: 'admin@example.test', password: adminCredentialFixture }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('uses the configured auth header for permission groups and API-key creation', async () => {
|
|
const mintedMetabaseCredential = 'mb_generated';
|
|
const sessionFixture = 'session-fixture';
|
|
const fetchMock = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(new Response(JSON.stringify([{ id: 2, name: 'Administrators' }]), { status: 200 }))
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({ unmasked_key: mintedMetabaseCredential }), { status: 200 }));
|
|
globalThis.fetch = fetchMock;
|
|
|
|
const client = new MetabaseClient(
|
|
{ apiUrl: 'https://metabase.example.test', apiKey: sessionFixture, authHeaderName: 'X-Metabase-Session' },
|
|
fastRetryConfig,
|
|
);
|
|
|
|
await expect(client.getPermissionGroups()).resolves.toEqual([{ id: 2, name: 'Administrators' }]);
|
|
await expect(client.createApiKey({ name: 'KTX CLI test', groupId: 2 })).resolves.toBe(mintedMetabaseCredential);
|
|
|
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'https://metabase.example.test/api/permissions/group',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: { 'Content-Type': 'application/json', 'X-Metabase-Session': sessionFixture },
|
|
}),
|
|
);
|
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://metabase.example.test/api/api-key',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ name: 'KTX CLI test', group_id: 2 }),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('stripOptionalClauses', () => {
|
|
it('drops optional blocks that contain Metabase template variables', () => {
|
|
const input = 'SELECT * FROM x WHERE 1=1 [[AND a = {{ a }} ]] [[AND b = {{ b }} ]]';
|
|
expect(stripOptionalClauses(input)).toBe('SELECT * FROM x WHERE 1=1 ');
|
|
});
|
|
|
|
it('preserves bracket sequences that contain no template variables', () => {
|
|
const input = "SELECT * FROM x WHERE col LIKE '[[abc]]'";
|
|
expect(stripOptionalClauses(input)).toBe(input);
|
|
});
|
|
|
|
it('leaves naked template variables intact', () => {
|
|
const input = 'SELECT * FROM x WHERE id = {{ id }}';
|
|
expect(stripOptionalClauses(input)).toBe(input);
|
|
});
|
|
});
|
|
|
|
describe('getDummyValueForWidgetType', () => {
|
|
it('returns widget-specific date and number values', () => {
|
|
expect(getDummyValueForWidgetType('date/range')).toBe('2020-01-01~2020-12-31');
|
|
expect(getDummyValueForWidgetType('date/all-options')).toBe('2020-01-01~2020-12-31');
|
|
expect(getDummyValueForWidgetType('date/single')).toBe('2020-01-01');
|
|
expect(getDummyValueForWidgetType('date/relative')).toBe('past30days');
|
|
expect(getDummyValueForWidgetType('date/month-year')).toBe('2020-01');
|
|
expect(getDummyValueForWidgetType('date/quarter-year')).toBe('Q1-2020');
|
|
expect(getDummyValueForWidgetType('number/=')).toBe('1');
|
|
expect(getDummyValueForWidgetType('number/between')).toBe('1');
|
|
});
|
|
|
|
it('falls back to an array placeholder for string, identifier, and unknown widgets', () => {
|
|
expect(getDummyValueForWidgetType('string/=')).toEqual(['placeholder']);
|
|
expect(getDummyValueForWidgetType('category')).toEqual(['placeholder']);
|
|
expect(getDummyValueForWidgetType(undefined)).toEqual(['placeholder']);
|
|
});
|
|
});
|
|
|
|
describe('MetabaseClient legacy native dataset query support', () => {
|
|
it('reads SQL and template tags from dataset_query.native', async () => {
|
|
const client = new MetabaseClient(runtime, fastRetryConfig);
|
|
const card = legacyNativeCard('SELECT * FROM orders WHERE status = {{ status }}', {
|
|
status: {
|
|
name: 'status',
|
|
type: 'text',
|
|
default: 'paid',
|
|
},
|
|
});
|
|
|
|
expect(client.getNativeSql(card)).toBe('SELECT * FROM orders WHERE status = {{ status }}');
|
|
expect(client.getTemplateTags(card)).toEqual({
|
|
status: expect.objectContaining({ name: 'status', type: 'text' }),
|
|
});
|
|
await expect(client.getCardSql(card)).resolves.toBe('SELECT * FROM orders WHERE status = {{ status }}');
|
|
});
|
|
});
|
|
|
|
describe('MetabaseClient.getResolvedSql', () => {
|
|
function makeClient(setup?: (client: MetabaseClient) => void): MetabaseClient {
|
|
const client = new MetabaseClient({ apiUrl: 'http://test', apiKey: 'k' });
|
|
setup?.(client);
|
|
return client;
|
|
}
|
|
|
|
it('strips optional clauses locally and skips /api/dataset/native when no naked variables remain', async () => {
|
|
const requestSpy = vi.fn();
|
|
const client = makeClient((client) => {
|
|
Reflect.set(client, 'requestWithCustomRetry', requestSpy);
|
|
});
|
|
const card = nativeCard('SELECT * FROM x WHERE 1=1 [[AND end > {{ auction_end }} ]]', {
|
|
auction_end: {
|
|
id: 'tag-1',
|
|
name: 'auction_end',
|
|
type: 'dimension',
|
|
'widget-type': 'date/all-options',
|
|
'display-name': 'Auction End',
|
|
},
|
|
});
|
|
|
|
const result = await client.getResolvedSql(card);
|
|
|
|
expect(requestSpy).not.toHaveBeenCalled();
|
|
expect(result?.resolutionStatus).toBe('resolved');
|
|
expect(result?.resolvedSql).toBe('SELECT * FROM x WHERE 1=1 ');
|
|
expect(result?.templateTags[0]).toMatchObject({ name: 'auction_end', type: 'dimension' });
|
|
});
|
|
|
|
it('inlines saved-question references locally and skips /api/dataset/native when no other variables remain', async () => {
|
|
const requestSpy = vi.fn();
|
|
const getCardSpy = vi.fn().mockResolvedValue({
|
|
id: 5996,
|
|
name: 'Base card',
|
|
type: 'model',
|
|
query_type: 'native',
|
|
database_id: 6,
|
|
dataset_query: {
|
|
type: 'native',
|
|
database: 6,
|
|
stages: [{ 'lib/type': 'mbql.stage/native', native: 'SELECT a, b FROM base' }],
|
|
},
|
|
});
|
|
const client = makeClient((client) => {
|
|
Reflect.set(client, 'requestWithCustomRetry', requestSpy);
|
|
Reflect.set(client, 'getCard', getCardSpy);
|
|
});
|
|
const card = nativeCard('SELECT * FROM {{#5996-base}} t [[WHERE end > {{ end }}]]', {
|
|
'#5996-base': {
|
|
id: 't1',
|
|
name: '#5996-base',
|
|
type: 'card',
|
|
'card-id': 5996,
|
|
},
|
|
end: {
|
|
id: 't2',
|
|
name: 'end',
|
|
type: 'dimension',
|
|
'widget-type': 'date/range',
|
|
},
|
|
});
|
|
|
|
const result = await client.getResolvedSql(card);
|
|
|
|
expect(requestSpy).not.toHaveBeenCalled();
|
|
expect(getCardSpy).toHaveBeenCalledWith(5996);
|
|
expect(result?.resolutionStatus).toBe('resolved');
|
|
expect(result?.resolvedSql).toBe('SELECT * FROM (SELECT a, b FROM base) t ');
|
|
});
|
|
|
|
it('inlines native-query snippets before checking for remaining variables', async () => {
|
|
const requestSpy = vi.fn().mockResolvedValue([
|
|
{
|
|
id: 1,
|
|
name: 'account_join',
|
|
content: 'LEFT JOIN accounts a ON a.account_id = mart.account_id',
|
|
},
|
|
]);
|
|
const requestWithCustomRetrySpy = vi.fn();
|
|
const client = makeClient((client) => {
|
|
Reflect.set(client, 'request', requestSpy);
|
|
Reflect.set(client, 'requestWithCustomRetry', requestWithCustomRetrySpy);
|
|
});
|
|
const card = nativeCard('SELECT a.account_name FROM mart {{snippet: account_join}}', {
|
|
'snippet: account_join': {
|
|
id: 'snippet-tag',
|
|
name: 'snippet: account_join',
|
|
type: 'snippet',
|
|
'snippet-name': 'account_join',
|
|
'snippet-id': 1,
|
|
},
|
|
});
|
|
|
|
const result = await client.getResolvedSql(card);
|
|
|
|
expect(requestSpy).toHaveBeenCalledWith('GET', '/api/native-query-snippet');
|
|
expect(requestWithCustomRetrySpy).not.toHaveBeenCalled();
|
|
expect(result?.resolutionStatus).toBe('resolved');
|
|
expect(result?.resolvedSql).toBe(
|
|
'SELECT a.account_name FROM mart LEFT JOIN accounts a ON a.account_id = mart.account_id',
|
|
);
|
|
expect(result?.resolvedSql).not.toContain('{{snippet:');
|
|
});
|
|
|
|
it('uses /api/dataset/native for naked variables and prepends a warning comment', async () => {
|
|
const requestSpy = vi.fn().mockResolvedValue({ query: "SELECT * WHERE id = 'placeholder' AND n = 1" });
|
|
const client = makeClient((client) => {
|
|
Reflect.set(client, 'requestWithCustomRetry', requestSpy);
|
|
});
|
|
const card = nativeCard('SELECT * WHERE id = {{ id }} AND n = {{ n }}', {
|
|
id: { id: 't1', name: 'id', type: 'text' },
|
|
n: { id: 't2', name: 'n', type: 'number' },
|
|
});
|
|
|
|
const result = await client.getResolvedSql(card);
|
|
|
|
expect(requestSpy).toHaveBeenCalledTimes(1);
|
|
expect(result?.resolutionStatus).toBe('resolved');
|
|
const sql = result?.resolvedSql ?? '';
|
|
expect(sql.startsWith('--')).toBe(true);
|
|
expect(sql).toMatch(/KTX_PLACEHOLDER_WARNING/);
|
|
expect(sql).toMatch(/\bid\b/);
|
|
expect(sql).toMatch(/\bn\b/);
|
|
});
|
|
|
|
it('falls back to raw native SQL with truthful template tags when /api/dataset/native errors', async () => {
|
|
const requestSpy = vi.fn().mockRejectedValue(new Error('Metabase 500'));
|
|
const client = makeClient((client) => {
|
|
Reflect.set(client, 'requestWithCustomRetry', requestSpy);
|
|
});
|
|
const card = nativeCard('SELECT * FROM x WHERE end > {{ auction_end }}', {
|
|
auction_end: {
|
|
id: 'tag-id',
|
|
name: 'auction_end',
|
|
type: 'dimension',
|
|
'widget-type': 'date/range',
|
|
'display-name': 'Auction End',
|
|
},
|
|
});
|
|
|
|
const result = await client.getResolvedSql(card);
|
|
|
|
expect(result?.resolutionStatus).toBe('fallback');
|
|
expect(result?.resolvedSql).toContain('{{ auction_end }}');
|
|
expect(result?.templateTags).toHaveLength(1);
|
|
expect(result?.templateTags[0]).toMatchObject({
|
|
name: 'auction_end',
|
|
type: 'dimension',
|
|
displayName: 'Auction End',
|
|
});
|
|
});
|
|
});
|