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

@ -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(