Compare commits

...

74 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
semantic-release-bot
1ec29e82f7 chore(release): 0.14.0 [skip ci]
## [0.14.0](https://github.com/Kaelio/ktx/compare/v0.13.1...v0.14.0) (2026-06-30)

### Features

* **connectors:** add MongoDB connector ([#305](https://github.com/Kaelio/ktx/issues/305)) ([#310](https://github.com/Kaelio/ktx/issues/310)) ([2afab61](2afab61417))
* **docs:** add system theme option to theme toggle ([#324](https://github.com/Kaelio/ktx/issues/324)) ([4f08418](4f084186f1))
* ktx batch — scan resilience, analytics SQL craft, connector hardening ([#312](https://github.com/Kaelio/ktx/issues/312)) ([f65a5b0](f65a5b0e2e)), closes [#1](https://github.com/Kaelio/ktx/issues/1)

### Bug Fixes

* **gdrive:** validate folder access, run config test, harden Drive API ([#321](https://github.com/Kaelio/ktx/issues/321)) ([ca231df](ca231df5fe))

### Documentation

* improve CLI flags table readability ([#323](https://github.com/Kaelio/ktx/issues/323)) ([50afcae](50afcae9f4))

### Other Changes

* refresh star history chart [skip ci] ([46df7f3](46df7f3b24))
* refresh star history chart [skip ci] ([b0dca62](b0dca62c0e))
* refresh star history chart [skip ci] ([967a413](967a413a06))
* refresh star history chart [skip ci] ([89f2543](89f25435d5))
* refresh star history chart [skip ci] ([73e4c8b](73e4c8b270))
* refresh star history chart [skip ci] ([77c38e9](77c38e9ea2))
* refresh star history chart [skip ci] ([f61ea76](f61ea76007))
* refresh star history chart [skip ci] ([a155c0b](a155c0b844))
* remove private benchmark specs ([1c5d16a](1c5d16abc3))
2026-06-30 13:11:53 +00:00
Andrey Avtomonov
1c5d16abc3 chore: remove private benchmark specs 2026-06-30 11:13:44 +02:00
Andrey Avtomonov
67a69dba8b
Resumable and fault-tolerant source ingest (spec #22) (#315)
* docs: add spider2-specs handoff directory for benchmark-driven feature specs

* feat(cli): connection-scoped wiki pages

Add an optional `connections` frontmatter field so database-specific wiki
knowledge can be scoped to a connection without polluting searches about other
databases, while page keys stay a flat, globally-unique namespace.

- connections: single string or list; absent/empty ⇒ unscoped (applies to all)
- wiki_search (MCP) and `ktx wiki --connection` return unscoped ∪ matching
  pages, filtered at the disk-load seam so all three search lanes draw their
  candidate pool from the already-scoped set (not a post-filter)
- wiki_write accepts connections with REPLACE semantics and rejects a
  connection-scoped write whose key collides with a disjoint-connection page
  (data-loss guard; hard error, no silent clobber)
- explicit connection-id args (wiki_search, memory_ingest, ktx wiki) are
  validated against ktx.yaml via a shared assertConfiguredConnectionId, which
  also closes the prior gap where memory_ingest's connectionId was unvalidated;
  persisted ids absent from config warn (not fail) in `ktx status`
- prompt guidance in the wiki_capture skill and external-ingest prompt; the
  session connectionId is surfaced to the memory agent and ingest work units

Implements spider2-specs/specs/01-connection-scoped-wiki.md; intake draft moved
to spider2-specs/done/.

* docs(spider2-specs): add specs/ refinement stage and composite-key join spec

Describe the todo/ → specs/ → done/ pipeline in the README (refined specs are
the durable artifact; intake drafts move to done/ on ship) and add a
MEDIUM-priority spec for multi-column composite-key join detection found during
the first sqlite smoke test.

* feat(cli): add --verbatim ingest mode for authoritative documents

Store each --text/--file document body unchanged as a GLOBAL wiki page
instead of routing it through the memory agent, which may rewrite,
condense, or re-title it. The LLM derives only metadata (summary, tags,
sl_refs) and only for frontmatter fields the document does not already
set; the stored body is written by code and never edited.

- Deterministic page key: files derive it from the filename, inline
  text from its leading Markdown heading (headless inline text is
  rejected — pass it as --file instead).
- Idempotent: re-running the same body is a no-op; a different body at
  the same key fails loudly rather than overwriting.
- Works with llm.provider.backend: none, deriving a degraded summary
  from the heading or first sentence.
- Existing frontmatter (including unmodeled fields like effective_date)
  passes through untouched; --connection-id scopes the page.

* feat(cli): SQL-authoring craft and per-dialect notes tool for the analytics skill

Spec 07: add a dialect-agnostic <sql_craft> block to the ktx-analytics skill (schema discovery, composition, window-function correctness, numeric precision, answer completeness) with one worked window-then-filter example. Workflow steps gain pointers into it; existing guidance is unchanged.

Spec 08: add a read-only sql_dialect_notes MCP tool returning a connection's engine SQL conventions (FQTN form, identifier quoting/case, date/time, top-N idiom, JSON access), resolved through the existing sqlAnalysisDialectForDriver path. Notes are per-dialect markdown files under context/sql-analysis/dialects, served by the tool and copied to dist (package-internal, never installed). Non-SQL connections return a clear KtxExpectedError. The flat skill gains a one-line pointer to the tool.

Both spider2-specs intake drafts move to done/ with implementation notes.

* feat(cli): tolerate objects that fail introspection during scan

Isolate per-object introspection failures so one broken or inaccessible object no longer zeroes out a connection's whole semantic layer: the sqlite and bigquery connectors introspect each object defensively (tryIntrospectObject), the live-database adapter records a scan outcome and fetch report, and enabled_tables accepts catalog.db.name, db.name, or bare names with a clear no-match error. Includes matching ktx-daemon introspection changes, docs, and tests.

* docs(spider2-specs): add 06-scan-tolerate-broken-objects spec

* feat(cli): generalize analytics fan-out rule to multi-hop join chains

The ktx-analytics skill's fan-out rule only reliably caught single-hop
inflation; agents still silently fanned out on multi-hop chains where the
offending one-to-many join sits several hops below the SUM/COUNT and is easy
to miss.

Rewrite the Composition rule so the danger reads as cumulative across the whole
chain (pre-aggregate per measure-owning table), add an affirmative
grain-verification habit (default: pre-aggregate to grain; escape hatch:
COUNT(DISTINCT key) for pure counts only; SUM/AVG of a fanned-out measure must
pre-aggregate), and add one generic wrong-vs-right worked example. Content-only
and dialect-agnostic; no new tool, flag, or config.

Implements spider2-specs/specs/09 and annotates spec 07's one-example
constraint as superseded.

* feat(cli): add panel-completeness, time-series window, and text-encoded numeric SQL craft

Extend the analytics skill's <sql_craft> with three correctness habits and
route the dialect-specific halves through sql_dialect_notes:

- Panel completeness (spec 10): full-domain spine -> LEFT JOIN -> COALESCE for
  "each/every/all/per" questions, defaulted by measure additivity.
- Time-series windows (spec 11): explicit cumulative frames, calendar-range
  rolling windows with minimum-periods guards, and period-over-period via LAG.
- Text-encoded numerics (spec 12): sample distinct values, strip/scale/cast in
  one early CTE, and confirm coverage with a failure-detecting cast.

Add per-dialect Series, Rolling window, and Safe cast notes to all seven
dialect files so the skill stays dialect-agnostic while the engine-specific
syntax lives in sql_dialect_notes. Tests updated and passing (19).

* docs(spider2-specs): add specs 10-12 for analytics SQL-craft additions

Refined specs and completion records for the panel-completeness spine (10),
time-series window recipes (11), and text-encoded numeric parsing (12)
implemented in the preceding commit.

* docs(spider2-specs): add backlog intake drafts 13-14

- 13: canonical authoritative-source measures
- 14: output-completeness final check

* skill(analytics): spec 14 output-completeness + iter1 (active column planning)

Bundles two changes (entangled in SKILL.md; future spider2 iterations land as
separate commits):

- spec 14 (output-completeness): multi-part "answer every requested output" rule
  + a "Final completeness check" in workflow Step 6 and <sql_craft>; analytics
  skill-content test updated; intake draft -> done/, refined spec added.
- iter1 experiment: spec 14's passive end-check did not change behavior on the
  benchmark's output-completeness failures, so (a) the Plan step now writes the
  exact output-column list UP FRONT as a contract the final SELECT must match,
  and (b) "expose identity" -> "project BOTH the entity id and its name" (covers
  both omission directions). All generic craft.

Driven by the Spider 2.0-Lite failure analysis (incomplete output was the
largest failure bucket); benchmark only as motivation.

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

* skill(analytics): iter2 — deterministic order in string/array aggregation

GROUP_CONCAT/string_agg/array_agg element order is undefined without an explicit
ORDER BY; also note SQLite's default text sort is binary/case-sensitive (uppercase
before lowercase) vs case-insensitive (COLLATE NOCASE). Generic SQLite craft.

Spider 2.0-Lite motivation: an ordered-ingredient-list question failed only on the
within-string element order (right elements, wrong order); benchmark as motivation only.

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

* feat(mcp): structured, leveled logging for the MCP server

Add one synchronous pino logger per MCP server process, written through the
io.stderr sink: plain JSON when stderr is not a TTY, colorized pino-pretty
(sync, in-process) when it is. Every tool call logs tool.start with its raw
params BEFORE the handler runs and tool.end after (info / warn past
KTX_MCP_SLOW_TOOL_MS / error), correlated by callId plus sessionId, so a
runaway sql_execution leaves a recoverable start line with its exact SQL and
no matching end. HTTP logs session.open/close and wires the previously-dead
transport.onerror to transport.error; stdio routes its transport error
through the logger. Level via KTX_MCP_LOG_LEVEL (default info). Existing
mcp_request_completed telemetry and registerParsedTool are unchanged; no
worker/async transport and no redaction in v1 (logs are local-only).

Implements spider2-specs/specs/15-mcp-server-structured-logging.md and moves
the intake draft to done/.

* feat(mcp): report uptimeMs in MCP server /health

The /health endpoint now includes uptimeMs (monotonic elapsed time since
the server started), mirroring the Python daemon's uptime_ms telemetry
field.

* feat(cli): bound read-query execution with a per-connection deadline

Enforce one shared query deadline (default 30s, overridable per connection via
query_timeout_ms) on every executeReadOnly path, so an accidentally-expensive
LLM-authored query returns a fast "query exceeded Ns" KtxQueryError instead of
hanging the MCP server.

- New shared contract context/connections/query-deadline.ts
  (resolveQueryDeadlineMs, queryDeadlineExceededError); query_timeout_ms added to
  the shared warehouse schema; BigQuery's job_timeout_ms removed.
- SQLite runs the read query in a short-lived forked child process and enforces
  the deadline with SIGKILL. worker_threads + terminate() was tried first but
  cannot interrupt a synchronous better-sqlite3 scan (the native loop never
  yields); SIGKILL reclaims the process in ~2ms and keeps the event loop free.
- Remote connectors apply a real server-side statement timeout and re-wrap their
  own timeout signal as KtxQueryError: Postgres statement_timeout/57014, MySQL
  max_execution_time/3024, Snowflake STATEMENT_TIMEOUT_IN_SECONDS/604, ClickHouse
  max_execution_time + aligned request_timeout/159, SQL Server requestTimeout/
  ETIMEOUT, BigQuery jobTimeoutMs.
- Relationship validation skips a candidate to review on a deadline timeout
  instead of aborting the pass; the deadline surfaces through the existing MCP
  pino logger as a matched tool.start/tool.end(error) pair (no new logging code).

Also fixes a pre-existing, unrelated invalid cast in mcp-server-factory.test.ts
that was breaking tsc -p tsconfig.test.json.

* docs(spider2-specs): mark spec 16 (bounded query execution) done

Append Implementation notes to the refined spec (what shipped, where, and the
worker-thread -> child-process+SIGKILL deviation with its evidence) and move the
intake draft from todo/ to done/.

* skill(analytics): iter3 — measure-as-amount, inter-event gap, top-per-metric career

Three generic interpretation rules: a named business measure (sales/revenue/spend)
means its amount not a row count; "inter-event duration/gap" is LAG/LEAD time-between
events not a magnitude column; "highest across several achievements" aggregates per
metric over the whole history. All three demonstrably FIRE (verified on local008/003/152
SQL). local008 flips to correct (mechanism-aligned). 003/152 still fail on a different
axis (source-column / grouping). Generic craft; benchmark only as motivation.

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

* skill(analytics): spine-for-extreme-selection + aggregate-over-selected-set

Two generic answer-completeness refinements:
- Selecting the extreme group (lowest/highest count over a period/category
  domain) must rank over the COMPLETE spine, not only groups with fact rows —
  an empty period is a genuine 0 and often the true minimum.
- An aggregate scoped to a per-entity selected set ('avg revenue per actor in
  those top-3 films') is computed ACROSS that set, distinct from the per-item
  value; project both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* skill(analytics): iter2 — sharpen extreme-selection spine + top-N ranking-measure

- spine-for-extreme: concrete cue that a zero-row period never appears in a
  GROUP BY of the facts; generate the full calendar, LEFT JOIN, COALESCE, then rank.
- aggregate-over-selected-set: top-N selection ranks by the named ranking measure
  (the item's own revenue), independent of the per-item share that feeds the aggregate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* skill(analytics): iter3 — comparison-between-two-extremes is one wide row

Distinguishes a cross-item comparison ('the difference between the highest and
lowest month' -> single wide row, both extremes side by side + the comparison
column) from 'report a metric for each group' (-> stays long). Generic, question-
derived; targets the wide-vs-long shape gap without affecting per-group long output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* skill(analytics): iter4 — anchor a period bucket to the named lifecycle event

When a record carries multiple lifecycle timestamps (created/placed, approved,
shipped, delivered, completed, settled) and the question counts/measures records
in a named *completed state* by period ("delivered orders by month", "shipped
items per week"), bucket the period by that named event's own timestamp, not the
record-creation timestamp; the state value is the qualifying filter, the matching
timestamp is the time anchor. Wording priority is explicit — purchased/placed/
created/submitted/ordered keep the start-event timestamp — and a non-temporal
state filter (counts by customer/city/seller with no period) introduces no anchor.

Generic analytics craft: counting completed-state records by their creation date
silently answers "records that later reached that state, grouped by when they
started" instead of the question asked. Surfaced via the spider2-autofix loop;
FAIR_PRODUCT (adversary-screened, restatable from question wording + schema/
semantic-layer lifecycle descriptions, no gold dependency).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* skill(analytics): iter5 — canonicalize observed URL-path variants before page-level analysis

When a question groups/filters/sequences web pages by a path/url column, sample
its distinct values; if the data itself shows /route and /route/ variants for the
same page context, canonicalize in an early CTE (preserve / as root, strip trailing
slashes from non-root paths, map an observed empty path to / only when the column is
a URL path with blank root-page events) and use the canonical path everywhere above.
Explicitly forbids inventing aliases the data doesn't show: no merging different
route names, no stripping query/fragment/host/scheme, no lowercasing, and no
canonicalization when the question asks for raw URL/path or slash-vs-no-slash diffs.

Generic web-analytics craft: raw request logs routinely store the same user-visible
page with and without a trailing slash, so grouping raw labels silently splits one
page into several. Surfaced via the spider2-autofix loop (Codex runner, round r2);
FAIR_PRODUCT (adversary-screened, restatable from URL-path semantics + page-grain
question wording + solver-observed distinct values, no gold dependency). The rule
fired mechanism-aligned on both targets; flipped local330 (landing/exit page counts),
local331 residual is a separate sequence-semantics axis beyond canonicalization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* skill(analytics): iter6 — coverage over a selected group is a set-membership aggregate

When a question first selects a group of entities ("the top 5 actors", "these
products") and then asks what count/share/percentage of a DIFFERENT subject domain
relates to *these* selected entities ("what % of customers rented films featuring
these actors"), the subject set is the UNION across the whole group: count DISTINCT
subject ids once across the selected entities and return one collective value at the
subject-domain grain — not one row per selected entity (which double-counts subjects
related to more than one entity and answers a different question). Narrowly guarded:
emit one row per entity only when the wording says "for each / per / by / list" or
asks for each entity's own metric ("top 5 players and their batting averages").

The collective-coverage cousin of the existing per-entity selected-set rule. Generic
analytics craft (per-entity metric vs set-level coverage). Surfaced via the
spider2-autofix loop (Codex runner, round r3); FAIR_PRODUCT (adversary-screened,
restatable from wording alone, no gold dependency). Flipped local195 mechanism-aligned
(union COUNT(DISTINCT customer)/total, one scalar); 0 regression across 5 passing
per-entity top-N guards (local023/024/029/212/221 stayed long).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* skill(analytics): label-only joins must LEFT JOIN — incomplete dims silently drop fact rows

Mirror of the existing fan-out rule for the DROP direction: an inner JOIN to a
dimension table used only to attach a display attribute silently discards every
fact row whose key has no parent when the dimension is incomplete (trimmed
catalogs, late-arriving / SCD-gap rows), shrinking counts/sums and the universe
over which shares/averages/medians are computed. Guidance: LEFT JOIN pure
enrichment; inner-join a dimension only when intended as a filter; key the
aggregate/GROUP BY on the fact column, not the dimension column.

Spider2 autofix round 'joindim': flips complex_oracle local050 (FAIL->PASS,
official scorer) — solver dropped the gratuitous products inner-join and
recovered the exact gold. local060/063 also adopt LEFT JOIN (rule fires) but
remain gold-convention-blocked. Guards local061/067 held.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(spider2-specs): add todo/17 — lifecycle-event metrics (semantic-layer)

Draft intake spec surfaced by the spider2-autofix loop (round r1): the model-layer
form of the shipped iter4 lifecycle-date-anchoring skill rule — infer per-state
lifecycle-event metrics (e.g. delivered_orders with defaultTimeDimension = the
delivery timestamp) during enrichment so the correct time anchor is the default for
any consumer, not only an agent that loaded the skill. Generic; FAIR_PRODUCT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(connectors): accept leading underscore in connection/identifier ids

The safe-identifier validator regex /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ allowed an
underscore everywhere except the first character, so a connection id / database
name that legitimately starts with '_' (valid in Snowflake, e.g. _1000_GENOMES)
could never be ingested or queried. Allow a leading underscore across all 16
duplicated validators (connection ids, source ids, page/wiki keys, warehouse-
verification tool schemas). Path-safety is unaffected — '.' and '/' remain
excluded, and assertSafePathToken still blocks traversal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(analytics): generic geospatial query guidance

Add a Snowflake ST_* dialect note (ST_MAKEPOINT lon-first, ST_DWITHIN/ST_CONTAINS/
ST_WITHIN/ST_INTERSECTS, bbox->polygon via ST_MAKEPOLYGON/ST_MAKELINE) and a
dialect-agnostic 'Spatial predicates' recipe in the analytics skill (resolve the
entity geometry, build an area-of-interest polygon, test with the engine's
containment/proximity/overlap predicate; mind lon/lat argument order). Steers the
solver off hand-rolled lat/lon BETWEEN boxes toward correct, index-assisted
geospatial predicates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(analytics): parse code/dependency text by language grammar

Add two generic <sql_craft> rules: (1) parse imported/required/loaded packages by
the language or manifest format (Java import keep-package-path allowing underscores/
mixed-case; Python import/from + alias stripping; R library/require; .ipynb parse
JSON cell source before language rules; JSON manifests flatten the dependency object
keys), stripping comments/prose and splitting multi-import lines; (2) on a
de-duplicated table with a documented copy/occurrence count, choose COUNT(*) vs the
weight column from the population the question names, not silently. Steers off one
broad regex that drops valid identifiers and matches prose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(analytics): source filters/dates/measures from the owning fact grain

Add a <sql_craft> rule for joined fact tables at different grains (parent order
vs child line item): read each predicate, calendar bucket, and measure from the
table whose grain the question names, not whichever is in scope post-join. An
order-grain filter ("orders that are Complete", "the order's creation date")
must come from the parent even though the child carries its own status/created_at;
line price/cost come from the child. Mirror at metric grain: don't combine a
parent-grain count with child rows (num_of_item * SUM(line_price) per line) —
aggregate each measure at its own grain before combining.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(analytics): collapse multi-valued classes to one representative per entity before counting/concentration

When an entity carries a multi-valued classification array (IPC/CPC codes, tags)
and the methodology counts entities-per-class or a concentration/diversity metric
(HHI, originality, share), pick ONE representative per entity first (the array's
main/primary/first flag, else a defined fallback like most-frequent), then
aggregate; and use COUNT(DISTINCT entity) when the denominator is defined as a
count of entities. Unnesting the array otherwise multiplies an entity's weight by
its code count, inflating per-class frequencies and skewing the ranking/score.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(connectors): introspect BigQuery datasets hosted in foreign projects

A dataset_ids/dataset_id entry may now be written `project.dataset` to
introspect a dataset hosted in another project while query jobs still bill to
credentials.project_id. Entries are parsed once at the config boundary into
canonical {project, dataset} pairs; introspection, primary-key discovery,
testConnection, getTableRowCount, and listTables (grouped per project) all
resolve in the dataset's own project, and scanned tables are labeled with that
project so sampling, distinct-value, and read queries resolve. Bare entries are
unchanged.

Implements spider2-specs/specs/18-bigquery-cross-project-datasets.md.

* feat(scan): durable, resumable, bounded relationship detection during enrichment

Move the enrichment persistence boundary to the cost boundary and bound the
open-ended relationship stage (spec 19).

- Checkpoint descriptions + embeddings into the queryable `_schema` manifest
  (and the raw enrichment artifacts) before relationship detection runs, via a
  new `onCheckpoint` hook + `writeLocalScanEnrichmentCheckpoint`. An interrupted,
  budget-truncated, or failed relationship stage now degrades to "no joins",
  never "no descriptions".
- Resume the enrichment cache by content identity: re-key the SQLite stage store
  on `(connection_id, stage, input_hash)` so a re-run with a fresh runId resumes
  finished descriptions/embeddings instead of re-paying for LLM work. The
  disposable cache recreates its table if the on-disk key shape differs.
- Make the relationship stage observable and bounded: a sticky wall-clock budget
  (`scan.relationships.detectionBudgetMs`, default 600000 ms) + per-unit progress
  + honored `ctx.signal`, threaded through profiling, validation, and composite
  detection. On exhaustion/abort it stops scheduling, finalizes, and returns a
  partial result instead of throwing or hanging.
- Mark a budget/abort-truncated result partial (diagnostics `partial`/`partialReason`
  + recoverable `relationship_detection_partial` warning). A graceful partial saves
  as a completed stage and resumes cheaply; raising the budget changes inputHash
  and forces a fresh, fuller run. A process killed mid-stage saves nothing.

Document `detectionBudgetMs` in the ktx.yaml reference. Append implementation
notes to specs/19 and move the intake draft to done/.

Also carries the in-tree per-table enrichment LLM timeout work it builds on
(`description-generation.ts` + the `enrichment_timeout` warning code), which is
intertwined in `local-enrichment.ts`/`types.ts` and cannot be split into a
separately-building commit.

* feat(scan): bound + retry the per-table enrichment LLM call

The batched table-description call had no retry (sampleTable retried 3x, this did
not), so a single transient backend error (e.g. an overloaded/burst rejection when
many tables enrich concurrently) silently nulled a whole table's descriptions —
observed dropping ~70% of a db's tables during a bad window despite ample quota.

- Wrap generateObject in retryAsync (3 attempts + backoff; KTX_ENRICH_LLM_ATTEMPTS).
- Fresh per-attempt timeout (KTX_ENRICH_LLM_TIMEOUT_MS, default 120s) still bounds a
  wedged wide table; a timeout is surfaced as KtxAbortedError so it is NOT retried
  (one wedge stays one timeout, not 3x).
- Granular per-table progress + start/done/retry/timeout logging.

Composes with spec 19 (its non-goal #1): spec 19 makes completed descriptions durable;
this makes more of them complete.

* feat(scan): survive a hung LLM enrichment backend and resume descriptions

Two compounding failure modes on the per-table description-enrichment path (spec 20):

Enforced per-table timeout for subprocess backends. The runtime declares whether it owns an SDK subprocess (subprocessForkSpec on KtxLlmRuntimePort); codex/claude-code calls run behind a ktx-owned detached child that is tree-killed (SIGKILL of the process group on POSIX, taskkill /T on Windows) on the deadline or ctx.signal, reaping the wedged model grandchild. HTTP backends keep native fetch abort. Default stays 120s, one-wedge-one-timeout.

Incremental, resumable descriptions persistence. generateDescriptions flushes enriched tables per batch to an inputHash-tagged durable record (at a stable, non-syncId path) plus only the changed manifest shards, skips already-enriched tables on resume, and never lets one table's failure discard the stage (a skipped table costs one missing description, not the whole stage's output).

Spec 20 refined + intake draft moved to done/.

* feat(scan): selective enrichment stages (--stages) + per-stage cache keys

Split the single coarse enrichment cache key into per-stage hashes
(descriptions <- snapshot + LLM identity; embeddings <- snapshot + embedding
identity + description digest; relationships <- snapshot + relationship settings
+ LLM identity), so changing one stage's inputs invalidates only that stage and
never throws away the expensive per-table descriptions on an unrelated edit.

Add `ktx ingest --stages <list>` to force-re-run a chosen subset on an
already-ingested connection: a named stage bypasses the completed-stage
short-circuit while the per-table descriptions resume record still skips
already-enriched tables, and unselected stages are left untouched on disk. Feed
embeddings + relationships their description context from the on-disk _schema
when descriptions do not run this invocation, and carry descriptions into the
llmProposals evidence packet (closing a latent gap on the full-run path too).
Surface an enrichment_stage_stale warning when an unselected stage's inputs have
drifted, rather than silently cascading the work.

Implements spider2-specs/specs/21-selective-enrichment-stages.md.

* test(analytics): realign SKILL.md acceptance test with the evolved skill

Three assertions in analytics-skill-content.test.ts drifted from the analytics
SKILL.md as later iterations edited the skill without updating the test:

- the sub-heading was renamed Window functions -> Ordering & aggregation
  determinism (iter2), so follow the source name;
- the rule "Expose identity, not just the label" was renamed to "Project BOTH
  identity and label" (spec 14), so match the new wording;
- the dialect-FQTN guard false-positived on the Java package example
  com.planet_ink.coffee_mud, whose backticks made a 3-segment package path read
  as a BigQuery/Snowflake `a.b.c` table reference. Drop the backticks so the
  guard stays at full strength without weakening it.

* fix(scan): --stages subset must not delete unselected stages' on-disk artifacts

A --stages subset that omitted descriptions wiped all on-disk ai/db descriptions
from the written _schema. runLocalScan writes the structural manifest shard from
the bare snapshot BEFORE enrichment runs, and the shard merge treats ai/db as
scan-managed and overwrites them with whatever the run emits — none, on a subset
that skips descriptions. Enrichment then read the already-wiped shard via
loadPriorDescriptions and had nothing to restore.

runLocalScanEnrichment now returns the best-available descriptions (fresh-this-run
if descriptions ran, else loaded from the on-disk _schema) instead of [], and
runLocalScan captures the prior descriptions before the structural write and feeds
them to both the structural write and enrichment, so an unselected stage's
artifacts survive. Joins were already preserved for --stages descriptions via the
manual/inferred preservedJoins path.

Tests: a full runLocalScan --stages relationships path test (RED without the fix,
GREEN with it — the earlier unit test missed the structural-pre-write ordering),
plus enrichment-layer contract tests for both directions. Validated live on
northwind: --stages relationships keeps all 110 descriptions + 22 joins (was
wiping to 0); --stages descriptions restores descriptions from the spec-20 resume
record (no LLM calls) while keeping joins.

* feat(dialects): bigquery nested-data (ARRAY/STRUCT/UNNEST), geospatial (GEOGRAPHY), SAFE_DIVIDE

bigquery.md lacked the two sections that define BigQuery analytics (present in snowflake.md):
- Nested & repeated data: UNNEST to flatten arrays of STRUCTs (GA360 hits, GA4 event_params),
  dot-notation field access, key-value param scalar-subquery extraction, fan-out/COUNT(DISTINCT) guard.
- Geospatial (GEOGRAPHY): ST_GEOGPOINT (lon-first), containment/proximity/distance/intersection
  predicates, areal allocation via ST_AREA(ST_INTERSECTION()).
- SAFE_DIVIDE for zero-denominator-safe rates; sharded-table shard-presence note.
Generic BigQuery craft surfaced by sql_dialect_notes; product-completeness (any BQ analyst benefits).

* spec(ingest): resumable + fault-tolerant source ingest (#22)

Refined spec for two source-ingest durability gaps surfaced by a real
user report on a ~2-day dbt ingest: (1) interrupted runs restart every
work unit from scratch (no cross-run reuse), and (2) the final
integration gate is all-or-nothing — one unfixable artifact discards the
whole run.

Design: automatic content-keyed work-unit resume reusing the scan
durability primitive (specs 19/20), plus a deterministic dangling-edge
prune that replaces the fatal final-gate throw so a single bad model
costs only that model, not the run. Prune operates on the integrated
tree and never poisons the cache, so resume and prune self-heal.

* refactor(scan): route enrichment resume through shared cache

* feat(ingest): replay cached work unit patches

* refactor(ingest): return structured final gate findings

* feat(ingest): prune final gates without LLM repair

* docs(ingest): document final gate pruning

* test(ingest): cover stale work unit cache recompute

* fix(ingest): refresh stale cache recompute metadata

* test(ingest): cover missing-target prune and self-heal

* fix(ingest): defer pruneable final gate findings

* fix(ingest): replay pruned cached work unit intent

* chore(ingest): verify resumable source ingest self-heal

* test(ingest): cover final gate prune source path resolution

* fix(ingest): resolve final gate prune sources canonically

* fix: defer wiki ref cleanup out of stage 3

* test: cover non-cascading final gate join pruning

* test: cover intrinsic final gate source drops

* docs(spec): record implementation notes for resumable source ingest (#22)

* fix(ingest): prune dangling joins on untouched sources and stop storing cache patch text

- final gate: drop a dropped source's dangling join edges from every owner on
  the connection, including untouched siblings the touched-scoped gate never
  revisits, so a committed orphan join can't break SL queries
- work-unit cache: drop the stored patch text; replay re-derives the diff from
  the before/after artifact snapshots, carrying each touched file only once
- scan enrichment: checkpoint recomputed embeddings before the kill-prone
  relationship stage even when descriptions load from disk, using the
  best-available description set so the manifest merge can't delete them
- sl: extract listSlSourceFiles so the final gate and resolveSlSourceFile
  share one listing path

* fix(scan): accept relationships mode in enrichment state metadata

Listing run stages after a relationships-mode scan threw "Invalid scan
enrichment cache metadata" because the parser hand-enumerated only the
structural/enriched modes while a relationships scan persists its stage with
mode 'relationships'. Derive the mode and stage allowlists from the canonical
KTX_SCAN_MODES and KTX_SCAN_ENRICHMENT_STAGES registries so the runtime check
cannot drift from the type again.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:29:28 +02:00
github-actions[bot]
46df7f3b24 chore: refresh star history chart [skip ci] 2026-06-29 18:46:41 +00:00
Andrey Avtomonov
f65a5b0e2e
feat: ktx batch — scan resilience, analytics SQL craft, connector hardening (#312)
* docs: add spider2-specs handoff directory for benchmark-driven feature specs

* feat(cli): connection-scoped wiki pages

Add an optional `connections` frontmatter field so database-specific wiki
knowledge can be scoped to a connection without polluting searches about other
databases, while page keys stay a flat, globally-unique namespace.

- connections: single string or list; absent/empty ⇒ unscoped (applies to all)
- wiki_search (MCP) and `ktx wiki --connection` return unscoped ∪ matching
  pages, filtered at the disk-load seam so all three search lanes draw their
  candidate pool from the already-scoped set (not a post-filter)
- wiki_write accepts connections with REPLACE semantics and rejects a
  connection-scoped write whose key collides with a disjoint-connection page
  (data-loss guard; hard error, no silent clobber)
- explicit connection-id args (wiki_search, memory_ingest, ktx wiki) are
  validated against ktx.yaml via a shared assertConfiguredConnectionId, which
  also closes the prior gap where memory_ingest's connectionId was unvalidated;
  persisted ids absent from config warn (not fail) in `ktx status`
- prompt guidance in the wiki_capture skill and external-ingest prompt; the
  session connectionId is surfaced to the memory agent and ingest work units

Implements spider2-specs/specs/01-connection-scoped-wiki.md; intake draft moved
to spider2-specs/done/.

* docs(spider2-specs): add specs/ refinement stage and composite-key join spec

Describe the todo/ → specs/ → done/ pipeline in the README (refined specs are
the durable artifact; intake drafts move to done/ on ship) and add a
MEDIUM-priority spec for multi-column composite-key join detection found during
the first sqlite smoke test.

* feat(cli): add --verbatim ingest mode for authoritative documents

Store each --text/--file document body unchanged as a GLOBAL wiki page
instead of routing it through the memory agent, which may rewrite,
condense, or re-title it. The LLM derives only metadata (summary, tags,
sl_refs) and only for frontmatter fields the document does not already
set; the stored body is written by code and never edited.

- Deterministic page key: files derive it from the filename, inline
  text from its leading Markdown heading (headless inline text is
  rejected — pass it as --file instead).
- Idempotent: re-running the same body is a no-op; a different body at
  the same key fails loudly rather than overwriting.
- Works with llm.provider.backend: none, deriving a degraded summary
  from the heading or first sentence.
- Existing frontmatter (including unmodeled fields like effective_date)
  passes through untouched; --connection-id scopes the page.

* feat(cli): SQL-authoring craft and per-dialect notes tool for the analytics skill

Spec 07: add a dialect-agnostic <sql_craft> block to the ktx-analytics skill (schema discovery, composition, window-function correctness, numeric precision, answer completeness) with one worked window-then-filter example. Workflow steps gain pointers into it; existing guidance is unchanged.

Spec 08: add a read-only sql_dialect_notes MCP tool returning a connection's engine SQL conventions (FQTN form, identifier quoting/case, date/time, top-N idiom, JSON access), resolved through the existing sqlAnalysisDialectForDriver path. Notes are per-dialect markdown files under context/sql-analysis/dialects, served by the tool and copied to dist (package-internal, never installed). Non-SQL connections return a clear KtxExpectedError. The flat skill gains a one-line pointer to the tool.

Both spider2-specs intake drafts move to done/ with implementation notes.

* feat(cli): tolerate objects that fail introspection during scan

Isolate per-object introspection failures so one broken or inaccessible object no longer zeroes out a connection's whole semantic layer: the sqlite and bigquery connectors introspect each object defensively (tryIntrospectObject), the live-database adapter records a scan outcome and fetch report, and enabled_tables accepts catalog.db.name, db.name, or bare names with a clear no-match error. Includes matching ktx-daemon introspection changes, docs, and tests.

* docs(spider2-specs): add 06-scan-tolerate-broken-objects spec

* feat(cli): generalize analytics fan-out rule to multi-hop join chains

The ktx-analytics skill's fan-out rule only reliably caught single-hop
inflation; agents still silently fanned out on multi-hop chains where the
offending one-to-many join sits several hops below the SUM/COUNT and is easy
to miss.

Rewrite the Composition rule so the danger reads as cumulative across the whole
chain (pre-aggregate per measure-owning table), add an affirmative
grain-verification habit (default: pre-aggregate to grain; escape hatch:
COUNT(DISTINCT key) for pure counts only; SUM/AVG of a fanned-out measure must
pre-aggregate), and add one generic wrong-vs-right worked example. Content-only
and dialect-agnostic; no new tool, flag, or config.

Implements spider2-specs/specs/09 and annotates spec 07's one-example
constraint as superseded.

* feat(cli): add panel-completeness, time-series window, and text-encoded numeric SQL craft

Extend the analytics skill's <sql_craft> with three correctness habits and
route the dialect-specific halves through sql_dialect_notes:

- Panel completeness (spec 10): full-domain spine -> LEFT JOIN -> COALESCE for
  "each/every/all/per" questions, defaulted by measure additivity.
- Time-series windows (spec 11): explicit cumulative frames, calendar-range
  rolling windows with minimum-periods guards, and period-over-period via LAG.
- Text-encoded numerics (spec 12): sample distinct values, strip/scale/cast in
  one early CTE, and confirm coverage with a failure-detecting cast.

Add per-dialect Series, Rolling window, and Safe cast notes to all seven
dialect files so the skill stays dialect-agnostic while the engine-specific
syntax lives in sql_dialect_notes. Tests updated and passing (19).

* docs(spider2-specs): add specs 10-12 for analytics SQL-craft additions

Refined specs and completion records for the panel-completeness spine (10),
time-series window recipes (11), and text-encoded numeric parsing (12)
implemented in the preceding commit.

* docs(spider2-specs): add backlog intake drafts 13-14

- 13: canonical authoritative-source measures
- 14: output-completeness final check

* skill(analytics): spec 14 output-completeness + iter1 (active column planning)

Bundles two changes (entangled in SKILL.md; future spider2 iterations land as
separate commits):

- spec 14 (output-completeness): multi-part "answer every requested output" rule
  + a "Final completeness check" in workflow Step 6 and <sql_craft>; analytics
  skill-content test updated; intake draft -> done/, refined spec added.
- iter1 experiment: spec 14's passive end-check did not change behavior on the
  benchmark's output-completeness failures, so (a) the Plan step now writes the
  exact output-column list UP FRONT as a contract the final SELECT must match,
  and (b) "expose identity" -> "project BOTH the entity id and its name" (covers
  both omission directions). All generic craft.

Driven by the Spider 2.0-Lite failure analysis (incomplete output was the
largest failure bucket); benchmark only as motivation.

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

* skill(analytics): iter2 — deterministic order in string/array aggregation

GROUP_CONCAT/string_agg/array_agg element order is undefined without an explicit
ORDER BY; also note SQLite's default text sort is binary/case-sensitive (uppercase
before lowercase) vs case-insensitive (COLLATE NOCASE). Generic SQLite craft.

Spider 2.0-Lite motivation: an ordered-ingredient-list question failed only on the
within-string element order (right elements, wrong order); benchmark as motivation only.

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

* feat(mcp): structured, leveled logging for the MCP server

Add one synchronous pino logger per MCP server process, written through the
io.stderr sink: plain JSON when stderr is not a TTY, colorized pino-pretty
(sync, in-process) when it is. Every tool call logs tool.start with its raw
params BEFORE the handler runs and tool.end after (info / warn past
KTX_MCP_SLOW_TOOL_MS / error), correlated by callId plus sessionId, so a
runaway sql_execution leaves a recoverable start line with its exact SQL and
no matching end. HTTP logs session.open/close and wires the previously-dead
transport.onerror to transport.error; stdio routes its transport error
through the logger. Level via KTX_MCP_LOG_LEVEL (default info). Existing
mcp_request_completed telemetry and registerParsedTool are unchanged; no
worker/async transport and no redaction in v1 (logs are local-only).

Implements spider2-specs/specs/15-mcp-server-structured-logging.md and moves
the intake draft to done/.

* feat(mcp): report uptimeMs in MCP server /health

The /health endpoint now includes uptimeMs (monotonic elapsed time since
the server started), mirroring the Python daemon's uptime_ms telemetry
field.

* feat(cli): bound read-query execution with a per-connection deadline

Enforce one shared query deadline (default 30s, overridable per connection via
query_timeout_ms) on every executeReadOnly path, so an accidentally-expensive
LLM-authored query returns a fast "query exceeded Ns" KtxQueryError instead of
hanging the MCP server.

- New shared contract context/connections/query-deadline.ts
  (resolveQueryDeadlineMs, queryDeadlineExceededError); query_timeout_ms added to
  the shared warehouse schema; BigQuery's job_timeout_ms removed.
- SQLite runs the read query in a short-lived forked child process and enforces
  the deadline with SIGKILL. worker_threads + terminate() was tried first but
  cannot interrupt a synchronous better-sqlite3 scan (the native loop never
  yields); SIGKILL reclaims the process in ~2ms and keeps the event loop free.
- Remote connectors apply a real server-side statement timeout and re-wrap their
  own timeout signal as KtxQueryError: Postgres statement_timeout/57014, MySQL
  max_execution_time/3024, Snowflake STATEMENT_TIMEOUT_IN_SECONDS/604, ClickHouse
  max_execution_time + aligned request_timeout/159, SQL Server requestTimeout/
  ETIMEOUT, BigQuery jobTimeoutMs.
- Relationship validation skips a candidate to review on a deadline timeout
  instead of aborting the pass; the deadline surfaces through the existing MCP
  pino logger as a matched tool.start/tool.end(error) pair (no new logging code).

Also fixes a pre-existing, unrelated invalid cast in mcp-server-factory.test.ts
that was breaking tsc -p tsconfig.test.json.

* docs(spider2-specs): mark spec 16 (bounded query execution) done

Append Implementation notes to the refined spec (what shipped, where, and the
worker-thread -> child-process+SIGKILL deviation with its evidence) and move the
intake draft from todo/ to done/.

* skill(analytics): iter3 — measure-as-amount, inter-event gap, top-per-metric career

Three generic interpretation rules: a named business measure (sales/revenue/spend)
means its amount not a row count; "inter-event duration/gap" is LAG/LEAD time-between
events not a magnitude column; "highest across several achievements" aggregates per
metric over the whole history. All three demonstrably FIRE (verified on local008/003/152
SQL). local008 flips to correct (mechanism-aligned). 003/152 still fail on a different
axis (source-column / grouping). Generic craft; benchmark only as motivation.

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

* skill(analytics): spine-for-extreme-selection + aggregate-over-selected-set

Two generic answer-completeness refinements:
- Selecting the extreme group (lowest/highest count over a period/category
  domain) must rank over the COMPLETE spine, not only groups with fact rows —
  an empty period is a genuine 0 and often the true minimum.
- An aggregate scoped to a per-entity selected set ('avg revenue per actor in
  those top-3 films') is computed ACROSS that set, distinct from the per-item
  value; project both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* skill(analytics): iter2 — sharpen extreme-selection spine + top-N ranking-measure

- spine-for-extreme: concrete cue that a zero-row period never appears in a
  GROUP BY of the facts; generate the full calendar, LEFT JOIN, COALESCE, then rank.
- aggregate-over-selected-set: top-N selection ranks by the named ranking measure
  (the item's own revenue), independent of the per-item share that feeds the aggregate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* skill(analytics): iter3 — comparison-between-two-extremes is one wide row

Distinguishes a cross-item comparison ('the difference between the highest and
lowest month' -> single wide row, both extremes side by side + the comparison
column) from 'report a metric for each group' (-> stays long). Generic, question-
derived; targets the wide-vs-long shape gap without affecting per-group long output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* skill(analytics): iter4 — anchor a period bucket to the named lifecycle event

When a record carries multiple lifecycle timestamps (created/placed, approved,
shipped, delivered, completed, settled) and the question counts/measures records
in a named *completed state* by period ("delivered orders by month", "shipped
items per week"), bucket the period by that named event's own timestamp, not the
record-creation timestamp; the state value is the qualifying filter, the matching
timestamp is the time anchor. Wording priority is explicit — purchased/placed/
created/submitted/ordered keep the start-event timestamp — and a non-temporal
state filter (counts by customer/city/seller with no period) introduces no anchor.

Generic analytics craft: counting completed-state records by their creation date
silently answers "records that later reached that state, grouped by when they
started" instead of the question asked. Surfaced via the spider2-autofix loop;
FAIR_PRODUCT (adversary-screened, restatable from question wording + schema/
semantic-layer lifecycle descriptions, no gold dependency).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* skill(analytics): iter5 — canonicalize observed URL-path variants before page-level analysis

When a question groups/filters/sequences web pages by a path/url column, sample
its distinct values; if the data itself shows /route and /route/ variants for the
same page context, canonicalize in an early CTE (preserve / as root, strip trailing
slashes from non-root paths, map an observed empty path to / only when the column is
a URL path with blank root-page events) and use the canonical path everywhere above.
Explicitly forbids inventing aliases the data doesn't show: no merging different
route names, no stripping query/fragment/host/scheme, no lowercasing, and no
canonicalization when the question asks for raw URL/path or slash-vs-no-slash diffs.

Generic web-analytics craft: raw request logs routinely store the same user-visible
page with and without a trailing slash, so grouping raw labels silently splits one
page into several. Surfaced via the spider2-autofix loop (Codex runner, round r2);
FAIR_PRODUCT (adversary-screened, restatable from URL-path semantics + page-grain
question wording + solver-observed distinct values, no gold dependency). The rule
fired mechanism-aligned on both targets; flipped local330 (landing/exit page counts),
local331 residual is a separate sequence-semantics axis beyond canonicalization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* skill(analytics): iter6 — coverage over a selected group is a set-membership aggregate

When a question first selects a group of entities ("the top 5 actors", "these
products") and then asks what count/share/percentage of a DIFFERENT subject domain
relates to *these* selected entities ("what % of customers rented films featuring
these actors"), the subject set is the UNION across the whole group: count DISTINCT
subject ids once across the selected entities and return one collective value at the
subject-domain grain — not one row per selected entity (which double-counts subjects
related to more than one entity and answers a different question). Narrowly guarded:
emit one row per entity only when the wording says "for each / per / by / list" or
asks for each entity's own metric ("top 5 players and their batting averages").

The collective-coverage cousin of the existing per-entity selected-set rule. Generic
analytics craft (per-entity metric vs set-level coverage). Surfaced via the
spider2-autofix loop (Codex runner, round r3); FAIR_PRODUCT (adversary-screened,
restatable from wording alone, no gold dependency). Flipped local195 mechanism-aligned
(union COUNT(DISTINCT customer)/total, one scalar); 0 regression across 5 passing
per-entity top-N guards (local023/024/029/212/221 stayed long).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* skill(analytics): label-only joins must LEFT JOIN — incomplete dims silently drop fact rows

Mirror of the existing fan-out rule for the DROP direction: an inner JOIN to a
dimension table used only to attach a display attribute silently discards every
fact row whose key has no parent when the dimension is incomplete (trimmed
catalogs, late-arriving / SCD-gap rows), shrinking counts/sums and the universe
over which shares/averages/medians are computed. Guidance: LEFT JOIN pure
enrichment; inner-join a dimension only when intended as a filter; key the
aggregate/GROUP BY on the fact column, not the dimension column.

Spider2 autofix round 'joindim': flips complex_oracle local050 (FAIL->PASS,
official scorer) — solver dropped the gratuitous products inner-join and
recovered the exact gold. local060/063 also adopt LEFT JOIN (rule fires) but
remain gold-convention-blocked. Guards local061/067 held.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(spider2-specs): add todo/17 — lifecycle-event metrics (semantic-layer)

Draft intake spec surfaced by the spider2-autofix loop (round r1): the model-layer
form of the shipped iter4 lifecycle-date-anchoring skill rule — infer per-state
lifecycle-event metrics (e.g. delivered_orders with defaultTimeDimension = the
delivery timestamp) during enrichment so the correct time anchor is the default for
any consumer, not only an agent that loaded the skill. Generic; FAIR_PRODUCT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(connectors): accept leading underscore in connection/identifier ids

The safe-identifier validator regex /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ allowed an
underscore everywhere except the first character, so a connection id / database
name that legitimately starts with '_' (valid in Snowflake, e.g. _1000_GENOMES)
could never be ingested or queried. Allow a leading underscore across all 16
duplicated validators (connection ids, source ids, page/wiki keys, warehouse-
verification tool schemas). Path-safety is unaffected — '.' and '/' remain
excluded, and assertSafePathToken still blocks traversal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(analytics): generic geospatial query guidance

Add a Snowflake ST_* dialect note (ST_MAKEPOINT lon-first, ST_DWITHIN/ST_CONTAINS/
ST_WITHIN/ST_INTERSECTS, bbox->polygon via ST_MAKEPOLYGON/ST_MAKELINE) and a
dialect-agnostic 'Spatial predicates' recipe in the analytics skill (resolve the
entity geometry, build an area-of-interest polygon, test with the engine's
containment/proximity/overlap predicate; mind lon/lat argument order). Steers the
solver off hand-rolled lat/lon BETWEEN boxes toward correct, index-assisted
geospatial predicates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(analytics): parse code/dependency text by language grammar

Add two generic <sql_craft> rules: (1) parse imported/required/loaded packages by
the language or manifest format (Java import keep-package-path allowing underscores/
mixed-case; Python import/from + alias stripping; R library/require; .ipynb parse
JSON cell source before language rules; JSON manifests flatten the dependency object
keys), stripping comments/prose and splitting multi-import lines; (2) on a
de-duplicated table with a documented copy/occurrence count, choose COUNT(*) vs the
weight column from the population the question names, not silently. Steers off one
broad regex that drops valid identifiers and matches prose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(analytics): source filters/dates/measures from the owning fact grain

Add a <sql_craft> rule for joined fact tables at different grains (parent order
vs child line item): read each predicate, calendar bucket, and measure from the
table whose grain the question names, not whichever is in scope post-join. An
order-grain filter ("orders that are Complete", "the order's creation date")
must come from the parent even though the child carries its own status/created_at;
line price/cost come from the child. Mirror at metric grain: don't combine a
parent-grain count with child rows (num_of_item * SUM(line_price) per line) —
aggregate each measure at its own grain before combining.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(analytics): collapse multi-valued classes to one representative per entity before counting/concentration

When an entity carries a multi-valued classification array (IPC/CPC codes, tags)
and the methodology counts entities-per-class or a concentration/diversity metric
(HHI, originality, share), pick ONE representative per entity first (the array's
main/primary/first flag, else a defined fallback like most-frequent), then
aggregate; and use COUNT(DISTINCT entity) when the denominator is defined as a
count of entities. Unnesting the array otherwise multiplies an entity's weight by
its code count, inflating per-class frequencies and skewing the ranking/score.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(connectors): introspect BigQuery datasets hosted in foreign projects

A dataset_ids/dataset_id entry may now be written `project.dataset` to
introspect a dataset hosted in another project while query jobs still bill to
credentials.project_id. Entries are parsed once at the config boundary into
canonical {project, dataset} pairs; introspection, primary-key discovery,
testConnection, getTableRowCount, and listTables (grouped per project) all
resolve in the dataset's own project, and scanned tables are labeled with that
project so sampling, distinct-value, and read queries resolve. Bare entries are
unchanged.

Implements spider2-specs/specs/18-bigquery-cross-project-datasets.md.

* feat(scan): durable, resumable, bounded relationship detection during enrichment

Move the enrichment persistence boundary to the cost boundary and bound the
open-ended relationship stage (spec 19).

- Checkpoint descriptions + embeddings into the queryable `_schema` manifest
  (and the raw enrichment artifacts) before relationship detection runs, via a
  new `onCheckpoint` hook + `writeLocalScanEnrichmentCheckpoint`. An interrupted,
  budget-truncated, or failed relationship stage now degrades to "no joins",
  never "no descriptions".
- Resume the enrichment cache by content identity: re-key the SQLite stage store
  on `(connection_id, stage, input_hash)` so a re-run with a fresh runId resumes
  finished descriptions/embeddings instead of re-paying for LLM work. The
  disposable cache recreates its table if the on-disk key shape differs.
- Make the relationship stage observable and bounded: a sticky wall-clock budget
  (`scan.relationships.detectionBudgetMs`, default 600000 ms) + per-unit progress
  + honored `ctx.signal`, threaded through profiling, validation, and composite
  detection. On exhaustion/abort it stops scheduling, finalizes, and returns a
  partial result instead of throwing or hanging.
- Mark a budget/abort-truncated result partial (diagnostics `partial`/`partialReason`
  + recoverable `relationship_detection_partial` warning). A graceful partial saves
  as a completed stage and resumes cheaply; raising the budget changes inputHash
  and forces a fresh, fuller run. A process killed mid-stage saves nothing.

Document `detectionBudgetMs` in the ktx.yaml reference. Append implementation
notes to specs/19 and move the intake draft to done/.

Also carries the in-tree per-table enrichment LLM timeout work it builds on
(`description-generation.ts` + the `enrichment_timeout` warning code), which is
intertwined in `local-enrichment.ts`/`types.ts` and cannot be split into a
separately-building commit.

* feat(scan): bound + retry the per-table enrichment LLM call

The batched table-description call had no retry (sampleTable retried 3x, this did
not), so a single transient backend error (e.g. an overloaded/burst rejection when
many tables enrich concurrently) silently nulled a whole table's descriptions —
observed dropping ~70% of a db's tables during a bad window despite ample quota.

- Wrap generateObject in retryAsync (3 attempts + backoff; KTX_ENRICH_LLM_ATTEMPTS).
- Fresh per-attempt timeout (KTX_ENRICH_LLM_TIMEOUT_MS, default 120s) still bounds a
  wedged wide table; a timeout is surfaced as KtxAbortedError so it is NOT retried
  (one wedge stays one timeout, not 3x).
- Granular per-table progress + start/done/retry/timeout logging.

Composes with spec 19 (its non-goal #1): spec 19 makes completed descriptions durable;
this makes more of them complete.

* feat(scan): survive a hung LLM enrichment backend and resume descriptions

Two compounding failure modes on the per-table description-enrichment path (spec 20):

Enforced per-table timeout for subprocess backends. The runtime declares whether it owns an SDK subprocess (subprocessForkSpec on KtxLlmRuntimePort); codex/claude-code calls run behind a ktx-owned detached child that is tree-killed (SIGKILL of the process group on POSIX, taskkill /T on Windows) on the deadline or ctx.signal, reaping the wedged model grandchild. HTTP backends keep native fetch abort. Default stays 120s, one-wedge-one-timeout.

Incremental, resumable descriptions persistence. generateDescriptions flushes enriched tables per batch to an inputHash-tagged durable record (at a stable, non-syncId path) plus only the changed manifest shards, skips already-enriched tables on resume, and never lets one table's failure discard the stage (a skipped table costs one missing description, not the whole stage's output).

Spec 20 refined + intake draft moved to done/.

* feat(scan): selective enrichment stages (--stages) + per-stage cache keys

Split the single coarse enrichment cache key into per-stage hashes
(descriptions <- snapshot + LLM identity; embeddings <- snapshot + embedding
identity + description digest; relationships <- snapshot + relationship settings
+ LLM identity), so changing one stage's inputs invalidates only that stage and
never throws away the expensive per-table descriptions on an unrelated edit.

Add `ktx ingest --stages <list>` to force-re-run a chosen subset on an
already-ingested connection: a named stage bypasses the completed-stage
short-circuit while the per-table descriptions resume record still skips
already-enriched tables, and unselected stages are left untouched on disk. Feed
embeddings + relationships their description context from the on-disk _schema
when descriptions do not run this invocation, and carry descriptions into the
llmProposals evidence packet (closing a latent gap on the full-run path too).
Surface an enrichment_stage_stale warning when an unselected stage's inputs have
drifted, rather than silently cascading the work.

Implements spider2-specs/specs/21-selective-enrichment-stages.md.

* test(analytics): realign SKILL.md acceptance test with the evolved skill

Three assertions in analytics-skill-content.test.ts drifted from the analytics
SKILL.md as later iterations edited the skill without updating the test:

- the sub-heading was renamed Window functions -> Ordering & aggregation
  determinism (iter2), so follow the source name;
- the rule "Expose identity, not just the label" was renamed to "Project BOTH
  identity and label" (spec 14), so match the new wording;
- the dialect-FQTN guard false-positived on the Java package example
  com.planet_ink.coffee_mud, whose backticks made a 3-segment package path read
  as a BigQuery/Snowflake `a.b.c` table reference. Drop the backticks so the
  guard stays at full strength without weakening it.

* fix(scan): --stages subset must not delete unselected stages' on-disk artifacts

A --stages subset that omitted descriptions wiped all on-disk ai/db descriptions
from the written _schema. runLocalScan writes the structural manifest shard from
the bare snapshot BEFORE enrichment runs, and the shard merge treats ai/db as
scan-managed and overwrites them with whatever the run emits — none, on a subset
that skips descriptions. Enrichment then read the already-wiped shard via
loadPriorDescriptions and had nothing to restore.

runLocalScanEnrichment now returns the best-available descriptions (fresh-this-run
if descriptions ran, else loaded from the on-disk _schema) instead of [], and
runLocalScan captures the prior descriptions before the structural write and feeds
them to both the structural write and enrichment, so an unselected stage's
artifacts survive. Joins were already preserved for --stages descriptions via the
manual/inferred preservedJoins path.

Tests: a full runLocalScan --stages relationships path test (RED without the fix,
GREEN with it — the earlier unit test missed the structural-pre-write ordering),
plus enrichment-layer contract tests for both directions. Validated live on
northwind: --stages relationships keeps all 110 descriptions + 22 joins (was
wiping to 0); --stages descriptions restores descriptions from the spec-20 resume
record (no LLM calls) while keeping joins.

* feat(dialects): bigquery nested-data (ARRAY/STRUCT/UNNEST), geospatial (GEOGRAPHY), SAFE_DIVIDE

bigquery.md lacked the two sections that define BigQuery analytics (present in snowflake.md):
- Nested & repeated data: UNNEST to flatten arrays of STRUCTs (GA360 hits, GA4 event_params),
  dot-notation field access, key-value param scalar-subquery extraction, fan-out/COUNT(DISTINCT) guard.
- Geospatial (GEOGRAPHY): ST_GEOGPOINT (lon-first), containment/proximity/distance/intersection
  predicates, areal allocation via ST_AREA(ST_INTERSECTION()).
- SAFE_DIVIDE for zero-denominator-safe rates; sharded-table shard-presence note.
Generic BigQuery craft surfaced by sql_dialect_notes; product-completeness (any BQ analyst benefits).

* feat(dialects): sqlite ROUND half-up FP-underflow note (+1e-9 before ROUND)

SQLite ROUND(x,n) rounds half-away-from-zero, but binary FP stores an exact
half-way value just below it, so ROUND(6.475,2) returns 6.47 not 6.48. Add a
dialect note: nudge by a tiny epsilon (1e-9) below display precision before
rounding for deterministic half-up, leaving non-boundary values unchanged.
Generic SQLite craft surfaced by sql_dialect_notes (any analyst rounding a
displayed average/rate/price benefits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(analytics): list-as-delimited-string, answer-literally, drop free-text columns

Add SKILL.md guidance to emit list-valued answer cells as delimited
STRING (not ARRAY/repeated column), answer the literal ask without
unrequested transformations (HAVING for aggregate bounds), and avoid
projecting unrequested free-text columns that corrupt row-delimited output.

* fix(scan,mcp): gitignore runtime logs, budget-guard LLM proposal, validate enrich timeout

- gitignore `.ktx/logs/` in both scaffold + setup-merge lists: the managed MCP
  daemon writes raw tool params (SQL, memory_ingest content) to mcp.log under a
  version-controlled `.ktx/`, and snowflake.log already sat there unprotected.
- gate the LLM relationship proposal on the detection budget/abort signal so an
  exhausted or aborted stage cannot start a fresh LLM call; document the boundary.
- validate KTX_ENRICH_LLM_TIMEOUT_MS (NaN/0 → 120s default) like enrichAttempts,
  so a bad value no longer times out every table immediately.
- daemon introspection now warns on malformed column/FK rows instead of dropping
  them silently, matching the table-row path and the "surface broken objects" goal.
- docs: document `ktx wiki -c/--connection`; fix the SQLite query-deadline schema
  doc (forked-subprocess SIGKILL, not worker-thread termination).

* fix(scan,wiki,mcp): address PR #312 review findings

- scan: key the description pipeline (resume map, enriched-schema and
  embedding-text lookups, manifest write/read) by full table identity via
  tableRefKey/buildTableRef, so two same-named tables in different schemas no
  longer cross-assign descriptions or skip a sibling on resume
- scan: re-throw a genuine context cancel during the batched description LLM
  call so Ctrl-C resumes the stage instead of nulling tables and recording it
  completed; per-table timeouts still degrade (context.signal not aborted)
- scan: report statisticalValidation 'skipped' (not 'completed') when a
  budget/abort stop leaves relationship profiling partial
- wiki: sync the full page corpus into the sqlite index and filter only the
  candidate/result set, so a connection-scoped search no longer prunes other
  connections' pages and cached embeddings from the shared index
- wiki: route verbatim ingest through the canonical writePageAndSync so
  contentHash is set and later syncs can short-circuit
- mcp: drop the as-unknown-as cast in serializeMcpError
- dialects/analytics: document the integer-division trap on postgres/sqlite/tsql

Adds regression tests for each behavior change.

* fix(wiki): scope connection filter before SQLite lane limit

Connection-scoped wiki search applied the connectionId allowlist after
the lexical/semantic lanes had already truncated to laneCandidatePoolLimit
over the full (connection-agnostic) corpus. When the requested connection
was a minority of a large corpus, its pages were crowded out of the
candidate pool before filtering, so a semantic-only match could be missed
outright and lexical hits under-ranked.

Push the path allowlist into searchLexicalCandidates/searchSemanticCandidates
so LIMIT applies to in-scope rows, matching what the token lane already did,
and drop the now-redundant post-limit JS filters.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:35:57 +00:00
Pintouch
2afab61417
feat(connectors): add MongoDB connector (#305) (#310)
* refactor(connectors): split KtxDialect into core and KtxSqlDialect

Separate the dialect contract into a driver-agnostic core (display/ref
formatting and type mapping) and a SQL-only extension (query generators).
The catalog and entity-details paths resolve the core dialect for any
snapshot driver, so it must stay free of SQL generation; this is the
prerequisite refactor for adding non-SQL primary sources.

- KtxDialect keeps type, formatDisplayRef, parseDisplayRef,
  columnDisplayTablePartCount, mapDataType, mapToDimensionType
- KtxSqlDialect extends it with quoteIdentifier, formatTableName, and the
  query/sample/statistics generators; the 7 SQL dialects implement it
- add getSqlDialectForDriver for SQL drivers; the 7 connectors and the
  relationship-benchmark harness consume it
- thread the relationship pipeline (profiling/validation/composite/
  discovery) as KtxSqlDialect | null so a non-SQL source skips coverage SQL
  and its candidates stay in review; local-enrichment builds the SQL
  dialect only when the connector advertises readOnlySql

Pure extraction: no behavior change for the existing 7 drivers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(connectors): add MongoDB connector for issue #305

Add a read-only MongoDB connector that treats a database as a primary
context source: collections map to tables and inferred top-level fields to
columns. MongoDB is the first non-SQL source (readOnlySql: false), so
ktx sql and metric compilation do not apply, but its collections flow
through ingest, descriptions, and relationship discovery.

- schema-inference: infer a flat column schema from the most recent
  sample_size documents (by _id desc, or order_by for non-ObjectId keys).
  Union BSON types per field, mark multi-type fields mixed (string), keep
  sub-documents/arrays as a single opaque json column, derive nullability
  from presence, treat _id as the primary key
- connector: KtxMongoDbScanConnector behind an injectable client seam;
  strictly read-only (find/listCollections/estimatedDocumentCount only),
  no executeReadOnly; resolves env:/file: via resolveKtxConfigReference
- core-only KtxMongoDbDialect and a live-database introspection adapter
- wire the mongodb driver: driver union, dialect registry, driver
  registration (scopeConfigKey databases), mongodbConnectionSchema,
  connection-drivers, normalizeDriver, the live-database route, and the
  ktx setup picker. ktx sql is refused by the read-only SQL capability gate
- tests: schema inference, connector snapshot via a fake client, dialect,
  driver-schema parsing, and the ktx sql rejection

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(integrations): document the MongoDB primary source

Add a MongoDB section to the primary-sources reference: connection config
(url, databases, enabled_tables, sample_size, order_by), mongodb+srv/TLS/
Atlas notes, the schema-inference explainer, a features matrix, and the
non-SQL caveat. Update the frontmatter and connection field reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(connectors): address review blockers on the MongoDB connector

- introspect: skip estimatedDocumentCount for views. The count command is
  rejected on a MongoDB view (CommandNotSupportedOnView), so counting a view
  aborted introspect for the whole connection; compute estimatedRows only for
  real collections, as ClickHouse does.
- sl: refuse a semantic-layer query against a non-SQL connection instead of
  defaulting it to the Postgres dialect. compileLocalSlQuery (the shared CLI +
  MCP path) now rejects a driver with no SQL dialect via the new
  isSqlQueryableDriver authority, keeping MongoDB context-only per issue #305.
- tests: cover input.tableScope and the empty-scope skip for the Mongo
  connector (the scan layer does not post-filter), the view no-count path, and
  the ktx sl query refusal for a mongodb connection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* polish(mongodb): compute sampled nullCount and document sampling caveats

Address the non-blocking review notes:

- sampleColumn now counts null/absent values over the sampled window instead of
  returning nullCount: null, since the documents are already in hand
- warn that a custom order_by must be indexed (an unindexed sort hits MongoDB's
  in-memory sort limit on large collections) in the connection schema and docs
- note that sampled values for nested fields are stringified, not faithfully
  serialized, so the json opacity is deliberate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(examples): add a MongoDB connector example

A manual, container-backed example mirroring examples/postgres-historic:

- docker-compose.yml + init/seed.js seed a representative dataset (nested
  documents, arrays, a Decimal128, a mixed-type field, a nullable field, an
  ObjectId reference, and a view) on first container start
- scripts/smoke.sh + introspect-smoke.mjs assert the connector's inferred
  schema with no LLM credentials — the same introspection entry point ktx
  ingest's database-schema stage uses, including the view-no-count path
- README.md documents the smoke and a full keyless ktx ingest run
  (claude-code LLM + managed sentence-transformers embeddings)

Works with Docker Compose or podman compose. Verified end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: ignore examples/** in knip to fix dead-code false positives

The MongoDB connector example files (examples/mongodb/init/seed.js and
examples/mongodb/scripts/introspect-smoke.mjs) are used at runtime but were
flagged as unused by knip. Add examples/** to the ignore array, matching the
existing .context/** entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114qQV8fJ5a5ME3XbMVRzbL

* fix(mongodb): refuse non-SQL connections before SQL analysis

`ktx sql` and the MCP sql_execution tool resolved a SQL-analysis dialect
(falling back to Postgres for a non-SQL driver) and ran read-only
validation before the connector capability gate refused the connection.
For a MongoDB connection that spun up the parser/daemon and produced
Postgres parser diagnostics instead of a clean non-SQL refusal.

Route both entry points through a shared assertSqlQueryableConnection
guard before dialect selection, mirroring compileLocalSlQuery. The
federated duckdb path has no driver and is exempted at each call site.
Add CLI and MCP regression tests asserting validation/connector work
never starts for a MongoDB connection.

* fix(mongodb): pass CI gates (dialect boundary, secrets, setup test)

Three latent failures in the connector surfaced once CI ran on the branch:

- connector.ts imported the concrete KtxMongoDbDialect, which the connector
  dialect-import boundary forbids. Route it through getDialectForDriver('mongodb')
  and widen inferKtxMongoCollectionColumns to the base KtxDialect (it only uses
  mapDataType/mapToDimensionType).
- detect-secrets flagged a test ObjectId hex and the mongodb+srv example URL;
  annotate both with allowlist pragmas.
- the "shows every supported database" setup test omitted the new MongoDB option.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Luca Martial <48870843+luca-martial@users.noreply.github.com>
Co-authored-by: Luca Martial <lucamrtl@gmail.com>
Co-authored-by: Andrey Avtomonov <andreybavt@gmail.com>
2026-06-29 15:17:56 +02:00
Bittu kumar
4f084186f1
feat(docs): add system theme option to theme toggle (#324)
* feat(docs): add system theme option to theme toggle

* fix(docs): address review on theme toggle

Render the switcher as a proper radio group (role="radiogroup"/role="radio"
with aria-checked, roving tabindex, and arrow-key navigation) instead of
independent aria-pressed toggles, since the three options are mutually
exclusive. Update the component docstring to describe the three-option
switcher and note that it reads `theme` (not `resolvedTheme`) so the System
option can show as selected. Drop unrelated formatting churn.

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

---------

Co-authored-by: Luca Martial <lucamrtl@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Luca Martial <48870843+luca-martial@users.noreply.github.com>
2026-06-28 20:41:59 -04:00
Bittu kumar
50afcae9f4
docs: improve CLI flags table readability (#323)
* docs(readme): improve CLI flags table readability

* feat: add more details

* docs(cli-reference): keep table default markers consistent

Revert the "no default" markers for --target and --no-input from em
dash back to the plain hyphen used in every other CLI-reference table,
and drop the code-font styling on the descriptive "ktx project dir"
default so it reads as text rather than a literal value.

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

---------

Co-authored-by: Luca Martial <lucamrtl@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:08:47 -04:00
github-actions[bot]
b0dca62c0e chore: refresh star history chart [skip ci] 2026-06-28 18:29:57 +00:00
Andrey Avtomonov
ca231df5fe
fix(gdrive): validate folder access, run config test, harden Drive API (#321)
* fix(gdrive): validate folder access, run config test, harden Drive API

Connection test and setup validation now verify folder_id resolves to an accessible Drive folder before counting Docs, via a shared verifyGdriveFolderAndCountDocs helper, so a wrong or unshared folder fails instead of passing with 0 docs.

Move gdrive-config.test.ts under test/ so Vitest's test/** glob actually runs it; escape folder_id in the Drive query; add retry/backoff on transient Google API responses; and record skipped non-Google-Doc files in the staged manifest.

* chore: sync uv.lock to ktx-daemon/ktx-sl 0.13.1
2026-06-28 01:02:37 +02:00
ARYAN
5645dc4d28
Add gdrive context source adapter (#209)
* Add gdrive context source adapter

* feat(gdrive): normalize internal doc links, tabs, and header/footer structure

* fix(gdrive): reject generic source credential flags

* test(gdrive): include local adapter in expected list

* fix(gdrive): remove dead exports and silence false positive secret checks

* fix(setup): restore notion source auth flow
2026-06-27 23:41:32 +02:00
github-actions[bot]
967a413a06 chore: refresh star history chart [skip ci] 2026-06-27 18:30:01 +00:00
github-actions[bot]
89f25435d5 chore: refresh star history chart [skip ci] 2026-06-27 07:11:18 +00:00
github-actions[bot]
73e4c8b270 chore: refresh star history chart [skip ci] 2026-06-26 18:42:37 +00:00
github-actions[bot]
77c38e9ea2 chore: refresh star history chart [skip ci] 2026-06-25 18:50:21 +00:00
github-actions[bot]
f61ea76007 chore: refresh star history chart [skip ci] 2026-06-24 18:40:31 +00:00
github-actions[bot]
a155c0b844 chore: refresh star history chart [skip ci] 2026-06-23 18:48:05 +00:00
semantic-release-bot
2830cb5ac7 chore(release): 0.13.1 [skip ci]
## [0.13.1](https://github.com/Kaelio/ktx/compare/v0.13.0...v0.13.1) (2026-06-23)

### Bug Fixes

* **sqlserver:** hoist leading CTEs out of row-limit derived-table wrap ([#311](https://github.com/Kaelio/ktx/issues/311)) ([c815e10](c815e10fb3))

### Other Changes

* refresh star history chart [skip ci] ([9f715f9](9f715f93f1))
* refresh star history chart [skip ci] ([144943e](144943ec1d))
* refresh star history chart [skip ci] ([e550091](e550091a76))
* refresh star history chart [skip ci] ([1f16a89](1f16a89c94))
2026-06-23 13:09:33 +00:00
Andrey Avtomonov
c815e10fb3
fix(sqlserver): hoist leading CTEs out of row-limit derived-table wrap (#311)
* test(sql): cover leading CTE row-limit wrapping

* fix(sql): hoist leading CTEs before generic row limits

* fix(sqlserver): hoist leading CTEs before TOP row limits

* test(scan): note relationship limiter coverage boundary

* chore: sync uv.lock to ktx-daemon/ktx-sl 0.13.0
2026-06-23 13:03:46 +00:00
github-actions[bot]
9f715f93f1 chore: refresh star history chart [skip ci] 2026-06-22 19:14:30 +00:00
github-actions[bot]
144943ec1d chore: refresh star history chart [skip ci] 2026-06-21 18:37:28 +00:00
github-actions[bot]
e550091a76 chore: refresh star history chart [skip ci] 2026-06-20 18:35:41 +00:00
github-actions[bot]
1f16a89c94 chore: refresh star history chart [skip ci] 2026-06-19 18:42:28 +00:00
semantic-release-bot
d62dc46a86 chore(release): 0.13.0 [skip ci]
## [0.13.0](https://github.com/Kaelio/ktx/compare/v0.12.0...v0.13.0) (2026-06-19)

### Features

* **cli:** let ktx setup --agents choose an install directory ([#298](https://github.com/Kaelio/ktx/issues/298)) ([4e61020](4e61020089))
* **duckdb:** cross-database federation via derived DuckDB connection ([#295](https://github.com/Kaelio/ktx/issues/295)) ([6c815ef](6c815ef529))

### Bug Fixes

* classify mcp query failures ([#302](https://github.com/Kaelio/ktx/issues/302)) ([7e29543](7e29543398))
* **cli:** make connection-not-configured errors actionable and expected ([#301](https://github.com/Kaelio/ktx/issues/301)) ([8a50601](8a50601582))
* **cli:** stop framing Claude Code session limits as auth failures (KLO-734) ([#300](https://github.com/Kaelio/ktx/issues/300)) ([b81391c](b81391cd9f))
* **git:** disable gpg signing for ktx's own commits ([#299](https://github.com/Kaelio/ktx/issues/299)) ([9587049](9587049283))
* **sl:** parse user filter expressions as predicates, not projections ([#307](https://github.com/Kaelio/ktx/issues/307)) ([fb50c11](fb50c11d16))

### Tests

* **cli:** persist warehouse connection in sl query tests ([#303](https://github.com/Kaelio/ktx/issues/303)) ([fde9f98](fde9f9862d)), closes [#301](https://github.com/Kaelio/ktx/issues/301)

### Other Changes

* refresh star history chart [skip ci] ([4dae8c3](4dae8c34dd))
* refresh star history chart [skip ci] ([01ccc73](01ccc73e40))
* refresh star history chart [skip ci] ([e8bfb3d](e8bfb3d301))
* refresh star history chart [skip ci] ([e4e7b40](e4e7b40c23))
* refresh star history chart [skip ci] ([e817736](e817736b91))
* refresh star history chart [skip ci] ([674b58b](674b58b3ed))
* refresh star history chart [skip ci] ([ed44f46](ed44f46f2a))
2026-06-19 08:49:46 +00:00
Luca Martial
fb50c11d16
fix(sl): parse user filter expressions as predicates, not projections (#307)
* fix(sl): parse user filter expressions as predicates, not projections

User-authored filters and segments were parsed in a projection context
(`SELECT {expr}`). On T-SQL a top-level `col = 'value'` projection is the
`alias = expression` aliasing syntax, so an equality filter parsed this way
became `'value' AS col` — dropping the comparison entirely and silently
skipping computed-column expansion (the column hid behind the alias).

Parse user fragments as predicates (`SELECT * WHERE {expr}`) at every parse
site — the parser cache, measure-filter CASE WHEN generation, computed-column
expansion, and measure-filter/segment column qualification. For plain
non-condition expressions the column set is identical, so this is a no-op
everywhere except the T-SQL alias case it fixes.

Add cross-dialect regression tests (tsql, postgres, snowflake, bigquery)
locking equality filters/segments to comparison shape and confirming `= 'x'`
now matches `IN ('x')` on T-SQL.

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

* Shorten T-SQL predicate comments

* docs(sl): tighten T-SQL predicate docstrings and AGENTS docstring rule

Trim the parser and regression-test docstrings to the 1-3 line bar and
extend the AGENTS.md comment guidance to cover docstrings explicitly.

* refactor(sl): route all filter parsing through parse_predicate

Consolidate the predicate-context parse into a single parse_predicate
helper and route every filter-parsing call site through it: measure
CASE-WHEN filters, segments, computed-column-in-filter, the
aggregate-locality HAVING rewrite, and the planner OR-mixing /
top-level-AND split. The locality and split paths still parsed user
filters in projection context, so a named-measure equality filter
compiled to `0 AS measure` on T-SQL. Add a locality regression test
covering the HAVING rewrite path.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Andrey Avtomonov <andreybavt@gmail.com>
2026-06-19 08:47:44 +00:00
github-actions[bot]
4dae8c34dd chore: refresh star history chart [skip ci] 2026-06-18 18:53:52 +00:00
github-actions[bot]
01ccc73e40 chore: refresh star history chart [skip ci] 2026-06-17 07:59:36 +00:00
github-actions[bot]
e8bfb3d301 chore: refresh star history chart [skip ci] 2026-06-16 19:18:05 +00:00
Andrey Avtomonov
fde9f9862d
test(cli): persist warehouse connection in sl query tests (#303)
#301 strengthened resolveLocalConnectionId to reject connections absent
from ktx.yaml, but three sl query tests configured the warehouse
connection only in memory while runKtxSl reloads the project from disk,
so they returned exit code 1 once the check landed on main. Persist the
connection to ktx.yaml so it survives the reload and the tests exercise
the real on-disk load path.
2026-06-15 17:23:59 +02:00
Kevin Messiaen
6c815ef529
feat(duckdb): cross-database federation via derived DuckDB connection (#295)
* feat(duckdb): add @duckdb/node-api dependency for federation

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

* refactor(connectors): extract resolveStringReference to shared module

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

* refactor(connectors): route all identical connectors through shared resolveStringReference

Collapse the 5 remaining private copies in bigquery, clickhouse, mysql,
snowflake, and sqlserver into the shared module. Fix a latent bug in the
shared module where `~/path` was incorrectly sliced (dropping only `~`,
leaving the leading `/` and making resolve() ignore homedir). Add a
tilde-expansion test that caught the bug and now covers that branch.

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

* feat(sl): reserve _ktx_ connection-id prefix for virtual connections

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

* feat(connections): derive virtual federated connection from compatible members

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

* feat(duckdb): federated executor builds READ_ONLY attaches and runs SQL

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(duckdb): close federated DuckDB instance and escape quotes in attach url

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

* feat(sl): union member source directories for _ktx_federated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(query): route _ktx_federated through DuckDB executor

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sl): use duckdb dialect for federated query compilation

Bypass assertSafeConnectionId for _ktx_federated in resolveLocalConnectionId
and loadComputableSources, and resolve the compute dialect to 'duckdb' when
connectionId is FEDERATED_CONNECTION_ID instead of falling through to the
default postgres lookup.

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

* test(duckdb): end-to-end cross-catalog federated join

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(duckdb): harden federated join test with multi-book join-key coverage

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

* feat(ingest): keep declared cross-DB joins to federated siblings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(setup): surface federated connection availability after adding a member

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(setup): mark federationNoticeFor @internal for dead-code gate

Also marks attachTypeForDriver, buildAttachStatements, and
isReservedConnectionId @internal — all three are exported solely for
unit-test access with no production cross-file consumer.

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

* docs(concepts): document cross-database federation

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

* docs(concepts): correct sqlite two-part naming in federation doc

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(duckdb): quote federated catalog alias so hyphenated connection ids attach

* refactor(duckdb): single-source federation driver list, dedup attach loads

Collapse the parallel ATTACH_COMPATIBLE_DRIVERS set and ATTACH_TYPE_BY_DRIVER
map into one map in federation.ts whose keys are the membership rule. Replace
FederatedMember.config (read only via a type-erasing cast) with a typed url
field extracted at derive time. Emit INSTALL/LOAD once per distinct driver
type instead of once per member.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(duckdb): close federated DuckDB instance on connect failure; dedup id validation

Wrap the federated DuckDB instance in its own try/finally so a failing
connect() or a throwing connection.closeSync() no longer leaks the native
instance. Route setup-sources connection-id validation through the canonical
assertSafeConnectionId so the reserved _ktx_ prefix guard applies there too.
Derive the federated dialect through sqlAnalysisDialectForDriver instead of a
hardcoded literal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(federation): carry member connection config and projectDir on FederatedMember

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(federation): resolve per-member attach targets via canonical connector resolvers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(federation): quote mysql attach-string values like postgres

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(federation): resolve member attach targets via canonical resolvers, supporting sqlite path:

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(federation): thread projectDir through deriveFederatedConnection callers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(federation): add shared project read-only SQL executor that routes _ktx_federated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(federation): exercise shared executor default federated path with real DuckDB

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(federation): route ingest query executor through shared executor

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(federation): route MCP sql_execution _ktx_federated through shared executor

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(federation): preserve cross-DB joins to federated siblings in manifest re-emit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(federation): preserve declared cross-DB joins through scan re-ingest

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(federation): document sibling-ref invariant, drop unsafe casts in test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(federation): namespace federated source names by member to avoid collisions

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(federation): document member-namespaced federated source names

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(federation): preserve member SSL/search_path in attach, classify federated MCP errors

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(federation): simplify federated dispatch and parallelize sibling reads

Dedup the federated driver ternary in local-query, derive the prefixed
source.name from the already-built name, drop the duplicated error in
federatedAttachTarget's exhaustive switch, inline the one-line
cleanupConnector wrapper, and parallelize federatedSiblingTargets' shard
reads (was sequential await-in-for on the scan hot path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(federation): carry headerTypes through shared SQL executor

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(federation): add shared federated connection listing builder

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(federation): route ktx sql through shared executor for _ktx_federated parity

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(federation): show _ktx_federated in ktx connection list

Surfaces the virtual federated connection in the output of
`ktx connection list` so agents and users can discover cross-database
querying when 2+ attach-compatible connections are configured.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(federation): surface _ktx_federated in MCP connection_list

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(federation): ktx sql federated cross-file join end-to-end

Drive runKtxSql with the real federated DuckDB executor against two on-disk
sqlite files, stubbing only SQL validation. The test surfaced that the JSON
output path could not serialize bigint values DuckDB returns for integer
columns; printJson now coerces bigint to JSON numbers, matching the
plain/pretty paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(federation): document direct _ktx_federated query surface

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(federation): coerce DuckDB bigint to number in shared federated executor

DuckDB returns integer columns as JS bigint, which JSON.stringify cannot
serialize. The CLI --json path worked around this with a replacer, but the
MCP sql_execution tool serializes via plain JSON.stringify and crashed on
any federated query selecting an integer column. Coerce bigint to Number
once in executeFederatedQuery so every consumer (CLI, MCP, ingest, SL)
gets a JSON-safe result, and remove the now-redundant CLI replacer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(federation): simplify driver map and collapse forked MCP SQL path

- Replace the identity-valued ATTACH_TYPE_BY_DRIVER record with a
  ATTACH_COMPATIBLE_DRIVERS Set; the driver name doubles as the attach
  type, so the map encoded nothing beyond membership.
- Switch federatedAttachTarget directly on the driver with a default
  throw, dropping the unreachable post-switch throw and its comment.
- Route the MCP sql_execution standard-connection case through the
  shared executeProjectReadOnlySql instead of reimplementing the
  connector create/capability-check/execute/cleanup ceremony, so
  federated and standard connections share one execution path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(federation): allowlist placeholder credentials for detect-secrets

The federation doc example URL and the federated-attach test fixtures use
literal placeholder credentials that trip detect-secrets. Mark them with
line-scoped pragma allowlist comments so a real secret added later is still
caught.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(federation): correct SL addressing, join pruning, and id-quoting guidance

- Federated SL list/search records carry the virtual `_ktx_federated`
  connection id (member origin stays in the prefixed source name), so rows
  round-trip to `ktx sl -c _ktx_federated read` and the fts index no longer
  clobbers per-connection partitions.
- Prune semantic-layer joins by membership in the connection's own source set
  instead of matching the target's first dotted segment against other
  connection ids; a same-connection join whose target name collides with a
  sibling connection id is preserved, and orphan targets that would poison the
  planner are dropped.
- Document double-quoting for connection ids that are not bare SQL identifiers
  (e.g. "books-db".public.books) in the federated naming hint, the sl-query
  rejection error, and the federation docs.
- Preserve exact federated BIGINT values beyond 2^53 as strings instead of
  rounding, and steer the setup federation notice to raw SQL against
  `_ktx_federated`.

* fix(federation): carry ssl:true into postgres URL attach target

A postgres member configured with `url` plus `ssl: true` resolved to both a
connectionString and an ssl flag, but the federated attach builder early-returned
the bare URL and dropped the ssl intent. DuckDB then handed libpq a URL with no
sslmode, so the URL path silently diverged from the discrete-field path (which
emits sslmode=require) and from the direct scan path (which enforces TLS).

Append sslmode=require to the URL when the member sets ssl, unless the URL
already pins a stronger sslmode.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Andrey Avtomonov <andreybavt@gmail.com>
2026-06-15 15:01:39 +00:00
Kevin Messiaen
b81391cd9f
fix(cli): stop framing Claude Code session limits as auth failures (KLO-734) (#300)
The Claude Code auth probe wrapped every error as "authentication is not
usable", so a subscription session limit told users to re-authenticate —
which cannot help, since auth already succeeded and only a reset clears it.

Discriminate probe failures with describeClaudeProbeFailure: session-limit
and rate-limit hits get their own messages (preserving the upstream reset
text), and genuine auth errors keep the original guidance. Also add the
session/usage-limit markers to the shared rate-limit classifier so the
governor stops treating a cap as a generic error.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:58:03 +02:00
Andrey Avtomonov
7e29543398
fix: classify mcp query failures (#302) 2026-06-15 12:48:24 +00:00
Andrey Avtomonov
8a50601582
fix(cli): make connection-not-configured errors actionable and expected (#301)
The MCP sql_execution/sl_query tools and the `ktx sql` CLI threw a plain Error naming no valid connection ids when an agent passed an unconfigured connectionId (or omitted it with multiple connections). The message reached the agent verbatim but gave it nothing to correct with, so it re-guessed for days, and each correct caller-driven rejection filed in PostHog Error Tracking as a ktx fault (issue 019eb10c, 8 occurrences on one install).

Add a shared resolver (resolveConfiguredConnection / resolveRequiredConnectionId) that throws KtxExpectedError listing the configured connections, and route the three SQL-execution call sites through it. Expected-error classification keeps these out of Error Tracking while the actionable message lets agents self-correct.
2026-06-15 14:38:44 +02:00
Kevin Messiaen
9587049283
fix(git): disable gpg signing for ktx's own commits (#299)
ktx commits under a synthetic identity (ktx <ktx@example.com>) that can
never own a GPG secret key. On a machine with commit.gpgsign=true, git
tried to sign every ktx commit and failed with "No secret key", breaking
ingest, scan, wiki, memory, and bootstrap commits.

Inject commit.gpgsign=false as a per-invocation -c override in the single
core git client factory every ktx commit flows through. This honors the
existing principle of not mutating the user's repo config, and is
locale-independent (no error-message matching).

Also harden the repo-isolation fixture helper to disable signing on its
raw commits so the suite is deterministic regardless of the contributor's
global git config.

Fixes KLO-735.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:02:26 +02:00
github-actions[bot]
e4e7b40c23 chore: refresh star history chart [skip ci] 2026-06-15 08:10:51 +00:00
github-actions[bot]
e817736b91 chore: refresh star history chart [skip ci] 2026-06-14 07:43:07 +00:00
github-actions[bot]
674b58b3ed chore: refresh star history chart [skip ci] 2026-06-13 07:25:11 +00:00
Andrey Avtomonov
4e61020089
feat(cli): let ktx setup --agents choose an install directory (#298)
Split the fused directory concept into projectDir (what the agent config
references) and installRoot (where project-scoped files are written), so
users can install .claude/, .mcp.json, skills, and rules where they open
their agent instead of only in the ktx project directory.

- Add --install-dir <path> (resolved against cwd, created if missing,
  mutually exclusive with --global/--local, rejected for claude-desktop).
- Add an interactive directory menu: ktx project dir / Current directory
  (hidden when it equals the project dir) / Custom directory… / Global
  scope (shown only when every target supports it).
- Expand a leading ~ in typed/quoted paths so the ~/… menu hints round-trip.
- Record installRoot in the install manifest and merge key; thread it
  through file planning, MCP config paths, summaries, and next actions.
- Refresh uv.lock to 0.12.0 for the editable ktx-sl and ktx-daemon packages.
2026-06-13 00:46:56 +02:00
github-actions[bot]
ed44f46f2a chore: refresh star history chart [skip ci] 2026-06-12 18:46:09 +00:00
semantic-release-bot
cf2871ec8b chore(release): 0.12.0 [skip ci]
## [0.12.0](https://github.com/Kaelio/ktx/compare/v0.11.0...v0.12.0) (2026-06-12)

### Features

* **cli:** add ktx wordmark banner to setup intro ([#290](https://github.com/Kaelio/ktx/issues/290)) ([28953eb](28953eb616))
* **cli:** self-provision pinned uv and defer MCP Python runtime install ([#297](https://github.com/Kaelio/ktx/issues/297)) ([feb0818](feb0818444))
* **cli:** setup progress spinners, Tab-to-select, and banner polish ([#296](https://github.com/Kaelio/ktx/issues/296)) ([663eaff](663eaff940)), closes [#FF8A4C](https://github.com/Kaelio/ktx/issues/FF8A4C)

### Bug Fixes

* classify MCP SQL query errors as expected ([#285](https://github.com/Kaelio/ktx/issues/285)) ([036a745](036a745fc1))
* **cli:** clear error when ktx setup has no LLM backend under --no-input ([#281](https://github.com/Kaelio/ktx/issues/281)) ([0425160](0425160857))
* **cli:** isolate ktx-owned project repositories ([#283](https://github.com/Kaelio/ktx/issues/283)) ([2877b85](2877b85adc))
* **cli:** own a dedicated git repo at the project dir when nested in an enclosing repo ([#282](https://github.com/Kaelio/ktx/issues/282)) ([fd18caa](fd18caa26a))
* **cli:** survive ktx.yaml version skew and derive repo ownership from disk ([#293](https://github.com/Kaelio/ktx/issues/293)) ([0689d70](0689d709d2))
* **deps:** bump hono override to 4.12.21 to resolve dependabot alerts ([#288](https://github.com/Kaelio/ktx/issues/288)) ([56e0633](56e06334d2))
* **ingest:** verify repair outcomes and reject dangling join targets ([#292](https://github.com/Kaelio/ktx/issues/292)) ([a278d2f](a278d2f7d0))
* read semantic sources safely ([#284](https://github.com/Kaelio/ktx/issues/284)) ([f3f893b](f3f893bf01))
* **setup:** require explicit no-input database scope ([#286](https://github.com/Kaelio/ktx/issues/286)) ([853f39a](853f39a7c3))

### Documentation

* **integrations:** correct context-source ingestion details ([#291](https://github.com/Kaelio/ktx/issues/291)) ([7c3b4ce](7c3b4cea2c))
* **site:** relocate GitHub stars to sidebar footer, add light/dark switcher ([#294](https://github.com/Kaelio/ktx/issues/294)) ([e1067bf](e1067bf734))

### Code Refactoring

* enforce ktx naming and AGENTS.md compliance sweep ([#289](https://github.com/Kaelio/ktx/issues/289)) ([00cdf2d](00cdf2de90))

### Tests

* **ingest:** supply explicit no-input schema scope in skip-llm setup ([#287](https://github.com/Kaelio/ktx/issues/287)) ([058051f](058051f1b9)), closes [#286](https://github.com/Kaelio/ktx/issues/286) [#286](https://github.com/Kaelio/ktx/issues/286)

### Other Changes

* refresh star history chart [skip ci] ([005c5fc](005c5fc860))
* refresh star history chart [skip ci] ([b076431](b076431b0a))
* refresh star history chart [skip ci] ([65de75e](65de75ebd7))
* remove dead pnpm.onlyBuiltDependencies from package.json ([9ff0e86](9ff0e86bb8))
2026-06-12 16:45:18 +00:00
Andrey Avtomonov
feb0818444
feat(cli): self-provision pinned uv and defer MCP Python runtime install (#297)
Fixes a production crash-loop (PostHog issue 019eb68e): ktx mcp start
--foreground on a uv-less container eagerly installed the managed Python
runtime at boot, failed, and was restarted by its supervisor every ~62s
(122 exceptions from one install).

- MCP server factory now wires a lazy semantic-layer compute port that
  defers the runtime install to the first call, mirroring the already-lazy
  SQL-analysis port; the server boots and serves non-Python tools without
  the runtime.
- ktx no longer requires uv on PATH: it downloads its own pinned,
  sha256-verified uv build under the runtime root (KTX_RUNTIME_ROOT aware),
  always musl-static on Linux. PATH uv is never consulted.
- uv is acquired before the version dir is wiped, so a failed download
  cannot destroy an existing runtime.
- Acquisition failures (offline, intercepted download, unsupported
  platform) throw KtxExpectedError and stay out of Error Tracking; a
  missing binary inside a checksum-verified archive remains a plain Error.
- scripts/refresh-uv-manifest.mjs regenerates the pinned manifest
  (packages/cli/src/managed-uv-release.ts) on uv bumps.
- Setup consent prompt now discloses the uv download; docs updated.
2026-06-12 16:31:06 +00:00
Andrey Avtomonov
663eaff940
feat(cli): setup progress spinners, Tab-to-select, and banner polish (#296)
* fix(cli): double the height of the setup banner t crossbar

* fix(cli): unify setup multi-select hints and make Tab the select key

The six interactive multi-select surfaces in `ktx setup` documented three
different hint voices, one had no hint at all, and they named two different
select keys (Space vs Tab). Tab is the only key that can toggle selection
without colliding with type-to-search input, so make it the single documented
select key everywhere and compose every hint from one shared fragment
vocabulary in prompt-navigation.ts.

- Register `updateSettings({ aliases: { tab: 'space' } })` so Tab toggles flat
  multiselects; the alias applies only to non-text prompts, leaving typed
  search input (schema/Notion) untouched.
- Add the missing hint to the agent-targets prompt and drop the stray
  "Space to select … Esc …" info line plus the now-dead writeSetupInfo helper.
- Replace the schema-scope ad-hoc hint with the searchable-multiselect voice
  and standardize "filter" -> "search" vocabulary.
- Delete DEFAULT_TREE_PICKER_HELP_TEXT and the unused TreePickerChrome.helpText
  seam; render the shared tree hint instead.

* refactor(cli): show LLM check progress for every setup backend

Rename runLlmHealthCheckWithProgress to validateModelWithProgress and
wrap the Claude subscription and Codex auth probes in the same spinner
progress as the Anthropic API and Vertex backends, so each backend shows
consistent "Checking <provider> LLM" output during setup.

* feat(cli): add ktx-orange progress spinners to setup steps

Add a shared runWithCliSpinner helper and a TTY-aware createCliSpinner:
an animated clack spinner in a terminal, and a static stderr-only spinner
before raw-mode pickers (the table tree picker and demo tour), where the
animated spinner's stdin grab would otherwise corrupt the next prompt.

Wrap the slow setup waits in progress spinners: managed runtime install,
embedding daemon start + first-run model download, embeddings health
check, the connection-test gate, and source validation / dbt clone /
Metabase discovery. Recolor every spinner frame from clack's magenta to
the ktx mascot orange (#FF8A4C) via the static helper and clack's
styleFrame option.
2026-06-12 16:43:10 +02:00
Andrey Avtomonov
e1067bf734
docs(site): relocate GitHub stars to sidebar footer, add light/dark switcher (#294)
Move the live GitHub stars widget into the sidebar footer pill as a
type:"icon" link, sitting opposite the Slack mark (space-between) and
beside the theme switcher. Render it as inner content (GitHub mark +
star + compact count) so fumadocs supplies the anchor.

Replace the default fumadocs theme switcher with a custom two-icon
control where each icon selects its own theme. The built-in
"light-dark" mode is a single blind toggle that flips on any click, so
clicking the sun while already in light mode jumps to dark. useTheme is
sourced from fumadocs-ui/provider/base and the icons are inlined to
avoid bare next-themes / lucide-react imports.
2026-06-12 09:06:04 +00:00
Andrey Avtomonov
0689d709d2
fix(cli): survive ktx.yaml version skew and derive repo ownership from disk (#293)
* fix(cli): survive ktx.yaml version skew and derive repo ownership from disk

Loading ktx.yaml is now tolerant of keys this ktx version does not
recognize: they are stripped from the in-memory config (the file on disk
is never rewritten) and reported by ktx status as non-blocking warnings,
while invalid values on recognized fields still fail hard. Repo
ownership is derived from observed state (a .git directory plus a root
ktx.yaml) instead of a ktx.managed git-config marker, so projects
created by any past or future ktx classify identically. initKtxProject
now runs an explicit foreign-repo pre-check and writes ktx.yaml before
initializing git, so an interrupted init leaves only recoverable
residue instead of a bare .git misread as foreign.

* style(cli): trim comment blocks to constraint-only notes

* docs(agents): require constraint-only code comments
2026-06-11 22:10:47 +02:00
Andrey Avtomonov
a278d2f7d0
fix(ingest): verify repair outcomes and reject dangling join targets (#292)
One ingest integration hiccup no longer discards a whole source:

- Replace the duplicated gate-repair and textual-resolver loops with one
  shared constrained-repair loop whose success criterion is re-running
  the failed check (verify), not whether the agent edited files. Verify
  failures feed the retry prompt; maxAttempts is 2.
- Let the resolver declare a conflicting patch redundant: a verified
  no-change resolution is accepted as subsumed instead of failing the
  source (duplicate wiki-page creation from parallel work units).
- Carry per-source validation errors through validateWuTouchedSources
  into gate messages and work-unit failure reasons instead of
  discarding them.
- Move join-neighbor expansion into the shared validation path so
  work-unit validation and integration gates check the same set.
- Reject joins whose target resolves to no source, at sl_write time and
  in the gates, attributed to the declaring source. Resolution mirrors
  the Python engine exactly (case-sensitive name within the
  connection), with a case-mismatch hint for the writing agent.
2026-06-11 14:39:51 +02:00
Andrey Avtomonov
00cdf2de90
refactor: enforce ktx naming and AGENTS.md compliance sweep (#289)
Align the tree with AGENTS.md/CLAUDE.md conventions:

- Rewrite user-facing strings, docs, and tests to lowercase `ktx`
  (no bare uppercase `KTX` tokens remain outside literal identifiers).
- Drop the legacy `historicSql` migration path and its now-unused
  helpers, per the no-backward-compat rule.
- Remove `as unknown as` / `any` casts: narrow `BaseTool` generics to
  `z.ZodObject`, add a typed `createLookerClient`, and delete the dead
  `getParametersSchema`/`toAnthropicFormat` pre-AI-SDK helpers.
- Use `InvalidArgumentError` for Commander parse failures.
- Finish the adapter→connector prose conversion in the `ktx.yaml` docs
  while keeping the literal `adapters` config key.
2026-06-11 13:49:45 +02:00
github-actions[bot]
005c5fc860 chore: refresh star history chart [skip ci] 2026-06-11 07:49:11 +00:00
Luca Martial
7c3b4cea2c
docs(integrations): correct context-source ingestion details (#291)
Verified the dbt, MetricFlow, LookML, Metabase, Looker, and Notion
sections of context-sources.mdx against the adapter code and fixed
claims that did not match the implementation:

- dbt: replace "test coverage" framing with the actual constraint/enum/
  join derivation; name both overlay and wiki outputs; fix work-unit
  granularity (per models/ schema file above 25 YAML files).
- MetricFlow: relationships come from entities (not dimensions); surface
  the join edges they produce.
- LookML: chunking is one work unit per model (not connected component);
  add the wiki output; note that a connection: mismatch disables SL writes.
- Metabase: dashboards are never fetched (no dashboard endpoint); work
  units are per collection; "usage patterns" is really card output schema.
- Looker: drop invented "purpose/audience" framing; describe triage as a
  prioritization gate; include Looks alongside explores and dashboards.
- Notion: not knowledge-only (it writes SL sources for mapped non-Notion
  targets); remove the nonexistent database-schema extraction; reframe
  "What it provides" as inputs; document root_data_source_ids.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:14:47 -07:00
Andrey Avtomonov
28953eb616
feat(cli): add ktx wordmark banner to setup intro (#290)
Render a lowercase ktx half-block wordmark with the brand-orange gradient
above the `ktx setup` intro on interactive TTYs. The banner degrades
through truecolor, xterm-256, and monochrome, and is skipped on non-TTY,
non-Unicode, or too-narrow terminals.

Extract shared color/Unicode capability detection into io helpers
(shouldUseColorOutput, colorDepthForOutput, unicodeSupported) so the
banner and doctor report route through one implementation.
2026-06-10 14:47:34 +00:00
Andrey Avtomonov
56e06334d2
fix(deps): bump hono override to 4.12.21 to resolve dependabot alerts (#288) 2026-06-10 12:26:01 +00:00
Andrey Avtomonov
058051f1b9
test(ingest): supply explicit no-input schema scope in skip-llm setup (#287)
The "prints provider setup guidance when a skip-llm setup project runs
ingest" test drives runKtxSetup in --no-input (inputMode: disabled) mode
with a postgres warehouse but databaseSchemas: []. PR #286 changed the
no-input contract to require an explicit database scope instead of
auto-scanning, so setup now exits 1 and the test's first assertion
(resolves.toBe(0)) fails. #286 updated setup-databases.test.ts but missed
this runKtxSetup call in ingest.test.ts. Pass databaseSchemas: ['public']
to satisfy the new contract, matching #286's own test updates.
2026-06-10 12:22:22 +00:00
Andrey Avtomonov
2877b85adc
fix(cli): isolate ktx-owned project repositories (#283)
* fix(cli): isolate ktx project git repos

* fix(cli): remove inert auto commit config

* test(cli): drop stale auto commit fixtures

* docs: document isolated ktx project repos

* test(cli): keep stale config grep clean

* fix(cli): guide setup away from foreign repos at the project dir

ktx owns the git repo rooted at the project dir and refuses to adopt one it
did not create (the Finding 3 isolation invariant). But setup steered users
straight into that failure: the interactive menu offers "Current directory"
first, and `--no-input --yes --project-dir <repo-root>` created directly in
place — both then threw a generic "Failed to initialize git repository:"
wrapper from deep in GitService.initialize().

Extract the ownership rule into a shared `classifyKtxRepoOwnership(dir)` used by
both GitService.initialize() (the invariant) and the setup wizard (pre-flight
guidance), so the decision derives from one rule. Setup now detects a foreign
repo before constructing GitService and: interactively re-prompts (the user
picks the existing `ktx-project` subfolder), or non-interactively returns a
clean missing-input with the actionable message. The typed foreign-repo error
is also surfaced verbatim instead of being buried under the generic wrapper.

Empty/non-repo current directories still work — only foreign repos are blocked.

* fix(cli): keep classifyKtxRepoOwnership total for non-directory paths

The setup ownership guard runs before the existing not-a-directory check, so
pointing a custom/--project-dir path at a file made classifyKtxRepoOwnership
lstat `<file>/.git`, hit ENOTDIR, and throw — crashing the setup step instead
of returning the friendly "path exists and is not a directory" result.

A path that is a file (or missing) holds no git repo for ktx to avoid, so treat
ENOTDIR like ENOENT and return 'unowned'. The downstream existingFolderState
check still rejects a non-directory with its friendly message, and the
classifier no longer throws raw errno for any caller.
2026-06-10 14:12:25 +02:00
Andrey Avtomonov
f3f893bf01
fix: read semantic sources safely (#284)
* fix: read semantic sources safely

* test: retarget reindex per-scope error case to a broken manifest

Reading a broken standalone source was made non-fatal in de1f1a8d (it is
surfaced for repair instead of throwing), so the reindex per-scope error
test no longer captured an error. Point it at a corrupt manifest shard,
which is the remaining fatal read failure the per-scope catch must
isolate, and assert the captured error names the offending file.

* fix(sl): decouple semantic-layer file names from warehouse naming rules

The in-file `name:` field is now the sole source identity; the filename is
a derived label that never participates in identity. This removes the
"Unsafe semantic-layer source name" failure class entirely: any warehouse
identifier (Snowflake's uppercase SIGNED_UP, EVENT$LOG, dotted names) can
be read, overlaid, edited, and deleted.

- New `source-files.ts`: one total filename derivation (safe lowercase
  names verbatim; otherwise slug + sha256-hash suffix, immune to
  case-insensitive-filesystem collisions) and one by-name file resolver.
- Reads resolve by name everywhere; the path-from-name fast path and
  `assertSafeSourceName` are gone.
- Writes resolve-then-write: rewrites land on the file that declares the
  name (human renames survive); new sources get a derived filename; a
  derived path occupied by a different source fails instead of clobbering.
- `readSourceFile` returns null for missing files instead of forcing every
  caller to launder IO errors; `deleteSource` distinguishes manifest-backed
  sources from not-found instead of silently succeeding.
- `sl_write_source` accepts verbatim warehouse identifiers (snake_case is
  now a recommendation for new sources) and rejects sourceName/source.name
  mismatches; `sl_edit_source` rejects name-changing edits.
- Ingest projection commits, gate-repair allowlists, and touched-source
  derivation use resolved paths / in-file names instead of interpolating
  `<connId>/<name>.yaml`.
- Collapsed the five parallel path derivations and duplicated path-token
  helpers onto the shared module; dropped dead service methods.

* fix(sl): resolve sources by declared name end-to-end and gate warehouse SQL with the parser-backed validator

- Key broken/renamed semantic-layer files by their recoverable in-file
  name (slSourceNameForFile) so mid-edit sources stay reachable under
  their real identity in reads, listings, and search
- Derive finalization touched sources from composed-source diffs and
  recover deleted files' declared names from the pre-change commit
  instead of parsing hash-derived filenames
- Resolve revert/rollback paths against history (listFilesAtCommit) so
  human-renamed files are restored where they lived at preHead
- Validate ingest sql_execution through the daemon's sqlglot
  validateReadOnly in the connection's dialect, sharing one
  driver-to-dialect map (sql-analysis/dialect.ts) across MCP and ingest
- Harden the local read-only SQL backstop: accept leading comments,
  reject smuggled second statements, and strip trailing
  semicolons/comments before row-limit wrapping
2026-06-10 14:06:13 +02:00
Andrey Avtomonov
853f39a7c3
fix(setup): require explicit no-input database scope (#286)
* test(setup): supply explicit --no-input scope to disabled-mode database tests

* fix(setup): require explicit database scope in --no-input instead of auto-scanning the warehouse

* docs(setup): document --no-input database scope requirement
2026-06-10 10:36:53 +00:00
Andrey Avtomonov
036a745fc1
fix: classify MCP SQL query errors as expected (#285) 2026-06-10 11:42:31 +02:00
github-actions[bot]
b076431b0a chore: refresh star history chart [skip ci] 2026-06-10 07:36:58 +00:00
Andrey Avtomonov
fd18caa26a
fix(cli): own a dedicated git repo at the project dir when nested in an enclosing repo (#282)
GitService.initialize() used checkIsRepo(), which is true whenever the project
dir sits anywhere inside a git working tree. So when a ktx project lived in a
subdirectory of an enclosing repo, ktx skipped `git init` and silently adopted
the enclosing repo as its store.

Every ktx relative path assumes the project dir IS the working-tree root. During
ingest, wiki/SL pages are written through a session worktree (whose root is the
worktree dir, so the page is recorded at repo-relative `wiki/global/<key>.md`)
and then squash-merged into the main worktree. With an adopted enclosing repo,
the main worktree's root is the enclosing git root, so the merge wrote the page
to `<gitRoot>/wiki/global/` — outside the project dir. reindex scans
`<projectDir>/wiki/global/`, found nothing, and wiki_search silently returned
empty (knowledge_pages = 0) even though ingest reported success.

Detect the project dir's own root with checkIsRepo(IS_REPO_ROOT) and initialize
a dedicated repo there unless the project dir is already a repo root. This keeps
adopting a user-created repo when the project dir IS that repo's root, fixes the
silent wiki/SL/memory divergence at its source for every writer, and stops ktx
from committing its scaffold into the user's enclosing repo.

Regression tests cover both layers: a project nested in an enclosing repo gets
its own .git (and the enclosing repo stays untouched), and a wiki page written
through a session worktree + squash-merge lands in the project dir and is
discovered by reindex.
2026-06-09 23:37:24 +02:00
github-actions[bot]
65de75ebd7 chore: refresh star history chart [skip ci] 2026-06-09 18:49:48 +00:00
Andrey Avtomonov
0425160857
fix(cli): clear error when ktx setup has no LLM backend under --no-input (#281)
* fix(cli): fail clearly when ktx setup has no LLM backend under --no-input

Non-interactive `ktx setup` silently defaulted the LLM backend to `anthropic`
and then failed with `Missing Anthropic API key: pass --anthropic-api-key-env
or --anthropic-api-key-file` — confusing for users who selected a different
provider (e.g. `--target claude-code`) and never asked for the Anthropic API
backend.

That silent default could never succeed: it was reached only when no backend,
Anthropic key, or Vertex flag was supplied, and in exactly that case the
Anthropic credential resolver always failed (no env fallback in disabled mode).
Unlike embeddings, the LLM has no credential-free default (anthropic needs a
key, vertex needs gcloud ADC, claude-code/codex need a logged-in local CLI), so
there is nothing safe to assume.

`chooseBackend` now fails clearly in disabled mode with no backend, naming the
(hidden) `--llm-backend` flag and its choices and noting each backend's
credential needs. `--llm-backend` stays hidden in `--help`, consistent with the
rest of the documented automation surface; the error message is the discovery
path.

- Add a unit test (no backend, disabled -> clear message) and a CLI/integration
  test (`--target claude-code --no-input` -> exit 1, clear message, not the
  Anthropic red herring).
- Document the no-default behavior and add a Common-errors row in
  docs-site ktx-setup.mdx.

* refactor(cli): single source of truth for setup LLM backends

The set of LLM backends a user can pick during `ktx setup` (claude-code,
codex, anthropic, vertex) was hand-enumerated in five places: the
`--llm-backend` arg parser, the `KtxSetupLlmBackend` union, the interactive
prompt's narrowing, the prompt options, and the missing-backend error. Only
some had TypeScript coverage, so adding a backend could silently drift (e.g.
a valid value rejected by the parser, or routed to anthropic by the prompt's
`? : 'anthropic'` fallback).

Collapse them onto one `KTX_SETUP_LLM_BACKENDS` list:
- `KtxSetupLlmBackend` is derived from it.
- `isKtxSetupLlmBackend` is the shared validator; the arg parser and the
  prompt both route through it instead of re-listing literals.
- The prompt options derive from the list, with a `Record<KtxSetupLlmBackend,
  string>` label map so a new backend fails to compile until it has a label.
- The missing-backend error builds its choice list from the same source.

Behavior-preserving: identical accepted values and parse error, identical
prompt options (asserted by an existing test), and the prompt's unreachable
fallback now cancels rather than silently assuming anthropic.
2026-06-09 17:11:39 +00:00
Andrey Avtomonov
9ff0e86bb8 chore: remove dead pnpm.onlyBuiltDependencies from package.json
pnpm 11 no longer reads the package.json "pnpm" field and warns on it.
The build allowlist already lives in pnpm-workspace.yaml via allowBuilds
(better-sqlite3, esbuild, sharp), so this block was redundant noise.
2026-06-09 17:24:43 +02:00
607 changed files with 37604 additions and 5781 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

@ -263,6 +263,35 @@ and route ingest, setup, memory, indexing, and docs through it. Do not add an
`auto_commit`-style switch unless the user explicitly asks for staged-only runs
and accepts the extra runtime path.
## Code Comments and Docstrings
Code must be self-explanatory. Clear names, types, and signatures do the
documenting; a comment or docstring exists only to state what the code cannot
show. Everything else belongs in the PR description or nowhere.
- **MUST**: Keep each comment to 1-3 lines stating only what the code cannot
show: a cross-file invariant ("error-severity issues never reach here — the
doctor exits on them first"), a required ordering ("ktx.yaml is written
before git init, so a crash cannot leave a bare `.git`"), or a library quirk
("zod reports unknown record keys as `invalid_key`").
- **MUST**: Hold docstrings (Python `"""..."""`, JSDoc/TSDoc) to the same bar.
A docstring states a function's purpose or contract in 1-3 lines; when a real
quirk or invariant motivates the code, note it once and briefly. Let
self-explanatory code carry the rest — a well-named, well-typed function
often needs no docstring at all.
- **MUST**: State each invariant once, at the public entry point. Do not repeat
the same guarantee across a module docstring, a helper, its wrapper, and the
call site.
- **MUST NOT**: Write multi-paragraph docstrings or prose comment blocks —
design rationale, alternatives considered, change narration ("is now written
before…"), caller enumerations ("shared by X, Y, and Z"), worked examples
that restate the code, or the same explanation repeated in a module docstring
and the function it describes. That is the author addressing the reviewer; it
belongs in the PR description and rots once merged.
- **MAY**: Open a regression test with a 1-3 line comment stating the scenario
it guards when the test name cannot carry it. Omit design history and
references to removed designs.
## TypeScript Standards
- Use Node 22+ and pnpm workspace commands.

View file

@ -1,6 +1,6 @@
# Contributing to KTX
# Contributing to ktx
Thanks for your interest in KTX. This page covers **how to contribute** and
Thanks for your interest in **ktx**. This page covers **how to contribute** and
the **contributor rewards program**. For development setup, repository
layout, and verification commands, see the
[Contributing guide in the docs](https://docs.kaelio.com/ktx/docs/community/contributing).
@ -23,7 +23,7 @@ layout, and verification commands, see the
## Contributor rewards program
We send merch to contributors whose pull requests get merged. The goal is
to thank the people building KTX with us, not to drive volume.
to thank the people building **ktx** with us, not to drive volume.
### How it works
@ -76,7 +76,7 @@ See the [Community & Support](https://docs.kaelio.com/ktx/docs/community/support
page for the full guide. The short version:
- **Questions, "how do I...", setup help, sharing patterns**: join the
[KTX Slack](https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ).
[**ktx** Slack](https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ).
- **Bugs**: use the [Bug report](.github/ISSUE_TEMPLATE/bug_report.yml)
template.
- **Feature requests**: use the
@ -87,7 +87,7 @@ page for the full guide. The short version:
## Code of conduct
KTX follows the
**ktx** follows the
[Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
Be respectful, assume good intent, and keep discussion focused on the
project. Report concerns to the maintainers in Slack or by email at

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

@ -2,20 +2,20 @@
## Reporting a vulnerability
If you believe you've found a security vulnerability in KTX, please report it
If you believe you've found a security vulnerability in **ktx**, please report it
**privately** through GitHub Security Advisories:
[Report a vulnerability](https://github.com/Kaelio/ktx/security/advisories/new)
If you cannot use GitHub Security Advisories, email `support@kaelio.com`
instead. Please do **not** open a public issue, post in the KTX Slack, or
instead. Please do **not** open a public issue, post in the **ktx** Slack, or
share details elsewhere until we have published a fix.
When reporting, please include:
- A description of the issue and its impact
- Steps to reproduce
- The KTX version affected
- The **ktx** version affected
## What to expect

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Before After
Before After

View file

@ -2,21 +2,10 @@ import { source } from "@/lib/source";
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import type { ReactNode } from "react";
import { baseOptions } from "@/app/layout.config";
import { GitHubStars } from "@/components/github-stars";
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout
tree={source.pageTree}
{...baseOptions}
sidebar={{
banner: (
<div className="flex">
<GitHubStars />
</div>
),
}}
>
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
);

View file

@ -870,117 +870,75 @@ body::after {
}
/*
GitHub star widget (navbar)
Split pill: GitHub mark + "Star" gold star + count.
GitHub star widget (sidebar footer pill)
Rendered as the `icon` of a fumadocs icon-link, so it sits in the footer
pill beside the Slack mark and the theme toggle. GitHub mark + star glyph
+ live count; the star rotates to coral on hover. The !important sizes win
over fumadocs' `[&_svg]:size-4.5` rule on the wrapping link.
*/
.ktx-stars {
display: inline-flex;
align-items: stretch;
height: 32px;
border-radius: 999px;
border: 1px solid var(--color-fd-border);
background: color-mix(in oklch, var(--color-fd-card) 72%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
align-items: center;
gap: 6px;
font-family: var(--font-display), var(--font-sans), sans-serif;
font-size: 13px;
line-height: 1;
color: var(--color-fd-foreground);
text-decoration: none;
overflow: hidden;
box-shadow: 0 1px 2px rgba(27, 27, 24, 0.04);
transition:
transform 0.3s var(--ktx-ease),
box-shadow 0.3s var(--ktx-ease),
border-color 0.3s ease;
animation: ktx-stars-in 0.5s var(--ktx-ease) both;
}
@keyframes ktx-stars-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
/* Push the stars to the opposite (right) end of the footer pill, leaving the
Slack mark on the left like justify-content: space-between. The auto margin
absorbs the pill's free space; we cancel the theme toggle's own ms-auto so
that single gap lands before the stars, not between stars and the toggle. */
#nd-sidebar a[aria-label="Star ktx on GitHub"] {
margin-inline-start: auto;
}
.ktx-stars:hover {
transform: translateY(-1px);
border-color: color-mix(in oklch, var(--color-fd-primary) 45%, var(--color-fd-border));
box-shadow:
0 6px 18px -8px rgba(14, 116, 144, 0.28),
0 1px 2px rgba(27, 27, 24, 0.05);
}
.ktx-stars:focus-visible {
outline: 2px solid var(--color-fd-ring);
outline-offset: 2px;
}
.dark .ktx-stars {
background: color-mix(in oklch, var(--color-fd-card) 60%, transparent);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.dark .ktx-stars:hover {
border-color: rgba(34, 211, 238, 0.4);
box-shadow:
0 6px 18px -8px rgba(34, 211, 238, 0.3),
0 1px 2px rgba(0, 0, 0, 0.3);
}
.ktx-stars-seg {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 11px;
}
.ktx-stars-seg--count {
border-left: 1px solid var(--color-fd-border);
background: color-mix(in oklch, var(--color-fd-primary) 6%, transparent);
transition: background 0.3s var(--ktx-ease);
}
.ktx-stars:hover .ktx-stars-seg--count {
background: color-mix(in oklch, var(--color-fd-primary) 12%, transparent);
#nd-sidebar [data-theme-toggle] {
margin-inline-start: 0;
}
.ktx-stars-gh {
width: 15px;
height: 15px;
opacity: 0.85;
width: 16px !important;
height: 16px !important;
flex-shrink: 0;
}
.ktx-stars-text {
font-weight: 500;
letter-spacing: -0.01em;
.ktx-stars-count-wrap {
display: inline-flex;
align-items: center;
gap: 4px;
}
.ktx-stars-star {
width: 14px;
height: 14px;
fill: #f5b301;
transition: transform 0.3s var(--ktx-ease), filter 0.3s var(--ktx-ease);
width: 12px !important;
height: 12px !important;
flex-shrink: 0;
fill: currentColor;
opacity: 0.7;
transition:
transform 0.3s var(--ktx-ease),
fill 0.3s var(--ktx-ease),
opacity 0.3s var(--ktx-ease);
}
.ktx-stars:hover .ktx-stars-star {
transform: scale(1.18) rotate(-8deg);
filter: drop-shadow(0 1px 4px rgba(245, 179, 1, 0.55));
/* The wrapping fumadocs link owns the hover; rotate + colour the star from it. */
#nd-sidebar a:hover .ktx-stars-star {
transform: rotate(-14deg) scale(1.12);
fill: var(--ktx-coral);
opacity: 1;
}
.ktx-stars-count {
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--color-fd-foreground);
letter-spacing: -0.01em;
}
/* Skeleton shown only on the rare cold (uncached) fetch */
.ktx-stars--skeleton {
animation: none;
}
.ktx-stars-skeleton-bar {
display: inline-block;
width: 26px;
height: 11px;
height: 10px;
border-radius: 4px;
background: linear-gradient(
90deg,
@ -997,16 +955,8 @@ body::after {
to { background-position: -200% 0; }
}
/* Compact on phones: drop the "Star" word, keep mark + count */
@media (max-width: 640px) {
.ktx-stars-text { display: none; }
.ktx-stars-seg { padding: 0 9px; }
}
@media (prefers-reduced-motion: reduce) {
.ktx-stars { animation: none; transition: none; }
.ktx-stars:hover { transform: none; }
.ktx-stars:hover .ktx-stars-star { transform: none; }
#nd-sidebar a:hover .ktx-stars-star { transform: none; }
.ktx-stars-skeleton-bar { animation: none; }
}

View file

@ -1,12 +1,21 @@
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
import { Logo } from "@/components/logo";
import { SlackIcon } from "@/components/slack-icon";
import { GitHubStars, GITHUB_REPO_URL } from "@/components/github-stars";
import { ThemeToggle } from "@/components/theme-toggle";
export const baseOptions: BaseLayoutProps = {
nav: {
title: Logo,
transparentMode: "top",
},
// Custom two-icon switcher (light / dark) where each icon selects its own
// theme. The default "light-dark" switcher is a single blind toggle — both
// icons just flip the theme, so clicking the sun while already in light mode
// jumps to dark, which reads as broken.
slots: {
themeSwitch: ThemeToggle,
},
links: [
{
type: "icon",
@ -16,5 +25,13 @@ export const baseOptions: BaseLayoutProps = {
url: "https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ",
external: true,
},
{
type: "icon",
label: "Star ktx on GitHub",
icon: <GitHubStars />,
text: "GitHub",
url: GITHUB_REPO_URL,
external: true,
},
],
};

View file

@ -2,7 +2,7 @@ import { Suspense } from "react";
import { GitHubIcon } from "@/components/github-icon";
const REPO = "kaelio/ktx";
const REPO_URL = `https://github.com/${REPO}`;
export const GITHUB_REPO_URL = `https://github.com/${REPO}`;
const API_URL = `https://api.github.com/repos/${REPO}`;
async function fetchStarCount(): Promise<number | null> {
@ -41,53 +41,42 @@ function StarGlyph() {
);
}
async function StarsContent() {
async function StarsInner() {
const count = await fetchStarCount();
const label =
count === null
? "Star ktx on GitHub"
: `Star ktx on GitHub — ${count.toLocaleString("en-US")} stars`;
return (
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
className="ktx-stars"
>
<span className="ktx-stars-seg ktx-stars-seg--label">
<GitHubIcon className="ktx-stars-gh" />
<span className="ktx-stars-text">Star</span>
</span>
{count !== null && (
<span className="ktx-stars-seg ktx-stars-seg--count">
<span className="ktx-stars">
<GitHubIcon className="ktx-stars-gh" />
{count !== null ? (
<span className="ktx-stars-count-wrap">
<StarGlyph />
<span className="ktx-stars-count">{formatStars(count)}</span>
</span>
) : (
<span className="ktx-stars-count">Star</span>
)}
</a>
</span>
);
}
function StarsSkeleton() {
return (
<span className="ktx-stars ktx-stars--skeleton" aria-hidden="true">
<span className="ktx-stars-seg ktx-stars-seg--label">
<GitHubIcon className="ktx-stars-gh" />
<span className="ktx-stars-text">Star</span>
</span>
<span className="ktx-stars-seg ktx-stars-seg--count">
<span className="ktx-stars-skeleton-bar" />
</span>
<span className="ktx-stars" aria-hidden="true">
<GitHubIcon className="ktx-stars-gh" />
<span className="ktx-stars-skeleton-bar" />
</span>
);
}
/**
* Footer star widget GitHub mark + live count. Rendered as the `icon` of a
* fumadocs `type: "icon"` link, so it lands in the sidebar footer pill beside
* the Slack icon and the theme toggle. fumadocs supplies the surrounding <a>
* (href + aria-label), so this renders inner content only no anchor.
*/
export function GitHubStars() {
return (
<Suspense fallback={<StarsSkeleton />}>
<StarsContent />
<StarsInner />
</Suspense>
);
}

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

@ -0,0 +1,157 @@
"use client";
import {
useEffect,
useRef,
useState,
type ComponentProps,
type KeyboardEvent,
type SVGProps,
} from "react";
import { useTheme } from "fumadocs-ui/provider/base";
/**
* Three-icon theme switcher (light / system / dark) rendered as a radio group
* each icon selects its own theme, unlike fumadocs' default "light-dark"
* switcher, which is a single blind toggle that flips on any click. Reads
* `theme`, not `resolvedTheme`, so the "system" option can show as selected
* (resolvedTheme collapses system to light/dark). Dropped into the sidebar
* footer pill via `slots.themeSwitch`, so fumadocs passes the container
* className (left divider, `ms-auto`, rounded inner buttons); we merge it onto
* our own base.
*
* Icons are inlined (the project doesn't depend on `lucide-react` directly);
* `useTheme` is re-exported by fumadocs so we avoid a bare `next-themes` import.
*/
function SunIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
);
}
function MonitorIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8" />
<path d="M12 16v4" />
</svg>
);
}
function MoonIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
);
}
const OPTIONS = [
["light", SunIcon],
["system", MonitorIcon],
["dark", MoonIcon],
] as const;
function cx(...classes: (string | false | undefined)[]): string {
return classes.filter(Boolean).join(" ");
}
export function ThemeToggle({ className, ...props }: ComponentProps<"div">) {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const active = mounted ? theme : null;
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
// Pre-mount nothing is selected, so keep the first control tabbable.
const selectedIndex = OPTIONS.findIndex(([key]) => key === active);
const rovingIndex = selectedIndex === -1 ? 0 : selectedIndex;
// Radio-group keyboard model: arrows move focus and pick that theme.
function onKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
const delta =
event.key === "ArrowRight" || event.key === "ArrowDown"
? 1
: event.key === "ArrowLeft" || event.key === "ArrowUp"
? -1
: 0;
if (delta === 0) return;
event.preventDefault();
const next = (index + delta + OPTIONS.length) % OPTIONS.length;
setTheme(OPTIONS[next][0]);
buttonsRef.current[next]?.focus();
}
return (
<div
className={cx("inline-flex items-center overflow-hidden border", className)}
data-theme-toggle=""
role="radiogroup"
aria-label="Theme"
{...props}
>
{OPTIONS.map(([key, Icon], index) => (
<button
key={key}
ref={(el) => {
buttonsRef.current[index] = el;
}}
type="button"
role="radio"
aria-label={key}
aria-checked={active === key}
tabIndex={index === rovingIndex ? 0 : -1}
onClick={() => setTheme(key)}
onKeyDown={(event) => onKeyDown(event, index)}
className={cx(
"size-6.5 p-1.5 transition-colors",
active === key
? "bg-fd-accent text-fd-accent-foreground"
: "text-fd-muted-foreground hover:text-fd-accent-foreground",
)}
>
<Icon className="size-full" />
</button>
))}
</div>
);
}

View file

@ -48,6 +48,11 @@ directory. Use it from any directory to generate editor or agent schema files.
| `stop` | Stop the **ktx** daemon |
| `status` | Show managed Python runtime status and readiness checks |
`install` is self-contained: **ktx** downloads its own pinned, checksum-verified
`uv` build under the runtime root and uses it to provision Python and the
runtime wheel. Nothing needs to be installed on `PATH` first; the host only
needs network access to `github.com` during the first install.
## `admin runtime` Options
| Flag | Description | Default |

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
@ -34,8 +34,10 @@ connection is selected.
| `--query-history` | Include database query-history usage patterns | Stored connection default |
| `--no-query-history` | Skip database query-history usage patterns for this run | Stored connection default |
| `--query-history-window-days <days>` | BigQuery/Snowflake query-history lookback window for this run | Stored connection default |
| `--stages <list>` | Comma-separated enrichment stages to (re)run: `descriptions`, `embeddings`, `relationships` | All three |
| `--text <content>` | Capture inline text into **ktx** memory; repeatable | `[]` |
| `--file <path>` | Capture a text file into **ktx** memory; use `-` for stdin; repeatable | `[]` |
| `--verbatim` | Store each `--text`/`--file` document body unchanged as a `GLOBAL` wiki page; the LLM derives metadata only | `false` |
| `--connection-id <connectionId>` | **ktx** connection id to tag captured text/file notes | - |
| `--user-id <id>` | Memory user id for text/file capture attribution | `local-cli` |
| `--fail-fast` | Stop after the first failed text/file item | `false` |
@ -63,6 +65,65 @@ use `--no-input` to fail fast with install guidance.
`--text` and `--file` cannot be combined with a positional `connectionId` or
`--all`; pass `--connection-id <id>` instead to tag captured notes.
### Verbatim ingest
By default, captured text is routed through the memory agent, which decides what
to persist and may rewrite, condense, split, or re-title it. For *authoritative*
documents — metric definitions, formula specs, runbooks, compliance text — that
paraphrasing is a defect. Add `--verbatim` to store each `--text`/`--file`
document body **unchanged** as a `GLOBAL` wiki page:
- The stored body is the input document, written by code; the LLM never edits it.
It is used only to derive page metadata (`summary`, `tags`, `sl_refs`), and even
that is skipped for fields the document's own frontmatter already sets.
- The page key is deterministic: a `--file` derives it from the filename, inline
`--text` from the document's leading Markdown heading (inline text without a
heading is rejected — pass it as `--file` instead).
- Ingest is idempotent. Re-running the same document is a safe no-op; a different
body at the same key fails loudly rather than overwriting.
- `--verbatim` works with `llm.provider.backend: none` — the only ingest path that
does. With no backend the `summary` is derived from the heading or first
sentence and `tags`/`sl_refs` are left empty; the full body is still stored.
- Existing frontmatter passes through untouched (including fields **ktx** does not
model, such as `effective_date` or `version`); generated metadata only fills
absent fields. `--connection-id <id>` scopes the page to that connection by
setting its `connections` frontmatter.
### Selecting enrichment stages
Database enrichment runs three stages: `descriptions` (one LLM call per table),
`embeddings` (vectors over the schema and descriptions), and `relationships`
(join detection, optionally LLM-proposed). Each stage is cached on a **per-stage
hash of only its own inputs**, so changing one stage's inputs invalidates only
that stage. Switching the description LLM re-runs only `descriptions`; upgrading
the embeddings model re-runs only `embeddings`; turning on
`scan.relationships.llmProposals` re-runs only `relationships`. The expensive
per-table descriptions are never thrown away because an unrelated setting moved.
`--stages <list>` re-runs a chosen subset on an already-ingested connection. A
named stage is **force-recomputed** (it bypasses the completed-stage cache),
while unselected stages are left exactly as they are on disk:
- `ktx ingest warehouse --stages embeddings` — re-embed on a new model, keeping
descriptions and joins.
- `ktx ingest --all --stages relationships --no-query-history` — backfill joins
across every database after enabling `llmProposals`, without re-paying for
descriptions.
- `ktx ingest warehouse --stages descriptions` — re-run thin descriptions (for
example after raising `KTX_ENRICH_LLM_TIMEOUT_MS`). When nothing the
descriptions depend on changed, the per-table resume record means only the
tables that previously failed are re-sent to the LLM.
Stage names are validated: an unknown or empty name (`--stages foo`, `--stages
descriptions,foo`, `--stages ""`) is a hard parse error. Naming all three
(`--stages descriptions,embeddings,relationships`) forces a full enrichment
recompute, which is **not** the same as omitting the flag (omitting resumes
whatever is already done). After a selective run, **ktx** warns
(`enrichment_stage_stale`) when an unselected stage's inputs no longer match what
it was last built from — for example, re-running `descriptions` flags
`embeddings` as stale until you re-run `--stages embeddings`. The warning is
informational; **ktx** never silently cascades the extra work.
## Examples
```bash
@ -77,6 +138,11 @@ ktx ingest warehouse --query-history
# Set the lookback window for BigQuery or Snowflake query history
ktx ingest warehouse --query-history-window-days 30
# Re-embed one connection on a new embeddings model (descriptions/joins untouched)
ktx ingest warehouse --stages embeddings
# Backfill LLM-proposed joins across every database without re-describing
ktx ingest --all --stages relationships --no-query-history
# Build a context-source connection
ktx ingest notion
@ -91,6 +157,12 @@ ktx ingest --file docs/revenue-notes.md --connection-id warehouse
# Capture one stdin item
printf "Refunds are excluded from net revenue." | ktx ingest --file -
# Store an authoritative document verbatim (body preserved exactly)
ktx ingest --file docs/rfm-bucket-definitions.md --verbatim
# Store it verbatim and scope it to one connection
ktx ingest --file docs/haversine-formula.md --verbatim --connection-id warehouse
```
## Output
@ -108,6 +180,21 @@ notion skipped skipped done done
Use `--json` when a script or agent needs the selected plan and per-target
results.
## Final validation pruning
At the end of a context-source ingest, **ktx** validates the composed semantic
layer and wiki before saving it. If the final validation finds dangling
references, **ktx** removes the reference instead of failing accepted work. This
can remove joins that point at missing semantic sources, wiki `refs`, wiki
`sl_refs`, and inline wiki body references. If a generated semantic source is
invalid, **ktx** drops that source from the final save.
The stored ingest report records these changes as `finalGatePrunedReferences`
and `finalGateDroppedSources`. The trace emits `final_gate_reference_pruned`,
`final_gate_source_dropped`, `final_gate_prune_committed`, and
`final_gate_prune_finished` events when pruning runs. If validation still fails
after pruning, the ingest fails and the report keeps the final validation error.
## Inspect context-source ingest traces
Context-source ingest writes persistent JSONL traces for postmortem debugging.
@ -191,3 +278,7 @@ according to `ingest.rateLimit`.
| Python runtime is missing | The selected ingest target needs runtime-backed SQL analysis or source parsing | Accept the interactive prompt, rerun with `--yes`, or run the suggested `ktx admin runtime install` command |
| Context-source options were ignored | Query-history flags were supplied for a context-source connection | Omit database-only flags when ingesting context-source connections |
| Text ingest stops early | `--fail-fast` was used and one item failed | Fix the failed item or rerun without `--fail-fast` to collect all failures |
| `--verbatim requires --text or --file` | `--verbatim` was passed without a document to store | Add `--text` or `--file`, or drop `--verbatim` |
| Inline verbatim text needs a leading heading | `--text --verbatim` content has no `# Heading` to derive a stable key | Add a leading Markdown heading, or pass the content as `--file <path>` |
| A different page already exists at key | A verbatim re-run targeted an existing key with a different body | Use a distinct document name/key, or remove the existing page first |
| Connection scope conflict | Frontmatter `connections` disagrees with `--connection-id` | Remove one so the intended scope is unambiguous |

View file

@ -68,3 +68,4 @@ hosts and origins for browser clients.
| No **ktx** project found | Current directory has no `ktx.yaml` and `KTX_PROJECT_DIR` is unset | Run from a **ktx** project or pass `--project-dir <path>` |
| Non-loopback host rejected | The server needs token auth before binding beyond localhost | Pass `--token <token>` or set `KTX_MCP_TOKEN` |
| Client cannot connect | Host, port, token, allowed host, or allowed origin does not match the client | Check `ktx mcp status`, then restart with explicit `--host`, `--port`, `--allowed-host`, and `--allowed-origin` values |
| A Python-backed tool reports a runtime install failure | A tool that needs the managed Python runtime (metric compute, query-history SQL analysis) ran on a host that cannot reach `github.com` to download the pinned `uv` and Python | The server still starts and serves catalog and search tools. Restore network access and retry, or pre-build the runtime where network is available: `ktx admin runtime install --yes` |

View file

@ -29,9 +29,18 @@ below.
| `--agents` | Install agent configuration and rules only | `false` |
| `--target <target>` | Agent target: `claude-code`, `claude-desktop`, `codex`, `cursor`, `opencode`, or `universal` | - |
| `--global` | Install agent integration into the global target scope for `claude-code` or `codex` | `false` |
| `--install-dir <path>` | Install project-scoped agent configuration | ktx project dir |
| `--yes` | Accept project creation and runtime install defaults where setup asks for confirmation | `false` |
| `--no-input` | Disable interactive terminal input | - |
> **`--install-dir <path>`**
>
> Installs project-scoped agent configuration into the specified directory.
> The path is resolved against the current directory and created if it doesn't
> exist. Use it to install `.claude/`, `.mcp.json`, and rules where you open
> your agent (for example, `--install-dir .`). This option is mutually exclusive
> with `--global` and `--local`.
Use the global `--project-dir <path>` option when setup should target a
specific directory.
@ -67,6 +76,13 @@ of Anthropic API key or Vertex flags. After you choose a backend, `ktx setup`
writes that backend's per-role model preset to `ktx.yaml`. To change a model,
edit the matching `llm.models.<role>` value in `ktx.yaml`.
With `--no-input`, `ktx setup` does not assume a default LLM provider, because
every backend needs credentials only you can supply. Pass `--llm-backend`
explicitly. Note that `--target` selects the agent integration, not the LLM
provider: `ktx setup --target claude-code --no-input` still needs
`--llm-backend claude-code` to use your Claude subscription for **ktx** LLM
calls.
### Embeddings
| Flag | Description |
@ -104,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 |
@ -118,6 +134,21 @@ incomplete.
MySQL, and SQL Server; `schema_names` for Snowflake; `dataset_ids` for
BigQuery; and `databases` for ClickHouse.
A BigQuery `--database-schema` value may be qualified as `project.dataset` to
scan a dataset hosted in another project (such as
`bigquery-public-data.austin_311`); a bare value stays in the credentials'
project. Setup does not discover foreign-project datasets, so supply qualified
entries explicitly. See
[Primary sources → BigQuery](/docs/integrations/primary-sources#cross-project-datasets).
With `--no-input`, scope for a scope-bearing driver (PostgreSQL, MySQL,
ClickHouse, SQL Server, BigQuery, Snowflake) must come from `--database-schema`
or from existing connection config in `ktx.yaml` (for example
`connections.<id>.dataset_ids`). When neither is set, the database step fails
fast and prints the missing scope flag and config key — non-interactive setup
never auto-discovers and scans every schema. SQLite has no scope and is
unaffected.
### Query History
| Flag | Description |
@ -162,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 |
@ -247,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
```
@ -276,6 +314,7 @@ Use `ktx status` for repeatable readiness checks after setup exits.
|-------|-------|----------|
| Setup resumes an unexpected project | `KTX_PROJECT_DIR` or nearest `ktx.yaml` points to another directory | Pass `--project-dir <path>` explicitly |
| Setup cannot run in CI | Required values are missing and `--no-input` disables prompts | Provide the relevant automation flags or create a fixture `ktx.yaml` |
| `Missing LLM backend: pass --llm-backend …` | `--no-input` setup ran without an LLM backend; `--target` does not select one | Pass `--llm-backend claude-code`, `codex`, `anthropic`, or `vertex` (with that backend's credential flags) |
| Provider health check fails | Provider key, model id, Vertex project, or Vertex location is invalid | Fix the `env:` or `file:` reference and rerun setup |
| Python runtime is missing | The selected setup needs runtime-backed agent, query-history, Looker, or local embedding features | Accept the interactive prompt, rerun with `--yes`, or run the suggested `ktx admin runtime install` command |
| `--enable-query-history` is rejected | The selected database driver does not support query history | Use Postgres, BigQuery, or Snowflake, or rerun without query-history flags |

View file

@ -94,6 +94,6 @@ stats, and are always shown (they do not require external communication).
|-------|-------|----------|
| No **ktx** project found | Current directory has no `ktx.yaml` and `KTX_PROJECT_DIR` is unset | `ktx status` runs setup checks; run from a **ktx** project or set `KTX_PROJECT_DIR` for project checks |
| Project config check fails | The project directory is missing or has an invalid `ktx.yaml` | Run `ktx setup` to resume setup |
| Schema validation fails | `ktx.yaml` does not match the current config schema | Run `ktx status --validate --json` for structured issue details, then edit `ktx.yaml` or rerun `ktx setup` |
| Schema validation fails | A field **ktx** recognizes has an invalid value. Unrecognized keys are reported as non-blocking warnings (exit `0`), not failures | Run `ktx status --validate --json` for structured issue details, then edit `ktx.yaml` or rerun `ktx setup` |
| Semantic search check warns | Embeddings are not configured or the provider probe failed | Run `ktx setup` or inspect the check's `fix` field in JSON output |
| Query history check warns | A database has query history enabled but the warehouse prerequisites are missing | Fix the warehouse extension, grants, or history access, then rerun `ktx status` |

View file

@ -28,10 +28,17 @@ Edit the Markdown files under `wiki/` directly, or ingest source content with
| Flag | Description | Default |
|------|-------------|---------|
| `--user-id <id>` | Local user id | `local` |
| `-c, --connection <id>` | Scope results to one connection: unscoped pages plus pages tagged with that connection | - |
| `--limit <number>` | Maximum search results (search mode only) | - |
| `--output <mode>` | Output mode: `pretty` (default in TTY), `plain` (TSV), or `json` | `pretty` |
| `--json` | Shortcut for `--output=json` (overrides `--output`) | `false` |
`-c, --connection <id>` takes a connection id from the `connections` map in
`ktx.yaml` (an unknown id is rejected). It narrows both list and search to
pages that are not tied to any connection plus pages tagged with that
connection, so an agent working against one database sees only the wiki
knowledge relevant to it.
`ktx wiki <query>` uses hybrid search when `storage.search` is `sqlite-fts5`.
**ktx** combines lexical SQLite FTS5 matches, token matches, and semantic matches
from wiki page embeddings stored in `.ktx/db.sqlite`. If embeddings are not
@ -50,6 +57,12 @@ ktx wiki --json
# Search wiki pages
ktx wiki "monthly recurring revenue"
# List pages scoped to one connection (unscoped + connection-tagged)
ktx wiki --connection warehouse
# Search within one connection's scope
ktx wiki "monthly recurring revenue" -c warehouse
# Search wiki pages as JSON
ktx wiki "monthly recurring revenue" --json --limit 10

View file

@ -0,0 +1,161 @@
---
title: Cross-database federation
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
live in different databases. **ktx** achieves this by embedding DuckDB and
using its `ATTACH` mechanism to connect each member database read-only. The
join executes inside DuckDB at query time — live data, no ETL, no copy.
You run federated queries as raw SQL against the `_ktx_federated` connection
(see [Querying the federated connection
directly](#querying-the-federated-connection-directly)). Semantic-layer queries
(`ktx sl query` / the `sl_query` tool) stay per-connection; pointing one at
`_ktx_federated` returns an error telling you to use raw SQL instead.
Federation activates automatically when a `ktx.yaml` file declares two or more
attach-compatible connections. There is nothing to configure and no federation
block to add. With zero or one compatible connection the behavior is unchanged.
## Which connections participate
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 |
| `sqlserver` | No — standalone connection |
Non-participating connections continue to work exactly as they did. They are
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`, `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.
A minimal `ktx.yaml` that triggers federation:
```yaml
connections:
pg_books:
driver: postgres
url: "postgres://user:pass@localhost:5432/books" # pragma: allowlist secret
sqlite_reviews:
driver: sqlite
path: ./data/reviews.db
```
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 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
quotes any identifier: `"books-db".public.books`. Writing it unquoted
(`books-db.public.books`) is a SQL syntax error, not a federation feature.
For the example above:
- `pg_books.public.books` — the `books` table in the `public` schema of the
postgres connection
- `sqlite_reviews.reviews` — the `reviews` table in the sqlite connection
These fully qualified names are what you write when you query the federated
connection with raw SQL (see [Querying the federated connection
directly](#querying-the-federated-connection-directly)). A source file's own
`table:` field is not prefixed this way — see [Source files keep member-native
table refs](#source-files-keep-member-native-table-refs) below.
## Source names in the federated view
When you list or search semantic-layer sources under the federated connection,
each source's `name` is prefixed with its member connection id — for example
`pg_books.books` and `sqlite_reviews.reviews`. The prefix keeps names unique
when two members own a table with the same name: a `users` table in each of
`pg_app` and `sqlite_app` surfaces as `pg_app.users` and `sqlite_app.users`
rather than colliding on a bare `users`.
## Source files keep member-native table refs
A source file's physical `table:` field is not prefixed with the connection id.
It stays the member-native reference the connector uses on its own —
`public.books` for the postgres member, `reviews` for the sqlite member —
because the same file backs a per-connection semantic-layer query against that
member, which runs on the member's own driver where a `pg_books.` catalog prefix
would point at a database that does not exist. The connection-id prefix is a
DuckDB catalog name that appears only in raw federated SQL; the member prefix on
the source `name` (above) is independent of it.
## Cross-database joins
Write a cross-database join as raw SQL against `_ktx_federated` — see
[Querying the federated connection
directly](#querying-the-federated-connection-directly) below for a runnable
example. DuckDB attaches both members and resolves the join live at query time.
Declaring the join in a source file's `joins:` block is not supported yet. The
semantic layer plans each connection on its own, so a `joins:` entry whose `to:`
points at a table in another member is not resolved across the federation
boundary. Until that lands, express cross-database joins as raw SQL.
## Querying the federated connection directly
The federated connection is addressable by its id,
`_ktx_federated`, anywhere **ktx** runs read-only SQL. The same id works for the
`ktx sql` command and for a data agent calling the `sql_execution` MCP tool, so
both surfaces can run a cross-database query without a source file:
```bash
ktx sql -c _ktx_federated \
"SELECT b.title, avg(r.rating) AS avg_rating
FROM pg_books.public.books b
JOIN sqlite_reviews.reviews r ON b.id = r.book_id
GROUP BY b.title"
```
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 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
are `SELECT`/`WITH` only. No writes, no DDL, and no mutations reach any member
database through the federation engine.
## Current limitations
- **Raw SQL joins only.** Cross-database joins are written as raw SQL; declaring
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, 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

@ -1,5 +1,5 @@
{
"title": "Concepts",
"defaultOpen": true,
"pages": ["the-context-layer", "semantic-layer-internals", "wiki-retrieval"]
"pages": ["the-context-layer", "semantic-layer-internals", "cross-database-federation", "wiki-retrieval"]
}

View file

@ -46,7 +46,7 @@ read, how to think, and where to put the results.
</p>
<ul className="mt-3 space-y-2 text-sm leading-6 text-fd-foreground">
<li><code className="text-[13px] font-semibold">llm</code> - provider, models, prompt cache</li>
<li><code className="text-[13px] font-semibold">ingest</code> - adapters, embeddings, work units</li>
<li><code className="text-[13px] font-semibold">ingest</code> - connectors, embeddings, work units</li>
<li><code className="text-[13px] font-semibold">scan</code> - enrichment, relationships</li>
<li><code className="text-[13px] font-semibold">agent</code> - research-agent feature flags</li>
</ul>
@ -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,13 +120,16 @@ 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
Warehouse connections are open objects: the listed fields are validated, and
any other field is preserved and passed through to the connector. Use
`enabled_tables` to scope ingest to a specific list of
`schema.table` names - useful for smoke tests.
`enabled_tables` to scope ingest to a specific list of objects - useful for
smoke tests. Each entry accepts a `catalog.db.name`, `db.name`, or bare `name`
qualifier. ktx restricts the scan to the listed objects and fails with a clear
error (naming the available objects) if none match.
```yaml
connections:
@ -137,6 +141,18 @@ connections:
- public.customers
```
For SQLite, which exposes a single `main` schema, the qualified `main.<name>`
and the bare `<name>` forms select the same object:
```yaml
connections:
local-db:
driver: sqlite
path: ./warehouse.db
enabled_tables:
- customers # equivalent to main.customers
```
Connector-specific scope fields let setup and scan use the same warehouse
boundary:
@ -158,6 +174,12 @@ connections:
dataset_ids: [analytics, mart]
```
A BigQuery `dataset_ids` / `dataset_id` entry may be written `project.dataset`
to introspect a dataset hosted in another project (for example
`bigquery-public-data.austin_311`); jobs still bill to the `project_id` in
`credentials_json`. A bare `dataset` keeps using your own project. See
[Primary sources → BigQuery](/docs/integrations/primary-sources#cross-project-datasets).
For Postgres, MySQL, SQL Server, and Snowflake connections, set
`maxConnections` when scan or ingest work needs to stay below the target's
connection cap. Postgres, MySQL, and SQL Server default to `10`; Snowflake
@ -195,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
@ -325,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
@ -344,15 +426,14 @@ setup:
## `storage`
`storage` controls where **ktx** keeps its own state and search index, and how
state changes are committed. Defaults work for a single-user local project.
`storage` controls where **ktx** keeps its own state and search index. Defaults
work for a single-user local project.
```yaml
storage:
state: sqlite # sqlite | postgres
search: sqlite-fts5 # sqlite-fts5 | postgres-hybrid
git:
auto_commit: true
author: "ktx <ktx@example.com>"
```
@ -360,8 +441,7 @@ storage:
|-------|------|---------|---------|
| `state` | `sqlite` \| `postgres` | `sqlite` | Backend for ktx state. `sqlite` uses `.ktx/db.sqlite`; `postgres` expects a configured Postgres connection. |
| `search` | `sqlite-fts5` \| `postgres-hybrid` | `sqlite-fts5` | Backend for search indexes. `postgres-hybrid` combines lexical and vector search in Postgres. |
| `git.auto_commit` | `boolean` | `true` | When `true`, ktx auto-commits changes to the git-backed state store. |
| `git.author` | `string` | `ktx <ktx@example.com>` | Git author identity for auto-commits. Standard `Name <email>` form. |
| `git.author` | `string` | `ktx <ktx@example.com>` | Git author identity for commits. Standard `Name <email>` form. |
## `llm`
@ -442,7 +522,7 @@ provider-specific model identifiers.
## `ingest`
`ingest` controls how **ktx** builds context from your stack. It lists the
adapters to run, the embedding provider used when adapters embed documents,
connectors to run, the embedding provider used when connectors embed documents,
and the concurrency and failure policy for work units.
```yaml
@ -473,12 +553,12 @@ ingest:
jitter: true
```
### Adapters
### Connectors
`adapters` is a list of adapter IDs that should run. Each ID matches a
`adapters` is a list of connector IDs that should run. Each ID matches a
connector that **ktx** ships locally:
| Adapter ID | What it ingests |
| Connector ID | What it ingests |
|------------|-----------------|
| `live-database` | Live warehouse introspection (schemas, tables, columns, samples). |
| `historic-sql` | Query history from Postgres `pg_stat_statements`, BigQuery `INFORMATION_SCHEMA.JOBS`, or Snowflake query history. |
@ -488,7 +568,7 @@ connector that **ktx** ships locally:
| `looker` | Looker dashboards and looks via the API. |
| `metabase` | Metabase cards, dashboards, and database mappings. |
| `notion` | Notion pages and databases for wiki context. |
| `fake` | Test/demo adapter. Useful in fixtures. |
| `fake` | Test/demo connector. Useful in fixtures. |
### Embeddings
@ -556,6 +636,7 @@ scan:
profileConcurrency: 4
validationConcurrency: 4
validationBudget: all
detectionBudgetMs: 600000
```
### Enrichment
@ -584,6 +665,7 @@ the manifest.
| `relationships.profileConcurrency` | `int > 0` | `4` | Parallel relationship-profile queries against the database. For pooled connectors, effective database concurrency is also bounded by the connection's `maxConnections`. |
| `relationships.validationConcurrency` | `int > 0` | `4` | Parallel relationship validation queries against the database. |
| `relationships.validationBudget` | `all` \| `int ≥ 0` | runtime default | Cap on validation queries per scan. `all` means unlimited. |
| `relationships.detectionBudgetMs` | `int > 0` | `600000` | Wall-clock budget (ms) for the whole relationship-detection stage, checked at table-profile, candidate-validation, and composite-probe boundaries. On exhaustion the stage stops scheduling new work and writes the joins found so far, marked partial; descriptions and embeddings are already durable. Sits above the per-query deadline. Raise it to trigger a fresher, fuller run. |
## `agent`
@ -608,19 +690,6 @@ agent:
| `run_research.max_iterations` | `int ≥ 0` | `20` | Maximum tool-call iterations per research run. |
| `run_research.default_toolset` | `string[]` | `[sl_query, wiki_search, sl_read_source]` | Tool identifiers exposed to the research agent. |
## `memory`
`memory` controls the agent memory subsystem.
```yaml
memory:
auto_commit: true
```
| Field | Type | Default | Purpose |
|-------|------|---------|---------|
| `auto_commit` | `boolean` | `true` | When `true`, ktx auto-commits memory updates to the git-backed store. |
## A full example
Combining the blocks above:
@ -645,7 +714,6 @@ storage:
state: sqlite
search: sqlite-fts5
git:
auto_commit: true
author: "ktx <ktx@example.com>"
llm:
provider:
@ -678,17 +746,25 @@ scan:
agent:
run_research:
enabled: true
memory:
auto_commit: true
```
## Validating your config
**ktx** validates `ktx.yaml` strictly: unknown keys at the top level or inside
strict blocks cause setup and CLI commands to fail with a precise path
(`scan.relationships.acceptThreshhold: Unrecognized key`). Warehouse
connections accept extra driver-specific fields, so passthrough values like
`historicSql` and `context.queryHistory` are allowed.
**ktx** validates `ktx.yaml` when it loads, and treats two kinds of problems
differently:
- **An invalid value on a field ktx recognizes** (for example
`llm.provider.backend: nope`) is a hard error. Setup and CLI commands stop and
report the exact path so you can fix it.
- **An unrecognized key** — one left over from a different **ktx** version, or a
typo such as `scan.relationships.acceptThreshhold` — is tolerated, not fatal.
**ktx** ignores the key and keeps running, so a misspelled field quietly falls
back to its default instead of taking effect. `ktx status` lists each ignored
key as a warning (and exits `0`) so you can remove or correct it when
convenient.
Warehouse connections accept extra driver-specific fields, so passthrough values
like `historicSql` and `context.queryHistory` are allowed.
To re-validate without running anything else:

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
@ -307,12 +308,12 @@ connection is unreachable or misconfigured the build is blocked up front and
**ktx** names the failing connection by id and connector type:
```text
KTX cannot build context: a required connection failed its live test.
ktx cannot build context: a required connection failed its live test.
Failed connections:
warehouse (postgres)
Each connection must be reachable before KTX builds context.
Each connection must be reachable before ktx builds context.
Run `ktx connection test <id>` to see the error, fix the connection, then retry.
```
@ -338,6 +339,16 @@ separate `ktx` binary on `PATH`. If the CLI path changes, rerun
## What setup writes
**ktx** writes plain files so people and agents can review changes in git.
**ktx** initializes a git repository at the project directory and writes context
changes there. If the project directory is nested inside another repository,
**ktx** still keeps its own repo and does not commit to the parent repo.
Because **ktx** owns that repository, it will not adopt one it did not create. If
you point setup at a directory that is already a git repository's root - such as
an existing application checkout - **ktx** stops and asks you to pick a dedicated
directory instead. In the setup wizard choose the **New subfolder** option (for
example `ktx-project`), or pass a fresh `--project-dir` when running setup
non-interactively.
| Path | Purpose |
|------|---------|

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

@ -61,11 +61,14 @@ committing the file.
## A typical review session
The loop above describes the shape. In practice, one review session looks like
this:
The loop above describes the shape. Run these commands from the **ktx** project
directory. **ktx** keeps that directory as its own git repository, even when the
directory lives inside another repository, so reviewing context changes never
requires committing to a parent application repo.
```bash
# 1. Run ingest on a branch
cd /path/to/ktx-project
git checkout -b ingest/2026-05-21
ktx ingest --all

View file

@ -44,12 +44,17 @@ Use this order for most context changes:
Semantic sources are YAML files for queryable tables or custom SQL. They define
agent-facing measures, dimensions, segments, joins, and grain.
Semantic source files live at:
Semantic source files live under:
```text
semantic-layer/<connection-id>/<source-name>.yaml
semantic-layer/<connection-id>/
```
The file's `name:` field is the source's identity — it carries the warehouse
identifier verbatim, including case. The filename is a derived label: simple
lowercase names get `<source-name>.yaml`, anything else gets a slugged
filename. Renaming a file does not rename the source.
### Minimal source
```yaml
@ -152,7 +157,7 @@ joins:
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Source identifier. Use lowercase words and underscores. |
| `name` | Yes | Source identity (not the filename). When overlaying an ingested table, match the manifest identifier verbatim, including case (e.g. `SIGNED_UP`); for a new standalone source, lowercase words and underscores are recommended. |
| `descriptions` | No | Description map keyed by source, such as `user`, `dbt`, or `ai`. |
| `table` or `sql` | Yes | Database table or custom SQL expression. Use exactly one. |
| `grain` | Yes | Columns that uniquely identify a row at the source grain. |
@ -316,6 +321,23 @@ Useful frontmatter:
5. Add `sl_refs` for relevant semantic sources.
6. Search again with a user-like phrase.
### Ingest an authoritative document verbatim
When the document is already the source of truth — a metric-definition sheet, a
formula spec, a runbook, compliance text — you want **ktx** to index and surface
it, not re-author it. Instead of hand-copying the file into `wiki/global/`, ingest
it verbatim:
```bash
ktx ingest --file docs/rfm-bucket-definitions.md --verbatim
```
The body is stored byte-for-byte (the LLM only derives `summary`, `tags`, and
`sl_refs` for the absent frontmatter fields), the page key is derived from the
filename, and re-running is a safe no-op. Existing frontmatter — including fields
**ktx** does not model, like `effective_date` — passes through unchanged. See
[`ktx ingest`](/docs/cli-reference/ktx-ingest) for the full flag reference.
## Review context changes
Before accepting agent-written context:

View file

@ -68,19 +68,30 @@ If you choose an install mode, it then asks which targets to install:
```
When every selected target supports both project and global setup, the command
also asks where to install supported agent config:
When at least one selected target supports project-scoped setup, the command
asks where to install agent config:
```txt
◆ Where should ktx install supported agent config?
◆ Where should ktx install agent config?
│ ktx project: /path/to/your/ktx-project
│ ○ Project scope (ktx project directory)
│ ○ ktx project directory /path/to/your/ktx-project
│ ○ Current directory /path/to/where/you/ran/ktx
│ ○ Custom directory… (enter a path)
│ ○ Global scope (user config)
```
The first three choices write project-scoped files (`.claude/`, `.mcp.json`,
`.cursor/`, skills, and rules) into the chosen directory while still pointing
them at this ktx project. Use **Current directory** or **Custom directory…**
when you open your coding agent from somewhere other than the ktx project
directory. **Current directory** is hidden when it is already the ktx project
directory, and **Global scope** appears only when every selected target
supports global setup. Non-interactive runs pass `--install-dir <path>` (for
example `--install-dir .`) for the same result.
## Generated files
**ktx** writes MCP client configuration and analytics guidance by default. It writes

View file

@ -1,6 +1,6 @@
---
title: Context Sources
description: Ingest semantic context from dbt, MetricFlow, LookML, Metabase, Looker, and Notion.
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`, or `notion` |
| `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 |
@ -38,15 +38,16 @@ LookML uses top-level `repoUrl`, and MetricFlow uses nested
## dbt
Ingests schema definitions, model descriptions, column metadata, and test coverage from a dbt project.
Ingests schema definitions, model descriptions, column metadata, and column test definitions from a dbt project.
### What it provides
- Model and source definitions from `schema.yml` files
- Column descriptions and types
- Test coverage signals
- Semantic model references (if using dbt semantic layer)
- Data lineage between models
- Column names, descriptions, and data types
- Column tests, mapped to semantic facts — `not_null` / `unique` become column constraints, `accepted_values` becomes enum value lists, and `relationships` becomes join / foreign-key edges
- Model and source tags, and source freshness settings
MetricFlow `semantic_models:` and `metrics:` are ingested through the separate [MetricFlow](#metricflow) source, not the dbt driver.
### Connection config
@ -87,9 +88,9 @@ connections:
### What gets ingested
- YAML semantic sources generated from dbt schema files
- One work unit per semantic source (for projects with >25 YAML files) or all at once for smaller projects
- Column descriptions, tests, and relationships are preserved
- **Semantic-layer overlays** (`semantic-layer/*.yaml`): descriptions, constraints, enum values, and joins from the dbt YAML are written onto the semantic source for the matching warehouse table. Overlays land on the warehouse connection that owns the table, which is usually a different connection than the dbt source itself.
- **Wiki pages** (`wiki/`): for definitions or relationships that don't map to a confirmed physical table.
- **Work units** for parallel processing: one per schema file under `models/` when the project has more than 25 YAML files, otherwise a single combined unit.
---
@ -101,7 +102,7 @@ Ingests MetricFlow semantic models and metric definitions. Useful when your team
- Semantic model definitions (entities, dimensions, measures)
- Cross-model metric definitions
- Dimension and entity relationships between models
- Entity relationships between models, inferred from matching foreign and primary entities
### Connection config
@ -133,7 +134,7 @@ For a local path:
### What gets ingested
- Semantic models with their entities, dimensions, and measures
- Semantic models with their entities, dimensions, measures, and the join edges inferred from entity relationships
- Metric definitions with their expressions and filters
- Work units organized by connected component (metrics + related semantic models grouped together)
@ -178,10 +179,10 @@ For a local path:
### What gets ingested
- View and model definitions organized by connected component
- LookML field types mapped to semantic layer column types
- Join definitions and relationship cardinalities
- SQL table references for warehouse mapping validation
- One work unit per model, plus a unit for orphan views and one per dashboard
- Semantic-layer sources per view — overlays for thin `sql_table_name` wrappers, standalone sources for `derived_table` views
- Measures, joins (with their Looker `relationship:`), and field types mapped to column types (`yesno` → boolean, date/timestamp → time)
- Wiki pages for relationships and descriptions, with warehouse identifiers verified before writing
### Warehouse mapping
@ -192,19 +193,19 @@ Optionally validate that LookML references match your expected Looker connection
expectedLookerConnectionName: postgres_connection
```
This validates that LookML model `connection:` declarations match expectations, flagging mismatches during ingestion.
This compares each model's `connection:` declaration against the expected name. Mismatched models are flagged, and semantic-layer writes are disabled for them during that ingest while wiki extraction still proceeds.
---
## Metabase
Ingests dashboards, questions, and their underlying SQL queries from a Metabase instance. Maps Metabase databases to your **ktx** warehouse connections.
Ingests collections, questions, models, and metrics — with their underlying SQL — from a Metabase instance. Maps Metabase databases to your **ktx** warehouse connections.
### What it provides
- Dashboard metadata and organization
- Question/query definitions (native SQL and structured queries)
- Table and column usage patterns from queries
- Collections and their hierarchy, used to organize ingested context
- Questions, models, and metrics — resolved SQL for both native and structured (MBQL) queries
- Each card's output schema: column types and primary/foreign-key hints
- Database-to-warehouse relationship mapping
### Connection config
@ -233,9 +234,9 @@ Generate an API key in Metabase: **Admin > Settings > Authentication > API Keys*
### What gets ingested
- Semantic sources generated from SQL queries in questions
- Wiki pages for dashboards (purpose, key metrics, relationships)
- Work units per dashboard and per question
- Semantic-layer sources generated from each card's resolved SQL and column metadata, written to the mapped warehouse connection
- Fallback wiki notes only when a referenced table can't be mapped or an identifier can't be verified
- One work unit per Metabase collection; re-syncs reprocess only collections with changed cards
### Warehouse mapping
@ -289,10 +290,10 @@ Generate API credentials in Looker: **Admin > Users > Edit > API Keys**.
### What gets ingested
- Semantic sources from explore field definitions
- Wiki pages for dashboards (purpose, audience, key metrics)
- Triage signals for automated content classification
- Work units per explore and per dashboard
- Semantic-layer sources from explore fields, written to the mapped warehouse connection (mapped explores only)
- Wiki pages capturing reusable metric, segment, and domain knowledge from dashboards and Looks
- Usage and recency signals that drive a triage gate, focusing processing on high-value content
- Work units per explore, per dashboard, and per Look
### Warehouse mapping
@ -314,10 +315,10 @@ Ingests pages and databases from a Notion workspace as wiki pages. Useful for ca
### What it provides
- Wiki pages synthesized from Notion content
- Page hierarchy and relationships
- Database schemas (when Notion databases describe primary sources)
- Semantic clustering for organized ingestion
- Notion pages crawled from selected roots or all accessible content
- Page bodies and blocks normalized to Markdown
- Page hierarchy and cross-page links (child pages, mentions, relations)
- Notion databases and their data-source rows as individual pages
### Connection config
@ -356,6 +357,7 @@ Create an integration at [notion.so/my-integrations](https://www.notion.so/my-in
| `crawl_mode` | `all_accessible` or `selected_roots` | - |
| `root_page_ids` | Page IDs to crawl from (for `selected_roots`) | `[]` |
| `root_database_ids` | Database IDs to include | `[]` |
| `root_data_source_ids` | Data-source IDs to include (for `selected_roots`) | `[]` |
| `max_pages_per_run` | Pages processed per sync | `1000` |
| `max_knowledge_creates_per_run` | New pages created per sync | `25` |
| `max_knowledge_updates_per_run` | Pages updated per sync | `20` |
@ -363,17 +365,178 @@ Create an integration at [notion.so/my-integrations](https://www.notion.so/my-in
### What gets ingested
- Wiki pages synthesized from Notion content (not raw copies)
- Domain context extracted and organized by topic
- Triage signals for classifying page relevance
- Work units clustered by semantic similarity for efficient processing
- Semantic-layer sources when a page defines a reusable dataset or metric mapped to a confirmed non-Notion target; otherwise the fact stays wiki-only
- Page-relevance triage that skips transient content (task lists, status updates, date-titled snapshots)
- Work units clustered by embedding similarity for efficient synthesis
### Notes
- Notion is knowledge-only - it does not produce semantic layer sources
- Notion is wiki-first: it writes durable wiki pages by default and only emits semantic-layer sources for content mapped to a confirmed non-Notion target; unmapped facts stay wiki-only
- Rate limits apply; large workspaces may require multiple ingestion runs
- Incremental sync cursors are stored in `.ktx/db.sqlite`; don't add
`last_successful_cursor` to `ktx.yaml`
---
## 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.
### What it provides
- Wiki pages synthesized from Google Docs content
- Folder-scoped knowledge ingestion from a specific Drive folder
- Markdown normalization for headings, lists, paragraphs, links, common inline formatting, and Google Docs tables
### Connection config
```yaml title="ktx.yaml"
connections:
company-docs:
driver: gdrive
service_account_key_ref: file:/absolute/path/to/google-service-account.json
folder_id: your-google-drive-folder-id
recursive: false
```
### Authentication
| Method | Config |
|--------|--------|
| Service account JSON key file | `service_account_key_ref: file:/absolute/path/to/key.json` |
### Google Cloud setup
1. Create a Google Cloud project.
2. Enable the Google Drive API.
3. Enable the Google Docs API.
4. Create a service account.
5. Download the service account JSON key.
6. Share the target Drive folder with the service account email.
7. Reference the key in `ktx.yaml` with `service_account_key_ref`.
### Required scopes
- `https://www.googleapis.com/auth/drive.readonly`
- `https://www.googleapis.com/auth/documents.readonly`
### Configuration options
| Field | Description | Default |
|-------|-------------|---------|
| `service_account_key_ref` | File reference to the service account JSON key | - |
| `folder_id` | Google Drive folder ID to ingest | - |
| `recursive` | Traverse subfolders under `folder_id` | `false` |
### What gets ingested
- Google Docs documents only
- Wiki-oriented knowledge content
- One work unit per staged Google Doc
### Notes
- `gdrive` is knowledge-only in v1; it does not produce semantic layer sources
- `ktx setup` supports Google Drive configuration, including the service-account key ref, folder id, and recursive crawl flag
- `ktx connection test <connectionId>` supports `gdrive`: it verifies that `folder_id` resolves to a folder the service account can read, then reports the number of Google Docs visible in it. A wrong or unshared `folder_id` fails the test instead of reporting zero docs
- Only Google Docs are ingested in v1; other file types (Sheets, Slides, PDFs) in the folder are skipped and recorded in the staged manifest
- The service account must be granted access to the target folder explicitly
## Common errors
| Error or symptom | Likely cause | Recovery |

View file

@ -1,6 +1,6 @@
---
title: Primary Sources
description: Connect ktx to PostgreSQL, Snowflake, BigQuery, MySQL, ClickHouse, SQL Server, or SQLite.
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,15 +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`, or `sqlite` |
| `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, 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 |
| `job_timeout_ms` | No | BigQuery | BigQuery query job timeout in milliseconds |
| `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
@ -218,6 +226,37 @@ BigQuery dataset scope is stored in `connections.<id>.dataset_ids`. Interactive
setup discovers datasets from credentials plus location, then writes the chosen
dataset ids as the scan scope.
### Cross-project datasets
To introspect a dataset hosted in a **different project** than the one your
credentials bill to — for example Google's `bigquery-public-data`, a partner's
shared project, or an organization's central data project — qualify the entry
as `project.dataset`:
```yaml title="ktx.yaml"
connections:
public-bq:
driver: bigquery
credentials_json: file:~/.config/gcloud/bq-service-account.json
location: US
dataset_ids:
- bigquery-public-data.austin_311
- bigquery-public-data.census_bureau_usa
- analytics
```
**ktx** introspects each dataset in its host project while every query job still
bills to the `project_id` inside your `credentials_json`. A bare `dataset` entry
(no prefix) is scanned in your own project, exactly as before. A single
connection may mix datasets from several projects, and two projects may host
datasets with the same name without colliding.
Interactive setup does not enumerate datasets in projects your credentials don't
own, so hand-write `project.dataset` entries for foreign datasets. The wizard's
table picker also only lists datasets in your connection's `location` region;
this affects table selection only — ingest and `discover_data` introspect a
cross-project dataset regardless of region.
### Authentication
| Method | Config |
@ -267,7 +306,77 @@ staged artifact shape as Postgres and Snowflake.
- Parameter binding uses named `@param` syntax
- Arrays flattened to comma-separated strings in results
- Location specified at query execution time
- Supports `max_bytes_billed` and `job_timeout_ms` limits from `ktx.yaml`
- Supports the `max_bytes_billed` limit from `ktx.yaml`; the shared `query_timeout_ms` field maps to the query job's `jobTimeoutMs`
---
## 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
---
@ -510,12 +619,141 @@ No authentication required - SQLite is file-based. The file must be readable by
- Foreign key enforcement requires explicit `PRAGMA foreign_keys = ON`
- Database file must exist before `ktx connection test` or ingest runs
---
## 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
as a table and each inferred top-level field as a column. MongoDB is a non-SQL
source: `ktx sql` and semantic-layer metric compilation do not apply to a MongoDB
connection, but its collections still flow through `ktx ingest`, descriptions, and
relationship discovery.
### Connection config
```yaml title="ktx.yaml"
connections:
mongo-prod:
driver: mongodb
url: env:MONGO_URL
databases: [app]
enabled_tables: [app.users, app.orders] # optional collection allowlist
sample_size: 1000
# order_by: createdAt # only when _id is not an ObjectId
```
Standard `mongodb://` and `mongodb+srv://` connection strings are supported,
including TLS and MongoDB Atlas — pass the full connection string (with its
query parameters) as `url`. The `databases` list selects which databases to
introspect; if omitted, **ktx** uses the database in the URL path. `ktx setup`
also offers MongoDB and stores the selected databases under
`connections.<id>.databases`.
### Authentication
| Method | Config |
|--------|--------|
| Connection string | `url: env:MONGO_URL` or `url: file:/path/to/secret` |
| Atlas / TLS | Use a `mongodb+srv://` URL with the credentials and TLS options Atlas provides |
### Schema inference
MongoDB has no fixed schema, so **ktx** infers one by sampling the most recent
`sample_size` documents per collection (default 1000), sorted by `_id`
descending. Because an ObjectId embeds its creation time, this captures the
collection's current shape with zero configuration. When `_id` is not an
ObjectId (custom string or UUID keys), set `order_by` to a timestamp field such
as `createdAt` so "most recent" is well-defined. A custom `order_by` field
should be indexed — an unindexed sort hits MongoDB's in-memory sort limit and
fails on large collections (`_id`, the default, is always indexed).
For each top-level field, **ktx** unions the BSON types seen and derives
nullability from how often the field is present:
- Scalar BSON types map to `string`, `number`, `time`, or `boolean`
- A field seen with more than one type is recorded as `mixed` and treated as a string
- Sub-documents and arrays become a single opaque `json` column (no dotted-path
columns); their sampled values are stringified, not faithfully serialized
- `_id` is the primary key
### Features
| Feature | Supported | Notes |
|---------|-----------|-------|
| Collections (as tables) | Yes | Via `listCollections`; `system.*` collections are excluded |
| Primary keys | Yes | `_id` |
| Foreign keys | No | MongoDB has no formal foreign keys |
| Row count estimates | Yes | Via `estimatedDocumentCount` |
| Column statistics | No | - |
| Query history | No | - |
| Table sampling | Yes | Reads the most recent documents |
| Nested analysis | Yes | Sub-documents and arrays modeled as opaque `json` |
| Read-only SQL (`ktx sql`) | No | MongoDB is not a SQL source |
### Dialect notes
- Strictly read-only: the connector only issues `find`, `listCollections`,
`estimatedDocumentCount`, and read aggregations
- Sampling rides the `_id` index and uses a server-side time limit so large
collections do not stall a run; a custom `order_by` must be indexed for the
same guarantee
- `sample_size` trades inference coverage for speed; raise it for collections
with highly variable documents
## Common errors
| 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,4 +1,4 @@
# KTX release runbook
# ktx release runbook
This runbook covers the maintainer workflow for publishing `@kaelio/ktx` to
npm through GitHub Actions. The workflow uses semantic-release to choose the
@ -36,7 +36,7 @@ Before you publish, confirm these requirements:
publish the first stable version as `0.1.0`.
semantic-release doesn't support choosing an arbitrary first `0.x` stable
release. If KTX has no stable tag yet and you need the first stable release to
release. If **ktx** has no stable tag yet and you need the first stable release to
be `0.1.0`, create and push the baseline tag once before running the live
stable workflow:
@ -46,7 +46,7 @@ git tag v0.0.0 "${root_commit}"
git push origin v0.0.0
```
KTX follows the same versioning schema as the main Kaelio release workflow:
**ktx** follows the same versioning schema as the main Kaelio release workflow:
breaking-change and `major` commit markers create a minor release, not an
automatic major release. A major version requires an intentional manual release
path.

View file

@ -17,17 +17,25 @@ The copied project initializes its own Git repository on first use.
## orbit-relationship-verification
`orbit-relationship-verification/` is a checked-in KTX project used by
`orbit-relationship-verification/` is a checked-in **ktx** project used by
`pnpm run relationships:verify-orbit`. It points the `orbit` SQLite connection
at the Orbit-style no-declared-constraint relationship fixture and verifies that
relationship enrichment writes nine accepted joins without requiring a local
warehouse credential.
## mongodb
`mongodb/` is a manual container-backed example for the MongoDB connector. It
seeds a representative dataset (nested documents, arrays, a mixed-type field, a
nullable field, and a view), then exercises the connector as a fast no-LLM
introspection smoke (`scripts/smoke.sh`) and documents a full keyless
`ktx ingest` run. Works with Docker Compose or `podman compose`.
## postgres-historic
`postgres-historic/` is a manual Docker-backed smoke for Postgres
query-history ingest via `pg_stat_statements`. It verifies setup, staged
query-history artifacts, KTX daemon batch SQL analysis, bounded pattern
query-history artifacts, **ktx** daemon batch SQL analysis, bounded pattern
WorkUnit shards, and no-WorkUnit idempotency for unchanged bucketed table
inputs and pattern shards.

View file

@ -1,6 +1,6 @@
# local-warehouse fixture
This directory is a contributor fixture for KTX CLI smoke tests. It uses the
This directory is a contributor fixture for **ktx** CLI smoke tests. It uses the
internal fake ingest adapter so tests can run without a live database or
external service.

View file

@ -5,7 +5,6 @@ storage:
state: sqlite
search: sqlite-fts5
git:
auto_commit: true
author: "ktx <ktx@example.com>"
ingest:
adapters:
@ -18,5 +17,3 @@ agent:
- sl_query
- wiki_search
- sl_read_source
memory:
auto_commit: true

127
examples/mongodb/README.md Normal file
View file

@ -0,0 +1,127 @@
# MongoDB Connector Example
A manual, self-contained example for the **ktx** MongoDB connector. It starts a
local MongoDB, seeds a representative dataset, and exercises the connector both
as a fast no-LLM introspection smoke and as a full `ktx ingest` run.
MongoDB is a **context-only** primary source: collections become tables and
inferred top-level fields become columns, but `ktx sql` and semantic-layer
metric compilation do not apply. See
[`docs-site/content/docs/integrations/primary-sources.mdx`](../../docs-site/content/docs/integrations/primary-sources.mdx).
## Prerequisites
- Docker with Compose v2, or Podman with `podman compose`
- Node and pnpm matching the **ktx** workspace
- The built CLI: `pnpm --filter @kaelio/ktx run build`
- For the full ingest only: `uv` on `PATH` and a usable local Claude Code
session (the keyless `claude-code` LLM backend)
## What the seed contains
[`init/seed.js`](init/seed.js) creates the `app` database with:
- `users``_id` (ObjectId), scalar fields, a nested `address`, an array
`tags`, a `Decimal128` `balance`, a `ref` field that holds more than one type
(inferred `mixed`), and an `age` field absent from one document (nullable)
- `orders` — an ObjectId `user_id` reference for relationship discovery
- `active_users` — a **view** (to confirm introspection never runs a count
command on a view)
MongoDB applies the script once on first container start. Apply it by hand with:
```bash
mongosh "mongodb://localhost:27117" < examples/mongodb/init/seed.js
```
## Smoke (no LLM credentials)
From the **ktx** repository root:
```bash
examples/mongodb/scripts/smoke.sh
```
It starts MongoDB on `127.0.0.1:27117`, seeds it, and asserts the connector's
inferred schema (collections → tables, nested → `json`, `mixed`, nullability,
`_id` primary key, and a view introspected with `estimatedRows: null`). This
drives the same entry point `ktx ingest`'s "database schema" stage uses, without
needing an LLM or embeddings.
Podman:
```bash
KTX_MONGODB_COMPOSE="podman compose" examples/mongodb/scripts/smoke.sh
```
Set `KTX_MONGODB_KEEP=1` to leave the container running after the script exits.
## Full `ktx ingest`
The public database-ingest path requires a configured model and embeddings.
This runs entirely locally with the keyless `claude-code` LLM backend and the
**ktx**-managed `sentence-transformers` embedding daemon — no API keys.
Start MongoDB and create a project:
```bash
docker compose -f examples/mongodb/docker-compose.yml up -d --wait # or: podman compose
node packages/cli/dist/bin.js admin init /tmp/ktx-mongodb-example
```
Add the connection and a keyless enrichment stack to
`/tmp/ktx-mongodb-example/ktx.yaml`:
```yaml
connections:
mongo-prod:
driver: mongodb
url: mongodb://localhost:27117/app
databases:
- app
llm:
provider:
backend: claude-code
models:
default: sonnet
scan:
enrichment:
mode: llm
embeddings:
backend: sentence-transformers
model: all-MiniLM-L6-v2
dimensions: 384
sentenceTransformers:
base_url: ""
```
Test the connection and ingest:
```bash
node packages/cli/dist/bin.js connection test mongo-prod --project-dir /tmp/ktx-mongodb-example
node packages/cli/dist/bin.js ingest mongo-prod --project-dir /tmp/ktx-mongodb-example --yes --plain
```
The first ingest starts the **ktx** embedding daemon and downloads the
`all-MiniLM-L6-v2` model. Expected final state: `Database schema: done`.
Inspect the result:
- `raw-sources/mongo-prod/live-database/<run>/tables/*.json` — one per
collection, including the `active_users` view with `estimatedRows: null`
- `raw-sources/mongo-prod/live-database/<run>/enrichment/relationships.json`
inferred relationships sit in `review` (a non-SQL source has no read-only SQL
coverage validation), with `accepted: []`
- `semantic-layer/mongo-prod/_schema/app.yaml` — the schema with per-column AI
descriptions
`ktx sql -c mongo-prod "SELECT 1"` is refused by the read-only SQL capability
gate, and `ktx sl query -c mongo-prod ...` is refused because MongoDB is not a
SQL source.
## Cleanup
```bash
docker compose -f examples/mongodb/docker-compose.yml down -v # or: podman compose
rm -rf /tmp/ktx-mongodb-example
```

View file

@ -0,0 +1,14 @@
services:
mongodb:
image: mongo:7
ports:
# Non-default host port so the example does not clash with a local MongoDB.
- "27117:27017"
healthcheck:
test: ["CMD-SHELL", "mongosh --quiet --eval \"db.runCommand({ ping: 1 }).ok\" | grep -q 1"]
interval: 2s
timeout: 5s
retries: 30
volumes:
# MongoDB runs *.js here once, on first start, against an empty data dir.
- ./init:/docker-entrypoint-initdb.d:ro

View file

@ -0,0 +1,64 @@
// Seed a representative MongoDB dataset for the ktx connector example.
//
// MongoDB runs this once on first container start (it is mounted into
// /docker-entrypoint-initdb.d). It can also be applied by hand:
// mongosh "mongodb://localhost:27117" < examples/mongodb/init/seed.js
//
// The shapes here exercise the connector's schema inference end to end:
// scalar BSON types, a nested sub-document, an array, Decimal128, dates, a
// field with more than one type (-> "mixed"), an absent field (-> nullable),
// an ObjectId reference for relationship discovery, and a view (to confirm
// introspection never runs a count command on a view).
const app = db.getSiblingDB('app');
app.users.drop();
app.orders.drop();
app.users.insertMany([
{
email: 'ada@example.com',
age: 31,
active: true,
created: new Date('2026-01-04T10:00:00Z'),
balance: NumberDecimal('120.50'),
address: { city: 'NY', zip: '10001' },
tags: ['admin', 'early-access'],
ref: 'abc',
},
{
email: 'grace@example.com',
active: false,
created: new Date('2026-02-11T08:30:00Z'),
balance: NumberDecimal('0.00'),
address: { city: 'SF', zip: '94016' },
tags: [],
ref: 42, // a second type for this field -> inferred "mixed"
// age intentionally absent -> inferred nullable
},
{
email: 'linus@example.com',
age: 27,
active: true,
created: new Date('2026-03-01T12:00:00Z'),
balance: NumberDecimal('9.99'),
address: { city: 'Austin', zip: '73301' },
tags: ['beta'],
ref: null,
},
]);
const userIds = app.users.find({}, { _id: 1 }).toArray().map((u) => u._id);
app.orders.insertMany([
{ user_id: userIds[0], total: 120.5, status: 'paid', placed: new Date('2026-03-02T09:00:00Z') },
{ user_id: userIds[0], total: 9.99, status: 'pending', placed: new Date('2026-03-05T14:00:00Z') },
{ user_id: userIds[1], total: 50.25, status: 'paid', placed: new Date('2026-03-06T16:00:00Z') },
]);
// A view, to confirm introspection does not issue a count command on it
// (MongoDB rejects count on a view with CommandNotSupportedOnView).
app.createView('active_users', 'users', [{ $match: { active: true } }]);
print('users: ' + app.users.countDocuments());
print('orders: ' + app.orders.countDocuments());
print('collections: ' + app.getCollectionNames().join(', '));

View file

@ -0,0 +1,53 @@
// Deterministic, no-LLM smoke for the MongoDB connector. Drives the same
// introspection entry point ktx ingest's "database schema" stage uses, against
// the seeded example database, and asserts the inferred schema.
//
// Usage: node introspect-smoke.mjs [mongoUrl]
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const ktxRoot = resolve(here, '../../..');
const connectorUrl = `file://${resolve(
ktxRoot,
'packages/cli/dist/connectors/mongodb/live-database-introspection.js',
)}`;
const mongoUrl = process.argv[2] ?? 'mongodb://localhost:27117/app';
const { createMongoDbLiveDatabaseIntrospection } = await import(connectorUrl);
function assert(condition, message) {
if (!condition) {
throw new Error(`assertion failed: ${message}`);
}
}
const port = createMongoDbLiveDatabaseIntrospection({
connections: { 'mongo-example': { driver: 'mongodb', url: mongoUrl, databases: ['app'] } },
});
const snapshot = await port.extractSchema('mongo-example');
const tables = new Map(snapshot.tables.map((table) => [table.name, table]));
assert(snapshot.driver === 'mongodb', 'snapshot driver is mongodb');
assert(['orders', 'users'].every((name) => tables.has(name)), 'users and orders collections introspected');
const users = tables.get('users');
const columns = new Map(users.columns.map((column) => [column.name, column]));
assert(columns.get('_id')?.primaryKey === true && columns.get('_id')?.nullable === false, '_id is the non-null primary key');
assert(columns.get('age')?.nullable === true, 'age is nullable (absent in one document)');
assert(columns.get('email')?.nullable === false, 'email is non-nullable (present in every document)');
assert(columns.get('address')?.normalizedType === 'json', 'nested address maps to opaque json');
assert(columns.get('tags')?.normalizedType === 'json', 'array tags maps to opaque json');
assert(columns.get('ref')?.nativeType === 'mixed', 'ref with two types is inferred as mixed');
const view = tables.get('active_users');
assert(view?.kind === 'view', 'active_users is a view');
assert(view?.estimatedRows === null, 'a view is introspected without a count (estimatedRows null)');
console.log(`OK: introspected ${snapshot.tables.length} collections from ${mongoUrl}`);
for (const table of snapshot.tables) {
console.log(` - ${table.db}.${table.name} (${table.kind}, ${table.columns.length} columns)`);
}
process.exit(0);

View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
# Manual smoke for the MongoDB connector: start MongoDB, seed it, and assert the
# connector's schema introspection (the deterministic, no-LLM half of ktx ingest's
# "database schema" stage). The full enrichment ingest is documented in README.md.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
EXAMPLE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
KTX_ROOT="$(cd "$EXAMPLE_DIR/../.." && pwd)"
COMPOSE_FILE="$EXAMPLE_DIR/docker-compose.yml"
CONNECTOR="$KTX_ROOT/packages/cli/dist/connectors/mongodb/live-database-introspection.js"
MONGO_URL="${KTX_MONGODB_URL:-mongodb://localhost:27117/app}"
# Compose engine: docker by default, override for podman:
# KTX_MONGODB_COMPOSE="podman compose" examples/mongodb/scripts/smoke.sh
COMPOSE="${KTX_MONGODB_COMPOSE:-docker compose}"
cleanup() {
if [[ "${KTX_MONGODB_KEEP:-0}" != "1" ]]; then
$COMPOSE -f "$COMPOSE_FILE" down -v >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
if [[ ! -f "$CONNECTOR" ]]; then
echo "Build the CLI first: pnpm --filter @kaelio/ktx run build" >&2
exit 1
fi
echo "Starting MongoDB and seeding (${COMPOSE})…"
$COMPOSE -f "$COMPOSE_FILE" up -d --wait
echo "Asserting connector introspection against ${MONGO_URL}"
node "$SCRIPT_DIR/introspect-smoke.mjs" "$MONGO_URL"
echo "Smoke passed."

View file

@ -1,11 +1,11 @@
# Orbit-style relationship discovery verification
This KTX project backs the default `relationships:verify-orbit` command. It uses
This **ktx** project backs the default `relationships:verify-orbit` command. It uses
the checked-in Orbit-style SQLite fixture from the relationship discovery
benchmark corpus, with no declared primary keys or foreign keys in the database
schema.
Run from the KTX workspace root:
Run from the **ktx** workspace root:
```bash
pnpm run relationships:verify-orbit

View file

@ -6,7 +6,6 @@ storage:
state: sqlite
search: sqlite-fts5
git:
auto_commit: true
author: "ktx <ktx@example.com>"
ingest:
adapters: []

View file

@ -14,7 +14,7 @@ generated local project.
The managed Python runtime smoke requires `uv` on `PATH`, isolates
`KTX_RUNTIME_ROOT`, verifies `ktx admin runtime status`, runs `ktx sl query --yes` to
install the core runtime from the bundled wheel, checks `ktx admin runtime status`,
starts and reuses the KTX daemon, and stops it.
starts and reuses the **ktx** daemon, and stops it.
The artifact manifest contains the public `@kaelio/ktx` npm tarball and the
bundled `kaelio-ktx` runtime wheel. The smoke does not install standalone

View file

@ -17,19 +17,19 @@ unchanged bounded pattern shards do not schedule LLM work.
## Prerequisites
- Docker with Compose v2
- Node and pnpm matching the KTX workspace
- `uv` on `PATH` so the KTX-managed Python runtime can install the bundled
- Node and pnpm matching the **ktx** workspace
- `uv` on `PATH` so the **ktx**-managed Python runtime can install the bundled
runtime wheel
## Run
From the KTX repository root:
From the **ktx** repository root:
```bash
examples/postgres-historic/scripts/smoke.sh
```
The smoke creates a temporary KTX project, isolates the managed Python runtime
The smoke creates a temporary **ktx** project, isolates the managed Python runtime
under the temporary project parent, starts Postgres on `127.0.0.1:55432`, and
uses this connection URL:
@ -41,7 +41,7 @@ Set `KTX_POSTGRES_HISTORIC_KEEP_DOCKER=1` to leave the container running after
the script exits.
The smoke validates the query-history raw snapshot path without requiring LLM
credentials. It uses KTX's local stage-only ingest API after `ktx setup`, so the
credentials. It uses **ktx**'s local stage-only ingest API after `ktx setup`, so the
deterministic reader, batch SQL parser, stable artifact writer, and diff-based
WorkUnit planning are checked independently from curation.
@ -124,6 +124,6 @@ table.
- Missing grants: confirm `GRANT pg_read_all_stats TO ktx_reader;`.
- Empty snapshot: rerun `scripts/generate-workload.sh base` and keep
`--query-history-min-executions 2` for the smoke.
- SQL-analysis failures: run `pnpm run ktx -- dev runtime status` from the KTX
- SQL-analysis failures: run `pnpm run ktx -- dev runtime status` from the **ktx**
repository root and confirm `uv`, the bundled Python wheel, and the managed
runtime all pass.

View file

@ -17,7 +17,9 @@
"test/**/*.test-utils.ts",
"test/**/acceptance-fixtures.ts",
"src/context/scan/relationship-benchmarks.ts!",
"src/context/scan/relationship-benchmark-report.ts!"
"src/context/scan/relationship-benchmark-report.ts!",
"src/connectors/sqlite/read-query-child.ts!",
"src/context/llm/subprocess-generate-object-child.ts!"
]
},
"docs-site": {
@ -38,7 +40,8 @@
"conventional-changelog-conventionalcommits"
],
"ignore": [
".context/**"
".context/**",
"examples/**"
],
"ignoreBinaries": [
"uv",

View file

@ -1,6 +1,6 @@
{
"name": "ktx-workspace",
"version": "0.11.0",
"version": "0.16.0",
"description": "Workspace root for ktx packages",
"private": true,
"type": "module",
@ -69,11 +69,6 @@
"typescript": "^6.0.3",
"yaml": "^2.9.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3"
]
},
"license": "Apache-2.0",
"repository": {
"type": "git",

View file

@ -1,6 +1,6 @@
{
"name": "@kaelio/ktx",
"version": "0.11.0",
"version": "0.16.0",
"description": "Standalone ktx context layer for data agents",
"author": {
"name": "Kaelio",
@ -51,11 +51,15 @@
"@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",
"@commander-js/extra-typings": "14.0.0",
"@duckdb/node-api": "1.5.3-r.3",
"@google-cloud/bigquery": "^8.3.1",
"google-auth-library": "10.6.2",
"@looker/sdk": "^26.8.0",
"@looker/sdk-node": "^26.8.0",
"@looker/sdk-rtl": "^21.6.5",
@ -70,11 +74,14 @@
"ink": "^7.0.3",
"lookml-parser": "7.1.0",
"minimatch": "^10.2.5",
"mongodb": "^6.12.0",
"mssql": "^12.5.4",
"mysql2": "^3.22.3",
"openai": "^6.38.0",
"p-limit": "^7.3.0",
"pg": "^8.21.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"posthog-node": "^5.34.9",
"react": "^19.2.6",
"semver": "^7.8.1",

View file

@ -7,10 +7,17 @@ const promptsSource = join(packageRoot, 'src', 'prompts');
const promptsTarget = join(packageRoot, 'dist', 'prompts');
const skillsSource = join(packageRoot, 'src', 'skills');
const skillsTarget = join(packageRoot, 'dist', 'skills');
// Per-dialect SQL notes are markdown served by the sql_dialect_notes MCP tool;
// tsc does not emit non-.ts files, so copy them next to their compiled module.
const dialectNotesSource = join(packageRoot, 'src', 'context', 'sql-analysis', 'dialects');
const dialectNotesTarget = join(packageRoot, 'dist', 'context', 'sql-analysis', 'dialects');
await rm(promptsTarget, { recursive: true, force: true });
await rm(skillsTarget, { recursive: true, force: true });
await rm(dialectNotesTarget, { recursive: true, force: true });
await mkdir(dirname(promptsTarget), { recursive: true });
await mkdir(dirname(skillsTarget), { recursive: true });
await mkdir(dirname(dialectNotesTarget), { recursive: true });
await cp(promptsSource, promptsTarget, { recursive: true });
await cp(skillsSource, skillsTarget, { recursive: true });
await cp(dialectNotesSource, dialectNotesTarget, { recursive: true });

View file

@ -24,7 +24,7 @@ export function registerAdminCommands(program: Command, context: KtxCliCommandCo
admin
.command('init')
.description('Initialize a Git-backed KTX project directory for maintenance scripts')
.description('Initialize a Git-backed ktx project directory for maintenance scripts')
.argument('[directory]', 'Project directory')
.option('--force', 'Rewrite ktx.yaml and scaffold files in an existing project', false)
.action(

View file

@ -81,27 +81,39 @@ class KtxCliPromptCancelledError extends Error {
}
export function createClackSpinner(): KtxCliSpinner {
return spinner();
// clack colors the animated spinner frame magenta by default; styleFrame
// (typed in SpinnerOptions, absent from the README) recolors it ktx orange.
return spinner({ styleFrame: orange });
}
function magenta(text: string): string {
return ansiColor(text, 35, 39);
// ktx mascot orange (#FF8A4C) via 24-bit truecolor.
function orange(text: string): string {
if (!ansiEnabled()) {
return text;
}
return `${ESC}[38;2;255;138;76m${text}${ESC}[39m`;
}
function red(text: string): string {
return ansiColor(text, 31, 39);
}
/**
* Stderr-only, non-animated spinner. Use this instead of {@link createCliSpinner}
* when the next step reads stdin in raw mode (an Ink TUI or a keypress wait):
* the animated clack spinner seizes stdin via `@clack/core`'s `block()` and
* leaves it dirty, which the following raw-mode reader misreads as a stray key.
*/
export function createStaticCliSpinner(io: KtxCliSpinnerIo): KtxCliSpinner {
return {
start(message) {
io.stderr.write(`${magenta('◐')} ${message}\n`);
io.stderr.write(`${orange('◐')} ${message}\n`);
},
message(message) {
io.stderr.write(`${magenta('│')} ${message}\n`);
io.stderr.write(`${orange('│')} ${message}\n`);
},
stop(message) {
io.stderr.write(`${magenta('◇')} ${message}\n`);
io.stderr.write(`${orange('◇')} ${message}\n`);
},
error(message) {
io.stderr.write(`${red('■')} ${message}\n`);
@ -109,6 +121,30 @@ export function createStaticCliSpinner(io: KtxCliSpinnerIo): KtxCliSpinner {
};
}
/**
* Animated spinner in an interactive terminal, static `◐/◇/■` lines otherwise
* (scripts, CI, piped output) so logs stay clean and uncluttered by frames.
*/
export function createCliSpinner(io: KtxCliIo): KtxCliSpinner {
return io.stdout.isTTY === true ? createClackSpinner() : createStaticCliSpinner(io);
}
export async function runWithCliSpinner<T>(
spinner: KtxCliSpinner,
text: { start: string; success: string; failure: string },
run: () => Promise<T>,
): Promise<T> {
spinner.start(text.start);
try {
const value = await run();
spinner.stop(text.success);
return value;
} catch (error) {
spinner.error(text.failure);
throw error;
}
}
export function createClackPromptAdapter(): KtxCliPromptAdapter {
return {
async confirm(options) {

View file

@ -21,7 +21,7 @@ export function formatClaudeCodePromptCachingWarning(fields: string[]): string |
if (fields.length === 0) {
return null;
}
return `claude-code ignores ${fields.join(', ')} because the Claude Agent SDK does not expose KTX prompt-cache TTL, tool, or history markers.`;
return `claude-code ignores ${fields.join(', ')} because the Claude Agent SDK does not expose ktx prompt-cache TTL, tool, or history markers.`;
}
export function formatClaudeCodePromptCachingFix(): string {

View file

@ -133,7 +133,7 @@ export function parseBooleanStringOption(value: string): boolean {
}
export function parseSafeConnectionIdOption(value: string): string {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(value)) {
throw new InvalidArgumentError(`Unsafe connection id: ${value}`);
}
return value;
@ -252,8 +252,8 @@ export function resolveCommandProjectDirOverride(command: CommandWithGlobalOptio
function createBaseProgram(info: KtxCliPackageInfo, io: KtxCliIo): Command {
return new Command()
.name('ktx')
.description('KTX data agent context layer CLI')
.option('--project-dir <path>', 'KTX project directory (default: KTX_PROJECT_DIR, nearest ktx.yaml, or cwd)')
.description('ktx data agent context layer CLI')
.option('--project-dir <path>', 'ktx project directory (default: KTX_PROJECT_DIR, nearest ktx.yaml, or cwd)')
.option('--debug', 'Enable diagnostic logging to stderr')
.version(`${info.name} ${info.version}`, '-v, --version', 'Show CLI version')
.helpOption('-h, --help', 'Show this help text')
@ -466,7 +466,7 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
const attachProjectGroup = shouldAttachCommandProjectGroup(path, hasProject);
telemetry.beginCommandSpan({
commandPath: path,
flagsPresent: collectCommandFlagsPresent(commandNode as unknown as CommandUnknownOpts),
flagsPresent: collectCommandFlagsPresent(actionCommand),
projectDir: attachProjectGroup ? projectDir : undefined,
hasProject,
attachProjectGroup,

View file

@ -61,7 +61,7 @@ export function packageInfoFromJson(packageJson: unknown): KtxCliPackageInfo {
typeof packageJson.name !== 'string' ||
typeof packageJson.version !== 'string'
) {
throw new Error('Invalid KTX CLI package metadata');
throw new Error('Invalid ktx CLI package metadata');
}
return {
@ -77,7 +77,7 @@ async function runInit(args: { projectDir: string; force: boolean }, io: KtxCliI
force: args.force,
});
io.stdout.write(`Initialized KTX project at ${result.projectDir}\n`);
io.stdout.write(`Initialized ktx project at ${result.projectDir}\n`);
io.stdout.write(`Config: ${result.configPath}\n`);
io.stdout.write(`Commit: ${result.commitHash ?? 'none'}\n`);
return 0;

View file

@ -37,7 +37,7 @@ export function registerConnectionCommands(program: Command, context: KtxCliComm
connection
.command('test')
.description('Test one or all configured connections (default: all)')
.argument('[connectionId]', 'KTX connection id to test (omit to test all)')
.argument('[connectionId]', 'ktx connection id to test (omit to test all)')
.option('--all', 'Test every configured connection and print a summary list')
.action(async (connectionId: string | undefined, options: { all?: boolean }, command) => {
if (options.all === true && connectionId !== undefined) {

View file

@ -1,10 +1,12 @@
import { type Command, Option } from '@commander-js/extra-typings';
import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
import {
collectOption,
type KtxCliCommandContext,
parsePositiveIntegerOption,
resolveCommandProjectDir,
} from '../cli-program.js';
import { KTX_SCAN_ENRICHMENT_STAGES } from '../context/scan/enrichment-state.js';
import type { KtxScanEnrichmentStage } from '../context/scan/types.js';
import type { KtxCliDeps, KtxCliIo } from '../index.js';
import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
import type { KtxPublicIngestArgs } from '../public-ingest.js';
@ -14,6 +16,36 @@ import { resolveConnectionSelection } from './connection-selection.js';
profileMark('module:commands/ingest-commands');
/**
* Parses `--stages` into an ordered, de-duplicated subset of the canonical
* enrichment-stage registry. An unknown or empty name is a hard parse error so
* a typo never silently degrades to "run everything."
*
* @internal
*/
export function parseEnrichmentStagesOption(value: string): KtxScanEnrichmentStage[] {
const names = value
.split(',')
.map((name) => name.trim())
.filter((name) => name.length > 0);
if (names.length === 0) {
throw new InvalidArgumentError(
`must be a non-empty comma-separated list of stages (${KTX_SCAN_ENRICHMENT_STAGES.join(', ')})`,
);
}
const valid = new Set<string>(KTX_SCAN_ENRICHMENT_STAGES);
const selected = new Set<KtxScanEnrichmentStage>();
for (const name of names) {
if (!valid.has(name)) {
throw new InvalidArgumentError(
`unknown stage "${name}"; valid stages are ${KTX_SCAN_ENRICHMENT_STAGES.join(', ')}`,
);
}
selected.add(name as KtxScanEnrichmentStage);
}
return KTX_SCAN_ENRICHMENT_STAGES.filter((stage) => selected.has(stage));
}
interface IngestCommandOptions {
runTextIngest: (args: KtxTextIngestArgs, io: KtxCliIo, deps: KtxCliDeps) => Promise<number>;
}
@ -25,16 +57,26 @@ export function registerIngestCommands(
): void {
const ingest = program
.command('ingest')
.description('Build or inspect KTX context, or capture text into memory')
.description('Build or inspect ktx context, or capture text into memory')
.usage('[options] [connectionId]')
.argument('[connectionId]', 'Configured connection id to ingest (omit to ingest all)')
.option('--all', 'Ingest all configured connections', false)
.addOption(new Option('--query-history', 'Include database query-history usage patterns').conflicts('noQueryHistory'))
.addOption(new Option('--no-query-history', 'Skip database query-history usage patterns'))
.option('--query-history-window-days <days>', 'Query-history lookback window for this run', parsePositiveIntegerOption)
.option('--text <content>', 'Capture inline text into KTX memory; repeatable', collectOption, [])
.option('--file <path>', 'Capture a text file into KTX memory; use - for stdin; repeatable', collectOption, [])
.option('--connection-id <connectionId>', 'KTX connection id to tag captured text/file notes')
.option(
'--stages <stages>',
'Comma-separated enrichment stages to (re)run (descriptions,embeddings,relationships); omit to run all',
parseEnrichmentStagesOption,
)
.option('--text <content>', 'Capture inline text into ktx memory; repeatable', collectOption, [])
.option('--file <path>', 'Capture a text file into ktx memory; use - for stdin; repeatable', collectOption, [])
.option(
'--verbatim',
'Store each --text/--file document body unchanged as a GLOBAL wiki page; the LLM derives only metadata',
false,
)
.option('--connection-id <connectionId>', 'ktx connection id to tag captured text/file notes')
.option('--user-id <id>', 'Memory user id for text/file capture attribution', 'local-cli')
.option('--fail-fast', 'Stop after the first failed text/file item', false)
.addOption(new Option('--plain', 'Print plain text output').conflicts(['json']))
@ -47,6 +89,14 @@ export function registerIngestCommands(
const projectDir = resolveCommandProjectDir(command);
const hasTextCapture = options.text.length > 0 || options.file.length > 0;
if (options.verbatim === true && !hasTextCapture) {
command.error('error: --verbatim requires --text or --file');
}
if (options.stages !== undefined && hasTextCapture) {
command.error('error: --stages applies to database ingest only; it cannot be combined with --text or --file');
}
if (hasTextCapture) {
if (connectionId !== undefined) {
command.error(
@ -66,6 +116,7 @@ export function registerIngestCommands(
userId: options.userId,
json: options.json === true,
failFast: options.failFast === true,
...(options.verbatim === true ? { verbatim: true } : {}),
},
context.io,
context.deps,
@ -87,6 +138,7 @@ export function registerIngestCommands(
inputMode: options.input === false ? 'disabled' : 'auto',
queryHistory,
...(options.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: options.queryHistoryWindowDays } : {}),
...(options.stages ? { stages: options.stages } : {}),
cliVersion: context.packageInfo.version,
runtimeInstallPolicy: runtimeInstallPolicyFromFlags(options),
};

View file

@ -27,6 +27,7 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
.usage('[options] [query...]')
.argument('[query...]', 'Search query; omit to list all pages')
.option('--user-id <id>', 'Local user id', 'local')
.option('-c, --connection <id>', 'Scope results to one connection (unscoped pages plus pages tagged with it)')
.option('--limit <number>', 'Maximum search results (search mode only)', parsePositiveIntegerOption)
.addOption(
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
@ -46,6 +47,7 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
query: string[],
options: {
userId: string;
connection?: string;
limit?: number;
output?: 'pretty' | 'plain' | 'json';
json?: boolean;
@ -57,6 +59,7 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
command: 'list',
projectDir: resolveCommandProjectDir(command),
userId: options.userId,
...(options.connection !== undefined ? { connectionId: options.connection } : {}),
output: options.output,
json: options.json,
cliVersion: context.packageInfo.version,
@ -68,6 +71,7 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
projectDir: resolveCommandProjectDir(command),
query: query.join(' '),
userId: options.userId,
...(options.connection !== undefined ? { connectionId: options.connection } : {}),
output: options.output,
json: options.json,
...(isDebugEnabled(command) ? { debug: true } : {}),

View file

@ -27,11 +27,11 @@ function binPath(): string {
function formatMcpStartResultMessage(input: { status: 'started' | 'already-running'; url: string }): string {
return [
input.status === 'started' ? `KTX MCP daemon started: ${input.url}` : `KTX MCP daemon already running: ${input.url}`,
input.status === 'started' ? `ktx MCP daemon started: ${input.url}` : `ktx MCP daemon already running: ${input.url}`,
'',
'KTX is ready for configured agents.',
'Open your agent for this KTX project and ask a data question, for example:',
' "Use KTX to show me the available tables and metrics."',
'ktx is ready for configured agents.',
'Open your agent for this ktx project and ask a data question, for example:',
' "Use ktx to show me the available tables and metrics."',
'',
].join('\n');
}
@ -50,14 +50,14 @@ async function printMcpStatus(context: KtxCliCommandContext, projectDir: string)
export function registerMcpCommands(program: Command, context: KtxCliCommandContext): void {
const mcp = program
.command('mcp')
.description('Manage the KTX MCP HTTP server (bare command: show status)')
.description('Manage the ktx MCP HTTP server (bare command: show status)')
.action(async (_options, command) => {
await printMcpStatus(context, resolveCommandProjectDir(command));
});
mcp
.command('stdio')
.description('Run the KTX MCP server over stdio')
.description('Run the ktx MCP server over stdio')
.action(async (_options, command) => {
await (context.deps.mcp?.runStdioServer ?? runKtxMcpStdioServer)({
projectDir: resolveCommandProjectDir(command),
@ -68,7 +68,7 @@ export function registerMcpCommands(program: Command, context: KtxCliCommandCont
mcp
.command('start')
.description('Start the KTX MCP HTTP server')
.description('Start the ktx MCP HTTP server')
.option('--host <host>', 'Host to bind', '127.0.0.1')
.option('--port <n>', 'Port to bind', parsePositiveIntegerOption, 7878)
.option('--token <token>', 'Bearer token required for non-loopback binding')
@ -96,7 +96,7 @@ export function registerMcpCommands(program: Command, context: KtxCliCommandCont
allowedOrigins: options.allowedOrigin,
io: context.io,
});
context.io.stdout.write(`KTX MCP server listening at http://${options.host}:${options.port}/mcp\n`);
context.io.stdout.write(`ktx MCP server listening at http://${options.host}:${options.port}/mcp\n`);
return;
}
const result = await (context.deps.mcp?.startDaemon ?? startKtxMcpDaemon)({
@ -114,24 +114,24 @@ export function registerMcpCommands(program: Command, context: KtxCliCommandCont
mcp
.command('stop')
.description('Stop the KTX MCP daemon')
.description('Stop the ktx MCP daemon')
.action(async (_options, command) => {
const result = await (context.deps.mcp?.stopDaemon ?? stopKtxMcpDaemon)({
projectDir: resolveCommandProjectDir(command),
});
context.io.stdout.write(result.status === 'stopped' ? 'KTX MCP daemon stopped.\n' : 'KTX MCP daemon is not running.\n');
context.io.stdout.write(result.status === 'stopped' ? 'ktx MCP daemon stopped.\n' : 'ktx MCP daemon is not running.\n');
});
mcp
.command('status')
.description('Show KTX MCP daemon status')
.description('Show ktx MCP daemon status')
.action(async (_options, command) => {
await printMcpStatus(context, resolveCommandProjectDir(command));
});
mcp
.command('logs')
.description('Print the KTX MCP daemon log')
.description('Print the ktx MCP daemon log')
.option('--follow', 'Follow log output', false)
.action(async (options, command) => {
const logPath = mcpDaemonLayout(resolveCommandProjectDir(command)).logPath;

View file

@ -18,7 +18,7 @@ async function runRuntimeArgs(context: KtxCliCommandContext, args: KtxRuntimeArg
export function registerRuntimeCommands(program: Command, context: KtxCliCommandContext): void {
const runtime = program
.command('runtime')
.description('Install, start, stop, and inspect the KTX-managed Python runtime')
.description('Install, start, stop, and inspect the ktx-managed Python runtime')
.showHelpAfterError();
runtime
@ -38,7 +38,7 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand
runtime
.command('start')
.description('Start the KTX daemon')
.description('Start the ktx daemon')
.addOption(createRuntimeFeatureOption())
.option('--force', 'Restart even when a matching daemon is already running', false)
.action(async (options: { feature: RuntimeFeature; force?: boolean }, command: CommandWithGlobalOptions) => {
@ -53,8 +53,8 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand
runtime
.command('stop')
.description('Stop the KTX daemon')
.option('--all', 'Stop all KTX daemon processes recorded or discoverable on this machine', false)
.description('Stop the ktx daemon')
.option('--all', 'Stop all ktx daemon processes recorded or discoverable on this machine', false)
.action(async (options: { all?: boolean }, command: CommandWithGlobalOptions) => {
await runRuntimeArgs(context, {
command: 'stop',

View file

@ -2,7 +2,7 @@ import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-
import type { KtxCliCommandContext } from '../cli-program.js';
import { resolveCommandProjectDir } from '../cli-program.js';
import type { KtxSetupDatabaseDriver } from '../setup-databases.js';
import type { KtxSetupLlmBackend } from '../setup-models.js';
import { isKtxSetupLlmBackend, type KtxSetupLlmBackend } from '../setup-models.js';
import type { KtxSetupSourceType } from '../setup-sources.js';
async function runSetupArgs(
@ -16,7 +16,7 @@ async function runSetupArgs(
function positiveInteger(value: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Expected a positive integer, received ${value}`);
throw new InvalidArgumentError(`Expected a positive integer, received ${value}`);
}
return parsed;
}
@ -29,7 +29,7 @@ function embeddingBackend(value: string): 'openai' | 'sentence-transformers' {
}
function llmBackend(value: string): KtxSetupLlmBackend {
if (value === 'anthropic' || value === 'vertex' || value === 'claude-code' || value === 'codex') {
if (isKtxSetupLlmBackend(value)) {
return value;
}
throw new InvalidArgumentError(`invalid choice '${value}'`);
@ -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' ||
@ -57,7 +58,9 @@ function sourceType(value: string): KtxSetupSourceType {
value === 'metabase' ||
value === 'looker' ||
value === 'lookml' ||
value === 'notion'
value === 'notion' ||
value === 'sigma' ||
value === 'gdrive'
) {
return value;
}
@ -89,6 +92,7 @@ function shouldShowSetupEntryMenu(
target?: string;
global?: boolean;
local?: boolean;
installDir?: string;
skipAgents?: boolean;
yes?: boolean;
input?: boolean;
@ -131,6 +135,9 @@ function shouldShowSetupEntryMenu(
metabaseDatabaseId?: number;
notionCrawlMode?: string;
notionRootPageId?: string[];
gdriveServiceAccountKeyRef?: string;
gdriveFolderId?: string;
gdriveRecursive?: boolean;
skipSources?: boolean;
},
command: Command,
@ -159,6 +166,7 @@ function shouldShowSetupEntryMenu(
'target',
'global',
'local',
'installDir',
'skipAgents',
'yes',
'input',
@ -195,6 +203,9 @@ function shouldShowSetupEntryMenu(
'sourceTarget',
'metabaseDatabaseId',
'notionCrawlMode',
'gdriveServiceAccountKeyRef',
'gdriveFolderId',
'gdriveRecursive',
'skipSources',
].some((optionName) => optionWasSpecified(command, optionName));
}
@ -202,8 +213,8 @@ function shouldShowSetupEntryMenu(
export function registerSetupCommands(program: Command, context: KtxCliCommandContext): void {
const setup = program
.command('setup')
.description('Set up or resume a local KTX project')
.addOption(new Option('--project-dir <path>', 'KTX project directory').hideHelp())
.description('Set up or resume a local ktx project')
.addOption(new Option('--project-dir <path>', 'ktx project directory').hideHelp())
.option('--agents', 'Install agent integration only', false)
.addOption(
new Option('--target <target>', 'Agent target').choices([
@ -217,6 +228,10 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
)
.option('--global', 'Install agent integration into the global target scope', false)
.option('--local', 'Install Claude Code MCP config into the private per-project ~/.claude.json scope', false)
.option(
'--install-dir <path>',
'Directory to install project-scoped agent config into (defaults to the ktx project directory)',
)
.addOption(new Option('--skip-agents', 'Leave agent integration incomplete for now').hideHelp().default(false))
.option('--yes', 'Accept project creation and runtime install defaults where setup confirms', false)
.option('--no-input', 'Disable interactive terminal input')
@ -295,7 +310,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
.hideHelp(),
)
.addOption(
new Option('--skip-databases', 'Leave database setup incomplete; KTX cannot work until a database is added')
new Option('--skip-databases', 'Leave database setup incomplete; ktx cannot work until a database is added')
.hideHelp()
.default(false),
)
@ -331,6 +346,12 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
.default([] as string[])
.hideHelp(),
)
.addOption(
new Option('--gdrive-service-account-key-ref <ref>', 'file: reference to a Google service account JSON key')
.hideHelp(),
)
.addOption(new Option('--gdrive-folder-id <id>', 'Google Drive folder id to ingest').hideHelp())
.addOption(new Option('--gdrive-recursive', 'Recursively traverse Google Drive subfolders').hideHelp().default(false))
.addOption(new Option('--skip-sources', 'Mark optional source setup complete with no sources').hideHelp().default(false))
.showHelpAfterError();
@ -394,6 +415,16 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
context.setExitCode(1);
return;
}
if (options.installDir && (options.global || options.local)) {
context.io.stderr.write('Choose either --install-dir or a scope flag (--global / --local), not both.\n');
context.setExitCode(1);
return;
}
if (options.installDir && options.target === 'claude-desktop') {
context.io.stderr.write('--install-dir does not apply to --target claude-desktop, which is always global.\n');
context.setExitCode(1);
return;
}
const creatingDatabaseConnection = options.database.length > 0 || options.databaseUrl !== undefined;
if (creatingDatabaseConnection && options.databaseConnectionId.length > 1) {
@ -412,6 +443,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
agents: options.agents === true,
...(options.target ? { target: options.target } : {}),
agentScope: resolvedAgentScope,
...(options.installDir ? { installRoot: options.installDir } : {}),
skipAgents: options.skipAgents === true,
inputMode: options.input === false ? 'disabled' : 'auto',
...(debugEnabled ? { debug: true } : {}),
@ -469,6 +501,11 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
...(options.metabaseDatabaseId !== undefined ? { metabaseDatabaseId: options.metabaseDatabaseId } : {}),
...(options.notionCrawlMode ? { notionCrawlMode: options.notionCrawlMode } : {}),
...(options.notionRootPageId.length > 0 ? { notionRootPageIds: options.notionRootPageId } : {}),
...(options.gdriveServiceAccountKeyRef
? { gdriveServiceAccountKeyRef: options.gdriveServiceAccountKeyRef }
: {}),
...(options.gdriveFolderId ? { gdriveFolderId: options.gdriveFolderId } : {}),
...(options.gdriveRecursive ? { gdriveRecursive: true } : {}),
runInitialSourceIngest: false,
skipSources: options.skipSources === true,
showEntryMenu: shouldShowSetupEntryMenu(options, command),

View file

@ -44,7 +44,7 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
.description('List, search, validate, or query local semantic-layer sources')
.usage('[options] [query...]')
.argument('[query...]', 'Search query; omit to list all sources')
.option('--connection-id <id>', 'KTX connection id')
.option('--connection-id <id>', 'ktx connection id')
.option('--limit <number>', 'Maximum search results (search mode only)', parsePositiveIntegerOption)
.addOption(
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([

View file

@ -26,7 +26,7 @@ export function registerSqlCommands(program: Command, context: KtxCliCommandCont
.command('sql')
.description('Execute parser-validated read-only SQL against a configured connection')
.argument('<sql...>', 'SQL query to execute')
.requiredOption('-c, --connection <id>', 'KTX connection id')
.requiredOption('-c, --connection <id>', 'ktx connection id')
.option('--max-rows <n>', 'Maximum rows to return', parseSqlMaxRowsOption, DEFAULT_MAX_ROWS)
.addOption(
new Option('--output <mode>', 'Output mode: pretty (default), plain (TSV), or json').choices([

View file

@ -15,7 +15,7 @@ function inputMode(options: { input?: boolean }): { inputMode?: 'disabled' } {
export function registerStatusCommands(program: Command, context: KtxCliCommandContext): void {
program
.command('status')
.description('Check current KTX setup and project readiness')
.description('Check current ktx setup and project readiness')
.option('--json', 'Print JSON output', false)
.option('-v, --verbose', 'Show every check, including passing ones', false)
.option('--validate', 'Only validate the ktx.yaml schema; skip readiness checks', false)

View file

@ -1,14 +1,21 @@
import type { KtxProjectConnectionConfig } from './context/project/config.js';
const KTX_DATABASE_DRIVER_IDS = new Set([
/** @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
// dialect-notes coverage set above.
const databaseDriverIds = new Set<string>([...KTX_DATABASE_DRIVER_IDS, 'mongodb']);
export function normalizeConnectionDriver(connection: KtxProjectConnectionConfig): string {
return String(connection.driver ?? '')
@ -17,5 +24,5 @@ export function normalizeConnectionDriver(connection: KtxProjectConnectionConfig
}
export function isDatabaseDriver(driver: string): boolean {
return KTX_DATABASE_DRIVER_IDS.has(driver.trim().toLowerCase());
return databaseDriverIds.has(driver.trim().toLowerCase());
}

View file

@ -3,9 +3,13 @@ import { DefaultLookerConnectionClientFactory } from './context/ingest/adapters/
import type { LookerClient } from './context/ingest/adapters/looker/client.js';
import type { MetabaseRuntimeClient } from './context/ingest/adapters/metabase/client-port.js';
import { type NotionBotInfo, NotionClient } from './context/ingest/adapters/notion/notion-client.js';
import { parseGdriveConnectionConfig, resolveGdriveServiceAccountKey } from './context/connections/gdrive-config.js';
import { createLocalLookerCredentialResolver } from './context/ingest/adapters/looker/local-looker.adapter.js';
import { metabaseRuntimeConfigFromLocalConnection } from './context/ingest/adapters/metabase/local-metabase.adapter.js';
import { createGoogleDocsClients, verifyGdriveFolderAndCountDocs } from './context/ingest/adapters/gdrive/gdrive-client.js';
import { gdriveServiceAccountKeySchema } from './context/ingest/adapters/gdrive/types.js';
import { testRepoConnection } from './context/ingest/repo-fetch.js';
import { federatedConnectionListing } from './context/connections/federation.js';
import { getDriverRegistration } from './context/connections/drivers.js';
import { parseNotionConnectionConfig, resolveNotionConnectionAuthToken } from './context/connections/notion-config.js';
import { resolveKtxConfigReference } from './context/core/config-reference.js';
@ -30,6 +34,10 @@ export type KtxConnectionArgs =
type MetabaseTestPort = Pick<MetabaseRuntimeClient, 'testConnection' | 'getDatabases' | 'cleanup'>;
type LookerTestPort = Pick<LookerClient, 'testConnection'>;
type NotionTestPort = Pick<NotionClient, 'retrieveBotUser'>;
type GdriveTestPort = Pick<
ReturnType<typeof createGoogleDocsClients>['drive'],
'listFiles' | 'getFile'
>;
type TestRepoConnection = typeof testRepoConnection;
export interface KtxConnectionDeps {
@ -37,11 +45,13 @@ export interface KtxConnectionDeps {
createMetabaseClient?: (project: KtxLocalProject, connectionId: string) => Promise<MetabaseTestPort>;
createLookerClient?: (project: KtxLocalProject, connectionId: string) => Promise<LookerTestPort>;
createNotionClient?: (project: KtxLocalProject, connectionId: string) => Promise<NotionTestPort>;
createGdriveClient?: (project: KtxLocalProject, connectionId: string) => Promise<GdriveTestPort>;
testRepoConnection?: TestRepoConnection;
}
const SUPPORTED_TEST_DRIVERS = [
'sqlite',
'duckdb',
'postgres',
'mysql',
'clickhouse',
@ -51,6 +61,7 @@ const SUPPORTED_TEST_DRIVERS = [
'metabase',
'looker',
'notion',
'gdrive',
'dbt',
'metricflow',
'lookml',
@ -134,7 +145,7 @@ async function createDefaultLookerClient(
connectionId: string,
): Promise<LookerTestPort> {
const factory = new DefaultLookerConnectionClientFactory(createLocalLookerCredentialResolver(project));
return (await factory.createClient(connectionId)) as unknown as LookerTestPort;
return factory.createLookerClient(connectionId);
}
async function testLookerConnection(
@ -182,6 +193,34 @@ async function testNotionConnection(
return { bot: describeNotionBot(bot) };
}
async function createDefaultGdriveClient(
project: KtxLocalProject,
connectionId: string,
): Promise<GdriveTestPort> {
const connection = project.config.connections[connectionId];
if (!connection) {
throw new Error(`Connection "${connectionId}" is not configured in ktx.yaml`);
}
const parsed = parseGdriveConnectionConfig(connection);
const keyText = await resolveGdriveServiceAccountKey(parsed.service_account_key_ref);
const key = gdriveServiceAccountKeySchema.parse(JSON.parse(keyText));
return createGoogleDocsClients(key).drive;
}
async function testGdriveConnection(
project: KtxLocalProject,
connectionId: string,
createClient: (project: KtxLocalProject, connectionId: string) => Promise<GdriveTestPort>,
): Promise<{ docs: number }> {
const connection = project.config.connections[connectionId];
if (!connection) {
throw new Error(`Connection "${connectionId}" is not configured in ktx.yaml`);
}
const parsed = parseGdriveConnectionConfig(connection);
const client = await createClient(project, connectionId);
return { docs: await verifyGdriveFolderAndCountDocs(client, parsed.folder_id) };
}
interface GitConnectionFields {
repoUrl: string;
authToken: string | null;
@ -270,6 +309,15 @@ async function testConnectionByDriver(
return { driver, detailKey: 'Bot', detailValue: result.bot };
}
if (driver === 'gdrive') {
const result = await testGdriveConnection(
project,
connectionId,
deps.createGdriveClient ?? createDefaultGdriveClient,
);
return { driver, detailKey: 'Docs', detailValue: String(result.docs) };
}
if (driver === 'dbt' || driver === 'metricflow' || driver === 'lookml') {
const result = await testGitRepoConnection(
project,
@ -447,15 +495,23 @@ export async function runKtxConnection(
io.stdout.write('No connections configured. Run `ktx setup` to add one.\n');
return 0;
}
const idWidth = Math.max('ID'.length, ...entries.map(([id]) => id.length));
const driverWidth = Math.max(
'DRIVER'.length,
const federated = federatedConnectionListing(project.config.connections, args.projectDir);
const idCandidates = [...entries.map(([id]) => id), ...(federated ? [federated.id] : [])];
const driverLengths = [
...entries.map(([, c]) => (c.driver ?? 'unknown').length),
);
...(federated ? [federated.driver.length] : []),
];
const idWidth = Math.max('ID'.length, ...idCandidates.map((id) => id.length));
const driverWidth = Math.max('DRIVER'.length, ...driverLengths);
io.stdout.write(`${'ID'.padEnd(idWidth)} ${'DRIVER'.padEnd(driverWidth)}\n`);
for (const [id, connection] of entries) {
io.stdout.write(`${id.padEnd(idWidth)} ${(connection.driver ?? 'unknown').padEnd(driverWidth)}\n`);
}
if (federated) {
io.stdout.write(`${federated.id.padEnd(idWidth)} ${federated.driver.padEnd(driverWidth)}\n`);
io.stdout.write(` federates: ${federated.members.join(', ')}\n`);
io.stdout.write(` ${federated.hint}\n`);
}
return 0;
}

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

@ -1,8 +1,14 @@
import { BigQuery, type TableField } from '@google-cloud/bigquery';
import { normalizeBigQueryProjectId, normalizeBigQueryRegion } from '../../context/connections/bigquery-identifiers.js';
import { getDialectForDriver } from '../../context/connections/dialects.js';
import {
normalizeBigQueryDatasetId,
normalizeBigQueryProjectId,
normalizeBigQueryRegion,
} from '../../context/connections/bigquery-identifiers.js';
import { getSqlDialectForDriver } from '../../context/connections/dialects.js';
import { resolveQueryDeadlineMs, queryDeadlineExceededError } from '../../context/connections/query-deadline.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { tryIntrospectObject } from '../../context/scan/object-introspection.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
connectorTestFailure,
@ -26,9 +32,7 @@ import {
type KtxTableSampleInput,
type KtxTableSampleResult,
} from '../../context/scan/types.js';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import { resolveStringReference } from '../shared/string-reference.js';
export interface KtxBigQueryConnectionConfig {
driver?: string;
@ -37,14 +41,25 @@ export interface KtxBigQueryConnectionConfig {
credentials_json?: string;
location?: string;
max_bytes_billed?: number | string;
job_timeout_ms?: number;
query_timeout_ms?: number;
[key: string]: unknown;
}
/**
* A dataset to introspect, paired with the project that hosts it. `project`
* defaults to the billing project (`credentials.project_id`) when an entry has
* no `project.` prefix; a fully-qualified `project.dataset` entry resolves to
* its own host project. Jobs always bill in `credentials.project_id`.
*/
export interface BigQueryDatasetRef {
project: string;
dataset: string;
}
export interface KtxBigQueryResolvedConnectionConfig {
projectId: string;
credentials: Record<string, unknown>;
datasetIds: string[];
datasetIds: BigQueryDatasetRef[];
location?: string;
}
@ -97,7 +112,7 @@ export interface KtxBigQueryDataset {
export interface KtxBigQueryClient {
getDatasets(input?: { maxResults?: number }): Promise<[Array<{ id?: string }>, ...unknown[]]>;
dataset(datasetId: string): KtxBigQueryDataset;
dataset(datasetId: string, projectId: string): KtxBigQueryDataset;
createQueryJob(input: {
query: string;
location?: string;
@ -118,7 +133,6 @@ export interface KtxBigQueryScanConnectorOptions {
env?: NodeJS.ProcessEnv;
now?: () => Date;
maxBytesBilled?: number | string;
queryTimeoutMs?: number;
}
class DefaultBigQueryClientFactory implements KtxBigQueryClientFactory {
@ -126,8 +140,8 @@ class DefaultBigQueryClientFactory implements KtxBigQueryClientFactory {
const client = new BigQuery(input);
return {
getDatasets: (options) => client.getDatasets(options) as Promise<[Array<{ id?: string }>, ...unknown[]]>,
dataset: (datasetId) => {
const dataset = client.dataset(datasetId);
dataset: (datasetId, projectId) => {
const dataset = client.dataset(datasetId, { projectId });
return {
get: () => dataset.get() as Promise<unknown>,
getTables: () => dataset.getTables() as Promise<[KtxBigQueryTableRef[], ...unknown[]]>,
@ -138,18 +152,6 @@ class DefaultBigQueryClientFactory implements KtxBigQueryClientFactory {
}
}
function resolveStringReference(value: string, env: NodeJS.ProcessEnv): string {
if (value.startsWith('env:')) {
return env[value.slice('env:'.length)] ?? '';
}
if (value.startsWith('file:')) {
const rawPath = value.slice('file:'.length);
const path = rawPath.startsWith('~') ? resolve(homedir(), rawPath.slice(1)) : rawPath;
return readFileSync(path, 'utf-8').trim();
}
return value;
}
function stringConfigValue(
connection: KtxBigQueryConnectionConfig | undefined,
key: keyof KtxBigQueryConnectionConfig,
@ -159,14 +161,48 @@ function stringConfigValue(
return typeof value === 'string' && value.trim().length > 0 ? resolveStringReference(value.trim(), env) : undefined;
}
function datasetIds(connection: KtxBigQueryConnectionConfig, env: NodeJS.ProcessEnv): string[] {
if (Array.isArray(connection.dataset_ids) && connection.dataset_ids.length > 0) {
return connection.dataset_ids
.filter((dataset) => dataset.trim().length > 0)
.map((dataset) => resolveStringReference(dataset, env));
/**
* Parse one `dataset_ids` / `dataset_id` entry into a canonical
* {@link BigQueryDatasetRef}. A `project.dataset` prefix selects the host
* project; a bare entry defaults to `defaultProject` (the billing project).
* More than one dot, or an empty segment, is a config error naming the
* connection never a silent mis-introspection at scan time.
*/
function parseBigQueryDatasetEntry(entry: string, defaultProject: string, connectionId: string): BigQueryDatasetRef {
const context = `connections.${connectionId}.dataset_ids entry "${entry}"`;
const parts = entry.split('.');
if (parts.length === 1) {
return { project: defaultProject, dataset: normalizeBigQueryDatasetId(parts[0]!, context) };
}
const datasetId = stringConfigValue(connection, 'dataset_id', env);
return datasetId ? [datasetId] : [];
if (parts.length === 2) {
const [project, dataset] = parts;
if (!project || !dataset) {
throw new Error(`Invalid BigQuery dataset entry for ${context}: empty project or dataset segment`);
}
return {
project: normalizeBigQueryProjectId(project, context),
dataset: normalizeBigQueryDatasetId(dataset, context),
};
}
throw new Error(
`Invalid BigQuery dataset entry for ${context}: expected "dataset" or "project.dataset", got more than one "."`,
);
}
function resolveDatasetRefs(
connection: KtxBigQueryConnectionConfig,
env: NodeJS.ProcessEnv,
defaultProject: string,
connectionId: string,
): BigQueryDatasetRef[] {
const rawEntries =
Array.isArray(connection.dataset_ids) && connection.dataset_ids.length > 0
? connection.dataset_ids.map((dataset) => resolveStringReference(dataset, env))
: [stringConfigValue(connection, 'dataset_id', env)].filter((value): value is string => Boolean(value));
return rawEntries
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.map((entry) => parseBigQueryDatasetEntry(entry, defaultProject, connectionId));
}
function bigQueryMaxBytesBilledFromConnection(
@ -183,12 +219,25 @@ function bigQueryMaxBytesBilledFromConnection(
return undefined;
}
function bigQueryJobTimeoutMsFromConnection(connection: KtxBigQueryConnectionConfig | undefined): number | undefined {
const value = connection?.job_timeout_ms;
if (typeof value !== 'number') {
return undefined;
// jobTimeoutMs cancels the job with a "Job timed out" message (or a timeout
// reason in the errors array) once the deadline elapses.
function isBigQueryTimeoutError(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false;
}
return Number.isInteger(value) && value > 0 ? value : undefined;
const topMessage = (error as { message?: unknown }).message;
if (typeof topMessage === 'string' && /timed out|timeout/i.test(topMessage)) {
return true;
}
const errors = (error as { errors?: unknown }).errors;
return (
Array.isArray(errors) &&
errors.some((entry) => {
const reason = (entry as { reason?: unknown })?.reason;
const message = (entry as { message?: unknown })?.message;
return reason === 'timeout' || (typeof message === 'string' && /timed out|timeout/i.test(message));
})
);
}
function tableKind(metadataType: string | undefined): KtxSchemaTable['kind'] {
@ -281,7 +330,7 @@ export function bigQueryConnectionConfigFromConfig(input: {
if (!projectId) {
throw new Error(`Native BigQuery connector requires credentials_json.project_id for connections.${input.connectionId}`);
}
const resolvedDatasetIds = datasetIds(input.connection, env);
const resolvedDatasetIds = resolveDatasetRefs(input.connection, env, projectId, input.connectionId);
const location = stringConfigValue(input.connection, 'location', env);
return { projectId, credentials, datasetIds: resolvedDatasetIds, ...(location ? { location } : {}) };
}
@ -304,8 +353,8 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
private readonly clientFactory: KtxBigQueryClientFactory;
private readonly now: () => Date;
private readonly maxBytesBilled?: number | string;
private readonly queryTimeoutMs?: number;
private readonly dialect = getDialectForDriver('bigquery');
private readonly deadlineMs: number;
private readonly dialect = getSqlDialectForDriver('bigquery');
private client: KtxBigQueryClient | null = null;
constructor(options: KtxBigQueryScanConnectorOptions) {
@ -318,7 +367,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
this.clientFactory = options.clientFactory ?? new DefaultBigQueryClientFactory();
this.now = options.now ?? (() => new Date());
this.maxBytesBilled = options.maxBytesBilled ?? bigQueryMaxBytesBilledFromConnection(options.connection);
this.queryTimeoutMs = options.queryTimeoutMs ?? bigQueryJobTimeoutMsFromConnection(options.connection);
this.deadlineMs = resolveQueryDeadlineMs(options.connection);
this.id = `bigquery:${options.connectionId}`;
}
@ -326,8 +375,8 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
try {
const client = this.getClient();
await client.getDatasets({ maxResults: 1 });
for (const datasetId of this.resolved.datasetIds) {
await client.dataset(datasetId).get();
for (const ref of this.resolved.datasetIds) {
await client.dataset(ref.dataset, ref.project).get();
}
return { success: true };
} catch (error) {
@ -338,22 +387,23 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
async introspect(input: KtxScanInput, _ctx: KtxScanContext): Promise<KtxSchemaSnapshot> {
this.assertConnection(input.connectionId);
const tables: KtxSchemaTable[] = [];
const datasetIds = this.requireDatasetIdsForScan();
const datasetRefs = this.requireDatasetIdsForScan();
const snapshotWarnings: KtxScanWarning[] = [];
for (const datasetId of datasetIds) {
for (const ref of datasetRefs) {
const scopedNames = input.tableScope
? scopedTableNames(input.tableScope, { catalog: this.resolved.projectId, db: datasetId })
? scopedTableNames(input.tableScope, { catalog: ref.project, db: ref.dataset })
: null;
tables.push(...(await this.introspectDataset(datasetId, scopedNames, snapshotWarnings)));
tables.push(...(await this.introspectDataset(ref, scopedNames, snapshotWarnings)));
}
const datasetLabels = datasetRefs.map((ref) => this.qualifiedDatasetLabel(ref));
return {
connectionId: this.connectionId,
driver: 'bigquery',
extractedAt: this.now().toISOString(),
scope: { catalogs: [this.resolved.projectId], datasets: datasetIds },
scope: { catalogs: [...new Set(datasetRefs.map((ref) => ref.project))], datasets: datasetLabels },
metadata: {
project_id: this.resolved.projectId,
datasets: datasetIds,
datasets: datasetLabels,
table_count: tables.length,
total_columns: tables.reduce((sum, table) => sum + table.columns.length, 0),
},
@ -414,11 +464,14 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
return { values: valueRows.filter((row) => row.val !== null).map((row) => String(row.val)), cardinality };
}
async getTableRowCount(tableName: string, datasetId = this.resolved.datasetIds[0]): Promise<number> {
if (!datasetId) {
async getTableRowCount(
tableName: string,
ref: BigQueryDatasetRef | undefined = this.resolved.datasetIds[0],
): Promise<number> {
if (!ref) {
return 0;
}
const tables = await this.introspectDataset(datasetId, null, []);
const tables = await this.introspectDataset(ref, null, []);
return tables.find((table) => table.name === tableName)?.estimatedRows ?? 0;
}
@ -436,12 +489,28 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
}
async listTables(datasetIds?: string[]): Promise<KtxTableListEntry[]> {
const projectId = normalizeBigQueryProjectId(this.resolved.projectId, 'table discovery');
const region = normalizeBigQueryRegion(this.resolved.location ?? 'US', 'table discovery');
if (!datasetIds || datasetIds.length === 0) {
return this.listTablesInProject(this.resolved.projectId, region);
}
const datasetsByProject = new Map<string, string[]>();
for (const entry of datasetIds) {
const ref = parseBigQueryDatasetEntry(entry.trim(), this.resolved.projectId, this.connectionId);
datasetsByProject.set(ref.project, [...(datasetsByProject.get(ref.project) ?? []), ref.dataset]);
}
const entries: KtxTableListEntry[] = [];
for (const [project, datasets] of datasetsByProject) {
entries.push(...(await this.listTablesInProject(project, region, datasets)));
}
return entries;
}
private async listTablesInProject(project: string, region: string, datasets?: string[]): Promise<KtxTableListEntry[]> {
const projectId = normalizeBigQueryProjectId(project, 'table discovery');
const params: Record<string, unknown> = {};
const filter = datasetIds && datasetIds.length > 0 ? 'AND table_schema IN UNNEST(@dataset_ids)' : '';
if (datasetIds && datasetIds.length > 0) {
params.dataset_ids = datasetIds;
const filter = datasets && datasets.length > 0 ? 'AND table_schema IN UNNEST(@dataset_ids)' : '';
if (datasets && datasets.length > 0) {
params.dataset_ids = datasets;
}
const rows = await this.queryRaw<{ table_schema: string; table_name: string; table_type: string }>(
`
@ -456,7 +525,7 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
params,
);
return rows.map((row) => ({
catalog: this.resolved.projectId,
catalog: project,
schema: row.table_schema,
name: row.table_name,
kind:
@ -480,34 +549,48 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
return this.client;
}
private requireDatasetIdsForScan(): string[] {
private requireDatasetIdsForScan(): BigQueryDatasetRef[] {
if (this.resolved.datasetIds.length === 0) {
throw new Error(`Native BigQuery scan requires connections.${this.connectionId}.dataset_ids or dataset_id`);
}
return this.resolved.datasetIds;
}
// Bare in the billing project, qualified `project.dataset` otherwise, so the
// snapshot's scope/metadata stay unambiguous when two projects host the same
// dataset name. The dotless form is the unchanged single-project label.
private qualifiedDatasetLabel(ref: BigQueryDatasetRef): string {
return ref.project === this.resolved.projectId ? ref.dataset : `${ref.project}.${ref.dataset}`;
}
private async query(sql: string, params?: Record<string, unknown>): Promise<KtxQueryResult> {
const [job] = await this.getClient().createQueryJob({
query: sql,
...(this.resolved.location ? { location: this.resolved.location } : {}),
...(params && Object.keys(params).length > 0 ? { params } : {}),
...(this.maxBytesBilled ? { maximumBytesBilled: String(this.maxBytesBilled) } : {}),
...(this.queryTimeoutMs ? { jobTimeoutMs: this.queryTimeoutMs } : {}),
});
const [rows, , response] = await job.getQueryResults();
let headers = response?.schema?.fields?.map((field) => field.name || '') ?? [];
const headerTypes = response?.schema?.fields?.map((field) => String(field.type || 'STRING')) ?? [];
if (headers.length === 0 && rows.length > 0) {
headers = Object.keys(rows[0]!);
try {
const [job] = await this.getClient().createQueryJob({
query: sql,
...(this.resolved.location ? { location: this.resolved.location } : {}),
...(params && Object.keys(params).length > 0 ? { params } : {}),
...(this.maxBytesBilled ? { maximumBytesBilled: String(this.maxBytesBilled) } : {}),
jobTimeoutMs: this.deadlineMs,
});
const [rows, , response] = await job.getQueryResults();
let headers = response?.schema?.fields?.map((field) => field.name || '') ?? [];
const headerTypes = response?.schema?.fields?.map((field) => String(field.type || 'STRING')) ?? [];
if (headers.length === 0 && rows.length > 0) {
headers = Object.keys(rows[0]!);
}
return {
headers,
headerTypes: headerTypes.length > 0 ? headerTypes : undefined,
rows: rows.map((row) => headers.map((header) => normalizeValue(row[header]))),
totalRows: rows.length,
rowCount: rows.length,
};
} catch (error) {
if (isBigQueryTimeoutError(error)) {
throw queryDeadlineExceededError(this.deadlineMs, { cause: error });
}
throw error;
}
return {
headers,
headerTypes: headerTypes.length > 0 ? headerTypes : undefined,
rows: rows.map((row) => headers.map((header) => normalizeValue(row[header]))),
totalRows: rows.length,
rowCount: rows.length,
};
}
private async queryRaw<T extends Record<string, unknown>>(sql: string, params?: Record<string, unknown>): Promise<T[]> {
@ -521,18 +604,18 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
}
private async introspectDataset(
datasetId: string,
ref: BigQueryDatasetRef,
scopedNames: readonly string[] | null,
snapshotWarnings: KtxScanWarning[],
): Promise<KtxSchemaTable[]> {
if (scopedNames && scopedNames.length === 0) return [];
const dataset = this.getClient().dataset(datasetId);
const dataset = this.getClient().dataset(ref.dataset, ref.project);
const [tableRefs] = await dataset.getTables();
const scopeSet = scopedNames ? new Set(scopedNames) : null;
const filteredTableRefs = scopeSet ? tableRefs.filter((tableRef) => scopeSet.has(tableRef.id ?? '')) : tableRefs;
const primaryKeysResult = await tryConstraintQuery(
{ schema: datasetId, kind: 'primary_key', isDeniedError },
() => this.primaryKeys(datasetId),
{ schema: ref.dataset, kind: 'primary_key', isDeniedError },
() => this.primaryKeys(ref),
);
const primaryKeys = primaryKeysResult.ok ? primaryKeysResult.value : new Map<string, Set<string>>();
if (!primaryKeysResult.ok) {
@ -541,41 +624,51 @@ export class KtxBigQueryScanConnector implements KtxScanConnector {
const tables: KtxSchemaTable[] = [];
for (const tableRef of filteredTableRefs) {
const tableName = tableRef.id || '';
const [table] = await tableRef.get();
const fields = table.metadata.schema?.fields ?? [];
tables.push({
catalog: this.resolved.projectId,
db: datasetId,
name: tableName,
kind: tableKind(table.metadata.type),
comment: table.metadata.description || null,
estimatedRows: firstNumber(table.metadata.numRows) ?? 0,
columns: fields.map((field) => this.toSchemaColumn(tableName, field, primaryKeys)),
foreignKeys: [],
});
const outcome = await tryIntrospectObject<KtxSchemaTable>(
{ object: tableName, catalog: ref.project, db: ref.dataset },
async () => {
const [table] = await tableRef.get();
const fields = table.metadata.schema?.fields ?? [];
return {
catalog: ref.project,
db: ref.dataset,
name: tableName,
kind: tableKind(table.metadata.type),
comment: table.metadata.description || null,
estimatedRows: firstNumber(table.metadata.numRows) ?? 0,
columns: fields.map((field) => this.toSchemaColumn(tableName, field, primaryKeys)),
foreignKeys: [],
};
},
);
if (outcome.ok) {
tables.push(outcome.table);
} else {
snapshotWarnings.push(outcome.warning);
}
}
return tables;
}
private async primaryKeys(datasetId: string): Promise<Map<string, Set<string>>> {
private async primaryKeys(ref: BigQueryDatasetRef): Promise<Map<string, Set<string>>> {
const rows = await this.queryRaw<{ table_name: string; column_name: string }>(
'SELECT tc.table_name, kcu.column_name ' +
'FROM `' +
this.resolved.projectId +
ref.project +
'.' +
datasetId +
ref.dataset +
'.INFORMATION_SCHEMA.TABLE_CONSTRAINTS` tc ' +
'JOIN `' +
this.resolved.projectId +
ref.project +
'.' +
datasetId +
ref.dataset +
'.INFORMATION_SCHEMA.KEY_COLUMN_USAGE` kcu ' +
'ON tc.constraint_name = kcu.constraint_name ' +
'AND tc.table_schema = kcu.table_schema ' +
'AND tc.table_name = kcu.table_name ' +
"WHERE tc.constraint_type = 'PRIMARY KEY' " +
"AND tc.table_schema = '" +
datasetId +
ref.dataset +
"' " +
"AND NOT REGEXP_CONTAINS(kcu.column_name, r'^(stacksync_record_id|sync_primary_key)_') " +
'ORDER BY tc.table_name, kcu.ordinal_position',

View file

@ -1,4 +1,4 @@
import type { KtxDialect } from '../../context/connections/dialects.js';
import type { KtxSqlDialect } from '../../context/connections/dialects.js';
import {
columnDisplayPartCount,
formatDialectDisplayRef,
@ -11,7 +11,7 @@ import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/typ
type BigQueryTableNameRef = Pick<KtxTableRef, 'name'> & Partial<Pick<KtxTableRef, 'catalog' | 'db'>>;
/** @internal */
export class KtxBigQueryDialect implements KtxDialect {
export class KtxBigQueryDialect implements KtxSqlDialect {
readonly type = 'bigquery' as const;
private readonly typeMappings: Record<string, KtxSchemaDimensionType> = {

View file

@ -1,12 +1,11 @@
import { createClient } from '@clickhouse/client';
import { getDialectForDriver } from '../../context/connections/dialects.js';
import { getSqlDialectForDriver } from '../../context/connections/dialects.js';
import { resolveQueryDeadlineMs, queryDeadlineExceededError } from '../../context/connections/query-deadline.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 KtxTableRef, type KtxTableSampleInput, type KtxTableListEntry, type KtxTableSampleResult } from '../../context/scan/types.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import { readFileSync } from 'node:fs';
import { resolveStringReference } from '../shared/string-reference.js';
import { Agent as HttpsAgent } from 'node:https';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
export interface KtxClickHouseConnectionConfig {
driver?: string;
@ -142,23 +141,25 @@ function stringConfigValue(
return typeof value === 'string' && value.trim().length > 0 ? resolveStringReference(value.trim(), env) : undefined;
}
function resolveStringReference(value: string, env: NodeJS.ProcessEnv): string {
if (value.startsWith('env:')) {
const envName = value.slice('env:'.length);
return env[envName] ?? '';
}
if (value.startsWith('file:')) {
const rawPath = value.slice('file:'.length);
const path = rawPath.startsWith('~') ? resolve(homedir(), rawPath.slice(1)) : rawPath;
return readFileSync(path, 'utf-8').trim();
}
return value;
}
function maybeNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
// ClickHouse error code 159 = TIMEOUT_EXCEEDED, raised when max_execution_time
// is hit. The client surfaces it via a numeric/string `code` or a "Code: 159"
// message prefix depending on transport.
function isClickHouseTimeoutError(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false;
}
const code = (error as { code?: unknown }).code;
if (code === 159 || code === '159') {
return true;
}
const message = (error as { message?: unknown }).message;
return typeof message === 'string' && (/\bCode:\s*159\b/.test(message) || message.includes('TIMEOUT_EXCEEDED'));
}
function parseClickHouseUrl(url: string): Partial<KtxClickHouseConnectionConfig> {
const parsed = new URL(url);
return {
@ -299,7 +300,8 @@ export class KtxClickHouseScanConnector implements KtxScanConnector {
private readonly clientFactory: KtxClickHouseClientFactory;
private readonly endpointResolver?: KtxClickHouseEndpointResolver;
private readonly now: () => Date;
private readonly dialect = getDialectForDriver('clickhouse');
private readonly deadlineMs: number;
private readonly dialect = getSqlDialectForDriver('clickhouse');
private client: KtxClickHouseClient | null = null;
private resolvedEndpoint: KtxClickHouseResolvedEndpoint | null = null;
@ -314,6 +316,7 @@ export class KtxClickHouseScanConnector implements KtxScanConnector {
this.clientFactory = options.clientFactory ?? new DefaultClickHouseClientFactory();
this.endpointResolver = options.endpointResolver;
this.now = options.now ?? (() => new Date());
this.deadlineMs = resolveQueryDeadlineMs(this.connection);
this.id = `clickhouse:${options.connectionId}`;
}
@ -599,9 +602,13 @@ export class KtxClickHouseScanConnector implements KtxScanConnector {
username: config.username,
password: config.password ?? '',
database: config.database,
request_timeout: 30_000,
// The server aborts at max_execution_time (seconds); request_timeout must
// outlast it so the HTTP client receives the code-159 error instead of
// giving up first and leaving the query running.
request_timeout: this.deadlineMs + 5_000,
clickhouse_settings: {
output_format_json_quote_64bit_integers: 1,
max_execution_time: Math.ceil(this.deadlineMs / 1000),
},
...(isProxied && config.ssl
? {
@ -628,24 +635,31 @@ export class KtxClickHouseScanConnector implements KtxScanConnector {
private async query(sql: string, params?: Record<string, unknown>): Promise<Omit<KtxQueryResult, 'rowCount'>> {
const client = await this.clientForQuery();
const resultSet = await client.query({
query: assertReadOnlySql(sql),
format: 'JSONCompact',
...(params ? { query_params: params } : {}),
});
const response = (await resultSet.json()) as ClickHouseCompactResponse;
const meta = response.meta ?? [];
return {
headers: meta.map((field) => field.name),
headerTypes: meta.map((field) => field.type),
rows: response.data ?? [],
totalRows: response.rows ?? response.data?.length ?? 0,
};
try {
const resultSet = await client.query({
query: assertReadOnlySql(sql),
format: 'JSONCompact',
...(params ? { query_params: params } : {}),
});
const response = (await resultSet.json()) as ClickHouseCompactResponse;
const meta = response.meta ?? [];
return {
headers: meta.map((field) => field.name),
headerTypes: meta.map((field) => field.type),
rows: response.data ?? [],
totalRows: response.rows ?? response.data?.length ?? 0,
};
} catch (error) {
if (isClickHouseTimeoutError(error)) {
throw queryDeadlineExceededError(this.deadlineMs, { cause: error });
}
throw error;
}
}
private assertConnection(connectionId: string): void {
if (connectionId !== this.connectionId) {
throw new Error(`KTX ClickHouse connector ${this.id} cannot serve connection ${connectionId}`);
throw new Error(`ktx ClickHouse connector ${this.id} cannot serve connection ${connectionId}`);
}
}
}

View file

@ -1,4 +1,4 @@
import type { KtxDialect } from '../../context/connections/dialects.js';
import type { KtxSqlDialect } from '../../context/connections/dialects.js';
import {
columnDisplayPartCount,
formatDialectDisplayRef,
@ -11,7 +11,7 @@ import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/typ
type ClickHouseTableNameRef = Pick<KtxTableRef, 'name'> & Partial<Pick<KtxTableRef, 'catalog' | 'db'>>;
/** @internal */
export class KtxClickHouseDialect implements KtxDialect {
export class KtxClickHouseDialect implements KtxSqlDialect {
readonly type = 'clickhouse' as const;
private readonly typeMappings: Record<string, KtxSchemaDimensionType> = {

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

@ -0,0 +1,97 @@
import { sqliteDatabasePathFromConfig, type KtxSqliteConnectionConfig } from '../sqlite/connector.js';
import { postgresPoolConfigFromConfig, type KtxPostgresConnectionConfig } from '../postgres/connector.js';
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 {
// libpq/DuckDB key-value values quote with single quotes and backslash-escape.
return /[\s'\\]/.test(value) ? `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'` : value;
}
function withRequiredSslMode(connectionString: string): string {
// DuckDB passes this libpq URL straight to the server, so an ssl:true member
// must carry sslmode in the URL itself; keep a stronger mode the URL already pins.
const url = new URL(connectionString);
if (url.searchParams.has('sslmode')) {
return connectionString;
}
url.searchParams.set('sslmode', 'require');
return url.toString();
}
function postgresAttachString(member: FederatedMember, env: NodeJS.ProcessEnv): string {
const cfg = postgresPoolConfigFromConfig({
connectionId: member.connectionId,
connection: member.connection as KtxPostgresConnectionConfig,
env,
});
if (cfg.connectionString) {
return cfg.ssl ? withRequiredSslMode(cfg.connectionString) : cfg.connectionString;
}
const parts: string[] = [];
if (cfg.host) parts.push(`host=${kvKeyword(cfg.host)}`);
if (cfg.port) parts.push(`port=${cfg.port}`);
if (cfg.database) parts.push(`dbname=${kvKeyword(cfg.database)}`);
if (cfg.user) parts.push(`user=${kvKeyword(cfg.user)}`);
if (cfg.password) parts.push(`password=${kvKeyword(cfg.password)}`);
if (cfg.ssl) {
parts.push('sslmode=require');
}
if (cfg.options) {
parts.push(`options=${kvKeyword(cfg.options)}`);
}
return parts.join(' ');
}
function mysqlAttachString(member: FederatedMember, env: NodeJS.ProcessEnv): string {
const cfg = mysqlConnectionPoolConfigFromConfig({
connectionId: member.connectionId,
connection: member.connection as KtxMysqlConnectionConfig,
env,
});
const parts: string[] = [
`host=${kvKeyword(cfg.host)}`,
`port=${cfg.port}`,
`database=${kvKeyword(cfg.database)}`,
`user=${kvKeyword(cfg.user)}`,
];
if (cfg.password) {
parts.push(`password=${kvKeyword(cfg.password)}`);
}
if (cfg.ssl) {
parts.push('ssl_mode=REQUIRED');
}
return parts.join(' ');
}
/**
* Resolves a federated member's ktx.yaml config into the connection target
* DuckDB's ATTACH wants for that driver, reusing each connector's canonical
* resolver so federation and standalone scans agree on config interpretation.
*/
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,
projectDir: member.projectDir,
connection: member.connection as KtxSqliteConnectionConfig,
});
case 'postgres':
return postgresAttachString(member, env);
case 'mysql':
return mysqlAttachString(member, env);
default:
throw new Error(`Driver "${member.driver}" cannot be attached by DuckDB federation.`);
}
}

View file

@ -0,0 +1,64 @@
import { DuckDBInstance } from '@duckdb/node-api';
import { federatedAttachTarget } from './federated-attach.js';
import type {
KtxSqlQueryExecutionInput,
KtxSqlQueryExecutionResult,
} from '../../context/connections/query-executor.js';
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('"', '""')}"`;
}
/** @internal */
export function buildAttachStatements(members: FederatedMember[], env: NodeJS.ProcessEnv): string[] {
const attachments = members.map((member) => ({
type: attachTypeForDriver(member.driver),
url: federatedAttachTarget(member, env),
alias: member.connectionId,
}));
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];
}
export async function executeFederatedQuery(
members: FederatedMember[],
input: KtxSqlQueryExecutionInput,
env: NodeJS.ProcessEnv = process.env,
): Promise<KtxSqlQueryExecutionResult> {
const sql = limitSqlForExecution(assertReadOnlySql(input.sql), input.maxRows);
const attachStatements = buildAttachStatements(members, env);
const instance = await DuckDBInstance.create(':memory:');
try {
const connection = await instance.connect();
try {
for (const statement of attachStatements) {
await connection.run(statement);
}
const reader = await connection.runAndReadAll(sql);
const rows = toJsonSafeRows(normalizeQueryRows(reader.getRows()));
const headers = reader.columnNames();
return {
headers,
rows,
totalRows: rows.length,
command: 'SELECT',
rowCount: rows.length,
};
} finally {
connection.closeSync();
}
} finally {
instance.closeSync();
}
}

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,413 @@
import { MongoClient } from 'mongodb';
import { resolveKtxConfigReference } from '../../context/core/config-reference.js';
import {
connectorTestFailure,
createKtxConnectorCapabilities,
type KtxColumnSampleInput,
type KtxColumnSampleResult,
type KtxConnectorTestResult,
type KtxScanConnector,
type KtxScanContext,
type KtxScanInput,
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 { getDialectForDriver } from '../../context/connections/dialects.js';
import { inferKtxMongoCollectionColumns, type KtxMongoDocument, MONGO_ID_FIELD } from './schema-inference.js';
const DEFAULT_SAMPLE_SIZE = 1000;
const SAMPLE_MAX_TIME_MS = 30_000;
export interface KtxMongoDbConnectionConfig {
driver?: string;
url?: string;
database?: string;
databases?: string[];
enabled_tables?: string[];
sample_size?: number;
order_by?: string;
[key: string]: unknown;
}
export interface KtxMongoListedCollection {
name: string;
type?: string;
}
interface KtxMongoFindOptions {
sort: Record<string, 1 | -1>;
limit: number;
projection?: Record<string, 1>;
}
/** Driver-agnostic seam over the `mongodb` client so the connector is unit-testable without a server. */
export interface KtxMongoClient {
listCollections(databaseName: string): Promise<KtxMongoListedCollection[]>;
estimatedDocumentCount(databaseName: string, collectionName: string): Promise<number>;
find(databaseName: string, collectionName: string, options: KtxMongoFindOptions): Promise<KtxMongoDocument[]>;
ping(databaseName: string): Promise<void>;
close(): Promise<void>;
}
export interface KtxMongoClientFactory {
create(url: string): KtxMongoClient;
}
export interface KtxMongoDbScanConnectorOptions {
connectionId: string;
connection: KtxMongoDbConnectionConfig | undefined;
clientFactory?: KtxMongoClientFactory;
env?: NodeJS.ProcessEnv;
now?: () => Date;
}
class DefaultMongoClient implements KtxMongoClient {
private readonly client: MongoClient;
private connected = false;
constructor(url: string) {
this.client = new MongoClient(url);
}
private async connectedClient(): Promise<MongoClient> {
if (!this.connected) {
await this.client.connect();
this.connected = true;
}
return this.client;
}
async listCollections(databaseName: string): Promise<KtxMongoListedCollection[]> {
const client = await this.connectedClient();
const collections = await client.db(databaseName).listCollections({}, { nameOnly: false }).toArray();
return collections.map((collection) => ({ name: collection.name, type: collection.type }));
}
async estimatedDocumentCount(databaseName: string, collectionName: string): Promise<number> {
const client = await this.connectedClient();
return client.db(databaseName).collection(collectionName).estimatedDocumentCount();
}
async find(
databaseName: string,
collectionName: string,
options: KtxMongoFindOptions,
): Promise<KtxMongoDocument[]> {
const client = await this.connectedClient();
return client
.db(databaseName)
.collection(collectionName)
.find({}, { sort: options.sort, limit: options.limit, maxTimeMS: SAMPLE_MAX_TIME_MS, ...(options.projection ? { projection: options.projection } : {}) })
.toArray() as Promise<KtxMongoDocument[]>;
}
async ping(databaseName: string): Promise<void> {
const client = await this.connectedClient();
await client.db(databaseName).command({ ping: 1 });
}
async close(): Promise<void> {
if (this.connected) {
await this.client.close();
this.connected = false;
}
}
}
class DefaultMongoClientFactory implements KtxMongoClientFactory {
create(url: string): KtxMongoClient {
return new DefaultMongoClient(url);
}
}
export function isKtxMongoDbConnectionConfig(
connection: KtxMongoDbConnectionConfig | undefined,
): connection is KtxMongoDbConnectionConfig {
return String(connection?.driver ?? '').toLowerCase() === 'mongodb';
}
function databaseFromUrl(url: string): string | undefined {
try {
const path = new URL(url).pathname.replace(/^\/+/, '');
const database = path.split('/')[0];
return database && database.length > 0 ? decodeURIComponent(database) : undefined;
} catch {
return undefined;
}
}
function configuredDatabases(connection: KtxMongoDbConnectionConfig, fallback: string | undefined): string[] {
if (Array.isArray(connection.databases)) {
const selected = connection.databases
.filter((database): database is string => typeof database === 'string' && database.trim().length > 0)
.map((database) => database.trim());
if (selected.length > 0) {
return [...new Set(selected)];
}
}
const single = typeof connection.database === 'string' && connection.database.trim().length > 0
? connection.database.trim()
: fallback;
return single ? [single] : [];
}
function positiveInteger(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback;
}
function normalizeSampleValue(value: unknown): unknown {
if (value === null || value === undefined) {
return null;
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === 'object') {
const bsontype = (value as { _bsontype?: unknown })._bsontype;
return typeof bsontype === 'string' ? String(value) : JSON.stringify(value);
}
return value;
}
function unionDocumentKeys(documents: readonly KtxMongoDocument[]): string[] {
const keys: string[] = [];
const seen = new Set<string>();
for (const document of documents) {
for (const key of Object.keys(document)) {
if (!seen.has(key)) {
seen.add(key);
keys.push(key);
}
}
}
return keys;
}
export class KtxMongoDbScanConnector implements KtxScanConnector {
readonly id: string;
readonly driver = 'mongodb' as const;
readonly capabilities = createKtxConnectorCapabilities({
tableSampling: true,
columnSampling: true,
columnStats: false,
readOnlySql: false,
nestedAnalysis: true,
formalForeignKeys: false,
estimatedRowCounts: true,
});
private readonly connectionId: string;
private readonly connection: KtxMongoDbConnectionConfig;
private readonly url: string;
private readonly databases: string[];
private readonly sampleSize: number;
private readonly orderBy: string;
private readonly enabledTables: ReadonlySet<string> | null;
private readonly clientFactory: KtxMongoClientFactory;
private readonly now: () => Date;
private readonly dialect = getDialectForDriver('mongodb');
private client: KtxMongoClient | null = null;
constructor(options: KtxMongoDbScanConnectorOptions) {
const connection = options.connection ?? {};
const inputDriver = connection.driver ?? 'unknown';
if (!isKtxMongoDbConnectionConfig(connection)) {
throw new Error(`Native MongoDB connector cannot run driver "${inputDriver}"`);
}
const env = options.env ?? process.env;
const url = resolveKtxConfigReference(
typeof connection.url === 'string' ? connection.url.trim() : undefined,
env,
);
if (!url) {
throw new Error(`Native MongoDB connector requires connections.${options.connectionId}.url`);
}
const databases = configuredDatabases(connection, databaseFromUrl(url));
if (databases.length === 0) {
throw new Error(
`Native MongoDB connector requires connections.${options.connectionId}.databases (or a database in the URL)`,
);
}
const enabledTables = Array.isArray(connection.enabled_tables)
? new Set(
connection.enabled_tables
.filter((table): table is string => typeof table === 'string' && table.trim().length > 0)
.map((table) => table.trim()),
)
: null;
this.connectionId = options.connectionId;
this.connection = connection;
this.url = url;
this.databases = databases;
this.sampleSize = positiveInteger(connection.sample_size, DEFAULT_SAMPLE_SIZE);
this.orderBy = typeof connection.order_by === 'string' && connection.order_by.trim().length > 0
? connection.order_by.trim()
: MONGO_ID_FIELD;
this.enabledTables = enabledTables && enabledTables.size > 0 ? enabledTables : null;
this.clientFactory = options.clientFactory ?? new DefaultMongoClientFactory();
this.now = options.now ?? (() => new Date());
this.id = `mongodb:${options.connectionId}`;
}
async testConnection(): Promise<KtxConnectorTestResult> {
try {
await this.clientForQuery().ping(this.databases[0]!);
return { success: true };
} catch (error) {
return connectorTestFailure(error);
}
}
async introspect(input: KtxScanInput, _ctx: KtxScanContext): Promise<KtxSchemaSnapshot> {
this.assertConnection(input.connectionId);
const client = this.clientForQuery();
const tables: KtxSchemaTable[] = [];
for (const database of this.databases) {
const scopedNames = input.tableScope
? new Set(scopedTableNames(input.tableScope, { catalog: null, db: database }))
: null;
const collections = await client.listCollections(database);
for (const collection of collections) {
if (collection.name.startsWith('system.')) {
continue;
}
if (scopedNames && !scopedNames.has(collection.name)) {
continue;
}
if (this.enabledTables && !this.enabledTables.has(`${database}.${collection.name}`)) {
continue;
}
tables.push(await this.introspectCollection(client, database, collection));
}
}
return {
connectionId: this.connectionId,
driver: 'mongodb',
extractedAt: this.now().toISOString(),
scope: { schemas: this.databases },
metadata: {
databases: this.databases,
sample_size: this.sampleSize,
order_by: this.orderBy,
table_count: tables.length,
total_columns: tables.reduce((sum, table) => sum + table.columns.length, 0),
},
tables,
};
}
private async introspectCollection(
client: KtxMongoClient,
database: string,
collection: KtxMongoListedCollection,
): Promise<KtxSchemaTable> {
const isView = collection.type === 'view';
// estimatedDocumentCount issues a count command, which MongoDB rejects on a
// view (CommandNotSupportedOnView); only count real collections.
const estimatedRows = isView ? null : await client.estimatedDocumentCount(database, collection.name);
const documents = await client.find(database, collection.name, {
sort: { [this.orderBy]: -1 },
limit: this.sampleSize,
});
return {
catalog: null,
db: database,
name: collection.name,
kind: isView ? 'view' : 'table',
comment: null,
estimatedRows,
columns: inferKtxMongoCollectionColumns(documents, this.dialect),
foreignKeys: [],
};
}
async sampleTable(input: KtxTableSampleInput, _ctx: KtxScanContext): Promise<KtxTableSampleResult> {
this.assertConnection(input.connectionId);
const { database, collection } = this.resolveTableRef(input.table);
const documents = await this.clientForQuery().find(database, collection, {
sort: { [this.orderBy]: -1 },
limit: input.limit,
});
const headers = input.columns && input.columns.length > 0 ? input.columns : unionDocumentKeys(documents);
const rows = documents.map((document) => headers.map((header) => normalizeSampleValue(document[header])));
return { headers, rows, totalRows: documents.length };
}
async sampleColumn(input: KtxColumnSampleInput, _ctx: KtxScanContext): Promise<KtxColumnSampleResult> {
this.assertConnection(input.connectionId);
const { database, collection } = this.resolveTableRef(input.table);
const documents = await this.clientForQuery().find(database, collection, {
sort: { [this.orderBy]: -1 },
limit: input.limit,
projection: { [input.column]: 1 },
});
const values: unknown[] = [];
let nullCount = 0;
for (const document of documents) {
const value = document[input.column];
if (value === null || value === undefined) {
nullCount += 1;
continue;
}
values.push(normalizeSampleValue(value));
}
return { values, nullCount, distinctCount: null };
}
async listSchemas(): Promise<string[]> {
return [...this.databases];
}
async listTables(schemas?: string[]): Promise<KtxTableListEntry[]> {
const client = this.clientForQuery();
const databases = schemas && schemas.length > 0 ? schemas : this.databases;
const entries: KtxTableListEntry[] = [];
for (const database of databases) {
const collections = await client.listCollections(database);
for (const collection of collections) {
if (collection.name.startsWith('system.')) {
continue;
}
entries.push({
catalog: null,
schema: database,
name: collection.name,
kind: collection.type === 'view' ? 'view' : 'table',
});
}
}
return entries;
}
async cleanup(): Promise<void> {
if (this.client) {
await this.client.close();
this.client = null;
}
}
private resolveTableRef(table: KtxTableRef): { database: string; collection: string } {
return { database: table.db ?? this.databases[0]!, collection: table.name };
}
private clientForQuery(): KtxMongoClient {
if (!this.client) {
this.client = this.clientFactory.create(this.url);
}
return this.client;
}
private assertConnection(connectionId: string): void {
if (connectionId !== this.connectionId) {
throw new Error(`ktx MongoDB connector ${this.id} cannot serve connection ${connectionId}`);
}
}
}

View file

@ -0,0 +1,64 @@
import type { KtxDialect } from '../../context/connections/dialects.js';
import {
columnDisplayPartCount,
formatDialectDisplayRef,
parseDialectDisplayRef,
} from '../../context/connections/dialect-helpers.js';
import type { KtxDialectTableRef } from '../../context/connections/dialect-helpers.js';
import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/types.js';
/**
* Display/type-mapping half of the dialect contract for MongoDB. Collections map
* to `db.collection` display refs (ansi two-part shape). MongoDB is a non-SQL
* source, so it implements {@link KtxDialect} only never {@link KtxSqlDialect}.
*/
export class KtxMongoDbDialect implements KtxDialect {
readonly type = 'mongodb' as const;
private readonly typeMappings: Record<string, KtxSchemaDimensionType> = {
objectid: 'string',
string: 'string',
uuid: 'string',
binary: 'string',
regex: 'string',
int: 'number',
long: 'number',
double: 'number',
decimal: 'number',
bool: 'boolean',
date: 'time',
timestamp: 'time',
object: 'string',
array: 'string',
json: 'string',
null: 'string',
mixed: 'string',
};
formatDisplayRef(table: KtxDialectTableRef): 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 normalized = nativeType.toLowerCase().trim();
if (normalized === 'object' || normalized === 'array') {
return 'json';
}
return normalized || 'mixed';
}
mapToDimensionType(nativeType: string): KtxSchemaDimensionType {
if (!nativeType) {
return 'string';
}
return this.typeMappings[nativeType.toLowerCase().trim()] ?? 'string';
}
}

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 {
KtxMongoDbScanConnector,
type KtxMongoClientFactory,
type KtxMongoDbConnectionConfig,
} from './connector.js';
interface CreateMongoDbLiveDatabaseIntrospectionOptions {
connections: Record<string, KtxProjectConnectionConfig>;
clientFactory?: KtxMongoClientFactory;
now?: () => Date;
}
export function createMongoDbLiveDatabaseIntrospection(
options: CreateMongoDbLiveDatabaseIntrospectionOptions,
): LiveDatabaseIntrospectionPort {
return {
async extractSchema(connectionId: string, introspectionOptions?: LiveDatabaseIntrospectionOptions) {
const connection = options.connections[connectionId] as KtxMongoDbConnectionConfig | undefined;
const connector = new KtxMongoDbScanConnector({
connectionId,
connection,
clientFactory: options.clientFactory,
now: options.now,
});
try {
return await connector.introspect(
{
connectionId,
driver: 'mongodb',
...(introspectionOptions?.tableScope ? { tableScope: introspectionOptions.tableScope } : {}),
},
{ runId: `mongodb-${connectionId}` },
);
} finally {
await connector.cleanup();
}
},
};
}

View file

@ -0,0 +1,129 @@
import type { KtxSchemaColumn } from '../../context/scan/types.js';
import type { KtxDialect } from '../../context/connections/dialects.js';
export type KtxMongoDocument = Record<string, unknown>;
/** Top-level field name MongoDB guarantees on every document; used as the primary key. */
export const MONGO_ID_FIELD = '_id';
const BSON_TYPE_NAMES: Record<string, string> = {
objectid: 'objectId',
int32: 'int',
long: 'long',
double: 'double',
decimal128: 'decimal',
binary: 'binary',
uuid: 'uuid',
timestamp: 'timestamp',
bsonregexp: 'regex',
bsonsymbol: 'string',
};
/**
* Canonical BSON type name for a sampled value as the `mongodb` driver hydrates
* it: BSON wrapper objects expose `_bsontype`; everything else maps from the JS
* runtime type. Sub-documents and arrays collapse to opaque `object`/`array`.
* @internal
*/
export function bsonTypeOf(value: unknown): string {
if (value === null || value === undefined) {
return 'null';
}
if (typeof value === 'string') {
return 'string';
}
if (typeof value === 'boolean') {
return 'bool';
}
if (typeof value === 'number') {
return Number.isInteger(value) ? 'int' : 'double';
}
if (typeof value === 'bigint') {
return 'long';
}
if (value instanceof Date) {
return 'date';
}
if (Array.isArray(value)) {
return 'array';
}
if (typeof value === 'object') {
const bsontype = (value as { _bsontype?: unknown })._bsontype;
if (typeof bsontype === 'string') {
return BSON_TYPE_NAMES[bsontype.toLowerCase()] ?? bsontype;
}
return 'object';
}
return 'mixed';
}
interface FieldAccumulator {
types: Set<string>;
present: number;
nullSeen: boolean;
}
function resolveNativeType(types: ReadonlySet<string>): string {
if (types.size === 0) {
return 'null';
}
if (types.size > 1) {
return 'mixed';
}
return [...types][0]!;
}
/**
* Infer a flat column schema from sampled documents. Each top-level field becomes
* one column: BSON types are unioned (a field seen with >1 type is `mixed` and
* treated as a string), nullability is derived from field presence and observed
* nulls, and sub-documents/arrays remain a single opaque `json` column. `_id` is
* the non-nullable primary key.
*/
export function inferKtxMongoCollectionColumns(
documents: readonly KtxMongoDocument[],
dialect: KtxDialect,
): KtxSchemaColumn[] {
const total = documents.length;
const order: string[] = [];
const fields = new Map<string, FieldAccumulator>();
for (const document of documents) {
if (!document || typeof document !== 'object') {
continue;
}
for (const [name, value] of Object.entries(document)) {
let accumulator = fields.get(name);
if (!accumulator) {
accumulator = { types: new Set(), present: 0, nullSeen: false };
fields.set(name, accumulator);
order.push(name);
}
accumulator.present += 1;
const bsonType = bsonTypeOf(value);
if (bsonType === 'null') {
accumulator.nullSeen = true;
} else {
accumulator.types.add(bsonType);
}
}
}
return order.map((name) => {
const accumulator = fields.get(name)!;
const nativeType = resolveNativeType(accumulator.types);
const isId = name === MONGO_ID_FIELD;
const nullable = isId
? false
: accumulator.present < total || accumulator.nullSeen || accumulator.types.size === 0;
return {
name,
nativeType,
normalizedType: dialect.mapDataType(nativeType),
dimensionType: dialect.mapToDimensionType(nativeType),
nullable,
primaryKey: isId,
comment: null,
};
});
}

View file

@ -1,8 +1,7 @@
import mysql, { type FieldPacket, type Pool, type RowDataPacket } from 'mysql2/promise';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import { getDialectForDriver } from '../../context/connections/dialects.js';
import { getSqlDialectForDriver } from '../../context/connections/dialects.js';
import { resolveQueryDeadlineMs, queryDeadlineExceededError } from '../../context/connections/query-deadline.js';
import { resolveStringReference } from '../shared/string-reference.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
import {
constraintDiscoveryWarning,
@ -183,19 +182,6 @@ function stringConfigValue(
return typeof value === 'string' && value.trim().length > 0 ? resolveStringReference(value.trim(), env) : undefined;
}
function resolveStringReference(value: string, env: NodeJS.ProcessEnv): string {
if (value.startsWith('env:')) {
const envName = value.slice('env:'.length);
return env[envName] ?? '';
}
if (value.startsWith('file:')) {
const rawPath = value.slice('file:'.length);
const path = rawPath.startsWith('~') ? resolve(homedir(), rawPath.slice(1)) : rawPath;
return readFileSync(path, 'utf-8').trim();
}
return value;
}
function maybeNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
@ -297,6 +283,11 @@ function isDeniedError(error: unknown): boolean {
);
}
// errno 3024 = ER_QUERY_TIMEOUT, raised when max_execution_time is exceeded.
function isMysqlTimeoutError(error: unknown): boolean {
return Boolean(error) && typeof error === 'object' && (error as { errno?: unknown }).errno === 3024;
}
function pushConstraintWarnings(
warnings: KtxScanWarning[],
schemas: readonly string[],
@ -406,7 +397,8 @@ export class KtxMysqlScanConnector implements KtxScanConnector {
private readonly poolFactory: KtxMysqlPoolFactory;
private readonly endpointResolver?: KtxMysqlEndpointResolver;
private readonly now: () => Date;
private readonly dialect = getDialectForDriver('mysql');
private readonly deadlineMs: number;
private readonly dialect = getSqlDialectForDriver('mysql');
private pool: KtxMysqlPool | null = null;
private resolvedEndpoint: KtxMysqlResolvedEndpoint | null = null;
@ -421,6 +413,7 @@ export class KtxMysqlScanConnector implements KtxScanConnector {
this.poolFactory = options.poolFactory ?? new DefaultMysqlPoolFactory();
this.endpointResolver = options.endpointResolver;
this.now = options.now ?? (() => new Date());
this.deadlineMs = resolveQueryDeadlineMs(this.connection);
this.id = `mysql:${options.connectionId}`;
}
@ -778,6 +771,9 @@ export class KtxMysqlScanConnector implements KtxScanConnector {
const pool = await this.poolForQuery();
const connection = await pool.getConnection();
try {
// max_execution_time (ms) bounds read-only SELECTs server-side; our path
// only runs SELECT/WITH, so the session setting always applies.
await connection.query('SET SESSION max_execution_time = ?', [this.deadlineMs]);
const [rows, fields] = await connection.query(assertReadOnlySql(sql), queryParams(params));
const headers = fields.map((field) => field.name);
const headerTypes = fields.map((field) => String(field.type ?? 'unknown'));
@ -787,6 +783,11 @@ export class KtxMysqlScanConnector implements KtxScanConnector {
rows: rows.map((row) => headers.map((header) => row[header])),
totalRows: rows.length,
};
} catch (error) {
if (isMysqlTimeoutError(error)) {
throw queryDeadlineExceededError(this.deadlineMs, { cause: error });
}
throw error;
} finally {
connection.release();
}
@ -794,7 +795,7 @@ export class KtxMysqlScanConnector implements KtxScanConnector {
private assertConnection(connectionId: string): void {
if (connectionId !== this.connectionId) {
throw new Error(`KTX MySQL connector ${this.id} cannot serve connection ${connectionId}`);
throw new Error(`ktx MySQL connector ${this.id} cannot serve connection ${connectionId}`);
}
}
}

View file

@ -1,4 +1,4 @@
import type { KtxDialect } from '../../context/connections/dialects.js';
import type { KtxSqlDialect } from '../../context/connections/dialects.js';
import {
columnDisplayPartCount,
formatDialectDisplayRef,
@ -11,7 +11,7 @@ import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/typ
type MysqlTableNameRef = Pick<KtxTableRef, 'name'> & Partial<Pick<KtxTableRef, 'catalog' | 'db'>>;
/** @internal */
export class KtxMysqlDialect implements KtxDialect {
export class KtxMysqlDialect implements KtxSqlDialect {
readonly type = 'mysql' as const;
private readonly typeMappings: Record<string, KtxSchemaDimensionType> = {

View file

@ -1,7 +1,6 @@
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import { getDialectForDriver } from '../../context/connections/dialects.js';
import { resolveStringReference } from '../shared/string-reference.js';
import { getSqlDialectForDriver } from '../../context/connections/dialects.js';
import { resolveQueryDeadlineMs, queryDeadlineExceededError } from '../../context/connections/query-deadline.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
@ -262,6 +261,11 @@ function isDeniedError(error: unknown): boolean {
return code === '42501' || code === '42P01';
}
// 57014 = query_canceled, which is how statement_timeout surfaces.
function isPostgresTimeoutError(error: unknown): boolean {
return Boolean(error) && typeof error === 'object' && (error as { code?: unknown }).code === '57014';
}
function queryRows(result: KtxPostgresQueryResult): unknown[][] {
const headers = (result.fields ?? []).map((field) => field.name);
return result.rows.map((row) => headers.map((header) => row[header]));
@ -281,17 +285,6 @@ function stringConfigValue(
return typeof value === 'string' && value.trim().length > 0 ? resolveStringReference(value.trim(), env) : undefined;
}
function resolveStringReference(value: string, env: NodeJS.ProcessEnv): string {
if (value.startsWith('env:')) {
return env[value.slice('env:'.length)] ?? '';
}
if (value.startsWith('file:')) {
const rawPath = value.slice('file:'.length);
const path = rawPath.startsWith('~') ? resolve(homedir(), rawPath.slice(1)) : rawPath;
return readFileSync(path, 'utf-8').trim();
}
return value;
}
function numberValue(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
@ -397,9 +390,13 @@ export function postgresPoolConfigFromConfig(input: {
: { host, port: numberValue(merged.port) ?? 5432, database, user, password }),
};
const searchPathSchemas = searchPathSchemasFromConnection(merged);
// statement_timeout (ms) bounds every query on connections from this pool, so
// the server itself aborts a runaway query and frees the connection cleanly.
const serverOptions = [`-c statement_timeout=${resolveQueryDeadlineMs(merged)}`];
if (searchPathSchemas.length > 0) {
config.options = `-c search_path=${searchPathSchemas.join(',')}`;
serverOptions.unshift(`-c search_path=${searchPathSchemas.join(',')}`);
}
config.options = serverOptions.join(' ');
if (merged.ssl && sslmode !== 'prefer' && sslmode !== 'disable') {
config.ssl = { rejectUnauthorized: merged.rejectUnauthorized ?? true };
}
@ -425,7 +422,8 @@ export class KtxPostgresScanConnector implements KtxScanConnector {
private readonly poolFactory: KtxPostgresPoolFactory;
private readonly endpointResolver?: KtxPostgresEndpointResolver;
private readonly now: () => Date;
private readonly dialect = getDialectForDriver('postgres');
private readonly deadlineMs: number;
private readonly dialect = getSqlDialectForDriver('postgres');
private pool: KtxPostgresPool | null = null;
private lastIdlePoolError: Error | null = null;
private resolvedEndpoint: KtxPostgresResolvedEndpoint | null = null;
@ -441,6 +439,7 @@ export class KtxPostgresScanConnector implements KtxScanConnector {
this.poolFactory = options.poolFactory ?? new DefaultPostgresPoolFactory();
this.endpointResolver = options.endpointResolver;
this.now = options.now ?? (() => new Date());
this.deadlineMs = resolveQueryDeadlineMs(this.connection);
this.id = `postgres:${options.connectionId}`;
}
@ -832,6 +831,11 @@ export class KtxPostgresScanConnector implements KtxScanConnector {
totalRows: result.rows.length,
rowCount: result.rows.length,
};
} catch (error) {
if (isPostgresTimeoutError(error)) {
throw queryDeadlineExceededError(this.deadlineMs, { cause: error });
}
throw error;
} finally {
client.release();
}

View file

@ -1,4 +1,4 @@
import type { KtxDialect } from '../../context/connections/dialects.js';
import type { KtxSqlDialect } from '../../context/connections/dialects.js';
import {
columnDisplayPartCount,
formatDialectDisplayRef,
@ -11,7 +11,7 @@ import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/typ
type PostgresTableNameRef = Pick<KtxTableRef, 'name'> & Partial<Pick<KtxTableRef, 'catalog' | 'db'>>;
/** @internal */
export class KtxPostgresDialect implements KtxDialect {
export class KtxPostgresDialect implements KtxSqlDialect {
readonly type = 'postgres' as const;
private readonly typeMappings: Record<string, KtxSchemaDimensionType> = {

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,20 @@
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
/**
* Resolves a config string that may reference an environment variable
* (`env:NAME`) or a file (`file:/path`, `~` expands to the home dir).
* Plain values pass through unchanged.
*/
export function resolveStringReference(value: string, env: NodeJS.ProcessEnv): string {
if (value.startsWith('env:')) {
return env[value.slice('env:'.length)] ?? '';
}
if (value.startsWith('file:')) {
const rawPath = value.slice('file:'.length);
const path = rawPath.startsWith('~') ? resolve(homedir(), rawPath.slice(rawPath[1] === '/' ? 2 : 1)) : rawPath;
return readFileSync(path, 'utf-8').trim();
}
return value;
}

View file

@ -1,8 +1,7 @@
import { createPrivateKey } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import { getDialectForDriver } from '../../context/connections/dialects.js';
import { getSqlDialectForDriver } from '../../context/connections/dialects.js';
import { resolveQueryDeadlineMs, queryDeadlineExceededError } from '../../context/connections/query-deadline.js';
import { resolveStringReference } from '../shared/string-reference.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
@ -62,6 +61,7 @@ export interface KtxSnowflakeResolvedConnectionConfig {
passphrase?: string;
role?: string;
maxConnections: number;
deadlineMs: number;
}
export interface KtxSnowflakeRawColumnMetadata {
@ -107,7 +107,7 @@ export interface KtxSnowflakeScanConnectorOptions {
connectionId: string;
connection: KtxSnowflakeConnectionConfig | undefined;
/**
* KTX project directory. When provided, snowflake-sdk's logger is redirected to
* ktx project directory. When provided, snowflake-sdk's logger is redirected to
* `<projectDir>/.ktx/logs/snowflake.log` so its JSON output does not bleed into
* the CLI's TTY. Tests that use a fake driverFactory can leave this undefined.
*/
@ -135,18 +135,6 @@ export interface KtxSnowflakeColumnDistinctValuesResult {
const DATE_TYPES = ['DATE', 'TIMESTAMP', 'TIMESTAMP_LTZ', 'TIMESTAMP_NTZ', 'TIMESTAMP_TZ', 'TIME'];
function resolveStringReference(value: string, env: NodeJS.ProcessEnv): string {
if (value.startsWith('env:')) {
return env[value.slice('env:'.length)] ?? '';
}
if (value.startsWith('file:')) {
const rawPath = value.slice('file:'.length);
const path = rawPath.startsWith('~') ? resolve(homedir(), rawPath.slice(1)) : rawPath;
return readFileSync(path, 'utf-8').trim();
}
return value;
}
function stringConfigValue(
connection: KtxSnowflakeConnectionConfig | undefined,
key: keyof KtxSnowflakeConnectionConfig,
@ -195,6 +183,22 @@ function isDeniedError(error: unknown): boolean {
return false;
}
// Snowflake cancels with code 604 and a "reached its statement ... timeout"
// message once STATEMENT_TIMEOUT_IN_SECONDS elapses.
function isSnowflakeTimeoutError(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false;
}
const code = (error as { code?: unknown }).code;
const message = (error as { message?: unknown }).message;
return (
code === 604 ||
code === '604' ||
code === '000604' ||
(typeof message === 'string' && /reached its (statement|warehouse) .*timeout/i.test(message))
);
}
function normalizeSnowflakeValue(value: unknown, columnType?: string): unknown {
if (columnType && DATE_TYPES.some((type) => columnType.toUpperCase().includes(type))) {
if (typeof value === 'number') {
@ -296,6 +300,7 @@ export function snowflakeConnectionConfigFromConfig(input: {
connectionId: input.connectionId,
defaultValue: 4,
}),
deadlineMs: resolveQueryDeadlineMs(input.connection),
};
const role = stringConfigValue(input.connection, 'role', env);
if (role) {
@ -353,13 +358,23 @@ class SnowflakeSdkDriver implements KtxSnowflakeDriver {
async query(sql: string, params?: unknown): Promise<KtxQueryResult> {
const binds = Array.isArray(params) ? toSnowflakeBinds(params) : undefined;
const statementTimeoutSeconds = Math.ceil(this.resolved.deadlineMs / 1000);
try {
const pool = await this.getPool();
const result = await pool.use(async (connection: snowflake.Connection) =>
this.executeSnowflakeQuery(connection, sql, binds),
);
const result = await pool.use(async (connection: snowflake.Connection) => {
// Bound the statement server-side; Snowflake cancels and frees the
// warehouse slot when STATEMENT_TIMEOUT_IN_SECONDS is reached.
await this.executeSnowflakeQuery(
connection,
`ALTER SESSION SET STATEMENT_TIMEOUT_IN_SECONDS = ${statementTimeoutSeconds}`,
);
return this.executeSnowflakeQuery(connection, sql, binds);
});
return { ...result, totalRows: result.rows.length, rowCount: result.rows.length };
} catch (error) {
if (isSnowflakeTimeoutError(error)) {
throw queryDeadlineExceededError(this.resolved.deadlineMs, { cause: error });
}
const message = error instanceof Error ? error.message : String(error);
if (/timeout/i.test(message) && /pool|acquire/i.test(message)) {
throw new Error(
@ -561,7 +576,7 @@ export class KtxSnowflakeScanConnector implements KtxScanConnector {
private readonly resolved: KtxSnowflakeResolvedConnectionConfig;
private readonly driverFactory: KtxSnowflakeDriverFactory;
private readonly dialect = getDialectForDriver('snowflake');
private readonly dialect = getSqlDialectForDriver('snowflake');
private readonly now: () => Date;
private driverInstance: KtxSnowflakeDriver | null = null;

View file

@ -1,4 +1,4 @@
import type { KtxDialect } from '../../context/connections/dialects.js';
import type { KtxSqlDialect } from '../../context/connections/dialects.js';
import {
columnDisplayPartCount,
formatDialectDisplayRef,
@ -11,7 +11,7 @@ import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/typ
type SnowflakeTableNameRef = Pick<KtxTableRef, 'name'> & Partial<Pick<KtxTableRef, 'catalog' | 'db'>>;
/** @internal */
export class KtxSnowflakeDialect implements KtxDialect {
export class KtxSnowflakeDialect implements KtxSqlDialect {
readonly type = 'snowflake' as const;
private readonly typeMappings: Record<string, KtxSchemaDimensionType> = {

View file

@ -3,19 +3,44 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { isAbsolute, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getDialectForDriver } from '../../context/connections/dialects.js';
import { fork, type ChildProcess } from 'node:child_process';
import { getSqlDialectForDriver } from '../../context/connections/dialects.js';
import { resolveQueryDeadlineMs, queryDeadlineExceededError } from '../../context/connections/query-deadline.js';
import { assertReadOnlySql, limitSqlForExecution } from '../../context/connections/read-only-sql.js';
import { normalizeQueryRows } from '../../context/connections/query-executor.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 KtxSchemaForeignKey, type KtxSchemaSnapshot, type KtxSchemaTable, type KtxTableListEntry, type KtxTableRef, type KtxTableSampleInput, type KtxTableSampleResult } from '../../context/scan/types.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 KtxScanWarning, 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';
import { tryIntrospectObject } from '../../context/scan/object-introspection.js';
export interface KtxSqliteConnectionConfig {
driver?: string;
path?: string;
url?: string;
query_timeout_ms?: number;
[key: string]: unknown;
}
// In dist, connector.js and read-query-child.js are siblings; under vitest the
// compiled .js is absent and Node strips types from the .ts when forking it.
const readQueryChildUrl = existsSync(fileURLToPath(new URL('./read-query-child.js', import.meta.url)))
? new URL('./read-query-child.js', import.meta.url)
: new URL('./read-query-child.ts', import.meta.url);
/** @internal */
export function forkReadQueryChild(): ChildProcess {
// Empty execArgv so the child is a clean Node process (no inherited vitest /
// inspector flags); advanced serialization preserves BigInt/Buffer in rows.
return fork(readQueryChildUrl, {
execArgv: [],
serialization: 'advanced',
stdio: ['ignore', 'ignore', 'inherit', 'ipc'],
});
}
type ReadQueryChildMessage =
| { ok: true; headers: string[]; rows: unknown[]; totalRows: number }
| { ok: false; message: string };
/** @internal */
export interface SqliteDatabasePathInput {
connectionId: string;
@ -25,6 +50,8 @@ export interface SqliteDatabasePathInput {
export interface KtxSqliteScanConnectorOptions extends SqliteDatabasePathInput {
now?: () => Date;
/** @internal Test seam: spawn the read-query child so tests can observe its lifecycle. */
spawnReadQueryChild?: () => ChildProcess;
}
export interface KtxSqliteReadOnlyQueryInput extends KtxReadOnlyQueryInput {
@ -97,30 +124,6 @@ function sqlitePathFromUrl(url: string): string {
return url;
}
function stripLeadingSqlComments(sql: string): string {
let index = 0;
while (index < sql.length) {
while (/\s/.test(sql[index] ?? '')) {
index += 1;
}
if (sql.startsWith('--', index)) {
const end = sql.indexOf('\n', index + 2);
index = end === -1 ? sql.length : end + 1;
continue;
}
if (sql.startsWith('/*', index)) {
const end = sql.indexOf('*/', index + 2);
if (end === -1) {
return sql.slice(index);
}
index = end + 2;
continue;
}
break;
}
return sql.slice(index);
}
export function isKtxSqliteConnectionConfig(
connection: KtxSqliteConnectionConfig | undefined,
): connection is KtxSqliteConnectionConfig {
@ -157,13 +160,17 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
private readonly connectionId: string;
private readonly dbPath: string;
private readonly now: () => Date;
private readonly dialect = getDialectForDriver('sqlite');
private readonly deadlineMs: number;
private readonly spawnReadQueryChild: () => ChildProcess;
private readonly dialect = getSqlDialectForDriver('sqlite');
private db: Database.Database | null = null;
constructor(options: KtxSqliteScanConnectorOptions) {
this.connectionId = options.connectionId;
this.dbPath = sqliteDatabasePathFromConfig(options);
this.now = options.now ?? (() => new Date());
this.deadlineMs = resolveQueryDeadlineMs(options.connection);
this.spawnReadQueryChild = options.spawnReadQueryChild ?? forkReadQueryChild;
this.id = `sqlite:${options.connectionId}`;
}
@ -182,17 +189,27 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
async introspect(input: KtxScanInput, _ctx: KtxScanContext): Promise<KtxSchemaSnapshot> {
this.assertConnection(input.connectionId);
const database = this.database();
const scopedNames = input.tableScope ? scopedTableNames(input.tableScope, { catalog: null, db: null }) : null;
const scopeClause = scopedNames ? `AND name IN (${scopedNames.map(() => '?').join(', ')})` : '';
const rawTables =
scopedNames && scopedNames.length === 0
? []
: (database
.prepare(
`SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ${scopeClause} ORDER BY name`,
)
.all(...(scopedNames ?? [])) as SqliteMasterRow[]);
const tables = rawTables.map((table) => this.readTable(database, table));
const allObjects = database
.prepare(
`SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name`,
)
.all() as SqliteMasterRow[];
const scopedNames = input.tableScope
? new Set(scopedTableNames(input.tableScope, { catalog: null, db: null }))
: null;
const selectedObjects = scopedNames ? allObjects.filter((object) => scopedNames.has(object.name)) : allObjects;
const tables: KtxSchemaTable[] = [];
const warnings: KtxScanWarning[] = [];
for (const object of selectedObjects) {
const outcome = await tryIntrospectObject({ object: object.name }, () => this.readTable(database, object));
if (outcome.ok) {
tables.push(outcome.table);
} else {
warnings.push(outcome.warning);
}
}
const fileStats = existsSync(this.dbPath) ? statSync(this.dbPath) : null;
return {
connectionId: this.connectionId,
@ -204,8 +221,12 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
file_size: fileStats ? fileStats.size : 0,
table_count: tables.length,
total_columns: tables.reduce((sum, table) => sum + table.columns.length, 0),
// Carries the full object inventory so a zero-match enabled_tables scope
// can report which objects were actually available.
...(scopedNames ? { discovered_object_names: allObjects.map((object) => object.name) } : {}),
},
tables,
...(warnings.length > 0 ? { warnings } : {}),
};
}
@ -253,12 +274,81 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
return null;
}
async executeReadOnly(input: KtxSqliteReadOnlyQueryInput, _ctx: KtxScanContext): Promise<KtxQueryResult> {
async executeReadOnly(input: KtxSqliteReadOnlyQueryInput, ctx: KtxScanContext): Promise<KtxQueryResult> {
this.assertConnection(input.connectionId);
const result = this.query(limitSqlForExecution(stripLeadingSqlComments(input.sql), input.maxRows), input.params);
// Validate and row-limit on the main thread so invalid SQL fails instantly
// without spawning a process and read-only enforcement stays at the boundary.
const sql = limitSqlForExecution(input.sql, input.maxRows);
const result = await this.runReadQueryOffProcess(sql, input.params, ctx.signal);
return { ...result, rowCount: result.rows.length };
}
// The LLM-SQL path runs off the event loop in a short-lived child process so a
// pathological scan cannot freeze the MCP server, and the deadline is enforced
// by SIGKILL-ing that process. A synchronous better-sqlite3 scan never yields,
// so a worker-thread terminate cannot interrupt it — only the OS reclaiming the
// whole process frees the CPU. One short-lived process per call; killed on
// completion, deadline, or external abort.
private runReadQueryOffProcess(
sql: string,
params: Record<string, unknown> | unknown[] | undefined,
signal: AbortSignal | undefined,
): Promise<Omit<KtxQueryResult, 'rowCount'>> {
const deadlineMs = this.deadlineMs;
const dbPath = this.dbPath;
return new Promise((resolvePromise, rejectPromise) => {
const child = this.spawnReadQueryChild();
let settled = false;
const onDeadline = () => settle(() => rejectPromise(queryDeadlineExceededError(deadlineMs)));
const timer = setTimeout(onDeadline, deadlineMs);
function settle(finish: () => void): void {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
signal?.removeEventListener('abort', onDeadline);
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
}
finish();
}
child.on('message', (message: ReadQueryChildMessage) => {
if (message.ok) {
settle(() =>
resolvePromise({
headers: message.headers,
rows: normalizeQueryRows(message.rows),
totalRows: message.totalRows,
}),
);
} else {
settle(() => rejectPromise(new Error(message.message)));
}
});
child.on('error', (error) => settle(() => rejectPromise(error)));
child.on('exit', (code, processSignal) => {
if (!settled) {
settle(() =>
rejectPromise(
new Error(`SQLite read process exited before returning a result (code ${code}, signal ${processSignal}).`),
),
);
}
});
if (signal?.aborted) {
onDeadline();
return;
}
signal?.addEventListener('abort', onDeadline, { once: true });
try {
child.send({ dbPath, sql, params });
} catch (error) {
settle(() => rejectPromise(error instanceof Error ? error : new Error(String(error))));
}
});
}
async getColumnDistinctValues(
table: KtxTableRef,
columnName: string,
@ -334,16 +424,7 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
const foreignKeys = database
.prepare(`PRAGMA foreign_key_list(${this.dialect.quoteIdentifier(table.name)})`)
.all() as SqliteForeignKeyRow[];
const estimatedRows =
table.type === 'table'
? Number(
(
database
.prepare(`SELECT COUNT(*) AS count FROM ${this.dialect.quoteIdentifier(table.name)}`)
.get() as { count: unknown }
).count,
)
: null;
const estimatedRows = table.type === 'table' ? this.readRowCount(database, table.name) : null;
return {
catalog: null,
db: null,
@ -364,6 +445,19 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
};
}
// A row-count read is profiling, not structure: a failure here leaves the
// object's structure intact rather than skipping the whole object.
private readRowCount(database: Database.Database, name: string): number | null {
try {
const row = database.prepare(`SELECT COUNT(*) AS count FROM ${this.dialect.quoteIdentifier(name)}`).get() as {
count: unknown;
};
return Number(row.count);
} catch {
return null;
}
}
private mapForeignKeys(rows: SqliteForeignKeyRow[]): KtxSchemaForeignKey[] {
return rows
.sort((a, b) => a.id - b.id || a.seq - b.seq)
@ -379,7 +473,7 @@ export class KtxSqliteScanConnector implements KtxScanConnector {
private assertConnection(connectionId: string): void {
if (connectionId !== this.connectionId) {
throw new Error(`KTX SQLite connector ${this.id} cannot serve connection ${connectionId}`);
throw new Error(`ktx SQLite connector ${this.id} cannot serve connection ${connectionId}`);
}
}
}

View file

@ -1,4 +1,4 @@
import type { KtxDialect } from '../../context/connections/dialects.js';
import type { KtxSqlDialect } from '../../context/connections/dialects.js';
import {
columnDisplayPartCount,
formatDialectDisplayRef,
@ -11,7 +11,7 @@ import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/typ
type SqliteTableNameRef = Pick<KtxTableRef, 'name'> & Partial<Pick<KtxTableRef, 'catalog' | 'db'>>;
/** @internal */
export class KtxSqliteDialect implements KtxDialect {
export class KtxSqliteDialect implements KtxSqlDialect {
readonly type = 'sqlite' as const;
private readonly typeMappings: Record<string, KtxSchemaDimensionType> = {

View file

@ -0,0 +1,40 @@
import Database from 'better-sqlite3';
// Runs on a forked child process (no bundler, no test transform), so it imports
// only better-sqlite3 and node builtins. The SQL is already read-only-validated
// and row-limited by the parent; this process just executes it and posts the
// structured-cloneable raw rows back over IPC. Its only cancellation mechanism
// is the parent sending SIGKILL: a synchronous better-sqlite3 scan never yields,
// so neither a worker-thread terminate nor any in-process timer can interrupt
// it — only the OS reclaiming the whole process can.
interface ReadQueryRequest {
dbPath: string;
sql: string;
params?: Record<string, unknown> | unknown[];
}
type ReadQueryResponse =
| { ok: true; headers: string[]; rows: unknown[]; totalRows: number }
| { ok: false; message: string };
process.once('message', (request: ReadQueryRequest) => {
let db: Database.Database | undefined;
let response: ReadQueryResponse;
try {
db = new Database(request.dbPath, { readonly: true, fileMustExist: true });
const statement = db.prepare(request.sql);
const rows = (request.params ? statement.all(request.params) : statement.all()) as unknown[];
response = {
ok: true,
headers: statement.columns().map((column) => column.name),
rows,
totalRows: rows.length,
};
} catch (error) {
response = { ok: false, message: error instanceof Error ? error.message : String(error) };
} finally {
db?.close();
}
process.send?.(response, () => process.exit(0));
});

View file

@ -1,5 +1,6 @@
import { assertReadOnlySql } from '../../context/connections/read-only-sql.js';
import { getDialectForDriver } from '../../context/connections/dialects.js';
import { assertReadOnlySql, hoistLeadingCte, stripTrailingSqlNoise } from '../../context/connections/read-only-sql.js';
import { getSqlDialectForDriver } from '../../context/connections/dialects.js';
import { resolveQueryDeadlineMs, queryDeadlineExceededError } from '../../context/connections/query-deadline.js';
import { tryConstraintQuery } from '../../context/scan/constraint-discovery.js';
import { scopedTableNames } from '../../context/scan/table-ref.js';
import {
@ -25,10 +26,8 @@ import {
type KtxTableSampleInput,
type KtxTableSampleResult,
} from '../../context/scan/types.js';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import sql from 'mssql';
import { resolveStringReference } from '../shared/string-reference.js';
export interface KtxSqlServerConnectionConfig {
driver?: string;
@ -52,6 +51,8 @@ export interface KtxSqlServerPoolConfig {
database: string;
user: string;
password?: string;
// ms; on expiry mssql sends a TDS attention that cancels the query server-side.
requestTimeout: number;
options: { encrypt: true; trustServerCertificate: boolean };
pool: { max: number; min: number; idleTimeoutMillis: number };
}
@ -208,18 +209,6 @@ function stringConfigValue(
return typeof value === 'string' && value.trim().length > 0 ? resolveStringReference(value.trim(), env) : undefined;
}
function resolveStringReference(value: string, env: NodeJS.ProcessEnv): string {
if (value.startsWith('env:')) {
return env[value.slice('env:'.length)] ?? '';
}
if (value.startsWith('file:')) {
const rawPath = value.slice('file:'.length);
const path = rawPath.startsWith('~') ? resolve(homedir(), rawPath.slice(1)) : rawPath;
return readFileSync(path, 'utf-8').trim();
}
return value;
}
function parseSqlServerUrl(url: string): Partial<KtxSqlServerConnectionConfig> {
const parsed = new URL(url);
return {
@ -283,15 +272,21 @@ function isDeniedError(error: unknown): boolean {
return number === 229 || number === 230 || number === 297;
}
// mssql raises a RequestError with code 'ETIMEOUT' once requestTimeout elapses.
function isSqlServerTimeoutError(error: unknown): boolean {
return Boolean(error) && typeof error === 'object' && (error as { code?: unknown }).code === 'ETIMEOUT';
}
function limitSqlForSqlServerExecution(sqlText: string, maxRows: number | undefined): string {
const trimmed = assertReadOnlySql(sqlText).replace(/;+\s*$/, '');
const trimmed = stripTrailingSqlNoise(assertReadOnlySql(sqlText));
if (!maxRows) {
return trimmed;
}
if (!Number.isInteger(maxRows) || maxRows <= 0) {
throw new Error('maxRows must be a positive integer.');
}
return `SELECT TOP ${maxRows} * FROM (${trimmed}) AS ktx_query_result`;
const { withPrefix, body } = hoistLeadingCte(trimmed);
return `${withPrefix}SELECT TOP ${maxRows} * FROM (${body}) AS ktx_query_result`;
}
export function isKtxSqlServerConnectionConfig(
@ -341,6 +336,7 @@ export function sqlServerConnectionPoolConfigFromConfig(input: {
database,
user,
password: stringConfigValue(merged, 'password', env),
requestTimeout: resolveQueryDeadlineMs(merged),
options: { encrypt: true, trustServerCertificate: merged.trustServerCertificate ?? true },
pool: { max: maxConnections, min: 0, idleTimeoutMillis: 30000 },
};
@ -366,7 +362,8 @@ export class KtxSqlServerScanConnector implements KtxScanConnector {
private readonly poolFactory: KtxSqlServerPoolFactory;
private readonly endpointResolver?: KtxSqlServerEndpointResolver;
private readonly now: () => Date;
private readonly dialect = getDialectForDriver('sqlserver');
private readonly deadlineMs: number;
private readonly dialect = getSqlDialectForDriver('sqlserver');
private pool: KtxSqlServerPool | null = null;
private resolvedEndpoint: KtxSqlServerResolvedEndpoint | null = null;
@ -383,6 +380,7 @@ export class KtxSqlServerScanConnector implements KtxScanConnector {
this.poolFactory = options.poolFactory ?? new DefaultSqlServerPoolFactory();
this.endpointResolver = options.endpointResolver;
this.now = options.now ?? (() => new Date());
this.deadlineMs = resolveQueryDeadlineMs(this.connection);
this.id = `sqlserver:${options.connectionId}`;
}
@ -817,7 +815,15 @@ export class KtxSqlServerScanConnector implements KtxScanConnector {
request.input(key, value);
}
}
const result = await request.query(assertReadOnlySql(query));
let result: KtxSqlServerQueryResult;
try {
result = await request.query(assertReadOnlySql(query));
} catch (error) {
if (isSqlServerTimeoutError(error)) {
throw queryDeadlineExceededError(this.deadlineMs, { cause: error });
}
throw error;
}
const recordset = result.recordset ?? [];
const columnMetadata = recordset.columns ?? {};
const metadataHeaders = Object.keys(columnMetadata);
@ -833,7 +839,7 @@ export class KtxSqlServerScanConnector implements KtxScanConnector {
private assertConnection(connectionId: string): void {
if (connectionId !== this.connectionId) {
throw new Error(`KTX SQL Server connector ${this.id} cannot serve connection ${connectionId}`);
throw new Error(`ktx SQL Server connector ${this.id} cannot serve connection ${connectionId}`);
}
}
}

View file

@ -1,4 +1,4 @@
import type { KtxDialect } from '../../context/connections/dialects.js';
import type { KtxSqlDialect } from '../../context/connections/dialects.js';
import {
columnDisplayPartCount,
formatDialectDisplayRef,
@ -11,7 +11,7 @@ import type { KtxSchemaDimensionType, KtxTableRef } from '../../context/scan/typ
type SqlServerTableNameRef = Pick<KtxTableRef, 'name'> & Partial<Pick<KtxTableRef, 'catalog' | 'db'>>;
/** @internal */
export class KtxSqlServerDialect implements KtxDialect {
export class KtxSqlServerDialect implements KtxSqlDialect {
readonly type = 'sqlserver' as const;
private readonly typeMappings: Record<string, KtxSchemaDimensionType> = {

View file

@ -98,6 +98,7 @@ export interface ContextBuildArgs {
queryHistory?: Extract<KtxPublicIngestArgs, { command: 'run' }>['queryHistory'];
queryHistoryWindowDays?: number;
scanMode?: Extract<KtxPublicIngestArgs, { command: 'run' }>['scanMode'];
stages?: Extract<KtxPublicIngestArgs, { command: 'run' }>['stages'];
detectRelationships?: boolean;
cliVersion?: string;
runtimeInstallPolicy?: KtxManagedPythonInstallPolicy;
@ -415,7 +416,7 @@ export function renderContextBuildView(
const hasActive = allTargets.some((t) => t.status === 'running' || t.status === 'queued');
const allDone = totalCount > 0 && !hasActive;
const headerParts = [options.title ?? 'Building KTX context'];
const headerParts = [options.title ?? 'Building ktx context'];
if (totalCount > 0) {
const progressParts: string[] = [`${doneCount}/${totalCount}`];
if (state.totalElapsedMs > 0) progressParts.push(formatDuration(state.totalElapsedMs));
@ -738,7 +739,7 @@ function failedStepDetail(result: KtxPublicIngestTargetResult): string | null {
const INTERNAL_FAILURE_LINE_RE =
/^(Report|Run|Job|Status|Adapter|Connection|Sync|Mode|Dry run|Diff|Tasks|Work units|Failed tasks|Saved memory|Provenance rows):\s*/;
const ACTIONABLE_FAILURE_LINE_RE =
/^(Missing bundled Python runtime manifest|KTX Python runtime is required|KTX daemon HTTP|Error:|Failed\b|Could not\b|Cannot\b)/;
/^(Missing bundled Python runtime manifest|ktx Python runtime is required|ktx daemon HTTP|Error:|Failed\b|Could not\b|Cannot\b)/;
function trimErrorPrefix(line: string): string {
return line.replace(/^Error:\s*/, '');
@ -749,7 +750,7 @@ function firstCapturedFailureLine(output: string | undefined): string | null {
.split(/\r?\n/)
.map((candidate) => candidate.trim())
.filter((candidate) => candidate.length > 0)
.filter((candidate) => !candidate.startsWith('KTX scan completed'))
.filter((candidate) => !candidate.startsWith('ktx scan completed'))
.filter((candidate) => !INTERNAL_FAILURE_LINE_RE.test(candidate));
const line = lines.find((candidate) => ACTIONABLE_FAILURE_LINE_RE.test(candidate)) ?? lines.at(-1) ?? null;
return line ? trimErrorPrefix(line) : null;
@ -789,7 +790,7 @@ function failureTextForTarget(input: {
const code = networkErrorCode(input.error, input.capturedOutput);
if (code && isLocalSqlAnalysisConnectionRefused({ capturedOutput: input.capturedOutput, fallback: input.fallback })) {
return [
`KTX could not reach the local SQL analysis runtime while processing query history for ${input.target.connectionId}.`,
`ktx could not reach the local SQL analysis runtime while processing query history for ${input.target.connectionId}.`,
`Reason: ${NETWORK_ERROR_REASONS[code]} (${code}).`,
`Retry: ${retryCommand({
projectDir: input.projectDir,
@ -803,7 +804,7 @@ function failureTextForTarget(input: {
if (code) {
const operation = input.target.operation === 'database-ingest' ? 'reading schema for' : 'ingesting';
return [
`KTX lost its connection to ${friendlyDriverName(input.target.driver)} while ${operation} ${input.target.connectionId}.`,
`ktx lost its connection to ${friendlyDriverName(input.target.driver)} while ${operation} ${input.target.connectionId}.`,
`Reason: ${NETWORK_ERROR_REASONS[code]} (${code}).`,
`Retry: ${retryCommand({
projectDir: input.projectDir,
@ -990,6 +991,7 @@ export async function runContextBuild(
...(args.queryHistory ? { queryHistory: args.queryHistory } : {}),
...(args.queryHistoryWindowDays !== undefined ? { queryHistoryWindowDays: args.queryHistoryWindowDays } : {}),
...(args.scanMode ? { scanMode: args.scanMode } : {}),
...(args.stages ? { stages: args.stages } : {}),
...(args.detectRelationships !== undefined ? { detectRelationships: args.detectRelationships } : {}),
...(args.cliVersion ? { cliVersion: args.cliVersion } : {}),
...(args.runtimeInstallPolicy ? { runtimeInstallPolicy: args.runtimeInstallPolicy } : {}),

View file

@ -0,0 +1,64 @@
import { createHash } from 'node:crypto';
type ContentCacheMetadata = Record<string, unknown>;
export interface ContentResultCacheLookup {
namespace: string;
scopeKey: string;
inputHash: string;
}
export interface ContentResultCacheCompleted<TOutput = unknown> extends ContentResultCacheLookup {
runId: string;
status: 'completed';
output: TOutput;
errorMessage: null;
metadata: ContentCacheMetadata;
updatedAt: string;
}
export interface ContentResultCacheFailed extends ContentResultCacheLookup {
runId: string;
status: 'failed';
output: null;
errorMessage: string;
metadata: ContentCacheMetadata;
updatedAt: string;
}
export type ContentResultCacheRecord<TOutput = unknown> =
| ContentResultCacheCompleted<TOutput>
| ContentResultCacheFailed;
export interface ContentResultCache {
findCompletedResult<TOutput = unknown>(
input: ContentResultCacheLookup,
): Promise<ContentResultCacheCompleted<TOutput> | null>;
findLatestCompletedResult(input: {
namespace: string;
scopeKey: string;
}): Promise<ContentResultCacheCompleted | null>;
saveCompletedResult<TOutput = unknown>(
input: Omit<ContentResultCacheCompleted<TOutput>, 'status' | 'errorMessage'>,
): Promise<void>;
saveFailedResult(input: Omit<ContentResultCacheFailed, 'status' | 'output'>): Promise<void>;
deleteResult(input: ContentResultCacheLookup): Promise<void>;
listRunResults(runId: string): Promise<ContentResultCacheRecord[]>;
}
function stableJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(stableJson).join(',')}]`;
}
if (value && typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>).sort(([left], [right]) =>
left.localeCompare(right),
);
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(',')}}`;
}
return JSON.stringify(value) ?? 'undefined';
}
export function stableContentHash(value: unknown): string {
return createHash('sha256').update(stableJson(value)).digest('hex');
}

View file

@ -0,0 +1,281 @@
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import Database from 'better-sqlite3';
import type {
ContentResultCache,
ContentResultCacheCompleted,
ContentResultCacheFailed,
ContentResultCacheLookup,
ContentResultCacheRecord,
} from './content-result-cache.js';
export interface SqliteContentResultCacheOptions {
dbPath: string;
}
interface ResultRow {
run_id: string;
namespace: string;
scope_key: string;
input_hash: string;
status: 'completed' | 'failed';
output_json: string | null;
error_message: string | null;
metadata_json: string;
updated_at: string;
}
const RESULTS_TABLE = 'local_content_results';
const RESULTS_PRIMARY_KEY = ['namespace', 'scope_key', 'input_hash'] as const;
function isSafeRunId(runId: string): boolean {
return /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(runId);
}
function parseResultRow<TOutput = unknown>(row: ResultRow): ContentResultCacheRecord<TOutput> {
const base = {
runId: row.run_id,
namespace: row.namespace,
scopeKey: row.scope_key,
inputHash: row.input_hash,
metadata: JSON.parse(row.metadata_json || '{}') as Record<string, unknown>,
updatedAt: row.updated_at,
};
if (row.status === 'completed') {
return {
...base,
status: 'completed',
output: JSON.parse(row.output_json ?? 'null') as TOutput,
errorMessage: null,
};
}
return {
...base,
status: 'failed',
output: null,
errorMessage: row.error_message ?? 'Unknown content result failure',
};
}
export class SqliteContentResultCache implements ContentResultCache {
private readonly db: Database.Database;
constructor(options: SqliteContentResultCacheOptions) {
mkdirSync(dirname(options.dbPath), { recursive: true });
this.db = new Database(options.dbPath);
this.db.pragma('journal_mode = WAL');
this.db.exec('DROP TABLE IF EXISTS local_scan_enrichment_stages');
this.dropResultsTableIfPrimaryKeyDiffers();
this.db.exec(`
CREATE TABLE IF NOT EXISTS local_content_results (
run_id TEXT NOT NULL,
namespace TEXT NOT NULL,
scope_key TEXT NOT NULL,
input_hash TEXT NOT NULL,
status TEXT NOT NULL,
output_json TEXT,
error_message TEXT,
metadata_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (namespace, scope_key, input_hash)
);
CREATE INDEX IF NOT EXISTS local_content_results_lookup_idx
ON local_content_results (namespace, scope_key, input_hash, updated_at);
CREATE INDEX IF NOT EXISTS local_content_results_run_idx
ON local_content_results (run_id, updated_at, namespace);
`);
}
private dropResultsTableIfPrimaryKeyDiffers(): void {
const columns = this.db.prepare(`PRAGMA table_info(${RESULTS_TABLE})`).all() as Array<{
name: string;
pk: number;
}>;
if (columns.length === 0) {
return;
}
const primaryKey = columns
.filter((column) => column.pk > 0)
.sort((left, right) => left.pk - right.pk)
.map((column) => column.name);
const matches =
primaryKey.length === RESULTS_PRIMARY_KEY.length &&
primaryKey.every((name, index) => name === RESULTS_PRIMARY_KEY[index]);
if (!matches) {
this.db.exec(`DROP TABLE ${RESULTS_TABLE}`);
}
}
async findCompletedResult<TOutput = unknown>(
input: ContentResultCacheLookup,
): Promise<ContentResultCacheCompleted<TOutput> | null> {
const row = this.db
.prepare(
`
SELECT *
FROM local_content_results
WHERE namespace = ?
AND scope_key = ?
AND input_hash = ?
AND status = 'completed'
ORDER BY updated_at DESC
LIMIT 1
`,
)
.get(input.namespace, input.scopeKey, input.inputHash) as ResultRow | undefined;
if (!row) {
return null;
}
const parsed = parseResultRow<TOutput>(row);
return parsed.status === 'completed' ? parsed : null;
}
async findLatestCompletedResult(input: {
namespace: string;
scopeKey: string;
}): Promise<ContentResultCacheCompleted | null> {
const row = this.db
.prepare(
`
SELECT *
FROM local_content_results
WHERE namespace = ?
AND scope_key = ?
AND status = 'completed'
ORDER BY updated_at DESC
LIMIT 1
`,
)
.get(input.namespace, input.scopeKey) as ResultRow | undefined;
if (!row) {
return null;
}
const parsed = parseResultRow(row);
return parsed.status === 'completed' ? parsed : null;
}
async saveCompletedResult<TOutput = unknown>(
input: Omit<ContentResultCacheCompleted<TOutput>, 'status' | 'errorMessage'>,
): Promise<void> {
this.db
.prepare(
`
INSERT INTO local_content_results (
run_id,
namespace,
scope_key,
input_hash,
status,
output_json,
error_message,
metadata_json,
updated_at
)
VALUES (
@runId,
@namespace,
@scopeKey,
@inputHash,
'completed',
@outputJson,
NULL,
@metadataJson,
@updatedAt
)
ON CONFLICT(namespace, scope_key, input_hash) DO UPDATE SET
run_id = excluded.run_id,
status = excluded.status,
output_json = excluded.output_json,
error_message = excluded.error_message,
metadata_json = excluded.metadata_json,
updated_at = excluded.updated_at
`,
)
.run({
runId: input.runId,
namespace: input.namespace,
scopeKey: input.scopeKey,
inputHash: input.inputHash,
outputJson: JSON.stringify(input.output),
metadataJson: JSON.stringify(input.metadata),
updatedAt: input.updatedAt,
});
}
async saveFailedResult(input: Omit<ContentResultCacheFailed, 'status' | 'output'>): Promise<void> {
this.db
.prepare(
`
INSERT INTO local_content_results (
run_id,
namespace,
scope_key,
input_hash,
status,
output_json,
error_message,
metadata_json,
updated_at
)
VALUES (
@runId,
@namespace,
@scopeKey,
@inputHash,
'failed',
NULL,
@errorMessage,
@metadataJson,
@updatedAt
)
ON CONFLICT(namespace, scope_key, input_hash) DO UPDATE SET
run_id = excluded.run_id,
status = excluded.status,
output_json = excluded.output_json,
error_message = excluded.error_message,
metadata_json = excluded.metadata_json,
updated_at = excluded.updated_at
`,
)
.run({
runId: input.runId,
namespace: input.namespace,
scopeKey: input.scopeKey,
inputHash: input.inputHash,
errorMessage: input.errorMessage,
metadataJson: JSON.stringify(input.metadata),
updatedAt: input.updatedAt,
});
}
async deleteResult(input: ContentResultCacheLookup): Promise<void> {
this.db
.prepare(
`
DELETE FROM local_content_results
WHERE namespace = ?
AND scope_key = ?
AND input_hash = ?
`,
)
.run(input.namespace, input.scopeKey, input.inputHash);
}
async listRunResults(runId: string): Promise<ContentResultCacheRecord[]> {
if (!isSafeRunId(runId)) {
return [];
}
const rows = this.db
.prepare(
`
SELECT *
FROM local_content_results
WHERE run_id = ?
ORDER BY updated_at ASC, namespace ASC
`,
)
.all(runId) as ResultRow[];
return rows.map((row) => parseResultRow(row));
}
}

View file

@ -1,4 +1,5 @@
const BIGQUERY_PROJECT_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
const BIGQUERY_DATASET_ID_PATTERN = /^[A-Za-z0-9_]+$/;
const BIGQUERY_REGION_PATTERN = /^[a-z0-9-]+$/;
export function normalizeBigQueryProjectId(value: string, context = 'historic-SQL ingest'): string {
@ -8,6 +9,13 @@ export function normalizeBigQueryProjectId(value: string, context = 'historic-SQ
return value;
}
export function normalizeBigQueryDatasetId(value: string, context = 'historic-SQL ingest'): string {
if (!BIGQUERY_DATASET_ID_PATTERN.test(value)) {
throw new Error(`Invalid BigQuery dataset id for ${context}: ${value}`);
}
return value;
}
export function normalizeBigQueryRegion(value: string, context = 'historic-SQL ingest'): string {
const normalized = value.trim().toLowerCase().replace(/^region-/, '');
if (!BIGQUERY_REGION_PATTERN.test(normalized)) {

View file

@ -0,0 +1,24 @@
import type { KtxProjectConnectionConfig } from '../project/config.js';
function listConfiguredConnectionIds(connections: Record<string, KtxProjectConnectionConfig>): string[] {
return Object.keys(connections).sort();
}
/**
* Validate a connection id supplied as an explicit command/tool argument against
* the canonical `ktx.yaml` connections map. Returns the id when configured;
* otherwise throws an error that lists the configured ids so the caller can fix
* the typo. Use for explicit arguments only persisted page frontmatter that
* references a since-removed connection must warn, not fail.
*/
export function assertConfiguredConnectionId(
connections: Record<string, KtxProjectConnectionConfig>,
connectionId: string,
): string {
if (Object.hasOwn(connections, connectionId)) {
return connectionId;
}
const ids = listConfiguredConnectionIds(connections);
const configured = ids.length > 0 ? ids.join(', ') : '(none configured)';
throw new Error(`Unknown connection "${connectionId}". Configured connections: ${configured}.`);
}

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,20 +1,40 @@
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';
import { KtxSqliteDialect } from '../../connectors/sqlite/dialect.js';
import { KtxSnowflakeDialect } from '../../connectors/snowflake/dialect.js';
import { KtxSqlServerDialect } from '../../connectors/sqlserver/dialect.js';
import { KtxExpectedError } from '../../errors.js';
import type { KtxConnectionDriver, KtxSchemaDimensionType, KtxTableRef } from '../scan/types.js';
import type { KtxDialectTableRef } from './dialect-helpers.js';
/**
* Driver-agnostic dialect surface every connection implements, including
* non-SQL sources like MongoDB: display/ref formatting and type mapping. The
* catalog and entity-details paths resolve this for any snapshot driver, so it
* must stay free of SQL generation.
*/
export interface KtxDialect {
readonly type: KtxConnectionDriver;
quoteIdentifier(identifier: string): string;
formatTableName(table: KtxDialectTableRef): string;
formatDisplayRef(table: KtxDialectTableRef): string;
parseDisplayRef(display: string): KtxTableRef | null;
columnDisplayTablePartCount(): 1 | 2 | 3;
mapToDimensionType(nativeType: string): KtxSchemaDimensionType;
mapDataType(nativeType: string): string;
}
/**
* SQL query generation, implemented only by SQL warehouse drivers. The relationship
* profiling/validation pipeline is the sole caller and is gated on the
* `readOnlySql` capability, so these methods are unreachable for a non-SQL source.
*/
export interface KtxSqlDialect extends KtxDialect {
quoteIdentifier(identifier: string): string;
formatTableName(table: KtxDialectTableRef): string;
getLimitOffsetClause(limit: number, offset?: number): string;
getTopClause(limit: number): string;
getRandomSampleFilter(samplePct: number): string;
@ -30,23 +50,15 @@ export interface KtxDialect {
getDistinctCountExpression(column: string): string;
textLengthExpression(columnSql: string): string;
castToText(columnSql: string): string;
mapToDimensionType(nativeType: string): KtxSchemaDimensionType;
mapDataType(nativeType: string): string;
}
const supportedDrivers: KtxConnectionDriver[] = [
'bigquery',
'clickhouse',
'mysql',
'postgres',
'sqlite',
'snowflake',
'sqlserver',
];
type KtxSqlDriver = Exclude<KtxConnectionDriver, 'mongodb'>;
const dialectFactories: Record<KtxConnectionDriver, () => KtxDialect> = {
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(),
@ -54,11 +66,54 @@ const dialectFactories: Record<KtxConnectionDriver, () => KtxDialect> = {
sqlserver: () => new KtxSqlServerDialect(),
};
const dialectFactories: Record<KtxConnectionDriver, () => KtxDialect> = {
...sqlDialectFactories,
mongodb: () => new KtxMongoDbDialect(),
};
const supportedSqlDrivers = Object.keys(sqlDialectFactories).sort();
export function getDialectForDriver(driver: string): KtxDialect {
const normalized = driver.toLowerCase().trim();
const factory = dialectFactories[normalized as KtxConnectionDriver];
if (factory) {
return factory();
}
throw new Error(`Unsupported warehouse driver "${driver}". Supported drivers: ${supportedDrivers.join(', ')}`);
throw new Error(
`Unsupported driver "${driver}". Supported drivers: ${Object.keys(dialectFactories).sort().join(', ')}`,
);
}
export function getSqlDialectForDriver(driver: string): KtxSqlDialect {
const normalized = driver.toLowerCase().trim();
const factory = sqlDialectFactories[normalized as KtxSqlDriver];
if (factory) {
return factory();
}
throw new Error(`Driver "${driver}" has no SQL dialect. SQL drivers: ${supportedSqlDrivers.join(', ')}`);
}
/**
* Whether a driver can generate and execute SQL. Single source of truth for the
* SQL/non-SQL boundary: a driver is SQL-queryable iff it has a SQL dialect, so
* non-SQL sources (e.g. mongodb) are excluded without a hand-maintained list.
*/
export function isSqlQueryableDriver(driver: string | undefined): boolean {
const normalized = (driver ?? '').toLowerCase().trim();
return Object.prototype.hasOwnProperty.call(sqlDialectFactories, normalized);
}
/**
* Refuse a non-SQL connection (e.g. mongodb) at a read-only-SQL entry point before
* any dialect selection or parser/daemon work, so it is never validated as Postgres.
* The federated `duckdb` connection has no driver callers skip this guard for it.
*/
export function assertSqlQueryableConnection(connectionId: string, driver: string | undefined): void {
if (!isSqlQueryableDriver(driver)) {
throw new KtxExpectedError(
`Connection '${connectionId}' uses the non-SQL driver '${driver ?? 'unknown'}'. ` +
'Read-only SQL (ktx sql, the sql_execution tool) requires a SQL warehouse connection; ' +
'MongoDB and other context-only sources are searchable and ingestable, not SQL-queryable.',
);
}
}

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,48 @@ 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',
hasHistoricSqlReader: false,
load: async () => {
const m = await import('../../connectors/mongodb/connector.js');
return {
isConnectionConfig: (connection) => {
const typedConnection = connection as Parameters<typeof m.isKtxMongoDbConnectionConfig>[0];
return m.isKtxMongoDbConnectionConfig(typedConnection);
},
createScanConnector: ({ connectionId, connection }) => {
const typedConnection = connection as Parameters<typeof m.isKtxMongoDbConnectionConfig>[0];
if (!m.isKtxMongoDbConnectionConfig(typedConnection)) {
throw invalidConnectionConfig('mongodb');
}
return new m.KtxMongoDbScanConnector({ connectionId, connection: typedConnection });
},
};
},
},
mysql: {
driver: 'mysql',
scopeConfigKey: 'schemas',

View file

@ -0,0 +1,84 @@
import type { KtxProjectConnectionConfig } from '../project/config.js';
/** Stable id for the runtime-derived federated connection. Never written to ktx.yaml. */
export const FEDERATED_CONNECTION_ID = '_ktx_federated';
/**
* 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', 'duckdb']);
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 === 'duckdb' ? null : normalized;
}
export interface FederatedMember {
connectionId: string;
driver: string;
projectDir: string;
connection: KtxProjectConnectionConfig;
}
export interface FederatedConnectionDescriptor {
id: typeof FEDERATED_CONNECTION_ID;
driver: 'duckdb';
members: FederatedMember[];
}
/**
* Derives a virtual federated connection when a project declares 2+
* attach-compatible databases. Returns null otherwise single-DB and
* incompatible projects are unaffected.
*/
export function deriveFederatedConnection(
connections: Record<string, KtxProjectConnectionConfig>,
projectDir: string,
): FederatedConnectionDescriptor | null {
const members: FederatedMember[] = Object.entries(connections)
.filter(([, config]) => ATTACH_COMPATIBLE_DRIVERS.has(config.driver.toLowerCase()))
.map(([connectionId, config]) => ({
connectionId,
driver: config.driver.toLowerCase(),
projectDir,
connection: config,
}));
if (members.length < 2) {
return null;
}
return { id: FEDERATED_CONNECTION_ID, driver: 'duckdb', members };
}
export interface FederatedConnectionListing {
id: typeof FEDERATED_CONNECTION_ID;
driver: 'duckdb';
members: string[];
hint: string;
}
/**
* Listing-facing view of the virtual federated connection for `ktx connection`
* and MCP `connection_list`. Derived from the same declared state as
* deriveFederatedConnection, so both surfaces describe one connection.
*/
export function federatedConnectionListing(
connections: Record<string, KtxProjectConnectionConfig>,
projectDir: string,
): FederatedConnectionListing | null {
const descriptor = deriveFederatedConnection(connections, projectDir);
if (!descriptor) {
return null;
}
return {
id: FEDERATED_CONNECTION_ID,
driver: 'duckdb',
members: descriptor.members.map((member) => member.connectionId),
hint: 'Cross-database queries run here. Name tables connectionId.schema.table (or connectionId.table for sqlite); double-quote any id that is not a bare SQL identifier, e.g. "books-db".public.books.',
};
}

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