mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
refactor: remove legacy ktx compatibility shims (#211)
* refactor: remove legacy ktx compatibility shims * fix: restore overlay collision guidance
This commit is contained in:
parent
a954a29a76
commit
96952fb43c
59 changed files with 294 additions and 342 deletions
|
|
@ -166,8 +166,8 @@ agent also needs pinned `ktx` admin commands.
|
|||
|
||||
After setup, **ktx** prints **Required before using agents** with the exact
|
||||
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
|
||||
separate skill upload steps under `.ktx/agents/claude/`.
|
||||
it before opening your agent. Claude Desktop gets a stdio MCP config entry and
|
||||
prints separate skill upload steps under `.ktx/agents/claude/`.
|
||||
|
||||
## Workspace layout
|
||||
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ context-source drivers share the map.
|
|||
|
||||
| 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` |
|
||||
| `sqlite` | Warehouse | `driver` | `url` or `path`, `enabled_tables` |
|
||||
| `sqlserver` | Warehouse | `driver` | `url`, `enabled_tables` |
|
||||
|
|
|
|||
|
|
@ -183,10 +183,8 @@ Claude Desktop skill packages for the **ktx** workflows:
|
|||
|
||||
- `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or
|
||||
`%AppData%/Claude/claude_desktop_config.json` (Windows) gets an
|
||||
`mcpServers.ktx` entry that runs the **ktx** MCP server over stdio via a local
|
||||
launcher shim at `.ktx/agents/claude/ktx-plugin-runner.sh`. The shim locates
|
||||
a usable Node.js (Volta, NVM, Homebrew, system) so Claude Desktop can spawn
|
||||
the server without needing `node` in PATH.
|
||||
`mcpServers.ktx` entry that runs the **ktx** MCP server over stdio with the
|
||||
current Node.js executable and the installed `ktx` CLI entrypoint.
|
||||
- `.ktx/agents/claude/ktx-analytics.zip` contains the `ktx-analytics` skill.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -274,9 +274,7 @@ async function testConnectionByDriver(
|
|||
|
||||
if (
|
||||
driver === 'sqlite' ||
|
||||
driver === 'sqlite3' ||
|
||||
driver === 'postgres' ||
|
||||
driver === 'postgresql' ||
|
||||
driver === 'mysql' ||
|
||||
driver === 'clickhouse' ||
|
||||
driver === 'sqlserver' ||
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ function metadataResults(): Map<string, FakeQueryResult> {
|
|||
describe('KtxPostgresScanConnector', () => {
|
||||
it('resolves configuration safely', () => {
|
||||
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(
|
||||
postgresPoolConfigFromConfig({
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ export function isKtxPostgresConnectionConfig(
|
|||
connection: KtxPostgresConnectionConfig | undefined,
|
||||
): connection is KtxPostgresConnectionConfig {
|
||||
const driver = String(connection?.driver ?? '').toLowerCase();
|
||||
return driver === 'postgres' || driver === 'postgresql';
|
||||
return driver === 'postgres';
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ export function isKtxSqliteConnectionConfig(
|
|||
connection: KtxSqliteConnectionConfig | undefined,
|
||||
): connection is KtxSqliteConnectionConfig {
|
||||
const driver = String(connection?.driver ?? '').toLowerCase();
|
||||
return driver === 'sqlite' || driver === 'sqlite3';
|
||||
return driver === 'sqlite';
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
|
|
|
|||
|
|
@ -694,7 +694,7 @@ function isLocalSqlAnalysisConnectionRefused(input: { capturedOutput?: string; f
|
|||
|
||||
function friendlyDriverName(driver: string): string {
|
||||
const normalized = driver.toLowerCase();
|
||||
if (normalized === 'postgres' || normalized === 'postgresql') return 'PostgreSQL';
|
||||
if (normalized === 'postgres') return 'PostgreSQL';
|
||||
if (normalized === 'mysql') return 'MySQL';
|
||||
if (normalized === 'sqlserver') return 'SQL Server';
|
||||
if (normalized === 'bigquery') return 'BigQuery';
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { getDialectForDriver } from './dialects.js';
|
|||
describe('getDialectForDriver', () => {
|
||||
it.each([
|
||||
['postgres', '"public"."orders"'],
|
||||
['postgresql', '"public"."orders"'],
|
||||
['mysql', '`public`.`orders`'],
|
||||
['clickhouse', '`public`.`orders`'],
|
||||
['sqlite', '"orders"'],
|
||||
|
|
@ -24,7 +23,12 @@ describe('getDialectForDriver', () => {
|
|||
|
||||
it('throws with a supported-driver list for unknown drivers', () => {
|
||||
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"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,14 +2,12 @@ import type { KtxSchemaDimensionType, KtxTableRef } from '../scan/types.js';
|
|||
|
||||
type SupportedDriver =
|
||||
| 'postgres'
|
||||
| 'postgresql'
|
||||
| 'mysql'
|
||||
| 'sqlserver'
|
||||
| 'snowflake'
|
||||
| 'bigquery'
|
||||
| 'clickhouse'
|
||||
| 'sqlite'
|
||||
| 'sqlite3';
|
||||
| 'sqlite';
|
||||
|
||||
export interface KtxDialect {
|
||||
readonly type: SupportedDriver;
|
||||
|
|
@ -23,9 +21,7 @@ const supportedDrivers: SupportedDriver[] = [
|
|||
'clickhouse',
|
||||
'mysql',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'sqlite',
|
||||
'sqlite3',
|
||||
'snowflake',
|
||||
'sqlserver',
|
||||
];
|
||||
|
|
@ -83,11 +79,9 @@ function createDialect(type: SupportedDriver, quote: (identifier: string) => str
|
|||
|
||||
const dialects: Record<SupportedDriver, KtxDialect> = {
|
||||
postgres: createDialect('postgres', doubleQuoted),
|
||||
postgresql: createDialect('postgresql', doubleQuoted),
|
||||
mysql: createDialect('mysql', backtickQuoted),
|
||||
clickhouse: createDialect('clickhouse', backtickQuoted),
|
||||
sqlite: createDialect('sqlite', doubleQuoted, true),
|
||||
sqlite3: createDialect('sqlite3', doubleQuoted, true),
|
||||
snowflake: createDialect('snowflake', doubleQuoted),
|
||||
bigquery: createDialect('bigquery', bigQueryQuoted),
|
||||
sqlserver: createDialect('sqlserver', bracketQuoted),
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ export function createDefaultLocalQueryExecutor(options: DefaultLocalQueryExecut
|
|||
return {
|
||||
async execute(input: KtxSqlQueryExecutionInput): Promise<KtxSqlQueryExecutionResult> {
|
||||
const driver = driverFor(input);
|
||||
if (driver === 'postgres' || driver === 'postgresql') {
|
||||
if (driver === 'postgres') {
|
||||
return postgres.execute(input);
|
||||
}
|
||||
if (driver === 'sqlite' || driver === 'sqlite3') {
|
||||
if (driver === 'sqlite') {
|
||||
return sqlite.execute(input);
|
||||
}
|
||||
throw new Error(`No local query executor is configured for driver "${input.connection?.driver ?? 'unknown'}".`);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ describe('local connection info helpers', () => {
|
|||
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', () => {
|
||||
expect(localConnectionTypeForConfig('prod-metabase', { driver: 'metabase', api_url: 'https://metabase.example.com' })).toBe(
|
||||
'metabase',
|
||||
|
|
|
|||
|
|
@ -20,10 +20,8 @@ export interface LocalConnectionInfo {
|
|||
|
||||
const DRIVER_TO_CONNECTION_TYPE: Record<string, ConnectionType> = {
|
||||
postgres: 'POSTGRESQL',
|
||||
postgresql: 'POSTGRESQL',
|
||||
sqlite: 'SQLITE',
|
||||
sqlserver: 'SQLSERVER',
|
||||
mssql: 'SQLSERVER',
|
||||
mysql: 'MYSQL',
|
||||
clickhouse: 'CLICKHOUSE',
|
||||
snowflake: 'SNOWFLAKE',
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export function createPostgresQueryExecutor(options: PostgresQueryExecutorOption
|
|||
async execute(input: KtxSqlQueryExecutionInput): Promise<KtxSqlQueryExecutionResult> {
|
||||
const driver = connectionDriver(input);
|
||||
const connection = input.connection;
|
||||
if (driver !== 'postgres' && driver !== 'postgresql') {
|
||||
if (driver !== 'postgres') {
|
||||
throw new Error(`Local Postgres execution cannot run driver "${connection?.driver ?? 'unknown'}".`);
|
||||
}
|
||||
if (typeof connection?.url !== 'string' || connection.url.trim().length === 0) {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ function sqlitePathFromUrl(url: string): string {
|
|||
/** @internal */
|
||||
export function sqliteDatabasePathFromConnection(input: KtxSqlQueryExecutionInput): string {
|
||||
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'}".`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export function queryHistoryDialectForConnection(connection: unknown): HistoricS
|
|||
}
|
||||
const conn = recordOrNull(connection);
|
||||
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 === 'snowflake') return 'snowflake';
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ describe('createDaemonLiveDatabaseIntrospection', () => {
|
|||
const introspection = createDaemonLiveDatabaseIntrospection({
|
||||
connections: {
|
||||
warehouse: {
|
||||
driver: 'postgresql',
|
||||
driver: 'postgres',
|
||||
url: 'postgres://localhost:5432/warehouse',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ function optionalString(value: unknown): string | undefined {
|
|||
|
||||
function normalizeDriver(driver: unknown): string {
|
||||
const normalized = String(driver ?? '').trim().toLowerCase();
|
||||
return normalized === 'postgresql' ? 'postgres' : normalized;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requirePostgresConnection(
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ describe('looker dialect and target validation helpers', () => {
|
|||
it('maps Looker dialect names to KTX connection types', () => {
|
||||
expect(lookerDialectToConnectionType('bigquery_standard_sql')).toBe('BIGQUERY');
|
||||
expect(lookerDialectToConnectionType('postgres')).toBe('POSTGRESQL');
|
||||
expect(lookerDialectToConnectionType('mssql')).toBe('SQLSERVER');
|
||||
expect(lookerDialectToConnectionType('mssql')).toBeNull();
|
||||
expect(lookerDialectToConnectionType('tsql')).toBeNull();
|
||||
expect(lookerDialectToConnectionType('unknown')).toBeNull();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,12 +7,9 @@ const LOOKER_DIALECT_TO_CONNECTION_TYPE = {
|
|||
bigquery_standard_sql: 'BIGQUERY',
|
||||
snowflake: 'SNOWFLAKE',
|
||||
postgres: 'POSTGRESQL',
|
||||
postgresql: 'POSTGRESQL',
|
||||
mysql: 'MYSQL',
|
||||
sqlite: 'SQLITE',
|
||||
sqlserver: 'SQLSERVER',
|
||||
mssql: 'SQLSERVER',
|
||||
tsql: 'SQLSERVER',
|
||||
clickhouse: 'CLICKHOUSE',
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -168,7 +168,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
|
||||
const historicSqlDialectByDriver = new Map<string, 'postgres' | 'bigquery' | 'snowflake'>([
|
||||
['postgres', 'postgres'],
|
||||
['postgresql', 'postgres'],
|
||||
['bigquery', 'bigquery'],
|
||||
['snowflake', 'snowflake'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@
|
|||
{
|
||||
"name": "sl_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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -349,7 +349,8 @@
|
|||
"description": "Measures to select. Use semantic-layer keys when available."
|
||||
},
|
||||
"dimensions": {
|
||||
"description": "Dimensions to group by. Strings and {dimension, granularity} are accepted.",
|
||||
"default": [],
|
||||
"description": "Dimensions to group by. Use {field, granularity?} entries.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
|
|
@ -389,7 +390,8 @@
|
|||
}
|
||||
},
|
||||
"order_by": {
|
||||
"description": "Sort clauses. Strings and Cube-style {id, desc} are accepted.",
|
||||
"default": [],
|
||||
"description": "Sort clauses. Use {field, direction?} entries.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
|
|
@ -489,7 +491,7 @@
|
|||
{
|
||||
"name": "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": {
|
||||
"type": "object",
|
||||
"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": {
|
||||
"description": "Optional column filter.",
|
||||
|
|
@ -560,7 +562,10 @@
|
|||
"description": "Column name to inspect."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"table"
|
||||
]
|
||||
},
|
||||
"description": "Tables or columns to inspect. Maximum 20 entities."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,13 +54,13 @@ const toolDescriptions = {
|
|||
'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" }).',
|
||||
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:
|
||||
'Search profile-sampled warehouse values to locate likely source columns for business values. Example: dictionary_search({ values: ["Acme Corp"], connectionId: "warehouse" }).',
|
||||
sl_read_source:
|
||||
'Read a semantic-layer YAML source by connection id and source name. Example: sl_read_source({ connectionId: "warehouse", sourceName: "orders" }).',
|
||||
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:
|
||||
'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:
|
||||
|
|
@ -93,44 +93,16 @@ const slQueryMeasureSchema = z.union([
|
|||
}),
|
||||
]);
|
||||
|
||||
const slQueryDimensionSchema = z.preprocess(
|
||||
(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({
|
||||
const slQueryDimensionSchema = z.object({
|
||||
field: z.string().min(1).describe('Dimension to group by, e.g. "orders.created_at" or "orders.status".'),
|
||||
granularity: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe('Time grain for time dimensions: day, week, month, quarter, or year.'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const slQueryOrderBySchema = z.preprocess(
|
||||
(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({
|
||||
const slQueryOrderBySchema = z.object({
|
||||
field: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
|
@ -141,8 +113,7 @@ const slQueryOrderBySchema = z.preprocess(
|
|||
.enum(['asc', 'desc'])
|
||||
.default('asc')
|
||||
.describe('Sort direction: "asc" or "desc". Defaults to "asc".'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const slQuerySchema = z.object({
|
||||
connectionId: connectionIdSchema
|
||||
|
|
@ -152,7 +123,7 @@ const slQuerySchema = z.object({
|
|||
dimensions: z
|
||||
.array(slQueryDimensionSchema)
|
||||
.default([])
|
||||
.describe('Dimensions to group by. Strings and {dimension, granularity} are accepted.'),
|
||||
.describe('Dimensions to group by. Use {field, granularity?} entries.'),
|
||||
filters: z
|
||||
.array(z.string().describe('Semantic-layer filter expression, e.g. "orders.status = paid".'))
|
||||
.default([])
|
||||
|
|
@ -164,28 +135,16 @@ const slQuerySchema = z.object({
|
|||
order_by: z
|
||||
.array(slQueryOrderBySchema)
|
||||
.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.'),
|
||||
include_empty: z.boolean().default(true).describe('Whether to include empty dimension groups. Defaults to true.'),
|
||||
});
|
||||
|
||||
const entityDetailsTableRefSchema = z.preprocess(
|
||||
(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({
|
||||
const entityDetailsTableRefSchema = z.object({
|
||||
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.'),
|
||||
name: z.string().min(1).describe('Table name.'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const entityDetailsSchema = z.object({
|
||||
connectionId: connectionIdSchema.describe('Connection id whose latest scan snapshot should be read.'),
|
||||
|
|
@ -194,7 +153,7 @@ const entityDetailsSchema = z.object({
|
|||
z.object({
|
||||
table: z
|
||||
.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
|
||||
.array(z.string().min(1).describe('Column name to inspect.'))
|
||||
.optional()
|
||||
|
|
|
|||
|
|
@ -24,17 +24,14 @@ interface CreateLocalProjectMcpContextPortsOptions {
|
|||
function dialectForDriver(driver: string | undefined): string {
|
||||
const normalized = (driver ?? 'postgres').toUpperCase();
|
||||
const map: Record<string, string> = {
|
||||
POSTGRESQL: 'postgres',
|
||||
POSTGRES: 'postgres',
|
||||
BIGQUERY: 'bigquery',
|
||||
SNOWFLAKE: 'snowflake',
|
||||
MYSQL: 'mysql',
|
||||
SQLSERVER: 'tsql',
|
||||
MSSQL: 'tsql',
|
||||
SQLITE: 'sqlite',
|
||||
DUCKDB: 'duckdb',
|
||||
CLICKHOUSE: 'clickhouse',
|
||||
REDSHIFT: 'redshift',
|
||||
DATABRICKS: 'databricks',
|
||||
};
|
||||
return map[normalized] ?? 'postgres';
|
||||
|
|
|
|||
|
|
@ -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 semanticLayer: KtxSemanticLayerMcpPort = {
|
||||
readSource: vi.fn(),
|
||||
|
|
@ -489,9 +525,7 @@ describe('createKtxMcpServer', () => {
|
|||
measures: ['orders.count'],
|
||||
order_by: [
|
||||
{ field: 'orders.total', direction: 'desc' },
|
||||
{ id: 'orders.quarter_label', desc: false },
|
||||
{ id: 'orders.created_at', desc: true },
|
||||
'orders.segment',
|
||||
{ field: 'orders.segment' },
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -501,8 +535,6 @@ describe('createKtxMcpServer', () => {
|
|||
query: expect.objectContaining({
|
||||
order_by: [
|
||||
{ field: 'orders.total', direction: 'desc' },
|
||||
{ field: 'orders.quarter_label', direction: 'asc' },
|
||||
{ field: 'orders.created_at', direction: 'desc' },
|
||||
{ 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 semanticLayer = makeAllContextTools().semanticLayer!;
|
||||
|
||||
|
|
@ -524,7 +584,7 @@ describe('createKtxMcpServer', () => {
|
|||
await getTool(fake.tools, 'sl_query').handler({
|
||||
connectionId: 'warehouse',
|
||||
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(
|
||||
|
|
@ -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 entityDetails = makeAllContextTools().entityDetails!;
|
||||
|
||||
|
|
@ -550,7 +630,7 @@ describe('createKtxMcpServer', () => {
|
|||
|
||||
await getTool(fake.tools, 'entity_details').handler({
|
||||
connectionId: 'warehouse',
|
||||
entities: [{ table: { schema: 'public', table: 'orders' }, columns: ['id'] }],
|
||||
entities: [{ table: { catalog: null, db: 'public', name: 'orders' }, columns: ['id'] }],
|
||||
});
|
||||
|
||||
expect(entityDetails.read).toHaveBeenCalledWith({
|
||||
|
|
@ -1018,7 +1098,7 @@ describe('createKtxMcpServer', () => {
|
|||
await getTool(fake.tools, 'sl_query').handler({
|
||||
connectionId: '00000000-0000-4000-8000-000000000001',
|
||||
measures: ['orders.count'],
|
||||
dimensions: ['orders.created_at'],
|
||||
dimensions: [{ field: 'orders.created_at' }],
|
||||
filters: ['orders.status = paid'],
|
||||
limit: 25,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { connectionConfigSchema } from './driver-schemas.js';
|
|||
describe('connectionConfigSchema (driver discriminated union)', () => {
|
||||
it.each([
|
||||
['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
|
||||
['snowflake', 'snowflake://account/db'],
|
||||
['bigquery', 'bigquery://project/dataset'],
|
||||
|
|
@ -32,6 +31,10 @@ describe('connectionConfigSchema (driver discriminated union)', () => {
|
|||
it('rejects an unknown driver', () => {
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
|
||||
const warehouseDrivers = [
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'mysql',
|
||||
'snowflake',
|
||||
'bigquery',
|
||||
|
|
@ -41,7 +40,6 @@ function warehouseConnectionSchema<const Driver extends WarehouseDriver>(driver:
|
|||
|
||||
const warehouseConnectionSchemas = [
|
||||
warehouseConnectionSchema('postgres'),
|
||||
warehouseConnectionSchema('postgresql'),
|
||||
warehouseConnectionSchema('mysql'),
|
||||
warehouseConnectionSchema('snowflake'),
|
||||
warehouseConnectionSchema('bigquery'),
|
||||
|
|
|
|||
|
|
@ -8,12 +8,8 @@ import type { KtxTableRef } from './types.js';
|
|||
*
|
||||
* Accepted entry forms:
|
||||
* "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)
|
||||
* { 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(
|
||||
connection: Record<string, unknown> | undefined,
|
||||
|
|
@ -33,16 +29,6 @@ function parseEnabledTableEntry(value: unknown): KtxTableRef | null {
|
|||
if (typeof value === 'string') {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1878,6 +1878,15 @@ describe('resolveEnabledTables', () => {
|
|||
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', () => {
|
||||
expect(resolveEnabledTables(undefined)).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -126,19 +126,17 @@ function normalizeDriver(driver: string | undefined): KtxConnectionDriver {
|
|||
const normalized = (driver ?? '').toLowerCase();
|
||||
if (
|
||||
normalized === 'postgres' ||
|
||||
normalized === 'postgresql' ||
|
||||
normalized === 'sqlite' ||
|
||||
normalized === 'sqlite3' ||
|
||||
normalized === 'mysql' ||
|
||||
normalized === 'clickhouse' ||
|
||||
normalized === 'sqlserver' ||
|
||||
normalized === 'bigquery' ||
|
||||
normalized === 'snowflake'
|
||||
) {
|
||||
return normalized === 'sqlite3' ? 'sqlite' : normalized;
|
||||
return normalized;
|
||||
}
|
||||
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'}"`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,9 +47,9 @@ describe('scopedTableNames', () => {
|
|||
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' }]);
|
||||
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', () => {
|
||||
|
|
@ -57,7 +57,7 @@ describe('scopedTableNames', () => {
|
|||
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([
|
||||
{ catalog: null, db: 'public', name: 'users' },
|
||||
{ catalog: 'A', db: 'public', name: 'users' },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* (catalog, db) namespace. `catalog: null` is treated as a wildcard so that
|
||||
* legacy 2-part `"db.name"` entries continue to match. Same for `db: null`.
|
||||
* (catalog, db) namespace.
|
||||
*/
|
||||
export function scopedTableNames(
|
||||
scope: ReadonlySet<KtxTableRefKey>,
|
||||
|
|
@ -45,8 +44,8 @@ export function scopedTableNames(
|
|||
const wantDb = namespace.db ?? null;
|
||||
for (const key of scope) {
|
||||
const ref = tableRefFromKey(key);
|
||||
if (wantCatalog !== null && ref.catalog !== null && ref.catalog !== wantCatalog) continue;
|
||||
if (wantDb !== null && ref.db !== null && ref.db !== wantDb) continue;
|
||||
if (ref.catalog !== wantCatalog) continue;
|
||||
if (ref.db !== wantDb) continue;
|
||||
names.add(ref.name);
|
||||
}
|
||||
return [...names];
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { KtxTableRefKey } from './table-ref.js';
|
|||
export type KtxConnectionDriver =
|
||||
| 'sqlite'
|
||||
| 'postgres'
|
||||
| 'postgresql'
|
||||
| 'sqlserver'
|
||||
| 'bigquery'
|
||||
| 'snowflake'
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type {
|
|||
KtxTableRef,
|
||||
} from './types.js';
|
||||
|
||||
type CatalogDriver = KtxConnectionDriver | 'sqlite3';
|
||||
type CatalogDriver = KtxConnectionDriver;
|
||||
|
||||
export interface WarehouseCatalogServiceDeps {
|
||||
fileStore: KtxFileStorePort;
|
||||
|
|
@ -129,7 +129,7 @@ function splitDisplay(display: string): string[] {
|
|||
}
|
||||
|
||||
function formatDisplay(driver: CatalogDriver, table: KtxTableRef): string {
|
||||
if (driver === 'sqlite' || driver === 'sqlite3') {
|
||||
if (driver === 'sqlite') {
|
||||
return table.name;
|
||||
}
|
||||
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 {
|
||||
const parts = splitDisplay(display);
|
||||
if (driver === 'sqlite' || driver === 'sqlite3') {
|
||||
if (driver === 'sqlite') {
|
||||
return parts.length === 1 ? { catalog: null, db: null, name: parts[0]! } : null;
|
||||
}
|
||||
if (driver === 'bigquery' || driver === 'snowflake' || driver === 'sqlserver') {
|
||||
|
|
@ -156,7 +156,7 @@ function parseDisplay(driver: CatalogDriver, display: string): KtxTableRef | nul
|
|||
}
|
||||
|
||||
function expectedDisplayPartCount(driver: CatalogDriver): number {
|
||||
if (driver === 'sqlite' || driver === 'sqlite3') {
|
||||
if (driver === 'sqlite') {
|
||||
return 1;
|
||||
}
|
||||
if (driver === 'bigquery' || driver === 'snowflake' || driver === 'sqlserver') {
|
||||
|
|
|
|||
|
|
@ -48,17 +48,14 @@ function assertSafeConnectionId(connectionId: string): string {
|
|||
function dialectForDriver(driver: string | undefined): string {
|
||||
const normalized = (driver ?? 'postgres').toUpperCase();
|
||||
const map: Record<string, string> = {
|
||||
POSTGRESQL: 'postgres',
|
||||
POSTGRES: 'postgres',
|
||||
BIGQUERY: 'bigquery',
|
||||
SNOWFLAKE: 'snowflake',
|
||||
MYSQL: 'mysql',
|
||||
SQLSERVER: 'tsql',
|
||||
MSSQL: 'tsql',
|
||||
SQLITE: 'sqlite',
|
||||
DUCKDB: 'duckdb',
|
||||
CLICKHOUSE: 'clickhouse',
|
||||
REDSHIFT: 'redshift',
|
||||
DATABRICKS: 'databricks',
|
||||
};
|
||||
return map[normalized] ?? 'postgres';
|
||||
|
|
|
|||
|
|
@ -392,7 +392,7 @@ describe('local semantic-layer helpers', () => {
|
|||
).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 = [
|
||||
'name: orders',
|
||||
'columns:',
|
||||
|
|
@ -406,9 +406,7 @@ describe('local semantic-layer helpers', () => {
|
|||
validateLocalSlSource(invalidYaml, { project, connectionId: 'warehouse', sourceName: 'orders' }),
|
||||
).resolves.toEqual({
|
||||
valid: false,
|
||||
errors: [
|
||||
"semantic-layer/warehouse/orders.yaml: column 'status' patches a manifest column but is in 'columns:' — move it to 'column_overrides:'",
|
||||
],
|
||||
errors: expect.arrayContaining([expect.stringContaining('columns.0.type')]),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -266,23 +266,6 @@ export async function validateLocalSlSource(
|
|||
try {
|
||||
const parsed = parseYamlRecord(rawYaml);
|
||||
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 errors: string[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 overlayPath = 'semantic-layer/conn-1/orders.yaml';
|
||||
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');
|
||||
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -1082,17 +1082,14 @@ export class SemanticLayerService {
|
|||
static mapDialect(connectionType: string): string {
|
||||
const normalized = connectionType.toUpperCase();
|
||||
const map: Record<string, string> = {
|
||||
POSTGRESQL: 'postgres',
|
||||
POSTGRES: 'postgres',
|
||||
BIGQUERY: 'bigquery',
|
||||
SNOWFLAKE: 'snowflake',
|
||||
MYSQL: 'mysql',
|
||||
SQLSERVER: 'tsql',
|
||||
MSSQL: 'tsql',
|
||||
SQLITE: 'sqlite',
|
||||
DUCKDB: 'duckdb',
|
||||
CLICKHOUSE: 'clickhouse',
|
||||
REDSHIFT: 'redshift',
|
||||
DATABRICKS: 'databricks',
|
||||
};
|
||||
return map[normalized] ?? 'postgres';
|
||||
|
|
@ -1513,7 +1510,7 @@ export function composeOverlay(base: SemanticLayerSource, overlay: Record<string
|
|||
for (const column of computedColumns) {
|
||||
if (baseByLowerName.has(column.name.toLowerCase())) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -597,7 +597,7 @@ describe('runKtxDoctor', () => {
|
|||
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.WAREHOUSE_DATABASE_URL = 'postgresql://reader@example.test/warehouse';
|
||||
process.env.NOTION_TOKEN = 'notion-secret';
|
||||
|
|
@ -644,10 +644,9 @@ describe('runKtxDoctor', () => {
|
|||
).resolves.toBe(0);
|
||||
|
||||
const out = testIo.stdout();
|
||||
expect(out).toContain('Warnings');
|
||||
expect(out).toContain('connections.warehouse.readonly is no longer used.');
|
||||
expect(out).toContain('connections.local.file_path was removed.');
|
||||
expect(out).toContain('connections.docs.last_successful_cursor is local sync state.');
|
||||
expect(out).not.toContain('connections.warehouse.readonly is no longer used.');
|
||||
expect(out).not.toContain('connections.local.file_path was removed.');
|
||||
expect(out).not.toContain('connections.docs.last_successful_cursor is local sync state.');
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.WAREHOUSE_DATABASE_URL;
|
||||
delete process.env.NOTION_TOKEN;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ export type KtxDatabaseContextDepth = 'fast' | 'deep';
|
|||
const KTX_DATABASE_DRIVER_IDS = new Set([
|
||||
'sqlite',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'mysql',
|
||||
'clickhouse',
|
||||
'sqlserver',
|
||||
|
|
|
|||
|
|
@ -17,14 +17,14 @@ export async function createKtxCliScanConnector(
|
|||
`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');;
|
||||
if (!isKtxSqliteConnectionConfig(connection)) {
|
||||
throw invalidConnectionConfigError(connectionId, driver);
|
||||
}
|
||||
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');;
|
||||
if (!isKtxPostgresConnectionConfig(connection)) {
|
||||
throw invalidConnectionConfigError(connectionId, driver);
|
||||
|
|
|
|||
|
|
@ -175,6 +175,35 @@ describe('buildPublicIngestPlan', () => {
|
|||
).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', () => {
|
||||
const project = projectWithConnections({
|
||||
warehouse: { driver: 'postgres', context: { queryHistory: { enabled: false } } },
|
||||
|
|
@ -1045,7 +1074,7 @@ describe('runKtxPublicIngest', () => {
|
|||
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 project = deepReadyProject({ warehouse: { driver: 'postgres' } });
|
||||
const runScan = vi.fn(async () => 0);
|
||||
|
|
@ -1074,8 +1103,8 @@ describe('runKtxPublicIngest', () => {
|
|||
command: 'run',
|
||||
projectDir: '/tmp/project',
|
||||
connectionId: 'warehouse',
|
||||
mode: 'enriched',
|
||||
detectRelationships: true,
|
||||
mode: 'structural',
|
||||
detectRelationships: false,
|
||||
dryRun: false,
|
||||
},
|
||||
expect.objectContaining({ capturedOutput: expect.any(Function) }),
|
||||
|
|
|
|||
|
|
@ -134,7 +134,6 @@ const sourceAdapterByDriver = new Map<string, string>([
|
|||
['metabase', 'metabase'],
|
||||
['local_metabase', 'metabase'],
|
||||
['looker', 'looker'],
|
||||
['local_looker', 'looker'],
|
||||
['notion', 'notion'],
|
||||
['metricflow', 'metricflow'],
|
||||
['dbt', 'dbt'],
|
||||
|
|
@ -143,7 +142,6 @@ const sourceAdapterByDriver = new Map<string, string>([
|
|||
|
||||
const queryHistoryDialectByDriver = new Map<string, HistoricSqlDialect>([
|
||||
['postgres', 'postgres'],
|
||||
['postgresql', 'postgres'],
|
||||
['bigquery', 'bigquery'],
|
||||
['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 {
|
||||
const value = connection.source_dir;
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||||
|
|
@ -340,8 +332,7 @@ function resolveDatabaseTargetOptions(input: {
|
|||
const requestedQh =
|
||||
explicitQueryHistory === 'enabled' ||
|
||||
(explicitQueryHistory !== 'disabled' && (windowOverrideRequested || storedEnabled));
|
||||
let depth =
|
||||
input.args.depth ?? depthFromLegacyScanMode(input.args.scanMode) ?? databaseContextDepth(input.connection) ?? 'fast';
|
||||
let depth = input.args.depth ?? databaseContextDepth(input.connection) ?? 'fast';
|
||||
const queryHistory = {
|
||||
enabled: false,
|
||||
...(input.args.queryHistoryWindowDays !== undefined
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
const config: KtxProjectConfig = {
|
||||
...buildDefaultKtxProjectConfig(),
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export function resolveProjectRuntimeRequirements(
|
|||
|
||||
for (const [connectionId, connection] of Object.entries(config.connections)) {
|
||||
const driver = normalizeDriver(connection.driver);
|
||||
if ((driver === 'looker' || driver === 'local_looker') && !hasDaemonOverride(env)) {
|
||||
if (driver === 'looker' && !hasDaemonOverride(env)) {
|
||||
requirements.push({
|
||||
feature: 'core',
|
||||
reason: 'looker-source',
|
||||
|
|
@ -141,7 +141,7 @@ export function resolvePublicIngestRuntimeRequirements(
|
|||
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({
|
||||
feature: 'core',
|
||||
reason: 'looker-source',
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ const createPostgresLiveDatabaseIntrospection = vi.hoisted(() =>
|
|||
);
|
||||
const isKtxPostgresConnectionConfig = vi.hoisted(() =>
|
||||
vi.fn((connection: { driver?: string } | undefined) =>
|
||||
['postgres', 'postgresql'].includes(String(connection?.driver ?? '').toLowerCase()),
|
||||
String(connection?.driver ?? '').toLowerCase() === 'postgres',
|
||||
),
|
||||
);
|
||||
const KtxPostgresScanConnector = vi.hoisted(
|
||||
|
|
|
|||
|
|
@ -82,7 +82,6 @@ describe('setup agents', () => {
|
|||
{ 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([
|
||||
{ kind: 'file', path: join(tempDir, '.ktx/agents/claude/ktx-plugin-runner.sh'), role: 'launcher' },
|
||||
{
|
||||
kind: 'file',
|
||||
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') },
|
||||
]);
|
||||
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',
|
||||
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');
|
||||
await expect(stat(analyticsSkillPath)).resolves.toBeDefined();
|
||||
await expect(stat(adminSkillPath)).rejects.toThrow();
|
||||
const launcherStat = await stat(launcherPath);
|
||||
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');
|
||||
await expect(stat(launcherPath)).rejects.toThrow();
|
||||
|
||||
const configPath = join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
|
||||
const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
|
||||
mcpServers: { ktx: { command: string; args: string[]; env?: Record<string, string> } };
|
||||
};
|
||||
expect(config.mcpServers.ktx).toEqual({
|
||||
command: launcherPath,
|
||||
args: ['--project-dir', tempDir, 'mcp', 'stdio'],
|
||||
command: process.execPath,
|
||||
args: [expect.stringContaining('bin.js'), '--project-dir', tempDir, 'mcp', 'stdio'],
|
||||
});
|
||||
|
||||
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');
|
||||
await expect(stat(analyticsSkillPath)).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 {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
|
|
@ -911,7 +905,6 @@ describe('setup agents', () => {
|
|||
|
||||
await expect(stat(analyticsSkillPath)).rejects.toThrow();
|
||||
await expect(stat(adminSkillPath)).rejects.toThrow();
|
||||
await expect(stat(launcherPath)).rejects.toThrow();
|
||||
const afterConfig = JSON.parse(await readFile(configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 type { Writable } from 'node:stream';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -55,7 +55,7 @@ export interface KtxAgentInstallManifest {
|
|||
| {
|
||||
kind: 'file';
|
||||
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[] }
|
||||
>;
|
||||
|
|
@ -312,15 +312,12 @@ function collectClaudeDesktopForwardedEnv(source: NodeJS.ProcessEnv): Record<str
|
|||
return captured;
|
||||
}
|
||||
|
||||
function claudeDesktopMcpEntry(input: {
|
||||
launcherPath: string;
|
||||
projectDir: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Record<string, unknown> {
|
||||
function claudeDesktopMcpEntry(input: { projectDir: string; env?: NodeJS.ProcessEnv }): Record<string, unknown> {
|
||||
const captured = collectClaudeDesktopForwardedEnv(input.env ?? process.env);
|
||||
const launcher = ktxCliLauncher();
|
||||
return {
|
||||
command: input.launcherPath,
|
||||
args: ['--project-dir', input.projectDir, 'mcp', 'stdio'],
|
||||
command: launcher.command,
|
||||
args: [...launcher.args, '--project-dir', input.projectDir, 'mcp', 'stdio'],
|
||||
...(Object.keys(captured).length > 0 ? { env: captured } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -336,11 +333,10 @@ async function installMcpClientConfig(input: {
|
|||
|
||||
if (input.target === 'claude-desktop') {
|
||||
const config = claudeDesktopConfigPath();
|
||||
const launcherPath = claudeDesktopLauncherPath(input.projectDir);
|
||||
await writeJsonKey(
|
||||
config.path,
|
||||
config.jsonPath,
|
||||
claudeDesktopMcpEntry({ launcherPath, projectDir: input.projectDir }),
|
||||
claudeDesktopMcpEntry({ projectDir: input.projectDir }),
|
||||
);
|
||||
entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath });
|
||||
return { entries, snippets, notices };
|
||||
|
|
@ -406,10 +402,6 @@ function claudeDesktopAdminSkillBundlePath(projectDir: string): string {
|
|||
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 */
|
||||
export function plannedKtxAgentFiles(input: {
|
||||
projectDir: string;
|
||||
|
|
@ -449,7 +441,6 @@ export function plannedKtxAgentFiles(input: {
|
|||
}
|
||||
if (input.target === 'claude-desktop') {
|
||||
return [
|
||||
{ kind: 'file', path: claudeDesktopLauncherPath(input.projectDir), role: 'launcher' as const },
|
||||
{
|
||||
kind: 'file',
|
||||
path: claudeDesktopAnalyticsSkillBundlePath(input.projectDir),
|
||||
|
|
@ -593,61 +584,6 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
|
|||
].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: {
|
||||
projectDir: string;
|
||||
path: string;
|
||||
|
|
@ -675,15 +611,6 @@ function claudeDesktopSkillNameForBundle(path: string): 'ktx-analytics' | 'ktx'
|
|||
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 {
|
||||
return [
|
||||
`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)}.`);
|
||||
}
|
||||
|
||||
if (hasEntryRole(targetEntries, 'launcher')) {
|
||||
lines.push('Starts KTX over stdio from Claude Desktop.');
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${targetDisplayName(install.target)} · ${scopeDisplayName(install.scope)}`,
|
||||
lines,
|
||||
|
|
@ -1139,10 +1062,6 @@ async function installTarget(input: {
|
|||
const launcher = ktxCliLauncher();
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== 'file') continue;
|
||||
if (entry.role === 'launcher') {
|
||||
await writeClaudeDesktopLauncher({ path: entry.path, launcher });
|
||||
continue;
|
||||
}
|
||||
if (entry.role === 'claude-desktop-skill-bundle') {
|
||||
await writeClaudeDesktopSkillBundle({
|
||||
projectDir: input.projectDir,
|
||||
|
|
|
|||
|
|
@ -260,8 +260,6 @@ function createPromptAdapter(): KtxSetupDatabasesPromptAdapter {
|
|||
|
||||
function normalizeDriver(driver: string | undefined): KtxSetupDatabaseDriver | null {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,16 +43,12 @@ function sqlAnalysisDialectForDriver(driver: string | undefined): SqlAnalysisDia
|
|||
const normalized = String(driver ?? '').trim().toLowerCase();
|
||||
const map: Record<string, SqlAnalysisDialect> = {
|
||||
postgres: 'postgres',
|
||||
postgresql: 'postgres',
|
||||
bigquery: 'bigquery',
|
||||
snowflake: 'snowflake',
|
||||
mysql: 'mysql',
|
||||
sqlserver: 'tsql',
|
||||
mssql: 'tsql',
|
||||
sqlite: 'sqlite',
|
||||
sqlite3: 'sqlite',
|
||||
clickhouse: 'clickhouse',
|
||||
redshift: 'redshift',
|
||||
};
|
||||
return map[normalized] ?? 'postgres';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,10 +92,6 @@ type ClaudeCodeAuthProbe = (input: {
|
|||
|
||||
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 {
|
||||
connectionId: string;
|
||||
adapter: string;
|
||||
|
|
@ -332,7 +328,6 @@ function buildConnectionStatus(
|
|||
|
||||
switch (driver) {
|
||||
case 'postgres':
|
||||
case 'postgresql':
|
||||
case 'mysql':
|
||||
case 'clickhouse':
|
||||
case 'sqlserver': {
|
||||
|
|
@ -701,7 +696,7 @@ async function buildQueryHistoryStatus(
|
|||
}
|
||||
|
||||
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'],
|
||||
notion: ['notion'],
|
||||
metabase: ['metabase'],
|
||||
|
|
@ -740,30 +735,6 @@ function buildWarnings(
|
|||
): 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) {
|
||||
const requiredDrivers = ADAPTER_DRIVER_REQUIREMENT[adapter];
|
||||
if (!requiredDrivers) continue;
|
||||
|
|
|
|||
|
|
@ -327,7 +327,7 @@ def introspect_database_response(
|
|||
now: NowProvider | None = None,
|
||||
) -> DatabaseIntrospectionResponse:
|
||||
driver = _driver_name(request.driver)
|
||||
if driver not in {"postgres", "postgresql"}:
|
||||
if driver != "postgres":
|
||||
raise ValueError('database introspection supports only driver "postgres"')
|
||||
|
||||
rows = (load_rows or _load_postgres_rows)(request)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from semantic_layer.models import QueryResult, SourceDefinition
|
|||
|
||||
|
||||
class SemanticLayerQueryRequest(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
sources: list[dict[str, Any]]
|
||||
query: dict[str, Any]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from concurrent.futures import ProcessPoolExecutor
|
|||
from typing import Literal
|
||||
|
||||
import sqlglot
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlglot import exp
|
||||
|
||||
SqlAnalysisClause = Literal["select", "where", "join", "groupBy", "having", "orderBy"]
|
||||
|
|
@ -23,8 +23,6 @@ class AnalyzeSqlBatchRequest(BaseModel):
|
|||
|
||||
|
||||
class AnalyzeSqlBatchResult(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
tables_touched: list[str] = Field(default_factory=list)
|
||||
columns_by_clause: dict[SqlAnalysisClause, list[str]] = Field(default_factory=dict)
|
||||
error: str | None = None
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, Field
|
||||
from semantic_layer.table_identifier_parser import (
|
||||
ParseTableIdentifierItem as SharedParseTableIdentifierItem,
|
||||
parse_table_identifier_batch,
|
||||
|
|
@ -30,8 +29,6 @@ class ParseTableIdentifierBatchRequest(BaseModel):
|
|||
|
||||
|
||||
class ParsedIdentifier(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
ok: bool
|
||||
catalog: str | None = None
|
||||
schema_: str | None = Field(default=None, alias="schema")
|
||||
|
|
@ -60,7 +57,15 @@ def parse_table_identifier_response(
|
|||
)
|
||||
return ParseTableIdentifierBatchResponse(
|
||||
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()
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
with pytest.raises(ValueError, match="at least one schema"):
|
||||
DatabaseIntrospectionRequest(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ from __future__ import annotations
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ktx_daemon.semantic_layer import (
|
||||
SemanticLayerQueryRequest,
|
||||
ValidateSourcesRequest,
|
||||
|
|
@ -95,6 +97,16 @@ def test_query_semantic_layer_emits_plan_and_sql_debug_events(
|
|||
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:
|
||||
invalid_source = {
|
||||
**ORDERS_SOURCE,
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ class SourceLoader:
|
|||
name = col.get("name")
|
||||
if name in base_by_name:
|
||||
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))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue