From 853f39a7c3f4db44979439da57b35f5506283795 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 10 Jun 2026 12:36:53 +0200 Subject: [PATCH] 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 --- .../content/docs/cli-reference/ktx-setup.mdx | 8 ++ packages/cli/src/setup-databases.ts | 53 ++++---- packages/cli/test/setup-databases.test.ts | 120 +++++++++++++++--- skills/ktx/SKILL.md | 4 + 4 files changed, 138 insertions(+), 47 deletions(-) diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index f75fdf46..51fed155 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -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..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 | diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index 002ead30..987b28ee 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -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) { diff --git a/packages/cli/test/setup-databases.test.ts b/packages/cli/test/setup-databases.test.ts index 957dfdb2..16626e34 100644 --- a/packages/cli/test/setup-databases.test.ts +++ b/packages/cli/test/setup-databases.test.ts @@ -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, diff --git a/skills/ktx/SKILL.md b/skills/ktx/SKILL.md index ef316b63..746b2a5d 100644 --- a/skills/ktx/SKILL.md +++ b/skills/ktx/SKILL.md @@ -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