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>
This commit is contained in:
Kevin Messiaen 2026-06-15 22:01:39 +07:00 committed by GitHub
parent b81391cd9f
commit 6c815ef529
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 2608 additions and 271 deletions

View file

@ -0,0 +1,66 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { runKtxConnection } from '../src/connection.js';
import { initKtxProject } from '../src/context/project/project.js';
import { parseKtxProjectConfig, serializeKtxProjectConfig } from '../src/context/project/config.js';
import type { KtxProjectConnectionConfig } from '../src/context/project/config.js';
function makeIo() {
const out: string[] = [];
return {
io: {
stdout: { isTTY: false, write: (c: string) => { out.push(c); return true; } },
stderr: { write: () => true },
},
stdout: () => out.join(''),
};
}
async function writeConnections(
projectDir: string,
connections: Record<string, KtxProjectConnectionConfig>,
): Promise<void> {
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
await writeFile(join(projectDir, 'ktx.yaml'), serializeKtxProjectConfig({ ...config, connections }), 'utf-8');
}
describe('ktx connection list federated entry', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-conn-fed-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('shows _ktx_federated when 2+ attach-compatible connections exist', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
books_db: { driver: 'sqlite' },
reviews_db: { driver: 'sqlite' },
});
const io = makeIo();
const code = await runKtxConnection({ command: 'list', projectDir }, io.io);
const printed = io.stdout();
expect(code).toBe(0);
expect(printed).toContain('_ktx_federated');
expect(printed).toContain('books_db, reviews_db');
expect(printed).toContain('Cross-database queries run here');
});
it('omits _ktx_federated with a single connection', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeConnections(projectDir, {
books_db: { driver: 'sqlite' },
});
const io = makeIo();
await runKtxConnection({ command: 'list', projectDir }, io.io);
expect(io.stdout()).not.toContain('_ktx_federated');
});
});

View file

@ -0,0 +1,143 @@
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { federatedAttachTarget } from '../../../src/connectors/duckdb/federated-attach.js';
import type { FederatedMember } from '../../../src/context/connections/federation.js';
const member = (over: Partial<FederatedMember>): FederatedMember => ({
connectionId: 'm',
driver: 'sqlite',
projectDir: '/proj',
connection: { driver: 'sqlite' },
...over,
});
describe('federatedAttachTarget', () => {
it('resolves a sqlite path: config to an absolute filesystem path against projectDir', () => {
const dir = mkdtempSync(join(tmpdir(), 'ktx-attach-'));
writeFileSync(join(dir, 'reviews.db'), '');
try {
const target = federatedAttachTarget(
member({ driver: 'sqlite', projectDir: dir, connection: { driver: 'sqlite', path: './reviews.db' } }),
{},
);
expect(target).toBe(join(dir, 'reviews.db'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('resolves a sqlite file:// url to a filesystem path', () => {
const target = federatedAttachTarget(
member({ driver: 'sqlite', connection: { driver: 'sqlite', url: 'file:///data/reviews.db' } }),
{},
);
expect(target).toBe('/data/reviews.db');
});
it('builds a libpq connection string for postgres from host/database/user', () => {
const target = federatedAttachTarget(
member({
driver: 'postgres',
connection: { driver: 'postgres', host: 'h', port: 5433, database: 'books', username: 'u', password: 'p' },
}),
{},
);
expect(target).toContain('host=h');
expect(target).toContain('port=5433');
expect(target).toContain('dbname=books');
expect(target).toContain('user=u');
expect(target).toContain('password=p');
});
it('passes a postgres url through as the connection string', () => {
const target = federatedAttachTarget(
member({ driver: 'postgres', connection: { driver: 'postgres', url: 'env:PG_URL' } }),
{ PG_URL: 'postgresql://localhost/books' },
);
expect(target).toBe('postgresql://localhost/books');
});
it('adds sslmode=require to a postgres url when the member sets ssl', () => {
const target = federatedAttachTarget(
member({ driver: 'postgres', connection: { driver: 'postgres', url: 'env:PG_URL', ssl: true } }),
{ PG_URL: 'postgresql://localhost/books' },
);
expect(target).toContain('sslmode=require');
});
it('keeps a stronger sslmode already pinned in a postgres url', () => {
const target = federatedAttachTarget(
member({ driver: 'postgres', connection: { driver: 'postgres', url: 'env:PG_URL', ssl: true } }),
{ PG_URL: 'postgresql://localhost/books?sslmode=verify-full' },
);
expect(target).toContain('sslmode=verify-full');
expect(target).not.toContain('sslmode=require');
});
it('builds a mysql connection string from host/database/user', () => {
const target = federatedAttachTarget(
member({
driver: 'mysql',
connection: { driver: 'mysql', host: 'h', port: 3307, database: 'app', username: 'u', password: 'p' },
}),
{},
);
expect(target).toContain('host=h');
expect(target).toContain('port=3307');
expect(target).toContain('database=app');
expect(target).toContain('user=u');
expect(target).toContain('password=p');
});
it('quotes mysql values containing spaces', () => {
const target = federatedAttachTarget(
member({
driver: 'mysql',
connection: { driver: 'mysql', host: 'h', database: 'app', username: 'u', password: 'pass word' }, // pragma: allowlist secret
}),
{},
);
expect(target).toContain("password='pass word'"); // pragma: allowlist secret
});
it('emits sslmode=require for a postgres member configured with discrete fields and ssl', () => {
const target = federatedAttachTarget(
member({
driver: 'postgres',
connection: { driver: 'postgres', host: 'h', database: 'db', username: 'u', ssl: true },
}),
{},
);
expect(target).toContain('sslmode=require');
});
it('passes through the postgres search_path as options', () => {
const target = federatedAttachTarget(
member({
driver: 'postgres',
connection: { driver: 'postgres', host: 'h', database: 'db', username: 'u', schema: 'analytics' },
}),
{},
);
expect(target).toContain('search_path=analytics');
});
it('emits ssl_mode=REQUIRED for a mysql member with ssl', () => {
const target = federatedAttachTarget(
member({
driver: 'mysql',
connection: { driver: 'mysql', host: 'h', database: 'db', username: 'u', ssl: true },
}),
{},
);
expect(target).toContain('ssl_mode=REQUIRED');
});
it('throws for an unsupported driver', () => {
expect(() => federatedAttachTarget(member({ driver: 'snowflake', connection: { driver: 'snowflake' } }), {})).toThrow(
/cannot be attached/i,
);
});
});

View file

@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { buildAttachStatements } from '../../../src/connectors/duckdb/federated-executor.js';
import { attachTypeForDriver, type FederatedMember } from '../../../src/context/connections/federation.js';
const member = (
connectionId: string,
driver: string,
connection: FederatedMember['connection'],
): FederatedMember => ({ connectionId, driver, projectDir: '/proj', connection });
describe('attachTypeForDriver', () => {
it('maps drivers to DuckDB attach extension types', () => {
expect(attachTypeForDriver('postgres')).toBe('postgres');
expect(attachTypeForDriver('mysql')).toBe('mysql');
expect(attachTypeForDriver('sqlite')).toBe('sqlite');
});
it('throws for an unsupported driver', () => {
expect(() => attachTypeForDriver('snowflake')).toThrow(/cannot be attached/i);
});
});
describe('buildAttachStatements', () => {
it('loads each driver type once, then emits READ_ONLY ATTACH aliased by connectionId, resolving env refs', () => {
const stmts = buildAttachStatements(
[
member('pg_books', 'postgres', { driver: 'postgres', url: 'env:PG_URL' }),
member('sqlite_reviews', 'sqlite', { driver: 'sqlite', path: '/data/reviews.db' }),
],
{ PG_URL: 'postgresql://localhost/books' },
);
expect(stmts).toEqual([
'INSTALL postgres; LOAD postgres;',
'INSTALL sqlite; LOAD sqlite;',
'ATTACH \'postgresql://localhost/books\' AS "pg_books" (TYPE postgres, READ_ONLY);',
'ATTACH \'/data/reviews.db\' AS "sqlite_reviews" (TYPE sqlite, READ_ONLY);',
]);
});
it('loads a shared driver type only once across members', () => {
const stmts = buildAttachStatements(
[
member('pg_a', 'postgres', { driver: 'postgres', url: 'postgresql://h/a' }),
member('pg_b', 'postgres', { driver: 'postgres', url: 'postgresql://h/b' }),
],
{},
);
expect(stmts).toEqual([
'INSTALL postgres; LOAD postgres;',
'ATTACH \'postgresql://h/a\' AS "pg_a" (TYPE postgres, READ_ONLY);',
'ATTACH \'postgresql://h/b\' AS "pg_b" (TYPE postgres, READ_ONLY);',
]);
});
it('quotes a hyphenated connection id as a DuckDB identifier', () => {
const stmts = buildAttachStatements(
[member('postgres-warehouse', 'postgres', { driver: 'postgres', url: 'postgresql://h/db' })],
{},
);
expect(stmts.at(-1)).toBe(`ATTACH 'postgresql://h/db' AS "postgres-warehouse" (TYPE postgres, READ_ONLY);`);
});
it('escapes single quotes in a resolved attach target', () => {
const stmts = buildAttachStatements(
[member('pg', 'postgres', { driver: 'postgres', url: "postgresql://u:it's@h/db" })],
{},
);
expect(stmts.at(-1)).toBe('ATTACH \'postgresql://u:it\'\'s@h/db\' AS "pg" (TYPE postgres, READ_ONLY);');
});
});

View file

@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { executeFederatedQuery } from '../../../src/connectors/duckdb/federated-executor.js';
import type { FederatedMember } from '../../../src/context/connections/federation.js';
describe('federated cross-catalog join (live DuckDB)', () => {
it('joins two sqlite catalogs and enforces read-only', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ktx-fed-'));
const booksPath = join(dir, 'books.db');
const reviewsPath = join(dir, 'reviews.db');
const books = new Database(booksPath);
books.exec("CREATE TABLE books (id INTEGER, title TEXT); INSERT INTO books VALUES (1, 'Dune'), (2, 'Foundation');");
books.close();
const reviews = new Database(reviewsPath);
reviews.exec('CREATE TABLE reviews (book_id INTEGER, stars INTEGER); INSERT INTO reviews VALUES (1, 5), (1, 4), (2, 2);');
reviews.close();
const members: FederatedMember[] = [
{ connectionId: 'books_db', driver: 'sqlite', projectDir: dir, connection: { driver: 'sqlite', path: booksPath } },
{ connectionId: 'reviews_db', driver: 'sqlite', projectDir: dir, connection: { driver: 'sqlite', path: reviewsPath } },
];
try {
const result = await executeFederatedQuery(members, {
connectionId: '_ktx_federated',
connection: undefined,
sql: 'SELECT b.title, AVG(r.stars) AS avg_stars FROM books_db.books b JOIN reviews_db.reviews r ON b.id = r.book_id GROUP BY b.title ORDER BY b.title',
});
expect(result.headers).toEqual(['title', 'avg_stars']);
// ORDER BY title: Dune, Foundation
expect(result.rows.map((row) => row[0])).toEqual(['Dune', 'Foundation']);
expect(Number(result.rows[0][1])).toBeCloseTo(4.5); // Dune: (5+4)/2
expect(Number(result.rows[1][1])).toBeCloseTo(2.0); // Foundation: 2/1
await expect(
executeFederatedQuery(members, {
connectionId: '_ktx_federated',
connection: undefined,
sql: "INSERT INTO books_db.books VALUES (2, 'Hack')",
}),
).rejects.toThrow(/read-only/i);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('returns integer columns as JSON-safe numbers, not bigint', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ktx-fed-bigint-'));
const booksPath = join(dir, 'books.db');
const reviewsPath = join(dir, 'reviews.db');
const books = new Database(booksPath);
books.exec("CREATE TABLE books (id INTEGER, title TEXT); INSERT INTO books VALUES (1, 'Dune'), (2, 'Foundation');");
books.close();
const reviews = new Database(reviewsPath);
reviews.exec('CREATE TABLE reviews (book_id INTEGER, stars INTEGER); INSERT INTO reviews VALUES (1, 5), (1, 4), (2, 2);');
reviews.close();
const members: FederatedMember[] = [
{ connectionId: 'books_db', driver: 'sqlite', projectDir: dir, connection: { driver: 'sqlite', path: booksPath } },
{ connectionId: 'reviews_db', driver: 'sqlite', projectDir: dir, connection: { driver: 'sqlite', path: reviewsPath } },
];
try {
const result = await executeFederatedQuery(members, {
connectionId: '_ktx_federated',
connection: undefined,
sql: 'SELECT b.id, count(*) AS n FROM books_db.books b JOIN reviews_db.reviews r ON b.id = r.book_id GROUP BY b.id ORDER BY b.id',
});
for (const row of result.rows) {
for (const cell of row) {
expect(typeof cell).not.toBe('bigint');
}
}
expect(() => JSON.stringify(result)).not.toThrow();
expect(result.rows[0][0]).toBe(1);
expect(Number(result.rows[0][1])).toBeGreaterThan(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('preserves a BIGINT beyond 2^53 as an exact string instead of rounding', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ktx-fed-bigint-large-'));
const idsPath = join(dir, 'ids.db');
const otherPath = join(dir, 'other.db');
const ids = new Database(idsPath);
// 9007199254740993 = 2^53 + 1, which rounds to ...992 as a JS number; the
// literal lives in SQL text so sqlite stores it exactly.
ids.exec('CREATE TABLE ids (big_id INTEGER); INSERT INTO ids VALUES (9007199254740993);');
ids.close();
const other = new Database(otherPath);
other.exec('CREATE TABLE t (x INTEGER); INSERT INTO t VALUES (1);');
other.close();
const members: FederatedMember[] = [
{ connectionId: 'ids_db', driver: 'sqlite', projectDir: dir, connection: { driver: 'sqlite', path: idsPath } },
{ connectionId: 'other_db', driver: 'sqlite', projectDir: dir, connection: { driver: 'sqlite', path: otherPath } },
];
try {
const result = await executeFederatedQuery(members, {
connectionId: '_ktx_federated',
connection: undefined,
sql: 'SELECT big_id FROM ids_db.ids',
});
expect(result.rows[0][0]).toBe('9007199254740993');
expect(() => JSON.stringify(result)).not.toThrow();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('joins catalogs whose connection ids contain hyphens', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ktx-fed-hyphen-'));
const booksPath = join(dir, 'books.db');
const reviewsPath = join(dir, 'reviews.db');
const books = new Database(booksPath);
books.exec("CREATE TABLE books (id INTEGER, title TEXT); INSERT INTO books VALUES (1, 'Dune');");
books.close();
const reviews = new Database(reviewsPath);
reviews.exec('CREATE TABLE reviews (book_id INTEGER, stars INTEGER); INSERT INTO reviews VALUES (1, 5), (1, 3);');
reviews.close();
const members: FederatedMember[] = [
{ connectionId: 'books-db', driver: 'sqlite', projectDir: dir, connection: { driver: 'sqlite', path: booksPath } },
{ connectionId: 'reviews-db', driver: 'sqlite', projectDir: dir, connection: { driver: 'sqlite', path: reviewsPath } },
];
try {
const result = await executeFederatedQuery(members, {
connectionId: '_ktx_federated',
connection: undefined,
sql: 'SELECT b.title, AVG(r.stars) AS avg_stars FROM "books-db".books b JOIN "reviews-db".reviews r ON b.id = r.book_id GROUP BY b.title',
});
expect(result.rows[0][0]).toBe('Dune');
expect(Number(result.rows[0][1])).toBeCloseTo(4.0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { resolveStringReference } from '../../../src/connectors/shared/string-reference.js';
describe('resolveStringReference', () => {
it('returns plain values unchanged', () => {
expect(resolveStringReference('postgres://localhost/db', {})).toBe('postgres://localhost/db');
});
it('resolves env: references from the provided env', () => {
expect(resolveStringReference('env:MY_URL', { MY_URL: 'resolved-url' })).toBe('resolved-url');
});
it('returns empty string for a missing env var', () => {
expect(resolveStringReference('env:NOPE', {})).toBe('');
});
it('resolves file: references and trims whitespace', () => {
const dir = mkdtempSync(join(tmpdir(), 'ktx-strref-'));
const file = join(dir, 'secret.txt');
writeFileSync(file, ' hunter2\n');
try {
expect(resolveStringReference(`file:${file}`, {})).toBe('hunter2');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('expands ~ in file: references to the home directory', () => {
const name = `.ktx-strref-test-${process.pid}.txt`;
const abs = join(homedir(), name);
writeFileSync(abs, 'tilde-secret\n');
try {
expect(resolveStringReference(`file:~/${name}`, {})).toBe('tilde-secret');
} finally {
rmSync(abs, { force: true });
}
});
});

View file

@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import {
deriveFederatedConnection,
federatedConnectionListing,
FEDERATED_CONNECTION_ID,
} from '../../../src/context/connections/federation.js';
const conns = (entries: Record<string, { driver: string; [k: string]: unknown }>) => entries as never;
describe('deriveFederatedConnection', () => {
it('returns null with zero compatible members', () => {
expect(deriveFederatedConnection(conns({ snow: { driver: 'snowflake' } }), '/proj')).toBeNull();
});
it('returns null with exactly one compatible member', () => {
expect(deriveFederatedConnection(conns({ pg: { driver: 'postgres' } }), '/proj')).toBeNull();
});
it('derives a descriptor with two compatible members', () => {
const result = deriveFederatedConnection(
conns({ pg: { driver: 'postgres' }, lite: { driver: 'sqlite' } }),
'/proj',
);
expect(result).not.toBeNull();
expect(result?.id).toBe(FEDERATED_CONNECTION_ID);
expect(result?.driver).toBe('duckdb');
expect(result?.members.map((m) => m.connectionId).sort()).toEqual(['lite', 'pg']);
});
it('carries each member connection config and projectDir', () => {
const result = deriveFederatedConnection(
conns({ pg: { driver: 'postgres', host: 'h' }, lite: { driver: 'sqlite', path: './a.db' } }),
'/proj',
);
const pg = result?.members.find((m) => m.connectionId === 'pg');
expect(pg?.connection).toEqual({ driver: 'postgres', host: 'h' });
expect(pg?.projectDir).toBe('/proj');
});
it('excludes incompatible members from the group', () => {
const result = deriveFederatedConnection(
conns({ pg: { driver: 'postgres' }, my: { driver: 'mysql' }, snow: { driver: 'snowflake' } }),
'/proj',
);
expect(result?.members.map((m) => m.connectionId).sort()).toEqual(['my', 'pg']);
});
it('is case-insensitive on driver names', () => {
const result = deriveFederatedConnection(
conns({ pg: { driver: 'POSTGRES' }, lite: { driver: 'SQLite' } }),
'/proj',
);
expect(result?.members).toHaveLength(2);
});
});
describe('federatedConnectionListing', () => {
it('returns null with fewer than 2 attach-compatible connections', () => {
expect(
federatedConnectionListing({ books_db: { driver: 'sqlite', path: './b.db' } }, '/tmp/p'),
).toBeNull();
});
it('returns id, driver, member ids and a usage hint with 2+ members', () => {
const listing = federatedConnectionListing(
{
books_db: { driver: 'sqlite', path: './b.db' },
reviews_db: { driver: 'sqlite', path: './r.db' },
snow: { driver: 'snowflake', account: 'x' },
},
'/tmp/p',
);
expect(listing).not.toBeNull();
expect(listing!.id).toBe(FEDERATED_CONNECTION_ID);
expect(listing!.driver).toBe('duckdb');
expect(listing!.members).toEqual(['books_db', 'reviews_db']);
expect(listing!.hint).toContain('Cross-database');
expect(listing!.hint).toContain('connectionId.table');
});
});

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { executeProjectReadOnlySql } from '../../../src/context/connections/project-sql-executor.js';
import type { KtxLocalProject } from '../../../src/context/project/project.js';
function fakeProject(projectDir: string, connections: Record<string, { driver: string; path: string }>): KtxLocalProject {
return {
projectDir,
configPath: join(projectDir, 'ktx.yaml'),
config: { connections } as unknown as KtxLocalProject['config'],
coreConfig: {} as KtxLocalProject['coreConfig'],
git: {} as KtxLocalProject['git'],
fileStore: {} as KtxLocalProject['fileStore'],
};
}
describe('executeProjectReadOnlySql — federated integration (real DuckDB)', () => {
it('runs a federated cross-catalog join through the default executeFederatedQuery', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ktx-fed-exec-'));
const booksPath = join(dir, 'books.db');
const reviewsPath = join(dir, 'reviews.db');
const books = new Database(booksPath);
books.exec("CREATE TABLE books (id INTEGER, title TEXT); INSERT INTO books VALUES (1, 'Dune'), (2, 'Foundation');");
books.close();
const reviews = new Database(reviewsPath);
reviews.exec('CREATE TABLE reviews (book_id INTEGER, stars INTEGER); INSERT INTO reviews VALUES (1, 5), (1, 4), (2, 2);');
reviews.close();
const project = fakeProject(dir, {
books_db: { driver: 'sqlite', path: booksPath },
reviews_db: { driver: 'sqlite', path: reviewsPath },
});
try {
const result = await executeProjectReadOnlySql({
project,
input: {
connectionId: '_ktx_federated',
connection: undefined,
sql: 'SELECT b.title, AVG(r.stars) AS avg_stars FROM books_db.books b JOIN reviews_db.reviews r ON b.id = r.book_id GROUP BY b.title ORDER BY b.title',
maxRows: 100,
},
createConnector: () => {
throw new Error('federated path must not create a scan connector');
},
});
expect(result.rows.map((row) => row[0])).toEqual(['Dune', 'Foundation']);
expect(Number(result.rows[0][1])).toBeCloseTo(4.5);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,116 @@
import { describe, expect, it, vi } from 'vitest';
import type { executeFederatedQuery } from '../../../src/connectors/duckdb/federated-executor.js';
import { executeProjectReadOnlySql } from '../../../src/context/connections/project-sql-executor.js';
import type { KtxLocalProject } from '../../../src/context/project/project.js';
import type { KtxScanConnector } from '../../../src/context/scan/types.js';
function fakeProject(connections: Record<string, { driver: string }>): KtxLocalProject {
return {
projectDir: '/tmp/proj',
configPath: '/tmp/proj/ktx.yaml',
config: { connections } as unknown as KtxLocalProject['config'],
coreConfig: {} as KtxLocalProject['coreConfig'],
git: {} as KtxLocalProject['git'],
fileStore: {} as KtxLocalProject['fileStore'],
};
}
describe('executeProjectReadOnlySql — federated routing', () => {
it('routes _ktx_federated through the federated executor with derived members', async () => {
const project = fakeProject({ pg: { driver: 'postgres' }, lite: { driver: 'sqlite' } });
const executeFederated = vi.fn<typeof executeFederatedQuery>(async () => ({
headers: ['x'],
rows: [[1]],
totalRows: 1,
command: 'SELECT',
rowCount: 1,
}));
const createConnector = vi.fn();
const result = await executeProjectReadOnlySql({
project,
input: { connectionId: '_ktx_federated', connection: undefined, sql: 'SELECT 1', maxRows: 100 },
createConnector: createConnector as never,
executeFederated,
});
expect(result.rows).toEqual([[1]]);
expect(executeFederated).toHaveBeenCalledOnce();
const members = executeFederated.mock.calls[0][0];
expect(members.map((m) => m.connectionId).sort()).toEqual(['lite', 'pg']);
expect(createConnector).not.toHaveBeenCalled();
});
it('throws when _ktx_federated requested but fewer than 2 compatible members', async () => {
const project = fakeProject({ pg: { driver: 'postgres' } });
await expect(
executeProjectReadOnlySql({
project,
input: { connectionId: '_ktx_federated', connection: undefined, sql: 'SELECT 1', maxRows: 100 },
createConnector: (() => {
throw new Error('should not be called');
}) as never,
executeFederated: vi.fn(),
}),
).rejects.toThrow(/fewer than 2/i);
});
it('routes a normal connection through the scan connector', async () => {
const project = fakeProject({ pg: { driver: 'postgres' } });
const connector = {
driver: 'postgres',
capabilities: { readOnlySql: true },
executeReadOnly: vi.fn(async () => ({ headers: ['a'], rows: [['v']], totalRows: 1, rowCount: 1 })),
cleanup: vi.fn(async () => {}),
};
const result = await executeProjectReadOnlySql({
project,
input: { connectionId: 'pg', connection: { driver: 'postgres' }, sql: 'SELECT a', maxRows: 50 },
createConnector: (async () => connector) as never,
executeFederated: vi.fn(),
});
expect(result.rows).toEqual([['v']]);
expect(connector.executeReadOnly).toHaveBeenCalledOnce();
expect(connector.cleanup).toHaveBeenCalledOnce();
});
});
function connectorReturning(result: {
headers: string[];
headerTypes?: string[];
rows: unknown[][];
totalRows: number;
rowCount: number | null;
}): KtxScanConnector {
return {
driver: 'sqlite',
capabilities: { readOnlySql: true },
async executeReadOnly() {
return result;
},
} as unknown as KtxScanConnector;
}
describe('executeProjectReadOnlySql headerTypes', () => {
it('forwards connector headerTypes on the non-federated branch', async () => {
const project = {
projectDir: '/tmp/p',
config: { connections: { books_db: { driver: 'sqlite', path: './b.db' } } },
} as never;
const result = await executeProjectReadOnlySql({
project,
input: { connectionId: 'books_db', connection: undefined, sql: 'SELECT 1', maxRows: 10 },
createConnector: () =>
connectorReturning({
headers: ['id'],
headerTypes: ['INTEGER'],
rows: [[1]],
totalRows: 1,
rowCount: 1,
}),
});
expect(result.headerTypes).toEqual(['INTEGER']);
});
});

View file

@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { buildJoinsByTable, buildLiveDatabaseManifestShards } from '../../../src/context/ingest/adapters/live-database/manifest.js';
const joinData = (toTable: string) => ({
fromTable: 'books',
fromColumns: ['id'],
toTable,
toColumns: ['book_id'],
relationship: 'one_to_many',
source: 'manual' as const,
});
describe('buildJoinsByTable federated siblings', () => {
it('keeps a forward join whose target is a federated sibling table', () => {
const result = buildJoinsByTable(
new Set(['books']), // current snapshot
[joinData('sqlite_reviews.reviews')], // target NOT local
new Map(),
new Set(['sqlite_reviews.reviews']), // federated sibling targets
);
expect(result.get('books')?.map((j) => j.to)).toEqual(['sqlite_reviews.reviews']);
// The sibling target must NOT get a reverse entry (it has no shard in this snapshot)
expect(result.get('sqlite_reviews.reviews')).toBeUndefined();
});
it('still drops a join whose target is neither local nor a sibling', () => {
const result = buildJoinsByTable(new Set(['books']), [joinData('ghost')], new Map(), new Set());
expect(result.get('books')).toBeUndefined();
});
it('keeps both directions for a fully-local join (unchanged behavior)', () => {
const result = buildJoinsByTable(new Set(['books', 'authors']), [joinData('authors')], new Map(), new Set());
expect(result.get('books')?.map((j) => j.to)).toEqual(['authors']);
expect(result.get('authors')?.map((j) => j.to)).toEqual(['books']); // reverse still added for local joins
});
});
describe('buildLiveDatabaseManifestShards federated preserved joins', () => {
it('keeps a preserved manual join whose target is a federated sibling', () => {
const result = buildLiveDatabaseManifestShards({
connectionType: 'POSTGRES',
tables: [{ name: 'books', catalog: null, db: 'public', columns: [{ name: 'id', type: 'int' }] }],
joins: [],
existingPreservedJoins: new Map([
[
'books',
[{ to: 'sqlite_reviews.reviews', on: 'books.id = reviews.book_id', relationship: 'one_to_many', source: 'manual' }],
],
]),
federatedSiblingTargets: new Set(['sqlite_reviews.reviews']),
mapColumnType: (t) => t,
});
const shard = result.shards.get('public');
expect(shard?.tables.books?.joins?.map((j) => j.to)).toEqual(['sqlite_reviews.reviews']);
});
it('still drops a preserved join whose target is neither local nor a sibling', () => {
const result = buildLiveDatabaseManifestShards({
connectionType: 'POSTGRES',
tables: [{ name: 'books', catalog: null, db: 'public', columns: [{ name: 'id', type: 'int' }] }],
joins: [],
existingPreservedJoins: new Map([
['books', [{ to: 'ghost', on: 'books.id = ghost.id', relationship: 'one_to_many', source: 'manual' }]],
]),
federatedSiblingTargets: new Set(),
mapColumnType: (t) => t,
});
expect(result.shards.get('public')?.tables.books?.joins).toBeUndefined();
});
});

View file

@ -2,7 +2,7 @@
{
"name": "connection_list",
"title": "Connection List",
"description": "List configured read-only data connections available to this ktx project. Use this before connection-scoped tools when the project may have multiple warehouses.",
"description": "List configured read-only data connections available to this ktx project. Use this before connection-scoped tools when the project may have multiple warehouses. A \"_ktx_federated\" entry (when present) queries all its member databases together; use its id for cross-database joins.",
"inputSchema": {
"type": "object",
"properties": {},
@ -24,6 +24,15 @@
},
"connectionType": {
"type": "string"
},
"members": {
"type": "array",
"items": {
"type": "string"
}
},
"hint": {
"type": "string"
}
},
"required": [

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { createLocalProjectMcpContextPorts } from '../../../src/context/mcp/local-project-ports.js';
const project = {
projectDir: '/tmp/p',
config: {
connections: {
books_db: { driver: 'sqlite', path: './b.db' },
reviews_db: { driver: 'sqlite', path: './r.db' },
},
},
} as never;
describe('MCP connection_list federated entry', () => {
it('includes _ktx_federated with members and hint', async () => {
const ports = createLocalProjectMcpContextPorts(project, { embeddingService: null });
const list = await ports.connections!.list();
const federated = list.find((c) => c.id === '_ktx_federated');
expect(federated).toBeDefined();
expect(federated!.connectionType).toBe('DUCKDB');
expect(federated!.members).toEqual(['books_db', 'reviews_db']);
expect(federated!.hint).toContain('Cross-database');
});
it('omits _ktx_federated with a single connection', async () => {
const single = {
projectDir: '/tmp/p',
config: { connections: { books_db: { driver: 'sqlite', path: './b.db' } } },
} as never;
const ports = createLocalProjectMcpContextPorts(single, { embeddingService: null });
const list = await ports.connections!.list();
expect(list.find((c) => c.id === '_ktx_federated')).toBeUndefined();
});
});

View file

@ -0,0 +1,99 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { describe, expect, it, vi } from 'vitest';
import { createLocalProjectMcpContextPorts } from '../../../src/context/mcp/local-project-ports.js';
import { initKtxProject } from '../../../src/context/project/project.js';
describe('MCP sql_execution — federated routing (live DuckDB)', () => {
it('routes _ktx_federated through the shared federated executor, validating with the duckdb dialect', async () => {
const dir = await mkdtemp(join(tmpdir(), 'ktx-mcp-fed-'));
try {
const booksPath = join(dir, 'books.db');
const reviewsPath = join(dir, 'reviews.db');
const books = new Database(booksPath);
books.exec("CREATE TABLE books (id INTEGER, title TEXT); INSERT INTO books VALUES (1, 'Dune');");
books.close();
const reviews = new Database(reviewsPath);
reviews.exec('CREATE TABLE reviews (book_id INTEGER, stars INTEGER); INSERT INTO reviews VALUES (1, 5), (1, 3);');
reviews.close();
const project = await initKtxProject({ projectDir: dir });
project.config.connections.books_db = { driver: 'sqlite', path: booksPath };
project.config.connections.reviews_db = { driver: 'sqlite', path: reviewsPath };
const validateReadOnly = vi.fn(async () => ({ ok: true, error: null }));
const ports = createLocalProjectMcpContextPorts(project, {
sqlAnalysis: {
analyzeForFingerprint: vi.fn(),
analyzeBatch: vi.fn(),
validateReadOnly,
} as never,
localScan: {
createConnector: () => {
throw new Error('federated path must not create a scan connector');
},
},
embeddingService: null,
});
const result = await ports.sqlExecution?.execute({
connectionId: '_ktx_federated',
sql: 'SELECT b.title, AVG(r.stars) AS avg_stars FROM books_db.books b JOIN reviews_db.reviews r ON b.id = r.book_id GROUP BY b.title',
maxRows: 100,
});
expect(result?.rows?.[0]?.[0]).toBe('Dune');
// Federated validation uses the duckdb dialect, not a member driver.
expect(validateReadOnly).toHaveBeenCalledWith(expect.any(String), 'duckdb');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('serializes integer columns from a federated query without throwing on bigint', async () => {
const dir = await mkdtemp(join(tmpdir(), 'ktx-mcp-fed-int-'));
try {
const booksPath = join(dir, 'books.db');
const reviewsPath = join(dir, 'reviews.db');
const books = new Database(booksPath);
books.exec("CREATE TABLE books (id INTEGER, title TEXT); INSERT INTO books VALUES (1, 'Dune');");
books.close();
const reviews = new Database(reviewsPath);
reviews.exec('CREATE TABLE reviews (book_id INTEGER, stars INTEGER); INSERT INTO reviews VALUES (1, 5), (1, 3);');
reviews.close();
const project = await initKtxProject({ projectDir: dir });
project.config.connections.books_db = { driver: 'sqlite', path: booksPath };
project.config.connections.reviews_db = { driver: 'sqlite', path: reviewsPath };
const validateReadOnly = vi.fn(async () => ({ ok: true, error: null }));
const ports = createLocalProjectMcpContextPorts(project, {
sqlAnalysis: {
analyzeForFingerprint: vi.fn(),
analyzeBatch: vi.fn(),
validateReadOnly,
} as never,
localScan: {
createConnector: () => {
throw new Error('federated path must not create a scan connector');
},
},
embeddingService: null,
});
const result = await ports.sqlExecution?.execute({
connectionId: '_ktx_federated',
sql: 'SELECT b.title, count(*) AS n FROM books_db.books b JOIN reviews_db.reviews r ON b.id = r.book_id GROUP BY b.title',
maxRows: 100,
});
expect(() => JSON.stringify(result)).not.toThrow();
expect(result?.rows?.[0]?.[0]).toBe('Dune');
expect(result?.rows?.[0]?.[1]).toBe(2);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,130 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import YAML from 'yaml';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { buildDefaultKtxProjectConfig } from '../../../src/context/project/config.js';
import type { GitService } from '../../../src/context/core/git.service.js';
import { LocalGitFileStore } from '../../../src/context/project/local-git-file-store.js';
import type { KtxLocalProject } from '../../../src/context/project/project.js';
import { writeLocalScanManifestShards } from '../../../src/context/scan/local-enrichment-artifacts.js';
import type { KtxSchemaSnapshot } from '../../../src/context/scan/types.js';
// `writeLocalScanManifestShards` commits its output via git; the file is
// already on disk before the commit call, so the stub only returns commit info.
const stubGitCommitFile: Pick<GitService, 'commitFile'> = {
commitFile: async () => ({
commitHash: 'stub',
shortHash: 'stub',
message: 'stub',
author: 'ktx',
authorEmail: 'ktx@example.com',
timestamp: new Date().toISOString(),
committedDate: new Date().toISOString(),
created: true,
}),
};
const stubGit = stubGitCommitFile as GitService;
function fakeProject(projectDir: string, connections: KtxLocalProject['config']['connections']): KtxLocalProject {
const fileStore = new LocalGitFileStore({ rootDir: projectDir, git: stubGit });
return {
projectDir,
configPath: join(projectDir, 'ktx.yaml'),
config: { ...buildDefaultKtxProjectConfig(), connections },
coreConfig: {} as KtxLocalProject['coreConfig'],
git: stubGit,
fileStore,
};
}
const EXISTING_BOOKS_SHARD = `tables:
books:
table: public.books
columns:
- name: id
type: number
pk: true
joins:
- to: sqlite_reviews.reviews
on: books.id = reviews.book_id
relationship: one_to_many
source: manual
`;
const booksSnapshot: KtxSchemaSnapshot = {
connectionId: 'pg_books',
driver: 'postgres',
extractedAt: new Date().toISOString(),
scope: {},
metadata: {},
tables: [
{
name: 'books',
catalog: null,
db: 'public',
kind: 'table',
comment: null,
estimatedRows: null,
columns: [
{
name: 'id',
nativeType: 'integer',
normalizedType: 'integer',
dimensionType: 'number',
nullable: false,
primaryKey: true,
comment: null,
},
],
foreignKeys: [],
},
],
};
describe('writeLocalScanManifestShards federated cross-DB joins', () => {
let tempDir: string;
let project: KtxLocalProject;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-enrich-fed-'));
project = fakeProject(join(tempDir, 'project'), {
pg_books: { driver: 'postgres' },
sqlite_reviews: { driver: 'sqlite' },
});
await project.fileStore.writeFile(
'semantic-layer/pg_books/_schema/public.yaml',
EXISTING_BOOKS_SHARD,
'ktx',
'ktx@example.com',
'seed',
{ skipLock: true },
);
await project.fileStore.writeFile(
'semantic-layer/sqlite_reviews/_schema/main.yaml',
'tables:\n reviews:\n table: reviews\n columns:\n - name: book_id\n type: number\n',
'ktx',
'ktx@example.com',
'seed',
{ skipLock: true },
);
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('preserves a manual cross-DB join to a sqlite sibling across a re-scan', async () => {
await writeLocalScanManifestShards({
project,
connectionId: 'pg_books',
syncId: 'sync1',
driver: 'postgres',
snapshot: booksSnapshot,
dryRun: false,
});
const { content } = await project.fileStore.readFile('semantic-layer/pg_books/_schema/public.yaml');
const shard = YAML.parse(content) as { tables: Record<string, { joins?: Array<{ to: string }> }> };
expect(shard.tables.books?.joins?.map((j) => j.to)).toEqual(['sqlite_reviews.reviews']);
});
});

View file

@ -0,0 +1,111 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { buildDefaultKtxProjectConfig } from '../../../src/context/project/config.js';
import { executeProjectReadOnlySql } from '../../../src/context/connections/project-sql-executor.js';
import type { GitService } from '../../../src/context/core/git.service.js';
import { LocalGitFileStore } from '../../../src/context/project/local-git-file-store.js';
import type { KtxLocalProject } from '../../../src/context/project/project.js';
import { loadLocalSlSourceRecords } from '../../../src/context/sl/local-sl.js';
const BOOKS_MANIFEST = `tables:
books:
table: main.books
columns:
- name: id
type: number
pk: true
- name: title
type: string
`;
const REVIEWS_MANIFEST = `tables:
reviews:
table: main.reviews
columns:
- name: book_id
type: number
pk: true
- name: stars
type: number
`;
// On-disk file store only (no git init/commit) so manifest seeding never hits
// the gpg-signing path; connections also carry real sqlite paths so the
// federated executor can attach them.
function fakeProject(projectDir: string, connections: KtxLocalProject['config']['connections']): KtxLocalProject {
const fileStore = new LocalGitFileStore({ rootDir: projectDir, git: {} as GitService });
const config = { ...buildDefaultKtxProjectConfig(), connections };
return {
projectDir,
configPath: join(projectDir, 'ktx.yaml'),
config,
coreConfig: {} as KtxLocalProject['coreConfig'],
git: {} as GitService,
fileStore,
};
}
async function seedManifest(project: KtxLocalProject, path: string, content: string): Promise<void> {
await project.fileStore.writeFile(path, content, 'ktx', 'ktx@example.com', 'seed manifest', { skipLock: true });
}
describe('federated SL source loading and physical execution (real DuckDB)', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-local-query-fed-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('namespaces source names while keeping physical table refs, and executes against them', async () => {
const projectDir = join(tempDir, 'project');
const booksPath = join(tempDir, 'books.db');
const reviewsPath = join(tempDir, 'reviews.db');
const books = new Database(booksPath);
books.exec("CREATE TABLE books (id INTEGER, title TEXT); INSERT INTO books VALUES (1, 'Dune'), (2, 'Foundation');");
books.close();
const reviews = new Database(reviewsPath);
reviews.exec('CREATE TABLE reviews (book_id INTEGER, stars INTEGER); INSERT INTO reviews VALUES (1, 5), (1, 4), (2, 2);');
reviews.close();
const project = fakeProject(projectDir, {
sqlite_books: { driver: 'sqlite', path: booksPath },
sqlite_reviews: { driver: 'sqlite', path: reviewsPath },
});
await seedManifest(project, 'semantic-layer/sqlite_books/_schema/main.yaml', BOOKS_MANIFEST);
await seedManifest(project, 'semantic-layer/sqlite_reviews/_schema/main.yaml', REVIEWS_MANIFEST);
// (a) Name-vs-physical separation: federated loading namespaces source.name
// by member id while source.table stays the unprefixed physical ref.
const records = await loadLocalSlSourceRecords(project, { connectionId: '_ktx_federated' });
const byName = new Map(records.map((record) => [record.source.name, record.source.table]));
expect([...byName.keys()].sort()).toEqual(['sqlite_books.books', 'sqlite_reviews.reviews']);
expect(byName.get('sqlite_books.books')).toBe('main.books');
expect(byName.get('sqlite_reviews.reviews')).toBe('main.reviews');
// (b) Physical targeting end-to-end: a federated query joining the two
// attached catalogs by their connectionId-prefixed physical refs returns
// the correct joined rows through live DuckDB.
const result = await executeProjectReadOnlySql({
project,
input: {
connectionId: '_ktx_federated',
connection: undefined,
sql: 'SELECT b.title, AVG(r.stars) AS avg_stars FROM sqlite_books.books b JOIN sqlite_reviews.reviews r ON b.id = r.book_id GROUP BY b.title ORDER BY b.title',
maxRows: 100,
},
createConnector: () => {
throw new Error('federated path must not create a scan connector');
},
});
expect(result.rows.map((row) => row[0])).toEqual(['Dune', 'Foundation']);
expect(Number(result.rows[0][1])).toBeCloseTo(4.5);
});
});

View file

@ -0,0 +1,207 @@
import { describe, expect, it, vi } from 'vitest';
import type { KtxSemanticLayerComputePort } from '../../../src/context/daemon/semantic-layer-compute.js';
import type { KtxLocalProject } from '../../../src/context/project/project.js';
import { compileLocalSlQuery } from '../../../src/context/sl/local-query.js';
function makeFakeProject(): KtxLocalProject {
const fileStore = {
listFiles: vi.fn(async () => ({ files: [] })),
readFile: vi.fn(async () => ({ content: '' })),
writeFile: vi.fn(async () => ({})),
deleteFile: vi.fn(async () => ({})),
fileHistory: vi.fn(async () => []),
headCommit: vi.fn(async () => null),
} as unknown as KtxLocalProject['fileStore'];
return {
projectDir: '/tmp/fake-ktx-project',
configPath: '/tmp/fake-ktx-project/ktx.yaml',
config: {
connections: {
pg_books: { driver: 'postgres' },
sqlite_reviews: { driver: 'sqlite' },
},
storage: { state: 'sqlite', search: 'sqlite-fts5', git: {} },
llm: {},
ingest: {},
agent: {},
scan: {},
} as unknown as KtxLocalProject['config'],
coreConfig: {} as KtxLocalProject['coreConfig'],
git: {} as KtxLocalProject['git'],
fileStore,
};
}
function makeFakeProjectWithFiles(
connections: Record<string, { driver: string }>,
files: Record<string, string>,
): KtxLocalProject {
const fileStore = {
listFiles: vi.fn(async (dir: string) => ({
files: Object.keys(files).filter((path) => path.startsWith(`${dir}/`)),
})),
readFile: vi.fn(async (path: string) => ({ content: files[path] ?? '' })),
writeFile: vi.fn(async () => ({})),
deleteFile: vi.fn(async () => ({})),
fileHistory: vi.fn(async () => []),
headCommit: vi.fn(async () => null),
} as unknown as KtxLocalProject['fileStore'];
return {
projectDir: '/tmp/fake-ktx-project',
configPath: '/tmp/fake-ktx-project/ktx.yaml',
config: {
connections,
storage: { state: 'sqlite', search: 'sqlite-fts5', git: {} },
llm: {},
ingest: {},
agent: {},
scan: {},
} as unknown as KtxLocalProject['config'],
coreConfig: {} as KtxLocalProject['coreConfig'],
git: {} as KtxLocalProject['git'],
fileStore,
};
}
function makeFakeCompute(): KtxSemanticLayerComputePort & {
lastDialect: string | undefined;
lastSources: Array<{ name: string; joins?: Array<{ to: string }> }> | undefined;
} {
const fake = {
lastDialect: undefined as string | undefined,
lastSources: undefined as Array<{ name: string; joins?: Array<{ to: string }> }> | undefined,
query: vi.fn(async (input: { dialect: string; query: unknown; sources: unknown[] }) => {
fake.lastDialect = input.dialect;
fake.lastSources = input.sources as Array<{ name: string; joins?: Array<{ to: string }> }>;
return {
sql: 'select 1',
dialect: input.dialect,
columns: [],
plan: { measures: [], dimensions: [] },
};
}),
validateSources: vi.fn(),
generateSources: vi.fn(),
};
return fake;
}
describe('compileLocalSlQuery — federated connection', () => {
it('rejects federated queries and points to raw SQL', async () => {
const project = makeFakeProject();
const compute = makeFakeCompute();
await expect(
compileLocalSlQuery(project, {
connectionId: '_ktx_federated',
query: { measures: [], dimensions: [] },
compute,
execute: false,
}),
).rejects.toThrow(/_ktx_federated[\s\S]*ktx sql/);
// The compute adapter must never be invoked for a federated query.
expect(compute.query).not.toHaveBeenCalled();
});
it('still uses the driver dialect for a normal connection', async () => {
const project = makeFakeProject();
const compute = makeFakeCompute();
await compileLocalSlQuery(project, {
connectionId: 'pg_books',
query: { measures: [], dimensions: [] },
compute,
execute: false,
});
expect(compute.lastDialect).toBe('postgres');
});
it('drops a cross-connection join target so a member query is not poisoned', async () => {
// A preserved cross-DB join (to: sqlite_reviews.reviews) would otherwise be
// an orphan target the planner rejects, breaking every pg_books SL query.
const manifest = `tables:
books:
table: public.books
columns:
- name: id
type: number
pk: true
- name: author_id
type: number
joins:
- to: sqlite_reviews.reviews
on: books.id = reviews.book_id
relationship: one_to_many
- to: authors
on: books.author_id = authors.id
relationship: many_to_one
authors:
table: public.authors
columns:
- name: id
type: number
pk: true
`;
const project = makeFakeProjectWithFiles(
{ pg_books: { driver: 'postgres' }, sqlite_reviews: { driver: 'sqlite' } },
{ 'semantic-layer/pg_books/_schema/public.yaml': manifest },
);
const compute = makeFakeCompute();
await compileLocalSlQuery(project, {
connectionId: 'pg_books',
query: { measures: [], dimensions: [] },
compute,
execute: false,
});
expect(compute.query).toHaveBeenCalledTimes(1);
const books = compute.lastSources?.find((source) => source.name === 'books');
// The same-connection join survives; only the federated-sibling target is dropped.
expect(books?.joins?.map((join) => join.to)).toEqual(['authors']);
});
it('keeps a same-connection join whose target name collides with another connection id', async () => {
// Connection ids and source names share a vocabulary, so a sibling connection
// can be named `authors` while a same-connection source is also `authors`. The
// join target resolves within the connection and must not be pruned.
const manifest = `tables:
books:
table: public.books
columns:
- name: id
type: number
pk: true
- name: author_id
type: number
joins:
- to: authors
on: books.author_id = authors.id
relationship: many_to_one
authors:
table: public.authors
columns:
- name: id
type: number
pk: true
`;
const project = makeFakeProjectWithFiles(
{ pg_books: { driver: 'postgres' }, authors: { driver: 'postgres' } },
{ 'semantic-layer/pg_books/_schema/public.yaml': manifest },
);
const compute = makeFakeCompute();
await compileLocalSlQuery(project, {
connectionId: 'pg_books',
query: { measures: [], dimensions: [] },
compute,
execute: false,
});
const books = compute.lastSources?.find((source) => source.name === 'books');
expect(books?.joins?.map((join) => join.to)).toEqual(['authors']);
});
});

View file

@ -0,0 +1,108 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { buildDefaultKtxProjectConfig } from '../../../src/context/project/config.js';
import type { GitService } from '../../../src/context/core/git.service.js';
import { LocalGitFileStore } from '../../../src/context/project/local-git-file-store.js';
import type { KtxLocalProject } from '../../../src/context/project/project.js';
import { loadLocalSlSourceRecords } from '../../../src/context/sl/local-sl.js';
const BOOKS_MANIFEST = `tables:
books:
table: public.books
columns:
- name: book_id
type: number
pk: true
- name: title
type: string
`;
const REVIEWS_MANIFEST = `tables:
reviews:
table: main.reviews
columns:
- name: review_id
type: number
pk: true
- name: rating
type: number
`;
// Build a project backed only by an on-disk file store (no git init, no
// commit), so the fixture never hits the gpg-signing path during init.
function fakeProject(projectDir: string, connections: KtxLocalProject['config']['connections']): KtxLocalProject {
const fileStore = new LocalGitFileStore({ rootDir: projectDir, git: {} as GitService });
const config = { ...buildDefaultKtxProjectConfig(), connections };
return {
projectDir,
configPath: join(projectDir, 'ktx.yaml'),
config,
coreConfig: {} as KtxLocalProject['coreConfig'],
git: {} as GitService,
fileStore,
};
}
// `skipLock: true` writes the file to disk without committing, avoiding git.
async function seedManifest(project: KtxLocalProject, path: string, content: string): Promise<void> {
await project.fileStore.writeFile(path, content, 'ktx', 'ktx@example.com', 'seed manifest', { skipLock: true });
}
describe('federated semantic-layer source loading', () => {
let tempDir: string;
let project: KtxLocalProject;
let singleMemberProject: KtxLocalProject;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-local-sl-fed-'));
project = fakeProject(join(tempDir, 'project'), {
pg_books: { driver: 'postgres' },
sqlite_reviews: { driver: 'sqlite' },
});
await seedManifest(project, 'semantic-layer/pg_books/_schema/public.yaml', BOOKS_MANIFEST);
await seedManifest(project, 'semantic-layer/sqlite_reviews/_schema/main.yaml', REVIEWS_MANIFEST);
singleMemberProject = fakeProject(join(tempDir, 'single'), {
pg_books: { driver: 'postgres' },
});
await seedManifest(singleMemberProject, 'semantic-layer/pg_books/_schema/public.yaml', BOOKS_MANIFEST);
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('namespaces member source records by connection id for _ktx_federated', async () => {
const records = await loadLocalSlSourceRecords(project, { connectionId: '_ktx_federated' });
const names = records.map((r) => r.source.name).sort();
expect(names).toEqual(['pg_books.books', 'sqlite_reviews.reviews']);
});
it('keeps colliding member table names distinct via namespacing', async () => {
const collide = fakeProject(join(tempDir, 'collide'), {
pg_a: { driver: 'postgres' },
sqlite_b: { driver: 'sqlite' },
});
const usersManifest = `tables:\n users:\n table: public.users\n columns:\n - name: id\n type: number\n`;
await seedManifest(collide, 'semantic-layer/pg_a/_schema/public.yaml', usersManifest);
await seedManifest(collide, 'semantic-layer/sqlite_b/_schema/main.yaml', usersManifest);
const records = await loadLocalSlSourceRecords(collide, { connectionId: '_ktx_federated' });
expect(records.map((r) => r.source.name).sort()).toEqual(['pg_a.users', 'sqlite_b.users']);
});
it('tags member records with the virtual federated connection id so reads round-trip', async () => {
const records = await loadLocalSlSourceRecords(project, { connectionId: '_ktx_federated' });
// The federated connection owns no directory and is addressed by one virtual
// id; the member-prefixed names (asserted above) prove the union read from
// member dirs, so the (connectionId, name) pair resolves back via `sl read`.
expect(records.map((r) => r.connectionId)).toEqual(['_ktx_federated', '_ktx_federated']);
});
it('returns empty for _ktx_federated when fewer than 2 compatible members', async () => {
const records = await loadLocalSlSourceRecords(singleMemberProject, { connectionId: '_ktx_federated' });
expect(records).toEqual([]);
});
});

View file

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { assertSafeConnectionId, isReservedConnectionId } from '../../../src/context/sl/source-files.js';
describe('reserved connection ids', () => {
it('flags _ktx_ prefixed ids as reserved', () => {
expect(isReservedConnectionId('_ktx_federated')).toBe(true);
expect(isReservedConnectionId('_ktx_anything')).toBe(true);
});
it('does not flag normal ids', () => {
expect(isReservedConnectionId('pg_books')).toBe(false);
expect(isReservedConnectionId('sqlite_reviews')).toBe(false);
});
it('rejects a user-supplied reserved id', () => {
expect(() => assertSafeConnectionId('_ktx_federated')).toThrow(/reserved/i);
});
it('still accepts normal ids', () => {
expect(assertSafeConnectionId('pg_books')).toBe('pg_books');
});
});

View file

@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
import { createKtxCliIngestQueryExecutor } from '../src/ingest-query-executor.js';
describe('federated query executor routing', () => {
it('routes _ktx_federated to the DuckDB federated executor, not a single connector', async () => {
const project = {
projectDir: '/tmp/x',
config: { connections: { pg: { driver: 'postgres', url: 'env:PG' }, lite: { driver: 'sqlite', url: '/x.db' } } },
} as never;
const federatedSpy = vi.fn(async () => ({
headers: ['n'], rows: [[1]], totalRows: 1, command: 'SELECT', rowCount: 1,
}));
const executor = createKtxCliIngestQueryExecutor(project, { executeFederated: federatedSpy });
const result = await executor.execute({
connectionId: '_ktx_federated',
connection: undefined,
sql: 'select 1 as n',
});
expect(federatedSpy).toHaveBeenCalledOnce();
expect(result.totalRows).toBe(1);
});
it('throws if _ktx_federated requested but fewer than 2 compatible members', async () => {
const project = {
projectDir: '/tmp/x',
config: { connections: { pg: { driver: 'postgres', url: 'env:PG' } } },
} as never;
const executor = createKtxCliIngestQueryExecutor(project, { executeFederated: vi.fn() });
await expect(
executor.execute({ connectionId: '_ktx_federated', connection: undefined, sql: 'select 1' }),
).rejects.toThrow(/2 attach-compatible/i);
});
});

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { federationNoticeFor } from '../src/setup-databases.js';
describe('federationNoticeFor', () => {
it('returns a notice naming members when 2+ compatible exist', () => {
const notice = federationNoticeFor({
pg_books: { driver: 'postgres' },
sqlite_reviews: { driver: 'sqlite' },
} as never, '/proj');
expect(notice).toMatch(/pg_books/);
expect(notice).toMatch(/sqlite_reviews/);
expect(notice).toMatch(/cross-database/i);
// Cross-DB joins via a source's `joins:` list are unsupported; the notice
// must steer users to raw SQL against the federated connection instead.
expect(notice).toMatch(/_ktx_federated/);
expect(notice).not.toMatch(/joins:/);
});
it('returns null with fewer than 2 compatible', () => {
expect(federationNoticeFor({ pg: { driver: 'postgres' } } as never, '/proj')).toBeNull();
});
it('returns null when the second db is incompatible', () => {
expect(
federationNoticeFor({ pg: { driver: 'postgres' }, snow: { driver: 'snowflake' } } as never, '/proj'),
).toBeNull();
});
});

View file

@ -0,0 +1,90 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { parseKtxProjectConfig, serializeKtxProjectConfig } from '../src/context/project/config.js';
import { initKtxProject } from '../src/context/project/project.js';
import type { SqlAnalysisPort } from '../src/context/sql-analysis/ports.js';
import type { KtxCliIo } from '../src/cli-runtime.js';
import { runKtxSql } from '../src/sql.js';
function fakeIo(): { io: KtxCliIo; out: () => string; err: () => string } {
let out = '';
let err = '';
return {
io: {
stdout: { write: (chunk: string) => ((out += chunk), true) },
stderr: { write: (chunk: string) => ((err += chunk), true) },
} as unknown as KtxCliIo,
out: () => out,
err: () => err,
};
}
// Validation needs the Python daemon, unavailable in unit tests; execution is real.
const stubSqlAnalysis: SqlAnalysisPort = {
analyzeForFingerprint: async () => ({ fingerprint: '', normalizedSql: '', tablesTouched: [], literalSlots: [] }),
analyzeBatch: async () => new Map([['cli-sql', { tablesTouched: [], columnsByClause: {} }]]),
validateReadOnly: async () => ({ ok: true, error: null }),
};
describe('ktx sql federated integration', () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'ktx-fed-int-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it('joins books and reviews across two sqlite files', async () => {
const projectDir = join(dir, 'project');
await initKtxProject({ projectDir });
const books = new Database(join(projectDir, 'books.db'));
books.exec("CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT); INSERT INTO books VALUES (1, 'Clean Code');");
books.close();
const reviews = new Database(join(projectDir, 'reviews.db'));
reviews.exec('CREATE TABLE reviews (id INTEGER PRIMARY KEY, book_id INTEGER, rating INTEGER); INSERT INTO reviews VALUES (1, 1, 5);');
reviews.close();
const config = parseKtxProjectConfig(await readFile(join(projectDir, 'ktx.yaml'), 'utf-8'));
await writeFile(
join(projectDir, 'ktx.yaml'),
serializeKtxProjectConfig({
...config,
connections: {
books_db: { driver: 'sqlite', path: 'books.db' },
reviews_db: { driver: 'sqlite', path: 'reviews.db' },
},
}),
'utf-8',
);
const { io, out, err } = fakeIo();
const code = await runKtxSql(
{
command: 'execute',
projectDir,
connectionId: '_ktx_federated',
sql: 'SELECT b.title, r.rating FROM books_db.books b JOIN reviews_db.reviews r ON b.id = r.book_id',
maxRows: 100,
json: true,
cliVersion: 'test',
},
io,
{ createSqlAnalysis: () => stubSqlAnalysis },
);
expect(code, err()).toBe(0);
const payload = JSON.parse(out()) as { connectionId: string; headers: string[]; rows: unknown[][] };
expect(payload.connectionId).toBe('_ktx_federated');
expect(payload.headers).toEqual(['title', 'rating']);
expect(payload.rows).toHaveLength(1);
expect(payload.rows[0][0]).toBe('Clean Code');
expect(Number(payload.rows[0][1])).toBe(5);
});
});

View file

@ -345,6 +345,58 @@ describe('runKtxSql', () => {
expect(connector.executeReadOnly).not.toHaveBeenCalled();
expect(connector.cleanup).toHaveBeenCalledTimes(1);
expect(io.stderr()).toContain('Connection "warehouse" does not support read-only SQL execution.');
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,
});
});
});