@kaelio/ktx 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/assets/python/kaelio_ktx-0.16.0-py3-none-any.whl +0 -0
  2. package/assets/python/manifest.json +4 -4
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/commands/setup-commands.js +1 -0
  5. package/dist/connection-drivers.d.ts +1 -1
  6. package/dist/connection-drivers.js +2 -0
  7. package/dist/connection.js +1 -0
  8. package/dist/connectors/athena/connector.d.ts +160 -0
  9. package/dist/connectors/athena/connector.js +378 -0
  10. package/dist/connectors/athena/dialect.d.ts +31 -0
  11. package/dist/connectors/athena/dialect.js +146 -0
  12. package/dist/connectors/athena/live-database-introspection.d.ts +10 -0
  13. package/dist/connectors/athena/live-database-introspection.js +24 -0
  14. package/dist/connectors/duckdb/connector.d.ts +60 -0
  15. package/dist/connectors/duckdb/connector.js +282 -0
  16. package/dist/connectors/duckdb/dialect.d.ts +31 -0
  17. package/dist/connectors/duckdb/dialect.js +155 -0
  18. package/dist/connectors/duckdb/federated-attach.js +7 -0
  19. package/dist/connectors/duckdb/federated-executor.js +7 -13
  20. package/dist/connectors/duckdb/live-database-introspection.d.ts +8 -0
  21. package/dist/connectors/duckdb/live-database-introspection.js +24 -0
  22. package/dist/connectors/shared/duckdb-json-safe.d.ts +3 -0
  23. package/dist/connectors/shared/duckdb-json-safe.js +12 -0
  24. package/dist/context/connections/connection-type-dialect.d.ts +2 -0
  25. package/dist/context/connections/connection-type-dialect.js +20 -0
  26. package/dist/context/connections/connection-type.d.ts +6 -15
  27. package/dist/context/connections/connection-type.js +1 -10
  28. package/dist/context/connections/dialects.js +4 -0
  29. package/dist/context/connections/drivers.js +38 -0
  30. package/dist/context/connections/federation.d.ts +1 -1
  31. package/dist/context/connections/federation.js +6 -5
  32. package/dist/context/connections/local-warehouse-descriptor.d.ts +1 -0
  33. package/dist/context/connections/local-warehouse-descriptor.js +10 -0
  34. package/dist/context/connections/project-sql-executor.d.ts +24 -0
  35. package/dist/context/connections/project-sql-executor.js +56 -0
  36. package/dist/context/connections/query-policy.d.ts +12 -0
  37. package/dist/context/connections/query-policy.js +46 -0
  38. package/dist/context/core/config-reference.js +11 -1
  39. package/dist/context/daemon/semantic-layer-compute.d.ts +16 -0
  40. package/dist/context/daemon/semantic-layer-compute.js +44 -2
  41. package/dist/context/ingest/adapters/looker/mapping.d.ts +1 -0
  42. package/dist/context/ingest/adapters/looker/mapping.js +3 -10
  43. package/dist/context/ingest/adapters/looker/types.d.ts +6 -15
  44. package/dist/context/ingest/adapters/metabase/mapping.d.ts +1 -0
  45. package/dist/context/ingest/adapters/metabase/mapping.js +1 -0
  46. package/dist/context/mcp/context-tools.js +1 -0
  47. package/dist/context/mcp/local-project-ports.d.ts +1 -1
  48. package/dist/context/mcp/local-project-ports.js +53 -51
  49. package/dist/context/project/config.d.ts +92 -0
  50. package/dist/context/project/driver-schemas.d.ts +46 -0
  51. package/dist/context/project/driver-schemas.js +8 -0
  52. package/dist/context/scan/local-scan.js +3 -1
  53. package/dist/context/scan/types.d.ts +1 -1
  54. package/dist/context/sl/local-query.js +23 -6
  55. package/dist/context/sl/semantic-layer.service.d.ts +0 -4
  56. package/dist/context/sl/semantic-layer.service.js +3 -20
  57. package/dist/context/sl/tools/sl-warehouse-validation.d.ts +1 -1
  58. package/dist/context/sl/tools/sl-warehouse-validation.js +2 -2
  59. package/dist/context/sl/types.d.ts +1 -0
  60. package/dist/context/sql-analysis/dialect-notes.d.ts +1 -1
  61. package/dist/context/sql-analysis/dialect-notes.js +3 -2
  62. package/dist/context/sql-analysis/dialect.js +1 -0
  63. package/dist/context/sql-analysis/dialects/athena.md +12 -0
  64. package/dist/context/sql-analysis/dialects/duckdb.md +10 -0
  65. package/dist/local-adapters.js +17 -0
  66. package/dist/setup-databases.d.ts +1 -1
  67. package/dist/setup-databases.js +52 -2
  68. package/dist/sql.js +9 -14
  69. package/dist/status-project.js +2 -1
  70. package/dist/telemetry/events.d.ts +1 -1
  71. package/package.json +3 -1
  72. package/assets/python/kaelio_ktx-0.15.0-py3-none-any.whl +0 -0
@@ -6,8 +6,10 @@ const warehouseDrivers = [
6
6
  'snowflake',
7
7
  'bigquery',
8
8
  'sqlite',
9
+ 'duckdb',
9
10
  'clickhouse',
10
11
  'sqlserver',
12
+ 'athena',
11
13
  ];
12
14
  function warehouseConnectionSchema(driver) {
13
15
  return z
@@ -28,6 +30,10 @@ function warehouseConnectionSchema(driver) {
28
30
  .positive()
29
31
  .optional()
30
32
  .describe('Maximum execution time for a single read-only query, in milliseconds (default 30000). Enforced as a server-side statement timeout for remote engines and by SIGKILL-ing a forked query subprocess for in-process SQLite. A query exceeding it is cancelled and returns a "query exceeded Ns" error so the agent can revise.'),
33
+ query_policy: z
34
+ .enum(['read-only-sql', 'semantic-layer-only'])
35
+ .optional()
36
+ .describe('Agent-facing query authorship policy (default "read-only-sql"). "read-only-sql" allows parser-validated read-only SQL plus semantic-layer queries. "semantic-layer-only" rejects raw SQL on this connection (`ktx sql`, the sql_execution tool, and federated queries that include it) and restricts semantic-layer queries to measures predefined in the semantic-layer sources. ktx-internal scan and ingest queries are unaffected.'),
31
37
  })
32
38
  .describe(`${driver} warehouse connection. Additional driver-tunable fields (e.g. context.queryHistory) are accepted and passed through.`);
33
39
  }
@@ -37,8 +43,10 @@ const warehouseConnectionSchemas = [
37
43
  warehouseConnectionSchema('snowflake'),
38
44
  warehouseConnectionSchema('bigquery'),
39
45
  warehouseConnectionSchema('sqlite'),
46
+ warehouseConnectionSchema('duckdb'),
40
47
  warehouseConnectionSchema('clickhouse'),
41
48
  warehouseConnectionSchema('sqlserver'),
49
+ warehouseConnectionSchema('athena'),
42
50
  ];
43
51
  const mongodbConnectionSchema = z
44
52
  .looseObject({
@@ -43,15 +43,17 @@ function normalizeDriver(driver) {
43
43
  const normalized = (driver ?? '').toLowerCase();
44
44
  if (normalized === 'postgres' ||
45
45
  normalized === 'sqlite' ||
46
+ normalized === 'duckdb' ||
46
47
  normalized === 'mysql' ||
47
48
  normalized === 'clickhouse' ||
48
49
  normalized === 'sqlserver' ||
49
50
  normalized === 'bigquery' ||
50
51
  normalized === 'snowflake' ||
52
+ normalized === 'athena' ||
51
53
  normalized === 'mongodb') {
52
54
  return normalized;
53
55
  }
54
- throw new Error(`Standalone ktx scan supports postgres/sqlite/mysql/clickhouse/sqlserver/bigquery/snowflake/mongodb in this phase, received "${driver ?? 'unknown'}"`);
56
+ throw new Error(`Standalone ktx scan supports postgres/sqlite/duckdb/mysql/clickhouse/sqlserver/bigquery/snowflake/athena/mongodb in this phase, received "${driver ?? 'unknown'}"`);
55
57
  }
56
58
  function tablePathCount(paths) {
57
59
  return paths.filter((path) => path.startsWith('tables/') && path.endsWith('.json')).length;
@@ -1,5 +1,5 @@
1
1
  import type { KtxTableRefKey } from './table-ref.js';
2
- export type KtxConnectionDriver = 'sqlite' | 'postgres' | 'sqlserver' | 'bigquery' | 'snowflake' | 'mysql' | 'clickhouse' | 'mongodb';
2
+ export type KtxConnectionDriver = 'sqlite' | 'duckdb' | 'postgres' | 'sqlserver' | 'bigquery' | 'snowflake' | 'mysql' | 'clickhouse' | 'athena' | 'mongodb';
3
3
  /** Canonical scan-mode registry. Runtime validation derives its allowlist here. */
4
4
  export declare const KTX_SCAN_MODES: readonly ["structural", "relationships", "enriched"];
5
5
  export type KtxScanMode = (typeof KTX_SCAN_MODES)[number];
@@ -1,15 +1,31 @@
1
1
  import { isSqlQueryableDriver } from '../connections/dialects.js';
2
2
  import { FEDERATED_CONNECTION_ID } from '../connections/federation.js';
3
+ import { connectionQueryPolicy, restrictedFederatedMemberIds } from '../connections/query-policy.js';
3
4
  import { resolveRequiredConnectionId } from '../connections/resolve-connection.js';
4
5
  import { sqlAnalysisDialectForDriver } from '../sql-analysis/dialect.js';
5
6
  import { loadLocalSlSourceRecords } from './local-sl.js';
6
7
  import { toResolvedWire } from './semantic-layer.service.js';
7
8
  import { assertSafeConnectionId } from './source-files.js';
8
9
  const COMPILE_ONLY_REASON = 'Local semantic-layer query compiled SQL but no data-source execution adapter is configured.';
9
- const FEDERATED_SL_QUERY_UNSUPPORTED = `Semantic-layer queries are per-connection and cannot target the federated connection '${FEDERATED_CONNECTION_ID}'. ` +
10
- `Run a cross-database query as read-only SQL instead ktx sql -c ${FEDERATED_CONNECTION_ID} "SELECT ..." or the sql_execution tool — ` +
11
- 'using catalog-qualified table names (connectionId.schema.table, or connectionId.table for sqlite; ' +
12
- 'double-quote ids that are not bare identifiers, e.g. "books-db".public.books).';
10
+ const FEDERATED_SL_QUERY_PREFIX = `Semantic-layer queries are per-connection and cannot target the federated connection '${FEDERATED_CONNECTION_ID}'. `;
11
+ // The raw-SQL fallback is only valid when federated raw SQL is allowed; when a
12
+ // member is restricted (query_policy: semantic-layer-only), assertRawSqlAllowed
13
+ // rejects the same path, so directing the agent there would burn a guaranteed
14
+ // failure. Derive the message from the restricted-member set instead.
15
+ function federatedSlQueryUnsupportedMessage(project) {
16
+ const restricted = restrictedFederatedMemberIds(project.config, project.projectDir);
17
+ if (restricted.length > 0) {
18
+ return (FEDERATED_SL_QUERY_PREFIX +
19
+ `Cross-database SQL through '${FEDERATED_CONNECTION_ID}' is also disabled because member connection(s) ` +
20
+ `${restricted.map((id) => `'${id}'`).join(', ')} are restricted to semantic-layer queries ` +
21
+ '(query_policy: semantic-layer-only). Query each connection on its own through the semantic layer ' +
22
+ '(the sl_query tool or `ktx sl query` with its connection id).');
23
+ }
24
+ return (FEDERATED_SL_QUERY_PREFIX +
25
+ `Run a cross-database query as read-only SQL instead — ktx sql -c ${FEDERATED_CONNECTION_ID} "SELECT ..." or the sql_execution tool — ` +
26
+ 'using catalog-qualified table names (connectionId.schema.table, or connectionId.table for sqlite; ' +
27
+ 'double-quote ids that are not bare identifiers, e.g. "books-db".public.books).');
28
+ }
13
29
  function resolveLocalConnectionId(project, requested) {
14
30
  return assertSafeConnectionId(resolveRequiredConnectionId(project.config, requested));
15
31
  }
@@ -39,7 +55,7 @@ function headersFromColumns(columns) {
39
55
  }
40
56
  export async function compileLocalSlQuery(project, options) {
41
57
  if (options.connectionId === FEDERATED_CONNECTION_ID) {
42
- throw new Error(FEDERATED_SL_QUERY_UNSUPPORTED);
58
+ throw new Error(federatedSlQueryUnsupportedMessage(project));
43
59
  }
44
60
  await options.onProgress?.({ progress: 0, message: 'Compiling query' });
45
61
  const connectionId = resolveLocalConnectionId(project, options.connectionId);
@@ -50,11 +66,12 @@ export async function compileLocalSlQuery(project, options) {
50
66
  }
51
67
  const dialect = sqlAnalysisDialectForDriver(driver);
52
68
  const sources = await loadComputableSources(project, connectionId);
69
+ const predefinedMeasuresOnly = connectionQueryPolicy(project.config.connections[connectionId]) === 'semantic-layer-only';
53
70
  await options.onProgress?.({ progress: 0.3, message: 'Generating SQL' });
54
71
  const response = await options.compute.query({
55
72
  sources,
56
73
  dialect,
57
- query: options.query,
74
+ query: { ...options.query, predefined_measures_only: predefinedMeasuresOnly },
58
75
  });
59
76
  if (!options.execute) {
60
77
  await options.onProgress?.({ progress: 1, message: 'Fetched 0 rows' });
@@ -147,10 +147,6 @@ export declare class SemanticLayerService {
147
147
  descriptions: Record<string, string>;
148
148
  }>;
149
149
  }>;
150
- /**
151
- * All callers should use this instead of maintaining their own dialect maps.
152
- */
153
- static mapDialect(connectionType: string): string;
154
150
  /**
155
151
  * Execute a semantic layer query: load composed sources, generate SQL via
156
152
  * the python SL engine, and execute the generated SQL against the data source.
@@ -1,5 +1,6 @@
1
1
  import YAML from 'yaml';
2
2
  import { noopLogger } from '../../context/core/config.js';
3
+ import { dialectForConnectionType } from '../connections/connection-type-dialect.js';
3
4
  import { normalizeSemanticLayerDescriptions } from './description-normalization.js';
4
5
  import { isOverlaySource, resolvedSourceSchema, sourceDefinitionSchema, sourceOverlaySchema } from './schemas.js';
5
6
  import { isSlYamlPath, resolveSlSourceFile, slDeclaredSourceName, slSourceFilePath } from './source-files.js';
@@ -572,7 +573,7 @@ export class SemanticLayerService {
572
573
  if (!connection) {
573
574
  throw new Error(`Data source not found: ${connectionId}`);
574
575
  }
575
- return SemanticLayerService.mapDialect(connection.connectionType);
576
+ return dialectForConnectionType(connection.connectionType);
576
577
  }
577
578
  async listFilesForConnection(connectionId) {
578
579
  const dir = `${SL_DIR_PREFIX}/${connectionId}`;
@@ -937,24 +938,6 @@ export class SemanticLayerService {
937
938
  }
938
939
  return { columns, tables };
939
940
  }
940
- /**
941
- * All callers should use this instead of maintaining their own dialect maps.
942
- */
943
- static mapDialect(connectionType) {
944
- const normalized = connectionType.toUpperCase();
945
- const map = {
946
- POSTGRES: 'postgres',
947
- BIGQUERY: 'bigquery',
948
- SNOWFLAKE: 'snowflake',
949
- MYSQL: 'mysql',
950
- SQLSERVER: 'tsql',
951
- SQLITE: 'sqlite',
952
- DUCKDB: 'duckdb',
953
- CLICKHOUSE: 'clickhouse',
954
- DATABRICKS: 'databricks',
955
- };
956
- return map[normalized] ?? 'postgres';
957
- }
958
941
  /**
959
942
  * Execute a semantic layer query: load composed sources, generate SQL via
960
943
  * the python SL engine, and execute the generated SQL against the data source.
@@ -980,7 +963,7 @@ export class SemanticLayerService {
980
963
  if (!connection) {
981
964
  throw new Error(`Data source not found: ${connectionId}`);
982
965
  }
983
- const dialect = SemanticLayerService.mapDialect(connection.connectionType);
966
+ const dialect = dialectForConnectionType(connection.connectionType);
984
967
  // 3. Generate SQL via python SL engine
985
968
  const { data: slResult, error: slError } = await this.python.query({
986
969
  sources: sources.map(toResolvedWire),
@@ -1,7 +1,7 @@
1
1
  import type { GitService } from '../../../context/core/git.service.js';
2
2
  import type { KtxFileStorePort } from '../../../context/core/file-store.js';
3
3
  import type { SlConnectionCatalogPort, SlSourcesIndexPort } from '../ports.js';
4
- import { SemanticLayerService } from '../semantic-layer.service.js';
4
+ import type { SemanticLayerService } from '../semantic-layer.service.js';
5
5
  export interface SlValidationDeps {
6
6
  semanticLayerService: SemanticLayerService;
7
7
  connections: SlConnectionCatalogPort;
@@ -1,14 +1,14 @@
1
1
  import YAML from 'yaml';
2
2
  import { SYSTEM_GIT_AUTHOR } from '../../../context/tools/authors.js';
3
+ import { dialectForConnectionType } from '../../connections/connection-type-dialect.js';
3
4
  import { sourceOverlaySchema } from '../schemas.js';
4
- import { SemanticLayerService } from '../semantic-layer.service.js';
5
5
  import { resolveSlSourceFile, slSourceFilePath } from '../source-files.js';
6
6
  import { sourceDefinitionSchema } from './base-semantic-layer.tool.js';
7
7
  function resolveDialect(warehouse) {
8
8
  if (!warehouse) {
9
9
  return null;
10
10
  }
11
- return SemanticLayerService.mapDialect(warehouse);
11
+ return dialectForConnectionType(warehouse);
12
12
  }
13
13
  function wrapWithZeroRowQuery(sql, dialect) {
14
14
  if (dialect === 'tsql') {
@@ -115,6 +115,7 @@ export interface SemanticLayerQueryInput {
115
115
  }>;
116
116
  limit?: number;
117
117
  include_empty?: boolean;
118
+ predefined_measures_only?: boolean;
118
119
  }
119
120
  export interface SemanticLayerQueryExecutionResult {
120
121
  sql: string;
@@ -1,6 +1,6 @@
1
1
  import type { SqlAnalysisDialect } from './ports.js';
2
2
  /** @internal Dialects with an authored ./dialects/<dialect>.md file. */
3
- export declare const DIALECTS_WITH_NOTES: readonly ["postgres", "mysql", "snowflake", "bigquery", "sqlite", "clickhouse", "tsql"];
3
+ export declare const DIALECTS_WITH_NOTES: readonly ["postgres", "mysql", "snowflake", "bigquery", "sqlite", "duckdb", "clickhouse", "tsql", "athena"];
4
4
  /**
5
5
  * SQL syntax notes for a resolved dialect. Falls back to `postgres` — the
6
6
  * resolver's own default for unrecognized drivers — so any SQL connection yields
@@ -4,8 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  // dialect), served by the sql_dialect_notes MCP tool. They are package-internal:
5
5
  // copy-runtime-assets.mjs ships them to dist, and they are never installed onto an
6
6
  // agent target. The set covers every dialect reachable from a configured warehouse
7
- // driver; duckdb/databricks are intentionally absent because no connector produces
8
- // them.
7
+ // driver; databricks is intentionally absent because no connector produces it.
9
8
  /** @internal Dialects with an authored ./dialects/<dialect>.md file. */
10
9
  export const DIALECTS_WITH_NOTES = [
11
10
  'postgres',
@@ -13,8 +12,10 @@ export const DIALECTS_WITH_NOTES = [
13
12
  'snowflake',
14
13
  'bigquery',
15
14
  'sqlite',
15
+ 'duckdb',
16
16
  'clickhouse',
17
17
  'tsql',
18
+ 'athena',
18
19
  ];
19
20
  const notesCache = new Map();
20
21
  function readDialectNotes(dialect) {
@@ -14,6 +14,7 @@ const SQLGLOT_DIALECTS = {
14
14
  duckdb: 'duckdb',
15
15
  clickhouse: 'clickhouse',
16
16
  databricks: 'databricks',
17
+ athena: 'athena',
17
18
  };
18
19
  export function sqlAnalysisDialectForDriver(driver) {
19
20
  return SQLGLOT_DIALECTS[(driver ?? '').toLowerCase()] ?? 'postgres';
@@ -0,0 +1,12 @@
1
+ **athena** SQL conventions (Trino engine over the Glue Data Catalog):
2
+ - **FQTN:** `database.table` (e.g. `analytics.orders`); a bare `table` resolves against the query's default database. Cross-catalog is `catalog.database.table` (e.g. `awsdatacatalog.analytics.orders`).
3
+ - **Identifiers:** case-insensitive and folded to lowercase; double-quote (`"Name"`) to keep case, spaces, or a reserved word. String literals use single quotes only.
4
+ - **Date/time:** native `DATE`/`TIMESTAMP`. Bucket with `date_trunc('month', ts)`, pull parts with `EXTRACT(YEAR FROM ts)`, shift with `date_add('day', -30, current_date)`, difference with `date_diff('day', a, b)`, and format with `date_format(ts, '%Y-%m')`. Parse text with `date_parse(str, '%Y-%m-%d')` or `from_iso8601_timestamp(str)`; `current_date` / `now()` are available.
5
+ - **Top-N / windows:** Athena has no `QUALIFY` — wrap the window in a subquery and filter it: `SELECT * FROM (SELECT ..., ROW_NUMBER() OVER (PARTITION BY key ORDER BY x DESC) AS rn FROM t) WHERE rn = 1` returns one row per key. Use `ORDER BY ... LIMIT n` for a global top-N, and paginate with `OFFSET m LIMIT n` (offset first — `LIMIT n OFFSET m` is a syntax error).
6
+ - **Series:** `CROSS JOIN UNNEST(sequence(DATE '2023-01-01', DATE '2023-12-01', INTERVAL '1' MONTH)) AS s(d)` expands a generated array into a date spine (use `sequence(1, 12)` for integers), then `LEFT JOIN` the aggregated facts onto it so empty periods still appear.
7
+ - **Rolling window over time:** a native `RANGE` frame spans real dates and tolerates gaps — `AVG(amount) OVER (ORDER BY day RANGE BETWEEN INTERVAL '29' DAY PRECEDING AND CURRENT ROW)` is a trailing 30-day average without a spine; guard minimum periods with `COUNT(*) OVER (<same frame>)`.
8
+ - **Approximate aggregates:** `approx_distinct(x)` for cardinality and `approx_percentile(x, 0.5)` for quantiles are far cheaper than exact `COUNT(DISTINCT ...)` on large scans.
9
+ - **Arrays & maps:** explode with `CROSS JOIN UNNEST(arr) AS t(x)` (add `WITH ORDINALITY` for an index); build with `array_agg(x)`, join with `array_join(arr, ',')`, index 1-based (`arr[1]`), and read a map with `element_at(m, key)`.
10
+ - **Safe cast:** `TRY_CAST(x AS DOUBLE)` yields `NULL` for a value that does not parse instead of raising, so counting residual `NULL`s catches an encoding the sample missed; `TRY(expr)` swallows other runtime errors.
11
+ - **Integer division:** `/` between integers truncates (`5 / 2` → `2`); cast an operand (`x / CAST(y AS DOUBLE)`) to keep the fraction, and round only in the final projection.
12
+ - **JSON:** `json_extract_scalar(col, '$.a.b')` returns varchar, `json_extract(col, '$.a')` returns json; cast a JSON string with `CAST(json_parse(col) AS ...)`.
@@ -0,0 +1,10 @@
1
+ **duckdb** SQL conventions:
2
+ - **FQTN:** `schema.table` within one database (e.g. `main.orders`); a bare `table` resolves against `main`. A second, attached database is `db.schema.table` (e.g. `sales.main.orders`).
3
+ - **Identifiers:** case-insensitive; double-quote (`"Name"`) to keep a name with spaces or a reserved word.
4
+ - **Date/time:** native `DATE`/`TIMESTAMP`. Bucket with `date_trunc('month', ts)`, pull parts with `EXTRACT(YEAR FROM ts)`, format with `strftime(ts, '%Y-%m')`, and use `CURRENT_DATE`; cast text with `col::DATE` (or `TRY_CAST(col AS DATE)` to null bad values).
5
+ - **Series:** `FROM generate_series(DATE '2023-01-01', DATE '2023-12-01', INTERVAL 1 MONTH) AS s(d)` builds a date spine (use `range(...)` for integers), then `LEFT JOIN` the aggregated facts onto it so empty periods still appear.
6
+ - **Rolling window over time:** a native calendar-range frame spans real dates and tolerates gaps — `AVG(amount) OVER (ORDER BY day RANGE BETWEEN INTERVAL 29 DAYS PRECEDING AND CURRENT ROW)` is a trailing 30-day average without a spine; guard minimum periods with `COUNT(*) OVER (<same frame>)`.
7
+ - **Integer division:** unlike postgres, `/` is true division (`5 / 2` → `2.5`), so a ratio keeps its fraction; use `//` for floor division (`5 // 2` → `2`) when you want the integer quotient, and round only in the final projection.
8
+ - **Safe cast:** duckdb has `TRY_CAST` — `TRY_CAST(x AS DOUBLE)` yields `NULL` for a value that does not parse instead of raising, so counting residual `NULL`s among non-sentinel rows catches an encoding the sample missed without a regex guard.
9
+ - **Top-N / windows:** filter a window inline with `QUALIFY` — `SELECT ... QUALIFY ROW_NUMBER() OVER (PARTITION BY key ORDER BY x DESC) = 1` returns one row per key without a wrapping CTE; use `ORDER BY ... LIMIT n` for a global top-N.
10
+ - **JSON / semi-structured:** `col->'k'` returns JSON, `col->>'k'` returns text, deep path `json_extract(col, '$.a.b')`; duckdb also has native `STRUCT`, `LIST`, and `MAP` — read a struct field with `col.field` and a list element with `col[1]` (1-indexed).
@@ -1,3 +1,5 @@
1
+ import { createAthenaLiveDatabaseIntrospection } from './connectors/athena/live-database-introspection.js';
2
+ import { isKtxAthenaConnectionConfig } from './connectors/athena/connector.js';
1
3
  import { createBigQueryLiveDatabaseIntrospection } from './connectors/bigquery/live-database-introspection.js';
2
4
  import { isKtxBigQueryConnectionConfig, KtxBigQueryScanConnector } from './connectors/bigquery/connector.js';
3
5
  import { createClickHouseLiveDatabaseIntrospection } from './connectors/clickhouse/live-database-introspection.js';
@@ -9,6 +11,8 @@ import { isKtxPostgresConnectionConfig } from './connectors/postgres/connector.j
9
11
  import { KtxPostgresHistoricSqlQueryClient } from './connectors/postgres/historic-sql-query-client.js';
10
12
  import { createSqliteLiveDatabaseIntrospection } from './connectors/sqlite/live-database-introspection.js';
11
13
  import { isKtxSqliteConnectionConfig } from './connectors/sqlite/connector.js';
14
+ import { createDuckDbLiveDatabaseIntrospection } from './connectors/duckdb/live-database-introspection.js';
15
+ import { isKtxDuckDbConnectionConfig } from './connectors/duckdb/connector.js';
12
16
  import { createSqlServerLiveDatabaseIntrospection } from './connectors/sqlserver/live-database-introspection.js';
13
17
  import { isKtxSqlServerConnectionConfig } from './connectors/sqlserver/connector.js';
14
18
  import { BigQueryHistoricSqlQueryHistoryReader } from './context/ingest/adapters/historic-sql/bigquery-query-history-reader.js';
@@ -74,6 +78,10 @@ function createKtxCliLiveDatabaseIntrospection(project, options = {}) {
74
78
  projectDir: project.projectDir,
75
79
  connections: project.config.connections,
76
80
  });
81
+ const duckdb = createDuckDbLiveDatabaseIntrospection({
82
+ projectDir: project.projectDir,
83
+ connections: project.config.connections,
84
+ });
77
85
  const mysql = createMysqlLiveDatabaseIntrospection({
78
86
  connections: project.config.connections,
79
87
  });
@@ -89,6 +97,9 @@ function createKtxCliLiveDatabaseIntrospection(project, options = {}) {
89
97
  const bigquery = createBigQueryLiveDatabaseIntrospection({
90
98
  connections: project.config.connections,
91
99
  });
100
+ const athena = createAthenaLiveDatabaseIntrospection({
101
+ connections: project.config.connections,
102
+ });
92
103
  return {
93
104
  async extractSchema(connectionId, options) {
94
105
  const connection = project.config.connections[connectionId];
@@ -109,6 +120,9 @@ function createKtxCliLiveDatabaseIntrospection(project, options = {}) {
109
120
  if (isKtxSqliteConnectionConfig(connection)) {
110
121
  return sqlite.extractSchema(connectionId, options);
111
122
  }
123
+ if (isKtxDuckDbConnectionConfig(connection)) {
124
+ return duckdb.extractSchema(connectionId, options);
125
+ }
112
126
  if (isKtxMysqlConnectionConfig(connection)) {
113
127
  return mysql.extractSchema(connectionId, options);
114
128
  }
@@ -121,6 +135,9 @@ function createKtxCliLiveDatabaseIntrospection(project, options = {}) {
121
135
  if (isKtxBigQueryConnectionConfig(connection)) {
122
136
  return bigquery.extractSchema(connectionId, options);
123
137
  }
138
+ if (isKtxAthenaConnectionConfig(connection)) {
139
+ return athena.extractSchema(connectionId, options);
140
+ }
124
141
  if (hasSnowflakeDriver(connection)) {
125
142
  const { createSnowflakeLiveDatabaseIntrospection } = await import('./connectors/snowflake/live-database-introspection.js');
126
143
  const { isKtxSnowflakeConnectionConfig } = await import('./connectors/snowflake/connector.js');
@@ -9,7 +9,7 @@ import { type DatabaseScopePickResult, type PickDatabaseScopeArgs } from './data
9
9
  import type { KtxManagedPythonInstallPolicy } from './managed-python-command.js';
10
10
  import type { ManagedPythonCoreDaemonOptions } from './managed-python-http.js';
11
11
  import { type KtxSetupPromptOption } from './setup-prompts.js';
12
- export type KtxSetupDatabaseDriver = 'sqlite' | 'postgres' | 'mysql' | 'clickhouse' | 'sqlserver' | 'bigquery' | 'snowflake' | 'mongodb';
12
+ export type KtxSetupDatabaseDriver = 'sqlite' | 'duckdb' | 'postgres' | 'mysql' | 'clickhouse' | 'sqlserver' | 'bigquery' | 'snowflake' | 'athena' | 'mongodb';
13
13
  export interface KtxSetupDatabasesArgs {
14
14
  projectDir: string;
15
15
  inputMode: 'auto' | 'disabled';
@@ -37,8 +37,10 @@ const DRIVER_OPTIONS = [
37
37
  { value: 'mysql', label: 'MySQL' },
38
38
  { value: 'clickhouse', label: 'ClickHouse' },
39
39
  { value: 'sqlserver', label: 'SQL Server' },
40
+ { value: 'athena', label: 'Amazon Athena' },
40
41
  { value: 'mongodb', label: 'MongoDB' },
41
42
  { value: 'sqlite', label: 'SQLite' },
43
+ { value: 'duckdb', label: 'DuckDB' },
42
44
  ];
43
45
  const DRIVER_LABELS = Object.fromEntries(DRIVER_OPTIONS.map((option) => [option.value, option.label]));
44
46
  const HISTORIC_SQL_DIALECT_BY_DRIVER = {
@@ -48,12 +50,14 @@ const HISTORIC_SQL_DIALECT_BY_DRIVER = {
48
50
  };
49
51
  const DEFAULT_CONNECTION_IDS = {
50
52
  sqlite: 'sqlite-local',
53
+ duckdb: 'duckdb-local',
51
54
  postgres: 'postgres-warehouse',
52
55
  mysql: 'mysql-warehouse',
53
56
  clickhouse: 'clickhouse-warehouse',
54
57
  sqlserver: 'sqlserver-warehouse',
55
58
  bigquery: 'bigquery-warehouse',
56
59
  snowflake: 'snowflake-warehouse',
60
+ athena: 'athena-warehouse',
57
61
  mongodb: 'mongodb-source',
58
62
  };
59
63
  const SUGGESTED_SCOPE_PATTERN = /^(mart|prod|analytics|core|dim|fact|gold)(_|$)/i;
@@ -118,6 +122,13 @@ const SCOPE_DISCOVERY_SPECS = {
118
122
  configSingleField: 'schema_name',
119
123
  suggest: defaultSuggest,
120
124
  },
125
+ athena: {
126
+ noun: 'database',
127
+ nounPlural: 'databases',
128
+ promptLabel: 'Glue databases',
129
+ configArrayField: 'databases',
130
+ suggest: defaultSuggest,
131
+ },
121
132
  };
122
133
  const DRIVER_CONNECTION_DEFAULTS = {
123
134
  postgres: { port: '5432' },
@@ -541,6 +552,15 @@ async function buildConnectionConfig(input) {
541
552
  return 'back';
542
553
  return path ? { driver: 'sqlite', path } : null;
543
554
  }
555
+ if (driver === 'duckdb') {
556
+ if (args.inputMode === 'disabled' && !args.databaseUrl)
557
+ return null;
558
+ const path = args.databaseUrl ??
559
+ (await promptText(prompts, 'DuckDB database file\nEnter a relative or absolute path, for example ./warehouse.duckdb.', stringConfigField(input.existingConnection, 'path')));
560
+ if (path === undefined)
561
+ return 'back';
562
+ return path ? { driver: 'duckdb', path } : null;
563
+ }
544
564
  if (driver === 'postgres' || driver === 'mysql' || driver === 'clickhouse' || driver === 'sqlserver') {
545
565
  return await buildUrlConnectionConfig({
546
566
  driver,
@@ -664,6 +684,34 @@ async function buildConnectionConfig(input) {
664
684
  ...(role ? { role } : {}),
665
685
  };
666
686
  }
687
+ if (driver === 'athena') {
688
+ if (args.inputMode === 'disabled' && !args.databaseUrl)
689
+ return null;
690
+ const region = await promptText(prompts, 'AWS region\nFor example us-east-1.', stringConfigField(input.existingConnection, 'region'));
691
+ if (region === undefined)
692
+ return 'back';
693
+ if (!region)
694
+ return null;
695
+ const s3StagingDir = await promptText(prompts, 'S3 staging directory\nAthena writes query results here. For example s3://my-bucket/athena-results/.', stringConfigField(input.existingConnection, 's3_staging_dir'));
696
+ if (s3StagingDir === undefined)
697
+ return 'back';
698
+ if (!s3StagingDir)
699
+ return null;
700
+ const workgroup = await promptText(prompts, 'Athena workgroup (optional)\nPress Enter to use the default workgroup "primary".', stringConfigField(input.existingConnection, 'workgroup'));
701
+ if (workgroup === undefined)
702
+ return 'back';
703
+ const catalog = await promptText(prompts, 'Glue Data Catalog name (optional)\nPress Enter to use the default "AwsDataCatalog".', stringConfigField(input.existingConnection, 'catalog'));
704
+ if (catalog === undefined)
705
+ return 'back';
706
+ return {
707
+ driver: 'athena',
708
+ region,
709
+ s3_staging_dir: s3StagingDir,
710
+ ...(workgroup ? { workgroup } : {}),
711
+ ...(catalog ? { catalog } : {}),
712
+ ...scriptedScopeConfigForDriver('athena', args.databaseSchemas),
713
+ };
714
+ }
667
715
  throw new Error(`Unsupported database driver: ${driver}`);
668
716
  }
669
717
  async function maybeApplyHistoricSqlConfig(input) {
@@ -1031,9 +1079,11 @@ async function maybeConfigureDatabaseScope(input) {
1031
1079
  const project = await loadKtxProject({ projectDir: input.projectDir });
1032
1080
  const connection = project.config.connections[input.connectionId];
1033
1081
  const driver = normalizeDriver(connection?.driver);
1034
- if (!driver || driver === 'sqlite')
1082
+ const spec = driver ? SCOPE_DISCOVERY_SPECS[driver] : undefined;
1083
+ // Drivers with no scope spec are single-namespace (sqlite, duckdb): there is no
1084
+ // schema to choose, so skip the scope picker and ingest every table.
1085
+ if (!driver || !spec)
1035
1086
  return okValidateResult();
1036
- const spec = SCOPE_DISCOVERY_SPECS[driver];
1037
1087
  const existingTables = connection?.enabled_tables;
1038
1088
  const hasExistingTables = Array.isArray(existingTables) && existingTables.length > 0;
1039
1089
  const existingScope = spec ? configuredScopeValues(connection, spec) : [];
package/dist/sql.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { FEDERATED_CONNECTION_ID } from './context/connections/federation.js';
2
- import { executeProjectReadOnlySql } from './context/connections/project-sql-executor.js';
2
+ import { executeProjectRawSql } from './context/connections/project-sql-executor.js';
3
3
  import { assertSqlQueryableConnection } from './context/connections/dialects.js';
4
4
  import { resolveConfiguredConnection } from './context/connections/resolve-connection.js';
5
5
  import { loadKtxProject } from './context/project/project.js';
@@ -91,6 +91,8 @@ export async function runKtxSql(args, io = process, deps = {}) {
91
91
  const connection = isFederated ? undefined : resolveConfiguredConnection(project.config, args.connectionId);
92
92
  driver = isFederated ? 'duckdb' : String(connection?.driver ?? 'unknown').toLowerCase();
93
93
  demoConnection = isFederated ? false : isDemoConnection(args.connectionId, connection);
94
+ // Fail fast before creating the SQL-analysis daemon port; executeProjectRawSql
95
+ // re-asserts this for every caller.
94
96
  if (!isFederated) {
95
97
  assertSqlQueryableConnection(args.connectionId, connection?.driver);
96
98
  }
@@ -103,25 +105,18 @@ export async function runKtxSql(args, io = process, deps = {}) {
103
105
  }));
104
106
  const analysisPort = createSqlAnalysis();
105
107
  const dialect = isFederated ? 'duckdb' : sqlAnalysisDialectForDriver(connection?.driver);
106
- const validation = await analysisPort.validateReadOnly(args.sql, dialect);
107
- if (!validation.ok) {
108
- throw new Error(validation.error ?? 'SQL is not read-only.');
109
- }
110
- const referencedTableCount = await safeReferencedTableCount(analysisPort, args.sql, dialect);
111
108
  const createScanConnector = deps.createScanConnector ?? createKtxCliScanConnector;
112
- const result = await executeProjectReadOnlySql({
109
+ const result = await executeProjectRawSql({
113
110
  project,
114
- input: {
115
- connectionId: args.connectionId,
116
- projectDir: args.projectDir,
117
- connection,
118
- sql: args.sql,
119
- maxRows: args.maxRows,
120
- },
111
+ connectionId: args.connectionId,
112
+ sql: args.sql,
113
+ maxRows: args.maxRows,
114
+ sqlAnalysis: analysisPort,
121
115
  createConnector: (connectionId) => createScanConnector(project, connectionId),
122
116
  executeFederated: deps.executeFederated,
123
117
  runId: 'cli-sql',
124
118
  });
119
+ const referencedTableCount = await safeReferencedTableCount(analysisPort, args.sql, dialect);
125
120
  const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
126
121
  printSqlResult(resultOutput(args.connectionId, result), mode, io);
127
122
  await emitTelemetryEvent({
@@ -236,6 +236,7 @@ function buildConnectionStatus(name, conn, env) {
236
236
  const hint = envHint(conn.credentials_json);
237
237
  return warn(hint ? `credentials missing (env: ${hint})` : 'credentials not set', hint ? `Set ${hint}` : 'Rerun `ktx setup`');
238
238
  }
239
+ case 'duckdb':
239
240
  case 'sqlite': {
240
241
  const path = conn.path;
241
242
  if (typeof path === 'string' && path.length > 0)
@@ -361,7 +362,7 @@ async function buildQueryHistoryStatus(project, options) {
361
362
  return statuses;
362
363
  }
363
364
  const ADAPTER_DRIVER_REQUIREMENT = {
364
- 'live-database': ['postgres', 'mysql', 'snowflake', 'bigquery', 'clickhouse', 'sqlite', 'sqlserver'],
365
+ 'live-database': ['postgres', 'mysql', 'snowflake', 'bigquery', 'clickhouse', 'sqlite', 'duckdb', 'sqlserver'],
365
366
  dbt: ['dbt', 'dbt-core', 'dbt-cloud'],
366
367
  notion: ['notion'],
367
368
  metabase: ['metabase'],
@@ -66,8 +66,8 @@ export declare const telemetryEventSchemas: {
66
66
  project: "project";
67
67
  models: "models";
68
68
  sources: "sources";
69
- context: "context";
70
69
  runtime: "runtime";
70
+ context: "context";
71
71
  secrets: "secrets";
72
72
  agents: "agents";
73
73
  "demo-tour": "demo-tour";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaelio/ktx",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Standalone ktx context layer for data agents",
5
5
  "author": {
6
6
  "name": "Kaelio",
@@ -36,6 +36,8 @@
36
36
  "@ai-sdk/devtools": "0.0.18",
37
37
  "@ai-sdk/google-vertex": "^4.0.134",
38
38
  "@anthropic-ai/claude-agent-sdk": "0.3.146",
39
+ "@aws-sdk/client-athena": "^3.1068.0",
40
+ "@aws-sdk/client-glue": "^3.1068.0",
39
41
  "@clack/core": "1.3.1",
40
42
  "@clack/prompts": "1.4.0",
41
43
  "@clickhouse/client": "^1.18.5",