feat(cli): redesign database scope picker for searchable schema-first setup (#203)

* feat: add searchable setup prompt pickers

* fix: make snowflake scope discovery single query

* fix: make bigquery table discovery schema scoped

* fix: honor mysql and clickhouse database scope

* feat: wire schema scope discovery for all relational setup drivers

* feat: add schema-first database scope picker

* test: update setup prompt stubs for type-check

* docs: document database scope picker fields

* Fix database setup edit preservation

---------

Co-authored-by: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com>
This commit is contained in:
Andrey Avtomonov 2026-05-22 14:22:11 +02:00 committed by GitHub
parent fd2ba62d92
commit c87d14a554
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1530 additions and 331 deletions

View file

@ -69,6 +69,14 @@ export interface KtxSetupDatabasesPromptAdapter {
initialValues?: string[];
}): Promise<string[]>;
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
autocompleteMultiselect(options: {
message: string;
options: KtxSetupPromptOption[];
placeholder?: string;
required?: boolean;
maxItems?: number;
initialValues?: string[];
}): Promise<string[]>;
text(options: { message: string; placeholder?: string; initialValue?: string }): Promise<string | undefined>;
password(options: { message: string }): Promise<string | undefined>;
cancel(message: string): void;
@ -134,8 +142,26 @@ interface ScopeDiscoverySpec {
nounPlural: string;
promptLabel: string;
configArrayField: string;
configSingleField: string;
defaultSelection: (values: string[]) => string[];
configSingleField?: string;
suggest: ScopeSuggest;
}
interface ScopeSuggestion {
excluded: Set<string>;
suggested: Set<string>;
}
type ScopeSuggest = (values: string[]) => ScopeSuggestion;
const SUGGESTED_SCOPE_PATTERN = /^(mart|prod|analytics|core|dim|fact|gold)(_|$)/i;
const EXCLUDED_SCOPE_PATTERN = /^(information_schema|pg_catalog|pg_toast|_airbyte_|mysql$|performance_schema$|sys$)/i;
function defaultSuggest(values: string[]): ScopeSuggestion {
const excluded = new Set(values.filter((value) => EXCLUDED_SCOPE_PATTERN.test(value)));
const suggested = new Set(
values.filter((value) => !excluded.has(value) && SUGGESTED_SCOPE_PATTERN.test(value)),
);
return { excluded, suggested };
}
const SCOPE_DISCOVERY_SPECS: Partial<Record<KtxSetupDatabaseDriver, ScopeDiscoverySpec>> = {
@ -145,10 +171,22 @@ const SCOPE_DISCOVERY_SPECS: Partial<Record<KtxSetupDatabaseDriver, ScopeDiscove
promptLabel: 'PostgreSQL schemas',
configArrayField: 'schemas',
configSingleField: 'schema',
defaultSelection(schemas) {
const nonPublic = schemas.filter((s) => s !== 'public');
return nonPublic.length > 0 ? nonPublic : schemas;
},
suggest: defaultSuggest,
},
mysql: {
noun: 'database',
nounPlural: 'databases',
promptLabel: 'MySQL databases',
configArrayField: 'schemas',
configSingleField: 'schema',
suggest: defaultSuggest,
},
clickhouse: {
noun: 'database',
nounPlural: 'databases',
promptLabel: 'ClickHouse databases',
configArrayField: 'databases',
suggest: defaultSuggest,
},
sqlserver: {
noun: 'schema',
@ -156,7 +194,7 @@ const SCOPE_DISCOVERY_SPECS: Partial<Record<KtxSetupDatabaseDriver, ScopeDiscove
promptLabel: 'SQL Server schemas',
configArrayField: 'schemas',
configSingleField: 'schema',
defaultSelection: (schemas) => schemas,
suggest: defaultSuggest,
},
bigquery: {
noun: 'dataset',
@ -164,7 +202,7 @@ const SCOPE_DISCOVERY_SPECS: Partial<Record<KtxSetupDatabaseDriver, ScopeDiscove
promptLabel: 'BigQuery datasets',
configArrayField: 'dataset_ids',
configSingleField: 'dataset_id',
defaultSelection: (datasets) => datasets,
suggest: defaultSuggest,
},
snowflake: {
noun: 'schema',
@ -172,10 +210,7 @@ const SCOPE_DISCOVERY_SPECS: Partial<Record<KtxSetupDatabaseDriver, ScopeDiscove
promptLabel: 'Snowflake schemas',
configArrayField: 'schema_names',
configSingleField: 'schema_name',
defaultSelection(schemas) {
const nonPublic = schemas.filter((s) => s !== 'PUBLIC');
return nonPublic.length > 0 ? nonPublic : schemas;
},
suggest: defaultSuggest,
},
};
@ -386,6 +421,28 @@ async function defaultListSchemas(projectDir: string, connectionId: string): Pro
}
}
if (driver === 'mysql') {
const { KtxMysqlScanConnector, isKtxMysqlConnectionConfig } = await import('./connectors/mysql/connector.js');;
if (!isKtxMysqlConnectionConfig(connection)) return [];
const connector = new KtxMysqlScanConnector({ connectionId, connection });
try {
return await connector.listSchemas();
} finally {
await connector.cleanup();
}
}
if (driver === 'clickhouse') {
const { KtxClickHouseScanConnector, isKtxClickHouseConnectionConfig } = await import('./connectors/clickhouse/connector.js');;
if (!isKtxClickHouseConnectionConfig(connection)) return [];
const connector = new KtxClickHouseScanConnector({ connectionId, connection });
try {
return await connector.listSchemas();
} finally {
await connector.cleanup();
}
}
if (driver === 'bigquery') {
const { KtxBigQueryScanConnector, isKtxBigQueryConnectionConfig } = await import('./connectors/bigquery/connector.js');;
if (!isKtxBigQueryConnectionConfig(connection)) return [];
@ -606,6 +663,33 @@ function normalizeFileReference(value: string): string {
return `file:${normalized}`;
}
function displayFileReference(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
if (value.startsWith('file:')) return value.slice('file:'.length);
return value;
}
function scriptedScopeConfigForDriver(
driver: KtxSetupDatabaseDriver,
databaseSchemas: string[],
): Record<string, unknown> {
if (databaseSchemas.length === 0) return {};
if (driver === 'bigquery') return { dataset_ids: databaseSchemas };
if (driver === 'clickhouse') return { databases: databaseSchemas };
return { schemas: databaseSchemas };
}
function databaseNameFromLiteralUrl(url: string): string | undefined {
if (url.startsWith('env:') || url.startsWith('file:')) {
return undefined;
}
try {
return new URL(url).pathname.replace(/^\/+/, '') || undefined;
} catch {
return undefined;
}
}
async function promptCredential(input: {
prompts: KtxSetupDatabasesPromptAdapter;
message: string;
@ -694,7 +778,7 @@ async function buildFieldsConnectionConfig(input: {
database,
username,
...(passwordRef ? { password: passwordRef } : {}),
...(input.args.databaseSchemas.length > 0 ? { schemas: input.args.databaseSchemas } : {}),
...scriptedScopeConfigForDriver(input.driver, input.args.databaseSchemas),
};
}
@ -720,10 +804,11 @@ async function buildPastedUrlConnectionConfig(input: {
return {
driver: input.driver,
url,
...(input.args.databaseSchemas.length > 0 ? { schemas: input.args.databaseSchemas } : {}),
...scriptedScopeConfigForDriver(input.driver, input.args.databaseSchemas),
};
}
const database = input.driver === 'clickhouse' ? databaseNameFromLiteralUrl(url) : undefined;
if (urlHasCredentials(url)) {
const ref = await writeProjectLocalSecretReference({
projectDir: input.args.projectDir,
@ -733,14 +818,16 @@ async function buildPastedUrlConnectionConfig(input: {
return {
driver: input.driver,
url: ref,
...(input.args.databaseSchemas.length > 0 ? { schemas: input.args.databaseSchemas } : {}),
...(database ? { database } : {}),
...scriptedScopeConfigForDriver(input.driver, input.args.databaseSchemas),
};
}
return {
driver: input.driver,
url,
...(input.args.databaseSchemas.length > 0 ? { schemas: input.args.databaseSchemas } : {}),
...(database ? { database } : {}),
...scriptedScopeConfigForDriver(input.driver, input.args.databaseSchemas),
};
}
@ -756,6 +843,7 @@ async function buildUrlConnectionConfig(input: {
if (input.args.databaseUrl) {
const url = normalizeInputReference(input.args.databaseUrl);
if (urlHasCredentials(url)) {
const database = input.driver === 'clickhouse' ? databaseNameFromLiteralUrl(url) : undefined;
const ref = await writeProjectLocalSecretReference({
projectDir: input.args.projectDir,
fileName: `${input.connectionId}-url`,
@ -764,13 +852,16 @@ async function buildUrlConnectionConfig(input: {
return {
driver: input.driver,
url: ref,
...(input.args.databaseSchemas.length > 0 ? { schemas: input.args.databaseSchemas } : {}),
...(database ? { database } : {}),
...scriptedScopeConfigForDriver(input.driver, input.args.databaseSchemas),
};
}
const database = input.driver === 'clickhouse' ? databaseNameFromLiteralUrl(url) : undefined;
return {
driver: input.driver,
url,
...(input.args.databaseSchemas.length > 0 ? { schemas: input.args.databaseSchemas } : {}),
...(database ? { database } : {}),
...scriptedScopeConfigForDriver(input.driver, input.args.databaseSchemas),
};
}
@ -822,16 +913,10 @@ async function buildConnectionConfig(input: {
});
}
if (driver === 'bigquery') {
const datasetId = await promptText(
prompts,
'BigQuery dataset\nFor example analytics.',
stringConfigField(input.existingConnection, 'dataset_id'),
);
if (datasetId === undefined) return 'back';
const credentialsPath = await promptText(
prompts,
'Path to service account JSON file',
stringConfigField(input.existingConnection, 'credentials_json'),
displayFileReference(stringConfigField(input.existingConnection, 'credentials_json')),
);
if (credentialsPath === undefined) return 'back';
const location = await promptText(
@ -840,12 +925,12 @@ async function buildConnectionConfig(input: {
stringConfigField(input.existingConnection, 'location') ?? 'US',
);
if (location === undefined) return 'back';
if (!datasetId || !credentialsPath) return null;
if (!credentialsPath) return null;
return {
driver: 'bigquery',
dataset_id: datasetId,
credentials_json: normalizeFileReference(credentialsPath),
...(location ? { location } : {}),
...scriptedScopeConfigForDriver('bigquery', args.databaseSchemas),
};
}
if (driver === 'snowflake') {
@ -1260,9 +1345,17 @@ function withExistingPrimaryEditPromptDefaults(input: {
Array.isArray(previousArray) &&
previousArray.length > 0
) {
delete merged[spec.configSingleField];
if (spec.configSingleField) {
delete merged[spec.configSingleField];
}
merged[spec.configArrayField] = previousArray;
} else if (!Object.hasOwn(input.next, spec.configArrayField) && !Object.hasOwn(input.next, spec.configSingleField)) {
} else if (
!Object.hasOwn(input.next, spec.configArrayField) &&
(!spec.configSingleField || !Object.hasOwn(input.next, spec.configSingleField))
) {
if (!spec.configSingleField) {
return merged;
}
const previousSingle = input.previous[spec.configSingleField];
if (typeof previousSingle === 'string' && previousSingle.trim().length > 0) {
merged[spec.configSingleField] = previousSingle;
@ -1272,6 +1365,9 @@ function withExistingPrimaryEditPromptDefaults(input: {
if (!Object.hasOwn(input.next, 'enabled_tables') && Array.isArray(input.previous.enabled_tables)) {
merged.enabled_tables = input.previous.enabled_tables;
}
if (!Object.hasOwn(input.next, 'context') && input.previous.context !== undefined) {
merged.context = input.previous.context;
}
return merged;
}
@ -1286,6 +1382,9 @@ function configuredScopeValues(
.filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
.map((v) => v.trim());
}
if (!spec.configSingleField) {
return [];
}
const singleVal = connection[spec.configSingleField];
return typeof singleVal === 'string' && singleVal.trim().length > 0 ? [singleVal.trim()] : [];
}
@ -1318,6 +1417,7 @@ async function maybeConfigureDatabaseScope(input: {
args: KtxSetupDatabasesArgs;
deps: KtxSetupDatabasesDeps;
io: KtxCliIo;
prompts: KtxSetupDatabasesPromptAdapter;
forcePrompt?: boolean;
}): Promise<ConnectionSetupStatus> {
const project = await loadKtxProject({ projectDir: input.projectDir });
@ -1378,32 +1478,53 @@ async function maybeConfigureDatabaseScope(input: {
});
}
writeSetupSection(input.io, 'Discovering tables', [
`Connecting to ${input.connectionId}`,
]);
writeSetupSection(input.io, 'Discovering tables', [`Connecting to ${input.connectionId}`]);
const schemasFilter = await (async (): Promise<string[]> => {
if (cliSchemas.length > 0) return cliSchemas;
if (!spec) return [];
try {
return unique(
await (input.deps.listSchemas ?? defaultListSchemas)(input.projectDir, input.connectionId),
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
input.io.stderr.write(
`Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; ${detail}\n`,
);
return [];
}
})();
const schemas = unique(
cliSchemas.length > 0
? cliSchemas
: await (async (): Promise<string[]> => {
if (!spec) return [];
try {
return await (input.deps.listSchemas ?? defaultListSchemas)(input.projectDir, input.connectionId);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
input.io.stderr.write(
`Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; ${detail}\n`,
);
return [];
}
})(),
);
if (spec && schemas.length === 0) {
return 'ready';
}
const schemaSuggestion =
cliSchemas.length > 0
? { excluded: new Set<string>(), suggested: new Set(cliSchemas) }
: spec?.suggest(schemas) ?? { excluded: new Set<string>(), suggested: new Set<string>() };
const existingEnabled =
hasExistingTables && input.forcePrompt === true
? (existingTables ?? []).filter((table): table is string => typeof table === 'string')
: [];
let discovered: KtxTableListEntry[];
let pickResult: DatabaseScopePickResult;
try {
discovered = await (input.deps.listTables ?? defaultListTables)(
input.projectDir,
input.connectionId,
schemasFilter.length > 0 ? schemasFilter : undefined,
pickResult = await (input.deps.pickDatabaseScope ?? defaultPickDatabaseScope)(
{
connectionId: input.connectionId,
schemaNoun: spec?.noun ?? 'schema',
schemaNounPlural: spec?.nounPlural ?? 'schemas',
schemas,
schemaSuggestion,
existing: { enabledTables: existingEnabled },
supportsSchemaScope: spec !== undefined,
initialSchemas: cliSchemas.length > 0 ? cliSchemas : undefined,
prompts: input.prompts,
listTablesForSchemas: (selectedSchemas) =>
(input.deps.listTables ?? defaultListTables)(input.projectDir, input.connectionId, selectedSchemas),
},
input.io,
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
@ -1414,55 +1535,11 @@ async function maybeConfigureDatabaseScope(input: {
);
return input.forcePrompt === true ? 'failed' : 'ready';
}
if (discovered.length === 0) {
if (input.forcePrompt === true) {
input.io.stderr.write(`No tables discovered for ${input.connectionId}; edit was not saved.\n`);
}
return input.forcePrompt === true ? 'failed' : 'ready';
}
const allQualified = discovered.map((t) => `${t.schema}.${t.name}`);
const schemasInDiscovery = unique(discovered.map((t) => t.schema));
const defaultSchemas = (() => {
if (cliSchemas.length > 0) return cliSchemas;
if (!spec) return schemasInDiscovery;
return spec.defaultSelection(schemasInDiscovery);
})();
const existingEnabled =
hasExistingTables && input.forcePrompt === true
? (existingTables ?? []).filter(
(table): table is string => typeof table === 'string' && allQualified.includes(table),
)
: [];
let activeSchemas: string[];
let enabledTables: string[];
if (discovered.length === 1) {
enabledTables = allQualified;
activeSchemas = spec ? schemasInDiscovery : [];
} else {
const pickResult = await (input.deps.pickDatabaseScope ?? defaultPickDatabaseScope)(
{
connectionId: input.connectionId,
schemaNoun: spec?.noun ?? 'schema',
schemaNounPlural: spec?.nounPlural ?? 'schemas',
discovered,
existing: { enabledTables: existingEnabled },
defaultSchemas,
supportsSchemaScope: spec !== undefined,
},
input.io,
);
if (pickResult.kind === 'back') {
return 'back';
}
enabledTables = pickResult.enabledTables;
activeSchemas = pickResult.activeSchemas;
if (pickResult.kind === 'back') {
return 'back';
}
const enabledTables = pickResult.enabledTables;
const activeSchemas = pickResult.activeSchemas;
if (spec) {
await writeScopeConfig({
@ -1488,7 +1565,7 @@ async function maybeConfigureDatabaseScope(input: {
]);
}
writeSetupSection(input.io, `Tables enabled for ${input.connectionId}`, [
`${enabledTables.length}/${discovered.length} tables enabled`,
`${enabledTables.length} tables enabled`,
]);
return 'ready';
}