mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-25 12:01:03 +02:00
Compare commits
74 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49a4ae6f5b | ||
|
|
a6dd8cf730 | ||
|
|
4ebce75449 | ||
|
|
a0d19ba26f | ||
|
|
5d17469601 | ||
|
|
a651b82e2f | ||
|
|
66768fe009 | ||
|
|
f310391da5 | ||
|
|
fe7e6bd1fa | ||
|
|
6d01030745 | ||
|
|
3c4fcc27c7 | ||
|
|
f21594c42a | ||
|
|
acd20ac248 | ||
|
|
139ac08320 | ||
|
|
1ec29e82f7 | ||
|
|
1c5d16abc3 | ||
|
|
67a69dba8b | ||
|
|
46df7f3b24 | ||
|
|
f65a5b0e2e | ||
|
|
2afab61417 | ||
|
|
4f084186f1 | ||
|
|
50afcae9f4 | ||
|
|
b0dca62c0e | ||
|
|
ca231df5fe | ||
|
|
5645dc4d28 | ||
|
|
967a413a06 | ||
|
|
89f25435d5 | ||
|
|
73e4c8b270 | ||
|
|
77c38e9ea2 | ||
|
|
f61ea76007 | ||
|
|
a155c0b844 | ||
|
|
2830cb5ac7 | ||
|
|
c815e10fb3 | ||
|
|
9f715f93f1 | ||
|
|
144943ec1d | ||
|
|
e550091a76 | ||
|
|
1f16a89c94 | ||
|
|
d62dc46a86 | ||
|
|
fb50c11d16 | ||
|
|
4dae8c34dd | ||
|
|
01ccc73e40 | ||
|
|
e8bfb3d301 | ||
|
|
fde9f9862d | ||
|
|
6c815ef529 | ||
|
|
b81391cd9f | ||
|
|
7e29543398 | ||
|
|
8a50601582 | ||
|
|
9587049283 | ||
|
|
e4e7b40c23 | ||
|
|
e817736b91 | ||
|
|
674b58b3ed | ||
|
|
4e61020089 | ||
|
|
ed44f46f2a | ||
|
|
cf2871ec8b | ||
|
|
feb0818444 | ||
|
|
663eaff940 | ||
|
|
e1067bf734 | ||
|
|
0689d709d2 | ||
|
|
a278d2f7d0 | ||
|
|
00cdf2de90 | ||
|
|
005c5fc860 | ||
|
|
7c3b4cea2c | ||
|
|
28953eb616 | ||
|
|
56e06334d2 | ||
|
|
058051f1b9 | ||
|
|
2877b85adc | ||
|
|
f3f893bf01 | ||
|
|
853f39a7c3 | ||
|
|
036a745fc1 | ||
|
|
b076431b0a | ||
|
|
fd18caa26a | ||
|
|
65de75ebd7 | ||
|
|
0425160857 | ||
|
|
9ff0e86bb8 |
607 changed files with 37604 additions and 5781 deletions
72
.github/workflows/star-history.yml
vendored
72
.github/workflows/star-history.yml
vendored
|
|
@ -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
|
||||
29
AGENTS.md
29
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
157
docs-site/components/theme-toggle.tsx
Normal file
157
docs-site/components/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
161
docs-site/content/docs/concepts/cross-database-federation.mdx
Normal file
161
docs-site/content/docs/concepts/cross-database-federation.mdx
Normal 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.
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|------|---------|
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
127
examples/mongodb/README.md
Normal 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
|
||||
```
|
||||
14
examples/mongodb/docker-compose.yml
Normal file
14
examples/mongodb/docker-compose.yml
Normal 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
|
||||
64
examples/mongodb/init/seed.js
Normal file
64
examples/mongodb/init/seed.js
Normal 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(', '));
|
||||
53
examples/mongodb/scripts/introspect-smoke.mjs
Normal file
53
examples/mongodb/scripts/introspect-smoke.mjs
Normal 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);
|
||||
37
examples/mongodb/scripts/smoke.sh
Executable file
37
examples/mongodb/scripts/smoke.sh
Executable 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."
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ storage:
|
|||
state: sqlite
|
||||
search: sqlite-fts5
|
||||
git:
|
||||
auto_commit: true
|
||||
author: "ktx <ktx@example.com>"
|
||||
ingest:
|
||||
adapters: []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
555
packages/cli/src/connectors/athena/connector.ts
Normal file
555
packages/cli/src/connectors/athena/connector.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
175
packages/cli/src/connectors/athena/dialect.ts
Normal file
175
packages/cli/src/connectors/athena/dialect.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
395
packages/cli/src/connectors/duckdb/connector.ts
Normal file
395
packages/cli/src/connectors/duckdb/connector.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
192
packages/cli/src/connectors/duckdb/dialect.ts
Normal file
192
packages/cli/src/connectors/duckdb/dialect.ts
Normal 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
|
||||
`;
|
||||
}
|
||||
}
|
||||
97
packages/cli/src/connectors/duckdb/federated-attach.ts
Normal file
97
packages/cli/src/connectors/duckdb/federated-attach.ts
Normal 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.`);
|
||||
}
|
||||
}
|
||||
64
packages/cli/src/connectors/duckdb/federated-executor.ts
Normal file
64
packages/cli/src/connectors/duckdb/federated-executor.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
413
packages/cli/src/connectors/mongodb/connector.ts
Normal file
413
packages/cli/src/connectors/mongodb/connector.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
64
packages/cli/src/connectors/mongodb/dialect.ts
Normal file
64
packages/cli/src/connectors/mongodb/dialect.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
129
packages/cli/src/connectors/mongodb/schema-inference.ts
Normal file
129
packages/cli/src/connectors/mongodb/schema-inference.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
14
packages/cli/src/connectors/shared/duckdb-json-safe.ts
Normal file
14
packages/cli/src/connectors/shared/duckdb-json-safe.ts
Normal 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)));
|
||||
}
|
||||
20
packages/cli/src/connectors/shared/string-reference.ts
Normal file
20
packages/cli/src/connectors/shared/string-reference.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
40
packages/cli/src/connectors/sqlite/read-query-child.ts
Normal file
40
packages/cli/src/connectors/sqlite/read-query-child.ts
Normal 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));
|
||||
});
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
64
packages/cli/src/context/cache/content-result-cache.ts
vendored
Normal file
64
packages/cli/src/context/cache/content-result-cache.ts
vendored
Normal 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');
|
||||
}
|
||||
281
packages/cli/src/context/cache/sqlite-content-result-cache.ts
vendored
Normal file
281
packages/cli/src/context/cache/sqlite-content-result-cache.ts
vendored
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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}.`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
84
packages/cli/src/context/connections/federation.ts
Normal file
84
packages/cli/src/context/connections/federation.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue