mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
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:
parent
036a745fc1
commit
853f39a7c3
4 changed files with 138 additions and 47 deletions
|
|
@ -125,6 +125,14 @@ incomplete.
|
||||||
MySQL, and SQL Server; `schema_names` for Snowflake; `dataset_ids` for
|
MySQL, and SQL Server; `schema_names` for Snowflake; `dataset_ids` for
|
||||||
BigQuery; and `databases` for ClickHouse.
|
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
|
### Query History
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|
|
|
||||||
|
|
@ -1382,35 +1382,32 @@ async function maybeConfigureDatabaseScope(input: {
|
||||||
const cliSchemas = input.args.databaseSchemas;
|
const cliSchemas = input.args.databaseSchemas;
|
||||||
|
|
||||||
if (input.args.inputMode === 'disabled') {
|
if (input.args.inputMode === 'disabled') {
|
||||||
if (spec) {
|
if (!spec) {
|
||||||
let scopeToWrite: string[] = cliSchemas;
|
return okValidateResult();
|
||||||
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(', ')}`,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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) {
|
if (spec && cliSchemas.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -2145,17 +2145,11 @@ describe('setup databases step', () => {
|
||||||
expect(listTables).toHaveBeenCalledWith(tempDir, 'postgres-warehouse', ['analytics']);
|
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 io = makeIo();
|
||||||
const prompts = makePromptAdapter({});
|
const prompts = makePromptAdapter({});
|
||||||
const testConnection = vi.fn(async () => 0);
|
const testConnection = vi.fn(async () => 0);
|
||||||
const scanConnection = vi.fn(async asyncScanProjectDir => {
|
const scanConnection = vi.fn(async () => 0);
|
||||||
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 listSchemas = vi.fn(async () => ['orbit_analytics', 'orbit_raw', 'public']);
|
const listSchemas = vi.fn(async () => ['orbit_analytics', 'orbit_raw', 'public']);
|
||||||
|
|
||||||
const result = await runKtxSetupDatabasesStep(
|
const result = await runKtxSetupDatabasesStep(
|
||||||
|
|
@ -2172,13 +2166,93 @@ describe('setup databases step', () => {
|
||||||
{ prompts, testConnection, scanConnection, listSchemas },
|
{ 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(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'));
|
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||||
expect(config.connections.warehouse).toMatchObject({
|
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 () => {
|
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:',
|
' warehouse:',
|
||||||
' driver: postgres',
|
' driver: postgres',
|
||||||
' url: env:DATABASE_URL',
|
' url: env:DATABASE_URL',
|
||||||
|
' schemas:',
|
||||||
|
" - 'public'",
|
||||||
' analytics:',
|
' analytics:',
|
||||||
' driver: snowflake',
|
' driver: snowflake',
|
||||||
' authMethod: password',
|
' authMethod: password',
|
||||||
|
|
@ -2312,7 +2388,7 @@ describe('setup databases step', () => {
|
||||||
databaseDrivers: ['postgres'],
|
databaseDrivers: ['postgres'],
|
||||||
databaseConnectionId: 'warehouse',
|
databaseConnectionId: 'warehouse',
|
||||||
databaseUrl: 'env:DATABASE_URL',
|
databaseUrl: 'env:DATABASE_URL',
|
||||||
databaseSchemas: [],
|
databaseSchemas: ['public'],
|
||||||
skipDatabases: false,
|
skipDatabases: false,
|
||||||
},
|
},
|
||||||
io.io,
|
io.io,
|
||||||
|
|
@ -2342,7 +2418,7 @@ describe('setup databases step', () => {
|
||||||
databaseDrivers: ['postgres'],
|
databaseDrivers: ['postgres'],
|
||||||
databaseConnectionId: 'warehouse',
|
databaseConnectionId: 'warehouse',
|
||||||
databaseUrl: 'env:DATABASE_URL',
|
databaseUrl: 'env:DATABASE_URL',
|
||||||
databaseSchemas: [],
|
databaseSchemas: ['public'],
|
||||||
skipDatabases: false,
|
skipDatabases: false,
|
||||||
},
|
},
|
||||||
io.io,
|
io.io,
|
||||||
|
|
@ -2409,7 +2485,7 @@ describe('setup databases step', () => {
|
||||||
databaseDrivers: ['postgres'],
|
databaseDrivers: ['postgres'],
|
||||||
databaseConnectionId: 'warehouse',
|
databaseConnectionId: 'warehouse',
|
||||||
databaseUrl: 'env:DATABASE_URL',
|
databaseUrl: 'env:DATABASE_URL',
|
||||||
databaseSchemas: [],
|
databaseSchemas: ['public'],
|
||||||
skipDatabases: false,
|
skipDatabases: false,
|
||||||
},
|
},
|
||||||
io.io,
|
io.io,
|
||||||
|
|
@ -2470,7 +2546,7 @@ describe('setup databases step', () => {
|
||||||
inputMode: 'disabled',
|
inputMode: 'disabled',
|
||||||
databaseDrivers: ['snowflake'],
|
databaseDrivers: ['snowflake'],
|
||||||
databaseConnectionId: 'snowflake',
|
databaseConnectionId: 'snowflake',
|
||||||
databaseSchemas: [],
|
databaseSchemas: ['PUBLIC'],
|
||||||
enableQueryHistory: true,
|
enableQueryHistory: true,
|
||||||
queryHistoryWindowDays: 30,
|
queryHistoryWindowDays: 30,
|
||||||
queryHistoryServiceAccountPatterns: ['^svc_'],
|
queryHistoryServiceAccountPatterns: ['^svc_'],
|
||||||
|
|
@ -2532,7 +2608,7 @@ describe('setup databases step', () => {
|
||||||
inputMode: 'disabled',
|
inputMode: 'disabled',
|
||||||
databaseDrivers: ['snowflake'],
|
databaseDrivers: ['snowflake'],
|
||||||
databaseConnectionId: 'snowflake',
|
databaseConnectionId: 'snowflake',
|
||||||
databaseSchemas: [],
|
databaseSchemas: ['PUBLIC'],
|
||||||
skipDatabases: false,
|
skipDatabases: false,
|
||||||
},
|
},
|
||||||
io.io,
|
io.io,
|
||||||
|
|
@ -2818,6 +2894,8 @@ describe('setup databases step', () => {
|
||||||
' warehouse:',
|
' warehouse:',
|
||||||
' driver: postgres',
|
' driver: postgres',
|
||||||
' url: env:DATABASE_URL',
|
' url: env:DATABASE_URL',
|
||||||
|
' schemas:',
|
||||||
|
" - 'public'",
|
||||||
' context:',
|
' context:',
|
||||||
' queryHistory:',
|
' queryHistory:',
|
||||||
' enabled: true',
|
' enabled: true',
|
||||||
|
|
@ -3172,6 +3250,8 @@ describe('setup databases step', () => {
|
||||||
' warehouse:',
|
' warehouse:',
|
||||||
' driver: postgres',
|
' driver: postgres',
|
||||||
' url: env:DATABASE_URL',
|
' url: env:DATABASE_URL',
|
||||||
|
' schemas:',
|
||||||
|
" - 'public'",
|
||||||
'',
|
'',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'utf-8',
|
'utf-8',
|
||||||
|
|
@ -3228,6 +3308,8 @@ describe('setup databases step', () => {
|
||||||
' warehouse:',
|
' warehouse:',
|
||||||
' driver: postgres',
|
' driver: postgres',
|
||||||
' readonly: true',
|
' readonly: true',
|
||||||
|
' schemas:',
|
||||||
|
" - 'public'",
|
||||||
' historicSql:',
|
' historicSql:',
|
||||||
' enabled: true',
|
' enabled: true',
|
||||||
' dialect: postgres',
|
' dialect: postgres',
|
||||||
|
|
@ -3324,7 +3406,7 @@ describe('setup databases step', () => {
|
||||||
databaseDrivers: ['postgres'],
|
databaseDrivers: ['postgres'],
|
||||||
databaseConnectionId: 'warehouse',
|
databaseConnectionId: 'warehouse',
|
||||||
databaseUrl: 'env:DATABASE_URL',
|
databaseUrl: 'env:DATABASE_URL',
|
||||||
databaseSchemas: [],
|
databaseSchemas: ['public'],
|
||||||
enableQueryHistory: true,
|
enableQueryHistory: true,
|
||||||
skipDatabases: false,
|
skipDatabases: false,
|
||||||
},
|
},
|
||||||
|
|
@ -3373,7 +3455,7 @@ describe('setup databases step', () => {
|
||||||
inputMode: 'disabled',
|
inputMode: 'disabled',
|
||||||
databaseDrivers: ['snowflake'],
|
databaseDrivers: ['snowflake'],
|
||||||
databaseConnectionId: 'warehouse',
|
databaseConnectionId: 'warehouse',
|
||||||
databaseSchemas: [],
|
databaseSchemas: ['PUBLIC'],
|
||||||
enableQueryHistory: true,
|
enableQueryHistory: true,
|
||||||
skipDatabases: false,
|
skipDatabases: false,
|
||||||
},
|
},
|
||||||
|
|
@ -3485,7 +3567,7 @@ describe('setup databases step', () => {
|
||||||
databaseDrivers: ['postgres'],
|
databaseDrivers: ['postgres'],
|
||||||
databaseConnectionId: 'replay',
|
databaseConnectionId: 'replay',
|
||||||
databaseUrl: 'env:DATABASE_URL',
|
databaseUrl: 'env:DATABASE_URL',
|
||||||
databaseSchemas: [],
|
databaseSchemas: ['public'],
|
||||||
skipDatabases: false,
|
skipDatabases: false,
|
||||||
},
|
},
|
||||||
io.io,
|
io.io,
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,10 @@ Do not discover these inputs across multiple setup runs.
|
||||||
--skip-agents
|
--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
|
- Configure one new database connection per setup invocation. For multiple
|
||||||
connections, rerun setup once per connection.
|
connections, rerun setup once per connection.
|
||||||
- Pasting a literal `--database-url` is safe: the CLI relocates the URL
|
- Pasting a literal `--database-url` is safe: the CLI relocates the URL
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue