Compare commits

...

14 commits

Author SHA1 Message Date
Andrey Avtomonov
49a4ae6f5b
fix(ci): repair slow sl test and sync uv.lock to 0.16.0 (#345)
* test(sl): assert predefined_measures_only in slow sl query test

The slow-only sl query test asserted the outgoing compute.query() request
with an exact nested query object, so the predefined_measures_only flag
added by the semantic-layer-only query policy (#334) broke the match and
turned the Slow TypeScript tests CI job red on main.

* chore: sync uv.lock ktx-sl/ktx-daemon to 0.16.0

The 0.16.0 release bumped the pyproject versions but left uv.lock pinning
the editable ktx-sl/ktx-daemon packages at 0.15.0.
2026-07-07 12:38:33 +00:00
semantic-release-bot
a6dd8cf730 chore(release): 0.16.0 [skip ci]
## [0.16.0](https://github.com/Kaelio/ktx/compare/v0.15.0...v0.16.0) (2026-07-03)

### Features

* Add duckdb connector ([#308](https://github.com/Kaelio/ktx/issues/308)) ([3c4fcc2](3c4fcc27c7))
* **athena:** first-class AWS Athena warehouse identity (SL + BI mapping) ([#332](https://github.com/Kaelio/ktx/issues/332)) ([f310391](f310391da5))
* **connector:** add Amazon Athena connector via Glue Data Catalog ([#309](https://github.com/Kaelio/ktx/issues/309)) ([fe7e6bd](fe7e6bd1fa))
* query_policy semantic-layer-only restricts agents to predefined semantic-layer measures ([#334](https://github.com/Kaelio/ktx/issues/334)) ([a651b82](a651b82e2f))

### Bug Fixes

* **deps:** patch 22 Dependabot security alerts ([#328](https://github.com/Kaelio/ktx/issues/328)) ([6d01030](6d01030745))
* **sl:** classify semantic-query request rejections as expected, not faults ([#339](https://github.com/Kaelio/ktx/issues/339)) ([a0d19ba](a0d19ba26f))
* **sl:** correct reserved-word/week-grain SQL and classify sl_query errors ([#340](https://github.com/Kaelio/ktx/issues/340)) ([4ebce75](4ebce75449))
* **telemetry:** classify daemon query rejections as expected, not faults ([#335](https://github.com/Kaelio/ktx/issues/335)) ([5d17469](5d17469601))

### Documentation

* reflect recently added connectors in ingestion diagram and README ([#333](https://github.com/Kaelio/ktx/issues/333)) ([66768fe](66768fe009))
2026-07-03 21:22:57 +00:00
Andrey Avtomonov
4ebce75449
fix(sl): correct reserved-word/week-grain SQL and classify sl_query errors (#340)
Reserved-word columns (like, default, ...) referenced as source.col were
quoted with postgres double quotes even on BigQuery/MySQL, where a
double-quoted token is a string literal, not an identifier -- the
"Unexpected string literal" semantic-layer errors. quote_reserved_identifiers
now uses the identifier quote char of the dialect it will be parsed in
(backtick for BigQuery/MySQL), threaded through the planner and generator
parse sites; week_<weekday> granularity now emits WEEK(<weekday>) on BigQuery
instead of the invalid WEEK_MONDAY.

On the telemetry side, warehouse rejections from the sl_query execution path
are classified as expected (KtxQueryError) via a new shared markExpected()
helper, so routine agent/warehouse query failures stop reaching PostHog Error
Tracking as ktx faults; the sql_execution catch is refactored onto the same
helper. The daemon-compile boundary is deliberately left unclassified here so
genuine daemon crashes stay visible.
2026-07-03 23:19:33 +02:00
Andrey Avtomonov
a0d19ba26f
fix(sl): classify semantic-query request rejections as expected, not faults (#339)
The daemon rejects an invalid semantic-query request (unknown source,
ambiguous measure, no join path) with a plain ValueError; the Node compute
port now maps the daemon's exit code 3 / HTTP 400 to KtxExpectedError so these
routine, caller-driven rejections stay out of Error Tracking.

A dedicated SemanticLayerRequestError(ValueError) is raised only for engine
rejections and routed through both daemon transports and the HTTP handler.
Because pydantic v2 ValidationError subclasses ValueError, malformed sources or
responses (contract faults) are kept as faults: they are reported and mapped to
exit 1 / HTTP 500 / plain Error on every path. Non-object stdin is likewise a
fault (exit 1), not exit 3.
2026-07-03 23:17:33 +02:00
Andrey Avtomonov
5d17469601
fix(telemetry): classify daemon query rejections as expected, not faults (#335)
* fix(telemetry): classify daemon query rejections as expected, not faults

Semantic-layer query rejections and warehouse-execution rejections from the
sl_query MCP tool were wrapped as generic Errors, so reportException filed them
as PostHog $exception faults indistinguishable from real ktx bugs.

The daemon already separates a caller rejection (planner ValueError -> exit 3 /
HTTP 400) from a crash. The Node runner now carries that distinction as a typed
KtxDaemonComputeError, and a shared throwClassifiedQueryError promotes daemon
input-rejections and warehouse rejections to KtxQueryError while daemon crashes
and native JS faults still reach Error Tracking. query_semantic_layer stops
report_exception-ing expected ValueErrors, and a missing 'file:' secret now
raises KtxExpectedError so absent .ktx/secrets/<conn>-password stops filing
faults.

* chore: sync uv.lock to ktx-daemon/ktx-sl 0.15.0
2026-07-03 22:39:34 +02:00
Luca Martial
a651b82e2f
feat: query_policy semantic-layer-only restricts agents to predefined semantic-layer measures (#334)
* feat(sl): add predefined_measures_only guard to semantic query planning

SemanticQuery gains a predefined_measures_only flag; the planner rejects
any measure resolved with Provenance.COMPOSED (runtime aggregate
expressions and query-time derivations) while predefined measures,
predefined derived chains, dimensions, filters, and segments pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(config): add per-connection query_policy to warehouse connections

query_policy: semantic-layer-only | read-only-sql (default) on the
warehouse connection schema, plus a policy module with the raw-SQL
guard, federated member restriction lookup, and the project-level
predicate used to gate sql_execution registration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(cli): enforce query_policy on raw SQL through one shared executor

ktx sql and the MCP sql_execution tool now share executeProjectRawSql
(resolve, policy check, read-only validation, execute), collapsing
their duplicated validate-then-execute paths. Restricted connections
are rejected before validation; federated raw SQL is rejected when any
member is restricted. sql_execution is not registered when every SQL
connection is restricted, and connection_list marks restricted
connections so agents route to sl_query. executeProjectReadOnlySql
stays generic for ktx-internal SQL (scan, ingest, SL-generated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(sl): compile queries with predefined_measures_only from query_policy

compileLocalSlQuery injects the flag from the connection's query_policy,
never from caller input, covering both ktx sl query and the MCP
sl_query tool through the daemon compile path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: document query_policy semantic-layer-only

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sl): close semantic-layer-only bypasses via filters and federated hint

The predefined_measures_only guard only inspected query.measures, so a
composed aggregate written into `filters` slipped through _classify_filters
into a HAVING clause untouched — letting a restricted agent evaluate
arbitrary aggregates (e.g. threshold-probing `sum(x) BETWEEN a AND b`).
Reject filter clauses that compose an aggregate function; a HAVING that
compares a predefined measure by name (`orders.revenue > 100`) still works.

Also make the federated sl_query error policy-aware: when a member is
restricted, raw federated SQL is disabled too, so stop directing the agent
to `ktx sql -c _ktx_federated` / sql_execution (a guaranteed failure) and
point to per-connection semantic-layer queries instead.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Andrey Avtomonov <andreybavt@gmail.com>
2026-07-03 08:54:17 +00:00
Luca Martial
66768fe009
docs: reflect recently added connectors in ingestion diagram and README (#333)
* docs(diagram): show recently added connectors in ingestion flow

Add Amazon Athena and MongoDB (plus a "& more" chip), Sigma, and Google
Drive to the ingestion diagram source cards so it reflects the current
connector set. Trim the Databases card body to one line so the added
chips fit the fixed-height card without clipping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(readme): list newly added connectors in the "Works with" summary

Include DuckDB, Amazon Athena, MongoDB, Sigma, and Google Drive so the
README matches the integrations reference pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:07:22 -04:00
Andrey Avtomonov
f310391da5
feat(athena): first-class AWS Athena warehouse identity (SL + BI mapping) (#332)
* feat: support Athena warehouse identity

* fix: support Athena and DuckDB BI table parsing
2026-07-02 15:10:29 +02:00
Patel Dhrit
fe7e6bd1fa
feat(connector): add Amazon Athena connector via Glue Data Catalog (#309)
* feat(connector): add Amazon Athena connector via Glue Data Catalog

* fix(athena): address reviewer feedback

* fix(athena): wire scope discovery, fix normalizeDriver, tighten types and tests

* fix(athena): honor databases scope, wire sql-analysis dialect, harden config resolution

- introspect() limits to the configured `databases` scope instead of scanning
  every Glue database in the account (docs promised this; connector ignored it)
- add athena -> athena to sql-analysis SQLGLOT_DIALECTS so `ktx sql` and MCP
  read-only validation parse Athena SQL under the Trino grammar, not postgres
- stringConfigValue coerces a resolved-empty `env:` reference to undefined so
  optional fields fall back to their defaults (workgroup 'primary', catalog
  'AwsDataCatalog') instead of ''
- drop trailing whitespace in dialect.test.ts

* fix(athena): integrate with main's SQL/non-SQL dialect split and add dialect notes

Rebase onto main, which introduced the KtxDialect (core) vs KtxSqlDialect
(SQL-only) split for MongoDB:
- KtxAthenaDialect implements KtxSqlDialect; the connector resolves it via
  getSqlDialectForDriver so SQL-generation methods stay in scope
- add authored athena.md SQL notes for the sql_dialect_notes MCP tool, required
  now that athena resolves to the athena sqlglot dialect (dialect-notes coverage
  is derived from the warehouse-driver registry)

---------

Co-authored-by: Andrey Avtomonov <andreybavt@gmail.com>
2026-07-02 15:00:26 +02:00
Andrey Avtomonov
6d01030745
fix(deps): patch 22 Dependabot security alerts (#328)
Bump transitive dependencies to their patched versions to clear all open
Dependabot advisories. npm fixes go through the pnpm-workspace.yaml
overrides block; the Python fix goes through uv.lock.

npm: undici 6.27.0/7.28.0, hono 4.12.25, form-data 4.0.6, ws 8.21.0,
vite 8.0.16, esbuild 0.28.1, js-yaml 4.2.0.
pip: starlette 1.3.1.
2026-07-02 09:24:18 +00:00
Kevin Messiaen
3c4fcc27c7
feat: Add duckdb connector (#308)
* refactor(duckdb): extract shared json-safe bigint helper

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(duckdb): add and register the duckdb primary connector

Add KtxDuckDbDialect, KtxDuckDbScanConnector (local file-backed, read-only,
never-create, main-schema introspection via information_schema and
duckdb_constraints() for foreign keys), and register the duckdb driver across
the dialect factory, driver registry, connection-type enum, warehouse descriptor,
config schema, scan normalization, connection test drivers, and status display.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(duckdb): route live-database ingest through the DuckDB connector

Add the DuckDB live-database introspection bridge and dispatch duckdb
connections to it in local-adapters, matching the SQLite path. Repoint the
config-rejection test off duckdb (now a valid driver) onto the no-driver case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(duckdb): add duckdb to the setup database flow

Offer DuckDB in the interactive checklist and via ktx setup --database duckdb,
with a file-path prompt and duckdb-local default connection id, parallel to SQLite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(duckdb): attach native duckdb files in federation

Native .duckdb members ATTACH with (READ_ONLY) and no TYPE/INSTALL/LOAD, since
the duckdb format needs no extension. attachTypeForDriver returns null for the
native case; buildAttachStatements builds load statements from non-null types
only and emits a conditional ATTACH clause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(duckdb): document the duckdb primary-source connector

Add a DuckDB section to the primary-sources integration page (config, read-only
never-create behavior, main-schema scope, federation) and update the
supported-driver assertion in dialects.test.ts to include duckdb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(duckdb): use single-namespace display shape for main-only refs

DuckDB v1 introspects the main schema and sets db=null on every table, so its
display refs are single-namespace like SQLite. The ansi shape emitted a 1-part
table display it then refused to parse, breaking column-level display resolution.
Switch the dialect to the sqlite display shape and add a round-trip test plus a
composite-foreign-key test that were missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(duckdb): resolve connector dialect via getDialectForDriver

Route the connector's dialect through the shared factory like every other
connector, now that duckdb is registered. Single construction path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(duckdb): skip schema picker for single-file duckdb setup

DuckDB is a single-file, single-namespace ('main') database like SQLite,
but the setup scope step only skipped the schema picker for sqlite. DuckDB
fell into the multi-schema path with an empty schema list, rendering a
broken picker ("No matches found" for main). Extend the file-based-driver
early-return to cover duckdb so it ingests every table directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(duckdb): reuse shared config helper and derive scope skip

Route duckdb path resolution through the shared resolveStringReference
helper instead of a local third copy of env:/file: handling. Derive the
setup scope-picker skip from SCOPE_DISCOVERY_SPECS membership rather than
a hardcoded sqlite/duckdb driver list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(duckdb): use a genuinely unknown driver in the rejection test

The merged "rejects unknown drivers" test used `driver: duckdb` as its
unknown-driver stand-in, which stopped being unknown once this branch
added the duckdb connector. Switch to `nonsense` so it again exercises
the unsupported-driver config error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(duckdb): cover dialect, connector, and live-introspection branches

Codecov flagged uncovered branches as dead code; all are real connector,
dialect, and live-ingest behavior. Add unit tests instead of removing them.

- dialect: precedence ladder, sample/clause builders, profiling expressions
- connector: url/env config forms, error throws, never-create guard,
  cardinality cap branches, table-scope empty/non-empty paths
- live-introspection: full-schema and table-scope extraction

Functions 100%, lines ~99% across the duckdb connector dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: add DuckDB to supported-driver references

The DuckDB connector PR documented the connector itself but left the
scattered supported-driver enumerations stale. Add duckdb to the
federation concept page (participation table, activation, table naming,
limitations), the ktx setup CLI reference, the ktx.yaml warehouse-driver
table, the primary-sources field reference, and the quickstart driver
list (which also restores the missing ClickHouse entry).

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Andrey Avtomonov <andreybavt@gmail.com>
2026-07-01 12:06:02 +00:00
semantic-release-bot
f21594c42a chore(release): 0.15.0 [skip ci]
## [0.15.0](https://github.com/Kaelio/ktx/compare/v0.14.0...v0.15.0) (2026-06-30)

### Features

* **sigma:** add Sigma Computing context-source adapter ([#316](https://github.com/Kaelio/ktx/issues/316)) ([acd20ac](acd20ac248)), closes [#168](https://github.com/Kaelio/ktx/issues/168)

### Other Changes

* remove star history refresh workflow ([139ac08](139ac08320))
2026-06-30 23:16:54 +00:00
Matt Senick (Sigma)
acd20ac248
feat(sigma): add Sigma Computing context-source adapter (#316)
* feat(sigma): add Sigma Computing context-source adapter

Closes #168

Adds a full ingest adapter for Sigma Computing so `ktx ingest` can pull
data model specs and workbook summaries into the ktx context layer. The
implementation follows the same fetch → chunk → project → LLM pattern
used by the Looker, Metabase, and MetricFlow adapters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sigma): address PR review comments

- Remove manifest from rawFiles; moves to peerFileIndex so fetchedAt
  changes don't mark all work units dirty every run
- Fix workbookFilter.updatedSince eviction bug: fetch full universe first,
  apply filter client-side, evict only on archived/deleted
- Remove measure projection entirely; project() writes measures: [] and
  the sigma_ingest skill surfaces Lookup/aggregation formulas as wiki prose
- Remove joins projection (v1 limitation); project() writes joins: [] and
  Lookup relationships are described in wiki prose instead
- Remove write-back dead code: createDataModel, updateDataModel,
  SigmaDataModelPushResult, mutate/post/put
- Fix emitBatches notes pluralization bug ('2 data modelss' → '2 data models')
- Add tokenInflight dedup on ensureToken to coalesce concurrent auth requests
- Retry spec fetch when existing staged spec is null (transient failure cache)
- Drop unused WorkbookFilter import from client-port.ts
- Note in docs that joins are not projected from Sigma data models in this release

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* updates

* fix(sigma): restore sigma in local adapter test + small cleanups

The gdrive↔sigma merge dropped 'sigma' from the expected adapter source
list in local-adapters.test.ts while keeping gdrive, so the slow TS suite
failed even though the source registers both. Add 'sigma' back at its
registration position (after metabase, before gdrive).

Also:
- Move the orphaned SigmaPullConfig docstring onto the schema it documents
  and drop the stale BullMQ reference (standalone ktx has no BullMQ; the
  config lives in the ingest job's bundleRef.config).
- Drop an O(n^2) find() round-trip in fetch() when building the active
  data-model list; filter once and reuse for the eviction id set.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Andrey Avtomonov <andreybavt@gmail.com>
Co-authored-by: Luca Martial <48870843+luca-martial@users.noreply.github.com>
2026-07-01 01:14:57 +02:00
Andrey Avtomonov
139ac08320 chore: remove star history refresh workflow 2026-06-30 15:17:05 +02:00
140 changed files with 8975 additions and 557 deletions

View file

@ -1,72 +0,0 @@
name: Refresh star history chart
on:
schedule:
# Twice daily at 06:00 and 18:00 UTC.
- cron: "0 6,18 * * *"
workflow_dispatch:
permissions:
contents: write
env:
DO_NOT_TRACK: "1"
KTX_TELEMETRY_DISABLED: "1"
NEXT_TELEMETRY_DISABLED: "1"
concurrency:
group: star-history-refresh
cancel-in-progress: true
jobs:
refresh:
name: Regenerate assets/star-history.svg
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# RELEASE_PAT can push to the protected main branch; the default
# GITHUB_TOKEN is rejected by the branch-protection hook (GH006).
token: ${{ secrets.RELEASE_PAT }}
- name: Fetch fresh star-history SVG
run: |
set -euo pipefail
# cachebust forces star-history to regenerate instead of serving its
# own server-side cache; --location follows the slug-normalizing 301.
url="https://api.star-history.com/svg?repos=Kaelio/ktx&type=Date&cachebust=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
curl --fail --location --silent --show-error \
--retry 3 --retry-delay 5 --max-time 60 \
-o assets/star-history.svg.new "$url"
# Guard against error pages / truncated responses before overwriting.
if ! grep -q "</svg>" assets/star-history.svg.new; then
echo "Downloaded file is not a valid SVG; aborting." >&2
exit 1
fi
if [ "$(wc -c < assets/star-history.svg.new)" -lt 1000 ]; then
echo "Downloaded SVG is suspiciously small; aborting." >&2
exit 1
fi
# The star-history API returns the SVG without a trailing newline,
# which end-of-file-fixer rewrites whenever pre-commit runs
# --all-files on a PR. Because the refresh commit below uses [skip ci],
# the hook never runs against it here, so an un-normalized file
# silently breaks the pre-commit check on every open PR. Normalize to
# exactly one trailing newline before committing.
printf '%s\n' "$(cat assets/star-history.svg.new)" > assets/star-history.svg
rm -f assets/star-history.svg.new
- name: Commit if changed
run: |
set -euo pipefail
if git diff --quiet -- assets/star-history.svg; then
echo "Star-history chart unchanged; nothing to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add assets/star-history.svg
# [skip ci] keeps this housekeeping commit from triggering KTX CI.
git commit -m "chore: refresh star history chart [skip ci]"
git push

View file

@ -103,8 +103,9 @@ upkeep and don't absorb the rest of your company's knowledge.
- You don't have a SQL warehouse - **ktx** sits on top of one
- You only need one ad-hoc query - `psql` or a notebook will do
Works with PostgreSQL, Snowflake, BigQuery, ClickHouse, MySQL, SQL Server, and
SQLite. Integrates with dbt, MetricFlow, LookML, Looker, Metabase, and Notion.
Works with PostgreSQL, Snowflake, BigQuery, ClickHouse, MySQL, SQL Server,
SQLite, DuckDB, Amazon Athena, and MongoDB. Integrates with dbt, MetricFlow,
LookML, Looker, Metabase, Sigma, Notion, and Google Drive.
## Quick Start

View file

@ -68,14 +68,14 @@ const EDGE_STROKE = "#94a3b8";
const sourceData: SourceNodeData[] = [
{
title: "Databases",
body: "Schemas, columns, keys, row counts, and query history.",
items: ["PostgreSQL", "Snowflake", "BigQuery", "SQLite"],
body: "Schemas, keys, row counts, query history.",
items: ["PostgreSQL", "Snowflake", "BigQuery", "Athena", "MongoDB", "& more"],
accent: "#3b82f6",
},
{
title: "BI tools",
body: "Dashboards, questions, explores, usage, and trusted examples.",
items: ["Metabase", "Looker"],
items: ["Metabase", "Looker", "Sigma"],
accent: "#f97316",
},
{
@ -87,7 +87,7 @@ const sourceData: SourceNodeData[] = [
{
title: "Docs and notes",
body: "Policies, caveats, team definitions, and analyst context.",
items: ["Notion", "Any text"],
items: ["Notion", "Google Drive", "Any text"],
accent: "#10b981",
},
];

View file

@ -8,7 +8,7 @@ can also capture free-form text into **ktx** memory. Database connections build
enriched context — schema plus AI-generated descriptions, embeddings, and
relationship evidence — and require a configured model and embeddings.
Context-source connections ingest metadata from tools such as dbt, Looker,
Metabase, MetricFlow, LookML, and Notion. Pass `--text` or `--file` to capture
Metabase, MetricFlow, LookML, Notion, and Sigma. Pass `--text` or `--file` to capture
inline text or text files into memory instead.
## Command signature

View file

@ -120,9 +120,9 @@ runtime features are missing.
| Flag | Description |
|------|-------------|
| `--database <driver>` | Database driver to configure; repeatable. Choices: `sqlite`, `postgres`, `mysql`, `clickhouse`, `sqlserver`, `bigquery`, `snowflake` |
| `--database <driver>` | Database driver to configure; repeatable. Choices: `sqlite`, `duckdb`, `postgres`, `mysql`, `clickhouse`, `sqlserver`, `bigquery`, `snowflake` |
| `--database-connection-id <id>` | Existing selected connection id; repeatable. With `--database` or `--database-url`, connection id for the new connection. |
| `--database-url <url>` | URL, `env:NAME`, or `file:/path` for one new URL-style database connection; also used as the SQLite path |
| `--database-url <url>` | URL, `env:NAME`, or `file:/path` for one new URL-style database connection; also used as the SQLite or DuckDB path |
| `--database-schema <schema>` | Database schema or dataset to include; repeatable |
| `--skip-databases` | Leave database setup incomplete |
@ -193,7 +193,7 @@ sources. This is equivalent to passing `--skip-sources` in scripted setup.
| Flag | Description |
|------|-------------|
| `--source <type>` | Context-source connector type: `dbt`, `metricflow`, `metabase`, `looker`, `lookml`, or `notion` |
| `--source <type>` | Context-source connector type: `dbt`, `metricflow`, `metabase`, `looker`, `lookml`, `notion`, or `sigma` |
| `--source-connection-id <id>` | Connection id for context-source setup |
| `--source-path <path>` | Local source path for dbt, MetricFlow, or LookML |
| `--source-git-url <url>` | Git URL for dbt, MetricFlow, or LookML |
@ -278,6 +278,13 @@ ktx setup \
--notion-crawl-mode selected_roots \
--notion-root-page-id abc123def456
# Add a Sigma source
ktx setup \
--source sigma \
--source-connection-id sigma-main \
--source-client-id your-client-id \
--source-client-secret-ref env:SIGMA_CLIENT_SECRET
# Install project-scoped agent integration for Codex
ktx setup --agents --target codex
```

View file

@ -1,6 +1,6 @@
---
title: Cross-database federation
description: How ktx federates postgres, mysql, and sqlite connections so a single read-only SQL query can join across them without copying data.
description: How ktx federates postgres, mysql, sqlite, and duckdb connections so a single read-only SQL query can join across them without copying data.
---
Cross-database federation lets a single read-only SQL query join tables that
@ -20,13 +20,14 @@ block to add. With zero or one compatible connection the behavior is unchanged.
## Which connections participate
The v1 federation engine supports three drivers:
The v1 federation engine supports four drivers:
| Driver | Participates in federation |
|--------|---------------------------|
| `postgres` | Yes |
| `mysql` | Yes |
| `sqlite` | Yes |
| `duckdb` | Yes |
| `snowflake` | No — standalone connection |
| `bigquery` | No — standalone connection |
| `clickhouse` | No — standalone connection |
@ -38,7 +39,7 @@ queried independently; they do not appear as federation members.
## How it activates
**ktx** inspects the connections in `ktx.yaml` at startup. When it finds two or
more connections whose driver is `postgres`, `mysql`, or `sqlite`, it
more connections whose driver is `postgres`, `mysql`, `sqlite`, or `duckdb`, it
instantiates the DuckDB federation engine and attaches each one read-only.
There is no `federation:` key, no opt-in flag, and no connection-level setting
to enable. The engine is derived entirely from what is already declared.
@ -60,9 +61,10 @@ Two attach-compatible connections are present, so federation is active.
## Table naming in federated queries
Inside a federated query, postgres and mysql tables use a three-part name:
`connectionId.schema.table`. SQLite tables, which have no schema layer in
DuckDB, use the two-part form `connectionId.table`. In both cases the
connection's `id` field in `ktx.yaml` becomes the catalog name inside DuckDB.
`connectionId.schema.table`. SQLite and DuckDB tables use the two-part form
`connectionId.table`, since ktx addresses both as single-namespace members. In
both cases the connection's `id` field in `ktx.yaml` becomes the catalog name
inside DuckDB.
If a connection `id` is not a bare SQL identifier — for example it contains a
hyphen, like `books-db` — double-quote it in the query the same way DuckDB
@ -131,12 +133,17 @@ ktx sql -c _ktx_federated \
Table names follow the rules from
[Table naming in federated queries](#table-naming-in-federated-queries):
three-part `connectionId.schema.table` for postgres and mysql, two-part
`connectionId.table` for sqlite. The `_ktx_federated` id is virtual — it is
never written to `ktx.yaml` and only exists when two or more attach-compatible
`connectionId.table` for sqlite and duckdb. The `_ktx_federated` id is virtual —
it is never written to `ktx.yaml` and only exists when two or more attach-compatible
connections are declared. It surfaces in `ktx connection` and in the agent's
connection list so the id is discoverable. Querying a single member database
directly with its own connection id (`ktx sql -c pg_books ...`) is unchanged.
If any member connection sets
[`query_policy: semantic-layer-only`](/docs/configuration/ktx-yaml#query-policy),
raw SQL against `_ktx_federated` is rejected as a whole: a federated query can
touch any member's tables, so one restricted member restricts the federation.
## Federated queries are read-only
DuckDB attaches every member database with read-only access. Federated queries
@ -149,6 +156,6 @@ database through the federation engine.
them in a source's `joins:` block and automatic discovery of cross-database
relationships are not available yet. Intra-database relationship discovery for
each member connection is unchanged.
- **postgres, mysql, and sqlite only.** Other drivers (snowflake, bigquery,
clickhouse, sqlserver) do not participate in federation in this version. They
remain usable as standalone connections.
- **postgres, mysql, sqlite, and duckdb only.** Other drivers (snowflake,
bigquery, clickhouse, sqlserver) do not participate in federation in this
version. They remain usable as standalone connections.

View file

@ -109,6 +109,7 @@ context-source drivers share the map.
| `postgres` | Warehouse | `driver` | `url`, `enabled_tables`, `historicSql`, `context.queryHistory` |
| `mysql` | Warehouse | `driver` | `url`, `enabled_tables` |
| `sqlite` | Warehouse | `driver` | `url` or `path`, `enabled_tables` |
| `duckdb` | Warehouse | `driver` | `url` or `path`, `enabled_tables` |
| `sqlserver` | Warehouse | `driver` | `url`, `enabled_tables` |
| `bigquery` | Warehouse | `driver` | `credentials_json`, `dataset_ids`, `enabled_tables`, `historicSql` |
| `snowflake` | Warehouse | `driver` | `schema_names`, `enabled_tables`, `historicSql` |
@ -119,6 +120,7 @@ context-source drivers share the map.
| `dbt` | Context source | `driver`, one of `source_dir` or `repo_url` | `branch`, `path`, `profiles_path`, `target`, `project_name` |
| `metricflow` | Context source | `driver`, `metricflow.repoUrl` | `metricflow.branch`, `metricflow.path`, `metricflow.auth_token_ref` |
| `notion` | Context source | `driver`, `auth_token_ref` | `crawl_mode`, `root_*_ids`, `max_*_per_run` |
| `sigma` | Context source | `driver`, `client_id`, `client_secret_ref` | `api_url` |
### Warehouse drivers
@ -215,6 +217,41 @@ connections:
observed in-scope query history. The block uses `mode: exclude` and remains
hand-editable.
### Query policy
Set `query_policy: semantic-layer-only` on a warehouse connection to stop
agents from authoring SQL against it. The default, `read-only-sql`, allows
parser-validated read-only SQL through `ktx sql` and the `sql_execution` MCP
tool alongside semantic-layer queries.
```yaml
connections:
warehouse:
driver: snowflake
query_policy: semantic-layer-only
```
With `semantic-layer-only`:
- `ktx sql` and the `sql_execution` MCP tool reject the connection with a
clear error. When every SQL connection in the project is restricted, the
`sql_execution` tool is not registered at all.
- Raw SQL against the federated connection (`_ktx_federated`) is rejected
when any member connection is restricted.
- Semantic-layer queries (`ktx sl query`, the `sl_query` tool) accept only
measures predefined in the semantic-layer sources. Composed aggregate
expressions such as `sum(orders.amount)` are rejected wherever they appear,
including inside `filters` (a `HAVING`-style clause may only compare a
predefined measure by name, e.g. `orders.revenue > 100`). Grouping by
declared dimensions, filtering on columns, and segments remain available.
- `connection_list` marks the connection as restricted so agents route to
`sl_query` instead of burning a failed call.
The policy governs agent-facing query authorship, not data access: **ktx**'s
own scan, ingest, and semantic-layer-generated SQL still run, and context
tools such as `entity_details` and `dictionary_search` still expose schema
metadata and sampled values.
### Metabase
```yaml
@ -345,6 +382,31 @@ connections:
| `max_knowledge_creates_per_run` | Max new wiki pages created per run (0-25). |
| `max_knowledge_updates_per_run` | Max existing wiki pages updated per run (0-100). |
### Sigma
```yaml
connections:
sigma-main:
driver: sigma
api_url: https://api.sigmacomputing.com
client_id: "<your-client-id>"
client_secret_ref: env:SIGMA_CLIENT_SECRET
workbookFilter:
includeArchived: false
includeExplorations: false
updatedSince: "2026-01-01T00:00:00Z"
```
| Field | Purpose |
|-------|---------|
| `api_url` | Sigma API base URL. Defaults to `https://api.sigmacomputing.com` (GCP US). Override for AWS US (`https://aws-api.sigmacomputing.com`) or other regions. |
| `client_id` | Sigma OAuth client ID. Required. |
| `client_secret` / `client_secret_ref` | Literal secret or reference. Prefer the `_ref`. |
| `connectionMappings` | Maps Sigma internal connection UUIDs to **ktx** warehouse connection IDs. Enables `sl_validate` for projected semantic-layer sources. |
| `workbookFilter.includeArchived` | Include archived workbooks during ingest. Default: `false`. |
| `workbookFilter.includeExplorations` | Include exploration workbooks during ingest. Default: `false`. |
| `workbookFilter.updatedSince` | ISO 8601 date string. Only workbooks updated on or after this date are fetched. Useful for limiting ingest scope at large scale. |
## `setup`
Captured by the setup wizard. The only field **ktx** still reads is

View file

@ -218,7 +218,8 @@ The wizard walks you through everything **ktx** needs in one pass:
3. **Embeddings** - picks an embeddings backend. Choose OpenAI for hosted
embeddings or `sentence-transformers` to run locally without an API key.
4. **Database** - adds at least one primary connection. Supported drivers:
SQLite, PostgreSQL, MySQL, SQL Server, BigQuery, and Snowflake.
PostgreSQL, Snowflake, BigQuery, MySQL, ClickHouse, SQL Server, SQLite, and
DuckDB.
5. **Context sources** - optionally adds dbt, MetricFlow, LookML, Looker,
Metabase, or Notion. You can skip and add them later.
6. **Build** - offers to run the first ingest so semantic sources and wiki

View file

@ -102,6 +102,7 @@ Supported source types:
| `looker` | Looker API | Explores, looks, dashboards, and model metadata |
| `metabase` | Metabase API | Questions, dashboards, table metadata, and mappings |
| `notion` | Notion API | Wiki pages and business knowledge |
| `sigma` | Sigma API | Data model specs, pages, element metadata, and workbook metadata |
Context-source ingest writes semantic source YAML and wiki Markdown, reconciling
with local edits.

View file

@ -1,6 +1,6 @@
---
title: Context Sources
description: Ingest semantic context from dbt, MetricFlow, LookML, Metabase, Looker, Notion, and Google Drive.
description: Ingest semantic context from dbt, MetricFlow, LookML, Metabase, Looker, Notion, Sigma, and Google Drive.
---
Context sources feed your existing analytics tooling into **ktx**. During ingestion, **ktx** extracts metadata from each source and uses a reconciliation agent to reconcile it with your existing semantic layer and knowledge base - preserving accepted edits rather than overwriting.
@ -27,7 +27,7 @@ LookML uses top-level `repoUrl`, and MetricFlow uses nested
| Field | Required | Description |
|-------|----------|-------------|
| `driver` | Yes | Source connector: `dbt`, `metricflow`, `lookml`, `metabase`, `looker`, `notion`, or `gdrive` |
| `driver` | Yes | Source connector: `dbt`, `metricflow`, `lookml`, `metabase`, `looker`, `notion`, `sigma`, or `gdrive` |
| `source_dir` | For local file sources | Absolute or project-relative source directory |
| `repo_url` | For Git-hosted dbt sources | Git repository URL |
| `repoUrl` | For Git-hosted LookML sources | Git repository URL |
@ -378,6 +378,101 @@ Create an integration at [notion.so/my-integrations](https://www.notion.so/my-in
---
## Sigma
Ingests data model definitions and workbook metadata from a Sigma workspace as semantic context. Uses the Sigma REST API to fetch data model specs and workbook summaries.
### What it provides
- Data model names, folder paths, and ownership metadata
- Page and element definitions within each data model
- Column identifiers and data types where available
- Workbook names, paths, descriptions, and version metadata
### Connection config
```yaml title="ktx.yaml"
connections:
sigma-main:
driver: sigma
api_url: https://api.sigmacomputing.com # Omit for GCP US (default)
client_id: "<your-client-id>"
client_secret_ref: env:SIGMA_CLIENT_SECRET
```
For the AWS US region, override `api_url`:
```yaml title="ktx.yaml"
connections:
sigma-main:
driver: sigma
api_url: https://aws-api.sigmacomputing.com
client_id: "<your-client-id>"
client_secret_ref: env:SIGMA_CLIENT_SECRET
```
### Authentication
| Method | Config |
|--------|--------|
| OAuth client credentials | `client_id` + `client_secret_ref: env:SIGMA_CLIENT_SECRET` |
Generate a client in Sigma: **Administration → Developer Access → Add New Client**.
### What gets ingested
- Active data model specs, organized by folder into work units
- Workbook metadata (name, path, description, version) — archived and exploration workbooks excluded by default
- Models backed by CSV uploads or unsupported connector subtypes are listed in the manifest but skipped during spec fetch (a Sigma API limitation)
### Warehouse connection mapping
`connectionMappings` is optional. Without it, **ktx** produces wiki knowledge only — no semantic-layer sources are written and warehouse validation is skipped. To get semantic-layer output and enable `sl_validate`, map each Sigma internal connection UUID to a **ktx** warehouse connection ID:
```yaml title="ktx.yaml"
connections:
sigma-main:
driver: sigma
client_id: "<your-client-id>"
client_secret_ref: env:SIGMA_CLIENT_SECRET
connectionMappings:
"<sigma-internal-uuid>": snowflake-prod # data models using this connection get SL sources
```
Find the Sigma connection UUID in **Administration → Connections** or from the `source.connectionId` field in a fetched data model spec. Data model elements whose `connectionId` has no mapping are ingested as wiki-only.
### Workbook filter
At large scale, you can limit which workbooks are fetched during ingest using `workbookFilter`:
```yaml title="ktx.yaml"
connections:
sigma-main:
driver: sigma
client_id: "<your-client-id>"
client_secret_ref: env:SIGMA_CLIENT_SECRET
workbookFilter:
includeArchived: false # default
includeExplorations: false # default
updatedSince: "2026-01-01T00:00:00Z" # only recently updated workbooks
```
| Field | Default | Description |
|-------|---------|-------------|
| `includeArchived` | `false` | Include archived workbooks |
| `includeExplorations` | `false` | Include exploration workbooks |
| `updatedSince` | — | ISO 8601 date; only workbooks updated on or after this date are fetched |
### Notes
- `connectionMappings` is optional for wiki-only ingest; it is required to generate semantic-layer sources and run warehouse validation
- Context ingest (`ktx ingest sigma-main`) fetches from the Sigma API directly
- Ingest is incremental: items whose `updatedAt` timestamp is unchanged since the last run are skipped
- Models backed by CSV uploads or unsupported connector subtypes cannot have their spec exported; these are skipped with a warning (a Sigma API limitation)
- Joins are not projected from Sigma data models in this release; `joins: []` is always written by the projection step. Lookup relationships visible in data model specs are captured as wiki knowledge instead.
---
## Google Drive
Ingests Google Docs from a shared Google Drive folder as wiki-ready knowledge content. This v1 implementation is knowledge-only and ingests Google Docs MIME types only.

View file

@ -1,6 +1,6 @@
---
title: Primary Sources
description: Connect ktx to PostgreSQL, Snowflake, BigQuery, MySQL, ClickHouse, SQL Server, SQLite, or MongoDB.
description: Connect ktx to PostgreSQL, Snowflake, BigQuery, MySQL, ClickHouse, SQL Server, SQLite, DuckDB, MongoDB, or Amazon Athena.
---
**ktx** connects to your data warehouse or database to build schema context,
@ -26,17 +26,23 @@ Agents should prefer environment or file references over literal secrets.
| Field | Required | Applies to | Description |
|-------|----------|------------|-------------|
| `driver` | Yes | all connections | Connector driver such as `postgres`, `snowflake`, `bigquery`, `mysql`, `clickhouse`, `sqlserver`, `sqlite`, or `mongodb` |
| `driver` | Yes | all connections | Connector driver such as `postgres`, `snowflake`, `bigquery`, `mysql`, `clickhouse`, `sqlserver`, `sqlite`, `duckdb`, `mongodb`, or `athena` |
| `url` | One of the connection methods | URL-style connectors | Database URL, `env:NAME`, or `file:/path/to/secret` |
| `host`, `port`, `database`, `username`, `password` | One of the connection methods | PostgreSQL, MySQL, SQL Server | Field-by-field connection values |
| `schema` or `schemas` | No | schema-aware warehouses | Single schema or list of schemas to scan |
| `databases` | No | ClickHouse, MongoDB | List of databases to scan |
| `databases` | No | ClickHouse, MongoDB, Athena | List of databases to scan |
| `sample_size`, `order_by` | No | MongoDB | Schema-inference sampling controls (recent documents, sort field) |
| `context.queryHistory` | No | PostgreSQL, Snowflake, BigQuery | Enables query-history ingestion when the warehouse supports it |
| `path` | Yes for path-style SQLite | SQLite | Local SQLite database path or `env:NAME` reference |
| `path` | Yes for path-style SQLite/DuckDB | SQLite, DuckDB | Local SQLite or DuckDB database path or `env:NAME` reference |
| `max_bytes_billed` | No | BigQuery | Maximum bytes billed per query job |
| `query_timeout_ms` | No | all warehouses | Maximum execution time for a single read-only query, in milliseconds (default 30000). A query exceeding it is cancelled server-side (or, for SQLite, by terminating the off-process executor) and returns a `query exceeded Ns` error so the agent can revise. |
| `project_id` | No | BigQuery | Optional local descriptor and mapping metadata; not used for BigQuery authentication |
| `region` | Yes | Athena | AWS region where the Athena workgroup and Glue catalog reside (e.g. `us-east-1`) |
| `s3_staging_dir` | Yes | Athena | S3 URI for Athena query result storage (e.g. `s3://my-bucket/athena-results/`) |
| `workgroup` | No | Athena | Athena workgroup name (default `primary`) |
| `catalog` | No | Athena | Glue Data Catalog name (default `AwsDataCatalog`) |
| `database` | No | Athena | Default Glue database name passed as the query execution context |
| `databases` | No | Athena | Glue databases to include in schema scans; written by `ktx setup` and read by `ktx ingest` |
## PostgreSQL
@ -304,6 +310,76 @@ staged artifact shape as Postgres and Snowflake.
---
## Amazon Athena
Connects to Amazon Athena using the AWS Glue Data Catalog for schema introspection and the Athena query API for read-only SQL execution. Authentication uses the standard AWS credential chain — no credentials are embedded in `ktx.yaml`.
### Connection config
```yaml title="ktx.yaml"
connections:
my-athena:
driver: athena
region: us-east-1
s3_staging_dir: s3://my-bucket/athena-results/
```
With optional fields:
```yaml title="ktx.yaml"
connections:
my-athena:
driver: athena
region: us-east-1
s3_staging_dir: env:ATHENA_S3_STAGING_DIR
workgroup: analytics
catalog: AwsDataCatalog
database: my_default_database
databases:
- analytics
- raw
```
`ktx setup` writes the `databases` array when you select Glue databases during setup. `ktx scan` reads it to limit introspection to those databases.
### Authentication
**ktx** uses the AWS SDK default credential chain — no credentials appear in `ktx.yaml`. The chain resolves credentials in this order:
| Method | How to configure |
|--------|-----------------|
| Environment variables | Set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and optionally `AWS_SESSION_TOKEN` |
| Shared credentials file | Configure `~/.aws/credentials` with a `[default]` or named profile; set `AWS_PROFILE` to select a non-default profile |
| IAM instance profile | Attach an IAM role to the EC2 instance or ECS task — no local configuration needed |
| IAM roles for service accounts (EKS) | Annotate the pod's service account with the IAM role ARN |
The IAM principal must have `athena:StartQueryExecution`, `athena:GetQueryExecution`, `athena:GetQueryResults`, `glue:GetDatabases`, and `glue:GetTables` permissions, plus read access to the S3 results bucket.
### Features
| Feature | Supported | Notes |
|---------|-----------|-------|
| Tables & views | Yes | Via AWS Glue Data Catalog |
| Primary keys | No | Glue does not expose constraint metadata |
| Foreign keys | No | Not available in Glue/Athena |
| Row count estimates | No | Glue table statistics are often stale |
| Column statistics | No | - |
| Query history | No | - |
| Table sampling | Yes | `SELECT ... LIMIT n` |
### Dialect notes
- SQL dialect is Presto/Trino; identifiers are quoted with double-quotes
- Table names use three-part format: `"catalog"."database"."table"` (e.g. `"AwsDataCatalog"."analytics"."orders"`)
- Partition columns (`PartitionKeys` in Glue) are included after regular columns in the schema and are fully queryable
- Athena does not support `TABLESAMPLE`; random sampling uses `ORDER BY rand()`
- Query execution is asynchronous: **ktx** starts the query, polls until completion, then fetches results from S3
- Results are stored in `s3_staging_dir`; the IAM principal needs write access to that bucket
- Use `workgroup` to apply per-workgroup cost controls and result configuration
- The connector always uses your account's default Glue Data Catalog; cross-account catalog access (`CatalogId` pointing to another account) is not supported
---
## MySQL
Standard MySQL/MariaDB connector with full foreign key support and schema introspection.
@ -545,6 +621,52 @@ No authentication required - SQLite is file-based. The file must be readable by
---
## DuckDB
File-based connector using the DuckDB Node.js API. Ideal for local analytics, embedded warehouses, and cross-database federation.
### Connection config
```yaml title="ktx.yaml"
connections:
warehouse:
driver: duckdb
path: data/warehouse.duckdb
```
`path` is resolved relative to the project directory. The `.duckdb` file must already exist — **ktx** never creates a missing database file.
### Authentication
No authentication required — DuckDB is file-based. The `.duckdb` file must be readable by the process running **ktx**.
### Features
| Feature | Supported | Notes |
|---------|-----------|-------|
| Tables & views | Yes | Via `information_schema` on the `main` schema |
| Primary keys | Yes | Via `information_schema.table_constraints` |
| Foreign keys | Yes | Via DuckDB's `duckdb_constraints()` catalog function |
| Row count estimates | Yes | Exact count via `SELECT COUNT(*)` |
| Column statistics | No | - |
| Query history | No | - |
| Table sampling | Yes | - |
| Nested analysis | No | - |
### Dialect notes
- Introspection scans the `main` schema only
- Execution is read-only; **ktx** opens the file without write access
- Parameter binding uses positional `?` placeholders
- Uses `LIMIT X OFFSET Y` for pagination
- Database file must exist before `ktx connection test` or ingest runs
### Cross-database federation
When a project declares two or more attach-compatible connections — any combination of `postgres`, `mysql`, `sqlite`, and `duckdb` — **ktx** derives a cross-database federation connection. That connection can ATTACH a native `.duckdb` file, allowing semantic queries to join across sources without manually copying data.
---
## MongoDB
Connects to MongoDB as a primary context source. **ktx** treats each collection
@ -629,7 +751,9 @@ nullability from how often the field is present:
| Error or symptom | Likely cause | Recovery |
|------------------|--------------|----------|
| Connection URL appears in git diff | A literal credential URL was written to `ktx.yaml` | Replace it with `env:NAME` or `file:/path/to/secret` and rotate exposed credentials |
| Database ingest returns no tables | Schema, database, or project filter is wrong, or the user lacks metadata permissions | Verify the schema list and grant metadata read permissions |
| Database ingest returns no tables | Schema, database, or project filter is wrong, or the user lacks metadata permissions | Verify the schema list and grant metadata read permissions. For Athena, confirm the IAM principal has `glue:GetDatabases` and `glue:GetTables` permissions |
| Query history is empty | Query history extension or warehouse history view is unavailable | Enable the warehouse-specific history feature, then rerun `ktx ingest <connectionId> --query-history` or `ktx setup` |
| Column statistics are missing | Connector cannot access stats tables or the warehouse does not expose them | Grant stats permissions where supported; otherwise rely on schema-level context without column statistics |
| Semantic query execution fails | Connection is missing, unreachable, or query execution is disabled | Run `ktx connection test <id>` and check the `ktx sl query` flags |
| Athena query fails with `ACCESS_DENIED` | IAM principal lacks `athena:StartQueryExecution` or S3 write access to `s3_staging_dir` | Attach a policy granting Athena query permissions and `s3:PutObject` on the staging bucket |
| Athena ingest finds databases but no tables | IAM principal has `glue:GetDatabases` but not `glue:GetTables` | Grant `glue:GetTables` on the relevant Glue catalog resources |

View file

@ -1,6 +1,6 @@
{
"name": "ktx-workspace",
"version": "0.14.0",
"version": "0.16.0",
"description": "Workspace root for ktx packages",
"private": true,
"type": "module",

View file

@ -1,6 +1,6 @@
{
"name": "@kaelio/ktx",
"version": "0.14.0",
"version": "0.16.0",
"description": "Standalone ktx context layer for data agents",
"author": {
"name": "Kaelio",
@ -51,6 +51,8 @@
"@ai-sdk/devtools": "0.0.18",
"@ai-sdk/google-vertex": "^4.0.134",
"@anthropic-ai/claude-agent-sdk": "0.3.146",
"@aws-sdk/client-athena": "^3.1068.0",
"@aws-sdk/client-glue": "^3.1068.0",
"@clack/core": "1.3.1",
"@clack/prompts": "1.4.0",
"@clickhouse/client": "^1.18.5",

View file

@ -38,6 +38,7 @@ function llmBackend(value: string): KtxSetupLlmBackend {
function databaseDriver(value: string): KtxSetupDatabaseDriver {
if (
value === 'sqlite' ||
value === 'duckdb' ||
value === 'postgres' ||
value === 'mysql' ||
value === 'clickhouse' ||
@ -58,6 +59,7 @@ function sourceType(value: string): KtxSetupSourceType {
value === 'looker' ||
value === 'lookml' ||
value === 'notion' ||
value === 'sigma' ||
value === 'gdrive'
) {
return value;

View file

@ -3,12 +3,14 @@ import type { KtxProjectConnectionConfig } from './context/project/config.js';
/** @internal Canonical SQL-warehouse driver ids; the dialect-notes coverage test derives its required coverage from this set. */
export const KTX_DATABASE_DRIVER_IDS = [
'sqlite',
'duckdb',
'postgres',
'mysql',
'clickhouse',
'sqlserver',
'bigquery',
'snowflake',
'athena',
] as const;
// mongodb is a database driver but has no SQL dialect, so it sits outside the

View file

@ -51,6 +51,7 @@ export interface KtxConnectionDeps {
const SUPPORTED_TEST_DRIVERS = [
'sqlite',
'duckdb',
'postgres',
'mysql',
'clickhouse',

View file

@ -0,0 +1,555 @@
import { AthenaClient, StartQueryExecutionCommand, GetQueryExecutionCommand, GetQueryResultsCommand } from '@aws-sdk/client-athena';
import { GlueClient, GetDatabasesCommand, GetTablesCommand } from '@aws-sdk/client-glue';
import { getSqlDialectForDriver } from '../../context/connections/dialects.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
import {
connectorTestFailure,
createKtxConnectorCapabilities,
type KtxConnectorTestResult,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
type KtxColumnStatsResult,
type KtxQueryResult,
type KtxReadOnlyQueryInput,
type KtxScanConnector,
type KtxScanContext,
type KtxScanInput,
type KtxSchemaColumn,
type KtxSchemaSnapshot,
type KtxSchemaTable,
type KtxTableListEntry,
type KtxTableRef,
type KtxTableSampleInput,
type KtxTableSampleResult,
} from '../../context/scan/types.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import { resolveStringReference } from '../shared/string-reference.js';
export interface KtxAthenaConnectionConfig {
driver?: string;
region?: string;
s3_staging_dir?: string;
workgroup?: string;
catalog?: string;
database?: string;
databases?: string[];
[key: string]: unknown;
}
export interface KtxAthenaResolvedConnectionConfig {
region: string;
s3StagingDir: string;
workgroup: string;
catalog: string;
database: string | undefined;
databases: string[];
}
interface KtxAthenaQueryExecutionStatus {
State?: string;
StateChangeReason?: string;
}
interface KtxAthenaQueryExecution {
Status?: KtxAthenaQueryExecutionStatus;
}
interface KtxAthenaColumnInfo {
Name?: string;
Type?: string;
}
interface KtxAthenaDatum {
VarCharValue?: string;
}
interface KtxAthenaRow {
Data?: KtxAthenaDatum[];
}
interface KtxAthenaResultSet {
Rows?: KtxAthenaRow[];
ResultSetMetadata?: { ColumnInfo?: KtxAthenaColumnInfo[] };
}
/** @internal */
export interface KtxAthenaClient {
startQueryExecution(input: {
QueryString: string;
ResultConfiguration: { OutputLocation: string };
WorkGroup: string;
QueryExecutionContext?: { Database?: string; Catalog?: string };
}): Promise<{ QueryExecutionId?: string }>;
getQueryExecution(input: { QueryExecutionId: string }): Promise<{ QueryExecution?: KtxAthenaQueryExecution }>;
getQueryResults(input: { QueryExecutionId: string; NextToken?: string }): Promise<{
ResultSet?: KtxAthenaResultSet;
NextToken?: string;
}>;
}
interface KtxGlueColumnDef {
Name?: string;
Type?: string;
Comment?: string;
}
interface KtxGlueStorageDescriptor {
Columns?: KtxGlueColumnDef[];
}
/** @internal */
export interface KtxGlueTable {
Name?: string;
TableType?: string;
StorageDescriptor?: KtxGlueStorageDescriptor;
PartitionKeys?: KtxGlueColumnDef[];
Description?: string;
Parameters?: Record<string, string>;
}
/** @internal */
export interface KtxGlueClient {
getDatabases(input: { CatalogId?: string; NextToken?: string }): Promise<{
DatabaseList?: Array<{ Name?: string }>;
NextToken?: string;
}>;
getTables(input: { DatabaseName: string; CatalogId?: string; NextToken?: string }): Promise<{
TableList?: KtxGlueTable[];
NextToken?: string;
}>;
}
export interface KtxAthenaClientFactory {
createAthenaClient(region: string): KtxAthenaClient;
createGlueClient(region: string): KtxGlueClient;
}
class DefaultAthenaClientFactory implements KtxAthenaClientFactory {
createAthenaClient(region: string): KtxAthenaClient {
const client = new AthenaClient({ region });
return {
startQueryExecution: async (input) => {
const result = await client.send(
new StartQueryExecutionCommand({
QueryString: input.QueryString,
ResultConfiguration: { OutputLocation: input.ResultConfiguration.OutputLocation },
WorkGroup: input.WorkGroup,
QueryExecutionContext: input.QueryExecutionContext,
}),
);
return { QueryExecutionId: result.QueryExecutionId };
},
getQueryExecution: async (input) => {
const result = await client.send(new GetQueryExecutionCommand({ QueryExecutionId: input.QueryExecutionId }));
return {
QueryExecution: result.QueryExecution
? {
Status: {
State: result.QueryExecution.Status?.State,
StateChangeReason: result.QueryExecution.Status?.StateChangeReason,
},
}
: undefined,
};
},
getQueryResults: async (input) => {
const result = await client.send(
new GetQueryResultsCommand({ QueryExecutionId: input.QueryExecutionId, NextToken: input.NextToken }),
);
return {
ResultSet: result.ResultSet as KtxAthenaResultSet | undefined,
NextToken: result.NextToken,
};
},
};
}
createGlueClient(region: string): KtxGlueClient {
const client = new GlueClient({ region });
return {
getDatabases: async (input) => {
const result = await client.send(new GetDatabasesCommand({ CatalogId: input.CatalogId, NextToken: input.NextToken }));
return {
DatabaseList: result.DatabaseList?.map((db) => ({ Name: db.Name })),
NextToken: result.NextToken,
};
},
getTables: async (input) => {
const result = await client.send(
new GetTablesCommand({ DatabaseName: input.DatabaseName, CatalogId: input.CatalogId, NextToken: input.NextToken }),
);
return {
TableList: result.TableList as KtxGlueTable[] | undefined,
NextToken: result.NextToken,
};
},
};
}
}
function stringConfigValue(
connection: KtxAthenaConnectionConfig | undefined,
key: keyof KtxAthenaConnectionConfig,
env: NodeJS.ProcessEnv,
): string | undefined {
const value = connection?.[key];
if (typeof value !== 'string' || value.trim().length === 0) return undefined;
// Resolve before checking emptiness: an unset `env:` reference resolves to '',
// which must become undefined so `?? default` applies instead of keeping ''.
const resolved = resolveStringReference(value.trim(), env).trim();
return resolved.length > 0 ? resolved : undefined;
}
function configuredAthenaDatabases(connection: KtxAthenaConnectionConfig): string[] {
if (!Array.isArray(connection.databases)) return [];
const selected = connection.databases
.filter((database): database is string => typeof database === 'string' && database.trim().length > 0)
.map((database) => database.trim());
return [...new Set(selected)];
}
export function isKtxAthenaConnectionConfig(
connection: unknown,
): connection is KtxAthenaConnectionConfig {
return (
typeof connection === 'object' &&
connection !== null &&
String((connection as { driver?: unknown }).driver ?? '').toLowerCase() === 'athena'
);
}
/** @internal */
export function athenaConnectionConfigFromConfig(input: {
connectionId: string;
connection: KtxAthenaConnectionConfig | undefined;
env?: NodeJS.ProcessEnv;
}): KtxAthenaResolvedConnectionConfig {
const inputDriver = input.connection?.driver ?? 'unknown';
if (!isKtxAthenaConnectionConfig(input.connection)) {
throw new Error(`Native Athena connector cannot run driver "${String(inputDriver)}"`);
}
const env = input.env ?? process.env;
const region = stringConfigValue(input.connection, 'region', env);
if (!region) {
throw new Error(`Native Athena connector requires connections.${input.connectionId}.region`);
}
const s3StagingDir = stringConfigValue(input.connection, 's3_staging_dir', env);
if (!s3StagingDir) {
throw new Error(`Native Athena connector requires connections.${input.connectionId}.s3_staging_dir`);
}
return {
region,
s3StagingDir,
workgroup: stringConfigValue(input.connection, 'workgroup', env) ?? 'primary',
catalog: stringConfigValue(input.connection, 'catalog', env) ?? 'AwsDataCatalog',
database: stringConfigValue(input.connection, 'database', env),
databases: configuredAthenaDatabases(input.connection),
};
}
function glueTableKind(tableType: string | undefined): 'table' | 'view' {
const t = String(tableType ?? '').toUpperCase();
if (t === 'VIRTUAL_VIEW') return 'view';
return 'table';
}
const POLL_INTERVAL_MS = 250;
const QUERY_TIMEOUT_MS = 5 * 60 * 1000;
export interface KtxAthenaScanConnectorOptions {
connectionId: string;
connection: KtxAthenaConnectionConfig | undefined;
clientFactory?: KtxAthenaClientFactory;
env?: NodeJS.ProcessEnv;
now?: () => Date;
}
export class KtxAthenaScanConnector implements KtxScanConnector {
readonly id: string;
readonly driver = 'athena' as const;
readonly capabilities = createKtxConnectorCapabilities({
tableSampling: true,
columnSampling: true,
columnStats: false,
readOnlySql: true,
nestedAnalysis: false,
formalForeignKeys: false,
estimatedRowCounts: false,
});
private readonly connectionId: string;
private readonly resolved: KtxAthenaResolvedConnectionConfig;
private readonly clientFactory: KtxAthenaClientFactory;
private readonly now: () => Date;
private readonly dialect = getSqlDialectForDriver('athena');
private athenaClient: KtxAthenaClient | null = null;
private glueClient: KtxGlueClient | null = null;
constructor(options: KtxAthenaScanConnectorOptions) {
this.connectionId = options.connectionId;
this.resolved = athenaConnectionConfigFromConfig({
connectionId: options.connectionId,
connection: options.connection,
env: options.env,
});
this.clientFactory = options.clientFactory ?? new DefaultAthenaClientFactory();
this.now = options.now ?? (() => new Date());
this.id = `athena:${options.connectionId}`;
}
async testConnection(): Promise<KtxConnectorTestResult> {
try {
await this.listDatabasesPaginated({ maxResults: 1 });
return { success: true };
} catch (error) {
return connectorTestFailure(error);
}
}
async introspect(input: KtxScanInput, _ctx: KtxScanContext): Promise<KtxSchemaSnapshot> {
this.assertConnection(input.connectionId);
// Honor the configured `databases` scope (written by `ktx setup`); fall back
// to every Glue database only when the scope is unset.
const databases =
this.resolved.databases.length > 0 ? this.resolved.databases : await this.listDatabasesPaginated({});
const tables: KtxSchemaTable[] = [];
for (const database of databases) {
const scopedNames = input.tableScope
? scopedTableNames(input.tableScope, { catalog: this.resolved.catalog, db: database })
: null;
tables.push(...(await this.introspectDatabase(database, scopedNames)));
}
return {
connectionId: this.connectionId,
driver: 'athena',
extractedAt: this.now().toISOString(),
scope: { catalogs: [this.resolved.catalog], datasets: databases },
metadata: {
catalog: this.resolved.catalog,
databases,
table_count: tables.length,
total_columns: tables.reduce((sum, t) => sum + t.columns.length, 0),
},
tables,
warnings: [],
};
}
async sampleTable(input: KtxTableSampleInput, _ctx: KtxScanContext): Promise<KtxTableSampleResult & { headerTypes?: string[] }> {
this.assertConnection(input.connectionId);
const result = await this.query(this.dialect.generateSampleQuery(this.qTableName(input.table), input.limit, input.columns));
return { headers: result.headers, headerTypes: result.headerTypes, rows: result.rows, totalRows: result.totalRows };
}
async sampleColumn(input: KtxColumnSampleInput, _ctx: KtxScanContext): Promise<KtxColumnSampleResult> {
this.assertConnection(input.connectionId);
const result = await this.query(
this.dialect.generateColumnSampleQuery(this.qTableName(input.table), input.column, input.limit),
);
return {
values: result.rows.filter((row) => row.length > 0 && row[0] !== null).map((row) => row[0]),
nullCount: null,
distinctCount: null,
};
}
async columnStats(_input: KtxColumnStatsInput, _ctx: KtxScanContext): Promise<KtxColumnStatsResult | null> {
return null;
}
async executeReadOnly(input: KtxReadOnlyQueryInput, _ctx: KtxScanContext): Promise<KtxQueryResult> {
this.assertConnection(input.connectionId);
const limitedSql = limitSqlForExecution(assertReadOnlySql(input.sql), input.maxRows);
const result = await this.query(limitedSql);
return { ...result, rowCount: result.rows.length };
}
async listSchemas(): Promise<string[]> {
return this.listDatabasesPaginated({});
}
async listTables(databases?: string[]): Promise<KtxTableListEntry[]> {
const targetDatabases = databases && databases.length > 0 ? databases : await this.listDatabasesPaginated({});
const entries: KtxTableListEntry[] = [];
for (const database of targetDatabases) {
const glueTables = await this.listGlueTablesPaginated(database);
for (const t of glueTables) {
if (!t.Name) continue;
entries.push({
catalog: this.resolved.catalog,
schema: database,
name: t.Name,
kind: glueTableKind(t.TableType),
});
}
}
return entries;
}
async cleanup(): Promise<void> {
this.athenaClient = null;
this.glueClient = null;
}
qTableName(table: Pick<KtxTableRef, 'name'> & Partial<Pick<KtxTableRef, 'catalog' | 'db'>>): string {
return this.dialect.formatTableName(table);
}
private getAthenaClient(): KtxAthenaClient {
if (!this.athenaClient) {
this.athenaClient = this.clientFactory.createAthenaClient(this.resolved.region);
}
return this.athenaClient;
}
private getGlueClient(): KtxGlueClient {
if (!this.glueClient) {
this.glueClient = this.clientFactory.createGlueClient(this.resolved.region);
}
return this.glueClient;
}
private async listDatabasesPaginated(opts: { maxResults?: number }): Promise<string[]> {
const names: string[] = [];
let nextToken: string | undefined;
do {
const result = await this.getGlueClient().getDatabases({ NextToken: nextToken });
for (const db of result.DatabaseList ?? []) {
if (db.Name) names.push(db.Name);
if (opts.maxResults && names.length >= opts.maxResults) return names;
}
nextToken = result.NextToken;
} while (nextToken);
return names;
}
private async listGlueTablesPaginated(database: string): Promise<KtxGlueTable[]> {
const tables: KtxGlueTable[] = [];
let nextToken: string | undefined;
do {
const result = await this.getGlueClient().getTables({ DatabaseName: database, NextToken: nextToken });
tables.push(...(result.TableList ?? []));
nextToken = result.NextToken;
} while (nextToken);
return tables;
}
private async introspectDatabase(database: string, scopedNames: readonly string[] | null): Promise<KtxSchemaTable[]> {
if (scopedNames && scopedNames.length === 0) return [];
const glueTables = await this.listGlueTablesPaginated(database);
const scopeSet = scopedNames ? new Set(scopedNames) : null;
return glueTables
.filter((t): t is KtxGlueTable & { Name: string } => Boolean(t.Name) && (!scopeSet || scopeSet.has(t.Name!)))
.map((t) => ({
catalog: this.resolved.catalog,
db: database,
name: t.Name,
kind: glueTableKind(t.TableType),
comment: t.Description ?? null,
estimatedRows: null,
columns: this.toSchemaColumns(t),
foreignKeys: [],
}));
}
private toSchemaColumns(table: KtxGlueTable): KtxSchemaColumn[] {
const columns = [...(table.StorageDescriptor?.Columns ?? []), ...(table.PartitionKeys ?? [])];
return columns
.filter((col): col is KtxGlueColumnDef & { Name: string } => Boolean(col.Name))
.map((col) => {
const nativeType = String(col.Type ?? 'string').toLowerCase();
return {
name: col.Name,
nativeType,
normalizedType: this.dialect.mapDataType(nativeType),
dimensionType: this.dialect.mapToDimensionType(nativeType),
nullable: true,
primaryKey: false,
comment: col.Comment ?? null,
};
});
}
private async query(sql: string): Promise<KtxQueryResult> {
const athena = this.getAthenaClient();
const { QueryExecutionId } = await athena.startQueryExecution({
QueryString: sql,
ResultConfiguration: { OutputLocation: this.resolved.s3StagingDir },
WorkGroup: this.resolved.workgroup,
...(this.resolved.database || this.resolved.catalog
? {
QueryExecutionContext: {
...(this.resolved.database ? { Database: this.resolved.database } : {}),
...(this.resolved.catalog ? { Catalog: this.resolved.catalog } : {}),
},
}
: {}),
});
if (!QueryExecutionId) {
throw new Error('Athena did not return a QueryExecutionId');
}
await this.waitForQueryCompletion(athena, QueryExecutionId);
const rows: unknown[][] = [];
let headers: string[] = [];
let headerTypes: string[] = [];
let nextToken: string | undefined;
let firstPage = true;
do {
const result = await athena.getQueryResults({ QueryExecutionId, NextToken: nextToken });
const resultSet = result.ResultSet;
if (firstPage) {
const columnInfo = resultSet?.ResultSetMetadata?.ColumnInfo ?? [];
headers = columnInfo.map((col) => col.Name ?? '');
headerTypes = columnInfo.map((col) => String(col.Type ?? 'varchar').toUpperCase());
firstPage = false;
}
const pageRows = resultSet?.Rows ?? [];
// Athena includes the header row as the first row of the first page — skip it.
const dataRows = nextToken === undefined ? pageRows.slice(1) : pageRows;
for (const row of dataRows) {
rows.push((row.Data ?? []).map((d) => d.VarCharValue ?? null));
}
nextToken = result.NextToken;
} while (nextToken);
return {
headers,
headerTypes: headerTypes.length > 0 ? headerTypes : undefined,
rows,
totalRows: rows.length,
rowCount: rows.length,
};
}
private async waitForQueryCompletion(athena: KtxAthenaClient, queryExecutionId: string): Promise<void> {
const terminalStates = new Set(['SUCCEEDED', 'FAILED', 'CANCELLED']);
const deadline = this.now().getTime() + QUERY_TIMEOUT_MS;
for (;;) {
const { QueryExecution } = await athena.getQueryExecution({ QueryExecutionId: queryExecutionId });
const state = QueryExecution?.Status?.State ?? '';
if (state === 'SUCCEEDED') return;
if (terminalStates.has(state)) {
const reason = QueryExecution?.Status?.StateChangeReason ?? state;
throw new Error(`Athena query ${state}: ${reason}`);
}
if (this.now().getTime() >= deadline) {
throw new Error(`Athena query ${queryExecutionId} timed out after ${QUERY_TIMEOUT_MS / 1000}s`);
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
}
private assertConnection(connectionId: string): void {
if (connectionId !== this.connectionId) {
throw new Error(`Athena connector ${this.connectionId} cannot scan connection ${connectionId}`);
}
}
}

View file

@ -0,0 +1,175 @@
import type { KtxSqlDialect } from '../../context/connections/dialects.js';
import {
columnDisplayPartCount,
formatDialectDisplayRef,
formatDialectTableName,
parseDialectDisplayRef,
} from '../../context/connections/dialect-helpers.js';
import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/types.js';
type AthenaTableNameRef = Pick<KtxTableRef, 'name'> & Partial<Pick<KtxTableRef, 'catalog' | 'db'>>;
/** @internal */
export class KtxAthenaDialect implements KtxSqlDialect {
readonly type = 'athena' as const;
private readonly dimensionTypeMappings: Record<string, KtxSchemaDimensionType> = {
timestamp: 'time',
date: 'time',
bigint: 'number',
int: 'number',
integer: 'number',
tinyint: 'number',
smallint: 'number',
double: 'number',
float: 'number',
real: 'number',
boolean: 'boolean',
};
quoteIdentifier(identifier: string): string {
return `"${identifier.replace(/"/g, '""')}"`;
}
formatTableName(table: AthenaTableNameRef): string {
return formatDialectTableName(table, this.quoteIdentifier.bind(this), 'ansi');
}
formatDisplayRef(table: AthenaTableNameRef): string {
return formatDialectDisplayRef(table, 'ansi');
}
parseDisplayRef(display: string): KtxTableRef | null {
return parseDialectDisplayRef(display, 'ansi');
}
columnDisplayTablePartCount(): 1 | 2 | 3 {
return columnDisplayPartCount('ansi');
}
mapDataType(nativeType: string): string {
const base = nativeType.toLowerCase().trim().split('<')[0]!.split('(')[0]!.trim();
const typeMap: Record<string, string> = {
string: 'VARCHAR',
varchar: 'VARCHAR',
char: 'CHAR',
binary: 'VARBINARY',
bigint: 'BIGINT',
int: 'INTEGER',
integer: 'INTEGER',
tinyint: 'TINYINT',
smallint: 'SMALLINT',
double: 'DOUBLE',
float: 'FLOAT',
real: 'REAL',
decimal: 'DECIMAL',
boolean: 'BOOLEAN',
timestamp: 'TIMESTAMP',
date: 'DATE',
array: 'ARRAY',
map: 'MAP',
struct: 'STRUCT',
uniontype: 'UNION',
};
return typeMap[base] ?? nativeType.toUpperCase();
}
mapToDimensionType(nativeType: string): KtxSchemaDimensionType {
const base = nativeType.toLowerCase().trim().split('<')[0]!.split('(')[0]!.trim();
const mapped = this.dimensionTypeMappings[base];
if (mapped) return mapped;
if (base.includes('timestamp') || base.includes('date')) return 'time';
if (base.includes('int') || base.includes('float') || base.includes('double') || base.includes('decimal') || base.includes('real')) return 'number';
if (base.includes('bool')) return 'boolean';
return 'string';
}
generateSampleQuery(tableName: string, limit: number, columns?: string[]): string {
const columnList =
columns && columns.length > 0 ? columns.map((c) => this.quoteIdentifier(c)).join(', ') : '*';
return `SELECT ${columnList} FROM ${tableName} LIMIT ${limit}`;
}
generateColumnSampleQuery(tableName: string, columnName: string, limit: number): string {
const quoted = this.quoteIdentifier(columnName);
return `SELECT ${quoted} FROM ${tableName} WHERE ${quoted} IS NOT NULL LIMIT ${limit}`;
}
generateCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string {
return `
SELECT approx_distinct(${columnName}) AS cardinality
FROM (
SELECT ${columnName}
FROM ${tableName}
WHERE ${columnName} IS NOT NULL
LIMIT ${sampleSize}
)
`;
}
generateRandomizedCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string {
return `
SELECT approx_distinct(${columnName}) AS cardinality
FROM (
SELECT ${columnName}
FROM ${tableName}
WHERE ${columnName} IS NOT NULL
ORDER BY rand()
LIMIT ${sampleSize}
)
`;
}
generateDistinctValuesQuery(tableName: string, columnName: string, limit: number): string {
return `
SELECT DISTINCT CAST(${columnName} AS VARCHAR) AS val
FROM ${tableName}
WHERE ${columnName} IS NOT NULL
ORDER BY val
LIMIT ${limit}
`;
}
generateColumnStatisticsQuery(_schemaName: string, _tableName: string): string | null {
return null;
}
getNullCountExpression(column: string): string {
return `COUNT_IF(${column} IS NULL)`;
}
getDistinctCountExpression(column: string): string {
return `approx_distinct(${column})`;
}
textLengthExpression(columnSql: string): string {
return `LENGTH(CAST(${columnSql} AS VARCHAR))`;
}
castToText(columnSql: string): string {
return `CAST(${columnSql} AS VARCHAR)`;
}
getSampleValueAggregation(innerSql: string): string {
return `(SELECT array_join(array_agg(CAST(value AS VARCHAR)), '\u001f') FROM (${innerSql}) AS relationship_profile_values)`;
}
getLimitOffsetClause(limit: number, offset?: number): string {
const safeLimit = Math.max(1, Math.floor(limit));
const safeOffset = offset !== undefined ? Math.floor(offset) : 0;
return safeOffset > 0 ? `OFFSET ${safeOffset} LIMIT ${safeLimit}` : `LIMIT ${safeLimit}`;
}
getTopClause(_limit: number): string {
return '';
}
getTableSampleClause(_samplePct: number): string {
return '';
}
getRandomSampleFilter(samplePct: number): string {
if (samplePct <= 0 || samplePct >= 1) return '';
return `rand() < ${samplePct}`;
}
}

View file

@ -0,0 +1,44 @@
import type {
LiveDatabaseIntrospectionOptions,
LiveDatabaseIntrospectionPort,
} from '../../context/ingest/adapters/live-database/types.js';
import type { KtxProjectConnectionConfig } from '../../context/project/config.js';
import {
KtxAthenaScanConnector,
type KtxAthenaClientFactory,
type KtxAthenaConnectionConfig,
} from './connector.js';
interface CreateAthenaLiveDatabaseIntrospectionOptions {
connections: Record<string, KtxProjectConnectionConfig>;
clientFactory?: KtxAthenaClientFactory;
now?: () => Date;
}
export function createAthenaLiveDatabaseIntrospection(
options: CreateAthenaLiveDatabaseIntrospectionOptions,
): LiveDatabaseIntrospectionPort {
return {
async extractSchema(connectionId: string, introspectionOptions?: LiveDatabaseIntrospectionOptions) {
const connection = options.connections[connectionId] as KtxAthenaConnectionConfig | undefined;
const connector = new KtxAthenaScanConnector({
connectionId,
connection,
clientFactory: options.clientFactory,
now: options.now,
});
try {
return await connector.introspect(
{
connectionId,
driver: 'athena',
...(introspectionOptions?.tableScope ? { tableScope: introspectionOptions.tableScope } : {}),
},
{ runId: `athena-${connectionId}` },
);
} finally {
await connector.cleanup();
}
},
};
}

View file

@ -0,0 +1,395 @@
import { DuckDBInstance, type DuckDBConnection } from '@duckdb/node-api';
import { existsSync, statSync } from 'node:fs';
import { isAbsolute, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveStringReference } from '../shared/string-reference.js';
import { getSqlDialectForDriver } from '../../context/connections/dialects.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
import { normalizeQueryRows } from '../../context/connections/query-executor.js';
import { toJsonSafeRows } from '../shared/duckdb-json-safe.js';
import {
connectorTestFailure,
createKtxConnectorCapabilities,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxColumnStatsInput,
type KtxColumnStatsResult,
type KtxConnectorTestResult,
type KtxQueryResult,
type KtxReadOnlyQueryInput,
type KtxScanConnector,
type KtxScanContext,
type KtxScanInput,
type KtxSchemaForeignKey,
type KtxSchemaSnapshot,
type KtxSchemaTable,
type KtxTableListEntry,
type KtxTableRef,
type KtxTableSampleInput,
type KtxTableSampleResult,
} from '../../context/scan/types.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
const MAIN_SCHEMA = 'main';
export interface KtxDuckDbConnectionConfig {
driver?: string;
path?: string;
url?: string;
[key: string]: unknown;
}
/** @internal */
export interface DuckDbDatabasePathInput {
connectionId: string;
projectDir?: string;
connection: KtxDuckDbConnectionConfig | undefined;
}
export interface KtxDuckDbScanConnectorOptions extends DuckDbDatabasePathInput {
now?: () => Date;
}
export interface KtxDuckDbColumnDistinctValuesOptions {
maxCardinality: number;
limit: number;
sampleSize?: number;
}
export interface KtxDuckDbColumnDistinctValuesResult {
values: string[] | null;
cardinality: number;
}
interface InfoSchemaTableRow {
table_name: string;
table_type: string;
}
interface InfoSchemaColumnRow {
column_name: string;
data_type: string;
is_nullable: string;
}
// `path` may be an env:/file: reference; `url` resolves env: only, since file:
// on a url is a native URI form (handled by duckDbPathFromUrl), not a file read.
function stringConfigValue(
connection: KtxDuckDbConnectionConfig | undefined,
key: 'path' | 'url',
): string | undefined {
const value = connection?.[key];
if (typeof value !== 'string' || value.trim().length === 0) {
return undefined;
}
const trimmed = value.trim();
if (key === 'url') {
return trimmed.startsWith('env:') ? (process.env[trimmed.slice('env:'.length)] ?? '') : trimmed;
}
return resolveStringReference(trimmed, process.env);
}
function duckDbPathFromUrl(url: string): string {
if (url.startsWith('file:')) {
return fileURLToPath(url);
}
if (url.startsWith('duckdb:')) {
const parsed = new URL(url);
return decodeURIComponent(parsed.pathname);
}
return url;
}
export function isKtxDuckDbConnectionConfig(
connection: KtxDuckDbConnectionConfig | undefined,
): connection is KtxDuckDbConnectionConfig {
return String(connection?.driver ?? '').toLowerCase() === 'duckdb';
}
/** @internal */
export function duckDbDatabasePathFromConfig(input: DuckDbDatabasePathInput): string {
const inputDriver = input.connection?.driver ?? 'unknown';
if (!isKtxDuckDbConnectionConfig(input.connection)) {
throw new Error(`Native DuckDB connector cannot run driver "${inputDriver}"`);
}
const configuredPath =
stringConfigValue(input.connection, 'path') ?? duckDbPathFromUrl(stringConfigValue(input.connection, 'url') ?? '');
if (!configuredPath) {
throw new Error(`Native DuckDB connector requires connections.${input.connectionId}.path or url`);
}
return isAbsolute(configuredPath) ? configuredPath : resolve(input.projectDir ?? process.cwd(), configuredPath);
}
export class KtxDuckDbScanConnector implements KtxScanConnector {
readonly id: string;
readonly driver = 'duckdb' as const;
readonly capabilities = createKtxConnectorCapabilities({
tableSampling: true,
columnSampling: true,
columnStats: false,
readOnlySql: true,
nestedAnalysis: false,
formalForeignKeys: true,
estimatedRowCounts: true,
});
private readonly connectionId: string;
private readonly dbPath: string;
private readonly now: () => Date;
private readonly dialect = getSqlDialectForDriver('duckdb');
private instance: DuckDBInstance | null = null;
private connection: DuckDBConnection | null = null;
constructor(options: KtxDuckDbScanConnectorOptions) {
this.connectionId = options.connectionId;
this.dbPath = duckDbDatabasePathFromConfig(options);
this.now = options.now ?? (() => new Date());
this.id = `duckdb:${options.connectionId}`;
}
async testConnection(): Promise<KtxConnectorTestResult> {
try {
if (!existsSync(this.dbPath) || !statSync(this.dbPath).isFile()) {
return { success: false, error: `File not found: ${this.dbPath}` };
}
await this.query('SELECT 1');
return { success: true };
} catch (error) {
return connectorTestFailure(error);
}
}
async introspect(input: KtxScanInput, _ctx: KtxScanContext): Promise<KtxSchemaSnapshot> {
this.assertConnection(input.connectionId);
const scopedNames = input.tableScope ? scopedTableNames(input.tableScope, { catalog: null, db: null }) : null;
const tableRows = await this.readTableRows(scopedNames);
const tables: KtxSchemaTable[] = [];
for (const row of tableRows) {
tables.push(await this.readTable(row));
}
return {
connectionId: this.connectionId,
driver: 'duckdb' as const,
extractedAt: this.now().toISOString(),
scope: {},
metadata: {
file_path: this.dbPath,
table_count: tables.length,
total_columns: tables.reduce((sum, table) => sum + table.columns.length, 0),
},
tables,
};
}
async listSchemas(): Promise<string[]> {
return [MAIN_SCHEMA];
}
async listTables(_schemas?: string[]): Promise<KtxTableListEntry[]> {
const rows = await this.readTableRows(null);
return rows.map((row) => ({
catalog: null,
schema: MAIN_SCHEMA,
name: row.table_name,
kind: row.table_type === 'VIEW' ? ('view' as const) : ('table' as const),
}));
}
async sampleTable(input: KtxTableSampleInput, _ctx: KtxScanContext): Promise<KtxTableSampleResult> {
this.assertConnection(input.connectionId);
const result = await this.query(
this.dialect.generateSampleQuery(this.qTableName(input.table), input.limit, input.columns),
);
return { headers: result.headers, rows: result.rows, totalRows: result.totalRows };
}
async sampleColumn(input: KtxColumnSampleInput, _ctx: KtxScanContext): Promise<KtxColumnSampleResult> {
this.assertConnection(input.connectionId);
const result = await this.query(
this.dialect.generateColumnSampleQuery(this.qTableName(input.table), input.column, input.limit),
);
const values = result.rows.filter((row) => row.length > 0 && row[0] !== null).map((row) => row[0]);
return { values, nullCount: null, distinctCount: null };
}
async columnStats(_input: KtxColumnStatsInput, _ctx: KtxScanContext): Promise<KtxColumnStatsResult | null> {
return null;
}
async executeReadOnly(input: KtxReadOnlyQueryInput, _ctx: KtxScanContext): Promise<KtxQueryResult> {
this.assertConnection(input.connectionId);
const result = await this.query(limitSqlForExecution(input.sql, input.maxRows));
return { ...result, rowCount: result.rows.length };
}
async getColumnDistinctValues(
table: KtxTableRef,
columnName: string,
options: KtxDuckDbColumnDistinctValuesOptions,
): Promise<KtxDuckDbColumnDistinctValuesResult | null> {
const sampleSize = options.sampleSize ?? 10000;
const tableName = this.qTableName(table);
const quotedColumn = this.dialect.quoteIdentifier(columnName);
const cardinalityResult = await this.query(
this.dialect.generateCardinalitySampleQuery(tableName, quotedColumn, sampleSize),
);
if (cardinalityResult.rows.length === 0) {
return null;
}
const cardinality = Number(cardinalityResult.rows[0][0]);
if (Number.isNaN(cardinality)) {
return null;
}
if (cardinality === 0) {
return { values: [], cardinality: 0 };
}
if (cardinality > options.maxCardinality) {
return { values: null, cardinality };
}
const valuesResult = await this.query(
this.dialect.generateDistinctValuesQuery(tableName, quotedColumn, options.limit),
);
return {
values: valuesResult.rows.filter((row) => row.length > 0 && row[0] !== null).map((row) => String(row[0])),
cardinality,
};
}
async getTableRowCount(tableName: string): Promise<number> {
const result = await this.query(`SELECT COUNT(*) AS count FROM ${this.dialect.quoteIdentifier(tableName)}`);
return Number(result.rows[0]?.[0] ?? 0);
}
qTableName(table: Pick<KtxTableRef, 'name'>): string {
return this.dialect.formatTableName(table);
}
quoteIdentifier(identifier: string): string {
return this.dialect.quoteIdentifier(identifier);
}
async cleanup(): Promise<void> {
this.connection?.closeSync();
this.instance?.closeSync();
this.connection = null;
this.instance = null;
}
private async db(): Promise<DuckDBConnection> {
if (!this.connection) {
// DuckDBInstance.create() creates the file if missing, so this pre-check
// enforces the never-create rule. Do not remove it.
if (!existsSync(this.dbPath) || !statSync(this.dbPath).isFile()) {
throw new Error(`File not found: ${this.dbPath}`);
}
this.instance = await DuckDBInstance.create(this.dbPath, { access_mode: 'read_only' });
this.connection = await this.instance.connect();
}
return this.connection;
}
private async query(sql: string): Promise<Omit<KtxQueryResult, 'rowCount'>> {
const connection = await this.db();
const reader = await connection.runAndReadAll(assertReadOnlySql(sql));
const rows = toJsonSafeRows(normalizeQueryRows(reader.getRows()));
return {
headers: reader.columnNames(),
rows,
totalRows: rows.length,
};
}
private async readTableRows(scopedNames: string[] | null): Promise<InfoSchemaTableRow[]> {
if (scopedNames && scopedNames.length === 0) {
return [];
}
const scopeClause = scopedNames
? `AND table_name IN (${scopedNames.map((name) => `'${name.replaceAll("'", "''")}'`).join(', ')})`
: '';
const result = await this.query(
`SELECT table_name, table_type
FROM information_schema.tables
WHERE table_schema = '${MAIN_SCHEMA}' ${scopeClause}
ORDER BY table_name`,
);
return result.rows.map((row) => ({ table_name: String(row[0]), table_type: String(row[1]) }));
}
private async readTable(table: InfoSchemaTableRow): Promise<KtxSchemaTable> {
const columnsResult = await this.query(
`SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = '${MAIN_SCHEMA}' AND table_name = '${table.table_name.replaceAll("'", "''")}'
ORDER BY ordinal_position`,
);
const columns = columnsResult.rows.map<InfoSchemaColumnRow>((row) => ({
column_name: String(row[0]),
data_type: String(row[1]),
is_nullable: String(row[2]),
}));
const primaryKeys = await this.readPrimaryKeyColumns(table.table_name);
const isView = table.table_type === 'VIEW';
const estimatedRows = isView ? null : await this.getTableRowCount(table.table_name);
return {
catalog: null,
db: null,
name: table.table_name,
kind: isView ? 'view' : 'table',
comment: null,
estimatedRows,
columns: columns.map((column) => ({
name: column.column_name,
nativeType: column.data_type,
normalizedType: this.dialect.mapDataType(column.data_type),
dimensionType: this.dialect.mapToDimensionType(column.data_type),
nullable: column.is_nullable === 'YES' && !primaryKeys.has(column.column_name),
primaryKey: primaryKeys.has(column.column_name),
comment: null,
})),
foreignKeys: await this.readForeignKeys(table.table_name),
};
}
private async readPrimaryKeyColumns(tableName: string): Promise<Set<string>> {
const result = await this.query(
`SELECT kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
WHERE tc.table_schema = '${MAIN_SCHEMA}'
AND tc.table_name = '${tableName.replaceAll("'", "''")}'
AND tc.constraint_type = 'PRIMARY KEY'`,
);
return new Set(result.rows.map((row) => String(row[0])));
}
private async readForeignKeys(tableName: string): Promise<KtxSchemaForeignKey[]> {
// information_schema.constraint_column_usage in DuckDB returns the constrained
// columns (source), not the referenced columns. Use duckdb_constraints() which
// exposes constraint_column_names and referenced_column_names directly.
const result = await this.query(
`SELECT unnest(constraint_column_names) AS from_column,
referenced_table,
unnest(referenced_column_names) AS to_column,
constraint_name
FROM duckdb_constraints()
WHERE schema_name = '${MAIN_SCHEMA}'
AND table_name = '${tableName.replaceAll("'", "''")}'
AND constraint_type = 'FOREIGN KEY'`,
);
return result.rows.map((row) => ({
fromColumn: String(row[0]),
toCatalog: null,
toDb: null,
toTable: String(row[1]),
toColumn: String(row[2]),
constraintName: row[3] === null ? null : String(row[3]),
}));
}
private assertConnection(connectionId: string): void {
if (connectionId !== this.connectionId) {
throw new Error(`ktx DuckDB connector ${this.id} cannot serve connection ${connectionId}`);
}
}
}

View file

@ -0,0 +1,192 @@
import type { KtxSqlDialect } from '../../context/connections/dialects.js';
import {
columnDisplayPartCount,
formatDialectDisplayRef,
formatDialectTableName,
limitOffsetClause,
parseDialectDisplayRef,
} from '../../context/connections/dialect-helpers.js';
import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/types.js';
type DuckDbTableNameRef = Pick<KtxTableRef, 'name'> & Partial<Pick<KtxTableRef, 'catalog' | 'db'>>;
/** @internal */
export class KtxDuckDbDialect implements KtxSqlDialect {
readonly type = 'duckdb' as const;
private readonly typeMappings: Record<string, KtxSchemaDimensionType> = {
TIMESTAMP: 'time',
'TIMESTAMP WITH TIME ZONE': 'time',
TIMESTAMPTZ: 'time',
DATE: 'time',
TIME: 'time',
BIGINT: 'number',
INTEGER: 'number',
SMALLINT: 'number',
TINYINT: 'number',
HUGEINT: 'number',
UBIGINT: 'number',
UINTEGER: 'number',
DECIMAL: 'number',
NUMERIC: 'number',
REAL: 'number',
FLOAT: 'number',
DOUBLE: 'number',
VARCHAR: 'string',
CHAR: 'string',
TEXT: 'string',
BLOB: 'string',
UUID: 'string',
BOOLEAN: 'boolean',
BOOL: 'boolean',
};
quoteIdentifier(identifier: string): string {
return `"${identifier.replace(/"/g, '""')}"`;
}
// v1 introspects the `main` schema only and sets db=null on every table, so
// refs are single-namespace like SQLite — use the matching display shape.
formatTableName(table: DuckDbTableNameRef): string {
return formatDialectTableName(table, this.quoteIdentifier.bind(this), 'sqlite');
}
formatDisplayRef(table: DuckDbTableNameRef): string {
return formatDialectDisplayRef(table, 'sqlite');
}
parseDisplayRef(display: string): KtxTableRef | null {
return parseDialectDisplayRef(display, 'sqlite');
}
columnDisplayTablePartCount(): 1 | 2 | 3 {
return columnDisplayPartCount('sqlite');
}
mapDataType(nativeType: string): string {
return nativeType;
}
mapToDimensionType(nativeType: string): KtxSchemaDimensionType {
if (!nativeType) {
return 'string';
}
let normalized = nativeType.toUpperCase().trim();
if (normalized.includes('(')) {
normalized = normalized.split('(')[0].trim();
}
if (this.typeMappings[normalized]) {
return this.typeMappings[normalized];
}
if (normalized.includes('TIME') || normalized.includes('DATE')) {
return 'time';
}
if (
normalized.includes('INT') ||
normalized.includes('DEC') ||
normalized.includes('NUM') ||
normalized.includes('REAL') ||
normalized.includes('FLOAT') ||
normalized.includes('DOUBLE')
) {
return 'number';
}
if (normalized.includes('BOOL')) {
return 'boolean';
}
return 'string';
}
generateSampleQuery(tableName: string, limit: number, columns?: string[]): string {
const columnList =
columns && columns.length > 0 ? columns.map((column) => this.quoteIdentifier(column)).join(', ') : '*';
return `SELECT ${columnList} FROM ${tableName} LIMIT ${limit}`;
}
generateColumnSampleQuery(tableName: string, columnName: string, limit: number): string {
const quoted = this.quoteIdentifier(columnName);
return `SELECT ${quoted} FROM ${tableName} WHERE ${quoted} IS NOT NULL AND TRIM(CAST(${quoted} AS VARCHAR)) != '' LIMIT ${limit}`;
}
getRandomSampleFilter(samplePct: number): string {
if (samplePct <= 0 || samplePct >= 1) {
return '';
}
return `RANDOM() < ${samplePct}`;
}
getTableSampleClause(samplePct: number): string {
if (samplePct <= 0 || samplePct >= 1) {
return '';
}
return `USING SAMPLE ${Math.round(samplePct * 100)} PERCENT (bernoulli)`;
}
getLimitOffsetClause(limit: number, offset?: number): string {
return limitOffsetClause(limit, offset);
}
getTopClause(_limit: number): string {
return '';
}
getNullCountExpression(column: string): string {
return `SUM(CASE WHEN ${column} IS NULL THEN 1 ELSE 0 END)`;
}
getDistinctCountExpression(column: string): string {
return `COUNT(DISTINCT ${column})`;
}
textLengthExpression(columnSql: string): string {
return `LENGTH(CAST(${columnSql} AS VARCHAR))`;
}
castToText(columnSql: string): string {
return `CAST(${columnSql} AS VARCHAR)`;
}
getSampleValueAggregation(innerSql: string): string {
return `(SELECT STRING_AGG(CAST(value AS VARCHAR), chr(31)) FROM (${innerSql}) AS relationship_profile_values)`;
}
generateCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string {
return `
WITH sampled AS (
SELECT ${columnName} AS val
FROM ${tableName}
WHERE ${columnName} IS NOT NULL
LIMIT ${sampleSize}
)
SELECT COUNT(DISTINCT val) AS cardinality
FROM sampled
`;
}
generateDistinctValuesQuery(tableName: string, columnName: string, limit: number): string {
return `
SELECT DISTINCT CAST(${columnName} AS VARCHAR) AS val
FROM ${tableName}
WHERE ${columnName} IS NOT NULL
ORDER BY val
LIMIT ${limit}
`;
}
generateColumnStatisticsQuery(_schemaName: string, _tableName: string): string | null {
return null;
}
generateRandomizedCardinalitySampleQuery(tableName: string, columnName: string, sampleSize: number): string {
return `
WITH sampled AS (
SELECT ${columnName} AS val
FROM ${tableName}
WHERE ${columnName} IS NOT NULL
USING SAMPLE ${sampleSize} ROWS
)
SELECT COUNT(DISTINCT val) AS cardinality
FROM sampled
`;
}
}

View file

@ -4,6 +4,7 @@ import {
mysqlConnectionPoolConfigFromConfig,
type KtxMysqlConnectionConfig,
} from '../mysql/connector.js';
import { duckDbDatabasePathFromConfig, type KtxDuckDbConnectionConfig } from '../../connectors/duckdb/connector.js';
import type { FederatedMember } from '../../context/connections/federation.js';
function kvKeyword(value: string): string {
@ -74,6 +75,12 @@ function mysqlAttachString(member: FederatedMember, env: NodeJS.ProcessEnv): str
*/
export function federatedAttachTarget(member: FederatedMember, env: NodeJS.ProcessEnv): string {
switch (member.driver.toLowerCase()) {
case 'duckdb':
return duckDbDatabasePathFromConfig({
connectionId: member.connectionId,
projectDir: member.projectDir,
connection: member.connection as KtxDuckDbConnectionConfig,
});
case 'sqlite':
return sqliteDatabasePathFromConfig({
connectionId: member.connectionId,

View file

@ -7,25 +7,12 @@ import type {
import { normalizeQueryRows } from '../../context/connections/query-executor.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
import { attachTypeForDriver, type FederatedMember } from '../../context/connections/federation.js';
import { toJsonSafeRows } from '../shared/duckdb-json-safe.js';
function quoteDuckdbIdentifier(id: string): string {
return `"${id.replaceAll('"', '""')}"`;
}
const MIN_SAFE_BIGINT = BigInt(Number.MIN_SAFE_INTEGER);
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
// DuckDB returns integer columns as JS bigint (unserializable by JSON). Values
// in Number's safe range become Number; larger magnitudes become strings so a
// BIGINT beyond 2^53 keeps its exact value instead of silently rounding.
function jsonSafeBigint(value: bigint): number | string {
return value >= MIN_SAFE_BIGINT && value <= MAX_SAFE_BIGINT ? Number(value) : value.toString();
}
function toJsonSafeRows(rows: unknown[][]): unknown[][] {
return rows.map((row) => row.map((cell) => (typeof cell === 'bigint' ? jsonSafeBigint(cell) : cell)));
}
/** @internal */
export function buildAttachStatements(members: FederatedMember[], env: NodeJS.ProcessEnv): string[] {
const attachments = members.map((member) => ({
@ -34,13 +21,12 @@ export function buildAttachStatements(members: FederatedMember[], env: NodeJS.Pr
alias: member.connectionId,
}));
const loadStatements = [...new Set(attachments.map((a) => a.type))].map(
(type) => `INSTALL ${type}; LOAD ${type};`,
);
const attachStatements = attachments.map(
({ type, url, alias }) =>
`ATTACH '${url.replaceAll("'", "''")}' AS ${quoteDuckdbIdentifier(alias)} (TYPE ${type}, READ_ONLY);`,
);
const loadTypes = [...new Set(attachments.map((a) => a.type).filter((type): type is string => type !== null))];
const loadStatements = loadTypes.map((type) => `INSTALL ${type}; LOAD ${type};`);
const attachStatements = attachments.map(({ type, url, alias }) => {
const options = type === null ? 'READ_ONLY' : `TYPE ${type}, READ_ONLY`;
return `ATTACH '${url.replaceAll("'", "''")}' AS ${quoteDuckdbIdentifier(alias)} (${options});`;
});
return [...loadStatements, ...attachStatements];
}

View file

@ -0,0 +1,40 @@
import type {
LiveDatabaseIntrospectionOptions,
LiveDatabaseIntrospectionPort,
} from '../../context/ingest/adapters/live-database/types.js';
import type { KtxProjectConnectionConfig } from '../../context/project/config.js';
import { KtxDuckDbScanConnector, type KtxDuckDbConnectionConfig } from './connector.js';
export interface CreateDuckDbLiveDatabaseIntrospectionOptions {
projectDir?: string;
connections: Record<string, KtxProjectConnectionConfig>;
now?: () => Date;
}
export function createDuckDbLiveDatabaseIntrospection(
options: CreateDuckDbLiveDatabaseIntrospectionOptions,
): LiveDatabaseIntrospectionPort {
return {
async extractSchema(connectionId: string, introspectionOptions?: LiveDatabaseIntrospectionOptions) {
const connection = options.connections[connectionId] as KtxDuckDbConnectionConfig | undefined;
const connector = new KtxDuckDbScanConnector({
connectionId,
connection,
projectDir: options.projectDir,
now: options.now,
});
try {
return await connector.introspect(
{
connectionId,
driver: 'duckdb',
...(introspectionOptions?.tableScope ? { tableScope: introspectionOptions.tableScope } : {}),
},
{ runId: `duckdb-${connectionId}` },
);
} finally {
await connector.cleanup();
}
},
};
}

View file

@ -0,0 +1,14 @@
const MIN_SAFE_BIGINT = BigInt(Number.MIN_SAFE_INTEGER);
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
// DuckDB returns integer columns as JS bigint (unserializable by JSON). Values
// in Number's safe range become Number; larger magnitudes become strings so a
// BIGINT beyond 2^53 keeps its exact value instead of silently rounding.
/** @internal */
export function jsonSafeBigint(value: bigint): number | string {
return value >= MIN_SAFE_BIGINT && value <= MAX_SAFE_BIGINT ? Number(value) : value.toString();
}
export function toJsonSafeRows(rows: unknown[][]): unknown[][] {
return rows.map((row) => row.map((cell) => (typeof cell === 'bigint' ? jsonSafeBigint(cell) : cell)));
}

View file

@ -0,0 +1,24 @@
import type { ConnectionType } from './connection-type.js';
const CONNECTION_TYPE_TO_SQLGLOT = {
POSTGRESQL: 'postgres',
SQLITE: 'sqlite',
DUCKDB: 'duckdb',
SQLSERVER: 'tsql',
BIGQUERY: 'bigquery',
SNOWFLAKE: 'snowflake',
MYSQL: 'mysql',
CLICKHOUSE: 'clickhouse',
ATHENA: 'athena',
METABASE: null,
LOOKER: null,
NOTION: null,
} satisfies Record<ConnectionType, string | null>;
export function dialectForConnectionType(connectionType: string): string {
return CONNECTION_TYPE_TO_SQLGLOT[connectionType.toUpperCase() as ConnectionType] ?? 'postgres';
}
export function warehouseTargetDialect(connectionType: string): string | null {
return CONNECTION_TYPE_TO_SQLGLOT[connectionType.toUpperCase() as ConnectionType] ?? null;
}

View file

@ -3,25 +3,16 @@ import { z } from 'zod';
export const connectionTypeSchema = z.enum([
'POSTGRESQL',
'SQLITE',
'DUCKDB',
'SQLSERVER',
'BIGQUERY',
'SNOWFLAKE',
'CENTRALREACH',
'EPIC',
'CERNER',
'ATHENA',
'QUICKBOOKS',
'WORKDAY',
'REST',
'S3',
'SLACK',
'METABASE',
'LOOKER',
'NOTION',
'MYSQL',
'CLICKHOUSE',
'PLAIN',
'BETTERSTACK',
]);
export type ConnectionType = z.infer<typeof connectionTypeSchema>;

View file

@ -1,5 +1,7 @@
import { KtxAthenaDialect } from '../../connectors/athena/dialect.js';
import { KtxBigQueryDialect } from '../../connectors/bigquery/dialect.js';
import { KtxClickHouseDialect } from '../../connectors/clickhouse/dialect.js';
import { KtxDuckDbDialect } from '../../connectors/duckdb/dialect.js';
import { KtxMongoDbDialect } from '../../connectors/mongodb/dialect.js';
import { KtxMysqlDialect } from '../../connectors/mysql/dialect.js';
import { KtxPostgresDialect } from '../../connectors/postgres/dialect.js';
@ -53,8 +55,10 @@ export interface KtxSqlDialect extends KtxDialect {
type KtxSqlDriver = Exclude<KtxConnectionDriver, 'mongodb'>;
const sqlDialectFactories: Record<KtxSqlDriver, () => KtxSqlDialect> = {
athena: () => new KtxAthenaDialect(),
bigquery: () => new KtxBigQueryDialect(),
clickhouse: () => new KtxClickHouseDialect(),
duckdb: () => new KtxDuckDbDialect(),
mysql: () => new KtxMysqlDialect(),
postgres: () => new KtxPostgresDialect(),
sqlite: () => new KtxSqliteDialect(),

View file

@ -26,6 +26,23 @@ function invalidConnectionConfig(driver: KtxConnectionDriver): Error {
/** @internal */
export const driverRegistrations: Record<KtxConnectionDriver, KtxDriverRegistration> = {
athena: {
driver: 'athena',
scopeConfigKey: 'databases',
hasHistoricSqlReader: false,
load: async () => {
const m = await import('../../connectors/athena/connector.js');
return {
isConnectionConfig: (connection) => m.isKtxAthenaConnectionConfig(connection),
createScanConnector: ({ connectionId, connection }) => {
if (!m.isKtxAthenaConnectionConfig(connection)) {
throw invalidConnectionConfig('athena');
}
return new m.KtxAthenaScanConnector({ connectionId, connection });
},
};
},
},
bigquery: {
driver: 'bigquery',
scopeConfigKey: 'dataset_ids',
@ -68,6 +85,27 @@ export const driverRegistrations: Record<KtxConnectionDriver, KtxDriverRegistrat
};
},
},
duckdb: {
driver: 'duckdb',
scopeConfigKey: null,
hasHistoricSqlReader: false,
load: async () => {
const m = await import('../../connectors/duckdb/connector.js');
return {
isConnectionConfig: (connection) => {
const typedConnection = connection as Parameters<typeof m.isKtxDuckDbConnectionConfig>[0];
return m.isKtxDuckDbConnectionConfig(typedConnection);
},
createScanConnector: ({ connectionId, connection, projectDir }) => {
const typedConnection = connection as Parameters<typeof m.isKtxDuckDbConnectionConfig>[0];
if (!m.isKtxDuckDbConnectionConfig(typedConnection)) {
throw invalidConnectionConfig('duckdb');
}
return new m.KtxDuckDbScanConnector({ connectionId, connection: typedConnection, projectDir });
},
};
},
},
mongodb: {
driver: 'mongodb',
scopeConfigKey: 'databases',

View file

@ -4,18 +4,19 @@ import type { KtxProjectConnectionConfig } from '../project/config.js';
export const FEDERATED_CONNECTION_ID = '_ktx_federated';
/**
* Drivers DuckDB can ATTACH for federation. The driver name doubles as the
* DuckDB extension/TYPE name, so this set is the single source of truth for
* both membership (a driver participates iff it appears here) and attach type.
* Drivers DuckDB can ATTACH for federation. Membership is governed by this set;
* the attach TYPE is governed by attachTypeForDriver, which returns the driver
* name for extension-backed engines and null for a native DuckDB file (attached
* with no INSTALL/LOAD and no TYPE).
*/
const ATTACH_COMPATIBLE_DRIVERS = new Set(['postgres', 'mysql', 'sqlite']);
const ATTACH_COMPATIBLE_DRIVERS = new Set(['postgres', 'mysql', 'sqlite', 'duckdb']);
export function attachTypeForDriver(driver: string): string {
export function attachTypeForDriver(driver: string): string | null {
const normalized = driver.toLowerCase();
if (!ATTACH_COMPATIBLE_DRIVERS.has(normalized)) {
throw new Error(`Driver "${driver}" cannot be attached by DuckDB federation.`);
}
return normalized;
return normalized === 'duckdb' ? null : normalized;
}
export interface FederatedMember {

View file

@ -1,5 +1,6 @@
import type { KtxProjectConnectionConfig } from '../project/config.js';
import type { ConnectionType } from './connection-type.js';
import { connectionQueryPolicy } from './query-policy.js';
export interface LocalWarehouseDescriptor {
id: string;
@ -18,16 +19,19 @@ export interface LocalConnectionInfo {
connectionType: string;
members?: string[];
hint?: string;
queryPolicy?: 'semantic-layer-only';
}
const DRIVER_TO_CONNECTION_TYPE: Record<string, ConnectionType> = {
postgres: 'POSTGRESQL',
sqlite: 'SQLITE',
duckdb: 'DUCKDB',
sqlserver: 'SQLSERVER',
mysql: 'MYSQL',
clickhouse: 'CLICKHOUSE',
snowflake: 'SNOWFLAKE',
bigquery: 'BIGQUERY',
athena: 'ATHENA',
};
export function localConnectionToWarehouseDescriptor(
@ -90,10 +94,17 @@ export function localConnectionInfoFromConfig(
if (!connection) {
return null;
}
const restricted = connectionQueryPolicy(connection) === 'semantic-layer-only';
return {
id,
name: id,
connectionType: localConnectionTypeForConfig(id, connection),
...(restricted
? {
queryPolicy: 'semantic-layer-only' as const,
hint: 'Raw SQL is disabled on this connection; query it with sl_query using predefined measures.',
}
: {}),
};
}

View file

@ -1,8 +1,15 @@
import { executeFederatedQuery } from '../../connectors/duckdb/federated-executor.js';
import { KtxExpectedError, KtxQueryError, isNativeProgrammingFault } from '../../errors.js';
import { sqlAnalysisDialectForDriver } from '../sql-analysis/dialect.js';
import type { SqlAnalysisPort } from '../sql-analysis/ports.js';
import { assertSafeConnectionId } from '../sl/source-files.js';
import type { KtxLocalProject } from '../project/project.js';
import type { KtxScanConnector, KtxScanContext } from '../scan/types.js';
import { assertSqlQueryableConnection } from './dialects.js';
import { deriveFederatedConnection, FEDERATED_CONNECTION_ID } from './federation.js';
import { assertRawSqlAllowed } from './query-policy.js';
import type { KtxSqlQueryExecutionInput, KtxSqlQueryExecutionResult } from './query-executor.js';
import { resolveConfiguredConnection } from './resolve-connection.js';
export interface ExecuteProjectReadOnlySqlDeps {
project: KtxLocalProject;
@ -56,3 +63,71 @@ export async function executeProjectReadOnlySql(
await connector?.cleanup?.();
}
}
type RawSqlProgressCallback = (event: { progress: number; message: string }) => void | Promise<void>;
export interface ExecuteProjectRawSqlDeps {
project: KtxLocalProject;
connectionId: string;
sql: string;
maxRows: number;
sqlAnalysis: SqlAnalysisPort;
createConnector: (connectionId: string) => Promise<KtxScanConnector> | KtxScanConnector;
executeFederated?: typeof executeFederatedQuery;
runId: string;
onProgress?: RawSqlProgressCallback;
}
/**
* Single guarded path for user-authored (raw) SQL `ktx sql` and the MCP
* sql_execution tool. Enforces the connection's query_policy and the parser
* read-only guard before executing; ktx-internal SQL (semantic-layer, ingest)
* calls executeProjectReadOnlySql directly and is not subject to query_policy.
*/
export async function executeProjectRawSql(deps: ExecuteProjectRawSqlDeps): Promise<KtxSqlQueryExecutionResult> {
const { project } = deps;
await deps.onProgress?.({ progress: 0, message: 'Validating SQL' });
const isFederated = deps.connectionId === FEDERATED_CONNECTION_ID;
const connectionId = isFederated ? deps.connectionId : assertSafeConnectionId(deps.connectionId);
const connection = isFederated ? undefined : resolveConfiguredConnection(project.config, connectionId);
if (!isFederated) {
assertSqlQueryableConnection(connectionId, connection!.driver);
}
assertRawSqlAllowed(project.config, project.projectDir, connectionId);
const dialect = sqlAnalysisDialectForDriver(isFederated ? 'duckdb' : connection!.driver);
const validation = await deps.sqlAnalysis.validateReadOnly(deps.sql, dialect);
if (!validation.ok) {
// A read-only guard rejecting the caller's SQL is an expected outcome, not a
// ktx fault: classify it so reportException keeps it out of Error Tracking.
throw new KtxQueryError(validation.error ?? 'SQL is not read-only.');
}
await deps.onProgress?.({ progress: 0.3, message: 'Executing' });
const result = await executeProjectReadOnlySql({
project,
input: {
connectionId,
projectDir: project.projectDir,
connection,
sql: deps.sql,
maxRows: deps.maxRows,
},
createConnector: deps.createConnector,
executeFederated: deps.executeFederated,
runId: deps.runId,
}).catch((error: unknown) => {
// A warehouse/driver rejection (e.g. the caller's SQL failed to compile) is a
// surfaced operational outcome, not a ktx fault: mark it expected while
// preserving the warehouse's own diagnostics. A native JS error (TypeError,
// etc.) signals a bug in connector code — let it propagate unchanged so Error
// Tracking still sees it.
if (isNativeProgrammingFault(error) || error instanceof KtxExpectedError) {
throw error;
}
throw new KtxQueryError(error instanceof Error ? error.message : String(error), { cause: error });
});
await deps.onProgress?.({ progress: 1, message: `Fetched ${result.rowCount ?? result.rows.length} rows` });
return result;
}

View file

@ -0,0 +1,61 @@
import { KtxQueryError } from '../../errors.js';
import type { KtxProjectConfig, KtxProjectConnectionConfig } from '../project/config.js';
import { deriveFederatedConnection, FEDERATED_CONNECTION_ID } from './federation.js';
import { isSqlQueryableDriver } from './dialects.js';
export type KtxConnectionQueryPolicy = 'read-only-sql' | 'semantic-layer-only';
export function connectionQueryPolicy(
connection: KtxProjectConnectionConfig | undefined,
): KtxConnectionQueryPolicy {
return connection !== undefined && connection.query_policy === 'semantic-layer-only'
? 'semantic-layer-only'
: 'read-only-sql';
}
/** Member ids whose policy blocks raw SQL through the federated connection. */
export function restrictedFederatedMemberIds(config: KtxProjectConfig, projectDir: string): string[] {
const descriptor = deriveFederatedConnection(config.connections, projectDir);
if (!descriptor) {
return [];
}
return descriptor.members
.filter((member) => connectionQueryPolicy(member.connection) === 'semantic-layer-only')
.map((member) => member.connectionId);
}
export function assertRawSqlAllowed(config: KtxProjectConfig, projectDir: string, connectionId: string): void {
if (connectionId === FEDERATED_CONNECTION_ID) {
const restricted = restrictedFederatedMemberIds(config, projectDir);
if (restricted.length > 0) {
throw new KtxQueryError(
`Federated SQL execution is disabled: member connection(s) ${restricted
.map((id) => `"${id}"`)
.join(', ')} are restricted to semantic-layer queries (query_policy: semantic-layer-only in ktx.yaml).`,
);
}
return;
}
if (connectionQueryPolicy(config.connections[connectionId]) === 'semantic-layer-only') {
throw new KtxQueryError(
`Connection "${connectionId}" is restricted to semantic-layer queries (query_policy: semantic-layer-only in ktx.yaml); ` +
'raw SQL execution is disabled. Query it through the semantic layer with predefined measures instead ' +
'(the sl_query tool or `ktx sl query`).',
);
}
}
/**
* False only when the project has SQL-queryable connections and every one of
* them is restricted then no raw-SQL surface can succeed and the
* sql_execution tool should not be offered at all.
*/
export function projectAllowsRawSql(config: KtxProjectConfig): boolean {
const sqlConnections = Object.values(config.connections).filter((connection) =>
isSqlQueryableDriver(connection.driver),
);
if (sqlConnections.length === 0) {
return true;
}
return sqlConnections.some((connection) => connectionQueryPolicy(connection) === 'read-only-sql');
}

View file

@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import { KtxExpectedError } from '../../errors.js';
/** @internal */
export function resolveKtxHomePath(path: string): string {
@ -28,7 +29,15 @@ export function resolveKtxConfigReference(value: string | undefined, env: NodeJS
if (value.startsWith('file:')) {
const filePath = resolveKtxHomePath(value.slice('file:'.length).trim());
const fileValue = readFileSync(filePath, 'utf8').trim();
let fileValue: string;
try {
fileValue = readFileSync(filePath, 'utf8').trim();
} catch (error) {
// A `file:` secret whose target is missing or unreadable is a config
// problem (secrets/ is gitignored, so absent after a clone), not a ktx
// fault — classify it so it stays out of Error Tracking.
throw new KtxExpectedError(`Could not read the secret file referenced in ktx.yaml: ${filePath}`, { cause: error });
}
return fileValue.length > 0 ? fileValue : undefined;
}

View file

@ -4,6 +4,44 @@ import { URL } from 'node:url';
import { spawn } from 'node:child_process';
import type { ResolvedSemanticLayerSource, SemanticLayerQueryInput } from '../sl/types.js';
// The daemon signals "the caller sent a well-formed request I refused as
// invalid" with a distinct subprocess exit code (INPUT_REJECTED_EXIT_CODE in
// python/ktx-daemon/__main__.py) or HTTP 400. Kept in sync across the boundary.
const DAEMON_INPUT_REJECTED_EXIT_CODE = 3;
const DAEMON_INPUT_REJECTED_HTTP_STATUS = 400;
/**
* A ktx-daemon compute call failed. `inputRejected` is true when the daemon
* refused the request as invalid (a caller-driven outcome) rather than faulting;
* callers can promote those to an expected KtxQueryError while letting genuine
* daemon crashes reach Error Tracking. `detail` is the daemon's own message,
* unwrapped for surfacing to the caller.
*/
export class KtxDaemonComputeError extends Error {
readonly inputRejected: boolean;
readonly detail: string;
constructor(message: string, options: { inputRejected: boolean; detail: string; cause?: unknown }) {
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
this.name = 'KtxDaemonComputeError';
this.inputRejected = options.inputRejected;
this.detail = options.detail;
}
}
/** The daemon reports HTTP failures as `{ "detail": "…" }`; fall back to the raw body. */
function daemonHttpErrorDetail(raw: string): string {
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === 'object' && typeof (parsed as { detail?: unknown }).detail === 'string') {
return (parsed as { detail: string }).detail;
}
} catch {
// Non-JSON body (e.g. a proxy error page) — surface it verbatim.
}
return raw;
}
interface KtxSemanticLayerComputeQueryResult {
sql: string;
dialect: string;
@ -128,7 +166,13 @@ function runProcessJson(
const stdoutText = Buffer.concat(stdout).toString('utf8').trim();
const stderrText = Buffer.concat(stderr).toString('utf8').trim();
if (code !== 0) {
reject(new Error(`ktx-daemon ${subcommand} failed: ${stderrText || `exit code ${code}`}`));
const detail = stderrText || `exit code ${code}`;
reject(
new KtxDaemonComputeError(`ktx-daemon ${subcommand} failed: ${detail}`, {
inputRejected: code === DAEMON_INPUT_REJECTED_EXIT_CODE,
detail,
}),
);
return;
}
try {
@ -168,7 +212,12 @@ function postJson(baseUrl: string): KtxDaemonHttpJsonRunner {
const text = Buffer.concat(chunks).toString('utf8');
const statusCode = response.statusCode ?? 0;
if (statusCode < 200 || statusCode >= 300) {
reject(new Error(`ktx-daemon HTTP ${path} failed with ${statusCode}: ${text}`));
reject(
new KtxDaemonComputeError(`ktx-daemon HTTP ${path} failed with ${statusCode}: ${text}`, {
inputRejected: statusCode === DAEMON_INPUT_REJECTED_HTTP_STATUS,
detail: daemonHttpErrorDetail(text),
}),
);
return;
}
try {

View file

@ -1,4 +1,5 @@
import type { ParsedTargetTable } from '../../parsed-target-table.js';
import { warehouseTargetDialect } from '../../../connections/connection-type-dialect.js';
import type { LookerWarehouseConnectionInfo } from './client.js';
import type { LookerPullConfig, LookerRuntimeCursors, StagedExploreFile, StagedLookmlModelsFile } from './types.js';
@ -11,6 +12,7 @@ const LOOKER_DIALECT_TO_CONNECTION_TYPE = {
sqlite: 'SQLITE',
sqlserver: 'SQLSERVER',
clickhouse: 'CLICKHOUSE',
awsathena: 'ATHENA',
} as const;
/** @internal */
@ -72,16 +74,6 @@ export interface LookerMappingClient {
getExplore(modelName: string, exploreName: string): Promise<StagedExploreFile>;
}
const SQLGLOT_DIALECT_BY_CONNECTION_TYPE: Partial<Record<LookerWarehouseTargetConnectionType, string>> = {
BIGQUERY: 'bigquery',
SNOWFLAKE: 'snowflake',
POSTGRESQL: 'postgres',
MYSQL: 'mysql',
SQLITE: 'sqlite',
SQLSERVER: 'tsql',
CLICKHOUSE: 'clickhouse',
};
export async function discoverLookerConnections(
client: Pick<LookerMappingClient, 'listLookerConnections'>,
): Promise<LookerWarehouseConnectionInfo[]> {
@ -100,7 +92,7 @@ export function lookerDialectToConnectionType(dialect: string | null): LookerWar
/** @internal */
export function sqlglotDialectForConnectionType(connectionType: string): string | null {
return SQLGLOT_DIALECT_BY_CONNECTION_TYPE[connectionType as LookerWarehouseTargetConnectionType] ?? null;
return warehouseTargetDialect(connectionType);
}
/** @internal */

View file

@ -8,9 +8,9 @@ export const METABASE_ENGINE_TO_CONNECTION_TYPE = {
snowflake: 'SNOWFLAKE',
sqlserver: 'SQLSERVER',
mysql: 'MYSQL',
athena: 'ATHENA',
} as const;
export interface DiscoveredMetabaseDatabase {
id: number;
name: string;

View file

@ -0,0 +1,148 @@
import { readdir, readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
import type { ChunkResult, DiffSet, WorkUnit } from '../../types.js';
import {
type SigmaManifest,
type StagedDataModelFile,
type StagedWorkbookFile,
sigmaManifestSchema,
stagedDataModelFileSchema,
stagedWorkbookFileSchema,
STAGED_FILES,
} from './types.js';
interface LoadedBundle {
manifest: SigmaManifest | null;
dataModelsByPath: Map<string, StagedDataModelFile>;
workbooksByPath: Map<string, StagedWorkbookFile>;
allPaths: string[];
}
async function walkStagedDir(stagedDir: string): Promise<string[]> {
let entries;
try {
entries = await readdir(stagedDir, { withFileTypes: true, recursive: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
const paths: string[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const abs = join(entry.parentPath, entry.name);
paths.push(relative(stagedDir, abs).replace(/\\/g, '/'));
}
paths.sort();
return paths;
}
async function loadBundle(stagedDir: string): Promise<LoadedBundle> {
const allPaths = await walkStagedDir(stagedDir);
let manifest: SigmaManifest | null = null;
try {
const body = await readFile(join(stagedDir, STAGED_FILES.manifest), 'utf-8');
manifest = sigmaManifestSchema.parse(JSON.parse(body));
} catch {
manifest = null;
}
const dataModelsByPath = new Map<string, StagedDataModelFile>();
const dmPrefix = `${STAGED_FILES.dataModelsDir}/`;
for (const path of allPaths) {
if (!path.startsWith(dmPrefix) || !path.endsWith('.json')) continue;
try {
const body = await readFile(join(stagedDir, path), 'utf-8');
const parsed = stagedDataModelFileSchema.parse(JSON.parse(body));
dataModelsByPath.set(path, parsed);
} catch {
// Malformed file — skip.
}
}
const workbooksByPath = new Map<string, StagedWorkbookFile>();
const wbPrefix = `${STAGED_FILES.workbooksDir}/`;
for (const path of allPaths) {
if (!path.startsWith(wbPrefix) || !path.endsWith('.json')) continue;
try {
const body = await readFile(join(stagedDir, path), 'utf-8');
const parsed = stagedWorkbookFileSchema.parse(JSON.parse(body));
workbooksByPath.set(path, parsed);
} catch {
// Malformed file — skip.
}
}
return { manifest, dataModelsByPath, workbooksByPath, allPaths };
}
/** Max data models per LLM work unit. Controls parallel processing granularity. */
const DATA_MODELS_PER_UNIT = 50;
/** Max workbooks per LLM work unit. Controls incremental re-sync granularity. */
const WORKBOOKS_PER_UNIT = 2000;
function emitBatches(
paths: string[],
perUnit: number,
unitKeyBase: string,
labelBase: string,
noun: string,
allPaths: string[],
): WorkUnit[] {
const batches = Math.ceil(paths.length / perUnit) || 0;
const units: WorkUnit[] = [];
for (let i = 0; i < batches; i++) {
const batch = paths.slice(i * perUnit, (i + 1) * perUnit);
const rawFiles = [...batch].sort();
const rawFilesSet = new Set(rawFiles);
const suffix = batches > 1 ? `-${i}` : '';
units.push({
unitKey: `${unitKeyBase}${suffix}`,
displayLabel: batches > 1 ? `${labelBase} (${i + 1}/${batches})` : labelBase,
rawFiles,
peerFileIndex: allPaths.filter((p) => !rawFilesSet.has(p)).sort(),
dependencyPaths: [],
notes: `${batch.length} ${noun}${batch.length === 1 ? '' : 's'}`,
});
}
return units;
}
function emitWorkUnits(bundle: LoadedBundle): WorkUnit[] {
if (!bundle.manifest) return [];
const dmPaths = [...bundle.dataModelsByPath.keys()].sort();
const wbPaths = [...bundle.workbooksByPath.keys()].sort();
return [
...emitBatches(dmPaths, DATA_MODELS_PER_UNIT, 'sigma-data-models', 'Sigma: data models', 'data model', bundle.allPaths),
...emitBatches(wbPaths, WORKBOOKS_PER_UNIT, 'sigma-workbooks', 'Sigma: workbooks', 'workbook', bundle.allPaths),
];
}
interface ChunkOptions {
diffSet?: DiffSet;
}
export async function chunkSigmaStagedDir(stagedDir: string, opts: ChunkOptions = {}): Promise<ChunkResult> {
const bundle = await loadBundle(stagedDir);
if (!bundle.manifest) {
return { workUnits: [] };
}
const firstRunUnits = emitWorkUnits(bundle);
if (!opts.diffSet) {
return { workUnits: firstRunUnits };
}
const touched = new Set([...opts.diffSet.added, ...opts.diffSet.modified]);
const kept: WorkUnit[] = [];
for (const wu of firstRunUnits) {
const anyTouched = wu.rawFiles.some((p) => touched.has(p));
if (!anyTouched) continue;
const changedFiles = wu.rawFiles.filter((p) => touched.has(p));
const unchangedFiles = wu.rawFiles.filter((p) => !touched.has(p));
const deps = new Set([...wu.dependencyPaths, ...unchangedFiles]);
kept.push({ ...wu, rawFiles: changedFiles.sort(), dependencyPaths: [...deps].sort() });
}
const eviction =
opts.diffSet.deleted.length > 0 ? { deletedRawPaths: [...opts.diffSet.deleted].sort() } : undefined;
return { workUnits: kept, eviction };
}

View file

@ -0,0 +1,51 @@
import type { FetchContext } from '../../types.js';
import type { SigmaPullConfig, WorkbookFilterInput } from './types.js';
export interface SigmaTestConnectionResult {
success: boolean;
message?: string;
error?: string;
}
/** Data model summary shape from GET /v2/dataModels list response. */
export interface SigmaDataModelSummary {
dataModelId: string;
dataModelUrlId: string;
name: string;
path: string;
latestVersion: number;
ownerId: string;
createdAt: string;
updatedAt: string;
isArchived?: boolean;
}
/** Workbook summary shape from GET /v2/workbooks list response. */
export interface SigmaWorkbookSummary {
workbookId: string;
workbookUrlId: string;
name: string;
path: string;
latestVersion: number;
ownerId: string;
createdAt: string;
updatedAt: string;
isArchived?: boolean;
description?: string;
}
/** Re-exported so callers can reference the type without importing from types.ts directly. */
export type { WorkbookFilterInput as ListWorkbooksOptions } from './types.js';
export interface SigmaRuntimeClient {
testConnection(): Promise<SigmaTestConnectionResult>;
listDataModels(): Promise<SigmaDataModelSummary[]>;
listWorkbooks(opts?: WorkbookFilterInput): Promise<SigmaWorkbookSummary[]>;
/** Returns the raw spec object from GET /v2/dataModels/{id}/spec. */
getDataModelSpec(dataModelId: string): Promise<unknown>;
cleanup(): Promise<void>;
}
export interface SigmaClientFactory {
createClient(config: SigmaPullConfig, ctx: FetchContext): Promise<SigmaRuntimeClient> | SigmaRuntimeClient;
}

View file

@ -0,0 +1,231 @@
import type {
ListWorkbooksOptions,
SigmaDataModelSummary,
SigmaRuntimeClient,
SigmaTestConnectionResult,
SigmaWorkbookSummary,
} from './client-port.js';
export interface SigmaClientRuntimeConfig {
apiUrl: string;
clientId: string;
clientSecret: string;
}
export interface SigmaClientConfig {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
timeoutMs: number;
}
export const DEFAULT_SIGMA_CLIENT_CONFIG: SigmaClientConfig = {
maxRetries: 3,
baseDelayMs: 500,
maxDelayMs: 10_000,
timeoutMs: 30_000,
};
interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
}
interface PaginatedResponse<T> {
entries: T[];
nextPage: string | null;
total?: number;
}
function isNonRetryable500(text: string): boolean {
try {
const body = JSON.parse(text) as Record<string, unknown>;
// service_error indicates a deterministic Sigma rejection (e.g. unsupported data
// source subtype). Retrying will not help, so throw immediately.
return body['code'] === 'service_error';
} catch {
return false;
}
}
export class DefaultSigmaClient implements SigmaRuntimeClient {
private accessToken: string | null = null;
private refreshToken: string | null = null;
private tokenExpiresAt = 0;
private tokenInflight: Promise<void> | null = null;
constructor(
private readonly runtimeConfig: SigmaClientRuntimeConfig,
private readonly clientConfig: SigmaClientConfig = DEFAULT_SIGMA_CLIENT_CONFIG,
) {}
private get apiUrl(): string {
return this.runtimeConfig.apiUrl.replace(/\/$/, '');
}
private basicAuthHeader(): string {
const credentials = Buffer.from(
`${this.runtimeConfig.clientId}:${this.runtimeConfig.clientSecret}`,
).toString('base64');
return `Basic ${credentials}`;
}
private async fetchToken(body: URLSearchParams): Promise<TokenResponse> {
const res = await fetch(`${this.apiUrl}/v2/auth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: this.basicAuthHeader(),
},
body: body.toString(),
signal: AbortSignal.timeout(this.clientConfig.timeoutMs),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Sigma auth failed (${res.status}): ${text}`);
}
return res.json() as Promise<TokenResponse>;
}
private async ensureToken(): Promise<void> {
const now = Date.now();
// Refresh 60 s before expiry so in-flight requests don't get 401.
if (this.accessToken && now < this.tokenExpiresAt - 60_000) {
return;
}
if (this.tokenInflight) return this.tokenInflight;
const body = new URLSearchParams();
if (this.refreshToken) {
body.set('grant_type', 'refresh_token');
body.set('refresh_token', this.refreshToken);
} else {
body.set('grant_type', 'client_credentials');
}
this.tokenInflight = this.fetchToken(body)
.then((data) => {
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token ?? null;
this.tokenExpiresAt = Date.now() + data.expires_in * 1000;
})
.finally(() => {
this.tokenInflight = null;
});
return this.tokenInflight;
}
private async request<T>(path: string, query?: Record<string, string>): Promise<T> {
await this.ensureToken();
const url = new URL(`${this.apiUrl}${path}`);
if (query) {
for (const [k, v] of Object.entries(query)) {
url.searchParams.set(k, v);
}
}
let lastError: Error | null = null;
for (let attempt = 0; attempt <= this.clientConfig.maxRetries; attempt++) {
if (attempt > 0) {
const delay = Math.min(
this.clientConfig.baseDelayMs * 2 ** (attempt - 1),
this.clientConfig.maxDelayMs,
);
await new Promise<void>((resolve) => setTimeout(resolve, delay));
}
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${this.accessToken}` },
signal: AbortSignal.timeout(this.clientConfig.timeoutMs),
});
if (res.status === 401) {
// Token rejected — force full re-auth and retry once.
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiresAt = 0;
await this.ensureToken();
const retried = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${this.accessToken}` },
signal: AbortSignal.timeout(this.clientConfig.timeoutMs),
});
if (!retried.ok) {
const text = await retried.text().catch(() => '');
throw new Error(`Sigma API error after token refresh (${retried.status}): ${text}`);
}
return retried.json() as Promise<T>;
}
if (res.status === 429 || res.status >= 500) {
const text = await res.text().catch(() => '');
lastError = new Error(`Sigma API error (${res.status}): ${text}`);
if (isNonRetryable500(text)) throw lastError;
continue;
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Sigma API error (${res.status}): ${text}`);
}
return res.json() as Promise<T>;
}
throw lastError ?? new Error('Sigma API request failed after retries');
}
private async paginateAll<T>(path: string, query: Record<string, string> = {}): Promise<T[]> {
const all: T[] = [];
let page: string | null = null;
do {
const q: Record<string, string> = { ...query, limit: '1000' };
if (page) {
q['page'] = page;
}
const res = await this.request<PaginatedResponse<T>>(path, q);
all.push(...res.entries);
page = res.nextPage ?? null;
} while (page !== null);
return all;
}
async testConnection(): Promise<SigmaTestConnectionResult> {
try {
await this.ensureToken();
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}
async listDataModels(): Promise<SigmaDataModelSummary[]> {
return this.paginateAll<SigmaDataModelSummary>('/v2/dataModels');
}
async listWorkbooks(opts: ListWorkbooksOptions = {}): Promise<SigmaWorkbookSummary[]> {
const query: Record<string, string> = {};
if (!opts.includeExplorations) query['excludeExplorations'] = 'true';
let results = await this.paginateAll<SigmaWorkbookSummary>('/v2/workbooks', query);
if (!opts.includeArchived) {
results = results.filter((wb) => !wb.isArchived);
}
if (opts.updatedSince) {
const since = new Date(opts.updatedSince).getTime();
results = results.filter((wb) => new Date(wb.updatedAt).getTime() >= since);
}
return results;
}
async getDataModelSpec(dataModelId: string): Promise<unknown> {
return this.request<unknown>(`/v2/dataModels/${encodeURIComponent(dataModelId)}/spec`);
}
async cleanup(): Promise<void> {
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiresAt = 0;
}
}

View file

@ -0,0 +1,21 @@
import { readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { STAGED_FILES } from './types.js';
export async function detectSigmaStagedDir(stagedDir: string): Promise<boolean> {
try {
await stat(join(stagedDir, STAGED_FILES.manifest));
} catch {
return false;
}
for (const subdir of [STAGED_FILES.dataModelsDir, STAGED_FILES.workbooksDir]) {
let entries: string[];
try {
entries = await readdir(join(stagedDir, subdir));
} catch {
continue;
}
if (entries.some((name) => name.endsWith('.json'))) return true;
}
return false;
}

View file

@ -0,0 +1,241 @@
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { FetchContext } from '../../types.js';
import type { SigmaClientFactory } from './client-port.js';
import {
type SigmaManifest,
type SigmaProjectionConfig,
type StagedDataModelFile,
type StagedWorkbookFile,
parseSigmaPullConfig,
stagedDataModelFileSchema,
stagedWorkbookFileSchema,
STAGED_FILES,
} from './types.js';
export interface SigmaFetchLogger {
log(message: string): void;
warn(message: string): void;
}
const noopLogger: SigmaFetchLogger = { log: () => undefined, warn: () => undefined };
export interface FetchSigmaBundleParams {
pullConfig: unknown;
stagedDir: string;
ctx: FetchContext;
clientFactory: SigmaClientFactory;
logger?: SigmaFetchLogger;
}
async function loadExistingStagedFiles(stagedDir: string): Promise<Map<string, StagedDataModelFile>> {
const existing = new Map<string, StagedDataModelFile>();
const dmDir = join(stagedDir, STAGED_FILES.dataModelsDir);
let entries: string[];
try {
entries = await readdir(dmDir);
} catch {
return existing;
}
for (const entry of entries) {
if (!entry.endsWith('.json')) continue;
try {
const body = await readFile(join(dmDir, entry), 'utf-8');
const parsed = stagedDataModelFileSchema.parse(JSON.parse(body));
existing.set(parsed.sigmaId, parsed);
} catch {
// Skip malformed files.
}
}
return existing;
}
async function loadExistingWorkbookFiles(stagedDir: string): Promise<Map<string, StagedWorkbookFile>> {
const existing = new Map<string, StagedWorkbookFile>();
const wbDir = join(stagedDir, STAGED_FILES.workbooksDir);
let entries: string[];
try {
entries = await readdir(wbDir);
} catch {
return existing;
}
for (const entry of entries) {
if (!entry.endsWith('.json')) continue;
try {
const body = await readFile(join(wbDir, entry), 'utf-8');
const parsed = stagedWorkbookFileSchema.parse(JSON.parse(body));
existing.set(parsed.sigmaId, parsed);
} catch {
// Skip malformed files.
}
}
return existing;
}
export async function fetchSigmaBundle({
pullConfig,
stagedDir,
ctx,
clientFactory,
logger = noopLogger,
}: FetchSigmaBundleParams): Promise<void> {
const config = parseSigmaPullConfig(pullConfig);
const client = await clientFactory.createClient(config, ctx);
try {
await mkdir(join(stagedDir, STAGED_FILES.dataModelsDir), { recursive: true });
await mkdir(join(stagedDir, STAGED_FILES.workbooksDir), { recursive: true });
// Load existing staged files to enable incremental sync.
const existingByModelId = await loadExistingStagedFiles(stagedDir);
const existingByWorkbookId = await loadExistingWorkbookFiles(stagedDir);
logger.log('Listing Sigma data models...');
const summaries = await client.listDataModels();
const nonArchived = summaries.filter((dm) => !dm.isArchived);
const nonArchivedIds = new Set(nonArchived.map((dm) => dm.dataModelId));
let active = nonArchived;
if (config.dataModelFilter?.updatedSince) {
const since = new Date(config.dataModelFilter.updatedSince).getTime();
active = active.filter((dm) => new Date(dm.updatedAt).getTime() >= since);
}
logger.log(`Found ${active.length} active data model(s) (${summaries.length} total).`);
let fetched = 0;
let skipped = 0;
const SPEC_CONCURRENCY = 10;
const queue = [...active];
await Promise.all(
Array.from({ length: Math.min(SPEC_CONCURRENCY, queue.length) }, async () => {
let summary;
while ((summary = queue.shift()) !== undefined) {
const existing = existingByModelId.get(summary.dataModelId);
// Only skip when the cached spec was successfully fetched. spec: null means
// the previous attempt failed transiently — retry regardless of updatedAt.
if (existing && existing.updatedAt === summary.updatedAt && existing.spec !== null) {
logger.log(`Unchanged: ${summary.name}`);
skipped++;
continue;
}
let spec: unknown = null;
try {
spec = await client.getDataModelSpec(summary.dataModelId);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('dataSource subtype not supported')) {
logger.warn(
`Skipping spec for "${summary.name}" (${summary.dataModelId}): data source type not supported by Sigma spec export API.`,
);
} else {
logger.warn(`Failed to fetch spec for "${summary.name}" (${summary.dataModelId}): ${msg}`);
}
}
const staged: StagedDataModelFile = {
sigmaId: summary.dataModelId,
name: summary.name,
path: summary.path,
latestVersion: summary.latestVersion,
updatedAt: summary.updatedAt,
isArchived: summary.isArchived ?? false,
dataModelUrlId: summary.dataModelUrlId,
spec,
};
const filePath = join(stagedDir, STAGED_FILES.dataModelsDir, `${summary.dataModelId}.json`);
await writeFile(filePath, JSON.stringify(staged, null, 2), 'utf-8');
logger.log(`Staged data model: ${summary.name}`);
fetched++;
}
}),
);
// Remove staged files for models that are archived or deleted — but not those merely outside the filter window.
for (const [modelId] of existingByModelId) {
if (nonArchivedIds.has(modelId)) continue;
try {
await rm(join(stagedDir, STAGED_FILES.dataModelsDir, `${modelId}.json`));
logger.log(`Removed stale staged file for model ${modelId}.`);
} catch {
// Best-effort removal.
}
}
// Fetch workbooks (summary metadata only — no separate spec endpoint).
// Fetch the full non-archived/non-exploration universe first so eviction is based on
// all known workbooks, not just the updatedSince slice. Mirrors the data-model path.
logger.log('Listing Sigma workbooks...');
const { updatedSince, ...filterWithoutSince } = config.workbookFilter ?? {};
const allWorkbooks = await client.listWorkbooks(filterWithoutSince);
const nonArchivedWorkbookIds = new Set(allWorkbooks.map((wb) => wb.workbookId));
const activeWorkbooks = updatedSince
? allWorkbooks.filter((wb) => new Date(wb.updatedAt).getTime() >= new Date(updatedSince).getTime())
: allWorkbooks;
logger.log(`Found ${activeWorkbooks.length} workbook(s) to process (${allWorkbooks.length} total).`);
let workbooksFetched = 0;
let workbooksSkipped = 0;
for (const wb of activeWorkbooks) {
const existing = existingByWorkbookId.get(wb.workbookId);
if (existing && existing.updatedAt === wb.updatedAt) {
workbooksSkipped++;
continue;
}
const staged: StagedWorkbookFile = {
sigmaId: wb.workbookId,
name: wb.name,
path: wb.path,
latestVersion: wb.latestVersion,
updatedAt: wb.updatedAt,
isArchived: wb.isArchived ?? false,
workbookUrlId: wb.workbookUrlId,
description: wb.description,
};
const filePath = join(stagedDir, STAGED_FILES.workbooksDir, `${wb.workbookId}.json`);
await writeFile(filePath, JSON.stringify(staged, null, 2), 'utf-8');
logger.log(`Staged workbook: ${wb.name}`);
workbooksFetched++;
}
// Evict only workbooks that are archived or deleted — not those outside the updatedSince window.
for (const [workbookId] of existingByWorkbookId) {
if (nonArchivedWorkbookIds.has(workbookId)) continue;
try {
await rm(join(stagedDir, STAGED_FILES.workbooksDir, `${workbookId}.json`));
logger.log(`Removed stale staged file for workbook ${workbookId}.`);
} catch {
// Best-effort removal.
}
}
const projectionConfig: SigmaProjectionConfig = {
connectionMappings: config.connectionMappings ?? {},
workbookFilter: config.workbookFilter ?? { includeArchived: false, includeExplorations: false },
};
await writeFile(
join(stagedDir, STAGED_FILES.projectionConfig),
JSON.stringify(projectionConfig, null, 2),
'utf-8',
);
const manifest: SigmaManifest = {
sigmaConnectionId: config.sigmaConnectionId,
fetchedAt: new Date().toISOString(),
dataModelCount: active.length,
workbookCount: activeWorkbooks.length,
};
await writeFile(join(stagedDir, STAGED_FILES.manifest), JSON.stringify(manifest, null, 2), 'utf-8');
logger.log(
`Sigma fetch complete. Data models: ${fetched} fetched, ${skipped} unchanged. Workbooks: ${workbooksFetched} fetched, ${workbooksSkipped} unchanged.`,
);
} finally {
await client.cleanup();
}
}

View file

@ -0,0 +1,76 @@
import type { KtxProjectConnectionConfig } from '../../../../context/project/config.js';
import type { KtxLocalProject } from '../../../../context/project/project.js';
import { resolveKtxConfigReference } from '../../../core/config-reference.js';
import { DEFAULT_SIGMA_CLIENT_CONFIG, DefaultSigmaClient, type SigmaClientConfig } from './client.js';
import type { SigmaClientFactory, SigmaRuntimeClient } from './client-port.js';
import type { SigmaFetchLogger } from './fetch.js';
import type { SigmaPullConfig } from './types.js';
import { SigmaSourceAdapter } from './sigma.adapter.js';
import type { FetchContext } from '../../types.js';
function stringField(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
export function sigmaRuntimeConfigFromLocalConnection(
connectionId: string,
connection: KtxProjectConnectionConfig | undefined,
env: NodeJS.ProcessEnv = process.env,
): { apiUrl: string; clientId: string; clientSecret: string } {
if (!connection || String(connection.driver).toLowerCase() !== 'sigma') {
throw new Error(`Connection "${connectionId}" is not a Sigma connection`);
}
const apiUrl = stringField(connection.api_url) ?? 'https://api.sigmacomputing.com';
const clientId = stringField(connection.client_id);
const literalSecret = stringField(connection.client_secret);
const secretRef = stringField(connection.client_secret_ref);
const clientSecret =
literalSecret ?? (secretRef ? (resolveKtxConfigReference(secretRef, env) ?? null) : null);
if (!clientId) {
throw new Error(`Connection "${connectionId}" is missing Sigma client_id`);
}
if (!clientSecret) {
throw new Error(
`Connection "${connectionId}" is missing Sigma client_secret or client_secret_ref`,
);
}
return { apiUrl, clientId, clientSecret };
}
interface CreateLocalSigmaSourceAdapterOptions {
env?: NodeJS.ProcessEnv;
defaultClientConfig?: SigmaClientConfig;
logger?: SigmaFetchLogger;
}
class LocalSigmaClientFactory implements SigmaClientFactory {
constructor(
private readonly project: KtxLocalProject,
private readonly options: CreateLocalSigmaSourceAdapterOptions,
) {}
createClient(config: SigmaPullConfig, _ctx: FetchContext): SigmaRuntimeClient {
const runtimeConfig = sigmaRuntimeConfigFromLocalConnection(
config.sigmaConnectionId,
this.project.config.connections[config.sigmaConnectionId],
this.options.env,
);
return new DefaultSigmaClient(
runtimeConfig,
this.options.defaultClientConfig ?? DEFAULT_SIGMA_CLIENT_CONFIG,
);
}
}
export function createLocalSigmaSourceAdapter(
project: KtxLocalProject,
options: CreateLocalSigmaSourceAdapterOptions = {},
): SigmaSourceAdapter {
return new SigmaSourceAdapter({
clientFactory: new LocalSigmaClientFactory(project, options),
...(options.logger ? { logger: options.logger } : {}),
});
}

View file

@ -0,0 +1,231 @@
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { z } from 'zod';
import type { SemanticLayerService } from '../../../../context/sl/semantic-layer.service.js';
import type { SemanticLayerSource } from '../../../../context/sl/types.js';
import type { DeterministicProjectionContext, ProjectionResult } from '../../types.js';
import { sigmaProjectionConfigSchema, stagedDataModelFileSchema, STAGED_FILES } from './types.js';
async function readProjectionConfig(stagedDir: string): Promise<Record<string, string>> {
try {
const body = await readFile(join(stagedDir, STAGED_FILES.projectionConfig), 'utf-8');
return sigmaProjectionConfigSchema.parse(JSON.parse(body)).connectionMappings;
} catch {
return {};
}
}
const SIGMA_AUTHOR = { name: 'Sigma', email: 'system@kaelio.dev' } as const;
// Best-effort schema for the raw spec blob stored in staged data model files.
const warehouseTableSourceSchema = z.object({
kind: z.literal('warehouse-table'),
connectionId: z.string(),
path: z.array(z.string()),
});
const specColumnSchema = z
.object({
id: z.string(),
formula: z.string().optional(),
name: z.string().optional(),
hidden: z.boolean().optional(),
description: z.string().optional(),
format: z.object({ kind: z.string() }).passthrough().optional(),
})
.passthrough();
const specElementSchema = z
.object({
id: z.string(),
kind: z.string().optional(),
name: z.string().optional(),
hidden: z.boolean().optional(),
source: z.object({ kind: z.string() }).passthrough().optional(),
columns: z.array(specColumnSchema).optional(),
})
.passthrough();
const specPageSchema = z
.object({
id: z.string(),
name: z.string().optional(),
elements: z.array(specElementSchema).optional(),
})
.passthrough();
const sigmaSpecSchema = z
.object({
name: z.string().optional(),
pages: z.array(specPageSchema).optional(),
})
.passthrough();
type SpecColumn = z.infer<typeof specColumnSchema>;
/** Extract the column name from a bracket formula like `[TABLE/Column Name]` or `[Column]`. */
function extractColumnName(formula: string): string | null {
const match = /\[(?:[^\]/]+\/)?([^\]]+)\]/.exec(formula.trim());
return match?.[1] ?? null;
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
function inferColumnType(col: SpecColumn): string {
const kind = col.format?.kind;
if (kind === 'datetime' || kind === 'date') return 'time';
if (kind === 'number' || kind === 'currency' || kind === 'percent') return 'number';
return 'string';
}
function buildSourceFromElement(
dataModelName: string,
elementName: string | undefined,
elementId: string,
warehousePath: string[],
columns: SpecColumn[],
): SemanticLayerSource | null {
const table = warehousePath.join('.');
if (!table) return null;
const modelSlug = slugify(dataModelName || elementId);
const elemSlug = elementName ? slugify(elementName) : '';
const sourceName = elemSlug && elemSlug !== modelSlug ? `${modelSlug}_${elemSlug}` : modelSlug;
if (!sourceName) return null;
const slColumns: SemanticLayerSource['columns'] = [];
for (const col of columns) {
if (col.hidden) continue;
if (!col.formula) continue;
// Aggregation formulas (Sum, Count, etc.) are Sigma-specific expressions that don't map to
// warehouse columns — skip them silently. The sigma_ingest skill surfaces them as wiki candidates.
if (/^[A-Za-z]+\(/.test(col.formula.trim())) continue;
const displayName = col.name ?? extractColumnName(col.formula);
if (!displayName) continue;
const colSlug = slugify(displayName);
if (!colSlug) continue;
slColumns.push({
name: colSlug,
type: inferColumnType(col),
...(col.description ? { descriptions: { user: col.description } } : {}),
});
}
const source: SemanticLayerSource = {
name: sourceName,
table,
grain: [],
columns: slColumns,
joins: [],
measures: [],
};
if (dataModelName) {
source.descriptions = { user: dataModelName };
}
return source;
}
type SlService = Pick<SemanticLayerService, 'writeSource'> & {
forWorktree(workdir: string): Pick<SemanticLayerService, 'writeSource'>;
};
/** @internal */
export async function projectSigmaDataModels(
ctx: DeterministicProjectionContext,
slService: SlService,
): Promise<ProjectionResult> {
const svc = ctx.workdir ? slService.forWorktree(ctx.workdir) : slService;
const warnings: string[] = [];
const errors: string[] = [];
const touchedSources: Array<{ connectionId: string; sourceName: string }> = [];
const connectionMappings = await readProjectionConfig(ctx.stagedDir);
const dmDir = join(ctx.stagedDir, STAGED_FILES.dataModelsDir);
let entries: string[];
try {
entries = await readdir(dmDir);
} catch {
return { warnings, errors, touchedSources, changedWikiPageKeys: [] };
}
for (const entry of entries) {
if (!entry.endsWith('.json')) continue;
let stagedFile: z.infer<typeof stagedDataModelFileSchema>;
try {
const body = await readFile(join(dmDir, entry), 'utf-8');
stagedFile = stagedDataModelFileSchema.parse(JSON.parse(body));
} catch {
warnings.push(`Skipping malformed staged file: ${entry}`);
continue;
}
if (!stagedFile.spec) continue;
let spec: z.infer<typeof sigmaSpecSchema>;
try {
spec = sigmaSpecSchema.parse(stagedFile.spec);
} catch {
warnings.push(`Skipping unparseable spec for data model "${stagedFile.name}"`);
continue;
}
for (const page of spec.pages ?? []) {
for (const element of page.elements ?? []) {
if (element.hidden) continue;
const warehouseSource = warehouseTableSourceSchema.safeParse(element.source);
if (!warehouseSource.success) continue;
const source = buildSourceFromElement(
stagedFile.name,
element.name,
element.id,
warehouseSource.data.path,
element.columns ?? [],
);
if (!source) continue;
// Only write SL sources for elements whose Sigma connection is mapped to a warehouse connection.
// Writing under an unmapped connection produces gate failures because the Sigma connection
// is not a warehouse connection and cannot be validated.
const targetConnectionId = connectionMappings[warehouseSource.data.connectionId];
if (!targetConnectionId) {
warnings.push(
`Skipping SL source for "${stagedFile.name}" / "${element.name ?? element.id}": ` +
`no connectionMappings entry for Sigma connection ${warehouseSource.data.connectionId}. ` +
`Add a connectionMappings entry in ktx.yaml to enable SL projection for this element.`,
);
continue;
}
try {
const result = await svc.writeSource(
targetConnectionId,
source,
SIGMA_AUTHOR.name,
SIGMA_AUTHOR.email,
`Sigma: import data model "${stagedFile.name}"`,
);
touchedSources.push({ connectionId: targetConnectionId, sourceName: source.name });
warnings.push(...result.warnings);
} catch (err) {
errors.push(`Failed to write source "${source.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
}
}
return { warnings, errors, touchedSources, changedWikiPageKeys: [] };
}

View file

@ -0,0 +1,53 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { ChunkResult, DeterministicProjectionContext, DiffSet, FetchContext, ProjectionResult, SourceAdapter } from '../../types.js';
import { chunkSigmaStagedDir } from './chunk.js';
import type { SigmaClientFactory } from './client-port.js';
import { detectSigmaStagedDir } from './detect.js';
import { fetchSigmaBundle, type SigmaFetchLogger } from './fetch.js';
import { projectSigmaDataModels } from './project.js';
import { sigmaProjectionConfigSchema, STAGED_FILES } from './types.js';
export interface SigmaSourceAdapterDeps {
clientFactory: SigmaClientFactory;
logger?: SigmaFetchLogger;
}
export class SigmaSourceAdapter implements SourceAdapter {
readonly source = 'sigma';
readonly skillNames: string[] = ['sigma_ingest'];
constructor(private readonly deps: SigmaSourceAdapterDeps) {}
detect(stagedDir: string): Promise<boolean> {
return detectSigmaStagedDir(stagedDir);
}
async fetch(pullConfig: unknown, stagedDir: string, ctx: FetchContext): Promise<void> {
await fetchSigmaBundle({
pullConfig,
stagedDir,
ctx,
clientFactory: this.deps.clientFactory,
...(this.deps.logger ? { logger: this.deps.logger } : {}),
});
}
chunk(stagedDir: string, diffSet?: DiffSet): Promise<ChunkResult> {
return chunkSigmaStagedDir(stagedDir, { diffSet });
}
async listTargetConnectionIds(stagedDir: string): Promise<string[]> {
try {
const body = await readFile(join(stagedDir, STAGED_FILES.projectionConfig), 'utf-8');
const config = sigmaProjectionConfigSchema.parse(JSON.parse(body));
return [...new Set(Object.values(config.connectionMappings))].sort();
} catch {
return [];
}
}
project(ctx: DeterministicProjectionContext): Promise<ProjectionResult> {
return projectSigmaDataModels(ctx, ctx.semanticLayerService);
}
}

View file

@ -0,0 +1,105 @@
import { z } from 'zod';
const sigmaLocalConnectionIdSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/);
/** Filters applied when listing workbooks. Shared with ListWorkbooksOptions in client-port.ts. */
const workbookFilterSchema = z.object({
includeArchived: z.boolean().default(false),
includeExplorations: z.boolean().default(false),
/** ISO 8601 date string. Only workbooks updated on or after this date are included. */
updatedSince: z.string().optional(),
});
/** Input shape for listWorkbooks — all fields optional since the client applies its own defaults. */
export type WorkbookFilterInput = z.input<typeof workbookFilterSchema>;
const dataModelFilterSchema = z.object({
/** ISO 8601 date string. Only data models updated on or after this date are fetched. */
updatedSince: z.string().optional(),
});
/** The lean config the adapter needs at `fetch()` time, stored in the ingest job's `bundleRef.config`. */
const sigmaPullConfigSchema = z.object({
/** The ktx connection ID for the Sigma instance being swept. */
sigmaConnectionId: sigmaLocalConnectionIdSchema,
/**
* Maps Sigma internal connection UUIDs (source.connectionId in data model specs)
* to ktx warehouse connection IDs. When present, projected semantic-layer sources
* are written under the mapped warehouse connection rather than the Sigma connection.
*/
connectionMappings: z.record(z.string(), z.string()).optional(),
/** Filters applied when listing workbooks. Defaults exclude archived and exploration workbooks. */
workbookFilter: workbookFilterSchema.default({ includeArchived: false, includeExplorations: false }),
/** Filters applied when listing data models. */
dataModelFilter: dataModelFilterSchema.optional(),
});
export type SigmaPullConfig = z.infer<typeof sigmaPullConfigSchema>;
export function parseSigmaPullConfig(raw: unknown): SigmaPullConfig {
return sigmaPullConfigSchema.parse(raw);
}
/** Written to stagedDir during fetch() and read back by project(), listTargetConnectionIds(), and the sigma_ingest skill. */
export const sigmaProjectionConfigSchema = z.object({
connectionMappings: z.record(z.string(), z.string()).default({}),
/** Filters that were active when workbooks were last fetched. Tells the skill what the staged set covers. */
workbookFilter: workbookFilterSchema.default({ includeArchived: false, includeExplorations: false }),
});
export type SigmaProjectionConfig = z.infer<typeof sigmaProjectionConfigSchema>;
/**
* A staged data model file, one per `data-models/<id>.json`.
* Stores the summary metadata plus the raw spec blob from GET /v2/dataModels/{id}/spec.
*/
export const stagedDataModelFileSchema = z.object({
sigmaId: z.string(),
name: z.string(),
/** Full path in Sigma, e.g. "Finance/Revenue Model". */
path: z.string(),
latestVersion: z.number(),
updatedAt: z.string(),
isArchived: z.boolean().default(false),
/** URL-safe slug Sigma uses in the web UI (dataModelUrlId from the API). */
dataModelUrlId: z.string().optional(),
/** Raw spec from GET /v2/dataModels/{id}/spec (JSON format). */
spec: z.unknown(),
});
export type StagedDataModelFile = z.infer<typeof stagedDataModelFileSchema>;
/** The manifest written once per `fetch()`. Presence acts as the detect() sentinel. */
export const sigmaManifestSchema = z.object({
sigmaConnectionId: sigmaLocalConnectionIdSchema,
fetchedAt: z.string(),
dataModelCount: z.number().int(),
workbookCount: z.number().int().default(0),
});
export type SigmaManifest = z.infer<typeof sigmaManifestSchema>;
/**
* A staged workbook file, one per `workbooks/<id>.json`.
* Stores the summary metadata from GET /v2/workbooks (no separate spec endpoint).
*/
export const stagedWorkbookFileSchema = z.object({
sigmaId: z.string(),
name: z.string(),
path: z.string(),
latestVersion: z.number(),
updatedAt: z.string(),
isArchived: z.boolean().default(false),
workbookUrlId: z.string().optional(),
description: z.string().optional(),
});
export type StagedWorkbookFile = z.infer<typeof stagedWorkbookFileSchema>;
/** Filenames inside stagedDir. Centralized so chunk() + fetch() + detect() all agree. */
export const STAGED_FILES = {
manifest: 'sigma-manifest.json',
projectionConfig: 'sigma-projection-config.json',
dataModelsDir: 'data-models',
workbooksDir: 'workbooks',
} as const;

View file

@ -40,6 +40,8 @@ import { pullConfigFromIntegrationConfig } from './adapters/lookml/pull-config.j
import { createLocalMetabaseSourceAdapter } from './adapters/metabase/local-metabase.adapter.js';
import type { MetabaseClientLogger } from './adapters/metabase/client.js';
import type { MetabaseFetchLogger } from './adapters/metabase/fetch.js';
import { createLocalSigmaSourceAdapter } from './adapters/sigma/local-sigma.adapter.js';
import type { SigmaFetchLogger } from './adapters/sigma/fetch.js';
import { MetricflowSourceAdapter } from './adapters/metricflow/metricflow.adapter.js';
import { pullConfigFromMetricflowIntegration } from './adapters/metricflow/pull-config.js';
import { LocalNotionRuntimeStore } from './adapters/notion/local-state-store.js';
@ -72,7 +74,8 @@ export interface DefaultLocalIngestAdaptersOptions {
type LocalIngestOperationalLogger = MetabaseClientLogger &
MetabaseFetchLogger &
LookerClientLogger &
NotionFetchLogger;
NotionFetchLogger &
SigmaFetchLogger;
export function createDefaultLocalIngestAdapters(
project: KtxLocalProject,
@ -105,6 +108,9 @@ export function createDefaultLocalIngestAdapters(
createLocalMetabaseSourceAdapter(project, {
...(options.logger ? { logger: options.logger } : {}),
}),
createLocalSigmaSourceAdapter(project, {
...(options.logger ? { logger: options.logger } : {}),
}),
new GdriveSourceAdapter(),
new LookerSourceAdapter({
clientFactory: {
@ -271,6 +277,27 @@ export async function localPullConfigForAdapter(
'Metabase scheduled pulls fan out by mapping. Call runLocalMetabaseIngest() or use `ktx ingest <metabase-source-id>` from the CLI.',
);
}
if (adapter.source === 'sigma') {
const sigmaConn = project.config.connections[connectionId];
const connectionMappings =
sigmaConn && 'connectionMappings' in sigmaConn && sigmaConn.connectionMappings != null
? (sigmaConn.connectionMappings as Record<string, string>)
: undefined;
const workbookFilter =
sigmaConn && 'workbookFilter' in sigmaConn && sigmaConn.workbookFilter != null
? (sigmaConn.workbookFilter as { includeArchived?: boolean; includeExplorations?: boolean; updatedSince?: string })
: undefined;
const dataModelFilter =
sigmaConn && 'dataModelFilter' in sigmaConn && sigmaConn.dataModelFilter != null
? (sigmaConn.dataModelFilter as { updatedSince?: string })
: undefined;
return {
sigmaConnectionId: connectionId,
...(connectionMappings ? { connectionMappings } : {}),
...(workbookFilter ? { workbookFilter } : {}),
...(dataModelFilter ? { dataModelFilter } : {}),
};
}
const connection = project.config.connections[connectionId];
if (adapter.source === HISTORIC_SQL_SOURCE_KEY) {
if (options.historicSqlPullConfigOverride) {

View file

@ -243,6 +243,7 @@ const connectionListOutputSchema = z.object({
connectionType: z.string(),
members: z.array(z.string()).optional(),
hint: z.string().optional(),
queryPolicy: z.literal('semantic-layer-only').optional(),
}),
),
});

View file

@ -3,16 +3,15 @@ import { KtxExpectedError, KtxQueryError, isNativeProgrammingFault } from '../..
import { isDatabaseDriver, normalizeConnectionDriver } from '../../connection-drivers.js';
import { sqlDialectNotes } from '../../context/sql-analysis/dialect-notes.js';
import type { KtxProjectConnectionConfig } from '../../context/project/config.js';
import { executeProjectReadOnlySql } from '../../context/connections/project-sql-executor.js';
import { FEDERATED_CONNECTION_ID, federatedConnectionListing } from '../../context/connections/federation.js';
import { assertSqlQueryableConnection } from '../../context/connections/dialects.js';
import { resolveConfiguredConnection } from '../../context/connections/resolve-connection.js';
import { executeProjectRawSql } from '../../context/connections/project-sql-executor.js';
import { federatedConnectionListing } from '../../context/connections/federation.js';
import { projectAllowsRawSql, restrictedFederatedMemberIds } from '../../context/connections/query-policy.js';
import {
type LocalConnectionInfo,
localConnectionInfoFromConfig,
} from '../../context/connections/local-warehouse-descriptor.js';
import type { KtxEmbeddingPort } from '../../context/core/embedding.js';
import type { KtxSemanticLayerComputePort } from '../../context/daemon/semantic-layer-compute.js';
import { KtxDaemonComputeError, type KtxSemanticLayerComputePort } from '../../context/daemon/semantic-layer-compute.js';
import type { KtxLocalProject } from '../../context/project/project.js';
import { createKtxEntityDetailsService } from '../../context/scan/entity-details.js';
import type { LocalScanMcpOptions } from '../../context/scan/local-scan.js';
@ -35,13 +34,32 @@ interface CreateLocalProjectMcpContextPortsOptions {
embeddingService: KtxEmbeddingPort | null;
}
/**
* Reclassify a query-path failure. Warehouse/driver rejections and daemon
* input-rejections are caller-driven outcomes (KtxQueryError, kept out of Error
* Tracking while preserving the underlying diagnostics); native JS faults, daemon
* crashes, and already-expected errors propagate unchanged so genuine ktx bugs
* still reach Error Tracking.
*/
function throwClassifiedQueryError(error: unknown): never {
if (error instanceof KtxDaemonComputeError) {
if (error.inputRejected) {
throw new KtxQueryError(error.detail, { cause: error });
}
throw error;
}
if (isNativeProgrammingFault(error) || error instanceof KtxExpectedError) {
throw error;
}
throw new KtxQueryError(error instanceof Error ? error.message : String(error), { cause: error });
}
async function executeValidatedReadOnlySql(
project: KtxLocalProject,
options: CreateLocalProjectMcpContextPortsOptions,
input: { connectionId: string; sql: string; maxRows: number },
onProgress?: KtxMcpProgressCallback,
): Promise<KtxSqlExecutionResponse> {
await onProgress?.({ progress: 0, message: 'Validating SQL' });
if (!options.sqlAnalysis) {
throw new Error('sql_execution requires parser-backed SQL validation.');
}
@ -50,52 +68,22 @@ async function executeValidatedReadOnlySql(
throw new Error('sql_execution requires a local scan connector factory.');
}
const isFederated = input.connectionId === FEDERATED_CONNECTION_ID;
const connectionId = isFederated ? input.connectionId : assertSafeConnectionId(input.connectionId);
const connection = isFederated ? undefined : resolveConfiguredConnection(project.config, connectionId);
if (!isFederated) {
assertSqlQueryableConnection(connectionId, connection!.driver);
}
const dialect = sqlAnalysisDialectForDriver(isFederated ? 'duckdb' : connection!.driver);
const validation = await options.sqlAnalysis.validateReadOnly(input.sql, dialect);
if (!validation.ok) {
// A read-only guard rejecting the agent's SQL is an expected outcome, not a
// ktx fault: classify it so reportException keeps it out of Error Tracking.
throw new KtxQueryError(validation.error ?? 'SQL is not read-only.');
}
await onProgress?.({ progress: 0.3, message: 'Executing' });
const result = await executeProjectReadOnlySql({
const result = await executeProjectRawSql({
project,
input: {
connectionId,
projectDir: project.projectDir,
connection,
sql: input.sql,
maxRows: input.maxRows,
},
connectionId: input.connectionId,
sql: input.sql,
maxRows: input.maxRows,
sqlAnalysis: options.sqlAnalysis,
createConnector,
runId: 'mcp-sql-execution',
}).catch((error: unknown) => {
// A warehouse/driver rejection (e.g. the agent's SQL failed to compile) is a
// surfaced operational outcome, not a ktx fault: mark it expected while
// preserving the warehouse's own diagnostics. A native JS error (TypeError,
// etc.) signals a bug in connector code — let it propagate unchanged so Error
// Tracking still sees it.
if (isNativeProgrammingFault(error) || error instanceof KtxExpectedError) {
throw error;
}
throw new KtxQueryError(error instanceof Error ? error.message : String(error), { cause: error });
onProgress,
});
const response = {
return {
headers: result.headers,
...(result.headerTypes ? { headerTypes: result.headerTypes } : {}),
rows: result.rows,
rowCount: result.rowCount ?? result.rows.length,
};
await onProgress?.({ progress: 1, message: `Fetched ${response.rowCount} rows` });
return response;
}
/** @internal Resolves a connection's dialect SQL notes; throws KtxExpectedError for an unknown or non-SQL-warehouse connection. */
@ -130,12 +118,17 @@ export function createLocalProjectMcpContextPorts(
.sort((a, b) => a.id.localeCompare(b.id));
const federated = federatedConnectionListing(project.config.connections, project.projectDir);
if (federated) {
const restricted = restrictedFederatedMemberIds(project.config, project.projectDir);
configured.push({
id: federated.id,
name: federated.id,
connectionType: 'DUCKDB',
members: federated.members,
hint: federated.hint,
hint:
restricted.length > 0
? `Federated SQL is disabled: member connection(s) ${restricted.join(', ')} have query_policy: semantic-layer-only.`
: federated.hint,
...(restricted.length > 0 ? { queryPolicy: 'semantic-layer-only' as const } : {}),
});
}
return configured;
@ -197,15 +190,19 @@ export function createLocalProjectMcpContextPorts(
if (!options.semanticLayerCompute) {
throw new Error('sl_query requires a semantic-layer query adapter.');
}
return compileLocalSlQuery(project, {
connectionId: input.connectionId,
query: input.query,
compute: options.semanticLayerCompute,
execute: Boolean(options.queryExecutor),
maxRows: input.query.limit,
queryExecutor: options.queryExecutor,
onProgress: executionOptions?.onProgress,
});
try {
return await compileLocalSlQuery(project, {
connectionId: input.connectionId,
query: input.query,
compute: options.semanticLayerCompute,
execute: Boolean(options.queryExecutor),
maxRows: input.query.limit,
queryExecutor: options.queryExecutor,
onProgress: executionOptions?.onProgress,
});
} catch (error) {
throwClassifiedQueryError(error);
}
},
},
entityDetails: {
@ -231,7 +228,10 @@ export function createLocalProjectMcpContextPorts(
},
};
if (options.sqlAnalysis && options.localScan?.createConnector) {
// Register sql_execution only when some connection can accept raw SQL; in
// mixed projects the tool stays and executeProjectRawSql rejects restricted
// connection ids at request time.
if (options.sqlAnalysis && options.localScan?.createConnector && projectAllowsRawSql(project.config)) {
ports.sqlExecution = {
async execute(input, executionOptions) {
return executeValidatedReadOnlySql(project, options, input, executionOptions?.onProgress);

View file

@ -11,8 +11,10 @@ const warehouseDrivers = [
'snowflake',
'bigquery',
'sqlite',
'duckdb',
'clickhouse',
'sqlserver',
'athena',
] as const;
type WarehouseDriver = (typeof warehouseDrivers)[number];
@ -40,6 +42,12 @@ function warehouseConnectionSchema<const Driver extends WarehouseDriver>(driver:
.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.',
),
query_policy: z
.enum(['read-only-sql', 'semantic-layer-only'])
.optional()
.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.',
),
})
.describe(
`${driver} warehouse connection. Additional driver-tunable fields (e.g. context.queryHistory) are accepted and passed through.`,
@ -52,8 +60,10 @@ const warehouseConnectionSchemas = [
warehouseConnectionSchema('snowflake'),
warehouseConnectionSchema('bigquery'),
warehouseConnectionSchema('sqlite'),
warehouseConnectionSchema('duckdb'),
warehouseConnectionSchema('clickhouse'),
warehouseConnectionSchema('sqlserver'),
warehouseConnectionSchema('athena'),
] as const;
const mongodbConnectionSchema = z
@ -251,6 +261,47 @@ const metricflowConnectionSchema = z
})
.describe('MetricFlow / SL context-source connection.');
const sigmaConnectionSchema = z
.looseObject({
driver: z.literal('sigma'),
api_url: z
.string()
.url()
.default('https://api.sigmacomputing.com')
.describe('Sigma API base URL. Defaults to the GCP US endpoint; change for other regions.'),
client_id: z.string().min(1).describe('Sigma API client ID.'),
client_secret: z.string().min(1).optional().describe('Literal Sigma client secret. Prefer client_secret_ref.'),
client_secret_ref: z
.string()
.min(1)
.optional()
.describe('Reference to Sigma client secret (e.g. env:SIGMA_CLIENT_SECRET).'),
connectionMappings: z
.record(z.string(), z.string())
.optional()
.describe(
'Maps Sigma internal connection UUIDs to ktx warehouse connection IDs. ' +
'When set, projected semantic-layer sources land under the mapped warehouse connection ' +
'instead of the Sigma connection, enabling sl_validate. ' +
'Find UUIDs in data model specs under source.connectionId.',
),
workbookFilter: z
.object({
includeArchived: z.boolean().default(false),
includeExplorations: z.boolean().default(false),
updatedSince: z.string().optional().describe('ISO 8601 date string. Only workbooks updated on or after this date are ingested.'),
})
.optional()
.describe('Filters applied when listing workbooks during ingest. Defaults exclude archived and exploration workbooks.'),
dataModelFilter: z
.object({
updatedSince: z.string().optional().describe('ISO 8601 date string. Only data models updated on or after this date are fetched.'),
})
.optional()
.describe('Filters applied when listing data models during ingest.'),
})
.describe('Sigma Computing API connection for ingesting data models.');
export const connectionConfigSchema = z.discriminatedUnion('driver', [
...warehouseConnectionSchemas,
mongodbConnectionSchema,
@ -261,4 +312,5 @@ export const connectionConfigSchema = z.discriminatedUnion('driver', [
gdriveConnectionSchema,
dbtConnectionSchema,
metricflowConnectionSchema,
sigmaConnectionSchema,
]);

View file

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

View file

@ -2,12 +2,14 @@ import type { KtxTableRefKey } from './table-ref.js';
export type KtxConnectionDriver =
| 'sqlite'
| 'duckdb'
| 'postgres'
| 'sqlserver'
| 'bigquery'
| 'snowflake'
| 'mysql'
| 'clickhouse'
| 'athena'
| 'mongodb';
/** Canonical scan-mode registry. Runtime validation derives its allowlist here. */

View file

@ -4,6 +4,7 @@ import type { KtxMcpProgressCallback } from '../mcp/types.js';
import type { KtxLocalProject } from '../../context/project/project.js';
import { isSqlQueryableDriver } from '../connections/dialects.js';
import { FEDERATED_CONNECTION_ID } from '../connections/federation.js';
import { connectionQueryPolicy, restrictedFederatedMemberIds } from '../connections/query-policy.js';
import { resolveRequiredConnectionId } from '../connections/resolve-connection.js';
import { sqlAnalysisDialectForDriver } from '../sql-analysis/dialect.js';
import { loadLocalSlSourceRecords } from './local-sl.js';
@ -14,11 +15,31 @@ import type { SemanticLayerQueryExecutionResult, SemanticLayerQueryInput, Semant
const COMPILE_ONLY_REASON =
'Local semantic-layer query compiled SQL but no data-source execution adapter is configured.';
const FEDERATED_SL_QUERY_UNSUPPORTED =
`Semantic-layer queries are per-connection and cannot target the federated connection '${FEDERATED_CONNECTION_ID}'. ` +
`Run a cross-database query as read-only SQL instead — ktx sql -c ${FEDERATED_CONNECTION_ID} "SELECT ..." or the sql_execution tool — ` +
'using catalog-qualified table names (connectionId.schema.table, or connectionId.table for sqlite; ' +
'double-quote ids that are not bare identifiers, e.g. "books-db".public.books).';
const FEDERATED_SL_QUERY_PREFIX =
`Semantic-layer queries are per-connection and cannot target the federated connection '${FEDERATED_CONNECTION_ID}'. `;
// The raw-SQL fallback is only valid when federated raw SQL is allowed; when a
// member is restricted (query_policy: semantic-layer-only), assertRawSqlAllowed
// rejects the same path, so directing the agent there would burn a guaranteed
// failure. Derive the message from the restricted-member set instead.
function federatedSlQueryUnsupportedMessage(project: KtxLocalProject): string {
const restricted = restrictedFederatedMemberIds(project.config, project.projectDir);
if (restricted.length > 0) {
return (
FEDERATED_SL_QUERY_PREFIX +
`Cross-database SQL through '${FEDERATED_CONNECTION_ID}' is also disabled because member connection(s) ` +
`${restricted.map((id) => `'${id}'`).join(', ')} are restricted to semantic-layer queries ` +
'(query_policy: semantic-layer-only). Query each connection on its own through the semantic layer ' +
'(the sl_query tool or `ktx sl query` with its connection id).'
);
}
return (
FEDERATED_SL_QUERY_PREFIX +
`Run a cross-database query as read-only SQL instead — ktx sql -c ${FEDERATED_CONNECTION_ID} "SELECT ..." or the sql_execution tool — ` +
'using catalog-qualified table names (connectionId.schema.table, or connectionId.table for sqlite; ' +
'double-quote ids that are not bare identifiers, e.g. "books-db".public.books).'
);
}
export interface CompileLocalSlQueryOptions {
connectionId?: string;
@ -79,7 +100,7 @@ export async function compileLocalSlQuery(
options: CompileLocalSlQueryOptions,
): Promise<CompileLocalSlQueryResult> {
if (options.connectionId === FEDERATED_CONNECTION_ID) {
throw new Error(FEDERATED_SL_QUERY_UNSUPPORTED);
throw new Error(federatedSlQueryUnsupportedMessage(project));
}
await options.onProgress?.({ progress: 0, message: 'Compiling query' });
const connectionId = resolveLocalConnectionId(project, options.connectionId);
@ -93,11 +114,13 @@ export async function compileLocalSlQuery(
const dialect = sqlAnalysisDialectForDriver(driver);
const sources = await loadComputableSources(project, connectionId);
const predefinedMeasuresOnly =
connectionQueryPolicy(project.config.connections[connectionId]) === 'semantic-layer-only';
await options.onProgress?.({ progress: 0.3, message: 'Generating SQL' });
const response = await options.compute.query({
sources,
dialect,
query: options.query,
query: { ...options.query, predefined_measures_only: predefinedMeasuresOnly },
});
if (!options.execute) {

View file

@ -2,6 +2,7 @@ import YAML from 'yaml';
import type { KtxFileStorePort } from '../../context/core/file-store.js';
import type { KtxLogger } from '../../context/core/config.js';
import { noopLogger } from '../../context/core/config.js';
import { dialectForConnectionType } from '../connections/connection-type-dialect.js';
import type { TableUsageOutput } from '../ingest/adapters/historic-sql/skill-schemas.js';
import type { SlConnectionCatalogPort, SlPythonPort } from './ports.js';
import { normalizeSemanticLayerDescriptions } from './description-normalization.js';
@ -674,7 +675,7 @@ export class SemanticLayerService {
if (!connection) {
throw new Error(`Data source not found: ${connectionId}`);
}
return SemanticLayerService.mapDialect(connection.connectionType);
return dialectForConnectionType(connection.connectionType);
}
async listFilesForConnection(connectionId: string): Promise<string[]> {
@ -1107,25 +1108,6 @@ export class SemanticLayerService {
return { columns, tables };
}
/**
* All callers should use this instead of maintaining their own dialect maps.
*/
static mapDialect(connectionType: string): string {
const normalized = connectionType.toUpperCase();
const map: Record<string, string> = {
POSTGRES: 'postgres',
BIGQUERY: 'bigquery',
SNOWFLAKE: 'snowflake',
MYSQL: 'mysql',
SQLSERVER: 'tsql',
SQLITE: 'sqlite',
DUCKDB: 'duckdb',
CLICKHOUSE: 'clickhouse',
DATABRICKS: 'databricks',
};
return map[normalized] ?? 'postgres';
}
/**
* Execute a semantic layer query: load composed sources, generate SQL via
* the python SL engine, and execute the generated SQL against the data source.
@ -1153,7 +1135,7 @@ export class SemanticLayerService {
if (!connection) {
throw new Error(`Data source not found: ${connectionId}`);
}
const dialect = SemanticLayerService.mapDialect(connection.connectionType);
const dialect = dialectForConnectionType(connection.connectionType);
// 3. Generate SQL via python SL engine
const { data: slResult, error: slError } = await this.python.query({

View file

@ -2,9 +2,10 @@ import YAML from 'yaml';
import type { GitService } from '../../../context/core/git.service.js';
import type { KtxFileListResult, KtxFileReadResult, KtxFileStorePort } from '../../../context/core/file-store.js';
import { SYSTEM_GIT_AUTHOR } from '../../../context/tools/authors.js';
import { dialectForConnectionType } from '../../connections/connection-type-dialect.js';
import type { SlConnectionCatalogPort, SlSourcesIndexPort } from '../ports.js';
import { sourceOverlaySchema } from '../schemas.js';
import { SemanticLayerService } from '../semantic-layer.service.js';
import type { SemanticLayerService } from '../semantic-layer.service.js';
import { resolveSlSourceFile, slSourceFilePath } from '../source-files.js';
import type { SemanticLayerSource } from '../types.js';
import { sourceDefinitionSchema } from './base-semantic-layer.tool.js';
@ -28,7 +29,7 @@ function resolveDialect(warehouse: string | null): string | null {
if (!warehouse) {
return null;
}
return SemanticLayerService.mapDialect(warehouse);
return dialectForConnectionType(warehouse);
}
function wrapWithZeroRowQuery(sql: string, dialect: string): string {

View file

@ -81,6 +81,9 @@ export interface SemanticLayerQueryInput {
order_by?: Array<string | { field: string; direction?: string }>;
limit?: number;
include_empty?: boolean;
// Set by compileLocalSlQuery from the connection's query_policy, never from
// caller input: the planner rejects runtime-composed measures when true.
predefined_measures_only?: boolean;
}
export interface SemanticLayerQueryExecutionResult {

View file

@ -6,8 +6,7 @@ import type { SqlAnalysisDialect } from './ports.js';
// dialect), served by the sql_dialect_notes MCP tool. They are package-internal:
// copy-runtime-assets.mjs ships them to dist, and they are never installed onto an
// agent target. The set covers every dialect reachable from a configured warehouse
// driver; duckdb/databricks are intentionally absent because no connector produces
// them.
// driver; databricks is intentionally absent because no connector produces it.
/** @internal Dialects with an authored ./dialects/<dialect>.md file. */
export const DIALECTS_WITH_NOTES = [
@ -16,8 +15,10 @@ export const DIALECTS_WITH_NOTES = [
'snowflake',
'bigquery',
'sqlite',
'duckdb',
'clickhouse',
'tsql',
'athena',
] as const;
type DialectWithNotes = (typeof DIALECTS_WITH_NOTES)[number];

View file

@ -16,6 +16,7 @@ const SQLGLOT_DIALECTS: Record<string, SqlAnalysisDialect> = {
duckdb: 'duckdb',
clickhouse: 'clickhouse',
databricks: 'databricks',
athena: 'athena',
};
export function sqlAnalysisDialectForDriver(driver: string | undefined): SqlAnalysisDialect {

View file

@ -0,0 +1,12 @@
**athena** SQL conventions (Trino engine over the Glue Data Catalog):
- **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`).
- **Identifiers:** case-insensitive and folded to lowercase; double-quote (`"Name"`) to keep case, spaces, or a reserved word. String literals use single quotes only.
- **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.
- **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).
- **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.
- **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>)`.
- **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.
- **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)`.
- **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.
- **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.
- **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 ...)`.

View file

@ -0,0 +1,10 @@
**duckdb** SQL conventions:
- **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`).
- **Identifiers:** case-insensitive; double-quote (`"Name"`) to keep a name with spaces or a reserved word.
- **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).
- **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.
- **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>)`.
- **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.
- **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.
- **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.
- **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).

View file

@ -1,3 +1,5 @@
import { createAthenaLiveDatabaseIntrospection } from './connectors/athena/live-database-introspection.js';
import { isKtxAthenaConnectionConfig } from './connectors/athena/connector.js';
import { createBigQueryLiveDatabaseIntrospection } from './connectors/bigquery/live-database-introspection.js';
import { isKtxBigQueryConnectionConfig, KtxBigQueryScanConnector, type KtxBigQueryConnectionConfig } from './connectors/bigquery/connector.js';
import { createClickHouseLiveDatabaseIntrospection } from './connectors/clickhouse/live-database-introspection.js';
@ -9,6 +11,8 @@ import { isKtxPostgresConnectionConfig, type KtxPostgresConnectionConfig } from
import { KtxPostgresHistoricSqlQueryClient } from './connectors/postgres/historic-sql-query-client.js';
import { createSqliteLiveDatabaseIntrospection } from './connectors/sqlite/live-database-introspection.js';
import { isKtxSqliteConnectionConfig } from './connectors/sqlite/connector.js';
import { createDuckDbLiveDatabaseIntrospection } from './connectors/duckdb/live-database-introspection.js';
import { isKtxDuckDbConnectionConfig } from './connectors/duckdb/connector.js';
import { createSqlServerLiveDatabaseIntrospection } from './connectors/sqlserver/live-database-introspection.js';
import { isKtxSqlServerConnectionConfig } from './connectors/sqlserver/connector.js';
import { BigQueryHistoricSqlQueryHistoryReader } from './context/ingest/adapters/historic-sql/bigquery-query-history-reader.js';
@ -104,6 +108,10 @@ function createKtxCliLiveDatabaseIntrospection(
projectDir: project.projectDir,
connections: project.config.connections,
});
const duckdb = createDuckDbLiveDatabaseIntrospection({
projectDir: project.projectDir,
connections: project.config.connections,
});
const mysql = createMysqlLiveDatabaseIntrospection({
connections: project.config.connections,
});
@ -119,6 +127,9 @@ function createKtxCliLiveDatabaseIntrospection(
const bigquery = createBigQueryLiveDatabaseIntrospection({
connections: project.config.connections,
});
const athena = createAthenaLiveDatabaseIntrospection({
connections: project.config.connections,
});
return {
async extractSchema(connectionId: string, options?: LiveDatabaseIntrospectionOptions) {
const connection = project.config.connections[connectionId];
@ -139,6 +150,9 @@ function createKtxCliLiveDatabaseIntrospection(
if (isKtxSqliteConnectionConfig(connection)) {
return sqlite.extractSchema(connectionId, options);
}
if (isKtxDuckDbConnectionConfig(connection)) {
return duckdb.extractSchema(connectionId, options);
}
if (isKtxMysqlConnectionConfig(connection)) {
return mysql.extractSchema(connectionId, options);
}
@ -151,6 +165,9 @@ function createKtxCliLiveDatabaseIntrospection(
if (isKtxBigQueryConnectionConfig(connection)) {
return bigquery.extractSchema(connectionId, options);
}
if (isKtxAthenaConnectionConfig(connection)) {
return athena.extractSchema(connectionId, options);
}
if (hasSnowflakeDriver(connection)) {
const { createSnowflakeLiveDatabaseIntrospection } = await import('./connectors/snowflake/live-database-introspection.js');
const { isKtxSnowflakeConnectionConfig } = await import('./connectors/snowflake/connector.js');;

View file

@ -139,6 +139,7 @@ const sourceAdapterByDriver = new Map<string, string>([
['metricflow', 'metricflow'],
['dbt', 'dbt'],
['lookml', 'lookml'],
['sigma', 'sigma'],
]);
export function publicProgressMessage(message: string, target: KtxPublicIngestPlanTarget): string {

View file

@ -64,12 +64,14 @@ const execFileAsync = promisify(execFileCallback);
export type KtxSetupDatabaseDriver =
| 'sqlite'
| 'duckdb'
| 'postgres'
| 'mysql'
| 'clickhouse'
| 'sqlserver'
| 'bigquery'
| 'snowflake'
| 'athena'
| 'mongodb';
export interface KtxSetupDatabasesArgs {
@ -157,8 +159,10 @@ const DRIVER_OPTIONS: Array<{ value: KtxSetupDatabaseDriver; label: string }> =
{ value: 'mysql', label: 'MySQL' },
{ value: 'clickhouse', label: 'ClickHouse' },
{ value: 'sqlserver', label: 'SQL Server' },
{ value: 'athena', label: 'Amazon Athena' },
{ value: 'mongodb', label: 'MongoDB' },
{ value: 'sqlite', label: 'SQLite' },
{ value: 'duckdb', label: 'DuckDB' },
];
const DRIVER_LABELS = Object.fromEntries(DRIVER_OPTIONS.map((option) => [option.value, option.label])) as Record<
@ -174,12 +178,14 @@ const HISTORIC_SQL_DIALECT_BY_DRIVER: Partial<Record<KtxSetupDatabaseDriver, His
const DEFAULT_CONNECTION_IDS: Record<KtxSetupDatabaseDriver, string> = {
sqlite: 'sqlite-local',
duckdb: 'duckdb-local',
postgres: 'postgres-warehouse',
mysql: 'mysql-warehouse',
clickhouse: 'clickhouse-warehouse',
sqlserver: 'sqlserver-warehouse',
bigquery: 'bigquery-warehouse',
snowflake: 'snowflake-warehouse',
athena: 'athena-warehouse',
mongodb: 'mongodb-source',
};
@ -265,6 +271,13 @@ const SCOPE_DISCOVERY_SPECS: Partial<Record<KtxSetupDatabaseDriver, ScopeDiscove
configSingleField: 'schema_name',
suggest: defaultSuggest,
},
athena: {
noun: 'database',
nounPlural: 'databases',
promptLabel: 'Glue databases',
configArrayField: 'databases',
suggest: defaultSuggest,
},
};
type UrlDriverType = Extract<KtxSetupDatabaseDriver, 'postgres' | 'mysql' | 'clickhouse' | 'sqlserver'>;
@ -813,6 +826,18 @@ async function buildConnectionConfig(input: {
if (path === undefined) return 'back';
return path ? { driver: 'sqlite', path } : null;
}
if (driver === 'duckdb') {
if (args.inputMode === 'disabled' && !args.databaseUrl) return null;
const path =
args.databaseUrl ??
(await promptText(
prompts,
'DuckDB database file\nEnter a relative or absolute path, for example ./warehouse.duckdb.',
stringConfigField(input.existingConnection, 'path'),
));
if (path === undefined) return 'back';
return path ? { driver: 'duckdb', path } : null;
}
if (driver === 'postgres' || driver === 'mysql' || driver === 'clickhouse' || driver === 'sqlserver') {
return await buildUrlConnectionConfig({
driver,
@ -953,6 +978,47 @@ async function buildConnectionConfig(input: {
...(role ? { role } : {}),
};
}
if (driver === 'athena') {
if (args.inputMode === 'disabled' && !args.databaseUrl) return null;
const region = await promptText(
prompts,
'AWS region\nFor example us-east-1.',
stringConfigField(input.existingConnection, 'region'),
);
if (region === undefined) return 'back';
if (!region) return null;
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'),
);
if (s3StagingDir === undefined) return 'back';
if (!s3StagingDir) return null;
const workgroup = await promptText(
prompts,
'Athena workgroup (optional)\nPress Enter to use the default workgroup "primary".',
stringConfigField(input.existingConnection, 'workgroup'),
);
if (workgroup === undefined) return 'back';
const catalog = await promptText(
prompts,
'Glue Data Catalog name (optional)\nPress Enter to use the default "AwsDataCatalog".',
stringConfigField(input.existingConnection, 'catalog'),
);
if (catalog === undefined) return 'back';
return {
driver: 'athena',
region,
s3_staging_dir: s3StagingDir,
...(workgroup ? { workgroup } : {}),
...(catalog ? { catalog } : {}),
...scriptedScopeConfigForDriver('athena', args.databaseSchemas),
};
}
throw new Error(`Unsupported database driver: ${driver}`);
}
@ -1417,9 +1483,11 @@ async function maybeConfigureDatabaseScope(input: {
const project = await loadKtxProject({ projectDir: input.projectDir });
const connection = project.config.connections[input.connectionId];
const driver = normalizeDriver(connection?.driver);
if (!driver || driver === 'sqlite') return okValidateResult();
const spec = driver ? SCOPE_DISCOVERY_SPECS[driver] : undefined;
// Drivers with no scope spec are single-namespace (sqlite, duckdb): there is no
// schema to choose, so skip the scope picker and ingest every table.
if (!driver || !spec) return okValidateResult();
const spec = SCOPE_DISCOVERY_SPECS[driver];
const existingTables = connection?.enabled_tables;
const hasExistingTables = Array.isArray(existingTables) && existingTables.length > 0;
const existingScope = spec ? configuredScopeValues(connection, spec) : [];

View file

@ -16,6 +16,8 @@ import {
import { gdriveServiceAccountKeySchema } from './context/ingest/adapters/gdrive/types.js';
import { cloneOrPull, testRepoConnection } from './context/ingest/repo-fetch.js';
import { DEFAULT_METABASE_CLIENT_CONFIG, MetabaseClient } from './context/ingest/adapters/metabase/client.js';
import { DEFAULT_SIGMA_CLIENT_CONFIG, DefaultSigmaClient } from './context/ingest/adapters/sigma/client.js';
import { sigmaRuntimeConfigFromLocalConnection } from './context/ingest/adapters/sigma/local-sigma.adapter.js';
import { discoverMetabaseDatabases, type DiscoveredMetabaseDatabase } from './context/ingest/adapters/metabase/mapping.js';
import { loadDbtSchemaFiles } from './context/ingest/dbt-shared/schema-files.js';
import { loadProjectInfo } from './context/ingest/dbt-shared/project-vars.js';
@ -46,7 +48,7 @@ import {
type KtxSetupPromptOption,
} from './setup-prompts.js';
export type KtxSetupSourceType = 'dbt' | 'metricflow' | 'metabase' | 'looker' | 'lookml' | 'notion' | 'gdrive';
export type KtxSetupSourceType = 'dbt' | 'metricflow' | 'metabase' | 'looker' | 'lookml' | 'notion' | 'sigma' | 'gdrive';
const DEFAULT_NOTION_MAX_KNOWLEDGE_CREATES_PER_RUN = 25;
@ -115,6 +117,7 @@ export interface KtxSetupSourcesDeps {
validateLooker?: (projectDir: string, connectionId: string) => Promise<SourceValidationResult>;
validateLookml?: (connection: KtxProjectConnectionConfig) => Promise<SourceValidationResult>;
validateNotion?: (connection: KtxProjectConnectionConfig) => Promise<SourceValidationResult>;
validateSigma?: (connection: KtxProjectConnectionConfig) => Promise<SourceValidationResult>;
validateGdrive?: (connection: KtxProjectConnectionConfig) => Promise<SourceValidationResult>;
pickNotionRootPages?: typeof pickNotionRootPages;
discoverMetabaseDatabases?: (args: {
@ -138,6 +141,7 @@ const SOURCE_OPTIONS: Array<{ value: KtxSetupSourceType; label: string }> = [
{ value: 'metricflow', label: 'MetricFlow' },
{ value: 'looker', label: 'Looker' },
{ value: 'lookml', label: 'LookML' },
{ value: 'sigma', label: 'Sigma Computing' },
{ value: 'gdrive', label: 'Google Drive' },
];
@ -248,6 +252,7 @@ const SOURCE_CREDENTIAL_FLAG: Record<KtxSetupSourceType, SourceCredentialFlag> =
notion: { field: 'sourceAuthTokenRef', flag: '--source-auth-token-ref' },
metabase: { field: 'sourceApiKeyRef', flag: '--source-api-key-ref' },
looker: { field: 'sourceClientSecretRef', flag: '--source-client-secret-ref' },
sigma: { field: 'sourceClientSecretRef', flag: '--source-client-secret-ref' },
gdrive: { field: null, flag: '--gdrive-service-account-key-ref' },
};
@ -577,6 +582,18 @@ function buildNotionConnection(args: KtxSetupSourcesArgs): KtxProjectConnectionC
};
}
function buildSigmaConnection(args: KtxSetupSourcesArgs): KtxProjectConnectionConfig {
if (!args.sourceClientId) {
throw new Error('Missing Sigma client id: pass --source-client-id.');
}
return {
driver: 'sigma',
api_url: args.sourceUrl ?? 'https://api.sigmacomputing.com',
client_id: args.sourceClientId,
client_secret_ref: credentialRef(args.sourceClientSecretRef, 'Sigma client secret ref'),
};
}
function buildGdriveConnection(args: KtxSetupSourcesArgs): KtxProjectConnectionConfig {
const folderId = args.gdriveFolderId?.trim();
if (!folderId) {
@ -713,6 +730,23 @@ async function defaultValidateNotion(connection: KtxProjectConnectionConfig): Pr
return { ok: true, detail: `roots=${roots.length}` };
}
async function defaultValidateSigma(connection: KtxProjectConnectionConfig): Promise<SourceValidationResult> {
try {
const runtimeConfig = sigmaRuntimeConfigFromLocalConnection('sigma-main', connection);
const client = new DefaultSigmaClient(runtimeConfig, DEFAULT_SIGMA_CLIENT_CONFIG);
try {
const result = await client.testConnection();
return result.success
? { ok: true, detail: 'Sigma API connection verified' }
: { ok: false, message: result.error ?? 'Sigma connection test failed' };
} finally {
await client.cleanup();
}
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : String(err) };
}
}
async function defaultValidateGdrive(connection: KtxProjectConnectionConfig): Promise<SourceValidationResult> {
const config = parseGdriveConnectionConfig(connection);
const keyText = await resolveGdriveServiceAccountKey(config.service_account_key_ref);
@ -1370,6 +1404,43 @@ async function promptForInteractiveSource(
]);
}
if (source === 'sigma') {
return await runSourcePromptSteps(initialState, () => [
...connectionSteps,
async (state) => {
const sourceUrl = await promptText(prompts, {
message: 'Sigma API URL',
initialValue: state.sourceUrl ?? 'https://api.sigmacomputing.com',
});
if (sourceUrl === undefined) return 'back';
state.sourceUrl = sourceUrl;
return 'next';
},
async (state) => {
const sourceClientId = await promptText(prompts, {
message: 'Sigma client ID',
...(state.sourceClientId ? { initialValue: state.sourceClientId } : {}),
});
if (sourceClientId === undefined) return 'back';
state.sourceClientId = sourceClientId;
return 'next';
},
async (state) => {
const ref = await chooseSourceCredentialRef({
prompts,
projectDir: args.projectDir,
label: 'Sigma client secret',
envName: 'SIGMA_CLIENT_SECRET',
secretFileName: `${state.sourceConnectionId ?? 'sigma-main'}-client-secret`,
existingRef: state.sourceClientSecretRef,
});
if (ref === 'back') return 'back';
state.sourceClientSecretRef = ref;
return 'next';
},
]);
}
if (source === 'notion') {
return await runSourcePromptSteps(initialState, (state) => [
...connectionSteps,
@ -1638,6 +1709,13 @@ function sourceArgsFromExistingConnection(input: {
return sourceArgs;
}
if (input.source === 'sigma') {
sourceArgs.sourceUrl = stringField(input.connection.api_url) ?? undefined;
sourceArgs.sourceClientId = stringField(input.connection.client_id) ?? undefined;
sourceArgs.sourceClientSecretRef = stringField(input.connection.client_secret_ref) ?? undefined;
return sourceArgs;
}
if (input.source === 'gdrive') {
sourceArgs.gdriveServiceAccountKeyRef = stringField(input.connection.service_account_key_ref);
sourceArgs.gdriveFolderId = stringField(input.connection.folder_id);
@ -1826,6 +1904,9 @@ function buildConnection(source: KtxSetupSourceType, args: KtxSetupSourcesArgs):
if (source === 'lookml') {
return buildLookmlConnection(args);
}
if (source === 'sigma') {
return buildSigmaConnection(args);
}
if (source === 'notion') {
return buildNotionConnection(args);
}
@ -1854,6 +1935,9 @@ async function validateSource(
if (source === 'lookml') {
return await (deps.validateLookml ?? defaultValidateLookml)(args.connection);
}
if (source === 'sigma') {
return await (deps.validateSigma ?? defaultValidateSigma)(args.connection);
}
if (source === 'notion') {
return await (deps.validateNotion ?? defaultValidateNotion)(args.connection);
}

View file

@ -0,0 +1,189 @@
---
name: sigma_ingest
description: Extract durable ktx wiki knowledge from staged Sigma data model specs and workbook summaries. Load for WorkUnits with unitKey sigma-data-models or sigma-workbooks.
callers: [memory_agent]
---
# Sigma Ingest
Sigma ingest turns staged data model specs and workbook summaries into durable ktx wiki knowledge. The deterministic `project()` step has already written semantic-layer YAML for all warehouse-table data model elements before this skill runs — do not re-write those SL sources.
## Work unit structure
Sigma produces at minimum two work units per ingest run:
- `sigma-data-models` or `sigma-data-models-N`
- `rawFiles`: `data-models/<id>.json` files (one per data model in this batch)
- `peerFileIndex`: `workbooks/<id>.json` files + `sigma-manifest.json` + `sigma-projection-config.json`
- When the workspace has more than 50 data models, split into batches: `sigma-data-models-0`, `sigma-data-models-1`, … with `displayLabel` like `"Sigma: data models (1/8)"`. When ≤50 data models, the unitKey is simply `sigma-data-models` with no suffix.
- `sigma-workbooks` or `sigma-workbooks-N`
- `rawFiles`: `workbooks/<id>.json` files (one per workbook in this batch)
- `peerFileIndex`: `data-models/<id>.json` files + `sigma-manifest.json` + `sigma-projection-config.json`
- When the workspace has more than 2000 workbooks, split into batches: `sigma-workbooks-0`, `sigma-workbooks-1`, … with `displayLabel` like `"Sigma: workbooks (1/4)"`. When ≤2000 workbooks, the unitKey is simply `sigma-workbooks` with no suffix.
`sigma-manifest.json` and `sigma-projection-config.json` are never in `rawFiles`. They live at the staged dir root and always appear in `peerFileIndex`.
## Staged file shapes
**`data-models/<id>.json`** — one per data model (in `rawFiles` for data-model units):
```json
{
"sigmaId": "abc-123",
"name": "Revenue Model",
"path": "Finance/Revenue Model",
"latestVersion": 3,
"updatedAt": "2026-01-15T00:00:00Z",
"isArchived": false,
"spec": {
"name": "Revenue Model",
"pages": [{
"id": "p1",
"name": "Main",
"elements": [{
"id": "elem1",
"kind": "table",
"name": "Opportunities",
"hidden": false,
"source": {
"kind": "warehouse-table",
"connectionId": "<sigma-internal-uuid>",
"path": ["DATABASE", "SCHEMA", "OPPORTUNITIES"]
},
"columns": [
{ "id": "c1", "name": "Deal Amount", "formula": "[OPPORTUNITIES/Amount]", "description": "Net contract value in USD" },
{ "id": "c2", "name": "Total ARR", "formula": "Sum([OPPORTUNITIES/ARR])", "description": "Annualised recurring revenue" }
]
}]
}]
}
}
```
`source.kind` discriminates:
- `warehouse-table` — element maps directly to a warehouse table. Has `connectionId` and `path` (array of path segments forming the fully-qualified table name). `project()` writes an SL source when `connectionMappings` covers this `connectionId`.
- `table` — element is a derived view layered on top of another element; identified by `source.elementId`. No warehouse path. Wiki-only.
**`workbooks/<id>.json`** — one per workbook, in `rawFiles` for workbook units (summary only; no spec endpoint exists):
```json
{
"sigmaId": "wb-abc",
"name": "ARR Tracker",
"path": "Finance/Dashboards",
"latestVersion": 2,
"updatedAt": "2026-01-16T00:00:00Z",
"isArchived": false,
"workbookUrlId": "57a96EMo3G...",
"description": "Tracks ARR by segment and cohort for the finance team"
}
```
**Peer files (available via `peerFileIndex`, not `rawFiles`):**
**`sigma-manifest.json`** — fetch summary; use for provenance only.
**`sigma-projection-config.json`** — written by `fetch()`, contains two fields the skill must read:
- `connectionMappings`: `{sigmaInternalUuid: ktxWarehouseConnectionId}`. Use the mapped warehouse connection ID for `entity_details` when verifying warehouse identifiers found in data model specs.
- `workbookFilter`: the filter settings that were active when workbooks were last fetched:
- `includeArchived` (default `false`) — when `false`, archived workbooks are not in `workbooks/`; `isArchived: true` files will only appear when this was `true`.
- `includeExplorations` (default `false`) — when `false`, exploration-type workbooks (unsaved analyses) are excluded; treat present workbooks as intentional, curated reports.
- `updatedSince` (optional ISO 8601 string) — when set, only workbooks updated on or after this date are staged; the set is a recent-changes slice, not the full workspace. Do not infer that absent workbooks were deleted.
`sigma-manifest.json` also reflects any active `dataModelFilter`. When `dataModelFilter.updatedSince` was set during fetch, `dataModelCount` reflects only matching models, not the full workspace. Do not infer that absent data models were deleted.
Read `sigma-projection-config.json` first and keep `workbookFilter` in scope while processing the WorkUnit.
## Required workflow
1. Read every `rawFiles` entry for the WorkUnit.
2. Read `sigma-projection-config.json` from the staged dir to get `connectionMappings`.
3. For each data model file: extract business semantics from element names, column descriptions, and the domain context of the model. Skip hidden elements and hidden columns.
4. For each workbook file: extract business domain knowledge from the name and description. When `workbookFilter.updatedSince` is set, treat the staged set as a recent-changes slice — absent workbooks were not deleted, they were simply outside the filter window.
5. Use `discover_data` before writing to find existing wiki pages on the same topic.
6. Write wiki candidates with `context_candidate_write`. Do not call `wiki_write` directly from a Sigma WorkUnit; Stage 4 reconciliation promotes candidates.
7. Do not write or edit SL sources. The `project()` step owns all SL output for Sigma.
## Identifier Verification Protocol
Before writing a wiki page or SL source on any topic:
1. `discover_data({query: "<topic>"})` - see what wikis, SL sources, and raw
tables already exist. Prefer updating existing pages over creating new ones.
Before emitting any `schema.table` or `schema.table.column` into a wiki body,
SL source, `tables:` frontmatter, `sl_refs`, or `emit_unmapped_fallback`:
2. `entity_details({connectionId, targets: [{display: "<identifier>"}]})` -
confirm the identifier resolves; inspect native types, FK/PK, and
sampleValues. Use the warehouse `connectionId` from `connectionMappings` in
`sigma-projection-config.json`, not the Sigma connection ID. If
`connectionMappings` has no entry for the element's `source.connectionId`,
skip `entity_details` — there is no mapped warehouse to verify against —
and wrap any identifier references with `[unverified - from <rawPath>]`.
3. For literal values from the source, such as status codes or plan tiers,
check whether they appear in `entity_details` sampleValues for the relevant
column. If sampleValues is short or the sample may have missed real values,
run a `sql_execution` probe with the same warehouse connection id:
`sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"})`.
4. If the candidate identifier still does not resolve, do one of:
- Use `sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"})`.
If it errors, the identifier is fictional.
- Wrap the identifier in `[unverified - from <rawPath>]` in the wiki body,
citing the exact raw path that mentioned it.
- When recording `emit_unmapped_fallback` with `no_physical_table`, include
the failing probe error in `clarification`.
5. Never copy `<schema>.<table>` placeholder strings from these instructions
into output.
## Data model elements
### Warehouse-table elements (`source.kind === "warehouse-table"`)
`project()` writes an SL source for a warehouse-table element **only when** the element's `source.connectionId` has an entry in `connectionMappings`. When no mapping exists, no SL source is written and the element is wiki-only.
To determine whether an SL source exists: check whether `connectionMappings[element.source.connectionId]` resolves. If it does, use `sl_discover` to find the source by its slugified name (`<dataModelName>_<elementName>`), then:
- Read the existing SL source with `sl_read_source` to understand what columns and measures are captured.
- Write a wiki candidate about the business domain if the element name, column descriptions, or data model description reveals durable knowledge not already in the wiki.
- `sl_refs` in the wiki candidate should point to the already-written SL source name.
If `connectionMappings` has no entry for the element's `source.connectionId`, treat the element as wiki-only — do not attempt `sl_discover` or `sl_read_source` for it, as no source was written.
### Joins within a data model
Joins are not projected in v1; `joins: []` is always written by `project()`. `Lookup()` formulas may be described in wiki prose instead.
### Non-warehouse elements (`source.kind === "table"`)
These reference another element by `elementId` — they are derived views layered on top of a warehouse-table element. They have no warehouse path of their own. Do not attempt SL writes for these elements. They may produce wiki candidates if their column names or descriptions reveal business semantics not captured by the underlying warehouse-table element.
## Workbooks
Workbooks have summary metadata only. There is no spec endpoint.
Extract business domain knowledge from:
- `name`: the workbook's primary topic (e.g. "ARR Tracker" → ARR tracking concepts)
- `description`: business context and intended audience
- `path`: team or functional area (e.g. `Finance/Dashboards`)
Write wiki candidates when the name or description reveals a reusable business concept, metric definition, or domain convention. Write one candidate per distinct concept, not one per workbook.
Skip workbooks whose name or description contains no durable business semantics (e.g. "Untitled Workbook", "Test Dashboard").
## Capture rules
Write wiki candidates for:
- Metric definitions mentioned in element names or column descriptions (e.g. "Net ARR", "Churned MRR")
- Domain conventions such as cohort definitions, segment taxonomies, or fiscal calendar rules
- Relationships between business entities revealed by data model joins
Skip:
- Visualization settings, layout, colors, chart types
- Owner names, folder paths, and version numbers as wiki narrative
- Hidden elements and hidden columns
- Data model names that are purely technical with no business meaning
- When `workbookFilter.includeExplorations` is `false` (the default), all staged workbooks are intentional reports — no extra exploration filter needed. When it is `true`, workbooks without a description or with a generic auto-generated name are likely ephemeral explorations; skip those.
## Usage signals
Sigma workbooks carry `latestVersion` but no usage counts. Treat a higher `latestVersion` as weak evidence of continued maintenance; do not include version numbers in wiki prose.

View file

@ -1,6 +1,6 @@
import { executeFederatedQuery } from './connectors/duckdb/federated-executor.js';
import { FEDERATED_CONNECTION_ID } from './context/connections/federation.js';
import { executeProjectReadOnlySql } from './context/connections/project-sql-executor.js';
import { executeProjectRawSql } from './context/connections/project-sql-executor.js';
import type { KtxSqlQueryExecutionResult } from './context/connections/query-executor.js';
import { assertSqlQueryableConnection } from './context/connections/dialects.js';
import { resolveConfiguredConnection } from './context/connections/resolve-connection.js';
@ -137,6 +137,8 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps:
const connection = isFederated ? undefined : resolveConfiguredConnection(project.config, args.connectionId);
driver = isFederated ? 'duckdb' : String(connection?.driver ?? 'unknown').toLowerCase();
demoConnection = isFederated ? false : isDemoConnection(args.connectionId, connection);
// Fail fast before creating the SQL-analysis daemon port; executeProjectRawSql
// re-asserts this for every caller.
if (!isFederated) {
assertSqlQueryableConnection(args.connectionId, connection?.driver);
}
@ -152,26 +154,19 @@ export async function runKtxSql(args: KtxSqlArgs, io: KtxCliIo = process, deps:
}));
const analysisPort = createSqlAnalysis();
const dialect: SqlAnalysisDialect = isFederated ? 'duckdb' : sqlAnalysisDialectForDriver(connection?.driver);
const validation = await analysisPort.validateReadOnly(args.sql, dialect);
if (!validation.ok) {
throw new Error(validation.error ?? 'SQL is not read-only.');
}
const referencedTableCount = await safeReferencedTableCount(analysisPort, args.sql, dialect);
const createScanConnector = deps.createScanConnector ?? createKtxCliScanConnector;
const result = await executeProjectReadOnlySql({
const result = await executeProjectRawSql({
project,
input: {
connectionId: args.connectionId,
projectDir: args.projectDir,
connection,
sql: args.sql,
maxRows: args.maxRows,
},
connectionId: args.connectionId,
sql: args.sql,
maxRows: args.maxRows,
sqlAnalysis: analysisPort,
createConnector: (connectionId) => createScanConnector(project!, connectionId),
executeFederated: deps.executeFederated,
runId: 'cli-sql',
});
const referencedTableCount = await safeReferencedTableCount(analysisPort, args.sql, dialect);
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
printSqlResult(resultOutput(args.connectionId, result), mode, io);
await emitTelemetryEvent({

View file

@ -412,6 +412,7 @@ function buildConnectionStatus(
const hint = envHint((conn as Record<string, unknown>).credentials_json);
return warn(hint ? `credentials missing (env: ${hint})` : 'credentials not set', hint ? `Set ${hint}` : 'Rerun `ktx setup`');
}
case 'duckdb':
case 'sqlite': {
const path = (conn as Record<string, unknown>).path;
if (typeof path === 'string' && path.length > 0) return ok(`path: ${path}`);
@ -553,7 +554,7 @@ async function buildQueryHistoryStatus(
}
const ADAPTER_DRIVER_REQUIREMENT: Record<string, string[]> = {
'live-database': ['postgres', 'mysql', 'snowflake', 'bigquery', 'clickhouse', 'sqlite', 'sqlserver'],
'live-database': ['postgres', 'mysql', 'snowflake', 'bigquery', 'clickhouse', 'sqlite', 'duckdb', 'sqlserver'],
dbt: ['dbt', 'dbt-core', 'dbt-cloud'],
notion: ['notion'],
metabase: ['metabase'],

View file

@ -688,7 +688,7 @@ describe('runKtxConnection', () => {
await initKtxProject({ projectDir });
await writeFile(
join(projectDir, 'ktx.yaml'),
'connections:\n mystery:\n driver: duckdb\n',
'connections:\n mystery:\n driver: nonsense\n',
'utf-8',
);
const io = makeIo();

View file

@ -0,0 +1,630 @@
import { describe, expect, it, vi } from 'vitest';
import {
athenaConnectionConfigFromConfig,
isKtxAthenaConnectionConfig,
KtxAthenaScanConnector,
type KtxAthenaClientFactory,
type KtxAthenaClient,
type KtxGlueClient,
} from '../../../src/connectors/athena/connector.js';
import { createAthenaLiveDatabaseIntrospection } from '../../../src/connectors/athena/live-database-introspection.js';
import { tableRefSet } from '../../../src/context/scan/table-ref.js';
function fakeClientFactory(options: { queryState?: string; queryError?: string } = {}): KtxAthenaClientFactory {
const state = options.queryState ?? 'SUCCEEDED';
const queries = new Map<string, string>();
let execCounter = 0;
const fakeAthenaClient: KtxAthenaClient = {
startQueryExecution: vi.fn(async (input) => {
const id = `exec-${++execCounter}`;
queries.set(id, input.QueryString);
return { QueryExecutionId: id };
}),
getQueryExecution: vi.fn(async () => ({
QueryExecution: {
Status: {
State: state,
StateChangeReason: options.queryError,
},
},
})),
getQueryResults: vi.fn(async (input) => {
const sql = queries.get(input.QueryExecutionId) ?? '';
// Column sample query: single-column result for the queried column only.
if (sql.includes('IS NOT NULL')) {
return {
ResultSet: {
ResultSetMetadata: { ColumnInfo: [{ Name: 'status', Type: 'string' }] },
Rows: [
{ Data: [{ VarCharValue: 'status' }] }, // header row
{ Data: [{ VarCharValue: 'paid' }] },
],
},
NextToken: undefined,
};
}
return {
ResultSet: {
ResultSetMetadata: {
ColumnInfo: [
{ Name: 'id', Type: 'bigint' },
{ Name: 'status', Type: 'string' },
],
},
Rows: [
// Header row (Athena always includes it on first page)
{ Data: [{ VarCharValue: 'id' }, { VarCharValue: 'status' }] },
// Data row
{ Data: [{ VarCharValue: '1' }, { VarCharValue: 'paid' }] },
],
},
NextToken: undefined,
};
}),
};
const fakeGlueClient: KtxGlueClient = {
getDatabases: vi.fn(async () => ({
DatabaseList: [{ Name: 'analytics' }],
NextToken: undefined,
})),
getTables: vi.fn(async () => ({
TableList: [
{
Name: 'orders',
TableType: 'EXTERNAL_TABLE',
Description: 'Orders table',
StorageDescriptor: {
Columns: [
{ Name: 'id', Type: 'bigint', Comment: 'Order id' },
{ Name: 'status', Type: 'string' },
],
},
PartitionKeys: [{ Name: 'dt', Type: 'date', Comment: 'Partition date' }],
},
],
NextToken: undefined,
})),
};
return {
createAthenaClient: vi.fn(() => fakeAthenaClient),
createGlueClient: vi.fn(() => fakeGlueClient),
};
}
const connection = {
driver: 'athena',
region: 'us-east-1',
s3_staging_dir: 's3://my-bucket/athena-results/',
workgroup: 'analytics',
catalog: 'AwsDataCatalog',
database: 'analytics',
} as const;
describe('KtxAthenaScanConnector', () => {
it('identifies athena connection configs correctly', () => {
expect(isKtxAthenaConnectionConfig(connection)).toBe(true);
expect(isKtxAthenaConnectionConfig({ driver: 'bigquery' })).toBe(false);
expect(isKtxAthenaConnectionConfig(null)).toBe(false);
expect(isKtxAthenaConnectionConfig(undefined)).toBe(false);
});
it('resolves configuration and throws on missing required fields', () => {
expect(athenaConnectionConfigFromConfig({ connectionId: 'dw', connection })).toMatchObject({
region: 'us-east-1',
s3StagingDir: 's3://my-bucket/athena-results/',
workgroup: 'analytics',
catalog: 'AwsDataCatalog',
database: 'analytics',
});
expect(() =>
athenaConnectionConfigFromConfig({ connectionId: 'dw', connection: { driver: 'athena' } }),
).toThrow('connections.dw.region');
expect(() =>
athenaConnectionConfigFromConfig({
connectionId: 'dw',
connection: { driver: 'athena', region: 'us-east-1' },
}),
).toThrow('connections.dw.s3_staging_dir');
});
it('applies defaults for optional config fields', () => {
const resolved = athenaConnectionConfigFromConfig({
connectionId: 'dw',
connection: { driver: 'athena', region: 'us-east-1', s3_staging_dir: 's3://bucket/' },
});
expect(resolved.workgroup).toBe('primary');
expect(resolved.catalog).toBe('AwsDataCatalog');
expect(resolved.database).toBeUndefined();
});
it('introspects databases, tables, and columns from Glue', async () => {
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: fakeClientFactory(),
now: () => new Date('2026-06-21T10:00:00.000Z'),
});
const snapshot = await connector.introspect(
{ connectionId: 'dw', driver: 'athena' },
{ runId: 'scan-1' },
);
expect(snapshot).toMatchObject({
connectionId: 'dw',
driver: 'athena',
extractedAt: '2026-06-21T10:00:00.000Z',
scope: { catalogs: ['AwsDataCatalog'], datasets: ['analytics'] },
metadata: {
catalog: 'AwsDataCatalog',
databases: ['analytics'],
table_count: 1,
total_columns: 3,
},
});
expect(snapshot.tables[0]).toMatchObject({
catalog: 'AwsDataCatalog',
db: 'analytics',
name: 'orders',
kind: 'table',
comment: 'Orders table',
estimatedRows: null,
foreignKeys: [],
});
expect(snapshot.tables[0]?.columns).toEqual([
{
name: 'id',
nativeType: 'bigint',
normalizedType: 'BIGINT',
dimensionType: 'number',
nullable: true,
primaryKey: false,
comment: 'Order id',
},
{
name: 'status',
nativeType: 'string',
normalizedType: 'VARCHAR',
dimensionType: 'string',
nullable: true,
primaryKey: false,
comment: null,
},
{
name: 'dt',
nativeType: 'date',
normalizedType: 'DATE',
dimensionType: 'time',
nullable: true,
primaryKey: false,
comment: 'Partition date',
},
]);
});
it('respects tableScope and excludes tables not in scope', async () => {
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: fakeClientFactory(),
now: () => new Date('2026-06-21T10:00:00.000Z'),
});
const scopedSnapshot = await connector.introspect(
{
connectionId: 'dw',
driver: 'athena',
tableScope: tableRefSet([{ catalog: 'AwsDataCatalog', db: 'analytics', name: 'nonexistent' }]),
},
{ runId: 'scan-1' },
);
expect(scopedSnapshot.tables).toHaveLength(0);
const matchingSnapshot = await connector.introspect(
{
connectionId: 'dw',
driver: 'athena',
tableScope: tableRefSet([{ catalog: 'AwsDataCatalog', db: 'analytics', name: 'orders' }]),
},
{ runId: 'scan-1' },
);
expect(matchingSnapshot.tables).toHaveLength(1);
expect(matchingSnapshot.tables[0]?.name).toBe('orders');
});
it('limits introspection to the configured databases scope', async () => {
const requestedDatabases: string[] = [];
const getDatabases = vi.fn(async () => ({
DatabaseList: [{ Name: 'analytics' }, { Name: 'raw' }, { Name: 'staging' }],
NextToken: undefined,
}));
const glueClient: KtxGlueClient = {
getDatabases,
getTables: vi.fn(async (input) => {
requestedDatabases.push(input.DatabaseName);
return {
TableList: [
{
Name: `${input.DatabaseName}_orders`,
TableType: 'EXTERNAL_TABLE',
StorageDescriptor: { Columns: [{ Name: 'id', Type: 'bigint' }] },
},
],
NextToken: undefined,
};
}),
};
const clientFactory: KtxAthenaClientFactory = {
createAthenaClient: vi.fn(() => fakeClientFactory().createAthenaClient('us-east-1')),
createGlueClient: vi.fn(() => glueClient),
};
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection: { ...connection, databases: ['analytics', 'raw'] },
clientFactory,
now: () => new Date('2026-06-21T10:00:00.000Z'),
});
const snapshot = await connector.introspect({ connectionId: 'dw', driver: 'athena' }, { runId: 'scan-1' });
// Scope is taken from config, so the account-wide database list is never enumerated.
expect(getDatabases).not.toHaveBeenCalled();
expect(requestedDatabases).toEqual(['analytics', 'raw']);
expect(snapshot.scope).toMatchObject({ datasets: ['analytics', 'raw'] });
expect(snapshot.tables.map((t) => t.db)).toEqual(['analytics', 'raw']);
});
it('resolves optional env-referenced config to defaults when the variable is unset', () => {
const resolved = athenaConnectionConfigFromConfig({
connectionId: 'dw',
connection: {
driver: 'athena',
region: 'us-east-1',
s3_staging_dir: 's3://bucket/',
workgroup: 'env:ATHENA_WORKGROUP_UNSET',
catalog: 'env:GLUE_CATALOG_UNSET',
},
env: {},
});
expect(resolved.workgroup).toBe('primary');
expect(resolved.catalog).toBe('AwsDataCatalog');
});
it('samples a table via Athena query execution', async () => {
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: fakeClientFactory(),
});
const result = await connector.sampleTable(
{
connectionId: 'dw',
table: { catalog: 'AwsDataCatalog', db: 'analytics', name: 'orders' },
columns: ['id', 'status'],
limit: 10,
},
{ runId: 'scan-1' },
);
expect(result).toMatchObject({
headers: ['id', 'status'],
rows: [['1', 'paid']],
totalRows: 1,
});
});
it('samples a column via Athena query execution', async () => {
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: fakeClientFactory(),
});
const result = await connector.sampleColumn(
{
connectionId: 'dw',
table: { catalog: 'AwsDataCatalog', db: 'analytics', name: 'orders' },
column: 'status',
limit: 10,
},
{ runId: 'scan-1' },
);
expect(result).toMatchObject({
values: ['paid'],
nullCount: null,
distinctCount: null,
});
});
it('executes read-only SQL and rejects write statements', async () => {
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: fakeClientFactory(),
});
await expect(
connector.executeReadOnly(
{ connectionId: 'dw', sql: 'SELECT id, status FROM "analytics"."orders"', maxRows: 100 },
{ runId: 'scan-1' },
),
).resolves.toMatchObject({
headers: ['id', 'status'],
rows: [['1', 'paid']],
rowCount: 1,
});
await expect(
connector.executeReadOnly({ connectionId: 'dw', sql: 'DELETE FROM orders' }, { runId: 'scan-1' }),
).rejects.toThrow('Only read-only SELECT/WITH queries can be executed locally');
});
it('lists schemas (databases) from Glue', async () => {
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: fakeClientFactory(),
});
await expect(connector.listSchemas()).resolves.toEqual(['analytics']);
});
it('lists tables from Glue', async () => {
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: fakeClientFactory(),
});
await expect(connector.listTables(['analytics'])).resolves.toEqual([
{
catalog: 'AwsDataCatalog',
schema: 'analytics',
name: 'orders',
kind: 'table',
},
]);
});
it('returns null for columnStats', async () => {
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: fakeClientFactory(),
});
await expect(
connector.columnStats(
{ connectionId: 'dw', table: { catalog: 'AwsDataCatalog', db: 'analytics', name: 'orders' }, column: 'status' },
{ runId: 'scan-1' },
),
).resolves.toBeNull();
});
it('tests connection successfully', async () => {
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: fakeClientFactory(),
});
await expect(connector.testConnection()).resolves.toMatchObject({ success: true });
});
it('returns failure result when testConnection throws', async () => {
const factory = fakeClientFactory();
const glueClient = factory.createGlueClient('us-east-1');
vi.mocked(glueClient.getDatabases).mockRejectedValue(new Error('Access denied'));
const brokenFactory: KtxAthenaClientFactory = {
createAthenaClient: factory.createAthenaClient,
createGlueClient: vi.fn(() => glueClient),
};
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: brokenFactory,
});
await expect(connector.testConnection()).resolves.toMatchObject({
success: false,
error: 'Access denied',
});
});
it('cleans up without throwing', async () => {
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: fakeClientFactory(),
});
await connector.listSchemas();
await expect(connector.cleanup()).resolves.toBeUndefined();
});
it('throws when query execution fails', async () => {
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: fakeClientFactory({ queryState: 'FAILED', queryError: 'Syntax error in SQL' }),
});
await expect(
connector.executeReadOnly({ connectionId: 'dw', sql: 'SELECT 1' }, { runId: 'scan-1' }),
).rejects.toThrow('Athena query FAILED: Syntax error in SQL');
});
it('throws when query execution times out', async () => {
let callCount = 0;
// First now() call sets the deadline; second call simulates time past it.
const now = () => (++callCount === 1 ? new Date(0) : new Date(5 * 60 * 1000 + 1));
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: fakeClientFactory({ queryState: 'RUNNING' }),
now,
});
await expect(
connector.executeReadOnly({ connectionId: 'dw', sql: 'SELECT 1' }, { runId: 'scan-1' }),
).rejects.toThrow('timed out after 300s');
});
it('passes the exact column list to Athena when sampling specific columns', async () => {
const factory = fakeClientFactory();
const athenaClient = factory.createAthenaClient('us-east-1');
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: { createAthenaClient: vi.fn(() => athenaClient), createGlueClient: factory.createGlueClient },
});
await connector.sampleTable(
{
connectionId: 'dw',
table: { catalog: 'AwsDataCatalog', db: 'analytics', name: 'orders' },
columns: ['id', 'status'],
limit: 5,
},
{ runId: 'scan-1' },
);
expect(vi.mocked(athenaClient.startQueryExecution).mock.calls[0]?.[0].QueryString).toBe(
'SELECT "id", "status" FROM "AwsDataCatalog"."analytics"."orders" LIMIT 5',
);
});
it('paginates Glue databases and tables across multiple pages', async () => {
const glueClient: KtxGlueClient = {
getDatabases: vi.fn()
.mockResolvedValueOnce({ DatabaseList: [{ Name: 'db1' }], NextToken: 'page2' })
.mockResolvedValueOnce({ DatabaseList: [{ Name: 'db2' }], NextToken: undefined }),
getTables: vi.fn().mockImplementation(async ({ DatabaseName }: { DatabaseName: string }) => {
if (DatabaseName === 'db1') {
return {
TableList: [
{
Name: 'table_a',
TableType: 'EXTERNAL_TABLE',
StorageDescriptor: { Columns: [{ Name: 'id', Type: 'bigint' }] },
},
],
NextToken: undefined,
};
}
return {
TableList: [
{
Name: 'table_b',
TableType: 'EXTERNAL_TABLE',
StorageDescriptor: { Columns: [{ Name: 'id', Type: 'bigint' }] },
},
],
NextToken: undefined,
};
}),
};
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: {
createAthenaClient: vi.fn(() => fakeClientFactory().createAthenaClient('us-east-1')),
createGlueClient: vi.fn(() => glueClient),
},
now: () => new Date('2026-06-21T10:00:00.000Z'),
});
const snapshot = await connector.introspect({ connectionId: 'dw', driver: 'athena' }, { runId: 'scan-1' });
expect(vi.mocked(glueClient.getDatabases)).toHaveBeenCalledTimes(2);
expect(snapshot.metadata).toMatchObject({ databases: ['db1', 'db2'], table_count: 2 });
expect(snapshot.tables.map((t) => t.name)).toEqual(['table_a', 'table_b']);
});
it('paginates Athena query results across multiple pages', async () => {
const factory = fakeClientFactory();
const athenaClient = factory.createAthenaClient('us-east-1');
vi.mocked(athenaClient.getQueryResults)
.mockResolvedValueOnce({
ResultSet: {
ResultSetMetadata: {
ColumnInfo: [
{ Name: 'id', Type: 'bigint' },
{ Name: 'status', Type: 'string' },
],
},
Rows: [
// Header row — only present on the first page
{ Data: [{ VarCharValue: 'id' }, { VarCharValue: 'status' }] },
{ Data: [{ VarCharValue: '1' }, { VarCharValue: 'paid' }] },
{ Data: [{ VarCharValue: '2' }, { VarCharValue: 'shipped' }] },
],
},
NextToken: 'page-2',
})
.mockResolvedValueOnce({
ResultSet: {
ResultSetMetadata: { ColumnInfo: [] },
// No header row on subsequent pages
Rows: [{ Data: [{ VarCharValue: '3' }, { VarCharValue: 'pending' }] }],
},
NextToken: undefined,
});
const connector = new KtxAthenaScanConnector({
connectionId: 'dw',
connection,
clientFactory: { createAthenaClient: vi.fn(() => athenaClient), createGlueClient: factory.createGlueClient },
});
const result = await connector.executeReadOnly(
{ connectionId: 'dw', sql: 'SELECT id, status FROM "analytics"."orders"', maxRows: 100 },
{ runId: 'scan-1' },
);
expect(result.headers).toEqual(['id', 'status']);
expect(result.rows).toEqual([
['1', 'paid'],
['2', 'shipped'],
['3', 'pending'],
]);
expect(result.rowCount).toBe(3);
expect(vi.mocked(athenaClient.getQueryResults)).toHaveBeenCalledTimes(2);
expect(vi.mocked(athenaClient.getQueryResults).mock.calls[1]?.[0].NextToken).toBe('page-2');
});
it('adapts to the live-database introspection port via factory', async () => {
const introspection = createAthenaLiveDatabaseIntrospection({
connections: { dw: connection },
clientFactory: fakeClientFactory(),
now: () => new Date('2026-06-21T10:00:00.000Z'),
});
await expect(introspection.extractSchema('dw')).resolves.toMatchObject({
connectionId: 'dw',
driver: 'athena',
metadata: { catalog: 'AwsDataCatalog' },
tables: expect.arrayContaining([
expect.objectContaining({
db: 'analytics',
name: 'orders',
columns: expect.arrayContaining([
expect.objectContaining({ name: 'id', dimensionType: 'number' }),
]),
}),
]),
});
});
});

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { KtxAthenaDialect } from '../../../src/connectors/athena/dialect.js';
describe('KtxAthenaDialect', () => {
const dialect = new KtxAthenaDialect();
it('quotes identifiers and formats catalog.database.table names', () => {
expect(dialect.quoteIdentifier('my"col')).toBe('"my""col"');
expect(dialect.formatTableName({ catalog: 'AwsDataCatalog', db: 'analytics', name: 'orders' })).toBe(
'"AwsDataCatalog"."analytics"."orders"',
);
expect(dialect.formatTableName({ db: 'analytics', name: 'orders' })).toBe('"analytics"."orders"');
expect(dialect.formatTableName({ name: 'orders' })).toBe('"orders"');
});
it('maps native Athena/Glue types to normalized types and dimension types', () => {
expect(dialect.mapDataType('bigint')).toBe('BIGINT');
expect(dialect.mapDataType('string')).toBe('VARCHAR');
expect(dialect.mapDataType('array<string>')).toBe('ARRAY');
expect(dialect.mapDataType('map<string,bigint>')).toBe('MAP');
expect(dialect.mapDataType('struct<id:bigint>')).toBe('STRUCT');
expect(dialect.mapDataType('decimal(18,2)')).toBe('DECIMAL');
expect(dialect.mapDataType('UNKNOWN_TYPE')).toBe('UNKNOWN_TYPE');
expect(dialect.mapToDimensionType('timestamp')).toBe('time');
expect(dialect.mapToDimensionType('date')).toBe('time');
expect(dialect.mapToDimensionType('bigint')).toBe('number');
expect(dialect.mapToDimensionType('double')).toBe('number');
expect(dialect.mapToDimensionType('decimal(10,2)')).toBe('number');
expect(dialect.mapToDimensionType('boolean')).toBe('boolean');
expect(dialect.mapToDimensionType('string')).toBe('string');
expect(dialect.mapToDimensionType('varchar')).toBe('string');
});
it('generates correct sample and column-sample SQL', () => {
expect(dialect.generateSampleQuery('"analytics"."orders"', 10, ['id', 'status'])).toBe(
'SELECT "id", "status" FROM "analytics"."orders" LIMIT 10',
);
expect(dialect.generateSampleQuery('"analytics"."orders"', 5)).toBe(
'SELECT * FROM "analytics"."orders" LIMIT 5',
);
expect(dialect.generateColumnSampleQuery('"analytics"."orders"', 'status', 20)).toBe(
'SELECT "status" FROM "analytics"."orders" WHERE "status" IS NOT NULL LIMIT 20',
);
});
it('generates Presto-style cardinality and distinct-values SQL', () => {
expect(dialect.generateCardinalitySampleQuery('"t"', '"col"', 1000)).toContain('approx_distinct');
expect(dialect.generateRandomizedCardinalitySampleQuery('"t"', '"col"', 500)).toContain('rand()');
expect(dialect.generateDistinctValuesQuery('"t"', '"col"', 50)).toContain(
'SELECT DISTINCT CAST("col" AS VARCHAR) AS val',
);
});
it('returns null for column statistics (unsupported)', () => {
expect(dialect.generateColumnStatisticsQuery('analytics', 'orders')).toBeNull();
});
it('produces Trino-correct OFFSET-before-LIMIT ordering', () => {
expect(dialect.getLimitOffsetClause(10)).toBe('LIMIT 10');
expect(dialect.getLimitOffsetClause(10, 0)).toBe('LIMIT 10');
expect(dialect.getLimitOffsetClause(10, 20)).toBe('OFFSET 20 LIMIT 10');
});
it('uses unit-separator (U+001F) as the array_join delimiter', () => {
const sql = dialect.getSampleValueAggregation('SELECT value FROM t');
const separatorIndex =
sql.indexOf("array_join(array_agg(CAST(value AS VARCHAR)), '") +
"array_join(array_agg(CAST(value AS VARCHAR)), '".length;
expect(sql.charCodeAt(separatorIndex)).toBe(0x1f);
});
});

View file

@ -0,0 +1,280 @@
import { DuckDBInstance } from '@duckdb/node-api';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
KtxDuckDbScanConnector,
duckDbDatabasePathFromConfig,
isKtxDuckDbConnectionConfig,
} from '../../../src/connectors/duckdb/connector.js';
import { tableRefSet } from '../../../src/context/scan/table-ref.js';
let dir: string;
let dbPath: string;
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), 'ktx-duckdb-'));
dbPath = join(dir, 'warehouse.duckdb');
const instance = await DuckDBInstance.create(dbPath);
const connection = await instance.connect();
await connection.run('CREATE TABLE customers (id BIGINT PRIMARY KEY, name VARCHAR, big BIGINT)');
await connection.run(
`INSERT INTO customers VALUES (1, 'Ada', 9223372036854775807), (2, 'Lin', 10)`,
);
await connection.run('CREATE TABLE orders (id BIGINT, customer_id BIGINT REFERENCES customers(id))');
await connection.run('INSERT INTO orders VALUES (1, 1), (2, 2)');
// Composite primary key + composite foreign key, to exercise the parallel
// unnest() zip of constraint/referenced column names in readForeignKeys.
await connection.run('CREATE TABLE regions (country VARCHAR, code VARCHAR, PRIMARY KEY (country, code))');
await connection.run(
'CREATE TABLE stores (id BIGINT, country VARCHAR, code VARCHAR, FOREIGN KEY (country, code) REFERENCES regions(country, code))',
);
await connection.run('CREATE TABLE empty_table (id BIGINT)');
connection.closeSync();
instance.closeSync();
});
afterAll(async () => {
await rm(dir, { recursive: true, force: true });
});
function connector(connection: Record<string, unknown> = { driver: 'duckdb', path: dbPath }) {
return new KtxDuckDbScanConnector({ connectionId: 'warehouse', connection, projectDir: dir });
}
describe('isKtxDuckDbConnectionConfig', () => {
it('accepts duckdb driver, rejects others', () => {
expect(isKtxDuckDbConnectionConfig({ driver: 'duckdb' })).toBe(true);
expect(isKtxDuckDbConnectionConfig({ driver: 'sqlite' })).toBe(false);
});
});
describe('duckDbDatabasePathFromConfig', () => {
it('resolves a relative path against projectDir', () => {
const resolved = duckDbDatabasePathFromConfig({
connectionId: 'warehouse',
projectDir: dir,
connection: { driver: 'duckdb', path: 'warehouse.duckdb' },
});
expect(resolved).toBe(dbPath);
});
it('derives the path from a file: url', () => {
const resolved = duckDbDatabasePathFromConfig({
connectionId: 'warehouse',
connection: { driver: 'duckdb', url: pathToFileURL(dbPath).href },
});
expect(resolved).toBe(dbPath);
});
it('derives the path from a duckdb: url', () => {
const resolved = duckDbDatabasePathFromConfig({
connectionId: 'warehouse',
connection: { driver: 'duckdb', url: `duckdb://${dbPath}` },
});
expect(resolved).toBe(dbPath);
});
it('resolves an env: reference in path', () => {
process.env.KTX_TEST_DUCKDB_PATH = dbPath;
try {
const resolved = duckDbDatabasePathFromConfig({
connectionId: 'warehouse',
connection: { driver: 'duckdb', path: 'env:KTX_TEST_DUCKDB_PATH' },
});
expect(resolved).toBe(dbPath);
} finally {
delete process.env.KTX_TEST_DUCKDB_PATH;
}
});
it('rejects a non-duckdb driver', () => {
expect(() =>
duckDbDatabasePathFromConfig({
connectionId: 'warehouse',
connection: { driver: 'sqlite', path: dbPath },
}),
).toThrow(/cannot run driver "sqlite"/);
});
it('requires a path or url', () => {
expect(() =>
duckDbDatabasePathFromConfig({
connectionId: 'warehouse',
connection: { driver: 'duckdb' },
}),
).toThrow(/requires connections\.warehouse\.path or url/);
});
});
describe('KtxDuckDbScanConnector', () => {
it('testConnection succeeds for an existing file', async () => {
const c = connector();
expect(await c.testConnection()).toEqual({ success: true });
await c.cleanup();
});
it('testConnection fails (never creating) for a missing file', async () => {
const c = connector({ driver: 'duckdb', path: join(dir, 'absent.duckdb') });
const result = await c.testConnection();
expect(result.success).toBe(false);
await c.cleanup();
});
it('introspects main-schema tables, columns, and foreign keys', async () => {
const c = connector();
const snapshot = await c.introspect({ connectionId: 'warehouse', driver: 'duckdb' }, { runId: 't' });
const names = snapshot.tables.map((t) => t.name).sort();
expect(names).toEqual(['customers', 'empty_table', 'orders', 'regions', 'stores']);
const orders = snapshot.tables.find((t) => t.name === 'orders');
expect(orders?.foreignKeys[0]).toMatchObject({ fromColumn: 'customer_id', toTable: 'customers', toColumn: 'id' });
await c.cleanup();
});
it('maps a composite foreign key column-for-column to the referenced table', async () => {
const c = connector();
const snapshot = await c.introspect({ connectionId: 'warehouse', driver: 'duckdb' }, { runId: 't' });
const stores = snapshot.tables.find((t) => t.name === 'stores');
const fks = stores?.foreignKeys.map((fk) => ({ fromColumn: fk.fromColumn, toTable: fk.toTable, toColumn: fk.toColumn }));
expect(fks).toEqual([
{ fromColumn: 'country', toTable: 'regions', toColumn: 'country' },
{ fromColumn: 'code', toTable: 'regions', toColumn: 'code' },
]);
await c.cleanup();
});
it('lists tables', async () => {
const c = connector();
const tables = (await c.listTables()).map((t) => t.name).sort();
expect(tables).toEqual(['customers', 'empty_table', 'orders', 'regions', 'stores']);
await c.cleanup();
});
it('samples a table', async () => {
const c = connector();
const sample = await c.sampleTable(
{ connectionId: 'warehouse', table: { name: 'customers', catalog: null, db: null }, limit: 1 },
{ runId: 't' },
);
expect(sample.rows.length).toBe(1);
await c.cleanup();
});
it('stringifies BIGINT beyond 2^53 in read-only results', async () => {
const c = connector();
const result = await c.executeReadOnly(
{ connectionId: 'warehouse', sql: 'SELECT big FROM customers WHERE id = 1', maxRows: 10 },
{ runId: 't' },
);
expect(result.rows[0][0]).toBe('9223372036854775807');
await c.cleanup();
});
it('rejects non-read-only SQL', async () => {
const c = connector();
await expect(
c.executeReadOnly({ connectionId: 'warehouse', sql: 'DELETE FROM customers', maxRows: 10 }, { runId: 't' }),
).rejects.toThrow();
await c.cleanup();
});
it('returns distinct values under the cardinality cap', async () => {
const c = connector();
const distinct = await c.getColumnDistinctValues({ name: 'customers', catalog: null, db: null }, 'name', {
maxCardinality: 10,
limit: 10,
});
expect(distinct?.values?.sort()).toEqual(['Ada', 'Lin']);
await c.cleanup();
});
it('withholds values but reports the count when cardinality exceeds the cap', async () => {
const c = connector();
const distinct = await c.getColumnDistinctValues({ name: 'customers', catalog: null, db: null }, 'name', {
maxCardinality: 1,
limit: 10,
});
expect(distinct).toEqual({ values: null, cardinality: 2 });
await c.cleanup();
});
it('samples a single column, dropping null rows', async () => {
const c = connector();
const sample = await c.sampleColumn(
{ connectionId: 'warehouse', table: { name: 'customers', catalog: null, db: null }, column: 'name', limit: 10 },
{ runId: 't' },
);
expect(sample.values.sort()).toEqual(['Ada', 'Lin']);
expect(sample.nullCount).toBeNull();
await c.cleanup();
});
it('counts table rows', async () => {
const c = connector();
expect(await c.getTableRowCount('customers')).toBe(2);
await c.cleanup();
});
it('lists only the main schema and reports no column stats', async () => {
const c = connector();
expect(await c.listSchemas()).toEqual(['main']);
expect(await c.columnStats({ connectionId: 'warehouse', table: { name: 'customers', catalog: null, db: null }, column: 'id' }, { runId: 't' })).toBeNull();
await c.cleanup();
});
it('rejects operations for a mismatched connection id', async () => {
const c = connector();
await expect(
c.executeReadOnly({ connectionId: 'other', sql: 'SELECT 1', maxRows: 1 }, { runId: 't' }),
).rejects.toThrow(/cannot serve connection other/);
await c.cleanup();
});
it('exposes the dialect identifier quoting', () => {
expect(connector().quoteIdentifier('a"b')).toBe('"a""b"');
});
// Opening a connection must never create the file: the db() guard throws
// rather than letting DuckDBInstance.create() materialize a missing path.
it('refuses to open (never creating) a missing file when a query runs', async () => {
const c = connector({ driver: 'duckdb', path: join(dir, 'absent.duckdb') });
await expect(c.listTables()).rejects.toThrow(/File not found/);
await c.cleanup();
});
it('returns no tables for an empty table scope', async () => {
const c = connector();
const snapshot = await c.introspect(
{ connectionId: 'warehouse', driver: 'duckdb', tableScope: new Set() },
{ runId: 't' },
);
expect(snapshot.tables).toEqual([]);
await c.cleanup();
});
it('restricts introspection to the named tables in a non-empty scope', async () => {
const c = connector();
const snapshot = await c.introspect(
{
connectionId: 'warehouse',
driver: 'duckdb',
tableScope: tableRefSet([{ catalog: null, db: null, name: 'customers' }]),
},
{ runId: 't' },
);
expect(snapshot.tables.map((t) => t.name)).toEqual(['customers']);
await c.cleanup();
});
it('reports zero cardinality and an empty value list for an empty column', async () => {
const c = connector();
const distinct = await c.getColumnDistinctValues({ name: 'empty_table', catalog: null, db: null }, 'id', {
maxCardinality: 10,
limit: 10,
});
expect(distinct).toEqual({ values: [], cardinality: 0 });
await c.cleanup();
});
});

View file

@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import { KtxDuckDbDialect } from '../../../src/connectors/duckdb/dialect.js';
describe('KtxDuckDbDialect', () => {
const dialect = new KtxDuckDbDialect();
it('quotes identifiers with double quotes and escapes embedded quotes', () => {
expect(dialect.quoteIdentifier('order"s')).toBe('"order""s"');
});
it('maps integer types to number dimension', () => {
expect(dialect.mapToDimensionType('BIGINT')).toBe('number');
expect(dialect.mapToDimensionType('DOUBLE')).toBe('number');
});
it('maps timestamp types to time dimension', () => {
expect(dialect.mapToDimensionType('TIMESTAMP')).toBe('time');
expect(dialect.mapToDimensionType('DATE')).toBe('time');
});
it('maps text types to string dimension', () => {
expect(dialect.mapToDimensionType('VARCHAR')).toBe('string');
});
it('maps boolean types to boolean dimension', () => {
expect(dialect.mapToDimensionType('BOOLEAN')).toBe('boolean');
expect(dialect.mapToDimensionType('BOOL')).toBe('boolean');
});
it('falls back to string for an empty or unknown native type', () => {
expect(dialect.mapToDimensionType('')).toBe('string');
expect(dialect.mapToDimensionType('JSON')).toBe('string');
});
// The precedence ladder strips parameters before substring rules fire, so a
// parameterized DECIMAL still resolves through the numeric branch rather than
// the string fallback.
it('strips type parameters before resolving the dimension', () => {
expect(dialect.mapToDimensionType('DECIMAL(10,2)')).toBe('number');
expect(dialect.mapToDimensionType('VARCHAR(255)')).toBe('string');
});
// Types absent from the exact-match table still resolve via substring rules:
// TIMESTAMP_NS (time), UINT128/HUGEINT-like (number), and lowercase input.
it('resolves unlisted types through substring matching, case-insensitively', () => {
expect(dialect.mapToDimensionType('timestamp_ns')).toBe('time');
expect(dialect.mapToDimensionType('INT128')).toBe('number');
expect(dialect.mapToDimensionType(' double ')).toBe('number');
});
it('generates a limited sample query', () => {
expect(dialect.generateSampleQuery('"t"', 5)).toBe('SELECT * FROM "t" LIMIT 5');
});
it('quotes selected columns in a sample query', () => {
expect(dialect.generateSampleQuery('"t"', 5, ['a', 'b'])).toBe('SELECT "a", "b" FROM "t" LIMIT 5');
});
it('builds a non-null, non-blank column sample query', () => {
expect(dialect.generateColumnSampleQuery('"t"', 'email', 3)).toBe(
`SELECT "email" FROM "t" WHERE "email" IS NOT NULL AND TRIM(CAST("email" AS VARCHAR)) != '' LIMIT 3`,
);
});
// A degenerate sample percentage (<=0 or >=1) means "no sampling", so both the
// random filter and the TABLESAMPLE clause must collapse to an empty string.
it('returns empty sample clauses outside the (0,1) range and real clauses inside it', () => {
expect(dialect.getRandomSampleFilter(0)).toBe('');
expect(dialect.getRandomSampleFilter(1)).toBe('');
expect(dialect.getRandomSampleFilter(0.25)).toBe('RANDOM() < 0.25');
expect(dialect.getTableSampleClause(0)).toBe('');
expect(dialect.getTableSampleClause(0.1)).toBe('USING SAMPLE 10 PERCENT (bernoulli)');
});
// A type missing from the exact-match table but containing BOOL still resolves
// through the substring branch rather than the string fallback.
it('resolves a BOOL-substring type to boolean', () => {
expect(dialect.mapToDimensionType('MYBOOL')).toBe('boolean');
});
it('builds limit/offset, sample-value aggregation, and randomized cardinality clauses', () => {
expect(dialect.getLimitOffsetClause(10, 5)).toContain('LIMIT 10');
expect(dialect.getSampleValueAggregation('SELECT 1')).toContain('STRING_AGG');
expect(dialect.generateRandomizedCardinalitySampleQuery('"t"', 'c', 100)).toContain('USING SAMPLE 100 ROWS');
});
it('exposes profiling expressions and a null column-statistics query', () => {
expect(dialect.getNullCountExpression('c')).toBe('SUM(CASE WHEN c IS NULL THEN 1 ELSE 0 END)');
expect(dialect.getDistinctCountExpression('c')).toBe('COUNT(DISTINCT c)');
expect(dialect.textLengthExpression('c')).toBe('LENGTH(CAST(c AS VARCHAR))');
expect(dialect.castToText('c')).toBe('CAST(c AS VARCHAR)');
expect(dialect.mapDataType('BIGINT')).toBe('BIGINT');
expect(dialect.getTopClause(5)).toBe('');
expect(dialect.generateColumnStatisticsQuery('main', 't')).toBeNull();
});
// Guards the single-namespace (db=null) display shape: v1 introspects only
// `main`, so a display ref must round-trip as a bare table name. An ANSI shape
// would emit a 1-part name it then refuses to parse, breaking column lookups.
it('round-trips a single-namespace display ref and reports a 1-part column shape', () => {
const table = { catalog: null, db: null, name: 'orders' };
const display = dialect.formatDisplayRef(table);
expect(display).toBe('orders');
expect(dialect.parseDisplayRef(display)).toMatchObject({ name: 'orders' });
expect(dialect.columnDisplayTablePartCount()).toBe(1);
expect(dialect.formatTableName(table)).toBe('"orders"');
});
});

View file

@ -135,6 +135,14 @@ describe('federatedAttachTarget', () => {
expect(target).toContain('ssl_mode=REQUIRED');
});
it('resolves a duckdb member to its database file path', () => {
const target = federatedAttachTarget(
{ connectionId: 'dux', driver: 'duckdb', projectDir: '/p', connection: { driver: 'duckdb', path: 'a.duckdb' } },
{},
);
expect(target).toBe('/p/a.duckdb');
});
it('throws for an unsupported driver', () => {
expect(() => federatedAttachTarget(member({ driver: 'snowflake', connection: { driver: 'snowflake' } }), {})).toThrow(
/cannot be attached/i,

View file

@ -67,4 +67,27 @@ describe('buildAttachStatements', () => {
);
expect(stmts.at(-1)).toBe('ATTACH \'postgresql://u:it\'\'s@h/db\' AS "pg" (TYPE postgres, READ_ONLY);');
});
it('attaches a native duckdb member with no TYPE and no INSTALL/LOAD', () => {
const statements = buildAttachStatements(
[{ connectionId: 'dux', driver: 'duckdb', projectDir: '/p', connection: { driver: 'duckdb', path: '/p/a.duckdb' } }],
{},
);
expect(statements.some((s) => s.startsWith('INSTALL'))).toBe(false);
expect(statements.find((s) => s.startsWith('ATTACH'))).toContain('(READ_ONLY)');
expect(statements.find((s) => s.startsWith('ATTACH'))).not.toContain('TYPE');
});
it('mixes a duckdb member with a postgres member, loading only postgres', () => {
const statements = buildAttachStatements(
[
{ connectionId: 'dux', driver: 'duckdb', projectDir: '/p', connection: { driver: 'duckdb', path: '/p/a.duckdb' } },
{ connectionId: 'pg', driver: 'postgres', projectDir: '/p', connection: { driver: 'postgres', url: 'postgres://h/db' } },
],
{},
);
expect(statements).toContain('INSTALL postgres; LOAD postgres;');
expect(statements.some((s) => s.includes('INSTALL duckdb'))).toBe(false);
expect(statements.filter((s) => s.startsWith('ATTACH')).length).toBe(2);
});
});

View file

@ -0,0 +1,45 @@
import { DuckDBInstance } from '@duckdb/node-api';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createDuckDbLiveDatabaseIntrospection } from '../../../src/connectors/duckdb/live-database-introspection.js';
import { tableRefSet } from '../../../src/context/scan/table-ref.js';
let dir: string;
let dbPath: string;
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), 'ktx-duckdb-live-'));
dbPath = join(dir, 'warehouse.duckdb');
const instance = await DuckDBInstance.create(dbPath);
const connection = await instance.connect();
await connection.run('CREATE TABLE customers (id BIGINT, name VARCHAR)');
await connection.run('CREATE TABLE orders (id BIGINT)');
connection.closeSync();
instance.closeSync();
});
afterAll(async () => {
await rm(dir, { recursive: true, force: true });
});
function port() {
return createDuckDbLiveDatabaseIntrospection({
projectDir: dir,
connections: { warehouse: { driver: 'duckdb', path: dbPath } },
});
}
describe('createDuckDbLiveDatabaseIntrospection', () => {
it('extracts the full schema for a connection', async () => {
const snapshot = await port().extractSchema('warehouse');
expect(snapshot.tables.map((t) => t.name).sort()).toEqual(['customers', 'orders']);
});
it('restricts extraction to a table scope', async () => {
const tableScope = tableRefSet([{ catalog: null, db: null, name: 'customers' }]);
const snapshot = await port().extractSchema('warehouse', { tableScope });
expect(snapshot.tables.map((t) => t.name)).toEqual(['customers']);
});
});

View file

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { jsonSafeBigint, toJsonSafeRows } from '../../../src/connectors/shared/duckdb-json-safe.js';
describe('duckdb json-safe bigint', () => {
it('keeps safe-range bigints as numbers', () => {
expect(jsonSafeBigint(42n)).toBe(42);
});
it('stringifies bigints beyond Number.MAX_SAFE_INTEGER', () => {
const big = BigInt(Number.MAX_SAFE_INTEGER) + 10n;
expect(jsonSafeBigint(big)).toBe(big.toString());
});
it('converts only bigint cells in a row matrix', () => {
expect(toJsonSafeRows([[1n, 'a', null]])).toEqual([[1, 'a', null]]);
});
});

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { connectionTypeSchema } from '../../../src/context/connections/connection-type.js';
import {
dialectForConnectionType,
warehouseTargetDialect,
} from '../../../src/context/connections/connection-type-dialect.js';
describe('connection type dialect resolution', () => {
it('maps warehouse connection types to sqlglot dialects', () => {
const cases: Array<[string, string]> = [
['POSTGRESQL', 'postgres'],
['SQLITE', 'sqlite'],
['DUCKDB', 'duckdb'],
['SQLSERVER', 'tsql'],
['BIGQUERY', 'bigquery'],
['SNOWFLAKE', 'snowflake'],
['MYSQL', 'mysql'],
['CLICKHOUSE', 'clickhouse'],
['ATHENA', 'athena'],
];
for (const [connectionType, dialect] of cases) {
expect(dialectForConnectionType(connectionType)).toBe(dialect);
expect(warehouseTargetDialect(connectionType)).toBe(dialect);
}
});
it('normalizes case and preserves the semantic-layer postgres fallback for unknown inputs', () => {
expect(dialectForConnectionType('athena')).toBe('athena');
expect(dialectForConnectionType('postgresql')).toBe('postgres');
expect(dialectForConnectionType('not-a-real-connection-type')).toBe('postgres');
});
it('rejects non-SQL targets for BI table parsing', () => {
expect(warehouseTargetDialect('METABASE')).toBeNull();
expect(warehouseTargetDialect('LOOKER')).toBeNull();
expect(warehouseTargetDialect('NOTION')).toBeNull();
expect(warehouseTargetDialect('not-a-real-connection-type')).toBeNull();
});
it('removes inherited non-ktx connection type values while keeping AWS Athena', () => {
expect(connectionTypeSchema.safeParse('ATHENA').success).toBe(true);
for (const removed of [
'CENTRALREACH',
'EPIC',
'CERNER',
'QUICKBOOKS',
'WORKDAY',
'REST',
'S3',
'SLACK',
'PLAIN',
'BETTERSTACK',
]) {
expect(connectionTypeSchema.safeParse(removed).success).toBe(false);
}
});
});

View file

@ -305,7 +305,7 @@ describe('getDialectForDriver', () => {
it('throws with a supported-driver list for unknown drivers', () => {
expect(() => getDialectForDriver('oracle')).toThrow(
'Unsupported driver "oracle". Supported drivers: bigquery, clickhouse, mongodb, mysql, postgres, snowflake, sqlite, sqlserver',
'Unsupported driver "oracle". Supported drivers: athena, bigquery, clickhouse, duckdb, mongodb, mysql, postgres, snowflake, sqlite, sqlserver',
);
});

View file

@ -22,6 +22,7 @@ const connectionFixtures: Record<KtxConnectionDriver, FixtureFactory> = {
schemas: ['public'],
}),
sqlite: () => ({ driver: 'sqlite', path: 'warehouse.db' }),
duckdb: (projectDir) => ({ driver: 'duckdb', path: join(projectDir, 'warehouse.duckdb') }),
mongodb: () => ({
driver: 'mongodb',
url: 'mongodb://localhost:27017/app',
@ -69,6 +70,11 @@ const connectionFixtures: Record<KtxConnectionDriver, FixtureFactory> = {
database: 'ANALYTICS',
schema: 'PUBLIC',
}),
athena: () => ({
driver: 'athena',
region: 'us-east-1',
s3_staging_dir: 's3://my-bucket/athena-results/',
}),
};
const allowedScopeKeys = new Set(['dataset_ids', 'databases', 'schemas', 'schema_names']);
@ -99,8 +105,10 @@ describe('driverRegistrations', () => {
const registryDrivers = Object.keys(driverRegistrations).sort();
expect(listSupportedDrivers()).toEqual(registryDrivers);
expect(listSupportedDrivers()).toEqual([
'athena',
'bigquery',
'clickhouse',
'duckdb',
'mongodb',
'mysql',
'postgres',
@ -138,7 +146,7 @@ describe('driverRegistrations', () => {
expect(connector.listTables).toEqual(expect.any(Function));
await connector.cleanup?.();
if (registration.driver === 'sqlite') {
if (registration.driver === 'sqlite' || registration.driver === 'duckdb') {
expect(registration.scopeConfigKey).toBeNull();
} else {
expect(registration.scopeConfigKey).not.toBeNull();

View file

@ -35,6 +35,21 @@ describe('localConnectionToWarehouseDescriptor', () => {
});
});
it('maps Athena connections to canonical warehouse descriptors', () => {
expect(
localConnectionToWarehouseDescriptor('athena-warehouse', {
driver: 'athena',
region: 'us-east-1',
s3_staging_dir: 's3://my-bucket/athena-results/',
database: 'analytics',
}),
).toMatchObject({
id: 'athena-warehouse',
connection_type: 'ATHENA',
database: 'analytics',
});
});
it('returns null for non-warehouse adapters', () => {
expect(
localConnectionToWarehouseDescriptor('looker', {
@ -51,6 +66,7 @@ describe('local connection info helpers', () => {
expect(localConnectionTypeForConfig('warehouse', { driver: 'postgres' })).toBe('POSTGRESQL');
expect(localConnectionTypeForConfig('bq', { driver: 'bigquery', project_id: 'acme' })).toBe('BIGQUERY');
expect(localConnectionTypeForConfig('snowflake', { driver: 'snowflake' })).toBe('SNOWFLAKE');
expect(localConnectionTypeForConfig('athena-warehouse', { driver: 'athena' })).toBe('ATHENA');
});
it('keeps removed driver aliases as display-only labels', () => {

View file

@ -1,10 +1,14 @@
import { describe, expect, it, vi } from 'vitest';
import type { executeFederatedQuery } from '../../../src/connectors/duckdb/federated-executor.js';
import { executeProjectReadOnlySql } from '../../../src/context/connections/project-sql-executor.js';
import { executeProjectRawSql, executeProjectReadOnlySql } from '../../../src/context/connections/project-sql-executor.js';
import type { KtxLocalProject } from '../../../src/context/project/project.js';
import type { KtxScanConnector } from '../../../src/context/scan/types.js';
import type { SqlAnalysisPort } from '../../../src/context/sql-analysis/ports.js';
import { KtxQueryError } from '../../../src/errors.js';
function fakeProject(connections: Record<string, { driver: string }>): KtxLocalProject {
function fakeProject(
connections: Record<string, { driver: string; query_policy?: 'semantic-layer-only' }>,
): KtxLocalProject {
return {
projectDir: '/tmp/proj',
configPath: '/tmp/proj/ktx.yaml',
@ -114,3 +118,97 @@ describe('executeProjectReadOnlySql headerTypes', () => {
expect(result.headerTypes).toEqual(['INTEGER']);
});
});
function fakeSqlAnalysis(validation: { ok: boolean; error: string | null }): SqlAnalysisPort {
return {
analyzeForFingerprint: vi.fn(),
analyzeBatch: vi.fn(),
validateReadOnly: vi.fn(async () => validation),
} as unknown as SqlAnalysisPort;
}
describe('executeProjectRawSql', () => {
it('validates then executes raw SQL on an unrestricted connection', async () => {
const project = fakeProject({ pg: { driver: 'postgres' } });
const sqlAnalysis = fakeSqlAnalysis({ ok: true, error: null });
const connector = connectorReturning({
headers: ['id'],
rows: [[1]],
totalRows: 1,
rowCount: 1,
});
const result = await executeProjectRawSql({
project,
connectionId: 'pg',
sql: 'SELECT id FROM orders',
maxRows: 25,
sqlAnalysis,
createConnector: () => connector,
runId: 'test-raw-sql',
});
expect(result.rows).toEqual([[1]]);
expect(sqlAnalysis.validateReadOnly).toHaveBeenCalledWith('SELECT id FROM orders', 'postgres');
});
it('rejects a restricted connection before validation or execution', async () => {
const project = fakeProject({ pg: { driver: 'postgres', query_policy: 'semantic-layer-only' } });
const sqlAnalysis = fakeSqlAnalysis({ ok: true, error: null });
const createConnector = vi.fn();
const execution = executeProjectRawSql({
project,
connectionId: 'pg',
sql: 'SELECT 1',
maxRows: 25,
sqlAnalysis,
createConnector: createConnector as never,
runId: 'test-raw-sql',
});
await expect(execution).rejects.toBeInstanceOf(KtxQueryError);
await expect(execution).rejects.toThrow(/query_policy: semantic-layer-only/);
expect(sqlAnalysis.validateReadOnly).not.toHaveBeenCalled();
expect(createConnector).not.toHaveBeenCalled();
});
it('rejects federated raw SQL when a member connection is restricted', async () => {
const project = fakeProject({
pg: { driver: 'postgres', query_policy: 'semantic-layer-only' },
lite: { driver: 'sqlite' },
});
const executeFederated = vi.fn();
await expect(
executeProjectRawSql({
project,
connectionId: '_ktx_federated',
sql: 'SELECT 1',
maxRows: 25,
sqlAnalysis: fakeSqlAnalysis({ ok: true, error: null }),
createConnector: vi.fn() as never,
executeFederated: executeFederated as never,
runId: 'test-raw-sql',
}),
).rejects.toThrow(/"pg"/);
expect(executeFederated).not.toHaveBeenCalled();
});
it('classifies a read-only validation failure as an expected query error', async () => {
const project = fakeProject({ pg: { driver: 'postgres' } });
const createConnector = vi.fn();
const execution = executeProjectRawSql({
project,
connectionId: 'pg',
sql: 'DROP TABLE orders',
maxRows: 25,
sqlAnalysis: fakeSqlAnalysis({ ok: false, error: 'SQL is not read-only: DROP.' }),
createConnector: createConnector as never,
runId: 'test-raw-sql',
});
await expect(execution).rejects.toBeInstanceOf(KtxQueryError);
await expect(execution).rejects.toThrow('SQL is not read-only: DROP.');
expect(createConnector).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import {
assertRawSqlAllowed,
connectionQueryPolicy,
projectAllowsRawSql,
restrictedFederatedMemberIds,
} from '../../../src/context/connections/query-policy.js';
import { parseKtxProjectConfig } from '../../../src/context/project/config.js';
import { KtxQueryError } from '../../../src/errors.js';
const PROJECT_DIR = '/tmp/proj';
function config(yaml: string) {
return parseKtxProjectConfig(yaml);
}
describe('connectionQueryPolicy', () => {
it('defaults to read-only-sql when the field is absent or the connection is unknown', () => {
const parsed = config(`
connections:
warehouse:
driver: sqlite
url: file:warehouse.db
`);
expect(connectionQueryPolicy(parsed.connections.warehouse)).toBe('read-only-sql');
expect(connectionQueryPolicy(undefined)).toBe('read-only-sql');
});
it('reads semantic-layer-only from ktx.yaml', () => {
const parsed = config(`
connections:
warehouse:
driver: snowflake
url: env:SNOWFLAKE_URL
query_policy: semantic-layer-only
`);
expect(connectionQueryPolicy(parsed.connections.warehouse)).toBe('semantic-layer-only');
});
it('rejects unknown query_policy values at config parse time', () => {
expect(() =>
config(`
connections:
warehouse:
driver: sqlite
url: file:warehouse.db
query_policy: everything-goes
`),
).toThrow();
});
});
describe('assertRawSqlAllowed', () => {
it('allows raw SQL on an unrestricted connection', () => {
const parsed = config(`
connections:
warehouse:
driver: sqlite
url: file:warehouse.db
`);
expect(() => assertRawSqlAllowed(parsed, PROJECT_DIR, 'warehouse')).not.toThrow();
});
it('rejects raw SQL on a restricted connection with an expected error naming the policy', () => {
const parsed = config(`
connections:
warehouse:
driver: sqlite
url: file:warehouse.db
query_policy: semantic-layer-only
`);
expect(() => assertRawSqlAllowed(parsed, PROJECT_DIR, 'warehouse')).toThrow(KtxQueryError);
expect(() => assertRawSqlAllowed(parsed, PROJECT_DIR, 'warehouse')).toThrow(
/query_policy: semantic-layer-only/,
);
});
it('rejects federated raw SQL when any member connection is restricted', () => {
const parsed = config(`
connections:
sales:
driver: sqlite
url: file:sales.db
query_policy: semantic-layer-only
events:
driver: sqlite
url: file:events.db
`);
expect(restrictedFederatedMemberIds(parsed, PROJECT_DIR)).toEqual(['sales']);
expect(() => assertRawSqlAllowed(parsed, PROJECT_DIR, '_ktx_federated')).toThrow(/"sales"/);
});
it('allows federated raw SQL when no member is restricted', () => {
const parsed = config(`
connections:
sales:
driver: sqlite
url: file:sales.db
events:
driver: sqlite
url: file:events.db
`);
expect(restrictedFederatedMemberIds(parsed, PROJECT_DIR)).toEqual([]);
expect(() => assertRawSqlAllowed(parsed, PROJECT_DIR, '_ktx_federated')).not.toThrow();
});
});
describe('projectAllowsRawSql', () => {
it('is true when at least one SQL connection is unrestricted', () => {
const parsed = config(`
connections:
finance:
driver: postgres
url: env:FINANCE_URL
query_policy: semantic-layer-only
warehouse:
driver: sqlite
url: file:warehouse.db
`);
expect(projectAllowsRawSql(parsed)).toBe(true);
});
it('is false when every SQL connection is restricted', () => {
const parsed = config(`
connections:
finance:
driver: postgres
url: env:FINANCE_URL
query_policy: semantic-layer-only
`);
expect(projectAllowsRawSql(parsed)).toBe(false);
});
it('is true for projects with no SQL-queryable connections', () => {
const parsed = config(`
connections:
docs:
driver: mongodb
url: mongodb://localhost:27017/app
`);
expect(projectAllowsRawSql(parsed)).toBe(true);
expect(projectAllowsRawSql(config('connections: {}'))).toBe(true);
});
});

View file

@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { resolveKtxConfigReference, resolveKtxHomePath } from '../../../src/context/core/config-reference.js';
import { KtxExpectedError } from '../../../src/errors.js';
describe('ktx config references', () => {
it('resolves env references without returning empty values', () => {
@ -22,6 +23,17 @@ describe('ktx config references', () => {
expect(resolveKtxConfigReference(`file:${keyPath}`, {})).toBe('file-gateway-key');
});
it('raises an expected error when a file reference is missing', () => {
const missing = join(tmpdir(), `ktx-config-reference-missing-${process.pid}`, 'absent-password');
try {
resolveKtxConfigReference(`file:${missing}`, {});
expect.unreachable('expected a thrown error for the missing secret file');
} catch (error) {
expect(error).toBeInstanceOf(KtxExpectedError);
expect((error as Error).message).toContain(missing);
}
});
it('returns literal values unchanged after trimming blank-only values', () => {
expect(resolveKtxConfigReference('provider/model', {})).toBe('provider/model');
expect(resolveKtxConfigReference(' ', {})).toBeUndefined();

View file

@ -1,7 +1,11 @@
import { once } from 'node:events';
import { createServer } from 'node:http';
import { describe, expect, it, vi } from 'vitest';
import { createHttpSemanticLayerComputePort, createPythonSemanticLayerComputePort } from '../../../src/context/daemon/semantic-layer-compute.js';
import {
createHttpSemanticLayerComputePort,
createPythonSemanticLayerComputePort,
KtxDaemonComputeError,
} from '../../../src/context/daemon/semantic-layer-compute.js';
const source = {
name: 'orders',
@ -174,6 +178,79 @@ describe('createPythonSemanticLayerComputePort', () => {
});
});
describe('KtxDaemonComputeError classification', () => {
const query = { sources: [source], dialect: 'postgres', query: { measures: ['count(*)'], dimensions: [] } };
function exitingPort(code: number, stderr: string) {
return createPythonSemanticLayerComputePort({
command: process.execPath,
args: [
'-e',
`process.stdin.on('data',()=>{});process.stdin.on('end',()=>{process.stderr.write(${JSON.stringify(stderr)});process.exit(${code})});`,
],
});
}
async function rejection(promise: Promise<unknown>): Promise<KtxDaemonComputeError> {
const error = await promise.then(
() => null,
(thrown: unknown) => thrown,
);
expect(error).toBeInstanceOf(KtxDaemonComputeError);
return error as KtxDaemonComputeError;
}
it('marks a subprocess input-rejection (exit 3) as inputRejected', async () => {
const error = await rejection(exitingPort(3, 'Measure expr does not reference any source').query(query));
expect(error.inputRejected).toBe(true);
expect(error.detail).toContain('does not reference any source');
});
it('marks a subprocess fault (exit 1) as not inputRejected', async () => {
const error = await rejection(exitingPort(1, 'Traceback: boom').query(query));
expect(error.inputRejected).toBe(false);
expect(error.detail).toContain('boom');
});
async function statusPort(statusCode: number, body: string): Promise<{ port: ReturnType<typeof createHttpSemanticLayerComputePort>; close: () => void }> {
const server = createServer((_request, response) => {
response.writeHead(statusCode, { 'content-type': 'application/json' });
response.end(body);
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('expected TCP server address');
}
return {
port: createHttpSemanticLayerComputePort({ baseUrl: `http://127.0.0.1:${address.port}` }),
close: () => server.close(),
};
}
it('marks an HTTP 400 as inputRejected and unwraps the daemon detail', async () => {
const { port, close } = await statusPort(400, JSON.stringify({ detail: 'Measure expr does not reference any source' }));
try {
const error = await rejection(port.query(query));
expect(error.inputRejected).toBe(true);
expect(error.detail).toBe('Measure expr does not reference any source');
} finally {
close();
}
});
it('marks an HTTP 500 as not inputRejected', async () => {
const { port, close } = await statusPort(500, JSON.stringify({ detail: 'Daemon request failed: boom' }));
try {
const error = await rejection(port.query(query));
expect(error.inputRejected).toBe(false);
} finally {
close();
}
});
});
describe('createHttpSemanticLayerComputePort', () => {
it('calls semantic query and validate HTTP endpoints through an injected runner', async () => {
const requestJson = vi.fn(async (path: string) => {

View file

@ -72,6 +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('awsathena')).toBe('ATHENA');
expect(lookerDialectToConnectionType('AWSATHENA')).toBe('ATHENA');
expect(lookerDialectToConnectionType('mssql')).toBeNull();
expect(lookerDialectToConnectionType('tsql')).toBeNull();
expect(lookerDialectToConnectionType('unknown')).toBeNull();
@ -80,6 +82,7 @@ describe('looker dialect and target validation helpers', () => {
it('maps supported warehouse connection types to sqlglot dialects', () => {
expect(sqlglotDialectForConnectionType('BIGQUERY')).toBe('bigquery');
expect(sqlglotDialectForConnectionType('POSTGRESQL')).toBe('postgres');
expect(sqlglotDialectForConnectionType('ATHENA')).toBe('athena');
expect(sqlglotDialectForConnectionType('LOOKER')).toBeNull();
});
@ -259,6 +262,44 @@ describe('collectExploreParseItems and projectParsedIdentifier', () => {
});
});
it('collects Athena parser inputs with the athena sqlglot dialect', () => {
expect(
collectExploreParseItems({
explore: {
...mappedExplore,
rawSqlTableName: 'analytics.orders',
joins: [
{
name: 'line_items',
type: 'left_outer',
relationship: 'one_to_many',
rawSqlTableName: 'analytics.line_items',
sqlOn: null,
from: null,
targetTable: null,
},
],
},
connectionMappings: { b2b_sandbox_bq: 'athena-warehouse' },
targetConnections: new Map([['athena-warehouse', { id: 'athena-warehouse', connection_type: 'ATHENA' }]]),
}),
).toEqual({
parsedTargetTables: {},
parseItems: [
{
key: 'b2b.sales_pipeline',
sql_table_name: 'analytics.orders',
dialect: 'athena',
},
{
key: 'b2b.sales_pipeline.line_items',
sql_table_name: 'analytics.line_items',
dialect: 'athena',
},
],
});
});
it('projects successful and failed parser rows into ktx parsed target tables', () => {
expect(
projectParsedIdentifier({

View file

@ -121,6 +121,15 @@ describe('validateMappingPhysicalMatch', () => {
expect(reason).toContain('engine');
});
it('accepts Athena mappings when the target connection type is ATHENA', () => {
expect(
validateMappingPhysicalMatch(
{ metabaseEngine: 'athena', metabaseDbName: 'analytics', metabaseHost: null },
{ connection_type: 'ATHENA', database: 'analytics' },
),
).toBeNull();
});
it('returns null when Postgres host and database both match after normalization', () => {
expect(
validateMappingPhysicalMatch(
@ -159,6 +168,18 @@ describe('validateMappingPhysicalMatch', () => {
});
});
describe('METABASE_ENGINE_TO_CONNECTION_TYPE', () => {
it('maps Metabase Athena databases to AWS Athena warehouse connections', () => {
expect(METABASE_ENGINE_TO_CONNECTION_TYPE.athena).toBe('ATHENA');
expect(
validateMappingPhysicalMatch(
{ metabaseEngine: 'athena', metabaseDbName: 'analytics', metabaseHost: null },
{ connection_type: 'POSTGRESQL', database: 'analytics' },
),
).toBe("Metabase database engine 'athena' does not match ktx connection type 'POSTGRESQL'");
});
});
describe('computeMetabaseMappingPhysicalMismatches', () => {
it('returns only mismatched physical mappings', () => {
expect(

View file

@ -0,0 +1,325 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { chunkSigmaStagedDir } from '../../../../../src/context/ingest/adapters/sigma/chunk.js';
// Keep in sync with constants in chunk.ts
const DATA_MODELS_PER_UNIT = 50;
const WORKBOOKS_PER_UNIT = 2000;
const FIXTURES = resolve(import.meta.dirname, '../../../../fixtures/sigma');
const SINGLE = join(FIXTURES, 'single-folder');
const MULTI = join(FIXTURES, 'multi-folder');
const EMPTY = join(FIXTURES, 'empty-manifest');
describe('chunkSigmaStagedDir — first run', () => {
it('single-folder fixture emits two WUs (data-models and workbooks)', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
expect(result.workUnits).toHaveLength(2);
});
it('data-models WU has correct unitKey and displayLabel', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models')!;
expect(wu).toBeDefined();
expect(wu.displayLabel).toBe('Sigma: data models');
});
it('workbooks WU has correct unitKey and displayLabel', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks')!;
expect(wu).toBeDefined();
expect(wu.displayLabel).toBe('Sigma: workbooks');
});
it('data-models WU rawFiles contains data model files but not the manifest', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models')!;
expect(wu.rawFiles).toContain('data-models/dm-aaa111.json');
expect(wu.rawFiles).toContain('data-models/dm-bbb222.json');
expect(wu.rawFiles).not.toContain('sigma-manifest.json');
expect(wu.rawFiles).not.toContain('workbooks/wb-xxx111.json');
});
it('manifest is in peerFileIndex so the LLM can read it without affecting the hash', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const dmWu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models')!;
const wbWu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks')!;
expect(dmWu.peerFileIndex).toContain('sigma-manifest.json');
expect(wbWu.peerFileIndex).toContain('sigma-manifest.json');
});
it('workbooks WU rawFiles contains workbook files but not the manifest', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks')!;
expect(wu.rawFiles).toContain('workbooks/wb-xxx111.json');
expect(wu.rawFiles).not.toContain('sigma-manifest.json');
expect(wu.rawFiles).not.toContain('data-models/dm-aaa111.json');
});
it('data-models WU peerFileIndex contains workbook files', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models')!;
expect(wu.peerFileIndex).toContain('workbooks/wb-xxx111.json');
});
it('workbooks WU peerFileIndex contains data model files', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks')!;
expect(wu.peerFileIndex).toContain('data-models/dm-aaa111.json');
expect(wu.peerFileIndex).toContain('data-models/dm-bbb222.json');
});
it('data-models WU notes describes model count', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models')!;
expect(wu.notes).toBe('2 data models');
});
it('workbooks WU notes describes workbook count', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks')!;
expect(wu.notes).toBe('1 workbook');
});
it('dependencyPaths is empty on first run for both WUs', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
for (const wu of result.workUnits) {
expect(wu.dependencyPaths).toEqual([]);
}
});
it('multi-folder fixture still emits two WUs (data-models and workbooks)', async () => {
const result = await chunkSigmaStagedDir(MULTI);
expect(result.workUnits).toHaveLength(2);
expect(result.workUnits.map((w) => w.unitKey).sort()).toEqual(['sigma-data-models', 'sigma-workbooks']);
});
it('multi-folder: data-models WU contains all data models regardless of folder', async () => {
const result = await chunkSigmaStagedDir(MULTI);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models')!;
expect(wu.rawFiles).toContain('data-models/dm-aaa111.json');
expect(wu.rawFiles).toContain('data-models/dm-bbb222.json');
expect(wu.rawFiles).toContain('data-models/dm-ccc333.json');
});
it('multi-folder: workbooks WU contains all workbooks regardless of folder', async () => {
const result = await chunkSigmaStagedDir(MULTI);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks')!;
expect(wu.rawFiles).toContain('workbooks/wb-yyy222.json');
expect(wu.rawFiles).toContain('workbooks/wb-zzz333.json');
});
it('unitKey is slug-safe (no slashes or spaces)', async () => {
const result = await chunkSigmaStagedDir(SINGLE);
for (const wu of result.workUnits) {
expect(wu.unitKey).toMatch(/^[a-zA-Z0-9_-]+$/);
}
});
it('empty-manifest fixture emits zero WUs', async () => {
const result = await chunkSigmaStagedDir(EMPTY);
expect(result.workUnits).toHaveLength(0);
});
it('missing manifest directory emits zero WUs without crashing', async () => {
const result = await chunkSigmaStagedDir('/tmp/sigma-nonexistent-dir-ktx-test');
expect(result.workUnits).toHaveLength(0);
});
it('is deterministic: two identical calls produce structurally equal output', async () => {
const r1 = await chunkSigmaStagedDir(SINGLE);
const r2 = await chunkSigmaStagedDir(SINGLE);
expect(JSON.stringify(r1)).toBe(JSON.stringify(r2));
});
});
describe('chunkSigmaStagedDir — data model batching', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'sigma-dm-batch-'));
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
const manifest = JSON.stringify({
fetchedAt: new Date().toISOString(),
dataModelCount: DATA_MODELS_PER_UNIT + 1,
workbookCount: 0,
sigmaConnectionId: 'conn-1',
});
await writeFile(join(stagedDir, 'sigma-manifest.json'), manifest);
for (let i = 0; i < DATA_MODELS_PER_UNIT + 1; i++) {
const dm = JSON.stringify({
sigmaId: `dm-${i}`,
name: `Data Model ${i}`,
path: 'Engineering',
latestVersion: 1,
updatedAt: '2026-01-01T00:00:00Z',
isArchived: false,
dataModelUrlId: `url-${i}`,
spec: null,
});
await writeFile(join(stagedDir, 'data-models', `dm-${String(i).padStart(6, '0')}.json`), dm);
}
});
afterEach(async () => {
await rm(stagedDir, { recursive: true, force: true });
});
it('splits into two data model WUs when count exceeds DATA_MODELS_PER_UNIT', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const dmUnits = result.workUnits.filter((w) => w.unitKey.startsWith('sigma-data-models'));
expect(dmUnits).toHaveLength(2);
});
it('batched data model WUs get indexed unitKeys (sigma-data-models-0, sigma-data-models-1)', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const keys = result.workUnits.map((w) => w.unitKey).filter((k) => k.startsWith('sigma-data-models')).sort();
expect(keys).toEqual(['sigma-data-models-0', 'sigma-data-models-1']);
});
it('first batch has exactly DATA_MODELS_PER_UNIT files (manifest excluded from rawFiles)', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models-0')!;
expect(wu.rawFiles).toHaveLength(DATA_MODELS_PER_UNIT);
});
it('displayLabel includes batch position when split', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-data-models-0')!;
expect(wu.displayLabel).toMatch(/\(1\/2\)/);
});
});
describe('chunkSigmaStagedDir — workbook batching', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'sigma-batch-'));
await mkdir(join(stagedDir, 'workbooks'), { recursive: true });
const manifest = JSON.stringify({
fetchedAt: new Date().toISOString(),
dataModelCount: 0,
workbookCount: WORKBOOKS_PER_UNIT + 1,
sigmaConnectionId: 'conn-1',
});
await writeFile(join(stagedDir, 'sigma-manifest.json'), manifest);
for (let i = 0; i < WORKBOOKS_PER_UNIT + 1; i++) {
const wb = JSON.stringify({
sigmaId: `wb-${i}`,
name: `Workbook ${i}`,
path: 'Finance',
latestVersion: 1,
updatedAt: '2026-01-01T00:00:00Z',
isArchived: false,
workbookUrlId: `url-${i}`,
});
await writeFile(join(stagedDir, 'workbooks', `wb-${String(i).padStart(6, '0')}.json`), wb);
}
});
afterEach(async () => {
await rm(stagedDir, { recursive: true, force: true });
});
it('splits into two workbook WUs when count exceeds WORKBOOKS_PER_UNIT', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const wbUnits = result.workUnits.filter((w) => w.unitKey.startsWith('sigma-workbooks'));
expect(wbUnits).toHaveLength(2);
});
it('batched WUs get indexed unitKeys (sigma-workbooks-0, sigma-workbooks-1)', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const keys = result.workUnits.map((w) => w.unitKey).filter((k) => k.startsWith('sigma-workbooks')).sort();
expect(keys).toEqual(['sigma-workbooks-0', 'sigma-workbooks-1']);
});
it('first batch has exactly WORKBOOKS_PER_UNIT files (manifest excluded from rawFiles)', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks-0')!;
expect(wu.rawFiles).toHaveLength(WORKBOOKS_PER_UNIT);
});
it('second batch has the remainder only', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks-1')!;
expect(wu.rawFiles).toHaveLength(1); // 1 overflow workbook
});
it('displayLabel includes batch position when split', async () => {
const result = await chunkSigmaStagedDir(stagedDir);
const wu = result.workUnits.find((w) => w.unitKey === 'sigma-workbooks-0')!;
expect(wu.displayLabel).toMatch(/\(1\/2\)/);
});
});
describe('chunkSigmaStagedDir — diffSet re-sync', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'sigma-chunk-diff-'));
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
const fs = await import('node:fs/promises');
const manifestBody = await fs.readFile(join(SINGLE, 'sigma-manifest.json'), 'utf-8');
await writeFile(join(stagedDir, 'sigma-manifest.json'), manifestBody);
for (const file of ['dm-aaa111.json', 'dm-bbb222.json']) {
const body = await fs.readFile(join(SINGLE, 'data-models', file), 'utf-8');
await writeFile(join(stagedDir, 'data-models', file), body);
}
});
afterEach(async () => {
await rm(stagedDir, { recursive: true, force: true });
});
it('only the WU containing the modified file is kept', async () => {
const result = await chunkSigmaStagedDir(stagedDir, {
diffSet: {
added: [],
modified: ['data-models/dm-aaa111.json'],
deleted: [],
unchanged: ['data-models/dm-bbb222.json', 'sigma-manifest.json'],
},
});
expect(result.workUnits).toHaveLength(1);
expect(result.workUnits[0]!.rawFiles).toEqual(['data-models/dm-aaa111.json']);
});
it('unchanged sibling data-model moves to dependencyPaths', async () => {
const result = await chunkSigmaStagedDir(stagedDir, {
diffSet: {
added: [],
modified: ['data-models/dm-aaa111.json'],
deleted: [],
unchanged: ['data-models/dm-bbb222.json', 'sigma-manifest.json'],
},
});
expect(result.workUnits[0]!.dependencyPaths).toContain('data-models/dm-bbb222.json');
});
it('all-unchanged diffSet produces zero WUs and no eviction', async () => {
const result = await chunkSigmaStagedDir(stagedDir, {
diffSet: {
added: [],
modified: [],
deleted: [],
unchanged: ['data-models/dm-aaa111.json', 'data-models/dm-bbb222.json', 'sigma-manifest.json'],
},
});
expect(result.workUnits).toHaveLength(0);
expect(result.eviction).toBeUndefined();
});
it('deleted paths produce an eviction unit listing those paths', async () => {
const result = await chunkSigmaStagedDir(stagedDir, {
diffSet: {
added: [],
modified: [],
deleted: ['data-models/dm-aaa111.json'],
unchanged: ['data-models/dm-bbb222.json', 'sigma-manifest.json'],
},
});
expect(result.eviction?.deletedRawPaths).toContain('data-models/dm-aaa111.json');
});
});

View file

@ -0,0 +1,309 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DefaultSigmaClient } from '../../../../../src/context/ingest/adapters/sigma/client.js';
const BASE = 'https://api.sigmacomputing.com';
const TOKEN_RESPONSE = {
access_token: 'test-token',
token_type: 'Bearer',
expires_in: 3600,
};
function makeResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function makeClient(): DefaultSigmaClient {
return new DefaultSigmaClient(
{ apiUrl: BASE, clientId: 'cid', clientSecret: 'csec' }, // pragma: allowlist secret
{ maxRetries: 1, baseDelayMs: 0, maxDelayMs: 0, timeoutMs: 5000 },
);
}
beforeEach(() => {
globalThis.fetch = vi.fn<typeof fetch>();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('DefaultSigmaClient.testConnection', () => {
it('returns success:true when auth succeeds', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE));
const client = makeClient();
const result = await client.testConnection();
expect(result.success).toBe(true);
});
it('returns success:false with error message when auth fails', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, 401));
const client = makeClient();
const result = await client.testConnection();
expect(result.success).toBe(false);
expect(result.error).toMatch(/401/);
});
});
describe('DefaultSigmaClient.listDataModels', () => {
it('returns entries from a single page', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE)) // auth
.mockResolvedValueOnce(
makeResponse({
entries: [
{
dataModelId: 'dm-1',
dataModelUrlId: 'url-1',
name: 'Revenue Model',
path: 'Finance/Revenue',
latestVersion: 1,
ownerId: 'user-1',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
isArchived: false,
},
],
nextPage: null,
}),
);
const client = makeClient();
const models = await client.listDataModels();
expect(models).toHaveLength(1);
expect(models[0]!.name).toBe('Revenue Model');
});
it('paginates across multiple pages', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(
makeResponse({
entries: [
{
dataModelId: 'dm-1',
dataModelUrlId: 'url-1',
name: 'Model A',
path: 'Finance/A',
latestVersion: 1,
ownerId: 'u1',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
},
],
nextPage: 'cursor-abc',
}),
)
.mockResolvedValueOnce(
makeResponse({
entries: [
{
dataModelId: 'dm-2',
dataModelUrlId: 'url-2',
name: 'Model B',
path: 'Finance/B',
latestVersion: 1,
ownerId: 'u1',
createdAt: '2026-01-02T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
},
],
nextPage: null,
}),
);
const client = makeClient();
const models = await client.listDataModels();
expect(models).toHaveLength(2);
expect(models.map((m) => m.name)).toEqual(['Model A', 'Model B']);
});
it('second page request includes cursor in query string', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(makeResponse({ entries: [{ dataModelId: 'dm-1', dataModelUrlId: 'url-1', name: 'A', path: 'F/A', latestVersion: 1, ownerId: 'u', createdAt: '', updatedAt: '' }], nextPage: 'cursor-xyz' }))
.mockResolvedValueOnce(makeResponse({ entries: [], nextPage: null }));
const client = makeClient();
await client.listDataModels();
const calls = vi.mocked(fetch).mock.calls;
const pageCall = calls[calls.length - 1]!;
expect(String(pageCall[0])).toContain('cursor-xyz');
});
});
function makeWorkbook(overrides: Record<string, unknown> = {}) {
return {
workbookId: 'wb-1',
workbookUrlId: 'Sales-Dashboard-wb1',
name: 'Sales Dashboard',
url: 'https://app.sigmacomputing.com/workbooks/wb-1',
path: 'Finance',
latestVersion: 3,
ownerId: 'user-1',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-15T00:00:00Z',
createdBy: 'user-1',
updatedBy: 'user-1',
isArchived: false,
...overrides,
};
}
describe('DefaultSigmaClient.listWorkbooks', () => {
it('returns entries from a single page', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(makeResponse({ entries: [makeWorkbook()], nextPage: null }));
const client = makeClient();
const workbooks = await client.listWorkbooks();
expect(workbooks).toHaveLength(1);
expect(workbooks[0]!.name).toBe('Sales Dashboard');
});
it('passes excludeExplorations=true by default', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(makeResponse({ entries: [], nextPage: null }));
const client = makeClient();
await client.listWorkbooks();
const url = String(vi.mocked(fetch).mock.calls[1]![0]);
expect(url).toContain('excludeExplorations=true');
});
it('omits excludeExplorations when includeExplorations=true', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(makeResponse({ entries: [], nextPage: null }));
const client = makeClient();
await client.listWorkbooks({ includeExplorations: true });
const url = String(vi.mocked(fetch).mock.calls[1]![0]);
expect(url).not.toContain('excludeExplorations');
});
it('filters out archived workbooks by default', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(
makeResponse({
entries: [makeWorkbook({ isArchived: false }), makeWorkbook({ workbookId: 'wb-2', name: 'Old', isArchived: true })],
nextPage: null,
}),
);
const client = makeClient();
const workbooks = await client.listWorkbooks();
expect(workbooks).toHaveLength(1);
expect(workbooks[0]!.name).toBe('Sales Dashboard');
});
it('includes archived workbooks when includeArchived=true', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(
makeResponse({
entries: [makeWorkbook({ isArchived: false }), makeWorkbook({ workbookId: 'wb-2', name: 'Old', isArchived: true })],
nextPage: null,
}),
);
const client = makeClient();
const workbooks = await client.listWorkbooks({ includeArchived: true });
expect(workbooks).toHaveLength(2);
});
it('filters workbooks by updatedSince', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(
makeResponse({
entries: [
makeWorkbook({ workbookId: 'wb-1', updatedAt: '2026-01-10T00:00:00Z' }),
makeWorkbook({ workbookId: 'wb-2', updatedAt: '2026-01-20T00:00:00Z' }),
],
nextPage: null,
}),
);
const client = makeClient();
const workbooks = await client.listWorkbooks({ updatedSince: '2026-01-15T00:00:00Z' });
expect(workbooks).toHaveLength(1);
expect(workbooks[0]!.workbookId).toBe('wb-2');
});
});
describe('DefaultSigmaClient.getDataModelSpec', () => {
it('calls the correct URL with encoded id', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(makeResponse({ schemaVersion: 1 }));
const client = makeClient();
const spec = await client.getDataModelSpec('dm/123');
expect(spec).toEqual({ schemaVersion: 1 });
const calls = vi.mocked(fetch).mock.calls;
expect(String(calls[1]![0])).toContain('/v2/dataModels/dm%2F123/spec');
});
});
describe('DefaultSigmaClient — error handling', () => {
it('retries on 500 and succeeds on retry', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE)) // auth
.mockResolvedValueOnce(makeResponse({ error: 'server error' }, 500)) // first attempt
.mockResolvedValueOnce(makeResponse({ entries: [], nextPage: null })); // retry
const client = makeClient();
const models = await client.listDataModels();
expect(models).toHaveLength(0);
});
it('throws after exhausting retries on 500', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValue(makeResponse({ error: 'server error' }, 500));
const client = makeClient();
await expect(client.listDataModels()).rejects.toThrow(/500/);
});
it('throws immediately on service_error 500 without retrying', async () => {
const serviceError = { requestId: 'abc', message: 'dataSource subtype not supported in data model read', code: 'service_error' };
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(makeResponse(serviceError, 500));
const client = makeClient();
await expect(client.getDataModelSpec('dm-1')).rejects.toThrow(/service_error/);
// Only 2 calls: auth + one request. No retries.
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2);
});
it('throws immediately on 404 (non-retryable)', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE))
.mockResolvedValueOnce(makeResponse({ error: 'not found' }, 404));
const client = makeClient();
await expect(client.getDataModelSpec('dm-999')).rejects.toThrow(/404/);
});
it('re-authenticates and retries on 401', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE)) // initial auth
.mockResolvedValueOnce(makeResponse({ error: 'expired' }, 401)) // 401 on first request
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE)) // re-auth
.mockResolvedValueOnce(makeResponse({ entries: [], nextPage: null })); // retried request
const client = makeClient();
const models = await client.listDataModels();
expect(models).toHaveLength(0);
});
});
describe('DefaultSigmaClient.cleanup', () => {
it('clears cached token so next call re-authenticates', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE)) // first auth
.mockResolvedValueOnce(makeResponse({ entries: [], nextPage: null })) // first list
.mockResolvedValueOnce(makeResponse(TOKEN_RESPONSE)) // second auth after cleanup
.mockResolvedValueOnce(makeResponse({ entries: [], nextPage: null })); // second list
const client = makeClient();
await client.listDataModels();
await client.cleanup();
await client.listDataModels();
// 4 calls total: 2 auths + 2 lists
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(4);
});
});

View file

@ -0,0 +1,61 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { detectSigmaStagedDir } from '../../../../../src/context/ingest/adapters/sigma/detect.js';
async function touch(dir: string, relPath: string, body = '{}'): Promise<void> {
const abs = join(dir, relPath);
await mkdir(join(abs, '..'), { recursive: true });
await writeFile(abs, body, 'utf-8');
}
describe('detectSigmaStagedDir', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'sigma-detect-'));
});
afterEach(async () => {
await rm(stagedDir, { recursive: true, force: true });
});
it('returns true when manifest and at least one data-model file are present', async () => {
await touch(stagedDir, 'sigma-manifest.json');
await touch(stagedDir, 'data-models/dm-aaa111.json');
expect(await detectSigmaStagedDir(stagedDir)).toBe(true);
});
it('returns true when manifest and at least one workbook file are present', async () => {
await touch(stagedDir, 'sigma-manifest.json');
await touch(stagedDir, 'workbooks/wb-xxx111.json');
expect(await detectSigmaStagedDir(stagedDir)).toBe(true);
});
it('returns false when sigma-manifest.json is absent', async () => {
await touch(stagedDir, 'data-models/dm-aaa111.json');
expect(await detectSigmaStagedDir(stagedDir)).toBe(false);
});
it('returns false for a completely empty directory', async () => {
expect(await detectSigmaStagedDir(stagedDir)).toBe(false);
});
it('returns false when manifest is present but both entity dirs are empty', async () => {
await touch(stagedDir, 'sigma-manifest.json');
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
await mkdir(join(stagedDir, 'workbooks'), { recursive: true });
expect(await detectSigmaStagedDir(stagedDir)).toBe(false);
});
it('returns false when manifest is present but entity dirs are absent', async () => {
await touch(stagedDir, 'sigma-manifest.json');
expect(await detectSigmaStagedDir(stagedDir)).toBe(false);
});
it('returns false when only unrelated files are present', async () => {
await touch(stagedDir, 'data-models/dm-aaa111.json');
expect(await detectSigmaStagedDir(stagedDir)).toBe(false);
});
});

View file

@ -0,0 +1,493 @@
import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SigmaClientFactory, SigmaRuntimeClient } from '../../../../../src/context/ingest/adapters/sigma/client-port.js';
import { fetchSigmaBundle } from '../../../../../src/context/ingest/adapters/sigma/fetch.js';
import type { SigmaPullConfig } from '../../../../../src/context/ingest/adapters/sigma/types.js';
const TEST_PULL_CONFIG = { sigmaConnectionId: 'sigma-prod' };
function makeSummary(id: string, name: string, path: string, isArchived = false) {
return {
dataModelId: id,
dataModelUrlId: `${name.replace(/\s+/g, '-')}-${id}`,
name,
path,
latestVersion: 1,
ownerId: 'user-1',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-15T00:00:00Z',
isArchived,
};
}
function makeFactory(client: Partial<SigmaRuntimeClient>): SigmaClientFactory {
const fullClient: SigmaRuntimeClient = {
testConnection: vi.fn().mockResolvedValue({ success: true }),
listDataModels: vi.fn().mockResolvedValue([]),
listWorkbooks: vi.fn().mockResolvedValue([]),
getDataModelSpec: vi.fn().mockResolvedValue(null),
cleanup: vi.fn().mockResolvedValue(undefined),
...client,
};
return {
createClient: vi.fn().mockResolvedValue(fullClient),
};
}
describe('fetchSigmaBundle', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'sigma-fetch-'));
});
afterEach(async () => {
await rm(stagedDir, { recursive: true, force: true });
});
it('creates sigma-manifest.json after a successful fetch', async () => {
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([
makeSummary('dm-1', 'Revenue Model', 'Finance/Revenue'),
]),
getDataModelSpec: vi.fn().mockResolvedValue({ schemaVersion: 1 }),
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const manifest = JSON.parse(await readFile(join(stagedDir, 'sigma-manifest.json'), 'utf-8'));
expect(manifest.sigmaConnectionId).toBe('sigma-prod');
expect(manifest.dataModelCount).toBe(1);
expect(manifest.fetchedAt).toBeDefined();
});
it('writes one data-model file per active model', async () => {
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([
makeSummary('dm-1', 'Revenue Model', 'Finance/Revenue'),
makeSummary('dm-2', 'ARR Model', 'Finance/ARR'),
]),
getDataModelSpec: vi.fn().mockResolvedValue({ schemaVersion: 1 }),
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const dm1 = JSON.parse(await readFile(join(stagedDir, 'data-models', 'dm-1.json'), 'utf-8'));
const dm2 = JSON.parse(await readFile(join(stagedDir, 'data-models', 'dm-2.json'), 'utf-8'));
expect(dm1.name).toBe('Revenue Model');
expect(dm2.name).toBe('ARR Model');
});
it('skips archived models and does not write their files', async () => {
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([
makeSummary('dm-1', 'Active Model', 'Finance/Active', false),
makeSummary('dm-archived', 'Archived Model', 'Finance/Old', true),
]),
getDataModelSpec: vi.fn().mockResolvedValue({ schemaVersion: 1 }),
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const manifest = JSON.parse(await readFile(join(stagedDir, 'sigma-manifest.json'), 'utf-8'));
expect(manifest.dataModelCount).toBe(1);
await expect(readFile(join(stagedDir, 'data-models', 'dm-archived.json'), 'utf-8')).rejects.toThrow();
});
it('logs a specific message for unsupported data source subtype (service_error)', async () => {
const warnMessages: string[] = [];
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([
makeSummary('dm-1', 'CSV Upload Model', 'Finance/CSV'),
]),
getDataModelSpec: vi.fn().mockRejectedValue(
new Error('Sigma API error (500): {"code":"service_error","message":"dataSource subtype not supported in data model read"}'),
),
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
logger: { log: () => undefined, warn: (m) => warnMessages.push(m) },
});
expect(warnMessages[0]).toContain('data source type not supported');
expect(warnMessages[0]).not.toContain('Sigma API error (500)');
});
it('writes null spec when getDataModelSpec throws, and does not abort the whole fetch', async () => {
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([
makeSummary('dm-1', 'Good Model', 'Finance/Good'),
makeSummary('dm-2', 'Broken Model', 'Finance/Broken'),
]),
getDataModelSpec: vi
.fn()
.mockResolvedValueOnce({ schemaVersion: 1 })
.mockRejectedValueOnce(new Error('Spec fetch failed')),
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const dm2 = JSON.parse(await readFile(join(stagedDir, 'data-models', 'dm-2.json'), 'utf-8'));
expect(dm2.spec).toBeNull();
const manifest = JSON.parse(await readFile(join(stagedDir, 'sigma-manifest.json'), 'utf-8'));
expect(manifest.dataModelCount).toBe(2);
});
it('calls cleanup on the client even when an error is thrown', async () => {
const cleanupMock = vi.fn().mockResolvedValue(undefined);
const factory = makeFactory({
listDataModels: vi.fn().mockRejectedValue(new Error('Network failure')),
cleanup: cleanupMock,
});
await expect(
fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
}),
).rejects.toThrow('Network failure');
expect(cleanupMock).toHaveBeenCalledOnce();
});
it('passes the resolved config to clientFactory.createClient', async () => {
const createClientMock = vi.fn().mockResolvedValue({
testConnection: vi.fn(),
listDataModels: vi.fn().mockResolvedValue([]),
listWorkbooks: vi.fn().mockResolvedValue([]),
getDataModelSpec: vi.fn(),
cleanup: vi.fn().mockResolvedValue(undefined),
} satisfies SigmaRuntimeClient);
const factory: SigmaClientFactory = { createClient: createClientMock };
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const calledConfig = createClientMock.mock.calls[0]![0] as SigmaPullConfig;
expect(calledConfig.sigmaConnectionId).toBe('sigma-prod');
});
it('writes sigma-projection-config.json with connectionMappings from pullConfig', async () => {
const factory = makeFactory({});
await fetchSigmaBundle({
pullConfig: { sigmaConnectionId: 'sigma-prod', connectionMappings: { 'uuid-1': 'snowflake-prod' } },
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const config = JSON.parse(await readFile(join(stagedDir, 'sigma-projection-config.json'), 'utf-8'));
expect(config.connectionMappings['uuid-1']).toBe('snowflake-prod');
});
it('writes sigma-projection-config.json with empty mappings when none are provided', async () => {
const factory = makeFactory({});
await fetchSigmaBundle({ pullConfig: TEST_PULL_CONFIG, stagedDir, ctx: {} as never, clientFactory: factory });
const config = JSON.parse(await readFile(join(stagedDir, 'sigma-projection-config.json'), 'utf-8'));
expect(config.connectionMappings).toEqual({});
});
it('writes workbookFilter defaults to projection config when not specified', async () => {
const factory = makeFactory({});
await fetchSigmaBundle({ pullConfig: TEST_PULL_CONFIG, stagedDir, ctx: {} as never, clientFactory: factory });
const config = JSON.parse(await readFile(join(stagedDir, 'sigma-projection-config.json'), 'utf-8'));
expect(config.workbookFilter.includeArchived).toBe(false);
expect(config.workbookFilter.includeExplorations).toBe(false);
expect(config.workbookFilter.updatedSince).toBeUndefined();
});
it('writes explicit workbookFilter settings to projection config', async () => {
const factory = makeFactory({});
await fetchSigmaBundle({
pullConfig: {
sigmaConnectionId: 'sigma-prod',
workbookFilter: { includeArchived: true, includeExplorations: false, updatedSince: '2026-01-01T00:00:00Z' },
},
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const config = JSON.parse(await readFile(join(stagedDir, 'sigma-projection-config.json'), 'utf-8'));
expect(config.workbookFilter.includeArchived).toBe(true);
expect(config.workbookFilter.updatedSince).toBe('2026-01-01T00:00:00Z');
});
it('throws on invalid pullConfig', async () => {
const factory = makeFactory({});
await expect(
fetchSigmaBundle({
pullConfig: { sigmaConnectionId: 'invalid id with spaces' },
stagedDir,
ctx: {} as never,
clientFactory: factory,
}),
).rejects.toThrow();
});
it('handles zero active models gracefully', async () => {
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([]),
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
const manifest = JSON.parse(await readFile(join(stagedDir, 'sigma-manifest.json'), 'utf-8'));
expect(manifest.dataModelCount).toBe(0);
});
it('skips spec fetch for a model whose updatedAt matches the existing staged file', async () => {
const summary = makeSummary('dm-1', 'Revenue Model', 'Finance/Revenue');
// Pre-populate a staged file with the same updatedAt.
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
const existingStaged = {
sigmaId: 'dm-1',
name: 'Revenue Model',
path: 'Finance/Revenue',
latestVersion: 1,
updatedAt: summary.updatedAt,
isArchived: false,
spec: { schemaVersion: 1, name: 'old' },
};
await writeFile(
join(stagedDir, 'data-models', 'dm-1.json'),
JSON.stringify(existingStaged),
'utf-8',
);
const getSpecMock = vi.fn().mockResolvedValue({ schemaVersion: 1 });
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([summary]),
getDataModelSpec: getSpecMock,
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
// Spec fetch must be skipped for the unchanged model.
expect(getSpecMock).not.toHaveBeenCalled();
});
it('retries spec fetch for a model whose updatedAt matches but staged spec is null (transient failure)', async () => {
const summary = makeSummary('dm-1', 'Revenue Model', 'Finance/Revenue');
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
const existingStaged = {
sigmaId: 'dm-1',
name: 'Revenue Model',
path: 'Finance/Revenue',
latestVersion: 1,
updatedAt: summary.updatedAt,
isArchived: false,
spec: null,
};
await writeFile(
join(stagedDir, 'data-models', 'dm-1.json'),
JSON.stringify(existingStaged),
'utf-8',
);
const freshSpec = { schemaVersion: 1, name: 'Revenue Model' };
const getSpecMock = vi.fn().mockResolvedValue(freshSpec);
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([summary]),
getDataModelSpec: getSpecMock,
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
expect(getSpecMock).toHaveBeenCalledWith('dm-1');
const written = JSON.parse(await readFile(join(stagedDir, 'data-models', 'dm-1.json'), 'utf-8'));
expect(written.spec).toEqual(freshSpec);
});
it('writes workbook count to manifest', async () => {
const factory = makeFactory({
listWorkbooks: vi.fn().mockResolvedValue([
{ workbookId: 'wb-1', workbookUrlId: 'wb-url-1', name: 'Sales Dashboard', path: 'Finance/Dashboards', latestVersion: 1, ownerId: 'u1', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-15T00:00:00Z', isArchived: false },
{ workbookId: 'wb-2', workbookUrlId: 'wb-url-2', name: 'ARR Tracker', path: 'Finance/Dashboards', latestVersion: 2, ownerId: 'u1', createdAt: '2026-01-02T00:00:00Z', updatedAt: '2026-01-16T00:00:00Z', isArchived: false },
]),
});
await fetchSigmaBundle({ pullConfig: TEST_PULL_CONFIG, stagedDir, ctx: {} as never, clientFactory: factory });
const manifest = JSON.parse(await readFile(join(stagedDir, 'sigma-manifest.json'), 'utf-8'));
expect(manifest.workbookCount).toBe(2);
});
it('writes one staged file per active workbook', async () => {
const factory = makeFactory({
listWorkbooks: vi.fn().mockResolvedValue([
{ workbookId: 'wb-1', workbookUrlId: 'wb-url-1', name: 'Sales Dashboard', path: 'Finance/Dashboards', latestVersion: 1, ownerId: 'u1', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-15T00:00:00Z', isArchived: false, description: 'Finance overview' },
]),
});
await fetchSigmaBundle({ pullConfig: TEST_PULL_CONFIG, stagedDir, ctx: {} as never, clientFactory: factory });
const wb = JSON.parse(await readFile(join(stagedDir, 'workbooks', 'wb-1.json'), 'utf-8'));
expect(wb.name).toBe('Sales Dashboard');
expect(wb.description).toBe('Finance overview');
});
it('skips workbook re-staging when updatedAt is unchanged', async () => {
await mkdir(join(stagedDir, 'workbooks'), { recursive: true });
const existing = { sigmaId: 'wb-1', name: 'Sales Dashboard', path: 'Finance', latestVersion: 1, updatedAt: '2026-01-15T00:00:00Z', isArchived: false };
await writeFile(join(stagedDir, 'workbooks', 'wb-1.json'), JSON.stringify(existing), 'utf-8');
const listWorkbooksMock = vi.fn().mockResolvedValue([
{ workbookId: 'wb-1', workbookUrlId: 'wb-url-1', name: 'Sales Dashboard', path: 'Finance', latestVersion: 1, ownerId: 'u1', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-15T00:00:00Z', isArchived: false },
]);
const factory = makeFactory({ listWorkbooks: listWorkbooksMock });
await fetchSigmaBundle({ pullConfig: TEST_PULL_CONFIG, stagedDir, ctx: {} as never, clientFactory: factory });
// File should still contain the pre-existing content (not overwritten).
const wb = JSON.parse(await readFile(join(stagedDir, 'workbooks', 'wb-1.json'), 'utf-8'));
expect(wb.sigmaId).toBe('wb-1');
});
it('removes the staged file when a model is no longer in the active list', async () => {
// Pre-populate a staged file for dm-stale.
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
const staleStaged = {
sigmaId: 'dm-stale',
name: 'Stale Model',
path: 'Old/Stale',
latestVersion: 1,
updatedAt: '2026-01-01T00:00:00Z',
isArchived: false,
spec: null,
};
await writeFile(
join(stagedDir, 'data-models', 'dm-stale.json'),
JSON.stringify(staleStaged),
'utf-8',
);
// API now returns only dm-1 (dm-stale was archived or deleted).
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([makeSummary('dm-1', 'Active Model', 'Finance/Active')]),
getDataModelSpec: vi.fn().mockResolvedValue({ schemaVersion: 1 }),
});
await fetchSigmaBundle({
pullConfig: TEST_PULL_CONFIG,
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
await expect(
readFile(join(stagedDir, 'data-models', 'dm-stale.json'), 'utf-8'),
).rejects.toThrow();
// The active model's file must still exist.
await expect(
readFile(join(stagedDir, 'data-models', 'dm-1.json'), 'utf-8'),
).resolves.toBeDefined();
});
it('filters spec fetches by dataModelFilter.updatedSince but preserves existing staged files for filtered-out models', async () => {
// Pre-stage the old model from a previous full fetch.
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
const oldStaged = {
sigmaId: 'dm-old', name: 'Old Model', path: 'Finance/Old',
latestVersion: 1, updatedAt: '2026-06-20T00:00:00Z', isArchived: false, spec: { schemaVersion: 0 },
};
await writeFile(join(stagedDir, 'data-models', 'dm-old.json'), JSON.stringify(oldStaged), 'utf-8');
const getSpecMock = vi.fn().mockResolvedValue({ schemaVersion: 1 });
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([
{ ...makeSummary('dm-old', 'Old Model', 'Finance/Old'), updatedAt: '2026-06-20T00:00:00Z' },
{ ...makeSummary('dm-new', 'New Model', 'Finance/New'), updatedAt: '2026-06-26T00:00:00Z' },
]),
getDataModelSpec: getSpecMock,
});
await fetchSigmaBundle({
pullConfig: { sigmaConnectionId: 'sigma-prod', dataModelFilter: { updatedSince: '2026-06-25T00:00:00Z' } },
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
// Only the new model's spec is fetched (old one is outside the filter window).
expect(getSpecMock).toHaveBeenCalledTimes(1);
// Manifest reflects only the filtered count.
const manifest = JSON.parse(await readFile(join(stagedDir, 'sigma-manifest.json'), 'utf-8'));
expect(manifest.dataModelCount).toBe(1);
// New model is staged.
await expect(readFile(join(stagedDir, 'data-models', 'dm-new.json'), 'utf-8')).resolves.toBeDefined();
// Old model's staged file is PRESERVED — it is still active, just outside the filter window.
await expect(readFile(join(stagedDir, 'data-models', 'dm-old.json'), 'utf-8')).resolves.toBeDefined();
});
it('includes all active models when dataModelFilter is not set', async () => {
const factory = makeFactory({
listDataModels: vi.fn().mockResolvedValue([
{ ...makeSummary('dm-old', 'Old Model', 'Finance/Old'), updatedAt: '2026-01-01T00:00:00Z' },
{ ...makeSummary('dm-new', 'New Model', 'Finance/New'), updatedAt: '2026-06-26T00:00:00Z' },
]),
getDataModelSpec: vi.fn().mockResolvedValue({ schemaVersion: 1 }),
});
await fetchSigmaBundle({ pullConfig: TEST_PULL_CONFIG, stagedDir, ctx: {} as never, clientFactory: factory });
const manifest = JSON.parse(await readFile(join(stagedDir, 'sigma-manifest.json'), 'utf-8'));
expect(manifest.dataModelCount).toBe(2);
});
it('removes the staged file when a workbook is no longer returned by the API', async () => {
await mkdir(join(stagedDir, 'workbooks'), { recursive: true });
const stale = {
sigmaId: 'wb-stale',
name: 'Old Dashboard',
path: 'Finance/Old',
latestVersion: 1,
updatedAt: '2026-01-01T00:00:00Z',
isArchived: false,
};
await writeFile(join(stagedDir, 'workbooks', 'wb-stale.json'), JSON.stringify(stale), 'utf-8');
const factory = makeFactory({
listWorkbooks: vi.fn().mockResolvedValue([
{ workbookId: 'wb-active', workbookUrlId: 'wb-url-active', name: 'Active Dashboard', path: 'Finance/Active', latestVersion: 1, ownerId: 'u1', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-16T00:00:00Z', isArchived: false },
]),
});
await fetchSigmaBundle({ pullConfig: TEST_PULL_CONFIG, stagedDir, ctx: {} as never, clientFactory: factory });
await expect(readFile(join(stagedDir, 'workbooks', 'wb-stale.json'), 'utf-8')).rejects.toThrow();
await expect(readFile(join(stagedDir, 'workbooks', 'wb-active.json'), 'utf-8')).resolves.toBeDefined();
});
it('workbookFilter.updatedSince filters fetch but preserves existing staged files for older workbooks', async () => {
// Pre-stage an old workbook from a previous full fetch.
await mkdir(join(stagedDir, 'workbooks'), { recursive: true });
const oldStaged = {
sigmaId: 'wb-old', name: 'Old Dashboard', path: 'Finance/Old',
latestVersion: 1, updatedAt: '2026-06-20T00:00:00Z', isArchived: false, workbookUrlId: 'wb-url-old',
};
await writeFile(join(stagedDir, 'workbooks', 'wb-old.json'), JSON.stringify(oldStaged), 'utf-8');
const listWorkbooksMock = vi.fn().mockResolvedValue([
{ workbookId: 'wb-old', workbookUrlId: 'wb-url-old', name: 'Old Dashboard', path: 'Finance/Old', latestVersion: 1, ownerId: 'u1', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-06-20T00:00:00Z', isArchived: false },
{ workbookId: 'wb-new', workbookUrlId: 'wb-url-new', name: 'New Dashboard', path: 'Finance/New', latestVersion: 1, ownerId: 'u1', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-06-26T00:00:00Z', isArchived: false },
]);
const factory = makeFactory({ listWorkbooks: listWorkbooksMock });
await fetchSigmaBundle({
pullConfig: { sigmaConnectionId: 'sigma-prod', workbookFilter: { updatedSince: '2026-06-25T00:00:00Z' } },
stagedDir,
ctx: {} as never,
clientFactory: factory,
});
// Only the new workbook is staged on this run.
await expect(readFile(join(stagedDir, 'workbooks', 'wb-new.json'), 'utf-8')).resolves.toBeDefined();
// Old workbook's staged file is PRESERVED — it is still active, just outside the filter window.
await expect(readFile(join(stagedDir, 'workbooks', 'wb-old.json'), 'utf-8')).resolves.toBeDefined();
// listWorkbooks is called without updatedSince to get the full universe for eviction.
expect(listWorkbooksMock).toHaveBeenCalledWith(expect.not.objectContaining({ updatedSince: expect.anything() }));
});
});

View file

@ -0,0 +1,301 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { projectSigmaDataModels } from '../../../../../src/context/ingest/adapters/sigma/project.js';
import type { DeterministicProjectionContext } from '../../../../../src/context/ingest/types.js';
import type { SemanticLayerService } from '../../../../../src/context/sl/semantic-layer.service.js';
import type { SemanticLayerSource } from '../../../../../src/context/sl/types.js';
function makeCtx(
stagedDir: string,
writeSource: (connectionId: string, source: SemanticLayerSource, ...rest: string[]) => Promise<{ warnings: string[] }>,
): DeterministicProjectionContext {
const svc = {
writeSource,
forWorktree: () => ({ writeSource }),
} as unknown as SemanticLayerService;
return {
connectionId: 'sigma-prod',
sourceKey: 'sigma-prod',
syncId: 'sync-1',
jobId: 'job-1',
runId: 'run-1',
stagedDir,
workdir: '',
semanticLayerService: svc,
};
}
function makeSpec(elements: unknown[]) {
return {
schemaVersion: 1,
name: 'Test Model',
pages: [{ id: 'p1', name: 'Main', elements }],
};
}
function makeStagedModel(id: string, name: string, spec: unknown) {
return JSON.stringify({
sigmaId: id,
name,
path: 'Finance/Models',
latestVersion: 1,
updatedAt: '2026-01-15T00:00:00Z',
isArchived: false,
spec,
});
}
/** Write a projection config that maps the given sigma connection IDs to 'warehouse-main'. */
async function writeProjectionConfig(stagedDir: string, sigmaConnectionIds: string[]): Promise<void> {
const mappings = Object.fromEntries(sigmaConnectionIds.map((id) => [id, 'warehouse-main']));
await writeFile(
join(stagedDir, 'sigma-projection-config.json'),
JSON.stringify({ connectionMappings: mappings }),
'utf-8',
);
}
describe('projectSigmaDataModels', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'sigma-project-'));
await mkdir(join(stagedDir, 'data-models'), { recursive: true });
});
afterEach(async () => {
await rm(stagedDir, { recursive: true, force: true });
});
it('returns empty result when data-models directory is missing', async () => {
const emptyDir = await mkdtemp(join(tmpdir(), 'sigma-project-empty-'));
try {
const writeSource = vi.fn().mockResolvedValue({ warnings: [] });
const result = await projectSigmaDataModels(makeCtx(emptyDir, writeSource), makeCtx(emptyDir, writeSource).semanticLayerService as never);
expect(result.touchedSources).toHaveLength(0);
expect(writeSource).not.toHaveBeenCalled();
} finally {
await rm(emptyDir, { recursive: true, force: true });
}
});
it('converts a warehouse-table element to a semantic-layer source', async () => {
await writeProjectionConfig(stagedDir, ['sigma-conn-uuid']);
const spec = makeSpec([
{
id: 'elem1',
kind: 'table',
name: 'Opportunities',
source: { kind: 'warehouse-table', connectionId: 'sigma-conn-uuid', path: ['FIVETRAN', 'SALESFORCE', 'OPPORTUNITIES'] },
columns: [
{ id: 'c1', formula: '[OPPORTUNITIES/Amount]', name: 'Deal Amount' },
{ id: 'c2', formula: 'Sum([OPPORTUNITIES/Amount])', name: 'Total Amount' },
],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Revenue Model', spec));
const written: Array<{ connectionId: string; source: SemanticLayerSource }> = [];
const writeSource = vi.fn().mockImplementation((connectionId: string, source: SemanticLayerSource) => {
written.push({ connectionId, source });
return Promise.resolve({ warnings: [] });
});
const result = await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
expect(writeSource).toHaveBeenCalledOnce();
expect(written[0]!.connectionId).toBe('warehouse-main');
const source = written[0]!.source;
expect(source.table).toBe('FIVETRAN.SALESFORCE.OPPORTUNITIES');
expect(source.columns.some((c) => c.name === 'deal_amount')).toBe(true);
expect(source.columns.some((c) => c.name === 'total_amount')).toBe(false);
expect(source.measures).toEqual([]);
expect(result.touchedSources).toHaveLength(1);
expect(result.errors).toHaveLength(0);
});
it('skips elements whose source kind is not warehouse-table', async () => {
const spec = makeSpec([
{
id: 'elem1',
kind: 'table',
name: 'Derived',
source: { kind: 'data-model', dataModelId: 'dm-other', elementId: 'e1' },
columns: [{ id: 'c1', formula: '[Derived/Revenue]' }],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Derived Model', spec));
const writeSource = vi.fn().mockResolvedValue({ warnings: [] });
const result = await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
expect(writeSource).not.toHaveBeenCalled();
expect(result.touchedSources).toHaveLength(0);
});
it('skips hidden elements', async () => {
const spec = makeSpec([
{
id: 'elem1',
kind: 'table',
name: 'Hidden',
hidden: true,
source: { kind: 'warehouse-table', connectionId: 'c', path: ['DB', 'SCHEMA', 'TABLE'] },
columns: [{ id: 'c1', formula: '[TABLE/Col]' }],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Hidden Model', spec));
const writeSource = vi.fn().mockResolvedValue({ warnings: [] });
const result = await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
expect(writeSource).not.toHaveBeenCalled();
expect(result.touchedSources).toHaveLength(0);
});
it('skips hidden columns', async () => {
await writeProjectionConfig(stagedDir, ['c']);
const spec = makeSpec([
{
id: 'elem1',
kind: 'table',
name: 'Revenue',
source: { kind: 'warehouse-table', connectionId: 'c', path: ['DB', 'S', 'T'] },
columns: [
{ id: 'c1', formula: '[T/Visible]', name: 'Visible' },
{ id: 'c2', formula: '[T/Hidden]', name: 'Hidden', hidden: true },
],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Revenue', spec));
const written: SemanticLayerSource[] = [];
const writeSource = vi.fn().mockImplementation((_: string, source: SemanticLayerSource) => {
written.push(source);
return Promise.resolve({ warnings: [] });
});
await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
const source = written[0]!;
expect(source.columns.some((c) => c.name === 'visible')).toBe(true);
expect(source.columns.some((c) => c.name === 'hidden')).toBe(false);
});
it('silently skips aggregation formula columns and never emits measures', async () => {
await writeProjectionConfig(stagedDir, ['c']);
const spec = makeSpec([
{
id: 'e1',
kind: 'table',
name: 'Sales',
source: { kind: 'warehouse-table', connectionId: 'c', path: ['DB', 'S', 'ORDERS'] },
columns: [
{ id: 'c1', formula: 'Sum([ORDERS/Revenue])', name: 'Total Revenue' },
{ id: 'c2', formula: 'CountDistinct([ORDERS/CustomerId])', name: 'Unique Customers' },
{ id: 'c3', formula: '[ORDERS/OrderDate]', name: 'Order Date' },
],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Sales', spec));
const written: SemanticLayerSource[] = [];
const writeSource = vi.fn().mockImplementation((_: string, source: SemanticLayerSource) => {
written.push(source);
return Promise.resolve({ warnings: [] });
});
await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
const source = written[0]!;
expect(source.measures).toEqual([]);
expect(source.columns.map((c) => c.name)).toContain('order_date');
expect(source.columns.map((c) => c.name)).not.toContain('total_revenue');
expect(source.columns.map((c) => c.name)).not.toContain('unique_customers');
});
it('skips models with null spec', async () => {
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'No Spec Model', null));
const writeSource = vi.fn().mockResolvedValue({ warnings: [] });
const result = await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
expect(writeSource).not.toHaveBeenCalled();
expect(result.touchedSources).toHaveLength(0);
});
it('routes to the mapped warehouse connection when connectionMappings is set', async () => {
// Write a projection config that maps the Sigma internal connection UUID to a ktx warehouse.
await writeFile(
join(stagedDir, 'sigma-projection-config.json'),
JSON.stringify({ connectionMappings: { 'sigma-internal-uuid': 'snowflake-prod' } }),
'utf-8',
);
const spec = makeSpec([
{
id: 'e1',
kind: 'table',
name: 'Accounts',
source: { kind: 'warehouse-table', connectionId: 'sigma-internal-uuid', path: ['PROD', 'SF', 'ACCOUNTS'] },
columns: [{ id: 'c1', formula: '[ACCOUNTS/Name]', name: 'Account Name' }],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Accounts', spec));
const written: Array<{ connectionId: string }> = [];
const writeSource = vi.fn().mockImplementation((connectionId: string) => {
written.push({ connectionId });
return Promise.resolve({ warnings: [] });
});
await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
expect(written[0]!.connectionId).toBe('snowflake-prod');
});
it('skips SL source and emits a warning when no connectionMappings entry exists for the element', async () => {
await writeFile(
join(stagedDir, 'sigma-projection-config.json'),
JSON.stringify({ connectionMappings: { 'other-uuid': 'snowflake-prod' } }),
'utf-8',
);
const spec = makeSpec([
{
id: 'e1',
kind: 'table',
name: 'Orders',
source: { kind: 'warehouse-table', connectionId: 'unmapped-uuid', path: ['DB', 'S', 'ORDERS'] },
columns: [{ id: 'c1', formula: '[ORDERS/Id]', name: 'Order Id' }],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Orders', spec));
const writeSource = vi.fn().mockResolvedValue({ warnings: [] });
const result = await projectSigmaDataModels(
makeCtx(stagedDir, writeSource),
makeCtx(stagedDir, writeSource).semanticLayerService as never,
);
expect(writeSource).not.toHaveBeenCalled();
expect(result.touchedSources).toHaveLength(0);
expect(result.warnings.some((w) => w.includes('no connectionMappings entry'))).toBe(true);
});
it('surfaces writeSource warnings in result', async () => {
await writeProjectionConfig(stagedDir, ['c']);
const spec = makeSpec([
{
id: 'e1',
kind: 'table',
name: 'Revenue',
source: { kind: 'warehouse-table', connectionId: 'c', path: ['DB', 'S', 'T'] },
columns: [{ id: 'c1', formula: '[T/Amount]', name: 'Amount' }],
},
]);
await writeFile(join(stagedDir, 'data-models', 'dm-1.json'), makeStagedModel('dm-1', 'Revenue', spec));
const writeSource = vi.fn().mockResolvedValue({ warnings: ['schema: some warning'] });
const result = await projectSigmaDataModels(makeCtx(stagedDir, writeSource), makeCtx(stagedDir, writeSource).semanticLayerService as never);
expect(result.warnings).toContain('schema: some warning');
});
});

View file

@ -0,0 +1,64 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SigmaSourceAdapter } from '../../../../../src/context/ingest/adapters/sigma/sigma.adapter.js';
import type { SigmaClientFactory } from '../../../../../src/context/ingest/adapters/sigma/client-port.js';
function makeFactory(): SigmaClientFactory {
return { createClient: vi.fn() };
}
describe('SigmaSourceAdapter.listTargetConnectionIds', () => {
let stagedDir: string;
beforeEach(async () => {
stagedDir = await mkdtemp(join(tmpdir(), 'sigma-adapter-'));
});
afterEach(async () => {
await rm(stagedDir, { recursive: true, force: true });
});
async function writeProjectionConfig(mappings: Record<string, string>) {
await writeFile(
join(stagedDir, 'sigma-projection-config.json'),
JSON.stringify({ connectionMappings: mappings }),
'utf-8',
);
}
it('returns mapped warehouse connection IDs when mappings are present', async () => {
await writeProjectionConfig({ 'uuid-a': 'snowflake-prod', 'uuid-b': 'snowflake-prod', 'uuid-c': 'bigquery-prod' });
const adapter = new SigmaSourceAdapter({ clientFactory: makeFactory() });
const ids = await adapter.listTargetConnectionIds(stagedDir);
expect(ids).toEqual(['bigquery-prod', 'snowflake-prod']);
});
it('returns empty array when connectionMappings is empty', async () => {
await writeProjectionConfig({});
const adapter = new SigmaSourceAdapter({ clientFactory: makeFactory() });
const ids = await adapter.listTargetConnectionIds(stagedDir);
expect(ids).toEqual([]);
});
it('returns empty array when the projection config file is missing', async () => {
const adapter = new SigmaSourceAdapter({ clientFactory: makeFactory() });
const ids = await adapter.listTargetConnectionIds(stagedDir);
expect(ids).toEqual([]);
});
it('returns empty array when the projection config is malformed', async () => {
await mkdir(stagedDir, { recursive: true });
await writeFile(join(stagedDir, 'sigma-projection-config.json'), 'not json', 'utf-8');
const adapter = new SigmaSourceAdapter({ clientFactory: makeFactory() });
const ids = await adapter.listTargetConnectionIds(stagedDir);
expect(ids).toEqual([]);
});
it('returns empty array when both projection config and manifest are missing', async () => {
const adapter = new SigmaSourceAdapter({ clientFactory: makeFactory() });
const ids = await adapter.listTargetConnectionIds(stagedDir);
expect(ids).toEqual([]);
});
});

View file

@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import {
parseSigmaPullConfig,
sigmaManifestSchema,
stagedDataModelFileSchema,
} from '../../../../../src/context/ingest/adapters/sigma/types.js';
describe('parseSigmaPullConfig', () => {
it('accepts a simple alphanumeric connection ID', () => {
const result = parseSigmaPullConfig({ sigmaConnectionId: 'sigma-prod' });
expect(result.sigmaConnectionId).toBe('sigma-prod');
});
it('accepts IDs with underscores', () => {
const result = parseSigmaPullConfig({ sigmaConnectionId: 'sigma_prod_2' });
expect(result.sigmaConnectionId).toBe('sigma_prod_2');
});
it('rejects IDs starting with a special char', () => {
expect(() => parseSigmaPullConfig({ sigmaConnectionId: '../prod' })).toThrow();
});
it('rejects IDs with spaces', () => {
expect(() => parseSigmaPullConfig({ sigmaConnectionId: 'sigma prod' })).toThrow();
});
it('rejects missing sigmaConnectionId', () => {
expect(() => parseSigmaPullConfig({})).toThrow();
});
it('rejects null', () => {
expect(() => parseSigmaPullConfig(null)).toThrow();
});
});
describe('stagedDataModelFileSchema', () => {
const minimal = {
sigmaId: 'dm-aaa111',
name: 'Revenue Model',
path: 'My Documents/Finance/Revenue Model',
latestVersion: 3,
updatedAt: '2026-01-15T10:00:00Z',
isArchived: false,
spec: { schemaVersion: 1, pages: [] },
};
it('parses a fully-populated file', () => {
const result = stagedDataModelFileSchema.parse(minimal);
expect(result.sigmaId).toBe('dm-aaa111');
expect(result.name).toBe('Revenue Model');
expect(result.isArchived).toBe(false);
});
it('coerces absent isArchived to false', () => {
const { isArchived: _, ...rest } = minimal;
void _;
const result = stagedDataModelFileSchema.parse(rest);
expect(result.isArchived).toBe(false);
});
it('accepts null spec', () => {
const result = stagedDataModelFileSchema.parse({ ...minimal, spec: null });
expect(result.spec).toBeNull();
});
it('rejects missing sigmaId', () => {
const { sigmaId: _, ...rest } = minimal;
void _;
expect(() => stagedDataModelFileSchema.parse(rest)).toThrow();
});
it('rejects missing name', () => {
const { name: _, ...rest } = minimal;
void _;
expect(() => stagedDataModelFileSchema.parse(rest)).toThrow();
});
it('rejects missing path', () => {
const { path: _, ...rest } = minimal;
void _;
expect(() => stagedDataModelFileSchema.parse(rest)).toThrow();
});
});
describe('sigmaManifestSchema', () => {
const valid = {
sigmaConnectionId: 'sigma-prod',
fetchedAt: '2026-01-15T10:00:00Z',
dataModelCount: 2,
};
it('parses a valid manifest', () => {
const result = sigmaManifestSchema.parse(valid);
expect(result.sigmaConnectionId).toBe('sigma-prod');
expect(result.dataModelCount).toBe(2);
});
it('rejects missing fetchedAt', () => {
const { fetchedAt: _, ...rest } = valid;
void _;
expect(() => sigmaManifestSchema.parse(rest)).toThrow();
});
it('rejects missing dataModelCount', () => {
const { dataModelCount: _, ...rest } = valid;
void _;
expect(() => sigmaManifestSchema.parse(rest)).toThrow();
});
it('rejects a non-integer dataModelCount', () => {
expect(() => sigmaManifestSchema.parse({ ...valid, dataModelCount: 2.5 })).toThrow();
});
});

View file

@ -73,6 +73,7 @@ describe('local ingest adapters', () => {
'lookml',
'dbt',
'metabase',
'sigma',
'gdrive',
'looker',
'metricflow',

View file

@ -33,6 +33,10 @@
},
"hint": {
"type": "string"
},
"queryPolicy": {
"type": "string",
"const": "semantic-layer-only"
}
},
"required": [

View file

@ -33,8 +33,7 @@ describe('per-dialect SQL notes', () => {
});
it('does not author notes for unreachable dialects', () => {
// duckdb/databricks appear in the resolver map but no connector produces them.
expect(DIALECTS_WITH_NOTES).not.toContain('duckdb');
// databricks appears in the resolver map but no connector produces it.
expect(DIALECTS_WITH_NOTES).not.toContain('databricks');
});

View file

@ -4,6 +4,7 @@ import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initKtxProject } from '../../../src/context/project/project.js';
import { KtxExpectedError, KtxQueryError } from '../../../src/errors.js';
import { KtxDaemonComputeError } from '../../../src/context/daemon/semantic-layer-compute.js';
import { createKtxConnectorCapabilities, type KtxQueryResult, type KtxScanConnector, type KtxSchemaSnapshot } from '../../../src/context/scan/types.js';
import { SemanticLayerService } from '../../../src/context/sl/semantic-layer.service.js';
import type { SemanticLayerSource } from '../../../src/context/sl/types.js';
@ -247,6 +248,80 @@ describe('createLocalProjectMcpContextPorts', () => {
expect(connector.cleanup).toHaveBeenCalled();
});
it('omits sql_execution when every SQL connection is semantic-layer-only', async () => {
const project = await initKtxProject({ projectDir: tempDir });
project.config.connections.warehouse = {
driver: 'postgres',
url: 'env:DATABASE_URL',
query_policy: 'semantic-layer-only',
};
const ports = createLocalProjectMcpContextPorts(project, {
sqlAnalysis: {
analyzeForFingerprint: vi.fn(),
analyzeBatch: vi.fn(),
validateReadOnly: vi.fn(async () => ({ ok: true, error: null })),
},
localScan: { createConnector: vi.fn(async () => testConnector()) },
embeddingService: null,
});
expect(ports.sqlExecution).toBeUndefined();
});
it('keeps sql_execution in mixed projects but rejects restricted connections and flags them in connection_list', async () => {
const project = await initKtxProject({ projectDir: tempDir });
project.config.connections.warehouse = {
driver: 'postgres',
url: 'env:DATABASE_URL',
};
project.config.connections.finance = {
driver: 'postgres',
url: 'env:FINANCE_URL',
query_policy: 'semantic-layer-only',
};
const createConnector = vi.fn(async () => testConnector());
const sqlAnalysis = {
analyzeForFingerprint: vi.fn(),
analyzeBatch: vi.fn(),
validateReadOnly: vi.fn(async () => ({ ok: true, error: null })),
};
const ports = createLocalProjectMcpContextPorts(project, {
sqlAnalysis,
localScan: { createConnector },
embeddingService: null,
});
expect(ports.sqlExecution).toBeDefined();
const execution = ports.sqlExecution?.execute({
connectionId: 'finance',
sql: 'select 1',
maxRows: 5,
});
await expect(execution).rejects.toBeInstanceOf(KtxQueryError);
await expect(execution).rejects.toThrow(/query_policy: semantic-layer-only/);
expect(sqlAnalysis.validateReadOnly).not.toHaveBeenCalled();
expect(createConnector).not.toHaveBeenCalled();
// Both postgres members federate, so the restricted member also blocks federated raw SQL.
await expect(
ports.sqlExecution?.execute({ connectionId: '_ktx_federated', sql: 'select 1', maxRows: 5 }),
).rejects.toThrow(/"finance"/);
await expect(ports.connections?.list()).resolves.toEqual([
expect.objectContaining({
id: 'finance',
queryPolicy: 'semantic-layer-only',
hint: expect.stringContaining('sl_query'),
}),
expect.objectContaining({ id: 'warehouse' }),
expect.objectContaining({
id: '_ktx_federated',
queryPolicy: 'semantic-layer-only',
hint: expect.stringContaining('finance'),
}),
]);
});
it('rejects sql_execution against an unconfigured connection with an actionable expected error', async () => {
const project = await initKtxProject({ projectDir: tempDir });
project.config.connections.warehouse = {
@ -1103,4 +1178,86 @@ describe('createLocalProjectMcpContextPorts', () => {
}),
);
});
async function seedOrdersWarehouse() {
const project = await initKtxProject({ projectDir: tempDir });
project.config.connections.warehouse = { driver: 'postgres', url: 'env:DATABASE_URL' };
await seedSlSourceFile(project, {
connectionId: 'warehouse',
sourceName: 'orders',
yaml: ['name: orders', 'table: public.orders', 'grain:', ' - id', 'columns:', ' - name: id', ' type: number', 'joins: []', 'measures: []', ''].join('\n'),
});
return project;
}
it('promotes a daemon input-rejection to an expected KtxQueryError carrying the daemon detail', async () => {
const project = await seedOrdersWarehouse();
const semanticLayerCompute = {
validateSources: vi.fn(),
generateSources: vi.fn(),
query: vi.fn(async () => {
throw new KtxDaemonComputeError("ktx-daemon semantic-query failed: Measure expr 'count(*)' does not reference any source", {
inputRejected: true,
detail: "Measure expr 'count(*)' does not reference any source",
});
}),
};
const ports = createLocalProjectMcpContextPorts(project, { semanticLayerCompute, embeddingService: null });
const rejection = ports.semanticLayer?.query({
connectionId: 'warehouse',
query: { measures: [{ expr: 'count(*)', name: 'n' }], dimensions: [] },
});
await expect(rejection).rejects.toBeInstanceOf(KtxQueryError);
await expect(rejection).rejects.toThrow("Measure expr 'count(*)' does not reference any source");
});
it('leaves a daemon crash as an unexpected fault', async () => {
const project = await seedOrdersWarehouse();
const semanticLayerCompute = {
validateSources: vi.fn(),
generateSources: vi.fn(),
query: vi.fn(async () => {
throw new KtxDaemonComputeError('ktx-daemon semantic-query failed: KeyError: boom', {
inputRejected: false,
detail: 'KeyError: boom',
});
}),
};
const ports = createLocalProjectMcpContextPorts(project, { semanticLayerCompute, embeddingService: null });
const rejection = ports.semanticLayer?.query({
connectionId: 'warehouse',
query: { measures: ['orders.order_count'], dimensions: [] },
});
await expect(rejection).rejects.toBeInstanceOf(KtxDaemonComputeError);
await expect(rejection).rejects.not.toBeInstanceOf(KtxQueryError);
});
it('wraps a warehouse execution rejection from sl_query as KtxQueryError', async () => {
const project = await seedOrdersWarehouse();
const semanticLayerCompute = {
validateSources: vi.fn(),
generateSources: vi.fn(),
query: vi.fn(async () => ({
sql: 'select count(*) from public.orders',
dialect: 'postgres',
columns: [{ name: 'orders.order_count' }],
plan: {},
})),
};
const queryExecutor = {
execute: vi.fn(async () => {
throw new Error("Unknown column '검사 유형' in 'SELECT'");
}),
};
const ports = createLocalProjectMcpContextPorts(project, { semanticLayerCompute, queryExecutor, embeddingService: null });
const rejection = ports.semanticLayer?.query({
connectionId: 'warehouse',
query: { measures: ['orders.order_count'], dimensions: [], limit: 5 },
});
await expect(rejection).rejects.toBeInstanceOf(KtxQueryError);
await expect(rejection).rejects.toThrow("Unknown column '검사 유형' in 'SELECT'");
});
});

View file

@ -24,6 +24,7 @@ const expectedAdapterSkillHeadings: Record<string, string> = {
lookml_ingest: '# LookML to ktx Semantic Layer',
metabase_ingest: '# Metabase to ktx Semantic Layer',
metricflow_ingest: '# MetricFlow to ktx Semantic Layer',
sigma_ingest: '# Sigma Ingest',
};
const verificationWriterSkills = [
'gdrive_synthesize',
@ -33,6 +34,7 @@ const verificationWriterSkills = [
'looker_ingest',
'metabase_ingest',
'metricflow_ingest',
'sigma_ingest',
'live_database_ingest',
'historic_sql_table_digest',
'historic_sql_patterns',

View file

@ -2175,6 +2175,40 @@ describe('local scan', () => {
};
expect(manifest.tables.orders?.joins?.some((join) => join.to === 'accounts')).toBe(true);
});
it('accepts athena as a native standalone scan driver when the host supplies a live-database adapter', async () => {
await writeFile(
join(project.projectDir, 'ktx.yaml'),
[
'connections:',
' warehouse:',
' driver: athena',
' region: us-east-1',
' s3_staging_dir: s3://my-bucket/athena-results/',
' databases:',
' - analytics',
'ingest:',
' adapters:',
' - live-database',
'',
].join('\n'),
'utf-8',
);
project = await loadKtxProject({ projectDir: project.projectDir });
const result = await runLocalScan({
project,
adapters: [fetchOnlyAdapter()],
connectionId: 'warehouse',
jobId: 'scan-run-athena',
now: () => new Date('2026-04-29T17:00:00.000Z'),
});
expect(result.report.driver).toBe('athena');
expect(result.report.artifactPaths.reportPath).toBe(
'raw-sources/warehouse/live-database/2026-04-29-170000-scan-run-athena/scan-report.json',
);
});
});
describe('resolveEnabledTables', () => {

View file

@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { KtxSemanticLayerComputePort } from '../../../src/context/daemon/semantic-layer-compute.js';
import { FEDERATED_CONNECTION_ID } from '../../../src/context/connections/federation.js';
import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js';
import { compileLocalSlQuery } from '../../../src/context/sl/local-query.js';
@ -67,6 +68,59 @@ grain: []
await rm(tempDir, { recursive: true, force: true });
});
it('injects predefined_measures_only when the connection query_policy is semantic-layer-only', async () => {
project.config.connections.warehouse = { driver: 'postgres', query_policy: 'semantic-layer-only' };
await compileLocalSlQuery(project, {
connectionId: 'warehouse',
query: { measures: ['orders.order_count'], dimensions: [], limit: 10 },
compute,
});
expect(compute.query).toHaveBeenCalledWith(
expect.objectContaining({
query: expect.objectContaining({ predefined_measures_only: true }),
}),
);
});
it('rejects a federated sl_query, pointing to per-connection SL when a member is restricted', async () => {
project.config.connections.warehouse = { driver: 'postgres', query_policy: 'semantic-layer-only' };
project.config.connections.analytics = { driver: 'postgres' };
let message = '';
try {
await compileLocalSlQuery(project, {
connectionId: FEDERATED_CONNECTION_ID,
query: { measures: ['orders.order_count'], dimensions: [] },
compute,
});
throw new Error('expected compileLocalSlQuery to reject');
} catch (e) {
message = (e as Error).message;
}
expect(message).toContain("member connection(s) 'warehouse'");
expect(message).toContain('query_policy: semantic-layer-only');
// Must not send the agent down the raw-SQL path that assertRawSqlAllowed rejects.
expect(message).not.toContain(`ktx sql -c ${FEDERATED_CONNECTION_ID}`);
expect(message).not.toContain('sql_execution');
expect(compute.query).not.toHaveBeenCalled();
});
it('rejects a federated sl_query, pointing to raw federated SQL when no member is restricted', async () => {
project.config.connections.analytics = { driver: 'postgres' };
await expect(
compileLocalSlQuery(project, {
connectionId: FEDERATED_CONNECTION_ID,
query: { measures: ['orders.order_count'], dimensions: [] },
compute,
}),
).rejects.toThrow(`ktx sql -c ${FEDERATED_CONNECTION_ID}`);
expect(compute.query).not.toHaveBeenCalled();
});
it('refuses a non-SQL (context-only) connection instead of compiling it as Postgres', async () => {
project.config.connections['mongo-prod'] = { driver: 'mongodb', url: 'mongodb://localhost:27017/app' };
await expect(
@ -109,6 +163,7 @@ grain: []
measures: ['orders.order_count'],
dimensions: ['orders.status'],
limit: 25,
predefined_measures_only: false,
},
});
expect(result).toEqual({
@ -190,6 +245,7 @@ grain: []
query: {
measures: ['sum(payments.amount)'],
dimensions: [],
predefined_measures_only: false,
},
});
});

Some files were not shown because too many files have changed in this diff Show more