mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
* feat(cli): define full warehouse dialect contract
* test(cli): keep dialect edge tests focused
* fix(cli): stabilize dialect contract foundation
* refactor(connectors): own read-only query preparation
* refactor(connectors): resolve dialects through registry
* refactor(connectors): keep concrete dialect classes internal
* chore(workspace): enforce dialect import boundary
* refactor(cli): resolve relationship dialect at scan boundary
* refactor(cli): use dialect display parsing for entity details
* refactor(cli): use dialect display parsing for warehouse catalog
* refactor(cli): use dialect SQL in relationship workflows
* test(cli): verify solid dialect scan workflow closure
* test: split cli tests from source tree
* refactor(cli): standardize BigQuery scope listing
* feat(sqlite): implement connector scope listing
* test(connectors): cover required table listing
* feat(cli): add warehouse driver registry
* refactor(setup): route scope discovery through driver registry
* refactor(cli): route local query execution through driver registry
* refactor(historic-sql): route dialect support through driver registry
* refactor(cli): test warehouse connections through driver registry
* fix(cli): close driver registry type export gaps
* Improve setup daemon diagnostics
* refactor(setup): centralize rail-prefixed diagnostics + query-history fallback
Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput
into clack.ts so the setup wizard, managed daemons, and embedding/agent steps
share one rail-formatted writer. setup-databases.ts also adds a
"disable query history and retry" option when the schema-context build fails
and query history is the likely culprit, surfaced via a new
failed-query-history-unavailable status.
* fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match
The setup picker's KtxTableListEntry was a 2-level { schema, name }, so
qualifiedTableId always wrote db.name into enabled_tables. When BigQuery,
Snowflake, or SQL Server later ran fast ingest, their introspect step filtered
the scope set with scopedTableNames(scope, { catalog: projectId|database, db })
— catalog was non-null on the introspect side but null in the scope refs, so
every entry was rejected, the live-database adapter staged zero table files,
and detect() failed with 'Adapter "live-database" did not recognize fetched
source output'.
Align the picker boundary with the canonical 3-level KtxTableRef:
- Add catalog: string | null to KtxTableListEntry.
- BigQuery/Snowflake/SQL Server listTables populate catalog from the
resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null.
- qualifiedTableId emits catalog.schema.name when catalog is non-null
(resolveEnabledTables already accepts the 3-part shape) and
schemasFromEnabledTables now goes through parseDottedTableEntry so it
recovers the schema correctly from both 2-part and 3-part entries.
- Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker
reuse.
Update listTables expectations in all seven connector tests and the setup /
picker test fixtures. Add a picker regression test that covers the
catalog-bearing round-trip (save + refine).
* fix(cli): allow debug telemetry under opt-out env
612 lines
21 KiB
TypeScript
612 lines
21 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import Database from 'better-sqlite3';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js';
|
|
import type { KtxLocalProject } from '../src/context/project/project.js';
|
|
import {
|
|
buildLocalStatsStatus,
|
|
buildProjectStatus,
|
|
renderProjectStatus,
|
|
} from '../src/status-project.js';
|
|
|
|
function projectWithConfig(config: KtxProjectConfig): KtxLocalProject {
|
|
return {
|
|
projectDir: '/work/proj',
|
|
configPath: '/work/proj/ktx.yaml',
|
|
config,
|
|
coreConfig: {} as KtxLocalProject['coreConfig'],
|
|
git: {} as KtxLocalProject['git'],
|
|
fileStore: {} as KtxLocalProject['fileStore'],
|
|
};
|
|
}
|
|
|
|
function withEmbeddings(
|
|
config: KtxProjectConfig,
|
|
embeddings: KtxProjectConfig['ingest']['embeddings'],
|
|
): KtxProjectConfig {
|
|
return {
|
|
...config,
|
|
ingest: { ...config.ingest, embeddings },
|
|
scan: { ...config.scan, enrichment: { ...config.scan.enrichment, embeddings } },
|
|
};
|
|
}
|
|
|
|
function withClaudeCodeLlm(config: KtxProjectConfig): KtxProjectConfig {
|
|
return {
|
|
...config,
|
|
llm: {
|
|
...config.llm,
|
|
provider: { backend: 'claude-code' },
|
|
models: { ...config.llm.models, default: 'sonnet' },
|
|
},
|
|
};
|
|
}
|
|
|
|
function baseProjectConfig(): KtxProjectConfig {
|
|
return withClaudeCodeLlm(buildDefaultKtxProjectConfig());
|
|
}
|
|
|
|
const stubClaudeCodeAuthProbe = async () => ({ ok: true as const });
|
|
|
|
describe('buildProjectStatus embeddings', () => {
|
|
it('reports sentence-transformers with explicit base_url as ok', async () => {
|
|
const project = projectWithConfig(
|
|
withEmbeddings(baseProjectConfig(), {
|
|
backend: 'sentence-transformers',
|
|
model: 'all-MiniLM-L6-v2',
|
|
dimensions: 384,
|
|
sentenceTransformers: { base_url: 'http://my-st:8080', pathPrefix: '' },
|
|
}),
|
|
);
|
|
|
|
const status = await buildProjectStatus(project, {
|
|
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
|
|
});
|
|
|
|
expect(status.embeddings).toMatchObject({
|
|
backend: 'sentence-transformers',
|
|
status: 'ok',
|
|
detail: 'service: http://my-st:8080',
|
|
});
|
|
});
|
|
|
|
it('reports sentence-transformers with omitted base_url as managed daemon (ok)', async () => {
|
|
const project = projectWithConfig(
|
|
withEmbeddings(baseProjectConfig(), {
|
|
backend: 'sentence-transformers',
|
|
model: 'all-MiniLM-L6-v2',
|
|
dimensions: 384,
|
|
}),
|
|
);
|
|
|
|
const status = await buildProjectStatus(project, {
|
|
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
|
|
});
|
|
|
|
expect(status.embeddings).toMatchObject({
|
|
backend: 'sentence-transformers',
|
|
status: 'ok',
|
|
detail: 'managed local embeddings daemon',
|
|
});
|
|
expect(status.verdictReason).not.toMatch(/embedding credentials missing/);
|
|
});
|
|
|
|
it('reports sentence-transformers with empty base_url string as managed daemon (ok)', async () => {
|
|
const project = projectWithConfig(
|
|
withEmbeddings(baseProjectConfig(), {
|
|
backend: 'sentence-transformers',
|
|
model: 'all-MiniLM-L6-v2',
|
|
dimensions: 384,
|
|
sentenceTransformers: { base_url: '', pathPrefix: '' },
|
|
}),
|
|
);
|
|
|
|
const status = await buildProjectStatus(project, {
|
|
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
|
|
});
|
|
|
|
expect(status.embeddings).toMatchObject({
|
|
backend: 'sentence-transformers',
|
|
status: 'ok',
|
|
detail: 'managed local embeddings daemon',
|
|
});
|
|
});
|
|
|
|
it('reports openai backend with missing key as warn', async () => {
|
|
const project = projectWithConfig(
|
|
withEmbeddings(baseProjectConfig(), {
|
|
backend: 'openai',
|
|
model: 'text-embedding-3-small',
|
|
dimensions: 1536,
|
|
openai: { api_key: 'env:OPENAI_API_KEY' }, // pragma: allowlist secret
|
|
}),
|
|
);
|
|
|
|
const status = await buildProjectStatus(project, {
|
|
env: {},
|
|
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
|
|
});
|
|
|
|
expect(status.embeddings.status).toBe('warn');
|
|
expect(status.verdictReason).toMatch(/embedding credentials missing/);
|
|
});
|
|
});
|
|
|
|
function withPostgresQueryHistory(config: KtxProjectConfig): KtxProjectConfig {
|
|
return {
|
|
...config,
|
|
connections: {
|
|
...config.connections,
|
|
analytics: {
|
|
driver: 'postgres',
|
|
url: 'env:ANALYTICS_DATABASE_URL',
|
|
context: { queryHistory: { enabled: true } },
|
|
} as KtxProjectConfig['connections'][string],
|
|
},
|
|
};
|
|
}
|
|
|
|
function withSnowflakeQueryHistory(config: KtxProjectConfig): KtxProjectConfig {
|
|
return {
|
|
...config,
|
|
connections: {
|
|
...config.connections,
|
|
warehouse: {
|
|
driver: 'snowflake',
|
|
account: 'EMOVRJS-CZ07756',
|
|
warehouse: 'COMPUTE_WH',
|
|
database: 'ANALYTICS',
|
|
username: 'svc_ktx',
|
|
password: 'env:SNOWFLAKE_PASSWORD', // pragma: allowlist secret
|
|
context: { queryHistory: { enabled: true } },
|
|
} as KtxProjectConfig['connections'][string],
|
|
},
|
|
};
|
|
}
|
|
|
|
function withBigQueryQueryHistory(config: KtxProjectConfig): KtxProjectConfig {
|
|
return {
|
|
...config,
|
|
connections: {
|
|
...config.connections,
|
|
bq: {
|
|
driver: 'bigquery',
|
|
credentials_json: 'env:BQ_CREDENTIALS_JSON',
|
|
context: { queryHistory: { enabled: true } },
|
|
} as KtxProjectConfig['connections'][string],
|
|
},
|
|
};
|
|
}
|
|
|
|
function withMysqlQueryHistory(config: KtxProjectConfig): KtxProjectConfig {
|
|
return {
|
|
...config,
|
|
connections: {
|
|
...config.connections,
|
|
legacy: {
|
|
driver: 'mysql',
|
|
host: 'db.example.com',
|
|
database: 'analytics',
|
|
username: 'svc',
|
|
password: 'env:MYSQL_PASSWORD', // pragma: allowlist secret
|
|
context: { queryHistory: { enabled: true } },
|
|
} as KtxProjectConfig['connections'][string],
|
|
},
|
|
};
|
|
}
|
|
|
|
function fakeStatusRunner(
|
|
dialect: 'postgres' | 'snowflake' | 'bigquery',
|
|
catalogName: string,
|
|
) {
|
|
return {
|
|
dialect,
|
|
catalogName,
|
|
async run() {
|
|
return { warnings: [], info: [] };
|
|
},
|
|
formatSuccessDetail(result: unknown) {
|
|
const typed = result as { warnings: string[]; info?: string[]; pgServerVersion?: string };
|
|
const info = typed.info && typed.info.length > 0 ? `; ${typed.info.join('; ')}` : '';
|
|
const base =
|
|
dialect === 'postgres'
|
|
? `pg_stat_statements ready (${typed.pgServerVersion ?? 'PostgreSQL 16.4'})`
|
|
: `${catalogName} ready`;
|
|
return { detail: `${base}${info}`, warnings: typed.warnings };
|
|
},
|
|
fixAdvice(error: unknown) {
|
|
return {
|
|
failHeadline: error instanceof Error ? error.message : String(error),
|
|
remediation: 'Fix query-history grants.',
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('buildProjectStatus query history dispatch', () => {
|
|
it('runs the shared probe for snowflake connections', async () => {
|
|
let probeCalls = 0;
|
|
const runner = fakeStatusRunner(
|
|
'snowflake',
|
|
'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY',
|
|
);
|
|
const project = projectWithConfig(withSnowflakeQueryHistory(baseProjectConfig()));
|
|
|
|
const status = await buildProjectStatus(project, {
|
|
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
|
|
queryHistoryReadinessProbe: async (input) => {
|
|
probeCalls += 1;
|
|
expect(input.connectionId).toBe('warehouse');
|
|
return {
|
|
ok: true,
|
|
dialect: 'snowflake',
|
|
runner,
|
|
result: { warnings: [], info: [] },
|
|
};
|
|
},
|
|
});
|
|
|
|
expect(probeCalls).toBe(1);
|
|
expect(status.queryHistory).toHaveLength(1);
|
|
expect(status.queryHistory[0]).toMatchObject({
|
|
connection: 'warehouse',
|
|
driver: 'snowflake',
|
|
dialect: 'snowflake',
|
|
status: 'ok',
|
|
});
|
|
expect(status.queryHistory[0].detail).toMatch(/SNOWFLAKE\.ACCOUNT_USAGE\.QUERY_HISTORY/);
|
|
expect(status.queryHistory[0].fix).toBeUndefined();
|
|
expect(status.verdict).not.toBe('blocked');
|
|
});
|
|
|
|
it('reports snowflake probe failures with the reader-provided remediation', async () => {
|
|
const project = projectWithConfig(withSnowflakeQueryHistory(baseProjectConfig()));
|
|
|
|
const status = await buildProjectStatus(project, {
|
|
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
|
|
queryHistoryReadinessProbe: async () => ({
|
|
ok: false,
|
|
dialect: 'snowflake',
|
|
runner: {
|
|
...fakeStatusRunner('snowflake', 'SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY'),
|
|
fixAdvice: () => ({
|
|
failHeadline: 'Snowflake role cannot read SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY',
|
|
remediation: 'GRANT IMPORTED PRIVILEGES ON DATABASE SNOWFLAKE TO ROLE ktx;',
|
|
}),
|
|
},
|
|
error: new Error('role cannot read SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY'),
|
|
}),
|
|
});
|
|
|
|
expect(status.queryHistory[0]).toMatchObject({
|
|
connection: 'warehouse',
|
|
driver: 'snowflake',
|
|
dialect: 'snowflake',
|
|
status: 'fail',
|
|
fix: 'GRANT IMPORTED PRIVILEGES ON DATABASE SNOWFLAKE TO ROLE ktx;',
|
|
});
|
|
expect(status.queryHistory[0].detail).not.toMatch(/Set connections.*driver to postgres/);
|
|
});
|
|
|
|
it('runs the bigquery probe for bigquery connections', async () => {
|
|
let probeCalls = 0;
|
|
const runner = fakeStatusRunner('bigquery', 'INFORMATION_SCHEMA.JOBS_BY_PROJECT');
|
|
const project = projectWithConfig(withBigQueryQueryHistory(baseProjectConfig()));
|
|
|
|
const status = await buildProjectStatus(project, {
|
|
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
|
|
queryHistoryReadinessProbe: async (input) => {
|
|
probeCalls += 1;
|
|
expect(input.connectionId).toBe('bq');
|
|
return {
|
|
ok: true,
|
|
dialect: 'bigquery',
|
|
runner,
|
|
result: { warnings: [], info: [] },
|
|
};
|
|
},
|
|
});
|
|
|
|
expect(probeCalls).toBe(1);
|
|
expect(status.queryHistory[0]).toMatchObject({
|
|
connection: 'bq',
|
|
driver: 'bigquery',
|
|
dialect: 'bigquery',
|
|
status: 'ok',
|
|
});
|
|
expect(status.queryHistory[0].detail).toMatch(/INFORMATION_SCHEMA\.JOBS_BY_PROJECT/);
|
|
});
|
|
|
|
it('fails with an accurate message for drivers without a query history reader', async () => {
|
|
const project = projectWithConfig(withMysqlQueryHistory(baseProjectConfig()));
|
|
|
|
const status = await buildProjectStatus(project, {
|
|
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
|
|
queryHistoryReadinessProbe: async () => {
|
|
throw new Error('postgres probe must not run for mysql');
|
|
},
|
|
});
|
|
|
|
expect(status.queryHistory).toHaveLength(1);
|
|
expect(status.queryHistory[0]).toMatchObject({
|
|
connection: 'legacy',
|
|
driver: 'mysql',
|
|
dialect: 'mysql',
|
|
status: 'fail',
|
|
detail: 'query history is not supported for driver "mysql"',
|
|
});
|
|
expect(status.queryHistory[0].fix).toMatch(
|
|
/Disable connections\.legacy\.context\.queryHistory/,
|
|
);
|
|
expect(status.queryHistory[0].fix).not.toMatch(/driver to postgres/);
|
|
});
|
|
});
|
|
|
|
describe('buildProjectStatus --fast', () => {
|
|
it('skips claude-code probe and Postgres query-history probe', async () => {
|
|
let claudeProbeCalls = 0;
|
|
let queryHistoryProbeCalls = 0;
|
|
const project = projectWithConfig(withPostgresQueryHistory(baseProjectConfig()));
|
|
|
|
const status = await buildProjectStatus(project, {
|
|
env: { ANALYTICS_DATABASE_URL: 'postgres://example' },
|
|
fast: true,
|
|
claudeCodeAuthProbe: async () => {
|
|
claudeProbeCalls += 1;
|
|
return { ok: true };
|
|
},
|
|
queryHistoryReadinessProbe: async () => {
|
|
queryHistoryProbeCalls += 1;
|
|
throw new Error('should not be called');
|
|
},
|
|
});
|
|
|
|
expect(claudeProbeCalls).toBe(0);
|
|
expect(queryHistoryProbeCalls).toBe(0);
|
|
expect(status.llm.status).toBe('skipped');
|
|
expect(status.llm.detail).toMatch(/--fast/);
|
|
expect(status.queryHistory).toHaveLength(1);
|
|
expect(status.queryHistory[0]).toMatchObject({
|
|
connection: 'analytics',
|
|
status: 'skipped',
|
|
});
|
|
expect(status.verdict).not.toBe('blocked');
|
|
});
|
|
|
|
it('does not call probes lazily when fast and reports skipped in render', async () => {
|
|
const project = projectWithConfig(withPostgresQueryHistory(baseProjectConfig()));
|
|
const status = await buildProjectStatus(project, {
|
|
env: { ANALYTICS_DATABASE_URL: 'postgres://example' },
|
|
fast: true,
|
|
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
|
|
queryHistoryReadinessProbe: async () => {
|
|
throw new Error('should not be called');
|
|
},
|
|
});
|
|
const rendered = renderProjectStatus(status, { verbose: false, useColor: false });
|
|
expect(rendered).toContain('auth probe skipped (--fast)');
|
|
expect(rendered).toContain('pg_stat_statements probe skipped (--fast)');
|
|
});
|
|
});
|
|
|
|
describe('buildLocalStatsStatus', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await mkdtemp(join(tmpdir(), 'ktx-status-stats-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function projectIn(dir: string): KtxLocalProject {
|
|
return {
|
|
projectDir: dir,
|
|
configPath: join(dir, 'ktx.yaml'),
|
|
config: baseProjectConfig(),
|
|
coreConfig: {} as KtxLocalProject['coreConfig'],
|
|
git: {} as KtxLocalProject['git'],
|
|
fileStore: {} as KtxLocalProject['fileStore'],
|
|
};
|
|
}
|
|
|
|
it('returns unavailable when .ktx/db.sqlite is missing', async () => {
|
|
const stats = await buildLocalStatsStatus(projectIn(tempDir));
|
|
expect(stats.unavailable).toMatch(/no \.ktx\/db\.sqlite/);
|
|
expect(stats.ingest.totalCompletedRuns).toBe(0);
|
|
expect(stats.projectDir.dbSqliteBytes).toBeNull();
|
|
});
|
|
|
|
it('reads counts from a seeded SQLite DB and walks projectDir folders', async () => {
|
|
await mkdir(join(tempDir, '.ktx'), { recursive: true });
|
|
await mkdir(join(tempDir, '.ktx', 'cache'), { recursive: true });
|
|
await writeFile(join(tempDir, '.ktx', 'cache', 'a.bin'), Buffer.alloc(2048));
|
|
await mkdir(join(tempDir, 'raw-sources', 'analytics'), { recursive: true });
|
|
await writeFile(join(tempDir, 'raw-sources', 'analytics', 'snap.json'), 'x'.repeat(100));
|
|
await writeFile(join(tempDir, 'raw-sources', 'analytics', 'snap.bin'), Buffer.alloc(512));
|
|
await mkdir(join(tempDir, 'wiki', 'global', 'sub'), { recursive: true });
|
|
await writeFile(join(tempDir, 'wiki', 'global', 'one.md'), '# one');
|
|
await writeFile(join(tempDir, 'wiki', 'global', 'sub', 'two.md'), '# two');
|
|
await mkdir(join(tempDir, 'semantic-layer'), { recursive: true });
|
|
await writeFile(join(tempDir, 'semantic-layer', 'orders.yaml'), 'name: orders');
|
|
await writeFile(join(tempDir, 'semantic-layer', 'users.yml'), 'name: users');
|
|
|
|
const dbPath = join(tempDir, '.ktx', 'db.sqlite');
|
|
const db = new Database(dbPath);
|
|
db.exec(`
|
|
CREATE TABLE local_ingest_reports (
|
|
run_id TEXT PRIMARY KEY,
|
|
adapter TEXT NOT NULL,
|
|
connection_id TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
completed_at TEXT NOT NULL,
|
|
raw_content_hashes_json TEXT NOT NULL,
|
|
body_json TEXT NOT NULL
|
|
);
|
|
INSERT INTO local_ingest_reports VALUES
|
|
('r1', 'live-database', 'analytics', 'done', '2026-04-01T10:00:00Z', '{}', '{}'),
|
|
('r2', 'live-database', 'analytics', 'done', '2026-05-10T10:00:00Z', '{}', '{}'),
|
|
('r3', 'notion', 'docs', 'done', '2026-05-01T10:00:00Z', '{}', '{}'),
|
|
('r4', 'notion', 'docs', 'error', '2026-05-02T10:00:00Z', '{}', '{}');
|
|
|
|
CREATE TABLE knowledge_pages (
|
|
path TEXT PRIMARY KEY,
|
|
key TEXT NOT NULL,
|
|
scope TEXT NOT NULL,
|
|
scope_id TEXT,
|
|
summary TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
tags TEXT NOT NULL,
|
|
search_text TEXT NOT NULL DEFAULT '',
|
|
embedding_json TEXT
|
|
);
|
|
INSERT INTO knowledge_pages VALUES
|
|
('a.md', 'a', 'GLOBAL', NULL, '', '', '[]', '', '[0.1,0.2]'),
|
|
('b.md', 'b', 'GLOBAL', NULL, '', '', '[]', '', NULL),
|
|
('c.md', 'c', 'PROJECT', NULL, '', '', '[]', '', '[0.3,0.4]');
|
|
|
|
CREATE TABLE local_sl_sources (
|
|
connection_id TEXT NOT NULL,
|
|
source_name TEXT NOT NULL,
|
|
search_text TEXT NOT NULL,
|
|
embedding_json TEXT,
|
|
content_hash TEXT,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (connection_id, source_name)
|
|
);
|
|
INSERT INTO local_sl_sources VALUES
|
|
('analytics', 'orders', '', '[0.1,0.2]', NULL, '2026-05-10T10:00:00Z'),
|
|
('analytics', 'users', '', NULL, NULL, '2026-05-10T10:00:00Z');
|
|
|
|
CREATE TABLE local_sl_dictionary_values (
|
|
connection_id TEXT NOT NULL,
|
|
source_name TEXT NOT NULL,
|
|
column_name TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
value_lower TEXT NOT NULL,
|
|
cardinality INTEGER,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (connection_id, source_name, column_name, value)
|
|
);
|
|
INSERT INTO local_sl_dictionary_values VALUES
|
|
('analytics', 'orders', 'status', 'open', 'open', 1, '2026-05-10T10:00:00Z'),
|
|
('analytics', 'orders', 'status', 'closed', 'closed', 1, '2026-05-10T10:00:00Z');
|
|
`);
|
|
db.close();
|
|
|
|
const stats = await buildLocalStatsStatus(projectIn(tempDir));
|
|
expect(stats.unavailable).toBeUndefined();
|
|
expect(stats.ingest.totalCompletedRuns).toBe(3);
|
|
expect(stats.ingest.perConnection).toEqual([
|
|
{ connectionId: 'analytics', adapter: 'live-database', lastCompletedAt: '2026-05-10T10:00:00Z' },
|
|
{ connectionId: 'docs', adapter: 'notion', lastCompletedAt: '2026-05-01T10:00:00Z' },
|
|
]);
|
|
expect(stats.wikiPages).toEqual([
|
|
{ scope: 'GLOBAL', count: 2, embeddedCount: 1 },
|
|
{ scope: 'PROJECT', count: 1, embeddedCount: 1 },
|
|
]);
|
|
expect(stats.semanticLayer).toEqual([
|
|
{
|
|
connectionId: 'analytics',
|
|
sourceCount: 2,
|
|
embeddedSourceCount: 1,
|
|
dictionaryValueCount: 2,
|
|
},
|
|
]);
|
|
expect(stats.projectDir.dbSqliteBytes).toBeGreaterThan(0);
|
|
expect(stats.projectDir.ktxCacheBytes).toBe(2048);
|
|
expect(stats.projectDir.rawSources).toEqual({ fileCount: 2, bytes: 612 });
|
|
});
|
|
|
|
it('tolerates a SQLite DB missing some tables', async () => {
|
|
await mkdir(join(tempDir, '.ktx'), { recursive: true });
|
|
const dbPath = join(tempDir, '.ktx', 'db.sqlite');
|
|
const db = new Database(dbPath);
|
|
db.exec(`
|
|
CREATE TABLE local_ingest_reports (
|
|
run_id TEXT PRIMARY KEY,
|
|
adapter TEXT NOT NULL,
|
|
connection_id TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
completed_at TEXT NOT NULL,
|
|
raw_content_hashes_json TEXT NOT NULL,
|
|
body_json TEXT NOT NULL
|
|
);
|
|
INSERT INTO local_ingest_reports VALUES
|
|
('r1', 'live-database', 'analytics', 'done', '2026-05-10T10:00:00Z', '{}', '{}');
|
|
`);
|
|
db.close();
|
|
|
|
const stats = await buildLocalStatsStatus(projectIn(tempDir));
|
|
expect(stats.unavailable).toBeUndefined();
|
|
expect(stats.ingest.totalCompletedRuns).toBe(1);
|
|
expect(stats.wikiPages).toEqual([]);
|
|
expect(stats.semanticLayer).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('renderProjectStatus Local data', () => {
|
|
it('renders the Local data section with seeded stats', async () => {
|
|
const project = projectWithConfig(baseProjectConfig());
|
|
const status = await buildProjectStatus(project, { claudeCodeAuthProbe: stubClaudeCodeAuthProbe });
|
|
status.localStats = {
|
|
ingest: {
|
|
totalCompletedRuns: 3,
|
|
perConnection: [
|
|
{ connectionId: 'analytics', adapter: 'live-database', lastCompletedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString() },
|
|
],
|
|
},
|
|
wikiPages: [
|
|
{ scope: 'GLOBAL', count: 2, embeddedCount: 2 },
|
|
{ scope: 'PROJECT', count: 1, embeddedCount: 0 },
|
|
],
|
|
semanticLayer: [
|
|
{
|
|
connectionId: 'analytics',
|
|
sourceCount: 12,
|
|
embeddedSourceCount: 10,
|
|
dictionaryValueCount: 200,
|
|
},
|
|
],
|
|
projectDir: {
|
|
dbSqliteBytes: 4096,
|
|
ktxCacheBytes: 1_048_576,
|
|
rawSources: { fileCount: 5, bytes: 200 },
|
|
},
|
|
};
|
|
const rendered = renderProjectStatus(status, { useColor: false });
|
|
expect(rendered).toContain('Local data');
|
|
expect(rendered).toContain('Wiki');
|
|
expect(rendered).not.toContain('Knowledge');
|
|
expect(rendered).toContain('3 completed runs');
|
|
expect(rendered).toContain('GLOBAL=2 (2 embedded)');
|
|
expect(rendered).toContain('PROJECT=1 (0 embedded)');
|
|
expect(rendered).toContain('12 sources (10 embedded) · 200 dictionary values');
|
|
expect(rendered).toContain('db=4.00 KiB');
|
|
expect(rendered).toContain('cache=1.00 MiB');
|
|
expect(rendered).not.toMatch(/wiki=\d+ md/);
|
|
expect(rendered).not.toMatch(/semantic-layer=\d+ yaml/);
|
|
});
|
|
|
|
it('renders unavailable note when DB is missing', async () => {
|
|
const project = projectWithConfig(baseProjectConfig());
|
|
const status = await buildProjectStatus(project, { claudeCodeAuthProbe: stubClaudeCodeAuthProbe });
|
|
status.localStats = {
|
|
ingest: { totalCompletedRuns: 0, perConnection: [] },
|
|
wikiPages: [],
|
|
semanticLayer: [],
|
|
projectDir: {
|
|
dbSqliteBytes: null,
|
|
ktxCacheBytes: 0,
|
|
rawSources: { fileCount: 0, bytes: 0 },
|
|
},
|
|
unavailable: 'no .ktx/db.sqlite yet',
|
|
};
|
|
const rendered = renderProjectStatus(status, { useColor: false });
|
|
expect(rendered).toContain('Local data');
|
|
expect(rendered).toContain('no .ktx/db.sqlite yet');
|
|
});
|
|
});
|