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).
This commit is contained in:
Andrey Avtomonov 2026-05-25 17:23:05 +02:00
parent c526601d52
commit cbe6a5f4b3
19 changed files with 193 additions and 51 deletions

View file

@ -454,6 +454,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
params,
);
return rows.map((row) => ({
catalog: this.resolved.projectId,
schema: row.table_schema,
name: row.table_name,
kind:

View file

@ -531,6 +531,7 @@ export class KtxClickHouseScanConnector implements KtxScanConnector {
{ schemas: filterSchemas },
);
return rows.map((row) => ({
catalog: null,
schema: row.database,
name: row.name,
kind: row.engine === 'View' || row.engine === 'MaterializedView' ? ('view' as const) : ('table' as const),

View file

@ -644,6 +644,7 @@ export class KtxMysqlScanConnector implements KtxScanConnector {
filterSchemas,
);
return rows.map((row) => ({
catalog: null,
schema: row.TABLE_SCHEMA,
name: row.TABLE_NAME,
kind: row.TABLE_TYPE === 'VIEW' ? ('view' as const) : ('table' as const),

View file

@ -607,6 +607,7 @@ export class KtxPostgresScanConnector implements KtxScanConnector {
[filterSchemas],
);
return rows.map((row) => ({
catalog: null,
schema: row.schema_name,
name: row.table_name,
kind: row.table_kind === 'v' ? ('view' as const) : ('table' as const),

View file

@ -438,6 +438,7 @@ class SnowflakeSdkDriver implements KtxSnowflakeDriver {
[this.resolved.database, ...(schemas ?? [])],
);
return result.rows.map((row) => ({
catalog: this.resolved.database,
schema: String(row[0]),
name: String(row[1]),
kind: String(row[2]) === 'VIEW' ? ('view' as const) : ('table' as const),
@ -704,6 +705,7 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector {
[this.resolved.database, ...(schemas ?? [])],
);
return result.rows.map((row) => ({
catalog: this.resolved.database,
schema: String(row[0]),
name: String(row[1]),
kind: String(row[2]) === 'VIEW' ? ('view' as const) : ('table' as const),

View file

@ -227,6 +227,7 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
.all() as SqliteMasterRow[];
return rows.map((row) => ({
catalog: null,
schema: '',
name: row.name,
kind: row.type === 'view' ? ('view' as const) : ('table' as const),

View file

@ -532,6 +532,7 @@ export class KtxSqlServerScanConnector implements KtxScanConnector {
params,
);
return rows.map((row) => ({
catalog: this.poolConfig.database,
schema: row.schema_name,
name: row.table_name,
kind: row.table_type === 'VIEW' ? ('view' as const) : ('table' as const),

View file

@ -27,12 +27,13 @@ export function resolveEnabledTables(
function parseEnabledTableEntry(value: unknown): KtxTableRef | null {
if (typeof value === 'string') {
return parseDottedEntry(value);
return parseDottedTableEntry(value);
}
return null;
}
function parseDottedEntry(value: string): KtxTableRef | null {
/** @internal */
export function parseDottedTableEntry(value: string): KtxTableRef | null {
const trimmed = value.trim();
if (trimmed.length === 0) return null;
const parts = trimmed.split('.');

View file

@ -297,6 +297,7 @@ export interface KtxQueryResult {
}
export interface KtxTableListEntry {
catalog: string | null;
schema: string;
name: string;
kind: 'table' | 'view';

View file

@ -1,3 +1,4 @@
import { parseDottedTableEntry } from './context/scan/enabled-tables.js';
import type { KtxTableListEntry } from './context/scan/types.js';
import type { KtxCliIo } from './cli-runtime.js';
import { profileMark } from './startup-profile.js';
@ -73,7 +74,9 @@ export interface PickDatabaseScopeArgs {
}
function qualifiedTableId(entry: KtxTableListEntry): string {
return `${entry.schema}.${entry.name}`;
return entry.catalog !== null
? `${entry.catalog}.${entry.schema}.${entry.name}`
: `${entry.schema}.${entry.name}`;
}
function tableTitle(entry: KtxTableListEntry): string {
@ -177,7 +180,8 @@ function schemasFromEnabledTables(enabledTables: readonly string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const qualified of enabledTables) {
const schema = qualified.split('.')[0] ?? '';
const ref = parseDottedTableEntry(qualified);
const schema = ref?.db ?? '';
if (schema.length === 0 || seen.has(schema)) continue;
seen.add(schema);
result.push(schema);

View file

@ -377,9 +377,9 @@ describe('KtxBigQueryScanConnector', () => {
});
await expect(connector.listTables(['analytics', 'mart'])).resolves.toEqual([
{ schema: 'analytics', name: 'orders', kind: 'table' },
{ schema: 'analytics', name: 'order_clone', kind: 'table' },
{ schema: 'mart', name: 'orders_mv', kind: 'view' },
{ catalog: 'project-1', schema: 'analytics', name: 'orders', kind: 'table' },
{ catalog: 'project-1', schema: 'analytics', name: 'order_clone', kind: 'table' },
{ catalog: 'project-1', schema: 'mart', name: 'orders_mv', kind: 'view' },
]);
expect(createQueryJob).toHaveBeenCalledTimes(1);

View file

@ -372,8 +372,8 @@ describe('KtxClickHouseScanConnector', () => {
await expect(connector.getTableRowCount('events')).resolves.toBe(2);
await expect(connector.listSchemas()).resolves.toEqual(['analytics', 'warehouse']);
await expect(connector.listTables(['analytics'])).resolves.toEqual([
{ schema: 'analytics', name: 'event_summary', kind: 'view' },
{ schema: 'analytics', name: 'events', kind: 'table' },
{ catalog: null, schema: 'analytics', name: 'event_summary', kind: 'view' },
{ catalog: null, schema: 'analytics', name: 'events', kind: 'table' },
]);
await expect(
connector.columnStats(

View file

@ -511,9 +511,9 @@ describe('KtxMysqlScanConnector', () => {
await expect(connector.getTableRowCount('orders')).resolves.toBe(2);
await expect(connector.listSchemas()).resolves.toEqual(['analytics', 'warehouse']);
await expect(connector.listTables(['analytics'])).resolves.toEqual([
{ schema: 'analytics', name: 'customers', kind: 'table' },
{ schema: 'analytics', name: 'orders', kind: 'table' },
{ schema: 'analytics', name: 'order_summary', kind: 'view' },
{ catalog: null, schema: 'analytics', name: 'customers', kind: 'table' },
{ catalog: null, schema: 'analytics', name: 'orders', kind: 'table' },
{ catalog: null, schema: 'analytics', name: 'order_summary', kind: 'view' },
]);
await expect(connector.columnStats(
{ connectionId: 'warehouse', table: { catalog: null, db: 'analytics', name: 'orders' }, column: 'status' },

View file

@ -390,9 +390,9 @@ describe('KtxPostgresScanConnector', () => {
await expect(connector.getTableRowCount({ db: 'public', name: 'orders' })).resolves.toBe(3);
await expect(connector.listSchemas()).resolves.toEqual(['public']);
await expect(connector.listTables(['public'])).resolves.toEqual([
{ schema: 'public', name: 'customers', kind: 'table' },
{ schema: 'public', name: 'orders', kind: 'table' },
{ schema: 'public', name: 'recent_orders', kind: 'view' },
{ catalog: null, schema: 'public', name: 'customers', kind: 'table' },
{ catalog: null, schema: 'public', name: 'orders', kind: 'table' },
{ catalog: null, schema: 'public', name: 'recent_orders', kind: 'view' },
]);
await expect(connector.testConnection()).resolves.toEqual({ success: true });

View file

@ -64,8 +64,8 @@ function fakeDriverFactory(): KtxSnowflakeDriverFactory {
]),
listSchemas: vi.fn(async () => ['PUBLIC', 'MART']),
listTables: vi.fn(async () => [
{ schema: 'PUBLIC', name: 'ORDERS', kind: 'table' as const },
{ schema: 'PUBLIC', name: 'ORDER_SUMMARY', kind: 'view' as const },
{ catalog: 'ANALYTICS', schema: 'PUBLIC', name: 'ORDERS', kind: 'table' as const },
{ catalog: 'ANALYTICS', schema: 'PUBLIC', name: 'ORDER_SUMMARY', kind: 'view' as const },
]),
cleanup: vi.fn(async () => undefined),
};
@ -572,8 +572,8 @@ describe('KtxSnowflakeScanConnector', () => {
});
await expect(connector.listTables(['MART', 'PUBLIC'])).resolves.toEqual([
{ schema: 'MART', name: 'ORDERS', kind: 'table' },
{ schema: 'PUBLIC', name: 'ORDER_SUMMARY', kind: 'view' },
{ catalog: 'ANALYTICS', schema: 'MART', name: 'ORDERS', kind: 'table' },
{ catalog: 'ANALYTICS', schema: 'PUBLIC', name: 'ORDER_SUMMARY', kind: 'view' },
]);
expect(queries).toHaveLength(1);

View file

@ -158,9 +158,9 @@ describe('KtxSqliteScanConnector', () => {
await expect(connector.listSchemas()).resolves.toEqual([]);
await expect(connector.listTables(['ignored'])).resolves.toEqual([
{ schema: '', name: 'customers', kind: 'table' },
{ schema: '', name: 'orders', kind: 'table' },
{ schema: '', name: 'recent_orders', kind: 'view' },
{ catalog: null, schema: '', name: 'customers', kind: 'table' },
{ catalog: null, schema: '', name: 'orders', kind: 'table' },
{ catalog: null, schema: '', name: 'recent_orders', kind: 'view' },
]);
});

View file

@ -390,9 +390,9 @@ describe('KtxSqlServerScanConnector', () => {
await expect(connector.getTableRowCount('orders')).resolves.toBe(2);
await expect(connector.listSchemas()).resolves.toEqual(['dbo', 'sales']);
await expect(connector.listTables(['dbo'])).resolves.toEqual([
{ schema: 'dbo', name: 'customers', kind: 'table' },
{ schema: 'dbo', name: 'order_summary', kind: 'view' },
{ schema: 'dbo', name: 'orders', kind: 'table' },
{ catalog: 'analytics', schema: 'dbo', name: 'customers', kind: 'table' },
{ catalog: 'analytics', schema: 'dbo', name: 'order_summary', kind: 'view' },
{ catalog: 'analytics', schema: 'dbo', name: 'orders', kind: 'table' },
]);
await expect(
connector.columnStats(

View file

@ -52,10 +52,10 @@ function captureRenderer(): {
}
const discovered = [
{ schema: 'analytics', name: 'customers', kind: 'table' as const },
{ schema: 'analytics', name: 'orders', kind: 'table' as const },
{ schema: 'public', name: 'events', kind: 'view' as const },
{ schema: 'public', name: 'sessions', kind: 'table' as const },
{ catalog: null, schema: 'analytics', name: 'customers', kind: 'table' as const },
{ catalog: null, schema: 'analytics', name: 'orders', kind: 'table' as const },
{ catalog: null, schema: 'public', name: 'events', kind: 'view' as const },
{ catalog: null, schema: 'public', name: 'sessions', kind: 'table' as const },
];
function promptAdapter(overrides: Partial<DatabaseScopePromptAdapter> = {}): DatabaseScopePromptAdapter {
@ -88,7 +88,7 @@ describe('pickDatabaseScope', () => {
select: vi.fn(async () => 'save'),
});
const listTablesForSchemas = vi.fn(async () => [
{ schema: 'analytics', name: 'orders', kind: 'table' as const },
{ catalog: null, schema: 'analytics', name: 'orders', kind: 'table' as const },
]);
const result = await pickDatabaseScope(
@ -114,6 +114,58 @@ describe('pickDatabaseScope', () => {
});
});
it('emits fully-qualified catalog.schema.name ids for catalog-bearing drivers and round-trips existing selection', async () => {
const promptsSave = promptAdapter({
autocompleteMultiselect: vi.fn(async () => ['analytics']),
select: vi.fn(async () => 'save'),
});
const listTablesForSchemas = vi.fn(async () => [
{ catalog: 'project-1', schema: 'analytics', name: 'orders', kind: 'table' as const },
{ catalog: 'project-1', schema: 'analytics', name: 'customers', kind: 'table' as const },
]);
const saveResult = await pickDatabaseScope(
baseArgs({
schemas: ['analytics'],
schemaSuggestion: { excluded: new Set(), suggested: new Set(['analytics']) },
listTablesForSchemas,
prompts: promptsSave,
}),
makeIo().io,
captureRenderer().renderer,
);
expect(saveResult).toEqual({
kind: 'selected',
activeSchemas: ['analytics'],
enabledTables: ['project-1.analytics.orders', 'project-1.analytics.customers'],
});
const { renderer, capture, setResult } = captureRenderer();
setResult({
kind: 'save',
selectedIds: ['project-1.analytics.orders'],
});
const refineResult = await pickDatabaseScope(
baseArgs({
schemas: ['analytics'],
schemaSuggestion: { excluded: new Set(), suggested: new Set(['analytics']) },
existing: { enabledTables: ['project-1.analytics.orders'] },
listTablesForSchemas,
prompts: promptAdapter({
autocompleteMultiselect: vi.fn(async () => ['analytics']),
select: vi.fn(async () => 'refine'),
}),
}),
makeIo().io,
renderer,
);
expect(refineResult).toEqual({
kind: 'selected',
activeSchemas: ['analytics'],
enabledTables: ['project-1.analytics.orders'],
});
expect([...(capture.state?.checked ?? [])]).toContain('project-1.analytics.orders');
});
it('routes partial existing allowlists through Stage 2 so save preserves table selections', async () => {
const { renderer, setResult } = captureRenderer();
setResult({ kind: 'save', selectedIds: ['analytics.customers'] });
@ -122,8 +174,8 @@ describe('pickDatabaseScope', () => {
select: vi.fn(async () => 'save'),
});
const listTablesForSchemas = vi.fn(async () => [
{ schema: 'analytics', name: 'customers', kind: 'table' as const },
{ schema: 'analytics', name: 'orders', kind: 'table' as const },
{ catalog: null, schema: 'analytics', name: 'customers', kind: 'table' as const },
{ catalog: null, schema: 'analytics', name: 'orders', kind: 'table' as const },
]);
const result = await pickDatabaseScope(

View file

@ -16,6 +16,7 @@ import type {
DatabaseScopePickResult,
PickDatabaseScopeArgs,
} from '../src/database-tree-picker.js';
import type { KtxSetupPromptOption } from '../src/setup-prompts.js';
function makeIo() {
let stdout = '';
@ -1039,7 +1040,7 @@ describe('setup databases step', () => {
const testConnection = vi.fn(async () => 0);
const scanConnection = vi.fn(async () => 0);
const listSchemas = vi.fn(async () => ['analytics', 'public']);
const listTables = vi.fn(async () => [{ schema: 'analytics', name: 'customers', kind: 'table' as const }]);
const listTables = vi.fn(async () => [{ catalog: null, schema: 'analytics', name: 'customers', kind: 'table' as const }]);
const pickers = makePickerStubs({
scopes: [{ schemas: ['analytics'], tables: ['analytics.customers'] }],
});
@ -1110,9 +1111,9 @@ describe('setup databases step', () => {
});
const listSchemas = vi.fn(async () => ['orbit_analytics', 'orbit_raw', 'public']);
const listTables = vi.fn(async () => [
{ schema: 'public', name: 'customers', kind: 'table' as const },
{ schema: 'public', name: 'orders', kind: 'table' as const },
{ schema: 'public', name: 'products', kind: 'table' as const },
{ catalog: null, schema: 'public', name: 'customers', kind: 'table' as const },
{ catalog: null, schema: 'public', name: 'orders', kind: 'table' as const },
{ catalog: null, schema: 'public', name: 'products', kind: 'table' as const },
]);
const pickers = makePickerStubs({
scopes: [{ schemas: ['public'], tables: ['public.customers', 'public.orders'] }],
@ -1184,8 +1185,8 @@ describe('setup databases step', () => {
const scanConnection = vi.fn(async () => 0);
const listSchemas = vi.fn(async () => ['analytics', 'public']);
const listTables = vi.fn(async () => [
{ schema: 'analytics', name: 'customers', kind: 'table' as const },
{ schema: 'public', name: 'orders', kind: 'table' as const },
{ catalog: null, schema: 'analytics', name: 'customers', kind: 'table' as const },
{ catalog: null, schema: 'public', name: 'orders', kind: 'table' as const },
]);
const pickers = makePickerStubs({ scopes: ['back'] });
@ -1250,8 +1251,8 @@ describe('setup databases step', () => {
const scanConnection = vi.fn(async () => 0);
const listSchemas = vi.fn(async () => ['public']);
const listTables = vi.fn(async () => [
{ schema: 'public', name: 'customers', kind: 'table' as const },
{ schema: 'public', name: 'orders', kind: 'table' as const },
{ catalog: null, schema: 'public', name: 'customers', kind: 'table' as const },
{ catalog: null, schema: 'public', name: 'orders', kind: 'table' as const },
]);
const pickers = makePickerStubs({ scopes: [{ schemas: ['public'], tables: 'back' }] });
@ -1311,8 +1312,8 @@ describe('setup databases step', () => {
return 'back';
});
const listTables = vi.fn(async () => [
{ schema: 'public', name: 'customers', kind: 'table' as const },
{ schema: 'public', name: 'orders', kind: 'table' as const },
{ catalog: null, schema: 'public', name: 'customers', kind: 'table' as const },
{ catalog: null, schema: 'public', name: 'orders', kind: 'table' as const },
]);
const pickers = makePickerStubs({ scopes: ['enable-all'] });
@ -1610,7 +1611,7 @@ describe('setup databases step', () => {
});
const listSchemas = vi.fn(async () => ['analytics', 'mart']);
const listTables = vi.fn(async (_projectDir: string, _connectionId: string, schemas?: string[]) =>
(schemas ?? []).map((schema) => ({ schema, name: 'orders', kind: 'table' as const })),
(schemas ?? []).map((schema) => ({ catalog: null, schema, name: 'orders', kind: 'table' as const })),
);
const pickDatabaseScope = vi.fn(async (args: PickDatabaseScopeArgs) => {
const scopedArgs = args as PickDatabaseScopeArgs & {
@ -1667,7 +1668,7 @@ describe('setup databases step', () => {
textValues: ['bigquery-warehouse', '/tmp/service-account.json', 'US'],
});
const listSchemas = vi.fn(async () => ['analytics']);
const listTables = vi.fn(async () => [{ schema: 'analytics', name: 'orders', kind: 'table' as const }]);
const listTables = vi.fn(async () => [{ catalog: 'project-1', schema: 'analytics', name: 'orders', kind: 'table' as const }]);
const pickDatabaseScope = vi.fn(async () => ({
kind: 'selected' as const,
activeSchemas: ['analytics'],
@ -1700,9 +1701,9 @@ describe('setup databases step', () => {
});
const listSchemas = vi.fn(async () => ['orbit_analytics', 'orbit_raw', 'public']);
const listTables = vi.fn(async () => [
{ schema: 'orbit_analytics', name: 'events', kind: 'table' as const },
{ schema: 'orbit_raw', name: 'inputs', kind: 'table' as const },
{ schema: 'public', name: 'misc', kind: 'table' as const },
{ catalog: null, schema: 'orbit_analytics', name: 'events', kind: 'table' as const },
{ catalog: null, schema: 'orbit_raw', name: 'inputs', kind: 'table' as const },
{ catalog: null, schema: 'public', name: 'misc', kind: 'table' as const },
]);
const pickers = makePickerStubs({
scopes: [
@ -1761,7 +1762,7 @@ describe('setup databases step', () => {
throw new Error('permission denied to list schemas');
});
const listTables = vi.fn(async (_projectDir: string, _connectionId: string, schemas?: string[]) =>
(schemas ?? []).map((schema) => ({ schema, name: 'events', kind: 'table' as const })),
(schemas ?? []).map((schema) => ({ catalog: null, schema, name: 'events', kind: 'table' as const })),
);
const pickers = makePickerStubs({
scopes: [
@ -1808,18 +1809,18 @@ describe('setup databases step', () => {
it('passes schemas and a lazy table callback to the scope picker instead of eager table discovery', async () => {
const listSchemas = vi.fn(async () => ['analytics', 'raw']);
const listTables = vi.fn(async (_projectDir: string, _connectionId: string, schemas?: string[]) =>
(schemas ?? []).map((schema) => ({ schema, name: 'orders', kind: 'table' as const })),
(schemas ?? []).map((schema) => ({ catalog: null, schema, name: 'orders', kind: 'table' as const })),
);
const pickDatabaseScope = vi.fn(async (args: PickDatabaseScopeArgs) => {
const lazyArgs = args as PickDatabaseScopeArgs & {
schemas: string[];
listTablesForSchemas: (schemas: string[]) => Promise<Array<{ schema: string; name: string; kind: 'table' }>>;
listTablesForSchemas: (schemas: string[]) => Promise<Array<{ catalog: string | null; schema: string; name: string; kind: 'table' }>>;
};
expect(lazyArgs.schemas).toEqual(['analytics', 'raw']);
expect(args).not.toHaveProperty('discovered');
expect(listTables).not.toHaveBeenCalled();
const tables = await lazyArgs.listTablesForSchemas(['analytics']);
expect(tables).toEqual([{ schema: 'analytics', name: 'orders', kind: 'table' }]);
expect(tables).toEqual([{ catalog: null, schema: 'analytics', name: 'orders', kind: 'table' }]);
return { kind: 'selected' as const, activeSchemas: ['analytics'], enabledTables: ['analytics.orders'] };
});
@ -2557,6 +2558,81 @@ describe('setup databases step', () => {
expect(io.stdout()).toContain('Setup written; query history will be skipped until fixed.');
});
it('lets interactive BigQuery setup disable unavailable query history and retry after scan failure', async () => {
const io = makeIo();
const failurePromptOptions: KtxSetupPromptOption[][] = [];
let failurePromptCount = 0;
const prompts = makePromptAdapter({
textValues: ['/tmp/service-account.json', 'US'],
});
vi.mocked(prompts.select).mockImplementation(async ({ message, options }) => {
if (message.startsWith('Enable query-history ingest')) return 'yes';
if (message.includes('How much database context should KTX build?')) return 'fast';
if (message.startsWith('Database setup failed for analytics')) {
failurePromptCount += 1;
failurePromptOptions.push(options);
if (failurePromptCount === 1) return 'disable-query-history';
throw new Error('setup did not disable query history before retrying');
}
throw new Error(`unexpected select prompt: ${message}`);
});
const runner = {
...fakeHistoricSqlRunner('bigquery', 'INFORMATION_SCHEMA.JOBS_BY_PROJECT'),
fixAdvice: () => ({
failHeadline: 'BigQuery principal cannot read INFORMATION_SCHEMA.JOBS_BY_PROJECT',
remediation:
'Grant roles/bigquery.resourceViewer on the BigQuery project, or grant a custom role containing bigquery.jobs.listAll.',
}),
};
const historicSqlReadinessProbe = vi.fn(async () => ({
ok: false as const,
dialect: 'bigquery' as const,
runner,
error: new Error('access denied'),
}));
let scanAttempts = 0;
const scanConnection = vi.fn(async () => {
scanAttempts += 1;
return scanAttempts === 1 ? 1 : 0;
});
const result = await runKtxSetupDatabasesStep(
{
projectDir: tempDir,
inputMode: 'auto',
databaseDrivers: ['bigquery'],
databaseConnectionId: 'analytics',
databaseSchemas: [],
skipDatabases: false,
},
io.io,
{
prompts,
testConnection: vi.fn(async () => 0),
scanConnection,
historicSqlReadinessProbe,
listSchemas: vi.fn(async () => ['analytics']),
listTables: vi.fn(async () => [{ catalog: null, schema: 'analytics', name: 'orders', kind: 'table' as const }]),
},
);
expect(result.status).toBe('ready');
expect(scanConnection).toHaveBeenCalledTimes(2);
expect(historicSqlReadinessProbe).toHaveBeenCalledTimes(1);
expect(failurePromptOptions[0]).toContainEqual({
value: 'disable-query-history',
label: 'Disable query history and retry',
});
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
expect(config.connections.analytics).toMatchObject({
context: {
queryHistory: {
enabled: false,
},
},
});
});
it('enables query history on an existing Postgres connection', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),