fix: stop requiring readonly connection config

This commit is contained in:
Andrey Avtomonov 2026-05-13 19:21:41 +02:00
parent 754e4a9039
commit 7824b7f3b6
55 changed files with 103 additions and 292 deletions

View file

@ -26,14 +26,14 @@ describe('createDefaultLocalQueryExecutor', () => {
await expect(
executor.execute({
connectionId: 'pg',
connection: { driver: 'postgres', readonly: true },
connection: { driver: 'postgres' },
sql: 'select 1',
}),
).resolves.toMatchObject({ headers: ['pg'] });
await expect(
executor.execute({
connectionId: 'local',
connection: { driver: 'sqlite', readonly: true },
connection: { driver: 'sqlite' },
sql: 'select 1',
}),
).resolves.toMatchObject({ headers: ['sqlite'] });
@ -51,7 +51,7 @@ describe('createDefaultLocalQueryExecutor', () => {
await expect(
executor.execute({
connectionId: 'warehouse',
connection: { driver: 'snowflake', readonly: true },
connection: { driver: 'snowflake' },
sql: 'select 1',
}),
).rejects.toThrow('No local query executor is configured for driver "snowflake".');

View file

@ -37,7 +37,7 @@ describe('createPostgresQueryExecutor', () => {
const result = await executor.execute({
connectionId: 'warehouse',
connection: { driver: 'postgres', url: 'postgres://example/db', readonly: true },
connection: { driver: 'postgres', url: 'postgres://example/db' },
sql: 'select status, count(*) as order_count from public.orders group by status',
maxRows: 50,
});
@ -80,7 +80,7 @@ describe('createPostgresQueryExecutor', () => {
await expect(
executor.execute({
connectionId: 'warehouse',
connection: { driver: 'postgres', url: 'postgres://example/db', readonly: true },
connection: { driver: 'postgres', url: 'postgres://example/db' },
sql: 'select * from broken',
maxRows: 10,
}),
@ -89,23 +89,15 @@ describe('createPostgresQueryExecutor', () => {
expect(client.end).toHaveBeenCalledTimes(1);
});
it('requires a Postgres url and read-only connection config', async () => {
it('requires a Postgres url', async () => {
const executor = createPostgresQueryExecutor({ clientFactory: vi.fn() });
await expect(
executor.execute({
connectionId: 'warehouse',
connection: { driver: 'postgres', readonly: true },
connection: { driver: 'postgres' },
sql: 'select 1',
}),
).rejects.toThrow('Local Postgres execution requires connections.warehouse.url');
await expect(
executor.execute({
connectionId: 'warehouse',
connection: { driver: 'postgres', url: 'postgres://example/db', readonly: false },
sql: 'select 1',
}),
).rejects.toThrow('Local query execution requires connections.warehouse.readonly: true');
});
});

View file

@ -37,18 +37,16 @@ export function createPostgresQueryExecutor(options: PostgresQueryExecutorOption
return {
async execute(input: KtxSqlQueryExecutionInput): Promise<KtxSqlQueryExecutionResult> {
const driver = connectionDriver(input);
const connection = input.connection;
if (driver !== 'postgres' && driver !== 'postgresql') {
throw new Error(`Local Postgres execution cannot run driver "${input.connection?.driver ?? 'unknown'}".`);
throw new Error(`Local Postgres execution cannot run driver "${connection?.driver ?? 'unknown'}".`);
}
if (input.connection?.readonly !== true) {
throw new Error(`Local query execution requires connections.${input.connectionId}.readonly: true.`);
}
if (typeof input.connection.url !== 'string' || input.connection.url.trim().length === 0) {
if (typeof connection?.url !== 'string' || connection.url.trim().length === 0) {
throw new Error(`Local Postgres execution requires connections.${input.connectionId}.url.`);
}
const client = clientFactory({
connectionString: input.connection.url,
connectionString: connection.url,
statement_timeout: options.statementTimeoutMs ?? 30_000,
query_timeout: options.queryTimeoutMs ?? 35_000,
connectionTimeoutMillis: options.connectionTimeoutMs ?? 5_000,

View file

@ -38,7 +38,7 @@ describe('createSqliteQueryExecutor', () => {
const result = await executor.execute({
connectionId: 'warehouse',
projectDir: tempDir,
connection: { driver: 'sqlite', path: 'warehouse.db', readonly: true },
connection: { driver: 'sqlite', path: 'warehouse.db' },
sql: 'select status, count(*) as order_count from orders group by status order by status',
maxRows: 10,
});
@ -60,7 +60,7 @@ describe('createSqliteQueryExecutor', () => {
sqliteDatabasePathFromConnection({
connectionId: 'warehouse',
projectDir: tempDir,
connection: { driver: 'sqlite', url: `file://${dbPath}`, readonly: true },
connection: { driver: 'sqlite', url: `file://${dbPath}` },
sql: 'select 1',
}),
).toBe(dbPath);
@ -74,7 +74,7 @@ describe('createSqliteQueryExecutor', () => {
sqliteDatabasePathFromConnection({
connectionId: 'warehouse',
projectDir: tempDir,
connection: { driver: 'sqlite', path: `file:${pointerPath}`, readonly: true },
connection: { driver: 'sqlite', path: `file:${pointerPath}` },
sql: 'select 1',
}),
).toBe(dbPath);
@ -89,7 +89,7 @@ describe('createSqliteQueryExecutor', () => {
sqliteDatabasePathFromConnection({
connectionId: 'warehouse',
projectDir: tempDir,
connection: { driver: 'sqlite', url: 'env:KTX_SQLITE_TEST_URL', readonly: true },
connection: { driver: 'sqlite', url: 'env:KTX_SQLITE_TEST_URL' },
sql: 'select 1',
}),
).toBe(dbPath);
@ -109,20 +109,20 @@ describe('createSqliteQueryExecutor', () => {
executor.execute({
connectionId: 'warehouse',
projectDir: tempDir,
connection: { driver: 'sqlite', path: 'warehouse.db', readonly: true },
connection: { driver: 'sqlite', path: 'warehouse.db' },
sql: 'delete from orders',
}),
).rejects.toThrow('Only read-only SELECT/WITH queries can be executed locally');
});
it('requires a SQLite driver, read-only config, and a database path', async () => {
it('requires a SQLite driver and a database path', async () => {
const executor = createSqliteQueryExecutor();
await expect(
executor.execute({
connectionId: 'warehouse',
projectDir: tempDir,
connection: { driver: 'postgres', path: 'warehouse.db', readonly: true },
connection: { driver: 'postgres', path: 'warehouse.db' },
sql: 'select 1',
}),
).rejects.toThrow('Local SQLite execution cannot run driver "postgres"');
@ -131,16 +131,7 @@ describe('createSqliteQueryExecutor', () => {
executor.execute({
connectionId: 'warehouse',
projectDir: tempDir,
connection: { driver: 'sqlite', path: 'warehouse.db', readonly: false },
sql: 'select 1',
}),
).rejects.toThrow('Local query execution requires connections.warehouse.readonly: true');
await expect(
executor.execute({
connectionId: 'warehouse',
projectDir: tempDir,
connection: { driver: 'sqlite', readonly: true },
connection: { driver: 'sqlite' },
sql: 'select 1',
}),
).rejects.toThrow('Local SQLite execution requires connections.warehouse.path or connections.warehouse.url');

View file

@ -54,9 +54,6 @@ export function sqliteDatabasePathFromConnection(input: KtxSqlQueryExecutionInpu
if (driver !== 'sqlite' && driver !== 'sqlite3') {
throw new Error(`Local SQLite execution cannot run driver "${input.connection?.driver ?? 'unknown'}".`);
}
if (input.connection?.readonly !== true) {
throw new Error(`Local query execution requires connections.${input.connectionId}.readonly: true.`);
}
const pathValue = stringConfigValue(input.connection, 'path');
const urlValue = stringConfigValue(input.connection, 'url');

View file

@ -45,7 +45,6 @@ describe('createDaemonLiveDatabaseIntrospection', () => {
warehouse: {
driver: 'postgres',
url: 'postgres://localhost:5432/warehouse',
readonly: true,
},
},
schemas: ['public'],
@ -157,7 +156,6 @@ describe('createDaemonLiveDatabaseIntrospection', () => {
warehouse: {
driver: 'postgresql',
url: 'postgres://localhost:5432/warehouse',
readonly: true,
},
},
baseUrl: `http://127.0.0.1:${address.port}`,
@ -186,20 +184,18 @@ describe('createDaemonLiveDatabaseIntrospection', () => {
}
});
it('requires a configured read-only postgres connection with a url', async () => {
it('requires a configured postgres connection with a url', async () => {
const introspection = createDaemonLiveDatabaseIntrospection({
connections: {
warehouse: {
driver: 'postgres',
url: 'postgres://localhost:5432/warehouse',
readonly: false,
},
},
runJson: vi.fn(async () => daemonResponse),
});
await expect(introspection.extractSchema('warehouse')).rejects.toThrow(
'Local live-database ingest requires connections.warehouse.readonly: true.',
'Local live-database ingest requires connections.warehouse.url.',
);
});
@ -210,7 +206,6 @@ describe('createDaemonLiveDatabaseIntrospection', () => {
warehouse: {
driver: 'snowflake',
url: 'snowflake://example',
readonly: true,
},
},
runJson,

View file

@ -162,9 +162,6 @@ function requirePostgresConnection(
if (driver !== 'postgres') {
throw new Error(`Local live-database ingest cannot run driver "${connection?.driver ?? 'unknown'}".`);
}
if (connection?.readonly !== true) {
throw new Error(`Local live-database ingest requires connections.${connectionId}.readonly: true.`);
}
if (typeof connection.url !== 'string' || connection.url.trim().length === 0) {
throw new Error(`Local live-database ingest requires connections.${connectionId}.url.`);
}

View file

@ -39,7 +39,6 @@ async function writeLiveDatabaseConfig(projectDir: string): Promise<void> {
' warehouse:',
' driver: postgres',
' url: postgres://localhost:5432/warehouse',
' readonly: true',
'ingest:',
' adapters:',
' - live-database',

View file

@ -75,7 +75,6 @@ describe('createLocalProjectMcpContextPorts', () => {
project.config.connections.warehouse = {
driver: 'postgres',
url: 'env:DATABASE_URL',
readonly: true,
};
const ports = createLocalProjectMcpContextPorts(project);
@ -89,7 +88,6 @@ describe('createLocalProjectMcpContextPorts', () => {
project.config.connections.warehouse = {
driver: 'postgres',
url: 'env:DATABASE_URL',
readonly: true,
};
const connector = testConnector();
const createConnector = vi.fn(async () => connector);
@ -125,7 +123,6 @@ describe('createLocalProjectMcpContextPorts', () => {
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
project.config.connections.warehouse = {
driver: 'postgres',
readonly: true,
};
project.config.ingest.adapters = ['fake'];
project.config.ingest.embeddings = {
@ -633,7 +630,6 @@ describe('createLocalProjectMcpContextPorts', () => {
project.config.connections.warehouse = {
driver: 'postgres',
url: 'env:DATABASE_URL',
readonly: true,
};
const shapeOnlyPorts = createLocalProjectMcpContextPorts(project);
await shapeOnlyPorts.semanticLayer?.writeSource({
@ -720,7 +716,6 @@ describe('createLocalProjectMcpContextPorts', () => {
project.config.connections.warehouse = {
driver: 'postgres',
url: 'env:DATABASE_URL',
readonly: true,
};
const shapeOnlyPorts = createLocalProjectMcpContextPorts(project);
await shapeOnlyPorts.semanticLayer?.writeSource({
@ -958,7 +953,6 @@ describe('createLocalProjectMcpContextPorts', () => {
project.config.connections.warehouse = {
driver: 'postgres',
url: 'postgres://localhost:5432/warehouse',
readonly: true,
};
project.config.ingest.adapters = ['live-database'];
project.config.llm = {
@ -1034,7 +1028,6 @@ describe('createLocalProjectMcpContextPorts', () => {
project.config.connections.warehouse = {
driver: 'postgres',
url: 'env:DATABASE_URL',
readonly: true,
};
project.config.ingest.adapters = ['live-database'];
const ports = createLocalProjectMcpContextPorts(project, {

View file

@ -145,7 +145,7 @@ describe('createLocalProjectMemoryCapture', () => {
it('captures a semantic-layer source for a named local connection id', async () => {
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
project.config.connections.warehouse = { driver: 'postgres', readonly: true };
project.config.connections.warehouse = { driver: 'postgres' };
const agentRunner = {
runLoop: async ({
toolSet,

View file

@ -69,7 +69,6 @@ export interface KtxProjectScanConfig {
export interface KtxProjectConnectionConfig {
driver: string;
url?: string;
readonly?: boolean;
[key: string]: unknown;
}

View file

@ -110,7 +110,6 @@ async function writeLiveDatabaseConfig(projectDir: string): Promise<void> {
' warehouse:',
' driver: postgres',
' url: env:DATABASE_URL',
' readonly: true',
'ingest:',
' adapters:',
' - live-database',
@ -1006,7 +1005,6 @@ describe('local scan', () => {
' warehouse:',
' driver: postgres',
' url: env:DATABASE_URL',
' readonly: true',
'ingest:',
' adapters:',
' - live-database',
@ -1363,7 +1361,6 @@ describe('local scan', () => {
' warehouse:',
' driver: sqlite',
' path: warehouse.db',
' readonly: true',
'ingest:',
' adapters:',
' - live-database',
@ -1396,7 +1393,6 @@ describe('local scan', () => {
' warehouse:',
' driver: mysql',
' url: env:MYSQL_URL',
' readonly: true',
'ingest:',
' adapters:',
' - live-database',
@ -1432,7 +1428,6 @@ describe('local scan', () => {
' database: analytics',
' username: reader',
' password: env:CLICKHOUSE_PASSWORD',
' readonly: true',
'ingest:',
' adapters:',
' - live-database',
@ -1468,7 +1463,6 @@ describe('local scan', () => {
' database: analytics',
' username: reader',
' schema: dbo',
' readonly: true',
'ingest:',
' adapters:',
' - live-database',

View file

@ -24,7 +24,6 @@ async function writeWarehouseConfig(projectDir: string): Promise<void> {
' warehouse:',
' driver: sqlite',
' path: warehouse.db',
' readonly: true',
'ingest:',
' adapters:',
' - live-database',

View file

@ -28,7 +28,6 @@ async function createProject(projectDir: string): Promise<void> {
' warehouse:',
' driver: sqlite',
' path: warehouse.db',
' readonly: true',
'ingest:',
' adapters:',
' - live-database',

View file

@ -14,7 +14,7 @@ describe('compileLocalSlQuery', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-local-query-'));
project = await initKtxProject({ projectDir: join(tempDir, 'project'), projectName: 'warehouse' });
project.config.connections.warehouse = { driver: 'postgres', readonly: true };
project.config.connections.warehouse = { driver: 'postgres' };
await project.fileStore.writeFile(
'semantic-layer/warehouse/orders.yaml',
`name: orders
@ -222,7 +222,7 @@ grain: []
expect(queryExecutor.execute).toHaveBeenCalledWith({
connectionId: 'warehouse',
projectDir: project.projectDir,
connection: { driver: 'postgres', readonly: true },
connection: { driver: 'postgres' },
sql: 'select status, count(*) as order_count from public.orders group by status',
maxRows: 10,
});
@ -248,7 +248,7 @@ grain: []
});
it('requires connectionId when multiple connections are configured', async () => {
project.config.connections.analytics = { driver: 'bigquery', readonly: true };
project.config.connections.analytics = { driver: 'bigquery' };
await expect(
compileLocalSlQuery(project, {