fix(setup): require explicit no-input database scope (#286)

* test(setup): supply explicit --no-input scope to disabled-mode database tests

* fix(setup): require explicit database scope in --no-input instead of auto-scanning the warehouse

* docs(setup): document --no-input database scope requirement
This commit is contained in:
Andrey Avtomonov 2026-06-10 12:36:53 +02:00 committed by GitHub
parent 036a745fc1
commit 853f39a7c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 138 additions and 47 deletions

View file

@ -125,6 +125,14 @@ incomplete.
MySQL, and SQL Server; `schema_names` for Snowflake; `dataset_ids` for
BigQuery; and `databases` for ClickHouse.
With `--no-input`, scope for a scope-bearing driver (PostgreSQL, MySQL,
ClickHouse, SQL Server, BigQuery, Snowflake) must come from `--database-schema`
or from existing connection config in `ktx.yaml` (for example
`connections.<id>.dataset_ids`). When neither is set, the database step fails
fast and prints the missing scope flag and config key — non-interactive setup
never auto-discovers and scans every schema. SQLite has no scope and is
unaffected.
### Query History
| Flag | Description |

View file

@ -1382,35 +1382,32 @@ async function maybeConfigureDatabaseScope(input: {
const cliSchemas = input.args.databaseSchemas;
if (input.args.inputMode === 'disabled') {
if (spec) {
let scopeToWrite: string[] = cliSchemas;
if (scopeToWrite.length === 0) {
try {
scopeToWrite = 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 okValidateResult();
}
}
if (scopeToWrite.length > 0) {
await writeScopeConfig({
projectDir: input.projectDir,
connectionId: input.connectionId,
values: scopeToWrite,
spec,
});
const capitalNounPlural = spec.nounPlural[0]!.toUpperCase() + spec.nounPlural.slice(1);
writeSetupSection(input.io, `${capitalNounPlural} saved for ${input.connectionId}`, [
`${scopeToWrite.join(', ')}`,
]);
}
if (!spec) {
return okValidateResult();
}
return okValidateResult();
if (cliSchemas.length > 0) {
await writeScopeConfig({
projectDir: input.projectDir,
connectionId: input.connectionId,
values: cliSchemas,
spec,
});
const capitalNounPlural = spec.nounPlural[0]!.toUpperCase() + spec.nounPlural.slice(1);
writeSetupSection(input.io, `${capitalNounPlural} saved for ${input.connectionId}`, [
`${cliSchemas.join(', ')}`,
]);
return okValidateResult();
}
if (existingScope.length > 0) {
return okValidateResult();
}
writePrefixedLines(
(chunk) => input.io.stderr.write(chunk),
`No ${spec.nounPlural} configured for ${input.connectionId}. ` +
`Pass --database-schema <${spec.noun}> (repeatable) or set ` +
`connections.${input.connectionId}.${spec.configArrayField} in ktx.yaml.`,
);
return failedValidateResult();
}
if (spec && cliSchemas.length > 0) {

View file

@ -2145,17 +2145,11 @@ describe('setup databases step', () => {
expect(listTables).toHaveBeenCalledWith(tempDir, 'postgres-warehouse', ['analytics']);
});
it('auto-selects all discovered Postgres schemas in non-interactive setup', async () => {
it('fails non-interactive setup when a scope-bearing connection has no schema configured', async () => {
const io = makeIo();
const prompts = makePromptAdapter({});
const testConnection = vi.fn(async () => 0);
const scanConnection = vi.fn(async asyncScanProjectDir => {
const config = parseKtxProjectConfig(await readFile(join(asyncScanProjectDir, 'ktx.yaml'), 'utf-8'));
expect(config.connections.warehouse).toMatchObject({
schemas: ['orbit_analytics', 'orbit_raw', 'public'],
});
return 0;
});
const scanConnection = vi.fn(async () => 0);
const listSchemas = vi.fn(async () => ['orbit_analytics', 'orbit_raw', 'public']);
const result = await runKtxSetupDatabasesStep(
@ -2172,13 +2166,93 @@ describe('setup databases step', () => {
{ prompts, testConnection, scanConnection, listSchemas },
);
expect(result.status).toBe('failed');
expect(listSchemas).not.toHaveBeenCalled();
expect(scanConnection).not.toHaveBeenCalled();
expect(io.stderr()).toContain('--database-schema');
expect(io.stderr()).toContain('connections.warehouse.schemas');
});
it('preserves existing BigQuery dataset_ids in non-interactive setup without rediscovering', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'connections:',
' bigquery-warehouse:',
' driver: bigquery',
' dataset_ids:',
" - 'sales'",
' credentials_json: env:BIGQUERY_CREDENTIALS_JSON',
'',
].join('\n'),
'utf-8',
);
const io = makeIo();
const testConnection = vi.fn(async () => 0);
const scanConnection = vi.fn(async () => 0);
const listSchemas = vi.fn(async () => ['sales', 'stripe', 'posthog', 'linear']);
const result = await runKtxSetupDatabasesStep(
{
projectDir: tempDir,
inputMode: 'disabled',
databaseConnectionIds: ['bigquery-warehouse'],
databaseSchemas: [],
skipDatabases: false,
},
io.io,
{ testConnection, scanConnection, listSchemas },
);
expect(result.status).toBe('ready');
expect(prompts.multiselect).not.toHaveBeenCalled();
expect(listSchemas).not.toHaveBeenCalled();
expect(scanConnection).toHaveBeenCalledWith(tempDir, 'bigquery-warehouse', expect.anything());
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
expect(config.connections['bigquery-warehouse']).toMatchObject({
driver: 'bigquery',
dataset_ids: ['sales'],
});
});
it('preserves existing Postgres schemas in non-interactive setup without rediscovering', async () => {
await writeFile(
join(tempDir, 'ktx.yaml'),
[
'connections:',
' warehouse:',
' driver: postgres',
' url: env:DATABASE_URL',
' schemas:',
" - 'analytics'",
'',
].join('\n'),
'utf-8',
);
const io = makeIo();
const testConnection = vi.fn(async () => 0);
const scanConnection = vi.fn(async () => 0);
const listSchemas = vi.fn(async () => ['analytics', 'raw', 'public']);
const result = await runKtxSetupDatabasesStep(
{
projectDir: tempDir,
inputMode: 'disabled',
databaseConnectionIds: ['warehouse'],
databaseSchemas: [],
skipDatabases: false,
},
io.io,
{ testConnection, scanConnection, listSchemas },
);
expect(result.status).toBe('ready');
expect(listSchemas).not.toHaveBeenCalled();
expect(scanConnection).toHaveBeenCalledWith(tempDir, 'warehouse', expect.anything());
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
expect(config.connections.warehouse).toMatchObject({
schemas: ['orbit_analytics', 'orbit_raw', 'public'],
driver: 'postgres',
schemas: ['analytics'],
});
expect(io.stdout()).toContain('✓ orbit_analytics, orbit_raw, public');
});
it('adds one non-interactive Postgres URL connection, tests it, scans it, and marks databases complete', async () => {
@ -2265,6 +2339,8 @@ describe('setup databases step', () => {
' warehouse:',
' driver: postgres',
' url: env:DATABASE_URL',
' schemas:',
" - 'public'",
' analytics:',
' driver: snowflake',
' authMethod: password',
@ -2312,7 +2388,7 @@ describe('setup databases step', () => {
databaseDrivers: ['postgres'],
databaseConnectionId: 'warehouse',
databaseUrl: 'env:DATABASE_URL',
databaseSchemas: [],
databaseSchemas: ['public'],
skipDatabases: false,
},
io.io,
@ -2342,7 +2418,7 @@ describe('setup databases step', () => {
databaseDrivers: ['postgres'],
databaseConnectionId: 'warehouse',
databaseUrl: 'env:DATABASE_URL',
databaseSchemas: [],
databaseSchemas: ['public'],
skipDatabases: false,
},
io.io,
@ -2409,7 +2485,7 @@ describe('setup databases step', () => {
databaseDrivers: ['postgres'],
databaseConnectionId: 'warehouse',
databaseUrl: 'env:DATABASE_URL',
databaseSchemas: [],
databaseSchemas: ['public'],
skipDatabases: false,
},
io.io,
@ -2470,7 +2546,7 @@ describe('setup databases step', () => {
inputMode: 'disabled',
databaseDrivers: ['snowflake'],
databaseConnectionId: 'snowflake',
databaseSchemas: [],
databaseSchemas: ['PUBLIC'],
enableQueryHistory: true,
queryHistoryWindowDays: 30,
queryHistoryServiceAccountPatterns: ['^svc_'],
@ -2532,7 +2608,7 @@ describe('setup databases step', () => {
inputMode: 'disabled',
databaseDrivers: ['snowflake'],
databaseConnectionId: 'snowflake',
databaseSchemas: [],
databaseSchemas: ['PUBLIC'],
skipDatabases: false,
},
io.io,
@ -2818,6 +2894,8 @@ describe('setup databases step', () => {
' warehouse:',
' driver: postgres',
' url: env:DATABASE_URL',
' schemas:',
" - 'public'",
' context:',
' queryHistory:',
' enabled: true',
@ -3172,6 +3250,8 @@ describe('setup databases step', () => {
' warehouse:',
' driver: postgres',
' url: env:DATABASE_URL',
' schemas:',
" - 'public'",
'',
].join('\n'),
'utf-8',
@ -3228,6 +3308,8 @@ describe('setup databases step', () => {
' warehouse:',
' driver: postgres',
' readonly: true',
' schemas:',
" - 'public'",
' historicSql:',
' enabled: true',
' dialect: postgres',
@ -3324,7 +3406,7 @@ describe('setup databases step', () => {
databaseDrivers: ['postgres'],
databaseConnectionId: 'warehouse',
databaseUrl: 'env:DATABASE_URL',
databaseSchemas: [],
databaseSchemas: ['public'],
enableQueryHistory: true,
skipDatabases: false,
},
@ -3373,7 +3455,7 @@ describe('setup databases step', () => {
inputMode: 'disabled',
databaseDrivers: ['snowflake'],
databaseConnectionId: 'warehouse',
databaseSchemas: [],
databaseSchemas: ['PUBLIC'],
enableQueryHistory: true,
skipDatabases: false,
},
@ -3485,7 +3567,7 @@ describe('setup databases step', () => {
databaseDrivers: ['postgres'],
databaseConnectionId: 'replay',
databaseUrl: 'env:DATABASE_URL',
databaseSchemas: [],
databaseSchemas: ['public'],
skipDatabases: false,
},
io.io,

View file

@ -88,6 +88,10 @@ Do not discover these inputs across multiple setup runs.
--skip-agents
```
- `--database-schema` is required for scope-bearing drivers (Postgres,
MySQL, ClickHouse, SQL Server, BigQuery, Snowflake) in `--no-input`:
setup fails fast without it unless the connection already has scope in
`ktx.yaml`. SQLite needs no scope.
- Configure one new database connection per setup invocation. For multiple
connections, rerun setup once per connection.
- Pasting a literal `--database-url` is safe: the CLI relocates the URL