diff --git a/packages/cli/src/connectors/bigquery/connector.ts b/packages/cli/src/connectors/bigquery/connector.ts index 2b72e47b..edebe284 100644 --- a/packages/cli/src/connectors/bigquery/connector.ts +++ b/packages/cli/src/connectors/bigquery/connector.ts @@ -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: diff --git a/packages/cli/src/connectors/clickhouse/connector.ts b/packages/cli/src/connectors/clickhouse/connector.ts index 2c1da22e..74ef7a77 100644 --- a/packages/cli/src/connectors/clickhouse/connector.ts +++ b/packages/cli/src/connectors/clickhouse/connector.ts @@ -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), diff --git a/packages/cli/src/connectors/mysql/connector.ts b/packages/cli/src/connectors/mysql/connector.ts index efb24d30..29dacc26 100644 --- a/packages/cli/src/connectors/mysql/connector.ts +++ b/packages/cli/src/connectors/mysql/connector.ts @@ -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), diff --git a/packages/cli/src/connectors/postgres/connector.ts b/packages/cli/src/connectors/postgres/connector.ts index f37c8279..f206fa6a 100644 --- a/packages/cli/src/connectors/postgres/connector.ts +++ b/packages/cli/src/connectors/postgres/connector.ts @@ -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), diff --git a/packages/cli/src/connectors/snowflake/connector.ts b/packages/cli/src/connectors/snowflake/connector.ts index 1a060a40..86d7ebe7 100644 --- a/packages/cli/src/connectors/snowflake/connector.ts +++ b/packages/cli/src/connectors/snowflake/connector.ts @@ -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), diff --git a/packages/cli/src/connectors/sqlite/connector.ts b/packages/cli/src/connectors/sqlite/connector.ts index 13b02a0f..e996bc25 100644 --- a/packages/cli/src/connectors/sqlite/connector.ts +++ b/packages/cli/src/connectors/sqlite/connector.ts @@ -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), diff --git a/packages/cli/src/connectors/sqlserver/connector.ts b/packages/cli/src/connectors/sqlserver/connector.ts index 53dc72ca..0115781d 100644 --- a/packages/cli/src/connectors/sqlserver/connector.ts +++ b/packages/cli/src/connectors/sqlserver/connector.ts @@ -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), diff --git a/packages/cli/src/context/scan/enabled-tables.ts b/packages/cli/src/context/scan/enabled-tables.ts index d4f1009c..96c94afd 100644 --- a/packages/cli/src/context/scan/enabled-tables.ts +++ b/packages/cli/src/context/scan/enabled-tables.ts @@ -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('.'); diff --git a/packages/cli/src/context/scan/types.ts b/packages/cli/src/context/scan/types.ts index ed794775..1d9e6d6a 100644 --- a/packages/cli/src/context/scan/types.ts +++ b/packages/cli/src/context/scan/types.ts @@ -297,6 +297,7 @@ export interface KtxQueryResult { } export interface KtxTableListEntry { + catalog: string | null; schema: string; name: string; kind: 'table' | 'view'; diff --git a/packages/cli/src/database-tree-picker.ts b/packages/cli/src/database-tree-picker.ts index a0b605ad..6698a0d2 100644 --- a/packages/cli/src/database-tree-picker.ts +++ b/packages/cli/src/database-tree-picker.ts @@ -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(); 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); diff --git a/packages/cli/test/connectors/bigquery/connector.test.ts b/packages/cli/test/connectors/bigquery/connector.test.ts index c1bbf9b4..11ad69d8 100644 --- a/packages/cli/test/connectors/bigquery/connector.test.ts +++ b/packages/cli/test/connectors/bigquery/connector.test.ts @@ -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); diff --git a/packages/cli/test/connectors/clickhouse/connector.test.ts b/packages/cli/test/connectors/clickhouse/connector.test.ts index f7005da0..aba3143f 100644 --- a/packages/cli/test/connectors/clickhouse/connector.test.ts +++ b/packages/cli/test/connectors/clickhouse/connector.test.ts @@ -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( diff --git a/packages/cli/test/connectors/mysql/connector.test.ts b/packages/cli/test/connectors/mysql/connector.test.ts index a38b522b..c8334164 100644 --- a/packages/cli/test/connectors/mysql/connector.test.ts +++ b/packages/cli/test/connectors/mysql/connector.test.ts @@ -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' }, diff --git a/packages/cli/test/connectors/postgres/connector.test.ts b/packages/cli/test/connectors/postgres/connector.test.ts index 2922a11a..e43e05a4 100644 --- a/packages/cli/test/connectors/postgres/connector.test.ts +++ b/packages/cli/test/connectors/postgres/connector.test.ts @@ -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 }); diff --git a/packages/cli/test/connectors/snowflake/connector.test.ts b/packages/cli/test/connectors/snowflake/connector.test.ts index 3074431f..1b00061b 100644 --- a/packages/cli/test/connectors/snowflake/connector.test.ts +++ b/packages/cli/test/connectors/snowflake/connector.test.ts @@ -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); diff --git a/packages/cli/test/connectors/sqlite/connector.test.ts b/packages/cli/test/connectors/sqlite/connector.test.ts index 4cc8cb80..27b00c57 100644 --- a/packages/cli/test/connectors/sqlite/connector.test.ts +++ b/packages/cli/test/connectors/sqlite/connector.test.ts @@ -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' }, ]); }); diff --git a/packages/cli/test/connectors/sqlserver/connector.test.ts b/packages/cli/test/connectors/sqlserver/connector.test.ts index a5ccdd46..b7318ab5 100644 --- a/packages/cli/test/connectors/sqlserver/connector.test.ts +++ b/packages/cli/test/connectors/sqlserver/connector.test.ts @@ -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( diff --git a/packages/cli/test/database-tree-picker.test.ts b/packages/cli/test/database-tree-picker.test.ts index 0c2f64eb..182f0235 100644 --- a/packages/cli/test/database-tree-picker.test.ts +++ b/packages/cli/test/database-tree-picker.test.ts @@ -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 { @@ -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( diff --git a/packages/cli/test/setup-databases.test.ts b/packages/cli/test/setup-databases.test.ts index d0f704d2..15d27e3c 100644 --- a/packages/cli/test/setup-databases.test.ts +++ b/packages/cli/test/setup-databases.test.ts @@ -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>; + listTablesForSchemas: (schemas: string[]) => Promise>; }; 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'),