refactor: remove legacy ktx compatibility shims (#211)

* refactor: remove legacy ktx compatibility shims

* fix: restore overlay collision guidance
This commit is contained in:
Andrey Avtomonov 2026-05-24 16:57:23 +02:00 committed by GitHub
parent a954a29a76
commit 96952fb43c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 294 additions and 342 deletions

View file

@ -166,8 +166,8 @@ agent also needs pinned `ktx` admin commands.
After setup, **ktx** prints **Required before using agents** with the exact After setup, **ktx** prints **Required before using agents** with the exact
commands to run. If the output includes `ktx mcp start --project-dir ...`, run commands to run. If the output includes `ktx mcp start --project-dir ...`, run
it before opening your agent. Claude Desktop uses its own launcher and prints it before opening your agent. Claude Desktop gets a stdio MCP config entry and
separate skill upload steps under `.ktx/agents/claude/`. prints separate skill upload steps under `.ktx/agents/claude/`.
## Workspace layout ## Workspace layout

View file

@ -105,7 +105,7 @@ context-source drivers share the map.
| Driver | Kind | Required fields | Common optional fields | | Driver | Kind | Required fields | Common optional fields |
|--------|------|-----------------|------------------------| |--------|------|-----------------|------------------------|
| `postgres` / `postgresql` | Warehouse | `driver` | `url`, `enabled_tables`, `historicSql`, `context.queryHistory` | | `postgres` | Warehouse | `driver` | `url`, `enabled_tables`, `historicSql`, `context.queryHistory` |
| `mysql` | Warehouse | `driver` | `url`, `enabled_tables` | | `mysql` | Warehouse | `driver` | `url`, `enabled_tables` |
| `sqlite` | Warehouse | `driver` | `url` or `path`, `enabled_tables` | | `sqlite` | Warehouse | `driver` | `url` or `path`, `enabled_tables` |
| `sqlserver` | Warehouse | `driver` | `url`, `enabled_tables` | | `sqlserver` | Warehouse | `driver` | `url`, `enabled_tables` |

View file

@ -183,10 +183,8 @@ Claude Desktop skill packages for the **ktx** workflows:
- `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or - `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or
`%AppData%/Claude/claude_desktop_config.json` (Windows) gets an `%AppData%/Claude/claude_desktop_config.json` (Windows) gets an
`mcpServers.ktx` entry that runs the **ktx** MCP server over stdio via a local `mcpServers.ktx` entry that runs the **ktx** MCP server over stdio with the
launcher shim at `.ktx/agents/claude/ktx-plugin-runner.sh`. The shim locates current Node.js executable and the installed `ktx` CLI entrypoint.
a usable Node.js (Volta, NVM, Homebrew, system) so Claude Desktop can spawn
the server without needing `node` in PATH.
- `.ktx/agents/claude/ktx-analytics.zip` contains the `ktx-analytics` skill. - `.ktx/agents/claude/ktx-analytics.zip` contains the `ktx-analytics` skill.
If you choose **Ask data questions + manage ktx with CLI commands**, **ktx** also If you choose **Ask data questions + manage ktx with CLI commands**, **ktx** also
generates `.ktx/agents/claude/ktx.zip` with the admin `ktx` skill. Claude generates `.ktx/agents/claude/ktx.zip` with the admin `ktx` skill. Claude

View file

@ -274,9 +274,7 @@ async function testConnectionByDriver(
if ( if (
driver === 'sqlite' || driver === 'sqlite' ||
driver === 'sqlite3' ||
driver === 'postgres' || driver === 'postgres' ||
driver === 'postgresql' ||
driver === 'mysql' || driver === 'mysql' ||
driver === 'clickhouse' || driver === 'clickhouse' ||
driver === 'sqlserver' || driver === 'sqlserver' ||

View file

@ -99,7 +99,7 @@ function metadataResults(): Map<string, FakeQueryResult> {
describe('KtxPostgresScanConnector', () => { describe('KtxPostgresScanConnector', () => {
it('resolves configuration safely', () => { it('resolves configuration safely', () => {
expect(isKtxPostgresConnectionConfig({ driver: 'postgres', url: 'env:DATABASE_URL' })).toBe(true); expect(isKtxPostgresConnectionConfig({ driver: 'postgres', url: 'env:DATABASE_URL' })).toBe(true);
expect(isKtxPostgresConnectionConfig({ driver: 'postgresql', host: 'db', database: 'analytics' })).toBe(true); expect(isKtxPostgresConnectionConfig({ driver: 'postgresql', host: 'db', database: 'analytics' })).toBe(false);
expect(isKtxPostgresConnectionConfig({ driver: 'mysql', host: 'db' })).toBe(false); expect(isKtxPostgresConnectionConfig({ driver: 'mysql', host: 'db' })).toBe(false);
expect( expect(
postgresPoolConfigFromConfig({ postgresPoolConfigFromConfig({

View file

@ -276,7 +276,7 @@ export function isKtxPostgresConnectionConfig(
connection: KtxPostgresConnectionConfig | undefined, connection: KtxPostgresConnectionConfig | undefined,
): connection is KtxPostgresConnectionConfig { ): connection is KtxPostgresConnectionConfig {
const driver = String(connection?.driver ?? '').toLowerCase(); const driver = String(connection?.driver ?? '').toLowerCase();
return driver === 'postgres' || driver === 'postgresql'; return driver === 'postgres';
} }
/** @internal */ /** @internal */

View file

@ -125,7 +125,7 @@ export function isKtxSqliteConnectionConfig(
connection: KtxSqliteConnectionConfig | undefined, connection: KtxSqliteConnectionConfig | undefined,
): connection is KtxSqliteConnectionConfig { ): connection is KtxSqliteConnectionConfig {
const driver = String(connection?.driver ?? '').toLowerCase(); const driver = String(connection?.driver ?? '').toLowerCase();
return driver === 'sqlite' || driver === 'sqlite3'; return driver === 'sqlite';
} }
/** @internal */ /** @internal */

View file

@ -694,7 +694,7 @@ function isLocalSqlAnalysisConnectionRefused(input: { capturedOutput?: string; f
function friendlyDriverName(driver: string): string { function friendlyDriverName(driver: string): string {
const normalized = driver.toLowerCase(); const normalized = driver.toLowerCase();
if (normalized === 'postgres' || normalized === 'postgresql') return 'PostgreSQL'; if (normalized === 'postgres') return 'PostgreSQL';
if (normalized === 'mysql') return 'MySQL'; if (normalized === 'mysql') return 'MySQL';
if (normalized === 'sqlserver') return 'SQL Server'; if (normalized === 'sqlserver') return 'SQL Server';
if (normalized === 'bigquery') return 'BigQuery'; if (normalized === 'bigquery') return 'BigQuery';

View file

@ -4,7 +4,6 @@ import { getDialectForDriver } from './dialects.js';
describe('getDialectForDriver', () => { describe('getDialectForDriver', () => {
it.each([ it.each([
['postgres', '"public"."orders"'], ['postgres', '"public"."orders"'],
['postgresql', '"public"."orders"'],
['mysql', '`public`.`orders`'], ['mysql', '`public`.`orders`'],
['clickhouse', '`public`.`orders`'], ['clickhouse', '`public`.`orders`'],
['sqlite', '"orders"'], ['sqlite', '"orders"'],
@ -24,7 +23,12 @@ describe('getDialectForDriver', () => {
it('throws with a supported-driver list for unknown drivers', () => { it('throws with a supported-driver list for unknown drivers', () => {
expect(() => getDialectForDriver('oracle')).toThrow( expect(() => getDialectForDriver('oracle')).toThrow(
'Unsupported warehouse driver "oracle". Supported drivers: bigquery, clickhouse, mysql, postgres, postgresql, sqlite, sqlite3, snowflake, sqlserver', 'Unsupported warehouse driver "oracle". Supported drivers: bigquery, clickhouse, mysql, postgres, sqlite, snowflake, sqlserver',
); );
}); });
it('rejects legacy driver aliases', () => {
expect(() => getDialectForDriver('postgresql')).toThrow('Unsupported warehouse driver "postgresql"');
expect(() => getDialectForDriver('sqlite3')).toThrow('Unsupported warehouse driver "sqlite3"');
});
}); });

View file

@ -2,14 +2,12 @@ import type { KtxSchemaDimensionType, KtxTableRef } from '../scan/types.js';
type SupportedDriver = type SupportedDriver =
| 'postgres' | 'postgres'
| 'postgresql'
| 'mysql' | 'mysql'
| 'sqlserver' | 'sqlserver'
| 'snowflake' | 'snowflake'
| 'bigquery' | 'bigquery'
| 'clickhouse' | 'clickhouse'
| 'sqlite' | 'sqlite';
| 'sqlite3';
export interface KtxDialect { export interface KtxDialect {
readonly type: SupportedDriver; readonly type: SupportedDriver;
@ -23,9 +21,7 @@ const supportedDrivers: SupportedDriver[] = [
'clickhouse', 'clickhouse',
'mysql', 'mysql',
'postgres', 'postgres',
'postgresql',
'sqlite', 'sqlite',
'sqlite3',
'snowflake', 'snowflake',
'sqlserver', 'sqlserver',
]; ];
@ -83,11 +79,9 @@ function createDialect(type: SupportedDriver, quote: (identifier: string) => str
const dialects: Record<SupportedDriver, KtxDialect> = { const dialects: Record<SupportedDriver, KtxDialect> = {
postgres: createDialect('postgres', doubleQuoted), postgres: createDialect('postgres', doubleQuoted),
postgresql: createDialect('postgresql', doubleQuoted),
mysql: createDialect('mysql', backtickQuoted), mysql: createDialect('mysql', backtickQuoted),
clickhouse: createDialect('clickhouse', backtickQuoted), clickhouse: createDialect('clickhouse', backtickQuoted),
sqlite: createDialect('sqlite', doubleQuoted, true), sqlite: createDialect('sqlite', doubleQuoted, true),
sqlite3: createDialect('sqlite3', doubleQuoted, true),
snowflake: createDialect('snowflake', doubleQuoted), snowflake: createDialect('snowflake', doubleQuoted),
bigquery: createDialect('bigquery', bigQueryQuoted), bigquery: createDialect('bigquery', bigQueryQuoted),
sqlserver: createDialect('sqlserver', bracketQuoted), sqlserver: createDialect('sqlserver', bracketQuoted),

View file

@ -22,10 +22,10 @@ export function createDefaultLocalQueryExecutor(options: DefaultLocalQueryExecut
return { return {
async execute(input: KtxSqlQueryExecutionInput): Promise<KtxSqlQueryExecutionResult> { async execute(input: KtxSqlQueryExecutionInput): Promise<KtxSqlQueryExecutionResult> {
const driver = driverFor(input); const driver = driverFor(input);
if (driver === 'postgres' || driver === 'postgresql') { if (driver === 'postgres') {
return postgres.execute(input); return postgres.execute(input);
} }
if (driver === 'sqlite' || driver === 'sqlite3') { if (driver === 'sqlite') {
return sqlite.execute(input); return sqlite.execute(input);
} }
throw new Error(`No local query executor is configured for driver "${input.connection?.driver ?? 'unknown'}".`); throw new Error(`No local query executor is configured for driver "${input.connection?.driver ?? 'unknown'}".`);

View file

@ -53,6 +53,11 @@ describe('local connection info helpers', () => {
expect(localConnectionTypeForConfig('snowflake', { driver: 'snowflake' })).toBe('SNOWFLAKE'); expect(localConnectionTypeForConfig('snowflake', { driver: 'snowflake' })).toBe('SNOWFLAKE');
}); });
it('keeps removed driver aliases as display-only labels', () => {
expect(localConnectionTypeForConfig('warehouse', { driver: 'postgresql' } as never)).toBe('postgresql');
expect(localConnectionTypeForConfig('warehouse', { driver: 'mssql' } as never)).toBe('mssql');
});
it('keeps non-warehouse adapter labels for display-only local connection surfaces', () => { it('keeps non-warehouse adapter labels for display-only local connection surfaces', () => {
expect(localConnectionTypeForConfig('prod-metabase', { driver: 'metabase', api_url: 'https://metabase.example.com' })).toBe( expect(localConnectionTypeForConfig('prod-metabase', { driver: 'metabase', api_url: 'https://metabase.example.com' })).toBe(
'metabase', 'metabase',

View file

@ -20,10 +20,8 @@ export interface LocalConnectionInfo {
const DRIVER_TO_CONNECTION_TYPE: Record<string, ConnectionType> = { const DRIVER_TO_CONNECTION_TYPE: Record<string, ConnectionType> = {
postgres: 'POSTGRESQL', postgres: 'POSTGRESQL',
postgresql: 'POSTGRESQL',
sqlite: 'SQLITE', sqlite: 'SQLITE',
sqlserver: 'SQLSERVER', sqlserver: 'SQLSERVER',
mssql: 'SQLSERVER',
mysql: 'MYSQL', mysql: 'MYSQL',
clickhouse: 'CLICKHOUSE', clickhouse: 'CLICKHOUSE',
snowflake: 'SNOWFLAKE', snowflake: 'SNOWFLAKE',

View file

@ -38,7 +38,7 @@ export function createPostgresQueryExecutor(options: PostgresQueryExecutorOption
async execute(input: KtxSqlQueryExecutionInput): Promise<KtxSqlQueryExecutionResult> { async execute(input: KtxSqlQueryExecutionInput): Promise<KtxSqlQueryExecutionResult> {
const driver = connectionDriver(input); const driver = connectionDriver(input);
const connection = input.connection; const connection = input.connection;
if (driver !== 'postgres' && driver !== 'postgresql') { if (driver !== 'postgres') {
throw new Error(`Local Postgres execution cannot run driver "${connection?.driver ?? 'unknown'}".`); throw new Error(`Local Postgres execution cannot run driver "${connection?.driver ?? 'unknown'}".`);
} }
if (typeof connection?.url !== 'string' || connection.url.trim().length === 0) { if (typeof connection?.url !== 'string' || connection.url.trim().length === 0) {

View file

@ -52,7 +52,7 @@ function sqlitePathFromUrl(url: string): string {
/** @internal */ /** @internal */
export function sqliteDatabasePathFromConnection(input: KtxSqlQueryExecutionInput): string { export function sqliteDatabasePathFromConnection(input: KtxSqlQueryExecutionInput): string {
const driver = connectionDriver(input); const driver = connectionDriver(input);
if (driver !== 'sqlite' && driver !== 'sqlite3') { if (driver !== 'sqlite') {
throw new Error(`Local SQLite execution cannot run driver "${input.connection?.driver ?? 'unknown'}".`); throw new Error(`Local SQLite execution cannot run driver "${input.connection?.driver ?? 'unknown'}".`);
} }

View file

@ -25,7 +25,7 @@ export function queryHistoryDialectForConnection(connection: unknown): HistoricS
} }
const conn = recordOrNull(connection); const conn = recordOrNull(connection);
const driver = String(conn?.driver ?? '').toLowerCase(); const driver = String(conn?.driver ?? '').toLowerCase();
if (driver === 'postgres' || driver === 'postgresql') return 'postgres'; if (driver === 'postgres') return 'postgres';
if (driver === 'bigquery') return 'bigquery'; if (driver === 'bigquery') return 'bigquery';
if (driver === 'snowflake') return 'snowflake'; if (driver === 'snowflake') return 'snowflake';
return null; return null;

View file

@ -155,7 +155,7 @@ describe('createDaemonLiveDatabaseIntrospection', () => {
const introspection = createDaemonLiveDatabaseIntrospection({ const introspection = createDaemonLiveDatabaseIntrospection({
connections: { connections: {
warehouse: { warehouse: {
driver: 'postgresql', driver: 'postgres',
url: 'postgres://localhost:5432/warehouse', url: 'postgres://localhost:5432/warehouse',
}, },
}, },

View file

@ -151,7 +151,7 @@ function optionalString(value: unknown): string | undefined {
function normalizeDriver(driver: unknown): string { function normalizeDriver(driver: unknown): string {
const normalized = String(driver ?? '').trim().toLowerCase(); const normalized = String(driver ?? '').trim().toLowerCase();
return normalized === 'postgresql' ? 'postgres' : normalized; return normalized;
} }
function requirePostgresConnection( function requirePostgresConnection(

View file

@ -72,7 +72,8 @@ describe('looker dialect and target validation helpers', () => {
it('maps Looker dialect names to KTX connection types', () => { it('maps Looker dialect names to KTX connection types', () => {
expect(lookerDialectToConnectionType('bigquery_standard_sql')).toBe('BIGQUERY'); expect(lookerDialectToConnectionType('bigquery_standard_sql')).toBe('BIGQUERY');
expect(lookerDialectToConnectionType('postgres')).toBe('POSTGRESQL'); expect(lookerDialectToConnectionType('postgres')).toBe('POSTGRESQL');
expect(lookerDialectToConnectionType('mssql')).toBe('SQLSERVER'); expect(lookerDialectToConnectionType('mssql')).toBeNull();
expect(lookerDialectToConnectionType('tsql')).toBeNull();
expect(lookerDialectToConnectionType('unknown')).toBeNull(); expect(lookerDialectToConnectionType('unknown')).toBeNull();
}); });

View file

@ -7,12 +7,9 @@ const LOOKER_DIALECT_TO_CONNECTION_TYPE = {
bigquery_standard_sql: 'BIGQUERY', bigquery_standard_sql: 'BIGQUERY',
snowflake: 'SNOWFLAKE', snowflake: 'SNOWFLAKE',
postgres: 'POSTGRESQL', postgres: 'POSTGRESQL',
postgresql: 'POSTGRESQL',
mysql: 'MYSQL', mysql: 'MYSQL',
sqlite: 'SQLITE', sqlite: 'SQLITE',
sqlserver: 'SQLSERVER', sqlserver: 'SQLSERVER',
mssql: 'SQLSERVER',
tsql: 'SQLSERVER',
clickhouse: 'CLICKHOUSE', clickhouse: 'CLICKHOUSE',
} as const; } as const;

View file

@ -168,7 +168,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
const historicSqlDialectByDriver = new Map<string, 'postgres' | 'bigquery' | 'snowflake'>([ const historicSqlDialectByDriver = new Map<string, 'postgres' | 'bigquery' | 'snowflake'>([
['postgres', 'postgres'], ['postgres', 'postgres'],
['postgresql', 'postgres'],
['bigquery', 'bigquery'], ['bigquery', 'bigquery'],
['snowflake', 'snowflake'], ['snowflake', 'snowflake'],
]); ]);

View file

@ -307,7 +307,7 @@
{ {
"name": "sl_query", "name": "sl_query",
"title": "Semantic Layer Query", "title": "Semantic Layer Query",
"description": "Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: \"warehouse\", measures: [\"orders.order_count\"], dimensions: [{ dimension: \"orders.created_at\", granularity: \"month\" }] }).", "description": "Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: \"warehouse\", measures: [\"orders.order_count\"], dimensions: [{ field: \"orders.created_at\", granularity: \"month\" }] }).",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -349,7 +349,8 @@
"description": "Measures to select. Use semantic-layer keys when available." "description": "Measures to select. Use semantic-layer keys when available."
}, },
"dimensions": { "dimensions": {
"description": "Dimensions to group by. Strings and {dimension, granularity} are accepted.", "default": [],
"description": "Dimensions to group by. Use {field, granularity?} entries.",
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
@ -389,7 +390,8 @@
} }
}, },
"order_by": { "order_by": {
"description": "Sort clauses. Strings and Cube-style {id, desc} are accepted.", "default": [],
"description": "Sort clauses. Use {field, direction?} entries.",
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
@ -489,7 +491,7 @@
{ {
"name": "entity_details", "name": "entity_details",
"title": "Entity Details", "title": "Entity Details",
"description": "Read table and column metadata from the latest live-database scan snapshot. Example: entity_details({ connectionId: \"warehouse\", entities: [{ table: { schema: \"public\", table: \"orders\" }, columns: [\"id\"] }] }).", "description": "Read table and column metadata from the latest live-database scan snapshot. Example: entity_details({ connectionId: \"warehouse\", entities: [{ table: { catalog: null, db: \"public\", name: \"orders\" }, columns: [\"id\"] }] }).",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -549,7 +551,7 @@
] ]
} }
], ],
"description": "Table display string or object ref. {schema, table} is accepted as an alias for {db, name}." "description": "Table display string or canonical object ref."
}, },
"columns": { "columns": {
"description": "Optional column filter.", "description": "Optional column filter.",
@ -560,7 +562,10 @@
"description": "Column name to inspect." "description": "Column name to inspect."
} }
} }
} },
"required": [
"table"
]
}, },
"description": "Tables or columns to inspect. Maximum 20 entities." "description": "Tables or columns to inspect. Maximum 20 entities."
} }

View file

@ -54,13 +54,13 @@ const toolDescriptions = {
'Search KTX wiki pages for reusable business context. Example: wiki_search({ query: "revenue recognition", limit: 5 }).', 'Search KTX wiki pages for reusable business context. Example: wiki_search({ query: "revenue recognition", limit: 5 }).',
wiki_read: 'Read a KTX wiki page by key returned from wiki_search. Example: wiki_read({ key: "global/revenue" }).', wiki_read: 'Read a KTX wiki page by key returned from wiki_search. Example: wiki_read({ key: "global/revenue" }).',
entity_details: entity_details:
'Read table and column metadata from the latest live-database scan snapshot. Example: entity_details({ connectionId: "warehouse", entities: [{ table: { schema: "public", table: "orders" }, columns: ["id"] }] }).', 'Read table and column metadata from the latest live-database scan snapshot. Example: entity_details({ connectionId: "warehouse", entities: [{ table: { catalog: null, db: "public", name: "orders" }, columns: ["id"] }] }).',
dictionary_search: dictionary_search:
'Search profile-sampled warehouse values to locate likely source columns for business values. Example: dictionary_search({ values: ["Acme Corp"], connectionId: "warehouse" }).', 'Search profile-sampled warehouse values to locate likely source columns for business values. Example: dictionary_search({ values: ["Acme Corp"], connectionId: "warehouse" }).',
sl_read_source: sl_read_source:
'Read a semantic-layer YAML source by connection id and source name. Example: sl_read_source({ connectionId: "warehouse", sourceName: "orders" }).', 'Read a semantic-layer YAML source by connection id and source name. Example: sl_read_source({ connectionId: "warehouse", sourceName: "orders" }).',
sl_query: sl_query:
'Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: "warehouse", measures: ["orders.order_count"], dimensions: [{ dimension: "orders.created_at", granularity: "month" }] }).', 'Execute a semantic-layer query and return rows, headers, generated SQL, and plan details. Example: sl_query({ connectionId: "warehouse", measures: ["orders.order_count"], dimensions: [{ field: "orders.created_at", granularity: "month" }] }).',
sql_execution: sql_execution:
'Execute one parser-validated read-only SQL query against a configured KTX connection. Example: sql_execution({ connectionId: "warehouse", sql: "select count(*) from public.orders", maxRows: 100 }).', 'Execute one parser-validated read-only SQL query against a configured KTX connection. Example: sql_execution({ connectionId: "warehouse", sql: "select count(*) from public.orders", maxRows: 100 }).',
memory_ingest: memory_ingest:
@ -93,44 +93,16 @@ const slQueryMeasureSchema = z.union([
}), }),
]); ]);
const slQueryDimensionSchema = z.preprocess( const slQueryDimensionSchema = z.object({
(value) => {
if (typeof value === 'string') return { field: value };
if (value && typeof value === 'object' && !Array.isArray(value)) {
const obj = { ...(value as Record<string, unknown>) };
if (!('field' in obj) && typeof obj.dimension === 'string') obj.field = obj.dimension;
return obj;
}
return value;
},
z.object({
field: z.string().min(1).describe('Dimension to group by, e.g. "orders.created_at" or "orders.status".'), field: z.string().min(1).describe('Dimension to group by, e.g. "orders.created_at" or "orders.status".'),
granularity: z granularity: z
.string() .string()
.min(1) .min(1)
.optional() .optional()
.describe('Time grain for time dimensions: day, week, month, quarter, or year.'), .describe('Time grain for time dimensions: day, week, month, quarter, or year.'),
}), });
);
const slQueryOrderBySchema = z.preprocess( const slQueryOrderBySchema = z.object({
(value) => {
if (typeof value === 'string') {
return { field: value };
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
const obj = { ...(value as Record<string, unknown>) };
if (!('field' in obj) && typeof obj.id === 'string') {
obj.field = obj.id;
}
if (!('direction' in obj) && 'desc' in obj) {
obj.direction = obj.desc === true ? 'desc' : 'asc';
}
return obj;
}
return value;
},
z.object({
field: z field: z
.string() .string()
.min(1) .min(1)
@ -141,8 +113,7 @@ const slQueryOrderBySchema = z.preprocess(
.enum(['asc', 'desc']) .enum(['asc', 'desc'])
.default('asc') .default('asc')
.describe('Sort direction: "asc" or "desc". Defaults to "asc".'), .describe('Sort direction: "asc" or "desc". Defaults to "asc".'),
}), });
);
const slQuerySchema = z.object({ const slQuerySchema = z.object({
connectionId: connectionIdSchema connectionId: connectionIdSchema
@ -152,7 +123,7 @@ const slQuerySchema = z.object({
dimensions: z dimensions: z
.array(slQueryDimensionSchema) .array(slQueryDimensionSchema)
.default([]) .default([])
.describe('Dimensions to group by. Strings and {dimension, granularity} are accepted.'), .describe('Dimensions to group by. Use {field, granularity?} entries.'),
filters: z filters: z
.array(z.string().describe('Semantic-layer filter expression, e.g. "orders.status = paid".')) .array(z.string().describe('Semantic-layer filter expression, e.g. "orders.status = paid".'))
.default([]) .default([])
@ -164,28 +135,16 @@ const slQuerySchema = z.object({
order_by: z order_by: z
.array(slQueryOrderBySchema) .array(slQueryOrderBySchema)
.default([]) .default([])
.describe('Sort clauses. Strings and Cube-style {id, desc} are accepted.'), .describe('Sort clauses. Use {field, direction?} entries.'),
limit: z.number().int().min(0).default(1000).describe('Maximum rows to return. Defaults to 1000.'), limit: z.number().int().min(0).default(1000).describe('Maximum rows to return. Defaults to 1000.'),
include_empty: z.boolean().default(true).describe('Whether to include empty dimension groups. Defaults to true.'), include_empty: z.boolean().default(true).describe('Whether to include empty dimension groups. Defaults to true.'),
}); });
const entityDetailsTableRefSchema = z.preprocess( const entityDetailsTableRefSchema = z.object({
(value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const obj = { ...(value as Record<string, unknown>) };
if (!('db' in obj) && typeof obj.schema === 'string') obj.db = obj.schema;
if (!('name' in obj) && typeof obj.table === 'string') obj.name = obj.table;
if (!('catalog' in obj)) obj.catalog = null;
return obj;
}
return value;
},
z.object({
catalog: z.string().nullable().describe('Catalog/project/database. Use null when not applicable.'), catalog: z.string().nullable().describe('Catalog/project/database. Use null when not applicable.'),
db: z.string().nullable().describe('Schema/database/dataset. Use null when not applicable.'), db: z.string().nullable().describe('Schema/database/dataset. Use null when not applicable.'),
name: z.string().min(1).describe('Table name.'), name: z.string().min(1).describe('Table name.'),
}), });
);
const entityDetailsSchema = z.object({ const entityDetailsSchema = z.object({
connectionId: connectionIdSchema.describe('Connection id whose latest scan snapshot should be read.'), connectionId: connectionIdSchema.describe('Connection id whose latest scan snapshot should be read.'),
@ -194,7 +153,7 @@ const entityDetailsSchema = z.object({
z.object({ z.object({
table: z table: z
.union([z.string().min(1), entityDetailsTableRefSchema]) .union([z.string().min(1), entityDetailsTableRefSchema])
.describe('Table display string or object ref. {schema, table} is accepted as an alias for {db, name}.'), .describe('Table display string or canonical object ref.'),
columns: z columns: z
.array(z.string().min(1).describe('Column name to inspect.')) .array(z.string().min(1).describe('Column name to inspect.'))
.optional() .optional()

View file

@ -24,17 +24,14 @@ interface CreateLocalProjectMcpContextPortsOptions {
function dialectForDriver(driver: string | undefined): string { function dialectForDriver(driver: string | undefined): string {
const normalized = (driver ?? 'postgres').toUpperCase(); const normalized = (driver ?? 'postgres').toUpperCase();
const map: Record<string, string> = { const map: Record<string, string> = {
POSTGRESQL: 'postgres',
POSTGRES: 'postgres', POSTGRES: 'postgres',
BIGQUERY: 'bigquery', BIGQUERY: 'bigquery',
SNOWFLAKE: 'snowflake', SNOWFLAKE: 'snowflake',
MYSQL: 'mysql', MYSQL: 'mysql',
SQLSERVER: 'tsql', SQLSERVER: 'tsql',
MSSQL: 'tsql',
SQLITE: 'sqlite', SQLITE: 'sqlite',
DUCKDB: 'duckdb', DUCKDB: 'duckdb',
CLICKHOUSE: 'clickhouse', CLICKHOUSE: 'clickhouse',
REDSHIFT: 'redshift',
DATABRICKS: 'databricks', DATABRICKS: 'databricks',
}; };
return map[normalized] ?? 'postgres'; return map[normalized] ?? 'postgres';

View file

@ -466,7 +466,43 @@ describe('createKtxMcpServer', () => {
}); });
}); });
it('sl_query normalizes order_by from cube-style {id, desc} and bare strings to {field, direction}', async () => { it('sl_query rejects cube-style order_by aliases and bare strings', async () => {
const fake = makeFakeServer();
const semanticLayer: KtxSemanticLayerMcpPort = {
readSource: vi.fn(),
query: vi.fn<KtxSemanticLayerMcpPort['query']>().mockResolvedValue({
sql: '',
headers: [],
rows: [],
totalRows: 0,
}),
};
createKtxMcpServer({
server: fake.server,
userContext: { userId: 'local-user' },
contextTools: { semanticLayer },
});
await expect(
getTool(fake.tools, 'sl_query').handler({
connectionId: 'warehouse',
measures: ['orders.count'],
order_by: [{ id: 'orders.quarter_label', desc: false }],
}),
).resolves.toMatchObject({ isError: true });
await expect(
getTool(fake.tools, 'sl_query').handler({
connectionId: 'warehouse',
measures: ['orders.count'],
order_by: ['orders.segment'],
}),
).resolves.toMatchObject({ isError: true });
expect(semanticLayer.query).not.toHaveBeenCalled();
});
it('sl_query accepts canonical order_by entries', async () => {
const fake = makeFakeServer(); const fake = makeFakeServer();
const semanticLayer: KtxSemanticLayerMcpPort = { const semanticLayer: KtxSemanticLayerMcpPort = {
readSource: vi.fn(), readSource: vi.fn(),
@ -489,9 +525,7 @@ describe('createKtxMcpServer', () => {
measures: ['orders.count'], measures: ['orders.count'],
order_by: [ order_by: [
{ field: 'orders.total', direction: 'desc' }, { field: 'orders.total', direction: 'desc' },
{ id: 'orders.quarter_label', desc: false }, { field: 'orders.segment' },
{ id: 'orders.created_at', desc: true },
'orders.segment',
], ],
}); });
@ -501,8 +535,6 @@ describe('createKtxMcpServer', () => {
query: expect.objectContaining({ query: expect.objectContaining({
order_by: [ order_by: [
{ field: 'orders.total', direction: 'desc' }, { field: 'orders.total', direction: 'desc' },
{ field: 'orders.quarter_label', direction: 'asc' },
{ field: 'orders.created_at', direction: 'desc' },
{ field: 'orders.segment', direction: 'asc' }, { field: 'orders.segment', direction: 'asc' },
], ],
}), }),
@ -511,7 +543,35 @@ describe('createKtxMcpServer', () => {
); );
}); });
it('sl_query normalizes cube-style dimensions to field dimensions', async () => { it('sl_query rejects cube-style dimensions and bare strings', async () => {
const fake = makeFakeServer();
const semanticLayer = makeAllContextTools().semanticLayer!;
createKtxMcpServer({
server: fake.server,
userContext: { userId: 'local-user' },
contextTools: { semanticLayer },
});
await expect(
getTool(fake.tools, 'sl_query').handler({
connectionId: 'warehouse',
measures: ['orders.count'],
dimensions: [{ dimension: 'orders.created_at', granularity: 'month' }],
}),
).resolves.toMatchObject({ isError: true });
await expect(
getTool(fake.tools, 'sl_query').handler({
connectionId: 'warehouse',
measures: ['orders.count'],
dimensions: ['orders.status'],
}),
).resolves.toMatchObject({ isError: true });
expect(semanticLayer.query).not.toHaveBeenCalled();
});
it('sl_query accepts canonical field dimensions', async () => {
const fake = makeFakeServer(); const fake = makeFakeServer();
const semanticLayer = makeAllContextTools().semanticLayer!; const semanticLayer = makeAllContextTools().semanticLayer!;
@ -524,7 +584,7 @@ describe('createKtxMcpServer', () => {
await getTool(fake.tools, 'sl_query').handler({ await getTool(fake.tools, 'sl_query').handler({
connectionId: 'warehouse', connectionId: 'warehouse',
measures: ['orders.count'], measures: ['orders.count'],
dimensions: [{ dimension: 'orders.created_at', granularity: 'month' }, 'orders.status'], dimensions: [{ field: 'orders.created_at', granularity: 'month' }, { field: 'orders.status' }],
}); });
expect(semanticLayer.query).toHaveBeenCalledWith( expect(semanticLayer.query).toHaveBeenCalledWith(
@ -538,7 +598,27 @@ describe('createKtxMcpServer', () => {
); );
}); });
it('entity_details normalizes sql-style schema table refs', async () => { it('entity_details rejects sql-style schema table ref aliases', async () => {
const fake = makeFakeServer();
const entityDetails = makeAllContextTools().entityDetails!;
createKtxMcpServer({
server: fake.server,
userContext: { userId: 'local-user' },
contextTools: { entityDetails },
});
await expect(
getTool(fake.tools, 'entity_details').handler({
connectionId: 'warehouse',
entities: [{ table: { schema: 'public', table: 'orders' }, columns: ['id'] }],
}),
).resolves.toMatchObject({ isError: true });
expect(entityDetails.read).not.toHaveBeenCalled();
});
it('entity_details accepts canonical table refs', async () => {
const fake = makeFakeServer(); const fake = makeFakeServer();
const entityDetails = makeAllContextTools().entityDetails!; const entityDetails = makeAllContextTools().entityDetails!;
@ -550,7 +630,7 @@ describe('createKtxMcpServer', () => {
await getTool(fake.tools, 'entity_details').handler({ await getTool(fake.tools, 'entity_details').handler({
connectionId: 'warehouse', connectionId: 'warehouse',
entities: [{ table: { schema: 'public', table: 'orders' }, columns: ['id'] }], entities: [{ table: { catalog: null, db: 'public', name: 'orders' }, columns: ['id'] }],
}); });
expect(entityDetails.read).toHaveBeenCalledWith({ expect(entityDetails.read).toHaveBeenCalledWith({
@ -1018,7 +1098,7 @@ describe('createKtxMcpServer', () => {
await getTool(fake.tools, 'sl_query').handler({ await getTool(fake.tools, 'sl_query').handler({
connectionId: '00000000-0000-4000-8000-000000000001', connectionId: '00000000-0000-4000-8000-000000000001',
measures: ['orders.count'], measures: ['orders.count'],
dimensions: ['orders.created_at'], dimensions: [{ field: 'orders.created_at' }],
filters: ['orders.status = paid'], filters: ['orders.status = paid'],
limit: 25, limit: 25,
}); });

View file

@ -4,7 +4,6 @@ import { connectionConfigSchema } from './driver-schemas.js';
describe('connectionConfigSchema (driver discriminated union)', () => { describe('connectionConfigSchema (driver discriminated union)', () => {
it.each([ it.each([
['postgres', 'postgres://user:pass@host:5432/db'], // pragma: allowlist secret ['postgres', 'postgres://user:pass@host:5432/db'], // pragma: allowlist secret
['postgresql', 'postgresql://user:pass@host:5432/db'], // pragma: allowlist secret
['mysql', 'mysql://user:pass@host:3306/db'], // pragma: allowlist secret ['mysql', 'mysql://user:pass@host:3306/db'], // pragma: allowlist secret
['snowflake', 'snowflake://account/db'], ['snowflake', 'snowflake://account/db'],
['bigquery', 'bigquery://project/dataset'], ['bigquery', 'bigquery://project/dataset'],
@ -32,6 +31,10 @@ describe('connectionConfigSchema (driver discriminated union)', () => {
it('rejects an unknown driver', () => { it('rejects an unknown driver', () => {
expect(() => connectionConfigSchema.parse({ driver: 'nope', url: 'x' })).toThrow(); expect(() => connectionConfigSchema.parse({ driver: 'nope', url: 'x' })).toThrow();
}); });
it('rejects legacy warehouse driver aliases', () => {
expect(() => connectionConfigSchema.parse({ driver: 'postgresql', url: 'postgresql://host/db' })).toThrow();
});
}); });
describe('connectionConfigSchema - context source drivers with mappings', () => { describe('connectionConfigSchema - context source drivers with mappings', () => {

View file

@ -7,7 +7,6 @@ import {
const warehouseDrivers = [ const warehouseDrivers = [
'postgres', 'postgres',
'postgresql',
'mysql', 'mysql',
'snowflake', 'snowflake',
'bigquery', 'bigquery',
@ -41,7 +40,6 @@ function warehouseConnectionSchema<const Driver extends WarehouseDriver>(driver:
const warehouseConnectionSchemas = [ const warehouseConnectionSchemas = [
warehouseConnectionSchema('postgres'), warehouseConnectionSchema('postgres'),
warehouseConnectionSchema('postgresql'),
warehouseConnectionSchema('mysql'), warehouseConnectionSchema('mysql'),
warehouseConnectionSchema('snowflake'), warehouseConnectionSchema('snowflake'),
warehouseConnectionSchema('bigquery'), warehouseConnectionSchema('bigquery'),

View file

@ -8,12 +8,8 @@ import type { KtxTableRef } from './types.js';
* *
* Accepted entry forms: * Accepted entry forms:
* "catalog.db.name" fully qualified * "catalog.db.name" fully qualified
* "db.name" schema-qualified (catalog = null; legacy / Postgres-shape) * "db.name" schema-qualified (catalog = null)
* "name" bare (catalog = db = null; SQLite-shape) * "name" bare (catalog = db = null; SQLite-shape)
* { catalog?, db?, name } escape hatch for identifiers containing dots
*
* The setup wizard writes the fully-qualified form going forward; the lenient
* parser keeps existing project configs working.
*/ */
export function resolveEnabledTables( export function resolveEnabledTables(
connection: Record<string, unknown> | undefined, connection: Record<string, unknown> | undefined,
@ -33,16 +29,6 @@ function parseEnabledTableEntry(value: unknown): KtxTableRef | null {
if (typeof value === 'string') { if (typeof value === 'string') {
return parseDottedEntry(value); return parseDottedEntry(value);
} }
if (value && typeof value === 'object' && !Array.isArray(value)) {
const entry = value as { catalog?: unknown; db?: unknown; name?: unknown };
const name = typeof entry.name === 'string' ? entry.name : null;
if (!name) return null;
return {
catalog: typeof entry.catalog === 'string' ? entry.catalog : null,
db: typeof entry.db === 'string' ? entry.db : null,
name,
};
}
return null; return null;
} }

View file

@ -1878,6 +1878,15 @@ describe('resolveEnabledTables', () => {
expect(result!.has(tableRefKey({ catalog: null, db: 'public', name: 'orders' }))).toBe(true); expect(result!.has(tableRefKey({ catalog: null, db: 'public', name: 'orders' }))).toBe(true);
}); });
it('ignores legacy enabled_tables object entries', () => {
expect(
resolveEnabledTables({
driver: 'postgres',
enabled_tables: [{ catalog: null, db: 'public', name: 'orders' }],
}),
).toBeNull();
});
it('returns null for undefined connection', () => { it('returns null for undefined connection', () => {
expect(resolveEnabledTables(undefined)).toBeNull(); expect(resolveEnabledTables(undefined)).toBeNull();
}); });

View file

@ -126,19 +126,17 @@ function normalizeDriver(driver: string | undefined): KtxConnectionDriver {
const normalized = (driver ?? '').toLowerCase(); const normalized = (driver ?? '').toLowerCase();
if ( if (
normalized === 'postgres' || normalized === 'postgres' ||
normalized === 'postgresql' ||
normalized === 'sqlite' || normalized === 'sqlite' ||
normalized === 'sqlite3' ||
normalized === 'mysql' || normalized === 'mysql' ||
normalized === 'clickhouse' || normalized === 'clickhouse' ||
normalized === 'sqlserver' || normalized === 'sqlserver' ||
normalized === 'bigquery' || normalized === 'bigquery' ||
normalized === 'snowflake' normalized === 'snowflake'
) { ) {
return normalized === 'sqlite3' ? 'sqlite' : normalized; return normalized;
} }
throw new Error( throw new Error(
`Standalone ktx scan supports postgres/postgresql/sqlite/mysql/clickhouse/sqlserver/bigquery/snowflake in this phase, received "${driver ?? 'unknown'}"`, `Standalone ktx scan supports postgres/sqlite/mysql/clickhouse/sqlserver/bigquery/snowflake in this phase, received "${driver ?? 'unknown'}"`,
); );
} }

View file

@ -47,9 +47,9 @@ describe('scopedTableNames', () => {
expect(scopedTableNames(scope, { catalog: 'ANALYTICS', db: 'STAGING' })).toEqual(['LISTINGS']); expect(scopedTableNames(scope, { catalog: 'ANALYTICS', db: 'STAGING' })).toEqual(['LISTINGS']);
}); });
it('treats null in the scope entry as a wildcard for that segment', () => { it('requires non-null scope segments to match the namespace', () => {
const scope = tableRefSet([{ catalog: null, db: 'public', name: 'users' }]); const scope = tableRefSet([{ catalog: null, db: 'public', name: 'users' }]);
expect(scopedTableNames(scope, { catalog: 'any-catalog', db: 'public' })).toEqual(['users']); expect(scopedTableNames(scope, { catalog: 'any-catalog', db: 'public' })).toEqual([]);
}); });
it('returns empty when no scope entry matches the namespace', () => { it('returns empty when no scope entry matches the namespace', () => {
@ -57,7 +57,7 @@ describe('scopedTableNames', () => {
expect(scopedTableNames(scope, { catalog: 'X', db: 'Y' })).toEqual([]); expect(scopedTableNames(scope, { catalog: 'X', db: 'Y' })).toEqual([]);
}); });
it('dedupes when the same name appears under different catalog projections', () => { it('dedupes exact namespace matches only', () => {
const scope: ReadonlySet<KtxTableRefKey> = tableRefSet([ const scope: ReadonlySet<KtxTableRefKey> = tableRefSet([
{ catalog: null, db: 'public', name: 'users' }, { catalog: null, db: 'public', name: 'users' },
{ catalog: 'A', db: 'public', name: 'users' }, { catalog: 'A', db: 'public', name: 'users' },

View file

@ -33,8 +33,7 @@ export function tableRefSet(refs: readonly KtxTableRef[]): ReadonlySet<KtxTableR
/** /**
* Return the bare table names from a scope that fall within the given * Return the bare table names from a scope that fall within the given
* (catalog, db) namespace. `catalog: null` is treated as a wildcard so that * (catalog, db) namespace.
* legacy 2-part `"db.name"` entries continue to match. Same for `db: null`.
*/ */
export function scopedTableNames( export function scopedTableNames(
scope: ReadonlySet<KtxTableRefKey>, scope: ReadonlySet<KtxTableRefKey>,
@ -45,8 +44,8 @@ export function scopedTableNames(
const wantDb = namespace.db ?? null; const wantDb = namespace.db ?? null;
for (const key of scope) { for (const key of scope) {
const ref = tableRefFromKey(key); const ref = tableRefFromKey(key);
if (wantCatalog !== null && ref.catalog !== null && ref.catalog !== wantCatalog) continue; if (ref.catalog !== wantCatalog) continue;
if (wantDb !== null && ref.db !== null && ref.db !== wantDb) continue; if (ref.db !== wantDb) continue;
names.add(ref.name); names.add(ref.name);
} }
return [...names]; return [...names];

View file

@ -3,7 +3,6 @@ import type { KtxTableRefKey } from './table-ref.js';
export type KtxConnectionDriver = export type KtxConnectionDriver =
| 'sqlite' | 'sqlite'
| 'postgres' | 'postgres'
| 'postgresql'
| 'sqlserver' | 'sqlserver'
| 'bigquery' | 'bigquery'
| 'snowflake' | 'snowflake'

View file

@ -8,7 +8,7 @@ import type {
KtxTableRef, KtxTableRef,
} from './types.js'; } from './types.js';
type CatalogDriver = KtxConnectionDriver | 'sqlite3'; type CatalogDriver = KtxConnectionDriver;
export interface WarehouseCatalogServiceDeps { export interface WarehouseCatalogServiceDeps {
fileStore: KtxFileStorePort; fileStore: KtxFileStorePort;
@ -129,7 +129,7 @@ function splitDisplay(display: string): string[] {
} }
function formatDisplay(driver: CatalogDriver, table: KtxTableRef): string { function formatDisplay(driver: CatalogDriver, table: KtxTableRef): string {
if (driver === 'sqlite' || driver === 'sqlite3') { if (driver === 'sqlite') {
return table.name; return table.name;
} }
return [table.catalog, table.db, table.name].filter((part): part is string => Boolean(part)).join('.'); return [table.catalog, table.db, table.name].filter((part): part is string => Boolean(part)).join('.');
@ -137,7 +137,7 @@ function formatDisplay(driver: CatalogDriver, table: KtxTableRef): string {
function parseDisplay(driver: CatalogDriver, display: string): KtxTableRef | null { function parseDisplay(driver: CatalogDriver, display: string): KtxTableRef | null {
const parts = splitDisplay(display); const parts = splitDisplay(display);
if (driver === 'sqlite' || driver === 'sqlite3') { if (driver === 'sqlite') {
return parts.length === 1 ? { catalog: null, db: null, name: parts[0]! } : null; return parts.length === 1 ? { catalog: null, db: null, name: parts[0]! } : null;
} }
if (driver === 'bigquery' || driver === 'snowflake' || driver === 'sqlserver') { if (driver === 'bigquery' || driver === 'snowflake' || driver === 'sqlserver') {
@ -156,7 +156,7 @@ function parseDisplay(driver: CatalogDriver, display: string): KtxTableRef | nul
} }
function expectedDisplayPartCount(driver: CatalogDriver): number { function expectedDisplayPartCount(driver: CatalogDriver): number {
if (driver === 'sqlite' || driver === 'sqlite3') { if (driver === 'sqlite') {
return 1; return 1;
} }
if (driver === 'bigquery' || driver === 'snowflake' || driver === 'sqlserver') { if (driver === 'bigquery' || driver === 'snowflake' || driver === 'sqlserver') {

View file

@ -48,17 +48,14 @@ function assertSafeConnectionId(connectionId: string): string {
function dialectForDriver(driver: string | undefined): string { function dialectForDriver(driver: string | undefined): string {
const normalized = (driver ?? 'postgres').toUpperCase(); const normalized = (driver ?? 'postgres').toUpperCase();
const map: Record<string, string> = { const map: Record<string, string> = {
POSTGRESQL: 'postgres',
POSTGRES: 'postgres', POSTGRES: 'postgres',
BIGQUERY: 'bigquery', BIGQUERY: 'bigquery',
SNOWFLAKE: 'snowflake', SNOWFLAKE: 'snowflake',
MYSQL: 'mysql', MYSQL: 'mysql',
SQLSERVER: 'tsql', SQLSERVER: 'tsql',
MSSQL: 'tsql',
SQLITE: 'sqlite', SQLITE: 'sqlite',
DUCKDB: 'duckdb', DUCKDB: 'duckdb',
CLICKHOUSE: 'clickhouse', CLICKHOUSE: 'clickhouse',
REDSHIFT: 'redshift',
DATABRICKS: 'databricks', DATABRICKS: 'databricks',
}; };
return map[normalized] ?? 'postgres'; return map[normalized] ?? 'postgres';

View file

@ -392,7 +392,7 @@ describe('local semantic-layer helpers', () => {
).rejects.toThrow('Invalid semantic-layer source'); ).rejects.toThrow('Invalid semantic-layer source');
}); });
it('reports legacy overlay column patches with a file-attributed migration hint', async () => { it('reports overlay columns that are not computed columns', async () => {
const invalidYaml = [ const invalidYaml = [
'name: orders', 'name: orders',
'columns:', 'columns:',
@ -406,9 +406,7 @@ describe('local semantic-layer helpers', () => {
validateLocalSlSource(invalidYaml, { project, connectionId: 'warehouse', sourceName: 'orders' }), validateLocalSlSource(invalidYaml, { project, connectionId: 'warehouse', sourceName: 'orders' }),
).resolves.toEqual({ ).resolves.toEqual({
valid: false, valid: false,
errors: [ errors: expect.arrayContaining([expect.stringContaining('columns.0.type')]),
"semantic-layer/warehouse/orders.yaml: column 'status' patches a manifest column but is in 'columns:' — move it to 'column_overrides:'",
],
}); });
}); });

View file

@ -266,23 +266,6 @@ export async function validateLocalSlSource(
try { try {
const parsed = parseYamlRecord(rawYaml); const parsed = parseYamlRecord(rawYaml);
const schema = parsed.table || parsed.sql ? sourceDefinitionSchema : sourceOverlaySchema; const schema = parsed.table || parsed.sql ? sourceDefinitionSchema : sourceOverlaySchema;
if (schema === sourceOverlaySchema && Array.isArray(parsed.columns)) {
const sourceName = options?.sourceName ?? (typeof parsed.name === 'string' ? parsed.name : 'source');
const path =
options?.connectionId && isSafeConnectionId(options.connectionId)
? `semantic-layer/${options.connectionId}/${sourceName}.yaml`
: `${sourceName}.yaml`;
const legacyColumnPatchErrors = parsed.columns
.filter((column): column is Record<string, unknown> => isRecord(column))
.filter((column) => typeof column.name === 'string' && (!column.expr || !column.type))
.map(
(column) =>
`${path}: column '${column.name}' patches a manifest column but is in 'columns:' — move it to 'column_overrides:'`,
);
if (legacyColumnPatchErrors.length > 0) {
return { valid: false, errors: legacyColumnPatchErrors };
}
}
const result = schema.parse(parsed); const result = schema.parse(parsed);
const errors: string[] = []; const errors: string[] = [];

View file

@ -847,7 +847,7 @@ describe('loadAllSources — standalone enrichment via inherits_columns_from', (
}); });
}); });
it('reports file-attributed errors for legacy overlay column patches', async () => { it('reports file-attributed errors for overlay columns that shadow manifest columns', async () => {
const schemaPath = 'semantic-layer/conn-1/_schema/marts.yaml'; const schemaPath = 'semantic-layer/conn-1/_schema/marts.yaml';
const overlayPath = 'semantic-layer/conn-1/orders.yaml'; const overlayPath = 'semantic-layer/conn-1/orders.yaml';
configService.listFiles.mockResolvedValue({ files: [schemaPath, overlayPath] }); configService.listFiles.mockResolvedValue({ files: [schemaPath, overlayPath] });
@ -871,7 +871,8 @@ describe('loadAllSources — standalone enrichment via inherits_columns_from', (
const { loadErrors } = await service.loadAllSources('conn-1'); const { loadErrors } = await service.loadAllSources('conn-1');
expect(loadErrors.join('\n')).toContain(overlayPath); expect(loadErrors.join('\n')).toContain(overlayPath);
expect(loadErrors.join('\n')).toContain("move it to 'column_overrides:'"); expect(loadErrors.join('\n')).toContain("column 'id' in columns already exists on manifest source 'orders'");
expect(loadErrors.join('\n')).not.toContain('column_overrides');
}); });
it('reports and logs directory listing failures instead of treating them as empty sources', async () => { it('reports and logs directory listing failures instead of treating them as empty sources', async () => {

View file

@ -1082,17 +1082,14 @@ export class SemanticLayerService {
static mapDialect(connectionType: string): string { static mapDialect(connectionType: string): string {
const normalized = connectionType.toUpperCase(); const normalized = connectionType.toUpperCase();
const map: Record<string, string> = { const map: Record<string, string> = {
POSTGRESQL: 'postgres',
POSTGRES: 'postgres', POSTGRES: 'postgres',
BIGQUERY: 'bigquery', BIGQUERY: 'bigquery',
SNOWFLAKE: 'snowflake', SNOWFLAKE: 'snowflake',
MYSQL: 'mysql', MYSQL: 'mysql',
SQLSERVER: 'tsql', SQLSERVER: 'tsql',
MSSQL: 'tsql',
SQLITE: 'sqlite', SQLITE: 'sqlite',
DUCKDB: 'duckdb', DUCKDB: 'duckdb',
CLICKHOUSE: 'clickhouse', CLICKHOUSE: 'clickhouse',
REDSHIFT: 'redshift',
DATABRICKS: 'databricks', DATABRICKS: 'databricks',
}; };
return map[normalized] ?? 'postgres'; return map[normalized] ?? 'postgres';
@ -1513,7 +1510,7 @@ export function composeOverlay(base: SemanticLayerSource, overlay: Record<string
for (const column of computedColumns) { for (const column of computedColumns) {
if (baseByLowerName.has(column.name.toLowerCase())) { if (baseByLowerName.has(column.name.toLowerCase())) {
throw new ColumnNameCollisionError( throw new ColumnNameCollisionError(
`column '${column.name}' in columns patches a manifest column on '${base.name}' — move it to 'column_overrides:'`, `column '${column.name}' in columns already exists on manifest source '${base.name}'`,
); );
} }
columnsByLowerName.set(column.name.toLowerCase(), column); columnsByLowerName.set(column.name.toLowerCase(), column);

View file

@ -597,7 +597,7 @@ describe('runKtxDoctor', () => {
expect(testIo.stdout()).toContain('ktx setup'); expect(testIo.stdout()).toContain('ktx setup');
}); });
it('warns about stale and unsupported per-driver connection fields', async () => { it('does not warn about removed-field migration hints', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
process.env.WAREHOUSE_DATABASE_URL = 'postgresql://reader@example.test/warehouse'; process.env.WAREHOUSE_DATABASE_URL = 'postgresql://reader@example.test/warehouse';
process.env.NOTION_TOKEN = 'notion-secret'; process.env.NOTION_TOKEN = 'notion-secret';
@ -644,10 +644,9 @@ describe('runKtxDoctor', () => {
).resolves.toBe(0); ).resolves.toBe(0);
const out = testIo.stdout(); const out = testIo.stdout();
expect(out).toContain('Warnings'); expect(out).not.toContain('connections.warehouse.readonly is no longer used.');
expect(out).toContain('connections.warehouse.readonly is no longer used.'); expect(out).not.toContain('connections.local.file_path was removed.');
expect(out).toContain('connections.local.file_path was removed.'); expect(out).not.toContain('connections.docs.last_successful_cursor is local sync state.');
expect(out).toContain('connections.docs.last_successful_cursor is local sync state.');
delete process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_API_KEY;
delete process.env.WAREHOUSE_DATABASE_URL; delete process.env.WAREHOUSE_DATABASE_URL;
delete process.env.NOTION_TOKEN; delete process.env.NOTION_TOKEN;

View file

@ -5,7 +5,6 @@ export type KtxDatabaseContextDepth = 'fast' | 'deep';
const KTX_DATABASE_DRIVER_IDS = new Set([ const KTX_DATABASE_DRIVER_IDS = new Set([
'sqlite', 'sqlite',
'postgres', 'postgres',
'postgresql',
'mysql', 'mysql',
'clickhouse', 'clickhouse',
'sqlserver', 'sqlserver',

View file

@ -17,14 +17,14 @@ export async function createKtxCliScanConnector(
`Connection "${connectionId}" has no \`driver\` field in ktx.yaml. Supported drivers: ${SUPPORTED_DRIVERS}.`, `Connection "${connectionId}" has no \`driver\` field in ktx.yaml. Supported drivers: ${SUPPORTED_DRIVERS}.`,
); );
} }
if (driver === 'sqlite' || driver === 'sqlite3') { if (driver === 'sqlite') {
const { KtxSqliteScanConnector, isKtxSqliteConnectionConfig } = await import('./connectors/sqlite/connector.js');; const { KtxSqliteScanConnector, isKtxSqliteConnectionConfig } = await import('./connectors/sqlite/connector.js');;
if (!isKtxSqliteConnectionConfig(connection)) { if (!isKtxSqliteConnectionConfig(connection)) {
throw invalidConnectionConfigError(connectionId, driver); throw invalidConnectionConfigError(connectionId, driver);
} }
return new KtxSqliteScanConnector({ connectionId, connection, projectDir: project.projectDir }); return new KtxSqliteScanConnector({ connectionId, connection, projectDir: project.projectDir });
} }
if (driver === 'postgres' || driver === 'postgresql') { if (driver === 'postgres') {
const { KtxPostgresScanConnector, isKtxPostgresConnectionConfig } = await import('./connectors/postgres/connector.js');; const { KtxPostgresScanConnector, isKtxPostgresConnectionConfig } = await import('./connectors/postgres/connector.js');;
if (!isKtxPostgresConnectionConfig(connection)) { if (!isKtxPostgresConnectionConfig(connection)) {
throw invalidConnectionConfigError(connectionId, driver); throw invalidConnectionConfigError(connectionId, driver);

View file

@ -175,6 +175,35 @@ describe('buildPublicIngestPlan', () => {
).toEqual(['--deep affects database ingest only; ignoring it for docs.']); ).toEqual(['--deep affects database ingest only; ignoring it for docs.']);
}); });
it('does not infer deep ingest from legacy scanMode values', () => {
const project = projectWithConnections({
warehouse: { driver: 'postgres' },
});
const plan = buildPublicIngestPlan(project, {
projectDir: '/tmp/project',
targetConnectionId: 'warehouse',
all: false,
scanMode: 'enriched',
});
expect(plan.targets[0]).toMatchObject({
connectionId: 'warehouse',
databaseDepth: 'fast',
steps: ['database-schema'],
});
});
it('rejects stale local Looker source driver aliases', () => {
const project = projectWithConnections({
local_looker: { driver: 'local_looker' } as never,
});
expect(() => buildPublicIngestPlan(project, { projectDir: '/tmp/project', all: true })).toThrow(
'unsupported public ingest driver "local_looker"',
);
});
it('upgrades effective depth when query history is explicitly enabled', () => { it('upgrades effective depth when query history is explicitly enabled', () => {
const project = projectWithConnections({ const project = projectWithConnections({
warehouse: { driver: 'postgres', context: { queryHistory: { enabled: false } } }, warehouse: { driver: 'postgres', context: { queryHistory: { enabled: false } } },
@ -1045,7 +1074,7 @@ describe('runKtxPublicIngest', () => {
expect(io.stdout()).toContain('warehouse requires deep ingest readiness'); expect(io.stdout()).toContain('warehouse requires deep ingest readiness');
}); });
it('can request enriched relationship scans for setup-managed context builds', async () => { it('does not infer enriched relationship scans from legacy scanMode values', async () => {
const io = makeIo(); const io = makeIo();
const project = deepReadyProject({ warehouse: { driver: 'postgres' } }); const project = deepReadyProject({ warehouse: { driver: 'postgres' } });
const runScan = vi.fn(async () => 0); const runScan = vi.fn(async () => 0);
@ -1074,8 +1103,8 @@ describe('runKtxPublicIngest', () => {
command: 'run', command: 'run',
projectDir: '/tmp/project', projectDir: '/tmp/project',
connectionId: 'warehouse', connectionId: 'warehouse',
mode: 'enriched', mode: 'structural',
detectRelationships: true, detectRelationships: false,
dryRun: false, dryRun: false,
}, },
expect.objectContaining({ capturedOutput: expect.any(Function) }), expect.objectContaining({ capturedOutput: expect.any(Function) }),

View file

@ -134,7 +134,6 @@ const sourceAdapterByDriver = new Map<string, string>([
['metabase', 'metabase'], ['metabase', 'metabase'],
['local_metabase', 'metabase'], ['local_metabase', 'metabase'],
['looker', 'looker'], ['looker', 'looker'],
['local_looker', 'looker'],
['notion', 'notion'], ['notion', 'notion'],
['metricflow', 'metricflow'], ['metricflow', 'metricflow'],
['dbt', 'dbt'], ['dbt', 'dbt'],
@ -143,7 +142,6 @@ const sourceAdapterByDriver = new Map<string, string>([
const queryHistoryDialectByDriver = new Map<string, HistoricSqlDialect>([ const queryHistoryDialectByDriver = new Map<string, HistoricSqlDialect>([
['postgres', 'postgres'], ['postgres', 'postgres'],
['postgresql', 'postgres'],
['bigquery', 'bigquery'], ['bigquery', 'bigquery'],
['snowflake', 'snowflake'], ['snowflake', 'snowflake'],
]); ]);
@ -309,12 +307,6 @@ function queryHistoryPullConfig(input: {
}; };
} }
function depthFromLegacyScanMode(
mode: Extract<KtxScanArgs, { command: 'run' }>['mode'] | undefined,
): KtxPublicIngestDepth | undefined {
return mode === 'enriched' || mode === 'relationships' ? 'deep' : undefined;
}
function sourceDirForConnection(connection: KtxProjectConnectionConfig): string | undefined { function sourceDirForConnection(connection: KtxProjectConnectionConfig): string | undefined {
const value = connection.source_dir; const value = connection.source_dir;
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
@ -340,8 +332,7 @@ function resolveDatabaseTargetOptions(input: {
const requestedQh = const requestedQh =
explicitQueryHistory === 'enabled' || explicitQueryHistory === 'enabled' ||
(explicitQueryHistory !== 'disabled' && (windowOverrideRequested || storedEnabled)); (explicitQueryHistory !== 'disabled' && (windowOverrideRequested || storedEnabled));
let depth = let depth = input.args.depth ?? databaseContextDepth(input.connection) ?? 'fast';
input.args.depth ?? depthFromLegacyScanMode(input.args.scanMode) ?? databaseContextDepth(input.connection) ?? 'fast';
const queryHistory = { const queryHistory = {
enabled: false, enabled: false,
...(input.args.queryHistoryWindowDays !== undefined ...(input.args.queryHistoryWindowDays !== undefined

View file

@ -26,6 +26,33 @@ describe('runtime requirement detection', () => {
); );
}); });
it('does not treat stale local Looker driver aliases as Looker sources', () => {
const config: KtxProjectConfig = {
...buildDefaultKtxProjectConfig(),
connections: {
stale: { driver: 'local_looker' } as never,
},
};
expect(resolveProjectRuntimeRequirements(config).features).toEqual([]);
expect(
resolvePublicIngestRuntimeRequirements({
projectDir: '/tmp/project',
warnings: [],
targets: [
{
connectionId: 'stale',
driver: 'local_looker',
operation: 'source-ingest',
adapter: 'local_looker',
debugCommand: 'ktx ingest stale --debug',
steps: ['source-ingest'],
},
],
}).features,
).toEqual([]);
});
it('requires core for query-history ingest unless SQL analysis is externally configured', () => { it('requires core for query-history ingest unless SQL analysis is externally configured', () => {
const config: KtxProjectConfig = { const config: KtxProjectConfig = {
...buildDefaultKtxProjectConfig(), ...buildDefaultKtxProjectConfig(),

View file

@ -96,7 +96,7 @@ export function resolveProjectRuntimeRequirements(
for (const [connectionId, connection] of Object.entries(config.connections)) { for (const [connectionId, connection] of Object.entries(config.connections)) {
const driver = normalizeDriver(connection.driver); const driver = normalizeDriver(connection.driver);
if ((driver === 'looker' || driver === 'local_looker') && !hasDaemonOverride(env)) { if (driver === 'looker' && !hasDaemonOverride(env)) {
requirements.push({ requirements.push({
feature: 'core', feature: 'core',
reason: 'looker-source', reason: 'looker-source',
@ -141,7 +141,7 @@ export function resolvePublicIngestRuntimeRequirements(
detail: `${target.connectionId} query-history ingest uses SQL analysis.`, detail: `${target.connectionId} query-history ingest uses SQL analysis.`,
}); });
} }
if ((driver === 'looker' || driver === 'local_looker' || adapter === 'looker') && !hasDaemonOverride(env)) { if ((driver === 'looker' || adapter === 'looker') && !hasDaemonOverride(env)) {
requirements.push({ requirements.push({
feature: 'core', feature: 'core',
reason: 'looker-source', reason: 'looker-source',

View file

@ -123,7 +123,7 @@ const createPostgresLiveDatabaseIntrospection = vi.hoisted(() =>
); );
const isKtxPostgresConnectionConfig = vi.hoisted(() => const isKtxPostgresConnectionConfig = vi.hoisted(() =>
vi.fn((connection: { driver?: string } | undefined) => vi.fn((connection: { driver?: string } | undefined) =>
['postgres', 'postgresql'].includes(String(connection?.driver ?? '').toLowerCase()), String(connection?.driver ?? '').toLowerCase() === 'postgres',
), ),
); );
const KtxPostgresScanConnector = vi.hoisted( const KtxPostgresScanConnector = vi.hoisted(

View file

@ -82,7 +82,6 @@ describe('setup agents', () => {
{ kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, { kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
]); ]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp' })).toEqual([ expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp' })).toEqual([
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' },
{ {
kind: 'file', kind: 'file',
path: join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'), path: join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'),
@ -127,7 +126,6 @@ describe('setup agents', () => {
{ kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md') }, { kind: 'file', path: join(tempDir, '.agents/skills/ktx/SKILL.md') },
]); ]);
expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' })).toEqual([ expect(plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-desktop', scope: 'global', mode: 'mcp-cli' })).toEqual([
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' },
{ {
kind: 'file', kind: 'file',
path: join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'), path: join(tempDir, '.ktx/agents/claude/ktx-analytics.zip'),
@ -518,19 +516,15 @@ describe('setup agents', () => {
const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'); const launcherPath = join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh');
await expect(stat(analyticsSkillPath)).resolves.toBeDefined(); await expect(stat(analyticsSkillPath)).resolves.toBeDefined();
await expect(stat(adminSkillPath)).rejects.toThrow(); await expect(stat(adminSkillPath)).rejects.toThrow();
const launcherStat = await stat(launcherPath); await expect(stat(launcherPath)).rejects.toThrow();
expect(launcherStat.mode & 0o111).not.toBe(0);
const launcher = await readFile(launcherPath, 'utf-8');
expect(launcher).toContain('KTX_CLI_BIN=');
expect(launcher).toContain('.nvm/versions/node');
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json'); const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
const config = JSON.parse(await readFile(configPath, 'utf-8')) as { const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: { ktx: { command: string; args: string[]; env?: Record<string, string> } }; mcpServers: { ktx: { command: string; args: string[]; env?: Record<string, string> } };
}; };
expect(config.mcpServers.ktx).toEqual({ expect(config.mcpServers.ktx).toEqual({
command: launcherPath, command: process.execPath,
args: ['--project-dir', tempDir, 'mcp', 'stdio'], args: [expect.stringContaining('bin.js'), '--project-dir', tempDir, 'mcp', 'stdio'],
}); });
expect(await readZipText(analyticsSkillPath, 'ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow'); expect(await readZipText(analyticsSkillPath, 'ktx-analytics/SKILL.md')).toContain('KTX Analytics Workflow');
@ -901,7 +895,7 @@ describe('setup agents', () => {
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json'); const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
await expect(stat(analyticsSkillPath)).resolves.toBeDefined(); await expect(stat(analyticsSkillPath)).resolves.toBeDefined();
await expect(stat(adminSkillPath)).resolves.toBeDefined(); await expect(stat(adminSkillPath)).resolves.toBeDefined();
await expect(stat(launcherPath)).resolves.toBeDefined(); await expect(stat(launcherPath)).rejects.toThrow();
const beforeConfig = JSON.parse(await readFile(configPath, 'utf-8')) as { const beforeConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: Record<string, unknown>; mcpServers: Record<string, unknown>;
}; };
@ -911,7 +905,6 @@ describe('setup agents', () => {
await expect(stat(analyticsSkillPath)).rejects.toThrow(); await expect(stat(analyticsSkillPath)).rejects.toThrow();
await expect(stat(adminSkillPath)).rejects.toThrow(); await expect(stat(adminSkillPath)).rejects.toThrow();
await expect(stat(launcherPath)).rejects.toThrow();
const afterConfig = JSON.parse(await readFile(configPath, 'utf-8')) as { const afterConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
mcpServers: Record<string, unknown>; mcpServers: Record<string, unknown>;
}; };

View file

@ -1,5 +1,5 @@
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path'; import { dirname, join, relative, resolve } from 'node:path';
import type { Writable } from 'node:stream'; import type { Writable } from 'node:stream';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@ -55,7 +55,7 @@ export interface KtxAgentInstallManifest {
| { | {
kind: 'file'; kind: 'file';
path: string; path: string;
role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-desktop-skill-bundle' | 'launcher'; role?: 'skill' | 'rule' | 'analytics-skill' | 'claude-desktop-skill-bundle';
} }
| { kind: 'json-key'; path: string; jsonPath: string[] } | { kind: 'json-key'; path: string; jsonPath: string[] }
>; >;
@ -312,15 +312,12 @@ function collectClaudeDesktopForwardedEnv(source: NodeJS.ProcessEnv): Record<str
return captured; return captured;
} }
function claudeDesktopMcpEntry(input: { function claudeDesktopMcpEntry(input: { projectDir: string; env?: NodeJS.ProcessEnv }): Record<string, unknown> {
launcherPath: string;
projectDir: string;
env?: NodeJS.ProcessEnv;
}): Record<string, unknown> {
const captured = collectClaudeDesktopForwardedEnv(input.env ?? process.env); const captured = collectClaudeDesktopForwardedEnv(input.env ?? process.env);
const launcher = ktxCliLauncher();
return { return {
command: input.launcherPath, command: launcher.command,
args: ['--project-dir', input.projectDir, 'mcp', 'stdio'], args: [...launcher.args, '--project-dir', input.projectDir, 'mcp', 'stdio'],
...(Object.keys(captured).length > 0 ? { env: captured } : {}), ...(Object.keys(captured).length > 0 ? { env: captured } : {}),
}; };
} }
@ -336,11 +333,10 @@ async function installMcpClientConfig(input: {
if (input.target === 'claude-desktop') { if (input.target === 'claude-desktop') {
const config = claudeDesktopConfigPath(); const config = claudeDesktopConfigPath();
const launcherPath = claudeDesktopLauncherPath(input.projectDir);
await writeJsonKey( await writeJsonKey(
config.path, config.path,
config.jsonPath, config.jsonPath,
claudeDesktopMcpEntry({ launcherPath, projectDir: input.projectDir }), claudeDesktopMcpEntry({ projectDir: input.projectDir }),
); );
entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }); entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath });
return { entries, snippets, notices }; return { entries, snippets, notices };
@ -406,10 +402,6 @@ function claudeDesktopAdminSkillBundlePath(projectDir: string): string {
return join(resolve(projectDir), '.ktx/agents/claude/ktx.zip'); return join(resolve(projectDir), '.ktx/agents/claude/ktx.zip');
} }
function claudeDesktopLauncherPath(projectDir: string): string {
return join(resolve(projectDir), '.ktx/agents/claude/ktx-plugin-runner.sh');
}
/** @internal */ /** @internal */
export function plannedKtxAgentFiles(input: { export function plannedKtxAgentFiles(input: {
projectDir: string; projectDir: string;
@ -449,7 +441,6 @@ export function plannedKtxAgentFiles(input: {
} }
if (input.target === 'claude-desktop') { if (input.target === 'claude-desktop') {
return [ return [
{ kind: 'file', path: claudeDesktopLauncherPath(input.projectDir), role: 'launcher' as const },
{ {
kind: 'file', kind: 'file',
path: claudeDesktopAnalyticsSkillBundlePath(input.projectDir), path: claudeDesktopAnalyticsSkillBundlePath(input.projectDir),
@ -593,61 +584,6 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
].join('\n'); ].join('\n');
} }
function claudeDesktopLauncherContent(input: { launcher: KtxCliLauncher }): string {
const binPath = input.launcher.args[0];
if (!binPath) {
throw new Error('Expected KTX CLI launcher to include a bin path.');
}
const candidates = [
input.launcher.command,
'/opt/homebrew/bin/node',
'/usr/local/bin/node',
'/usr/bin/node',
];
return [
'#!/bin/sh',
'set -eu',
'',
`KTX_CLI_BIN=${shellScriptQuote(binPath)}`,
'',
'run_with_node() {',
' node_bin=$1',
' shift',
' exec "$node_bin" "$KTX_CLI_BIN" "$@"',
'}',
'',
'if [ -n "${KTX_NODE:-}" ] && [ -x "${KTX_NODE:-}" ]; then',
' run_with_node "$KTX_NODE" "$@"',
'fi',
'',
'if [ -x "$HOME/.volta/bin/node" ]; then',
' run_with_node "$HOME/.volta/bin/node" "$@"',
'fi',
'',
...candidates.map((candidate) =>
[
`if [ -x ${shellScriptQuote(candidate)} ]; then`,
` run_with_node ${shellScriptQuote(candidate)} "$@"`,
'fi',
].join('\n'),
),
'',
'for candidate in "$HOME"/.nvm/versions/node/*/bin/node; do',
' if [ -x "$candidate" ]; then',
' run_with_node "$candidate" "$@"',
' fi',
'done',
'',
'if command -v node >/dev/null 2>&1; then',
' run_with_node "$(command -v node)" "$@"',
'fi',
'',
'echo "KTX Claude Desktop launcher could not find Node.js. Set KTX_NODE to a Node executable and rerun ktx setup --agents." >&2',
'exit 127',
'',
].join('\n');
}
async function writeClaudeDesktopSkillBundle(input: { async function writeClaudeDesktopSkillBundle(input: {
projectDir: string; projectDir: string;
path: string; path: string;
@ -675,15 +611,6 @@ function claudeDesktopSkillNameForBundle(path: string): 'ktx-analytics' | 'ktx'
throw new Error(`Unsupported Claude Desktop skill bundle path: ${path}`); throw new Error(`Unsupported Claude Desktop skill bundle path: ${path}`);
} }
async function writeClaudeDesktopLauncher(input: {
path: string;
launcher: KtxCliLauncher;
}): Promise<void> {
await mkdir(dirname(input.path), { recursive: true });
await writeFile(input.path, claudeDesktopLauncherContent({ launcher: input.launcher }), 'utf-8');
await chmod(input.path, 0o755);
}
function ruleInstructionContent(input: { projectDir: string }): string { function ruleInstructionContent(input: { projectDir: string }): string {
return [ return [
`Use the \`ktx\` CLI to query local semantic context and wiki knowledge for this project ` + `Use the \`ktx\` CLI to query local semantic context and wiki knowledge for this project ` +
@ -941,10 +868,6 @@ export function formatInstallSummaryLines(
lines.push(`${guidanceInstallLine(install.target)}.`); lines.push(`${guidanceInstallLine(install.target)}.`);
} }
if (hasEntryRole(targetEntries, 'launcher')) {
lines.push('Starts KTX over stdio from Claude Desktop.');
}
return { return {
title: `${targetDisplayName(install.target)} · ${scopeDisplayName(install.scope)}`, title: `${targetDisplayName(install.target)} · ${scopeDisplayName(install.scope)}`,
lines, lines,
@ -1139,10 +1062,6 @@ async function installTarget(input: {
const launcher = ktxCliLauncher(); const launcher = ktxCliLauncher();
for (const entry of entries) { for (const entry of entries) {
if (entry.kind !== 'file') continue; if (entry.kind !== 'file') continue;
if (entry.role === 'launcher') {
await writeClaudeDesktopLauncher({ path: entry.path, launcher });
continue;
}
if (entry.role === 'claude-desktop-skill-bundle') { if (entry.role === 'claude-desktop-skill-bundle') {
await writeClaudeDesktopSkillBundle({ await writeClaudeDesktopSkillBundle({
projectDir: input.projectDir, projectDir: input.projectDir,

View file

@ -260,8 +260,6 @@ function createPromptAdapter(): KtxSetupDatabasesPromptAdapter {
function normalizeDriver(driver: string | undefined): KtxSetupDatabaseDriver | null { function normalizeDriver(driver: string | undefined): KtxSetupDatabaseDriver | null {
const normalized = String(driver ?? '').toLowerCase(); const normalized = String(driver ?? '').toLowerCase();
if (normalized === 'postgresql') return 'postgres';
if (normalized === 'sqlite3') return 'sqlite';
return DRIVER_OPTIONS.some((option) => option.value === normalized) ? (normalized as KtxSetupDatabaseDriver) : null; return DRIVER_OPTIONS.some((option) => option.value === normalized) ? (normalized as KtxSetupDatabaseDriver) : null;
} }

View file

@ -43,16 +43,12 @@ function sqlAnalysisDialectForDriver(driver: string | undefined): SqlAnalysisDia
const normalized = String(driver ?? '').trim().toLowerCase(); const normalized = String(driver ?? '').trim().toLowerCase();
const map: Record<string, SqlAnalysisDialect> = { const map: Record<string, SqlAnalysisDialect> = {
postgres: 'postgres', postgres: 'postgres',
postgresql: 'postgres',
bigquery: 'bigquery', bigquery: 'bigquery',
snowflake: 'snowflake', snowflake: 'snowflake',
mysql: 'mysql', mysql: 'mysql',
sqlserver: 'tsql', sqlserver: 'tsql',
mssql: 'tsql',
sqlite: 'sqlite', sqlite: 'sqlite',
sqlite3: 'sqlite',
clickhouse: 'clickhouse', clickhouse: 'clickhouse',
redshift: 'redshift',
}; };
return map[normalized] ?? 'postgres'; return map[normalized] ?? 'postgres';
} }

View file

@ -92,10 +92,6 @@ type ClaudeCodeAuthProbe = (input: {
const PROJECT_READY_COMMANDS = KTX_NEXT_STEP_DIRECT_COMMANDS.map((step) => step.command); const PROJECT_READY_COMMANDS = KTX_NEXT_STEP_DIRECT_COMMANDS.map((step) => step.command);
function hasOwnField(value: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
interface LocalStatsIngestPerConnection { interface LocalStatsIngestPerConnection {
connectionId: string; connectionId: string;
adapter: string; adapter: string;
@ -332,7 +328,6 @@ function buildConnectionStatus(
switch (driver) { switch (driver) {
case 'postgres': case 'postgres':
case 'postgresql':
case 'mysql': case 'mysql':
case 'clickhouse': case 'clickhouse':
case 'sqlserver': { case 'sqlserver': {
@ -701,7 +696,7 @@ async function buildQueryHistoryStatus(
} }
const ADAPTER_DRIVER_REQUIREMENT: Record<string, string[]> = { const ADAPTER_DRIVER_REQUIREMENT: Record<string, string[]> = {
'live-database': ['postgres', 'postgresql', 'mysql', 'snowflake', 'bigquery', 'clickhouse', 'sqlite', 'sqlserver'], 'live-database': ['postgres', 'mysql', 'snowflake', 'bigquery', 'clickhouse', 'sqlite', 'sqlserver'],
dbt: ['dbt', 'dbt-core', 'dbt-cloud'], dbt: ['dbt', 'dbt-core', 'dbt-cloud'],
notion: ['notion'], notion: ['notion'],
metabase: ['metabase'], metabase: ['metabase'],
@ -740,30 +735,6 @@ function buildWarnings(
): WarningItem[] { ): WarningItem[] {
const warnings: WarningItem[] = []; const warnings: WarningItem[] = [];
for (const [connectionId, connection] of Object.entries(config.connections)) {
const driver = String(connection.driver ?? '').toLowerCase();
if (hasOwnField(connection, 'readonly')) {
warnings.push({
message: `connections.${connectionId}.readonly is no longer used.`,
fix: `Remove connections.${connectionId}.readonly from ktx.yaml.`,
});
}
if ((driver === 'sqlite' || driver === 'sqlite3') && hasOwnField(connection, 'file_path')) {
warnings.push({
message: `connections.${connectionId}.file_path was removed.`,
fix: `Rename connections.${connectionId}.file_path to path.`,
});
}
if (driver === 'notion' && hasOwnField(connection, 'last_successful_cursor')) {
warnings.push({
message: `connections.${connectionId}.last_successful_cursor is local sync state.`,
fix: 'Remove it from ktx.yaml. KTX stores the Notion cursor in .ktx/db.sqlite.',
});
}
}
for (const adapter of config.ingest.adapters) { for (const adapter of config.ingest.adapters) {
const requiredDrivers = ADAPTER_DRIVER_REQUIREMENT[adapter]; const requiredDrivers = ADAPTER_DRIVER_REQUIREMENT[adapter];
if (!requiredDrivers) continue; if (!requiredDrivers) continue;

View file

@ -327,7 +327,7 @@ def introspect_database_response(
now: NowProvider | None = None, now: NowProvider | None = None,
) -> DatabaseIntrospectionResponse: ) -> DatabaseIntrospectionResponse:
driver = _driver_name(request.driver) driver = _driver_name(request.driver)
if driver not in {"postgres", "postgresql"}: if driver != "postgres":
raise ValueError('database introspection supports only driver "postgres"') raise ValueError('database introspection supports only driver "postgres"')
rows = (load_rows or _load_postgres_rows)(request) rows = (load_rows or _load_postgres_rows)(request)

View file

@ -13,7 +13,7 @@ from semantic_layer.models import QueryResult, SourceDefinition
class SemanticLayerQueryRequest(BaseModel): class SemanticLayerQueryRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(extra="forbid")
sources: list[dict[str, Any]] sources: list[dict[str, Any]]
query: dict[str, Any] query: dict[str, Any]

View file

@ -5,7 +5,7 @@ from concurrent.futures import ProcessPoolExecutor
from typing import Literal from typing import Literal
import sqlglot import sqlglot
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, Field
from sqlglot import exp from sqlglot import exp
SqlAnalysisClause = Literal["select", "where", "join", "groupBy", "having", "orderBy"] SqlAnalysisClause = Literal["select", "where", "join", "groupBy", "having", "orderBy"]
@ -23,8 +23,6 @@ class AnalyzeSqlBatchRequest(BaseModel):
class AnalyzeSqlBatchResult(BaseModel): class AnalyzeSqlBatchResult(BaseModel):
model_config = ConfigDict(populate_by_name=True)
tables_touched: list[str] = Field(default_factory=list) tables_touched: list[str] = Field(default_factory=list)
columns_by_clause: dict[SqlAnalysisClause, list[str]] = Field(default_factory=dict) columns_by_clause: dict[SqlAnalysisClause, list[str]] = Field(default_factory=dict)
error: str | None = None error: str | None = None

View file

@ -1,9 +1,8 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict
from typing import Literal from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, Field
from semantic_layer.table_identifier_parser import ( from semantic_layer.table_identifier_parser import (
ParseTableIdentifierItem as SharedParseTableIdentifierItem, ParseTableIdentifierItem as SharedParseTableIdentifierItem,
parse_table_identifier_batch, parse_table_identifier_batch,
@ -30,8 +29,6 @@ class ParseTableIdentifierBatchRequest(BaseModel):
class ParsedIdentifier(BaseModel): class ParsedIdentifier(BaseModel):
model_config = ConfigDict(populate_by_name=True)
ok: bool ok: bool
catalog: str | None = None catalog: str | None = None
schema_: str | None = Field(default=None, alias="schema") schema_: str | None = Field(default=None, alias="schema")
@ -60,7 +57,15 @@ def parse_table_identifier_response(
) )
return ParseTableIdentifierBatchResponse( return ParseTableIdentifierBatchResponse(
results={ results={
key: ParsedIdentifier.model_validate(asdict(value)) key: ParsedIdentifier(
ok=value.ok,
catalog=value.catalog,
schema=value.schema_,
name=value.name,
canonical_table=value.canonical_table,
reason=value.reason,
detail=value.detail,
)
for key, value in shared_results.items() for key, value in shared_results.items()
} }
) )

View file

@ -138,6 +138,18 @@ def test_introspect_database_response_rejects_non_postgres_driver() -> None:
) )
def test_introspect_database_response_rejects_legacy_postgresql_driver() -> None:
with pytest.raises(ValueError, match='supports only driver "postgres"'):
introspect_database_response(
DatabaseIntrospectionRequest(
connection_id="warehouse",
driver="postgresql",
url="postgresql://readonly@example.test/warehouse",
),
load_rows=lambda request: DatabaseIntrospectionRows([], [], []),
)
def test_database_introspection_request_rejects_empty_schema_list() -> None: def test_database_introspection_request_rejects_empty_schema_list() -> None:
with pytest.raises(ValueError, match="at least one schema"): with pytest.raises(ValueError, match="at least one schema"):
DatabaseIntrospectionRequest( DatabaseIntrospectionRequest(

View file

@ -3,6 +3,8 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
import pytest
from ktx_daemon.semantic_layer import ( from ktx_daemon.semantic_layer import (
SemanticLayerQueryRequest, SemanticLayerQueryRequest,
ValidateSourcesRequest, ValidateSourcesRequest,
@ -95,6 +97,16 @@ def test_query_semantic_layer_emits_plan_and_sql_debug_events(
assert "public.orders" not in captured.err assert "public.orders" not in captured.err
def test_semantic_layer_request_rejects_project_id_field_name() -> None:
with pytest.raises(ValueError):
SemanticLayerQueryRequest(
sources=[],
dialect="postgres",
project_id="a" * 64,
query={"measures": ["orders.order_count"]},
)
def test_validate_semantic_layer_reports_duplicate_measure_names() -> None: def test_validate_semantic_layer_reports_duplicate_measure_names() -> None:
invalid_source = { invalid_source = {
**ORDERS_SOURCE, **ORDERS_SOURCE,

View file

@ -201,7 +201,7 @@ class SourceLoader:
name = col.get("name") name = col.get("name")
if name in base_by_name: if name in base_by_name:
raise ValueError( raise ValueError(
f"column '{name}' in columns patches a manifest column on '{base.name}' move it to 'column_overrides:'" f"column '{name}' in columns patches a manifest column on '{base.name}' - move it to 'column_overrides:'"
) )
source.columns.append(SourceColumn(**col)) source.columns.append(SourceColumn(**col))