mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
Merge remote-tracking branch 'origin/main' into fix-sl-query-source-column-type
# Conflicts: # packages/context/skills/metabase_ingest/SKILL.md # packages/context/skills/sl_capture/SKILL.md
This commit is contained in:
commit
cd49d5d4ae
168 changed files with 3567 additions and 1621 deletions
|
|
@ -12,7 +12,7 @@ refs:
|
|||
|
||||
## Customer Update Communication Standard
|
||||
|
||||
**Source:** Notion — People & Operating Norms, last edited 2026-05-07
|
||||
**Source:** Notion - People & Operating Norms, last edited 2026-05-07
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -12,23 +12,23 @@ refs:
|
|||
|
||||
## New Hire Week-One Onboarding Policy
|
||||
|
||||
**Source:** Notion — People & Operating Norms, last edited 2026-05-07
|
||||
**Source:** Notion - People & Operating Norms, last edited 2026-05-07
|
||||
**Owner:** Manager (not People Ops)
|
||||
|
||||
---
|
||||
|
||||
## Policy
|
||||
|
||||
Every new hire must understand **four things by end of week one**. The manager — not People Ops — is responsible for supplying this context.
|
||||
Every new hire must understand **four things by end of week one**. The manager - not People Ops - is responsible for supplying this context.
|
||||
|
||||
## Required Week-One Knowledge
|
||||
|
||||
| # | What the new hire must understand |
|
||||
|---|---|
|
||||
| 1 | **What Orbit sells** — the core procurement workflow product and value proposition |
|
||||
| 2 | **Why procurement workflow gets messy inside a customer** — the pain points that make Orbit necessary |
|
||||
| 3 | **Which team handles which part of the customer lifecycle** — team lanes and ownership boundaries |
|
||||
| 4 | **What their first useful project is** — a concrete, scoped piece of work they can contribute to immediately |
|
||||
| 1 | **What Orbit sells** - the core procurement workflow product and value proposition |
|
||||
| 2 | **Why procurement workflow gets messy inside a customer** - the pain points that make Orbit necessary |
|
||||
| 3 | **Which team handles which part of the customer lifecycle** - team lanes and ownership boundaries |
|
||||
| 4 | **What their first useful project is** - a concrete, scoped piece of work they can contribute to immediately |
|
||||
|
||||
## Ownership
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ tables:
|
|||
# Activation KPI Glossary
|
||||
|
||||
**Owner team:** Growth
|
||||
**Source:** Notion — Orbit Demo Home / Data Team - Onboarding / Activation KPI Glossary, last edited 2026-05-07
|
||||
**Source:** Notion - Orbit Demo Home / Data Team - Onboarding / Activation KPI Glossary, last edited 2026-05-07
|
||||
|
||||
Use this when a question is about signup-to-habit behavior. Orbit uses activation language across Growth, Product, and CS conversations.
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ A customer is **activated** when **all three** of the following happen **within
|
|||
| 2. Email Verified | `customer.email_verified_at` is not null | `orbit_analytics.customer` |
|
||||
| 3. First Project | At least one row in `orbit_analytics.project` for the customer | `orbit_analytics.project` |
|
||||
| 4. Team Invite | At least one row in `orbit_analytics.invite` for the customer | `orbit_analytics.invite` |
|
||||
| 5. Activated | All of (2), (3), and (4) within 14 days of (1) | — |
|
||||
| 5. Activated | All of (2), (3), and (4) within 14 days of (1) | - |
|
||||
|
||||
## Conversion-Rate KPIs
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ A customer is **activated** when **all three** of the following happen **within
|
|||
| **D14 Activation Rate** | `activated_customers_within_14_days / signups_in_cohort` |
|
||||
| **Time-to-Activate** | `median(activated_at - created_at)` in hours |
|
||||
|
||||
Growth conversations typically use D7 and D14 Activation Rate. Product and CS may ask about individual funnel steps — confirm whether they mean the full activation definition or only one stage.
|
||||
Growth conversations typically use D7 and D14 Activation Rate. Product and CS may ask about individual funnel steps - confirm whether they mean the full activation definition or only one stage.
|
||||
|
||||
## Source Notes
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ sl_refs:
|
|||
- mart_account_activity
|
||||
---
|
||||
|
||||
# Activation Policy Change — January 2026
|
||||
# Activation Policy Change - January 2026
|
||||
|
||||
**Governed metric key:** `activated_accounts`
|
||||
**Owner team:** growth
|
||||
|
|
@ -23,8 +23,8 @@ sl_refs:
|
|||
|
||||
The activation workflow changed on **2026-01-15**. All activation events are tagged with `policy_version`:
|
||||
|
||||
- `pre_2026_01_15` — events before the workflow update
|
||||
- `post_2026_01_15` — events after the workflow update
|
||||
- `pre_2026_01_15` - events before the workflow update
|
||||
- `post_2026_01_15` - events after the workflow update
|
||||
|
||||
## Activation Event Types
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ sl_refs:
|
|||
- mart_account_segments
|
||||
---
|
||||
|
||||
# ARR — Contract-First Definition
|
||||
# ARR - Contract-First Definition
|
||||
|
||||
**Governed metric key:** `arr`
|
||||
**Owner team:** finance
|
||||
|
|
@ -30,10 +30,10 @@ The dbt test on `mart_arr_daily.arr_cents` asserts the value equals **1,874,200,
|
|||
|
||||
## Intermediate model
|
||||
|
||||
`int_active_contract_arr` — active contract ARR as of 2026-03-31 (grain: `contract_id`).
|
||||
`int_active_contract_arr` - active contract ARR as of 2026-03-31 (grain: `contract_id`).
|
||||
|
||||
## Related
|
||||
|
||||
- `stg_contracts` — contract records (status: draft, active, cancelled, expired)
|
||||
- `stg_subscriptions` — fallback ARR source (status: active, cancelled, past_due, trialing)
|
||||
- `mart_arr_daily` — board-prep daily ARR mart
|
||||
- `stg_contracts` - contract records (status: draft, active, cancelled, expired)
|
||||
- `stg_subscriptions` - fallback ARR source (status: active, cancelled, past_due, trialing)
|
||||
- `mart_arr_daily` - board-prep daily ARR mart
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ refs:
|
|||
|
||||
# Orbit Company Overview
|
||||
|
||||
**Source:** Notion — Orbit Demo Home / Company Overview + Orbit Demo Home (root), last edited 2026-05-07
|
||||
**Source:** Notion - Orbit Demo Home / Company Overview + Orbit Demo Home (root), last edited 2026-05-07
|
||||
|
||||
## What Orbit Sells
|
||||
|
||||
Orbit sells procurement workflow and spend-control software. The core value proposition: route purchase requests, collect approvals, onboard suppliers, and issue purchase orders without turning every exception into a status hunt.
|
||||
|
||||
**Primary buyers:** Finance, Procurement, Business Operations.
|
||||
**Daily users:** department admins, office managers, IT leads, legal ops partners — anyone who has to get a vendor through the building.
|
||||
**Daily users:** department admins, office managers, IT leads, legal ops partners - anyone who has to get a vendor through the building.
|
||||
|
||||
## Product Workflow
|
||||
|
||||
|
|
|
|||
|
|
@ -21,18 +21,18 @@ sl_refs:
|
|||
|
||||
## Risk Levels
|
||||
|
||||
`low`, `medium`, `high` — derived from two signal types:
|
||||
`low`, `medium`, `high` - derived from two signal types:
|
||||
|
||||
1. **Support ticket signals** (`stg_support_tickets`): open or pending tickets with severity `high` or `critical` increase risk.
|
||||
2. **Procurement activity signals** (`stg_purchase_requests`, `stg_purchase_orders`): recent qualifying procurement actions reduce risk.
|
||||
|
||||
## Intermediate Model
|
||||
|
||||
`int_customer_health_signals` — combines open critical ticket count and recent procurement action count per account.
|
||||
`int_customer_health_signals` - combines open critical ticket count and recent procurement action count per account.
|
||||
|
||||
## Mart
|
||||
|
||||
`mart_customer_health` — account-grain risk mart as of **2026-03-31**.
|
||||
`mart_customer_health` - account-grain risk mart as of **2026-03-31**.
|
||||
|
||||
- `account_id`: dbt not_null, unique
|
||||
- `risk_level`: dbt accepted_values [low, medium, high]
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ refs:
|
|||
|
||||
## Customer Stakeholder Needs by Role
|
||||
|
||||
**Source:** Notion — Product & Customers, last edited 2026-05-07
|
||||
**Source:** Notion - Product & Customers, last edited 2026-05-07
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ These are recurring, role-specific customer needs observed across accounts. Use
|
|||
| Role | Primary Need | Implication |
|
||||
|---|---|---|
|
||||
| **Finance** | Committed spend visibility earlier in the procurement cycle | Surface budget commitments at request approval, not at PO creation |
|
||||
| **Department leaders** | Request speed — faster time from request to approval | Reduce approval routing friction; minimize back-and-forth |
|
||||
| **Department leaders** | Request speed - faster time from request to approval | Reduce approval routing friction; minimize back-and-forth |
|
||||
| **Procurement** | Supplier file complete before the first invoice | Supplier onboarding must be finished before PO is issued, not after |
|
||||
| **Legal** | Fewer emergency reviews | Route contracts with legal implications earlier; avoid last-minute escalations |
|
||||
| **Customer Success (internal)** | Renewal risk visible before the account is already annoyed | CS needs leading indicators of dissatisfaction, not lagging ones |
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ tables:
|
|||
|
||||
**Table:** `orbit_analytics.customer`
|
||||
**Grain:** one row per signed-up customer
|
||||
**Source:** Notion — Orbit Demo Home / Data Team - Onboarding / Orbit Customers Source, last edited 2026-05-07
|
||||
**Source:** Notion - Orbit Demo Home / Data Team - Onboarding / Orbit Customers Source, last edited 2026-05-07
|
||||
|
||||
Use this when a question needs customer identity, plan tier, signup timing, recent activity, or the standard customer joins.
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ Use this when a question needs customer identity, plan tier, signup timing, rece
|
|||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | number | Primary key, surrogate key |
|
||||
| `email` | string | Login email, unique — **do not use as join key** |
|
||||
| `email` | string | Login email, unique - **do not use as join key** |
|
||||
| `name` | string | Display name |
|
||||
| `country` | string | ISO 3166-1 alpha-2 code |
|
||||
| `plan_tier` | string | One of `free`, `pro`, `enterprise` |
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ refs:
|
|||
|
||||
## How We Work
|
||||
|
||||
**Source:** Notion — Orbit Demo Home / How We Work, last edited 2026-05-07
|
||||
**Source:** Notion - Orbit Demo Home / How We Work, last edited 2026-05-07
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ refs:
|
|||
|---|---|
|
||||
| **Monday** | Commitments and dependency checks |
|
||||
| **Tuesday – Thursday** | Customer calls, product work, implementation, and building |
|
||||
| **Friday** | Closing loops — review what shipped, what slipped, and write down any decisions |
|
||||
| **Friday** | Closing loops - review what shipped, what slipped, and write down any decisions |
|
||||
|
||||
Use this rhythm when scheduling work, meetings, or reviews. Do not schedule decision-making meetings on Fridays; use Friday to record decisions already made.
|
||||
|
||||
|
|
@ -64,11 +64,11 @@ These are explicitly codified rules Orbit has identified as recurring failure mo
|
|||
- **Escalations are coordination tools, not indicators of individual failure.** Escalating is the correct behavior when a problem exceeds the current team's ability to resolve it alone.
|
||||
- When escalating, the person escalating must:
|
||||
1. Bring in the right people (those with authority or context to unblock).
|
||||
2. Summarize current state clearly — what has been tried, what is blocked, and why.
|
||||
2. Summarize current state clearly - what has been tried, what is blocked, and why.
|
||||
3. Name the customer impact explicitly.
|
||||
4. Keep updates moving until the risk is resolved or a workaround is established.
|
||||
- Escalations that stall because no one owns the next update are a process failure, not a customer failure.
|
||||
- An escalation is closed when the risk is resolved or a documented workaround is in place — not when the immediate noise stops.
|
||||
- An escalation is closed when the risk is resolved or a documented workaround is in place - not when the immediate noise stops.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ refs:
|
|||
|
||||
## Known Product Gaps and Friction Points
|
||||
|
||||
**Source:** Notion — Product & Customers (Notes from Recent Customer Calls), last edited 2026-05-07
|
||||
**Source:** Notion - Product & Customers (Notes from Recent Customer Calls), last edited 2026-05-07
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ tables:
|
|||
|
||||
## Key measures (SL: `mart_account_activity`)
|
||||
|
||||
- `avg_pre_policy_activation_rate` — `avg(pre_policy_30_day_activation_rate)`
|
||||
- `avg_post_policy_activation_rate` — `avg(post_policy_30_day_activation_rate)`
|
||||
- `avg_pre_policy_activation_rate` - `avg(pre_policy_30_day_activation_rate)`
|
||||
- `avg_post_policy_activation_rate` - `avg(post_policy_30_day_activation_rate)`
|
||||
|
||||
## Common query patterns
|
||||
|
||||
|
|
|
|||
|
|
@ -37,10 +37,10 @@ tables:
|
|||
|
||||
## Key measures (SL: `mart_account_segments`)
|
||||
|
||||
- `account_count` — `count(*)`
|
||||
- `total_contract_arr_cents` — `sum(contract_arr_cents)`
|
||||
- `active_contract_arr_cents` — `sum(contract_arr_cents)` where `contract_status = 'active'`
|
||||
- `active_contract_arr_millions` — active ARR in $M
|
||||
- `account_count` - `count(*)`
|
||||
- `total_contract_arr_cents` - `sum(contract_arr_cents)`
|
||||
- `active_contract_arr_cents` - `sum(contract_arr_cents)` where `contract_status = 'active'`
|
||||
- `active_contract_arr_millions` - active ARR in $M
|
||||
|
||||
## Common query patterns
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ tables:
|
|||
|
||||
## Key measures (SL: `mart_arr_daily`)
|
||||
|
||||
- `total_arr_cents` — `sum(arr_cents)`
|
||||
- `arr_millions` — `round(sum(arr_cents) / 100000000.0, 3)` — ARR in $M
|
||||
- `total_arr_cents` - `sum(arr_cents)`
|
||||
- `arr_millions` - `round(sum(arr_cents) / 100000000.0, 3)` - ARR in $M
|
||||
|
||||
## Common query patterns
|
||||
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ tables:
|
|||
|
||||
## Key measures (SL: `mart_nrr_quarterly`)
|
||||
|
||||
- `avg_nrr` — `avg(net_revenue_retention)` across all rows
|
||||
- `avg_nrr_enterprise` — `avg(net_revenue_retention)` filtered to `segment = 'enterprise'`
|
||||
- `avg_nrr` - `avg(net_revenue_retention)` across all rows
|
||||
- `avg_nrr_enterprise` - `avg(net_revenue_retention)` filtered to `segment = 'enterprise'`
|
||||
- `total_expansion_arr_cents`, `total_contraction_arr_cents`, `total_churned_arr_cents`
|
||||
|
||||
## Common query patterns
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ tables:
|
|||
|
||||
## Key measures (SL: `mart_procurement_activity`)
|
||||
|
||||
- `total_active_requesters` — `sum(active_requesters)`
|
||||
- `active_requesters_200k_threshold` — `sum(active_requesters)` where `contract_arr_threshold_cents = 20000000`
|
||||
- `total_active_requesters` - `sum(active_requesters)`
|
||||
- `active_requesters_200k_threshold` - `sum(active_requesters)` where `contract_arr_threshold_cents = 20000000`
|
||||
|
||||
## Common query patterns
|
||||
|
||||
|
|
@ -44,4 +44,4 @@ tables:
|
|||
|
||||
- `active_requesters` counts non-internal, non-test requesters on large active contracts. See [orbit-procurement-qualifying-actions](orbit-procurement-qualifying-actions).
|
||||
- The standard threshold is `contract_arr_threshold_cents = 20000000` ($200k ARR).
|
||||
- Always filter by `contract_arr_threshold_cents` — the table contains rows for multiple threshold values.
|
||||
- Always filter by `contract_arr_threshold_cents` - the table contains rows for multiple threshold values.
|
||||
|
|
|
|||
|
|
@ -36,12 +36,12 @@ tables:
|
|||
|
||||
## Key measures (SL: `mart_revenue_daily`)
|
||||
|
||||
- `total_gross_revenue_cents` — `sum(gross_revenue_cents)`
|
||||
- `total_credits_cents` — `sum(credits_cents)`
|
||||
- `total_refunds_cents` — `sum(refunds_cents)`
|
||||
- `total_net_revenue_cents` — `sum(net_revenue_cents)`
|
||||
- `net_revenue_millions` — `round(sum(net_revenue_cents) / 100000000.0, 3)`
|
||||
- `gross_revenue_millions` — `round(sum(gross_revenue_cents) / 100000000.0, 3)`
|
||||
- `total_gross_revenue_cents` - `sum(gross_revenue_cents)`
|
||||
- `total_credits_cents` - `sum(credits_cents)`
|
||||
- `total_refunds_cents` - `sum(refunds_cents)`
|
||||
- `total_net_revenue_cents` - `sum(net_revenue_cents)`
|
||||
- `net_revenue_millions` - `round(sum(net_revenue_cents) / 100000000.0, 3)`
|
||||
- `gross_revenue_millions` - `round(sum(gross_revenue_cents) / 100000000.0, 3)`
|
||||
|
||||
## Common query patterns
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ sl_refs:
|
|||
- mart_nrr_quarterly
|
||||
---
|
||||
|
||||
# Orbit Metabase SQL Library — Patterns & Conventions
|
||||
# Orbit Metabase SQL Library - Patterns & Conventions
|
||||
|
||||
Collection **7 "SQL Library"** (parent: Orbit Showcase, collection 5) contains reference queries that demonstrate how to write Metabase native SQL against the Orbit analytics marts. Cards here are intentionally illustrative; several have `dashboardCount: 0` and are not embedded in live dashboards.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ sl_refs:
|
|||
- mart_nrr_quarterly
|
||||
---
|
||||
|
||||
# NRR — Discount Expiration Treatment
|
||||
# NRR - Discount Expiration Treatment
|
||||
|
||||
**Governed metric key:** `net_revenue_retention`
|
||||
**Owner team:** analytics
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ sl_refs:
|
|||
- mart_procurement_activity
|
||||
---
|
||||
|
||||
# Procurement — Qualifying Actions & Weekly Active Requesters
|
||||
# Procurement - Qualifying Actions & Weekly Active Requesters
|
||||
|
||||
**Governed metric key:** `weekly_active_requesters`
|
||||
**Owner team:** product
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ refs:
|
|||
|
||||
## Orbit Product Design Principles
|
||||
|
||||
**Source:** Notion — Product & Customers, last edited 2026-05-07
|
||||
**Source:** Notion - Product & Customers, last edited 2026-05-07
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ refs:
|
|||
|
||||
## Product Review Checklist
|
||||
|
||||
**Source:** Notion — Product & Customers, last edited 2026-05-07
|
||||
**Source:** Notion - Product & Customers, last edited 2026-05-07
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ sl_refs:
|
|||
- mart_revenue_daily
|
||||
---
|
||||
|
||||
# Revenue — Gross-to-Net Reconciliation
|
||||
# Revenue - Gross-to-Net Reconciliation
|
||||
|
||||
**Governed metric key:** `net_revenue`
|
||||
**Owner team:** finance
|
||||
|
|
@ -25,7 +25,7 @@ sl_refs:
|
|||
net_revenue = gross_revenue - credits - refunds
|
||||
```
|
||||
|
||||
All amounts are in **cents** (USD only — `stg_invoices.currency` is asserted to be `USD`).
|
||||
All amounts are in **cents** (USD only - `stg_invoices.currency` is asserted to be `USD`).
|
||||
|
||||
## Components
|
||||
|
||||
|
|
@ -38,12 +38,12 @@ All amounts are in **cents** (USD only — `stg_invoices.currency` is asserted t
|
|||
|
||||
## Intermediate model
|
||||
|
||||
`int_revenue_components` — daily gross, credit, refund, and net revenue components.
|
||||
`int_revenue_components` - daily gross, credit, refund, and net revenue components.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
- `reconciliation_check` must be `true` on every row of `mart_revenue_daily`.
|
||||
- `assert_february_2026_net_revenue` — a dbt singular test covering February 2026 net revenue total.
|
||||
- `assert_february_2026_net_revenue` - a dbt singular test covering February 2026 net revenue total.
|
||||
|
||||
## Line Item Types (`stg_invoice_line_items`)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ refs:
|
|||
|
||||
## Sales Ops → Customer Success Implementation Handoff
|
||||
|
||||
**Source:** Notion — People & Operating Norms, last edited 2026-05-07
|
||||
**Source:** Notion - People & Operating Norms, last edited 2026-05-07
|
||||
**Owner:** Sales Ops (sender), Customer Success (receiver)
|
||||
|
||||
---
|
||||
|
|
@ -27,7 +27,7 @@ Sales Ops must complete the handoff **before the first implementation call**. Cu
|
|||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| Current plan | Starter / Growth / Enterprise — use canonical plan name |
|
||||
| Current plan | Starter / Growth / Enterprise - use canonical plan name |
|
||||
| Account segment | self_serve / commercial / enterprise (see `orbit-plan-segment-normalization`) |
|
||||
| Contract shape | Term, ARR, any discounts or custom terms |
|
||||
| Renewal contact | Named person on the customer side responsible for renewal |
|
||||
|
|
@ -38,7 +38,7 @@ Sales Ops must complete the handoff **before the first implementation call**. Cu
|
|||
|
||||
- **Sales Ops** is responsible for populating and delivering the handoff before the first implementation call.
|
||||
- **Customer Success** is responsible for flagging missing fields to Sales Ops before the call, not during or after.
|
||||
- If a field is unknown at handoff time, Sales Ops must note it explicitly as "unknown — to be resolved by [date]" rather than leaving it blank.
|
||||
- If a field is unknown at handoff time, Sales Ops must note it explicitly as "unknown - to be resolved by [date]" rather than leaving it blank.
|
||||
|
||||
## Common Failure Mode
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ Handoffs that omit contract shape or renewal contact force CS to re-engage Sales
|
|||
- Enterprise accounts with parent/child account structures require extra care during handoff.
|
||||
- Small assumptions made during handoff in these accounts tend to produce large downstream problems (billing mismatches, approval routing failures, supplier onboarding gaps).
|
||||
- When the account has parent/child complexity, Sales Ops must explicitly flag it in the handoff and document the account hierarchy before the first implementation call.
|
||||
- CS should treat any undocumented parent/child relationship as a blocker — do not proceed with implementation setup until the structure is confirmed.
|
||||
- CS should treat any undocumented parent/child relationship as a blocker - do not proceed with implementation setup until the structure is confirmed.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export interface KtxCliCommandContext {
|
|||
deps: KtxCliDeps;
|
||||
packageInfo: KtxCliPackageInfo;
|
||||
setExitCode: (code: number) => void;
|
||||
runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise<number>;
|
||||
runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise<number>;
|
||||
writeDebug?: (command: string, commandContext: CommandWithGlobalOptions) => void;
|
||||
}
|
||||
|
||||
|
|
@ -32,14 +32,14 @@ export interface OutputModeOptions {
|
|||
}
|
||||
|
||||
interface KtxCommanderProgramOptions {
|
||||
runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise<number>;
|
||||
runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise<number>;
|
||||
}
|
||||
|
||||
export interface BuildKtxProgramOptions {
|
||||
io: KtxCliIo;
|
||||
deps: KtxCliDeps;
|
||||
packageInfo: KtxCliPackageInfo;
|
||||
runInit: (args: { projectDir: string; projectName?: string; force: boolean }, io: KtxCliIo) => Promise<number>;
|
||||
runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise<number>;
|
||||
setExitCode?: (code: number) => void;
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +58,8 @@ type CommandPathNode = CommandWithGlobalOptions & {
|
|||
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'status']);
|
||||
const COMMANDS_THAT_CREATE_PROJECT = new Set(['setup', 'ktx dev init']);
|
||||
const COMMANDS_WITH_OWN_MISSING_PROJECT_HANDLING = new Set(['status']);
|
||||
const GLOBAL_OPTIONS_WITH_VALUE = new Set(['--project-dir']);
|
||||
const GLOBAL_OPTIONS_WITHOUT_VALUE = new Set(['--debug', '--help', '-h', '--version', '-v']);
|
||||
|
||||
class KtxProjectMissingAbortError extends Error {
|
||||
readonly isKtxProjectMissingAbort = true;
|
||||
|
|
@ -72,24 +74,6 @@ function isKtxProjectMissingAbortError(error: unknown): error is KtxProjectMissi
|
|||
(typeof error === 'object' && error !== null && (error as { isKtxProjectMissingAbort?: unknown }).isKtxProjectMissingAbort === true)
|
||||
);
|
||||
}
|
||||
const REMOVED_COMMAND_PATHS = new Set([
|
||||
'scan',
|
||||
'wiki read',
|
||||
'wiki write',
|
||||
]);
|
||||
const GLOBAL_OPTIONS_WITH_VALUE = new Set(['--project-dir']);
|
||||
const OPTIONS_WITH_VALUE = new Set([
|
||||
'--project-dir',
|
||||
'--query-history-window-days',
|
||||
'--user-id',
|
||||
'--limit',
|
||||
'--format',
|
||||
'--connection-id',
|
||||
'--source-name',
|
||||
'--query-file',
|
||||
'--max-rows',
|
||||
]);
|
||||
|
||||
export interface CommandWithGlobalOptions {
|
||||
opts: () => object;
|
||||
optsWithGlobals?: () => object;
|
||||
|
|
@ -336,43 +320,32 @@ function formatCliError(error: unknown): string {
|
|||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function commandPathFromArgv(argv: string[]): string[] {
|
||||
const path: string[] = [];
|
||||
for (let index = 0; index < argv.length && path.length < 2; index += 1) {
|
||||
function firstTopLevelCommandToken(argv: string[]): string | null {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (arg === '--') {
|
||||
break;
|
||||
return null;
|
||||
}
|
||||
if ((path.length === 0 ? GLOBAL_OPTIONS_WITH_VALUE : OPTIONS_WITH_VALUE).has(arg)) {
|
||||
if (GLOBAL_OPTIONS_WITH_VALUE.has(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const optionsWithValue = path.length === 0 ? GLOBAL_OPTIONS_WITH_VALUE : OPTIONS_WITH_VALUE;
|
||||
if ([...optionsWithValue].some((option) => arg.startsWith(`${option}=`))) {
|
||||
if ([...GLOBAL_OPTIONS_WITH_VALUE].some((option) => arg.startsWith(`${option}=`))) {
|
||||
continue;
|
||||
}
|
||||
if (path.length === 0 && arg === '--debug') {
|
||||
if (GLOBAL_OPTIONS_WITHOUT_VALUE.has(arg) || arg.startsWith('-')) {
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('-')) {
|
||||
continue;
|
||||
}
|
||||
path.push(arg);
|
||||
return arg;
|
||||
}
|
||||
return path;
|
||||
return null;
|
||||
}
|
||||
|
||||
function removedCommandName(argv: string[]): string | null {
|
||||
const path = commandPathFromArgv(argv);
|
||||
if (path.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathKey = path.join(' ');
|
||||
return REMOVED_COMMAND_PATHS.has(pathKey) ? path.at(-1) ?? null : null;
|
||||
function isKnownTopLevelCommand(program: Command, commandName: string): boolean {
|
||||
return program.commands.some((command) => command.name() === commandName || command.aliases().includes(commandName));
|
||||
}
|
||||
|
||||
async function runBareInteractiveCommand(
|
||||
|
|
@ -489,9 +462,9 @@ export async function runCommanderKtxCli(
|
|||
return 0;
|
||||
}
|
||||
|
||||
const removedCommand = removedCommandName(argv);
|
||||
if (removedCommand) {
|
||||
io.stderr.write(`error: unknown command '${removedCommand}'\n`);
|
||||
const topLevelCommand = firstTopLevelCommandToken(argv);
|
||||
if (topLevelCommand && !isKnownTopLevelCommand(program, topLevelCommand)) {
|
||||
io.stderr.write(`error: unknown command '${topLevelCommand}'\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,14 +59,10 @@ export function packageInfoFromJson(packageJson: unknown): KtxCliPackageInfo {
|
|||
};
|
||||
}
|
||||
|
||||
async function runInit(
|
||||
args: { projectDir: string; projectName?: string; force: boolean },
|
||||
io: KtxCliIo,
|
||||
): Promise<number> {
|
||||
async function runInit(args: { projectDir: string; force: boolean }, io: KtxCliIo): Promise<number> {
|
||||
const { initKtxProject } = await import('@ktx/context/project');
|
||||
const result = await initKtxProject({
|
||||
projectDir: args.projectDir,
|
||||
projectName: args.projectName,
|
||||
force: args.force,
|
||||
});
|
||||
|
||||
|
|
@ -77,7 +73,7 @@ async function runInit(
|
|||
}
|
||||
|
||||
export async function runInitForCommander(
|
||||
args: { projectDir: string; projectName?: string; force: boolean },
|
||||
args: { projectDir: string; force: boolean },
|
||||
io: KtxCliIo,
|
||||
): Promise<number> {
|
||||
return await runInit(args, io);
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('lists configured connections without resolving secrets', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
warehouse: { driver: 'postgres', url: 'env:DATABASE_URL' },
|
||||
docs: { driver: 'notion', auth_token_ref: 'env:NOTION_TOKEN', crawl_mode: 'all_accessible' },
|
||||
|
|
@ -100,7 +100,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('prints an empty-state message that points at setup instead of removed connection add', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
const io = makeIo();
|
||||
|
||||
await expect(runKtxConnection({ command: 'list', projectDir }, io.io)).resolves.toBe(0);
|
||||
|
|
@ -111,7 +111,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('tests a native connection by calling connector.testConnection (not introspect)', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
warehouse: { driver: 'sqlite' },
|
||||
});
|
||||
|
|
@ -136,7 +136,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('reports the connector error and still cleans up when native testConnection fails', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
warehouse: { driver: 'sqlite' },
|
||||
});
|
||||
|
|
@ -155,7 +155,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('tests a configured Metabase connection through the Metabase runtime client', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
prod_metabase: {
|
||||
driver: 'metabase',
|
||||
|
|
@ -201,7 +201,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('tests a Looker connection through the Looker client', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
bi_looker: {
|
||||
driver: 'looker',
|
||||
|
|
@ -230,7 +230,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('falls back to userId when Looker metadata has no display name', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
bi_looker: {
|
||||
driver: 'looker',
|
||||
|
|
@ -255,7 +255,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('reports the Looker error when testConnection fails', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
bi_looker: {
|
||||
driver: 'looker',
|
||||
|
|
@ -277,7 +277,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('tests a Notion connection by retrieving the bot user', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
docs: {
|
||||
driver: 'notion',
|
||||
|
|
@ -302,7 +302,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('falls back to bot id when Notion bot has no name', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
docs: {
|
||||
driver: 'notion',
|
||||
|
|
@ -323,7 +323,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('tests a dbt connection via testRepoConnection (success)', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
process.env.DBT_TOKEN = 'gh_token_abc'; // pragma: allowlist secret
|
||||
await writeConnections(projectDir, {
|
||||
'dbt-main': {
|
||||
|
|
@ -354,7 +354,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('reports the git error when testRepoConnection fails for dbt', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
'dbt-main': {
|
||||
driver: 'dbt',
|
||||
|
|
@ -377,7 +377,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('tests a LookML connection via testRepoConnection with camelCase repoUrl', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
lookml_main: {
|
||||
driver: 'lookml',
|
||||
|
|
@ -400,7 +400,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('tests a MetricFlow connection via the nested metricflow block', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
mf_main: {
|
||||
driver: 'metricflow',
|
||||
|
|
@ -422,7 +422,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('--all: prints a single coherent list with one row per connection', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
warehouse: { driver: 'sqlite' },
|
||||
docs: { driver: 'notion', auth_token: 'secret_token', crawl_mode: 'all_accessible' }, // pragma: allowlist secret
|
||||
|
|
@ -450,7 +450,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('--all: marks failing connections, keeps passing ones, and returns non-zero', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeConnections(projectDir, {
|
||||
warehouse: { driver: 'sqlite' },
|
||||
broken: { driver: 'sqlite' },
|
||||
|
|
@ -476,7 +476,7 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('--all: shows an empty-state message when no connections are configured', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
const io = makeIo();
|
||||
|
||||
await expect(runKtxConnection({ command: 'test-all', projectDir }, io.io)).resolves.toBe(0);
|
||||
|
|
@ -488,16 +488,18 @@ describe('runKtxConnection', () => {
|
|||
|
||||
it('rejects unknown drivers with a helpful error', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await writeConnections(projectDir, {
|
||||
mystery: { driver: 'duckdb' },
|
||||
});
|
||||
await initKtxProject({ projectDir });
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
'connections:\n mystery:\n driver: duckdb\n',
|
||||
'utf-8',
|
||||
);
|
||||
const io = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxConnection({ command: 'test', projectDir, connectionId: 'mystery' }, io.io),
|
||||
).resolves.toBe(1);
|
||||
expect(io.stderr()).toContain('uses driver "duckdb"');
|
||||
expect(io.stderr()).toContain('Supported:');
|
||||
expect(io.stderr()).toContain('connections.mystery.driver');
|
||||
expect(io.stderr()).toContain('postgres');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ function projectWithConnections(connections: KtxProjectConfig['connections']): K
|
|||
return {
|
||||
projectDir: '/tmp/project',
|
||||
config: {
|
||||
...buildDefaultKtxProjectConfig('warehouse'),
|
||||
...buildDefaultKtxProjectConfig(),
|
||||
connections,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ export function defaultDemoProjectDir(): string {
|
|||
|
||||
function demoConfig(databasePath: string): string {
|
||||
return [
|
||||
'project: ktx-demo-orbit',
|
||||
'connections:',
|
||||
` ${DEMO_CONNECTION_ID}:`,
|
||||
' driver: sqlite',
|
||||
|
|
|
|||
|
|
@ -72,10 +72,10 @@ describe('dev Commander tree', () => {
|
|||
const testIo = makeIo();
|
||||
|
||||
try {
|
||||
await expect(runKtxCli(['dev', 'init', projectDir, '--name', 'warehouse'], testIo.io)).resolves.toBe(0);
|
||||
await expect(runKtxCli(['dev', 'init', projectDir], testIo.io)).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain(`Initialized KTX project at ${projectDir}`);
|
||||
await expect(readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).resolves.toContain('project: warehouse');
|
||||
await expect(readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).resolves.not.toContain('project:');
|
||||
expect(testIo.stderr()).toBe('');
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
|
|
@ -92,7 +92,7 @@ describe('dev Commander tree', () => {
|
|||
|
||||
try {
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', projectDir, 'dev', 'init', '--name', 'global-init'], testIo.io),
|
||||
runKtxCli(['--project-dir', projectDir, 'dev', 'init'], testIo.io),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(testIo.stdout()).toContain(`Initialized KTX project at ${projectDir}`);
|
||||
|
|
|
|||
|
|
@ -25,19 +25,17 @@ export function registerDevCommands(program: Command, context: KtxCliCommandCont
|
|||
.command('init')
|
||||
.description('Initialize a Git-backed KTX project directory for maintenance scripts')
|
||||
.argument('[directory]', 'Project directory')
|
||||
.option('--name <name>', 'Project name written to ktx.yaml')
|
||||
.option('--force', 'Rewrite ktx.yaml and scaffold files in an existing project', false)
|
||||
.action(
|
||||
async (
|
||||
projectDir: string | undefined,
|
||||
commandOptions: { name?: string; force?: boolean },
|
||||
commandOptions: { force?: boolean },
|
||||
command: CommandWithGlobalOptions,
|
||||
) => {
|
||||
context.setExitCode(
|
||||
await context.runInit(
|
||||
{
|
||||
projectDir: projectDir ? resolve(projectDir) : resolveCommandProjectDir(command),
|
||||
...(commandOptions.name ? { projectName: commandOptions.name } : {}),
|
||||
force: commandOptions.force === true,
|
||||
},
|
||||
context.io,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { basename, join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatDoctorReport,
|
||||
|
|
@ -64,6 +64,11 @@ describe('formatDoctorReport', () => {
|
|||
expect(output).toContain('Node 22+ · pnpm 10.20+');
|
||||
expect(output).not.toContain('v22.16.0');
|
||||
expect(output).toContain('Everything ready.');
|
||||
expect(output).toContain('ktx status --json');
|
||||
expect(output).toContain('ktx sl list');
|
||||
expect(output).toContain('ktx wiki list');
|
||||
expect(output).not.toContain('ktx scan');
|
||||
expect(output).not.toContain('ktx sl ask');
|
||||
});
|
||||
|
||||
it('shows the underlying detail for a single-check group on the group line', () => {
|
||||
|
|
@ -328,7 +333,6 @@ describe('runKtxDoctor', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'storrage:',
|
||||
' state: sqlite',
|
||||
'ingest:',
|
||||
|
|
@ -359,7 +363,7 @@ describe('runKtxDoctor', () => {
|
|||
it('emits structured JSON when ktx.yaml fails Zod validation', async () => {
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
['project: warehouse', 'storrage: {}', ''].join('\n'),
|
||||
['storrage: {}', ''].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
const testIo = makeIo();
|
||||
|
|
@ -387,7 +391,6 @@ describe('runKtxDoctor', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: sqlite',
|
||||
|
|
@ -418,7 +421,6 @@ describe('runKtxDoctor', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: sqlite',
|
||||
|
|
@ -452,7 +454,7 @@ describe('runKtxDoctor', () => {
|
|||
|
||||
const out = testIo.stdout();
|
||||
expect(out).toContain('KTX status');
|
||||
expect(out).toContain('· warehouse');
|
||||
expect(out).toContain(`· ${basename(tempDir)}`);
|
||||
expect(out).toContain('Connections (1)');
|
||||
expect(out).toContain('LLM');
|
||||
expect(out).toContain('anthropic');
|
||||
|
|
@ -465,10 +467,10 @@ describe('runKtxDoctor', () => {
|
|||
it('includes Postgres query-history readiness in project doctor output', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret
|
||||
process.env.OPENAI_API_KEY = 'test-key'; // pragma: allowlist secret
|
||||
process.env.WAREHOUSE_DATABASE_URL = 'postgresql://reader@example.test/warehouse';
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -520,15 +522,20 @@ describe('runKtxDoctor', () => {
|
|||
expect(out).toContain('pg_stat_statements ready (PostgreSQL 16.4)');
|
||||
expect(out).toContain('info: pg_stat_statements.max is 1000');
|
||||
expect(out).not.toContain('Update the Postgres parameter group or config');
|
||||
expect(out).toContain('ktx status --json');
|
||||
expect(out).toContain('ktx sl list');
|
||||
expect(out).toContain('ktx wiki list');
|
||||
expect(out).not.toContain('ktx scan');
|
||||
expect(out).not.toContain('ktx sl ask');
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.WAREHOUSE_DATABASE_URL;
|
||||
});
|
||||
|
||||
it('returns blocked verdict when LLM is not configured', async () => {
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: sqlite',
|
||||
|
|
@ -548,6 +555,7 @@ describe('runKtxDoctor', () => {
|
|||
).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stdout()).toContain('no LLM configured');
|
||||
expect(testIo.stdout()).not.toContain('ktx ask');
|
||||
expect(testIo.stdout()).toContain('ktx setup');
|
||||
});
|
||||
|
||||
|
|
@ -558,7 +566,6 @@ describe('runKtxDoctor', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -620,7 +627,6 @@ describe('runKtxDoctor', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: sqlite',
|
||||
|
|
@ -660,7 +666,6 @@ describe('runKtxDoctor', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: sqlite',
|
||||
|
|
@ -695,7 +700,6 @@ describe('runKtxDoctor', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: sqlite',
|
||||
|
|
@ -724,7 +728,6 @@ describe('runKtxDoctor', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'storrage:',
|
||||
' state: sqlite',
|
||||
'ingest:',
|
||||
|
|
@ -752,7 +755,7 @@ describe('runKtxDoctor', () => {
|
|||
it('emits structured JSON issues when validation fails', async () => {
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
['project: warehouse', 'storrage: {}', ''].join('\n'),
|
||||
['storrage: {}', ''].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
const testIo = makeIo();
|
||||
|
|
@ -788,7 +791,6 @@ describe('runKtxDoctor', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { join, resolve } from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import type { KtxConfigIssue } from '@ktx/context/project';
|
||||
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
|
||||
import type { BuildProjectStatusOptions } from './status-project.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
|
@ -287,7 +288,7 @@ interface RenderOptions {
|
|||
command?: 'setup' | 'project';
|
||||
}
|
||||
|
||||
const NEXT_STEPS_PROJECT = ['ktx scan', 'ktx wiki', 'ktx sl ask "…"'];
|
||||
const NEXT_STEPS_PROJECT = KTX_NEXT_STEP_DIRECT_COMMANDS.map((step) => step.command);
|
||||
|
||||
export function formatDoctorReport(report: DoctorReport, options: Partial<RenderOptions> = {}): string {
|
||||
const opts: RenderOptions = {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ describe('runKtxCli', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-cli-'));
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'project: cli-dispatch-fixture\n', 'utf-8');
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), '{}\n', 'utf-8');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -502,7 +502,7 @@ describe('runKtxCli', () => {
|
|||
|
||||
it('keeps representative JSON command stdout parseable', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
const commands = [
|
||||
['--project-dir', projectDir, 'status', '--json'],
|
||||
['--project-dir', projectDir, 'sl', 'list', '--json'],
|
||||
|
|
@ -580,7 +580,7 @@ describe('runKtxCli', () => {
|
|||
|
||||
try {
|
||||
delete process.env.KTX_PROJECT_DIR;
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
|
||||
process.chdir(tempDir);
|
||||
|
||||
await expect(runKtxCli([], testIo.io, { setup })).resolves.toBe(0);
|
||||
|
|
@ -1482,7 +1482,7 @@ describe('runKtxCli', () => {
|
|||
|
||||
it('dispatches public connection subcommands through the existing connection implementation', async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-connection-dispatch-'));
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'project: connection-dispatch\n', 'utf-8');
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), '{}\n', 'utf-8');
|
||||
const connection = vi.fn(async () => 0);
|
||||
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -106,10 +106,10 @@ export async function writeWarehouseConfig(projectDir: string): Promise<void> {
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' prod-metabase:',
|
||||
' driver: metabase',
|
||||
' api_url: https://metabase.example.test',
|
||||
' warehouse_a:',
|
||||
' driver: postgres',
|
||||
'ingest:',
|
||||
|
|
@ -126,7 +126,6 @@ export async function writeMetabaseConfig(projectDir: string): Promise<void> {
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -488,7 +487,6 @@ export async function runPublicMetabaseSyncModeCase(tempDir: string, input: Sync
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
`project: metabase-sync-mode-${input.name}`,
|
||||
'connections:',
|
||||
' prod-metabase:',
|
||||
' driver: metabase',
|
||||
|
|
|
|||
|
|
@ -633,7 +633,6 @@ describe('runKtxIngest', () => {
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: metabase-cli',
|
||||
'connections:',
|
||||
' prod-metabase:',
|
||||
' driver: metabase',
|
||||
|
|
@ -1099,7 +1098,7 @@ describe('runKtxIngest', () => {
|
|||
|
||||
it('passes managed daemon options to adapters and pull-config options when no explicit daemon URL is set', async () => {
|
||||
const projectDir = join(tempDir, 'managed-daemon-ingest-project');
|
||||
await initKtxProject({ projectDir, projectName: 'managed-daemon-ingest-project' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeWarehouseConfig(projectDir);
|
||||
const createdAdapters: SourceAdapter[] = [
|
||||
{ source: 'fake', skillNames: [], detect: async () => true, chunk: async () => ({ workUnits: [] }) },
|
||||
|
|
@ -1159,7 +1158,6 @@ describe('runKtxIngest', () => {
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: historic-sql-project',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -1224,7 +1222,6 @@ describe('runKtxIngest', () => {
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: historic-sql-progress-project',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -1353,7 +1350,6 @@ describe('runKtxIngest', () => {
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: historic-sql-step-progress-project',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -1446,7 +1442,6 @@ describe('runKtxIngest', () => {
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: historic-sql-concurrent-progress-project',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -1596,7 +1591,6 @@ describe('runKtxIngest', () => {
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: looker-cli',
|
||||
'connections:',
|
||||
' prod-looker:',
|
||||
' driver: looker',
|
||||
|
|
|
|||
|
|
@ -43,13 +43,13 @@ export interface PrintListArgs<Row> {
|
|||
io: KtxCliIo;
|
||||
}
|
||||
|
||||
export interface KtxJsonResultEnvelope<T> {
|
||||
interface KtxJsonResultEnvelope<T> {
|
||||
kind: string;
|
||||
data: T;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function writeJsonResult<T>(io: KtxCliIo, envelope: KtxJsonResultEnvelope<T>): void {
|
||||
function writeJsonResult<T>(io: KtxCliIo, envelope: KtxJsonResultEnvelope<T>): void {
|
||||
io.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initKtxProject } from '@ktx/context/project';
|
||||
import { initKtxProject, loadKtxProject } from '@ktx/context/project';
|
||||
import type { KtxEmbeddingPort } from '@ktx/context';
|
||||
import { writeLocalKnowledgePage } from '@ktx/context/wiki';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { runKtxKnowledge } from './knowledge.js';
|
||||
|
||||
|
|
@ -40,6 +41,28 @@ class FakeEmbeddingPort implements KtxEmbeddingPort {
|
|||
}
|
||||
}
|
||||
|
||||
interface WikiPageFixture {
|
||||
key?: string;
|
||||
summary?: string;
|
||||
content?: string;
|
||||
tags?: string[];
|
||||
slRefs?: string[];
|
||||
}
|
||||
|
||||
async function seedWikiPage(projectDir: string, fixture: WikiPageFixture = {}): Promise<void> {
|
||||
const project = await loadKtxProject({ projectDir });
|
||||
await writeLocalKnowledgePage(project, {
|
||||
key: fixture.key ?? 'metrics-revenue',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: fixture.summary ?? 'Revenue',
|
||||
content: fixture.content ?? 'Revenue is paid order value.',
|
||||
tags: fixture.tags ?? ['finance'],
|
||||
refs: [],
|
||||
slRefs: fixture.slRefs ?? ['orders'],
|
||||
});
|
||||
}
|
||||
|
||||
describe('runKtxKnowledge', () => {
|
||||
let tempDir: string;
|
||||
|
||||
|
|
@ -51,36 +74,10 @@ describe('runKtxKnowledge', () => {
|
|||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes, reads, lists, and searches wiki pages', async () => {
|
||||
it('lists and searches wiki pages', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
const writeIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'metrics-revenue',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
tags: ['finance'],
|
||||
refs: [],
|
||||
slRefs: ['orders'],
|
||||
},
|
||||
writeIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
expect(writeIo.stdout()).toContain('Wrote wiki/global/metrics-revenue.md');
|
||||
|
||||
const readIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local' }, readIo.io),
|
||||
).resolves.toBe(0);
|
||||
expect(readIo.stdout()).toContain('# metrics-revenue');
|
||||
expect(readIo.stdout()).toContain('Revenue is paid order value.');
|
||||
await initKtxProject({ projectDir });
|
||||
await seedWikiPage(projectDir);
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local' }, listIo.io)).resolves.toBe(0);
|
||||
|
|
@ -93,27 +90,10 @@ describe('runKtxKnowledge', () => {
|
|||
expect(searchIo.stdout()).toContain('metrics-revenue');
|
||||
});
|
||||
|
||||
it('prints wiki list, search, and read as public JSON envelopes', async () => {
|
||||
it('prints wiki list and search as public JSON envelopes', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'metrics-revenue',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
tags: ['finance'],
|
||||
refs: [],
|
||||
slRefs: ['orders'],
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
await initKtxProject({ projectDir });
|
||||
await seedWikiPage(projectDir);
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local', json: true }, listIo.io)).resolves.toBe(
|
||||
|
|
@ -137,53 +117,11 @@ describe('runKtxKnowledge', () => {
|
|||
data: { items: [expect.objectContaining({ key: 'metrics-revenue', summary: 'Revenue' })] },
|
||||
meta: { command: 'wiki search' },
|
||||
});
|
||||
|
||||
const readIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local', json: true }, readIo.io),
|
||||
).resolves.toBe(0);
|
||||
expect(JSON.parse(readIo.stdout())).toMatchObject({
|
||||
kind: 'wiki.page',
|
||||
data: {
|
||||
key: 'metrics-revenue',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects slash-delimited write keys with a flat-key suggestion', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
const writeIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'orbit/company-overview',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Orbit',
|
||||
content: 'Orbit overview.',
|
||||
tags: [],
|
||||
refs: [],
|
||||
slRefs: [],
|
||||
},
|
||||
writeIo.io,
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(writeIo.stderr()).toContain(
|
||||
'Invalid wiki key "orbit/company-overview". Wiki keys must be flat; use "orbit-company-overview".',
|
||||
);
|
||||
expect(writeIo.stdout()).toBe('');
|
||||
});
|
||||
|
||||
it('explains empty search results for a project without wiki pages', async () => {
|
||||
const projectDir = join(tempDir, 'empty-project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
|
||||
const searchIo = makeIo();
|
||||
await expect(
|
||||
|
|
@ -197,25 +135,14 @@ describe('runKtxKnowledge', () => {
|
|||
|
||||
it('uses configured embeddings for semantic wiki search', async () => {
|
||||
const projectDir = join(tempDir, 'semantic-project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'active-contract-arr-open-tickets',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Active Contract ARR Ranked by Open Support Ticket Count',
|
||||
content: 'Accounts ranked by annual recurring contract value and support ticket load.',
|
||||
tags: ['historic-sql'],
|
||||
refs: [],
|
||||
slRefs: [],
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
await initKtxProject({ projectDir });
|
||||
await seedWikiPage(projectDir, {
|
||||
key: 'active-contract-arr-open-tickets',
|
||||
summary: 'Active Contract ARR Ranked by Open Support Ticket Count',
|
||||
content: 'Accounts ranked by annual recurring contract value and support ticket load.',
|
||||
tags: ['historic-sql'],
|
||||
slRefs: [],
|
||||
});
|
||||
|
||||
const searchIo = makeIo();
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -5,20 +5,16 @@ import {
|
|||
} from '@ktx/context';
|
||||
import { loadKtxProject } from '@ktx/context/project';
|
||||
import {
|
||||
type LocalKnowledgeScope,
|
||||
type LocalKnowledgeSearchResult,
|
||||
type LocalKnowledgeSummary,
|
||||
listLocalKnowledgePages,
|
||||
readLocalKnowledgePage,
|
||||
searchLocalKnowledgePages,
|
||||
writeLocalKnowledgePage,
|
||||
} from '@ktx/context/wiki';
|
||||
import { resolveOutputMode } from './io/mode.js';
|
||||
import { printList, type PrintListColumn, writeJsonResult } from './io/print-list.js';
|
||||
import { printList, type PrintListColumn } from './io/print-list.js';
|
||||
|
||||
export type KtxKnowledgeArgs =
|
||||
| { command: 'list'; projectDir: string; userId: string; output?: string; json?: boolean }
|
||||
| { command: 'read'; projectDir: string; key: string; userId: string; json?: boolean }
|
||||
| {
|
||||
command: 'search';
|
||||
projectDir: string;
|
||||
|
|
@ -27,18 +23,6 @@ export type KtxKnowledgeArgs =
|
|||
output?: string;
|
||||
json?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
| {
|
||||
command: 'write';
|
||||
projectDir: string;
|
||||
key: string;
|
||||
scope: LocalKnowledgeScope;
|
||||
userId: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
refs: string[];
|
||||
slRefs: string[];
|
||||
};
|
||||
|
||||
type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo;
|
||||
|
|
@ -104,25 +88,6 @@ export async function runKtxKnowledge(
|
|||
});
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'read') {
|
||||
const page = await readLocalKnowledgePage(project, { key: args.key, userId: args.userId });
|
||||
if (!page) {
|
||||
throw new Error(`Wiki page "${args.key}" was not found`);
|
||||
}
|
||||
if (args.json) {
|
||||
writeJsonResult(io, {
|
||||
kind: 'wiki.page',
|
||||
data: page,
|
||||
meta: { command: 'wiki read' },
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
io.stdout.write(`# ${page.key}\n\n`);
|
||||
io.stdout.write(`Scope: ${page.scope}\n`);
|
||||
io.stdout.write(`Summary: ${page.summary}\n\n`);
|
||||
io.stdout.write(`${page.content}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'search') {
|
||||
const results = await searchLocalKnowledgePages(project, {
|
||||
query: args.query,
|
||||
|
|
@ -153,18 +118,6 @@ export async function runKtxKnowledge(
|
|||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
const write = await writeLocalKnowledgePage(project, {
|
||||
key: args.key,
|
||||
scope: args.scope,
|
||||
userId: args.userId,
|
||||
summary: args.summary,
|
||||
content: args.content,
|
||||
tags: args.tags,
|
||||
refs: args.refs,
|
||||
slRefs: args.slRefs,
|
||||
});
|
||||
io.stdout.write(`Wrote ${write.path}\n`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ describe('CLI local ingest adapters', () => {
|
|||
await writeProject(
|
||||
tempDir,
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -71,7 +70,6 @@ describe('CLI local ingest adapters', () => {
|
|||
await writeProject(
|
||||
tempDir,
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -103,7 +101,6 @@ describe('CLI local ingest adapters', () => {
|
|||
await writeProject(
|
||||
tempDir,
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' bq:',
|
||||
' driver: bigquery',
|
||||
|
|
@ -136,7 +133,6 @@ describe('CLI local ingest adapters', () => {
|
|||
await writeProject(
|
||||
tempDir,
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' sf:',
|
||||
' driver: snowflake',
|
||||
|
|
@ -172,7 +168,6 @@ describe('CLI local ingest adapters', () => {
|
|||
await writeProject(
|
||||
tempDir,
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' bq:',
|
||||
' driver: bigquery',
|
||||
|
|
|
|||
|
|
@ -39,11 +39,10 @@ describe('createKtxCliScanConnector', () => {
|
|||
});
|
||||
|
||||
it('creates a native sqlite connector from standalone config', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: sqlite',
|
||||
|
|
@ -61,11 +60,10 @@ describe('createKtxCliScanConnector', () => {
|
|||
});
|
||||
|
||||
it('passes canonical BigQuery YAML scan limits through to the connector', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: bigquery',
|
||||
|
|
@ -94,12 +92,11 @@ describe('createKtxCliScanConnector', () => {
|
|||
expect(bigQueryMock.constructorInputs[0]).not.toHaveProperty('maxBytesBilled');
|
||||
});
|
||||
|
||||
it('throws for structural daemon-only fallback configs', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
it('rejects daemon-only fallback driver configs at config parse time', async () => {
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: duckdb',
|
||||
|
|
@ -108,19 +105,17 @@ describe('createKtxCliScanConnector', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
const project = await loadKtxProject({ projectDir: tempDir });
|
||||
|
||||
await expect(createKtxCliScanConnector(project, 'warehouse')).rejects.toThrow(
|
||||
'Connection "warehouse" uses driver "duckdb", which has no native standalone KTX scan connector',
|
||||
await expect(loadKtxProject({ projectDir: tempDir })).rejects.toThrow(
|
||||
/connections\.warehouse\.driver:.*Invalid discriminator value/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a clear error when the connection block has no driver field', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
it('rejects connection blocks with no driver field at config parse time', async () => {
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' type: postgres',
|
||||
|
|
@ -129,10 +124,9 @@ describe('createKtxCliScanConnector', () => {
|
|||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
const project = await loadKtxProject({ projectDir: tempDir });
|
||||
|
||||
await expect(createKtxCliScanConnector(project, 'warehouse')).rejects.toThrow(
|
||||
'Connection "warehouse" has no `driver` field in ktx.yaml',
|
||||
await expect(loadKtxProject({ projectDir: tempDir })).rejects.toThrow(
|
||||
/connections\.warehouse\.driver:.*Invalid discriminator value/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { runKtxCli, type KtxCliDeps } from './index.js';
|
|||
|
||||
async function makeFixtureProject(prefix: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
await writeFile(join(dir, 'ktx.yaml'), 'project: project-dir-fixture\n', 'utf-8');
|
||||
await writeFile(join(dir, 'ktx.yaml'), '{}\n', 'utf-8');
|
||||
return dir;
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ describe('project directory defaults', () => {
|
|||
const projectDir = join(root, 'warehouse');
|
||||
const nestedDir = join(projectDir, 'nested', 'deeper');
|
||||
await mkdir(nestedDir, { recursive: true });
|
||||
await writeFile(join(projectDir, 'ktx.yaml'), 'project: warehouse\n', 'utf-8');
|
||||
await writeFile(join(projectDir, 'ktx.yaml'), '{}\n', 'utf-8');
|
||||
const expectedProjectDir = await realpath(projectDir);
|
||||
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ describe('resolveKtxProjectDir', () => {
|
|||
const project = join(tempDir, 'warehouse');
|
||||
const nested = join(project, 'nested', 'deeper');
|
||||
await mkdir(nested, { recursive: true });
|
||||
await writeFile(join(project, 'ktx.yaml'), 'project: warehouse\n', 'utf-8');
|
||||
await writeFile(join(project, 'ktx.yaml'), '{}\n', 'utf-8');
|
||||
|
||||
expect(resolveKtxProjectDir({ env: {}, cwd: nested })).toBe(resolve(project));
|
||||
expect(findNearestKtxProjectDir(nested)).toBe(resolve(project));
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ function projectWithConnections(connections: KtxProjectConfig['connections']): K
|
|||
return {
|
||||
projectDir: '/tmp/project',
|
||||
config: {
|
||||
...buildDefaultKtxProjectConfig('warehouse'),
|
||||
...buildDefaultKtxProjectConfig(),
|
||||
connections,
|
||||
},
|
||||
};
|
||||
|
|
@ -51,7 +51,7 @@ function deepReadyProject(
|
|||
connections: KtxProjectConfig['connections'],
|
||||
relationshipsEnabled = true,
|
||||
): KtxPublicIngestProject {
|
||||
const config = buildDefaultKtxProjectConfig('warehouse');
|
||||
const config = buildDefaultKtxProjectConfig();
|
||||
return {
|
||||
projectDir: '/tmp/project',
|
||||
config: {
|
||||
|
|
@ -85,7 +85,7 @@ describe('buildPublicIngestPlan', () => {
|
|||
it('plans warehouse connections as scan targets and source connections as source ingest targets', () => {
|
||||
const project = projectWithConnections({
|
||||
warehouse: { driver: 'postgres' },
|
||||
prod_metabase: { driver: 'metabase' },
|
||||
prod_metabase: { driver: 'metabase', api_url: 'https://metabase.example.com' },
|
||||
docs: { driver: 'notion' },
|
||||
});
|
||||
|
||||
|
|
@ -745,7 +745,7 @@ describe('runKtxPublicIngest', () => {
|
|||
const io = makeIo();
|
||||
const project = projectWithConnections({
|
||||
warehouse: { driver: 'postgres' },
|
||||
prod_metabase: { driver: 'metabase' },
|
||||
prod_metabase: { driver: 'metabase', api_url: 'https://metabase.example.com' },
|
||||
});
|
||||
const runScan = vi.fn(async () => 1);
|
||||
const runIngest = vi.fn(async () => 0);
|
||||
|
|
|
|||
|
|
@ -316,7 +316,7 @@ describe('runKtxScan', () => {
|
|||
});
|
||||
|
||||
it('runs structural scans and prints a dev-friendly plain summary', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
const runLocalScan = vi.fn(
|
||||
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
|
||||
runId: 'scan-run-1',
|
||||
|
|
@ -377,7 +377,7 @@ describe('runKtxScan', () => {
|
|||
});
|
||||
|
||||
it('passes managed daemon options to local ingest adapters when no explicit daemon URL is set', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
const createLocalIngestAdapters = vi.fn(() => []);
|
||||
const runLocalScan = vi.fn(
|
||||
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
|
||||
|
|
@ -421,7 +421,7 @@ describe('runKtxScan', () => {
|
|||
});
|
||||
|
||||
it('explains warnings, capability gaps, and relationships in human scan summaries', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
const runLocalScan = vi.fn(
|
||||
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
|
||||
runId: 'scan-run-1',
|
||||
|
|
@ -472,7 +472,7 @@ describe('runKtxScan', () => {
|
|||
});
|
||||
|
||||
it('prints review-only relationship summaries and validation capability warnings', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
const reviewOnlyReport: KtxScanReport = {
|
||||
...reportWithAttention,
|
||||
capabilityGaps: [],
|
||||
|
|
@ -525,7 +525,7 @@ describe('runKtxScan', () => {
|
|||
});
|
||||
|
||||
it('passes a scan progress port and prints TTY progress messages', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
|
||||
await input.progress?.update(0.15, 'Inspecting database schema');
|
||||
await input.progress?.update(0.55, 'Semantic layer comparison found 5 changes across 18 tables');
|
||||
|
|
@ -572,7 +572,7 @@ describe('runKtxScan', () => {
|
|||
});
|
||||
|
||||
it('uses injected structured progress without requiring TTY progress output', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
const progressEvents: Array<{ progress: number; message?: string; transient?: boolean }> = [];
|
||||
const structuredProgress = {
|
||||
async update(progress: number, message?: string, options?: { transient?: boolean }) {
|
||||
|
|
@ -674,7 +674,7 @@ describe('runKtxScan', () => {
|
|||
});
|
||||
|
||||
it('flushes transient TTY progress messages before printing scan failures', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
|
||||
await input.progress?.update(0.42, 'Generating descriptions 3/35 tables', { transient: true });
|
||||
throw new Error('scan failed');
|
||||
|
|
@ -711,7 +711,7 @@ describe('runKtxScan', () => {
|
|||
});
|
||||
|
||||
it('does not print live progress messages for non-TTY output', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
const runLocalScan = vi.fn(async (input: RunLocalScanOptions): Promise<LocalScanRunResult> => {
|
||||
await input.progress?.update(0.15, 'Inspecting database schema');
|
||||
return {
|
||||
|
|
@ -747,7 +747,7 @@ describe('runKtxScan', () => {
|
|||
});
|
||||
|
||||
it('uses terminal-aware visual styling only for TTY output', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
const runLocalScan = vi.fn(
|
||||
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
|
||||
runId: 'scan-run-1',
|
||||
|
|
@ -807,7 +807,7 @@ describe('runKtxScan', () => {
|
|||
});
|
||||
|
||||
it('honors NO_COLOR for TTY scan summaries', async () => {
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
const runLocalScan = vi.fn(
|
||||
async (_input: RunLocalScanOptions): Promise<LocalScanRunResult> => ({
|
||||
runId: 'scan-run-1',
|
||||
|
|
@ -853,11 +853,10 @@ describe('runKtxScan', () => {
|
|||
|
||||
it('passes native CLI adapters into local scan runs for mysql configs', async () => {
|
||||
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-'));
|
||||
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempProject });
|
||||
await writeFile(
|
||||
join(tempProject, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: mysql',
|
||||
|
|
@ -901,11 +900,10 @@ describe('runKtxScan', () => {
|
|||
|
||||
it('creates a native connector for standalone relationship scans', async () => {
|
||||
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-relationships-'));
|
||||
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempProject });
|
||||
await writeFile(
|
||||
join(tempProject, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: sqlite',
|
||||
|
|
@ -955,11 +953,10 @@ describe('runKtxScan', () => {
|
|||
|
||||
it('routes standalone postgres scans through the native connector before daemon fallback', async () => {
|
||||
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-postgres-'));
|
||||
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempProject });
|
||||
await writeFile(
|
||||
join(tempProject, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -1021,11 +1018,10 @@ describe('runKtxScan', () => {
|
|||
|
||||
it('passes native CLI adapters into local scan runs for clickhouse configs', async () => {
|
||||
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-clickhouse-'));
|
||||
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempProject });
|
||||
await writeFile(
|
||||
join(tempProject, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: clickhouse',
|
||||
|
|
@ -1072,11 +1068,10 @@ describe('runKtxScan', () => {
|
|||
|
||||
it('passes native CLI adapters into local scan runs for sqlserver configs', async () => {
|
||||
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-sqlserver-'));
|
||||
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempProject });
|
||||
await writeFile(
|
||||
join(tempProject, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: sqlserver',
|
||||
|
|
@ -1138,11 +1133,10 @@ describe('runKtxScan', () => {
|
|||
|
||||
it('passes native CLI adapters into local scan runs for bigquery configs', async () => {
|
||||
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-bigquery-'));
|
||||
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempProject });
|
||||
await writeFile(
|
||||
join(tempProject, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: bigquery',
|
||||
|
|
@ -1203,11 +1197,10 @@ describe('runKtxScan', () => {
|
|||
|
||||
it('passes native CLI adapters into local scan runs for snowflake configs', async () => {
|
||||
const tempProject = await mkdtemp(join(tmpdir(), 'ktx-scan-cli-native-snowflake-'));
|
||||
await initKtxProject({ projectDir: tempProject, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempProject });
|
||||
await writeFile(
|
||||
join(tempProject, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: snowflake',
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ describe('setup agents', () => {
|
|||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-'));
|
||||
await mkdir(join(tempDir, '.ktx', 'agents'), { recursive: true });
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ type ReadyProjectOverrides = Omit<Partial<KtxProjectConfig>, 'ingest' | 'llm' |
|
|||
};
|
||||
|
||||
async function writeReadyProject(projectDir: string, overrides: ReadyProjectOverrides = {}) {
|
||||
const defaults = buildDefaultKtxProjectConfig('revenue');
|
||||
const defaults = buildDefaultKtxProjectConfig();
|
||||
const readyConfig: KtxProjectConfig = {
|
||||
...defaults,
|
||||
setup: { database_connection_ids: ['warehouse'] },
|
||||
|
|
@ -595,7 +595,6 @@ describe('setup context build state', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: revenue',
|
||||
'connections: {}',
|
||||
'llm:',
|
||||
' provider:',
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ describe('setup databases step', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-databases-'));
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -242,7 +242,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -575,7 +574,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -622,7 +620,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -770,7 +767,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -815,7 +811,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -864,7 +859,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -936,7 +930,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -1010,7 +1003,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -1079,7 +1071,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -1146,7 +1137,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -1646,7 +1636,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -1939,7 +1928,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -2019,7 +2007,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' analytics:',
|
||||
' driver: bigquery',
|
||||
|
|
@ -2074,7 +2061,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -2123,7 +2109,6 @@ describe('setup databases step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ describe('setup embeddings step', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-embeddings-'));
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -446,11 +446,10 @@ describe('setup embeddings step', () => {
|
|||
|
||||
it('preserves already completed embeddings setup when no embedding args request changes', async () => {
|
||||
await mkdir(join(tempDir, '.ktx'), { recursive: true });
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse', force: true });
|
||||
await initKtxProject({ projectDir: tempDir, force: true });
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'setup:',
|
||||
' database_connection_ids: []',
|
||||
'connections: {}',
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ describe('setup Anthropic model step', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-models-'));
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir: tempDir });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -1049,11 +1049,10 @@ describe('setup Anthropic model step', () => {
|
|||
|
||||
it('preserves already completed llm setup when no model args request changes', async () => {
|
||||
await mkdir(join(tempDir, '.ktx'), { recursive: true });
|
||||
await initKtxProject({ projectDir: tempDir, projectName: 'warehouse', force: true });
|
||||
await initKtxProject({ projectDir: tempDir, force: true });
|
||||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'setup:',
|
||||
' database_connection_ids: []',
|
||||
'connections: {}',
|
||||
|
|
@ -1099,7 +1098,6 @@ describe('setup Anthropic model step', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'setup:',
|
||||
' database_connection_ids: []',
|
||||
'connections: {}',
|
||||
|
|
|
|||
|
|
@ -76,11 +76,10 @@ describe('setup project step', () => {
|
|||
|
||||
it('loads an existing project with --existing and drops config setup progress', async () => {
|
||||
const projectDir = join(tempDir, 'warehouse');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
|
|
@ -196,7 +195,7 @@ describe('setup project step', () => {
|
|||
expect.objectContaining({ message: `Create KTX project at ${projectDir}?` }),
|
||||
);
|
||||
expect(prompts.text).not.toHaveBeenCalled();
|
||||
expect(result.status === 'ready' ? result.project.config.project : '').toBe('ktx-project');
|
||||
expect(result.status === 'ready' ? result.project.configPath : '').toBe(join(projectDir, 'ktx.yaml'));
|
||||
expect(testIo.stdout()).toContain(`│ KTX will create:\n│ ${projectDir}`);
|
||||
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
initKtxProject,
|
||||
type KtxLocalProject,
|
||||
|
|
@ -156,7 +156,7 @@ async function persistProjectStep(project: KtxLocalProject): Promise<KtxLocalPro
|
|||
|
||||
async function createProject(projectDir: string, deps: KtxSetupProjectDeps): Promise<KtxLocalProject> {
|
||||
const initProject = deps.initProject ?? initKtxProject;
|
||||
const initialized = await initProject({ projectDir, projectName: basename(projectDir) || 'ktx-project' });
|
||||
const initialized = await initProject({ projectDir });
|
||||
return await persistProjectStep(initialized);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ describe('setup sources step', () => {
|
|||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-sources-'));
|
||||
projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'sources' });
|
||||
await initKtxProject({ projectDir });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -1024,6 +1024,8 @@ describe('setup sources step', () => {
|
|||
databaseMappings: { '1': 'warehouse' },
|
||||
syncEnabled: { '1': true },
|
||||
syncMode: 'ALL',
|
||||
selections: { collections: [], items: [] },
|
||||
defaultTagNames: [],
|
||||
},
|
||||
},
|
||||
deps: {
|
||||
|
|
@ -1181,6 +1183,8 @@ describe('setup sources step', () => {
|
|||
databaseMappings: { '1': 'warehouse' },
|
||||
syncEnabled: { '1': true },
|
||||
syncMode: 'ALL',
|
||||
selections: { collections: [], items: [] },
|
||||
defaultTagNames: [],
|
||||
},
|
||||
});
|
||||
const testPrompts = prompts({
|
||||
|
|
|
|||
|
|
@ -451,6 +451,8 @@ function buildMetabaseConnection(args: KtxSetupSourcesArgs): KtxProjectConnectio
|
|||
databaseMappings: { [String(args.metabaseDatabaseId)]: args.sourceWarehouseConnectionId },
|
||||
syncEnabled: { [String(args.metabaseDatabaseId)]: true },
|
||||
syncMode: 'ALL',
|
||||
selections: { collections: [], items: [] },
|
||||
defaultTagNames: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ describe('setup status', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: revenue',
|
||||
'llm:',
|
||||
' provider:',
|
||||
' backend: anthropic',
|
||||
|
|
@ -109,7 +108,6 @@ describe('setup status', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: revenue',
|
||||
'llm:',
|
||||
' provider:',
|
||||
...fixture.providerLines,
|
||||
|
|
@ -129,7 +127,6 @@ describe('setup status', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: revenue',
|
||||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
|
|
@ -162,7 +159,6 @@ describe('setup status', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: revenue',
|
||||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
|
|
@ -183,7 +179,6 @@ describe('setup status', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: revenue',
|
||||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
|
|
@ -206,7 +201,6 @@ describe('setup status', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: revenue',
|
||||
'setup:',
|
||||
' database_connection_ids: []',
|
||||
'connections:',
|
||||
|
|
@ -230,7 +224,7 @@ describe('setup status', () => {
|
|||
|
||||
it('reports agent status from the install manifest', async () => {
|
||||
await mkdir(join(tempDir, '.ktx', 'agents'), { recursive: true });
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
|
||||
await writeFile(
|
||||
join(tempDir, '.ktx/agents/install-manifest.json'),
|
||||
JSON.stringify(
|
||||
|
|
@ -256,7 +250,6 @@ describe('setup status', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: revenue',
|
||||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
|
|
@ -309,7 +302,6 @@ describe('setup status', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: revenue',
|
||||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
|
|
@ -319,7 +311,7 @@ describe('setup status', () => {
|
|||
' url: env:DATABASE_URL',
|
||||
' metabase:',
|
||||
' driver: metabase',
|
||||
' url: env:METABASE_URL',
|
||||
' api_url: https://metabase.example.test',
|
||||
' api_key_ref: env:METABASE_API_KEY',
|
||||
' warehouse_connection_id: warehouse',
|
||||
'llm:',
|
||||
|
|
@ -370,7 +362,7 @@ describe('setup status', () => {
|
|||
});
|
||||
|
||||
it('prints the readiness checklist for an existing project', async () => {
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
|
||||
|
||||
const rendered = formatKtxSetupStatus(await readKtxSetupStatus(tempDir));
|
||||
|
||||
|
|
@ -503,7 +495,7 @@ describe('setup status', () => {
|
|||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
|
|
@ -589,7 +581,7 @@ describe('setup status', () => {
|
|||
});
|
||||
|
||||
it('lets Back from new project creation return to the first setup intent menu', async () => {
|
||||
const existingConfig = 'project: revenue\nconnections: {}\n';
|
||||
const existingConfig = 'connections: {}\n';
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), existingConfig, 'utf-8');
|
||||
|
||||
const entryChoices = ['new-project', 'exit'];
|
||||
|
|
@ -645,7 +637,7 @@ describe('setup status', () => {
|
|||
const existingProjectDir = join(tempDir, 'existing');
|
||||
const newProjectDir = join(tempDir, 'fresh');
|
||||
await mkdir(existingProjectDir, { recursive: true });
|
||||
const existingConfig = 'project: revenue\nconnections: {}\n';
|
||||
const existingConfig = 'connections: {}\n';
|
||||
await writeFile(join(existingProjectDir, 'ktx.yaml'), existingConfig, 'utf-8');
|
||||
|
||||
const projectChoices = ['custom', 'create'];
|
||||
|
|
@ -722,7 +714,7 @@ describe('setup status', () => {
|
|||
const existingProjectDir = join(tempDir, 'existing');
|
||||
const newProjectDir = join(tempDir, 'fresh');
|
||||
await mkdir(existingProjectDir, { recursive: true });
|
||||
await writeFile(join(existingProjectDir, 'ktx.yaml'), 'project: revenue\nconnections: {}\n', 'utf-8');
|
||||
await writeFile(join(existingProjectDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
|
||||
|
||||
const projectChoices = ['custom', 'create'];
|
||||
const projectPrompts = {
|
||||
|
|
@ -1147,7 +1139,7 @@ describe('setup status', () => {
|
|||
});
|
||||
|
||||
it('lets Back from the first setup step return to the entry menu instead of exiting', async () => {
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'project: test\nconnections: {}\n', 'utf-8');
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
|
||||
const testIo = makeIo();
|
||||
|
||||
const entryChoices = ['setup', 'exit'];
|
||||
|
|
@ -1254,7 +1246,7 @@ describe('setup status', () => {
|
|||
it('runs sources after database setup', async () => {
|
||||
const calls: string[] = [];
|
||||
const io = makeIo();
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), ['project: revenue', 'connections: {}', ''].join('\n'), 'utf-8');
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
|
|
@ -1315,7 +1307,6 @@ describe('setup status', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: revenue',
|
||||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
|
|
@ -1374,7 +1365,6 @@ describe('setup status', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: revenue',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -1430,7 +1420,7 @@ describe('setup status', () => {
|
|||
it('runs context after sources and before agents in full setup', async () => {
|
||||
const calls: string[] = [];
|
||||
const io = makeIo();
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), ['project: revenue', 'connections: {}', ''].join('\n'), 'utf-8');
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
|
|
@ -1543,7 +1533,7 @@ describe('setup status', () => {
|
|||
it('runs agent setup after context succeeds in --agents mode', async () => {
|
||||
const calls: string[] = [];
|
||||
const io = makeIo();
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), ['project: revenue', 'connections: {}', ''].join('\n'), 'utf-8');
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
|
|
@ -1596,7 +1586,7 @@ describe('setup status', () => {
|
|||
projectDir: tempDir,
|
||||
installs: [{ target: 'codex' as const, scope: 'project' as const, mode: 'cli' as const }],
|
||||
}));
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), ['project: revenue', 'connections: {}', ''].join('\n'), 'utf-8');
|
||||
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
|
||||
|
||||
await expect(
|
||||
runKtxSetup(
|
||||
|
|
@ -1633,7 +1623,6 @@ describe('setup status', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: revenue',
|
||||
'setup:',
|
||||
' database_connection_ids:',
|
||||
' - warehouse',
|
||||
|
|
@ -1671,7 +1660,6 @@ describe('setup status', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: revenue',
|
||||
'setup:',
|
||||
' database_connection_ids: []',
|
||||
'connections: {}',
|
||||
|
|
@ -1778,7 +1766,6 @@ describe('setup status', () => {
|
|||
await writeFile(
|
||||
join(tempDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: revenue',
|
||||
'setup:',
|
||||
' database_connection_ids: []',
|
||||
'connections: {}',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest';
|
||||
import {
|
||||
ktxLocalStateDbPath,
|
||||
|
|
@ -317,7 +317,7 @@ export async function readKtxSetupStatus(projectDir: string): Promise<KtxSetupSt
|
|||
})) ?? [];
|
||||
|
||||
return {
|
||||
project: { path: resolvedProjectDir, ready: true, name: project.config.project },
|
||||
project: { path: resolvedProjectDir, ready: true, name: basename(project.projectDir) || project.projectDir },
|
||||
llm,
|
||||
embeddings,
|
||||
databases: databaseIds.map((connectionId) => ({
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ async function seedSlSource(input: {
|
|||
sourceName?: string;
|
||||
yaml?: string;
|
||||
}): Promise<void> {
|
||||
const project = await initKtxProject({ projectDir: input.projectDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: input.projectDir });
|
||||
await project.fileStore.writeFile(
|
||||
`semantic-layer/${input.connectionId ?? 'warehouse'}/${input.sourceName ?? 'orders'}.yaml`,
|
||||
input.yaml ?? ORDERS_YAML,
|
||||
|
|
@ -139,7 +139,7 @@ describe('runKtxSl', () => {
|
|||
|
||||
it('fails validation when a table-backed source declares columns absent from a matching warehouse manifest', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir });
|
||||
await project.fileStore.writeFile(
|
||||
'semantic-layer/postgres-warehouse/_schema/orbit_analytics.yaml',
|
||||
`tables:
|
||||
|
|
@ -189,7 +189,7 @@ joins: []
|
|||
|
||||
it('runs sl query and prints SQL output', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir });
|
||||
project.config.connections.warehouse = { driver: 'postgres' };
|
||||
await project.fileStore.writeFile(
|
||||
'semantic-layer/warehouse/orders.yaml',
|
||||
|
|
@ -246,7 +246,7 @@ joins: []
|
|||
|
||||
it('runs sl query from a JSON query file', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir });
|
||||
project.config.connections.warehouse = { driver: 'postgres' };
|
||||
await project.fileStore.writeFile(
|
||||
'semantic-layer/warehouse/orders.yaml',
|
||||
|
|
@ -313,7 +313,7 @@ joins: []
|
|||
|
||||
it('creates default sl query compute through the managed runtime helper', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir });
|
||||
project.config.connections.warehouse = { driver: 'postgres' };
|
||||
await project.fileStore.writeFile(
|
||||
'semantic-layer/warehouse/orders.yaml',
|
||||
|
|
@ -374,7 +374,7 @@ joins: []
|
|||
|
||||
it('executes sl query through the injected query executor', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir });
|
||||
project.config.connections.warehouse = { driver: 'postgres', url: 'postgres://example/db' };
|
||||
await project.fileStore.writeFile(
|
||||
'semantic-layer/warehouse/orders.yaml',
|
||||
|
|
@ -459,7 +459,7 @@ joins: []
|
|||
|
||||
it('executes sl query against a local SQLite connection through the default executor', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
const project = await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir });
|
||||
const dbPath = join(projectDir, 'warehouse.db');
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
|
|
@ -475,7 +475,6 @@ joins: []
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: sqlite',
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ async function writeSqliteScanConfig(projectDir: string, dbPath: string, enrich
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: sqlite',
|
||||
|
|
@ -218,7 +217,6 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: gateway-smoke',
|
||||
'llm:',
|
||||
' provider:',
|
||||
' backend: gateway',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { basename } from 'node:path';
|
||||
import type {
|
||||
KtxConfigIssue,
|
||||
KtxLocalProject,
|
||||
|
|
@ -8,6 +9,7 @@ import type {
|
|||
} from '@ktx/context/project';
|
||||
import type { PostgresPgssProbeResult } from '@ktx/context/ingest';
|
||||
import type { DoctorCheck } from './doctor.js';
|
||||
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
|
||||
|
||||
type ProjectStatusLevel = 'ok' | 'warn' | 'fail';
|
||||
type ProjectVerdict = 'ready' | 'partial' | 'blocked';
|
||||
|
|
@ -68,6 +70,8 @@ interface WarningItem {
|
|||
fix?: string;
|
||||
}
|
||||
|
||||
const PROJECT_READY_COMMANDS = KTX_NEXT_STEP_DIRECT_COMMANDS.map((step) => step.command);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -131,7 +135,7 @@ function buildLlmStatus(config: KtxProjectLlmConfig, env: NodeJS.ProcessEnv): Ll
|
|||
backend,
|
||||
model,
|
||||
status: 'fail',
|
||||
detail: 'no LLM configured — ktx ask will not work',
|
||||
detail: 'no LLM configured; research agent will not run',
|
||||
fix: 'Run: ktx setup (choose an LLM provider)',
|
||||
};
|
||||
}
|
||||
|
|
@ -570,7 +574,7 @@ function buildVerdict(
|
|||
if (llm.status === 'fail') {
|
||||
return {
|
||||
verdict: 'blocked',
|
||||
reason: 'LLM not configured — `ktx ask` will not work.',
|
||||
reason: 'LLM not configured; research agent will not run.',
|
||||
nextActions: ['ktx setup'],
|
||||
};
|
||||
}
|
||||
|
|
@ -604,7 +608,7 @@ function buildVerdict(
|
|||
return {
|
||||
verdict: 'ready',
|
||||
reason: 'Ready.',
|
||||
nextActions: ['ktx scan', 'ktx wiki', 'ktx sl ask "…"'],
|
||||
nextActions: [...PROJECT_READY_COMMANDS],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -650,7 +654,7 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
|
|||
const { verdict, reason, nextActions } = buildVerdict(llm, embeddings, connections, queryHistory, warnings);
|
||||
|
||||
return {
|
||||
projectName: config.project,
|
||||
projectName: basename(project.projectDir) || project.projectDir,
|
||||
projectDir: project.projectDir,
|
||||
config: configStatus,
|
||||
llm,
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ const connection = {
|
|||
dataset_id: 'analytics',
|
||||
credentials_json: JSON.stringify({ project_id: 'project-1', client_email: 'reader@example.test' }),
|
||||
location: 'US',
|
||||
};
|
||||
} as const;
|
||||
|
||||
describe('KtxBigQueryScanConnector', () => {
|
||||
it('resolves configuration safely', () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ You are backfilling knowledge from a historical chat transcript or archived SQL
|
|||
</role>
|
||||
|
||||
<stance>
|
||||
Moderately conservative. Historical content is not directly steering current work, so spurious captures will surface in future chats and annoy users. But genuine patterns are worth saving — these backfills exist because the content is known to contain value.
|
||||
Moderately conservative. Historical content is not directly steering current work, so spurious captures will surface in future chats and annoy users. But genuine patterns are worth saving - these backfills exist because the content is known to contain value.
|
||||
|
||||
Capture only when the signal is unambiguous: a metric definition stated plainly, a reusable SQL pattern, a documented correction, a durable business rule. Skip casual chatter and ambiguous interpretations.
|
||||
</stance>
|
||||
|
|
@ -12,10 +12,10 @@ Capture only when the signal is unambiguous: a metric definition stated plainly,
|
|||
1. Read the wiki and SL indexes to avoid creating duplicates.
|
||||
2. If the content has wiki-style signal, load the `wiki_capture` skill and follow its workflow.
|
||||
3. If the content has SL-style signal, load the `sl` skill and follow its Part 3 workflow.
|
||||
4. Prefer updating existing entries over creating new ones — backfills often duplicate existing knowledge.
|
||||
4. Prefer updating existing entries over creating new ones - backfills often duplicate existing knowledge.
|
||||
5. When done, exit the loop.
|
||||
</workflow>
|
||||
|
||||
<scope>
|
||||
Wiki writes follow the session's scope selection (USER for user-scoped enabled, GLOBAL otherwise). The `wiki_write` tool picks automatically — focus on capture judgment.
|
||||
Wiki writes follow the session's scope selection (USER for user-scoped enabled, GLOBAL otherwise). The `wiki_write` tool picks automatically - focus on capture judgment.
|
||||
</scope>
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ You are the reconciliation agent for a multi-file ingest bundle. Stage 3 WorkUni
|
|||
</role>
|
||||
|
||||
<stance>
|
||||
Parsimonious. Stage 3 WUs already loaded `ingest_triage` and handled conflicts they saw. Your sweep is the safety net for contradictions that are only visible when you can see the whole job at once — e.g. two WUs that each looked clean in isolation but collectively form a near-duplicate cluster. Do not redo work Stage 3 already did.
|
||||
Parsimonious. Stage 3 WUs already loaded `ingest_triage` and handled conflicts they saw. Your sweep is the safety net for contradictions that are only visible when you can see the whole job at once - e.g. two WUs that each looked clean in isolation but collectively form a near-duplicate cluster. Do not redo work Stage 3 already did.
|
||||
</stance>
|
||||
|
||||
<workflow>
|
||||
1. Load `ingest_triage`, then `sl_capture` + `wiki_capture`.
|
||||
2. Call `stage_list()` for the full index of this job's writes. If it is empty AND you have no evictions, exit — the runner short-circuits this case but the skill still teaches you to bail fast.
|
||||
2. Call `stage_list()` for the full index of this job's writes. If it is empty AND you have no evictions, exit - the runner short-circuits this case but the skill still teaches you to bail fast.
|
||||
3. If the system prompt includes `<canonical_pins>`, apply those pins before flagging a same-name or near-duplicate conflict. A pinned `canonicalArtifactKey` keeps the contested name when it is present in the Stage Index; competing variants keep or receive disambiguated names.
|
||||
4. Sweep both exact-key conflicts and near-duplicate writes. Compare WUs that wrote overlapping SL source names, overlapping wiki keys, the same `tables:` or `sl_refs:` action details, or obviously equivalent topic titles under different wiki keys. Call `stage_diff` to see the actual difference, and use `wiki_read`/`sl_read_source` when two different keys appear to describe the same table, metric, or source-of-truth mapping. If they're the same content, leave one canonical artifact and record the duplicate as subsumed. If they differ per `ingest_triage` rules, apply the correct resolution (rename + capture; election of canonical; silent replace for expression-only re-ingest change; or pinned canonical), then call `emit_conflict_resolution` with the artifact key and decision.
|
||||
5. For any `wiki_write`, `wiki_remove`, `sl_write_source`, or `sl_edit_source` call you make during reconciliation, include `rawPaths` with only the raw paths that directly caused that reconciliation action.
|
||||
|
|
@ -24,6 +24,6 @@ Wiki keys must be flat slugs, not directory paths. If a Stage 3 page used a path
|
|||
</scope>
|
||||
|
||||
<do_not>
|
||||
- Do not overwrite a Stage 3 WU's resolution that already matches `ingest_triage` output — that's churn.
|
||||
- Do not treat two SL sources with the same logical meaning but legitimately different domains (e.g. `finance.revenue` and `marketing.revenue`) as a conflict — that's by design.
|
||||
- Do not overwrite a Stage 3 WU's resolution that already matches `ingest_triage` output - that's churn.
|
||||
- Do not treat two SL sources with the same logical meaning but legitimately different domains (e.g. `finance.revenue` and `marketing.revenue`) as a conflict - that's by design.
|
||||
</do_not>
|
||||
|
|
|
|||
|
|
@ -10,19 +10,19 @@ A single artifact typically produces multiple actions: one SL source per table/v
|
|||
|
||||
<workflow>
|
||||
1. Review the wiki and SL indexes in the prompt. Prefer updating existing entries over creating duplicates.
|
||||
2. Load the `sl` skill for SL-writes and `wiki_capture` for wiki-writes. Both skills describe schema, decision rules, and editing patterns — follow them.
|
||||
2. Load the `sl` skill for SL-writes and `wiki_capture` for wiki-writes. Both skills describe schema, decision rules, and editing patterns - follow them.
|
||||
3. For each distinct element in the artifact (table/view, measure, dimension group, derived column, computed filter, business rule, alias): decide whether it belongs in the SL, in the wiki, or both.
|
||||
4. Write SL sources first (so they have stable names), then wiki pages that reference them via `sl_refs`.
|
||||
5. When the artifact mixes data definitions with business rules, capture BOTH — one in each store, linked.
|
||||
5. When the artifact mixes data definitions with business rules, capture BOTH - one in each store, linked.
|
||||
6. When you're done, exit the loop without calling any more tools.
|
||||
</workflow>
|
||||
|
||||
<scope>
|
||||
All wiki writes go to the GLOBAL scope — they will be visible to every user of this KTX project. Phrase wiki pages as objective business knowledge, not personal preference. The `wiki_write` tool handles scope selection automatically for external ingest.
|
||||
All wiki writes go to the GLOBAL scope - they will be visible to every user of this KTX project. Phrase wiki pages as objective business knowledge, not personal preference. The `wiki_write` tool handles scope selection automatically for external ingest.
|
||||
</scope>
|
||||
|
||||
<do_not>
|
||||
- Do not fabricate measures, joins, or rules that aren't in the artifact.
|
||||
- Do not invent column names. If a type is unclear, omit it rather than guess.
|
||||
- Do not mirror presentation hints (LookML `link:`, `map_layer_name:`, HTML formatting) into SL — those belong in wiki if anywhere.
|
||||
- Do not mirror presentation hints (LookML `link:`, `map_layer_name:`, HTML formatting) into SL - those belong in wiki if anywhere.
|
||||
</do_not>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<role>
|
||||
You capture durable knowledge from an analytics assistant's chat turn. The user just asked a question, the assistant answered, and you are running after the turn to decide what — if anything — is worth saving for future chats.
|
||||
You capture durable knowledge from an analytics assistant's chat turn. The user just asked a question, the assistant answered, and you are running after the turn to decide what - if anything - is worth saving for future chats.
|
||||
</role>
|
||||
|
||||
<criteria>
|
||||
|
|
@ -21,7 +21,7 @@ Skip:
|
|||
2. Identify durable knowledge OR reusable data patterns in the turn.
|
||||
3. If the turn has wiki-style signal (preferences, definitions, conventions), load the `wiki_capture` skill and follow its workflow.
|
||||
4. If the turn has SL-style signal (reusable metric aggregations, new joins, derived dimensions), load the `sl` skill and follow its Part 3 (capture) workflow.
|
||||
5. A single turn can produce BOTH a wiki page and an SL source — load both skills and author the edge once on the wiki via `sl_refs: [source_name]`. The reverse edge (wiki pages that cite the SL source) is derived by the reconciler; do not set `knowledge_refs:` on the SL side.
|
||||
5. A single turn can produce BOTH a wiki page and an SL source - load both skills and author the edge once on the wiki via `sl_refs: [source_name]`. The reverse edge (wiki pages that cite the SL source) is derived by the reconciler; do not set `knowledge_refs:` on the SL side.
|
||||
6. When you're done, exit the loop without calling any more tools. Do NOT emit a final text summary.
|
||||
</workflow>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ Reusable templates and scripts are durable knowledge regardless of subject matte
|
|||
|
||||
Analytics evidence (BI tools like Looker, Metabase, Tableau) is durable knowledge of *how the organization defines its metrics and segments*. The `signals.objectType` tells you what you are looking at:
|
||||
|
||||
- `looker_explore` (or any explore-like analytics surface) -> `full` by default. Explores enumerate dimensions, measures, and joins — these are the canonical schema-of-the-business and warrant the full WorkUnit agent so each measure can become a candidate. Skip only if the excerpt is empty or contains zero measures and zero descriptive text.
|
||||
- `looker_explore` (or any explore-like analytics surface) -> `full` by default. Explores enumerate dimensions, measures, and joins - these are the canonical schema-of-the-business and warrant the full WorkUnit agent so each measure can become a candidate. Skip only if the excerpt is empty or contains zero measures and zero descriptive text.
|
||||
- `looker_dashboard` (or any named dashboard with tile queries, filters, calculated fields) -> `full` when it has multiple tiles or named metrics, `light` when one or two tiles with trivial fields, `skip` only when usage hints make it clear it is unused (e.g. `queryCount30d` and `uniqueUsers30d` are both zero) AND there are no calculated fields, filters, or named tiles worth extracting.
|
||||
- `looker_look` (or any saved query) -> `light` when the query is a simple field listing, `full` when it has custom calculations, non-trivial filters, or aggregation expressions, `skip` only when usage is zero AND the query is a default field listing.
|
||||
|
||||
Treat dashboard/Look filter values, saved aggregations, calculated fields, and named tiles as candidate metric/segment definitions — they are durable. Do **not** mark BI evidence as `skip` solely because it is "configuration" or "tied to a data model"; that is exactly the durable knowledge we want to capture.
|
||||
Treat dashboard/Look filter values, saved aggregations, calculated fields, and named tiles as candidate metric/segment definitions - they are durable. Do **not** mark BI evidence as `skip` solely because it is "configuration" or "tied to a data model"; that is exactly the durable knowledge we want to capture.
|
||||
|
||||
Examples:
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ callers: [memory_agent]
|
|||
|
||||
# dbt → KTX (bundle ingest)
|
||||
|
||||
Use this skill for **uploaded** dbt projects (`dbt_project.yml` at stage root, `models/**`, `sources/**`, `schema.yml`). There is **no** `fetch()` in v1 — scheduled `dbt parse` / `manifest.json` pulls are out of scope; host-provided dbt sync may still backfill structured test metadata into `_schema` on the next sync.
|
||||
Use this skill for **uploaded** dbt projects (`dbt_project.yml` at stage root, `models/**`, `sources/**`, `schema.yml`). There is **no** `fetch()` in v1 - scheduled `dbt parse` / `manifest.json` pulls are out of scope; host-provided dbt sync may still backfill structured test metadata into `_schema` on the next sync.
|
||||
|
||||
## Mapping (models / sources → SL)
|
||||
|
||||
| dbt | KTX | Notes |
|
||||
|-----|--------|--------|
|
||||
| `models:` entry with `columns:` | **Overlay** on the manifest table with the same name (after `discover_data` / `entity_details`) | One SL source per physical table; model name may differ from DB name — resolve with `read_raw_file` + warehouse context. |
|
||||
| `models:` entry with `columns:` | **Overlay** on the manifest table with the same name (after `discover_data` / `entity_details`) | One SL source per physical table; model name may differ from DB name - resolve with `read_raw_file` + warehouse context. |
|
||||
| `sources:` → `tables:` | Same as models; use `identifier` when present instead of logical `name`. | Schema + name must match how the connection sees tables. |
|
||||
| Column `description` | `column_overrides[].descriptions.user` on the overlay | Do not overwrite `dbt` description keys from sync. |
|
||||
| `data_tests: not_null` / `unique` | Short hint in column `descriptions` or notes: “dbt: not null”, “dbt: unique” | Full structured metadata lands in manifest via **sync**; the skill keeps bundle-time SL text useful for the agent. |
|
||||
|
|
@ -73,4 +73,4 @@ If the same bundle also has MetricFlow `semantic_models:` / `metrics:`, the **`m
|
|||
- Do not invent column names, grain keys, or measure expressions from dbt model names, descriptions, tests, or common naming patterns.
|
||||
- Do not write computed `columns:`, `column_overrides:`, `grain:`, or `measures:` for a dbt model unless those exact column names are confirmed by dbt YAML columns or warehouse schema discovery.
|
||||
- Do not invent joins from `relationships` tests if the target model/table is not found in SL or the warehouse.
|
||||
- Do not read `peerFileIndex` paths — use `read_raw_file` only on `rawFiles` and `dependencyPaths` from the WorkUnit.
|
||||
- Do not read `peerFileIndex` paths - use `read_raw_file` only on `rawFiles` and `dependencyPaths` from the WorkUnit.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: Classify and resolve conflicts detected during bundle ingest (struc
|
|||
callers: [memory_agent]
|
||||
---
|
||||
|
||||
# Ingest Triage — conflict classification and resolution
|
||||
# Ingest Triage - conflict classification and resolution
|
||||
|
||||
This skill is loaded in two contexts:
|
||||
- By a Stage 3 WorkUnit agent when `sl_discover` reveals that a prior WU (or a prior sync) already wrote something that overlaps with what the current WU is about to write.
|
||||
|
|
@ -15,12 +15,12 @@ Apply the rules below before every write that could collide with an existing art
|
|||
## Decision tree
|
||||
|
||||
1. **Is this the same artifact I'm producing now, or a different one with the same name?**
|
||||
Read both. If names match and content matches (modulo whitespace): no conflict — skip the write, the prior one stands.
|
||||
Read both. If names match and content matches (modulo whitespace): no conflict - skip the write, the prior one stands.
|
||||
|
||||
2. **If content differs, is it an expression-only change (e.g. a different `sql:` body for the same measure name, same grain, same columns)?**
|
||||
Re-ingest change (expression-only): silently replace via `sl_edit_source`. No flag.
|
||||
|
||||
3. **If the difference is structural — grain, columns, filter, join shape — is the current bundle the re-ingest of a previously-ingested bundle (i.e. `priorProvenance` has a row for this raw file and artifact)?**
|
||||
3. **If the difference is structural - grain, columns, filter, join shape - is the current bundle the re-ingest of a previously-ingested bundle (i.e. `priorProvenance` has a row for this raw file and artifact)?**
|
||||
Re-ingest change (semantic break): replace + flag. Record in the IngestReport's `conflicts_resolved` list with `flagged_for_human: true`.
|
||||
|
||||
4. **If there's no prior-sync row (both are from THIS job), check for same-ingest contradictions:**
|
||||
|
|
@ -37,11 +37,11 @@ Apply the rules below before every write that could collide with an existing art
|
|||
|
||||
## Why same-ingest vs re-ingest differs
|
||||
|
||||
Within ONE bundle there's no user signal telling us which duplicate wins — we capture all variants and flag. Across bundles, re-uploading IS the signal that the new state is intended — we replace silently for expression changes and flag for semantic breaks.
|
||||
Within ONE bundle there's no user signal telling us which duplicate wins - we capture all variants and flag. Across bundles, re-uploading IS the signal that the new state is intended - we replace silently for expression changes and flag for semantic breaks.
|
||||
|
||||
## Naming disambiguation hints
|
||||
|
||||
When you rename to disambiguate, prefer domain suffixes that match the containing view/table/collection name: `customers.churn_risk_score` → `customers.churn_risk_engagement_based` (if the `customer_churn` view computes it from engagement); `billing.churn_risk_score` → `billing.churn_risk_billing_based`. Avoid numeric suffixes (`churn_risk_1`, `churn_risk_2`) — they disclose nothing.
|
||||
When you rename to disambiguate, prefer domain suffixes that match the containing view/table/collection name: `customers.churn_risk_score` → `customers.churn_risk_engagement_based` (if the `customer_churn` view computes it from engagement); `billing.churn_risk_score` → `billing.churn_risk_billing_based`. Avoid numeric suffixes (`churn_risk_1`, `churn_risk_2`) - they disclose nothing.
|
||||
|
||||
## Applying canonical pins
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ When you perform rename + capture, also write one page named `<canonical-concept
|
|||
- One paragraph per variant: what it computes, where it came from (raw file + line range), when to use it.
|
||||
- A closing "Choosing between these" paragraph if the variants are legitimately domain-specific.
|
||||
|
||||
Do not attempt to rank variants or pick a "best" — that's user-override territory.
|
||||
Do not attempt to rank variants or pick a "best" - that's user-override territory.
|
||||
|
||||
## Silence rules
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ LookML views map to SL sources, `measure:` to measures, `explore: { join: }` to
|
|||
| `view: X { sql_always_where: <p> }` | **Standalone** with `sql: SELECT * FROM <base> WHERE <p>` | Enforcement, not opt-in |
|
||||
| `explore: { join: Y { sql_on: …; relationship: … } }` | `joins:` entry `{ to: Y, on: "<local> = Y.<col>", relationship: … }` | On the overlay or standalone |
|
||||
| `conditionally_filter` / `always_filter` | `segments: [{ name, expr }]` | Callers reference by name |
|
||||
| Manifest entry | `_schema/*.yaml` | **Never edit** — auto-imported |
|
||||
| Manifest entry | `_schema/*.yaml` | **Never edit** - auto-imported |
|
||||
|
||||
Type map: `date`/`datetime`/`timestamp` → `time`; `yesno` → `boolean`; `number` → `number`; `string` → `string`. Ignore `drill_fields:` (UI only).
|
||||
|
||||
|
|
@ -92,14 +92,14 @@ SL source, `tables:` frontmatter, `sl_refs`, or `emit_unmapped_fallback`:
|
|||
`sql_execution({connectionName: "warehouse", sql: "SELECT 1 FROM analytics.orders LIMIT 0"})`.
|
||||
Replace `warehouse`, `analytics`, and `orders` with the verified connection,
|
||||
schema or dataset, and table from the WorkUnit evidence.
|
||||
3. Use only those names in `sql:`, `columns:`, and `grain:`. Map each `dimension_group` to ONE `{ name: <physical_col>, type: time, role: time }` entry — never one per timeframe.
|
||||
3. Use only those names in `sql:`, `columns:`, and `grain:`. Map each `dimension_group` to ONE `{ name: <physical_col>, type: time, role: time }` entry - never one per timeframe.
|
||||
|
||||
| LookML input | KTX `columns:` entry |
|
||||
|---|---|
|
||||
| `dimension_group: month { type: time; timeframes: [month]; sql: ${TABLE}.month_date ;; }` | `{ name: month_date, type: time, role: time }` |
|
||||
| `dimension_group: date { type: time; timeframes: [raw, date, week, month]; sql: ${TABLE}.date ;; }` | `{ name: date, type: time, role: time }` — single entry, NOT `date_raw`/`date_date`/`date_week` |
|
||||
| `dimension_group: date { type: time; timeframes: [raw, date, week, month]; sql: ${TABLE}.date ;; }` | `{ name: date, type: time, role: time }` - single entry, NOT `date_raw`/`date_date`/`date_week` |
|
||||
|
||||
**After every `sl_write_source`**: call `sl_validate`. It runs `SELECT * FROM (<your sql:>) LIMIT 0` against the connection. If a column name was invented, the warehouse's `Unrecognized name: …` error comes back verbatim. Treat that as a hard failure — re-read the real columns with `sl_discover` and rewrite.
|
||||
**After every `sl_write_source`**: call `sl_validate`. It runs `SELECT * FROM (<your sql:>) LIMIT 0` against the connection. If a column name was invented, the warehouse's `Unrecognized name: …` error comes back verbatim. Treat that as a hard failure - re-read the real columns with `sl_discover` and rewrite.
|
||||
|
||||
## Provenance markers
|
||||
|
||||
|
|
@ -110,13 +110,13 @@ When a wiki mixes LookML source prose with `sl_discover` output, tag sections:
|
|||
Customers fan out many-to-one into `accounts` via `account_id`.
|
||||
<!-- /from -->
|
||||
<!-- from: bq_schema -->
|
||||
`customers.admin_user_id` is nullable — orphan rows exist.
|
||||
`customers.admin_user_id` is nullable - orphan rows exist.
|
||||
<!-- /from -->
|
||||
```
|
||||
|
||||
Invisible in most renderers; lets a future pass audit provenance.
|
||||
|
||||
## Example 1 — overlay (thin wrapper)
|
||||
## Example 1 - overlay (thin wrapper)
|
||||
|
||||
LookML (excerpt):
|
||||
|
||||
|
|
@ -155,7 +155,7 @@ joins:
|
|||
relationship: many_to_one
|
||||
```
|
||||
|
||||
## Example 2 — standalone from `derived_table`
|
||||
## Example 2 - standalone from `derived_table`
|
||||
|
||||
```lookml
|
||||
view: lab_results {
|
||||
|
|
@ -189,7 +189,7 @@ measures:
|
|||
- { name: avg_delta, expr: "avg(delta)" }
|
||||
```
|
||||
|
||||
## Example 3 — standalone with `sql_always_where`
|
||||
## Example 3 - standalone with `sql_always_where`
|
||||
|
||||
```lookml
|
||||
view: rpt_daily_braze_email {
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ For each card:
|
|||
4. Decide:
|
||||
- Simple aggregation on a table that already has a source → `sl_edit_source` to add a measure.
|
||||
- Join between tables that should be linked in the SL graph → `sl_edit_source` to add a join.
|
||||
- Complex derived SQL (CTEs, multi-layer aggregation, scoring models) → `sl_write_source` with `source_type: sql`. When the SQL projects/filters from a single manifest-backed base table, set `inherits_columns_from: <manifest_key>` so columns inherit type and description from the manifest — see `sl_capture` skill for the slim form. Use `sl_discover` to discover the manifest key from the table reference in the SQL (it accepts `MARTS.CONSIGNMENTS`, `ANALYTICS.MARTS.CONSIGNMENTS`, or `CONSIGNMENTS`).
|
||||
- Complex derived SQL (CTEs, multi-layer aggregation, scoring models) → `sl_write_source` with `source_type: sql`. When the SQL projects/filters from a single manifest-backed base table, set `inherits_columns_from: <manifest_key>` so columns inherit type and description from the manifest - see `sl_capture` skill for the slim form. Use `sl_discover` to discover the manifest key from the table reference in the SQL (it accepts `MARTS.CONSIGNMENTS`, `ANALYTICS.MARTS.CONSIGNMENTS`, or `CONSIGNMENTS`).
|
||||
- New base table not yet in the semantic layer → `sl_write_source` with `source_type: table`.
|
||||
- Trivial query (`SELECT *`, simple `COUNT(*)` with no business logic) → do nothing; the runner will record this card as `action_type='skipped'`.
|
||||
- Duplicate of an existing measure → same as trivial; do nothing for this card.
|
||||
|
|
@ -102,7 +102,7 @@ Overlay shape: `name:` plus any of `measures:`, `segments:`, `descriptions:`, `j
|
|||
|
||||
**Join discovery:** When your card's SQL references warehouse tables (e.g. in `FROM` or `JOIN` clauses), call `sl_discover({ query: '<table>' })` before writing. The matching manifest entry's `name` is the value you use in `joins: [- to: <name>]` only when the card output exposes a local key that matches the target source grain (for example `account_id = mart_account_segments.account_id`). Do not declare a KTX join just because the card SQL joins that table internally. If the output only exposes display fields such as `account_name`, keep the SQL source self-contained or project the key before adding the join. Use `many_to_one` for FK-to-dimension joins, `one_to_many` for the reverse.
|
||||
|
||||
**Hard rule on join columns (prevents broken joins):** For every join you declare, the local column on the left of `on:` MUST be (a) present in your source's projected output and (b) a key/ID column, never a display value. If the natural FK isn't in your SELECT, add it to SELECT before declaring the join. Joining `account_name = mart_account_segments.account_id` is always wrong — names are not identifiers and the equality produces zero matches. The validator rejects this with a "display value to identifier" error; the tool will refuse to save it. Add `account_id` to your SELECT and join on `account_id = mart_account_segments.account_id`, or omit the join entirely.
|
||||
**Hard rule on join columns (prevents broken joins):** For every join you declare, the local column on the left of `on:` MUST be (a) present in your source's projected output and (b) a key/ID column, never a display value. If the natural FK isn't in your SELECT, add it to SELECT before declaring the join. Joining `account_name = mart_account_segments.account_id` is always wrong - names are not identifiers and the equality produces zero matches. The validator rejects this with a "display value to identifier" error; the tool will refuse to save it. Add `account_id` to your SELECT and join on `account_id = mart_account_segments.account_id`, or omit the join entirely.
|
||||
|
||||
## priorProvenance
|
||||
|
||||
|
|
@ -114,7 +114,7 @@ If the WU prompt includes a `priorProvenance` section for a card, it tells you w
|
|||
|
||||
## Deduplication
|
||||
|
||||
Before writing, scan all cards in this WU for near-duplicate groups — cards whose `resolvedSql` shares the same CTEs, base tables, joins, and aggregation structure but differs only in:
|
||||
Before writing, scan all cards in this WU for near-duplicate groups - cards whose `resolvedSql` shares the same CTEs, base tables, joins, and aggregation structure but differs only in:
|
||||
- Trailing filters (e.g. `date_trunc(week, date)` vs `date_trunc(month, date)`).
|
||||
- Minor `WHERE` clause variations.
|
||||
- Column aliases or output column subsets.
|
||||
|
|
@ -124,7 +124,7 @@ When you find a group of near-duplicates:
|
|||
1. Create ONE generalized source from the most comprehensive card in the group.
|
||||
2. Strip card-specific trailing filters from the SQL so the source covers all variants (e.g. keep daily grain instead of filtering to week/month).
|
||||
3. If each card had a distinct measure or filter, add them as separate measures on the single source.
|
||||
4. For all cards except the canonical one, do nothing — they'll be recorded as `action_type='skipped'` automatically by the runner.
|
||||
4. For all cards except the canonical one, do nothing - they'll be recorded as `action_type='skipped'` automatically by the runner.
|
||||
|
||||
Do NOT merge cards with fundamentally different business logic, even if they share CTEs.
|
||||
|
||||
|
|
@ -132,7 +132,7 @@ Do NOT merge cards with fundamentally different business logic, even if they sha
|
|||
|
||||
When a card's `resolvedSql` contains `GROUP BY` with aggregation functions (`SUM`, `COUNT`, `AVG`, …):
|
||||
|
||||
1. **Detect**: simple aggregation on base tables/joins — `SELECT` with `GROUP BY`, no complex CTEs or window functions.
|
||||
1. **Detect**: simple aggregation on base tables/joins - `SELECT` with `GROUP BY`, no complex CTEs or window functions.
|
||||
2. **Decompose**: strip the `GROUP BY` and aggregation functions. Keep `FROM`, `JOIN`, and `WHERE` intact.
|
||||
3. **Expose row-level columns**: include the grouped-by columns AND the raw columns being aggregated (e.g. `money_out` instead of `SUM(money_out) AS total_money_out`).
|
||||
4. **Define aggregations as measures**: convert each aggregation into a KSL measure (e.g. `sum(money_out)`).
|
||||
|
|
@ -144,17 +144,17 @@ Exception: keep the pre-aggregated SQL when the query involves multi-CTE pipelin
|
|||
|
||||
Every card carries a `resolvedSql` field. Check the staged card's `resolutionStatus` first:
|
||||
|
||||
- `resolutionStatus: "resolved"` — `{{#N}}` references are inlined and `[[ ... ]]` optional clauses have been dropped locally. If the resolved SQL contains no other parameters the SQL is executable as-is. If the card had **required** (non-bracketed) `{{ var }}` placeholders, the SQL is prefixed with a placeholder-warning comment block listing every dummy substitution Metabase made — see "Step A" below.
|
||||
- `resolutionStatus: "fallback"` — Metabase failed to resolve. The SQL still contains `{{#N}}`, `{{#N-name}} alias`, `{{ var }}`, and `[[ ... ]]` syntax. Do the translation steps below before writing a source.
|
||||
- `resolutionStatus: "resolved"` - `{{#N}}` references are inlined and `[[ ... ]]` optional clauses have been dropped locally. If the resolved SQL contains no other parameters the SQL is executable as-is. If the card had **required** (non-bracketed) `{{ var }}` placeholders, the SQL is prefixed with a placeholder-warning comment block listing every dummy substitution Metabase made - see "Step A" below.
|
||||
- `resolutionStatus: "fallback"` - Metabase failed to resolve. The SQL still contains `{{#N}}`, `{{#N-name}} alias`, `{{ var }}`, and `[[ ... ]]` syntax. Do the translation steps below before writing a source.
|
||||
|
||||
### Step A — Handle dummy-substituted placeholders (resolved cards only)
|
||||
### Step A - Handle dummy-substituted placeholders (resolved cards only)
|
||||
|
||||
When a card has a required `{{ var }}` outside any `[[ ]]` block, the resolver substitutes a **dummy value** purely so Metabase's parser will accept the query. The resulting SQL is prefixed with a comment like:
|
||||
|
||||
```sql
|
||||
-- PLACEHOLDER_WARNING: this SQL was extracted from a Metabase card with
|
||||
-- unbound template parameters. The placeholders below were substituted with DUMMY
|
||||
-- values to satisfy Metabase's parser — they DO NOT represent intended filters.
|
||||
-- values to satisfy Metabase's parser - they DO NOT represent intended filters.
|
||||
-- Drop the corresponding clauses (or expose them as runtime SL filters) before
|
||||
-- persisting this SQL as a semantic-layer source.
|
||||
-- {{ auction_end }} (type=dimension, widget=date/all-options) → '2020-01-01~2020-12-31'
|
||||
|
|
@ -165,7 +165,7 @@ WHERE start_date >= '2020-01-01' AND start_date < '2021-01-01' AND status = 'pla
|
|||
|
||||
For each listed placeholder: locate the WHERE clause(s) in the SQL that reference the dummy literal and **drop them**, then strip the warning comment. SL chat-time filters compose narrowing predicates dynamically, so the source should represent the unfiltered dataset.
|
||||
|
||||
For `fallback` cards, dropping is simpler — the SQL still has the `[[ ... ]]` brackets and `{{ var }}` placeholders intact:
|
||||
For `fallback` cards, dropping is simpler - the SQL still has the `[[ ... ]]` brackets and `{{ var }}` placeholders intact:
|
||||
|
||||
```sql
|
||||
-- before:
|
||||
|
|
@ -177,18 +177,18 @@ WHERE 1=1
|
|||
WHERE 1=1
|
||||
```
|
||||
|
||||
### Step B — Inline `{{#N}}` references (fallback cards only)
|
||||
### Step B - Inline `{{#N}}` references (fallback cards only)
|
||||
|
||||
Resolved cards already have `{{#N}}` inlined for you. For `fallback` cards, each `{{#N}}` (or `{{#N-some-slug}}`) in the SQL refers to another card's `resolvedSql`. The referenced card is in the WU's `rawFiles` or `dependencyPaths`. Read it with `read_raw_file`, then inline its SQL.
|
||||
|
||||
If the reference has an alias (`from {{#5996-listing-interactions}} tb`), the **outer** SQL probably uses that alias (`select tb.* ...`, `tb.column_name`, etc.). When you inline, you must EITHER:
|
||||
|
||||
1. **Pick a single base table inside the inlined SQL and rename its alias to the outer alias.** Useful when the inlined card is `SELECT * FROM listings JOIN ...` — set the LISTINGS alias to `tb` and `tb.*` keeps working in the outer query.
|
||||
1. **Pick a single base table inside the inlined SQL and rename its alias to the outer alias.** Useful when the inlined card is `SELECT * FROM listings JOIN ...` - set the LISTINGS alias to `tb` and `tb.*` keeps working in the outer query.
|
||||
2. **Replace the outer alias references with explicit columns from the inlined SQL.** Useful when the inlined card has multiple JOINs and `tb.*` is ambiguous.
|
||||
|
||||
Never leave the outer alias dangling: after inlining, **grep your SQL for the outer alias name and rewrite or remove every reference**. A leftover `tb.*` with no `tb` table is the most common failure mode here.
|
||||
|
||||
### Step C — Inlining cleanup checklist
|
||||
### Step C - Inlining cleanup checklist
|
||||
|
||||
After Steps A and B, your SQL must:
|
||||
|
||||
|
|
@ -209,11 +209,11 @@ For `source_type: sql`:
|
|||
- If `sl_discover` resolves the table, it is not outside the manifest. Do not write an `unmapped-table-*` fallback for resolved `orbit_raw`, `mart`, or other manifest-backed sources just because they appear inside card SQL.
|
||||
- If `sl_discover` cannot resolve a referenced table at all, write a single-line `wiki_write` with key `unmapped-table-<table_name>` and `rawPaths: ["cards/<id>.json"]` so the gap is documented, then call `emit_unmapped_fallback` with the staged card path as `rawPath`, `reason: "missing_target_table"`, `tableRef: "<table_name>"`, and `fallback: "wiki_only"`. Do not use this fallback if `sl_discover` resolved the table/source.
|
||||
|
||||
Joins on manifest-backed names compose: the manifest's joins are inherited automatically, and any overlay `joins:` are merged on top (deduped by `to` + `on`). Use `disable_joins: ["<on-clause>"]` in the overlay to suppress a specific manifest join. If `sl_discover` shows a manifest-backed source with `Joins: 0` and the warehouse FK metadata is genuinely absent, declaring application-level joins via the overlay is fair game — bootstrap with `sl_write_source` (overlay shape above), then refine via `sl_edit_source`.
|
||||
Joins on manifest-backed names compose: the manifest's joins are inherited automatically, and any overlay `joins:` are merged on top (deduped by `to` + `on`). Use `disable_joins: ["<on-clause>"]` in the overlay to suppress a specific manifest join. If `sl_discover` shows a manifest-backed source with `Joins: 0` and the warehouse FK metadata is genuinely absent, declaring application-level joins via the overlay is fair game - bootstrap with `sl_write_source` (overlay shape above), then refine via `sl_edit_source`.
|
||||
|
||||
## Cross-card references (`{{#N}}`)
|
||||
|
||||
Resolved cards (`resolutionStatus: "resolved"`) have these inlined for you. Unresolved cards (`resolutionStatus: "fallback"`) need manual handling — see "SQL translation from raw native to KSL" above.
|
||||
Resolved cards (`resolutionStatus: "resolved"`) have these inlined for you. Unresolved cards (`resolutionStatus: "fallback"`) need manual handling - see "SQL translation from raw native to KSL" above.
|
||||
|
||||
## Provenance markers
|
||||
|
||||
|
|
@ -237,7 +237,7 @@ Source definitions must follow ktx-sl YAML conventions:
|
|||
- `columns`: all columns with correct types (`string`, `number`, `time`, `boolean`).
|
||||
- Time columns: mark with `role: time`.
|
||||
- `joins`: use correct `relationship` types (`many_to_one` for FK→PK, `one_to_many` for reverse).
|
||||
- `joins.on`: `local_column = TARGET_SOURCE.target_column` — the right side MUST include the target source name.
|
||||
- `joins.on`: `local_column = TARGET_SOURCE.target_column` - the right side MUST include the target source name.
|
||||
- `measures.expr`: aggregation expression (e.g. `"sum(amount)"`); optional `filter` for business rules; required `description`.
|
||||
|
||||
Measure naming: descriptive `snake_case` (e.g. `total_revenue`, `avg_order_value`).
|
||||
|
|
@ -250,4 +250,4 @@ Measure naming: descriptive `snake_case` (e.g. `total_revenue`, `avg_order_value
|
|||
- If two measures differ only by a filter (e.g. `revenue` vs `paid_revenue`), they are distinct.
|
||||
- Use the card's `name` + `description` to write meaningful measure descriptions.
|
||||
- When multiple cards in a WU are near-duplicates, create ONE generalized source; the runner will skip the rest automatically.
|
||||
- Process every card in the WU — don't stop early.
|
||||
- Process every card in the WU - don't stop early.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ A MetricFlow `semantic_model` maps to an SL source; MetricFlow `measures` map to
|
|||
| `semantic_model: X { model: ref('t') }` with measures + dimensions | **Overlay** at `<connId>/X.yaml` with `measures`, computed-only `columns`, `column_overrides`, `joins` | The `model:` ref resolves to a manifest table. |
|
||||
| `semantic_model: X { model: source('s','t') }` | **Overlay** at `<connId>/X.yaml` over table `t`. | Same shape; `source()` still resolves to a physical table. |
|
||||
| `semantic_model: X { model: <literal> }` with no manifest entry | **Standalone** with explicit `sql:`, `grain:`, `columns:` | Happens when the dbt manifest isn't available. |
|
||||
| `semantic_model: Y { extends: X }` | **Merge** Y's measures/dimensions/entities into X's overlay, or write a single overlay named for the most-derived child (Y) containing both X's and Y's primitives | Do not emit a second overlay for X — flatten. |
|
||||
| `semantic_model: Y { extends: X }` | **Merge** Y's measures/dimensions/entities into X's overlay, or write a single overlay named for the most-derived child (Y) containing both X's and Y's primitives | Do not emit a second overlay for X - flatten. |
|
||||
| `measures: [{ name, agg, expr }]` | `measures: [{ name, expr: "<agg>(<expr>)" }]` | Aggregation inlined. `agg: count_distinct` → `count(distinct ...)`. |
|
||||
| `entities: [{ name, type: primary }]` | `grain: [<entity_name-or-expr>]` on the overlay/standalone | Primary/unique entities drive grain. |
|
||||
| `entities: [{ name, type: foreign }]` | `joins:` entry joining to the primary-entity's semantic_model | Only when a matching primary is discoverable. |
|
||||
|
|
@ -24,10 +24,10 @@ A MetricFlow `semantic_model` maps to an SL source; MetricFlow `measures` map to
|
|||
| `metrics: [{ type: derived, type_params: { expr, metrics } }]` | **Derived measure** on whichever source owns the referenced measures, with `expr:` referencing measure names | If the metric spans models, still write it once on the source owning the "primary" measure (the one the agent judges most central). Mention the cross-model chain in the description. |
|
||||
| `metrics: [{ type: ratio, type_params: { numerator, denominator } }]` | Same as derived; `expr: "numerator / NULLIF(denominator, 0)"` if no explicit expr | Safe-division by default. |
|
||||
| `metrics: [{ type: cumulative, type_params: { window, grain_to_date } }]` | **Standalone** source with a window-function SQL; reference the resulting column as a normal measure | KTX SL has no first-class cumulative primitive (spec Non-goals). |
|
||||
| `metrics: [{ type: conversion }]` | **Flag for human** — do NOT write. Emit a wiki note describing the intended semantics. | No KTX equivalent in v1. |
|
||||
| `metrics: [{ type: conversion }]` | **Flag for human** - do NOT write. Emit a wiki note describing the intended semantics. | No KTX equivalent in v1. |
|
||||
| Metric not mappable | Wiki page `<metric_name>-definition.md` with the full YAML body quoted | Capture the intent even if we can't emit SL. |
|
||||
|
||||
Type map: MetricFlow `time` to KTX `time`; `categorical` to `string`; `number` to `number`; `boolean` to `boolean`. Follow `expr` over `name` when both differ — `expr` is the physical column.
|
||||
Type map: MetricFlow `time` to KTX `time`; `categorical` to `string`; `number` to `number`; `boolean` to `boolean`. Follow `expr` over `name` when both differ - `expr` is the physical column.
|
||||
|
||||
Verify each MetricFlow model source table with entity_details before producing
|
||||
the corresponding sl_write_source.
|
||||
|
|
@ -67,7 +67,7 @@ Within one WorkUnit, multiple semantic_models linked by `extends:` are guarantee
|
|||
1. Start with the most-derived child (the one that no other semantic_model extends).
|
||||
2. Walk the `extends:` chain upward, accumulating measures, dimensions, entities.
|
||||
3. Write ONE overlay/standalone, named for the most-derived child's SL-appropriate name (not the base).
|
||||
4. Parents that lack their own distinctive content should NOT get a separate overlay. If a parent has unique measures a child doesn't inherit, consider whether the base is used elsewhere — if yes, write both; if no, still one overlay.
|
||||
4. Parents that lack their own distinctive content should NOT get a separate overlay. If a parent has unique measures a child doesn't inherit, consider whether the base is used elsewhere - if yes, write both; if no, still one overlay.
|
||||
5. Measure/dimension name collisions: child wins, but note the overridden parent in the overlay's description or in a sibling wiki page.
|
||||
|
||||
The spec's worked example has `orders`, `orders_ext` (extends orders), and `metrics/orders_final.yml` (defines `revenue` referencing both). The right output is ONE overlay named `orders_ext` (or `orders` if the team's naming favors the base) containing `order_count`, `gross_amount`, `refund_amount`, and a derived `revenue` measure. Provenance tags point to all three source files.
|
||||
|
|
@ -88,9 +88,9 @@ call `sql_execution` with the same warehouse connection name, for example:
|
|||
`sql:` must be sourced from raw files, `entity_details`, or a successful SQL
|
||||
probe.
|
||||
|
||||
After every `sl_write_source`, call `sl_validate`. The warehouse will reject invented columns with `Unrecognized name: <name>` — treat as a hard failure and re-read the schema.
|
||||
After every `sl_write_source`, call `sl_validate`. The warehouse will reject invented columns with `Unrecognized name: <name>` - treat as a hard failure and re-read the schema.
|
||||
|
||||
## Cumulative metrics — sql-standalone fallback
|
||||
## Cumulative metrics - sql-standalone fallback
|
||||
|
||||
KTX SL has no first-class `window:` or `grain_to_date:` primitive in v1 (spec Non-goals). Translate a MetricFlow cumulative metric to a standalone SL source with a window-function SQL:
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ measures:
|
|||
|
||||
Pick the time column based on the semantic_model's `defaults.agg_time_dimension` (e.g. `ordered_at`). If the MetricFlow config omits it, probe the base table for time-typed columns and choose the most obvious. After writing the standalone SQL source, call `emit_unmapped_fallback` with `rawPath` set to the MetricFlow file path, `reason: "cumulative_metric_unsupported"`, and `fallback: "sql_standalone"`.
|
||||
|
||||
## Conversion metrics — flag for human
|
||||
## Conversion metrics - flag for human
|
||||
|
||||
```yaml
|
||||
metrics:
|
||||
|
|
@ -159,7 +159,7 @@ name: orders_ext
|
|||
|
||||
Line ranges (`#L<start>-<end>`) point to the exact YAML span within the file (the `semantic_models:` entry for its own `name`). Use `read_raw_span` to identify those ranges before writing.
|
||||
|
||||
## Example 1 — single semantic_model to overlay
|
||||
## Example 1 - single semantic_model to overlay
|
||||
|
||||
```yaml
|
||||
# MetricFlow:
|
||||
|
|
@ -185,7 +185,7 @@ measures:
|
|||
grain: [order_id]
|
||||
```
|
||||
|
||||
## Example 2 — extends chain → one flattened overlay
|
||||
## Example 2 - extends chain → one flattened overlay
|
||||
|
||||
```yaml
|
||||
# MetricFlow:
|
||||
|
|
@ -232,7 +232,7 @@ measures:
|
|||
grain: [order_id]
|
||||
```
|
||||
|
||||
## Example 3 — derived metric spanning two semantic_models
|
||||
## Example 3 - derived metric spanning two semantic_models
|
||||
|
||||
```yaml
|
||||
# models/sales.yml
|
||||
|
|
@ -256,7 +256,7 @@ metrics:
|
|||
metrics: [{name: revenue}, {name: cost}]
|
||||
```
|
||||
|
||||
Because the WorkUnit bundles all three files (cross-component union via the metric), write the derived measure on ONE of the two sources — pick the source whose domain "owns" the metric (here, `sales` — margin is inherently a sales metric). Cross-source references aren't native in KTX SL; treat the metric's operands as already-resolvable in the target source's query context OR emit a standalone SQL that joins the two tables:
|
||||
Because the WorkUnit bundles all three files (cross-component union via the metric), write the derived measure on ONE of the two sources - pick the source whose domain "owns" the metric (here, `sales` - margin is inherently a sales metric). Cross-source references aren't native in KTX SL; treat the metric's operands as already-resolvable in the target source's query context OR emit a standalone SQL that joins the two tables:
|
||||
|
||||
```yaml
|
||||
# <connId>/sales.yaml
|
||||
|
|
@ -269,7 +269,7 @@ measures:
|
|||
```
|
||||
|
||||
```yaml
|
||||
# <connId>/margin.yaml — standalone because it spans two tables
|
||||
# <connId>/margin.yaml - standalone because it spans two tables
|
||||
# <!-- from: .../models/sales.yml#L1-8 -->
|
||||
# <!-- from: .../models/costs.yml#L1-8 -->
|
||||
# <!-- from: .../metrics/margin.yml#L1-8 -->
|
||||
|
|
@ -292,7 +292,7 @@ measures:
|
|||
|
||||
Also write a wiki page at `wiki/global/margin-metric.md` explaining the cross-source origin.
|
||||
|
||||
## Example 4 — filtered metric creates a new measure
|
||||
## Example 4 - filtered metric creates a new measure
|
||||
|
||||
```yaml
|
||||
metrics:
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ Search existing wiki pages for the same `tables:` or `sl_refs:` frontmatter and
|
|||
- Do not create SL sources under the Notion connection just because a page mentions a warehouse, dbt, Looker, or Metabase object. Use the mapped warehouse/source connection after discovery, or emit an unmapped fallback and write wiki-only.
|
||||
- Distinguish fallback reasons precisely: if a non-Notion warehouse/dbt connection exists but `sl_discover` cannot find the named table/source, use `no_physical_table`; reserve `no_connection_mapping` for cases where there is no plausible non-Notion target connection at all.
|
||||
- If `sl_discover` resolves the table/source, do not call `emit_unmapped_fallback` for that table. Use the resolved source for `sl_refs`, overlay edits, or wiki-only documentation.
|
||||
- When calling `emit_unmapped_fallback`, pass the table or source identifier as `tableRef` (e.g. `tableRef: "<schema>.<table>"`) — the tool generates the canonical detail string from the reason code and `tableRef`. Use the optional `clarification` field only to add context that does not contradict the reason. Do not restate the reason in `clarification`.
|
||||
- When calling `emit_unmapped_fallback`, pass the table or source identifier as `tableRef` (e.g. `tableRef: "<schema>.<table>"`) - the tool generates the canonical detail string from the reason code and `tableRef`. Use the optional `clarification` field only to add context that does not contradict the reason. Do not restate the reason in `clarification`.
|
||||
|
||||
## Identifier Verification Protocol
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: sl
|
||||
description: KTX's semantic layer — a structured catalog of sources (tables/views), measures, joins, and segments expressed as YAML. Covers the schema and how to query it via `sl_query`. Use when the task involves querying pre-defined metrics (ARR, churn, retention, LTV, MAU) or reading SL source YAML to understand the catalog. Capture is handled by the `sl_capture` skill (memory-agent only).
|
||||
description: KTX's semantic layer - a structured catalog of sources (tables/views), measures, joins, and segments expressed as YAML. Covers the schema and how to query it via `sl_query`. Use when the task involves querying pre-defined metrics (ARR, churn, retention, LTV, MAU) or reading SL source YAML to understand the catalog. Capture is handled by the `sl_capture` skill (memory-agent only).
|
||||
---
|
||||
|
||||
# Semantic Layer
|
||||
|
|
@ -8,10 +8,10 @@ description: KTX's semantic layer — a structured catalog of sources (tables/vi
|
|||
KTX's semantic layer (SL) is a structured catalog. Each **source** represents a table, a SQL view, or an overlay that enriches a manifest-backed table with measures, computed columns, joins, and named segments. The catalog is the single source of truth for reusable business metrics.
|
||||
|
||||
This skill covers two parts:
|
||||
- **Part 1** — Schema reference (what an SL source looks like).
|
||||
- **Part 2** — Querying via `sl_query`.
|
||||
- **Part 1** - Schema reference (what an SL source looks like).
|
||||
- **Part 2** - Querying via `sl_query`.
|
||||
|
||||
Capture (when and how to add new patterns to the SL) is a separate concern handled by the memory-agent — see the `sl_capture` skill if you are running in capture mode. The research agent **reads** and **queries** the SL via the tools described here; it does not write to it.
|
||||
Capture (when and how to add new patterns to the SL) is a separate concern handled by the memory-agent - see the `sl_capture` skill if you are running in capture mode. The research agent **reads** and **queries** the SL via the tools described here; it does not write to it.
|
||||
|
||||
For capture-time identifier verification, load `sl_capture`. Synthesis writer
|
||||
skills must verify warehouse identifiers with `discover_data`,
|
||||
|
|
@ -19,7 +19,7 @@ skills must verify warehouse identifiers with `discover_data`,
|
|||
|
||||
---
|
||||
|
||||
## Part 1 — Schema reference
|
||||
## Part 1 - Schema reference
|
||||
|
||||
An SL source is a YAML file at `semantic-layer/<connectionId>/<source_name>.yaml`. There are three flavors:
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ descriptions:
|
|||
measures:
|
||||
- name: total_revenue
|
||||
expr: sum(amount)
|
||||
description: Total order revenue — filter by status or region at query time
|
||||
description: Total order revenue - filter by status or region at query time
|
||||
columns: # computed dimensions only
|
||||
- name: is_large_order
|
||||
type: boolean
|
||||
|
|
@ -53,7 +53,7 @@ joins:
|
|||
```
|
||||
|
||||
Rules:
|
||||
- Do **not** repeat base-table columns, grain, `table`, or `source_type` in an overlay — those are inherited.
|
||||
- Do **not** repeat base-table columns, grain, `table`, or `source_type` in an overlay - those are inherited.
|
||||
- Overlay columns MUST be computed (`expr` + `type`).
|
||||
- Use `column_overrides` to add descriptions or metadata to inherited manifest columns. Do not put `type` or `expr` in `column_overrides`.
|
||||
- `exclude_columns` hides specific manifest columns; `disable_joins` suppresses specific auto-detected joins.
|
||||
|
|
@ -111,7 +111,7 @@ measures:
|
|||
expr: count(*)
|
||||
```
|
||||
|
||||
An SQL source is a one-shot answer: the aggregation is frozen, callers cannot re-group or re-filter by columns the SQL has collapsed, and the source is disconnected from the join graph. Prefer overlays + measures over SQL sources when possible — the `sl_capture` skill covers when SQL is justified.
|
||||
An SQL source is a one-shot answer: the aggregation is frozen, callers cannot re-group or re-filter by columns the SQL has collapsed, and the source is disconnected from the join graph. Prefer overlays + measures over SQL sources when possible - the `sl_capture` skill covers when SQL is justified.
|
||||
|
||||
### Columns
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ Every standalone column requires `name` and `type`. Overlays have computed colum
|
|||
|
||||
### Grain
|
||||
|
||||
`grain: [col_a, col_b]` — the set of columns that uniquely identify one row. The query engine uses grain to prevent fan-out in joins. Overlays inherit grain from the manifest unless they override.
|
||||
`grain: [col_a, col_b]` - the set of columns that uniquely identify one row. The query engine uses grain to prevent fan-out in joins. Overlays inherit grain from the manifest unless they override.
|
||||
|
||||
### Joins
|
||||
|
||||
|
|
@ -133,7 +133,7 @@ joins:
|
|||
- to: customers # target source name
|
||||
on: "customer_id = customers.id" # local_col = TARGET.target_col
|
||||
relationship: many_to_one # or one_to_many, one_to_one
|
||||
alias: primary_customer # optional — lets you join the same target twice
|
||||
alias: primary_customer # optional - lets you join the same target twice
|
||||
```
|
||||
|
||||
- `on` format: `local_col = TARGET.target_col`. Always qualify the right side with the target source name.
|
||||
|
|
@ -145,13 +145,13 @@ joins:
|
|||
measures:
|
||||
- name: total_arr
|
||||
expr: sum(arr_amount)
|
||||
description: Sum of ARR — filter by plan_name at query time
|
||||
description: Sum of ARR - filter by plan_name at query time
|
||||
filter: "is_active = true"
|
||||
segments: [paid_non_refunded]
|
||||
```
|
||||
|
||||
- `name` (required, snake_case).
|
||||
- `expr` (required): any valid SQL aggregate — `sum(x)`, `count(*)`, `count(distinct user_id)`, `avg(score)`.
|
||||
- `expr` (required): any valid SQL aggregate - `sum(x)`, `count(*)`, `count(distinct user_id)`, `avg(score)`.
|
||||
- `description` (required on capture): what the measure computes and how to use it.
|
||||
- `filter` (optional): SQL predicate applied as a WHERE clause specific to this measure.
|
||||
- `segments` (optional): names of segments defined on the same source. The engine AND-composes each segment's `expr` into this measure's effective filter.
|
||||
|
|
@ -167,23 +167,23 @@ segments:
|
|||
description: Orders that were paid and not refunded
|
||||
```
|
||||
|
||||
Named, reusable boolean predicates scoped to one source. Reference by bare name in a measure's `segments: []`, or by dotted form `source.segment_name` in an `sl_query`. Segments are predicates only — they are NOT selectable as dimensions. If you need to group by the predicate, add a `columns[]` entry instead.
|
||||
Named, reusable boolean predicates scoped to one source. Reference by bare name in a measure's `segments: []`, or by dotted form `source.segment_name` in an `sl_query`. Segments are predicates only - they are NOT selectable as dimensions. If you need to group by the predicate, add a `columns[]` entry instead.
|
||||
|
||||
### Cross-references with the wiki
|
||||
|
||||
The reverse edge (wiki pages that cite this source) is derived automatically from each wiki's `sl_refs:` — you don't emit anything on the SL side. Author the edge once on the wiki via `sl_refs:`; the post-write reconciler populates the knowledge↔SL index.
|
||||
The reverse edge (wiki pages that cite this source) is derived automatically from each wiki's `sl_refs:` - you don't emit anything on the SL side. Author the edge once on the wiki via `sl_refs:`; the post-write reconciler populates the knowledge↔SL index.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Querying via `sl_query`
|
||||
## Part 2 - Querying via `sl_query`
|
||||
|
||||
The `sl_query` tool generates correct SQL from a structured query. It handles joins, fan-out prevention, aggregation correctness, and filter classification automatically. Prefer it over writing raw SQL whenever the SL has the relevant sources.
|
||||
|
||||
### When to prefer sl_query over raw SQL
|
||||
|
||||
- A pre-defined measure already exists (`source.measure_name` appears in the catalog).
|
||||
- The question combines fields from multiple sources — the engine resolves the join path automatically.
|
||||
- The question asks for a standard metric (revenue, ARR, churn, retention, LTV, conversion, MAU, etc.) — even if no pre-defined measure exists, a runtime aggregation over a catalog column is usually correct.
|
||||
- The question combines fields from multiple sources - the engine resolves the join path automatically.
|
||||
- The question asks for a standard metric (revenue, ARR, churn, retention, LTV, conversion, MAU, etc.) - even if no pre-defined measure exists, a runtime aggregation over a catalog column is usually correct.
|
||||
|
||||
Use raw SQL (`sql_execution`) only when:
|
||||
- The computation requires multi-step CTEs whose intermediate grain is not a column in any source.
|
||||
|
|
@ -206,17 +206,17 @@ Use raw SQL (`sql_execution`) only when:
|
|||
- **`measures`**: mix pre-defined refs (`source.measure`) and runtime aggregations (`sum(source.column)`).
|
||||
- **`dimensions`**: column refs or `{ field, granularity }` objects for time grains (`day`, `week`, `month`, `quarter`, `year`).
|
||||
- **`filters`**: free-form SQL predicates. The engine auto-classifies each as WHERE or HAVING based on whether it references an aggregated measure.
|
||||
- **`segments`**: dotted `source.segment_name`. Each segment is AND-ed into the effective filter of every measure whose base source matches. Segments never become a global WHERE — use `filters` for cross-source predicates.
|
||||
- **`segments`**: dotted `source.segment_name`. Each segment is AND-ed into the effective filter of every measure whose base source matches. Segments never become a global WHERE - use `filters` for cross-source predicates.
|
||||
- **`order_by`**: string or `{ field, direction }`. Direction defaults to `asc`.
|
||||
- **`limit`**: integer row cap.
|
||||
|
||||
### Join resolution
|
||||
|
||||
You don't specify a base table. The engine infers the set of sources needed from the fields you reference and resolves the shortest join path through the catalog's declared joins. If no path exists between two sources, the query fails with a path-not-found error — check `discover_data` or `sl_discover` to see which sources are connected.
|
||||
You don't specify a base table. The engine infers the set of sources needed from the fields you reference and resolves the shortest join path through the catalog's declared joins. If no path exists between two sources, the query fails with a path-not-found error - check `discover_data` or `sl_discover` to see which sources are connected.
|
||||
|
||||
### Worked examples
|
||||
|
||||
Cross-source query — engine resolves `account_health_scores → accounts ← opportunities` automatically:
|
||||
Cross-source query - engine resolves `account_health_scores → accounts ← opportunities` automatically:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
---
|
||||
name: sl_capture
|
||||
description: How to capture new reusable patterns into KTX's semantic layer — when a measure, segment, or join belongs in the catalog and how to write it generically so it stays small and useful over time. Loaded by the post-turn memory-agent only. The research agent does not write to the SL.
|
||||
description: How to capture new reusable patterns into KTX's semantic layer - when a measure, segment, or join belongs in the catalog and how to write it generically so it stays small and useful over time. Loaded by the post-turn memory-agent only. The research agent does not write to the SL.
|
||||
callers: [memory_agent]
|
||||
---
|
||||
|
||||
# Semantic Layer — Capture
|
||||
# Semantic Layer - Capture
|
||||
|
||||
This skill covers **when** and **how** to capture new patterns into the semantic layer. For schema reference and query grammar, load the `sl` skill first.
|
||||
|
||||
|
|
@ -13,8 +13,8 @@ When the current turn produces a reusable pattern (business metric, derived view
|
|||
## SQL dialect
|
||||
|
||||
The user-facing prompt includes a `Warehouse:` line under the SL Sources index
|
||||
(e.g. `Warehouse: BIGQUERY`). All `expr` strings — measure expressions, segment
|
||||
predicates, computed-column SQL — execute on that warehouse and must use its
|
||||
(e.g. `Warehouse: BIGQUERY`). All `expr` strings - measure expressions, segment
|
||||
predicates, computed-column SQL - execute on that warehouse and must use its
|
||||
syntax. Date arithmetic in particular varies by dialect:
|
||||
|
||||
- **BigQuery**: `transaction_date >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY)` (when the column is `TIMESTAMP`); `event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)` (when `DATE`).
|
||||
|
|
@ -22,7 +22,7 @@ syntax. Date arithmetic in particular varies by dialect:
|
|||
- **Snowflake**: `transaction_date >= dateadd(day, -90, current_timestamp())`.
|
||||
|
||||
Match the column's manifest type (`type: time` → TIMESTAMP/DATETIME on the
|
||||
warehouse) — comparing TIMESTAMP to a DATE-arithmetic result fails on
|
||||
warehouse) - comparing TIMESTAMP to a DATE-arithmetic result fails on
|
||||
BigQuery. After every `sl_edit_source`/`sl_write_source`, the inline validator runs a
|
||||
`LIMIT 1` warehouse probe per measure and surfaces dialect mismatches; if
|
||||
you see an error trailer, fix the expression and retry rather than leaving
|
||||
|
|
@ -68,12 +68,12 @@ Callers filter `region = 'US'` at query time.
|
|||
**Bake constants in only when the filter has named business meaning that won't change** (`enterprise_arr` for a contractually defined tier), cannot be expressed via the source's dimensions, or comes from a regulated/fixed list.
|
||||
|
||||
**Time anchors and value lists belong in callers' filters, not in measure expressions or source SQL.**
|
||||
- Anti-pattern (date anchor inlined): `expr: count(distinct case when transaction_date >= '2026-04-12' then customer_id end)` — the date will need editing every time the question shifts, and every reader has to discover it.
|
||||
- Anti-pattern (value list inlined in source SQL): `WHERE product_category_1 IN ('Testosterone', 'Weight Loss', …)` — locks the source to today's catalog and blocks callers from broadening or narrowing.
|
||||
- Anti-pattern (date anchor inlined): `expr: count(distinct case when transaction_date >= '2026-04-12' then customer_id end)` - the date will need editing every time the question shifts, and every reader has to discover it.
|
||||
- Anti-pattern (value list inlined in source SQL): `WHERE product_category_1 IN ('Testosterone', 'Weight Loss', …)` - locks the source to today's catalog and blocks callers from broadening or narrowing.
|
||||
- Preferred: a generic measure (`count(distinct customer_id)`) plus either a named segment that captures the *meaning* of the anchor (`gh_new_products_since_launch`) or a query-time filter. Callers compose; the source stays small.
|
||||
- A date is durable to bake in only when it represents a regulatory cutover, a contractually fixed boundary, or a one-time event that reshapes how the source itself is read.
|
||||
|
||||
**If you create a segment whose expr matches a measure's filter, the measure MUST reference the segment via `segments: [segment_name]` rather than re-inlining the predicate.** This is the canonical pattern even with a single measure — duplicating the predicate inline defeats the purpose of naming it.
|
||||
**If you create a segment whose expr matches a measure's filter, the measure MUST reference the segment via `segments: [segment_name]` rather than re-inlining the predicate.** This is the canonical pattern even with a single measure - duplicating the predicate inline defeats the purpose of naming it.
|
||||
|
||||
Anti-pattern:
|
||||
```yaml
|
||||
|
|
@ -132,18 +132,18 @@ Overlay YAML may include `measures:`, `segments:`, `descriptions:`, `joins:`, `d
|
|||
- The metric requires per-user/per-entity derivation that cannot be expressed as a single `expr` (e.g., `EXISTS` over a time-windowed subset), OR
|
||||
- The metric requires multi-step CTEs whose intermediate grain is not a column in any existing source.
|
||||
|
||||
When an `sql` source is unavoidable, note in its `descriptions` map which SL gap forced the choice so it can be retired once the primitive ships. It must target a name NOT in the manifest — pick a distinct one (e.g. `mrr_waterfall_rollup`, not `fct_orders`).
|
||||
When an `sql` source is unavoidable, note in its `descriptions` map which SL gap forced the choice so it can be retired once the primitive ships. It must target a name NOT in the manifest - pick a distinct one (e.g. `mrr_waterfall_rollup`, not `fct_orders`).
|
||||
|
||||
## Slim standalone sources via `inherits_columns_from`
|
||||
|
||||
When a standalone SQL source filters or projects from a single manifest-backed base table (the common pattern for derived views like `aav_consignments` over `MARTS.CONSIGNMENTS`), set `inherits_columns_from:` to the base table's manifest key and list only column **names** in `columns:`. Compose-time enrichment fills `type`, `descriptions`, and `role` from the matching manifest column.
|
||||
|
||||
Discover the manifest key with `sl_discover` — pass the bare name (`CONSIGNMENTS`), the fully-qualified path (`ANALYTICS.MARTS.CONSIGNMENTS`), or any suffix; the tool resolves all forms and prints the canonical key in its output.
|
||||
Discover the manifest key with `sl_discover` - pass the bare name (`CONSIGNMENTS`), the fully-qualified path (`ANALYTICS.MARTS.CONSIGNMENTS`), or any suffix; the tool resolves all forms and prints the canonical key in its output.
|
||||
|
||||
```yaml
|
||||
name: aav_consignments
|
||||
descriptions:
|
||||
user: AAV consignments — filtered view of MARTS.CONSIGNMENTS for the auto-auction-vaulting channel.
|
||||
user: AAV consignments - filtered view of MARTS.CONSIGNMENTS for the auto-auction-vaulting channel.
|
||||
source_type: sql
|
||||
sql: |
|
||||
SELECT CONSIGNED_ITEM_ID, CASH_ADV_AMOUNT, ALT_VALUE_COMBINED, my_derived_flag
|
||||
|
|
@ -157,7 +157,7 @@ columns:
|
|||
- { name: CONSIGNED_ITEM_ID } # type/descriptions inherited from manifest
|
||||
- { name: CASH_ADV_AMOUNT }
|
||||
- { name: ALT_VALUE_COMBINED }
|
||||
- { name: my_derived_flag, type: boolean, expr: "CASH_ADV_AMOUNT > 0", descriptions: { user: "Computed locally — has any cash advance." } }
|
||||
- { name: my_derived_flag, type: boolean, expr: "CASH_ADV_AMOUNT > 0", descriptions: { user: "Computed locally - has any cash advance." } }
|
||||
measures:
|
||||
- name: total_cash_advance
|
||||
expr: sum(CASH_ADV_AMOUNT)
|
||||
|
|
@ -165,12 +165,12 @@ measures:
|
|||
|
||||
Rules:
|
||||
|
||||
- Inheritance fills only **blank** fields. If you set a `description` locally, it wins — useful when the base description is misleading in the filtered view.
|
||||
- Inheritance fills only **blank** fields. If you set a `description` locally, it wins - useful when the base description is misleading in the filtered view.
|
||||
- A column not in the manifest (a derived/aliased column, or one from a different table in a `JOIN`) needs its own `type` and `description` declared.
|
||||
- If `inherits_columns_from` doesn't resolve, the source still loads, but every column without a type triggers a validator error on the warehouse probe — `sl_discover` first to confirm the key.
|
||||
- Don't use `inherits_columns_from` for sources backed by `table:` (those should be overlays — see the rule against shadowing the manifest above).
|
||||
- If `inherits_columns_from` doesn't resolve, the source still loads, but every column without a type triggers a validator error on the warehouse probe - `sl_discover` first to confirm the key.
|
||||
- Don't use `inherits_columns_from` for sources backed by `table:` (those should be overlays - see the rule against shadowing the manifest above).
|
||||
|
||||
## Refinement — replace, don't append
|
||||
## Refinement - replace, don't append
|
||||
|
||||
When the user corrects a prior answer, the existing measure is wrong by the user's own standard. Replace it, don't add a parallel measure.
|
||||
|
||||
|
|
@ -234,14 +234,14 @@ SL source, `tables:` frontmatter, `sl_refs`, or `emit_unmapped_fallback`:
|
|||
|
||||
## Tool sequence
|
||||
|
||||
1. `sl_discover` — see what source files exist.
|
||||
2. `sl_discover({ query: "<table-or-source-name>" })` — **REQUIRED before the first write on any name**. Shows columns/joins/grain from the manifest. If the call returns a schema, you MUST write an overlay, not a standalone. Skipping this is the #1 cause of accidentally shadowing the manifest.
|
||||
3. `sl_read_source({ connectionId, sourceName })` — read the raw YAML before editing.
|
||||
1. `sl_discover` - see what source files exist.
|
||||
2. `sl_discover({ query: "<table-or-source-name>" })` - **REQUIRED before the first write on any name**. Shows columns/joins/grain from the manifest. If the call returns a schema, you MUST write an overlay, not a standalone. Skipping this is the #1 cause of accidentally shadowing the manifest.
|
||||
3. `sl_read_source({ connectionId, sourceName })` - read the raw YAML before editing.
|
||||
4. For modifications: `sl_edit_source({ connectionId, sourceName, yaml_edits: [{ oldText, newText, reason }] })` with exact-string replacements. `oldText` must match exactly and be unique in the file.
|
||||
5. For new sources or full rewrites: `sl_write_source({ connectionId, sourceName, source })` with the full structured source definition.
|
||||
6. For join discovery: use `sql_execution({connectionName: "warehouse", sql: "SELECT count(*) FROM public.orders o JOIN public.customers c ON c.id = o.customer_id LIMIT 20"})` with the target warehouse connection name and dialect-correct table names to verify the join key exists in both tables and assess cardinality before declaring the join.
|
||||
7. Cross-reference knowledge: author the edge once on the **wiki** side via `sl_refs: [source_name]` in the page's front-matter. The reverse edge (wiki pages that cite an SL source) is derived automatically by the reconciler — do not add a `knowledge_refs:` field to SL YAMLs.
|
||||
8. `sl_validate` — run after writing or editing to surface schema issues, duplicate measure names, and cross-source validation errors. Read-only; the writes are already committed (the squash-at-end flow will collapse them into one commit).
|
||||
7. Cross-reference knowledge: author the edge once on the **wiki** side via `sl_refs: [source_name]` in the page's front-matter. The reverse edge (wiki pages that cite an SL source) is derived automatically by the reconciler - do not add a `knowledge_refs:` field to SL YAMLs.
|
||||
8. `sl_validate` - run after writing or editing to surface schema issues, duplicate measure names, and cross-source validation errors. Read-only; the writes are already committed (the squash-at-end flow will collapse them into one commit).
|
||||
|
||||
## Editing patterns
|
||||
|
||||
|
|
@ -250,13 +250,13 @@ SL source, `tables:` frontmatter, `sl_refs`, or `emit_unmapped_fallback`:
|
|||
- Do NOT modify existing measures or their descriptions unless the current turn explicitly corrects them.
|
||||
- During bundle/external ingest, include `rawPaths` on every `sl_write_source`/`sl_edit_source` call with only the raw files that directly support the SL action.
|
||||
|
||||
## Worked example — additive overlay
|
||||
## Worked example - additive overlay
|
||||
|
||||
Conversation:
|
||||
- User: "What was the average order value last quarter?"
|
||||
- Assistant fell back to SQL: `SELECT AVG(amount) FROM orders WHERE order_date >= ...`
|
||||
|
||||
Existing index: `orders [measures=0, joins=0] — candidate for enrichment`.
|
||||
Existing index: `orders [measures=0, joins=0] - candidate for enrichment`.
|
||||
|
||||
```
|
||||
sl_discover()
|
||||
|
|
@ -279,9 +279,9 @@ sl_validate({ connectionId: "warehouse" })
|
|||
→ clean
|
||||
```
|
||||
|
||||
The overlay only contains `name` and `measures` — no columns, grain, or table. Those are inherited from the manifest.
|
||||
The overlay only contains `name` and `measures` - no columns, grain, or table. Those are inherited from the manifest.
|
||||
|
||||
## Worked example — refinement (replace)
|
||||
## Worked example - refinement (replace)
|
||||
|
||||
Prior turn:
|
||||
- [user] "How many active users do we have per region?"
|
||||
|
|
@ -307,7 +307,7 @@ sl_validate({ connectionId: "warehouse" })
|
|||
|
||||
If you only added a new measure, the old incorrect `active_count` would stay and future queries would keep answering the wrong question.
|
||||
|
||||
## Worked example — new join
|
||||
## Worked example - new join
|
||||
|
||||
Prior turn: user asked to correlate LTV with protocol count; assistant joined `fct_orders` with `fct_mau_multiprotocol` on `admin_user_id` in raw SQL.
|
||||
|
||||
|
|
@ -341,6 +341,6 @@ Always verify joins with `sql_execution` before adding them.
|
|||
- A measure whose filter matches a segment MUST reference the segment via `segments: [name]`.
|
||||
- Extract repeated predicates into named segments.
|
||||
- Use computed dimensions for derived categories.
|
||||
- When the user corrects a prior answer, replace — don't append.
|
||||
- When the user corrects a prior answer, replace - don't append.
|
||||
- Always run `sl_validate` after writing to surface issues.
|
||||
- If nothing is worth capturing, respond without calling any SL write tool.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: wiki_capture
|
||||
description: KTX's knowledge base — wiki pages for durable, reusable business knowledge. Covers capture workflow for user preferences, metric definitions, organizational conventions, and cross-references between wiki pages and semantic-layer sources. Loaded by the post-turn memory-agent only. The research agent reads wiki via `wiki_read`/`wiki_search` but does not write it.
|
||||
description: KTX's knowledge base - wiki pages for durable, reusable business knowledge. Covers capture workflow for user preferences, metric definitions, organizational conventions, and cross-references between wiki pages and semantic-layer sources. Loaded by the post-turn memory-agent only. The research agent reads wiki via `wiki_read`/`wiki_search` but does not write it.
|
||||
callers: [memory_agent]
|
||||
---
|
||||
|
||||
|
|
@ -8,14 +8,14 @@ callers: [memory_agent]
|
|||
|
||||
## Role
|
||||
|
||||
The knowledge base stores durable, reusable business knowledge for an analytics assistant. Each page is a self-contained rule, definition, or convention that answers "how should this concept be handled in this organization?" — written once and reused across chats.
|
||||
The knowledge base stores durable, reusable business knowledge for an analytics assistant. Each page is a self-contained rule, definition, or convention that answers "how should this concept be handled in this organization?" - written once and reused across chats.
|
||||
|
||||
Scope selection is handled by the runtime:
|
||||
- When user-scoped knowledge is enabled AND the caller is a chat turn, writes go to the user's **personal** scope.
|
||||
- When the caller is an admin-driven ingest (`sourceType: 'external_ingest'`), writes go to the **global** scope.
|
||||
- When user-scoped knowledge is disabled, all writes go to the global scope.
|
||||
|
||||
The `wiki_write` tool picks the right scope based on the session. Capture logic does not need to choose — focus on whether the content is worth capturing at all.
|
||||
The `wiki_write` tool picks the right scope based on the session. Capture logic does not need to choose - focus on whether the content is worth capturing at all.
|
||||
|
||||
## What to capture
|
||||
|
||||
|
|
@ -30,8 +30,8 @@ Do NOT capture:
|
|||
- One-off requests ("answer under 100 words").
|
||||
- Temporary instructions scoped to the current chat.
|
||||
- Ad-hoc formatting preferences.
|
||||
- Information already present in the semantic layer (column names, join paths, measure formulas — those belong in SL).
|
||||
- **Query results, snapshots, or time-bounded benchmark tables.** Numbers go stale; pasting "Oct 2025: 25%, Nov 2025: 19.9%, …" creates misinformation as soon as new data lands. Reference the SL source by name (`sl_refs`) and let future query tools pull live data — the wiki captures the *rule* (definition, exclusion, segmentation), the SL source captures the *measure*, and query execution captures the *current values*.
|
||||
- Information already present in the semantic layer (column names, join paths, measure formulas - those belong in SL).
|
||||
- **Query results, snapshots, or time-bounded benchmark tables.** Numbers go stale; pasting "Oct 2025: 25%, Nov 2025: 19.9%, …" creates misinformation as soon as new data lands. Reference the SL source by name (`sl_refs`) and let future query tools pull live data - the wiki captures the *rule* (definition, exclusion, segmentation), the SL source captures the *measure*, and query execution captures the *current values*.
|
||||
- **Interpretive narrative tied to a specific snapshot** ("M1 retention degraded sharply from Dec 2025"). The observation is anchored to data that will move; the actionable convention (e.g., "always exclude in-progress cohorts") may be worth capturing on its own, but the snapshot-specific commentary is not.
|
||||
|
||||
If nothing is worth capturing, respond without calling any tool.
|
||||
|
|
@ -40,13 +40,13 @@ If nothing is worth capturing, respond without calling any tool.
|
|||
|
||||
1. Read the wiki index (provided in the prompt) and decide whether the turn introduces durable knowledge.
|
||||
2. **Before writing**, search for related content so cross-references are accurate:
|
||||
- `discover_data` first when a page relates to data or SL concepts — find
|
||||
- `discover_data` first when a page relates to data or SL concepts - find
|
||||
existing wiki pages, SL sources, and raw warehouse schema together.
|
||||
- `wiki_search` with the topic — find related wiki pages to populate `refs`.
|
||||
- `sl_discover` with the concept — if the page defines a metric (revenue, churn, retention, LTV, ARR, MRR, CAC, attribution, etc.), find matching SL sources or measures to populate `sl_refs`. If no matches, pass `sl_refs: []` so future readers know you checked.
|
||||
- `wiki_search` with the topic - find related wiki pages to populate `refs`.
|
||||
- `sl_discover` with the concept - if the page defines a metric (revenue, churn, retention, LTV, ARR, MRR, CAC, attribution, etc.), find matching SL sources or measures to populate `sl_refs`. If no matches, pass `sl_refs: []` so future readers know you checked.
|
||||
3. If updating an existing page, `wiki_read` it first. Use the returned `structured.content` or markdown body as the exact stored text for targeted replacements; current tags, refs, and sl_refs are returned in structured metadata.
|
||||
4. `wiki_write` to create or update. Prefer merging into an existing page over creating a new one.
|
||||
5. `wiki_remove` only when a page is truly obsolete — not to replace stale content (update it instead).
|
||||
5. `wiki_remove` only when a page is truly obsolete - not to replace stale content (update it instead).
|
||||
|
||||
For bundle/external ingest, include `rawPaths` on every `wiki_write`/`wiki_remove` call with only the raw files that directly support that wiki action. This keeps ingest provenance tied to the actual source file, not every file in the WorkUnit.
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ SL source, `tables:` frontmatter, `sl_refs`, or `emit_unmapped_fallback`:
|
|||
|
||||
- **Keys** are short kebab-case topic identifiers: `leads-source-filter`, `revenue-definition`, `churn-calculation`. No namespacing, no prefixes.
|
||||
- **Summary** is a one-line hook (≤200 chars) shown in the index.
|
||||
- **Content** is concise markdown — actionable rules, not prose.
|
||||
- **Content** is concise markdown - actionable rules, not prose.
|
||||
|
||||
```
|
||||
## [Topic Title]
|
||||
|
|
@ -116,8 +116,8 @@ All three fields use REPLACE semantics on update:
|
|||
|
||||
Two modes:
|
||||
|
||||
- **Full content** — pass `content` to rewrite the whole page. Use when the page structure needs to change.
|
||||
- **Targeted edits** — pass `replacements: [{ oldText, newText }]` to apply exact-string replacements. Use for small updates; preserves the rest of the page.
|
||||
- **Full content** - pass `content` to rewrite the whole page. Use when the page structure needs to change.
|
||||
- **Targeted edits** - pass `replacements: [{ oldText, newText }]` to apply exact-string replacements. Use for small updates; preserves the rest of the page.
|
||||
|
||||
When editing, read the page first so the edit matches exact whitespace and indentation.
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ When editing, read the page first so the edit matches exact whitespace and inden
|
|||
|
||||
Organization (GLOBAL) pages are read-only from a user's personal-scope session. To override a global rule for a single user, write a personal page with the **same key**. At read time the USER page wins.
|
||||
|
||||
## Worked example — capturing a metric with cross-references
|
||||
## Worked example - capturing a metric with cross-references
|
||||
|
||||
User says: "Going forward, the official refund rate is total refunded amount divided by total gross transaction amount."
|
||||
|
||||
|
|
@ -133,7 +133,7 @@ User says: "Going forward, the official refund rate is total refunded amount div
|
|||
wiki_list_tags()
|
||||
→ existing tags include "finance"
|
||||
wiki_search({ query: "refund revenue paid orders" })
|
||||
→ returns `revenue-definition` (related — defines paid-orders filter)
|
||||
→ returns `revenue-definition` (related - defines paid-orders filter)
|
||||
sl_discover({ query: "refund rate" })
|
||||
→ returns fct_orders (score 0.08), fct_gaap_revenue (0.06)
|
||||
sl_read_source({ connectionId: "warehouse", sourceName: "fct_orders" })
|
||||
|
|
@ -155,6 +155,6 @@ Search-then-write order matters. Cross-references are part of the page's identit
|
|||
- Read existing pages before updating them.
|
||||
- Prefer merging into an existing page over creating a new one.
|
||||
- Prefer fewer, richer pages over many thin ones.
|
||||
- Write content as clear, actionable rules — not narrative prose.
|
||||
- Write content as clear, actionable rules - not narrative prose.
|
||||
- Discover cross-references via search before writing, not after.
|
||||
- If nothing is worth capturing, respond without calling any tool.
|
||||
|
|
|
|||
|
|
@ -36,7 +36,13 @@ describe('localConnectionToWarehouseDescriptor', () => {
|
|||
});
|
||||
|
||||
it('returns null for non-warehouse adapters', () => {
|
||||
expect(localConnectionToWarehouseDescriptor('looker', { driver: 'looker' })).toBeNull();
|
||||
expect(
|
||||
localConnectionToWarehouseDescriptor('looker', {
|
||||
driver: 'looker',
|
||||
base_url: 'https://looker.example.com',
|
||||
client_id: 'client',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -48,7 +54,9 @@ describe('local connection info helpers', () => {
|
|||
});
|
||||
|
||||
it('keeps non-warehouse adapter labels for display-only local connection surfaces', () => {
|
||||
expect(localConnectionTypeForConfig('prod-metabase', { driver: 'metabase' })).toBe('metabase');
|
||||
expect(localConnectionTypeForConfig('prod-metabase', { driver: 'metabase', api_url: 'https://metabase.example.com' })).toBe(
|
||||
'metabase',
|
||||
);
|
||||
expect(localConnectionTypeForConfig('missing-driver', {} as never)).toBe('unknown');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,20 @@ export const KTX_NOTION_ORG_KNOWLEDGE_WARNING =
|
|||
|
||||
type KtxNotionCrawlMode = 'all_accessible' | 'selected_roots';
|
||||
|
||||
export interface KtxNotionConnectionConfig extends KtxProjectConnectionConfig {
|
||||
type RawKtxNotionConnectionConfig = Extract<KtxProjectConnectionConfig, { driver: 'notion' }>;
|
||||
|
||||
export type KtxNotionConnectionConfig = Omit<
|
||||
RawKtxNotionConnectionConfig,
|
||||
| 'auth_token'
|
||||
| 'auth_token_ref'
|
||||
| 'crawl_mode'
|
||||
| 'root_page_ids'
|
||||
| 'root_database_ids'
|
||||
| 'root_data_source_ids'
|
||||
| 'max_pages_per_run'
|
||||
| 'max_knowledge_creates_per_run'
|
||||
| 'max_knowledge_updates_per_run'
|
||||
> & {
|
||||
driver: 'notion';
|
||||
auth_token: string | null;
|
||||
auth_token_ref: string | null;
|
||||
|
|
@ -24,7 +37,7 @@ export interface KtxNotionConnectionConfig extends KtxProjectConnectionConfig {
|
|||
max_pages_per_run: number;
|
||||
max_knowledge_creates_per_run: number;
|
||||
max_knowledge_updates_per_run: number;
|
||||
}
|
||||
};
|
||||
|
||||
export interface RedactedKtxNotionConnectionConfig {
|
||||
driver: 'notion';
|
||||
|
|
|
|||
|
|
@ -166,7 +166,6 @@ async function writeHistoricSqlProject(project: KtxLocalProject): Promise<KtxLoc
|
|||
await writeFile(
|
||||
join(project.projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -231,7 +230,7 @@ describe('historic-SQL local ingest retrieval acceptance', () => {
|
|||
});
|
||||
|
||||
it('projects table and pattern evidence into semantic-layer and wiki retrieval surfaces', async () => {
|
||||
const initialized = await initKtxProject({ projectDir: join(tempDir, 'project'), projectName: 'warehouse' });
|
||||
const initialized = await initKtxProject({ projectDir: join(tempDir, 'project') });
|
||||
const project = await writeHistoricSqlProject(initialized);
|
||||
const sqlAnalysis = acceptanceSqlAnalysis();
|
||||
const agentRunner = new HistoricSqlAcceptanceAgentRunner();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { buildDefaultKtxProjectConfig } from '../../../project/index.js';
|
||||
import { connectionConfigSchema } from '../../../project/driver-schemas.js';
|
||||
import { KtxYamlMetabaseSourceStateReader, LocalMetabaseDiscoveryCache } from './local-source-state-store.js';
|
||||
|
||||
describe('Metabase YAML source state and discovery cache', () => {
|
||||
|
|
@ -21,12 +22,13 @@ describe('Metabase YAML source state and discovery cache', () => {
|
|||
function projectWithMetabaseMappings(mappings: Record<string, unknown>) {
|
||||
return {
|
||||
config: {
|
||||
...buildDefaultKtxProjectConfig('metabase-cache-test'),
|
||||
...buildDefaultKtxProjectConfig(),
|
||||
connections: {
|
||||
'prod-metabase': {
|
||||
'prod-metabase': connectionConfigSchema.parse({
|
||||
driver: 'metabase',
|
||||
api_url: 'https://metabase.example.com',
|
||||
mappings,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ describe('local ingest adapters', () => {
|
|||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-local-adapters-'));
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
project = await loadKtxProject({ projectDir });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -314,11 +314,10 @@ describe('canonical local ingest', () => {
|
|||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-local-full-ingest-'));
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -443,7 +442,6 @@ describe('canonical local ingest', () => {
|
|||
await writeFile(
|
||||
join(project.projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -521,11 +519,10 @@ describe('canonical local ingest', () => {
|
|||
|
||||
it('runs historic-SQL evidence projection through the local bundle post-processor', async () => {
|
||||
const projectDir = join(tempDir, 'historic-sql-project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -605,11 +602,10 @@ describe('canonical local ingest', () => {
|
|||
|
||||
it('rejects direct Metabase scheduled pulls before requiring a local ingest LLM provider', async () => {
|
||||
const projectDir = join(tempDir, 'metabase-project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -637,7 +633,7 @@ describe('canonical local ingest', () => {
|
|||
|
||||
it('runs full MetricFlow local ingest from a dbt repo fixture through the canonical runner', async () => {
|
||||
const projectDir = join(tempDir, 'metricflow-run-project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
|
||||
const fixtureDir = join(tempDir, 'metricflow-fixture');
|
||||
await mkdir(join(fixtureDir, 'models'), { recursive: true });
|
||||
|
|
@ -685,7 +681,6 @@ describe('canonical local ingest', () => {
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -767,7 +762,6 @@ describe('canonical local ingest', () => {
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: local-mf',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -801,11 +795,10 @@ describe('canonical local ingest', () => {
|
|||
|
||||
it('runs scheduled Looker ingest through the canonical local runner and records SL target evidence', async () => {
|
||||
const projectDir = join(tempDir, 'looker-project');
|
||||
await initKtxProject({ projectDir, projectName: 'looker-runtime' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: looker-runtime',
|
||||
'connections:',
|
||||
' prod-looker:',
|
||||
' driver: looker',
|
||||
|
|
|
|||
|
|
@ -24,11 +24,10 @@ describe('createLocalBundleIngestRuntime', () => {
|
|||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-local-bundle-runtime-'));
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -149,7 +148,6 @@ describe('createLocalBundleIngestRuntime', () => {
|
|||
await writeFile(
|
||||
join(project.projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
|
|||
|
|
@ -27,11 +27,12 @@ describe('local mapping yaml reconciliation bridge', () => {
|
|||
const project = projectWithConnections({
|
||||
'prod-metabase': {
|
||||
driver: 'metabase',
|
||||
api_url: 'https://metabase.example.com',
|
||||
mappings: {
|
||||
databaseMappings: { '1': 'prod-warehouse' },
|
||||
syncEnabled: { '1': true },
|
||||
syncMode: 'ONLY',
|
||||
selections: { collections: [12] },
|
||||
selections: { collections: [12], items: [] },
|
||||
defaultTagNames: ['ktx'],
|
||||
},
|
||||
},
|
||||
|
|
@ -46,6 +47,8 @@ describe('local mapping yaml reconciliation bridge', () => {
|
|||
const project = projectWithConnections({
|
||||
'prod-looker': {
|
||||
driver: 'looker',
|
||||
base_url: 'https://looker.example.com',
|
||||
client_id: 'client',
|
||||
mappings: { connectionMappings: { analytics: 'prod-warehouse' } },
|
||||
},
|
||||
'prod-warehouse': { driver: 'postgres', url: 'postgresql://readonly@db.test/analytics' },
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ async function writeWarehouseConfig(projectDir: string): Promise<void> {
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -34,7 +33,6 @@ async function writeLiveDatabaseConfig(projectDir: string): Promise<void> {
|
|||
await writeFile(
|
||||
join(projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' warehouse:',
|
||||
' driver: postgres',
|
||||
|
|
@ -88,7 +86,7 @@ describe('local ingest', () => {
|
|||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-local-ingest-'));
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
await writeWarehouseConfig(projectDir);
|
||||
project = await loadKtxProject({ projectDir });
|
||||
});
|
||||
|
|
@ -574,7 +572,6 @@ describe('local ingest', () => {
|
|||
await writeFile(
|
||||
join(project.projectDir, 'ktx.yaml'),
|
||||
[
|
||||
'project: warehouse',
|
||||
'connections:',
|
||||
' notion-main:',
|
||||
' driver: notion',
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ describe('EntityDetailsTool', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-entity-details-'));
|
||||
project = await initKtxProject({ projectDir: join(tempDir, 'project'), projectName: 'warehouse' });
|
||||
project = await initKtxProject({ projectDir: join(tempDir, 'project') });
|
||||
await seedLiveDatabaseScan();
|
||||
tool = new EntityDetailsTool(() => new WarehouseCatalogService({ fileStore: project.fileStore }));
|
||||
context = {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ describe('WarehouseCatalogService', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-warehouse-catalog-'));
|
||||
project = await initKtxProject({ projectDir: join(tempDir, 'project'), projectName: 'warehouse' });
|
||||
project = await initKtxProject({ projectDir: join(tempDir, 'project') });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ describe('local KTX embedding config', () => {
|
|||
it('constructs deterministic embeddings from the default project config', () => {
|
||||
const createKtxEmbeddingProvider = vi.fn(() => ({}) as never);
|
||||
const provider = createLocalKtxEmbeddingProviderFromConfig(
|
||||
buildDefaultKtxProjectConfig('warehouse').ingest.embeddings,
|
||||
buildDefaultKtxProjectConfig().ingest.embeddings,
|
||||
{ createKtxEmbeddingProvider },
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
}
|
||||
|
||||
it('lists local project connections from ktx.yaml', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections.warehouse = {
|
||||
driver: 'postgres',
|
||||
url: 'env:DATABASE_URL',
|
||||
|
|
@ -84,7 +84,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('tests a local project connection through the native scan connector factory', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections.warehouse = {
|
||||
driver: 'postgres',
|
||||
url: 'env:DATABASE_URL',
|
||||
|
|
@ -120,7 +120,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('triggers canonical bundle ingest and reads status, report, and replay through MCP ports', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections.warehouse = {
|
||||
driver: 'postgres',
|
||||
};
|
||||
|
|
@ -216,7 +216,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('returns child run metadata for local Metabase fan-out triggers', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections = {
|
||||
'prod-metabase': {
|
||||
driver: 'metabase',
|
||||
|
|
@ -339,7 +339,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('writes, reads, and searches global wiki pages', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
const ports = createLocalProjectMcpContextPorts(project);
|
||||
|
||||
await expect(
|
||||
|
|
@ -383,7 +383,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('writes, lists, reads, and validates semantic-layer sources', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
const ports = createLocalProjectMcpContextPorts(project);
|
||||
|
||||
await expect(
|
||||
|
|
@ -449,7 +449,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('returns semantic-layer hybrid search metadata through local project ports', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
await writeLocalSlSource(project, {
|
||||
connectionId: 'warehouse',
|
||||
sourceName: 'orders',
|
||||
|
|
@ -518,7 +518,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('returns historic SQL usage frequency and snippet through semantic-layer list search', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
await project.fileStore.writeFile(
|
||||
'semantic-layer/warehouse/_schema/public.yaml',
|
||||
`tables:
|
||||
|
|
@ -566,7 +566,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('uses configured local embeddings for semantic-layer search when available', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.ingest.embeddings = { backend: 'none', dimensions: 2 };
|
||||
await writeLocalSlSource(project, {
|
||||
connectionId: 'warehouse',
|
||||
|
|
@ -607,7 +607,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('rejects path traversal keys before touching the project directory', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
const ports = createLocalProjectMcpContextPorts(project);
|
||||
|
||||
await expect(
|
||||
|
|
@ -626,7 +626,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('uses semantic compute for validation and compile-only sl_query when supplied', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections.warehouse = {
|
||||
driver: 'postgres',
|
||||
url: 'env:DATABASE_URL',
|
||||
|
|
@ -712,7 +712,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('executes local MCP sl_query when a query executor is configured', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections.warehouse = {
|
||||
driver: 'postgres',
|
||||
url: 'env:DATABASE_URL',
|
||||
|
|
@ -770,7 +770,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('exposes detailed local ingest trigger and status ports when local ingest is enabled', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections.warehouse = { driver: 'postgres' };
|
||||
project.config.ingest.adapters = ['fake'];
|
||||
project.config.ingest.embeddings = {
|
||||
|
|
@ -890,7 +890,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('passes local ingest pull-config options into runLocalIngest', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections.warehouse = { driver: 'postgres' };
|
||||
project.config.ingest.adapters = ['looker'];
|
||||
const runLocalIngest = vi.fn(async () => ({
|
||||
|
|
@ -949,7 +949,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('triggers fetch-capable local ingest without sourceDir config', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections.warehouse = {
|
||||
driver: 'postgres',
|
||||
url: 'postgres://localhost:5432/warehouse',
|
||||
|
|
@ -1024,7 +1024,7 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
});
|
||||
|
||||
it('lists and reads only artifacts that belong to a local scan report', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections.warehouse = {
|
||||
driver: 'postgres',
|
||||
url: 'env:DATABASE_URL',
|
||||
|
|
@ -1140,6 +1140,6 @@ describe('createLocalProjectMcpContextPorts', () => {
|
|||
}),
|
||||
).resolves.toBeNull();
|
||||
await expect(ports.scan?.listArtifacts?.({ runId: 'missing' })).resolves.toBeNull();
|
||||
await expect(readFile(join(project.projectDir, 'ktx.yaml'), 'utf-8')).resolves.toContain('project: warehouse');
|
||||
await expect(readFile(join(project.projectDir, 'ktx.yaml'), 'utf-8')).resolves.not.toContain('project:');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ describe('createKtxMcpServer', () => {
|
|||
it('runs MCP memory_capture against a local project memory port', async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-mcp-local-memory-'));
|
||||
try {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
const agentRunner = {
|
||||
runLoop: async ({
|
||||
toolSet,
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ describe('createLocalProjectMemoryCapture', () => {
|
|||
});
|
||||
|
||||
it('captures a wiki page through the local memory agent and persists pollable status', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
const agentRunner = {
|
||||
runLoop: async ({
|
||||
toolSet,
|
||||
|
|
@ -144,7 +144,7 @@ describe('createLocalProjectMemoryCapture', () => {
|
|||
});
|
||||
|
||||
it('captures a semantic-layer source for a named local connection id', async () => {
|
||||
const project = await initKtxProject({ projectDir: tempDir, projectName: 'warehouse' });
|
||||
const project = await initKtxProject({ projectDir: tempDir });
|
||||
project.config.connections.warehouse = { driver: 'postgres' };
|
||||
const agentRunner = {
|
||||
runLoop: async ({
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ describe('KTX project config', () => {
|
|||
it.each(['status', 'replay', 'run', 'watch'])('accepts former ingest subcommand name "%s" as a connection id', (connectionId) => {
|
||||
expect(
|
||||
parseKtxProjectConfig(`
|
||||
project: reserved-test
|
||||
connections:
|
||||
${connectionId}:
|
||||
driver: postgres
|
||||
|
|
@ -24,8 +23,7 @@ connections:
|
|||
});
|
||||
|
||||
it('builds the default standalone project config', () => {
|
||||
expect(buildDefaultKtxProjectConfig('warehouse')).toEqual({
|
||||
project: 'warehouse',
|
||||
expect(buildDefaultKtxProjectConfig()).toEqual({
|
||||
connections: {},
|
||||
storage: {
|
||||
state: 'sqlite',
|
||||
|
|
@ -84,15 +82,14 @@ connections:
|
|||
});
|
||||
|
||||
it('round-trips through YAML with stable defaults', () => {
|
||||
const serialized = serializeKtxProjectConfig(buildDefaultKtxProjectConfig('warehouse'));
|
||||
const serialized = serializeKtxProjectConfig(buildDefaultKtxProjectConfig());
|
||||
const parsed = parseKtxProjectConfig(serialized);
|
||||
|
||||
expect(serialized).toContain('project: warehouse');
|
||||
expect(serialized).not.toContain('project:');
|
||||
expect(serialized).not.toContain('live-database');
|
||||
expect(serialized).toContain(
|
||||
' embeddings:\n backend: deterministic\n model: deterministic\n dimensions: 8',
|
||||
);
|
||||
expect(parsed.project).toBe('warehouse');
|
||||
expect(parsed.ingest.adapters).toEqual([]);
|
||||
expect(parsed.ingest.embeddings).toEqual({
|
||||
backend: 'deterministic',
|
||||
|
|
@ -103,7 +100,6 @@ connections:
|
|||
|
||||
it('parses and serializes setup warehouse metadata without setup progress', () => {
|
||||
const config = parseKtxProjectConfig(`
|
||||
project: revenue
|
||||
setup:
|
||||
database_connection_ids:
|
||||
- warehouse
|
||||
|
|
@ -126,7 +122,6 @@ connections:
|
|||
|
||||
it('parses global direct Anthropic LLM config', () => {
|
||||
const config = parseKtxProjectConfig(`
|
||||
project: demo
|
||||
llm:
|
||||
provider:
|
||||
backend: anthropic
|
||||
|
|
@ -166,7 +161,6 @@ ingest:
|
|||
|
||||
it('parses global Vertex LLM config', () => {
|
||||
const config = parseKtxProjectConfig(`
|
||||
project: demo
|
||||
llm:
|
||||
provider:
|
||||
backend: vertex
|
||||
|
|
@ -188,7 +182,6 @@ llm:
|
|||
|
||||
it('parses gateway LLM, OpenAI scan embeddings, and sentence-transformers ingest embeddings', () => {
|
||||
const config = parseKtxProjectConfig(`
|
||||
project: demo
|
||||
llm:
|
||||
provider:
|
||||
backend: gateway
|
||||
|
|
@ -232,7 +225,6 @@ scan:
|
|||
|
||||
it('parses scan relationship settings', () => {
|
||||
const config = parseKtxProjectConfig(`
|
||||
project: demo
|
||||
scan:
|
||||
relationships:
|
||||
enabled: false
|
||||
|
|
@ -273,7 +265,6 @@ scan:
|
|||
|
||||
it('parses the scan relationship validation budget sentinel', () => {
|
||||
const config = parseKtxProjectConfig(`
|
||||
project: demo
|
||||
scan:
|
||||
relationships:
|
||||
validationBudget: all
|
||||
|
|
@ -285,7 +276,6 @@ scan:
|
|||
|
||||
it('rejects out-of-range scan relationship numeric settings', () => {
|
||||
const yaml = `
|
||||
project: demo
|
||||
scan:
|
||||
relationships:
|
||||
acceptThreshold: 2
|
||||
|
|
@ -316,7 +306,6 @@ scan:
|
|||
|
||||
it('rejects invalid scan relationship validation budget strings', () => {
|
||||
const yaml = `
|
||||
project: demo
|
||||
scan:
|
||||
relationships:
|
||||
validationBudget: infinite
|
||||
|
|
@ -327,7 +316,6 @@ scan:
|
|||
it('rejects unsupported local LLM and embedding fields', () => {
|
||||
expect(() =>
|
||||
parseKtxProjectConfig(`
|
||||
project: demo
|
||||
ingest:
|
||||
llm:
|
||||
backend: anthropic
|
||||
|
|
@ -336,7 +324,6 @@ ingest:
|
|||
|
||||
expect(() =>
|
||||
parseKtxProjectConfig(`
|
||||
project: demo
|
||||
scan:
|
||||
enrichment:
|
||||
backend: gateway
|
||||
|
|
@ -345,7 +332,6 @@ scan:
|
|||
|
||||
expect(() =>
|
||||
parseKtxProjectConfig(`
|
||||
project: demo
|
||||
scan:
|
||||
enrichment:
|
||||
mode: llm
|
||||
|
|
@ -356,7 +342,6 @@ scan:
|
|||
|
||||
expect(() =>
|
||||
parseKtxProjectConfig(`
|
||||
project: demo
|
||||
ingest:
|
||||
embeddings:
|
||||
provider: gateway
|
||||
|
|
@ -368,7 +353,6 @@ ingest:
|
|||
it('rejects gateway embedding configs', () => {
|
||||
expect(() =>
|
||||
parseKtxProjectConfig(`
|
||||
project: demo
|
||||
ingest:
|
||||
embeddings:
|
||||
backend: gateway
|
||||
|
|
@ -379,7 +363,6 @@ ingest:
|
|||
|
||||
expect(() =>
|
||||
parseKtxProjectConfig(`
|
||||
project: demo
|
||||
scan:
|
||||
enrichment:
|
||||
mode: llm
|
||||
|
|
@ -392,9 +375,9 @@ scan:
|
|||
});
|
||||
|
||||
it('fills optional sections when a minimal config is loaded', () => {
|
||||
const config = parseKtxProjectConfig('project: local\n');
|
||||
const config = parseKtxProjectConfig('{}\n');
|
||||
|
||||
expect(config).toEqual(buildDefaultKtxProjectConfig('local'));
|
||||
expect(config).toEqual(buildDefaultKtxProjectConfig());
|
||||
expect(config.ingest.embeddings).toEqual({
|
||||
backend: 'deterministic',
|
||||
model: 'deterministic',
|
||||
|
|
@ -406,14 +389,15 @@ scan:
|
|||
expect(() => parseKtxProjectConfig('- nope\n')).toThrow('ktx.yaml must contain a YAML object');
|
||||
});
|
||||
|
||||
it('rejects configs with a missing project name', () => {
|
||||
expect(() => parseKtxProjectConfig('connections: {}\n')).toThrow('ktx.yaml field "project" is required');
|
||||
it('accepts configs without a project name', () => {
|
||||
expect(parseKtxProjectConfig('connections: {}\n')).toMatchObject({
|
||||
connections: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown top-level fields under strict mode', () => {
|
||||
expect(() =>
|
||||
parseKtxProjectConfig(`
|
||||
project: demo
|
||||
storrage:
|
||||
state: sqlite
|
||||
`),
|
||||
|
|
@ -423,13 +407,12 @@ storrage:
|
|||
|
||||
describe('validateKtxProjectConfig', () => {
|
||||
it('returns ok: true with no issues for a valid config', () => {
|
||||
const result = validateKtxProjectConfig('project: warehouse\n');
|
||||
const result = validateKtxProjectConfig('connections: {}\n');
|
||||
expect(result).toEqual({ ok: true, issues: [] });
|
||||
});
|
||||
|
||||
it('collects every schema issue without throwing', () => {
|
||||
const result = validateKtxProjectConfig(`
|
||||
project: ""
|
||||
storage:
|
||||
search: not-a-real-backend
|
||||
scan:
|
||||
|
|
@ -441,7 +424,6 @@ scan:
|
|||
const paths = result.issues.map((issue) => issue.path);
|
||||
expect(paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'project',
|
||||
'storage.search',
|
||||
'scan.relationships.acceptThreshold',
|
||||
]),
|
||||
|
|
@ -450,7 +432,6 @@ scan:
|
|||
|
||||
it('attaches migration hints for known deprecated keys', () => {
|
||||
const result = validateKtxProjectConfig(`
|
||||
project: demo
|
||||
ingest:
|
||||
llm:
|
||||
backend: anthropic
|
||||
|
|
@ -499,18 +480,15 @@ describe('generateKtxProjectConfigJsonSchema', () => {
|
|||
|
||||
it('exposes every top-level ktx.yaml section under properties', () => {
|
||||
const properties = schema.properties as Record<string, unknown>;
|
||||
expect(Object.keys(properties).sort()).toEqual(
|
||||
['agent', 'connections', 'ingest', 'llm', 'memory', 'project', 'scan', 'setup', 'storage'].sort(),
|
||||
);
|
||||
expect(Object.keys(properties).sort()).toEqual(['agent', 'connections', 'ingest', 'llm', 'memory', 'scan', 'setup', 'storage'].sort());
|
||||
});
|
||||
|
||||
it('marks "project" as required', () => {
|
||||
expect(schema.required).toEqual(expect.arrayContaining(['project']));
|
||||
it('does not require any top-level fields', () => {
|
||||
expect(schema.required).toBeUndefined();
|
||||
});
|
||||
|
||||
it('carries .describe() text on top-level fields', () => {
|
||||
const properties = schema.properties as Record<string, { description?: string }>;
|
||||
expect(properties.project?.description).toMatch(/Project identifier/);
|
||||
expect(properties.llm?.description).toMatch(/LLM/);
|
||||
expect(properties.scan?.description).toMatch(/Schema-scan/);
|
||||
});
|
||||
|
|
@ -531,4 +509,11 @@ describe('generateKtxProjectConfigJsonSchema', () => {
|
|||
const relationships = scan?.properties?.relationships as { properties?: Record<string, { description?: string }> };
|
||||
expect(relationships?.properties?.acceptThreshold?.description).toMatch(/auto-accepted/);
|
||||
});
|
||||
|
||||
it('emits the mappings shapes under connections', () => {
|
||||
const serialized = JSON.stringify(schema);
|
||||
expect(serialized).toContain('databaseMappings');
|
||||
expect(serialized).toContain('connectionMappings');
|
||||
expect(serialized).toContain('expectedLookerConnectionName');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { KTX_MODEL_ROLES } from '@ktx/llm';
|
||||
import YAML from 'yaml';
|
||||
import * as z from 'zod';
|
||||
import { connectionConfigSchema } from './driver-schemas.js';
|
||||
|
||||
const KTX_LLM_BACKENDS = ['none', 'anthropic', 'vertex', 'gateway'] as const;
|
||||
const KTX_EMBEDDING_BACKENDS = ['none', 'deterministic', 'openai', 'sentence-transformers'] as const;
|
||||
|
|
@ -206,12 +207,7 @@ const storageSchema = z
|
|||
})
|
||||
.describe('Storage backends and commit policy for KTX state and search indexes.');
|
||||
|
||||
const connectionSchema = z
|
||||
.looseObject({
|
||||
driver: z.string().min(1).optional().describe('Connector driver identifier (e.g. "postgres", "bigquery", "snowflake").'),
|
||||
url: z.string().optional().describe('Connection URL or DSN. Format depends on the driver; may contain environment-variable references.'),
|
||||
})
|
||||
.describe('A single database/connector connection entry. Additional driver-specific fields are accepted and passed through.');
|
||||
const connectionSchema = connectionConfigSchema;
|
||||
|
||||
const agentSchema = z
|
||||
.strictObject({
|
||||
|
|
@ -242,11 +238,6 @@ const memorySchema = z
|
|||
|
||||
const ktxProjectConfigSchema = z
|
||||
.strictObject({
|
||||
project: z
|
||||
.string({ error: 'ktx.yaml field "project" is required' })
|
||||
.trim()
|
||||
.min(1, 'ktx.yaml field "project" is required')
|
||||
.describe('Project identifier; used in logs, ktx state files, and as the default workspace name.'),
|
||||
setup: setupSchema.optional().describe('Setup-wizard state. Written by `ktx setup`; may be omitted.'),
|
||||
connections: z
|
||||
.record(z.string(), connectionSchema)
|
||||
|
|
@ -336,8 +327,8 @@ function formatZodError(error: z.ZodError, input: unknown): string {
|
|||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildDefaultKtxProjectConfig(projectName = 'ktx-project'): KtxProjectConfig {
|
||||
return ktxProjectConfigSchema.parse({ project: projectName });
|
||||
export function buildDefaultKtxProjectConfig(): KtxProjectConfig {
|
||||
return ktxProjectConfigSchema.parse({});
|
||||
}
|
||||
|
||||
export function parseKtxProjectConfig(raw: string): KtxProjectConfig {
|
||||
|
|
|
|||
140
packages/context/src/project/driver-schemas.test.ts
Normal file
140
packages/context/src/project/driver-schemas.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { connectionConfigSchema } from './driver-schemas.js';
|
||||
|
||||
describe('connectionConfigSchema (driver discriminated union)', () => {
|
||||
it.each([
|
||||
['postgres', 'postgres://user:pass@host:5432/db'], // pragma: allowlist secret
|
||||
['postgresql', 'postgresql://user:pass@host:5432/db'], // pragma: allowlist secret
|
||||
['mysql', 'mysql://user:pass@host:3306/db'], // pragma: allowlist secret
|
||||
['snowflake', 'snowflake://account/db'],
|
||||
['bigquery', 'bigquery://project/dataset'],
|
||||
['sqlite', 'sqlite:///tmp/db.sqlite'],
|
||||
['clickhouse', 'clickhouse://host:8123/db'],
|
||||
['sqlserver', 'sqlserver://host:1433;database=db'],
|
||||
])('parses %s warehouse connection', (driver, url) => {
|
||||
expect(connectionConfigSchema.parse({ driver, url })).toMatchObject({ driver, url });
|
||||
});
|
||||
|
||||
it('preserves unknown warehouse fields via looseObject passthrough', () => {
|
||||
const parsed = connectionConfigSchema.parse({
|
||||
driver: 'postgres',
|
||||
url: 'postgres://x',
|
||||
historicSql: { enabled: true },
|
||||
context: { queryHistory: { enabled: false } },
|
||||
});
|
||||
expect(parsed).toMatchObject({
|
||||
driver: 'postgres',
|
||||
historicSql: { enabled: true },
|
||||
context: { queryHistory: { enabled: false } },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an unknown driver', () => {
|
||||
expect(() => connectionConfigSchema.parse({ driver: 'nope', url: 'x' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectionConfigSchema - context source drivers with mappings', () => {
|
||||
it('parses a metabase connection with mappings', () => {
|
||||
const parsed = connectionConfigSchema.parse({
|
||||
driver: 'metabase',
|
||||
api_url: 'https://metabase.example.com',
|
||||
api_key_ref: 'env:METABASE_API_KEY', // pragma: allowlist secret
|
||||
mappings: {
|
||||
databaseMappings: { '3': 'prod-warehouse' },
|
||||
syncEnabled: { '3': true },
|
||||
syncMode: 'ONLY',
|
||||
},
|
||||
});
|
||||
expect(parsed).toMatchObject({
|
||||
driver: 'metabase',
|
||||
api_url: 'https://metabase.example.com',
|
||||
mappings: {
|
||||
databaseMappings: { '3': 'prod-warehouse' },
|
||||
syncMode: 'ONLY',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a looker connection with connectionMappings', () => {
|
||||
const parsed = connectionConfigSchema.parse({
|
||||
driver: 'looker',
|
||||
base_url: 'https://looker.example.com',
|
||||
client_id: 'abc',
|
||||
client_secret_ref: 'env:LOOKER_CLIENT_SECRET', // pragma: allowlist secret
|
||||
mappings: { connectionMappings: { bigquery_prod: 'wh' } },
|
||||
});
|
||||
expect(parsed.mappings).toEqual({ connectionMappings: { bigquery_prod: 'wh' } });
|
||||
});
|
||||
|
||||
it('parses a lookml connection with expectedLookerConnectionName', () => {
|
||||
const parsed = connectionConfigSchema.parse({
|
||||
driver: 'lookml',
|
||||
repoUrl: 'https://github.com/acme/looker.git',
|
||||
branch: 'main',
|
||||
mappings: { expectedLookerConnectionName: 'bigquery_prod' },
|
||||
});
|
||||
expect(parsed.mappings).toEqual({ expectedLookerConnectionName: 'bigquery_prod' });
|
||||
});
|
||||
|
||||
it('rejects metabase mapping with non-integer database key', () => {
|
||||
expect(() =>
|
||||
connectionConfigSchema.parse({
|
||||
driver: 'metabase',
|
||||
api_url: 'https://x',
|
||||
mappings: { databaseMappings: { abc: 'wh' } },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectionConfigSchema - notion / dbt / metricflow', () => {
|
||||
it('parses a notion connection with selected_roots crawl', () => {
|
||||
const parsed = connectionConfigSchema.parse({
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_TOKEN',
|
||||
crawl_mode: 'selected_roots',
|
||||
root_page_ids: ['abc', 'def'],
|
||||
max_pages_per_run: 500,
|
||||
});
|
||||
expect(parsed).toMatchObject({
|
||||
driver: 'notion',
|
||||
crawl_mode: 'selected_roots',
|
||||
root_page_ids: ['abc', 'def'],
|
||||
max_pages_per_run: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects notion with unknown crawl_mode', () => {
|
||||
expect(() =>
|
||||
connectionConfigSchema.parse({
|
||||
driver: 'notion',
|
||||
auth_token_ref: 'env:NOTION_TOKEN',
|
||||
crawl_mode: 'everything',
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('parses a dbt connection from a local source_dir', () => {
|
||||
const parsed = connectionConfigSchema.parse({
|
||||
driver: 'dbt',
|
||||
source_dir: '/tmp/dbt-project',
|
||||
target: 'dev',
|
||||
});
|
||||
expect(parsed).toMatchObject({ driver: 'dbt', source_dir: '/tmp/dbt-project', target: 'dev' });
|
||||
});
|
||||
|
||||
it('parses a metricflow connection with nested config', () => {
|
||||
const parsed = connectionConfigSchema.parse({
|
||||
driver: 'metricflow',
|
||||
metricflow: {
|
||||
repoUrl: 'https://github.com/acme/sl.git',
|
||||
branch: 'main',
|
||||
},
|
||||
});
|
||||
expect(parsed).toMatchObject({
|
||||
driver: 'metricflow',
|
||||
metricflow: { repoUrl: 'https://github.com/acme/sl.git' },
|
||||
});
|
||||
});
|
||||
});
|
||||
205
packages/context/src/project/driver-schemas.ts
Normal file
205
packages/context/src/project/driver-schemas.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import * as z from 'zod';
|
||||
import {
|
||||
lookerMappingsSchema,
|
||||
lookmlMappingsSchema,
|
||||
metabaseMappingsSchema,
|
||||
} from './mappings-yaml-schema.js';
|
||||
|
||||
const warehouseDrivers = [
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'mysql',
|
||||
'snowflake',
|
||||
'bigquery',
|
||||
'sqlite',
|
||||
'clickhouse',
|
||||
'sqlserver',
|
||||
] as const;
|
||||
|
||||
type WarehouseDriver = (typeof warehouseDrivers)[number];
|
||||
|
||||
function warehouseConnectionSchema<const Driver extends WarehouseDriver>(driver: Driver) {
|
||||
return z
|
||||
.looseObject({
|
||||
driver: z.literal(driver),
|
||||
url: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe('Warehouse connection URL or DSN; may contain environment-variable references like env:DATABASE_URL.'),
|
||||
})
|
||||
.describe(
|
||||
`${driver} warehouse connection. Additional driver-tunable fields (e.g. historicSql, context.queryHistory) are accepted and passed through.`,
|
||||
);
|
||||
}
|
||||
|
||||
const warehouseConnectionSchemas = [
|
||||
warehouseConnectionSchema('postgres'),
|
||||
warehouseConnectionSchema('postgresql'),
|
||||
warehouseConnectionSchema('mysql'),
|
||||
warehouseConnectionSchema('snowflake'),
|
||||
warehouseConnectionSchema('bigquery'),
|
||||
warehouseConnectionSchema('sqlite'),
|
||||
warehouseConnectionSchema('clickhouse'),
|
||||
warehouseConnectionSchema('sqlserver'),
|
||||
] as const;
|
||||
|
||||
const positiveIntKeyMessage = (field: string) => `${field} keys must be positive-integer strings (e.g. "1", "42")`;
|
||||
|
||||
const positiveIntKeyRegex = /^[1-9]\d*$/;
|
||||
|
||||
const metabaseMappingsStrictSchema = metabaseMappingsSchema.superRefine((value, ctx) => {
|
||||
for (const key of Object.keys(value.databaseMappings ?? {})) {
|
||||
if (!positiveIntKeyRegex.test(key)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['databaseMappings', key],
|
||||
message: positiveIntKeyMessage('databaseMappings'),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(value.syncEnabled ?? {})) {
|
||||
if (!positiveIntKeyRegex.test(key)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['syncEnabled', key],
|
||||
message: positiveIntKeyMessage('syncEnabled'),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const metabaseConnectionSchema = z
|
||||
.looseObject({
|
||||
driver: z.literal('metabase'),
|
||||
api_url: z.string().url().describe('Metabase instance API URL (e.g. https://metabase.example.com).'),
|
||||
api_key: z.string().min(1).optional().describe('Literal Metabase API key. Prefer api_key_ref for safety.'),
|
||||
api_key_ref: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe('Reference to Metabase API key (e.g. env:METABASE_API_KEY or file:/path).'),
|
||||
network_proxy: z.looseObject({}).optional().describe('Optional network proxy configuration (snake_case form).'),
|
||||
networkProxy: z.looseObject({}).optional().describe('Optional network proxy configuration (camelCase form).'),
|
||||
mappings: metabaseMappingsStrictSchema
|
||||
.optional()
|
||||
.describe('Metabase database-to-warehouse mappings and sync configuration.'),
|
||||
})
|
||||
.describe('Metabase context-source connection.');
|
||||
|
||||
const lookerConnectionSchema = z
|
||||
.looseObject({
|
||||
driver: z.literal('looker'),
|
||||
base_url: z.string().url().describe('Looker instance base URL (e.g. https://looker.example.com).'),
|
||||
client_id: z.string().min(1).describe('Looker OAuth client ID.'),
|
||||
client_secret: z.string().min(1).optional().describe('Literal Looker OAuth client secret. Prefer client_secret_ref.'),
|
||||
client_secret_ref: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe('Reference to Looker OAuth client secret (e.g. env:LOOKER_CLIENT_SECRET).'),
|
||||
mappings: lookerMappingsSchema.optional().describe('Looker connection-name to KTX warehouse mappings.'),
|
||||
})
|
||||
.describe('Looker context-source connection.');
|
||||
|
||||
const lookmlConnectionSchema = z
|
||||
.looseObject({
|
||||
driver: z.literal('lookml'),
|
||||
repoUrl: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('Git URL of the LookML project (https, ssh, or file:). Field is camelCase by convention.'),
|
||||
branch: z.string().min(1).optional().describe('Git branch (default "main" downstream).'),
|
||||
path: z.string().optional().describe('Subdirectory within the repo when the LookML project lives in a monorepo.'),
|
||||
auth_token_ref: z.string().min(1).optional().describe('Reference to Git auth token for private repos (e.g. env:GITHUB_TOKEN).'),
|
||||
mappings: lookmlMappingsSchema.optional().describe('LookML expected-connection mapping for ingest gating.'),
|
||||
})
|
||||
.describe('LookML context-source connection.');
|
||||
|
||||
const notionConnectionSchema = z
|
||||
.looseObject({
|
||||
driver: z.literal('notion'),
|
||||
auth_token: z.string().min(1).optional().describe('Literal Notion integration token. Prefer auth_token_ref.'),
|
||||
auth_token_ref: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe('Reference to Notion integration token (e.g. env:NOTION_TOKEN).'),
|
||||
crawl_mode: z
|
||||
.enum(['selected_roots', 'all_accessible'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Crawl scope. "selected_roots" requires at least one of root_page_ids, root_database_ids, root_data_source_ids.',
|
||||
),
|
||||
root_page_ids: z.array(z.string().min(1)).optional().describe('Notion page IDs to crawl when crawl_mode is selected_roots.'),
|
||||
root_database_ids: z
|
||||
.array(z.string().min(1))
|
||||
.optional()
|
||||
.describe('Notion database IDs to crawl when crawl_mode is selected_roots.'),
|
||||
root_data_source_ids: z
|
||||
.array(z.string().min(1))
|
||||
.optional()
|
||||
.describe('Notion data source IDs to crawl when crawl_mode is selected_roots.'),
|
||||
max_pages_per_run: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(10000)
|
||||
.optional()
|
||||
.describe('Maximum Notion pages fetched in a single ingest run.'),
|
||||
max_knowledge_creates_per_run: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(25)
|
||||
.optional()
|
||||
.describe('Maximum new wiki pages created per run.'),
|
||||
max_knowledge_updates_per_run: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe('Maximum existing wiki pages updated per run.'),
|
||||
})
|
||||
.describe('Notion context-source connection.');
|
||||
|
||||
const dbtConnectionSchema = z
|
||||
.looseObject({
|
||||
driver: z.literal('dbt'),
|
||||
source_dir: z.string().min(1).optional().describe('Absolute or project-relative path to a local dbt project.'),
|
||||
repo_url: z.string().min(1).optional().describe('Git URL of the dbt project (https, ssh, or file:).'),
|
||||
branch: z.string().min(1).optional().describe('Git branch when using repo_url.'),
|
||||
path: z.string().optional().describe('Subdirectory within the repo when the dbt project lives in a monorepo.'),
|
||||
auth_token_ref: z.string().min(1).optional().describe('Reference to Git auth token for private repos.'),
|
||||
profiles_path: z.string().optional().describe('Override path to dbt profiles.yml.'),
|
||||
target: z.string().min(1).optional().describe('dbt target name (e.g. dev, prod).'),
|
||||
project_name: z.string().min(1).optional().describe('Override auto-detected dbt project name.'),
|
||||
})
|
||||
.describe('dbt context-source connection.');
|
||||
|
||||
const metricflowConnectionSchema = z
|
||||
.looseObject({
|
||||
driver: z.literal('metricflow'),
|
||||
metricflow: z
|
||||
.looseObject({
|
||||
repoUrl: z.string().min(1).describe('Git URL of the MetricFlow / SL project.'),
|
||||
branch: z.string().min(1).optional().describe('Git branch (default "main").'),
|
||||
path: z.string().optional().describe('Subdirectory within the repo when the SL config lives in a monorepo.'),
|
||||
auth_token_ref: z.string().min(1).optional().describe('Reference to Git auth token for private repos.'),
|
||||
})
|
||||
.describe('Nested MetricFlow configuration block.'),
|
||||
})
|
||||
.describe('MetricFlow / SL context-source connection.');
|
||||
|
||||
export const connectionConfigSchema = z.discriminatedUnion('driver', [
|
||||
...warehouseConnectionSchemas,
|
||||
metabaseConnectionSchema,
|
||||
lookerConnectionSchema,
|
||||
lookmlConnectionSchema,
|
||||
notionConnectionSchema,
|
||||
dbtConnectionSchema,
|
||||
metricflowConnectionSchema,
|
||||
]);
|
||||
|
||||
export type KtxConnectionConfig = z.infer<typeof connectionConfigSchema>;
|
||||
|
|
@ -15,6 +15,7 @@ export {
|
|||
serializeKtxProjectConfig,
|
||||
validateKtxProjectConfig,
|
||||
} from './config.js';
|
||||
export type { KtxConnectionConfig } from './driver-schemas.js';
|
||||
export type { LocalGitFileStoreDeps } from './local-git-file-store.js';
|
||||
export { LocalGitFileStore } from './local-git-file-store.js';
|
||||
export { ktxLocalStateDbPath } from './local-state-db.js';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
lookerMappingsSchema,
|
||||
lookmlMappingsSchema,
|
||||
metabaseMappingsSchema,
|
||||
parseConnectionMappingBootstrap,
|
||||
parseLookmlMappingBootstrap,
|
||||
parseLookerMappingBootstrap,
|
||||
|
|
@ -82,4 +85,17 @@ describe('ktx.yaml mapping bootstrap schema', () => {
|
|||
}),
|
||||
).toMatchObject({ adapter: 'looker', connectionId: 'prod-looker' });
|
||||
});
|
||||
|
||||
it('exports mapping shapes that parse documented examples', () => {
|
||||
expect(metabaseMappingsSchema.parse({ databaseMappings: { '1': 'wh' } })).toMatchObject({
|
||||
databaseMappings: { '1': 'wh' },
|
||||
syncMode: 'ALL',
|
||||
});
|
||||
expect(lookerMappingsSchema.parse({ connectionMappings: { x: 'wh' } })).toEqual({
|
||||
connectionMappings: { x: 'wh' },
|
||||
});
|
||||
expect(lookmlMappingsSchema.parse({ expectedLookerConnectionName: 'x' })).toEqual({
|
||||
expectedLookerConnectionName: 'x',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import * as z from 'zod';
|
||||
import type { KtxProjectConnectionConfig } from './config.js';
|
||||
|
||||
const metabaseSyncModeSchema = z.enum(['ALL', 'ONLY', 'EXCEPT']);
|
||||
const positiveIntegerValueSchema = z.number().int().positive();
|
||||
|
|
@ -11,24 +10,48 @@ const metabaseSelectionsSchema = z
|
|||
items: z.array(positiveIntegerValueSchema).default([]),
|
||||
});
|
||||
|
||||
const metabaseMappingsSchema = z
|
||||
export const metabaseMappingsSchema = z
|
||||
.object({
|
||||
databaseMappings: z.record(z.string(), stringTargetSchema).default({}),
|
||||
syncEnabled: z.record(z.string(), z.boolean()).default({}),
|
||||
syncMode: metabaseSyncModeSchema.default('ALL'),
|
||||
selections: metabaseSelectionsSchema.default({ collections: [], items: [] }),
|
||||
defaultTagNames: z.array(z.string().min(1)).default([]),
|
||||
});
|
||||
databaseMappings: z
|
||||
.record(z.string(), stringTargetSchema)
|
||||
.default({})
|
||||
.describe('Map of Metabase database ID (positive integer string) to KTX connection ID. Use null to explicitly unmap.'),
|
||||
syncEnabled: z
|
||||
.record(z.string(), z.boolean())
|
||||
.default({})
|
||||
.describe('Per-Metabase-database sync toggle, keyed by Metabase database ID string.'),
|
||||
syncMode: metabaseSyncModeSchema
|
||||
.default('ALL')
|
||||
.describe('Sync scope: ALL ingests every mapped DB; ONLY restricts to syncEnabled=true; EXCEPT excludes syncEnabled=true.'),
|
||||
selections: metabaseSelectionsSchema
|
||||
.default({ collections: [], items: [] })
|
||||
.describe('Optional Metabase collection and item IDs to scope ingest.'),
|
||||
defaultTagNames: z
|
||||
.array(z.string().min(1))
|
||||
.default([])
|
||||
.describe('Default tag names applied to ingested Metabase artifacts.'),
|
||||
})
|
||||
.describe('Metabase database-to-warehouse mapping and sync configuration.');
|
||||
|
||||
const lookerMappingsSchema = z
|
||||
export const lookerMappingsSchema = z
|
||||
.object({
|
||||
connectionMappings: z.record(z.string().min(1), stringTargetSchema).default({}),
|
||||
});
|
||||
connectionMappings: z
|
||||
.record(z.string().min(1), stringTargetSchema)
|
||||
.default({})
|
||||
.describe('Map of Looker connection name to KTX connection ID. Use null to explicitly unmap.'),
|
||||
})
|
||||
.describe('Looker connection-to-warehouse mapping configuration.');
|
||||
|
||||
const lookmlMappingsSchema = z
|
||||
export const lookmlMappingsSchema = z
|
||||
.object({
|
||||
expectedLookerConnectionName: z.string().min(1).nullable().default(null),
|
||||
});
|
||||
expectedLookerConnectionName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.nullable()
|
||||
.default(null)
|
||||
.describe('Looker connection name that LookML models must declare; mismatches block sl_write_source at ingest time.'),
|
||||
})
|
||||
.describe('LookML connection-name expectation for ingest gating.');
|
||||
|
||||
export type MetabaseMappingBootstrap = {
|
||||
adapter: 'metabase';
|
||||
|
|
@ -54,6 +77,11 @@ export type LookmlMappingBootstrap = {
|
|||
|
||||
export type ConnectionMappingBootstrap = MetabaseMappingBootstrap | LookerMappingBootstrap | LookmlMappingBootstrap;
|
||||
|
||||
type MappingConnectionInput = Record<string, unknown> & {
|
||||
driver?: unknown;
|
||||
mappings?: unknown;
|
||||
};
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
|
@ -66,13 +94,13 @@ function assertPositiveIntegerKeys(field: string, record: Record<string, unknown
|
|||
}
|
||||
}
|
||||
|
||||
function driverOf(connection: KtxProjectConnectionConfig): string {
|
||||
function driverOf(connection: MappingConnectionInput): string {
|
||||
return String(connection.driver ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
export function parseMetabaseMappingBootstrap(
|
||||
connectionId: string,
|
||||
connection: KtxProjectConnectionConfig,
|
||||
connection: MappingConnectionInput,
|
||||
): MetabaseMappingBootstrap {
|
||||
const rawMappings = recordValue(connection.mappings);
|
||||
assertPositiveIntegerKeys('databaseMappings', recordValue(rawMappings.databaseMappings));
|
||||
|
|
@ -91,7 +119,7 @@ export function parseMetabaseMappingBootstrap(
|
|||
|
||||
export function parseLookerMappingBootstrap(
|
||||
connectionId: string,
|
||||
connection: KtxProjectConnectionConfig,
|
||||
connection: MappingConnectionInput,
|
||||
): LookerMappingBootstrap {
|
||||
const parsed = lookerMappingsSchema.parse(recordValue(connection.mappings));
|
||||
return {
|
||||
|
|
@ -103,7 +131,7 @@ export function parseLookerMappingBootstrap(
|
|||
|
||||
export function parseLookmlMappingBootstrap(
|
||||
connectionId: string,
|
||||
connection: KtxProjectConnectionConfig,
|
||||
connection: MappingConnectionInput,
|
||||
): LookmlMappingBootstrap {
|
||||
const parsed = lookmlMappingsSchema.parse(recordValue(connection.mappings));
|
||||
return {
|
||||
|
|
@ -115,7 +143,7 @@ export function parseLookmlMappingBootstrap(
|
|||
|
||||
export function parseConnectionMappingBootstrap(
|
||||
connectionId: string,
|
||||
connection: KtxProjectConnectionConfig,
|
||||
connection: MappingConnectionInput,
|
||||
): ConnectionMappingBootstrap | null {
|
||||
if (!connection.mappings || typeof connection.mappings !== 'object' || Array.isArray(connection.mappings)) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -20,15 +20,13 @@ describe('KTX local project runtime', () => {
|
|||
|
||||
const result = await initKtxProject({
|
||||
projectDir,
|
||||
projectName: 'warehouse',
|
||||
authorName: 'Agent',
|
||||
authorEmail: 'agent@example.com',
|
||||
});
|
||||
|
||||
expect(result.projectDir).toBe(projectDir);
|
||||
expect(result.config.project).toBe('warehouse');
|
||||
expect(result.commitHash).toMatch(/^[0-9a-f]{40}$/);
|
||||
await expect(readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).resolves.toContain('project: warehouse');
|
||||
await expect(readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).resolves.not.toContain('project:');
|
||||
const gitignore = await readFile(join(projectDir, '.ktx/.gitignore'), 'utf-8');
|
||||
expect(gitignore).toContain('cache/');
|
||||
expect(gitignore).toContain('db.sqlite');
|
||||
|
|
@ -46,7 +44,7 @@ describe('KTX local project runtime', () => {
|
|||
|
||||
it('loads an initialized project with a working file store', async () => {
|
||||
const projectDir = join(tempDir, 'warehouse');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
|
||||
const loaded = await loadKtxProject({ projectDir });
|
||||
await loaded.fileStore.writeFile(
|
||||
|
|
@ -57,7 +55,6 @@ describe('KTX local project runtime', () => {
|
|||
'Add revenue page',
|
||||
);
|
||||
|
||||
expect(loaded.config.project).toBe('warehouse');
|
||||
await expect(loaded.fileStore.readFile('wiki/global/revenue.md')).resolves.toMatchObject({
|
||||
content: '# Revenue\n',
|
||||
});
|
||||
|
|
@ -65,16 +62,12 @@ describe('KTX local project runtime', () => {
|
|||
|
||||
it('rejects reinitializing an existing project unless force is set', async () => {
|
||||
const projectDir = join(tempDir, 'warehouse');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
await initKtxProject({ projectDir });
|
||||
|
||||
await expect(initKtxProject({ projectDir, projectName: 'warehouse' })).rejects.toThrow(
|
||||
'Project already contains ktx.yaml',
|
||||
);
|
||||
await expect(initKtxProject({ projectDir })).rejects.toThrow('Project already contains ktx.yaml');
|
||||
|
||||
await expect(initKtxProject({ projectDir, projectName: 'warehouse-v2', force: true })).resolves.toMatchObject({
|
||||
config: {
|
||||
project: 'warehouse-v2',
|
||||
},
|
||||
await expect(initKtxProject({ projectDir, force: true })).resolves.toMatchObject({
|
||||
configPath: join(projectDir, 'ktx.yaml'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { LocalGitFileStore } from './local-git-file-store.js';
|
|||
|
||||
export interface InitKtxProjectOptions {
|
||||
projectDir: string;
|
||||
projectName?: string;
|
||||
force?: boolean;
|
||||
authorName?: string;
|
||||
authorEmail?: string;
|
||||
|
|
@ -101,7 +100,7 @@ async function createRuntime(
|
|||
|
||||
export async function initKtxProject(options: InitKtxProjectOptions): Promise<InitKtxProjectResult> {
|
||||
const projectDir = resolve(options.projectDir);
|
||||
const projectName = options.projectName?.trim() || basename(projectDir) || 'ktx-project';
|
||||
const projectName = basename(projectDir) || 'ktx-project';
|
||||
const authorName = options.authorName ?? 'ktx';
|
||||
const authorEmail = options.authorEmail ?? 'ktx@example.com';
|
||||
const logger = options.logger ?? noopLogger;
|
||||
|
|
@ -112,7 +111,7 @@ export async function initKtxProject(options: InitKtxProjectOptions): Promise<In
|
|||
throw new Error(`Project already contains ktx.yaml: ${configPath}`);
|
||||
}
|
||||
|
||||
const config = buildDefaultKtxProjectConfig(projectName);
|
||||
const config = buildDefaultKtxProjectConfig();
|
||||
const runtime = await createRuntime(projectDir, config, authorName, authorEmail, logger);
|
||||
|
||||
await writeProjectFile(projectDir, 'ktx.yaml', serializeKtxProjectConfig(config));
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ describe('KTX setup config helpers', () => {
|
|||
});
|
||||
|
||||
it('sets setup database connection ids without duplicates', () => {
|
||||
const config = buildDefaultKtxProjectConfig('warehouse');
|
||||
const config = buildDefaultKtxProjectConfig();
|
||||
|
||||
const withDatabases = setKtxSetupDatabaseConnectionIds(config, ['warehouse', 'analytics', 'warehouse']);
|
||||
|
||||
|
|
|
|||
|
|
@ -231,7 +231,6 @@ describe('writeLocalScanEnrichmentArtifacts', () => {
|
|||
tempDir = await mkdtemp(join(tmpdir(), 'ktx-local-enrichment-artifacts-'));
|
||||
project = await initKtxProject({
|
||||
projectDir: join(tempDir, 'project'),
|
||||
projectName: 'warehouse',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
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