2026-05-17 10:29:07 +02:00
|
|
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
|
|
|
import { tmpdir } from 'node:os';
|
|
|
|
|
import { join } from 'node:path';
|
test: split cli tests from source tree (#216)
* feat(cli): define full warehouse dialect contract
* test(cli): keep dialect edge tests focused
* fix(cli): stabilize dialect contract foundation
* refactor(connectors): own read-only query preparation
* refactor(connectors): resolve dialects through registry
* refactor(connectors): keep concrete dialect classes internal
* chore(workspace): enforce dialect import boundary
* refactor(cli): resolve relationship dialect at scan boundary
* refactor(cli): use dialect display parsing for entity details
* refactor(cli): use dialect display parsing for warehouse catalog
* refactor(cli): use dialect SQL in relationship workflows
* test(cli): verify solid dialect scan workflow closure
* test: split cli tests from source tree
* refactor(cli): standardize BigQuery scope listing
* feat(sqlite): implement connector scope listing
* test(connectors): cover required table listing
* feat(cli): add warehouse driver registry
* refactor(setup): route scope discovery through driver registry
* refactor(cli): route local query execution through driver registry
* refactor(historic-sql): route dialect support through driver registry
* refactor(cli): test warehouse connections through driver registry
* fix(cli): close driver registry type export gaps
* Improve setup daemon diagnostics
* refactor(setup): centralize rail-prefixed diagnostics + query-history fallback
Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput
into clack.ts so the setup wizard, managed daemons, and embedding/agent steps
share one rail-formatted writer. setup-databases.ts also adds a
"disable query history and retry" option when the schema-context build fails
and query history is the likely culprit, surfaced via a new
failed-query-history-unavailable status.
* fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match
The setup picker's KtxTableListEntry was a 2-level { schema, name }, so
qualifiedTableId always wrote db.name into enabled_tables. When BigQuery,
Snowflake, or SQL Server later ran fast ingest, their introspect step filtered
the scope set with scopedTableNames(scope, { catalog: projectId|database, db })
— catalog was non-null on the introspect side but null in the scope refs, so
every entry was rejected, the live-database adapter staged zero table files,
and detect() failed with 'Adapter "live-database" did not recognize fetched
source output'.
Align the picker boundary with the canonical 3-level KtxTableRef:
- Add catalog: string | null to KtxTableListEntry.
- BigQuery/Snowflake/SQL Server listTables populate catalog from the
resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null.
- qualifiedTableId emits catalog.schema.name when catalog is non-null
(resolveEnabledTables already accepts the 3-part shape) and
schemasFromEnabledTables now goes through parseDottedTableEntry so it
recovers the schema correctly from both 2-part and 3-part entries.
- Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker
reuse.
Update listTables expectations in all seven connector tests and the setup /
picker test fixtures. Add a picker regression test that covers the
catalog-bearing round-trip (save + refine).
* fix(cli): allow debug telemetry under opt-out env
2026-05-26 08:49:05 +02:00
|
|
|
import { initKtxProject } from '../src/context/project/project.js';
|
|
|
|
|
import { parseKtxProjectConfig, serializeKtxProjectConfig } from '../src/context/project/config.js';
|
|
|
|
|
import type { KtxScanConnector } from '../src/context/scan/types.js';
|
|
|
|
|
import type { SqlAnalysisPort } from '../src/context/sql-analysis/ports.js';
|
2026-05-17 10:29:07 +02:00
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
test: split cli tests from source tree (#216)
* feat(cli): define full warehouse dialect contract
* test(cli): keep dialect edge tests focused
* fix(cli): stabilize dialect contract foundation
* refactor(connectors): own read-only query preparation
* refactor(connectors): resolve dialects through registry
* refactor(connectors): keep concrete dialect classes internal
* chore(workspace): enforce dialect import boundary
* refactor(cli): resolve relationship dialect at scan boundary
* refactor(cli): use dialect display parsing for entity details
* refactor(cli): use dialect display parsing for warehouse catalog
* refactor(cli): use dialect SQL in relationship workflows
* test(cli): verify solid dialect scan workflow closure
* test: split cli tests from source tree
* refactor(cli): standardize BigQuery scope listing
* feat(sqlite): implement connector scope listing
* test(connectors): cover required table listing
* feat(cli): add warehouse driver registry
* refactor(setup): route scope discovery through driver registry
* refactor(cli): route local query execution through driver registry
* refactor(historic-sql): route dialect support through driver registry
* refactor(cli): test warehouse connections through driver registry
* fix(cli): close driver registry type export gaps
* Improve setup daemon diagnostics
* refactor(setup): centralize rail-prefixed diagnostics + query-history fallback
Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput
into clack.ts so the setup wizard, managed daemons, and embedding/agent steps
share one rail-formatted writer. setup-databases.ts also adds a
"disable query history and retry" option when the schema-context build fails
and query history is the likely culprit, surfaced via a new
failed-query-history-unavailable status.
* fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match
The setup picker's KtxTableListEntry was a 2-level { schema, name }, so
qualifiedTableId always wrote db.name into enabled_tables. When BigQuery,
Snowflake, or SQL Server later ran fast ingest, their introspect step filtered
the scope set with scopedTableNames(scope, { catalog: projectId|database, db })
— catalog was non-null on the introspect side but null in the scope refs, so
every entry was rejected, the live-database adapter staged zero table files,
and detect() failed with 'Adapter "live-database" did not recognize fetched
source output'.
Align the picker boundary with the canonical 3-level KtxTableRef:
- Add catalog: string | null to KtxTableListEntry.
- BigQuery/Snowflake/SQL Server listTables populate catalog from the
resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null.
- qualifiedTableId emits catalog.schema.name when catalog is non-null
(resolveEnabledTables already accepts the 3-part shape) and
schemasFromEnabledTables now goes through parseDottedTableEntry so it
recovers the schema correctly from both 2-part and 3-part entries.
- Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker
reuse.
Update listTables expectations in all seven connector tests and the setup /
picker test fixtures. Add a picker regression test that covers the
catalog-bearing round-trip (save + refine).
* fix(cli): allow debug telemetry under opt-out env
2026-05-26 08:49:05 +02:00
|
|
|
import { runKtxSql } from '../src/sql.js';
|
2026-05-17 10:29:07 +02:00
|
|
|
|
2026-06-05 19:36:21 +02:00
|
|
|
const reportExceptionMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
|
|
|
|
|
|
|
|
vi.mock('../src/telemetry/exception.js', () => ({
|
|
|
|
|
reportException: reportExceptionMock,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-05-22 18:18:47 +02:00
|
|
|
function makeIo(options: { isTTY?: boolean } = {}) {
|
2026-05-17 10:29:07 +02:00
|
|
|
let stdout = '';
|
|
|
|
|
let stderr = '';
|
|
|
|
|
return {
|
|
|
|
|
io: {
|
|
|
|
|
stdout: {
|
2026-05-22 18:18:47 +02:00
|
|
|
isTTY: options.isTTY,
|
2026-05-17 10:29:07 +02:00
|
|
|
write: (chunk: string) => {
|
|
|
|
|
stdout += chunk;
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
stderr: {
|
|
|
|
|
write: (chunk: string) => {
|
|
|
|
|
stderr += chunk;
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
stdout: () => stdout,
|
|
|
|
|
stderr: () => stderr,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeSqlAnalysis(result: Awaited<ReturnType<SqlAnalysisPort['validateReadOnly']>>): SqlAnalysisPort {
|
|
|
|
|
return {
|
|
|
|
|
analyzeForFingerprint: vi.fn(),
|
2026-06-03 17:19:42 +02:00
|
|
|
analyzeBatch: vi.fn(async () => new Map([['cli-sql', { tablesTouched: [{ catalog: null, db: null, name: 'orders' }], columnsByClause: {} }]])),
|
2026-05-17 10:29:07 +02:00
|
|
|
validateReadOnly: vi.fn(async () => result),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeConnector(overrides: Partial<KtxScanConnector> = {}): KtxScanConnector {
|
|
|
|
|
return {
|
|
|
|
|
id: 'sqlite:warehouse',
|
|
|
|
|
driver: 'sqlite',
|
|
|
|
|
capabilities: {
|
|
|
|
|
structuralIntrospection: true,
|
|
|
|
|
tableSampling: true,
|
|
|
|
|
columnSampling: true,
|
|
|
|
|
columnStats: true,
|
|
|
|
|
readOnlySql: true,
|
|
|
|
|
nestedAnalysis: false,
|
|
|
|
|
eventStreamDiscovery: false,
|
|
|
|
|
formalForeignKeys: true,
|
|
|
|
|
estimatedRowCounts: true,
|
|
|
|
|
},
|
|
|
|
|
introspect: vi.fn(),
|
|
|
|
|
executeReadOnly: vi.fn(async () => ({
|
|
|
|
|
headers: ['id', 'status'],
|
|
|
|
|
headerTypes: ['integer', 'text'],
|
|
|
|
|
rows: [
|
|
|
|
|
[1, 'paid'],
|
|
|
|
|
[2, 'open'],
|
|
|
|
|
],
|
|
|
|
|
totalRows: 2,
|
|
|
|
|
rowCount: 2,
|
|
|
|
|
})),
|
|
|
|
|
cleanup: vi.fn(async () => undefined),
|
|
|
|
|
...overrides,
|
test: split cli tests from source tree (#216)
* feat(cli): define full warehouse dialect contract
* test(cli): keep dialect edge tests focused
* fix(cli): stabilize dialect contract foundation
* refactor(connectors): own read-only query preparation
* refactor(connectors): resolve dialects through registry
* refactor(connectors): keep concrete dialect classes internal
* chore(workspace): enforce dialect import boundary
* refactor(cli): resolve relationship dialect at scan boundary
* refactor(cli): use dialect display parsing for entity details
* refactor(cli): use dialect display parsing for warehouse catalog
* refactor(cli): use dialect SQL in relationship workflows
* test(cli): verify solid dialect scan workflow closure
* test: split cli tests from source tree
* refactor(cli): standardize BigQuery scope listing
* feat(sqlite): implement connector scope listing
* test(connectors): cover required table listing
* feat(cli): add warehouse driver registry
* refactor(setup): route scope discovery through driver registry
* refactor(cli): route local query execution through driver registry
* refactor(historic-sql): route dialect support through driver registry
* refactor(cli): test warehouse connections through driver registry
* fix(cli): close driver registry type export gaps
* Improve setup daemon diagnostics
* refactor(setup): centralize rail-prefixed diagnostics + query-history fallback
Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput
into clack.ts so the setup wizard, managed daemons, and embedding/agent steps
share one rail-formatted writer. setup-databases.ts also adds a
"disable query history and retry" option when the schema-context build fails
and query history is the likely culprit, surfaced via a new
failed-query-history-unavailable status.
* fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match
The setup picker's KtxTableListEntry was a 2-level { schema, name }, so
qualifiedTableId always wrote db.name into enabled_tables. When BigQuery,
Snowflake, or SQL Server later ran fast ingest, their introspect step filtered
the scope set with scopedTableNames(scope, { catalog: projectId|database, db })
— catalog was non-null on the introspect side but null in the scope refs, so
every entry was rejected, the live-database adapter staged zero table files,
and detect() failed with 'Adapter "live-database" did not recognize fetched
source output'.
Align the picker boundary with the canonical 3-level KtxTableRef:
- Add catalog: string | null to KtxTableListEntry.
- BigQuery/Snowflake/SQL Server listTables populate catalog from the
resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null.
- qualifiedTableId emits catalog.schema.name when catalog is non-null
(resolveEnabledTables already accepts the 3-part shape) and
schemasFromEnabledTables now goes through parseDottedTableEntry so it
recovers the schema correctly from both 2-part and 3-part entries.
- Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker
reuse.
Update listTables expectations in all seven connector tests and the setup /
picker test fixtures. Add a picker regression test that covers the
catalog-bearing round-trip (save + refine).
* fix(cli): allow debug telemetry under opt-out env
2026-05-26 08:49:05 +02:00
|
|
|
listSchemas: overrides.listSchemas ?? vi.fn(async () => []),
|
|
|
|
|
listTables: overrides.listTables ?? vi.fn(async () => []),
|
2026-05-17 10:29:07 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('runKtxSql', () => {
|
|
|
|
|
let tempDir: string;
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-sql-'));
|
2026-06-05 19:36:21 +02:00
|
|
|
reportExceptionMock.mockClear();
|
2026-05-17 10:29:07 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
2026-05-22 18:18:47 +02:00
|
|
|
vi.unstubAllEnvs();
|
2026-05-17 10:29:07 +02:00
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function writeConnections(
|
|
|
|
|
projectDir: string,
|
|
|
|
|
connections: ReturnType<typeof parseKtxProjectConfig>['connections'],
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
|
|
|
|
|
await writeFile(join(projectDir, 'ktx.yaml'), serializeKtxProjectConfig({ ...config, connections }), 'utf-8');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('validates SQL, executes through the scan connector, and prints a pretty table', async () => {
|
|
|
|
|
const projectDir = join(tempDir, 'project');
|
|
|
|
|
await initKtxProject({ projectDir });
|
|
|
|
|
await writeConnections(projectDir, { warehouse: { driver: 'sqlite', path: 'warehouse.db' } });
|
|
|
|
|
const sqlAnalysis = makeSqlAnalysis({ ok: true, error: null });
|
|
|
|
|
const connector = makeConnector();
|
|
|
|
|
const createScanConnector = vi.fn(async () => connector);
|
|
|
|
|
const io = makeIo();
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runKtxSql(
|
|
|
|
|
{
|
|
|
|
|
command: 'execute',
|
|
|
|
|
projectDir,
|
|
|
|
|
connectionId: 'warehouse',
|
|
|
|
|
sql: 'select id, status from orders',
|
|
|
|
|
maxRows: 1000,
|
|
|
|
|
output: 'pretty',
|
|
|
|
|
json: false,
|
|
|
|
|
cliVersion: '0.0.0-test',
|
|
|
|
|
},
|
|
|
|
|
io.io,
|
|
|
|
|
{
|
|
|
|
|
createSqlAnalysis: () => sqlAnalysis,
|
|
|
|
|
createScanConnector,
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
).resolves.toBe(0);
|
|
|
|
|
|
|
|
|
|
expect(sqlAnalysis.validateReadOnly).toHaveBeenCalledWith('select id, status from orders', 'sqlite');
|
|
|
|
|
expect(createScanConnector).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'warehouse');
|
|
|
|
|
expect(connector.executeReadOnly).toHaveBeenCalledWith(
|
|
|
|
|
{ connectionId: 'warehouse', sql: 'select id, status from orders', maxRows: 1000 },
|
|
|
|
|
{ runId: 'cli-sql' },
|
|
|
|
|
);
|
|
|
|
|
expect(connector.cleanup).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(io.stdout()).toContain('id status');
|
|
|
|
|
expect(io.stdout()).toContain('1 paid');
|
|
|
|
|
expect(io.stdout()).toContain('2 open');
|
|
|
|
|
expect(io.stdout()).toContain('2 rows');
|
|
|
|
|
expect(io.stderr()).toBe('');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-22 18:18:47 +02:00
|
|
|
it('emits debug telemetry for SQL without raw query text', async () => {
|
|
|
|
|
vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
|
|
|
|
|
vi.stubEnv('CI', '');
|
|
|
|
|
const projectDir = join(tempDir, 'project');
|
|
|
|
|
await initKtxProject({ projectDir });
|
|
|
|
|
await writeConnections(projectDir, { warehouse: { driver: 'sqlite', path: 'warehouse.db' } });
|
|
|
|
|
const io = makeIo({ isTTY: true });
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runKtxSql(
|
|
|
|
|
{
|
|
|
|
|
command: 'execute',
|
|
|
|
|
projectDir,
|
|
|
|
|
connectionId: 'warehouse',
|
|
|
|
|
sql: 'select count(*) from orders',
|
|
|
|
|
maxRows: 10,
|
|
|
|
|
output: 'json',
|
|
|
|
|
json: true,
|
|
|
|
|
cliVersion: '0.0.0-test',
|
|
|
|
|
},
|
|
|
|
|
io.io,
|
|
|
|
|
{
|
|
|
|
|
createSqlAnalysis: () => makeSqlAnalysis({ ok: true, error: null }),
|
|
|
|
|
createScanConnector: vi.fn(async () => makeConnector()),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
).resolves.toBe(0);
|
|
|
|
|
|
|
|
|
|
expect(io.stderr()).toContain('"event":"sql_completed"');
|
|
|
|
|
expect(io.stderr()).toContain('"queryVerb":"select"');
|
|
|
|
|
expect(io.stderr()).not.toContain('select count(*)');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-17 10:29:07 +02:00
|
|
|
it('prints JSON output', async () => {
|
|
|
|
|
const projectDir = join(tempDir, 'project');
|
|
|
|
|
await initKtxProject({ projectDir });
|
|
|
|
|
await writeConnections(projectDir, { warehouse: { driver: 'sqlite', path: 'warehouse.db' } });
|
|
|
|
|
const io = makeIo();
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runKtxSql(
|
|
|
|
|
{
|
|
|
|
|
command: 'execute',
|
|
|
|
|
projectDir,
|
|
|
|
|
connectionId: 'warehouse',
|
|
|
|
|
sql: 'select id from orders',
|
|
|
|
|
maxRows: 10,
|
|
|
|
|
output: undefined,
|
|
|
|
|
json: true,
|
|
|
|
|
cliVersion: '0.0.0-test',
|
|
|
|
|
},
|
|
|
|
|
io.io,
|
|
|
|
|
{
|
|
|
|
|
createSqlAnalysis: () => makeSqlAnalysis({ ok: true, error: null }),
|
|
|
|
|
createScanConnector: vi.fn(async () => makeConnector()),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
).resolves.toBe(0);
|
|
|
|
|
|
|
|
|
|
expect(JSON.parse(io.stdout())).toEqual({
|
|
|
|
|
connectionId: 'warehouse',
|
|
|
|
|
headers: ['id', 'status'],
|
|
|
|
|
headerTypes: ['integer', 'text'],
|
|
|
|
|
rows: [
|
|
|
|
|
[1, 'paid'],
|
|
|
|
|
[2, 'open'],
|
|
|
|
|
],
|
|
|
|
|
rowCount: 2,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('prints plain TSV output', async () => {
|
|
|
|
|
const projectDir = join(tempDir, 'project');
|
|
|
|
|
await initKtxProject({ projectDir });
|
|
|
|
|
await writeConnections(projectDir, { warehouse: { driver: 'sqlite', path: 'warehouse.db' } });
|
|
|
|
|
const io = makeIo();
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runKtxSql(
|
|
|
|
|
{
|
|
|
|
|
command: 'execute',
|
|
|
|
|
projectDir,
|
|
|
|
|
connectionId: 'warehouse',
|
|
|
|
|
sql: 'select id from orders',
|
|
|
|
|
maxRows: 10,
|
|
|
|
|
output: 'plain',
|
|
|
|
|
json: false,
|
|
|
|
|
cliVersion: '0.0.0-test',
|
|
|
|
|
},
|
|
|
|
|
io.io,
|
|
|
|
|
{
|
|
|
|
|
createSqlAnalysis: () => makeSqlAnalysis({ ok: true, error: null }),
|
|
|
|
|
createScanConnector: vi.fn(async () => makeConnector()),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
).resolves.toBe(0);
|
|
|
|
|
|
|
|
|
|
expect(io.stdout()).toBe('id\tstatus\n1\tpaid\n2\topen\n');
|
|
|
|
|
expect(io.stderr()).toBe('');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects non-read-only SQL before executing connector SQL', async () => {
|
2026-06-05 19:36:21 +02:00
|
|
|
vi.stubEnv('SQL_DB_PASSWORD', 'sql-db-password'); // pragma: allowlist secret
|
2026-05-17 10:29:07 +02:00
|
|
|
const projectDir = join(tempDir, 'project');
|
|
|
|
|
await initKtxProject({ projectDir });
|
2026-06-05 19:36:21 +02:00
|
|
|
await writeConnections(projectDir, { warehouse: { driver: 'postgres', password: 'env:SQL_DB_PASSWORD' } }); // pragma: allowlist secret
|
2026-05-17 10:29:07 +02:00
|
|
|
const connector = makeConnector();
|
|
|
|
|
const io = makeIo();
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runKtxSql(
|
|
|
|
|
{
|
|
|
|
|
command: 'execute',
|
|
|
|
|
projectDir,
|
|
|
|
|
connectionId: 'warehouse',
|
|
|
|
|
sql: 'delete from orders',
|
|
|
|
|
maxRows: 1000,
|
|
|
|
|
output: 'pretty',
|
|
|
|
|
json: false,
|
|
|
|
|
cliVersion: '0.0.0-test',
|
|
|
|
|
},
|
|
|
|
|
io.io,
|
|
|
|
|
{
|
|
|
|
|
createSqlAnalysis: () => makeSqlAnalysis({ ok: false, error: 'SQL contains read/write operation: Delete' }),
|
|
|
|
|
createScanConnector: vi.fn(async () => connector),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
).resolves.toBe(1);
|
|
|
|
|
|
|
|
|
|
expect(connector.executeReadOnly).not.toHaveBeenCalled();
|
|
|
|
|
expect(connector.cleanup).not.toHaveBeenCalled();
|
|
|
|
|
expect(io.stderr()).toContain('SQL contains read/write operation: Delete');
|
2026-06-05 19:36:21 +02:00
|
|
|
expect(reportExceptionMock).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
context: expect.objectContaining({ source: 'sql run', handled: true, fatal: false }),
|
|
|
|
|
projectDir,
|
|
|
|
|
redactionSecrets: expect.arrayContaining(['sql-db-password']),
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-05-17 10:29:07 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects missing connections', async () => {
|
|
|
|
|
const projectDir = join(tempDir, 'project');
|
|
|
|
|
await initKtxProject({ projectDir });
|
|
|
|
|
const io = makeIo();
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runKtxSql(
|
|
|
|
|
{
|
|
|
|
|
command: 'execute',
|
|
|
|
|
projectDir,
|
|
|
|
|
connectionId: 'warehouse',
|
|
|
|
|
sql: 'select 1',
|
|
|
|
|
maxRows: 1000,
|
|
|
|
|
output: 'pretty',
|
|
|
|
|
json: false,
|
|
|
|
|
cliVersion: '0.0.0-test',
|
|
|
|
|
},
|
|
|
|
|
io.io,
|
|
|
|
|
{
|
|
|
|
|
createSqlAnalysis: () => makeSqlAnalysis({ ok: true, error: null }),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
).resolves.toBe(1);
|
|
|
|
|
|
2026-06-15 14:38:44 +02:00
|
|
|
expect(io.stderr()).toContain(
|
|
|
|
|
'Connection "warehouse" is not configured in ktx.yaml. No connections are configured in ktx.yaml.',
|
|
|
|
|
);
|
2026-05-17 10:29:07 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects connectors without read-only SQL support and still cleans up', async () => {
|
|
|
|
|
const projectDir = join(tempDir, 'project');
|
|
|
|
|
await initKtxProject({ projectDir });
|
|
|
|
|
await writeConnections(projectDir, { warehouse: { driver: 'sqlite', path: 'warehouse.db' } });
|
|
|
|
|
const connector = makeConnector({
|
|
|
|
|
capabilities: {
|
|
|
|
|
...makeConnector().capabilities,
|
|
|
|
|
readOnlySql: false,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const io = makeIo();
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runKtxSql(
|
|
|
|
|
{
|
|
|
|
|
command: 'execute',
|
|
|
|
|
projectDir,
|
|
|
|
|
connectionId: 'warehouse',
|
|
|
|
|
sql: 'select 1',
|
|
|
|
|
maxRows: 1000,
|
|
|
|
|
output: 'pretty',
|
|
|
|
|
json: false,
|
|
|
|
|
cliVersion: '0.0.0-test',
|
|
|
|
|
},
|
|
|
|
|
io.io,
|
|
|
|
|
{
|
|
|
|
|
createSqlAnalysis: () => makeSqlAnalysis({ ok: true, error: null }),
|
|
|
|
|
createScanConnector: vi.fn(async () => connector),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
).resolves.toBe(1);
|
|
|
|
|
|
|
|
|
|
expect(connector.executeReadOnly).not.toHaveBeenCalled();
|
|
|
|
|
expect(connector.cleanup).toHaveBeenCalledTimes(1);
|
feat(duckdb): cross-database federation via derived DuckDB connection (#295)
* feat(duckdb): add @duckdb/node-api dependency for federation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(connectors): extract resolveStringReference to shared module
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(connectors): route all identical connectors through shared resolveStringReference
Collapse the 5 remaining private copies in bigquery, clickhouse, mysql,
snowflake, and sqlserver into the shared module. Fix a latent bug in the
shared module where `~/path` was incorrectly sliced (dropping only `~`,
leaving the leading `/` and making resolve() ignore homedir). Add a
tilde-expansion test that caught the bug and now covers that branch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sl): reserve _ktx_ connection-id prefix for virtual connections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(connections): derive virtual federated connection from compatible members
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(duckdb): federated executor builds READ_ONLY attaches and runs SQL
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(duckdb): close federated DuckDB instance and escape quotes in attach url
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sl): union member source directories for _ktx_federated
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(query): route _ktx_federated through DuckDB executor
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sl): use duckdb dialect for federated query compilation
Bypass assertSafeConnectionId for _ktx_federated in resolveLocalConnectionId
and loadComputableSources, and resolve the compute dialect to 'duckdb' when
connectionId is FEDERATED_CONNECTION_ID instead of falling through to the
default postgres lookup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(duckdb): end-to-end cross-catalog federated join
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(duckdb): harden federated join test with multi-book join-key coverage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(ingest): keep declared cross-DB joins to federated siblings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): surface federated connection availability after adding a member
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(setup): mark federationNoticeFor @internal for dead-code gate
Also marks attachTypeForDriver, buildAttachStatements, and
isReservedConnectionId @internal — all three are exported solely for
unit-test access with no production cross-file consumer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(concepts): document cross-database federation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(concepts): correct sqlite two-part naming in federation doc
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(duckdb): quote federated catalog alias so hyphenated connection ids attach
* refactor(duckdb): single-source federation driver list, dedup attach loads
Collapse the parallel ATTACH_COMPATIBLE_DRIVERS set and ATTACH_TYPE_BY_DRIVER
map into one map in federation.ts whose keys are the membership rule. Replace
FederatedMember.config (read only via a type-erasing cast) with a typed url
field extracted at derive time. Emit INSTALL/LOAD once per distinct driver
type instead of once per member.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(duckdb): close federated DuckDB instance on connect failure; dedup id validation
Wrap the federated DuckDB instance in its own try/finally so a failing
connect() or a throwing connection.closeSync() no longer leaks the native
instance. Route setup-sources connection-id validation through the canonical
assertSafeConnectionId so the reserved _ktx_ prefix guard applies there too.
Derive the federated dialect through sqlAnalysisDialectForDriver instead of a
hardcoded literal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(federation): carry member connection config and projectDir on FederatedMember
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(federation): resolve per-member attach targets via canonical connector resolvers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(federation): quote mysql attach-string values like postgres
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(federation): resolve member attach targets via canonical resolvers, supporting sqlite path:
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(federation): thread projectDir through deriveFederatedConnection callers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(federation): add shared project read-only SQL executor that routes _ktx_federated
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(federation): exercise shared executor default federated path with real DuckDB
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(federation): route ingest query executor through shared executor
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(federation): route MCP sql_execution _ktx_federated through shared executor
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(federation): preserve cross-DB joins to federated siblings in manifest re-emit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(federation): preserve declared cross-DB joins through scan re-ingest
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(federation): document sibling-ref invariant, drop unsafe casts in test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(federation): namespace federated source names by member to avoid collisions
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(federation): document member-namespaced federated source names
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(federation): preserve member SSL/search_path in attach, classify federated MCP errors
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(federation): simplify federated dispatch and parallelize sibling reads
Dedup the federated driver ternary in local-query, derive the prefixed
source.name from the already-built name, drop the duplicated error in
federatedAttachTarget's exhaustive switch, inline the one-line
cleanupConnector wrapper, and parallelize federatedSiblingTargets' shard
reads (was sequential await-in-for on the scan hot path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(federation): carry headerTypes through shared SQL executor
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(federation): add shared federated connection listing builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(federation): route ktx sql through shared executor for _ktx_federated parity
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(federation): show _ktx_federated in ktx connection list
Surfaces the virtual federated connection in the output of
`ktx connection list` so agents and users can discover cross-database
querying when 2+ attach-compatible connections are configured.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(federation): surface _ktx_federated in MCP connection_list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(federation): ktx sql federated cross-file join end-to-end
Drive runKtxSql with the real federated DuckDB executor against two on-disk
sqlite files, stubbing only SQL validation. The test surfaced that the JSON
output path could not serialize bigint values DuckDB returns for integer
columns; printJson now coerces bigint to JSON numbers, matching the
plain/pretty paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(federation): document direct _ktx_federated query surface
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(federation): coerce DuckDB bigint to number in shared federated executor
DuckDB returns integer columns as JS bigint, which JSON.stringify cannot
serialize. The CLI --json path worked around this with a replacer, but the
MCP sql_execution tool serializes via plain JSON.stringify and crashed on
any federated query selecting an integer column. Coerce bigint to Number
once in executeFederatedQuery so every consumer (CLI, MCP, ingest, SL)
gets a JSON-safe result, and remove the now-redundant CLI replacer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(federation): simplify driver map and collapse forked MCP SQL path
- Replace the identity-valued ATTACH_TYPE_BY_DRIVER record with a
ATTACH_COMPATIBLE_DRIVERS Set; the driver name doubles as the attach
type, so the map encoded nothing beyond membership.
- Switch federatedAttachTarget directly on the driver with a default
throw, dropping the unreachable post-switch throw and its comment.
- Route the MCP sql_execution standard-connection case through the
shared executeProjectReadOnlySql instead of reimplementing the
connector create/capability-check/execute/cleanup ceremony, so
federated and standard connections share one execution path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(federation): allowlist placeholder credentials for detect-secrets
The federation doc example URL and the federated-attach test fixtures use
literal placeholder credentials that trip detect-secrets. Mark them with
line-scoped pragma allowlist comments so a real secret added later is still
caught.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(federation): correct SL addressing, join pruning, and id-quoting guidance
- Federated SL list/search records carry the virtual `_ktx_federated`
connection id (member origin stays in the prefixed source name), so rows
round-trip to `ktx sl -c _ktx_federated read` and the fts index no longer
clobbers per-connection partitions.
- Prune semantic-layer joins by membership in the connection's own source set
instead of matching the target's first dotted segment against other
connection ids; a same-connection join whose target name collides with a
sibling connection id is preserved, and orphan targets that would poison the
planner are dropped.
- Document double-quoting for connection ids that are not bare SQL identifiers
(e.g. "books-db".public.books) in the federated naming hint, the sl-query
rejection error, and the federation docs.
- Preserve exact federated BIGINT values beyond 2^53 as strings instead of
rounding, and steer the setup federation notice to raw SQL against
`_ktx_federated`.
* fix(federation): carry ssl:true into postgres URL attach target
A postgres member configured with `url` plus `ssl: true` resolved to both a
connectionString and an ssl flag, but the federated attach builder early-returned
the bare URL and dropped the ssl intent. DuckDB then handed libpq a URL with no
sslmode, so the URL path silently diverged from the discrete-field path (which
emits sslmode=require) and from the direct scan path (which enforces TLS).
Append sslmode=require to the URL when the member sets ssl, unless the URL
already pins a stronger sslmode.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Andrey Avtomonov <andreybavt@gmail.com>
2026-06-15 22:01:39 +07:00
|
|
|
expect(io.stderr()).toContain('does not support read-only SQL execution.');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('routes _ktx_federated through the shared federated executor', async () => {
|
|
|
|
|
const projectDir = join(tempDir, 'project');
|
|
|
|
|
await initKtxProject({ projectDir });
|
|
|
|
|
await writeConnections(projectDir, {
|
|
|
|
|
books_db: { driver: 'sqlite', path: 'books.db' },
|
|
|
|
|
reviews_db: { driver: 'sqlite', path: 'reviews.db' },
|
|
|
|
|
});
|
|
|
|
|
const executeFederated = vi.fn(async () => ({
|
|
|
|
|
headers: ['title', 'rating'],
|
|
|
|
|
rows: [['Clean Code', 5]],
|
|
|
|
|
totalRows: 1,
|
|
|
|
|
command: 'SELECT',
|
|
|
|
|
rowCount: 1,
|
|
|
|
|
}));
|
|
|
|
|
const memberConnector = makeConnector({
|
|
|
|
|
executeReadOnly: vi.fn(async () => {
|
|
|
|
|
throw new Error('member connector must not be used for federated id');
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
const io = makeIo();
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
runKtxSql(
|
|
|
|
|
{
|
|
|
|
|
command: 'execute',
|
|
|
|
|
projectDir,
|
|
|
|
|
connectionId: '_ktx_federated',
|
|
|
|
|
sql: 'select 1',
|
|
|
|
|
maxRows: 100,
|
|
|
|
|
output: 'json',
|
|
|
|
|
json: true,
|
|
|
|
|
cliVersion: '0.0.0-test',
|
|
|
|
|
},
|
|
|
|
|
io.io,
|
|
|
|
|
{
|
|
|
|
|
createSqlAnalysis: () => makeSqlAnalysis({ ok: true, error: null }),
|
|
|
|
|
createScanConnector: vi.fn(async () => memberConnector),
|
|
|
|
|
executeFederated,
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
).resolves.toBe(0);
|
|
|
|
|
|
|
|
|
|
expect(executeFederated).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(memberConnector.executeReadOnly).not.toHaveBeenCalled();
|
|
|
|
|
expect(JSON.parse(io.stdout())).toEqual({
|
|
|
|
|
connectionId: '_ktx_federated',
|
|
|
|
|
headers: ['title', 'rating'],
|
|
|
|
|
rows: [['Clean Code', 5]],
|
|
|
|
|
rowCount: 1,
|
|
|
|
|
});
|
2026-05-17 10:29:07 +02:00
|
|
|
});
|
|
|
|
|
});
|